Pointers on React Component Designer Implementation

Hi, so I'm developing a perspective react component, and was wondering if I could get some pointers on how to implement the 'designer component implementation'.

Currently, I have a react diagram as a perspective component, and it initially starts with 4 nodes. In the image for example, if I add a node, the component's internal props updated, but the props in the designer are not updated.

As shown in the screenshot, the printing of the component's props shows that it's storing 5 nodes, but the designer's props did not update. Another tidbit is that whenever I stop the play button, the diagram goes back to the initial 4 nodes. This is where I assume I need to flesh out the 'designer component implementation'. because the designer isn't remembering any of these changes persistently, nor is anything able to write to it.

So back to my question, what should I be looking at here, on doing this 'designer component implementation'? I'm just not sure at all what's expected here.

Can you post your current component implementation?

It sounds like you’re not properly writing to the property tree.

2 Likes

The 'Designer Component' concept is just to allow you to render a 'live' editing UI for your component when the Designer is in 'edit' mode vs 'runtime/preview' mode - for a very simple example, consider the Audio Player component, which renders in 'design' mode but not in 'preview' mode by default.

1 Like

it is more than likely you are attempting to write the NodeItemProps or whatever the interface is for it straight up with the Object.

although you might put in your props.json that the type is object it doesn't accept that. You have to write a QualifiedValue to it.

this.props.store.props.write("schedules",new QualifiedValue(newSchedules));

Here is an example of me updating a list of schedules in our scheduler component. You would need to write over the entire Array<NodeItemProps>.

here is an example of just an object that gets pushed to the prop tree in a function:

  @bind
  selectSchedule(schedule:ScheduleDefinition){
      if (schedule === null){
        this.props.store.props.write("selectedSchedule",null);  
      }else{
      this.props.store.props.write("selectedSchedule",new QualifiedValue(schedule));
      }
  }
2 Likes

That's 100% not happening, yeah. The code's gotten a little messy at this point, but I tried doing props.store.props.write(), which doesn't seem to work. the type of the property that I'm trying to write to is a pretty complex object, it's structured like this:

image

I've tried both passing in an updated value directly, and as a QualifiedValue, neither of which seem to work.

Wrapping it as a QualifiedValue isn't working for me, any ideas on what else could be wrong?

can you share the code?

import * as React from 'react';
import { useState, useEffect, RefObject } from "react";
import {
    AbstractUIElementStore,
    // Component,
    ComponentMeta,
    ComponentProps,
    ComponentStoreDelegate,
    makeLogger,
    PComponent,
    PropertyTree,
    SizeObject,
    JsObject,
    ReactResizeDetector,
    QualifiedValue
} from '@inductiveautomation/perspective-client';
import { bind } from 'bind-decorator';
// const objectScan = require('object-scan');
// const cleanDeep = require('clean-deep');
import Diagram, { useSchema, createSchema } from 'beautiful-react-diagrams';
import { NodeCoordinates } from 'beautiful-react-diagrams/@types/DiagramSchema';
// import { SchemaType } from 'beautiful-react-diagrams/shared/Types';
// import Diagram from 'beautiful-react-diagrams';

export const COMPONENT_TYPE = "rad.display.diagram";

const logger = makeLogger(COMPONENT_TYPE);

export interface KanoaDiagramProps {
    diagramSchema: any;
    test: string;
    onChange?: any;
}

export class KanoaDiagramGatewayDelegate extends ComponentStoreDelegate {
    /**
     * Value that is set when a message should be pending.
     */
    private diagram: typeof Diagram | null = null;
    
    constructor(componentStore: AbstractUIElementStore) {
        // Required initialization of super.
        super(componentStore);
    }

    @bind
    init(diagram: typeof Diagram) {
        if (diagram) {
            this.diagram = diagram;
        }
    }

    @bind
    doSomething() {
        this.diagram;
    }
    
    /**
     * Maps our delegate state to component props. Invoked by the components HOC
     * Wrapper component in the ComponentStore and passed to the Perspective 
     * component in props, i.e. `this.props.props.delegate`.  Will do so
     * whenever this delegate notifies listeners of state changes.
     */

