Tuesday, 10 January 2017

Getting or Setting Multi-Value Metadata Fields with the REST API

A couple of years ago, I published a series of posts on getting and setting taxonomy field values in SharePoint workflows by using the REST API in custom workflow activities. These custom activities have served me well, but they've always been unable to work with multi-value taxonomy fields. It's time to fix that.

Background

As you probably already know, when you add a taxonomy field to a SharePoint list, SharePoint adds a corresponding hidden note field. For example, if you add a taxonomy field named Colours, you actually get:

  • A taxonomy field named Colours. Depending on whether the column allows the user to select multiple terms, the field accepts values of type SP.Taxonomy.TaxonomyFieldValue or Collection(SP.Taxonomy.TaxonomyFieldValue).
  • A hidden note field, probably named Colours_0
The hidden note field stores the contents of the field in a term string format. In a single-value taxonomy field, the format looks like this:

Red|87999a76-e3cb-433c-96ad-c6fe354db476

And in a multi-value taxonomy field, the format looks like this:

Blue|77788fee-9e1d-4df2-a21b-d41dd1734b71;Indigo|d631d196-6909-4b54-a8bb-3c15bcfec18a;Yellow|fdc643a8-3310-448d-9a5d-c9ba13f366fb

In other words, each managed metadata term is represented as [Label]|[Term GUID], and multiple terms are separated by semi-colons.

I've previously resisted using this hidden note field to work with taxonomy fields using REST, as I've always felt that you ought to be able to get or set the field value using the regular, visible taxonomy field. You can get or set single values using the regular taxonomy field, so why not multiple values? However, I eventually conceded it can't be done. Meanwhile, other bloggers, such as this excellent post from Beau Cameron, have demonstrated how you can update a multi-value taxonomy field over REST by targeting the hidden note field and providing a text value in the term string format shown above.

The Problem

We know we can set single-value or multi-value taxonomy fields by writing a text value to the hidden note field. However, if we want to automate this (for example in a custom workflow activity), we need a reliable way of identifying the hidden note field that corresponds to a particular taxonomy field. This is tricky:
  • The internal/static name of the note field is typically a randomly-generated GUID.
  • You can't assume that the title of the note field will always be the title of the taxonomy field with "_0" appended - it depends how the field was created.
Fortunately there is a more robust way of finding the hidden note field associated with a specific taxonomy field.

The Solution - Short Version

If you retrieve the properties of a taxonomy field in any list, you'll notice it has a property named TextField. This field stores the GUID identifier of the associated hidden note field. Given this identifier you can retrieve the hidden note field (reliably and programmatically) and get its internal name. Given the internal name of the hidden note field, you can update your taxonomy field by providing a string value.

The Solution - Long Version

First, send a REST request to retrieve the details of the taxonomy field:


[Site URL]_api/web/lists(guid'[List ID]')/fields?$filter=title eq '[Field Name]' or internalname eq '[FieldName]'

For example, suppose we've got a multi-value taxonomy field named Colours:

GET /_api/web/lists(guid'd190f637-2f62-41e8-b191-bf760daff64f')/fields?$filter=title eq 'Colours' or internalname eq 'Colours' HTTP/1.1
Accept: application/json; odata=verbose
Host: sp.jrjlee.net

The REST API will return a response that resembles the following:

