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