/** * 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;