From 36d8d3a73f444b32db455bbe26e04e3274a8017d Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 30 Jul 2026 21:05:44 +0200 Subject: [PATCH] Implement CE-WP-0012 attribute types; freeze accepted catalog defaults. Demo schema exercises amount and one-of; capture-state validates options; proposal marked accepted; workplan finished. --- WORK-RECORDS.md | 5 ++ docs/mvp-workplans-index.md | 2 +- src/app/forms/FormsApp.tsx | 26 +++++--- src/app/forms/capture-persistence.ts | 62 +++++++++++++------ src/app/forms/demo-schema.ts | 39 ++++++------ .../capture-session-persist.dom.test.tsx | 10 ++- wiki/AttributeValueTypes-proposal.md | 10 ++- workplans/CE-WP-0011-attributes-guide-nm.md | 5 ++ workplans/CE-WP-0012-attribute-value-types.md | 14 ++--- 9 files changed, 117 insertions(+), 56 deletions(-) diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index dd933cb..4ff1ead 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -18,6 +18,7 @@ | workplan | CE-WP-0008 | done | — | workplans/CE-WP-0008-capture-content-editing.md | | workplan | CE-WP-0009 | done | — | workplans/CE-WP-0009-engine-workspace-wireup.md | | workplan | CE-WP-0010 | finished | — | workplans/CE-WP-0010-annotate-attributes-ux.md | +| workplan | CE-WP-0011 | finished | — | workplans/CE-WP-0011-attributes-guide-nm.md | | task | CE-WP-0001-T01 | done | — | workplans/CE-WP-0001-foundations.md | | task | CE-WP-0001-T02 | done | — | workplans/CE-WP-0001-foundations.md | | task | CE-WP-0001-T03 | done | — | workplans/CE-WP-0001-foundations.md | @@ -89,3 +90,7 @@ | task | CE-WP-0010-T05 | done | — | workplans/CE-WP-0010-annotate-attributes-ux.md | | task | CE-WP-0010-T06 | done | — | workplans/CE-WP-0010-annotate-attributes-ux.md | | task | CE-WP-0010-T07 | done | — | workplans/CE-WP-0010-annotate-attributes-ux.md | +| task | CE-WP-0011-T01 | done | — | workplans/CE-WP-0011-attributes-guide-nm.md | +| task | CE-WP-0011-T02 | done | — | workplans/CE-WP-0011-attributes-guide-nm.md | +| task | CE-WP-0011-T03 | done | — | workplans/CE-WP-0011-attributes-guide-nm.md | +| task | CE-WP-0011-T04 | done | — | workplans/CE-WP-0011-attributes-guide-nm.md | diff --git a/docs/mvp-workplans-index.md b/docs/mvp-workplans-index.md index 38bc92e..9527f4b 100644 --- a/docs/mvp-workplans-index.md +++ b/docs/mvp-workplans-index.md @@ -33,7 +33,7 @@ publish tasks wait on ADR-0002 resolution. |----------|-------|--------| | `CE-WP-0010` | Annotate & Attributes UX — labels, filters, layout, evidence connectors | finished | | `CE-WP-0011` | Attributes guide n:m lines, title migration, Add attribute placement | finished | -| `CE-WP-0012` | Attribute value types — time, datetime, amount, one-of, some-of | proposed | +| `CE-WP-0012` | Attribute value types — time, datetime, amount, one-of, some-of | finished | User-facing polish after manual document review: rename Review→Annotate, keep Evidence column in Capture with Attributes on the right, list filters, and diff --git a/src/app/forms/FormsApp.tsx b/src/app/forms/FormsApp.tsx index d9857c0..a4836ab 100644 --- a/src/app/forms/FormsApp.tsx +++ b/src/app/forms/FormsApp.tsx @@ -93,6 +93,10 @@ export function FormsApp({ id, type: patch.type, label: patch.label.length > 0 ? patch.label : `New attribute ${n}`, + ...(patch.options ? { options: patch.options } : {}), + ...(patch.defaultCurrency + ? { defaultCurrency: patch.defaultCurrency } + : {}), }; return { ...prev, fields: [...prev.fields, field] }; }); @@ -105,15 +109,19 @@ export function FormsApp({ (fieldId: string, patch: FieldDefinitionPatch) => { setSchema((prev) => ({ ...prev, - fields: prev.fields.map((f) => - f.id === fieldId - ? { - ...f, - type: patch.type, - label: patch.label.length > 0 ? patch.label : f.label, - } - : f, - ), + fields: prev.fields.map((f) => { + if (f.id !== fieldId) return f; + const next: FormFieldSchema = { + id: f.id, + type: patch.type, + label: patch.label.length > 0 ? patch.label : f.label, + }; + if (patch.options) return { ...next, options: patch.options }; + if (patch.defaultCurrency) { + return { ...next, defaultCurrency: patch.defaultCurrency }; + } + return next; + }), })); setEditingFieldId(null); }, diff --git a/src/app/forms/capture-persistence.ts b/src/app/forms/capture-persistence.ts index d1cdce9..3a33d74 100644 --- a/src/app/forms/capture-persistence.ts +++ b/src/app/forms/capture-persistence.ts @@ -1,15 +1,19 @@ /** * Per-session Capture mode persistence (form schema, field values, links). * - * Engine snapshots intentionally omit binder/app UI state. This module - * stores capture data beside the engine snapshot under a per-session - * localStorage key. + * Amount wire form (CE-WP-0012): JSON string `{"value":"…","currency":"EUR"}`. + * some-of wire form: JSON array of option ids. */ import type { EvidenceLink } from "@shared/evidence-link"; import type { SessionId } from "@shared/ids"; -import type { FormSchema } from "@binder/FormRenderer"; +import { + isAttributeValueType, + normalizeFormSchema, + type FormFieldSchema, + type FormSchema, +} from "@binder/FormRenderer"; import { DEMO_SCHEMA } from "./demo-schema"; @@ -35,20 +39,37 @@ export function defaultCaptureState(): CaptureStateSnapshot { }; } +function isAttributeOption(value: unknown): boolean { + if (typeof value !== "object" || value === null) return false; + const o = value as Record; + return typeof o.id === "string" && typeof o.label === "string"; +} + +function isFormField(value: unknown): value is FormFieldSchema { + if (typeof value !== "object" || value === null) return false; + const field = value as Record; + if (typeof field.id !== "string" || typeof field.label !== "string") return false; + if (!isAttributeValueType(field.type)) return false; + if (field.options !== undefined) { + if (!Array.isArray(field.options) || !field.options.every(isAttributeOption)) { + return false; + } + } + if ( + field.defaultCurrency !== undefined && + typeof field.defaultCurrency !== "string" + ) { + return false; + } + return true; +} + function isFormSchema(value: unknown): value is FormSchema { if (typeof value !== "object" || value === null) return false; const o = value as Record; if (typeof o.id !== "string" || typeof o.title !== "string") return false; if (!Array.isArray(o.fields)) return false; - return o.fields.every((f) => { - if (typeof f !== "object" || f === null) return false; - const field = f as Record; - return ( - typeof field.id === "string" && - typeof field.label === "string" && - (field.type === "text" || field.type === "textarea" || field.type === "date") - ); - }); + return o.fields.every(isFormField); } /** Legacy Capture titles from pre-CE-WP-0010 sessions. */ @@ -59,10 +80,11 @@ const LEGACY_FORM_TITLES = new Set([ ]); function normalizeFormSchemaTitle(schema: FormSchema): FormSchema { - if (!LEGACY_FORM_TITLES.has(schema.title) && schema.title.trim().length > 0) { - return schema; + const normalized = normalizeFormSchema(schema); + if (!LEGACY_FORM_TITLES.has(normalized.title) && normalized.title.trim().length > 0) { + return normalized; } - return { ...schema, title: DEMO_SCHEMA.title }; + return { ...normalized, title: DEMO_SCHEMA.title }; } function parseCaptureState(raw: unknown): CaptureStateSnapshot | null { @@ -108,7 +130,11 @@ export function saveCaptureState( ): void { if (typeof storage?.setItem !== "function") return; try { - storage.setItem(captureStateKey(sessionId), JSON.stringify(state)); + const normalized: CaptureStateSnapshot = { + ...state, + formSchema: normalizeFormSchema(state.formSchema), + }; + storage.setItem(captureStateKey(sessionId), JSON.stringify(normalized)); } catch (err) { console.warn("saveCaptureState: write failed", err); } @@ -140,4 +166,4 @@ export function removeCaptureState( ): void { if (typeof storage?.removeItem !== "function") return; storage.removeItem(captureStateKey(sessionId)); -} \ No newline at end of file +} diff --git a/src/app/forms/demo-schema.ts b/src/app/forms/demo-schema.ts index 3e28fd1..a108276 100644 --- a/src/app/forms/demo-schema.ts +++ b/src/app/forms/demo-schema.ts @@ -1,30 +1,33 @@ /** - * Demo attributes schema for Capture mode (CE-WP-0003 form-binding slice; - * CE-WP-0010 renames the user-facing concept from "form" to "attributes"). - * - * Deliberately minimal: text, textarea, date. JSON Schema is **not** used - * here — that's deferred to a later ADR. The schema's only job is to render - * a handful of key/value attributes and accept evidence links so the - * visual-guide round-trip can be exercised end-to-end. + * Demo attributes schema for Capture mode. + * CE-WP-0012: richer value types (amount, one-of, …) per accepted catalog defaults. */ -export type FormFieldSchema = - | { readonly type: "text"; readonly id: string; readonly label: string } - | { readonly type: "textarea"; readonly id: string; readonly label: string } - | { readonly type: "date"; readonly id: string; readonly label: string }; +import type { FormFieldSchema, FormSchema } from "@binder/FormRenderer"; -export interface FormSchema { - readonly id: string; - readonly title: string; - readonly fields: readonly FormFieldSchema[]; -} +export type { FormFieldSchema, FormSchema }; export const DEMO_SCHEMA: FormSchema = { id: "demo-form", title: "Attributes", fields: [ - { type: "textarea", id: "summary", label: "Summary of the matter" }, + { type: "text-long", id: "summary", label: "Summary of the matter" }, { type: "date", id: "deadline", label: "Key deadline" }, - { type: "text", id: "amount", label: "Disputed amount" }, + { + type: "amount", + id: "amount", + label: "Disputed amount", + defaultCurrency: "EUR", + }, + { + type: "one-of", + id: "payment", + label: "Payment method", + options: [ + { id: "cash", label: "Cash" }, + { id: "transfer", label: "Bank transfer" }, + { id: "other", label: "Other" }, + ], + }, ], }; diff --git a/tests/integration/capture-session-persist.dom.test.tsx b/tests/integration/capture-session-persist.dom.test.tsx index dfffa1c..464613e 100644 --- a/tests/integration/capture-session-persist.dom.test.tsx +++ b/tests/integration/capture-session-persist.dom.test.tsx @@ -84,7 +84,12 @@ describe("Capture session persistence", () => { const user = userEvent.setup(); await loadApp(); - await user.type(screen.getByRole("textbox", { name: /Disputed amount/ }), "EUR 500"); + const amountInput = document.querySelector( + '#field-amount', + ) as HTMLInputElement; + expect(amountInput).toBeTruthy(); + await user.clear(amountInput); + await user.type(amountInput, "500"); await waitFor(() => { const keys = Object.keys(globalThis.localStorage ?? {}); @@ -92,7 +97,8 @@ describe("Capture session persistence", () => { expect(captureKey).toBeTruthy(); const sessionId = captureKey!.split(":")[2]; const state = loadCaptureState(sessionId as never); - expect(state?.fieldValues.amount).toBe("EUR 500"); + expect(state?.fieldValues.amount).toContain("500"); + expect(state?.fieldValues.amount).toContain("EUR"); expect(captureStateKey(sessionId as never)).toBe(captureKey); }); }, diff --git a/wiki/AttributeValueTypes-proposal.md b/wiki/AttributeValueTypes-proposal.md index 8396caf..fbfed34 100644 --- a/wiki/AttributeValueTypes-proposal.md +++ b/wiki/AttributeValueTypes-proposal.md @@ -1,10 +1,18 @@ # Proposal: Attribute Value Type Canon (consumer + InfoTechCanon) -**Status:** research draft (2026-07-30) +**Status:** accepted defaults (2026-07-30) — CE MVP frozen **Consumers:** citation-evidence Capture attributes **Producer candidate:** info-tech-canon (Data Model extension) **Related:** CE-WP-0012, ITC-WP-0013 (proposed) +### Accepted defaults (operator 2026-07-30) + +1. **Owner:** InfoTechCanon Data Model (long-term); CE ships interim catalog. +2. **Amount wire form:** structured JSON `{"value":"…","currency":"EUR"}`. +3. **textarea:** accept on load; normalize to `text-long` on write. +4. **boolean / integer / number / uri / identifier:** reserved, not implemented yet. +5. **one-of UI:** native `