Asynchronous Dynamic Properties in SharePoint Framework Webpart Property Panes

Recently Wictor Wilén penned a great how to on dynamically loading data into a SharePoint Framework Webpart property pane. Yet what if you want to dynamically, with asynchronous data, populate a property’s options based on the value of another property?

Within the Framework, we configure the property pane within the method: propertyPaneSettings(), which returns IPropertyPaneSettings, basically an array of “pages” for the pane. That catch is that this does not return a Promise, rather a static object, thus we have to have all data available that we want to present within the property pane at the time of loading the property pane. Wictor’s solution gets around this by loading asynchronous data for one field in the webpart’s OnInit method, which works well one level of dynamic data, but not dynamic data based on other property pane values.

A solution was found to this issue by using a method call, this.configureStart(); within a the propertyPaneSettings() method, thus allowing us to reload the property pane once our Async promise is complete. This has been documented in part on the GitHub Framework wiki, yet I want to offer a complete demonstration of this in action.

Caveat: As the SharePoint Framework is still in Developer Preview, currently up to Drop 5, this solution may change.

spfx-dynamic-propertypane2

The Code:

All code is available from github at https://github.com/eoverfield/SPFx-Demos/tree/master/WebPart-Properties-Dynamic-Load. If you clone the repo from https://github.com/eoverfield/SPFx-Demos, look in the WebPart-Properties-Dynamic-Load folder. This is based on the basic SPFx Yeoman template, Code Drop 5.

Walkthrough:

I suggest you download al code form GitHub although I will highlight the major changes made to enable asynchronous property pane options.

All significant modifications are within the Webpart src folder, in this case /src/webparts/ demoWpDynamicProperties.

Define the additional webpart properties

Within the webpart property configuration file, i.e. IDemoWpDynamicPropertiesWebPartProps.ts, add the additional properties we want to store.

1
2
3
4
5
6
export interface IDemoWpDynamicPropertiesWebPartProps {
  title: string;
  description: string;
  listName: string;
  listColumn: string;
}

Set default values for the additional webpart properties

This is not a requirement, but within the webpart manifest, it is best practices to set default properties values so that nothing is left undefined. In this case, update DemoWpDynamicPropertiesWebPart.manifest.json. Be aware that if a change is made to this file, to see the changes in the browser you will want to stop and start gulp.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "$schema": "../../../node_modules/@microsoft/sp-module-interfaces/lib/manifestSchemas/jsonSchemas/clientSideComponentManifestSchema.json",

  "id": "f149d188-ff1c-4149-92fa-8fbbc316e784",
  "componentType": "WebPart",
  "version": "0.0.1",
  "manifestVersion": 2,

  "preconfiguredEntries": [{
    "groupId": "f149d188-ff1c-4149-92fa-8fbbc316e784",
    "group": { "default": "Under Development" },
    "title": { "default": "Demo Dynamic Properties" },
    "description": { "default": "Demo of Dynamic Webpart Properties" },
    "officeFabricIconFontName": "Page",
    "properties": {
      "title": "Default title",
      "description": "Default Description",
      "listName": "",
      "listColumn": ""
    }
  }]
}

Create a new Interface to handle SharePoint and Mock data

I added a new file, ListService.ts, that provides a new interface, IListsService. There are quite a few ways to handle mock / SharePoint data, but I have been leaning more towards an interface to address access to specific types of data, in this case SharePoint List data.

The basics of this interface is that there are two methods, one to get all lists within a web and a second method to get all of the columns (fields) within a list. I am only providing the basic code for demonstration purposes. There is room for improvement.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
'use strict';

import {
  IWebPartContext,
  IPropertyPaneDropdownOption
} from '@microsoft/sp-client-preview';

//import the HttpClient to make calls to SharePoint
import { HttpClient } from '@microsoft/sp-client-base';

//create the exportable interface to different classes
export interface IListsService {
  getListNames(): Promise<IPropertyPaneDropdownOption[]>;
  getListColumnNames(listId: string): Promise<IPropertyPaneDropdownOption[]>;
}

//create an interface for array helpers. Should normally be in its own file, but for ease of use, added here
export interface IArrayHelper {
    sortByKey(array: Object[], key: string): Object[];
}
export class ArrayHelper implements IArrayHelper {
  constructor() {
  }

  //return a new array that is sorted by the object key of an array of objects
  public sortByKey(array: Object[], key: string) {
    return array.sort((a, b) => {
        var x = a[key]; var y = b[key];
        return ((x < y) ? -1 : ((x > y) ? 1 : 0));
    });
  }
}

//Class for mock data - when not conntected to SharePoint - for testing
export class MockListsService implements IListsService {
  constructor() {
  }

  //get a list of list names
  public getListNames(): Promise<IPropertyPaneDropdownOption[]> {
    return new Promise<IPropertyPaneDropdownOption[]>(resolve => {
      //add a delay to simulate slow connection
      setTimeout(() => {
        var options: Array<IPropertyPaneDropdownOption> = new Array<IPropertyPaneDropdownOption>();
        options.push( { key: '1', text: 'List 1' });
        options.push( { key: '2', text: 'List 2' });

        resolve(options);
      }, 1000);
    });
  }

