8/21/2017

SharePoint Framework Extensions: Application Customizer

In this post I want to show you step-by-step implementation of SharePoint Framework Application Customizer.
UPDATE: on 9/25/2017 SPFx Extensions were GA-ed. The post is updated to contain the latest version of API.
UPDATE: on 8/29/2017 Release Candidate was announced. The post is updated to contain newer version of API.
Use Case for the sample:
Your team is working on some project and you're using SharePoint Tasks list to assign and monitor tasks in the project. You want to notify a member of your team that he or she has overdue tasks. You want this notification to be available on any page on your site and also to provide link to the Tasks list view filtered by current member and by due date.
The result may look like that:

Key features of the sample:
For those who doesn't want to read the post you can get it from here.

Prerequsites

Before following the steps in this article, be sure to proceed next 3 steps:
  • Get Office Developer Tenant (you can get one for free by subscribing to Office 365 Developer Program. It's an optional step as Extensions reached General Availability but it's good to have the tenant to be able to get preview versions in future.
  • Set up your development environment
  • Create Tasks list on your tenant's site

General Information

Customizer Purpose

  • Inject custom JavaScript on the page
  • Insert custom HTML to well-known locations on the page - Placeholders

Entry Points

  • onInit() – runs code before page DOM is ready. Perform needed setup here
  • this.context.application._layoutChangedEvent – event that gets raised every time the layout changes in a page. It's marked as internal but still available for usage
  • this.context.application.navigatedEvent – event that gets raised every time there is a page navigation
  • this.context.placeholderProvider.changedEvent – event that gets raised when the list of currently available placeholders is changed

Helpful APIs

  • this.properties – properties that are passed to the customizer
  • this.context.pageContext – standard definitions for common SharePoint objects (site, web, user, list, etc.)
  • this.context.httpClient, this.context.spHttpClient, this.context.graphHttpClient – helpers to send http requests, http requests with SharePoint context and Microsoft Office Graph requests
  • this.context.placeholderProvider – helper object to work with placeholders.
  • this.context.application – helper object to work with "application". For now it can be used to attach to application's events.

Working with Placeholders

  • Currently available placeholders (as of Oct 2, 2017): Top, Bottom
  • Import PlaceholderContent and PlaceholderName definition:
    import { PlaceholderContent, PlaceholderName } from '@microsoft/sp-application-base';
    
  • Get placeholder instance:
    const placeholder = this.context.placeholderProvider.tryCreateContent(PlaceholderName.Top);
    
  • Insert markup:
    placeholder.domElement.innerHTML = `<div>Hello World</div>`;
    

Scaffolding the Project

First step in any SPFx project is to "scaffold" it using Yeoman SharePoint Generator. "Scaffold" in more classic terms means generate or create from a template.
Here are the steps to create a project.
  1. Open Command Prompt (Terminal in MacOS) or any Console tool
  2. Create a new directory for the project in the location you want
    mkdir overdue-tasks
    
  3. Go to the created directory
    cd overdue-tasks
    
  4. Start scaffolding new project with Yeoman SharePoint Generator
    yo @microsoft/sharepoint
    
  5. When prompted
    • Accept default solution name (overdue-tasks)
    • For baseline packages select SharePoint Online only (latest) as SPFx Extensions are not available for SharePoint 2016
    • Select Use the current folder
    • You can select either y or N for availability of your extension across tenant. It depends on how you're going to install it. If you use Feature Framework to add application customizer then this option will not work as it doesn't work with Feature Framework
    • Select Extension as a client-side component
    • Select Application Customizer as an extension type
    • Type OverdueTasks as a name
    • Accept default description
  6. Wait until Yeoman is installing all needed dependencies
Congratulations! The project has been scaffolded! Now you can open the project in favorite IDE.
Note: this is a TypeScript project so select the IDE that supports TypeScript language. I'm using Visual Studio Code.
The project structure should look like the one on the picture below

Debugging the Extension using gulp serve and query string

I decided to put this section at the beginning so you could run the code each time you want to check if everything works fine.
SharePoint Framework extensions cannot currently be tested using the local workbench, so you'll need to test and develop them directly against a live SharePoint Online site. You do not however need to deploy your customization to the app catalog to do this, which keeps the debugging experience simple and efficient. First, compile your code and host the compiled files from the local machine by running this command:
gulp serve --nobrowser
--nobrowser parameter tells to start the server but do not open workbench page in the browser.
If the code compiled without errors the server will be running on http://localhost:4321.
To test the extension
  1. Go to SharePoint Dev tenant
  2. Open any list/document library/modern page
  3. Append next query string parameters to the list's URL:
    ?loadSPFX=true&debugManifestsFile=https://localhost:4321/temp/manifests.js&customActions={"7ce714f7-704a-48a5-9a4a-9683c9f668f4":{"location":"ClientSideExtension.ApplicationCustomizer","properties":{"testMessage":"Tasks"}}}
    
    Where:
    • loadSPFX=true ensures that the SharePoint Framework is loaded on the page. For performance reasons, the framework is not normally loaded unless at least one extension is registered. Since no components are registered yet, we must explicitly load the framework.
    • debugManifestsFile=<URL> specifies that we want to load SPFx components that are being locally served. Normally the loader only looks for components in the App Catalog (for your deployed solution) and the SharePoint manifest server (for the system libraries).
    • customActions simulates Custom Action. The Key here specifies the GUID of the extension that should be loaded - ID value is located in manifest.json file of the extension. Location parameter should be ClientSideComponent.ApplicationCustomizer for this type of customizer. The properties parameter is an optional text string containing a JSON object that will be deserialized into this.properties for your extension (we will modify it later for the purposes of the sample).
    The full URL should be similar to following (depending on tenant URL and List URL):
    https://terentiev.sharepoint.com/Lists/Slider%20Test/AllItems.aspx?loadSPFX=true&debugManifestsFile=https://localhost:4321/temp/manifests.js&customActions={"7ce714f7-704a-48a5-9a4a-9683c9f668f4":{"location":"ClientSideExtension.ApplicationCustomizer","properties":{"testMessage":"Tasks"}}}
    
  4. Accept loading of Debug Manifests
  5. You should see a popup

Adding Additional Libraries

Next step is to add external libraries (modules) that will be used in the project.
In this sample we'll need to request data from SharePoint (overdue tasks and also a URL to redirect user to the Tasks list).
It may be done with REST API using this.context.spHttpClient helper. But I prefer to use SP PnP JS library which provides great syntax for generating REST requests.
PnP JS library can be added using npm package manager:
  1. In Command Prompt (Terminal) go to project directory.
  2. Install the PnP package
    npm install sp-pnp-js --save
    
Now the module can be imported to any file in the project
import pnp from 'sp-pnp-js';

Getting Data from SharePoint

As mentioned earlier we need to get overdue tasks and Tasks list url from SharePoint. And to get that data we need to know Tasks list id or Guid. It can be hardcoded but it's better to use this.properties object for that. Let's modify IOverdueTasksApplicationCustomizerProperties interface which describes what members will be available in this.properties:
export interface IOverdueTasksApplicationCustomizerProperties {
  tasksListTitle: string;
}
Also, we need to add fields to the Customizer class to store requested url and tasks:
private _viewUrl: string;
private _overdueTasks: any;
Best place to make SharePoint requests is onInit method. It returns Promise object which means that the SharePoint Framework will wait until the Promise is resolve and will call onRender only after that. So you can make all your requests and resolve the promise when you get all the responses.
So, all the code inside onInit should be wrapped with the Promise constructor:
public onInit(): Promise {
    return new Promise((resolve) => {
      // your code goes here
    });
  }
As I'm going to use SP PnP JS library to make requests, I need to initialize it first with SPFx Context to provide correct SharePoint context:
pnp.setup({
  spfxContext: this.context
});
One more great feature to use from PnP is batching: using batch object we can combine multiple requests and send it as single one. So, let's create a batch:
const batch = pnp.sp.createBatch();
The request for view url is shown below:
pnp.sp.web.lists.getByTitle(this.properties.tasksListTitle).views.getByTitle('Late Tasks').inBatch(batch).get().then((view: any) => {
  // our URL contains Late Tasks View URL and filter by current user
  this._viewUrl = `${view.ServerRelativeUrl}?FilterField1=AssignedTo&FilterValue1=${escape(this.context.pageContext.user.displayName)}`;
});
If look at the code it is similar to constructing REST API endpoint /web/lists/getByTitle('<title>>')/views/getByTitle('<view_title>') Then the request is added to the batch.
And when the response is received we're constructing the Url using Late Tasks view Url and filter by current user.
Why Late Tasks view? Because it has filter to show overdue tasks only.
Additionally you may notice usage of escape function when creating filter parameters in Url Query Strings. This function is imported from sp-lodash-subset module:
import { escape } from '@microsoft/sp-lodash-subset';
The request from overdue tasks:
let today: Date = new Date();
today.setHours(0, 0, 0, 0);

pnp.sp.web.lists.getByTitle(this.properties.tasksListTitle)
  .items.expand('AssignedTo/Id').select('Title, AssignedTo, AssignedTo/Id, DueDate')
  .filter(`AssignedTo/Id eq ${this.context.pageContext.legacyPageContext.userId} and DueDate lt datetime'${today.toISOString()}'`)
  .inBatch(batch)
  .get().then((items: any) =&gt {
    this._dueTasks = items;
});
Similarly to view Url request we're constructing REST endpoint to get items from the list. Additionally, we're "expanding" AssignedTo lookup field to get user id and then filtering the items by Due Date and expanded Assigned To User Id.
The request is also added to the batch.
The last command in onInit would be to execute batch. When the batch is executed we can finally call the method to render our content and resolve the promise:
batch.execute().then(() => { 
  this._renderPlaceholder();
  resolve(); 
});
Full code of onInit in listed below:
public onInit(): Promise {
  return new Promise((resolve) => {
    pnp.setup({
      spfxContext: this.context
    });

    const batch = pnp.sp.createBatch();

    pnp.sp.web.lists.getByTitle(this.properties.tasksListTitle).views.getByTitle('Late Tasks').inBatch(batch).get().then((view: any) => {
      this._viewUrl = `${view.ServerRelativeUrl}?FilterField1=AssignedTo&FilterValue1=${escape(this.context.pageContext.user.displayName)}`;
    });

    let today: Date = new Date();
    today.setHours(0, 0, 0, 0);

    pnp.sp.web.lists.getByTitle(this.properties.tasksListTitle)
      .items.expand('AssignedTo/Id').select('Title, AssignedTo, AssignedTo/Id, DueDate')
      .filter(`AssignedTo/Id eq ${this.context.pageContext.legacyPageContext.userId} and DueDate lt datetime'${today.toISOString()}'`)
      .inBatch(batch)
      .get().then((items: any) => {
        this._overdueTasks = items;
    });

    batch.execute().then(() => { 
      this._renderPlaceholder();
      resolve(); 
    });
  });
}
Now we have all the data to display a warning message. And rendering part is done in custom _renderPlaceholder method.
First thing to do here is to check if there are overdue tasks as there is no need to display message if there are no overdue tasks:
if (!this._overdueTasks || !this._overdueTasks.length) {
  return;
}
If there are overdue tasks we need to get Top placeholder content and insert HTML markup to it.
First, let's import PlaceholderContent and PlaceholderName definitions from sp-application-base:
import {
  BaseApplicationCustomizer,
  PlaceholderContent,
  PlaceholderName
} from '@microsoft/sp-application-base';
Then, let's add a field to store Placeholder instance:
private _topPlaceholder: PlaceholderContent;
Now let's get the instance in _renderPlaceholder:
if (!this._topPlaceholder) {
  this._topPlaceholder = this.context.placeholderProvider.tryCreateContent(PlaceholderName.Top, {
    onDispose: () => {}
  });
}
When getting the PlaceholderContent instance you can provide onDispose handler. It is done to be able to release allocated resources when your control is removed from the page.
In this sample we don't have any additional resources so the handler has no code. But it can be used, for example, to dispose React component.
And the last step is do add a markup:
this._topPlaceholder.domElement.innerHTML = `
<div class="${styles.app}">
  <div class="ms-bgColor-themeDark ms-fontColor-white ${styles.header}">
    <i class="ms-Icon ms-Icon--Info" aria-hidden="true"></i> ${escape(strings.Message)}&nbsp;
    <a href="${this._viewUrl}" target="_blank">${escape(strings.GoToList)}</a>
  </div>
</div>`;
Full code of _renderPlaceholder looks like that:
if (!this._overdueTasks || !this._overdueTasks.length) {
  return;
}

  if (!this._topPlaceholder) {
    this._topPlaceholder = this.context.placeholderProvider.tryCreateContent(PlaceholderName.Top, {
      onDispose: () => { }
    });
  }

  this._topPlaceholder.domElement.innerHTML = `
  <div class="${styles.app}">
<div class="ms-bgColor-themeDark ms-fontColor-white ${styles.header}">
>i aria-hidden="true" class="ms-Icon ms-Icon--Info"&lgt;</i> ${escape(strings.Message)}&nbsp;
      <a href="https://www.blogger.com/$%7Bthis._viewUrl%7D" target="_blank">${escape(strings.GoToList)}</a>
    </div>
</div>
`;
}

Deployment

Deployment of any SharePoint Framework solution is done similarly to the deployment on SharePoint Add-ins:
  • Package the solution
  • Deploy the solution to App Catalog
  • Install the app to the specific site
To package the solution you should run 2 Gulp tasks (note that you need to modify generated solution_folder/sharepoint/assets/elements.xml file if you want to change the title of the Custom Action or provide correct properties):
gulp bundle --ship
gulp package-solution --ship
The first one will compile and bundle all the code in Release mode (without debug information and with minification)
The second one will create .sppkg file in solution_folder/sharepoint folder and also prepare all the assets to be deployed to some storage. It may be Office 365 CDN, Azure Storage or any other type of public or organization storage. Preparation of the storage is a large separate topic. You can read about Office 365 CDN here and about Azure CDN here.
After the solution is packaged and the assets are deployed to some CDN the solution can be deployed (manually or using some custom routine) to App Catalog and later installed to some site.
By default, the application customizer will be available as soon as the app is installed to the site. If you need some custom logic that will optionally activate the Customizer you can use CSOM/JSOM for that:
  1. Delete elements.xml in solution_folder/sharepoint/assets
  2. Remove feature from package-solution.json that references elements.xml
  3. Deploy the solution to App Catalog and install on the site
  4. Get Web CustomActions collection:
    var ctx = new ClientContext("web_url");
    var web = ctx.Web;
    var customActions = web.UserCustomActions;
    
  5. Add new CustomAction with correct ClientSideComponentId and ClientSideComponentProperties: var ca = customActions.Add(); ca.ClientSideComponentId = new Guid("customzier_id"); ca.ClientSideComponentProperties = "customizer_props"; ca.Title = "Application Customizer Title"; ca.Location = "ClientSideExtension.ApplicationCustomizer"; ca.Update(); web.Update(); ctx.ExecuteQuery();
Now the Application Customizer will be added to the specific site.
And that's it!
Now you should be ready to create your Application Customizers, debug them and deploy. You can get the code from GitHub repo

Have fun!

No comments:

Post a Comment