Creating Better PCF Component – Part 1

This article will give you a deep explanation (step by step) to make a better quality PCF Component. We can make the PCF component better quality because we design the component to be testable as much as we can (It depends on your knowledge. The more knowledge you have, the more sophisticated the result). To make it testable, we will follow the Test Driven Development methodology.

So to make you understand the context, I need to give you what PCF-Component that we will build. This PCF-Component is an Image-Uploader to Base64String. So component input is just a string that we will parse to image control, the process is changing the file that is uploaded to be Base64String. This blog post will have another series to show you how the feature growing and how to fix the bug that we are not aware of when building the component.

Setup The Environment

You need to generate PCF init using PCF Cli in here. After you ready with all the things, we always begin with ControlManifest.Input.xml (This is the information of the PCF Component. We defined what input type, the label name, etc):

<?xml version="1.0" encoding="utf-8" ?>
<manifest>
  <control namespace="Insurgo.Pcf.Controls" constructor="ImageFromString" version="0.0.1" display-name-key="Image" description-key="Image" control-type="standard">
    <!--external-service-usage node declares whether this 3rd party PCF control is using external service or not, if yes, this control will be considered as premium and please also add the external domain it is using.
    If it is not using any external service, please set the enabled="false" and DO NOT add any domain below. The "enabled" will be true by default.
    Example1:
      <external-service-usage enabled="true">
        <domain>www.Microsoft.com</domain>
      </external-service-usage>
    Example2:
      <external-service-usage enabled="false">
      </external-service-usage>
    -->
    <external-service-usage enabled="true">
      <!--UNCOMMENT TO ADD EXTERNAL DOMAINS
      <domain></domain>
      <domain></domain>
      -->
    </external-service-usage>
    <!-- property node identifies a specific, configurable piece of data that the control expects from CDS -->
    <type-group name="Text">
      <type>SingleLine.Text</type>
      <type>SingleLine.TextArea</type>
      <type>Multiple</type>
    </type-group>
    <property name="ImageString" display-name-key="Image" description-key="Image" of-type-group="Text" 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="css/style.css" order="1" /> 
      <!-- UNCOMMENT TO ADD MORE RESOURCES
      <css path="css/ImageFromString.css" order="1" />
      <resx path="strings/ImageFromString.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>

We will add a bunch of npm packages using this command:

npm i -D typescript ts-node chai mocha jsdom @types/chai @types/mocha @types/jsdom

Here are the information of the packages that we are using:

Npm PackageDescription
chaiAssertion library
mochaTest runner
jsdomMock/Test Object of DOM Html Element
ts-nodeTypescript runner
Npm packages information

Before we running our tests, we can put the bootstrap file (a class that is called). I will create ./setup-test.ts to bootstrapping our tests environment. Here I do mock FileReader because, in node.js, we don’t have this implementation by default. We also setup JSDOM as our browser-mock-engine. Here is the implementation of ./setup-test.ts:

import { JSDOM } from 'jsdom';
const {
  window,
} = new JSDOM(
  '<html><body><div id="div-main"></div></body></html>',
  { url: 'http://localhost' }
);

declare global {
  namespace NodeJS {
    interface Global {
      document: Document;
      window: Window;
      navigator: Navigator;
      FileReader: any;
    }
  }
}

export class mockFileReader implements FileReader {
  error: DOMException | null;
  onabort: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null;
  onerror: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null;
  onload: ((this: FileReader, ev: ProgressEvent<FileReader>) => any);
  onloadend: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null;
  onloadstart: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null;
  onprogress: ((this: FileReader, ev: ProgressEvent<FileReader>) => any) | null;
  readyState: number;
  result: string | ArrayBuffer | null;
  abort(): void {
    throw new Error('Method not implemented.');
  }
  readAsArrayBuffer(blob: Blob): void {
    throw new Error('Method not implemented.');
  }
  readAsBinaryString(blob: Blob): void {
    throw new Error('Method not implemented.');
  }
  readAsDataURL(blob: Blob): void {
    this.result = 'call-from-mock-file-reader';
    this.onload({ target: { result: 'success' } } as ProgressEvent<FileReader>);
  }
  readAsText(blob: Blob, encoding?: string): void {
    throw new Error('Method not implemented.');
  }
  DONE: number;
  EMPTY: number;
  LOADING: number;
  addEventListener<K extends 'abort' | 'error' | 'load' | 'loadend' | 'loadstart' | 'progress'>(type: K, listener: (this: FileReader, ev: FileReaderEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
  addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
  addEventListener(type: any, listener: any, options?: any) {
    throw new Error('Method not implemented.');
  }
  removeEventListener<K extends 'abort' | 'error' | 'load' | 'loadend' | 'loadstart' | 'progress'>(type: K, listener: (this: FileReader, ev: FileReaderEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
  removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
  removeEventListener(type: any, listener: any, options?: any) {
    throw new Error('Method not implemented.');
  }
  dispatchEvent(event: Event): boolean {
    throw new Error('Method not implemented.');
  }
}

export function setup() {
  global.document = window.document;
  global.window = global.document.defaultView as Window;
  global.FileReader = mockFileReader;
}

Then the next step is to register a test script in package.json:

{
    "name": "pcf-project",
    "version": "1.0.0",
    "description": "Project containing your PowerApps Component Framework (PCF) control.",
    "scripts": {
        "build": "pcf-scripts build",
        "clean": "pcf-scripts clean",
        "rebuild": "pcf-scripts rebuild",
        "start": "pcf-scripts start",
        "test": "mocha -r ts-node/register './setup-test.ts' 'ImageFromString/**/*.spec.ts'"
    },
    "dependencies": {
        "@types/node": "^10.12.18",
        "@types/powerapps-component-framework": "^1.2.0"
    },
    "devDependencies": {
        "@types/chai": "^4.2.14",
        "@types/jsdom": "^16.2.5",
        "@types/mocha": "^8.0.4",
        "chai": "^4.2.0",
        "jsdom": "^16.4.0",
        "mocha": "^8.2.1",
        "pcf-scripts": "^1",
        "pcf-start": "^1",
        "ts-node": "^9.0.0",
        "typescript": "^4.0.5"
    }
}

We command our test runner to run all the tests inside ImageFromString folder with format name *.spec.ts. To run our test, we just need to run in our command prompt: npm run test.

[*Optional] To make our testing easy, we can install Mocha Test Explorer. Then we only need to add this setting in our .vscode/settings.json:

{
    "mochaExplorer.require": "ts-node/register",
    "mochaExplorer.files": "**/*.spec.ts",
    "mochaExplorer.logpanel": true,
    "mochaExplorer.ui": "bdd"
}

We can directly debug/run our test in UI which is more fun to do (at least for me).

Creating The Component

After the environment ready, now we will start to build the component. From my perspective, we will need an HTML Input with the type of file, a Button to process, then the Image to show the result. Based on this design, we split into 2 components. File-upload.ts to process the input, and Image.ts to show the result.

Here is the file-upload.spec.ts code:

import { expect } from 'chai';
import { fileUpload, controls } from './file-upload';
import { IInputs } from './generated/ManifestTypes';
import * as init from '../setup-test';

describe('fileupload control tests', () => {
  let context: ComponentFramework.Context<IInputs>;
  let htmlDivElement: HTMLDivElement;

  beforeEach(() => {
    init.setup();
    htmlDivElement = document.getElementById('div-main') as HTMLDivElement;
    context = {} as ComponentFramework.Context<IInputs>;
  });

  describe('generate fileupload', () => {
    it('can generate fileupload control', () => {
      const file = new fileUpload(context, htmlDivElement, () => { });
      file.generate();

      expect(file).not.null;

      const div = document.getElementById(controls.div);
      expect(div).not.null;

      const label = document.getElementById(controls.label);
      expect(label).not.null;

      const imageUpload = document.getElementById(controls.file);
      expect(imageUpload).not.null;

      const button = document.getElementById(controls.button);
      expect(button).not.null;

      const information = document.getElementById(controls.information);
      expect(information).not.null;
    });

    it('can validate control', () => {
      const file = new fileUpload(context, htmlDivElement, () => { });
      file.generate();
      file.submit();

      const information = document.getElementById(controls.information);
      expect(information?.style.display).to.equal('block');
    });

    it('can call convertToBase64', async () => {
      const file = new fileUpload(context, htmlDivElement, () => { });
      await file.convertToBase64({}).then(success => {
        expect(success).to.equal('call-from-mock-file-reader');
      });
    });
  });
});

Here is the implementation of file-upload.ts:

import { IInputs } from './generated/ManifestTypes';

export const controls = Object.freeze({
  div: 'file-div',
  file: 'file-fileupload',
  label: 'file-label',
  button: 'file-button-upload',
  information: 'file-information-label',
});

export class fileUpload {
  constructor(
    private context: ComponentFramework.Context<IInputs>,
    private htmlDivElement: HTMLDivElement,
    private successFn: (baseString: string | ArrayBuffer) => void) { }

  generate() {
    const maindiv = document.createElement('div') as HTMLDivElement;
    maindiv.setAttribute('class', 'mb-3');
    maindiv.setAttribute('id', controls.div);

    maindiv.appendChild(this.getLabel());
    maindiv.appendChild(this.getFileUpload());
    maindiv.appendChild(this.getButton());
    maindiv.appendChild(this.getInformation());

    this.htmlDivElement.appendChild(maindiv);
  }

  private getInformation(): HTMLLabelElement {
    const label = document.createElement('label');
    label.setAttribute('class', 'alert alert-danger');
    label.setAttribute('id', controls.information);
    label.style.display = 'none';
    label.innerHTML = 'Image is required';

    return label;
  }

  private getButton(): HTMLButtonElement {
    const button = document.createElement('button');
    button.setAttribute('class', 'form-control');
    button.innerText = 'Upload';
    button.setAttribute('id', controls.button);
    button.onclick = () => this.submit();

    return button;
  }

  private getLabel(): HTMLLabelElement {
    const label = document.createElement('label');
    label.setAttribute('class', 'form-label');
    label.innerText = 'Upload Image';
    label.setAttribute('id', controls.label);

    return label;
  }

  private getFileUpload(): HTMLInputElement {
    const fileUpload = document.createElement('input');
    fileUpload.setAttribute('id', controls.file);
    fileUpload.setAttribute('type', 'file');
    fileUpload.setAttribute('accept', 'image/*');
    fileUpload.setAttribute('class', 'form-control image-uploader');

    return fileUpload;
  }

  submit(): void {
    const imageFile = document.getElementById(
      controls.file
    ) as HTMLInputElement;
    const information = document.getElementById(controls.information);
    if (!information) return;

    const filePath = imageFile.value;
    information.style.display = !filePath ? 'block' : 'none';

    if (!filePath) {
      return;
    }

    const file = imageFile.files![0]; 
    this.convertToBase64(file as Blob).then(success => this.successFn(success));
  }

  convertToBase64(file: any): Promise<string | ArrayBuffer> {
    const fileReader = new FileReader();
    return new Promise((resolve, reject) => {
      fileReader.onerror = () => {
        fileReader.abort();
        reject(new DOMException("Problem parsing input file."));
      };

      fileReader.onload = () => {
        resolve(fileReader.result as string);
      };

      fileReader.readAsDataURL(file);
    });
  }
}

Image.spec.ts:

import { expect } from 'chai';
import { image, controls } from './image';
import { IInputs } from './generated/ManifestTypes';
import * as init from '../setup-test';
import { assert } from 'console';

describe('image control tests', () => {
  let context: ComponentFramework.Context<IInputs>;
  let htmlDivElement: HTMLDivElement;

  beforeEach(() => {
    init.setup();
    htmlDivElement = document.getElementById('div-main') as HTMLDivElement;
    context = {} as ComponentFramework.Context<IInputs>;
  });

  describe('generate image', () => {
    it('can generate image control', () => {
      const img = new image(context, htmlDivElement);
      img.generate();

      expect(img).not.null;

      const div = document.getElementById(controls.div);
      expect(div).not.null;

      const imageCtrl = document.getElementById(controls.image);
      expect(imageCtrl).not.null;
    });

    it('set src image', () => {
        const img = new image(context, htmlDivElement);
        img.generate();

        img.setSrc('http://localhost/img1.jpg');

        const imgCtrl = document.getElementById(controls.image) as HTMLImageElement;
        expect(imgCtrl.src).to.equal('http://localhost/img1.jpg');
    });
  });
});

Image.ts:

import { IInputs } from './generated/ManifestTypes';

export const controls = Object.freeze({
    image: 'image-image',
    div: 'image-main-div'
});

export class image {
    constructor(
        private context: ComponentFramework.Context<IInputs>,
        private htmlDivElement: HTMLDivElement) { }

    generate() {
        const maindiv = document.createElement('div') as HTMLDivElement;
        maindiv.setAttribute('class', 'mb-3');
        maindiv.setAttribute('id', controls.div);

        maindiv.appendChild(this.getImage());

        this.htmlDivElement.appendChild(maindiv);
    }

    getImage() {
        const image = document.createElement('img');
        image.setAttribute('class', 'img-fluid');
        image.setAttribute('id', controls.image);

        return image;
    }

    setSrc(imageString: string | ArrayBuffer) {
        const image = document.getElementById(controls.image) as HTMLImageElement;
        image.setAttribute('src', imageString as string);
    }
}

Styling

For styling files, I create bootstrap.ts to import Bootstrap and style.css for custom styling.

Here is bootstrap.ts:

export function addBootstrap(container: HTMLDivElement) {
    const link = document.createElement('link');
    link.href = 'https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-alpha3/dist/css/bootstrap.min.css';
    link.rel = 'stylesheet';
    link.crossOrigin = 'anonymous';
    link.integrity = 'sha384-CuOF+2SnTUfTwSZjCXf01h7uYhfOBuxIhGKPbfEJ3+FqH/s6cIFN9bGr1HmAg4fQ';
    container.appendChild(link);

    const script = document.createElement('script');
    script.src = 'https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-alpha3/dist/js/bootstrap.bundle.min.js';
    script.integrity = 'sha384-popRpmFF9JQgExhfw5tZT4I9/CI5e2QcuUZPOVXb1m7qUmeR2b50u+YFEYe1wgzy';
    script.crossOrigin = 'anonymous';
    container.appendChild(script);
}

css/Style.css:

.image-uploader {
    opacity: unset !important;
    position: relative !important;
    pointer-events: all !important;
    width: 100% !important;
    height: 25px !important;
}

Putting It All Together

Now we will combine the files that we already created in ./ImageFromString/index.ts file:

import { IInputs, IOutputs } from './generated/ManifestTypes';
import { fileUpload } from './file-upload';
import { addBootstrap } from './bootstrap';
import { image } from './image';

export class ImageFromString
  implements ComponentFramework.StandardControl<IInputs, IOutputs> {
  /**
   * Empty constructor.
   */
  constructor() { }

  private fileCtrl: fileUpload;
  private imgCtrl: image;
  private base64string: string;

  /**
   * 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
    addBootstrap(container);

    this.imgCtrl = new image(context, container);
    this.fileCtrl = new fileUpload(context, container, success => {
      this.imgCtrl.setSrc(success);
      this.base64string = success as string;
      notifyOutputChanged();
    });

    this.fileCtrl.generate();

    const hr = document.createElement('hr');
    container.appendChild(hr);

    this.imgCtrl.generate();

    if(context.parameters.ImageString.raw){
      this.imgCtrl.setSrc(context.parameters.ImageString.raw as string);
    }
  }

  /**
   * 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.imgCtrl.setSrc(context.parameters.ImageString.raw as string);
  }

  /**
   * 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 {
      ImageString: this.base64string
    };
  }

  /**
   * 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
  }
}

After all of these, you can pack the solution and install it into your environment. Here is the result from my side:

You can find the full code in My GitHub Repository.

Conclusion

  • Always try to implement testing in the first place. So you able to design and implement better.
  • Every PCF component has a different solution. So the npm packages that you need to install will vary.
  • If you are a first-timer developer using node.js like me. You need time to better understand the environment. Mocking FileReader in this sample is a ‘yack’ (ugly) solution for me. But it helps me at least test the thing. Maybe I can revisit this solution later on.

Creating Better PCF Component Series:

3 thoughts on “Creating Better PCF Component – Part 1

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

This site uses Akismet to reduce spam. Learn how your comment data is processed.