521 lines
20 KiB
TypeScript
521 lines
20 KiB
TypeScript
/**
|
|
* 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;
|