Creating Services with SPFx

Update 21st April 2017: While the SPFx service model behaves as expected on the SharePoint workbench, I've had less success when the web parts that consume the service are packaged and deployed via the App Catalog. When the script files and the service package are served locally, the service class is instantiated only once. When the script files are served from a CDN, the service class is instantiated multiple times: once by each web part that consumes the service. The call to ServiceKey.create is also executed once for each web part that consumes the service - which effectively means that the page-level ServiceScope hosts multiple instances of the same service, each registered against a different ServiceKey object. I can only assume that this is due to a nuance of the bundling process that I'm missing. The result of this is that the SPFx service model isn't currently all that useful to me for sharing data between web parts. As a workaround, I've reverted to storing data in a globally-scoped variable - not an ideal solution, but it works reliably. I'll post a further update as more information becomes available.

Update 11th May 2017: Waldek Mastykarz has confirmed that the shared services approach isn't currently supported beyond the local workbench. I still love this approach in theory, so lets hope the SharePoint team go for it.

In SharePoint developer land, we're all getting increasingly familiar with the benefits of using the SharePoint Framework (SPFx) to create self-contained client web parts. However, there are many scenarios where you might want to share functionality or data between the web parts on a page. For example, suppose you're building intranet-style functionality where a user subscribes to tags, and you use these tags across multiple web parts - news, events, classifieds, whatever you like - to filter what you retrieve and what the user sees. Retrieving those tags in every web part on the page would be inefficient and is likely to give you a poor user experience.

It turns out there's a neat way of meeting this requirement in the form of SPFx services. These are built as standalone node packages that you reference from your web parts. Multiple web parts on the page might use the service, but the service is only instantiated once. As a result, we can use our service to share both data and functionality within a web part page.

It seems that SPFx services are like the neglected sibling of SPFx web parts that no one wants to talk about - at the time of writing, the only documentation I've seen is this tech note on the Service Scope API and this subheading in the SPFx guidance. Actually getting a service up and running took a little bit of figuring out - especially configuring the package.json file correctly and testing the package locally - hence this post.

Sample Solution

Let's look at a dead simple service that gets a list of lists from the current SharePoint site. I've posted the bare-bones project on GitHub if you want to browse.

Key Points

Have a read of the existing documentation first (like I said, here and here) for an overview of the process. I'm just going to call out a few points that took a little more time to figure out.

Creating the project structure

Generate the project structure in the same way that you'd create an SPFx web part project - by using the yo @microsoft/sharepoint scaffolder (use the No JavaScript web framework option). When the project has been created, in the src folder, delete the entire webparts folder - you don't need any of this. Replace it with a folder named service (or a name of your choosing). 

Creating files

In the service folder, you'll want at least two files:
  • A TypeScript file that contains your service logic (in this case, ListGetter.ts).
  • A JSON-based manifest file that describes the component (ListGetter.manifest.json).
The manifest.json file looks very similar to the manifest for a web part (copy and paste opportunity), except you should set the componentType to Library instead of WebPart. (And if you do copy and paste, make sure you generate a new GUID for the id property.)

{    
  "$schema""../../node_modules/@microsoft/sp-module-interfaces/lib/manifestSchemas/jsonSchemas/clientSideComponentManifestSchema.json",
  "id""fb56669f-3d74-4982-abe6-02cfa1065272",
  "alias""ChorusListService",
  "componentType""Library",
  "version""0.0.1",
  "manifestVersion"2  
}

The ListGetter.ts file defines the service logic, and the requirements here are explained in more detail by Microsoft's Service Scope API note. At a high level, this file:
  • Exports an interface (IListGetter) that defines the shape of our service.
  • Exports a class (ListGetter) that provides a default implementation of the service.
Again, at a high level, the service class must:
  • Provide a constructor that accepts an argument of type ServiceScope. (This constructor gets called by the service locator implementation in the SharePoint Framework.)
  • Provide a static key of type ServiceKey that uniquely identifies your service and indicates the default implementation of the service.
The file looks like this:

import { ServiceScopeServiceKey } from '@microsoft/sp-core-library';
import { SPHttpClientSPHttpClientResponse } from '@microsoft/sp-http';
import { IDropdownOption } from 'office-ui-fabric-react';
import { IWebPartContext } from '@microsoft/sp-webpart-base';

/**
 * Interface for a service that retrieves lists from the current site
 */
export interface IListGetter<T> {
    /**
     * Retrieves the ID and Title of all the lists in a SharePoint site
     * @param context - the IWebPartContext object provided by the web part consuming this service
     * @param includeHidden - whether you want to include hidden lists in the results
     */
    getLists(contextIWebPartContextincludeHiddenBoolean): Promise<T>;
}

/**
 * An implementation of the IListGetter service
 * @class
 */
export default class ListGetter implements IListGetter<IDropdownOption[]> {
    /**
     * SPFx services must include a constructor that accepts an argument of type ServiceScope
     * @constructor
     * @param serviceScope
     */
    constructor(serviceScopeServiceScope) {
    }

