From e87e745379fa1e79b8846673f3e85336f188fef8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 12:44:43 -0700 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20WorkflowFieldsPanel=20?= =?UTF-8?q?=E2=80=94=20field-definition=20authoring=20with=20live=20badge?= =?UTF-8?q?=20preview=20(U13=20completion)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/WorkflowFieldsPanel.css | 195 +++++++ .../app/components/WorkflowFieldsPanel.tsx | 520 ++++++++++++++++++ .../app/components/WorkflowNodeEditor.tsx | 26 +- .../__tests__/WorkflowFieldsPanel.test.tsx | 327 +++++++++++ .../app/components/workflow-flow-mapping.ts | 43 +- packages/dashboard/vitest.config.ts | 1 + packages/i18n/locales/en/app.json | 34 ++ packages/i18n/locales/es/app.json | 34 ++ packages/i18n/locales/fr/app.json | 34 ++ packages/i18n/locales/ko/app.json | 34 ++ packages/i18n/locales/zh-CN/app.json | 34 ++ packages/i18n/locales/zh-TW/app.json | 34 ++ 12 files changed, 1311 insertions(+), 5 deletions(-) create mode 100644 packages/dashboard/app/components/WorkflowFieldsPanel.css create mode 100644 packages/dashboard/app/components/WorkflowFieldsPanel.tsx create mode 100644 packages/dashboard/app/components/__tests__/WorkflowFieldsPanel.test.tsx diff --git a/packages/dashboard/app/components/WorkflowFieldsPanel.css b/packages/dashboard/app/components/WorkflowFieldsPanel.css new file mode 100644 index 0000000000..f810741d9a --- /dev/null +++ b/packages/dashboard/app/components/WorkflowFieldsPanel.css @@ -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); +} diff --git a/packages/dashboard/app/components/WorkflowFieldsPanel.tsx b/packages/dashboard/app/components/WorkflowFieldsPanel.tsx new file mode 100644 index 0000000000..0f2066b748 --- /dev/null +++ b/packages/dashboard/app/components/WorkflowFieldsPanel.tsx @@ -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["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: ( + + {(field.options ?? []).slice(0, 2).map((o) => ( + + {o.label} + + ))} + + ), + }; + } + return { + node: ( + + {opt.label} + + ), + }; + } + if (field.type === "boolean") { + return { + node: ( + + {field.name} + + ), + }; + } + // 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: ( + + {sampleText} + + ), + }; + }, [field]); + + if (!sample) return null; + return ( +
+
{sample.node}
+
+ ); +} + +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(null); + + const patchField = useCallback( + (id: string, patch: Partial) => { + 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 = { 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 ( + + ); + } + 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 ( + + ); + } + 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 ( + { + const raw = e.target.value; + if (raw === "") return commit(undefined); + commit(field.type === "number" ? Number(raw) : raw); + }} + /> + ); + }; + + return ( + + ); +} + +export default WorkflowFieldsPanel; diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 6d3996dadd..6c159b1174 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -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([]); + // v2 custom field definitions the editor is authoring (KTD-13/14, U13). + const [fields, setFields] = useState([]); const [traitCatalog, setTraitCatalog] = useState([]); 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 && ( + + )} + {selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (