Skip to main content

Writing a Custom Widget

A custom widget is a React component plus a small piece of plain data that describes it. You write both with @devicechain/widget-sdk, ship them in an npm package, and register the package in the application that shows dashboards. The built-in widgets are declared through exactly the same interface, so a custom widget gets the same data binding, option validation and error handling.

Your widget receives resolved data. It does not authenticate, resolve datasources, poll, or open a telemetry connection. The host does all of that and hands your component a snapshot.

A complete package you can copy is in the repository under examples/widget-tank-level. It builds outside the monorepo against packed SDK tarballs.

Install the SDK​

npm install @devicechain/widget-sdk @devicechain/dashboards react

In your widget package, declare @devicechain/widget-sdk, @devicechain/dashboards and react as peer dependencies, never as dependencies. The SDK defines the React contexts a widget reads (selection, time zone, theme root), and a context is identified by the object that created it. The host and every widget package must therefore resolve one copy of the SDK.

The SDK root entry has no chart or map code. A widget that only draws DOM imports from the root and adds neither Apache ECharts nor MapLibre nor the built-in widgets to the host's bundle. Chart helpers (EChart, buildLineOption, buildGaugeOption, useChartTheme) are on @devicechain/widget-sdk/echarts, and echarts is an optional peer needed only by a package that imports that entry.

Declare a widget​

import { WidgetFrame, defineWidget, formatValue, pickSample } from '@devicechain/widget-sdk';

export const tankLevelWidget = defineWidget({
descriptor: {
id: 'acme/tank-level',
version: '1.0.0',
sdkApiVersion: 1,
optionsVersion: 1,
display: { title: 'Tank level', category: 'Telemetry' },
channel: 'measurement',
datasource: { mode: 'required', selectors: ['device', 'slot'] },
binding: { window: { option: 'window', min: 1, default: 1 } },
options: {
unit: { kind: 'string', doc: 'Unit shown beside the level.', label: 'Unit' },
window: { kind: 'number', integer: true, min: 1, doc: 'Samples kept.', label: 'Samples kept' },
},
defaults: { unit: '%', window: 1 },
interactions: {},
},
implementation: {
kind: 'trusted-react',
component: ({ data }) => (
<WidgetFrame title="Tank level">{formatValue(pickSample(data.latest, undefined)?.value, 1)}</WidgetFrame>
),
},
});

defineWidget infers the channel and the option schema from the literal. A component whose data disagrees with the channel, defaults that disagree with the schema, a binding that names a missing option, or an interaction the channel cannot carry is a compile error. The registry checks the same rules again when the host builds it, for callers that bypass the compiler.

The descriptor​

The descriptor is plain JSON-serializable data. It holds no component, callback, import URL or token, so the definition parser and the validators can read it without loading or trusting your code.

FieldMeaning
idA namespaced id, scope/name such as acme/tank-level, in lowercase letters, digits and hyphens. Ids without a slash are reserved for the built-in widgets. Namespace ownership is a convention between operators in this release; it does not prove who published a package.
versionThe release of this widget, as MAJOR.MINOR.PATCH. It is provenance. See widget versions and compatibility.
sdkApiVersionThe generation of the SDK contract the widget was written for. Currently 1.
optionsVersionThe shape of the stored options. Bump it only when stored options stop meaning the same thing.
displaytitle, and optionally description and category, for a host's Add menu. titleId and categoryId are optional message ids, described under labels and help text.
channelWhich data the widget consumes. See below.
datasource{ mode: 'none' }, or mode: 'optional' or 'required' with the selectors an author may pick: device, anchor or slot. A control channel widget targets one device, so it offers only device and slot.
bindingHow the host subscribes, written as constants and references to your own option keys.
optionsThe option schema.
constraintsOptional rules that span more than one option, such as greater-than.
defaultsThe starting options of a newly added widget.
interactionsThe host callbacks the widget asks for.

Channels and binding policy​

A widget declares one channel:

ChannelComponent receivesBinding policy
measurementlatest by measurement name, a rolling samples window, and an errorwindow: how many samples to keep
alarmThe current alarm rows, a total count, and load and error flagspageSize, and optionally state, severity and acknowledged filters
controlCommand history for a target devicepageSize
locationLocation samples for the bound entitynone
selectionNo data. The widget drives a slot from the host's candidate lookup.none
noneNo data. A static widget.none

A binding value is either a constant, { const: 25 }, or a reference to an option key, { option: 'window', min: 1, default: 1 }. The reference is checked against the option's kind when you compile. You cannot supply a GraphQL string or a subscription function: the host owns the transport. The selection and none channels open no data channel and must declare datasource: { mode: 'none' }.

When a slot has no device, or a bound device is deleted, the host shows its standard unbound or unavailable state and does not hand your component the previous entity's data.

Options​