{
    "d": {
        "results": [
            {
                "__metadata": {...},
                "DescriptionResource": {...},
                "TitleResource": {...},
                "AutoIndexed": false,
                "CanBeDeleted": true,
                "DefaultValue": "",
                "Description": "",
                "Direction": "none",
                "EnforceUniqueValues": false,
                "EntityPropertyName": "Colours",
                "Filterable": true,
                "FromBaseType": false,
                "Group": "Jason Columns",
                "Hidden": false,
                "Id": "5e198f9b-6daf-4c13-ad01-cdb616a06ab4",
                "Indexed": false,
                "InternalName": "Colours",
                "JSLink": "...",
                "ReadOnlyField": false,
                "Required": false,
                "SchemaXml": "...",
                "Scope": "/Lists/Things",
                "Sealed": false,
                "Sortable": false,
                "StaticName": "Colours",
                "Title": "Colours",
                "FieldTypeKind": 0,
                "TypeAsString": "TaxonomyFieldTypeMulti",
                "TypeDisplayName": "Managed Metadata",
                "TypeShortDescription": "Managed Metadata",
                "ValidationFormula": null,
                "ValidationMessage": null,
                "AllowMultipleValues": true,
                "IsRelationship": true,
                "LookupField": "Term$Resources:core,Language;",
                "LookupList": "{567ae30f-ee2d-4d07-8c4a-a6467a94959c}",
                "LookupWebId": "9361373e-19a2-42e5-bf36-a67adfeae11e",
                "PrimaryFieldId": null,
                "RelationshipDeleteBehavior": 0,
                "AnchorId": "00000000-0000-0000-0000-000000000000",
                "CreateValuesInEditForm": false,
                "IsAnchorValid": true,
                "IsKeyword": false,
                "IsPathRendered": false,
                "IsTermSetValid": true,
                "Open": false,
                "SspId": "8750d12e-49d8-4326-84a6-ae8bd5a953c6",
                "TargetTemplate": null,
                "TermSetId": "ba00c0bf-3de6-45d7-96b3-c23debc868c4",
                "TextField": "451e34e4-c9b6-43d9-8499-e0495c6dcb4a",
                "UserCreated": false
            }
        ]
    }
}

The TextField property is the ID of the hidden note field that is associated with this taxonomy field. If you're building a workflow activity, you can pull the value out of the response using the XPath query d/results(0)/TextField.

Once you've got the field ID, you can send another REST request - this time to retrieve the details of the hidden note field:

[Site URL]_api/web/lists(guid'[List ID]')/fields(guid'[Field ID]')

For example:

GET /_api/web/lists(guid'd190f637-2f62-41e8-b191-bf760daff64f')/fields(guid'451e34e4-c9b6-43d9-8499-e0495c6dcb4a') HTTP/1.1
Accept: application/json; odata=verbose
Host: sp.jrjlee.net


As before, the REST API returns a response that resembles the following:

{
    "d": {
        "__metadata": {...},
        "DescriptionResource": {...},
        "TitleResource": {...},
        "AutoIndexed": false,
        "CanBeDeleted": true,
        "DefaultValue": null,
        "Description": "",
        "Direction": "none",
        "EnforceUniqueValues": false,
        "EntityPropertyName": "le198f9b6daf4c13ad01cdb616a06ab4",
        "Filterable": false,
        "FromBaseType": false,
        "Group": "Jason Columns",
        "Hidden": true,
        "Id": "451e34e4-c9b6-43d9-8499-e0495c6dcb4a",
        "Indexed": false,
        "InternalName": "le198f9b6daf4c13ad01cdb616a06ab4",
        "JSLink": "clienttemplates.js",
        "ReadOnlyField": false,
        "Required": false,
        "SchemaXml": "...",
        "Scope": "/Lists/Things",
        "Sealed": false,
        "Sortable": false,
        "StaticName": "le198f9b6daf4c13ad01cdb616a06ab4",
        "Title": "Colours_0",
        "FieldTypeKind": 3,
        "TypeAsString": "Note",
        "TypeDisplayName": "Multiple lines of text",
        "TypeShortDescription": "Multiple lines of text",
        "ValidationFormula": null,
        "ValidationMessage": null,
        "AllowHyperlink": false,
        "AppendOnly": false,
        "NumberOfLines": 6,
        "RestrictedMode": true,
        "RichText": false,
        "WikiLinking": false
    }
}

This time, we grab the InternalName property (d/InternalName) from the response.

Now that we've got the internal name of the hidden note field, we can use the REST API to get or set taxonomy values programmatically. We send a MERGE request:

POST /_api/web/lists(guid'd190f637-2f62-41e8-b191-bf760daff64f')/Items(12) HTTP/1.1
If-Match: *
X-HTTP-Method: MERGE
Content-Type: application/json; odata=verbose
Host: sp.jrjlee.net

