Attributes UX: Key (type) labels, filter, Overlay without field target.
CE-WP-0010: rename form vocabulary in user-facing strings to attributes, show type next to keys, filter attributes at ≥3 characters, and allow card→highlight guide lines when no attribute is focused.
This commit is contained in:
parent
c07e369bdb
commit
32c5a2ede4
5 changed files with 137 additions and 53 deletions
|
|
@ -1,5 +1,6 @@
|
|||
/**
|
||||
* Shared label + type editor for add-field and edit-field flows (CE-WP-0007-T10/T11).
|
||||
* Shared key + type editor for add/edit attribute flows
|
||||
* (CE-WP-0007-T10/T11; CE-WP-0010 attributes vocabulary).
|
||||
* Styled to match EvidenceFormBody / InlineCaptureForm.
|
||||
*/
|
||||
|
||||
|
|
@ -48,7 +49,7 @@ export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
|||
<div style={{ marginBottom: 6, fontWeight: 600 }}>{p.badge}</div>
|
||||
)}
|
||||
<label style={labelStyle} htmlFor={`${p.testidPrefix}-label`}>
|
||||
Field label
|
||||
Attribute key
|
||||
</label>
|
||||
<input
|
||||
id={`${p.testidPrefix}-label`}
|
||||
|
|
@ -59,7 +60,7 @@ export function FieldDefinitionForm(p: FieldDefinitionFormProps) {
|
|||
style={inputStyle}
|
||||
/>
|
||||
<label style={labelStyle} htmlFor={`${p.testidPrefix}-type`}>
|
||||
Field type
|
||||
Attribute type
|
||||
</label>
|
||||
<select
|
||||
id={`${p.testidPrefix}-type`}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
|
||||
const SCHEMA: FormSchema = {
|
||||
id: "demo",
|
||||
title: "Demo form",
|
||||
title: "Attributes",
|
||||
fields: [
|
||||
{ type: "textarea", id: "summary", label: "Summary" },
|
||||
{ type: "date", id: "deadline", label: "Deadline" },
|
||||
|
|
@ -59,11 +59,12 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
|
|||
cleanup();
|
||||
});
|
||||
|
||||
it("renders each schema field with its label", () => {
|
||||
it("renders each schema field with key (type) labels", () => {
|
||||
renderWithProviders({ schema: SCHEMA });
|
||||
expect(screen.getByLabelText("Summary")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Deadline")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Amount")).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Summary \(textarea\)/)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Deadline \(date\)/)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/Amount \(text\)/)).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Attributes" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("registers each field with the rect registry as kind=field", () => {
|
||||
|
|
@ -79,7 +80,7 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
|
|||
const user = userEvent.setup();
|
||||
const { events, ctxValue } = renderWithProviders({ schema: SCHEMA });
|
||||
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||
await user.click(screen.getByLabelText("Summary"));
|
||||
await user.click(screen.getByLabelText(/Summary/));
|
||||
const fieldEvents = events.filter((e) => e.type === "FormFieldActivated");
|
||||
expect(fieldEvents).toHaveLength(1);
|
||||
expect(fieldEvents[0]).toMatchObject({
|
||||
|
|
@ -95,13 +96,29 @@ describe("FormRenderer (CE-WP-0003-T04)", () => {
|
|||
onValueChange: (id, value) => changes.push([id, value]),
|
||||
});
|
||||
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||
await user.type(screen.getByLabelText("Amount"), "42");
|
||||
await user.type(screen.getByLabelText(/Amount/), "42");
|
||||
expect(changes).toEqual([
|
||||
["amount", "4"],
|
||||
["amount", "2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters attributes when the query has 3+ characters", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { ctxValue } = renderWithProviders({
|
||||
schema: SCHEMA,
|
||||
values: { amount: "1500 EUR" },
|
||||
});
|
||||
cleanupCtx = () => ctxValue.observer.disconnect();
|
||||
const filter = screen.getByTestId("attributes-filter");
|
||||
await user.type(filter, "am");
|
||||
expect(document.querySelector('[data-field-id="summary"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-field-id="amount"]')).not.toBeNull();
|
||||
await user.type(filter, "o"); // "amo" — matches Amount
|
||||
expect(document.querySelector('[data-field-id="summary"]')).toBeNull();
|
||||
expect(document.querySelector('[data-field-id="amount"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the link-count chip when linkCounts[fieldId] > 0", () => {
|
||||
const { ctxValue } = renderWithProviders({
|
||||
schema: SCHEMA,
|
||||
|
|
|
|||
|
|
@ -1,15 +1,23 @@
|
|||
/**
|
||||
* FormRenderer — renders a FormSchema as a small evidence-backed form.
|
||||
* FormRenderer — renders a FormSchema as evidence-backed attributes.
|
||||
*
|
||||
* Each field registers itself with the rect registry under
|
||||
* `kind="field"` and the field's `id`, so the SVG visual guide (T07) can
|
||||
* draw curves from the active field to its linked evidence card and on
|
||||
* to the source highlight.
|
||||
* 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-field and edit-field flows use FieldDefinitionForm.
|
||||
* 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 { useRef, useState, type ChangeEvent, type CSSProperties } from "react";
|
||||
import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
|
||||
import type { EvidenceTarget } from "@citation-evidence/engine/shared";
|
||||
|
||||
|
|
@ -17,6 +25,8 @@ import { FieldDefinitionForm, type FieldType } from "./FieldDefinitionForm";
|
|||
import { useActiveState, type ActiveState } from "./state/active";
|
||||
import { useRegisterRect } from "./visual-guide/react-hooks";
|
||||
|
||||
const FILTER_MIN_CHARS = 3;
|
||||
|
||||
function isFieldActive(state: ActiveState, fieldId: string): boolean {
|
||||
return (
|
||||
state.activeTarget?.targetType === "form-field" &&
|
||||
|
|
@ -24,6 +34,18 @@ 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)[],
|
||||
minChars = FILTER_MIN_CHARS,
|
||||
): boolean {
|
||||
const q = query.trim();
|
||||
if (q.length < minChars) return true;
|
||||
const needle = q.toLowerCase();
|
||||
return parts.some((p) => p != null && String(p).toLowerCase().includes(needle));
|
||||
}
|
||||
|
||||
export interface FormFieldSchema {
|
||||
readonly type: "text" | "textarea" | "date";
|
||||
readonly id: string;
|
||||
|
|
@ -113,8 +135,8 @@ function FieldRow({
|
|||
onChangeType={onChangeEditType}
|
||||
onSave={onSaveEdit}
|
||||
onCancel={onCancelEdit}
|
||||
saveLabel="Save field"
|
||||
badge="Editing field"
|
||||
saveLabel="Save attribute"
|
||||
badge="Editing attribute"
|
||||
testidPrefix={`field-edit-${field.id}`}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -147,9 +169,9 @@ function FieldRow({
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Edit field ${field.label}`}
|
||||
aria-label={`Edit ${field.id}`}
|
||||
data-testid={`field-edit-toggle-${field.id}`}
|
||||
title="Edit field label and type"
|
||||
title="Edit attribute key and type"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onBeginEdit();
|
||||
|
|
@ -174,7 +196,8 @@ function FieldRow({
|
|||
paddingRight: 28,
|
||||
}}
|
||||
>
|
||||
{field.label}
|
||||
{field.label}{" "}
|
||||
<span style={{ fontWeight: 400, color: "#666" }}>({field.type})</span>
|
||||
{linkCount > 0 ? (
|
||||
<span
|
||||
data-testid={`field-${field.id}-chip`}
|
||||
|
|
@ -218,10 +241,11 @@ export function FormRenderer({
|
|||
onCancelFieldEdit,
|
||||
}: FormRendererProps) {
|
||||
const { state, focusTarget } = useActiveState();
|
||||
const [addLabel, setAddLabel] = useState("New field");
|
||||
const [addLabel, setAddLabel] = useState("New attribute");
|
||||
const [addType, setAddType] = useState<FieldType>("text");
|
||||
const [editLabel, setEditLabel] = useState("");
|
||||
const [editType, setEditType] = useState<FieldType>("text");
|
||||
const [filterQuery, setFilterQuery] = useState("");
|
||||
|
||||
const handleFocus = (fieldId: string) => {
|
||||
const target: EvidenceTarget = { targetType: "form-field", targetId: fieldId };
|
||||
|
|
@ -234,12 +258,21 @@ export function FormRenderer({
|
|||
onBeginEditField?.(field.id);
|
||||
};
|
||||
|
||||
const visibleFields = useMemo(
|
||||
() =>
|
||||
schema.fields.filter((field) =>
|
||||
matchesTextFilter(filterQuery, [
|
||||
field.label,
|
||||
field.type,
|
||||
field.id,
|
||||
values?.[field.id],
|
||||
]),
|
||||
),
|
||||
[schema.fields, filterQuery, values],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
data-form-id={schema.id}
|
||||
style={{ padding: 12 }}
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<div data-form-id={schema.id} style={{ padding: 12 }} role="group" aria-label="Attributes">
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
|
|
@ -247,16 +280,41 @@ export function FormRenderer({
|
|||
justifyContent: "space-between",
|
||||
gap: 8,
|
||||
marginBottom: 8,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 14, margin: 0, fontFamily: "system-ui, sans-serif" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 14,
|
||||
margin: 0,
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
{schema.title}
|
||||
</h2>
|
||||
<input
|
||||
type="search"
|
||||
value={filterQuery}
|
||||
onChange={(e) => setFilterQuery(e.target.value)}
|
||||
placeholder="Filter…"
|
||||
aria-label="Filter attributes"
|
||||
data-testid="attributes-filter"
|
||||
style={{
|
||||
flex: "1 1 100px",
|
||||
minWidth: 80,
|
||||
maxWidth: 160,
|
||||
fontSize: 12,
|
||||
padding: "4px 6px",
|
||||
border: "1px solid #ccc",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="add-field-button"
|
||||
onClick={() => {
|
||||
setAddLabel(`New field ${schema.fields.length + 1}`);
|
||||
setAddLabel(`New attribute ${schema.fields.length + 1}`);
|
||||
setAddType("text");
|
||||
onRequestAddField?.();
|
||||
}}
|
||||
|
|
@ -269,7 +327,7 @@ export function FormRenderer({
|
|||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Add field
|
||||
Add attribute
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -286,13 +344,22 @@ export function FormRenderer({
|
|||
})
|
||||
}
|
||||
onCancel={() => onCancelAddField?.()}
|
||||
saveLabel="Add field"
|
||||
badge="New form field"
|
||||
saveLabel="Add attribute"
|
||||
badge="New attribute"
|
||||
testidPrefix="field-add"
|
||||
/>
|
||||
)}
|
||||
|
||||
{schema.fields.map((field) => (
|
||||
{visibleFields.length === 0 && schema.fields.length > 0 && (
|
||||
<p
|
||||
style={{ fontSize: 12, color: "#888", margin: "8px 0" }}
|
||||
data-testid="attributes-filter-empty"
|
||||
>
|
||||
No matches.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{visibleFields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
field={field}
|
||||
|
|
@ -319,6 +386,6 @@ export function FormRenderer({
|
|||
onCancelEdit={() => onCancelFieldEdit?.()}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ export * from "./repos";
|
|||
export * from "./services";
|
||||
export * from "./state";
|
||||
export * from "./visual-guide";
|
||||
export { FormRenderer } from "./FormRenderer";
|
||||
export { FormRenderer, matchesTextFilter } from "./FormRenderer";
|
||||
export type {
|
||||
FieldDefinitionPatch,
|
||||
FormFieldSchema,
|
||||
FormRendererProps,
|
||||
FormSchema,
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@
|
|||
* `useSyncExternalStore` subscription via `useRectRegistryVersion` picks
|
||||
* up that single tick and React re-renders once per frame.
|
||||
*
|
||||
* Active-only: only the currently active triple is drawn. If any leg's
|
||||
* rect is missing (e.g. the viewer hasn't reported a highlight rect for
|
||||
* the active annotation yet), that leg is omitted but the other one
|
||||
* still renders.
|
||||
* Active-only: draws legs for the active evidence card. Attribute→card
|
||||
* when a Capture attribute is focused; card→highlight whenever the
|
||||
* highlight rect is available (Annotate or Capture). Missing legs are
|
||||
* omitted independently.
|
||||
*
|
||||
* MVP-sufficient. Future polish: easing the curve direction by source
|
||||
* type, animating the transition between active states, dimming
|
||||
|
|
@ -33,14 +33,6 @@ function rectCenter(rect: DOMRect): { x: number; y: number } {
|
|||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}
|
||||
|
||||
function rectBottomCenter(rect: DOMRect): { x: number; y: number } {
|
||||
return { x: rect.left + rect.width / 2, y: rect.bottom };
|
||||
}
|
||||
|
||||
function rectTopCenter(rect: DOMRect): { x: number; y: number } {
|
||||
return { x: rect.left + rect.width / 2, y: rect.top };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a quadratic bezier from `a` to `b` whose control point bulges
|
||||
* horizontally between them. The horizontal-bulge style is right for a
|
||||
|
|
@ -72,18 +64,24 @@ export function Overlay({
|
|||
const version = useRectRegistryVersion();
|
||||
|
||||
const paths = useMemo<readonly string[]>(() => {
|
||||
if (!state.activeTarget || !state.activeEvidenceItemId) return [];
|
||||
const fieldRect = registry.getRect("field", state.activeTarget.targetId);
|
||||
// CE-WP-0010: card → highlight is enough for Annotate mode (no attribute
|
||||
// target). Field → card still draws when Capture has an active target.
|
||||
if (!state.activeEvidenceItemId) return [];
|
||||
const fieldRect = state.activeTarget
|
||||
? registry.getRect("field", state.activeTarget.targetId)
|
||||
: null;
|
||||
const cardRect = registry.getRect("evidence-card", state.activeEvidenceItemId);
|
||||
const highlightRect = state.activeAnnotationId
|
||||
? registry.getRect("highlight", state.activeAnnotationId)
|
||||
: null;
|
||||
const out: string[] = [];
|
||||
if (fieldRect && cardRect) {
|
||||
out.push(bezierPath(rectBottomCenter(fieldRect), rectTopCenter(cardRect)));
|
||||
// Horizontal layout (Evidence | Attributes): centre-to-centre reads better
|
||||
// than the old bottom-strip geometry.
|
||||
out.push(bezierPath(rectCenter(fieldRect), rectCenter(cardRect)));
|
||||
}
|
||||
if (cardRect && highlightRect) {
|
||||
out.push(bezierPath(rectTopCenter(cardRect), rectCenter(highlightRect)));
|
||||
out.push(bezierPath(rectCenter(cardRect), rectCenter(highlightRect)));
|
||||
}
|
||||
void version; // memo invalidator
|
||||
return out;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue