Wednesday, September 5, 2012

A custom auto-complete field type for external data in the Sitecore editor

For one of our recent projects, I had to implement a custom Sitecore field that would use a web service as a data source. John West's post on doing this as a DropDown field was extremely helpful. For a basic how-to on creating custom fields, visit this SDN link.

In my scenario, the service could potentially return thousands of items, so a simple drop-down field could easily become painful to use. Using autocomplete became a requirement. I decided to go with jQuery's autocomplete plugin. Using jQuery within the Content Editor is a bit tricky, but definitely doable. An awesome example of custom fields that make use of jQuery is the FieldTypes shared source module.

The custom scripts need to be added to the content editor before anything else is rendered. Use the <renderContentEditor> pipeline to add a processor (or use a config patch file with a patch:before="*[1]" attribute). The processor should look something like this:

public void Process(PipelineArgs args)
{
  if (!Context.ClientPage.IsEvent)
  {
    HttpContext current = HttpContext.Current;
    if (current != null)
    {
     Page handler = current.Handler as Page;
     if (handler != null) {
     Assert.IsNotNull(handler.Header, "Content Editor <head> tag is missing runat='value'");
     handler.Header.Controls.Add(new LiteralControl("<script type='text/javascript' language='javascript' src='/sitecore/shell/custom/autocomplete.js'></script>"));
      }
    }
  }
}

For the actual custom field class, I decided to go with inheriting from the Sitecore.Web.UI.HtmlControls.Control and stick closely to what a regular DropList field would do. The data source of the custom field would contain three parameters: the url of the service to call, the field that would be used as a key, and the field that would be used as the display text of a service "item".

public Dictionary<string, string> ControlParameters = new Dictionary<string, string>() 
{
  {"serviceurl", string.Empty},
  {"textfield", string.Empty},
  {"keyfield", string.Empty}
};
private void LoadControlParameters()
{
  var parameters = Sitecore.StringUtil.ParseNameValueCollection(this.Source, '|', ':');
  foreach (string p in parameters.AllKeys)
    {
      ControlParameters[p] = parameters[p];
    }
}

The service I was calling used json by default, so I decided that the field would support json response and used System.Json.JasonValue for parsing the data:
protected virtual Dictionary<string, string> GetItems()
{
  LoadControlParameters();
  Dictionary<string, string> items = new Dictionary<string, string>();
  try
    {
      // Call the service
      string serviceResult = Sitecore.Web.WebUtil.ExecuteWebPage(ControlParameters["serviceurl"]);

      dynamic json = JsonValue.Parse(serviceResult);

      foreach (dynamic item in json)
      {
        items.Add(item[ControlParameters["keyfield"]].Value.ToString(), item[ControlParameters["textfield"]].Value.ToString());
      }
    }
  catch
    {
      // invalid endpoint
      Sitecore.Diagnostics.Log.Error(string.Format("{0}: Service End-Point Not Found - {1}", this.ToString(), ControlParameters["serviceurl"]), this);
    }

   return items;
}
private Dictionary<string, string> _suggestions;
public Dictionary<string, string> Suggestions
{
  get
  {
    if (_suggestions == null)
    {
      _suggestions = GetItems();
    }
    return _suggestions;
  }
}

Now that we have the key value pairs of suggestions for the field, all that's left is to override the DoRender method of the base Control.

protected override void DoRender(HtmlTextWriter output)
{
  string err = null;
  //check if the data source of the field is empty first
  if (string.IsNullOrEmpty(this.Source))
  {
    err = SC.Globalization.Translate.Text("Source is not defined for this field.");
  }
  else
  {
    //check if the suggestions contain a previously saved value
    //we want to show the value even if it is not returned by the service anymore
    bool found = Suggestions.ContainsKey(this.Value);

    //add any custom css for the field
    output.Write("<link rel='stylesheet' href='/sitecore/shell/custom/autofill.css' />");

    List<string> items = Suggestions.Select(a => string.Format("{0}|{1}", a.Value.Replace("'", string.Empty), a.Key)).ToList();
 
    //output the script for the autocomplete plugin
    string scr = @"
            <script>
                $sc(function () {
                    var availableTags = [
                    '" + String.Join("', '", items.ToArray()) + @"'    
                    ];
                    $sc('#au_{ID}').autocomplete({
                        source: availableTags, 
                        mustMatch: true,
                        focus: function(event, ui) {
                            $sc('#au_{ID}').val(ui.item.value.split('|')[0]);
                            return false;
                        },
                        select: function( event, ui ) {
                $sc('#{ID}').val(ui.item.value.split('|')[1]);
                            return false;
                        }
                    });
                });
         </script>
     <input type='text' class='scContentControl' id=au_{ID}".Replace("{ID}", this.ID) + @" value='{Value}'/>".Replace("{Value}", found ? Suggestions[this.Value] : this.Value);
     output.Write(scr);

     output.Write("<input type='hidden' value='" + this.Value + "' "+ this.GetControlAttributes()+ " />");
     
     //give the user any information that may be important           
     if (Suggestions.Count() == 0)
     {
       err = Sitecore.Globalization.Translate.Text("The service did not return any options.");
     }
     else if (!found && !string.IsNullOrEmpty(this.Value))
     {
       err = Sitecore.Globalization.Translate.Text("Value not in the selection list.");
     }
  }
  if (err != null)
  {
    output.Write("<div style=\"color:#999999;padding:2px 0px 0px 0px\">{0}</div>", err);
  }
}

The $sc variable is what I found Sitecore was overriding the $-function with. This was implemented for and tested on Sitecore 6.5.0.110818, and I can't be sure if the same variable will work with previous Sitecore versions. You can override it with your own variable by calling "jQuery.noConflict()" and adding that piece of javascript to the pipeline processor for injecting scripts.