Skip to content

How to make a Widget

Abigail Alexander edited this page Dec 19, 2022 · 13 revisions

This page describes how to create widgets for use in CS-Web-Proto, taking advantage of the work we have done to provide a base interface and connection to PVs.

NOTE: We separate components from Widgets in this document. A component is a React component which can be rendered directly from its props. A Widget is what will actually be used by CS-Web-Proto as it has a specified interface and is part of a toolchain which makes the app work well.

We have used a mixture of TypeScript (TS) and PropTypes to perform checking on the props which are provided to components and Widgets. This page will talk through our practices but there is a section at the end for if you would rather use plain JavaScript (JS).

TypeScript provides compile time checking and is useful for ensuring that the internals of your component work as expected. PropTypes provide runtime checking and is useful for ensuring that Widget definitions in screen files are correct.

Functional Components

We recommend using functional components. This a style for writing React components in which functions return JSX Elements. It encourages stateless components which can be easily tested and have clear links between input and output. This also makes components easier to test and compose.

Find out more about functional components here and here.

Creating your widget

Think about all the things which your component needs to render properly. Try to minimise this as much as possible. For a label, you might only need text.

// Types defined specifically for this project
import { IntProp, BoolProp, BoolPropOpt } from "../propTypes";
// library for added functionality to defined types
import PropType from "prop-types";

const MyExtraProps = {
  // Specifies prop 'MyProp' to be either an int or bool
  MyProp: PropType.oneOfType([IntProp, BoolProp]),
  // 'MySecondProp' is an optional bool
  MySecondProp: BoolPropOpt,
};

NOTE: Additional props should have a flat and simple shape, specifying props to be numbers, string, boolean etc. as opposed to a complex series of objects and arrays

The next step is to actually write your component. At this step, combine your additional props with the relevant base props. For a component that doesn't require a PV (process variable):

/**
 * EXTRA IMPORT, 'InferWidgetProps' used to convert prop types
 */
import { IntProp, BoolProp, BoolPropOpt, InferWidgetProps } from "../propTypes";
import PropType from "prop-types";

/**
 * NEW IMPORTS, Widget used to wrap a component to take advantage of common code to
 * integrate the component into the workflow
 * React is required to create JSX
 * PVComponent is a type definition used to combine types for this specific component
 * and types for all components
 */
import React from "react";
import { Widget } from "../widget";
import { Component } from "../widgetProps";

const MyExtraProps = {
  MyProp: PropType.oneOfType([IntProp, BoolProp]),
  MySecondProp: BoolPropOpt,
};

/**
 * NEW TYPE, full type definition
 */
export type MyComponentProps = InferWidgetProps<typeof MyExtraProps> &
  Component;

/**
 * NEW COMPONENT, the properties available on props are those on both Component and
 * MyExtraProps
 * The component is exported for testing
 */
export const MyComponent = (
  props: MyComponentProps
): JSX.Element => (
  const {
    MyProp,
    // MySecondProp is optional so setting default value
    MySecondProp = "Nothing",
    // Style is available because it is a property on PVComponent
    style
  } = props;


  <div style={props.style}>
    {MyProp + "and" + MySecondProp}
  </div>
);

NOTE: TS files which use JSX should use the *.tsx extension

Lastly, wrap the component into a widget and register the widget. This widget is not easily testable due to it's connection into the overall workflow, which is why the base component is exported to test instead.

import { IntProp, BoolProp, BoolPropOpt, InferWidgetProps } from "../propTypes";
import PropType from "prop-types";
import React from "react";
import { Widget } from "../widget";
/**
 * EXTRA IMPORT, 'PVWidgetPropType' is a type definition used to create a
 * a widget with a PV attached to it
 */
import { Component, WidgetPropType } from "../widgetProps";

const MyExtraProps = {
  MyProp: PropType.oneOfType([IntProp, BoolProp]),
  MySecondProp: BoolPropOpt,
};

export type MyComponentProps = InferWidgetProps<typeof MyExtraProps> &
  Component;

export const MyComponent = (
  props: MyComponentProps
): JSX.Element => (
  const {
    MyProp,
    MySecondProp = "Nothing",
    style
  } = props;


  <div style={props.style}>
    {MyProp + "and" + MySecondProp}
  </div>
);

/**
 * NEW TYPE, this combines the properties expected for the widget and the properties
 * defined for a widget that attaches to a PV, defined as a prop type instead of a
 * typescript type as this format is required for registering the widget
 */
const MyWidgetProps = {
  ...MyExtraProps,
  ...WidgetPropType
}

/**
 * NEW WIDGET, this wraps the component into a widget
 */
export const MyWidget = (
  props: InferWidgetProps<typeof MyWidgetProps>
): JSX.Element => (
  <Widget
    baseWidget={MyComponent}
    {...props}
  />
);

// Bad formatting because markdown doesn't recognise tags like <Widget />
/**
 * NEW REGISTRATION, registering a component makes it available to use in the app,
 * it defines the widget, the type on the widget, and the name of the widget
 */
registerWidget(MyWidget, MyWidgetProps, "MyWidget");

NOTE: To finish registration of the widget, an export statement must be added to ui/index.tsx

Finally, ensure you have exported your widget in src/ui/widgets/index.ts

export { MyWidget } from "./MyWidget/myWidget";

If your widget makes use of any props parsed from a file, there are additional steps that you need to follow defined in the "Loading different file formats" page. These involve specifying the props to parse, defining their simple and complex parsers and registering the type of .opi or .bob file widget that corresponds to the widget you have just created.

CSS Classes

If you want to add classes to your component, use CSS modules. This provides you with the capability to swap out CSS classes dynamically depending on the input.

You should also add a global class to your component with a simple name. This allows configuration of the styles from a global stylesheet and themes.

import classes from "./mycomponent.module.css";

const MyComponent = (
  props: InferWidgetProps<typeof MyExtraProps> & Component
): JSX.Element => (
  // component is the name of the css style in the classes file
  <div className={classes.component} style={props.style}>
    {props.MyProp}
  </div>
);

You might notice that when doing this, TS is very fussy and even requires you specify props which are marked as optional. This is due to some disagreement between TS and PropTypes in how to handle this and is a small development cost to pay for the benefits it provides, given that almost all Widgets rendered will be specified in a screen file rather than directly in JSX.

My component needs PVs!

Great! The process is very similar but there are a few things to be aware of.

PV components may use the following props to get information about the PV they are connected to:

  • pvName - get a string with the name of the PV
  • initializingPvName - string ofthe PV name before any macro substitution took place
  • connected - boolean on whether or not the PV has been connected to
  • value - VType with latest PV data
  • readonly - bool on if the PV is readonly to the client

You may use any number of these props when defining your component. If you don't need any of them you might be better off with a standard Widget (see above).

Import PVComponent, PVWidget and PVWidgetPropType in place of the normal widget imports then follow the instructions above as normal. If you are using an editor with auto-completion and TS integration you should be able to see all of the props available when you type props. and this should include your extra component props, the base component props and the PV component props listed just above. Ensure that, for any props that need to be parsed from a file, you have added in the parsers by following the steps in "Loading different file formats".

A brief example of a PV Widget is given below, which is a LED that monitors the alarm level of a PV:

import React from "react";
import { Widget } from "../widget";
import { InferWidgetProps } from "../propTypes";
import { PVComponent, PVWidgetPropType } from "../widgetProps";
import { registerWidget } from "../register";
import classes from "./led.module.css";
import { DAlarm } from "../../../types/dtypes";
import { LabelComponent } from "../Label/label";
import { getClass } from "../alarm";

// No current props but left for easy addition later
const LedProps = {};

export type LedComponentProps = InferWidgetProps<typeof LedProps> & PVComponent;

export const LedComponent = (props: LedComponentProps): JSX.Element => {
  const { value, connected } = props;

  const alarm = value?.getAlarm() || DAlarm.NONE;
  const className = getClass(classes, connected, alarm.quality);

  return <LabelComponent text="" className={className}></LabelComponent>;
};

const LedWidgetProps = {
  ...LedProps,
  ...PVWidgetPropType,
};

export const LED = (
  props: InferWidgetProps<typeof LedWidgetProps>
): JSX.Element => <Widget baseWidget={LedComponent} {...props} />;

registerWidget(LED, PVWidgetPropType, "led");

When instantiating your PVWidget, you only need to supply the pvName parameter and the PVWidget will provide the rest of the information via some Higher Order Components which are working behind the scenes:

<LED pvName={"loc://pv1"} />

Adding my widget from JSON

The component which converts JSON into a screen fromJson. It maintains a dictionary of text: widgets. To add your widget, import your widget into this file and add it to the dictionary with a useful name.

At Runtime

At runtime, the function which turns the JSON description into Widgets on the screen will compare the provided parameters against the PropType for the widget. It will raise an error if an incorrect type is applied, if a required prop is not provided or if unexpected properties are provided. This will cause an error widget to be displayed on the screen (it should be relatively obvious).

To get more information, open your browser console (Ctrl + Shift + I or similar) and you will see a message specifying the offending file and object. In the future this capability may be enhanced to provide greater utility.

Testing

Whether you are creating a Widget from scratch or making a small change to an existing Widget, testing is important. Creating stateless components is a good step towards testability. We are using [Jest] and [Enzyme] to test our components and testing takes place on any push with Travis CI. You can also perform testing yourself with npm run test. Press a to run all available tests and u to update a snapshot if it changed in a way you were expecting and it needs to be updated. The snapshot file should also be committed.

Many of our components already have tests so you can observe what we have done. Tests should be included with the filename component.test.tsx

Prop Types on their own

The above example have combined TypeScript and PropTypes, but it may be that you just want to use PropTypes as you are more familiar with how they work. In this case, simply avoid the references to TypeScript, types, InferProps etc. You will still need to import Widget and WidgetPropType or their PV equivalents and attach some PropTypes to the widget you export. Note that for PV widgets this will make things a little difficult as the type system supporting values is complex and has not yet been ported to PropTypes. This is not a particular priority - PropTypes is good for runtime checking of widget types and TypeScript is better for all of the behind the scenes work.