Creating a Frontend Contribution

In this example we will show how to make a program node contribution. This will include the component, web worker, presenter, and some other necessary details to create a fully functioning program node.

You can create your contribution using any library that supports creating a standards-compliant single-file Javascript WebComponent. We provide some additional tooling to assist with creating WebComponents using Angular Elements, and using this framework also allows you to reach smaller component sizes by sharing the framework dependencies.

In this document we will illustrate how to interact with the API, using either an Angular Component that is converted to a WebComponent using Angular Elements, or with a standard HTML5 WebComponent.

For the full list of available events and properties, please refer to the WebComponents API Reference and the typescript-api-reference included. Here you will find type information for the various properties and event data, as well as a description of the available events and properties.

A contribution must be defined in the ‘manifest.yaml’ file and all contributed nodes must be defined in the ‘contribution.json’ file, see How to configure, package and install a contribution.

Interacting with the API using Angular Components

An option for writing WebComponents is to use a framework to write the code and generate standards-compliant WebComponents. Below is an example of the necessary typescript files to use Angular Elements to write a WebComponent, and how to wire-up inputs and outputs to and from the WebComponent. Not all imports will be shown in the example files, but the API specific imports will be.

Create the structure of an example node. Here is a ScriptNode

script.node.ts

// imports
//API Imports
import { ProgramNode } from '@universal-robots/contribution-api';
// This is the structure of a ScriptNode. It includes a "type" property and one "parameter" that is "expressionString" of type string
export interface ScriptNode extends ProgramNode {
    type: 'ur-script';
    parameters: {
        expressionString: string
    };
}

Create a Worker for your component

script.behavior.worker.ts

// imports
//API Imports

import {
    ProgramBehaviors,
    registerProgramBehavior,
    ScriptBuilder,
    ValidationResponse,
} from '@universal-robots/contribution-api';
import { ScriptNode } from 'path/to/script.node';
const behaviors: ProgramBehaviors ={

    //The factory creates an instance of a ScriptNode with default values
    factory: () => {
    return  {
               type: 'ur-script',
               version: '1.0.0',
               parameters: {
                   expressionString: 'stringExpression'
               }
            } as ScriptNode;
    },
    //The programNodeLabel creates a label for the node in the program tree
    programNodeLabel: (node: ScriptNode): string => {
        return `Script`;
    },
    //Validates the input ScriptNode
    validator: (node: ScriptNode): ValidationResponse => {
        if (!node.parameters || !node.parameters.expressionString) {
            return { isValid: false, errorMessageKey: 'Script expression must not be null' };
        }
        return { isValid: true };
    },
    generateCodeBeforeChildren: generateCode,
    upgradeNode: (loadedNode: ProgramNode): ProgramNode => {
        // Code to update node from older version(s)
    },
};

function generateCode(node: ScriptNode): ScriptBuilder {
    const builder = new ScriptBuilder();
    if (node.parameters.expressionString) {
        builder.addRaw(node.parameters.expressionString);
    }
    return builder;
}

registerProgramBehavior(behaviors);

script.component.ts