An option has a kind (string, number, boolean or enum) and a doc sentence. A number can declare min, max and integer. An enum declares its values. A string can declare format: 'json', which requires the value to parse as JSON. required marks an option whose absence leaves the widget with nothing to show; an empty string counts as absent. The host validates stored options against the schema, and the console generates its settings form from it.

Read options defensively with the SDK helpers optString, optNumber and optBoolean. They return undefined for a hand-edited value of the wrong type instead of throwing.

Labels and help text​

Give every option a portable label, and a help where one sentence helps. A host that has never heard of your widget shows that text as is, so your options are understandable without any translation on the host. Each piece of text can also carry a message id that a host looks up in its own translations first:

TextPortable textOptional message id
Option labellabellabelId
Option helphelphelpId
Enum choicesvalueLabels, keyed by valuevalueLabelIds, keyed by value
The unset entry of an enumplaceholderplaceholderId
Widget title and categorydisplay.title, display.categorydisplay.titleId, display.categoryId

The console uses the message id when its own translations define it, then your portable text. A label with neither falls back to the option key made readable (maxRows shows as "Max rows"), and an enum choice with neither shows its raw value. A message id the host does not define is not an error, so your package works in a host that has never seen it.

options: {
show: {
kind: 'enum',
values: ['bar', 'number'],
doc: 'Whether the card draws the fill bar under the number.',
label: 'Show',
valueLabels: { bar: 'Number and fill bar', number: 'Number only' },
placeholder: 'Number and fill bar',
},
},

Specialized authoring controls​

An option can name a specialized authoring workflow in its control field. This is unrelated to the control data channel. Without it, the option gets the generic field for its kind: a text box, a number box, a checkbox or a drop-down.

The vocabulary is open. Any non-empty string registers, and a host that does not implement the named control shows the generic field for that option instead. A package therefore stays installable on a host older than the controls it names. Name your own controls in your namespace, such as acme/capacity-presets. Unprefixed names belong to the host.

The console implements the controls listed in HOST_OPTION_CONTROLS, exported by @devicechain/dashboards:

ControlWorkflow
command-vocabularyPicks one of the bound device's commands and writes its key, label and parameters together.
slot-targetPicks one of the dashboard's slots.
basemapMarks the raster map tile source and its attribution.

A control the console does not implement, including every namespaced one, gets the generic field. To give your options an authoring experience of their own, ship an editor.

A custom editor​

implementation.editor is an optional React component. When a widget has one, the console shows it in place of the generated option fields. The datasource fields and the console's notices stay the console's. The editor receives WidgetEditorProps, exported by @devicechain/widget-sdk:

PropMeaning
optionsThe widget's stored options, read-only.
setOptions(patch)The only way to change them. Several keys in one call apply together.
servicesHost services, passed explicitly. See below.
descriptorYour registration's descriptor, for its option labels, bounds and enum values.

setOptions is checked against your option schema before it is applied. A key the schema does not declare, or a value of the wrong kind, throws at the moment the editor makes the mistake rather than being stored for nothing to read. undefined and an empty string remove a key, as does false for a boolean. A number outside the declared bounds is brought within them.

services (WidgetEditorServices) holds:

ServiceMeaning
slotsThe dashboard's slots, keyed by name. Empty on a dashboard with none.
deviceTokenThe token of the device the widget is bound to directly, when it is.
commandChoices(deviceToken)The commands a device accepts. Optional: a host without a command vocabulary leaves it out.

An editor receives no hub, no client and no access token, and it must not import anything from the host application. A service the host cannot supply is absent, and your editor must cope with that. If the editor throws while rendering, the console replaces it with an error message and a retry button, and the author's other changes on the dashboard are kept.

implementation: {
kind: 'trusted-react',
component: TankLevelCard,
editor: function TankLevelCardEditor({ options, setOptions, descriptor }) {
return (
<label>
{descriptor.options.unit?.label ?? 'unit'}
<input value={options.unit ?? ''} onChange={(e) => setOptions({ unit: e.target.value })} />
</label>
);
},
},

Inside defineWidget, options and setOptions are typed from your option schema, so a misspelled key is also a compile error. The example package's card widget has a complete editor.

Interactions and the trust model​

interactions lists the host callbacks the widget uses: alarmActions (acknowledge and clear, alarm channel only), commandSend (control channel only), selection and candidates. The host supplies only the callbacks you declared, further limited to what the host supports and what the viewer is allowed to do. A widget with no callback behaves as read-only. The server stays authoritative: an action the viewer may not take is refused by the server, and the refusal reaches your component.

A widget package is trusted code. It is bundled into the host application at build time and runs in the host page with the host's privileges. The declarations in a descriptor shape the API; they are not a security boundary and do not stop malicious code in a package. The SDK does not pass your component the access token, the client or the raw hub, but nothing prevents code on the same page from reaching them. Install only packages you have reviewed, from publishers you trust. The host wraps every widget in an error boundary, so a widget that throws shows an error in its own frame and its siblings keep rendering. That does not contain an infinite loop or hostile code.

Next​