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:
+274
-57
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user