Build Custom Control using PowerApps Component Framework


It’s been some time now since Microsoft in it’s April release, released it’s new PCF component feature where you can build your custom control using PowerApp Component Framework. If you are not sure what PCF frame work is please read this article.

I recently got time to do some development related to it, So I’m documenting my experience of developing the custom controls.

First thing first.. Prepare your environment

Prerequisites Installations.

  1. You need to have either Visual Studio Code(Preferable) or Visual Studio(you can come across some bugs while packaging)
  2. Install npm(node.js) from here
  3. Download & Install PowerApp CLI on your machine from here. (The link to the downloadable file is given).

configure your development location and Create PowerApp Component Framework components.

  1. Create/locate your PCF component folder location. I created it in  C:\Users\*****\source\PCF Slider ver1.0.2
  2. Now Open your Visual Studio Command Prompt (Don’t use windows cmd otherwise-you can get msbuild error while packaging) and navigate to the above file location which you created.
  3. Navigate to your recently created folder (step 1) using cd.. and cd command.

4. Run the pac command to create the new component, The command is give below:

pac pcf init --namespace [Your Namespace] --name [Your Component Name] --template [Component Type]

For me I gave it as :

pac pcf init --namespace SliderControl --name SliderControlV102 --template field

The namespace as the name suggest will be used in your code for namespace.

The name whatever you provide will be the name of your component and same will reflect inside the solution and CRM.

The template is the type of data-source you want to pass. If it related to a single field then It should be field otherwise dataset

Currently there are two template/component types only, field and dataset.

5. Now to add all the node related dependencies for the PCF project you need to add npm, So do run this command npm install.

Understand the folder structure

Once you run all the above mentioned command you wil find the list of folders in your main folder.

One or two might now be there as this screenshot has been taken after build and packaging.

file structure you will find after all project dependencies are installed.

The slidercontrolV102 folder is the main folder for me, you need to open this folder in visual studio code.

The folders present inside the slidercontrol folder

You will find the Controlmanifest file and index.ts file already in there, if your component require any stylesheet for styling then you can create one .css file and add to this folder.

Development of your custom control

Now it’s time do some coding but prior to that you need to open visual studo code and open the control folder in there for me it was SliderControlV102.
The visual studio folder structure should appear like this:

file structure in VS

Configure the ControlManifest.Input file

This file contains your component’s definition.
The control tag present in the Manifest file is devides it into three parts :
1. property tag
2. resources tag
3. feature-usage tag

In control tag If you want , you can change the namespace, constructor, version number you can update. you can provide the description as per your component that you want to show in D365 .
Do not change the control-type.

My control structure appears like this.

<control namespace="SliderControl" constructor="SliderControlV102" version="0.0.1" display-name-key="SliderControlV102" description-key="SliderControlV102 description" control-type="standard">

Inside control tag we have property tag:

<property name="annualSales" display-name-key="annualSalesAmount" description-key="annual_Sales_Amount" of-type="OptionSet" usage="bound" required="true" />
  • name: you can change it to be the name of your control
  • display-name-key: you can change it to be the display name of your custom control
  • description-key: you can provide the description as per your component requirement that you want to show in D365
  • usage: bound or input, Bound will ask you to assign a CRM attribute with the property whereas input will allow you to provide a fixed data while configuring the component on the form.
  • of-type: if your control is going to support only single data-type then use of-type attribute.
    Valid values are:
    1. Currency
    2. DateAndTime.DateAndTime
    3. DateAndTime.DateOnly
    4. Decimal
    5. Enum
    6. FP
    7. Multiple
    8. Optionset
    9. SingleLine.Email
    10. SingleLine.Phone
    11. SingleLine.Text
    12. SingleLine.TextArea
    13. SingleLine.Ticker
    14. SingleLine.URL
    15. TwoOptions
    16. Whole.None

of-type-group: if you want your control to support multiple data-type then use of-type-group attribute. If you use this attribute then you have to define the type-group tag and the name of that type-group should be mentioned.
Below is a sample for defining a type-group:
<type-group name=”numbers”>
<type>Whole.None</type>
<type>Currency</type>
<type>FP</type>
<type>Decimal</type>
</type-group>

After control tag you will find resource tag:

Here you can provide the details(name and order) of all the files linked to your component, by default you will have Index.ts added in here.
I have added my .css file as per my need.


Finally my manifest file will appear like this:

<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="SliderControl" constructor="SliderControlV102" version="0.0.1" display-name-key="SliderControlV102" description-key="SliderControlV102 description" control-type="standard">
    <!-- property node identifies a specific, configurable piece of data that the control expects from CDS -->
   <property name="annualSales" display-name-key="annualSalesAmount" description-key="annual_Sales_Amount" of-type="OptionSet" usage="bound" required="true" />
    <!-- 
      Property node's of-type attribute can be of-type-group attribute. 
      Example:
      <type-group name="numbers">
        <type>Whole.None</type>
        <type>Currency</type>
        <type>FP</type>
        <type>Decimal</type>
      </type-group>
      <property name="sampleProperty" display-name-key="Property_Display_Key" description-key="Property_Desc_Key" of-type-group="numbers" usage="bound" required="true" />
    -->
   <resources>
      <code path="index.ts" order="1"/>
      <css path="sliderControl.css" order="1" />
      <!-- UNCOMMENT TO ADD MORE RESOURCES
      <css path="css/sliderControl.css" order="1" />
      <resx path="strings/sliderControl.1033.resx" version="1.0.0" />
      -->
    </resources>
    <!-- UNCOMMENT TO ENABLE THE SPECIFIED API
    <feature-usage>
      <uses-feature name="Device.captureAudio" required="true" />
      <uses-feature name="Device.captureImage" required="true" />
      <uses-feature name="Device.captureVideo" required="true" />
      <uses-feature name="Device.getBarcodeValue" required="true" />
      <uses-feature name="Device.getCurrentPosition" required="true" />
      <uses-feature name="Device.pickFile" required="true" />
      <uses-feature name="Utility" required="true" />
      <uses-feature name="WebAPI" required="true" />
    </feature-usage>
    -->
  </control>
</manifest>

Now it’s time to move towards designing and implementing the custom component.

before you start developing the control you need to have basic idea on TypeScript, HML, and Styling(.css)


Now you need to open the index.ts file and start designing your component.
You will find the namespace which you have provided earlier and the classname same as the name given in the start.
There will be four set of functions already defined in there:

  • init: This is similar to onload and this will be the first method that system will invoke. All your design should happen in this method
  • updateView: this method is invoked when property bag is changed; which includes fields, data-sets, global variables such as height and/or width
  • getOutputs: this method is called prior to receiving any data and will be returned back to the database to save the data.
  • destroy: add your cleanup code here


Before we start I will explain what I’m trying to achieve here.

As you can see in the image there is one Annual sales field which is of type option-set, to select a value in option-set user has to click twice so instead of that I tried to provide the user the experience to selecting the options-set value using the slider.
User can click on the slider range to select that particular value.

To achieve this first I will declare all the variable which we are going to use in our code later either to construct the html design or to populate all the values using client side code.
Here in the below code you will see all the declaration has been done in TypeScript by providing the data-type for each variable.

    private _value: number;
    private _notifyOutputChanged: () => void; //we intialise this //method to capture the change event  user does on the component
    private _container: HTMLDivElement;
    private _titleContainer: HTMLDivElement;
    private _title : HTMLDivElement;
    

    private _range : HTMLInputElement;
    private _sheet : HTMLStyleElement;
    private _spanValue : HTMLSpanElement;
    private _refreshData: EventListenerOrEventListenerObject;
    private _context : ComponentFramework.Context<IInputs>;

Now we will add all our code related to the design in the init function. The html designing need to be done dynamically so you will see all the html tags have been created at runtime.
In this function, let’s first initialize local variables from the input parameters. Then create a functions to handle events; in my case, I have created refreshData() function. Now, bind the event handlers to the variables defined for event listening.

this._context=context;
this._notifyOutputChanged = notifyOutputChanged;
this._refreshData = this.refreshData.bind(this);

After initializing the local variable we need to start building the html so as per the logic we will add the html code

I will first create the main div for my component, then the div to add the range value, after that dynamically create the no of div based on the no of values in option-set, further add the range and the span to show the range value. In the end you need to append your main div to the container

this._container= document.createElement("div");
        this._container.setAttribute("class","slidecontainer");
        this._titleContainer= document.createElement("div");
        this._titleContainer.setAttribute("class","title-container");

        // CRM attributes bound to the control properties. 
         // @ts-ignore 
         var optionsetAttribute: string = context.parameters.annualSales.attributes.LogicalName;
        //@ts-ignore
        var options = Xrm.Page.getAttribute(optionsetAttribute).getOptions();
        var width: number = 100/options.length;
        //@ts-ignore
        for(var i:number = 0; i < options.length; i++)
        {
            this._title =document.createElement("div");
            this._title.setAttribute("class","tile");
            this._title.innerText =options[i].text?options[i].text:"Blank";
            this._title.style.width=(width + "%").toString();
            this._titleContainer.appendChild(this._title);  
                    
        }
        this._container.appendChild(this._titleContainer);

        this._range=document.createElement("input");
        this._range.setAttribute("type","range");
        this._range.addEventListener("input", this._refreshData);
        this._range.setAttribute("min","1");
        this._range.setAttribute("max","5")
        this._range.setAttribute("class","slider")

        this._container.appendChild(this._range);

        this._sheet = document.createElement('style')
        this._sheet.innerHTML = ".slider::-webkit-slider-thumb { width: 20%;}";
        document.body.appendChild(this._sheet);

        this._spanValue =document.createElement("span");
        this._container.appendChild(this._spanValue);
        container.appendChild(this._container);

Now, I read the attribute value bound to the control from the context, split the values based on comma and in the for-loop I initialize my tags tag as span HTML element and set class attributes on that element.

At few lines you will see this commented code //@ts-ignore which is there to inform the compiler to skip the next line as the line has code related to dynamics CRM(Xrm) and compiler doesn’t can’t understand that so to skip the compile time error we add that comment.

public init(context: ComponentFramework.Context<IInputs>, notifyOutputChanged: () => void, state: ComponentFramework.Dictionary, container:HTMLDivElement)
    {
        this._context=context;
        this._notifyOutputChanged = notifyOutputChanged;
        this._refreshData = this.refreshData.bind(this);

        this._container= document.createElement("div");
        this._container.setAttribute("class","slidecontainer");
        this._titleContainer= document.createElement("div");
        this._titleContainer.setAttribute("class","title-container");

        // CRM attributes bound to the control properties. 
         // @ts-ignore 
         var optionsetAttribute: string = context.parameters.annualSales.attributes.LogicalName;
        //@ts-ignore
        var options = Xrm.Page.getAttribute(optionsetAttribute).getOptions();
        var width: number = 100/options.length;
        //@ts-ignore
        for(var i:number = 0; i < options.length; i++)
        {
            this._title =document.createElement("div");
            this._title.setAttribute("class","tile");
            this._title.innerText =options[i].text?options[i].text:"Blank";
            this._title.style.width=(width + "%").toString();
            this._titleContainer.appendChild(this._title);  
                    
        }
        this._container.appendChild(this._titleContainer);

        this._range=document.createElement("input");
        this._range.setAttribute("type","range");
        this._range.addEventListener("input", this._refreshData);
        this._range.setAttribute("min","1");
        this._range.setAttribute("max","5")
        this._range.setAttribute("class","slider")

        this._container.appendChild(this._range);

        this._sheet = document.createElement('style')
        this._sheet.innerHTML = ".slider::-webkit-slider-thumb { width: 20%;}";
        document.body.appendChild(this._sheet);

        this._spanValue =document.createElement("span");
        this._container.appendChild(this._spanValue);
        container.appendChild(this._container);
        this._value=context.parameters.annualSales.raw?context.parameters.annualSales.raw:0;
        this._range.value = (context.parameters.annualSales.raw?context.parameters.annualSales.raw:0).toString();
        //this._spanValue.innerHTML = (context.parameters.annualSales.formatted?context.parameters.annualSales.formatted:"0").toString();

        
      

         //@ts-ignore 
        var crmTagStringsAttributeValue = Xrm.Page.getAttribute(optionsetAttribute).getText();
          //@ts-ignore
        this._spanValue.innerHTML = crmTagStringsAttributeValue;
        

    }

After this we can provide our code for refreshData function which will also call the OOB notifyoutputChang() function.

public refreshData(evt: Event): void {
        
        this._spanValue.innerHTML = this._range.value;
        this._notifyOutputChanged();
}

