From 48a53df9fcfbd7bab220a12ebf5fa4e549100ab9 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 8 Jun 2026 00:46:06 +0200 Subject: [PATCH] CE-WP-0007 T10-T12: field add/edit dialog, pencil edit, integration tests - FieldDefinitionForm shared component (label + text/textarea/date) - Add field opens inline form; per-field pencil edit with stable ids - forms-field-edit.dom.test.tsx covers add, edit, and link-to-new-field - Workplan T10-T12 and README marked done --- src/app/forms/FormsApp.tsx | 119 ++++++++---- src/binder/FieldDefinitionForm.tsx | 117 ++++++++++++ src/binder/FormRenderer.tsx | 175 ++++++++++++++++-- .../integration/forms-field-edit.dom.test.tsx | 171 +++++++++++++++++ workplans/CE-WP-0007-capture-view-polish.md | 8 +- workplans/README.md | 4 +- 6 files changed, 540 insertions(+), 54 deletions(-) create mode 100644 src/binder/FieldDefinitionForm.tsx create mode 100644 tests/integration/forms-field-edit.dom.test.tsx diff --git a/src/app/forms/FormsApp.tsx b/src/app/forms/FormsApp.tsx index 9ce4da5..663db36 100644 --- a/src/app/forms/FormsApp.tsx +++ b/src/app/forms/FormsApp.tsx @@ -34,7 +34,7 @@ import { useScrollToAnnotation, } from "@work/index"; -import { FormRenderer } from "@binder/FormRenderer"; +import { FormRenderer, type FieldDefinitionPatch } from "@binder/FormRenderer"; import { DEMO_SCHEMA } from "./demo-schema"; import { HighlightRectBridge } from "./HighlightRectBridge"; @@ -64,24 +64,76 @@ export function FormsApp() { [schema], ); - const addField = useCallback(() => { - setSchema((prev) => { - const n = prev.fields.length + 1; - const field: FormFieldSchema = { - type: "text", - id: `field_${n}`, - label: `New field ${n}`, - }; - return { ...prev, fields: [...prev.fields, field] }; - }); + const [showAddFieldForm, setShowAddFieldForm] = useState(false); + const [editingFieldId, setEditingFieldId] = useState(null); + + const nextFieldId = useCallback((fields: readonly FormFieldSchema[]): string => { + let max = 0; + for (const f of fields) { + const m = /^field_(\d+)$/.exec(f.id); + if (m) max = Math.max(max, Number(m[1])); + } + return `field_${max + 1}`; }, []); + const handleConfirmAddField = useCallback( + (patch: FieldDefinitionPatch) => { + setSchema((prev) => { + const id = nextFieldId(prev.fields); + const n = prev.fields.length + 1; + const field: FormFieldSchema = { + id, + type: patch.type, + label: patch.label.length > 0 ? patch.label : `New field ${n}`, + }; + return { ...prev, fields: [...prev.fields, field] }; + }); + setShowAddFieldForm(false); + }, + [nextFieldId], + ); + + const handleSaveFieldEdit = useCallback( + (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, + ), + })); + setEditingFieldId(null); + }, + [], + ); + return (
- + { + setEditingFieldId(null); + setShowAddFieldForm(true); + }} + onConfirmAddField={handleConfirmAddField} + onCancelAddField={() => setShowAddFieldForm(false)} + onBeginEditField={(fieldId) => { + setShowAddFieldForm(false); + setEditingFieldId(fieldId); + }} + onSaveFieldEdit={handleSaveFieldEdit} + onCancelFieldEdit={() => setEditingFieldId(null)} + />
@@ -104,10 +156,24 @@ function ScrollBridge() { function FormPane({ schema, - onAddField, + showAddFieldForm, + editingFieldId, + onRequestAddField, + onConfirmAddField, + onCancelAddField, + onBeginEditField, + onSaveFieldEdit, + onCancelFieldEdit, }: { schema: FormSchema; - onAddField: () => void; + showAddFieldForm: boolean; + editingFieldId: string | null; + onRequestAddField: () => void; + onConfirmAddField: (patch: FieldDefinitionPatch) => void; + onCancelAddField: () => void; + onBeginEditField: (fieldId: string) => void; + onSaveFieldEdit: (fieldId: string, patch: FieldDefinitionPatch) => void; + onCancelFieldEdit: () => void; }) { const { document } = useActiveDocument(); const { bindings } = useBinder(); @@ -189,23 +255,14 @@ function FormPane({ schema={schema} linkCounts={linkCounts} linkHints={linkHints} - headerAction={ - - } + showAddFieldForm={showAddFieldForm} + onRequestAddField={onRequestAddField} + onConfirmAddField={onConfirmAddField} + onCancelAddField={onCancelAddField} + editingFieldId={editingFieldId} + onBeginEditField={onBeginEditField} + onSaveFieldEdit={onSaveFieldEdit} + onCancelFieldEdit={onCancelFieldEdit} /> ) : ( diff --git a/src/binder/FieldDefinitionForm.tsx b/src/binder/FieldDefinitionForm.tsx new file mode 100644 index 0000000..cbedb39 --- /dev/null +++ b/src/binder/FieldDefinitionForm.tsx @@ -0,0 +1,117 @@ +/** + * Shared label + type editor for add-field and edit-field flows (CE-WP-0007-T10/T11). + * Styled to match EvidenceFormBody / InlineCaptureForm. + */ + +import type { CSSProperties, ReactNode } from "react"; + +import type { FormFieldSchema } from "./FormRenderer"; + +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 interface FieldDefinitionFormProps { + readonly label: string; + readonly type: FieldType; + onChangeLabel(next: string): void; + onChangeType(next: FieldType): void; + onSave(): void; + onCancel(): void; + readonly saveLabel?: string; + readonly cancelLabel?: string; + readonly badge?: ReactNode; + readonly testidPrefix: string; +} + +export function FieldDefinitionForm(p: FieldDefinitionFormProps) { + const saveLabel = p.saveLabel ?? "Save"; + const cancelLabel = p.cancelLabel ?? "Cancel"; + + return ( +
+ {p.badge && ( +
{p.badge}
+ )} + + p.onChangeLabel(e.target.value)} + data-testid={`${p.testidPrefix}-label-input`} + style={inputStyle} + /> + + +
+ + +
+
+ ); +} + +const labelStyle: CSSProperties = { + display: "block", + color: "#666", + fontSize: 11, + marginBottom: 2, +}; + +const inputStyle: CSSProperties = { + width: "100%", + boxSizing: "border-box", + fontSize: 12, + padding: 4, + marginBottom: 6, +}; + +const buttonStyle: CSSProperties = { + fontSize: 12, + padding: "4px 10px", +}; \ No newline at end of file diff --git a/src/binder/FormRenderer.tsx b/src/binder/FormRenderer.tsx index f898422..920045a 100644 --- a/src/binder/FormRenderer.tsx +++ b/src/binder/FormRenderer.tsx @@ -6,18 +6,14 @@ * draw curves from the active field to its linked evidence card and on * to the source highlight. * - * Lives in `src/binder/` (not `src/work/`) because it depends on the - * rect-registry hooks in `binder/visual-guide`. See `wiki/DependencyMap.md` - * §2/§5 for the `work ⊄ binder` rule that motivates this placement. - * - * No styling beyond minimum legibility (workplan T06 note). Tailwind / - * design system can land later without changing the registry contract. + * CE-WP-0007-T10/T11: add-field and edit-field flows use FieldDefinitionForm. */ -import { useRef, type ChangeEvent, type ReactNode } from "react"; +import { useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode } from "react"; import type { EvidenceTarget } from "@shared/evidence-link"; +import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm"; import { useActiveState, type ActiveState } from "./state/active"; import { useRegisterRect } from "./visual-guide/react-hooks"; @@ -40,40 +36,91 @@ export interface FormSchema { readonly fields: readonly FormFieldSchema[]; } +export interface FieldDefinitionPatch { + readonly label: string; + readonly type: FieldType; +} + export interface FormRendererProps { readonly schema: FormSchema; readonly values?: Readonly>; readonly onValueChange?: (fieldId: string, value: string) => void; - /** - * Per-field annotation count. Rendered as a small chip beside the - * label so the user can tell which fields already have evidence. - */ readonly linkCounts?: Readonly>; - /** Hover preview for the evidence-count chip (first linked quote). */ readonly linkHints?: Readonly>; - readonly headerAction?: ReactNode; + readonly showAddFieldForm?: boolean; + readonly onRequestAddField?: () => void; + readonly onConfirmAddField?: (patch: FieldDefinitionPatch) => void; + readonly onCancelAddField?: () => void; + readonly editingFieldId?: string | null; + readonly onBeginEditField?: (fieldId: string) => void; + readonly onSaveFieldEdit?: (fieldId: string, patch: FieldDefinitionPatch) => void; + readonly onCancelFieldEdit?: () => void; } +const iconButtonStyle: CSSProperties = { + fontSize: 11, + padding: "2px 6px", + background: "white", + border: "1px solid #888", + borderRadius: 3, + cursor: "pointer", + lineHeight: 1, +}; + function FieldRow({ field, value, linkCount, linkHint, isActive, + isEditing, + editLabel, + editType, onChange, onFocus, + onBeginEdit, + onChangeEditLabel, + onChangeEditType, + onSaveEdit, + onCancelEdit, }: { field: FormFieldSchema; value: string; linkCount: number; linkHint?: string; isActive: boolean; + isEditing: boolean; + editLabel: string; + editType: FieldType; onChange: (next: string) => void; onFocus: () => void; + onBeginEdit: () => void; + onChangeEditLabel: (next: string) => void; + onChangeEditType: (next: FieldType) => void; + onSaveEdit: () => void; + onCancelEdit: () => void; }) { const ref = useRef(null); useRegisterRect("field", field.id, ref); + if (isEditing) { + return ( +
+ +
+ ); + } + const sharedProps = { id: `field-${field.id}`, value, @@ -90,6 +137,7 @@ function FieldRow({ data-link-count={String(linkCount)} aria-current={isActive ? "true" : undefined} style={{ + position: "relative", marginBottom: 12, fontFamily: "system-ui, sans-serif", padding: 4, @@ -97,9 +145,34 @@ function FieldRow({ background: isActive ? "#e8f0ff" : "transparent", }} > +
+ + {showAddFieldForm && ( + + onConfirmAddField?.({ + label: addLabel.trim(), + type: addType, + }) + } + onCancel={() => onCancelAddField?.()} + saveLabel="Add field" + badge="New form field" + testidPrefix="field-add" + /> + )} + {schema.fields.map((field) => ( onValueChange?.(field.id, next)} onFocus={() => handleFocus(field.id)} + onBeginEdit={() => beginEdit(field)} + onChangeEditLabel={setEditLabel} + onChangeEditType={setEditType} + onSaveEdit={() => + onSaveFieldEdit?.(field.id, { + label: editLabel.trim(), + type: editType, + }) + } + onCancelEdit={() => onCancelFieldEdit?.()} /> ))} ); -} +} \ No newline at end of file diff --git a/tests/integration/forms-field-edit.dom.test.tsx b/tests/integration/forms-field-edit.dom.test.tsx new file mode 100644 index 0000000..c4afffc --- /dev/null +++ b/tests/integration/forms-field-edit.dom.test.tsx @@ -0,0 +1,171 @@ +/** + * CE-WP-0007-T10/T11/T12 — add-field type+label dialog and field edit icon. + */ + +// @vitest-environment happy-dom + +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Selector } from "@shared/selector"; + +import type { PdfSelectionCapture } from "@anchor/index"; +import manifest from "../../fixtures/pdfs/manifest.json" with { type: "json" }; + +interface ViewerProps { + pdfUrl: string; + storedAnnotations: readonly { id: string; text: string; selectors: readonly Selector[] }[]; + scrollToAnnotationId?: string; + onSelectionCaptured(capture: PdfSelectionCapture, selectors: Selector[]): void; +} + +const viewerSnapshot: { onSelectionCaptured: ViewerProps["onSelectionCaptured"] | null } = { + onSelectionCaptured: null, +}; + +vi.mock("@anchor/index", async (importOriginal) => { + const original = await importOriginal(); + const MockPdfSpikeViewer = (props: ViewerProps) => { + viewerSnapshot.onSelectionCaptured = props.onSelectionCaptured; + return
; + }; + return { ...original, PdfSpikeViewer: MockPdfSpikeViewer }; +}); + +const FIXTURE = manifest.fixtures.find((f) => f.id === "fristsetzung-bezifferung")!; +const SYNTHETIC_CANONICAL = ["Pre.", FIXTURE.known_good_quote, "Post."].join(" "); + +import { seedSessionWithDoc } from "./helpers/seed-session"; + +function syntheticCaptureFor(text: string, page: number): PdfSelectionCapture { + return { + kind: "pdf", + text, + page, + rects: [{ x: 0.1, y: 0.2, width: 0.4, height: 0.04 }], + boundingRect: { x: 0.1, y: 0.2, width: 0.4, height: 0.04 }, + }; +} + +async function loadApp() { + const { App } = await import("@app/App"); + return render(); +} + +async function saveEvidenceInReview( + user: ReturnType, + commentary: string, +) { + await waitFor(() => expect(viewerSnapshot.onSelectionCaptured).not.toBeNull()); + await act(async () => { + viewerSnapshot.onSelectionCaptured!( + syntheticCaptureFor(FIXTURE.known_good_quote, FIXTURE.known_good_quote_page), + [{ type: "TextQuoteSelector", exact: FIXTURE.known_good_quote }], + ); + }); + await user.type(screen.getByTestId("inline-capture-commentary"), commentary); + await user.click(screen.getByTestId("inline-capture-save")); + await screen.findByText(new RegExp(commentary)); +} + +describe("Capture — field add/edit UX (CE-WP-0007-T10/T11)", () => { + beforeEach(() => { + viewerSnapshot.onSelectionCaptured = null; + globalThis.localStorage?.clear(); + if (typeof window !== "undefined") { + history.replaceState(null, "", window.location.pathname); + } + seedSessionWithDoc({ + sessionName: "T10-field-edit", + documentTitle: FIXTURE.filename, + canonicalText: SYNTHETIC_CANONICAL, + mode: "forms", + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + cleanup(); + }); + + it( + "adds a date field with a custom label via the add-field form", + { timeout: 15000 }, + async () => { + const user = userEvent.setup(); + await loadApp(); + + await user.click(screen.getByTestId("add-field-button")); + await screen.findByTestId("field-add-form"); + + await user.clear(screen.getByTestId("field-add-label-input")); + await user.type(screen.getByTestId("field-add-label-input"), "Hearing date"); + await user.selectOptions(screen.getByTestId("field-add-type-select"), "date"); + await user.click(screen.getByTestId("field-add-save")); + + const hearingInput = await screen.findByLabelText("Hearing date"); + expect(hearingInput.getAttribute("type")).toBe("date"); + }, + ); + + it( + "edits an existing field label and type via the pencil icon", + { timeout: 15000 }, + async () => { + const user = userEvent.setup(); + await loadApp(); + + await user.click(screen.getByTestId("field-edit-toggle-summary")); + await screen.findByTestId("field-edit-summary-form"); + + await user.clear(screen.getByTestId("field-edit-summary-label-input")); + await user.type( + screen.getByTestId("field-edit-summary-label-input"), + "Matter summary", + ); + await user.selectOptions( + screen.getByTestId("field-edit-summary-type-select"), + "text", + ); + await user.click(screen.getByTestId("field-edit-summary-save")); + + await screen.findByLabelText("Matter summary"); + expect(screen.queryByLabelText("Summary of the matter")).toBeNull(); + }, + ); + + it( + "links evidence to a newly added field", + { timeout: 20000 }, + async () => { + const user = userEvent.setup(); + seedSessionWithDoc({ + sessionName: "T12-field-link", + documentTitle: FIXTURE.filename, + canonicalText: SYNTHETIC_CANONICAL, + }); + await loadApp(); + await saveEvidenceInReview(user, "Link to custom field"); + + await user.click(screen.getByRole("button", { name: "Capture" })); + + await user.click(screen.getByTestId("add-field-button")); + const addLabel = screen.getByTestId("field-add-label-input"); + await user.clear(addLabel); + await user.type(addLabel, "Custom slot"); + await user.click(screen.getByTestId("field-add-save")); + + const customField = await screen.findByLabelText("Custom slot"); + await user.click(customField); + + const stripCard = screen.getByRole("button", { name: /Link to custom field/ }); + await user.click(stripCard); + + await waitFor(() => { + const chips = screen.getAllByText(/1 evidence/); + expect(chips.length).toBeGreaterThanOrEqual(1); + }); + }, + ); +}); \ No newline at end of file diff --git a/workplans/CE-WP-0007-capture-view-polish.md b/workplans/CE-WP-0007-capture-view-polish.md index 9b66102..e68c134 100644 --- a/workplans/CE-WP-0007-capture-view-polish.md +++ b/workplans/CE-WP-0007-capture-view-polish.md @@ -7,7 +7,7 @@ repo: citation-evidence repo_id: a677c189-b4e2-4f2a-9e48-faa482c277e6 topic_slug: citation_evidence_mvp topic_id: 96fa8e80-9f74-40f2-84cd-644e9747b9ec -status: active +status: done owner: Bernd created: 2026-06-07 updated: 2026-06-08 @@ -246,7 +246,7 @@ state_hub_task_id: "823d4986-892d-467e-819e-54f0f2a363e8" ```task id: CE-WP-0007-T10 priority: medium -status: todo +status: done depends_on: [T08] state_hub_task_id: "dab723e6-66a6-4587-8ab7-e0c5e4cb5d0a" ``` @@ -274,7 +274,7 @@ correct input type and label. ```task id: CE-WP-0007-T11 priority: medium -status: todo +status: done depends_on: [T10] state_hub_task_id: "c55541c7-57e2-4f5d-a4ef-ed54c470cbd9" ``` @@ -300,7 +300,7 @@ links and visual guide still resolve by field id. ```task id: CE-WP-0007-T12 priority: high -status: todo +status: done depends_on: [T10, T11] state_hub_task_id: "585b054e-eb5e-410e-90ee-84e698b13f7f" ``` diff --git a/workplans/README.md b/workplans/README.md index e7b3504..6ccf3cc 100644 --- a/workplans/README.md +++ b/workplans/README.md @@ -2,7 +2,7 @@ MVP workplans for the citation-evidence umbrella repo. CE-WP-0001..0006 delivered the PRD §20 reference scenario and Forms/Review UX polish. -CE-WP-0007 Capture-view polish is active (field add/edit UX in progress). +CE-WP-0007 delivered Capture-view polish including field add/edit UX. | Workplan | Title | Status | |----------|----------------------------------------|--------| @@ -12,7 +12,7 @@ CE-WP-0007 Capture-view polish is active (field add/edit UX in progress). | `CE-WP-0004` | Citation card export — Markdown + HTML renderers, sidebar export | done | | `CE-WP-0005` | Demo sessions — uploads, named sessions, ZIP export/import | done | | `CE-WP-0006` | Forms & review UX refinements — blob fix, scroll centre, linking | done | -| `CE-WP-0007` | Capture view polish — scroll, linking, layout, rename, field UX | active | +| `CE-WP-0007` | Capture view polish — scroll, linking, layout, rename, field UX | done | ## Order