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:
195
packages/dashboard/app/components/WorkflowFieldsPanel.css
Normal file
195
packages/dashboard/app/components/WorkflowFieldsPanel.css
Normal 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);
|
||||
}
|
||||
520
packages/dashboard/app/components/WorkflowFieldsPanel.tsx
Normal file
520
packages/dashboard/app/components/WorkflowFieldsPanel.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -176,6 +176,7 @@ const qualityAppComponentTests = [
|
||||
"TaskForm",
|
||||
"TaskIdIntegrityBanner",
|
||||
"TrackingRepoSelect",
|
||||
"WorkflowFieldsPanel",
|
||||
"WorkflowNodeEditor",
|
||||
"WorkflowResultsTab",
|
||||
"WorkflowSelector",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user