evidence-binder/src/FormRenderer.tsx
tegwick e462775bae
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Repair capability federation index
2026-08-21 08:58:31 +02:00

618 lines
16 KiB
TypeScript

/**
* FormRenderer — evidence-backed attributes (CE-WP-0010/0012).
*/
import {
useMemo,
useRef,
useState,
type ChangeEvent,
type CSSProperties,
} from "react";
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
import {
DEFAULT_CURRENCY,
displayTypeLabel,
formatOptionsText,
normalizeAttributeType,
parseAmountValue,
parseOptionsText,
parseSomeOfValue,
serializeAmountValue,
serializeSomeOfValue,
typeNeedsOptions,
type AttributeOption,
type FormFieldSchema,
type FormSchema,
} from "./attribute-types";
import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
import { useActiveState, type ActiveState } from "./state/active";
import { useRegisterRect } from "./visual-guide/react-hooks";
export type {
AttributeOption,
AttributeValueType,
FormFieldSchema,
FormSchema,
} from "./attribute-types";
export {
isAttributeValueType,
normalizeFormSchema,
normalizeAttributeType,
parseAmountValue,
serializeAmountValue,
} from "./attribute-types";
const FILTER_MIN_CHARS = 3;
function isFieldActive(state: ActiveState, fieldId: string): boolean {
return (
state.activeTarget?.targetType === "form-field" &&
state.activeTarget?.targetId === fieldId
);
}
export function matchesTextFilter(
query: string,
parts: readonly (string | undefined | null)[],
minChars = FILTER_MIN_CHARS,
): boolean {
const q = query.trim();
if (q.length < minChars) return true;
const needle = q.toLowerCase();
return parts.some((p) => p != null && String(p).toLowerCase().includes(needle));
}
export interface FieldDefinitionPatch {
readonly label: string;
readonly type: FieldType;
readonly options?: readonly AttributeOption[];
readonly defaultCurrency?: string;
}
export interface FormRendererProps {
readonly schema: FormSchema;
readonly values?: Readonly<Record<string, string>>;
readonly onValueChange?: (fieldId: string, value: string) => void;
readonly linkCounts?: Readonly<Record<string, number>>;
readonly linkHints?: Readonly<Record<string, string>>;
readonly showAddFieldForm?: boolean;
readonly onRequestAddField?: () => void;
readonly onConfirmAddField?: (patch: FieldDefinitionPatch) => void;
readonly onCancelAddField?: () => void;
readonly editingFieldId?: string | null;
readonly onBeginEditField?: (fieldId: string) => void;
readonly onSaveFieldEdit?: (fieldId: string, patch: FieldDefinitionPatch) => void;
readonly onCancelFieldEdit?: () => void;
}
const iconButtonStyle: CSSProperties = {
fontSize: 11,
padding: "2px 6px",
background: "white",
border: "1px solid #888",
borderRadius: 3,
cursor: "pointer",
lineHeight: 1,
};
const controlStyle: CSSProperties = {
width: "100%",
boxSizing: "border-box",
fontSize: 13,
padding: 4,
};
function ValueControl({
field,
value,
onChange,
onFocus,
}: {
field: FormFieldSchema;
value: string;
onChange: (next: string) => void;
onFocus: () => void;
}) {
const type = normalizeAttributeType(field.type);
const id = `field-${field.id}`;
if (type === "text-long") {
return (
<textarea
id={id}
rows={2}
value={value}
onFocus={onFocus}
onChange={(e) => onChange(e.target.value)}
style={controlStyle}
/>
);
}
if (type === "date" || type === "time") {
return (
<input
id={id}
type={type}
value={value}
onFocus={onFocus}
onChange={(e) => onChange(e.target.value)}
style={controlStyle}
/>
);
}
if (type === "datetime") {
// HTML datetime-local uses "YYYY-MM-DDTHH:MM" without timezone
return (
<input
id={id}
type="datetime-local"
value={value}
onFocus={onFocus}
onChange={(e) => onChange(e.target.value)}
style={controlStyle}
/>
);
}
if (type === "amount") {
const amount = parseAmountValue(value);
const currency = amount.currency || field.defaultCurrency || DEFAULT_CURRENCY;
return (
<div style={{ display: "flex", gap: 6 }} data-testid={`field-${field.id}-amount`}>
<input
id={id}
type="number"
step="0.01"
value={amount.value}
onFocus={onFocus}
onChange={(e) =>
onChange(serializeAmountValue({ value: e.target.value, currency }))
}
style={{ ...controlStyle, flex: 1 }}
/>
<input
type="text"
aria-label={`${field.label} currency`}
value={currency}
maxLength={3}
onFocus={onFocus}
onChange={(e) =>
onChange(
serializeAmountValue({
value: amount.value,
currency: e.target.value.toUpperCase(),
}),
)
}
data-testid={`field-${field.id}-currency`}
style={{ ...controlStyle, width: 56, textTransform: "uppercase" }}
/>
</div>
);
}
if (type === "one-of") {
const options = field.options ?? [];
return (
<select
id={id}
value={value}
onFocus={onFocus}
onChange={(e) => onChange(e.target.value)}
style={controlStyle}
data-testid={`field-${field.id}-select`}
>
<option value=""></option>
{options.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label}
</option>
))}
</select>
);
}
if (type === "some-of") {
const selected = new Set(parseSomeOfValue(value));
const options = field.options ?? [];
return (
<div
role="group"
aria-labelledby={`${id}-label`}
data-testid={`field-${field.id}-checks`}
style={{ display: "flex", flexDirection: "column", gap: 4 }}
>
{options.length === 0 && (
<span style={{ fontSize: 11, color: "#888" }}>No options defined.</span>
)}
{options.map((opt) => (
<label
key={opt.id}
style={{ display: "flex", gap: 6, alignItems: "center", fontSize: 12 }}
>
<input
type="checkbox"
checked={selected.has(opt.id)}
onFocus={onFocus}
onChange={(e) => {
const next = new Set(selected);
if (e.target.checked) next.add(opt.id);
else next.delete(opt.id);
onChange(serializeSomeOfValue([...next]));
}}
/>
{opt.label}
</label>
))}
</div>
);
}
// text (default)
return (
<input
id={id}
type="text"
value={value}
onFocus={onFocus}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
style={controlStyle}
/>
);
}
function FieldRow({
field,
value,
linkCount,
linkHint,
isActive,
isEditing,
editLabel,
editType,
editOptionsText,
editCurrency,
onChange,
onFocus,
onBeginEdit,
onChangeEditLabel,
onChangeEditType,
onChangeEditOptionsText,
onChangeEditCurrency,
onSaveEdit,
onCancelEdit,
}: {
field: FormFieldSchema;
value: string;
linkCount: number;
linkHint?: string;
isActive: boolean;
isEditing: boolean;
editLabel: string;
editType: FieldType;
editOptionsText: string;
editCurrency: string;
onChange: (next: string) => void;
onFocus: () => void;
onBeginEdit: () => void;
onChangeEditLabel: (next: string) => void;
onChangeEditType: (next: FieldType) => void;
onChangeEditOptionsText: (next: string) => void;
onChangeEditCurrency: (next: string) => void;
onSaveEdit: () => void;
onCancelEdit: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useRegisterRect("field", field.id, ref);
const typeLabel = displayTypeLabel(field.type);
if (isEditing) {
return (
<div ref={ref} data-field-id={field.id} style={{ marginBottom: 12 }}>
<FieldDefinitionForm
label={editLabel}
type={editType}
optionsText={editOptionsText}
defaultCurrency={editCurrency}
onChangeLabel={onChangeEditLabel}
onChangeType={onChangeEditType}
onChangeOptionsText={onChangeEditOptionsText}
onChangeDefaultCurrency={onChangeEditCurrency}
onSave={onSaveEdit}
onCancel={onCancelEdit}
saveLabel="Save attribute"
badge="Editing attribute"
testidPrefix={`field-edit-${field.id}`}
/>
</div>
);
}
return (
<div
ref={ref}
data-field-id={field.id}
data-link-count={String(linkCount)}
aria-current={isActive ? "true" : undefined}
style={{
position: "relative",
marginBottom: 12,
fontFamily: "system-ui, sans-serif",
padding: 4,
borderRadius: 4,
background: isActive ? "#e8f0ff" : "transparent",
}}
>
<button
type="button"
aria-label={`Edit ${field.id}`}
data-testid={`field-edit-toggle-${field.id}`}
title="Edit attribute key and type"
onClick={(e) => {
e.stopPropagation();
onBeginEdit();
}}
style={{
...iconButtonStyle,
position: "absolute",
top: 4,
right: 4,
zIndex: 1,
}}
>
</button>
<label
id={`field-${field.id}-label`}
htmlFor={
normalizeAttributeType(field.type) === "some-of"
? undefined
: `field-${field.id}`
}
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
marginBottom: 4,
paddingRight: 28,
}}
>
{field.label}{" "}
<span style={{ fontWeight: 400, color: "#666" }}>({typeLabel})</span>
{linkCount > 0 ? (
<span
data-testid={`field-${field.id}-chip`}
title={linkHint}
style={{
marginLeft: 8,
padding: "1px 6px",
borderRadius: 4,
background: "#e7f0ff",
color: "#0050b3",
fontSize: 11,
fontWeight: 500,
}}
>
{linkCount} evidence
</span>
) : null}
</label>
<ValueControl
field={field}
value={value}
onChange={onChange}
onFocus={onFocus}
/>
</div>
);
}
function buildPatch(
label: string,
type: FieldType,
optionsText: string,
currency: string,
): FieldDefinitionPatch {
const t = normalizeAttributeType(type);
const patch: FieldDefinitionPatch = { label, type: t };
if (typeNeedsOptions(t)) {
return { ...patch, options: parseOptionsText(optionsText) };
}
if (t === "amount") {
return {
...patch,
defaultCurrency: (currency || DEFAULT_CURRENCY).toUpperCase(),
};
}
return patch;
}
export function FormRenderer({
schema,
values,
onValueChange,
linkCounts,
linkHints,
showAddFieldForm,
onRequestAddField,
onConfirmAddField,
onCancelAddField,
editingFieldId,
onBeginEditField,
onSaveFieldEdit,
onCancelFieldEdit,
}: FormRendererProps) {
const { state, focusTarget } = useActiveState();
const [addLabel, setAddLabel] = useState("New attribute");
const [addType, setAddType] = useState<FieldType>("text");
const [addOptionsText, setAddOptionsText] = useState("");
const [addCurrency, setAddCurrency] = useState(DEFAULT_CURRENCY);
const [editLabel, setEditLabel] = useState("");
const [editType, setEditType] = useState<FieldType>("text");
const [editOptionsText, setEditOptionsText] = useState("");
const [editCurrency, setEditCurrency] = useState(DEFAULT_CURRENCY);
const [filterQuery, setFilterQuery] = useState("");
const handleFocus = (fieldId: string) => {
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
focusTarget(target);
};
const beginEdit = (field: FormFieldSchema) => {
setEditLabel(field.label);
setEditType(normalizeAttributeType(field.type));
setEditOptionsText(formatOptionsText(field.options));
setEditCurrency(field.defaultCurrency ?? DEFAULT_CURRENCY);
onBeginEditField?.(field.id);
};
const visibleFields = useMemo(
() =>
schema.fields.filter((field) => {
const optionText = (field.options ?? [])
.map((o) => `${o.id} ${o.label}`)
.join(" ");
return matchesTextFilter(filterQuery, [
field.label,
displayTypeLabel(field.type),
field.id,
values?.[field.id],
optionText,
]);
}),
[schema.fields, filterQuery, values],
);
return (
<div data-form-id={schema.id} style={{ padding: 12 }} role="group" aria-label="Attributes">
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
flexWrap: "wrap",
}}
>
<h2
style={{
fontSize: 14,
margin: 0,
fontFamily: "system-ui, sans-serif",
flex: "0 0 auto",
}}
>
{schema.title}
</h2>
<input
type="search"
value={filterQuery}
onChange={(e) => setFilterQuery(e.target.value)}
placeholder="Filter…"
aria-label="Filter attributes"
data-testid="attributes-filter"
style={{
flex: "1 1 100px",
minWidth: 80,
fontSize: 12,
padding: "4px 6px",
border: "1px solid #ccc",
borderRadius: 4,
}}
/>
</div>
{visibleFields.length === 0 && schema.fields.length > 0 && (
<p
style={{ fontSize: 12, color: "#888", margin: "8px 0" }}
data-testid="attributes-filter-empty"
>
No matches.
</p>
)}
{visibleFields.map((field) => (
<FieldRow
key={field.id}
field={field}
value={values?.[field.id] ?? ""}
linkCount={linkCounts?.[field.id] ?? 0}
{...(linkHints?.[field.id] != null
? { linkHint: linkHints[field.id] }
: {})}
isActive={isFieldActive(state, field.id)}
isEditing={editingFieldId === field.id}
editLabel={editLabel}
editType={editType}
editOptionsText={editOptionsText}
editCurrency={editCurrency}
onChange={(next) => onValueChange?.(field.id, next)}
onFocus={() => handleFocus(field.id)}
onBeginEdit={() => beginEdit(field)}
onChangeEditLabel={setEditLabel}
onChangeEditType={setEditType}
onChangeEditOptionsText={setEditOptionsText}
onChangeEditCurrency={setEditCurrency}
onSaveEdit={() =>
onSaveFieldEdit?.(
field.id,
buildPatch(editLabel.trim(), editType, editOptionsText, editCurrency),
)
}
onCancelEdit={() => onCancelFieldEdit?.()}
/>
))}
{showAddFieldForm ? (
<FieldDefinitionForm
label={addLabel}
type={addType}
optionsText={addOptionsText}
defaultCurrency={addCurrency}
onChangeLabel={setAddLabel}
onChangeType={setAddType}
onChangeOptionsText={setAddOptionsText}
onChangeDefaultCurrency={setAddCurrency}
onSave={() =>
onConfirmAddField?.(
buildPatch(addLabel.trim(), addType, addOptionsText, addCurrency),
)
}
onCancel={() => onCancelAddField?.()}
saveLabel="Add attribute"
badge="New attribute"
testidPrefix="field-add"
/>
) : (
<button
type="button"
data-testid="add-field-button"
onClick={() => {
setAddLabel(`New attribute ${schema.fields.length + 1}`);
setAddType("text");
setAddOptionsText("");
setAddCurrency(DEFAULT_CURRENCY);
onRequestAddField?.();
}}
style={{
fontSize: 11,
padding: "6px 10px",
border: "1px dashed #888",
borderRadius: 4,
background: "white",
cursor: "pointer",
width: "100%",
marginTop: 4,
}}
>
Add attribute
</button>
)}
</div>
);
}