Here is the steps I took for a working solution:
1. Correct Registry
Make sure you install the perspective npm packages from the correct registry. Add this to your .npmrc
file. Either create one in your local repository or in the home directory for your user.
.npmrc file
@inductiveautomation:registry=https://nexus.inductiveautomation.com/repository/node-packages/
2. Install dependencies
Run the following command to install the client and designer npm package. NB DO NOT SKIP adding version numbers when installing. In my experience, the repository will respond with not found
if a version number is absent. Since I am running ignition 8.1.33
I downloaded the perspective client and designer 2.1.33
. In other words, the patch and minor version should be equal to the ignition version, while the major version is 2 (at least for the newer version of ignition).
npm install @inductiveautomation/perspective-client@2.1.33 @inductiveautomation/perspective-designer@2.1.33`
3. Create regular component
Create your component and component meta. The component can be a react class or function component.
import {ComponentRegistry, PComponent, ComponentMeta, PropertyTree} from '@inductiveautomation/perspective-client`
// Create the meta class
class MyContainerMeta implements ComponentMeta {
isContainer = true; // Make sure to mark it as a container component for later
isDeepSelectable = true; // Also make it deep selectable in the designer
getComponentType() {
// Give it a unique "id"
return "com.yourcompany.yourlibraryname.mycontainercomponent"
}
getViewComponent() {
return MyContainerComponent;
}
getDefaultSize() {
/*
* This is required and will be added to your component
* when your do {...props.emit()}. I have chosen to avoid
* setting a default width as you can see in the code in the
* component bellow.
*/
return {
width: 360,
height: 360
};
}
getPropsReducer(propertyTree: PropertyTree) {
/*
* Resolve your custom props (that you will define in json later)
* by using the functions readString, readNumber, readArray etc..
* on the propertyTree
*/
return {
direction: propertyTree.readString("direction", "column"),
spacing: propertyTree.readNumber("spacing", 0)
}
}
}
// Function component
const MyContainerCompnent: PComponent = (props) => {
/* I am here using destructuring to remove the style prop
* from the emmited props. The style prop comes with ignition
* default styles like default height and width.
* I do not want those on my components.
*
* You can do:
* <div {...props.emit()} />
* directly in your jsx/tsx component if you want to keep them.
*/
const { style, ...emittedProps } = props.emit();
return (
// Your component
<div {...emittedProps} {...props.props}> // spread emitted props and your custom props that live under props.props
</ div>
);
}
// You should register your component meta with the component registry:
ComponentRegistry.register(MyContainerComponentMeta());
4. Define custom props
Now you have a regular non-container component. Before we add the container specialties, we'll create the props.json
file for defining what props can be passed to our component from the designer. This file should live in the resources folder in the java common scope. There is multiple examples of these custom props here.
{
"type": "object",
"additionalProperties": false,
"required": [
"direction",
"spacing"
],
"properties": {
"direction": {
"type": "string",
"enum": [
"row",
"column"
],
"description": "Direction of the stack component",
"default": "row"
},
"spacing": {
"type": "number",
"description": "Spacing between children. Number will be converted with the themes spacing scale",
"default": 0
}
}
}
5. Compile frontend tsx/jsx
There are described methods of compiling react code with gradle other places on the forum and in the ignition sdk examples repository. We are using maven and found that making our own build system works best for now. However you do it, make sure that the final js file(s) are located in the gateway resource folder, we prefer a single file at /resources/mounted/js/index.js
.
6. Register your component
There are multiple examples of how to register your component and mount your js in the ignition sdk examples repository. We don't need to do anything special for container components in this part.
6. Create DesignDelegate
To make our component a container, we need to add behaviour for when to add child components. This can be done with adding and registering a DesignDelegate from the perspective-designer
npm package.
class MyContainerComponentDesignDelegate implements ContainerDesignDelegate {
// Use the exact same component id as earlier in the component
type: string = "com.yourcompany.yourlibrary.mycontainercomponent";
isContainer = true; // Mark it as a container
// Implement addNewComponents like this
addNewComponents(
compDefs: Perspective.ComponentDefinition[],
selection: SelectionStore,
dropContainer: DesignerComponentStore,
preferredLocation?: PreferredLocation | undefined
): Perspective.ComponentInstanceDef[] {
return compDefs.map((compDef) => {
return {
component: compDef,
addressPath: dropContainer._addComponent(compDef)
};
});
}
}
/*
* Finally, register the DesignDelegate only if the PerspectiveDesigner global
* exists in the browser context. This will only exist in an Ignition designer and
* not when the code is run in a browser.
* You will get an error if you don't do this.
*/
if (window.hasOwnProperty("PerspectiveDesigner")) {
InteractionRegistry.registerInteractionDelegates(new StackContainerDesignDelegate())
}
7. Display child components
Your container will now work as a conainer in the designer, but all the components you add to it will not show up. That's because you need to render them within your component. This is how you can do it:
<div {...emittedProps} {...props.props}>
{props.store.children && props.store.children.length === 0 && (
<div>
{/* fallback content if the container is empty (optional) */}
<p>Drop components here</p>
</div>
)}
{/* Loop over all children */}
{props.store.children.map((componentStore, index) => {
// Get the react component
const Component = componentStore.getComponent()
/*
* Not quite sure how this LayoutBuilder works, but
* we currently just return an empty object to not alter
* any layout styles.
*/
const buildLayout: LayoutBuilder = () => ({})
const layout = layoutCallbackCreator.forStyle(buildLayout);
return (
{/* Render the component with the layout and key prop */}
<Component key={index} layout={layout} />
);
})}
</div>
Congratulations, You have now successfully created a container component!
Feel free to ask me if anything is unclear. I'll leave you with the full finished tsx file:
import React from "react";
import Perspective, {
ComponentMeta,
ComponentRegistry,
PComponent,
PropertyTree,
ComponentStoreDelegate,
AbstractUIElementStore, LayoutBuilder, layoutCallbackCreator
} from '@inductiveautomation/perspective-client';
import {
ContainerDesignDelegate,
DesignerComponentStore,
InteractionRegistry,
PreferredLocation,
SelectionStore
} from "@inductiveautomation/perspective-designer";
class MyContainerMeta implements ComponentMeta {
isContainer = true;
isDeepSelectable = true;
getComponentType() {
return "com.yourcompany.yourlibrary.mycontainercomponent";
}
getViewComponent() {
return MyContainerComponent;
}
getDefaultSize() {
return ({
width: 360,
height: 360
});
}
getPropsReducer(propertyTree: PropertyTree) {
return {
direction: propertyTree.readString("direction", "column"),
spacing: propertyTree.readNumber("spacing", 0)
}
}
}
const MyContainerComponent: PComponent = (props) => {
const { style, ...emittedProps } = props.emit();
return (
<div {...emittedProps} {...props.props}>
{props.store.children && props.store.children.length === 0 && (
<div>
<p>Drop components here</p>
</div>
)}
{props.store.children.map((componentStore, index) => {
const Component = componentStore.getComponent()
const buildLayout: LayoutBuilder = () => ({})
const layout = layoutCallbackCreator.forStyle(buildLayout);
return (
<Component key={index} layout={layout} />
);
})}
</div>
);
}
ComponentRegistry.register(new MyContainerComponent());
class MyContainerComponentDesignDelegate implements ContainerDesignDelegate {
type: string = "com.yourcompany.yourlibrary.mycontainercomponent";
isContainer = true;
addNewComponents(
compDefs: Perspective.ComponentDefinition[],
selection: SelectionStore,
dropContainer: DesignerComponentStore,
preferredLocation?: PreferredLocation | undefined
): Perspective.ComponentInstanceDef[] {
return compDefs.map((compDef) => {
return {
component: compDef,
addressPath: dropContainer._addComponent(compDef)
};
});
}
}
if (window.hasOwnProperty("PerspectiveDesigner")) {
InteractionRegistry.registerInteractionDelegates(new MyContainerComponentDesignDelegate())
}
NB: Be careful copying this code. I have changed some names and handwritten some code in the forum editor. Chances is that I have made some typos along the way. Also, please read the comments in the code. They may be important.