Friday, January 12, 2018

Explaining PropertyPaneAsyncChecklist

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.

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.
/**************************************************************
 * 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.

No comments:

Post a Comment