Leveraging SharePoint's OOB Taxonomy Picker

Thursday, June 30, 2016

11

In this post I'll go over how to instantiate the OOB SharePoint Taxonomy Picker using JavaScript.
Sometimes jslink just can't do the job, and we need to build a completely custom form with JavaScript and the REST APIs.  It's not too bad, but Managed Metadata fields are definitely a pain to handle.

I'm not a huge fan of the OOB Taxonomy Picker UI.  If you have the time and resources, it's worth considering rolling your own taxonomy control.  But for efficiency or consistency's sake, we can reuse the OOB picker with just a little bit of code.

Credit for the core of this code goes to this blog post.  There are a few other posts out on the interwebs with similar.  However, I could not get any to consistently work.  Many didn't load dependencies properly, or relied on resources which might not be loaded depending on what version of SharePoint and what type of page you're on.

So, I've bundled everything into an easy to consume wrapper, and made it as robust as possible by explicitly loading every required resource.  This has been tested on an OOB Publishing Page in both O365 and SP 2013 on prem.  It requires jQuery to be loaded.

Helper Functions

First, drop the code below anywhere on the page.  It looks hairy, but is actually fairly straight forward.

When called, it inserts a DIV on to the page to hold the taxonomy control.  It also adds a hidden textbox to hold the GUIDs of the selected terms.

Next, it uses JSOM to retrieve the ID of the current site's default Term Store.  Finally, it wires up some properties on the textbox, and registers an event which will cause SharePoint to transform the textbox into a taxonomy picker when the page loads.

var taxonomyPickerHelper = taxonomyPickerHelper || {
  initPicker: function (containerId, termSetId) {
    // Create empty picker template and hidden input field
    var pickerContainerId = containerId + '_picker';
    var pickerInputId = containerId + '_input';

    var html = '<input name="' + pickerInputId + '" type="hidden" id="';
    html += pickerInputId + '" />';
    html += '<div id="' + pickerContainerId;
    html += '" class="ms-taxonomy ms-taxonomy-height ms-taxonomy-width"></div>';

    jQuery('#' + containerId).html(html);

    // Get Termstore ID and init control
    taxonomyPickerHelper.getTermStoreId().then(function (sspId) {
      taxonomyPickerHelper.initPickerControl(sspId, termSetId, 
        pickerContainerId, pickerInputId);
    });
  },

  getSelectedValue: function (containerId) {
    return jQuery('#' + containerId + '_input input').val();
  },

  getTermStoreId: function () {
    var deferred = jQuery.Deferred();

    var context = new SP.ClientContext.get_current();
    var session = SP.Taxonomy.TaxonomySession.getTaxonomySession(context);
    var termStore = session.getDefaultSiteCollectionTermStore();

    context.load(session);
    context.load(termStore);

    context.executeQueryAsync(
      function () {
        var sspId = termStore.get_id().toString();
        deferred.resolve(sspId);
      },
       function () {
         deferred.reject("Unable to access Managed Metadata Service");
       }
     );

    return deferred.promise();
  },

  initPickerControl: function (sspId, termSetId, 
                               pickerContainerId, pickerInputId) {
    var tagUI = document.getElementById(pickerContainerId);
    if (tagUI) {
      tagUI['InputFieldId'] = pickerInputId;
      tagUI['SspId'] = sspId;
      tagUI['TermSetId'] = termSetId;
      tagUI['AnchorId'] = '00000000-0000-0000-0000-000000000000';
      tagUI['IsMulti'] = true;
      tagUI['AllowFillIn'] = false;
      tagUI['IsSpanTermSets'] = false;
      tagUI['IsSpanTermStores'] = false;
      tagUI['IsIgnoreFormatting'] = false;
      tagUI['IsIncludeDeprecated'] = false;
      tagUI['IsIncludeUnavailable'] = false;
      tagUI['IsIncludeTermSetName'] = false;
      tagUI['IsAddTerms'] = false;
      tagUI['IsIncludePathData'] = false;
      tagUI['IsUseCommaAsDelimiter'] = false;
      tagUI['Disable'] = false;
      tagUI['ExcludeKeyword'] = false;
      tagUI['JavascriptOnValidation'] = "";
      tagUI['DisplayPickerButton'] = true;
      tagUI['Lcid'] = 1033;
      tagUI['FieldName'] = '';
      tagUI['FieldId'] = '00000000-0000-0000-0000-000000000000';
      tagUI['WebServiceUrl'] = _spPageContextInfo.webServerRelativeUrl + '\u002f_vti_bin\u002fTaxonomyInternalService.json';

      SP.SOD.executeFunc('ScriptForWebTaggingUI.js', 
        'Microsoft.SharePoint.Taxonomy.ScriptForWebTaggingUI.taggingLoad',
        function () {
          Microsoft.SharePoint.Taxonomy.ScriptForWebTaggingUI.resetEventsRegistered();
        }
      );

      SP.SOD.executeFunc('ScriptForWebTaggingUI.js', 
        'Microsoft.SharePoint.Taxonomy.ScriptForWebTaggingUI.onLoad',
        function () {
          Microsoft.SharePoint.Taxonomy.ScriptForWebTaggingUI.onLoad(pickerContainerId);
        });
    }
  }
};

