Add attribute value types: time, datetime, amount, one-of, some-of.
Implement CE-WP-0012 interim catalog (accepted defaults): widgets, options editor, amount JSON encoding, textarea→text-long normalize.
This commit is contained in:
parent
9dc4880ec1
commit
8cf7bc1128
6 changed files with 628 additions and 73 deletions
|
|
@ -1,14 +1,5 @@
|
|||
/**
|
||||
* FormRenderer — renders a FormSchema as evidence-backed attributes.
|
||||
*
|
||||
* Each attribute registers itself with the rect registry under
|
||||
* `kind="field"` and the field's `id`, so the SVG visual guide can
|
||||
* draw curves from the active attribute to its linked evidence card and
|
||||
* on to the source highlight.
|
||||
*
|
||||
* CE-WP-0007-T10/T11: add/edit flows use FieldDefinitionForm.
|
||||
* CE-WP-0010: user-facing "Attributes" vocabulary, `Key (type)` labels,
|
||||
* and a ≥3-character filter next to the caption.
|
||||
* FormRenderer — evidence-backed attributes (CE-WP-0010/0012).
|
||||
*/
|
||||
|
||||
import {
|
||||
|
|
@ -21,10 +12,40 @@ import {
|
|||
|
||||
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
||||
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
displayTypeLabel,
|
||||
formatOptionsText,
|
||||
normalizeAttributeType,
|
||||
parseAmountValue,
|
||||
parseOptionsText,
|
||||
parseSomeOfValue,
|
||||
serializeAmountValue,
|
||||
serializeSomeOfValue,
|
||||
typeNeedsOptions,
|
||||
type AttributeOption,
|
||||
type AttributeValueType,
|
||||
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 {
|
||||
|
|
@ -34,7 +55,6 @@ function isFieldActive(state: ActiveState, fieldId: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
/** Case-insensitive substring match; empty / short queries match everything. */
|
||||
export function matchesTextFilter(
|
||||
query: string,
|
||||
parts: readonly (string | undefined | null)[],
|
||||
|
|
@ -46,21 +66,11 @@ export function matchesTextFilter(
|
|||
return parts.some((p) => p != null && String(p).toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
export interface FormFieldSchema {
|
||||
readonly type: "text" | "textarea" | "date";
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface FormSchema {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly fields: readonly FormFieldSchema[];
|
||||
}
|
||||
|
||||
export interface FieldDefinitionPatch {
|
||||
readonly label: string;
|
||||
readonly type: FieldType;
|
||||
readonly options?: readonly AttributeOption[];
|
||||
readonly defaultCurrency?: string;
|
||||
}
|
||||
|
||||
export interface FormRendererProps {
|
||||
|
|
@ -89,6 +99,174 @@ const iconButtonStyle: CSSProperties = {
|
|||
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,
|
||||
|
|
@ -98,11 +276,15 @@ function FieldRow({
|
|||
isEditing,
|
||||
editLabel,
|
||||
editType,
|
||||
editOptionsText,
|
||||
editCurrency,
|
||||
onChange,
|
||||
onFocus,
|
||||
onBeginEdit,
|
||||
onChangeEditLabel,
|
||||
onChangeEditType,
|
||||
onChangeEditOptionsText,
|
||||
onChangeEditCurrency,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
}: {
|
||||
|
|
@ -114,16 +296,21 @@ function FieldRow({
|
|||
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 (
|
||||
|
|
@ -131,8 +318,12 @@ function FieldRow({
|
|||
<FieldDefinitionForm
|
||||
label={editLabel}
|
||||
type={editType}
|
||||
optionsText={editOptionsText}
|
||||
defaultCurrency={editCurrency}
|
||||
onChangeLabel={onChangeEditLabel}
|
||||
onChangeType={onChangeEditType}
|
||||
onChangeOptionsText={onChangeEditOptionsText}
|
||||
onChangeDefaultCurrency={onChangeEditCurrency}
|
||||
onSave={onSaveEdit}
|
||||
onCancel={onCancelEdit}
|
||||
saveLabel="Save attribute"
|
||||
|
|
@ -143,15 +334,6 @@ function FieldRow({
|
|||
);
|
||||
}
|
||||
|
||||
const sharedProps = {
|
||||
id: `field-${field.id}`,
|
||||
value,
|
||||
onFocus,
|
||||
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
onChange(e.target.value),
|
||||
style: { width: "100%", boxSizing: "border-box" as const, fontSize: 13, padding: 4 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -187,7 +369,12 @@ function FieldRow({
|
|||
✎
|
||||
</button>
|
||||
<label
|
||||
htmlFor={sharedProps.id}
|
||||
id={`field-${field.id}-label`}
|
||||
htmlFor={
|
||||
normalizeAttributeType(field.type) === "some-of"
|
||||
? undefined
|
||||
: `field-${field.id}`
|
||||
}
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
|
|
@ -197,7 +384,7 @@ function FieldRow({
|
|||
}}
|
||||
>
|
||||
{field.label}{" "}
|
||||
<span style={{ fontWeight: 400, color: "#666" }}>({field.type})</span>
|
||||
<span style={{ fontWeight: 400, color: "#666" }}>({typeLabel})</span>
|
||||
{linkCount > 0 ? (
|
||||
<span
|
||||
data-testid={`field-${field.id}-chip`}
|
||||
|
|
@ -216,15 +403,36 @@ function FieldRow({
|
|||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea rows={2} {...sharedProps} />
|
||||
) : (
|
||||
<input type={field.type === "date" ? "date" : "text"} {...sharedProps} />
|
||||
)}
|
||||
<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,
|
||||
|
|
@ -243,8 +451,12 @@ export function FormRenderer({
|
|||
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) => {
|
||||
|
|
@ -254,20 +466,26 @@ export function FormRenderer({
|
|||
|
||||
const beginEdit = (field: FormFieldSchema) => {
|
||||
setEditLabel(field.label);
|
||||
setEditType(field.type);
|
||||
setEditType(normalizeAttributeType(field.type));
|
||||
setEditOptionsText(formatOptionsText(field.options));
|
||||
setEditCurrency(field.defaultCurrency ?? DEFAULT_CURRENCY);
|
||||
onBeginEditField?.(field.id);
|
||||
};
|
||||
|
||||
const visibleFields = useMemo(
|
||||
() =>
|
||||
schema.fields.filter((field) =>
|
||||
matchesTextFilter(filterQuery, [
|
||||
schema.fields.filter((field) => {
|
||||
const optionText = (field.options ?? [])
|
||||
.map((o) => `${o.id} ${o.label}`)
|
||||
.join(" ");
|
||||
return matchesTextFilter(filterQuery, [
|
||||
field.label,
|
||||
field.type,
|
||||
displayTypeLabel(field.type),
|
||||
field.id,
|
||||
values?.[field.id],
|
||||
]),
|
||||
),
|
||||
optionText,
|
||||
]);
|
||||
}),
|
||||
[schema.fields, filterQuery, values],
|
||||
);
|
||||
|
||||
|
|
@ -332,33 +550,39 @@ export function FormRenderer({
|
|||
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, {
|
||||
label: editLabel.trim(),
|
||||
type: editType,
|
||||
})
|
||||
onSaveFieldEdit?.(
|
||||
field.id,
|
||||
buildPatch(editLabel.trim(), editType, editOptionsText, editCurrency),
|
||||
)
|
||||
}
|
||||
onCancelEdit={() => onCancelFieldEdit?.()}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add control lives below the list so existing attributes stay primary */}
|
||||
{showAddFieldForm ? (
|
||||
<FieldDefinitionForm
|
||||
label={addLabel}
|
||||
type={addType}
|
||||
optionsText={addOptionsText}
|
||||
defaultCurrency={addCurrency}
|
||||
onChangeLabel={setAddLabel}
|
||||
onChangeType={setAddType}
|
||||
onChangeOptionsText={setAddOptionsText}
|
||||
onChangeDefaultCurrency={setAddCurrency}
|
||||
onSave={() =>
|
||||
onConfirmAddField?.({
|
||||
label: addLabel.trim(),
|
||||
type: addType,
|
||||
})
|
||||
onConfirmAddField?.(
|
||||
buildPatch(addLabel.trim(), addType, addOptionsText, addCurrency),
|
||||
)
|
||||
}
|
||||
onCancel={() => onCancelAddField?.()}
|
||||
saveLabel="Add attribute"
|
||||
|
|
@ -372,6 +596,8 @@ export function FormRenderer({
|
|||
onClick={() => {
|
||||
setAddLabel(`New attribute ${schema.fields.length + 1}`);
|
||||
setAddType("text");
|
||||
setAddOptionsText("");
|
||||
setAddCurrency(DEFAULT_CURRENCY);
|
||||
onRequestAddField?.();
|
||||
}}
|
||||
style={{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue