Dynamics 365 – Create AutoPopulate String Field with Custom PCF Control

PowerApps Component Framework

PCF (PowerApps Component Framework) are used to create Field type and Data-set type code components and provide an enhanced user experience for the users to view and work with data in forms, views, and dashboards.

Developing a custom control

Unlike web resources, PCF components are rendered as a part of the same context, load at the same time as any other components, providing rich experience for the users. Developers can bundle all the HTML, CSS, and TypeScript or JavaScript files into a single solution package file. Code components can be reused many times across different entities and forms.

Creating and deploying PCF controls are relatively easy with the help of XrmToolBox PCF Builder tool. All you have to do is write your code, PCF Builder tool handles rest.

What will we build?

In this tutorial, we will build a control that bounds on a string in the Opportunity Product and Order Product entities. Then we will auto populate the field Product Description as the user enter letters.

Our auto populate data will get fetched from our previous product records. So we can have clean data in our system.

Step 1. Create Your PCF Control Solution

First you have to install XrmToolBox if you didn’t yet. Go to XrmToolBox home page and wonload the app.

Once you installed it, open the tool box and search for PCF Builder in the Tool Library.

When you installed the PCF Builder, open it and connect to your environment.

  • Code Component Details section is the where you configure your control’s Name, Display Name and Template.
  • Control Location is the location where you want to create your solution folder in your computer.
  • Quick actions is the location where you create your Dynamics 365 Solution and deploy it quickly.
  • Command Prompt is the embedded CLI command promt and most of the time you don’t have to touch it.

Now fill your Component Details and click Create. Our template will be Field because we will bind our control to a Field.

A solution file will be created in the location you specifed. Go to that location, select the folder that has same name with your control, right click and open it with Visual Studio. After that fill your Solution Details and click Build. A solution file will be created for you. Now, whenever we click Build buttons, it will override our current files. So no worries here, we won’t have any duplicated folders.

Step 2. Coding Our Control

In the solution explorer the most important files for us are index.ts and ControlManifest.Input.xml

index.ts is the file our code logic will be run. ControlManifest.Input.xml is our Control Configuration file. We can define our Input fields, CSS files, Display Names, Descriptions etc. So it is feasible that we start with ControlManifest.Input.xml

Update your ControlManifest file as below.

<?xml version="1.0" encoding="utf-8"?>
<manifest>
  <control namespace="CodingTuto" constructor="AutoPopulate" version="0.0.1" display-name-key="AutoPopulate" description-key="Auto populate string fields." control-type="standard">
    <!-- property node identifies a specific, configurable piece of data that the control expects from CDS -->
    <property name="selectedValue" display-name-key="Selected Value" description-key="Selected field Value" of-type="SingleLine.Text" usage="bound" required="true" />
    <property name="relatedEntity" display-name-key="Related Entity" description-key="Current entity set name" of-type="SingleLine.Text" usage="input" required="true" />
    <resources>
      <code path="index.ts" order="1" />
      <css path="css/AutoPopulate.css" order="1" />
    </resources>
  </control>
</manifest>

As you can see we have 2 input property called selectedValue and relatedEntity.

selectedValue will read and write Product Description values. As the user enter letters, our code will read that letter and will fetch the Product Descriptions in our system. When user picks one, it will be written to the Product Description field.

relatedEntity will be input type attribute. The difference is, we will not bound it to a field. It will has a static value. Which is either opportunityproduct(Opportunity Product) or salesorderdetail(Order Product).

When you are ready now switch to the index.ts file. This is whereth action happens.

There are 4 pre-created methods in our index.ts file. The most important ones are init() and updateView() methods. Its crucial for you to learn what these methods do.

  1. init()
public init(context: ComponentFramework.Context<IInputs>, notifyOutputChanged: () => void, state: ComponentFramework.Dictionary, container:HTMLDivElement): void
	{
		// Add control initialization code
	

You can think init() method like OnLoad event of form scripts. This is the first method our PCF Control initializes. Most of the logic happens in here like initializing variables, creating form elements etc.

2. updateView()

public updateView(context: ComponentFramework.Context<IInputs>): void
	{
		// Add code to update control view
	}

updateView is called when any value in our properties has changed. It acts like onChange event. Whenever our properties is updated, this method will be triggered.

3. getOutputs()

public getOutputs(): IOutputs {
        let result = {
            selectedValue: this._currentValue
        };

        return result;
    }

Return method of the control. It will return the fetched product descriptions in a list.

Now, its time for us to write our logic. I will put the code below. You can copy it or you can write your own logic. It’s up to you. Still i want to explain each step.

I want to make end users feel this custom field like a real CRM field. For this, in init() method, i will create an input field. It will has a list attribute obviously. It will has an input listener to fetch records in each keystroke. It will has a placeholder like CRM fields. Also it will show and hide borders on mouseenter and mouseleave like other CRM fields do.

To sum it up, i am creating and styling my text input each time the page loads. You can find the full code at the end of this page.

In my updateView() method, i will fetch my product records to return my product descriptions that includes the entered text value. For example, if the user has entered “Sen”, i will return a list of product description fields that contains “Sen”.

When you finished writing your code, all you have to do is return back to PCF Builder and Building your Component first. Then Build and Deploying your Solution.

When deployment of your solution has finished, you need to bind your control to your CRM field. Since i will use my control on Product details field of my Opportunity Product entity, i opened my Opportunity Product in form designer.

Double click your field and select Control tab then click Add Control. Remember we had 2 properties in our control. This is where we set our control properties.

Now our control is ready. It’s simple as that.

Below is the full code.

import { IInputs, IOutputs } from "./generated/ManifestTypes";

export class pdAutoComplete implements ComponentFramework.StandardControl<IInputs, IOutputs> {

    //private _labelElement : HTMLLabelElement;
    private _divContainer: HTMLDivElement;

    // input element that is used to create the autocomplete
    private _inputElement: HTMLInputElement;

    //Datalist element
    private _datalistElement: HTMLDataListElement;

    // Reference to ComponentFramework Context object
    private _context: ComponentFramework.Context<IInputs>;

    // ad_productdetails Form field value
    private _currentValue: string;

    // PCF framework delegate which will be assigned to this object which would be called whenever any update happens. 
    private _notifyOutputChanged: () => void;

    // Event Handler 'refreshData' reference
    private _refreshData: EventListenerOrEventListenerObject;

    // current entity name
    private _entityName: string;

    // current product id
    private _productId: string;

    private customAutoCompleteSetSameProduct: {
        id: string,
        fields: {
            type: string,
            rowcount: number
        },
    }[] = [];

    private customAutoCompleteSetDiffProduct: {
        id: string,
        fields: {
            type: string,
            rowcount: number
        },
    }[] = [];

    /**
     * Empty constructor.
     */
    constructor() {

    }

    /**
     * Used to initialize the control instance. Controls can kick off remote server calls and other initialization actions here.
     * Data-set values are not initialized here, use updateView.
     * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to property names defined in the manifest, as well as utility functions.
     * @param notifyOutputChanged A callback method to alert the framework that the control has new outputs ready to be retrieved asynchronously.
     * @param state A piece of data that persists in one session for a single user. Can be set at any point in a controls life cycle by calling 'setControlState' in the Mode interface.
     * @param container If a control is marked control-type='standard', it will receive an empty div element within which it can render its content.
     */
    public init(context: ComponentFramework.Context<IInputs>, notifyOutputChanged: () => void, state: ComponentFramework.Dictionary, container: HTMLDivElement) {
        // Add control initialization code
        // Add control initialization code

        if (typeof (context.parameters.selectedValue) === "undefined" ||
            typeof (context.parameters.selectedValue.raw) === "undefined")
            return;

        this._context = context;
        this._divContainer = document.createElement("div");
        this._notifyOutputChanged = notifyOutputChanged;
        this._refreshData = this.refreshData.bind(this);

        var autoCompleteListUniqueId = this.uuidv4();

        // creating HTML elements for the input type list and binding it to the function which refreshes the control data
        this._inputElement = document.createElement("input");
        this._inputElement.setAttribute("list", "dataList" + autoCompleteListUniqueId);
        this._inputElement.setAttribute("name", "autoComplete");
        this._inputElement.addEventListener("input", this._refreshData);
        this._inputElement.setAttribute("value", this._context.parameters.selectedValue === undefined || this._context.parameters.selectedValue.raw === null ? "" : String(this._context.parameters.selectedValue.raw));
        this._inputElement.placeholder = "---";
        this._inputElement.classList.add("crmInput");
        this._inputElement.classList.add("borderNone");

        this._inputElement.addEventListener("mouseenter", this.onMouseEnter.bind(this));
        this._inputElement.addEventListener("mouseleave", this.onMouseLeave.bind(this));

        // creating HTML elements for data list 
        this._datalistElement = document.createElement("datalist");
        this._datalistElement.setAttribute("id", "dataList" + autoCompleteListUniqueId);

        this._entityName = this._context.parameters.relatedEntity.raw !== null ? this._context.parameters.relatedEntity.raw : "";

        //@ts-ignore
        this._productId = Xrm.Page.getAttribute("productid").getValue() != null ? Xrm.Page.getAttribute("productid").getValue()[0].id : "00000000-0000-0000-0000-000000000000";
        this._productId.replace("{", "").replace("}", "");

        this.initAutoComplete();

        // appending the HTML elements to the control's HTML container element.
        this._divContainer.appendChild(this._inputElement);

        //Add datalist element
        this._divContainer.appendChild(this._datalistElement);
        container.appendChild(this._divContainer);
    }

    /**
     * Updates the values to the internal value variable we are storing and also updates the html label that displays the value
     * @param evt : The "Input Properties" containing the parameters, control metadata and interface functions
     */
    public refreshData(evt: Event): void {
        this._currentValue = (this._inputElement.value as any) as string;
        this._notifyOutputChanged();
    }

    /**
     * Called when any value in the property bag has changed. This includes field values, data-sets, global values such as container height and width, offline status, control metadata values such as label, visible, etc.
     * @param context The entire property bag available to control via Context Object; It contains values as set up by the customizer mapped to names defined in the manifest, as well as utility functions
     */
    public updateView(context: ComponentFramework.Context<IInputs>): void {
        this._currentValue = (this._inputElement.value as any) as string;
        //@ts-ignore
        var currentProductId = Xrm.Page.getAttribute("productid").getValue() != null ? Xrm.Page.getAttribute("productid").getValue()[0].id : "00000000-0000-0000-0000-000000000000";
        currentProductId.replace("{", "").replace("}", "");

        if (this._productId !== currentProductId) { // Fetch the product details with new productId
            this._productId = currentProductId;
            this.resetAutoCompleteSets();
            this.initAutoComplete();
        }

        this.autoCompleteFnc();
    }

    private resetAutoCompleteSets() {
        this.customAutoCompleteSetSameProduct = [];
        this.customAutoCompleteSetDiffProduct = [];
    }

    /** 
     * It is called by the framework prior to a control receiving new data. 
     * @returns an object based on nomenclature defined in manifest, expecting object[s] for property marked as “bound” or “output”
     */
    public getOutputs(): IOutputs {
        let result = {
            selectedValue: this._currentValue
        };

        return result;
    }

    public initAutoComplete() {
        let entityLogicalName: string = "";
        let entityIdName: string = "";

        if (this._entityName == "opportunityproducts") {
            entityLogicalName = "opportunityproduct";
            entityIdName = "opportunityproductid";
        }
        else if (this._entityName == "quotedetails") {
            entityLogicalName = "quotedetail";
            entityIdName = "quotedetailid";
        }
        else {
            entityLogicalName = "salesorderdetail";
            entityIdName = "salesorderdetailid";
        }


        let prodDetailWithSameProd = '\
<fetch aggregate="true" distinct="true" > \
    <entity name="'+ entityLogicalName + '" > \
        <attribute name="ad_productdetails" alias="type" groupby="true" /> \
        <attribute name="'+ entityIdName + '" alias="rowcount" aggregate="count" /> \
        <order alias="rowcount" descending="true" /> \
        <filter type="and" > \
            <condition attribute="ad_productdetails" operator="not-null" /> \
            <condition attribute="createdon" operator="last-x-years" value="3" /> \
            <condition attribute="productid" operator="eq" value="'+ this._productId + '" />\
        </filter> \
    </entity> \
</fetch>';


        let prodDetailWithDifferentProd = '\
<fetch aggregate="true" distinct="true" > \
    <entity name="'+ entityLogicalName + '" > \
        <attribute name="ad_productdetails" alias="type" groupby="true" /> \
        <attribute name="'+ entityIdName + '" alias="rowcount" aggregate="count" /> \
        <order alias="rowcount" descending="true" /> \
        <filter type="and" > \
            <condition attribute="ad_productdetails" operator="not-null" /> \
            <condition attribute="createdon" operator="last-x-years" value="3" /> \
            <condition attribute="productid" operator="neq" value="'+ this._productId + '" />\
        </filter> \
    </entity> \
</fetch>';

        this.retrieveMultiplerRecords(prodDetailWithSameProd, true);
        this.retrieveMultiplerRecords(prodDetailWithDifferentProd, false);
    }

    public processType(prodDetails: { value: {}[] | any[]; }, isSameProduct: boolean) {
        if (prodDetails.value.length != 0 && isSameProduct)
            this.pushIntoSameProdAutoCompleteSet(prodDetails);

        else if (prodDetails.value.length != 0 && !isSameProduct)
            this.pushIntoDiffProdAutoCompleteSet(prodDetails);
    }

    public pushIntoSameProdAutoCompleteSet(sameProdDetails: { value: {}[] | any[]; }) {

        for (var resultCount = 0; resultCount < sameProdDetails.value.length; resultCount++) {

            var customTypeIndex = this.customAutoCompleteSetSameProduct.findIndex(function (def: any) { return def.id === sameProdDetails.value[resultCount].type });

            if (customTypeIndex > -1) {

                this.customAutoCompleteSetSameProduct[customTypeIndex].fields.rowcount += sameProdDetails.value[resultCount].rowcount;
                continue;
            }

            this.customAutoCompleteSetSameProduct.push(
                {
                    id: sameProdDetails.value[resultCount].type,
                    fields: {
                        type: sameProdDetails.value[resultCount].type,
                        rowcount: sameProdDetails.value[resultCount].rowcount,
                    },
                });
        }
    }

    public pushIntoDiffProdAutoCompleteSet(diffProdDetails: { value: {}[] | any[]; }) {

        for (var resultCount = 0; resultCount < diffProdDetails.value.length; resultCount++) {

            var customTypeIndex = this.customAutoCompleteSetDiffProduct.findIndex(function (def: any) { return def.id === diffProdDetails.value[resultCount].type });

            if (customTypeIndex > -1) {

                this.customAutoCompleteSetDiffProduct[customTypeIndex].fields.rowcount += diffProdDetails.value[resultCount].rowcount;
                continue;
            }

            this.customAutoCompleteSetDiffProduct.push(
                {
                    id: diffProdDetails.value[resultCount].type,
                    fields: {
                        type: diffProdDetails.value[resultCount].type,
                        rowcount: diffProdDetails.value[resultCount].rowcount,
                    },
                });
        }
    }

    public retrieveMultiplerRecords(autoCompleteFetchXml: string, isSameProduct: boolean) {
        //@ts-ignore
        const serverUrl = Xrm.Page.context.getClientUrl();

        var queryString = encodeURIComponent(autoCompleteFetchXml);

        // console.log(serverUrl + "/api/data/v9.0/" + this._entityName + "?fetchXml=" + queryString);

        let req = new XMLHttpRequest();
        req.open("GET", serverUrl + "/api/data/v9.0/" + this._entityName + "?fetchXml=" + queryString, true);
        req.setRequestHeader("Accept", "application/json");
        req.setRequestHeader("Content-Type", "application/json; charset=utf-8");
        req.setRequestHeader("OData-MaxVersion", "4.0");
        req.setRequestHeader("OData-Version", "4.0");
        req.onreadystatechange = () => {
            if (req.readyState == 4) {
                req.onreadystatechange = null;

                console.log("STATUS: " + req.status);
                if (req.status == 200) {
                    //console.log("req.response: " + req.response);

                    if (isSameProduct) {
                        var sameProddata = JSON.parse(req.response);
                        this.processType(sameProddata, isSameProduct);
                    }
                    else {
                        var differentProddata = JSON.parse(req.response);
                        this.processType(differentProddata, isSameProduct);
                    }
                } else {
                    var error = JSON.parse(req.response).error;
                    console.log(error.message);
                }
            }
        };
        req.send();
    }

    public autoCompleteFnc() {
        debugger;
        let addedSameProds = 0, addedDiffProds = 0;
        let addFirstX = 15;
        try {
            let productDetailsInput = this._currentValue;

            let resultSameProd: {
                id: string,
                fields: {
                    type: string,
                    rowcount: number,
                },
            }[] = [];

            let resultDiffProd: {
                id: string,
                fields: {
                    type: string,
                    rowcount: number,
                },
            }[] = [];

            let combinedResults: {
                id: string,
                fields: {
                    type: string,
                    rowcount: number,
                },
            }[] = [];

            let resultSetOfSameProds = {
                results: resultSameProd
            };

            let resultSetOfDiffProds = {
                results: resultDiffProd
            };

            let combinedResultSet = {
                results: combinedResults
            };

            let containsResults = [];

            for (var setCount = 0; addedSameProds < addFirstX && setCount < this.customAutoCompleteSetSameProduct.length; setCount++) {
                if (this.matches(productDetailsInput, this.customAutoCompleteSetSameProduct[setCount])) {
                    resultSetOfSameProds.results.push(this.customAutoCompleteSetSameProduct[setCount]);
                    addedSameProds++;
                }
                else if (this.contains(productDetailsInput, this.customAutoCompleteSetSameProduct[setCount])) {
                    containsResults.push(this.customAutoCompleteSetSameProduct[setCount])
                }
            }

            resultSetOfSameProds.results = resultSetOfSameProds.results.sort(
                function (a, b) {
                    if (a.fields.rowcount < b.fields.rowcount) {
                        return 1;
                    }
                    if (a.fields.rowcount > b.fields.rowcount) {
                        return -1;
                    }
                    return 0;
                });

            var additionals = addFirstX - addedSameProds;

            if (additionals && containsResults.length > 0) {
                containsResults = containsResults.slice(0, additionals).sort(
                    function (a, b) {
                        if (a.fields.rowcount < b.fields.rowcount) {
                            return 1;
                        }
                        if (a.fields.rowcount > b.fields.rowcount) {
                            return -1;
                        }
                        return 0;
                    });

                resultSetOfSameProds.results = resultSetOfSameProds.results.concat(containsResults);
            }

            containsResults = [];

            // Different Products Set
            for (var setCount = 0; addedDiffProds < addFirstX && setCount < this.customAutoCompleteSetDiffProduct.length; setCount++) {
                if (this.matches(productDetailsInput, this.customAutoCompleteSetDiffProduct[setCount])) {
                    resultSetOfDiffProds.results.push(this.customAutoCompleteSetDiffProduct[setCount]);
                    addedDiffProds++;
                }
                else if (this.contains(productDetailsInput, this.customAutoCompleteSetDiffProduct[setCount])) {
                    containsResults.push(this.customAutoCompleteSetDiffProduct[setCount])
                }
            }

            resultSetOfDiffProds.results = resultSetOfDiffProds.results.sort(
                function (a, b) {
                    if (a.fields.rowcount < b.fields.rowcount) {
                        return 1;
                    }
                    if (a.fields.rowcount > b.fields.rowcount) {
                        return -1;
                    }
                    return 0;
                });

            var additionals = addFirstX - addedDiffProds;

            if (additionals && containsResults.length > 0) {
                containsResults = containsResults.slice(0, additionals).sort(
                    function (a, b) {
                        if (a.fields.rowcount < b.fields.rowcount) {
                            return 1;
                        }
                        if (a.fields.rowcount > b.fields.rowcount) {
                            return -1;
                        }
                        return 0;
                    });

                resultSetOfDiffProds.results = resultSetOfDiffProds.results.concat(containsResults);
            }

            combinedResultSet.results = resultSetOfSameProds.results.concat(resultSetOfDiffProds.results);

            this.modifyAutoComplete(combinedResultSet);

        } catch (e) {
            console.log(e);
        }
    }

    private onMouseEnter(): void {
        this._inputElement.classList.remove("borderNone");
    }

    private onMouseLeave(): void {
        this._inputElement.classList.add("borderNone");
    }

    public contains(input: string, resultElement: { id: string; }) {
        var fixedInput = input.trim().toLowerCase();

        if (fixedInput === "") {
            return true;
        }

        return resultElement.id.toLowerCase().includes(fixedInput);
    }

    public matches(input: string, resultElement: { id: string; }) {
        if (input === "" || input === undefined) {
            return true;
        }

        var fixedInput = input.trim().toLowerCase();

        if (fixedInput === "" || fixedInput === undefined) {
            return true;
        }

        return fixedInput === resultElement.id.substring(0, fixedInput.length).toLowerCase();
    }

    public modifyAutoComplete(resultSet: results) {

        let optionsHtmlArray = new Array();

        let optionsHtml = "";
        var autoCompleteValues = resultSet.results.sort();

        for (var i = 0; i < autoCompleteValues.length; ++i) {
            optionsHtmlArray.push('<option value="');
            optionsHtmlArray.push(autoCompleteValues[i].id);
            optionsHtmlArray.push('" />');
        }
        optionsHtml = optionsHtmlArray.join("");

        //@ts-ignore 
        this._datalistElement.innerHTML = optionsHtml;
    }

    /** 
     * Called when the control is to be removed from the DOM tree. Controls should use this call for cleanup.
     * i.e. cancelling any pending remote calls, removing listeners, etc.
     */
    public destroy(): void {
        // Add code to cleanup control if necessary
        this._inputElement.removeEventListener("input", this._refreshData);
    }

    /** 
          * method to gnenerate unique ids in javassript so that I can have multiple PCF controls on the same form
        * https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
    */
    private uuidv4(): string {
        //@ts-ignore
        return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c =>
            (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
        );
    }

    private ExistsinArray(data: Array<any>, stringToSearch: string) {
        //First sort the array and then run through it and then see if the next (or previous) index is the same as the current. 
        const sortedArr = data.slice().sort();
        for (var i = 0; i < data.length - 1; i++) {
            if (sortedArr[i] === stringToSearch) {
                return true;
            }
        }
        return false;
    }
}

interface results {

    results: {
        id: string
    }[];

}

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s