    public fireDelegateEvent(eventName: string, eventObject: JsObject): void {
        // log messages left intentionally
        logger.info(() => `Firing ${eventName} event with message body ${eventObject}`);
    }

    // Used by our component to fire a message to the gateway.

    /**
     * Implements `handleEvent` of abstract class ComponentStoreDelegate.
     * 
     * Will automatically be invoked whenever a message is sent down from the corresponding delegate 
     * on the backend. Here we map messages and their payloads to the appropriate handlers.
     */
    handleEvent(eventName: string, eventObject: JsObject): void {
        logger.info(() => `Received '${eventName}' event!`);
    }

    handleComponentResponseEvent(eventObject: JsObject): void {
        logger.info(() => `Callback handling message with contents: ${JSON.stringify(eventObject)}`);
    }
}

export function KanoaDiagram(props: ComponentProps<KanoaDiagramProps>) {

    const diagramRef: RefObject<HTMLDivElement> = React.createRef();
    const initialSchema = createSchema({
        nodes: [
            { id: 'node-1', content: 'Node 1', coordinates: [250, 60], },
            { id: 'node-2', content: 'Node 2', coordinates: [100, 200], },
            { id: 'node-3', content: 'Node 3', coordinates: [250, 220], },
            { id: 'node-4', content: 'Node 4', coordinates: [400, 200], },
        ],
        links: [
            { input: 'node-1',  output: 'node-2' },
            { input: 'node-1',  output: 'node-3' },
            { input: 'node-1',  output: 'node-4' },
        ]
    });
    props.store.props.write('diagramSchema', new QualifiedValue(initialSchema));
    const [schema, { addNode, removeNode, onChange }] = useSchema(initialSchema);
    const [diagramSchema, setDiagramSchema] = useState(initialSchema);
    
    useEffect(() => {
        console.log("print 1: ", props.store.props.readObject("diagramSchema"))
        console.log("print 2: ", props.props.diagramSchema);
        props.store.props.write('diagramSchema', new QualifiedValue(diagramSchema));
        props.store.props.write('test', 'successful PropertyTree write');
        console.log("Node added");
        console.log("print 3: ", props.store.props.readObject("diagramSchema"))
        console.log("print 4: ",props.props.diagramSchema);
    }, [diagramSchema])

    const CustomRender = ({ id, content, data, inputs, outputs }) => (
        <div>
            <div role="button" style={{padding: '15px'}}>
            {content}
            </div>
            <div style={{marginTop: '10px',display:'flex', justifyContent:'space-between'}}>
            {inputs.map((port) => React.cloneElement(port))}
            {outputs.map((port) => React.cloneElement(port))}
            </div>
        </div>
    );
    
    const UncontrolledDiagram = () => {

        const deleteNodeFromSchema = (id) => {
            const nodeToRemove = schema.nodes.find(node => node.id === id)!;
            // removeNode(nodeToRemove);
            const updatedSchema = removeNode(nodeToRemove)
            console.log(updatedSchema);
            console.log(schema)
            props.store.props.write('diagramSchema', new QualifiedValue(schema));
            props.store.props.write('diagramSchema', schema);
            setDiagramSchema(schema);
            props.store.props.write('diagramSchema', new QualifiedValue(diagramSchema));
        };
        
        const addNewNode = () => {
            const coord: NodeCoordinates = [
                schema.nodes[schema.nodes.length - 1].coordinates[0] + 100,
                schema.nodes[schema.nodes.length - 1].coordinates[1],
            ];
            const nextNode = {
                id: `node-${schema.nodes.length+1}`,
                content: `Node ${schema.nodes.length+1}`,
                coordinates: coord,
                render: CustomRender,
                className: 'bi-diagram-node-default',
                data: {onClick: deleteNodeFromSchema},
                inputs: [{ id: `port-${Math.random()}`}],
                outputs: [{ id: `port-${Math.random()}`}],
            }!;
            
            // addNode(nextNode);
            addNode(nextNode);
            setDiagramSchema(schema);
        };

        return (
            <div style={{ height: '100%' }}>
                <button onClick={addNewNode}>Add new node</button>
                <Diagram schema={diagramSchema} onChange={onChange} />
            </div>
        );
    };
    
    return (
        <div {...props.emit()}>
            <div ref={diagramRef} />
            <div>{JSON.stringify(diagramSchema)}</div>
            <UncontrolledDiagram />
            <ReactResizeDetector
                    handleHeight={ true }
                    handleWidth={ true }
                    refreshMode="debounce"
                />
        </div>
    );
}

