Add a widget
Goal: build a reusable, record-level data-summarizing widget — its client module under
client/dedalo/core/widgets/, and its server-side compute in the horizontal info-widget engine — hosted and rendered by acomponent_infofield.
This is a step-by-step how-to. For the conceptual model and the full surface, read the reference first and keep it open:
- widgets — the
core/widgets/subsystem reference (the IPO config, the data contract, async widgets). - component_info — the host component; the only data-side caller of these widgets.
- component_info cookbook — copy-paste recipes (declare, async, datalist, observers, test, debug) that complement this how-to.
- area_maintenance — a different, unrelated widget family (see the warning below).
Two unrelated things are called 'widget'
Dédalo has two widget systems that share no code. This guide is about
core/widgets/ — record-level widgets driven by an ontology IPO
config and hosted by a component_info field. They are not the ~31 admin
panels under core/area_maintenance/widgets/ (make_backup,
media_control, dataframe_control, …), which are dispatched by
dd_area_maintenance_api and are a separate subsystem — see
area_maintenance and the
dedalo-area-maintenance skill. Everything below targets the info-widget
family.
When do you need this?
A core/widgets/ widget exists to compute read-only data from other components of a record (or across records / the search session) and surface it inside an info panel — a digitization percentage, a media-icon strip, a roll-up of descriptors, a sum of measurements.
| You want… | Do this |
|---|---|
A new summary that an existing widget already computes (e.g. another calculation formula) |
Ontology only. Add a properties.widgets[] entry to a component_info node, pointing widget_name/path at the existing widget and writing a new ipo. No code. See step 5. |
| A summary that needs new server logic (a computation no widget does yet) | Code. Add a compute case in the info-widget engine + its client module, then host it (the rest of this guide). |
| The cataloguer to type a value | Not a widget — use component_input_text or another data-owning component. |
| A back-office maintenance operation (backup, migrate, rebuild) | Not this surface — see area_maintenance. |
There is no scaffolder for widgets (unlike tools). You copy the reference client widget, rename, add a server compute case, and wire it into the ontology by hand.
Server compute is one descriptor module per widget
Each widget is one module under
src/core/components/component_info/widgets/<tld>/<name>.ts exporting one
InfoWidgetDescriptor ({name, path, isAsync?, computeData, …}), assembled
by widgets/registry.ts. Dispatch is by widget_name through the registry
map — never by loading an ontology-authored path, which would let a node
author choose which code the server executes. Adding a server-computed widget
means adding a descriptor module + a registry entry. The authoritative
checklist lives in src/core/components/component_info/widgets/README.md,
mechanically enforced by test/unit/info_widget_registry_tripwire.test.ts.
How it fits together
flowchart TB
ONT["ontology node (component_info)<br/>properties.widgets = [ {widget_name, path, ipo}, … ]"]
ONT --> CI["section read → component_info emit"]
CI -->|"computeInfoWidgets(componentTipo, context)"| ENG["widgets/registry.ts<br/>INFO_WIDGETS.get(widget_name) → descriptor.computeData()"]
ENG -->|"reads via readWidgetComponentData / resolveComponentValue"| COMP["other components of the record"]
ENG -->|"[{widget,key,widget_id,id,value}]"| CI
CI -->|"aggregated array"| DDO["component_info datum {context, data}"]
DDO --> CL["client: component_info.js → your render_<name>.js"]
On a section read the component_info emit path calls computeInfoWidgets(componentTipo, context). That aggregate reads the component node's properties.widgets, and for each non-async widget looks its widget_name up in the registry and runs the descriptor's computeData(ipo, context), concatenating every widget's output. An unknown widget_name throws WidgetNotRegisteredError, and a registered-but-unimplemented stub throws WidgetUnportedError — a widget never silently renders empty. The client component_info.js::get_widgets() then dynamically imports each widget's JS by its path and feeds it the matching value slice. The widget_name (registry key) and the ontology widget_name/path (client import) are the contract — the tripwire binds each descriptor's path to the client module.
Step-by-step
1. Copy the reference client widget
The minimal, working reference client module is client/dedalo/core/widgets/test/test_info/. Copy it under the domain (TLD) folder your widget belongs to, and rename every test_info occurrence — the directory, the file names and the JS named export must all match the new widget name exactly.
cp -r client/dedalo/core/widgets/test/test_info client/dedalo/core/widgets/<tld>/my_widget
# then rename inside core/widgets/<tld>/my_widget:
# js/test_info.js -> js/my_widget.js
# js/render_test_info.js -> js/render_my_widget.js
# css/test_info.less -> css/my_widget.less
# and every `test_info` identifier inside those files -> `my_widget`
The resulting client layout mirrors every other widget:
client/dedalo/core/widgets/<tld>/my_widget/
├── js/
│ ├── my_widget.js # client class; named export `my_widget`
│ └── render_my_widget.js # the DOM builder (edit / list views)
└── css/
└── my_widget.less # bundled into page.css
Naming is the contract
The JS export const my_widget = … must match the directory, and the
widget_name you dispatch on server-side. The client does
import('../../../core/widgets' + path + '/js/' + widget_name + '.js'); the
server switches on widget_name. A mismatch fails silently (no module /
no data).
2. Implement the server-side compute
Create src/core/components/component_info/widgets/<tld>/my_widget.ts exporting one InfoWidgetDescriptor, and add it to the INFO_WIDGETS array in widgets/registry.ts. Model it on widgets/test/test_info.ts (the reference). computeData receives the widget's ipo array and the WidgetContext ({ sectionTipo, sectionId, mode, lang, userId? }) and returns a flat array of uniform items:
// src/core/components/component_info/widgets/<tld>/my_widget.ts
import {
type InfoWidgetDescriptor,
type TypedInput,
type WidgetContext,
type WidgetItem,
readWidgetComponentData,
resolveCurrent,
} from '../widget_common.ts';
async function computeMyWidget(ipo: unknown[], context: WidgetContext): Promise<WidgetItem[]> {
const data: WidgetItem[] = [];
for (const [key, entry] of ipo.entries()) {
const block = entry as {
input?: { source?: TypedInput[] };
output?: { id?: string }[];
};
const output = Array.isArray(block.output) ? block.output : [];
// resolve a source component value, scoping 'current' to this record
let value: unknown = null;
for (const source of block.input?.source ?? []) {
const sourceSection = String(resolveCurrent(source.section_tipo, context.sectionTipo));
const sourceId = resolveCurrent(source.section_id, context.sectionId);
if (source.component_tipo == null) continue;
// never touch storage directly — go through the component read helper
const sourceData = (await readWidgetComponentData(
sourceSection, sourceId, source.component_tipo,
)) as { value?: unknown }[];
if (sourceData.length > 0) value = sourceData[0]?.value ?? null;
}
// one data item per output map
for (const dataMap of output) {
const id = dataMap.id ?? '';
data.push({ widget: 'my_widget', key, widget_id: id, id, value });
}
}
return data;
}
export const my_widget: InfoWidgetDescriptor = {
name: 'my_widget', // = ontology widget_name = client JS export
path: '/<tld>/my_widget', // = ontology path; tripwire-bound to the client module
computeData: computeMyWidget,
};
// …and in widgets/registry.ts: add `my_widget` to the INFO_WIDGETS array.
The key rules, verified against test_info.ts / registry.ts:
- No persistence. A widget never reads or writes the matrix directly. Read inputs through the engine's own helpers —
readWidgetComponentData(sectionTipo, sectionId, componentTipo)orresolveComponentValue()(both resolve the model and pull the stored data). The hostcomponent_infois ause_db_data = falsecompute path. resolveCurrent(declared, fallback)maps the source's'current'/undefinedsection_tipo/section_idto this record's values.- Emit both
widget_idandid.component_info's grid/export builders match onid; the live-compute fallback historically emitswidget_idtoo.computeTestInfoemits both — always include both. - Async widgets are skipped at read. Declare
isAsync: trueon the descriptor; the read aggregate skips it and the client fetches it via thedd_component_infoget_widget_dataaction. - Unknown names throw. A
widget_namewith no registry entry throwsWidgetNotRegisteredErroron read. Register your descriptor — or, if the compute is deliberately not implemented yet, register a stub carrying an explicitunported.reason, which throwsWidgetUnportedErrorwith that reason. There is no third option: a widget never fails quietly.
Confined process logic (SEC-052)
Only the generic calculation widget runs an ontology-specified process
formula, and the TS registry (widgets/calculation/functions.ts
CALCULATION_FUNCTIONS) hard-codes the allowed formulas (summarize,
to_euros, calculate_period) — it does not dynamically load
ontology-supplied code (process.file / engine are ignored). If your
widget needs a configurable formula, add a STATIC entry there; do not
re-introduce a dynamic include of ontology-supplied functions.
3. Implement the client class
js/my_widget.js imports widget_common, borrows its lifecycle prototypes, and assigns its own render views. This is test_info.js renamed:
import {widget_common} from '../../../widget_common/js/widget_common.js'
import {render_my_widget} from '../js/render_my_widget.js'
export const my_widget = function(){
this.id
this.section_tipo
this.section_id
this.lang
this.mode
this.value
this.node
this.events_tokens = []
this.ar_instances = []
this.status
return true
}//end my_widget
// lifecycle (from widget_common)
my_widget.prototype.init = widget_common.prototype.init
my_widget.prototype.build = widget_common.prototype.build
my_widget.prototype.render = widget_common.prototype.render
my_widget.prototype.destroy = widget_common.prototype.destroy
// render (your own)
my_widget.prototype.edit = render_my_widget.prototype.edit
my_widget.prototype.list = render_my_widget.prototype.list
Import depth
From core/widgets/<tld>/my_widget/js/, widget_common is three levels up
(../../../widget_common/js/widget_common.js). If your widget is not
nested in a TLD folder (e.g. core/widgets/my_widget/), adjust the relative
path accordingly — calculation and state (no TLD folder) and test_info
(under test/) differ here.
4. Implement the render views
js/render_my_widget.js builds the DOM. Use the shared ui.widget helper (core/common/js/ui.js) for the wrapper, and consume self.value — the server-built slice for this widget. Renamed from render_test_info.js, the skeleton is:
import {ui} from '../../../../common/js/ui.js'
export const render_my_widget = function(){ return true }
render_my_widget.prototype.edit = async function(options) {
const self = this
const content = await get_content_data(self) // build your <ul>/<table>/…
if (options.render_level==='content') {
return content
}
return ui.widget.build_wrapper_edit(self, { content_data: content })
}//end edit
render_my_widget.prototype.list = render_my_widget.prototype.edit // or a separate builder
self.value is the array component_info.js already filtered to your widget
(value.filter(item => item.widget === widget_name)), so each entry is one of the
{widget, key, widget_id, id, value} items your compute function returned. The
list/edit views mirror state's render_edit_state.js / render_list_state.js
if you need per-mode variants.
Add css/my_widget.less (bundled into page.css) for styling — see
design system / LESS.
5. Host it from a component_info node
A widget never appears on its own. Add it to the properties.widgets array of a component_info ontology node (its parent is the section/grouper, model is component_info). Each entry names your widget_name, the path under core/widgets, and the ipo config that drives the compute:
{
"widgets": [
{
"widget_name": "my_widget",
"path" : "/<tld>/my_widget",
"widget_info": "Short developer note: what this widget summarizes",
"ipo": [
{
"input": {
"type" : "component_data",
"source": [
{ "section_id": "current", "section_tipo": "current", "component_tipo": "<comp_tipo>" }
]
},
"process": null,
"output": [
{ "id": "summary", "label": "My summary", "value": "text" }
]
}
]
}
]
}
pathis the client import path (component_info.jsbuilds the ES import from it). The server never usespathto load code — it dispatches onwidget_namethrough the registry; the tripwire only VERIFIES the descriptor'spathmatches a real client module. Keeppathpointing at your client widget folder (leading slash included) and keepwidget_namematching your descriptor'sname.- Each
outputmap'sidbecomes one logical column in the info panel's grid/export. Your compute function must emit items whoseidmatches. - For the full IPO field reference and a two-widget example, see component_info → Ontology instantiation and widgets → IPO.
After editing the ontology, regenerate and reload the record carrying that component_info field. computeInfoWidgets runs your widget on every load (it is computed, not stored: use_db_data = false).
6. (Optional) Add a test
Server compute is the natural test surface. Add a bun:test that calls computeInfoWidgets for a component_info tipo whose node carries your widget, and asserts the emitted {widget, key, widget_id, id, value} shape. The existing info-widget gates live in test/parity/info_widget_differential.test.ts — extend that pattern. Run with bun test.
Worked example: a word_count widget
Summarize how many characters a record's main text component holds, shown in its info panel.
- Client copy & rename:
cp -r client/dedalo/core/widgets/test/test_info client/dedalo/core/widgets/oh/word_count; rename the files and JS export toword_count. - Server compute (
src/core/components/component_info/widgets/oh/word_count.ts): a descriptor whosecomputeDataresolves the source component viareadWidgetComponentData, computesString(value).length, and emits{ widget:'word_count', key, widget_id:'chars', id:'chars', value }; add it towidgets/registry.ts. - Client (
js/word_count.js+js/render_word_count.js): rendersself.value[0].valueas a labelled number insideui.widget.build_wrapper_edit. - Host — add to a
component_infonode:
{
"widget_name": "word_count",
"path" : "/oh/word_count",
"ipo": [
{
"input" : { "source": [ { "section_tipo": "current", "section_id": "current", "component_tipo": "oh26" } ] },
"process": null,
"output" : [ { "id": "chars", "label": "Characters", "value": "int" } ]
}
]
}
- Regenerate and reload a record of that section: the info panel now shows
Characters: 1284, recomputed every load.
Common pitfalls
widget_namedisagrees across the three places. The descriptorname(registry key), the clientexport const/directory, and the ontologywidget_namemust all match; thepathmust point at the client folder. The registry tripwire catches path/name mismatches; an ontology name with no registry entry throws on read.- Forgetting the registry entry. A descriptor module not listed in
widgets/registry.tsINFO_WIDGETSis dead code — the ontology name throwsWidgetNotRegisteredError. - Emitting only one of
id/widget_id.component_info's grid/export match onid; the client widget renders match onwidget_id.test_info.tsemits both — do the same. - Reading the matrix directly. Don't. Resolve inputs through
readWidgetComponentData()/resolveComponentValue(); the host info component is intentionallyuse_db_data = false. - Looking for a per-widget server class. There is none — one descriptor module per widget under
src/core/components/component_info/widgets/<tld>/. - Confusing the two widget systems. A folder under
core/area_maintenance/widgets/is not acomponent_infowidget, and vice-versa. Different bases, dispatchers and contracts. - Wrong relative-import depth in the client JS when your widget is (or isn't) inside a TLD subfolder — see the note in step 3.
- Expecting persistence. Widget output is recomputed on every load; there is no value to "save". If you need stored, typed data, you want a real component, not a widget.
Related
- widgets — the
core/widgets/subsystem reference (IPO, async, security). - component_info — the host component; ontology
properties.widgets, render views, grid/export. - component_info cookbook — recipes: declare a widget, add a calculation formula, make it async, add an edit datalist, wire observers, test without an instance, debug a blank panel.
- area_maintenance — the other, unrelated widget family (admin/operational panels).
- UI / widgets overview · themes & LESS — client styling.
- Creating tools — the other extension surface (has a scaffolder and
register.json; widgets do not). - Source of truth:
src/core/components/component_info/widgets/(README.md checklist, registry.ts dispatch, one descriptor module per widget) and the client'score/widgets/tree.