  //get a list of columns within a list
  public getListColumnNames(listId: string): Promise<IPropertyPaneDropdownOption[]> {
    return new Promise<IPropertyPaneDropdownOption[]>(resolve => {
      //add a delay to simulate slow connection
      setTimeout(() => {
        var options: Array<IPropertyPaneDropdownOption> = new Array<IPropertyPaneDropdownOption>();
        options.push( { key: 'column1', text: 'Column 1' });
        options.push( { key: 'column2', text: 'Column 2' });
        options.push( { key: 'column3', text: 'Column 3' });
        options.push( { key: 'column4', text: 'Column 4' });

        resolve(options);
      }, 1000);
    });
  }
}

//Class for getting live SharePoint data
export class ListsService implements IListsService {
  //add class vars for use in methods
  private _httpClient: HttpClient;
  private _webAbsoluteUrl: string;
  private _arrayHelper: ArrayHelper = new ArrayHelper();

  //constructor of class, set up values for our HttpClient and more
  public constructor(webPartContext: IWebPartContext) {
    this._httpClient = webPartContext.httpClient as any; // tslint:disable-line:no-any
    this._webAbsoluteUrl = webPartContext.pageContext.web.absoluteUrl;
  }

  //get the lists found in the current web
  public getListNames(): Promise<IPropertyPaneDropdownOption[]> {
    //using SharePoint API to get list of lists, ordered by Title. Select ID and Title of each list
    return this._httpClient.get(this._webAbsoluteUrl + `/_api/Lists/?$select=Id,Title&$filter=Hidden ne true&$orderby Title`)
      .then((response: Response) => {
        var options: Array<IPropertyPaneDropdownOption> = new Array<IPropertyPaneDropdownOption>();

        return response.json().then((data) => {
            data.value.forEach(list => {
                options.push( { key: list.Id, text: list.Title });
            });
            //since the returned values are pre-ordered, just return the list of lists
            return options;
        });
      });
  }

  //get a list of columns within a list
  public getListColumnNames(listId: string): Promise<IPropertyPaneDropdownOption[]> {
    //based on the list id, get the fields for a given list. Unable to sort as orderBy does not work with field selection
    return this._httpClient.get(this._webAbsoluteUrl + `/_api/web/lists(guid'` + listId + `')/fields?$select=Id,Title&$filter=Hidden ne true`)
      .then((response: Response) => {
        var options: Array<IPropertyPaneDropdownOption> = new Array<IPropertyPaneDropdownOption>();

        return response.json().then((data) => {
            //sort the fields
            data.value = this._arrayHelper.sortByKey(data.value, 'Title');

            data.value.forEach(list => {
                options.push( { key: list.Id, text: list.Title });
            });
            return options;
        });
    });
  }
}

Update the Webpart code

Most of the magic now happens in our Webpart source file, i.e. DemoWpDynamicPropertiesWebPart.ts.

First we need to import a few more references from @microsoft/sp-client-preview, PropertyPaneDroopDown to access dropdowns and IPropertyPaneDropdownOption to work with dropdown options.

1
2
3
4
5
6
7
8
import {
  BaseClientSideWebPart,
  IPropertyPaneSettings,
  IWebPartContext,
  PropertyPaneTextField,
  PropertyPaneDropdown,       //add reference to drop down
  IPropertyPaneDropdownOption //add reference to drop down options
} from '@microsoft/sp-client-preview';

We want to also import interfaces from our ListService.ts file

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
//import in the ListService class we create
import * as ListService from './ListService';
[\cc]

And we will be using <strong>DisplayMode</strong> from <strong>@microsoft/sp-client-base</strong> to know if we are in edit mode so we only load our lists for the property pane when in edit mode.

[cc lang="csharp" lines="2" tab_size="3" line_numbers="true" width="600" escaped="true"]
//import Env type from sp-client-base to be able to determine env currently running in
import { EnvironmentType, DisplayMode,  } from '@microsoft/sp-client-base';
[\cc]

Within our Webpart class, i.e. <strong>DemoWpDynamicPropertiesWebPart</strong> that extends <strong>BaseClientSideWebPart</strong>, we need to add / change a few aspects around.

Add new class level private variables to hold a list of lists and a list of columns for a specific list.

[cc lang="csharp" lines="3" tab_size="3" line_numbers="true" width="600" escaped="true"]
//vars to hold a list of lists and a list of columns for a given list
private _listOptions: IPropertyPaneDropdownOption[];
private _columnOptions: IPropertyPaneDropdownOption[];

To load the initial list of lists, I prefer Wictor’s solution of loading the list in the <strong>OnInit</strong> method, although in this case we are also resetting our class arrays of lists and columns, and then only in edit mode, we go and grab a list of lists.