And in the JSON body of the request we set our hidden field (using the GUID-based internal name) to our multi-value term string:

{
    "le198f9b6daf4c13ad01cdb616a06ab4": "Red|87999a76-e3cb-433c-96ad-c6fe354db476;Blue|77788fee-9e1d-4df2-a21b-d41dd1734b71;Violet|a1a70ca3-b104-49f7-86c9-38a265f35f4d",
    "__metadata": { "type": "SP.Data.ThingsListItem" }
}

The end result? Our Colours taxonomy field shows the new values as expected.























Wednesday, 14 December 2016

Controlling start options for SharePoint workflows in Visual Studio

When you build a reusable SharePoint workflow, it's useful to be able to control which start options are available. For example, if you only want your workflow to run once when an item is created, it makes sense to disable the "Changing an item will start this workflow" option.

SharePoint Designer provides some handy checkboxes that you can use to control your start options:










However, it's not immediately obvious how you can set these options for a Visual Studio workflow as the options aren't documented anywhere.

To control the start options for a SharePoint workflow in Visual Studio, you need to edit the feature element file that deploys your workflow. Within the feature element file, you need to add properties to the File element that deploys your Workflow.xaml file:

<?xml version="1.0" encoding="utf-8" ?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
  <Module Name="[Workflow Name]" Url="wfsvc/1c77e2780d9d49f197115fef9e7d9c50">
    <File Url="Workflow.xaml" 
          Type="GhostableInLibrary" 
          Path="[Workflow Name]\Workflow.xaml" 
          DoGUIDFixUp="TRUE" 
          ReplaceContent="TRUE">
      <Property Name="ContentType" Value="WorkflowServiceDefinition" />
      <Property Name="isReusable" Value="true" />
      <Property Name="RequiresInitiationForm" Value="True" />
      <Property Name="RequiresAssociationForm" Value="False" />      
      <Property Name="DisableAutoStartCreate" Value="True" />
      <Property Name="DisableAutoStartChange" Value="True" />
      <Property Name="WSPublishState" Value="3" />
      <Property Name="WSDisplayName" Value="[Workflow Title]" />
      <Property Name="WSDescription" Value="[Workflow Description]" />
      <Property Name="InitiationUrl" Value="wfsvc/....aspx" />
    </File>
    ...

</Elements>

In short:

  • To disable the "Creating a new item will start this workflow" option, add the DisableAutoStartCreate property and set the value to True.
  • To disable the "Changing an item will start this workflow" option, add the DisableAutoStartChange property and set the value to True.
  • To disable the "Allow this workflow to be manually started... " option, add the DisableManualStart property and set the value to True.
