/** * Schema-driven custom-field form section (U13 / KTD-14). * * Renders a task's workflow-defined custom fields ({@link WorkflowFieldDefinition}) * as editable widgets, grouped by `render.placement`: * - `detail` (and the default when unset) → inline, near the description. * - `detail-section` → inside a collapsible group. * Card-placed fields (`placement: "card"`) are intentionally NOT rendered here — * those surface as badges on {@link TaskCard}. * * Widget selection (per `type` + optional `render.widget`): * - enum → select (default) | radio | chips (single-select) * - multi-enum → chips (multi-select) * - boolean → toggle * - date → date input * - url/number → validated * - string → text input * - text → textarea * * Editing is per-field, save-on-commit (blur for inputs, change for * toggles/selects/chips/radio). Each save calls `onSave({ [fieldId]: value })`; * on a 400 the caller surfaces the typed rejection through `error`, which this * component renders inline beneath the offending field. * * Orphaned values — keys in `customFields` with no matching definition — render * read-only under a collapsed "Orphaned fields" disclosure (never destroyed, * KTD-13). * * Zero field definitions AND zero orphaned values → the component renders * nothing (null), so a task on a field-less workflow is byte-identical to * today's UI (snapshot-guarded by the test suite). */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ChevronRight, ChevronDown } from "lucide-react"; import type { WorkflowFieldDefinition, WorkflowFieldOption, CustomFieldRejection, } from "../api"; import "./TaskFieldsSection.css"; export interface TaskFieldsSectionProps { /** The task's workflow field definitions (from board-workflows payload). */ fieldDefs: WorkflowFieldDefinition[]; /** Current custom field values, keyed by field id. */ customFields: Record; /** * Persist a single-field patch. Resolves on success; the caller is expected * to throw / reject with the server's typed rejection so it can flow into * `error`. May be omitted to render read-only (e.g. archived tasks). */ onSave?: (patch: Record) => Promise; /** * The most recent typed rejection from a failed save (400), surfaced inline * beneath the matching field. Cleared by the caller on a successful save. */ error?: CustomFieldRejection | null; /** When true, fields render read-only (no edit affordances). */ readOnly?: boolean; } /** Resolve the effective widget for a field, applying the per-type default. */ function resolveWidget(field: WorkflowFieldDefinition): NonNullable["widget"] { const explicit = field.render?.widget; if (explicit) return explicit; switch (field.type) { case "enum": return "select"; case "multi-enum": return "chips"; case "boolean": return "toggle"; case "text": return "textarea"; default: return "input"; } } interface FieldRowProps { field: WorkflowFieldDefinition; value: unknown; onSave?: (patch: Record) => Promise; error?: CustomFieldRejection | null; readOnly: boolean; } function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) { const { t } = useTranslation("app"); const widget = resolveWidget(field); const fieldError = error && error.fieldId === field.id ? error : null; const disabled = readOnly || !onSave; // Serialize per-field saves: rapid chip/toggle/blur edits to the same field // would otherwise fire overlapping PATCHes whose responses can resolve out of // order, letting an older request clobber a newer selection. We chain each // save onto the previous one for this field so they apply in click order. const saveTailRef = useRef>(Promise.resolve()); const commit = useCallback( (next: unknown) => { if (!onSave) return; const run = () => onSave({ [field.id]: next }); // Run after any in-flight save for this field, regardless of its outcome, // so a rejected save doesn't permanently break the chain. The tail is kept // settled-always (.catch) so its own rejection never floats unhandled and // never blocks the next queued save — the caller surfaces failures via // `error`, so we intentionally swallow here for ordering purposes only. const prev = saveTailRef.current; saveTailRef.current = prev.then(run, run).catch(() => {}); }, [onSave, field.id], ); const labelId = `task-field-label-${field.id}`; const controlId = `task-field-${field.id}`; // Prop-derived string value for the uncontrolled-style inputs (date / text / // string / number / url). These were previously rendered with `defaultValue`, // which only seeds on mount — so an external refresh of `customFields` (SSE or // a save round-trip) left the DOM showing a stale value, and a later blur would // commit that stale value back over the refreshed one. We make them controlled // and re-sync to the latest prop whenever it changes. const propTextValue = field.type === "date" ? typeof value === "string" ? value.slice(0, 10) : "" : field.type === "number" ? typeof value === "number" ? String(value) : "" : typeof value === "string" ? value : ""; const [localValue, setLocalValue] = useState(propTextValue); useEffect(() => { setLocalValue(propTextValue); }, [propTextValue]); const renderControl = () => { // enum → select / radio / chips (single) if (field.type === "enum") { const current = typeof value === "string" ? value : ""; if (widget === "radio") { return (
{(field.options ?? []).map((opt: WorkflowFieldOption) => ( ))}
); } if (widget === "chips") { return (
{(field.options ?? []).map((opt) => { const active = current === opt.value; return ( ); })}
); } // default: select return ( ); } // multi-enum → chips (multi-select) if (field.type === "multi-enum") { const current = Array.isArray(value) ? (value as string[]) : []; return (
{(field.options ?? []).map((opt) => { const active = current.includes(opt.value); return ( ); })}
); } // boolean → toggle if (field.type === "boolean") { const checked = value === true; return ( ); } // date → date input if (field.type === "date") { return ( setLocalValue(e.target.value)} onBlur={(e) => { const next = e.target.value; if (next === propTextValue) return; commit(next === "" ? null : next); }} /> ); } // text → textarea if (field.type === "text") { return (