Then Update the UpdateView function, this basically runs when the datasource value gets change, so it then change the value of the component also.

public updateView(context: ComponentFramework.Context<IInputs>): void
    {
        this._context = context;
        this._value = context.parameters.annualSales.raw?context.parameters.annualSales.raw:0;
        this._range.value = (context.parameters.annualSales.raw?context.parameters.annualSales.raw:0).toString();
        //this._spanValue.innerHTML = (context.parameters.annualSales.formatted?context.parameters.annualSales.formatted:0).toString();
        // CRM attributes bound to the control properties. 
        // @ts-ignore 
        var crmTagStringsAttribute = context.parameters.annualSales.attributes.LogicalName;

        // @ts-ignore 
        var crmTagStringsAttributeValue = Xrm.Page.getAttribute(crmTagStringsAttribute).getText();
         // @ts-ignore
        this._spanValue.innerHTML = crmTagStringsAttributeValue;
    }

Finally we have getOutputs function to send the change from the framework component UI to the dataSaource and the destroy function to kill all the eventlistners .

    public getOutputs(): IOutputs
    {
        return {annualSales:parseInt(this._range.value)};
    }

    /** 
     * 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
    {
        this._range.removeEventListener("input", this._refreshData);
    }

Below is the entire index.ts Code:

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

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

    private _value: number;
    private _notifyOutputChanged: () => void;
    private _container: HTMLDivElement;
    private _titleContainer: HTMLDivElement;
    private _title : HTMLDivElement;
    

    private _range : HTMLInputElement;
    private _sheet : HTMLStyleElement;
    private _spanValue : HTMLSpanElement;
    private _refreshData: EventListenerOrEventListenerObject;
    private _context : ComponentFramework.Context<IInputs>;
    /**
     * 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)
    {
        this._context=context;
        this._notifyOutputChanged = notifyOutputChanged;
        this._refreshData = this.refreshData.bind(this);

        this._container= document.createElement("div");
        this._container.setAttribute("class","slidecontainer");
        this._titleContainer= document.createElement("div");
        this._titleContainer.setAttribute("class","title-container");

        // CRM attributes bound to the control properties. 
         // @ts-ignore 
         var optionsetAttribute: string = context.parameters.annualSales.attributes.LogicalName;
        //@ts-ignore
        var options = Xrm.Page.getAttribute(optionsetAttribute).getOptions();
        var width: number = 100/options.length;
        //@ts-ignore
        for(var i:number = 0; i < options.length; i++)
        {
            this._title =document.createElement("div");
            this._title.setAttribute("class","tile");
            this._title.innerText =options[i].text?options[i].text:"Blank";
            this._title.style.width=(width + "%").toString();
            this._titleContainer.appendChild(this._title);  
                    
        }
        this._container.appendChild(this._titleContainer);

        this._range=document.createElement("input");
        this._range.setAttribute("type","range");
        this._range.addEventListener("input", this._refreshData);
        this._range.setAttribute("min","1");
        this._range.setAttribute("max","5")
        this._range.setAttribute("class","slider")

        this._container.appendChild(this._range);

        this._sheet = document.createElement('style')
        this._sheet.innerHTML = ".slider::-webkit-slider-thumb { width: 20%;}";
        document.body.appendChild(this._sheet);

        this._spanValue =document.createElement("span");
        this._container.appendChild(this._spanValue);
        container.appendChild(this._container);
        this._value=context.parameters.annualSales.raw?context.parameters.annualSales.raw:0;
        this._range.value = (context.parameters.annualSales.raw?context.parameters.annualSales.raw:0).toString();
        //this._spanValue.innerHTML = (context.parameters.annualSales.formatted?context.parameters.annualSales.formatted:"0").toString();

        
      

         //@ts-ignore 
        var crmTagStringsAttributeValue = Xrm.Page.getAttribute(optionsetAttribute).getText();
          //@ts-ignore
        this._spanValue.innerHTML = crmTagStringsAttributeValue;
        

    }
    public refreshData(evt: Event): void {
        
        this._spanValue.innerHTML = this._range.value;
        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._context = context;
        this._value = context.parameters.annualSales.raw?context.parameters.annualSales.raw:0;
        this._range.value = (context.parameters.annualSales.raw?context.parameters.annualSales.raw:0).toString();
        //this._spanValue.innerHTML = (context.parameters.annualSales.formatted?context.parameters.annualSales.formatted:0).toString();
        // CRM attributes bound to the control properties. 
        // @ts-ignore 
        var crmTagStringsAttribute = context.parameters.annualSales.attributes.LogicalName;

        // @ts-ignore 
        var crmTagStringsAttributeValue = Xrm.Page.getAttribute(crmTagStringsAttribute).getText();
         // @ts-ignore
        this._spanValue.innerHTML = crmTagStringsAttributeValue;
    }

    /** 
     * 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
    {
        return {annualSales:parseInt(this._range.value)};
    }

    /** 
     * 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
    {
        this._range.removeEventListener("input", this._refreshData);
    }
}

Adding CSS to UI

As you can see in the code I have used classes to the HTML tags, so to make them work I have added this file in the control folder and passed the path in the manifest resource.

.slidecontainer {
  width: 100%;
}

.slider {
  -webkit-appearance: none;
  width: -webkit-fill-available;
  height: 25px;
  background: #d3d3d3;
  outline: none;
  opacity: 0.7;
  -webkit-transition: .2s;
  transition: opacity .2s;
}

.slider:hover {
  opacity: 1;
}

.slider::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  /* width: 20%; */
  height: 25px;
  background: #4CAF50;
  cursor: pointer;
}

.slider::-moz-range-thumb {
  width: 25px;
  height: 25px;
  background: #4CAF50;
  cursor: pointer;
}
.tile-container{
  position: relative;
  /* border: solid #BDBDBD 2px; */
  /* margin: 5px 0px; */
  width:100%;
  font-size:0; /* add */
}

.tile{
  font-size: 12px; /* or whatever you want */
  /* width: 20%; */
  min-height: 2px;
  /* border: 1px #BDBDBD solid; */
  display: inline-block;
  text-align: center;
}

Time to Build the project

You can use the Visual studio code terminal for this.
Directly open the terminal and type npm run build command to build the code.

Once build succeed type npm start command to run the code on localhost.

For me it says error loading as I have used the Xrm keyword to construct the code but if you use normal or hardcoded values it will show the output in there.

After testing the code in the browser, you can moved ahead by packaging the code in the solution and then deploy on CRM.

Packaging the PCF Component

Now that we built and tested the PCF control, lets package it in to a solution which enables us to transport the PCF control to CDS which can be further used in Forms, Dashboards.

  • In the ‘SliderControlV102’ folder which has been created in above steps, create another sub folder for the PCF control. In my case, I named the folder as ‘Deployment’.

Open the Visual studio command prompt again and point to the deployment folder uisng CD command

Once in the deployment folder location enter the below command to create the solution folders.

pac solution init --publisher-name [publisher name] --publisher-prefix [publisher prefix]
In my case it was :
pac solution init --publisher-name sanket --publisher-prefix prm

Once the solution project is created we need to add the component into this solution. To do this, we need to use the following command. The path needs to be where the project file (pcfproj) resides

pac solution add-reference --path [path or relative path of your PowerApps component framework project on disk]
in my case it was:
pac solution add-reference --path "C:\Users\***\source\PCF Slider ver1.0.2"

Once the above command is executed, you’ll see deployment.cdsproj created.

Now we need to run the few more command to finally gets the .Zip file in the debug->bin folder
Enter msbuild /t:restore and then msbuild

You will now get the .zip file created in your debug folder which is inside deployment folder (deployment->bin->debug->)

For me the file location is :C:\Users\***\surce\PCF Slider ver1.0.2\SliderControlV102\Deployment\bin\Debug

Import this solution zip file in any of your favorite D365 CE instance and publish customization.

Now open the account editor and add the same field again in there.

Open the field properties and go to controls tab, Click on Add control and search for the name of the control we created and click on add.

Provide all the devices where you want to display the field and the click save and publish the form.

Open the account entity in UCI form, Refresh the account record and you will see the custom component there.

Thanks guys for coming till here.. 🙂

One thought on “Build Custom Control using PowerApps Component Framework

  1. Awesome Article I was looking for something similar… Very detailed step by step implementation of PCF…
    Waiting to see more on same Topic

    Liked by 1 person

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