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
|
* Shared key + type editor for add/edit attribute flows
|
||||||
* (CE-WP-0007-T10/T11; CE-WP-0010 attributes vocabulary).
|
* (CE-WP-0007-T10/T11; CE-WP-0010 vocabulary; CE-WP-0012 types).
|
||||||
* Styled to match EvidenceFormBody / InlineCaptureForm.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
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"];
|
export type FieldType = AttributeValueType;
|
||||||
|
|
||||||
const FIELD_TYPES: readonly { value: FieldType; label: string }[] = [
|
|
||||||
{ value: "text", label: "Text" },
|
|
||||||
{ value: "textarea", label: "Text area" },
|
|
||||||
{ value: "date", label: "Date" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface FieldDefinitionFormProps {
|
export interface FieldDefinitionFormProps {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly type: FieldType;
|
readonly type: FieldType;
|
||||||
|
readonly options?: readonly AttributeOption[];
|
||||||
|
readonly defaultCurrency?: string;
|
||||||
onChangeLabel(next: string): void;
|
onChangeLabel(next: string): void;
|
||||||
onChangeType(next: FieldType): 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;
|
onSave(): void;
|
||||||
onCancel(): void;
|
onCancel(): void;
|
||||||
readonly saveLabel?: string;
|
readonly saveLabel?: string;
|
||||||
|
|
@ -32,6 +39,9 @@ export interface FieldDefinitionFormProps {
|
||||||
export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
||||||
const saveLabel = p.saveLabel ?? "Save";
|
const saveLabel = p.saveLabel ?? "Save";
|
||||||
const cancelLabel = p.cancelLabel ?? "Cancel";
|
const cancelLabel = p.cancelLabel ?? "Cancel";
|
||||||
|
const needsOptions = typeNeedsOptions(p.type);
|
||||||
|
const optionsText =
|
||||||
|
p.optionsText ?? formatOptionsText(p.options);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -64,17 +74,53 @@ export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id={`${p.testidPrefix}-type`}
|
id={`${p.testidPrefix}-type`}
|
||||||
value={p.type}
|
value={p.type === "textarea" ? "text-long" : p.type}
|
||||||
onChange={(e) => p.onChangeType(e.target.value as FieldType)}
|
onChange={(e) => p.onChangeType(e.target.value as FieldType)}
|
||||||
data-testid={`${p.testidPrefix}-type-select`}
|
data-testid={`${p.testidPrefix}-type-select`}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
>
|
>
|
||||||
{FIELD_TYPES.map((opt) => (
|
{IMPLEMENTED_FIELD_TYPES.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{opt.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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 }}>
|
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const SCHEMA: FormSchema = {
|
||||||
id: "demo",
|
id: "demo",
|
||||||
title: "Attributes",
|
title: "Attributes",
|
||||||
fields: [
|
fields: [
|
||||||
{ type: "textarea", id: "summary", label: "Summary" },
|
{ type: "text-long", id: "summary", label: "Summary" },
|
||||||
{ type: "date", id: "deadline", label: "Deadline" },
|
{ type: "date", id: "deadline", label: "Deadline" },
|
||||||
{ type: "text", id: "amount", label: "Amount" },
|
{ 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", () => {
|
it("renders each schema field with key (type) labels", () => {
|
||||||
renderWithProviders({ schema: SCHEMA });
|
renderWithProviders({ schema: SCHEMA });
|
||||||
expect(screen.getByLabelText(/Summary \(textarea\)/)).toBeTruthy();
|
expect(screen.getByLabelText(/Summary \(Long text\)/)).toBeTruthy();
|
||||||
expect(screen.getByLabelText(/Deadline \(date\)/)).toBeTruthy();
|
expect(screen.getByLabelText(/Deadline \(Date\)/)).toBeTruthy();
|
||||||
expect(screen.getByLabelText(/Amount \(text\)/)).toBeTruthy();
|
expect(screen.getByLabelText(/Amount \(Text\)/)).toBeTruthy();
|
||||||
expect(screen.getByRole("heading", { name: "Attributes" })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "Attributes" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* FormRenderer — renders a FormSchema as evidence-backed attributes.
|
* FormRenderer — evidence-backed attributes (CE-WP-0010/0012).
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -21,10 +12,40 @@ import {
|
||||||
|
|
||||||
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
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 { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
|
||||||
import { useActiveState, type ActiveState } from "./state/active";
|
import { useActiveState, type ActiveState } from "./state/active";
|
||||||
import { useRegisterRect } from "./visual-guide/react-hooks";
|
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;
|
const FILTER_MIN_CHARS = 3;
|
||||||
|
|
||||||
function isFieldActive(state: ActiveState, fieldId: string): boolean {
|
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(
|
export function matchesTextFilter(
|
||||||
query: string,
|
query: string,
|
||||||
parts: readonly (string | undefined | null)[],
|
parts: readonly (string | undefined | null)[],
|
||||||
|
|
@ -46,21 +66,11 @@ export function matchesTextFilter(
|
||||||
return parts.some((p) => p != null && String(p).toLowerCase().includes(needle));
|
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 {
|
export interface FieldDefinitionPatch {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly type: FieldType;
|
readonly type: FieldType;
|
||||||
|
readonly options?: readonly AttributeOption[];
|
||||||
|
readonly defaultCurrency?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormRendererProps {
|
export interface FormRendererProps {
|
||||||
|
|
@ -89,6 +99,174 @@ const iconButtonStyle: CSSProperties = {
|
||||||
lineHeight: 1,
|
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({
|
function FieldRow({
|
||||||
field,
|
field,
|
||||||
value,
|
value,
|
||||||
|
|
@ -98,11 +276,15 @@ function FieldRow({
|
||||||
isEditing,
|
isEditing,
|
||||||
editLabel,
|
editLabel,
|
||||||
editType,
|
editType,
|
||||||
|
editOptionsText,
|
||||||
|
editCurrency,
|
||||||
onChange,
|
onChange,
|
||||||
onFocus,
|
onFocus,
|
||||||
onBeginEdit,
|
onBeginEdit,
|
||||||
onChangeEditLabel,
|
onChangeEditLabel,
|
||||||
onChangeEditType,
|
onChangeEditType,
|
||||||
|
onChangeEditOptionsText,
|
||||||
|
onChangeEditCurrency,
|
||||||
onSaveEdit,
|
onSaveEdit,
|
||||||
onCancelEdit,
|
onCancelEdit,
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -114,16 +296,21 @@ function FieldRow({
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
editLabel: string;
|
editLabel: string;
|
||||||
editType: FieldType;
|
editType: FieldType;
|
||||||
|
editOptionsText: string;
|
||||||
|
editCurrency: string;
|
||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onBeginEdit: () => void;
|
onBeginEdit: () => void;
|
||||||
onChangeEditLabel: (next: string) => void;
|
onChangeEditLabel: (next: string) => void;
|
||||||
onChangeEditType: (next: FieldType) => void;
|
onChangeEditType: (next: FieldType) => void;
|
||||||
|
onChangeEditOptionsText: (next: string) => void;
|
||||||
|
onChangeEditCurrency: (next: string) => void;
|
||||||
onSaveEdit: () => void;
|
onSaveEdit: () => void;
|
||||||
onCancelEdit: () => void;
|
onCancelEdit: () => void;
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useRegisterRect("field", field.id, ref);
|
useRegisterRect("field", field.id, ref);
|
||||||
|
const typeLabel = displayTypeLabel(field.type);
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -131,8 +318,12 @@ function FieldRow({
|
||||||
<FieldDefinitionForm
|
<FieldDefinitionForm
|
||||||
label={editLabel}
|
label={editLabel}
|
||||||
type={editType}
|
type={editType}
|
||||||
|
optionsText={editOptionsText}
|
||||||
|
defaultCurrency={editCurrency}
|
||||||
onChangeLabel={onChangeEditLabel}
|
onChangeLabel={onChangeEditLabel}
|
||||||
onChangeType={onChangeEditType}
|
onChangeType={onChangeEditType}
|
||||||
|
onChangeOptionsText={onChangeEditOptionsText}
|
||||||
|
onChangeDefaultCurrency={onChangeEditCurrency}
|
||||||
onSave={onSaveEdit}
|
onSave={onSaveEdit}
|
||||||
onCancel={onCancelEdit}
|
onCancel={onCancelEdit}
|
||||||
saveLabel="Save attribute"
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -187,7 +369,12 @@ function FieldRow({
|
||||||
✎
|
✎
|
||||||
</button>
|
</button>
|
||||||
<label
|
<label
|
||||||
htmlFor={sharedProps.id}
|
id={`field-${field.id}-label`}
|
||||||
|
htmlFor={
|
||||||
|
normalizeAttributeType(field.type) === "some-of"
|
||||||
|
? undefined
|
||||||
|
: `field-${field.id}`
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
|
|
@ -197,7 +384,7 @@ function FieldRow({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{field.label}{" "}
|
{field.label}{" "}
|
||||||
<span style={{ fontWeight: 400, color: "#666" }}>({field.type})</span>
|
<span style={{ fontWeight: 400, color: "#666" }}>({typeLabel})</span>
|
||||||
{linkCount > 0 ? (
|
{linkCount > 0 ? (
|
||||||
<span
|
<span
|
||||||
data-testid={`field-${field.id}-chip`}
|
data-testid={`field-${field.id}-chip`}
|
||||||
|
|
@ -216,15 +403,36 @@ function FieldRow({
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</label>
|
</label>
|
||||||
{field.type === "textarea" ? (
|
<ValueControl
|
||||||
<textarea rows={2} {...sharedProps} />
|
field={field}
|
||||||
) : (
|
value={value}
|
||||||
<input type={field.type === "date" ? "date" : "text"} {...sharedProps} />
|
onChange={onChange}
|
||||||
)}
|
onFocus={onFocus}
|
||||||
|
/>
|
||||||
</div>
|
</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({
|
export function FormRenderer({
|
||||||
schema,
|
schema,
|
||||||
values,
|
values,
|
||||||
|
|
@ -243,8 +451,12 @@ export function FormRenderer({
|
||||||
const { state, focusTarget } = useActiveState();
|
const { state, focusTarget } = useActiveState();
|
||||||
const [addLabel, setAddLabel] = useState("New attribute");
|
const [addLabel, setAddLabel] = useState("New attribute");
|
||||||
const [addType, setAddType] = useState<FieldType>("text");
|
const [addType, setAddType] = useState<FieldType>("text");
|
||||||
|
const [addOptionsText, setAddOptionsText] = useState("");
|
||||||
|
const [addCurrency, setAddCurrency] = useState(DEFAULT_CURRENCY);
|
||||||
const [editLabel, setEditLabel] = useState("");
|
const [editLabel, setEditLabel] = useState("");
|
||||||
const [editType, setEditType] = useState<FieldType>("text");
|
const [editType, setEditType] = useState<FieldType>("text");
|
||||||
|
const [editOptionsText, setEditOptionsText] = useState("");
|
||||||
|
const [editCurrency, setEditCurrency] = useState(DEFAULT_CURRENCY);
|
||||||
const [filterQuery, setFilterQuery] = useState("");
|
const [filterQuery, setFilterQuery] = useState("");
|
||||||
|
|
||||||
const handleFocus = (fieldId: string) => {
|
const handleFocus = (fieldId: string) => {
|
||||||
|
|
@ -254,20 +466,26 @@ export function FormRenderer({
|
||||||
|
|
||||||
const beginEdit = (field: FormFieldSchema) => {
|
const beginEdit = (field: FormFieldSchema) => {
|
||||||
setEditLabel(field.label);
|
setEditLabel(field.label);
|
||||||
setEditType(field.type);
|
setEditType(normalizeAttributeType(field.type));
|
||||||
|
setEditOptionsText(formatOptionsText(field.options));
|
||||||
|
setEditCurrency(field.defaultCurrency ?? DEFAULT_CURRENCY);
|
||||||
onBeginEditField?.(field.id);
|
onBeginEditField?.(field.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibleFields = useMemo(
|
const visibleFields = useMemo(
|
||||||
() =>
|
() =>
|
||||||
schema.fields.filter((field) =>
|
schema.fields.filter((field) => {
|
||||||
matchesTextFilter(filterQuery, [
|
const optionText = (field.options ?? [])
|
||||||
|
.map((o) => `${o.id} ${o.label}`)
|
||||||
|
.join(" ");
|
||||||
|
return matchesTextFilter(filterQuery, [
|
||||||
field.label,
|
field.label,
|
||||||
field.type,
|
displayTypeLabel(field.type),
|
||||||
field.id,
|
field.id,
|
||||||
values?.[field.id],
|
values?.[field.id],
|
||||||
]),
|
optionText,
|
||||||
),
|
]);
|
||||||
|
}),
|
||||||
[schema.fields, filterQuery, values],
|
[schema.fields, filterQuery, values],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -332,33 +550,39 @@ export function FormRenderer({
|
||||||
isEditing={editingFieldId === field.id}
|
isEditing={editingFieldId === field.id}
|
||||||
editLabel={editLabel}
|
editLabel={editLabel}
|
||||||
editType={editType}
|
editType={editType}
|
||||||
|
editOptionsText={editOptionsText}
|
||||||
|
editCurrency={editCurrency}
|
||||||
onChange={(next) => onValueChange?.(field.id, next)}
|
onChange={(next) => onValueChange?.(field.id, next)}
|
||||||
onFocus={() => handleFocus(field.id)}
|
onFocus={() => handleFocus(field.id)}
|
||||||
onBeginEdit={() => beginEdit(field)}
|
onBeginEdit={() => beginEdit(field)}
|
||||||
onChangeEditLabel={setEditLabel}
|
onChangeEditLabel={setEditLabel}
|
||||||
onChangeEditType={setEditType}
|
onChangeEditType={setEditType}
|
||||||
|
onChangeEditOptionsText={setEditOptionsText}
|
||||||
|
onChangeEditCurrency={setEditCurrency}
|
||||||
onSaveEdit={() =>
|
onSaveEdit={() =>
|
||||||
onSaveFieldEdit?.(field.id, {
|
onSaveFieldEdit?.(
|
||||||
label: editLabel.trim(),
|
field.id,
|
||||||
type: editType,
|
buildPatch(editLabel.trim(), editType, editOptionsText, editCurrency),
|
||||||
})
|
)
|
||||||
}
|
}
|
||||||
onCancelEdit={() => onCancelFieldEdit?.()}
|
onCancelEdit={() => onCancelFieldEdit?.()}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Add control lives below the list so existing attributes stay primary */}
|
|
||||||
{showAddFieldForm ? (
|
{showAddFieldForm ? (
|
||||||
<FieldDefinitionForm
|
<FieldDefinitionForm
|
||||||
label={addLabel}
|
label={addLabel}
|
||||||
type={addType}
|
type={addType}
|
||||||
|
optionsText={addOptionsText}
|
||||||
|
defaultCurrency={addCurrency}
|
||||||
onChangeLabel={setAddLabel}
|
onChangeLabel={setAddLabel}
|
||||||
onChangeType={setAddType}
|
onChangeType={setAddType}
|
||||||
|
onChangeOptionsText={setAddOptionsText}
|
||||||
|
onChangeDefaultCurrency={setAddCurrency}
|
||||||
onSave={() =>
|
onSave={() =>
|
||||||
onConfirmAddField?.({
|
onConfirmAddField?.(
|
||||||
label: addLabel.trim(),
|
buildPatch(addLabel.trim(), addType, addOptionsText, addCurrency),
|
||||||
type: addType,
|
)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
onCancel={() => onCancelAddField?.()}
|
onCancel={() => onCancelAddField?.()}
|
||||||
saveLabel="Add attribute"
|
saveLabel="Add attribute"
|
||||||
|
|
@ -372,6 +596,8 @@ export function FormRenderer({
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddLabel(`New attribute ${schema.fields.length + 1}`);
|
setAddLabel(`New attribute ${schema.fields.length + 1}`);
|
||||||
setAddType("text");
|
setAddType("text");
|
||||||
|
setAddOptionsText("");
|
||||||
|
setAddCurrency(DEFAULT_CURRENCY);
|
||||||
onRequestAddField?.();
|
onRequestAddField?.();
|
||||||
}}
|
}}
|
||||||
style={{
|
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 { FormRenderer, matchesTextFilter } from "./FormRenderer";
|
||||||
export type {
|
export type {
|
||||||
FieldDefinitionPatch,
|
FieldDefinitionPatch,
|
||||||
FormFieldSchema,
|
|
||||||
FormRendererProps,
|
FormRendererProps,
|
||||||
FormSchema,
|
|
||||||
} from "./FormRenderer";
|
} 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 { BinderProvider, useBinder, useBinderOptional } from "./BinderProvider";
|
||||||
export type { BinderServices, BinderProviderProps } from "./BinderProvider";
|
export type { BinderServices, BinderProviderProps } from "./BinderProvider";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue