feat(dashboard): WorkflowFieldsPanel — field-definition authoring with live badge preview (U13 completion)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 12:44:43 -07:00
parent 14758f8edb
commit e87e745379
12 changed files with 1311 additions and 5 deletions

View File

@@ -0,0 +1,195 @@
/* WorkflowFieldsPanel (U13 / KTD-14) — sibling of the column panel; mirrors
* .wf-column-panel layout so the two read-side-by-side in the editor. */
.wf-fields-panel {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 300px;
min-width: 280px;
padding: var(--space-md);
border-left: 1px solid var(--border);
overflow-y: auto;
}
.wf-fields-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.wf-fields-add {
display: inline-flex;
align-items: center;
gap: 4px;
}
.wf-fields-panel-empty {
font-size: 0.75rem;
color: var(--text-muted);
margin: 0;
}
.wf-fields-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.wf-field-item {
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.wf-field-item-head {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.wf-field-name {
flex: 1;
min-width: 0;
}
.wf-field-id-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
}
.wf-field-id-static {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
color: var(--text-tertiary);
background: var(--surface-2, rgba(255, 255, 255, 0.04));
padding: 1px 6px;
border-radius: var(--radius-sm);
}
.wf-field-id-edit {
font-size: 0.65rem;
background: none;
border: none;
color: var(--accent, #4f7cff);
cursor: pointer;
padding: 0;
}
.wf-field-id-warn {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
margin: 0;
font-size: 0.65rem;
color: var(--ws-warning, #f59e0b);
}
.wf-field-row {
display: flex;
align-items: flex-end;
gap: var(--space-sm);
}
.wf-field-sub {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
font-size: 0.7rem;
color: var(--text-muted);
}
.wf-field-sub > span {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-tertiary);
}
.wf-field--checkbox {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
color: var(--text-muted);
}
.wf-field-required {
flex: 0 0 auto;
white-space: nowrap;
}
.wf-field-options {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-top: var(--space-xs);
border-top: 1px dashed var(--border);
}
.wf-field-options-label {
font-size: 0.65rem;
text-transform: uppercase;
color: var(--text-tertiary);
}
.wf-field-option-row {
display: flex;
align-items: center;
gap: 4px;
}
.wf-field-option-value,
.wf-field-option-label {
flex: 1;
min-width: 0;
}
.wf-field-option-colors {
display: inline-flex;
gap: 2px;
}
.wf-field-color-swatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid var(--border);
padding: 0;
cursor: pointer;
}
.wf-field-color-swatch.is-active {
outline: 2px solid var(--text-primary, #fff);
outline-offset: 1px;
}
.wf-field-option-add {
display: inline-flex;
align-items: center;
gap: 4px;
align-self: flex-start;
font-size: 0.7rem;
}
.wf-field-render {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding-top: var(--space-xs);
border-top: 1px dashed var(--border);
}
.wf-field-preview {
padding-top: var(--space-xs);
}

View File

@@ -0,0 +1,520 @@
/**
* WorkflowFieldsPanel — the workflow editor's custom-field authoring surface
* (U13 / KTD-14). Sibling to {@link WorkflowColumnPanel}: lives alongside the
* canvas in {@link WorkflowNodeEditor} and mutates the IR's `fields` array
* through the same state/save flow.
*
* Each field has: an immutable kebab-case `id` (editing it is remove+add
* semantics — the panel warns rather than silently re-keying values), a display
* `name`, a `type` (string|text|number|boolean|enum|multi-enum|date|url), a
* `required` toggle, a typed `default`, an options editor (value/label/color)
* for the enum kinds, and `render` controls (placement, widget, badge).
*
* Card-placed fields show a live badge preview reusing TaskCard's
* `.card-field-badge` classes so the authored chip matches the board exactly.
*
* Core validation (unique ids, options-required-for-enums, render whitelists)
* runs server-side at save and surfaces through the editor's existing inline
* mechanism — this panel only does light client guards and renders the
* resulting message via the shared error band.
*/
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, AlertTriangle } from "lucide-react";
import type {
WorkflowFieldDefinition,
WorkflowFieldType,
WorkflowFieldOption,
} from "../api";
import type { ToastType } from "../hooks/useToast";
import "./WorkflowFieldsPanel.css";
interface WorkflowFieldsPanelProps {
fields: WorkflowFieldDefinition[];
onChange: (next: WorkflowFieldDefinition[]) => void;
readOnly: boolean;
addToast: (message: string, type?: ToastType) => void;
}
const FIELD_TYPES: WorkflowFieldType[] = [
"string",
"text",
"number",
"boolean",
"enum",
"multi-enum",
"date",
"url",
];
/** Widgets valid per field type (the validator's whitelist mirrored client-side
* so the editor only offers legal combinations). */
const WIDGETS_BY_TYPE: Record<WorkflowFieldType, NonNullable<WorkflowFieldDefinition["render"]>["widget"][]> = {
string: ["input"],
text: ["textarea", "input"],
number: ["input"],
boolean: ["toggle"],
enum: ["select", "radio", "chips"],
"multi-enum": ["chips"],
date: ["input"],
url: ["input"],
};
/** A small preset palette for enum option colors (no dedicated color-picker
* component exists in the editor; the column panel uses none). */
const PRESET_COLORS = [
"#4f7cff",
"#22c55e",
"#f59e0b",
"#ef4444",
"#a855f7",
"#06b6d4",
"#ec4899",
"#64748b",
];
function isEnumKind(type: WorkflowFieldType): boolean {
return type === "enum" || type === "multi-enum";
}
/** Slugify a free-typed id into kebab-case (the validator accepts any non-empty
* string id, but kebab-case is the authoring convention). */
function kebab(raw: string): string {
return raw
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
let fieldSeq = 0;
function newFieldId(): string {
fieldSeq += 1;
return `field-${Date.now().toString(36)}-${fieldSeq}`;
}
/** A live badge preview for a card-placed field, styled exactly like a TaskCard
* badge (reuses `.card-field-badge` classes). */
function FieldBadgePreview({ field }: { field: WorkflowFieldDefinition }) {
const sample = useMemo<{ node: React.ReactNode } | null>(() => {
if (isEnumKind(field.type)) {
const opt = field.options?.[0];
if (!opt) return null;
if (field.type === "multi-enum") {
return {
node: (
<span className="card-field-badge card-field-badge--multi" title={field.name}>
{(field.options ?? []).slice(0, 2).map((o) => (
<span
key={o.value}
className="card-field-badge-token"
style={o.color ? { backgroundColor: o.color, borderColor: o.color, color: "#fff" } : undefined}
>
{o.label}
</span>
))}
</span>
),
};
}
return {
node: (
<span
className="card-field-badge card-field-badge--enum"
title={`${field.name}: ${opt.label}`}
style={opt.color ? { backgroundColor: opt.color, borderColor: opt.color, color: "#fff" } : undefined}
>
{opt.label}
</span>
),
};
}
if (field.type === "boolean") {
return {
node: (
<span className="card-field-badge card-field-badge--boolean" title={field.name}>
{field.name}
</span>
),
};
}
// string / text / number / date / url → simple labeled chip with sample text.
const sampleText =
field.type === "number" ? "42" : field.type === "date" ? "2026-06-04" : field.type === "url" ? "example.com" : field.name;
return {
node: (
<span className="card-field-badge" title={field.name}>
{sampleText}
</span>
),
};
}, [field]);
if (!sample) return null;
return (
<div className="wf-field-preview" data-testid={`wf-field-preview-${field.id}`}>
<div className="card-field-badges">{sample.node}</div>
</div>
);
}
export function WorkflowFieldsPanel({ fields, onChange, readOnly, addToast }: WorkflowFieldsPanelProps) {
const { t } = useTranslation("app");
// Per-field "editing the id" disclosure: editing an id is remove+add and is
// gated behind an explicit affordance so values are not silently re-keyed.
const [editingId, setEditingId] = useState<string | null>(null);
const patchField = useCallback(
(id: string, patch: Partial<WorkflowFieldDefinition>) => {
onChange(fields.map((f) => (f.id === id ? { ...f, ...patch } : f)));
},
[fields, onChange],
);
const addField = useCallback(() => {
const id = newFieldId();
onChange([
...fields,
{ id, name: t("workflowFields.newFieldName", "New field"), type: "string" },
]);
}, [fields, onChange, t]);
const removeField = useCallback(
(id: string) => {
onChange(fields.filter((f) => f.id !== id));
},
[fields, onChange],
);
const changeId = useCallback(
(oldId: string, raw: string) => {
const next = kebab(raw);
if (!next) return;
if (next !== oldId && fields.some((f) => f.id === next)) {
addToast(t("workflowFields.duplicateId", "A field with that id already exists"), "error");
return;
}
patchField(oldId, { id: next });
},
[fields, patchField, addToast, t],
);
const changeType = useCallback(
(id: string, type: WorkflowFieldType) => {
const field = fields.find((f) => f.id === id);
if (!field) return;
const patch: Partial<WorkflowFieldDefinition> = { type };
// Options only valid for enum kinds — seed an empty list when switching to
// an enum kind, strip it otherwise (validator: options iff enum-kind).
if (isEnumKind(type)) {
if (!field.options || field.options.length === 0) {
patch.options = [{ value: "option-1", label: t("workflowFields.newOptionLabel", "Option 1") }];
}
} else {
patch.options = undefined;
}
// Reset a now-invalid widget to the type's default (first valid widget).
if (field.render?.widget && !WIDGETS_BY_TYPE[type].includes(field.render.widget)) {
patch.render = { ...field.render, widget: undefined };
}
// Default value type changed — clear it to avoid a type-mismatch at save.
patch.default = undefined;
patchField(id, patch);
},
[fields, patchField, t],
);
const setOptions = useCallback(
(id: string, options: WorkflowFieldOption[]) => patchField(id, { options }),
[patchField],
);
const setRender = useCallback(
(id: string, render: WorkflowFieldDefinition["render"]) => {
// Drop an all-empty render object so v1/zero-field round-trips stay clean.
const empty = !render || (render.placement === undefined && render.widget === undefined && !render.badge);
patchField(id, { render: empty ? undefined : render });
},
[patchField],
);
const renderDefaultInput = (field: WorkflowFieldDefinition) => {
const commit = (value: unknown) => patchField(field.id, { default: value });
if (field.type === "boolean") {
return (
<label className="wf-field--checkbox">
<input
type="checkbox"
checked={field.default === true}
disabled={readOnly}
onChange={(e) => commit(e.target.checked)}
/>
<span>{t("workflowFields.defaultTrue", "Default on")}</span>
</label>
);
}
if (isEnumKind(field.type)) {
const current = field.type === "multi-enum"
? (Array.isArray(field.default) ? (field.default as string[])[0] ?? "" : "")
: (typeof field.default === "string" ? field.default : "");
return (
<select
aria-label={t("workflowFields.defaultLabel", "Default value")}
value={current}
disabled={readOnly}
onChange={(e) => {
const v = e.target.value;
if (v === "") return commit(undefined);
commit(field.type === "multi-enum" ? [v] : v);
}}
>
<option value="">{t("workflowFields.noDefault", "— none —")}</option>
{(field.options ?? []).map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
const typeAttr = field.type === "number" ? "number" : field.type === "date" ? "date" : field.type === "url" ? "url" : "text";
const currentText = field.type === "number"
? (typeof field.default === "number" ? String(field.default) : "")
: (typeof field.default === "string" ? field.default : "");
return (
<input
type={typeAttr}
aria-label={t("workflowFields.defaultLabel", "Default value")}
defaultValue={currentText}
disabled={readOnly}
onBlur={(e) => {
const raw = e.target.value;
if (raw === "") return commit(undefined);
commit(field.type === "number" ? Number(raw) : raw);
}}
/>
);
};
return (
<aside className="wf-fields-panel" data-testid="wf-fields-panel">
<header className="wf-fields-panel-header">
<h3>{t("workflowFields.title", "Fields")}</h3>
<button
className="wf-fields-add"
onClick={addField}
disabled={readOnly}
title={readOnly ? t("workflowFields.readOnlyHint", "Built-in workflows are read-only — duplicate to edit") : undefined}
>
<Plus size={13} /> {t("workflowFields.add", "Add field")}
</button>
</header>
{fields.length === 0 ? (
<p className="wf-fields-panel-empty">
{t("workflowFields.empty", "No custom fields yet. Add a field to extend the task form and cards.")}
</p>
) : (
<ul className="wf-fields-list">
{fields.map((field) => {
const widgets = WIDGETS_BY_TYPE[field.type];
const placement = field.render?.placement ?? "detail";
const idEditing = editingId === field.id;
return (
<li key={field.id} className="wf-field-item" data-testid={`wf-field-${field.id}`}>
<div className="wf-field-item-head">
<input
className="wf-field-name"
aria-label={t("workflowFields.nameLabel", "Field name")}
value={field.name}
disabled={readOnly}
onChange={(e) => patchField(field.id, { name: e.target.value })}
/>
<button
className="wf-field-remove"
aria-label={t("workflowFields.remove", "Remove field")}
disabled={readOnly}
onClick={() => removeField(field.id)}
>
<Trash2 size={13} />
</button>
</div>
{/* Immutable id with explicit "edit id" affordance (remove+add). */}
<div className="wf-field-id-row">
{idEditing ? (
<>
<input
className="wf-field-id"
aria-label={t("workflowFields.idLabel", "Field id")}
defaultValue={field.id}
disabled={readOnly}
onBlur={(e) => {
changeId(field.id, e.target.value);
setEditingId(null);
}}
/>
<p className="wf-field-id-warn" role="note">
<AlertTriangle size={11} aria-hidden />{" "}
{t("workflowFields.idWarn", "Changing the id discards values stored under the old id (remove + add).")}
</p>
</>
) : (
<>
<code className="wf-field-id-static">{field.id}</code>
<button
className="wf-field-id-edit"
disabled={readOnly}
onClick={() => setEditingId(field.id)}
>
{t("workflowFields.editId", "Edit id")}
</button>
</>
)}
</div>
<div className="wf-field-row">
<label className="wf-field-sub">
<span>{t("workflowFields.typeLabel", "Type")}</span>
<select
value={field.type}
disabled={readOnly}
onChange={(e) => changeType(field.id, e.target.value as WorkflowFieldType)}
>
{FIELD_TYPES.map((ty) => (
<option key={ty} value={ty}>{ty}</option>
))}
</select>
</label>
<label className="wf-field--checkbox wf-field-required">
<input
type="checkbox"
checked={field.required === true}
disabled={readOnly}
onChange={(e) => patchField(field.id, { required: e.target.checked || undefined })}
/>
<span>{t("workflowFields.required", "Required")}</span>
</label>
</div>
<label className="wf-field-sub">
<span>{t("workflowFields.default", "Default")}</span>
{renderDefaultInput(field)}
</label>
{isEnumKind(field.type) && (
<div className="wf-field-options" data-testid={`wf-field-options-${field.id}`}>
<span className="wf-field-options-label">{t("workflowFields.options", "Options")}</span>
{(field.options ?? []).map((opt, i) => (
<div key={i} className="wf-field-option-row">
<input
className="wf-field-option-value"
aria-label={t("workflowFields.optionValue", "Option value")}
value={opt.value}
disabled={readOnly}
onChange={(e) => {
const next = [...(field.options ?? [])];
next[i] = { ...opt, value: e.target.value };
setOptions(field.id, next);
}}
/>
<input
className="wf-field-option-label"
aria-label={t("workflowFields.optionLabel", "Option label")}
value={opt.label}
disabled={readOnly}
onChange={(e) => {
const next = [...(field.options ?? [])];
next[i] = { ...opt, label: e.target.value };
setOptions(field.id, next);
}}
/>
<div className="wf-field-option-colors" role="group" aria-label={t("workflowFields.optionColor", "Option color")}>
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
className={`wf-field-color-swatch${opt.color === c ? " is-active" : ""}`}
style={{ backgroundColor: c }}
aria-label={c}
aria-pressed={opt.color === c}
disabled={readOnly}
onClick={() => {
const next = [...(field.options ?? [])];
next[i] = { ...opt, color: opt.color === c ? undefined : c };
setOptions(field.id, next);
}}
/>
))}
</div>
<button
className="wf-field-option-remove"
aria-label={t("workflowFields.removeOption", "Remove option")}
disabled={readOnly}
onClick={() => setOptions(field.id, (field.options ?? []).filter((_, j) => j !== i))}
>
<Trash2 size={12} />
</button>
</div>
))}
<button
className="wf-field-option-add"
disabled={readOnly}
onClick={() => {
const n = (field.options ?? []).length + 1;
setOptions(field.id, [
...(field.options ?? []),
{ value: `option-${n}`, label: t("workflowFields.optionN", "Option {{n}}", { n }) },
]);
}}
>
<Plus size={12} /> {t("workflowFields.addOption", "Add option")}
</button>
</div>
)}
<div className="wf-field-render">
<label className="wf-field-sub">
<span>{t("workflowFields.placement", "Placement")}</span>
<select
value={placement}
disabled={readOnly}
onChange={(e) => setRender(field.id, { ...field.render, placement: e.target.value as "card" | "detail" | "detail-section" })}
>
<option value="detail">{t("workflowFields.placementDetail", "Detail (inline)")}</option>
<option value="detail-section">{t("workflowFields.placementSection", "Detail section")}</option>
<option value="card">{t("workflowFields.placementCard", "Card badge")}</option>
</select>
</label>
<label className="wf-field-sub">
<span>{t("workflowFields.widget", "Widget")}</span>
<select
value={field.render?.widget ?? ""}
disabled={readOnly}
onChange={(e) => setRender(field.id, { ...field.render, widget: (e.target.value || undefined) as NonNullable<WorkflowFieldDefinition["render"]>["widget"] })}
>
<option value="">{t("workflowFields.widgetDefault", "Default")}</option>
{widgets.map((w) => (
<option key={w} value={w}>{w}</option>
))}
</select>
</label>
<label className="wf-field--checkbox">
<input
type="checkbox"
checked={field.render?.badge === true}
disabled={readOnly}
onChange={(e) => setRender(field.id, { ...field.render, badge: e.target.checked || undefined })}
/>
<span>{t("workflowFields.badge", "Render as badge")}</span>
</label>
</div>
{placement === "card" && <FieldBadgePreview field={field} />}
</li>
);
})}
</ul>
)}
</aside>
);
}
export default WorkflowFieldsPanel;

View File

@@ -41,6 +41,7 @@ import {
emptyWorkflowIr,
emptyWorkflowLayout,
columnsOf,
fieldsOf,
columnsToBandNodes,
strictColumnForY,
validateColumnsClient,
@@ -55,6 +56,8 @@ import {
} from "./workflow-flow-mapping";
import { fetchTraits, type TraitCatalogEntry } from "../api";
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
import type { WorkflowFieldDefinition } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
type ExecutorKind = "model" | "agent" | "skill" | "cli";
@@ -132,6 +135,8 @@ function InnerEditor({
const { t } = useTranslation("app");
// v2 columns the editor is authoring for the active workflow.
const [columns, setColumns] = useState<WorkflowIrColumn[]>([]);
// v2 custom field definitions the editor is authoring (KTD-13/14, U13).
const [fields, setFields] = useState<WorkflowFieldDefinition[]>([]);
const [traitCatalog, setTraitCatalog] = useState<TraitCatalogEntry[]>([]);
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
@@ -185,12 +190,14 @@ function InnerEditor({
setNodes([]);
setEdges([]);
setColumns([]);
setFields([]);
return;
}
const flow = irToFlow(activeWorkflow);
setNodes(flow.nodes);
setEdges(flow.edges);
setColumns(columnsOf(activeWorkflow));
setFields(fieldsOf(activeWorkflow) as WorkflowFieldDefinition[]);
setSelectedNodeId(null);
setSelectedEdgeId(null);
setValidationError(null);
@@ -430,7 +437,13 @@ function InnerEditor({
setValidationError(null);
setServerNodeError(null);
try {
const { ir, layout } = flowToIr(activeWorkflow.name, nodes, edges, columns.length ? columns : undefined);
const { ir, layout } = flowToIr(
activeWorkflow.name,
nodes,
edges,
columns.length ? columns : undefined,
fields.length ? fields : undefined,
);
const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId);
setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w)));
// Validate by compiling — surfaces non-linear graphs as a banner.
@@ -456,7 +469,7 @@ function InnerEditor({
} finally {
setSaving(false);
}
}, [activeWorkflow, nodes, edges, columns, unplaced, blockingViolationCount, projectId, addToast, t]);
}, [activeWorkflow, nodes, edges, columns, fields, unplaced, blockingViolationCount, projectId, addToast, t]);
// Stamp the shared error-state badge onto offending nodes: unplaced step
// nodes and any node the server flagged (seam-in-branch). One component
@@ -690,6 +703,15 @@ function InnerEditor({
/>
)}
{activeWorkflow && (
<WorkflowFieldsPanel
fields={fields}
onChange={setFields}
readOnly={isBuiltin}
addToast={addToast}
/>
)}
{selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (
<aside className="wf-editor-inspector">
<h3>Node</h3>

View File

@@ -0,0 +1,327 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, within } from "@testing-library/react";
import { useState } from "react";
import type { WorkflowDefinition } from "@fusion/core";
import type { WorkflowFieldDefinition } from "../../api";
import { WorkflowFieldsPanel } from "../WorkflowFieldsPanel";
// ── Standalone (controlled) harness ──────────────────────────────────────────
// The panel is a controlled component (fields + onChange). A tiny stateful host
// mirrors how WorkflowNodeEditor drives it so edits round-trip through React.
function Host({
initial,
readOnly = false,
addToast = () => {},
onState,
}: {
initial: WorkflowFieldDefinition[];
readOnly?: boolean;
addToast?: (m: string, t?: "success" | "error" | "info" | "warning") => void;
onState?: (f: WorkflowFieldDefinition[]) => void;
}) {
const [fields, setFields] = useState<WorkflowFieldDefinition[]>(initial);
return (
<WorkflowFieldsPanel
fields={fields}
readOnly={readOnly}
addToast={addToast}
onChange={(next) => {
setFields(next);
onState?.(next);
}}
/>
);
}
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("WorkflowFieldsPanel — standalone", () => {
it("renders an empty state and adds a default string field", () => {
let latest: WorkflowFieldDefinition[] = [];
render(<Host initial={[]} onState={(f) => (latest = f)} />);
expect(screen.getByText(/No custom fields yet/i)).toBeInTheDocument();
fireEvent.click(screen.getByText("Add field").closest("button")!);
expect(latest).toHaveLength(1);
expect(latest[0].type).toBe("string");
expect(latest[0].name).toBe("New field");
});
it("changes a field to each supported type", () => {
let latest: WorkflowFieldDefinition[] = [];
render(
<Host
initial={[{ id: "f1", name: "F1", type: "string" }]}
onState={(f) => (latest = f)}
/>,
);
const typeSelect = within(screen.getByTestId("wf-field-f1")).getByDisplayValue("string");
for (const ty of ["text", "number", "boolean", "enum", "multi-enum", "date", "url"]) {
fireEvent.change(typeSelect, { target: { value: ty } });
expect(latest[0].type).toBe(ty);
}
});
it("seeds options when switching to enum and edits option value/label/color", () => {
let latest: WorkflowFieldDefinition[] = [];
render(
<Host
initial={[{ id: "sev", name: "Severity", type: "string" }]}
onState={(f) => (latest = f)}
/>,
);
const row = screen.getByTestId("wf-field-sev");
fireEvent.change(within(row).getByDisplayValue("string"), { target: { value: "enum" } });
// Options editor appears with a seeded option.
const opts = screen.getByTestId("wf-field-options-sev");
expect(latest[0].options).toHaveLength(1);
// Edit value + label.
fireEvent.change(within(opts).getByLabelText("Option value"), { target: { value: "high" } });
expect(latest[0].options![0].value).toBe("high");
fireEvent.change(within(opts).getByLabelText("Option label"), { target: { value: "High" } });
expect(latest[0].options![0].label).toBe("High");
// Pick a color via the swatch palette.
const swatches = within(opts).getByRole("group", { name: "Option color" });
const firstSwatch = within(swatches).getAllByRole("button")[0];
fireEvent.click(firstSwatch);
expect(latest[0].options![0].color).toBeTruthy();
});
it("adds and removes enum options (CRUD)", () => {
let latest: WorkflowFieldDefinition[] = [];
render(
<Host
initial={[
{ id: "tag", name: "Tag", type: "enum", options: [{ value: "a", label: "A" }] },
]}
onState={(f) => (latest = f)}
/>,
);
fireEvent.click(screen.getByText("Add option").closest("button")!);
expect(latest[0].options).toHaveLength(2);
fireEvent.click(screen.getAllByLabelText("Remove option")[0]);
expect(latest[0].options).toHaveLength(1);
});
it("edits render placement and widget controls", () => {
let latest: WorkflowFieldDefinition[] = [];
render(
<Host
initial={[{ id: "k", name: "K", type: "enum", options: [{ value: "x", label: "X" }] }]}
onState={(f) => (latest = f)}
/>,
);
const row = screen.getByTestId("wf-field-k");
// Placement → card.
fireEvent.change(within(row).getByText("Placement").parentElement!.querySelector("select")!, {
target: { value: "card" },
});
expect(latest[0].render?.placement).toBe("card");
// Widget → radio (valid for enum).
fireEvent.change(within(row).getByText("Widget").parentElement!.querySelector("select")!, {
target: { value: "radio" },
});
expect(latest[0].render?.widget).toBe("radio");
});
it("toggles required and edits a typed default", () => {
let latest: WorkflowFieldDefinition[] = [];
render(
<Host
initial={[{ id: "n", name: "N", type: "number" }]}
onState={(f) => (latest = f)}
/>,
);
fireEvent.click(screen.getByLabelText("Required", { selector: "input" }) ?? screen.getByText("Required").previousSibling as Element);
expect(latest[0].required).toBe(true);
const defInput = screen.getByLabelText("Default value");
fireEvent.change(defInput, { target: { value: "7" } });
fireEvent.blur(defInput);
expect(latest[0].default).toBe(7);
});
it("renders a live card badge preview for card-placed enum fields", () => {
render(
<Host
initial={[
{
id: "p",
name: "Priority",
type: "enum",
options: [{ value: "hi", label: "High", color: "#ef4444" }],
render: { placement: "card" },
},
]}
/>,
);
const preview = screen.getByTestId("wf-field-preview-p");
// Reuses the TaskCard badge class so the chip matches the board.
const badge = preview.querySelector(".card-field-badge");
expect(badge).toBeTruthy();
expect(badge!.textContent).toBe("High");
});
it("removes a field", () => {
let latest: WorkflowFieldDefinition[] = [];
render(
<Host
initial={[{ id: "gone", name: "Gone", type: "string" }]}
onState={(f) => (latest = f)}
/>,
);
fireEvent.click(screen.getByLabelText("Remove field"));
expect(latest).toHaveLength(0);
});
it("warns and blocks a duplicate id when editing the id", () => {
const addToast = vi.fn();
let latest: WorkflowFieldDefinition[] = [];
render(
<Host
initial={[
{ id: "alpha", name: "Alpha", type: "string" },
{ id: "beta", name: "Beta", type: "string" },
]}
addToast={addToast}
onState={(f) => (latest = f)}
/>,
);
// Reveal the id editor for beta and try to rename it to alpha.
const betaRow = screen.getByTestId("wf-field-beta");
fireEvent.click(within(betaRow).getByText("Edit id"));
const idInput = within(screen.getByTestId("wf-field-beta")).getByLabelText("Field id");
fireEvent.change(idInput, { target: { value: "alpha" } });
fireEvent.blur(idInput);
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/already exists/i), "error");
// No re-key happened: the blocked change never fired onChange, so the row
// still carries its original id (the panel re-renders the static id chip).
expect(latest).toHaveLength(0);
expect(screen.getByTestId("wf-field-beta")).toBeInTheDocument();
});
it("is fully read-only for built-in workflows", () => {
render(
<Host initial={[{ id: "f", name: "F", type: "string" }]} readOnly />,
);
expect((screen.getByText("Add field").closest("button") as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByLabelText("Field name") as HTMLInputElement).disabled).toBe(true);
});
});
// ── Round-trip through the editor's save flow ────────────────────────────────
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
fetchWorkflows: vi.fn(),
createWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
compileWorkflow: vi.fn(),
fetchTraits: vi.fn(),
fetchModels: vi.fn(),
fetchAgents: vi.fn(),
fetchDiscoveredSkills: vi.fn(),
};
});
import { fetchWorkflows, fetchTraits, updateWorkflow, compileWorkflow, fetchModels } from "../../api";
import { WorkflowNodeEditor } from "../WorkflowNodeEditor";
function v2DefWithField(): WorkflowDefinition {
return {
id: "WF-100",
name: "Custom",
description: "",
ir: {
version: "v2",
name: "Custom",
columns: [
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "triage" },
{ id: "step", kind: "prompt", column: "triage", config: { prompt: "do" } },
{ id: "end", kind: "end", column: "done" },
],
edges: [
{ from: "start", to: "step", condition: "success" },
{ from: "step", to: "end", condition: "success" },
],
fields: [
{
id: "severity",
name: "Severity",
type: "enum",
options: [{ value: "low", label: "Low" }],
render: { placement: "card" },
},
],
} as WorkflowDefinition["ir"],
layout: { start: { x: 0, y: 20 }, step: { x: 120, y: 60 }, end: { x: 360, y: 240 } },
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
describe("WorkflowFieldsPanel — editor round-trip", () => {
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue([
{ id: "intake", name: "Intake", builtin: true, flags: { intake: true } },
{ id: "complete", name: "Complete", builtin: true, flags: { complete: true } },
]);
vi.mocked(fetchModels).mockResolvedValue([]);
});
it("mounts the Fields panel and round-trips an added field into the saved IR", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithField()]);
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({
...v2DefWithField(),
...(updates as object),
}));
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByText("Save");
// The panel mounts and shows the workflow's existing field.
const panel = await screen.findByTestId("wf-fields-panel");
expect(within(panel).getByDisplayValue("Severity")).toBeInTheDocument();
// Add a second field, then save and assert the IR carries both fields.
fireEvent.click(within(panel).getByText("Add field").closest("button")!);
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
const ir = (updates as { ir: { version: string; fields?: WorkflowFieldDefinition[] } }).ir;
expect(ir.version).toBe("v2");
expect(ir.fields).toBeTruthy();
expect(ir.fields!.length).toBe(2);
expect(ir.fields!.some((f) => f.id === "severity")).toBe(true);
});
it("surfaces a core validation error at save (enum without options)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithField()]);
// Simulate the server rejecting the IR (parseWorkflowIr: options-required).
vi.mocked(updateWorkflow).mockRejectedValue(
new Error("Workflow field 'severity' of type 'enum' must declare non-empty options"),
);
const addToast = vi.fn();
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={addToast} />);
await screen.findByText("Save");
fireEvent.click(screen.getByText("Save").closest("button")!);
await waitFor(() =>
expect(addToast).toHaveBeenCalledWith(
expect.stringMatching(/must declare non-empty options/i),
"error",
),
);
});
});

View File

@@ -21,6 +21,20 @@ interface WorkflowForeachConfig {
template: { nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[] };
}
/** Local mirror of @fusion/core's WorkflowFieldDefinition (KTD-13). The core
* barrel does not re-export it and the dashboard build aliases @fusion/core to
* a types-only entry; the editor only needs to carry the array through the
* IR<->flow round-trip without inspecting it, so this minimal shape suffices. */
export interface WorkflowFieldDefinitionShape {
id: string;
name: string;
type: string;
required?: boolean;
default?: unknown;
options?: { value: string; label: string; color?: string }[];
render?: { placement?: string; widget?: string; badge?: boolean };
}
// ── foreach template region (KTD-3, U8) ──────────────────────────────────────
//
// A `foreach` node is authored inline as a React Flow group node whose template
@@ -272,6 +286,7 @@ export function flowToIr(
nodes: FlowNode<WorkflowFlowNodeData>[],
edges: FlowEdge[],
columns?: WorkflowIrColumn[],
fields?: WorkflowFieldDefinitionShape[],
): { ir: WorkflowIr; layout: Record<string, { x: number; y: number }> } {
const realNodes = nodes.filter((n) => !isColumnBandNode(n.id));
// Partition by parentId: foreach group children reassemble into that group's
@@ -287,7 +302,10 @@ export function flowToIr(
}
}
const groupIds = new Set(topNodes.filter((n) => n.data.kind === "foreach").map((n) => n.id));
const v2 = Array.isArray(columns) && columns.length > 0;
const hasFields = Array.isArray(fields) && fields.length > 0;
// Fields are a v2-only declaration: a workflow with fields but no custom
// columns still serializes as v2 (with the synthesized default columns).
const v2 = (Array.isArray(columns) && columns.length > 0) || hasFields;
const layout: Record<string, { x: number; y: number }> = {};
/** Project one flow node (top-level or template child) into an IR node. */
@@ -323,8 +341,9 @@ export function flowToIr(
};
}
const hasColumns = Array.isArray(columns) && columns.length > 0;
const irNodes: WorkflowIr["nodes"] = topNodes.map((node) => {
const column = v2 ? node.data.column ?? columnForY(node.position.y, columns!) : undefined;
const column = hasColumns ? node.data.column ?? columnForY(node.position.y, columns!) : undefined;
const base = toIrNode(node, node.id);
layout[node.id] = { x: Math.round(node.position.x), y: Math.round(node.position.y) };
return column ? { ...base, column } : base;
@@ -349,10 +368,16 @@ export function flowToIr(
const ir: WorkflowIrV2 = {
version: "v2",
name,
columns: columns!.map((c) => ({ id: c.id, name: c.name, traits: c.traits })),
columns: hasColumns ? columns!.map((c) => ({ id: c.id, name: c.name, traits: c.traits })) : [],
nodes: irNodes,
edges: irEdges,
};
if (hasFields) {
// The IR's `fields` is typed against @fusion/core's concrete
// WorkflowFieldDefinition; the editor carries the array through opaquely
// and the server validator is the source of truth, so assign via unknown.
(ir as { fields?: unknown }).fields = fields!.map((f) => ({ ...f }));
}
return { ir, layout };
}
@@ -513,6 +538,18 @@ export function columnsOf(def: WorkflowDefinition): WorkflowIrColumn[] {
return isV2(def.ir) ? def.ir.columns.map((c) => ({ ...c, traits: [...c.traits] })) : [];
}
/** Extract the editor's working custom-field list from a definition (KTD-13).
* v2 with `fields` → a deep-ish copy; v1 or no fields → empty. */
export function fieldsOf(def: WorkflowDefinition): WorkflowFieldDefinitionShape[] {
const ir = def.ir as { fields?: WorkflowFieldDefinitionShape[] };
if (!isV2(def.ir) || !Array.isArray(ir.fields)) return [];
return ir.fields.map((f) => ({
...f,
options: f.options ? f.options.map((o) => ({ ...o })) : undefined,
render: f.render ? { ...f.render } : undefined,
}));
}
/** Seed graph for a brand-new workflow: start → end with room to insert steps. */
export function emptyWorkflowIr(name: string): WorkflowIr {
return {

View File

@@ -176,6 +176,7 @@ const qualityAppComponentTests = [
"TaskForm",
"TaskIdIntegrityBanner",
"TrackingRepoSelect",
"WorkflowFieldsPanel",
"WorkflowNodeEditor",
"WorkflowResultsTab",
"WorkflowSelector",

View File

@@ -6715,6 +6715,40 @@
"unplacedCount_one": "{{count}} nodes not placed in a column",
"unplacedCount_other": "{{count}} nodes not placed in a column"
},
"workflowFields": {
"add": "Add field",
"addOption": "Add option",
"badge": "Render as badge",
"default": "Default",
"defaultLabel": "Default value",
"defaultTrue": "Default on",
"duplicateId": "A field with that id already exists",
"editId": "Edit id",
"empty": "No custom fields yet. Add a field to extend the task form and cards.",
"idLabel": "Field id",
"idWarn": "Changing the id discards values stored under the old id (remove + add).",
"nameLabel": "Field name",
"newFieldName": "New field",
"newOptionLabel": "Option 1",
"noDefault": "— none —",
"optionColor": "Option color",
"optionLabel": "Option label",
"optionN": "Option {{n}}",
"optionValue": "Option value",
"options": "Options",
"placement": "Placement",
"placementCard": "Card badge",
"placementDetail": "Detail (inline)",
"placementSection": "Detail section",
"readOnlyHint": "Built-in workflows are read-only — duplicate to edit",
"remove": "Remove field",
"removeOption": "Remove option",
"required": "Required",
"title": "Fields",
"typeLabel": "Type",
"widget": "Widget",
"widgetDefault": "Default"
},
"workflowNodes": {
"advisory": "Advisory",
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",

View File

@@ -6798,5 +6798,39 @@
"moreFields": "Campos adicionales",
"orphaned": "Campos huérfanos",
"saveFailed": "No se pudo guardar el campo"
},
"workflowFields": {
"add": "Add field",
"addOption": "Add option",
"badge": "Render as badge",
"default": "Default",
"defaultLabel": "Default value",
"defaultTrue": "Default on",
"duplicateId": "A field with that id already exists",
"editId": "Edit id",
"empty": "No custom fields yet. Add a field to extend the task form and cards.",
"idLabel": "Field id",
"idWarn": "Changing the id discards values stored under the old id (remove + add).",
"nameLabel": "Field name",
"newFieldName": "New field",
"newOptionLabel": "Option 1",
"noDefault": "— none —",
"optionColor": "Option color",
"optionLabel": "Option label",
"optionN": "Option {{n}}",
"optionValue": "Option value",
"options": "Options",
"placement": "Placement",
"placementCard": "Card badge",
"placementDetail": "Detail (inline)",
"placementSection": "Detail section",
"readOnlyHint": "Built-in workflows are read-only — duplicate to edit",
"remove": "Remove field",
"removeOption": "Remove option",
"required": "Required",
"title": "Fields",
"typeLabel": "Type",
"widget": "Widget",
"widgetDefault": "Default"
}
}

View File

@@ -6798,5 +6798,39 @@
"moreFields": "Champs supplémentaires",
"orphaned": "Champs orphelins",
"saveFailed": "Échec de l'enregistrement du champ"
},
"workflowFields": {
"add": "Add field",
"addOption": "Add option",
"badge": "Render as badge",
"default": "Default",
"defaultLabel": "Default value",
"defaultTrue": "Default on",
"duplicateId": "A field with that id already exists",
"editId": "Edit id",
"empty": "No custom fields yet. Add a field to extend the task form and cards.",
"idLabel": "Field id",
"idWarn": "Changing the id discards values stored under the old id (remove + add).",
"nameLabel": "Field name",
"newFieldName": "New field",
"newOptionLabel": "Option 1",
"noDefault": "— none —",
"optionColor": "Option color",
"optionLabel": "Option label",
"optionN": "Option {{n}}",
"optionValue": "Option value",
"options": "Options",
"placement": "Placement",
"placementCard": "Card badge",
"placementDetail": "Detail (inline)",
"placementSection": "Detail section",
"readOnlyHint": "Built-in workflows are read-only — duplicate to edit",
"remove": "Remove field",
"removeOption": "Remove option",
"required": "Required",
"title": "Fields",
"typeLabel": "Type",
"widget": "Widget",
"widgetDefault": "Default"
}
}

View File

@@ -6798,5 +6798,39 @@
"moreFields": "추가 필드",
"orphaned": "고아 필드",
"saveFailed": "필드 저장 실패"
},
"workflowFields": {
"add": "Add field",
"addOption": "Add option",
"badge": "Render as badge",
"default": "Default",
"defaultLabel": "Default value",
"defaultTrue": "Default on",
"duplicateId": "A field with that id already exists",
"editId": "Edit id",
"empty": "No custom fields yet. Add a field to extend the task form and cards.",
"idLabel": "Field id",
"idWarn": "Changing the id discards values stored under the old id (remove + add).",
"nameLabel": "Field name",
"newFieldName": "New field",
"newOptionLabel": "Option 1",
"noDefault": "— none —",
"optionColor": "Option color",
"optionLabel": "Option label",
"optionN": "Option {{n}}",
"optionValue": "Option value",
"options": "Options",
"placement": "Placement",
"placementCard": "Card badge",
"placementDetail": "Detail (inline)",
"placementSection": "Detail section",
"readOnlyHint": "Built-in workflows are read-only — duplicate to edit",
"remove": "Remove field",
"removeOption": "Remove option",
"required": "Required",
"title": "Fields",
"typeLabel": "Type",
"widget": "Widget",
"widgetDefault": "Default"
}
}

View File

@@ -6798,5 +6798,39 @@
"moreFields": "其他字段",
"orphaned": "孤立字段",
"saveFailed": "保存字段失败"
},
"workflowFields": {
"add": "Add field",
"addOption": "Add option",
"badge": "Render as badge",
"default": "Default",
"defaultLabel": "Default value",
"defaultTrue": "Default on",
"duplicateId": "A field with that id already exists",
"editId": "Edit id",
"empty": "No custom fields yet. Add a field to extend the task form and cards.",
"idLabel": "Field id",
"idWarn": "Changing the id discards values stored under the old id (remove + add).",
"nameLabel": "Field name",
"newFieldName": "New field",
"newOptionLabel": "Option 1",
"noDefault": "— none —",
"optionColor": "Option color",
"optionLabel": "Option label",
"optionN": "Option {{n}}",
"optionValue": "Option value",
"options": "Options",
"placement": "Placement",
"placementCard": "Card badge",
"placementDetail": "Detail (inline)",
"placementSection": "Detail section",
"readOnlyHint": "Built-in workflows are read-only — duplicate to edit",
"remove": "Remove field",
"removeOption": "Remove option",
"required": "Required",
"title": "Fields",
"typeLabel": "Type",
"widget": "Widget",
"widgetDefault": "Default"
}
}