CSS

Next, include this CSS file to style the taxonomy picker:

<link rel="stylesheet" type="text/css" 
      href="_layouts/15/1033/styles/WebTaggingUI.css" />

Container DIV

Add a DIV to hold the taxonomy picker:

<div id="my-taxonomypicker">
</div>

Load Dependencies and Initialize

Finally, add the JavaScript below to initialize the picker.  This bad boy makes sure all the necessary dependencies are loaded, then initializes the picker with taxonomyPickerHelper.initPicker.  That takes 2 arguments, the ID of the container to place the picker, and the GUID of the termset to load.

Note: You must plug in the GUID to your termset on line 21

Note 2: sp.rte.js is a required dependency on O365, but does not exist in 2013 on prem.  For O365, be sure to uncomment lines 13, 14, 17, and 24.

jQuery(document).ready(function () {
  SP.SOD.loadMultiple(['sp.js'], function () {
    SP.SOD.registerSod('sp.taxonomy.js', 
        SP.Utilities.Utility.getLayoutsPageUrl('sp.taxonomy.js'));
    SP.SOD.registerSod('scriptforwebtaggingui.js', 
        SP.Utilities.Utility.getLayoutsPageUrl('scriptforwebtaggingui.js'));
    SP.SOD.registerSod('sp.ui.rte.js', 
        SP.Utilities.Utility.getLayoutsPageUrl('sp.ui.rte.js'));
    SP.SOD.registerSod('scriptresources.resx', 
        SP.Utilities.Utility.getLayoutsPageUrl('ScriptResx.ashx?culture=en-us&name=ScriptResources'));

    // UNCOMMENT THIS FOR O365
    // SP.SOD.registerSod('ms.rte.js', 
    //     SP.Utilities.Utility.getLayoutsPageUrl('ms.rte.js'));

    // UNCOMMENT THIS FOR O365
    // SP.SOD.loadMultiple(['ms.rte.js'], function () {
      SP.SOD.loadMultiple(['sp.taxonomy.js', 'sp.ui.rte.js', 
                           'scriptresources.resx'], function () {

        taxonomyPickerHelper.initPicker('my-taxonomypicker', '<TERM SET GUID>');

      });
    // });
  });
});

Wrap Up

To get the selected terms, call:

taxonomyPickerHelper.getSelectedValue('my-taxonomypicker')

The full sample code is available for download here.


Search Display Templates Made Easy w/ Knockout

Thursday, June 30, 2016

3

I first started using Knockout JS in display templates for a solution that required complex interactive search results.  But I found that Knockout made working with display templates SO much easier in general, it's now my go-to solution for building any custom template!

The inline JavaScript / HTML mashup syntax that display templates use turns the simplest requirements into a giant, unreadable mess of <!--#_ _#--> and _#= =#_ tags.

Incorporating Knockout enables us to:

  • Write clean markup with easy-to-read databinding
  • Open the door to more complex interactive solutions

Why Knockout JS, specifically?  Mostly because it's what I'm most familiar with.  I'm sure other frameworks like ReactJS or AngularJS could be use with similar benefits.  I like Knockout because it's trivially simple to set up and get started with.  For simple templates, the data-binding feature is all we'll need.  But having Knockout also opens the door to dependency-tracking, 2-way data binding, event handling, custom components, and other cool stuff down the road.

Include KnockoutJS

First, we need to include the KnockoutJS framework on the site.  It is a single, standalone JavaScript file which you can download from http://knockoutjs.com.

Upload it some where on SharePoint (eg Master Page Gallery or Site Assets), then add a reference to it in the Master Page:

