Perspective Custom Container

Hi, I’ve been looking through the SDK documentation and examples, and have been developing perspective modules for some time. We’ve gotten to the point where we’d like to create our own perspective container to handle client-side events like zooming and mouse dragging. How can we create a custom container? The examples and documentation don’t seem to cover this topic.

Thanks,
Dillon

2 Likes

Hi
Are you trying to create cluttering and decluttering effect?

As a starting point, we’re trying to create a custom container for pan and zoom controls. Eventually we hope to create a custom container to optimize certain groupings of custom components, while still taking advantage of Ignition Designer’s tools (so not storing the components as a property list).

As far as I can tell from a quick skim of the implementation, containers just are components. The main thing you have to do is, in the designer scope, register a ContainerDesignDelegate (on the TS side) with the InteractionRegistry - then your component is in charge of implementing addNewComponents.

3 Likes

Thanks for the response! So would that ContainerDesignDelegate be registered in the web\packages\designer\typescript\* context? Do we need to implement anything outside of the web folder?

Do I need to mess with the properties in ComponentMeta on the client side? I noticed there is a isContainer.

Any publically available examples?

Thanks again!

I’ll delegate here, since I’m well out of my depth :slight_smile:
@ynejati @PerryAJ

We think it’s great that you’re experimenting with building your own container component. This is arguably one of the most challenging things you can do in an Ignition module. We do not have any publicly available examples at the moment, nor do we have any current plans to release any due to the complexity and time requirements of the task. However, we will gladly try and provide some useful information that may help.

Paul is correct, the main thing you need to do is to register your container component with the InteractionRegistry. The container is merely a component, but a component with a special design implementation as well as rules of interaction and layout.

Typically you will have:

  1. A Designer version of your container component (DesignerCustomContainer)
  2. A design delegate (CustomContainerDesignDelegate extends ContainerDesignDelegate) which implements designer type behaviors and interactions. You will want to set the isContainer member as true.
  3. Your delegate registered with the InteractionRegistry (InteractionRegistry.registerInteractionDelegates)
  4. isContainer = true in ComponentMeta set to true to aid in some Designer selection behavior to say the least.

Hopefully that helps.

-Yousuf

3 Likes

Hi @dillonu did you ever make progress on this? I’m looking at implementing pan and zoom functionality natively in Perspective and have a working demo EXCEPT for the fact that the event onMouseUp doesn’t fire correctly about 5% of the time and its a bug that makes me want to rip my hair out.

We never managed to make our own container, but we created a couple of components that make the parent container pan and zoom. It’s a bit hacky (and probably messes up a few event handlers within Ignition that we don’t use), but works for our use case.
We do sometimes have issues with onMouseUp when swapping applications, which we use onBlur and other window-events to handle.

It sounds like there’s a ticket in progress for a pan and zoom container, but I believe it’s been in that state since April '22. I wish this was available right now - I have a project that’s about to close that uses the pan and zoom container that doesn’t work 100% of the time and I wish I could replace it with a native Perspective component.
@ynejati Has there been progress made since the specification and prototyping phase (April 2022)?

I tried to reverse-engineer this a bit. As of now I am trying to make a similar container to the flex-container. I have managed to implement and register it as a regular component, and I have created a custom ContainerDesignDelegate implementation and registered with the ComponentDesignDelegateRegistry in the DesignerHook.java. This works and I am able to customize the Perspective Property Editor.

Where I struggle, is with the client side. I can't find any examples of how the react implementaiton of this would look like. It does not seem the @inductiveautomation/perspective-client package is public so i am importing all components blindly from the window object and guessing their implementations.

Here is my client implementation. I have added some comments with the issues I am facing and where I am unsure of what to do. The actual react component implementation is from MUI. This is supposed to be a perspective wrapper around it:

import React from "react";
import Stack from "@mui/material/Stack";

// @ts-ignore
const  {Component, ContainerDesignDelegate, ComponentRegistry, InteractionRegistry } = window.PerspectiveClient;


class StackMeta {

    getComponentType() {
        return "com.example.mylibrary.stack";
    }
    getViewComponent() {
        return MyStack;
    }

    isContainer() {
        return true;
    }

    getDefaultSize() {
        return ({
            width: 360,
            height: 360
        });
    }

    getPropsReducer(tree: any) {
        // This is my own reverse engineered way of getting the props. Probably not the ideal way.

        const propsMap = tree.root.value.value;
        const props: Record<string, any> = {};

        for (const [key, value] of propsMap.entries()) {
            props[key] = value.value.value;
        }

        return props;
    }
}

// ContainerDesignDelegate is undefined here
class StackContainerDesignDelegate extends ContainerDesignDelegate {

    addNewComponents() {
        // How do i implement this?
        // Am i supposed to handle the mounting and unmounting of the components myself?
    }
}

class MyStack extends Component {
    private props: any;

    render() {
        const { children, ...props } = this.props;

        return (
            <Stack {...props}>
                {/* It would be natural to me that the child components will be on the children prop. Is this the case? */}
                {children}
            </Stack>
        );
    }
}

ComponentRegistry.register(new StackMeta());

// InteractionRegistry is undefined here
InteractionRegistry.registerInteractionDelegates("com.example.mylibrary.stack", new StackContainerDesignDelegate());

Would appreciate any guidence