    /**
     * Retrieves the ID and Title of all the lists in a SharePoint site
     * @param context - the IWebPartContext object provided by the web part consuming this service
     * @param includeHidden - whether you want to include hidden lists in the results
     */
    public getLists(contextIWebPartContextincludeHiddenBoolean = false): Promise<IDropdownOption[]> {
        const endpoint = includeHidden 
          ? '/_api/web/lists?$select=Title,Id' 
          : '/_api/web/lists?$filter=Hidden%20eq%20false&$select=Title,Id';
        return new Promise<IDropdownOption[]>((resolve: (optionsIDropdownOption[]) => voidreject: (errorany=> void=> {
            context.spHttpClient
                .get(context.pageContext.web.absoluteUrl + endpointSPHttpClient.configurations.v1)
                .then((responseSPHttpClientResponse=> {
                    response.json().then((listsany=> {
                        const dropdownOptionsIDropdownOption[] = lists.value.map(list => {
                            return <IDropdownOption>({
                                key: list.Id,
                                text: list.Title
                            });
                        });
                        resolve(dropdownOptions);
                    });
                });
        });
    }

    /**
     * A lookup key that the service locator uses to retrieve this service
     */
    public static readonly serviceKeyServiceKey<IListGetter<IDropdownOption[]>> 
      = ServiceKey.create<IListGetter<IDropdownOption[]>>('Chorus.SPFxServices.ListGetter'ListGetter);
}

Editing the package.json file

This is the bit that took me the most time to figure out. In order for service consumers to be able to resolve your node package and to get some IntelliSense, you need to add main and typings entries to your package.json file. More broadly:
  • name: Make sure this is unique - globally unique if you plan to push it up to npm, or locally unique if you're publishing to a local feed.
  • description: Be nice and provide one.
  • private: If you want to publish to a feed - even a private company feed - you'll need to remove this. But it doesn't stop you testing locally.
  • main: This should point to the JavaScript file that defines your service (e.g. ListGetter.js) in the lib folder.
  • typings: This should point to the corresponding typings file (.d.ts) in the lib folder.
You may also want to use this opportunity to remove any unused dependencies from package.json.

Testing the package locally

To use your service, you need to import the service package into your web part project in the same way that you import other dependencies. However, you might not want to build and deploy the package to npm (or your company feed, etc) while you're building out your proof-of-concept. The easiest way to test it out is to add a relative folder reference in your web part project (assuming your service project and your web part project are on the same machine):

import ListGetter,{IListGetter} from '../../../../../ChorusListService';

For this to work, you need to reference the folder that contains the package.json file for your service package - in this case ChorusListService. Note that this approach does still require that the package.json file for your service is properly configured - your web part project won't resolve the package if it isn't.

Alternatively, if you can't use a folder reference for whatever reason or you want to know exactly what will be included in your node package, you can run the npm pack command at the root of your service project. This will generate a .tgz (TAR archive) file in your root folder named something like chorus-list-service-n.n.n.tgz. In terms of content and structure, this is exactly the same as your published node package. You can then install it in your web part project:

npm install C:\path\to\chorus-list-service-0.0.4.tgz

And then import it into your web part modules by name, just like a regular node module:

import ListGetter,{IListGetter} from 'chorus-list-service';

If you're able to import and use the functionality from the .tgz file, you can be pretty confident that things will continue to work when you deploy your package to npm or a company feed.

Consuming the service

Once you've imported the package into your web part module, you can put the service to use. First, define a field of your service interface type. Next, in the onInit method for your web part, grab the parent service scope from the web part context and retrieve a service instance, as follows:

private listGetterIListGetter<IDropdownOption[]>;

protected onInit<T>(): Promise<T> {
  const serviceScopeServiceScope = this.context.serviceScope.getParent();
  serviceScope.whenFinished(():void =>{      
    this.listGetter = serviceScope.consume(ListGetter.serviceKeyas IListGetter<IDropdownOption[]>;
  });

  ...
  return Promise.resolve();

You can then use the service anywhere you like - for example to populate a dropdown list in the property pane:

private listsIDropdownOption[];

protected onPropertyPaneConfigurationStart(): void {
  this.listsDropdownDisabled = !this.lists;

  // Display a loading indicator while we do the data retrieval
  this.context.statusRenderer.displayLoadingIndicator(this.domElement'available lists');

  // Use the service to get the list of lists
  this.listGetter.getLists(this.context as anyfalse)
    .then((listOptionsIDropdownOption[]): void => {
      this.lists = listOptions;
      this.listsDropdownDisabled = false;
      this.context.propertyPane.refresh();
    });
}

Next steps

This post illustrates a bare bones service that gets a list of lists from the current site. The idea is that multiple web parts on a page could all consume this service without duplicating functionality. To make the service useful, the next logical step is to build in some caching functionality to store the list data after the first request. That way, the service only needs to retrieve list data from the server once per page load, regardless of how many web parts request the data. You'll probably also want to create a mock implementation of the service that returns some dummy data, for example for when you're running on the workbench.

Comments

Popular posts from this blog

Server-side activities have been updated

The target principal name is incorrect. Cannot generate SSPI context.

Custom Workflow Activity for Creating a SharePoint Site