<!--MS:<SharePoint:ScriptLink language="javascript" name="~SiteCollection/_catalogs/masterpage/js/knockout-3.4.0.js" OnDemand="false" runat="server" Localizable="false">-->
<!--ME:</SharePoint:ScriptLink>-->

Display Template Markup

Full code example is at the end of this article.  Here I'll break down the template and explain what each part does:

Pre-Render Javascript (lines 18-22)


This JavaScript in the special inline JavaScript/HTML syntax runs before the markup is rendered. Here, we simply save a reference to the current search result item, and generate a unique ID.

Markup (lines 24-33)


This is where the HTML markup and Knockout data-bindings for the current item are defined.  This markup will be emitted to the search results page.

Note on line 24 where we plug the unique container ID generated above into the markup with _=# containerId #=_.  This is the only place where we need to use the _#= =#_ syntax, as the ID has to be inserted before applying Knockout data-binding.

The rest is arbitrary HTML and Knockout bindings!  Go crazy!  For those unfamiliar with Knockout, $root refers to the root data model object that the template is bound to.  Here, we'll bind the template to the search result item, ctx.CurrentItem.  So, any property available on the item can be used in the template.  Remember that only Managed Properties that have been called out in ManagedPropertyMapping (line 8) are available.

Post Render - Apply Data-bindings (lines 35-40)


Finally, we register a Post Render callback method which executes after the template markup has been emitted.

We tell Knockout to apply the data-bindings by calling ko.applyBindings.  We pass it the search result item to map from ($root in the markup bindings), as well as the HTML element containing the template to bind to.


<html xmlns:mso="urn:schemas-microsoft-com:office:office" xmlns:msdt="uuid:C2F41010-65B3-11d1-A29F-00AA00C14882">
<head>
  <title>My Custom Search Result Item</title>

  <!--[if gte mso 9]><xml>
  <mso:CustomDocumentProperties>
  <mso:TemplateHidden msdt:dt="string">0</mso:TemplateHidden>
  <mso:ManagedPropertyMapping msdt:dt="string">'Title','Path','Author'</mso:ManagedPropertyMapping>
  <mso:MasterPageDescription msdt:dt="string"></mso:MasterPageDescription>
  <mso:ContentTypeId msdt:dt="string">0x0101002039C03B61C64EC4A04F5361F385106603</mso:ContentTypeId>
  <mso:TargetControlType msdt:dt="string">;#SearchResults;#</mso:TargetControlType>
  <mso:HtmlDesignAssociated msdt:dt="string">1</mso:HtmlDesignAssociated>
  </mso:CustomDocumentProperties>
  </xml><![endif]-->
</head>
<body>
  <div id="item">
    <!--#_
    var encodedId = $htmlEncode(ctx.ClientControl.get_nextUniqueId());
    var containerId = "MyCustomItem_" + encodedId;
    var currentItem = ctx.CurrentItem;
    _#-->

    <div id="_#=containerId=#_">
      <div>
        <span><b>Title:</b></span>
        <span data-bind="text: $root.Title"></span>
      </div>
      <div>
        <span><b>Path:</b></span>
        <span data-bind="text: $root.Path"></span>
      </div>
    </div>

    <!--#_
    AddPostRenderCallback(ctx, function()
    {
      ko.applyBindings(currentItem, document.getElementById(containerId));
    });
    _#-->
  </div>
</body>
</html>


SharePoint Search Box for Explore Experience

Thursday, June 30, 2016

1

This post will focus on some issues that make SharePoint's Search Box webpart clumsy when used in a Explore / Discovery scenario, and how to address them:

  • Retaining active refiners between searches
  • Clearing the search keyword
  • Building a custom client-side Search Box

Empty Keyword

By default, when you hit Enter on an empty Search Box, nothing happens.  Why would anyone search for empty string any way?  But, this actually comes up a lot in the Explore experience because users are likely to start with the refiners.  Consider this flow:

  1. Land on the Explore page
  2. I browse by refining on Location = Seattle and Subject = Expenses
  3. I search for the keyword "reports", but don't find anything of interest
  4. Now, I want to go back to #2 and browse the rest of Seattle/Expense items

Without the ability to search for the empty string (or a "clear" button), the only way to do #4 would be to reload the page and re-apply the Seattle/Expense filters.

Luckily, the Search Box webpart supports empty string search-- it's just disabled by default.  It is controlled by the AllowEmptySearch property, which AFAIK can not be managed from the UI.  To set this property, export your Search Box, and edit the .webpart file in a text editor:

