Updating faceted search with client-side code

A while back I wrote up the faceted search example I’d presented at the London Sitecore User Group, and commented that ASP.Net WebForms wasn’t really great technology for providing that sort of UI. I noted that it would work better if it could be implemented using Ajax-style UI.

Having finally had a chance to work out a basic pattern for building JSON web services using the sort of technology that’s easily available in basic Sitecore 6.6 websites last week, I’ve now had a chance to get around to implementing a prototype of how the faceted search might be built with client-side processing.

The overall pattern

We want a JSON web service to run on the Sitecore server, which will take requests for query data and return results and facet data in a similar style to that generated by the original example referenced above. Then we need the HTML and JavaScript that can talk to that service, and produce the user interface.

Having done a bit of experimenting, I’ve broken that service down into two parts: one that fetches the configuration for the search (eg what facets to display, and information about them) and then one that takes the current facet settings and runs a query.

Another change that is suggested by moving to client-side UI is that the state for the facets should be reflected in the hash fragments of the URL. This way users can bookmark the state of a search, and the forward and backward behaviour of the browser will work as expected.

The basic HTML

We’re aiming for a similar UI to the t-shirt example from the previous attempt.

Basic UI

We need a block of UI that contains the set of filters, and a list of the results that were generated.

Because the HTML won’t include any actual data when it is downloaded from the server, we also need to have some “loading” placeholders as well:

<body>
    <h1>Search TShirts</h1>

    <form id="filters">
        <input type="hidden" id="configID" value="{027184E8-8007-49A2-B14C-1D02B766FE55}" />
        <div class="loading">-- Loading --</div>
    </form>

    <ul id="results">
        <li class="loading">-- Loading --</li>
    </ul>
</body>

The <form> element will contain the dropdown lists that the facets can be selected from. But we also need to be able to work out what the Sitecore ID of the configuration item for our facets. This value can be output by a sublayout, from its datasource.

The results can added to the <ul> element.

Fetching configuration

When the HTML for the page finishes loading the first thing that needs to happen is that the search configuration needs to be fetched. There needs to be a method on the search service that can take the GUID of the configuration template defined in the earlier posting, and it needs to return the display name for the facet and the HTML field name we plan to use. (And in more complex implementations it could also return data about the type of UI to display)

To do that we need some classes to represent the data, and an API method:

public class FilterConfig
{
    public string DisplayName { get; set; }
    public string FieldName { get; set; }
}

public class SearchConfiguration
{
    public FilterConfig[] Filters { get; set; }
    public bool Error { get; set; }
}

[WebMethod]
[ScriptMethod(ResponseFormat = ResponseFormat.Json)]
public SearchConfiguration GetConfig(string configID)
{
    SearchConfiguration result = new SearchConfiguration();

    Item cfgItem = Sitecore.Context.Database.GetItem(configID);
    if (cfgItem == null)
    {
        result.Error = true;
    }
    else
    {
        string facets = cfgItem.Fields["Facets"].Value;
        if (!string.IsNullOrEmpty(facets))
        {
            result.Filters = facets.Split('|')
                .Select(id => Sitecore.Context.Database.GetItem(id))
                .Select(itm => new FilterConfig() { DisplayName = itm.DisplayName, FieldName = itm.Fields["SearchIndexField"].Value })
                .Where(fc => !string.IsNullOrWhiteSpace(fc.FieldName) && !string.IsNullOrWhiteSpace(fc.DisplayName))
                .Select(fc => { fc.FieldName = fc.FieldName.ToLower(); return fc; })
                .ToArray();
        }
    }

    return result;
}

The code for the API call follows a similar pattern to the similar code in the WebForms version. It loads the item specified by the configuration item GUID and transforms the GUIDs in the “Facets” field and projects them into FilterConfig items to return.

Then the client side code can call this method:

var config = null;

function getConfig() {
    var cfgID = $("#configID").val();

    $.ajax({
        url: "/Search/SearchApi.asmx/GetConfig",
        data: data: JSON.stringify({ configID: cfgID}),
        contentType: "application/json; charset=utf-8",
        type: "POST",
        dataType: "json",
        async: false,
        success: function (data) {
            if (data.d.Error) {
                alert("Failed to find config");
            }

            config = data.d.Filters;
        }
    });
}

