Add widget-control logic action and multi-column CSV / multi-field dialogs

Extend the panel logic editor with two capabilities:

- action.widget: drive a panel widget by id from a flow — enable/disable,
  show/hide, and (for plot widgets) clear, pause, or resume the live plot.
  Wired via a per-widget command store consumed by a Canvas WidgetView wrapper
  (disable overlay / hide) and PlotWidget (pause/clear), reset on panel unmount.
- action.export now emits multiple named arrays as CSV columns with per-column
  labels and an alignment mode (common/any/interpolate); dialogs support asking
  for and displaying multiple values per dialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-19 07:51:18 +02:00
parent afefba3184
commit ba836f00e6
9 changed files with 668 additions and 102 deletions
+274 -57
View File
@@ -2,7 +2,7 @@ import { h, Fragment } from 'preact';
import { useState, useEffect, useRef } from 'preact/hooks';
import SearchableSelect from './SearchableSelect';
import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar } from './lib/types';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
@@ -17,6 +17,8 @@ function splitRef(ref: string): { ds: string; name: string } {
interface Props {
graph: LogicGraph;
onChange: (graph: LogicGraph) => void;
// The panel's widgets, offered as targets for the Widget-control action.
widgets?: Widget[];
// Panel-local state variables, offered as quick write targets / signals.
statevars?: StateVar[];
// Edit local state variables directly from the logic editor (the layout-side
@@ -56,12 +58,13 @@ const PALETTE: PaletteEntry[] = [
{ kind: 'action.write', label: 'Write', params: { target: '', expr: '' } },
{ kind: 'action.delay', label: 'Delay', params: { ms: '500' } },
{ kind: 'action.accumulate', label: 'Accumulate', params: { array: 'data', expr: '' } },
{ kind: 'action.export', label: 'Export CSV', params: { array: 'data', filename: '' } },
{ kind: 'action.export', label: 'Export CSV', params: { columns: '[{"array":"data","label":""}]', align: 'common', filename: '' } },
{ kind: 'action.clear', label: 'Clear array', params: { array: 'data' } },
{ kind: 'action.log', label: 'Log', params: { expr: '', label: '' } },
{ kind: 'action.widget', label: 'Widget control', params: { widget: '', op: 'disable' } },
{ kind: 'action.dialog.info', label: 'Info dialog', params: { title: 'Info', message: '' } },
{ kind: 'action.dialog.error', label: 'Error dialog', params: { title: 'Error', message: '' } },
{ kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { target: '', title: 'Set value', message: '', default: '' } },
{ kind: 'action.dialog.setpoint', label: 'Set-point dialog', params: { title: 'Set value', message: '', fields: '[{"type":"input","label":"","target":"","default":""}]' } },
];
const KIND_LABEL: Record<LogicNodeKind, string> = {
@@ -81,6 +84,7 @@ const KIND_LABEL: Record<LogicNodeKind, string> = {
'action.export': 'Export CSV',
'action.clear': 'Clear array',
'action.log': 'Log',
'action.widget': 'Widget control',
'action.dialog.info': 'Info dialog',
'action.dialog.error': 'Error dialog',
'action.dialog.setpoint': 'Set-point dialog',
@@ -122,7 +126,7 @@ function wirePathStr(x1: number, y1: number, x2: number, y2: number): string {
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
export default function LogicEditor({ graph, onChange, statevars, onStateVarsChange }: Props) {
export default function LogicEditor({ graph, onChange, widgets = [], statevars, onStateVarsChange }: Props) {
const nodes = graph.nodes;
const wires = graph.wires;
@@ -182,9 +186,14 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
if (selected?.kind === 'trigger.threshold' || selected?.kind === 'trigger.change') {
loadSignals(splitRef(selected.params.signal ?? '').ds);
}
if (selected?.kind === 'action.write' || selected?.kind === 'action.dialog.setpoint') {
if (selected?.kind === 'action.write') {
loadSignals(splitRef(selected.params.target ?? '').ds);
}
if (selected && selected.kind.startsWith('action.dialog.')) {
for (const f of parseDialogFields(selected)) {
if (f.type === 'input') loadSignals(splitRef(f.target ?? '').ds);
}
}
}, [selectedNode, selected?.kind]);
// ── History ────────────────────────────────────────────────────────────────
@@ -644,21 +653,13 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
)}
{selected.kind === 'action.export' && (
<Fragment>
<div class="wizard-field">
<label>Array name</label>
<input class="prop-input" value={selected.params.array ?? ''} list="flow-array-names"
placeholder="e.g. data"
onInput={(e) => patchParams(selected.id, { array: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>File name</label>
<input class="prop-input" value={selected.params.filename ?? ''}
placeholder="defaults to the array name"
onInput={(e) => patchParams(selected.id, { filename: (e.target as HTMLInputElement).value })} />
<p class="hint">Downloads the array as CSV (timestamp_ms, iso, value).</p>
</div>
</Fragment>
<ExportEditor
columns={parseExportColumns(selected)}
align={selected.params.align ?? 'common'}
filename={selected.params.filename ?? ''}
onColumns={(cols) => patchParams(selected.id, { columns: JSON.stringify(cols) })}
onAlign={(a) => patchParams(selected.id, { align: a })}
onFilename={(f) => patchParams(selected.id, { filename: f })} />
)}
{selected.kind === 'action.clear' && (
@@ -687,6 +688,40 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
</Fragment>
)}
{selected.kind === 'action.widget' && (() => {
const op = selected.params.op ?? 'disable';
const plotOnly = op === 'clearplot' || op === 'pauseplot' || op === 'resumeplot';
const choices = plotOnly ? widgets.filter(w => w.type === 'plot') : widgets;
return (
<Fragment>
<div class="wizard-field">
<label>Action</label>
<select class="prop-input" value={op}
onChange={(e) => patchParams(selected.id, { op: (e.target as HTMLSelectElement).value })}>
<option value="enable">Enable</option>
<option value="disable">Disable</option>
<option value="show">Show</option>
<option value="hide">Hide</option>
<option value="clearplot">Clear plot</option>
<option value="pauseplot">Pause plot</option>
<option value="resumeplot">Resume plot</option>
</select>
</div>
<div class="wizard-field">
<label>Widget</label>
<select class="prop-input" value={selected.params.widget ?? ''}
onChange={(e) => patchParams(selected.id, { widget: (e.target as HTMLSelectElement).value })}>
<option value=""> select a widget </option>
{choices.map(w => (
<option value={w.id}>{widgetLabel(w)}</option>
))}
</select>
{plotOnly && <p class="hint">Only plot widgets are listed for this action.</p>}
</div>
</Fragment>
);
})()}
{(selected.kind === 'trigger.start' || selected.kind === 'trigger.stop') && (
<p class="hint">{selected.kind === 'trigger.start'
? 'Fires once when the panel is opened — use it to initialise state or greet the operator.'
@@ -709,44 +744,35 @@ export default function LogicEditor({ graph, onChange, statevars, onStateVarsCha
<p class="hint">Waits for the operator to dismiss the dialog before continuing.
Embed live values with <code>{'{ds:name}'}</code> (e.g. Level is {'{stub:level}'}).</p>
</div>
<DialogFieldsEditor allowInput={false}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
</Fragment>
)}
{selected.kind === 'action.dialog.setpoint' && (() => {
const { ds, name } = splitRef(selected.params.target ?? '');
return (
<Fragment>
<div class="wizard-field">
<label>Target data source</label>
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
</div>
<div class="wizard-field">
<label>Target signal</label>
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
</div>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder="Set value"
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
placeholder="Prompt shown above the input. Embed values as {ds:name}."
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
<ExprField label="Default value (expression)" value={selected.params.default ?? ''}
onChange={(v) => patchParams(selected.id, { default: v })}
onInsert={(ds2, sig) => insertRef(selected.id, 'default', ds2, sig)}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
hint="Pre-fills the input (e.g. the current set-point {local:sp}). The operator can edit it; OK writes the entered number to the target, Cancel aborts the flow." />
</Fragment>
);
})()}
{selected.kind === 'action.dialog.setpoint' && (
<Fragment>
<div class="wizard-field">
<label>Title</label>
<input class="prop-input" value={selected.params.title ?? ''}
placeholder="Set value"
onInput={(e) => patchParams(selected.id, { title: (e.target as HTMLInputElement).value })} />
</div>
<div class="wizard-field">
<label>Message</label>
<textarea class="prop-input" rows={3} value={selected.params.message ?? ''}
placeholder="Prompt shown above the fields. Embed values as {ds:name}."
onInput={(e) => patchParams(selected.id, { message: (e.target as HTMLTextAreaElement).value })} />
</div>
<DialogFieldsEditor allowInput={true}
fields={parseDialogFields(selected)}
onChange={(f) => patchParams(selected.id, { fields: JSON.stringify(f) })}
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals} />
<p class="hint">Each <b>input</b> prompts the operator and writes the entered number to its
target on OK; each <b>display</b> shows a live value. Cancel aborts the flow.</p>
</Fragment>
)}
<button class="panel-btn" style="margin-top:1rem;" onClick={() => deleteNode(selected.id)}>Delete node</button>
</Fragment>
@@ -801,6 +827,184 @@ function ExprField({ label, value, onChange, onInsert, dsOptions, signalOptions,
);
}
// ── Export CSV: multi-column editor ──────────────────────────────────────────
interface ExportColumn { array: string; label: string; }
// The columns configured on an action.export node. Reads the JSON `columns`
// param; falls back to the legacy single `array` param (so old panels migrate
// transparently the first time the node is edited).
function parseExportColumns(n: LogicNode): ExportColumn[] {
const raw = (n.params.columns ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((c: any) => ({ array: String(c?.array ?? ''), label: String(c?.label ?? '') }));
}
} catch {}
}
const a = (n.params.array ?? '').trim();
return [{ array: a, label: '' }];
}
const ALIGN_OPTS: Array<{ value: string; label: string; hint: string }> = [
{ value: 'common', label: 'Common rows', hint: 'Only timestamps present in every array (intersection).' },
{ value: 'any', label: 'All rows', hint: 'Union of all timestamps; missing cells are left blank.' },
{ value: 'interpolate', label: 'Interpolate', hint: 'Union of all timestamps; missing cells linearly interpolated.' },
];
function ExportEditor({ columns, align, filename, onColumns, onAlign, onFilename }: {
columns: ExportColumn[];
align: string;
filename: string;
onColumns: (cols: ExportColumn[]) => void;
onAlign: (a: string) => void;
onFilename: (f: string) => void;
}) {
function patch(i: number, patch: Partial<ExportColumn>) {
onColumns(columns.map((c, j) => (j === i ? { ...c, ...patch } : c)));
}
const alignHint = ALIGN_OPTS.find(o => o.value === align)?.hint ?? '';
return (
<Fragment>
<div class="wizard-field">
<label>Columns (arrays)</label>
{columns.map((c, i) => (
<div key={i} class="flow-row-edit">
<input class="prop-input" value={c.array} list="flow-array-names"
placeholder="array name"
onInput={(e) => patch(i, { array: (e.target as HTMLInputElement).value })} />
<input class="prop-input" value={c.label}
placeholder="column label (optional)"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
<button class="flow-node-del" title="Remove column"
disabled={columns.length <= 1}
onClick={() => onColumns(columns.filter((_, j) => j !== i))}></button>
</div>
))}
<button class="panel-btn flow-row-add" onClick={() => onColumns([...columns, { array: '', label: '' }])}>+ Add column</button>
</div>
{columns.length > 1 && (
<div class="wizard-field">
<label>Alignment</label>
<select class="prop-select" value={align} onChange={(e) => onAlign((e.target as HTMLSelectElement).value)}>
{ALIGN_OPTS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<p class="hint">{alignHint}</p>
</div>
)}
<div class="wizard-field">
<label>File name</label>
<input class="prop-input" value={filename}
placeholder="defaults to the column labels"
onInput={(e) => onFilename((e.target as HTMLInputElement).value)} />
<p class="hint">Downloads a CSV with columns: timestamp_ms, iso, then one column per array.</p>
</div>
</Fragment>
);
}
// ── Dialogs: multi-field editor ──────────────────────────────────────────────
interface DialogFieldSpec { type: 'input' | 'display'; label: string; target?: string; default?: string; expr?: string; }
// The fields configured on an action.dialog.* node. Reads the JSON `fields`
// param; for a legacy set-point node it synthesises a single input from the old
// target/default params so existing panels migrate transparently.
function parseDialogFields(n: LogicNode): DialogFieldSpec[] {
const raw = (n.params.fields ?? '').trim();
if (raw) {
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed.map((f: any) => ({
type: f?.type === 'display' ? 'display' : 'input',
label: String(f?.label ?? ''),
target: String(f?.target ?? ''),
default: String(f?.default ?? ''),
expr: String(f?.expr ?? ''),
}));
}
} catch {}
}
if (n.kind === 'action.dialog.setpoint') {
return [{ type: 'input', label: '', target: n.params.target ?? '', default: n.params.default ?? '', expr: '' }];
}
return [];
}
function DialogFieldsEditor({ allowInput, fields, onChange, dsOptions, signalOptions, loadSignals }: {
allowInput: boolean;
fields: DialogFieldSpec[];
onChange: (fields: DialogFieldSpec[]) => void;
dsOptions: string[];
signalOptions: (ds: string) => string[];
loadSignals: (ds: string) => void;
}) {
function patch(i: number, patch: Partial<DialogFieldSpec>) {
onChange(fields.map((f, j) => (j === i ? { ...f, ...patch } : f)));
}
function add(type: 'input' | 'display') {
onChange([...fields, { type, label: '', target: '', default: '', expr: '' }]);
}
return (
<div class="wizard-field">
<label>{allowInput ? 'Fields' : 'Displayed values'}</label>
{fields.length === 0 && <p class="hint">None yet.</p>}
{fields.map((f, i) => {
const { ds, name } = splitRef(f.target ?? '');
return (
<div key={i} class="flow-field-edit">
<div class="flow-field-head">
{allowInput ? (
<select class="prop-select" value={f.type}
onChange={(e) => patch(i, { type: (e.target as HTMLSelectElement).value as 'input' | 'display' })}>
<option value="input">Input</option>
<option value="display">Display</option>
</select>
) : <span class="flow-field-kind">Display</span>}
<input class="prop-input" value={f.label} placeholder="label"
onInput={(e) => patch(i, { label: (e.target as HTMLInputElement).value })} />
<button class="flow-node-del" title="Remove field"
onClick={() => onChange(fields.filter((_, j) => j !== i))}></button>
</div>
{f.type === 'input' ? (
<Fragment>
<div class="flow-row-edit">
<SearchableSelect value={ds} options={dsOptions}
onSelect={(newDs) => { loadSignals(newDs); patch(i, { target: `${newDs}:` }); }} placeholder="ds…" />
<SearchableSelect value={name} options={signalOptions(ds)}
onSelect={(sig) => patch(i, { target: `${ds}:${sig}` })}
placeholder={ds ? 'signal…' : 'pick ds'} />
</div>
<input class={`prop-input${checkExpr(f.default ?? '') ? ' prop-input-error' : ''}`}
value={f.default ?? ''} placeholder="default value (expression, optional)"
onInput={(e) => patch(i, { default: (e.target as HTMLInputElement).value })} />
</Fragment>
) : (
<input class={`prop-input${checkExpr(f.expr ?? '') ? ' prop-input-error' : ''}`}
value={f.expr ?? ''} placeholder="value to show (expression, e.g. {stub:level})"
onInput={(e) => patch(i, { expr: (e.target as HTMLInputElement).value })} />
)}
</div>
);
})}
<div class="flow-field-add">
{allowInput && <button class="panel-btn flow-row-add" onClick={() => add('input')}>+ Add input</button>}
<button class="panel-btn flow-row-add" onClick={() => add('display')}>+ Add value to show</button>
</div>
</div>
);
}
// A human-friendly label for a widget in the Widget-control picker: its type
// plus its first signal (or id) so panels with many widgets stay legible.
function widgetLabel(w: Widget): string {
const sig = w.signals[0] ? `${w.signals[0].ds}:${w.signals[0].name}` : '';
return `${w.type}${sig ? ' · ' + sig : ''} (${w.id})`;
}
// One-line summary shown in a node block's body.
function nodeSummary(n: LogicNode): string {
switch (n.kind) {
@@ -819,12 +1023,25 @@ function nodeSummary(n: LogicNode): string {
case 'action.write': return `${n.params.target || '?'} = ${n.params.expr || ''}`;
case 'action.delay': return `wait ${n.params.ms || '0'} ms`;
case 'action.accumulate': return `${n.params.array || '?'}${n.params.expr || ''}`;
case 'action.export': return `export ${n.params.array || '?'} → csv`;
case 'action.export': {
const cols = parseExportColumns(n).filter(c => c.array);
const names = cols.map(c => c.label || c.array).join(', ');
return `export ${names || '?'} → csv`;
}
case 'action.clear': return `clear ${n.params.array || '?'}`;
case 'action.log': return `log ${n.params.label ? n.params.label + ': ' : ''}${n.params.expr || ''}`;
case 'action.widget': return `${n.params.op || 'disable'} ${n.params.widget || '?'}`;
case 'action.dialog.info': return `info: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.error': return `error: ${n.params.title || n.params.message || '…'}`;
case 'action.dialog.setpoint': return `set ${n.params.target || '?'} (ask)`;
case 'action.dialog.setpoint': {
const fs = parseDialogFields(n);
const ins = fs.filter(f => f.type === 'input').length;
const shows = fs.filter(f => f.type === 'display').length;
const parts: string[] = [];
if (ins) parts.push(`ask ${ins}`);
if (shows) parts.push(`show ${shows}`);
return parts.length ? parts.join(', ') : 'ask…';
}
default: return '';
}
}