Introduction
Last week I was working on a project that involved setting some managed meta data fields, both the single value and multi values fields. I typically use PnP-JS to streamline the calls to SharePoint however I found there are issues with PNP-Js and managed meta data fields. It’s been said that MMD fields are unable to be set with REST calls (PnP-Js uses REST). There are mixed reviews out there regarding whether it works or not. For me it did not work. My particular scenario involved update 6 single value fields and 1 multi value fields at the same time. While investigating I reach out to a friend Beau Cameron to verify my code as he wrote 2 great articles for single value fields and multi value fields. The results were to flaky using REST so the decision was made to use JSOM. In this article I will share the end code as there were a few things I want to document for future reference.
Using JSOM in SPFx
Before you can start writing the code you must first get your project setup in order to work with the JSOM libraries.
It’s a good idea to read up on the official guidance as this method follows it but I prefer to load the libraries differently. The Microsoft article loads the libraries in the configuration as external resource but you must specific the full url of your site and I prefer not to lock that value of the site url into a configuration file that would prevent me from using this in another tenant without rebuilding.
Install the Typings
install project dependencies
npm install @types/microsoft-ajax @types/sharepoint --save-dev
In the code editor, open the ./tsconfig.json file, and in the types property, after the webpack-env entry, add references to microsoft-ajax and sharepoint
{ "compilerOptions": { // ... "types": [ "es6-promise", "es6-collections", "webpack-env", "microsoft-ajax", "sharepoint" ] } }
Register the Scripts
In the file you wish to use the JSOM library, require the scripts.
require('sp-init'); require('microsoft-ajax'); require('sp-runtime'); require('sharepoint');
Loading the JSOM libraries
In the previous section I mentioned that I prefer to dynamically load the libraries to avoid hard coding the site url into the configuration files. In the code below is how I am loading the libraries dynamically.
Note: In the state I have 2 values
- loadingScripts: boolean, intially set to true
- Errors: any, initially set to []
This way you can wait until the scripts are loading to rendering fields that require them and I’m also collecting the errors for later consumption.
public componentDidMount() { this.loadScripts(); } public getSiteCollectionUrl(): string { let baseUrl = window.location.protocol + "//" + window.location.host; const pathname = window.location.pathname; const siteCollectionDetector = "/sites/"; if (pathname.indexOf(siteCollectionDetector) >= 0) { baseUrl += pathname.substring( 0, pathname.indexOf("/", siteCollectionDetector.length) ); } return baseUrl; } private loadScripts() { const siteColUrl = this.getSiteCollectionUrl(); try { SPComponentLoader.loadScript(siteColUrl + "/_layouts/15/init.js", { globalExportsName: "$_global_init", }) .then( (): Promise<{}> => { return SPComponentLoader.loadScript( siteColUrl + "/_layouts/15/MicrosoftAjax.js", { globalExportsName: "Sys", } ); } ) .then( (): Promise<{}> => { return SPComponentLoader.loadScript( siteColUrl + "/_layouts/15/SP.Runtime.js", { globalExportsName: "SP", } ); } ) .then( (): Promise<{}> => { return SPComponentLoader.loadScript( siteColUrl + "/_layouts/15/SP.js", { globalExportsName: "SP", } ); } ) .then( (): Promise<{}> => { return SPComponentLoader.loadScript( siteColUrl + "/_layouts/15/SP.taxonomy.js", { globalExportsName: "SP", } ); } ) .then((): void => { this.setState({ loadingScripts: false }); }) .catch((reason: any) => { this.setState({ loadingScripts: false, errors: [...this.state.errors, reason], }); }); } catch (error) { this.setState({ loadingScripts: false, errors: [...this.state.errors, error], }); } }
State values & Taxonomy Controls
To work with the taxonomy fields I will be using the Taxonomy Picker from the PnP React Controls
<TaxonomyPicker allowMultipleSelections={false} termsetNameOrID="By Whom" label="By Whom" values={this.state.byWhom} context={this.props.context} onValueChanged={(pickerTerms) => { this.setState({ byWhom: pickerTerms }); }} loading={this.state.loading} />
Setting up the JSOM call
This function I I am preparing the JSOM context getting the list and item and will be updating a number of MMD fields. My state values for the MMD are IPickerTerms from PnP React Controls.
Warning: You can see I have a function called getItem that will retrieve the list item using CAML query. I must do this in order to get a ‘fresh’ version of the item. If you use the JSOM getItemById you will receive a cached copy and performing an update on it will throw an error that it is unable to make a change. I also read that you can avoid that error by calling executeQueryAsync right after getItemById so it’s ‘fresh’ but I went this way.
private updateItemMetaData = async (siteUrl: string, listName: string, itemId: number) => { console.log(siteUrl, listName, itemId); return new Promise(async (resolve, reject) => { let context = new SP.ClientContext(siteUrl); let list = context.get_web().get_lists().getByTitle(listName); console.log("ctx list", list); let item = await this.getItem(context, list, itemId); console.log("ctx item", item); this.setTaxFieldProperty(context, item, list, "ByWhom", this.state.byWhom, true); this.setTaxFieldProperty(context, item, list, "SubCategory", this.state.subCategory, true); this.setTaxFieldProperty(context, item, list, "DocumentStatus", this.state.documentStatus, true); this.setTaxFieldProperty(context, item, list, "SubmittalAction", this.state.submittalAction, true); this.setTaxFieldProperty(context, item, list, "Phase", this.state.gaPhase, true); this.setTaxFieldProperty(context, item, list, "Agencies", this.state.agencies, true); this.setTaxFieldProperty(context, item, list, "Trades", this.state.trades, false); item.set_item("ActionDate", this.state.actionDate); item.update(); context.load(item); context.executeQueryAsync( () => { console.log("success", item.get_id()); resolve(); }, (sender, args) => { console.log("fail", args.get_message()); resolve(); } ); }); }; private getItem = async (context: SP.ClientContext, list: SP.List, itemId: number): Promise<SP.ListItem> => { return new Promise((resolve, reject) => { const query = `<View Scope='Recursive'><Query><Where><Eq><FieldRef Name=\"ID\" /><Value Type=\"Integer\">${itemId}</Value></Eq></Where></Query></View>`; let camlQuery = new SP.CamlQuery(); camlQuery.set_viewXml(query); let allItems = list.getItems(camlQuery); context.load(allItems, 'Include(Id)'); context.executeQueryAsync( () => { console.log("success", allItems); resolve(allItems.get_item(0)); }, (sender, args) => { console.log("fail", args.get_message()); resolve(); } ); }); }
The Update Function
In this section we will get the field from the list and update it correctly if it’s a single value or a multi value. Each type must be handled differently. For single value we can set the term as an object but for the multi value we must create a string of the terms. Creating the string of terms is done in the getTermString function.
private setTaxFieldProperty = ( context: SP.ClientContext, item: SP.ListItem, list: SP.List, fieldName: string, fieldValues: IPickerTerms, isSingle: boolean ) => { let field = list.get_fields().getByInternalNameOrTitle(fieldName); let taxField = context.castTo(field, SP.Taxonomy.TaxonomyField) as SP.Taxonomy.TaxonomyField; if (isSingle) { if (fieldValues && fieldValues.length >= 1) { let termValue = new SP.Taxonomy.TaxonomyFieldValue(); termValue.set_label(fieldValues[0].name); termValue.set_termGuid(new SP.Guid(fieldValues[0].key)); termValue.set_wssId(-1); taxField.setFieldValueByValue(item, termValue); } else { taxField.validateSetValue(item, null); } } else { if(fieldValues && fieldValues.length >= 1) { let termString = this.getTermString(fieldValues); console.log(termString); let termValueCollection = new SP.Taxonomy.TaxonomyFieldValueCollection(context, termString, taxField); taxField.setFieldValueByValueCollection(item, termValueCollection); } else { taxField.validateSetValue(item, null); } } }; private getTermString(pickerTerms) { let termString = ""; pickerTerms.forEach((term) => { termString += `-1;#${term["name"]}|${term["key"]};#`; }); return termString.slice(0, -2); }
Summary
This proof of concept was somewhat challenging to work with the taxonomy fields. So much so that I had to blog it at least for my reference on how I dealt with it. The code above is not meant to be a full solution just a snippet of the key pieces to reliable working with the taxonomy fields. If you are interested in the full look at the code you can check it out here, https://github.com/tom-daly/demos/tree/master/docprocessor. It goes without saying that it’s test/demo code.