View File

@@ -6798,5 +6798,39 @@
"moreFields": "其他欄位",
"orphaned": "孤立欄位",
"saveFailed": "儲存欄位失敗"
},
"workflowFields": {
"add": "Add field",
"addOption": "Add option",
"badge": "Render as badge",
"default": "Default",
"defaultLabel": "Default value",
"defaultTrue": "Default on",
"duplicateId": "A field with that id already exists",
"editId": "Edit id",
"empty": "No custom fields yet. Add a field to extend the task form and cards.",
"idLabel": "Field id",
"idWarn": "Changing the id discards values stored under the old id (remove + add).",
"nameLabel": "Field name",
"newFieldName": "New field",
"newOptionLabel": "Option 1",
"noDefault": "— none —",
"optionColor": "Option color",
"optionLabel": "Option label",
"optionN": "Option {{n}}",
"optionValue": "Option value",
"options": "Options",
"placement": "Placement",
"placementCard": "Card badge",
"placementDetail": "Detail (inline)",
"placementSection": "Detail section",
"readOnlyHint": "Built-in workflows are read-only — duplicate to edit",
"remove": "Remove field",
"removeOption": "Remove option",
"required": "Required",
"title": "Fields",
"typeLabel": "Type",
"widget": "Widget",
"widgetDefault": "Default"
}
}