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,26 +1,33 @@
|
|||
/**
|
||||
* Shared key + type editor for add/edit attribute flows
|
||||
* (CE-WP-0007-T10/T11; CE-WP-0010 attributes vocabulary).
|
||||
* Styled to match EvidenceFormBody / InlineCaptureForm.
|
||||
* (CE-WP-0007-T10/T11; CE-WP-0010 vocabulary; CE-WP-0012 types).
|
||||
*/
|
||||
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
import type { FormFieldSchema } from "./FormRenderer";
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
formatOptionsText,
|
||||
IMPLEMENTED_FIELD_TYPES,
|
||||
typeNeedsOptions,
|
||||
type AttributeOption,
|
||||
type AttributeValueType,
|
||||
} from "./attribute-types";
|
||||
|
||||
export type FieldType = FormFieldSchema["type"];
|
||||
|
||||
const FIELD_TYPES: readonly { value: FieldType; label: string }[] = [
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "textarea", label: "Text area" },
|
||||
{ value: "date", label: "Date" },
|
||||
];
|
||||
export type FieldType = AttributeValueType;
|
||||
|
||||
export interface FieldDefinitionFormProps {
|
||||
readonly label: string;
|
||||
readonly type: FieldType;
|
||||
readonly options?: readonly AttributeOption[];
|
||||
readonly defaultCurrency?: string;
|
||||
onChangeLabel(next: string): void;
|
||||
onChangeType(next: FieldType): void;
|
||||
onChangeOptions?(next: readonly AttributeOption[]): void;
|
||||
onChangeDefaultCurrency?(next: string): void;
|
||||
/** Raw options text (controlled by parent when provided). */
|
||||
readonly optionsText?: string;
|
||||
onChangeOptionsText?(next: string): void;
|
||||
onSave(): void;
|
||||
onCancel(): void;
|
||||
readonly saveLabel?: string;
|
||||
|
|
@ -32,6 +39,9 @@ export interface FieldDefinitionFormProps {
|
|||
export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
||||
const saveLabel = p.saveLabel ?? "Save";
|
||||
const cancelLabel = p.cancelLabel ?? "Cancel";
|
||||
const needsOptions = typeNeedsOptions(p.type);
|
||||
const optionsText =
|
||||
p.optionsText ?? formatOptionsText(p.options);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -64,17 +74,53 @@ export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
|||
</label>
|
||||
<select
|
||||
id={`${p.testidPrefix}-type`}
|
||||
value={p.type}
|
||||
value={p.type === "textarea" ? "text-long" : p.type}
|
||||
onChange={(e) => p.onChangeType(e.target.value as FieldType)}
|
||||
data-testid={`${p.testidPrefix}-type-select`}
|
||||
style={inputStyle}
|
||||
>
|
||||
{FIELD_TYPES.map((opt) => (
|
||||
{IMPLEMENTED_FIELD_TYPES.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{p.type === "amount" && (
|
||||
<>
|
||||
<label style={labelStyle} htmlFor={`${p.testidPrefix}-currency`}>
|
||||
Default currency
|
||||
</label>
|
||||
<input
|
||||
id={`${p.testidPrefix}-currency`}
|
||||
type="text"
|
||||
value={p.defaultCurrency ?? DEFAULT_CURRENCY}
|
||||
onChange={(e) => p.onChangeDefaultCurrency?.(e.target.value.toUpperCase())}
|
||||
data-testid={`${p.testidPrefix}-currency-input`}
|
||||
placeholder="EUR"
|
||||
maxLength={3}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{needsOptions && (
|
||||
<>
|
||||
<label style={labelStyle} htmlFor={`${p.testidPrefix}-options`}>
|
||||
Options (one per line: <code>id|label</code> or <code>label</code>)
|
||||
</label>
|
||||
<textarea
|
||||
id={`${p.testidPrefix}-options`}
|
||||
rows={4}
|
||||
value={optionsText}
|
||||
onChange={(e) => p.onChangeOptionsText?.(e.target.value)}
|
||||
data-testid={`${p.testidPrefix}-options-input`}
|
||||
placeholder={"cash|Cash\ntransfer|Bank transfer"}
|
||||
style={{ ...inputStyle, fontFamily: "ui-monospace, monospace" }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -115,4 +161,4 @@ const inputStyle: CSSProperties = {
|
|||
const buttonStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
padding: "4px 10px",
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const SCHEMA: FormSchema = {
|
|||
id: "demo",
|
||||
title: "Attributes",
|
||||
fields: [
|
||||
{ type: "textarea", id: "summary", label: "Summary" },
|
||||
{ type: "text-long", id: "summary", label: "Summary" },
|
||||
{ type: "date", id: "deadline", label: "Deadline" },
|
||||
{ type: "text", id: "amount", label: "Amount" },
|
||||
],
|
||||
|
|
@ -61,9 +61,9 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
|
|||
|
||||
it("renders each schema field with key (type) labels", () => {
|
||||
renderWithProviders({ schema: SCHEMA });
|
||||
expect(screen.getByLabelText(/Summary \(textarea\)/)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Deadline \(date\)/)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Amount \(text\)/)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Summary \(Long text\)/)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Deadline \(Date\)/)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Amount \(Text\)/)).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Attributes" })).toBeTruthy();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
43
src/attribute-types.test.ts
Normal file
43
src/attribute-types.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
normalizeAttributeType,
|
||||
parseAmountValue,
|
||||
parseOptionsText,
|
||||
parseSomeOfValue,
|
||||
serializeAmountValue,
|
||||
serializeSomeOfValue,
|
||||
} from "./attribute-types";
|
||||
|
||||
describe("attribute-types", () => {
|
||||
it("normalizes textarea to text-long", () => {
|
||||
expect(normalizeAttributeType("textarea")).toBe("text-long");
|
||||
expect(normalizeAttributeType("date")).toBe("date");
|
||||
});
|
||||
|
||||
it("round-trips amount JSON", () => {
|
||||
const raw = serializeAmountValue({ value: "1500.00", currency: "EUR" });
|
||||
expect(JSON.parse(raw)).toEqual({ value: "1500.00", currency: "EUR" });
|
||||
expect(parseAmountValue(raw)).toEqual({ value: "1500.00", currency: "EUR" });
|
||||
});
|
||||
|
||||
it("parses plain amount strings", () => {
|
||||
expect(parseAmountValue("42.5 USD")).toEqual({
|
||||
value: "42.5",
|
||||
currency: "USD",
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trips some-of arrays", () => {
|
||||
const raw = serializeSomeOfValue(["a", "b"]);
|
||||
expect(parseSomeOfValue(raw)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("parses options text", () => {
|
||||
const opts = parseOptionsText("cash|Cash\nBank transfer\n");
|
||||
expect(opts).toEqual([
|
||||
{ id: "cash", label: "Cash" },
|
||||
{ id: "bank-transfer", label: "Bank transfer" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
220
src/attribute-types.ts
Normal file
220
src/attribute-types.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* Attribute value types for Capture (CE-WP-0012).
|
||||
*
|
||||
* Interim consumer catalog aligned with
|
||||
* `citation-evidence/wiki/AttributeValueTypes-proposal.md` (accepted defaults).
|
||||
* Long-term owner: InfoTechCanon AttributeValueType catalog (ITC-WP-0013).
|
||||
*/
|
||||
|
||||
/** Implemented MVP types + legacy alias `textarea`. */
|
||||
export type AttributeValueType =
|
||||
| "text"
|
||||
| "text-long"
|
||||
| "textarea" // legacy alias of text-long
|
||||
| "date"
|
||||
| "time"
|
||||
| "datetime"
|
||||
| "amount"
|
||||
| "one-of"
|
||||
| "some-of";
|
||||
|
||||
/** Reserved in the proposal; not rendered yet. */
|
||||
export type ReservedAttributeValueType =
|
||||
| "boolean"
|
||||
| "integer"
|
||||
| "number"
|
||||
| "uri"
|
||||
| "identifier";
|
||||
|
||||
export interface AttributeOption {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface FormFieldSchema {
|
||||
readonly type: AttributeValueType;
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
/** CodeList options for one-of / some-of. */
|
||||
readonly options?: readonly AttributeOption[];
|
||||
/** Default ISO 4217 code for amount attributes. */
|
||||
readonly defaultCurrency?: string;
|
||||
}
|
||||
|
||||
export interface FormSchema {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly fields: readonly FormFieldSchema[];
|
||||
}
|
||||
|
||||
export const IMPLEMENTED_FIELD_TYPES: readonly {
|
||||
value: AttributeValueType;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "text-long", label: "Long text" },
|
||||
{ value: "date", label: "Date" },
|
||||
{ value: "time", label: "Time" },
|
||||
{ value: "datetime", label: "Date & time" },
|
||||
{ value: "amount", label: "Amount" },
|
||||
{ value: "one-of", label: "One of" },
|
||||
{ value: "some-of", label: "Some of" },
|
||||
];
|
||||
|
||||
const KNOWN_TYPES = new Set<string>([
|
||||
...IMPLEMENTED_FIELD_TYPES.map((t) => t.value),
|
||||
"textarea",
|
||||
]);
|
||||
|
||||
export function isAttributeValueType(value: unknown): value is AttributeValueType {
|
||||
return typeof value === "string" && KNOWN_TYPES.has(value);
|
||||
}
|
||||
|
||||
/** Normalize legacy wire ids. */
|
||||
export function normalizeAttributeType(type: AttributeValueType): AttributeValueType {
|
||||
return type === "textarea" ? "text-long" : type;
|
||||
}
|
||||
|
||||
export function displayTypeLabel(type: AttributeValueType): string {
|
||||
const n = normalizeAttributeType(type);
|
||||
return IMPLEMENTED_FIELD_TYPES.find((t) => t.value === n)?.label ?? n;
|
||||
}
|
||||
|
||||
export function typeNeedsOptions(type: AttributeValueType): boolean {
|
||||
const n = normalizeAttributeType(type);
|
||||
return n === "one-of" || n === "some-of";
|
||||
}
|
||||
|
||||
export interface AmountValue {
|
||||
readonly value: string;
|
||||
readonly currency: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CURRENCY = "EUR";
|
||||
|
||||
export function parseAmountValue(raw: string | undefined): AmountValue {
|
||||
if (!raw || raw.trim().length === 0) {
|
||||
return { value: "", currency: DEFAULT_CURRENCY };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
const o = parsed as Record<string, unknown>;
|
||||
return {
|
||||
value: typeof o.value === "string" ? o.value : String(o.value ?? ""),
|
||||
currency:
|
||||
typeof o.currency === "string" && o.currency.length > 0
|
||||
? o.currency
|
||||
: DEFAULT_CURRENCY,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// plain "1500 EUR" or bare number
|
||||
const m = raw.trim().match(/^([0-9]+(?:\.[0-9]+)?)\s*([A-Za-z]{3})?$/);
|
||||
if (m) {
|
||||
return {
|
||||
value: m[1] ?? "",
|
||||
currency: (m[2] ?? DEFAULT_CURRENCY).toUpperCase(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { value: raw, currency: DEFAULT_CURRENCY };
|
||||
}
|
||||
|
||||
export function serializeAmountValue(amount: AmountValue): string {
|
||||
return JSON.stringify({
|
||||
value: amount.value,
|
||||
currency: amount.currency || DEFAULT_CURRENCY,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseSomeOfValue(raw: string | undefined): readonly string[] {
|
||||
if (!raw || raw.trim().length === 0) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
} catch {
|
||||
// single id without JSON
|
||||
return [raw];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function serializeSomeOfValue(ids: readonly string[]): string {
|
||||
return JSON.stringify([...ids]);
|
||||
}
|
||||
|
||||
/** slug-ish option id from label */
|
||||
export function optionIdFromLabel(label: string, used: ReadonlySet<string>): string {
|
||||
let base = label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (!base) base = "opt";
|
||||
let id = base;
|
||||
let n = 2;
|
||||
while (used.has(id)) {
|
||||
id = `${base}-${n}`;
|
||||
n += 1;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse options editor text: one option per line.
|
||||
* `id|label` or bare `label` (id auto-derived).
|
||||
*/
|
||||
export function parseOptionsText(text: string): readonly AttributeOption[] {
|
||||
const used = new Set<string>();
|
||||
const out: AttributeOption[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const pipe = trimmed.indexOf("|");
|
||||
let id: string;
|
||||
let label: string;
|
||||
if (pipe >= 0) {
|
||||
id = trimmed.slice(0, pipe).trim();
|
||||
label = trimmed.slice(pipe + 1).trim();
|
||||
if (!id) id = optionIdFromLabel(label, used);
|
||||
} else {
|
||||
label = trimmed;
|
||||
id = optionIdFromLabel(label, used);
|
||||
}
|
||||
if (!label) continue;
|
||||
used.add(id);
|
||||
out.push({ id, label });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function formatOptionsText(options: readonly AttributeOption[] | undefined): string {
|
||||
if (!options || options.length === 0) return "";
|
||||
return options.map((o) => `${o.id}|${o.label}`).join("\n");
|
||||
}
|
||||
|
||||
export function normalizeFormField(field: FormFieldSchema): FormFieldSchema {
|
||||
const type = normalizeAttributeType(field.type);
|
||||
const base: FormFieldSchema = {
|
||||
id: field.id,
|
||||
label: field.label,
|
||||
type,
|
||||
};
|
||||
if (typeNeedsOptions(type) && field.options) {
|
||||
return { ...base, options: field.options };
|
||||
}
|
||||
if (type === "amount" && field.defaultCurrency) {
|
||||
return { ...base, defaultCurrency: field.defaultCurrency };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function normalizeFormSchema(schema: FormSchema): FormSchema {
|
||||
return {
|
||||
...schema,
|
||||
fields: schema.fields.map(normalizeFormField),
|
||||
};
|
||||
}
|
||||
24
src/index.ts
24
src/index.ts
|
|
@ -5,9 +5,29 @@ export * from "./visual-guide";
|
|||
export { FormRenderer, matchesTextFilter } from "./FormRenderer";
|
||||
export type {
|
||||
FieldDefinitionPatch,
|
||||
FormFieldSchema,
|
||||
FormRendererProps,
|
||||
FormSchema,
|
||||
} from "./FormRenderer";
|
||||
export {
|
||||
DEFAULT_CURRENCY,
|
||||
displayTypeLabel,
|
||||
formatOptionsText,
|
||||
IMPLEMENTED_FIELD_TYPES,
|
||||
isAttributeValueType,
|
||||
normalizeAttributeType,
|
||||
normalizeFormField,
|
||||
normalizeFormSchema,
|
||||
parseAmountValue,
|
||||
parseOptionsText,
|
||||
parseSomeOfValue,
|
||||
serializeAmountValue,
|
||||
serializeSomeOfValue,
|
||||
typeNeedsOptions,
|
||||
} from "./attribute-types";
|
||||
export type {
|
||||
AttributeOption,
|
||||
AttributeValueType,
|
||||
FormFieldSchema,
|
||||
FormSchema,
|
||||
} from "./attribute-types";
|
||||
export { BinderProvider, useBinder, useBinderOptional } from "./BinderProvider";
|
||||
export type { BinderServices, BinderProviderProps } from "./BinderProvider";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue