Tuesday, June 23, 2020

Setting Sub-Grid FilterXml In The Unified Interface And Other Naughty Things

This post started as a twitter poll where I asked if I should blog about an unsupported solution I developed for a rather unusual business requirement dealing with adding option set values to a control that didn’t actually exist in the Option Set of the system.  Because 5 more people voted for me to blog the solution than not, the code for that twitter poll is at the end of this blog post.  But, the real reason I suspect that most of you are here is to be able to set the fetchXml/filterXml of a sub-grid in the new Unified Interface of Dynamics CE / CDS, so let’s get started…

Most JS devs are capable enough to start snooping into the JS Dom of the grid control and find the setFilterXml function.  One would think calling this undocumented function would do what one desires, but nope, it does not.  There have even been attempts to re-write the function that may have worked at one time, but have never worked for me ( https://medium.com/@meelamri23/dynamically-set-fetchxml-to-subgrid-on-dynamics-365-v9-uci-a4a531200e73, https://community.dynamics.com/crm/f/microsoft-dynamics-crm-forum/299697/dynamics-365-unified-interface-inject-fetchxml-into-subgrid).  There is also the supported method of writing a plugin to edit the FetchXml on the server in a Retrieve Multiple plugin, (https://community.dynamics.com/crm/f/microsoft-dynamics-crm-forum/216881/how-to-set-up-custom-fetchxml-for-subgrid-in-dynamics-crm, https://sank8sinha.wordpress.com/2020/01/07/adding-filtered-views-in-uci-in-dynamics-365-crm-finally-achieved/) but unfortunately there is no client context and this may not be possible in some situations and requires a lot of extra effort.  

So what is the solution?  After hitting my head against the brick wall that is the setFilterXml function of the grid control, I decided to focus on figuring out how the fetch xml for the grid was getting determined in the first place.  When attempting to edit the metadata of an option set, as mentioned above, I discovered a function to access the global page state: “Xrm.Utility.getGlobalContext()._clientApiExecutor._store.getState()”.  This function returns a state object that contains the entire page model (metadata, ribbon rules, business process flows, etc).  I had used it to edit option set metadata to allow for dynamic values to be added to it (Picture a payment screen where customer’s previous payment information was in the option set drop down, which is way easier to select from rather than a lookup control.  The resultant function “resetOptions” is in the code block at the bottom of this page.) and I decided to see if I could find the metadata used to generate the Rest call to the server for the option set.

I fired up the Debugger window once more and dived into the call hierarchy used to generate the rest call to the server and discovered that the query was being built from the same metadata cache on the page.  “metadata.views” was an object with GUID properties of view metadata.  A couple quick edits in the debugger console and a refresh of the grid later, and I was in luck!  Editing the fetch xml in the metadata of the page state resulted in directly updating the fetch xml used to query and populate the grid results!  

I’ve since gone through and created a function to do the heavy lifting of finding the view id for the grid by the name of the grid control, and replacing any filters from the view with the filter xml provided as a parameter.  (Please note, this is unsupported.  It could break at any point so use it at your own risk.  With that being said everything I see points to that being unlikely for the foreseeable future)  The function is located below as TypeScript, because TypeScript is awesome and it’s not too hard to remove the typing if you’re just using plain old JS.  I’ve also gone through and documented the entirety of the GlobalState object returned from the “getState()” function as a TypeScript definition file, in the hopes that in the future I can use it to do more  “naughty” unsupported customizations.  You can access it here and is designed to go in your npm “node_modules/@types” folder.  (If someone wants to add it do the work of uploading it to GitHub and making it an npm package, be my guest!)

Call setSubgridFilterXml to set the sub-grid control fetch xml.  Since the metadata is shared at the page level, each grid will require a unique view to keep form interfering with other grids.
/**
 * Updates the Fetch XML of the Metadata which is used to generate the OData Query.
 * Since the metadata is shared at the page level, each grid will require a unique view to keep from interfering with other grids.
 * @param context Global Context
 * @param formContext Form Context
 * @param gridName Name of the Grid
 * @param filterXml Fetch Xml to set the Grid to
 */
export function setSubgridFilterXml(context: Xrm.GlobalContext, formContext: Xrm.FormContext, gridName: string, filterXml: string): void {
    console.info("Unsupported.setSubgridFilterXml(): Executing for grid: ", gridName, ", fetchXml: ", filterXml);
    const gridControl = formContext.getControl(gridName) as Xrm.Controls.GridControl;
    if (!gridControl) {
        console.warn(`No subgrid control found found name ${gridName} in Unsupported.setSubgridFilterXml()`);
        return;
    }
    try {
        const viewId = gridControl.getViewSelector().getCurrentView().id
            .toLowerCase()
            .replace("{", "")
            .replace("}", "");
        const view = getState(context).metadata.views[viewId];
        if (!view) {
            console.warn(`No view was found in the metadata for grid ${gridName} and viewId ${viewId}.`);
            return;
        }
        const originalXml = view.fetchXML;
        const fetchXml = removeFilters(removeLinkedEntities(originalXml));
        const insertAtIndex = fetchXml.lastIndexOf("</entity>");
        // Remove any white spaces between XML tags to ensure that different filters are compared the same when checking to refresh
        view.fetchXML = (fetchXml.substring(0, insertAtIndex) + filterXml + fetchXml.substring(insertAtIndex)).replace(/>\s+</g"><");

        if (view.fetchXML !== originalXml) {
            // Refresh to load the new Fetch            
            gridControl.refresh();
        }     } catch (err) {         CommonLib.error(err);         alert(`Error attempting unsupported method call setSubGridFetchXml for grid ${gridName}`);     } } function getState(context: Xrm.GlobalContext) {     return (context as XrmUnsupportedGlobalContext.Context)._clientApiExecutor._store.getState(); } function removeFilters(fetchXml: string): string {
    return removeXmlNode(fetchXml, "filter");
}

function removeLinkedEntities(fetchXml: string) {
    return removeXmlNode(fetchXml, "link-entity");
}

function removeXmlNode(xml: string, nodeName: string) {
    // Remove Empty tags i.e. <example /> or <example a="b" />
    xml = xml.replace(new RegExp(`<[\s]*${nodeName}[^/>]*\\/>`"gm"), "");

    const startTag = "<" + nodeName;
    const endTag = `</${nodeName}>`;
    let endIndex = xml.indexOf(endTag);

    // use first end Tag to do inner search
    while (endIndex >= 0) {
        endIndex += endTag.length;
        const startIndex = xml.substring(0, endIndex).lastIndexOf(startTag);
        xml = xml.substring(0, startIndex) + xml.substring(endIndex, xml.length);
        endIndex = xml.indexOf(endTag);
    }
    return xml; }

This code can be used to allow for dynamically defining the option set values in an option set.  It will still fail on save if the integer value is not actually defined in the system
/**
 * Crm only supports filtering option sets.  This supports resetting the options, although it will still fail on save unless other precautions are taken.
 * It will allow for setting the value.
 * @param context Global Context
 * @param formContext Form Context
 * @param attributeName The name of the attribute
 * @param options The options to reset the Option Sets to
 */
export function resetOptions(context: Xrm.GlobalContext, formContext: Xrm.FormContext, attributeName: string, options: Xrm.OptionSetValue[]) {
    console.warn("Unsupported.resetOptions(): Executing for attribute: " + attributeName);
    const att = formContext.getAttribute(attributeName);
    if (!att) {
        console.warn(`No Attribute found for ${attributeName} in resetOptions.`);
        return;
    }

    const metadata = getState(context).metadata.attributes[att.getParent().getEntityName()][attributeName];
    const nonExistingValues = options.filter(v => {
        return metadata.OptionSet.findIndex(o => {
            return o.Value === v.value as any;
        }) < 0;
    }).map(osv => {
        return {
            Color: "#0000ff",
            DefaultStatus: undefined,
            InvariantName: undefined,
            Label: osv.text,
            ParentValues: undefined,
            State: undefined,
            TransitionData: null,
            Value: osv.value
        } as XrmUnsupportedGlobalContext.Metadata.OptionSetMetadata;
    });

    metadata.OptionSet = nonExistingValues.concat(metadata.OptionSet);
}
Happy Coding!