This post is in continuation to the previous one. We configured a dropdown control in our property pane to let user select a sharePoint list. Now we'll add a checkbox list control to let user select multiple fields of the list selected in the first dropdown control.
Let's discuss the custom react component at the core, AsyncCheckList first.
Let's discuss the custom react component at the core, AsyncCheckList first.
AsyncChecklist.tsx
This file is where we render the actual html for the component. Check out the render method.
public render() { const loading = this.state.loading ? <Spinner label={this.props.strings.loading} /> : <div />; /* This React component's state has a boolean attribute name "loading" which is set true for the duration of items (List columns) being fetched. The constant "loading" in this method is accordingly set to spinner (Microsoft UI Fabric React component) or an empty div. */ const error = this.state.error != null ? <div className="ms-TextField-errorMessage ms-u-slideDownIn20"> { Text.format( this.props.strings.errorFormat, this.state.error ) } </div> : <div />; /* This React component's state has a string attribute name "error" which is set to the error message, if an exception is raised when list columns are asynchronudly fetched; otherwise state.error is kept null. The constant "error" in this method is accordingly used to display the error message formatted as per the strings.errorformat property of the react component or it is set to an empty div if there is no error. */ const checklistItems = this.state.items.map((item, index) => { return ( <Checkbox id={ item.id } label={ item.label } defaultChecked={ this.isCheckboxChecked(item.id) } disabled={ this.props.disable } onChange={ this.onCheckboxChange.bind(this) } inputProps={ { value: item.id } } className={ styles.checklistItem } key={ index } /> ); }); /* Items(List columns) are set in react component's state as an array (of type IChecklistItem, to be discussed later) attribute upon being succesfully fetched. Here that state attribute are being mapped (javaScript map function) into an array of type Checkbox (Microsoft UI Fabric React component). Label of the checkbox here is set to IChecklistItem.label and value is set to IChecklistItem.id . Two other attribute to be taken note of here are "defaultChecked" and "onChange" . */ return ( <div className={ styles.checklist }> <Label>{ this.props.strings.label }</Label> { loading } {!this.state.loading && <div className={ styles.checklistItems }> <div className={ styles.checklistPadding }> { checklistItems } </div> </div> } { error } </div> ); /* html return value of render method is what would be rendered in the browser (property pane). We return a div here with label of the custom control which was passed to the react component as one of its properties namely strings.label. Constant loading which was either set to spinner or an empty div earlier in the method is included next. If boolean loading in the component's state was false (i.e. items are not being asynchronusly fetched at the moment, they are either here or some error has occured) then include all the Checkbox elements from constant checklistItems defined earlier in the method. In the constant error which was either set to the formatted error message or an empty div, is included. */ }
In the method above, every checkbox was equipped with the locally defined method "onCheckboxChange" as the change event handler and another locally defined method was called for every checkbox to decide whether the checkbox should be rendered as checked or unchecked. Let's examine both methods.
/***************************************************************** * When a checkbox changes within the checklist * @param ev : The React.FormEvent object which contains the * element that has changed * @param checked : Whether the checkbox is not checked or not *****************************************************************/ private onCheckboxChange(ev?: React.FormEvent, checked?: boolean) { let checkboxKey = ev.currentTarget.attributes.getNamedItem('value').value; /* In render method we set the value attribute on each Checkbox to be IChecklistItem.id. Here we retrieve that id. */ let itemIndex = this.checkedItems.indexOf(checkboxKey); /* checkedItems is an string array defined in this class. This array holds the ids of all the checked Checkboxes, i.e. selected columns. Here we check if the box was already selected. */ if(checked) // Checkbox is now checked. { if(itemIndex == -1) // It was not previously checked. { this.checkedItems.push(checkboxKey); // Insert the id of newly checked Checkbox. } } else // Checkbox is now unchecked. { if(itemIndex >= 0) // It was previously checked. { this.checkedItems.splice(itemIndex, 1); // Remove the id of newly unchecked Checkbox. } } if(this.props.onChange) { this.props.onChange(this.checkedItems); // Call the Change handler of the component itself. } }
/****************************************************************** * Returns whether the checkbox with the specified ID should be * checked or not * @param checkboxId *******************************************************************/ private isCheckboxChecked(checkboxId: string) { return ( this.checkedItems.filter ( (checkedItem) => { return checkedItem.toLowerCase().trim() == checkboxId.toLowerCase().trim(); } ).length > 0 ); /* checkedItems is an string array defined in this class. This array holds the ids of all the checked Checkboxes, i.e. selected columns. Here we filter the array based on the condition if the current id exists in the array. Filtered array would either have a single element or none at all, i.e. length would either be 0 or 1. If it is 1, we return true i.e. Checkbox is already checked & should also be rendered so. */ }
Two more methods we should look next class are:
componentDidMount - This method is a react lifecycle method and it is called once as the react component gets "mounted" to the host DOM HTML element.
componentDidUpdate - This method is also a react lifecycle method and it is called everytime the react component gets redrawn on the browser.
componentDidMount - This method is a react lifecycle method and it is called once as the react component gets "mounted" to the host DOM HTML element.
componentDidUpdate - This method is also a react lifecycle method and it is called everytime the react component gets redrawn on the browser.
/************************************************************** * Called once after initial rendering **************************************************************/ public componentDidMount(): void { this.loadItems(); /*After initial rendering call the locally defined loadItems method to asynchronusly load the list column information. */ } /************************************************************** * Called immediately after updating occurs **************************************************************/ public componentDidUpdate(prevProps: IAsyncChecklistProps, prevState: {}): void { if (this.props.disable !== prevProps.disable || this.props.stateKey !== prevProps.stateKey) { /*If item was previously disabled and now enabled or property pane was closed before and now being reopened. Everytime user reopens the property pane, stateKey property get assigned with the current date time value and condition forces our component to load the items again. This is the sole purpose of stateKey property. */ this.loadItems(); } }
Let's look at the loadItems method.
/********************************************************* * Loads the checklist items asynchronously *********************************************************/ private loadItems() { let _this_ = this; _this_.checkedItems = this.getDefaultCheckedItems(); this.setState({ loading: true, items: new Array(), error: null }); /* Set the state to loading so we can render the spinner component. Initialize the items array to hold the fetched items. Set error to null. */ this.props.loadItems() // Call loadItems methods set externally. .then ( // Once external method returns without error. (items: IChecklistItem[]) => { // external method will give you array of IChecklistItem _this_.setState ( ( prevState: IAsyncChecklistState, props: IAsyncChecklistProps ): IAsyncChecklistState => { prevState.loading = false; // hide the spinner now. prevState.items = items; // set state with fetched items return prevState; } ); } ) .catch((error: any) => { // error happend. _this_.setState ( ( prevState: IAsyncChecklistState, props: IAsyncChecklistProps ): IAsyncChecklistState => { prevState.loading = false; // hide the spinner. prevState.error = error; // set state with error return prevState; } ); } ); }
Now look at the class signature itself.
export class AsyncChecklist extends React.Component<IAsyncChecklistProps, IAsyncChecklistState>
Clearly, we should look into interfaces IAsyncChecklistProps and IAsyncChecklistState. As evident from the names, former defines component properties and later does the same for component's state. In turn, they make use of two more interfaces namely IAsyncChecklistStrings and IChecklistItem, we shall discuss these too.
IAsyncChecklistProps
export interface IAsyncChecklistProps { /*This interface serves as the react component properties which gets passed to the component. */ loadItems: () => Promise<IChecklistItem[]>; /* This is that external (callback) function which gets passed to the react component. It expects no arguments and returns a Promise object of IChecklistItem[] . */ onChange?: (checkedKeys:string[]) => void; /* This is also a callback function which gets passed to the react component. It expects an string[] and returns nothing. This callback method will be called from change event handler on individual checkbox. Updated list of checbox id's would be passed to the callback function. */ checkedItems: string[]; /* This atribute feeds the react component information about which checkbox should be redered as checked initially. */ disable?: boolean; strings: IAsyncChecklistStrings; /* strings property is a collection of three strings. See IAsyncChecklistStrings section. */ stateKey?: string; /* stateKey is set to current datetime everytime the propertypane reopens. We use it to force loadItems function on every reopen of the pane. */ }
IAsyncChecklistState
export interface IAsyncChecklistState { /* This interface serves as the state of the react component. Keep in mind that react component updates the relevant DOM whenever there is a change in its state. */ loading: boolean; /* We set loading as true before fetching the column information and set it to false afterwards. */ items: IChecklistItem[]; /*This is the array which is set as the fetched column information*/ error: string; /*error text if something goes wrong during the fetch.*/ }
IChecklistItem
export interface IChecklistItem { /*This interface defines individual Checkbox as value and label. */ id: string; label: string; }
IAsyncChecklistStrings
export interface IAsyncChecklistStrings { /*This interface define one of the properties of the react component, namely strings.*/ label: string; // Custom property label loading: string; // Text to be displayed with spinner errorFormat: string; //Format string for error text. }
This was all about the react component. There was nothing SpFX specific in the react componet. Next up is the wrapper around the react component. This wrapper extends IPropertyPaneField which is defined in '@microsoft/sp-webpart-base', hence it is an SpFx specific class. It is the actual Custom property control. In our case, it also makes use of two interfaces whchi we have defined. These interfaces are IPropertyPaneAsyncChecklistProps and IPropertyPaneAsyncChecklistInternalProps. Let's do away with tw two interfaces first.
IPropertyPaneAsyncChecklistProps
export interface IPropertyPaneAsyncChecklistProps { /*Our Custom property actually extends IPropertyPaneField<IPropertyPaneAsyncChecklistProps> . We use this interfaace to provide a set of properties from our webpart to the custom control. */ loadItems: () => Promise<IChecklistItem[]>; /*Our Custom control simply receives this callback from our webpart and pass it on to the react component. */ onPropertyChange: (propertyPath: string, newCheckedKeys: string[]) => void; /* Webpart provides this callback to get notified about the updated array of selected checkboxes. This function expects webpart property path and an array of ids of selected checkboxes. It returns nothing. */ checkedItems: string[]; /*Webpart tell tells the custom property which checkboxes are already schecked and should be renderd so.*/ disable?: boolean; strings: IAsyncChecklistStrings; // See IAsyncChecklistStrings section. }
IPropertyPaneAsyncChecklistInternalProps
export interface IPropertyPaneAsyncChecklistInternalProps extends IPropertyPaneAsyncChecklistProps, IPropertyPaneCustomFieldProps { /* This interface doesn;t define any attribute of its own but it extends IPropertyPaneAsyncChecklistProps and IPropertyPaneCustomFieldProps. SpFx requires us to have properties attribute of type IPropertyPaneCustomFieldProps. But we need to provide our own set of properties too, so we define our properties in IPropertyPaneAsyncChecklistProps and have this interface extends IPropertyPaneAsyncChecklistProps. IPropertyPaneCustomFieldProps dictates that we have two more mandatory properties onRender and key. */ }
Next up is the Custom proeprty class, PropertyPaneAsyncChecklist.
PropertyPaneAsyncChecklist
Calss level attributes
public type: PropertyPaneFieldType = PropertyPaneFieldType.Custom; /*No choice here!! */ public targetProperty: string; //webpart property name. public properties: IPropertyPaneAsyncChecklistInternalProps; public loadedItems: boolean; private elem: HTMLElement;
Constructor of the class
constructor(targetProperty: string, properties: IPropertyPaneAsyncChecklistProps) { this.targetProperty = targetProperty; //webpart property name. this.properties = { /*Now we are mappping IPropertyPaneAsyncChecklistProps to IPropertyPaneAsyncChecklistInternalProps*/ loadItems: properties.loadItems, checkedItems: properties.checkedItems, onPropertyChange: properties.onPropertyChange, disable: properties.disable, strings: properties.strings, onRender: this.onRender.bind(this), /*onRender has to be set as mandated by IPropertyPaneCustomFieldProps. It is set to a locally defined function.*/ key: targetProperty /*key has to be set as mandated by IPropertyPaneCustomFieldProps. */ }; }
Render functions
/************************************************************ * Renders the AsyncChecklist property pane * SpFx calls it. *************************************************************/ public render(): void { if (!this.elem) { return; } this.onRender(this.elem); } /************************************************************* * Renders the AsyncChecklist property pane *************************************************************/ private onRender(elem: HTMLElement): void { if (!this.elem) { this.elem = elem; } const asyncChecklist: React.ReactElement<IAsyncChecklistProps> = React.createElement(AsyncChecklist, { // this is IAsyncChecklistProps loadItems: this.properties.loadItems, checkedItems: this.properties.checkedItems, onChange: this.onChange.bind(this), // locally defined callback disable: this.properties.disable, strings: this.properties.strings, stateKey: new Date().toString() }); ReactDom.render(asyncChecklist, elem); this.loadedItems = true; }
onChange function
private onChange(checkedKeys: string[]): void { // React component calls this function and this // function call the callback defined in the webpart. this.properties.onPropertyChange(this.targetProperty, checkedKeys); }
Now the webpart itself.
ListViewWebPart
Let's look at the property pane configuration method first.
private listDD:PropertyPaneAsyncDropdown ; private viewFieldsChecklist:PropertyPaneAsyncChecklist ; /* These are calss level variables. We cannot create instances of PropertyPaneAsyncDropdown or PropertyPaneAsyncChecklist here. They have to be instiated within getPropertyPaneConfiguration method, otherwise Spfx will complain that "Properties cannot be initlaized." You will not see any error during transpilation though. */ protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration { this.listDD = new PropertyPaneAsyncDropdown('listUrl', { label: strings.ListFieldLabel, loadOptions: this.loadLists.bind(this), onPropertyChange: this.onCustomPropertyPaneChange.bind(this), selectedKey: this.properties.listUrl }); this.viewFieldsChecklist = new PropertyPaneAsyncChecklist('viewFields' //viewFields is the webpart property name i.e. it is //target property. , { // this is IPropertyPaneAsyncChecklistProps loadItems: this.loadViewFieldsChecklistItems.bind(this), //locally defiend callback responsible to load items. checkedItems: this.properties.viewFields, //Webpart property which holds already checked columns. onPropertyChange: this.onCustomPropertyPaneChange.bind(this), //locally defiend callback to handle newly checked or //newly uncjecked boxes. disable: isEmpty(this.properties.listUrl), // If list has not been selected yet, keep this // custom property control disbaled. strings: strings.viewFieldsChecklistStrings // defiend in loc folder, mystrings.d.ts }); return { pages: [ { header: { description: strings.PropertyPaneDescription }, groups: [ { groupName: strings.BasicGroupName, groupFields: [ PropertyPaneTextField('description', { label: strings.DescriptionFieldLabel }), this.listDD, this.viewFieldsChecklist ] } ] } ] }; }
Two callbacks, onCustomPropertyPaneChange and loadViewFieldsChecklistItems
/************************************************* * When a custom property pane updates **************************************************/ private onCustomPropertyPaneChange( propertyPath: string, newValue: any): void { const oldValue = get(this.properties, propertyPath); // store the old value // Following two calls handle the update. Part of SpFx. update(this.properties, propertyPath, (): any => { return newValue; }); this.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue); // Resets dependent property panes if needed this.resetDependentPropertyPanes(propertyPath); // Refreshes the web part manually because custom fields //don't update since sp-webpart-base@1.1.1 // https://github.com/SharePoint/sp-dev-docs/issues/594 if (!this.disableReactivePropertyChanges) this.render(); } /********************************************************** * Loads the checklist items for the viewFields property **********************************************************/ private loadViewFieldsChecklistItems(): Promise<IChecklistItem[]> { return this.listService.getViewFieldsChecklistItems( this.context.pageContext.web.absoluteUrl, this.properties.listUrl); }
resetDependentPropertyPanes method.
/************************************************* * Resets dependent property panes if needed **************************************************/ private resetDependentPropertyPanes(propertyPath: string):void { if (propertyPath == "listUrl") { // if list dropdown was updated, // check if we should reset/disable/enable // list columns dropdown. this.resetViewFieldsPropertyPane(); } }
/********************************************************* * Resets the View Fields property pane and re-renders it *********************************************************/ private resetViewFieldsPropertyPane() { // Folowing two lines set the webpart property to // null & update it. this.properties.viewFields = null; update( this.properties, "viewFields", (): any => { return this.properties.viewFields; }); // These 2 lines set the properties of our Custom //property pane control. this.viewFieldsChecklist.properties.checkedItems = null; this.viewFieldsChecklist.properties.disable = isEmpty(this.properties.listUrl); // re render the control. this.viewFieldsChecklist.render(); }
This was it for the webpart.
Now look at the list service method to fetch the columns
/******************************************************** * Loads the checklist items for the viewFields property ********************************************************/ public getViewFieldsChecklistItems( webUrl: string, listUrl: string ): Promise<IChecklistItem[]> { // Resolves an empty array if no web or no list has been selected if (isEmpty(webUrl) || isEmpty(listUrl)) { return Promise.resolve(new Array<IChecklistItem[]>()); } // Otherwise gets the options asynchronously return new Promise<IChecklistItem[]>( (resolve, reject) => { this.getListFields( webUrl, listUrl, ['InternalName', 'Title'], // fetch these 2 props. 'Title' // order by column Title ) .then( (data:any) => { let fields:any[] = data.value; let items:IChecklistItem[] = // map it to IChecklistItem[] fields.map ( (field) => { return { id: field.InternalName, // InternalName is id. label: Text.format( "{0} \{\{{1}\}\}", field.Title, field.InternalName) // "Title {{InternalName}}" is label. }; } ); //this.viewFields = items; resolve(items); }) .catch((error) => { reject(error.statusText ? error.statusText : error); }); }); } /************************************************************************** * Returns a sorted array of all available list columns * for the specified web and list. * @param webUrl : The web URL from which the specified list is located * @param listTitle : The title of the list from which to load the fields * @param selectProperties : Optionnaly, the select properties to narrow * down the query size * @param orderBy : Optionnaly, the by which the results needs to be ordered ****************************************************************************/ public getListFields( webUrl: string, listUrl: string, selectProperties?: string[], orderBy?: string): Promise<any> { return new Promise<any>( (resolve,reject) => { let selectProps = selectProperties ? selectProperties.join(',') : ''; // comma delimited list of properties to be fetched. let order = orderBy ? orderBy : 'InternalName'; // if null, use InternalName let endpoint = Text.format( "{0}/_api/web/lists/GetByTitle('{1}')/Fields?$select={2}&$orderby={3}", webUrl, listUrl.split("/").pop(), // pop the title from url. selectProps, order ); this.spHttpClient .get( endpoint, SPHttpClient.configurations.v1 ) .then( (response: SPHttpClientResponse) => { if(response.ok) { resolve(response.json()); } else { reject(response); } }) .catch((error) => { reject(error); }); }); }You can download the code here.