It fetches the value from the hidden form field for the configuration item ID and passes it across to the server. The result data then gets stored for use by future methods.

Next the code needs to create the UI controls for the set of filters defined in the configuration:

function setupUI() {
    for (i in config) {
        var filter = config[i];

        var block = $('<div>');
        var span = $('<span class="fieldName">' + filter.DisplayName + '</span>');
        var select = $('<select>').attr({ id: filter.FieldName });

        span.appendTo(block);
        select.appendTo(block);
        block.appendTo('#filters');

        select.change(function () {
            if (!ignoreChangeEvents) {
                var val = $(this).val();
                if (typeof val === "undefined" | val == null | val == "") {
                    jQuery.bbq.removeState($(this).attr('id'));
                } else {
                    var state = {};
                    state[$(this).attr('id')] = val;
                    jQuery.bbq.pushState(state);
                }
            }
        });
    }
}

For each of the filter definitions returned in the config, a block of HTML is created to label the field and set up the dropdown list. And then it attaches an on-change event that’s called when the user changes the value. This event checks if we’re in a state where the changes to the UI controls should be reflected in the hash fragments of the URL. If so, the new value for the field gets added to the URL’s hash fragment. If the new value is empty or null, the code removes that item from the hash.

[The hash fragment management here makes use of a simple jQuery extension to provide automation for this: jQuery BBQ]

After this is set up we can run the initial search – which I’ll discuss in a second.

Once that initial search has been run, the last thing that needs to be done is to attach an event to the “hashchange” event so that whenever the URL’s hash fragment changes we run a new search:

function setupHashChangeEvent() {
    $(window).bind('hashchange', function (e) {
        ignoreChangeEvents = true;
        copyHashParamsToFormFields();
        ignoreChangeEvents = false;
        search(function (field) {
            return $('#' + field).val();
        });
    });
}

It stops the event handle for dropdown list changes from updating the hash fragment (so we don’t get an infinite loop), updates the UI dropdowns and then runs the search…

Running a search

Client side, the search code needs to show the “loading” placeholder, fetch the filter values to post back, talk to the server and then update the UI:

function search(filterFunction) {
    $(".loading").show();

    var filters = collateFilterData(filterFunction);
    var data = { filterValues: filters };

    $.ajax({
        url: "/Search/SearchApi.asmx/Search",
        data: JSON.stringify(data),
        contentType: "application/json; charset=utf-8",
        type: "POST",
        dataType: "json",
        async: false,
        success: function (data) {
            if (data.d.Error) {
                alert("error in search response");
                return;
            }
            bindDropdowns(data.d.Filters);
            bindResults(data.d.Results);
        }
    });
}

function collateFilterData(fn) {
    var filters = {};
    for (i in config) {
        var filter = config[i];

        var val = fn(filter.FieldName);
        if (typeof val === "undefined" | val === null) {
            val = "";
        }

        filters[filter.FieldName] = val;
    }

    return filters;
}

Depending on where we are in the code, the place we want to take the filter parameters from changes. For the first run of the code, when the page first loads, we want to take the filter values direct from the hash fragment. But for subsequent searches the values can come from the form fields that get set up after the first search. The function that allows fetching of the correct facet values is passed in as a parameter here.

On the server side, again we need some simple classes for the return data, and similar code to the old version of the code to run the search. The server receives the facet filter values from the client request, transforms them into a Lucene query, runs it and then constructs the result set:

public class SearchResults
{
    public IEnumerable<FilterData> Filters { get; set; }
    public IEnumerable<Result> Results { get; set; }
    public bool Error { get; set; }
}

public class Result
{
    public string DisplayName { get; set; }
    // plus whatever other fields are required
}

[WebMethod]
[ScriptMethod(ResponseFormat = ResponseFormat.Json)]
public SearchResults Search(Dictionary<string,object> filterValues)
{
    SearchResults retVal = new SearchResults();

    BooleanQuery query = buildQuery(filterValues);
    SearchResultCollection results = runQuery(query);

    retVal.Filters = buildFilterData(filterValues, results);
    retVal.Results = buildResults(results);

    return retVal;
}

private BooleanQuery buildQuery(Dictionary<string, object> filterValues)
{
    BooleanQuery query = new BooleanQuery();

    query.Add(new TermQuery(new Term("_template", ShortID.Encode(_template).ToLowerInvariant())), BooleanClause.Occur.MUST);

    foreach (var kvp in filterValues)
    {
        string val = (string)kvp.Value;
        if (!string.IsNullOrWhiteSpace(val))
        {
            query.Add(new TermQuery(new Term(kvp.Key, val)), BooleanClause.Occur.MUST);
        }
    }

    return query;
}

private SearchResultCollection runQuery(BooleanQuery query)
{
    Index index = Sitecore.Search.SearchManager.GetIndex("demo");

    using (IndexSearchContext isc = index.CreateSearchContext())
    {
        SearchHits hits = isc.Search(query, 1000);
        return hits.FetchResults(0, 1000);
    }
}

private IEnumerable<FilterData> buildFilterData(Dictionary<string, object> filterValues, SearchResultCollection results)
{
    var filters = new List<FilterData>();

    foreach (var kvp in filterValues)
    {
        var options = results.Select(r => r.Document.GetValues(kvp.Key))
                    .Where(v => v != null && v.Length > 0)
                    .SelectMany(v => v)
                    .Distinct()
                    .Select(v => Sitecore.Context.Database.GetItem(ShortID.Parse(v).ToID()))
                    .Select(v => new ListItem(v.DisplayName, v.ID.ToShortID().ToString().ToLower()))
                    .Select(li => { li.Selected = li.Value == kvp.Value.ToString(); return li;})
                    .OrderBy(i => i.Text);

        FilterData fd = new FilterData();
        fd.FieldName = kvp.Key;
        fd.Values = options.ToList();

        filters.Add(fd);
    }

    return filters;
}

private IEnumerable<Result> buildResults(SearchResultCollection results)
{
    return results
        .Select(r => r.GetObject<Item>())
        .Select(i => new Result() { DisplayName = i.DisplayName })
        .ToList();
}

As with the previous version of this code, this skips over pagination – but data for that could easily be passed in with the facet filter data. Also, the results here are only including the item display name. In reality the UI requirements of the UI designs would determine

Once the data is returned from the server, it can be bound to the UI. For the dropdowns, the code goes through the values that get sent back from the server. For each of the <select> elements that it matches up it removes the <option> elements that exist and add in new ones. And for the results, a similar process removes any previous results and inserts the new ones:

function bindDropdowns(dropdownValues) {
    for (i in dropdownValues) {
        var dropdownValue = dropdownValues[i];

        var ddl = $('#' + dropdownValue.FieldName);
        ddl.find('option').remove();
        ddl.append('<option value="">All ' + dropdownValue.FieldName + '</option>');

        for (v in dropdownValue.Values) {
            var li = dropdownValue.Values[v];
            ddl.append($("<option></option>")
                .attr("value", li.Value)
                .attr("selected", li.Selected)
                .text(li.Text));
        }
    }

    $("#filters .loading").hide();
}

function bindResults(resultValues) {
    var resultDiv = $("#results");
    resultDiv.children().remove();

    for (i in resultValues) {
        var resultValue = resultValues[i];
        resultDiv
            .append($("<li></li>")
            .text(resultValue.DisplayName));
    }

    $("#results .loading").hide();
}

That’s all the code that’s required for this example. Whether you change the URL or the dropdown lists, the results now update with the server-side results:

Selected

Taking it further

For more complex UI, it’s likely that the HTML to generate for results or facet filters would be more complex. And that makes having the jQuery code manipulate the HTML directly to create each result or filter harder to do. For situations like this the code can be made easier with other JavaScript libraries. At the simpler end, templating libraries like Handlebars can help. And at the complex end, UI frameworks like Knockout can be used instead of just jQuery.

Advertisements

One thought on “Updating faceted search with client-side code

  1. Pingback: Sorting for search, when you’re living in the dark ages | Jeremy Davis

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s