//imports
//API Imports
import { ScriptNode } from 'path/to/script.node';
import { ProgramPresenter } from '@universal-robots/contribution-api';
@Component({
    templateUrl: './script.component.html',
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class ScriptComponent implements ProgramPresenter {
    // Define the properties from the API that you care about using @Input decorators
    @Input() contributedNode: ScriptNode;

    // API used to update the contributed node
    @Input() presenterAPI: ProgramPresenterAPI;

    constructor() {
    }

    setScriptExpression(value: string) {
        this.contributedNode.parameters.scriptExpression = value;
        this.saveNode();
    }

    async saveNode() {
        this.cd.detectChanges();
        await this.presenterAPI.programNodeService.updateNode(this.contributedNode);
    }

}

script.component.html

<div *ngIf="contributedNode" class="component">
    <div class="section">
        <ur-text-input
           (valueChanged)="setScriptExpression($event)"
           [label]="'presenter.script.label.expressionString' | translate"
           [value]="contributedNode.parameters.expressionString"
        >
        </ur-text-input>
    </div>
</div>

The “‘presenter.script.label.expressionString’ | translate” value for the label is an example of how to use the translation key files en.json and ur.json in the assets/i18n folder. The ‘ur.json’ file will be replaced with files for other languages. A program node will need to have a key under ‘program.tree.nodes’ with the name of its component like ‘program.tree.nodes.script-angular-component’ and a value for the node title like ‘Script Node’. An application contribution has the same requirement of a translation key but under ‘application.nodes’, like ‘application.nodes.script-angular-component’. This has the added requirement of having to contain ‘title’ and ‘supportiveText’ properties, where ‘title’ is the title of the application node when presented, and ‘supportiveText’ is a further description, shown in the applications page on the node’s button.

assets/i18n/en.json

{
  "program": {
    "tree": {
      "nodes": {
        "script-angular-component": "Script Node"
      }
    }
  },
  "application": {
    "nodes": {
      "script-angular-app-component": {
        "title": "Script Application Node",
        "supportiveText": "Application node for scripting"
      }
    }
  },
  "presenter": {
    "script": {
      "label": {
        "expressionString": "Expression:"
      }
    }
  }
}

Create and register the component as a WebComponent in the Custom Element Registry

app.module.ts

// imports
import { ScriptComponent } from 'path/to/script.component';
import { PATH } from '../generated/contribution-constants';

@NgModule({
    declarations: [
        ScriptComponent
    ],
    entryComponents: [
        ScriptComponent
    ]
})
export class AppModule implements DoBootstrap{
    constructor(private injector: Injector){
    }

    ngDoBootstrap() {
        const exampleNodeComponent = createCustomElement(ScriptComponent, {injector: this.injector});
        customElements.define('script-angular-component', ScriptComponent);
    }

// This function is never called,  because we don't want to actually use the workers, just tell webpack about them
    registerWorkersWithWebPack() {
        new Worker('./path/to/script.behavior.worker.ts', { name: 'script', type: 'module' });
    }

    export const httpLoaderFactory = (http: HttpBackend) =>
        new MultiTranslateHttpLoader(http, [
            { prefix: PATH + '/assets/i18n/', suffix: '.json' },
            { prefix: './ui/assets/i18n/', suffix: '.json' },
        ]);
}

The function ‘HttpLoaderFactory’ handles translations in an Angular contribution. The path /assets/i18n/ that designates the translation json files used directly by the contribution is defined by the information provided in the ‘manifest.yaml’ file: see How to configure, package and install a contribution. The additional translation path ./ui/assets/i18n/ designates translation json files needed by the UI library. When using components from the UI library this path should always be included.

The imported PATH variable is from a file automatically generated during the build, based on manifest.yaml. For the following manifest.yaml manifest.yaml

metadata:
  vendorID: my-company
  vendorName: "My Company"
  urcapID: my-urcap
  urcapName: "My URCap"
  version: 1.0.0
artifacts:
  webArchives:
    - id: my-urcap
      folder: my-urcap

This could look like the following, where the last part of the PATH is the name of the web-archive. contribution-constants.ts

// AUTO GENERATED - DO NOT EDIT
export const VENDOR_ID = 'my-company';
export const URCAP_ID = 'my-urcap';
export const URCAP_VERSION = '1.0.0';
export const URCAP_ROS_NAMESPACE = 'Vendor_my-company/URCap_myurcap';
export const PATH = `/${VENDOR_ID}/${URCAP_ID}/my-urcap`;

In the angular.json file you need to add a property in the projects->”project name”->architect->build->options and projects->”project name”->architect->test->options properties. This is to help connect the web-worker and the webpack.

angular.json

 "projects": {
        "web-program-nodes": {
            "architect": {
                "build": {
                    "options": {
                        "webWorkerTsConfig": "tsconfig.worker.json"
                    },

                },
                "test": {
                    "options": {
                        "webWorkerTsConfig": "tsconfig.worker.json"
                    }
                },

In the tsconfig.worker.json you also need to include the path to the web-workers so that the webpack can find and correctly configure them.

tsconfig.worker.json

{
    "extends": "./tsconfig.json",
    "compilerOptions": {
        "outDir": "./out-tsc/worker",
        "lib": ["es2018", "webworker"],
        "types": []
    },
    "include": ["src/**/*.worker.ts"]
}

Interacting with the API using an HTML5 WebComponent

If you do not wish to use a framework to write your WebComponents, you can do it using standard HTML5 WebComponents. Below is an example, showing how to get input using attributes, and provide output from the component using events.

scriptnode.behavior.worker.js

// this webworker.js uses webpack to be able to use the UR contribution api
const api = require('@universal-robots/contribution-api');
const {ScriptBuilder} = require("@universal-robots/contribution-api");

const programBehavior = {
    factory: () => {
        return {
            type: 'my-sample-programnode',
            version: '1.0.0',
            allowsChildren: false,                 // false means this program node can not have child nodes
            parameters: {
                expressionString: ''
            }
        };
    },
    programNodeLabel(node) {
        return 'My Node';
    },
    generateCodeBeforeChildren: async (node, context) => {
        // use ScriptBuilder to generate your custom node urscript code
    },
    upgradeNode: (loadedNode) => {
        // Code to update node from older version(s)
    },    
};
api.registerProgramBehavior(programBehavior);

scriptnode.js

// Custom WebComponent
class ScriptHTML5Component extends HTMLElement {
    constructor(){
        super();
        this._contributedNode = null;
        this._expressionString = null;
    }

    // Provide a setter for the properties you care about from the API
    set contributedNode(value) {
        this._contributedNode = value;
    }

    get contributedNode() {
        return this._contributedNode;
    }


    connectedCallback() {
        // Define the HTML for the Component
        this.innerHTML = `
            <div style="margin-bottom: 1rem;min-height: 22px;white-space: nowrap;">
                <ur-input-field id='expression' type="text" label="Expression:"></ur-input-field>
            </div>
        `;
        // Update the HTML with the expression string
        this._expressionField = this._root.querySelector('#expression');
        this._expressionField.value = this.contributedNode.parameters.expressionString;
    }
}

window.customElements.define('script-html5-component', ScriptHTML5Component);

Note that “field.value = value” is used to set a value on a field element and not “field.setAttribute(‘value’, value)”. Also note that when using JavaScript then translations must be handled as i18n support is not built-in.

Updating Program Nodes

Special care should be taken with the upgradeNode function in the web worker. This is used for updating program-nodes when loading a program. Consider the following update to the program node, in the gripdistance sample, converting the closedDistance and openDistance properties into one distances object:

gripdistance-program.node.ts v. 1

export interface GripDistanceProgramNode extends ProgramNode {
    type: 'ur-sample-gripdistance-program';
    parameters: {
        closedDistance: Length;
        openDistance: Length;
        gripperToggle: boolean;
    };
}

gripdistance-program.node.ts v.2

export interface GripDistanceProgramNode extends ProgramNode {
    type: 'ur-sample-gripdistance-program';
    parameters: {
        distances: {open: Length, closed: Length};
        gripperToggle: boolean;
    };
}

In this case, it is then necessary to implement the upgradeNode function to be able to load programs containing gripdistance-program.node.ts v. 1 instances. If not, those programs will load with the default values for the new properties, potentially altering program behaviour.

gripdistance-program.behavior.worker.ts

    // snip
    const createGripDistanceProgramNode = (): GripDistanceProgramNode => ({
        type: 'ur-sample-gripdistance-program',
        version: '2.0.0',
        allowsChildren: false,
        parameters: {
            distances: {open: { value: 300, unit: 'mm' }, closed: { value: 0, unit: 'mm' }},
            gripperToggle: false
        },
    });
    // snip
    const upgradeNode = (loadedNode: ProgramNode): ProgramNode => {
        if (loadedNode.version === '1.0.0') {
            const newNode = createGripDistanceProgramNode();
            newNode.parameters.distances = {open: loadedNode.parameters.openDistance, closed: loadedNode.parameters.closedDistance};
            newNode.parameters.gripperToggle = loadedNode.parameters.gripperToggle;
            return newNode;
        }
        return loadedNode;
    };
    // snip 

Additional Resources

HTML5 Web Components (MDN)

Angular Elements

ngx-build-plus