618 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|