// export class KanoaDiagram extends Component<ComponentProps<KanoaDiagramProps>, any> {

//     private diagramRef: RefObject<HTMLDivElement> = React.createRef();
    

//     render() {
//         const { props, emit } = this.props;
//         const initialSchema = createSchema({
//             nodes: [
//               { id: 'node-1', content: 'Node 1', coordinates: [250, 60], },
//               { id: 'node-2', content: 'Node 2', coordinates: [100, 200], },
//               { id: 'node-3', content: 'Node 3', coordinates: [250, 220], },
//               { id: 'node-4', content: 'Node 4', coordinates: [400, 200], },
//             ],
//             links: [
//               { input: 'node-1',  output: 'node-2' },
//               { input: 'node-1',  output: 'node-3' },
//               { input: 'node-1',  output: 'node-4' },
//             ]
//         });
//         props.schema = initialSchema;

//         const CustomRender = ({ id, content, data, inputs, outputs }) => (
//             <div>
//               <div role="button" style={{padding: '15px'}}>
//                 {content}
//               </div>
//               <div style={{marginTop: '10px',display:'flex', justifyContent:'space-between'}}>
//                 {inputs.map((port) => React.cloneElement(port))}
//                 {outputs.map((port) => React.cloneElement(port))}
//               </div>
//             </div>
//         );
        
//         const UncontrolledDiagram = () => {
//         // create diagrams schema
//             const [schema, { addNode, removeNode, onChange }] = useSchema(initialSchema);

//             const deleteNodeFromSchema = (id) => {
//                 const nodeToRemove = schema.nodes.find(node => node.id === id)!;
//                 removeNode(nodeToRemove);
//               };
            
//             const addNewNode = () => {
//                 const coord: NodeCoordinates = [
//                     schema.nodes[schema.nodes.length - 1].coordinates[0] + 100,
//                     schema.nodes[schema.nodes.length - 1].coordinates[1],
//                 ];
//                 const nextNode = {
//                     id: `node-${schema.nodes.length+1}`,
//                     content: `Node ${schema.nodes.length+1}`,
//                     coordinates: coord,
//                     render: CustomRender,
//                     className: 'bi-diagram-node-default',
//                     data: {onClick: deleteNodeFromSchema},
//                     inputs: [{ id: `port-${Math.random()}`}],
//                     outputs: [{ id: `port-${Math.random()}`}],
//                 }!;
               
//                 addNode(nextNode);
//             };
        
//             const onDiagramChange = () => {
//                 // this.setState({schema: onChange(schema)});
//                 props.schema = onChange(schema);
//                 console.log(props.schema);
//             };

//             return (
//                 <div style={{ height: '100%' }}>
//                     <button onClick={addNewNode}>Add new node</button>
//                     <Diagram schema={schema} onChange={onDiagramChange} />
//                 </div>
//             );
//         };
        
//         return (
//             <div {...emit()}>
//                 <div ref={this.diagramRef} />
//                 <div>{JSON.stringify(props)}</div>
//                 <UncontrolledDiagram />
//                 <ReactResizeDetector
//                        handleHeight={ true }
//                        handleWidth={ true }
//                        refreshMode="debounce"
//                    />
//             </div>
//         );
//     }
// }

export class KanoaDiagramMeta implements ComponentMeta {

    getComponentType(): string {
        return COMPONENT_TYPE;
    }

    getViewComponent(): PComponent {
        return KanoaDiagram;
    }

    getDefaultSize(): SizeObject {
        return ({
            width: 475,
            height: 200
        });
    }

    // createDelegate(component: AbstractUIElementStore): ComponentStoreDelegate | undefined {
    //     return new KanoaDiagramGatewayDelegate(component);
    // }

    getPropsReducer(tree: PropertyTree): KanoaDiagramProps {
        return {
            diagramSchema: tree.readObject("diagramSchema"),
            test: tree.readString("nodes", "default"),
            onChange: tree.read("onChange"),
        };
    }
}

Sure, right now I'm thinking if I need to implement a DesignDelegate. Hopefully the issue is limited purely to the tsx file. You can ignore the GatewayDelegate class, since I already commented out the createDelegate function in the Meta class.

Edit: some extra context: in the useEffect hook, print 3 is correctly reading updated values. print 4 is not @Benjamin_Furlani

In looking at the library you added for the diagrams beautiful-react-diagrams I noticed the DiagramSchema is a complex type

GitHub Example
import { ElementType, ReactNode } from 'react';

export type PortAlignment = 'right' | 'left' | 'top' | 'bottom';

export type Port = {
  id: string;
  canLink?: Function;
  alignment?: PortAlignment;
};

export type NodeCoordinates = [number, number];

export type Node<P> = {
  id: string;
  coordinates: NodeCoordinates;
  disableDrag?: boolean;
  content?: ReactNode;
  inputs?: Port[];
  outputs?: Port[];
  type?: 'default';
  render?: (
    props: Omit<Node<P>, 'coordinates'>
  ) => ElementType | ReactNode;
  className?: string;
  data?: P;
};

export type Link = {
  input: string;
  output: string;
  label?: ReactNode;
  readonly?: boolean;
  className?: string;
};

export type DiagramSchema<P> = {
  nodes: Node<P>[];
  links?: Link[];
};

This being said, it may not be getting serialized properly. The props tree needs something that Java can understand as a JsonSchema to correlate to your props.json file.

You may try as an example

let newSchema:object = JSON.parse(JSON.stringify(initialSchema));
props.store.props.write('diagramSchema', new QualifiedValue());
1 Like

Hey first I just wanted to say thank you so much for your time, and looking into this so in depth. I've simplified the component a bit more for now:

export interface KanoaDiagramProps {
    diagramSchema: any;
    test: string;
    onChange?: any;
}

const initialSchema = createSchema({
    nodes: [
        { id: 'node-1', content: 'Node 1', coordinates: [250, 60], inputs: [], outputs: []},
        { id: 'node-2', content: 'Node 2', coordinates: [100, 200], inputs: [], outputs: []},
        { id: 'node-3', content: 'Node 3', coordinates: [250, 220], inputs: [], outputs: []},
        { id: 'node-4', content: 'Node 4', coordinates: [400, 200], inputs: [], outputs: []},
    ],
    links: [
        { input: 'node-1',  output: 'node-2' },
        { input: 'node-1',  output: 'node-3' },
        { input: 'node-1',  output: 'node-4' },
    ]
});

export class KanoaDiagramMeta implements ComponentMeta {

    getComponentType(): string {
        return COMPONENT_TYPE;
    }

    getViewComponent(): PComponent {
        return KanoaDiagram;
    }

    getDefaultSize(): SizeObject {
        return ({
            width: 475,
            height: 200
        });
    }

    getPropsReducer(tree: PropertyTree): KanoaDiagramProps {
        return {
            diagramSchema: tree.readObject("diagramSchema"),
            test: tree.readString("nodes", "default"),
            onChange: tree.read("onChange"),
        };
    }
}

export function KanoaDiagram(props: ComponentProps<KanoaDiagramProps>) {

    const [schema, { addNode, removeNode, onChange }] = useSchema(initialSchema);
    const [diagramSchema, setDiagramSchema] = useState(initialSchema);

    const CustomRender = ({ id, content, data, inputs, outputs }) => (
        <div>
            <div role="button" style={{padding: '15px'}}>
            {content}
            </div>
            <div style={{marginTop: '10px',display:'flex', justifyContent:'space-between'}}>
            {inputs.map((port) => React.cloneElement(port))}
            {outputs.map((port) => React.cloneElement(port))}
            </div>
        </div>
    );

    const deleteNodeFromSchema = (id) => {
        const nodeToRemove = schema.nodes.find(node => node.id === id)!;
        removeNode(nodeToRemove)
    };

    const addNewNode = () => {
        const coord: NodeCoordinates = [
            schema.nodes[schema.nodes.length - 1].coordinates[0] + 100,
            schema.nodes[schema.nodes.length - 1].coordinates[1],
        ];
        const nextNode = {
            id: `node-${schema.nodes.length+1}`,
            content: `Node ${schema.nodes.length+1}`,
            coordinates: coord,
            render: CustomRender,
            className: 'bi-diagram-node-default',
            data: {onClick: deleteNodeFromSchema},
            inputs: [{ id: `port-${Math.random()}`}],
            outputs: [{ id: `port-${Math.random()}`}],
        }!;
        
        addNode(nextNode);
        setDiagramSchema(schema);
        const updatedConvertSchema = JSON.parse(JSON.stringify(schema));
        props.store.props.write('diagramSchema', new QualifiedValue(updatedConvertSchema));
        props.store.props.write('text', String(schema.nodes.length));
    };
    
    const UncontrolledDiagram = () => {

        return (
            <div style={{ height: '100%' }}>
                {/* <button onClick={addNewNode}>Add new node</button> */}
                <Diagram schema={diagramSchema} onChange={onChange} />
            </div>
        );
    };
    
    return (
        <div {...props.emit()}>
            {/* <div ref={diagramRef} /> */}
            <button onClick={() => {
                addNewNode();
                props.store.props.write("diagramSchema", diagramSchema);
            }}>Add new node</button>
            <div>{JSON.stringify(diagramSchema)}</div>
            <UncontrolledDiagram />
            <ReactResizeDetector
                    handleHeight={ true }
                    handleWidth={ true }
                    refreshMode="debounce"
                />
        </div>
    );
}

Your suggestion didn't work out unfortunately, and in fact I'm getting the same error when I just try to do a write to a simple string property called 'text'. So there's a more pressing issue than just the type of prop I'm trying to write to, I think. I've additionally added an additional attempt to write to the propertyTree in the button onClick at the bottom with no response either.

For some reason it just seems like props.write() never really goes through for me, and I've confirmed that what I'm passing in should be an updated schema.

What I've found is that my diagramSchema prop starts out empty because I never initialized it to anything yet, properly updates to include the schema with the initial 4 nodes, but then never again updates on subsequent addNewNode calls. Meanwhile I've confirmed that the 'schema' and 'diagramSchema' variables update every time correctly.

Edit: also provided the interface props, initialSchema, and the meta class for the full picture

This isn’t necessary for what you’re trying to do, and won’t resolve your current issues.

There’s a lot going on in your example, I would recommend stepping back and creating a shorter, simpler example of writing to the property tree.

What are you expecting this to do (why are you writing to the same property 3 times in a row)?

Also, why are you unconditionally writing the initial schema to the property tree every time your component starts? Don’t do this if you want the user to supply their own property values.

1 Like

Hey thanks for the reply, there was initially a lot of clutter because I was trying a lot of things and hoping at least one of them would stick, a LOT of trial and error going on. And yes I've also realized to not include that initial assignment, because yes it was being called every time. This is an updated version that's more simplified now.

Let’s get your “test” property working first.

Double check the usage everywhere; you’re writing to “text”, but your props interface lists it as “test” and your props reducer reads it as “nodes”.

Edit: also, your onClick calls addNewNode, which does a property tree write, then calls props.store.props.write("diagramSchema", diagramSchema) itself. Only do the write once.

1 Like

Okay, I've essentially stripped down the component so that addNewNode is only writing to the 'test' property, which is in fact working. Good to sanity check that nothing bigger picture is causing trouble. I'll keep at it and get back to you when I hit another wall. Thank you so much.

On the side, do you know any ways to avoid having the gradle build fail, if there are only warnings in the TypeScript files? Many times I've commented out code, which then causes warnings about unused variables or imports, and it's using up a lot of time to comment and uncomment out all these related lines of code.

Great. I’d also recommend thinking through your end goal too. Usually components don’t manage their own properties, it’s up to the user of the component to manage the configuration.

I think this’ll depend on your typescript transpiler/bundler.

2 Likes

It would definitely be easier, however I think it should be necessary here. Any updates made in the diagram schema through using the component, like adding a node, or updating a node's coordinates, should also reflect back to the properties displayed in the designer. Some native Perspective components, and other custom components I've worked with also allow for this bidirectional flow of configuration.

Okay got some test results here, wonder if you might have an idea what's going on. Initial conditions in the designer:

After first click of "Add new node":

'test' property was written to, and 'diagramSchema' was set to the hard-coded initialSchema. Should include node 5, but hey at least it was written to at all.

On subsequent presses of the button, the designer props no longer change, while the component still recognizes the node being added.

I then try and reset the 'test' and 'diagramSchema' properties back to an empty string and object, and then click the add button again. Still no response, almost like writing to the properties is blocked off now.

EDIT: did actually get some error logging in the gateway, will look into. Not the most specific, but good for now. I think what's happening is that I'm messing up the tree initially, which then causes further attempts to write to it to fail.

I might, if there was some code to look at :wink:

Of course! Here's my current working version:

export function KanoaDiagram(props: ComponentProps<KanoaDiagramProps>) {

    const [schema, { addNode, removeNode, onChange }] = useSchema(initialSchema);
    const [diagramSchema, setDiagramSchema] = useState(initialSchema);

    const CustomRender = ({ id, content, data, inputs, outputs }) => (
        <div>
            <div role="button" style={{padding: '15px'}}>
            {content}
            </div>
            <div style={{marginTop: '10px',display:'flex', justifyContent:'space-between'}}>
            {inputs.map((port) => React.cloneElement(port))}
            {outputs.map((port) => React.cloneElement(port))}
            </div>
        </div>
    );

    const deleteNodeFromSchema = (id) => {
        const nodeToRemove = schema.nodes.find(node => node.id === id)!;
        removeNode(nodeToRemove)
    };

    const addNewNode = () => {
        const coord: NodeCoordinates = [
            schema.nodes[schema.nodes.length - 1].coordinates[0] + 100,
            schema.nodes[schema.nodes.length - 1].coordinates[1],
        ];
        const nextNode = {
            id: `node-${schema.nodes.length+1}`,
            content: `Node ${schema.nodes.length+1}`,
            coordinates: coord,
            render: CustomRender,
            className: 'bi-diagram-node-default',
            data: {onClick: deleteNodeFromSchema},
            inputs: [{ id: `port-${Math.random()}`}],
            outputs: [{ id: `port-${Math.random()}`}],
        }!;
        try {
            const words = ['apple', 'banana', 'orange', 'lemon', 'lime'];
            const roll = getRandomInt(0, 4)
            props.store.props.write('test', words[roll]);
            
            addNode(nextNode);
            setDiagramSchema(schema);
            props.store.props.write("diagramSchema", diagramSchema);
        } catch (e) {
            (console.error || console.log).call(console, e.stack || e);
        }

    };
    
    const UncontrolledDiagram = () => {

        return (
            <div style={{ height: '100%' }}>
                <Diagram schema={diagramSchema} onChange={onChange} />
            </div>
        );
    };
    
    return (
        <div {...props.emit()}>
            <button onClick={addNewNode}>Add new node</button>
            <div>{JSON.stringify(diagramSchema)}</div>
            <div>{JSON.stringify(schema)}</div>
            <UncontrolledDiagram />
            <ReactResizeDetector
                    handleHeight={ true }
                    handleWidth={ true }
                    refreshMode="debounce"
                />
        </div>
    );
}

I think it's got to be some typing mismatch at this point. The first call of addNewNode works (kinda), and then subsequent calls fail. I'll continue to debug.

The useState setter is asynchronous, you won’t see the changes right away. Do this instead:

setDiagramSchema(schema);
props.store.props.write("diagramSchema", schema);

Or maybe you don’t need the diagramSchema state at all?