Showing posts with label Unsupported. Show all posts
Showing posts with label Unsupported. Show all posts

Wednesday, January 13, 2021

How To Create Daily Bulk Delete Jobs in Dataverse/CDS/Power Apps/CRM As A Different User

UPDATE!

The first statement is a lie!  The UI for Dataverse/CDS/Power Apps/CRM bulk delete jobs does allow for creating a reoccurring daily Bulk Delete Job (Thanks Oliver Flint).   Even though it looks like a dropdown, you can type whatever number you’d like.  As such, this post is still helpful if you want to create a duplicate bulk delete job in multiple environments, or if you want to create it with someone else like an App User as the owner.

Original Post:

The UI for Dataverse/CDS/Power Apps/CRM bulk delete jobs does not allow for creating a reoccurring daily Bulk Delete Job.  The smallest value to choose from is weekly, which means if you want to run something daily, you’d have to create 7 jobs, one for each day of the week. Ew!  But, this can be set programatically via the SDK, and here is how (Please note, this is just code, it can be compiled and run anywhere.  When you run from the XTB though, you can either login with an Application User, or impersonate it if you have impersonation rights, which would set the owner of the bulk delete record, and help prevent any issues when a user leaves, but owns all of the Bulk Delete Jobs):

  1. Open the XrmToolBox, and connect to the environment (Bonus, connect with an application user to create the Bulk Delete Job as an application user, so that it isn’t owned by a person that leaves the company or has permissions removed.)
  2. Install the Code Now XrmToolBox Plugin if not already installed, and open it.
  3. If the logged in user is the XTB is the desired user, great, if you’d like the bulk delete request to be owned by a different user, push the Impersonate button at the top of the XTB and select the appropriate user.  (Testing has shown that impersonating the System user will not work to set the owner as system.  An Application User will be required)
  4. Copy and paste the following code into the window:
public static void CodeNow()
{
    var bulkDeleteRequest = new Microsoft.Crm.Sdk.Messages.BulkDeleteRequest
    {
        JobName = "Daily 3am Delete Job",
        QuerySet = new [] {
            new QueryExpression {
                ColumnSet = new ColumnSet("acme_tableid", "acme_tablename", "createdon"),
                EntityName = "acme_table",
                Criteria = {
                    Filters = {
                        new FilterExpression {
                            FilterOperator = LogicalOperator.And,
                            Conditions = {
                                new ConditionExpression("acme_delete_me", ConditionOperator.Equal, true)
                            }
                        }
                    }
                }
            }
        },
        StartDateTime = new DateTime(2021, 1, 8, 8, 0, 0, DateTimeKind.Utc),
        RecurrencePattern = "FREQ=DAILY;INTERVAL=1;",
        ToRecipients = new Guid[] { },
        CCRecipients = new Guid[] { },
        SendEmailNotification = false
    };

    Service.Execute(bulkDeleteRequest);
}

 

Update the following values

  1. Update the JobName to what ever your preference is.
  2. QuerySet is a collection of QueryExpressions.  Add at least one (I don’t know what happens if you add two, my guess is that all records returned from all Query Expressions will get deleted.  My guess is it is expecting to always have just the Primary Id of the table, the Primary Name Column, and the “Created On” column.
  3. Update the StartDateTime a future date to start. It's format is new DateTime(YYYY, MM, DD, ... ). Please note, the time is UTC, so the current value is Jan. 8, 2021 at 3am EST (Not EDT). 
  4. Update RecurrencePattern to your liking.  FREQ=DAILY;INTERVAL=1; means it will be ran every day.  (Not sure of other FREQ values could be, but you could use the FetchXmlBuilder to query for other existing values)
  5. Update ToRecipients and CCRecipients to what I believe are the SystemUser Ids that would be notified when the job runs.  (I’ve never used it, so don’t quote me on this one)
  6. Update SendEmailNotification to true to send out emails to the To and CC Recipients if desired.

Run the Code in Code Now… err… now!

Verify that the record was created correctly by navigating to the Bulk Delete Jobs:

  1. Open Advanced Settings by clicking the gearbox icon in the top right-hand corner
  2. Navigate to the "Data Management" area
  3. Click "Bulk Record Deletion"
  4. Select Recurring Bulk Delete System Jobs
  5. Verify that the job is created with the correct Owner and the Next Run values, and that it is in a "Waiting" status reason.
  6. You can verify the history of this job by using the Completed Bulk Deletion System Jobs view once the next run time has passed.

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!