As a bonus fact, if you want to set the workflow status to the current stage name (SharePoint Designer workflows do this automatically, and it's a pretty useful feature for at-a-glance progress monitoring), add the AutosetStatusToStageName property and set the value to True.

I figured this out by creating a basic reusable workflow in SharePoint Designer, saving it as a template, then cracking open the .wsp file to look at how SPD goes about constructing workflows. If you do this, you'll find that the feature element file created by SPD specifies a property named MetaInfo on the workflow.xaml file. This property contains a bunch of property values. If you can look beyond the hideous syntax it uses, you can pick out the names of some useful properties.


<Property Name="MetaInfo" Value="8;#vti_parserversion:SR|16.0.0.4456&#xD;&#xA;
                                 WSDescription:SW|&#xD;&#xA;
                                 IsProjectMode:SW|false&#xD;&#xA;
                                 isReusable:SW|true&#xD;&#xA;
                                 WSGUID:SW|2f0c508d...&#xD;&#xA;
                                 WSDisplayName:SW|ReusableTest&#xD;&#xA;
                                 WSPublishState:IW|3&#xD;&#xA;
                                 vti_author:SR|i:0#.w|jrjlee\\jason&#xD;&#xA;
                                 RequiresAssociationForm:SW|false&#xD;&#xA;
                                 AutosetStatusToStageName:SW|true&#xD;&#xA;
                                 DisableAutoStartChange:SW|true&#xD;&#xA;
                                 RestrictToType:SW|List&#xD;&#xA;
                                 vti_foldersubfolderitemcount:IW|0&#xD;&#xA;
                                 vti_modifiedby:SR|i:0#.w|jrjlee\\jason&#xD;&#xA;
                                 FileLeafRef:SW|workflow.xaml&#xD;&#xA;
                                 ContentTypeId:SW|0x01002...&#xD;&#xA;
                                 DisableAutoStartCreate:SW|true&#xD;&#xA;
                                 RequiresInitiationForm:SW|false&#xD;&#xA;
                                 InitiationUrl:SW|&#xD;&#xA;
                                 FormField:SW|&lt;Fields /&gt;&#xD;&#xA;
                                 AssociationUrl:SW|&#xD;&#xA;
                                 DisableManualStart:SW|true&#xD;&#xA;
                                 SPDConfig.LastEditMode:SW|TextBased&#xD;&#xA;
                                 vti_folderitemcount:IW|0&#xD;&#xA;" />



Tuesday, 6 December 2016

Applying a Logo to Every Site in SharePoint Online

A quick post today on a fairly common problem - you're given a site collection on Office 365 that has grown organically with hundreds of nested sites, four or five levels deep in places. The various site owners have used many different versions of your company logo, and you want to reintroduce a degree of consistency by applying the same logo to every site in a site collection.

The most efficient way to do this is to run some client-side code from PowerShell. Basically you need to iterate over the subsites of a given site and set the Web.SiteLogoUrl property on every site. The easiest way to do this is to:
  1. Install the excellent SharePointPnP.PowerShell cmdlets.
  2. Run a script something like the one below.
# UpdateSiteLogos.ps1
# Jason Lee 5/12/16

# Variables
$rootSiteUrl = "https://[tenant].sharepoint.com/sites/[path]"
$credentialManagerLabel = "ianWilliamsAdmin"
$logoUrl = "/sites/[path]/SiteAssets/smile.png"

# Recursive function to set site logo on specified site
# Provide the server-relative URL of the root site to start
function updateSiteLogo {
    Param(
        [parameter(Mandatory=$true)]       
        [String]
        $serverRelativeUrl
    )
    $web = Get-PnPWeb -Identity $serverRelativeUrl
    Write-Output ("Updating site {0}" -f $web.ServerRelativeUrl)
    $web.SiteLogoUrl = $logoUrl
    $web.Update()
    Execute-PnPQuery
   
    $subwebs = Get-PnPSubWebs -Web $web
    foreach ($subweb in $subwebs) {       
        updateSiteLogo($subweb.ServerRelativeUrl)
    }
   
}

# Connect to the site collection, then call updateSiteLogo on the root web
Connect-PnPOnline -Url $rootSiteUrl -Credentials $credentialManagerLabel
$rootweb = Get-PnPWeb
Write-Output "Setting site logos..."
updateSiteLogo($rootweb.ServerRelativeUrl)
Write-Output "Done."

The Get-PnPWeb cmdlet actually has its own Recurse option, and you could use that if you want to rather than doing your own recursion. I steered away from it because it gets all the subsites in one hit (the right approach in most circumstances), which can cause the script to hang for a while if you've got hundreds of nested sites. This approach instead walks the site structure one level at a time.


Tuesday, 5 July 2016

Get the Current User's Manager in JavaScript

Using the JavaScript Object Model to look up the manager of the current user (from the User Profile Service) seems to be a fairly common requirement - I've had to do it at least three times in the last couple of months.

First of all, you need to load the profile properties for the current user:


var context, userProperties;

var stage1 = function() {
     context = SP.ClientContext.get_current();
     var peopleManager = new SP.UserProfiles.PeopleManager(context);
     userProperties = peopleManager.getMyProperties();

     context.load(userProperties);
     context.executeQueryAsync(stage2, onQueryFail);
}


Then you can retrieve the value of the Manager property as follows:

var stage2 = function() {
    var manager = 
         userProperties.get_userProfileProperties()["Manager"];
}


And that's it. Easy once you know how.

Incidentally, the trickiest part of all this can be getting the SharePoint script files to load in the right order. You can't run your code until sp.userprofiles.js is loaded, and you can't load sp.userprofiles.js until sp.js is loaded. The sp.userprofiles.js library seems to be particularly awkward to load. I usually use the following pattern, cobbled together from various helpful forum posts:


$(document).ready(function () {
     
// Force sp.js to load, then call sharePointReady
     if (!SP.SOD.executeOrDelayUntilScriptLoaded(sharePointReady,
          'sp.js')) {
          LoadSodByKey('sp.js');
     }
});

function sharePointReady() {
     // Force sp.userprofiles.js to load, then call our custom code
     if (!SP.SOD.executeOrDelayUntilScriptLoaded(stage1,
          'sp.userprofiles.js')) {
          LoadSodByKey('userprofile');
     }
}


Adding Site Columns to Lists and List Views using the JavaScript Object Model

A couple of years ago, I posted on how to create SharePoint site columns using JavaScript. More recently, we needed to add a site column to a list in SharePoint Online as part of a scripted provisioning exercise. There were a few aspects of this that took a bit of trial and error, such as:
  • Retrieving the site column from the root site.
  • Getting the field to show up on the default list view.
  • Hiding the field from various forms.
So I figured it's probably worth sharing the end-to-end code (sanitised and simplified). First of all, let's create a site column named JasonNotes on the root site in the site collection:

var context; 
var rootWeb; 
var rootWebId = "..."; // The GUID identifier for the root web

var stage1 = function () {         
     context = new SP.ClientContext();
     
     // Get the fields collection for the root web      
     var rootWeb = context.get_site().openWebById(rootWebId);
     var fields = rootWeb.get_fields();
     
     // Create and add the field
     var fieldSchema = '<Field Type="Note"
                               Name="JasonNotes"
                               StaticName="JasonNotes"
                               DisplayName = "Jason Notes"
                               NumLines="10"
                               Required="FALSE"
                               Group="Jason Columns">
                        </Field>';
     fields.addFieldAsXml(fieldSchema, true,
         (SP.AddFieldOptions.addToDefaultContentType &            
          SP.AddFieldOptions.addFieldCheckDisplayName));     
     
     context.executeQueryAsync(stage2, onQueryFail);
};

The next stage is to add the site column to our list. At this point, we need to ensure that the field also gets added to the default list view. This is also a good opportunity to set any properties you require on the list field, such as whether you want it to appear on forms:



var listTarget; 
var listTitle = "..."; // The title of the target list

var stage2 = function () {
     context = SP.ClientContext.get_current();
     
     // Get the field from the root web
     rootWeb = context.get_site().openWebById(rootWebId);
     var webFields = rootWeb.get_fields();
     var fldJasonNotes = 
          webFields.getByInternalNameOrTitle("JasonNotes");


     // Get the list that we want to add the column to

     var web = context.get_web();
     listTarget = 
          web.get_lists().getByTitle(listTitle);
     
     // Add the field to the field collection for the list
     var listFields = listTarget.get_fields();
     var listFldJasonNotes = listFields.add(fldJasonNotes);
     
     // Show the field only on the New form            
     listFldJasonNotes.setShowInDisplayForm(false);
     listFldJasonNotes.setShowInEditForm(false);
     listFldJasonNotes.setShowInNewForm(true);
     listFldJasonNotes.update();

     // Add the JasonNotes field to the default view
     var defaultView = listTarget.get_defaultView();
     var defaultViewFields = defaultView.get_viewFields();
     defaultViewFields.add("JasonNotes");
     defaultView.update();
     listTarget.update();

     context.executeQueryAsync(stage3, onQueryFail);
};

There are a few noteworthy points in the code. First of all, when we've added the site column (fldJasonNotes) to the list, note that we need to grab a reference to the resulting list column (listFldJasonNotes) if we want to set list-specific properties.

Second, note how we approach adding the new column to the default list view:
  1. Call get_defaultView to get the default list view from the list instance.
  2. Call get_viewFields to get the field collection from the default list view.
  3. Add the field by name to the field collection.
  4. Call the update method on the view.
Hope that helps!