Add attribute value types: time, datetime, amount, one-of, some-of.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Implement CE-WP-0012 interim catalog (accepted defaults): widgets, options
editor, amount JSON encoding, textarea→text-long normalize.
This commit is contained in:
tegwick 2026-07-30 21:05:43 +02:00
parent 9dc4880ec1
commit 8cf7bc1128
6 changed files with 628 additions and 73 deletions

View file

@ -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",
};
};

View file

@ -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();
});

View file

@ -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={{

View 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
View 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),
};
}

View file

@ -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";