[cc lang="csharp" lines="20" tab_size="3" line_numbers="true" width="600" escaped="true"]
  //on the webpart initialization, process
  public onInit<T>(): Promise<T> {
    this._listOptions = [];
    this._columnOptions = [];

    //only execute when in edit mode
    if (DisplayMode.Edit === 2) {
      //go and load the dropdown list of SharePoint lists upon init so as to speed up process
      //inspiration from http://www.wictorwilen.se/sharepoint-framework-how-to-properly-dynamically-populate-dropdown-property-pane-fields
      var dataService = (this.context.environment.type === EnvironmentType.Test || this.context.environment.type === EnvironmentType.Local) ?
        new ListService.MockListsService() :
        new ListService.ListsService(this.context);

      return new Promise<T>((resolve: (args: T) => void, reject: (error: Error) => void) => {
        dataService.getListNames().then(
          (lists : IPropertyPaneDropdownOption[]) => {
            this._listOptions = lists;
          }
        );

        resolve(undefined);
      });

    }
  }

spfx-dynamic-propertypane2

When the selected list property value changes, as in when a property value of interst changes, we want to trap that and possibly do something different. In this case, when a new list is selected from the list dropdown, we want to trap this and reset the columns within that list dropdown, as well as reset the column previously selected. This may or may not be necessary when you want to dynamically load async property pane properties.

What I do find interesting is that after we handle our special case, we should call super.onPropertyChange(propertyPath, newValue); to have the default method called for all other properties, and to kick off other default onchange events.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  //override property change method so that when new list selected, we can update other columns
  public onPropertyChange(propertyPath: string, newValue: any): void {

    this.properties[propertyPath] = newValue;

    //if the list was changed, we need to go and get columns for that list
    if (propertyPath === 'listName') {
      //reset header and body columns
      this._columnOptions = [];
      this.properties.listColumn = '';
    }

    //and now complete normaly property change
    super.onPropertyChange(propertyPath, newValue);
  }

spfx-dynamic-propertypane2

Finally we need to update the method propertyPaneSettings(). This method simply returns a iPropertyPageSettings object, which is an array of “pages”, or property pane pages. First we load up our options based on the data we have available to us, i.e. the list of lists. If we do not have a list of columns, yet a list has been selected, we kick off a method in our ListService interface that returns a promise. When the promise completes, we can call a magic function, this.configureStart();, which makes this whole process work. this.configureStart(); will cause the property pane to reset itself with the newly retrieved data.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
  protected get propertyPaneSettings(): IPropertyPaneSettings {
    //set up additional dropdown properties for column selection based on list selection
    let templatePropertyColumn: any;

    //set up the column dropdown property with default settings
    templatePropertyColumn = PropertyPaneDropdown('listColumn', {
      label: 'Select Column',
      isDisabled: true,
      options: [{key: "", text: "Select List First"}]
    });

    //check to see if the list has been selected
    if (this.properties.listName && this.properties.listName.length > 0) {

      //if we have a list, check to see if columns are loaded. If they are not, then go and get them and after promise, kick off configureStart to reload
      if ((this._columnOptions.length < 1)) {
        templatePropertyColumn.properties.label = 'Loading Column List';

        var dataService = (this.context.environment.type === EnvironmentType.Test || this.context.environment.type === EnvironmentType.Local) ?
          new ListService.MockListsService() :
          new ListService.ListsService(this.context);

        dataService.getListColumnNames(this.properties.listName).then(
          (columns : IPropertyPaneDropdownOption[]) => {
            this._columnOptions = columns;

            //we now have the columns, so kick off reload
            //based on suggestion: https://github.com/SharePoint/sp-dev-docs/wiki/Async-data-fetch-in-the-property-pane
            //note, bugs may exist as documented on GitHub
            this.configureStart();
        });
      }
      else {
        //since we have the columns, go and and load them up now.
        templatePropertyColumn.properties.options = this._columnOptions;
        templatePropertyColumn.properties.selectedKey = this.properties.listColumn;
        templatePropertyColumn.properties.isDisabled = false;
      }
    }

    return {
      pages: [
        {
          header: {
            description: strings.BasicGroupName
          },
          groups: [
            {
              groupName: 'Web Part Options',
              groupFields: [
                PropertyPaneTextField('title', {
                  label: 'Title'
                }),
                PropertyPaneTextField('description', {
                  label: 'Description'
                }),
                PropertyPaneDropdown('listName', {
                  label: 'Select List',
                  options: this._listOptions
                }),
                templatePropertyColumn
              ]
            }
          ]
        }
      ]
    };
  }

Wrapup

I did skip a few minor changes, such as updates to the webpart render function, so download the code from github if you want to test the entire solution. Follow the Readme in the project folder as well as you will need to execute a few additional commands, i.e. npm install, to have NodeJS go and get all of the toolchain’s dependencies after you clone the repo.

Speak Your Mind

*


− five = 0