Import it back to the page, and you are good to go!

Preserving Selected Refiners

A bigger problem is that when you search for a new keyword, all refiners are reset.  Consider this use case:

  1. Filter on Location = Seattle, Subject = Expenses
  2. Try a bunch of different keywords within those categories

Since the Search Box clears existing refinements on submit, I'd have to reapply Seattle/Expenses refiners after every new keyword.  So, this is not usable at all.

We can partially solve this by setting the MaintainQueryState property to True (False by default).  Again, this is not available from the UI.  You have to export and re-import the webpart:

This retains selected refiners between searches, but introduces a new problem.  It maintains ALL query state, including paging, which gives unexpected results in this specific scenario:

  1. Search for keyword A
  2. Go to next page of results.  The query state is now on page 2.
  3. Search for keyword B
  4. If keyword B has only one page of results, you will see NO results because the query state is still on page 2.  Worse, the paging controls aren't available because it thinks there are no results at all!

What we really want is to retain selected refiners, but start back at page 1.  I can not figure out any way to achieve this using the OOB Search Box webpart (would love to hear if anyone else has).  Which brings me to the more interesting part of this article...

Custom Javascript Search Box

The goal is to have a textbox that does the following on every submit:

  • Search for entered keyword
  • Retain selected refiners
  • Start at page 1
  • Accepts empty keyword (clear keyword)

This can easily be achieved with just Javascript and a Content Editor Webpart.  This custom JS approach also opens the door to other custom behaviors.  Go crazy.  The sky's the limit!

To be clear, the OOB Search Box comes with other goodies like query suggestions and scope selection.  However, those often are not needed on an Explore page.  I feel it's more important to perfect the basic filter/search use cases. 

You can drop the following markup into a Content Editor Webpart, which does the following:

  1. Create a text box (line 1)
  2. Attach an event handler (keyup) for the Enter key (line 5)
  3. Get a reference to the search DataProvider on the page (lines 8-9)
  4. Set query state's keyword to value from textbox, and paging to first page (lines 13-14)
  5. Trigger a query (line 16)
  6. If there's a Search Results or Content Search webpart on the page, it should automatically pick up the new results.

<input id="search-box" type="text" placeholder="Search..." />

<script type="text/javascript">
  jQuery(document).ready(function () {
    jQuery('#search-box').keyup(function (event) {
      if (event.keyCode == 13) { // Enter key
        if (Srch.ScriptApplicationManager) {
          var appManager = Srch.ScriptApplicationManager.get_current();
          var provider = appManager.queryGroups['Default'].dataProvider;

          var keyword = jQuery('#search-box').val();

          provider.get_currentQueryState().s = 0; // page = 0
          provider.get_currentQueryState().k = keyword; // new keyword

          provider.issueQuery(); // execute search
        }

        return false;
      }
    });
  });
</script>


SharePoint Search vs Explore Experience

Tuesday, June 28, 2016

0

I stare deep into the empty search box, and the cursor blinks back at me-- a mocking, existential question mark.  What the heck do I type in? Does it matter? Does anything matter? I iterate pseudo-random combinations of keywords, hoping to discover that one serendipitous, magical arrangement. Despair sets in.

The SharePoint Search experience (search results page) is often used as a one-size-fits-all solution, even when users really need Discovery.  Or both.  There are small, yet very important differences between the two.  More and more, I'm building portals with a separate search-based Explore page, along side the usual Search Results via the global search box.

In the (in)famous words of Rumsfeld, "there are known unknowns... but there are also unknown unknowns". It's the difference between searching for a particular book on Amazon, or just exploring Kindle Books -> Sci Fi. From personal experience, at least 80% of the books on my Kindle came from the latter!

Search helps users quickly get to stuff, when they already have a good idea what they're looking for. The flow is simple: Enter a keyword to search -> Refine down the results -> Profit.

Often though, I have no idea what's out there.  I don't even know where to start.  Discovery, therefore, has to be a loosely guided process. Users likely start by exploring predefined buckets of information using the search refiners, rather than entering a search term right away. When the search box is used, they are likely trying many different keywords to get a sense of what's out there, rather than zeroing in on a particular item.

There are obviously many, many different ways to build a good Discovery experience. It doesn't have to be search-based at all.  But it makes a lot of sense to leverage the power of Search if your items are neatly groomed and tagged with appropriate metadata.

In the next couple posts, I'll focus on some enhancements to make the OOB search results page better suited for exploration: