[IGN-15726]Project settings in Perspective 8.3 for components

Hi! We are making a module which add Ignition Perspective components (react components). For this we need some common settings, this could be badge handling, style type, etc.

So I have made a project property view in the designer:

But I haven’t found a good way to store the data and to get the data down to the component.

ChatGPT suggested to use model delegate to send the data from the gateway to the component.

I use this to set the data:

public ObComponentTypeProjectSettingsEditor(DesignerContext context) {
   super(new GridBagLayout());

   project = context.getProject();
   Optional<Resource> resource = project.getSingletonResource(RESOURCE_TYPE);

   if (resource.isPresent()) {
      try {
         settings = ResourceUtil.decodeOrNull(resource.get(), context.createDeserializer(), ObComponentTypeProjectSettings.class);
      } catch (Exception e) {
         log.error("Failed to decode ObComponentTypeProjectSettings", e);
         settings = new ObComponentTypeProjectSettings();
      }
   } else {
         log.error("Failed to get ObComponentTypeProjectSettings");
         settings = new ObComponentTypeProjectSettings();
  }


…

@Override
public Object commit() {
   settings.setComponentType((String) componentTypeDropdown.getSelectedItem());
   return settings;
}

I’m not sure if this works. But the naming of the functions seems promising (project.getSingletonResource) and I hope the commit function stores the resource.

But in the model delegate I have trouble accessing this resource. As the gateway context don’t have “context.getProject”.

So I need some guidance.

  1. Is the suggested method to use resource and model delegate the correct way to get the project settings down to the component? Or should we use other methods?

  2. How do I store and access this resource?

Thanks in advance!

I'd create a small, Perspective-authenticated servlet that your client would call when loading.

  1. Client starts, loads your BrowserResource.
  2. Your BrowserResource queries your endpoint and is given the settings for the current project.
  3. Store the settings globally in the client, and use as needed in your components.

That sounds smart. Like setting window.mymodule.style = "flat" and accessing that from the component? Would that work in the designer?

Exactly.

Yup, nothing special is needed in the designer.

This is how I handle it in Embr:

Different modules add different things in that namespace as needed.

The only "gotcha" I've found is that you sometimes need to wait for the ClientStore to become available before you can reliably reference it.

Ben also asked for the ability to provide session props in a module, which is something we should do, but no timeline on when:

@paul-griffith for us we would like to have a project setting (which is the same for all sessions). I think I only need a guidance on how to store a setting from the "Project Properties" view in the designer (as shown above) and how to retrieve that setting in an Gateway endpoint.

Yeah - to me, the "natural" way to do that that end users would have a good time with (if they need to bind to it or otherwise read it easily) would be a read-only session property that your module is in charge of providing.

Ben's workaround(s) are probably the best solution in the short term - he's got more practical experience extending Perspective than anyone else I'm aware of.

Our solution to the same problem first party is simple - we just made the requisite changes first party in the page loading, and didn't think about the use case of third parties needing to extend things.

100%, the way I suggested is just a work around.
At the moment the only thing easy about extending Perspective is adding components. Beyond that, it gets hacky pretty quickly.

I’d like to see the access be controllable.

Some properties would benefit from read/write (similar to session.props.theme), while others only need read access (similar to session.props.id).

I think I solved the project setting issue. But it feels very hacky

        String projectName = component.getSession().getProjectName();
        Optional<RuntimeResourceCollection> resourceCollection = gatewayContext.getProjectManager().find(projectName);
        Optional<Resource> resource = resourceCollection.get().getSingletonResource(ObComponentTypeProjectSettings.RESOURCE_TYPE);
        return ResourceUtil.decodeOrNull(resource.get(), gatewayContext.createDeserializer(), ObComponentTypeProjectSettings.class);

Is there a better way to get the project resource collection than:

Optional<RuntimeResourceCollection> resourceCollection = gatewayContext.getProjectManager().find(projectName);
        Optional<Resource> resource = resourceCollection.get().getSingletonResource(ObComponentTypeProjectSettings.RESOURCE_TYPE);

Now I'm depending on the naming convention of the resource collection. The code will fail, if the mapping between project name and collection name changes.

A resource collection and a project are synonymous; they can't ever get out of sync.
'Resource collection' is just our invented nomenclature because we needed something to describe the root of the hierarchy that contains both projects and deployment modes, since both are modeled in the same way in 8.3+.
So a project is a resource collection, but not all resource collections are projects - squares and rectangles. If that helps.

As a aside:

I would recommend moving away from ResourceUtil.decodeOrNull in new code. I'd recommend you adjust your editor/workspace to serialize using GSON/JSON and perform the inverse operation against the stored resource data. Better for Git compatibility/human readability than the XML/Java deserialization route that is used automatically.

Thank you for the guidance! :folded_hands:

I'm not a java developer, so do you have any example code or javadoc to guide me on how to do this serialization using json?

Eh...If you're settled on using a project property window, as in your first screenshot, it's more or less pointless. You can override serialize and deserialize from AbstractPropertyEditorPanel to use GSON, which is what Perspective does in com.inductiveautomation.perspective.designer.project.properties.AbstractPerspectivePropsPanel:

But you can't actually change where the data gets stored, so it'll still be written as a data.bin file underneath the resource, even though it's perfectly readable UTF-8 JSON:

So ignore me unless you want to add a fair bit of work and move to a distinct resource node in the tree.

Lots of good suggestions here. This is how I would do it. Two slightly different approaches here. I would go with the singleton approach (see alternative at the bottom). After registering the custom project panel in the DesignerHook:

1. Gateway — ComponentModelDelegate

The ComponentModelDelegate is created per component instance. Load the config by asking the ProjectManager for the project's resource collection, then push it down to the client over the WebSocket on startup.

public class MyComponentModelDelegate extends ComponentModelDelegate {

    private static final String EVENT_CONFIG_UPDATE = "config-update";

    private final GatewayContext gatewayContext;
    private final Gson gson = new Gson(); // or your module's configured Gson

    public MyComponentModelDelegate(Component component, GatewayContext gatewayContext) {
        super(component);
        this.gatewayContext = gatewayContext;
    }

    @Override
    protected void onStartup() {
        getConfig().ifPresent(config -> {
            JsonObject payload = gson.toJsonTree(config).getAsJsonObject();
            fireEvent(EVENT_CONFIG_UPDATE, payload);
        });
    }

    @Override
    public void handleEvent(EventFiredMsg message) {
        // handle any events fired by the client-side delegate if needed
    }

    private Optional<CustomComponentConfig> getConfig() {
        String projectName = component.getSession().getProjectName();
        return gatewayContext.getProjectManager()
            .getProject(projectName)
            .flatMap(project -> {
                Resource resource = project.getResourceCollection()
                    .getResource(CustomComponentConfig.RESOURCE_TYPE);
                if (resource == null) return Optional.empty();
                String json = new String(resource.getData(), StandardCharsets.UTF_8);
                return Optional.of(gson.fromJson(json, CustomComponentConfig.class));
            });
    }
}

Registering the delegate factory in your GatewayHook

@Override
public void startup(GatewayContext context, ...) {
    PerspectiveContext perspectiveContext = PerspectiveContext.get(context);
    perspectiveContext.getComponentRegistry()
        .registerComponentModelDelegateFactory(
            MyComponent.COMPONENT_ID,
            comp -> new MyComponentModelDelegate(comp, context)
        );
}

Reacting to project saves

If you need the delegate to re-push updated config when the project is saved without restarting the component, subscribe to the PerspectiveProjectCache event bus:

@Override
protected void onStartup() {
    PerspectiveContext.get(gatewayContext)
        .getProjectCache()
        .getEventBus()
        .register(this);
    pushConfig();
}

@Override
protected void onShutdown() {
    PerspectiveContext.get(gatewayContext)
        .getProjectCache()
        .getEventBus()
        .unregister(this);
}

@Subscribe
public void onProjectUpdated(ProjectUpdatedEvent event) {
    if (event.project.name.equals(component.getSession().getProjectName())) {
        pushConfig();
    }
}

private void pushConfig() {
    getConfig().ifPresent(config -> {
        JsonObject payload = gson.toJsonTree(config).getAsJsonObject();
        fireEvent(EVENT_CONFIG_UPDATE, payload);
    });
}

2. Client — ComponentStoreDelegate

Create a TypeScript class that extends ComponentStoreDelegate. Implement handleEvent to receive events from the gateway delegate, store the data locally, and call this.notify() to trigger a re-render. Expose the data to React via mapStateToProps.

import { ComponentStoreDelegate, AbstractUIElementStore, JsObject } from '@inductiveautomation/perspective-client';
import { bind } from 'bind-decorator';

interface CustomComponentConfig {
    someValue: string;
    someList: string[];
}

interface MyDelegateState {
    config: CustomComponentConfig | null;
}

export class MyComponentStoreDelegate extends ComponentStoreDelegate {
    private config: CustomComponentConfig | null = null;

    constructor(component: AbstractUIElementStore) {
        super(component);
    }

    @bind
    handleEvent(eventName: string, eventObject: JsObject): void {
        switch (eventName) {
            case 'config-update':
                this.config = eventObject as CustomComponentConfig;
                this.notify(); // triggers mapStateToProps → component re-renders
                break;
            default:
                console.warn(`Unhandled delegate event: ${eventName}`);
        }
    }

    @bind
    mapStateToProps(): MyDelegateState {
        return { config: this.config };
    }
}

Wiring into ComponentMeta

import { ComponentMeta, AbstractUIElementStore, ComponentStoreDelegate } from '@inductiveautomation/perspective-client';

export class MyComponentMeta implements ComponentMeta {
    getComponentType(): string {
        return 'your.module.id.my-component';
    }

    getViewComponent(): PComponent {
        return MyComponent;
    }

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

    getPropsReducer(tree: PropertyTree): MyProps {
        return {
            // map PropertyTree to props as normal
        };
    }
}

Consuming delegate state in your React component

The ComponentStore merges your mapStateToProps() return value into props.delegate. Type it via the second generic parameter on ComponentProps.

import { ComponentProps, PComponent } from '@inductiveautomation/perspective-client';

type MyProps = {
    // your normal component props from the property tree
};

export class MyComponent extends PComponent<ComponentProps<MyProps, MyDelegateState>> {
    render() {
        const { config } = this.props.delegate ?? {};

        if (!config) {
            return <div>Loading...</div>;
        }

        return (
            <div>
                <span>{config.someValue}</span>
            </div>
        );
    }
}

Data Flow Diagram

Designer (Swing)
  AbstractPropertyEditorPanel
    initProps()    ← deserialize() ← project resource bytes
    setChanged()   ← user edits
    commit()       → serialize()   → project resource bytes
         │
         │ project save
         ▼
Gateway resource collection
         │
         │ component startup / project update
         ▼
ComponentModelDelegate.onStartup()
  getConfig() reads resource bytes → CustomComponentConfig
  fireEvent("config-update", payload)
         │
         │ WebSocket
         ▼
ComponentStoreDelegate.handleEvent("config-update", payload)
  this.config = payload
  this.notify()
         │
         ▼
mapStateToProps() → { config }
         │
         ▼
props.delegate.config → React render

Alternative: Singleton Component + Window Store

Instead of wiring each component with its own ComponentModelDelegate, you can designate a single singleton component that owns the gateway connection and publishes received config into a shared store mounted on window. Other components read from the store and subscribe to changes using the subscriptionFactory API.

This trades per-component WebSocket wiring for a single connection point with a shared observable. It's a good fit when many components need the same config and you don't want each one to independently negotiate a delegate lifecycle.

The window store

Create a store class that holds the config and exposes the subscription API. Mount it on window once so all components share the same instance.

// CustomComponentStore.ts
import {
    createSubscriptionMap,
    subscriptionFactory,
    SubscriptionHandler,
} from '@inductiveautomation/perspective-client';

interface CustomComponentConfig {
    someValue: string;
    someList: string[];
}

const STATE_CONFIG = 'config';

class CustomComponentStore {
    private config: CustomComponentConfig | null = null;
    private subscriptionMap = createSubscriptionMap([STATE_CONFIG]);
    public subscribe: SubscriptionHandler;
    private notify: (state?: string) => void;

    constructor() {
        const { subscribe, notify } = subscriptionFactory(
            this.subscriptionMap,
            'CustomComponentStore'
        );
        this.subscribe = subscribe;
        this.notify = notify;
    }

    setConfig(config: CustomComponentConfig): void {
        this.config = config;
        this.notify(STATE_CONFIG); // notify subscribers of the 'config' state key
    }

    getConfig(): CustomComponentConfig | null {
        return this.config;
    }
}

declare global {
    interface Window {
        customComponentStore: CustomComponentStore;
    }
}

if (!window.customComponentStore) {
    window.customComponentStore = new CustomComponentStore();
}

export const store = window.customComponentStore;

Singleton component delegate

The singleton component's ComponentStoreDelegate is the only place that receives events from the gateway and writes to the store. No other component needs a delegate for config.

import { ComponentStoreDelegate, AbstractUIElementStore, JsObject } from '@inductiveautomation/perspective-client';
import { store } from './CustomComponentStore';

export class SingletonComponentStoreDelegate extends ComponentStoreDelegate {
    constructor(component: AbstractUIElementStore) {
        super(component);
    }

    handleEvent(eventName: string, eventObject: JsObject): void {
        switch (eventName) {
            case 'config-update':
                store.setConfig(eventObject as CustomComponentConfig);
                break;
        }
    }
}

The gateway side (ComponentModelDelegate) is identical to the approach in section 1 — it pushes fireEvent("config-update", payload) on startup and on project save. The only difference is that only the singleton component type needs a delegate registered, not every component type.

Consuming components

Other components read from the store directly and subscribe to changes in their React lifecycle.
The disposer returned by subscribe must be called on unmount to avoid memory leaks.

import { store } from './CustomComponentStore';

export class OtherComponent extends PComponent<ComponentProps<OtherProps>> {
    private unsubscribe?: () => void;

    componentDidMount() {
        // subscribe to the 'config' state key specifically
        this.unsubscribe = store.subscribe(() => {
            this.forceUpdate();
        }, 'config');
    }

    componentWillUnmount() {
        this.unsubscribe?.();
    }

    render() {
        const config = store.getConfig();
        if (!config) return <div>Loading...</div>;
        return <div>{config.someValue}</div>;
    }
}

To subscribe to any change on the store (not a specific state key), omit the second argument:

this.unsubscribe = store.subscribe(() => {
    this.forceUpdate();
});

The subscriptionFactory will route this to the ANY_CHANGE_KEY listeners, which are notified on every notify() call regardless of which state key changed.

Data flow diagram

Gateway delegate (singleton component only)
  fireEvent("config-update", payload)
         │
         │ WebSocket
         ▼
SingletonComponentStoreDelegate.handleEvent()
  store.setConfig(payload)
    → this.notify('config')
         │
         ├──→ OtherComponent A subscriber fires → forceUpdate()
         ├──→ OtherComponent B subscriber fires → forceUpdate()
         └──→ OtherComponent C subscriber fires → forceUpdate()
                    ↓
              store.getConfig() → render

Bluntly, using a ComponentModelDelegate is a terrible way of getting global state into a Perspective client. It's far more complicated than reading an endpoint, and introduces weird dependencies between components and settings.

The custom store is neat idea though, I'll have to play with the SubscriptionHandler stuff sometime.

Right. You can put the singleton shadow component in a dock that is shared across the application. It will reliably get notified when there are updates, and notify any subscribers of updates. There would be no need to poll an endpoint for updates (though there's noting really wrong with that if it's just one entity doing the polling). It will also reliably initialize on its own, versus having to poll the client store.

Perhaps the best solution would be to combine both solutions.

  • A servlet endpoint with cache busting hash.
  • A front-end singleton initialized project properties store in your component module index file that polls this endpoint for updates, and uses our Subscription API.
  • A custom reusable front-end React hook that makes this subscription easy.
  • Custom components that make use of this custom React hook.

Good luck, Torstein. Let us know how it goes!

There's no need to poll.

Add a handler to the Page via clientStore.connection.handlers, and trigger it from the Gateway via PageModel.send when your resource changes.

You could also distribute the new settings over that same connection too, but you’d have to come up with a way of making the first load happen.

EDIT:
It would be nice to have the ability to register Page Handlers on the Gateway side. It currently requires reflection:

Using the WS channel is a good idea too.

If you're at the ICC this year, come find me. I'd like to hear your thoughts.

It’s not confirmed yet, but I should be there!