Operation Centre custom IDC elements

IDC allows to create custom dashboard elements for various purposes, such as general UI elements, value displays, buttons, etc.

To create a custom IDC element, a basic knowledge of TypeScript (JavaScript can be used as well) and React is required.

Note

IDC custom elements require EVA ICS 4.0.2 build 2024120401 or later.

Creating a new element module

Consider node.js and npm are installed.

Execute:

npx idc-create-custom-element my-element
cd my-element

The command automatically creates a folder named my-element with a single dashboard element.

Note

The default module bundler is webpack. The bundler can be switched to vite by specifying -b vite option.

The module contains a single TypeScript file src/index.tsx.

To build the element module, execute:

npm install
npm run build

This will create a dist folder with the compiled JavaScript file my-element.js.

To use the element module in IDC, it should be placed in pvt folder of EVA ICS node, the default path is /opt/eva4/pvt/vendored-apps/opcentre/idc/elements.

Here are several options how to deploy the element module:

  • Upload the module file manually or deploy using IaC and deployment.

  • If there is a local EVA ICS instance running, execute npm run install-local.

  • If there is access to a remote EVA ICS instance, execute npm run upload. The remote system and credentials are specified in config.json file, the format is the same as WebEngine configuration.

Note

To simplify the deployment process, it is recommended to pack all module resources (CSS, images, etc.) into a single file. Refer to webpack or vite documentation for more information.

After the element module is uploaded, refresh the web browser to load it. For debugging purposes, open the browser developer console. If Verbose console level is enabled, IDC displays the list of loaded module elements.

Multiple elements in a single module

A module can contain multiple elements. If possible, it is recommended to combine elements in a single module to reduce IDC loading time.

import { IDCElementCollection } from "idc-custom-elements";

const collection = new IDCElementCollection();

collection.add(element1);
collection.add(element2);
collection.add(element3);

export default collection.export();

Element API

Defining an element

IDCElement class allows to specify element parameters in build-pattern style:

import { IDCElement, IDCPropertyKind } from "idc-custom-elements";

const element = new IDCElement(
         "class-name", // element class, can override the default IDC elements if required
         Element // element view function
         )
    .actions(false) // if set to true, the element viewer gets additional parameters for action handling
    .boxed(true) // If set to true, the element is placed into <div class="idc-element-box"></div> container
                 // Useful if a view returns e.g. a `canvas` or `input` HTML element directly
    .description("Some element") // Element description, displayed in IDC sidebar
    .defaultValue("prop1", 12345) // Default value for an element property
    .defaultZIndex(10) // Default element z-index
    .defaultSize(20, 20) // Approximate default element size in pixels, to auto-align element on creation
    .group("My group") // Element group for grouping in IDC sidebar, the default is `Extra`
    .iconDraw(() => <div style={{ fontSize: 21, fontWeight: "bold" }}>E</div>) // A React JSX element with a custom element icon
    .prop("prop1", IDCPropertyKind.String, {}) // Element property, displayed in IDC sidebar
    .vendored("something"); // static data is sent to the viewer as-is,
                            // hidden from users and not stored when a dashboard is saved.

Element properties

Elements can have properties of the following types. For the majority of types, property parameters can be specified (for some they are mandatory to let users to modify the property).

enum IDCPropertyKind {
   // A boolean value (checkbox)
   Boolean = "boolean",
   // Formula input with automatic validation
   // (see https://pub.bma.ai/dev/docs/bmat/functions/numbers.calculateFormula.html)
   Formula = "formula",
   // A number input
   // params (object): { min, max }
   Number = "number",
   // Item OID. Warning: the OID is not subscribed and the engine does not have the item value
   // params (object): kind - specify item kind (unit, sensor, lvar, lmacro)
   OID = "oid",
   // Item OID. The OID is subscribed when the dashboard is opened
   // and the engine has the current item value
   // params (object): kind - specify item kind (unit, sensor, lvar, lmacro)
   OIDSubscribed = "oid_subscribed",
   // Select a number from a list
   // params (array): an array of numbers
   SelectNumber = "select_number",
   // Select a number using a slider
   // params (object): min, max, step
   SelectNumberSlider = "select_number_slider",
   // A string input
   String = "string",
   // A string list, the value is an array of strings
   StringList = "string_list",
   // Select a string from a list
   // params (array): an array of strings
   SelectString = "select_string",
   // Select a connected database
   SelectDatabase = "select_database",
   // If logged as administator, the user can select OID of any item in the
   // system. Useful to specify OIDs for stateless items (lmacros).
   // If logged as a regular user, a string input is displayed.
   // params (object): i - item mask, src: item source
   // see https://info.bma.ai/en/actual/eva4/core.html#item-list for more info
   SelectServerOID = "select_server_oid",
   // Select a color using a color picker
   SelectColor = "select_color",
   // A list of labels, values and colors as `Array<IDCValueMap>`:
   // interface IDCValueMap {
   //    value?: string;
   //    label: string;
   //    color: string;
   // }
   // params (object): title - dialog title, help - dialog help text
   ValueMap = "value_map",
   // Simplar to ValueMap, but with no labels
   // interface IDCValueMap {
   //    value?: string;
   //    color: string;
   // }
   // params (object): title - dialog title, help - dialog help text
   ValueColorMap = "value_color_map"
}