This will be delivered a PropertyTree and is expected to return your container's property shape, e.g.:

    getPropsReducer(tree: PropertyTree): ColumnContainerProps {
        return {
            breakpoints: tree.readArray("breakpoints", []),
            gutters: tree.readObject("gutters", {})
        };
    }

The signature is:

addNewComponents(compDefs: Array<ComponentDefinition>, selection: SelectionStore, dropContainer: DesignerComponentStore, preferredLoc?: PreferredLocation): Array<ComponentInstanceDef> {

ComponentInstanceDef relies on you using the (supposedly non-public) _addComponent API directly on dropContainer:

            return {
                component: compDef,
                addressPath: dropContainer._addComponent(compDef)
            };

I don't see any way around that, and all of our components do it, so as long as you're comfortable with that relatively minor risk you can use the same approach I think.

The children are available at this.props.store.children;, as far as I can tell. This will be an array of ComponentStore, automatically? ComponentStore has a getComponent() method that returns the actual React component, and you can render that however you want.

2 Likes

Thanks for the reply :slight_smile:

I managed to find and install the @inductiveautomation/perspective-client library. Now I have the typescript models to go after. I still cant find the InteractionRegistry or the ContainerDesignDelegate in the typescript library. I am using version 2.1.3 Has this changed since the original post in this thread 2 years ago?

I think the designer stuff might be in a different top level library. Possibly as simple as perspective-designer?

Thank you! I I found the perspective-designer npm package.

I am now rendering the components like I show bellow. Everyting now works when in the browser. However, when working with the component inside of the designer, I get an undefined error.

I have added my own custom error boundary that catches the error. It seems to happen with every single component that is a child of my container. I believe I might be missing a prop that the <Component /> expects?

How I render the component:

{props.store.children.map((componentStore, index) => {
                const Component = componentStore.getComponent()

                return (
                     <MyErrorBoundary key={index}>
                         <Component />
                     </MyErrorBoundary>
                );
})}

The error message:

TypeError: Cannot read properties of undefined (reading \'style\')

Stack trace from the perspective console (when removing the custom error boundary):

10:09:57.847 [Browser Thread: 52c5f2e0-9750-42de-99a9-89b5a66bb0f9] ERROR Perspective.Designer.Workspace - level: LEVEL_ERROR
message: "ui.ErrorBoundary: Component error caught in error boundary: {\"componentStack\":\"
    at <anonymous> (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:139938)\\n    at div\\n    at http://localhost:8088/res/boostkit/js/index.js:2969:23
   at Grid (http://localhost:8088/res/boostkit/js/index.js:6139:24)
    at BoostStack (http://localhost:8088/res/boostkit/js/index.js:9021:22)
    at u (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:713329)
    at n (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:514924)
    at x (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:165760)
    at <anonymous> (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:139938)
    at div
    at u (http://localhost:8088/res/perspective/js/PerspectiveDesignerComponents.0bb8d494d9684c2484bc.js:2:152059)
    at u (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:713329)
    at n (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:514924)
    at x (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:165760)
    at <anonymous> (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:139938)
    at q (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:376303)
    at DefaultPropsProvider (http://localhost:8088/res/boostkit/js/index.js:5076:3)
    at RtlProvider (http://localhost:8088/res/boostkit/js/index.js:5950:7)
    at ThemeProvider$3 (http://localhost:8088/res/boostkit/js/index.js:5929:5)
    at ThemeProvider$2 (http://localhost:8088/res/boostkit/js/index.js:5993:5)
    at ThemeProvider$1 (http://localhost:8088/res/boostkit/js/index.js:6177:14)
    at BoostKitThemeProvider (http://localhost:8088/res/boostkit/js/index.js:8947:34)
    at ThemeProvider (http://localhost:8088/res/boostkit/js/index.js:8978:1)
    at u (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:713329)
    at n (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:514924)
    at x (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:165760)
    at <anonymous> (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:139938)
    at div\\n    at div
    at g (http://localhost:8088/res/perspective/js/PerspectiveDesignerComponents.0bb8d494d9684c2484bc.js:2:120615)
    at u (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:713329)
    at n (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:514924)
    at x (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:165760)
    at <anonymous> (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:139938)
    at q (http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js:2:376303)
    at div\\n    at t (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:46733)
    at div\\n    at P (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:306018)
    at div\\n    at o (http://localhost:8088/res/perspective/js/PerspectiveDesigner.5f4bf915c726742fd4fe.js:2:72542)\"}"
line_number: 2
source: "http://localhost:8088/res/perspective/js/PerspectiveClient.48795d90aca914295681.js"

It is difficult to debug this as the error happens outside of my code and in the designer where I can't set breakpoints. I noticed that the debug session in the tools menu disconnects upon opening it. If I could get that working it might be easier to debug. If you know of anything that might help, it is greatly appreciated.

I'm officially out of my depth, but it looks like you might be missing this layoutCallbackCreator reference?

const ComponentClass: any = child.getComponent();

const buildLayout: LayoutBuilder = () => {
    if (display) {
        return {};
    } else {
        return {
            display: "none"
        };
    }
};
const layout = layoutCallbackCreator.forStyle(buildLayout);

return <ComponentClass key={ `component-${index}` } layout={layout} />;

That solved it! Thank you for your patience and help!

1 Like

Could you share the working codes:)

1 Like

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.

5 Likes

Where are you registering the Container Design Delate? Inside of the client or designer workspace?