View function

The view function is a React component that renders the element:

const Element = ({
  dragged, // is the element dragged, useful e.g. to stop updating data from server
  vendored, // static data from the element module
  setVariable, // set a dashboard global variable
  getVariable, // get a dashboard global variable
  forceUpdate, // forcibly update the whole dashboard
  ...params // current values of all element properties
}: {
  dragged: boolean;
  vendored?: any;
  setVariable: (name: string, value: string) => void;
  getVariable: (name: string) => string | undefined;
  forceUpdate: DispatchWithoutAction;
}): JSX.Element => {
  const parameters = params as ElementParameters;
  return <>Element</>;
};

Actions

If actions is set to true, the view function gets additional params fields for action handling:

  • disabled_actions - the actions should be disabled as the element is being currently edited

  • on_success: (result: ActionResult) => void; - a callback function to be called when an action is successfully executed

  • on_fail: (err: EvaError) => void - a callback function to be called when an action fails

Accessing WebEngine

While an element is loaded, it is considered EVA ICS WebEngine is logged in and running. EVA ICS WebEngine React hooks can be accessed as well, with no need to specify the engine object.

The current engine can be obtained either with get_engine function of EVA ICS WebEngine React module or by using window.$eva global object.

Example: a custom unit action button

Module code

import { JSX, DispatchWithoutAction } from "react";
import {
  IDCElement,
  IDCPropertyKind,
  IDCValueColorMap
} from "idc-custom-elements";
import { useEvaState, get_engine } from "@eva-ics/webengine-react";
import { Eva, EvaError, EvaErrorKind, ActionResult } from "@eva-ics/webengine";
import packageInfo from "../package.json";
import "./style.css";

// Element parameters interface
interface ElementParameters {
  oid: string;
  label: string;
  colors?: Array<IDCValueColorMap>;
  disabled_actions?: boolean;
  on_success?: (result: ActionResult) => void;
  on_fail?: (err: EvaError) => void;
}

// Element view function
const Element = ({
  dragged,
  vendored,
  setVariable,
  getVariable,
  forceUpdate,
  ...params
}: {
  dragged: boolean;
  vendored?: any;
  setVariable: (name: string, value: string) => void;
  getVariable: (name: string) => string | undefined;
  forceUpdate: DispatchWithoutAction;
}): JSX.Element => {
  const parameters = params as ElementParameters;
  const state = useEvaState({ oid: parameters.oid }, [parameters.oid]);
  if (!parameters.oid) {
    // we need to return some element in case if nothing is specified to let it
    // be dragged/deleted/selected by a user
    return (
      <>
        <div className="relay-label">{parameters.label || "Relay"}</div>
      </>
    );
  }
  // select background color for the current value
  const valueColor = parameters.colors?.find((c) => c.value == state.value);

  // handle action result
  const handle_action_finished = (result: ActionResult) => {
    if (result.exitcode === 0) {
      if (parameters.on_success) parameters.on_success(result);
    } else if (parameters.on_fail) {
      parameters.on_fail(
        new EvaError(EvaErrorKind.FUNC_FAILED, result.err || undefined)
      );
    }
  };

  // handle action event
  const handle_action = (e: React.MouseEvent<HTMLButtonElement>) => {
    if (parameters.disabled_actions) {
      return;
    }
    e.preventDefault();
    const eva_engine = get_engine() as Eva;
    eva_engine.action
      .toggle(parameters.oid, true)
      .then((result: any) => handle_action_finished(result))
      .catch((err: EvaError) => {
        if (parameters.on_fail) parameters.on_fail(err);
      });
  };

  // Render the element
  // Actions are disable either if disabled_actions is set (the current
  // dashboard is being edited) or if state.act > 0 (an action is in progress)
  return (
    <>
      <div className="relay-label">{parameters.label}</div>
      <button
        style={{ backgroundColor: valueColor?.color }}
        className="relay-button"
        type="button"
        value={state.value ? "🗲 ON" : "OFF"}
        disabled={parameters.disabled_actions || state.act > 0}
        onClick={handle_action}
      >
        {state.value ? "🗲 ON" : "OFF"}
      </button>
    </>
  );
};

// Register element
const element = new IDCElement("relay-button", Element)
  .description("Relay on/off")
  .group("Item actions")
  .prop("oid", IDCPropertyKind.OIDSubscribed, { kind: "unit" })
  .prop("label", IDCPropertyKind.String)
  .prop("colors", IDCPropertyKind.ValueColorMap)
  .defaultValue("label", "Relay")
  .defaultValue("colors", [
    { value: "0", color: "gray" },
    { value: "1", color: "yellow" }
  ])
  .actions(true)
  .boxed(true)
  .iconDraw(() => <div style={{ fontSize: 21, fontWeight: "bold" }}>R</div>);

export default element.export();

// output module name/version for debugging purposes, highly recommended
console.debug(
  `Element module ${packageInfo.name} v${packageInfo.version} loaded`
);

Module CSS styles

.relay-label {
  display: inline-block;
  margin-right: 10px;
}

.relay-button {
  border: 1px solid #ccc;
  border-radius: 5px;
}

The result

Relay button