docs: Plan 1 (frontend + contract + replay) for F4 column curation
Bite-sized TDD tasks: schema-linking types + columns modal, gate widget with staged per-table column selection, registry/summary wiring, and a replay fixture generated from the real physical.yaml catalog. Offline-verifiable in the replay server; harness wiring is Plan 2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
committed by
Marco Pancotti
co-authored by
Claude Opus 4.8
parent
94d171ddf1
commit
106a0de047
@@ -0,0 +1,707 @@
|
|||||||
|
# F4 schema-linking column curation — Plan 1 (frontend + contract + replay)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Render the F4 "tabelle/colonne/esclusioni" gate as a dedicated `schema-linking` widget: table rows with description + rationale, a per-table columns modal (checkbox, suggested pre-selected + bold), staged, emitting a structured response — verifiable offline in replay.
|
||||||
|
|
||||||
|
**Architecture:** New inline gate widget (`SchemaLinkingGateWidget`) owns staged state (`enacted` set + per-table selected-columns map); a controlled modal (`SchemaColumnsDialog`) renders one table's columns. On the gate's Confirm the widget emits a structured `UiResponse.tables[]`. Phase 1 is frontend-only: no backend/harness wiring — the response is exercised through the zero-dep replay server, whose gate 7 is rewritten to the new descriptor from the real catalog.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18 + TypeScript, Vite, vitest + @testing-library/react + userEvent, base-ui Dialog (`components/ui/dialog`), Tailwind v3 (GSD tokens). Replay: zero-dep node (`tools/replay/`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- `tsc` is the type gate — run `npx tsc -b` from `frontend/` before every commit (vitest does NOT type-check).
|
||||||
|
- Tests: vitest + MSW, **no network**. Single test run: `npx vitest run <file>`.
|
||||||
|
- UI chrome/labels in **English**; data *content* (table/column names, Italian descriptions) stays as-is.
|
||||||
|
- Reuse look&feel v2 tokens only: cards `border-border/70` + `shadow-sm`, `.thot-label` (mono uppercase micro-label), radii `rounded-xl` cards / `rounded-lg` inputs / `rounded-md` controls / `2xl` dialogs; **no `#000`/`#fff`, no new color tokens**. First action renders as filled primary.
|
||||||
|
- No em dashes in copy you write (use commas/colons/parentheses).
|
||||||
|
- Response contract (verbatim): `{ id, kind: "schema-linking", tables: [{ id, enacted, columns? }] }`. `columns` present only for **enacted promote** tables; column order follows catalog order.
|
||||||
|
- Verify offline in **replay** only (no VPN): `tools/replay/` server on :5333.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `frontend/src/api/types.ts` (modify) | Add `SchemaColumn`, `SchemaTable`; extend `WidgetDescriptor.tables?`, `UiResponse.tables?`. |
|
||||||
|
| `frontend/src/widgets/SchemaColumnsDialog.tsx` (create) | Controlled modal listing one table's columns; checkbox first; bold-when-selected; `readOnly` hides checkboxes. |
|
||||||
|
| `frontend/src/widgets/SchemaColumnsDialog.test.tsx` (create) | Unit: bold on selected, toggle callback, readOnly non-selectable. |
|
||||||
|
| `frontend/src/widgets/SchemaLinkingGateWidget.tsx` (create) | Inline gate: staged `enacted`+`selected`, table rows, opens dialog, Confirm → structured response. |
|
||||||
|
| `frontend/src/widgets/SchemaLinkingGateWidget.test.tsx` (create) | Unit: staged suggestion, structured response, column toggle, enact toggle, reserved. |
|
||||||
|
| `frontend/src/widgets/index.ts` (modify) | `register("schema-linking", SchemaLinkingGateWidget)`. |
|
||||||
|
| `frontend/src/shell/WidgetHost.tsx` (modify) | Summarize the `schema-linking` response for `lastUserEntry`. |
|
||||||
|
| `tools/replay/schema-linking-fixture.json` (create, generated) | The 6-table `tables[]` array extracted from `physical.yaml` + session `schema_linking.json`. |
|
||||||
|
| `tools/replay/augment-schema-linking.mjs` (create) | Rewrite replay gate 7 → `schema-linking` descriptor. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Type contract + SchemaColumnsDialog
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/api/types.ts`
|
||||||
|
- Create: `frontend/src/widgets/SchemaColumnsDialog.tsx`
|
||||||
|
- Test: `frontend/src/widgets/SchemaColumnsDialog.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `SchemaColumn`, `SchemaTable` (in `api/types.ts`); `SchemaColumnsDialog(props)` where
|
||||||
|
`props = { table: SchemaTable; open: boolean; readOnly: boolean; selected: Set<string>; onToggle: (column: string) => void; onClose: () => void }`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the types**
|
||||||
|
|
||||||
|
In `frontend/src/api/types.ts`, add above `WidgetDescriptor`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface SchemaColumn {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type?: string;
|
||||||
|
pk?: boolean;
|
||||||
|
suggested?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchemaTable {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: "promote" | "exclude";
|
||||||
|
recommended?: boolean;
|
||||||
|
description?: string;
|
||||||
|
rationale?: string;
|
||||||
|
columns: SchemaColumn[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `tables?: SchemaTable[];` to the `WidgetDescriptor` interface (before the `[k: string]: unknown` line), and add to `UiResponse`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
tables?: { id: string; enacted: boolean; columns?: string[] }[];
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test**
|
||||||
|
|
||||||
|
Create `frontend/src/widgets/SchemaColumnsDialog.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { SchemaColumnsDialog } from "./SchemaColumnsDialog";
|
||||||
|
import type { SchemaTable } from "../api/types";
|
||||||
|
|
||||||
|
const table: SchemaTable = {
|
||||||
|
id: "t-pat",
|
||||||
|
name: "dim_patient",
|
||||||
|
kind: "promote",
|
||||||
|
columns: [
|
||||||
|
{ name: "cod_paz", description: "Codice paziente", suggested: true },
|
||||||
|
{ name: "nome", description: "Nome", suggested: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("selected column renders bold; unselected is normal; toggle fires", async () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
render(
|
||||||
|
<SchemaColumnsDialog
|
||||||
|
table={table}
|
||||||
|
open
|
||||||
|
readOnly={false}
|
||||||
|
selected={new Set(["cod_paz"])}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("cod_paz").className).toContain("font-bold");
|
||||||
|
expect(screen.getByText("nome").className).not.toContain("font-bold");
|
||||||
|
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
|
||||||
|
expect(onToggle).toHaveBeenCalledWith("nome");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readOnly renders no checkboxes and no bold", () => {
|
||||||
|
render(
|
||||||
|
<SchemaColumnsDialog
|
||||||
|
table={table}
|
||||||
|
open
|
||||||
|
readOnly
|
||||||
|
selected={new Set()}
|
||||||
|
onToggle={() => {}}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||||
|
expect(screen.getByText("cod_paz")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("cod_paz").className).not.toContain("font-bold");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run src/widgets/SchemaColumnsDialog.test.tsx`
|
||||||
|
Expected: FAIL — cannot resolve `./SchemaColumnsDialog`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement the dialog**
|
||||||
|
|
||||||
|
Create `frontend/src/widgets/SchemaColumnsDialog.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import type { SchemaTable } from "../api/types";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "../components/ui/dialog";
|
||||||
|
|
||||||
|
export function SchemaColumnsDialog({
|
||||||
|
table,
|
||||||
|
open,
|
||||||
|
readOnly,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
table: SchemaTable;
|
||||||
|
open: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
selected: Set<string>;
|
||||||
|
onToggle: (column: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}): ReactElement {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="grid max-h-[70vh] w-[70vw] max-w-[46rem] grid-rows-[auto_1fr] gap-3 sm:max-w-[46rem]">
|
||||||
|
<DialogTitle className="font-mono text-sm">{table.name}</DialogTitle>
|
||||||
|
<div className="min-h-0 overflow-auto">
|
||||||
|
<ul className="flex flex-col divide-y divide-border/50">
|
||||||
|
{table.columns.map((col) => {
|
||||||
|
const on = selected.has(col.name);
|
||||||
|
const strong = on && !readOnly;
|
||||||
|
return (
|
||||||
|
<li key={col.name} className="flex items-start gap-3 py-2">
|
||||||
|
{!readOnly && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-0.5 size-4 shrink-0 accent-[oklch(var(--primary))]"
|
||||||
|
checked={on}
|
||||||
|
onChange={() => onToggle(col.name)}
|
||||||
|
aria-label={col.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className={`font-mono text-sm ${strong ? "font-bold text-foreground" : "text-foreground/90"}`}>
|
||||||
|
{col.name}
|
||||||
|
</div>
|
||||||
|
{col.description ? (
|
||||||
|
<p className={`text-sm ${strong ? "font-semibold text-foreground" : "text-muted-foreground"}`}>
|
||||||
|
{col.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run src/widgets/SchemaColumnsDialog.test.tsx`
|
||||||
|
Expected: PASS (2 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Typecheck + commit**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx tsc -b`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/api/types.ts frontend/src/widgets/SchemaColumnsDialog.tsx frontend/src/widgets/SchemaColumnsDialog.test.tsx
|
||||||
|
git commit -m "feat(frontend): schema-linking types + columns modal"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: SchemaLinkingGateWidget
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/widgets/SchemaLinkingGateWidget.tsx`
|
||||||
|
- Test: `frontend/src/widgets/SchemaLinkingGateWidget.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `WidgetProps` (`{ descriptor, onRespond }`), `SchemaTable`/`SchemaColumn` from `api/types.ts`, `SchemaColumnsDialog` from Task 1, `ReservedControls` from `./ReservedControls`.
|
||||||
|
- Produces: `SchemaLinkingGateWidget: React.FC<WidgetProps>` handling `descriptor.widget === "schema-linking"`, emitting `onRespond({ id, kind: "schema-linking", tables: [{ id, enacted, columns? }] })`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `frontend/src/widgets/SchemaLinkingGateWidget.test.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget";
|
||||||
|
import type { WidgetDescriptor } from "../api/types";
|
||||||
|
|
||||||
|
const descriptor: WidgetDescriptor = {
|
||||||
|
id: "u7",
|
||||||
|
widget: "schema-linking",
|
||||||
|
title: "F4 — Schema linking",
|
||||||
|
tables: [
|
||||||
|
{
|
||||||
|
id: "t-pat",
|
||||||
|
name: "dim_patient",
|
||||||
|
kind: "promote",
|
||||||
|
recommended: true,
|
||||||
|
description: "Anagrafica",
|
||||||
|
rationale: "Output paziente",
|
||||||
|
columns: [
|
||||||
|
{ name: "cod_paz", description: "Codice", suggested: true },
|
||||||
|
{ name: "nome", description: "Nome", suggested: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "x-sub",
|
||||||
|
name: "fact_sostituzione",
|
||||||
|
kind: "exclude",
|
||||||
|
recommended: true,
|
||||||
|
description: "Sostituzione",
|
||||||
|
rationale: "Non impianto iniziale",
|
||||||
|
columns: [{ name: "cod_paz" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
reserved: ["back", "exit", "other"],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("Confirm emits enacted tables with suggested columns (catalog order)", async () => {
|
||||||
|
const onRespond = vi.fn();
|
||||||
|
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
|
||||||
|
expect(onRespond).toHaveBeenCalledWith({
|
||||||
|
id: "u7",
|
||||||
|
kind: "schema-linking",
|
||||||
|
tables: [
|
||||||
|
{ id: "t-pat", enacted: true, columns: ["cod_paz"] },
|
||||||
|
{ id: "x-sub", enacted: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selecting a column in the modal adds it to the response", async () => {
|
||||||
|
const onRespond = vi.fn();
|
||||||
|
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||||
|
// dim_patient starts with 1 suggested of 2 -> its button reads "Colonne 1/2".
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /colonne 1\/2/i }));
|
||||||
|
await userEvent.click(screen.getByRole("checkbox", { name: "nome" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /close/i })); // dialog X button
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
|
||||||
|
expect(onRespond).toHaveBeenCalledWith({
|
||||||
|
id: "u7",
|
||||||
|
kind: "schema-linking",
|
||||||
|
tables: [
|
||||||
|
{ id: "t-pat", enacted: true, columns: ["cod_paz", "nome"] },
|
||||||
|
{ id: "x-sub", enacted: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declining a table's enact checkbox drops its columns", async () => {
|
||||||
|
const onRespond = vi.fn();
|
||||||
|
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||||
|
await userEvent.click(screen.getByRole("checkbox", { name: /dim_patient/i }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /confirm/i }));
|
||||||
|
expect(onRespond).toHaveBeenCalledWith({
|
||||||
|
id: "u7",
|
||||||
|
kind: "schema-linking",
|
||||||
|
tables: [
|
||||||
|
{ id: "t-pat", enacted: false },
|
||||||
|
{ id: "x-sub", enacted: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reserved control emits control", async () => {
|
||||||
|
const onRespond = vi.fn();
|
||||||
|
render(<SchemaLinkingGateWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: /go back/i }));
|
||||||
|
expect(onRespond).toHaveBeenCalledWith({ id: "u7", control: "back" });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run src/widgets/SchemaLinkingGateWidget.test.tsx`
|
||||||
|
Expected: FAIL — cannot resolve `./SchemaLinkingGateWidget`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the widget**
|
||||||
|
|
||||||
|
Create `frontend/src/widgets/SchemaLinkingGateWidget.tsx`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from "react";
|
||||||
|
import type { WidgetProps } from "./types";
|
||||||
|
import type { SchemaTable } from "../api/types";
|
||||||
|
import { SchemaColumnsDialog } from "./SchemaColumnsDialog";
|
||||||
|
import { ReservedControls } from "./ReservedControls";
|
||||||
|
|
||||||
|
export function SchemaLinkingGateWidget({ descriptor, onRespond }: WidgetProps) {
|
||||||
|
const tables: SchemaTable[] = descriptor.tables ?? [];
|
||||||
|
const [enacted, setEnacted] = useState<Set<string>>(
|
||||||
|
() => new Set(tables.filter((t) => t.recommended).map((t) => t.id))
|
||||||
|
);
|
||||||
|
const [selected, setSelected] = useState<Map<string, Set<string>>>(() => {
|
||||||
|
const m = new Map<string, Set<string>>();
|
||||||
|
for (const t of tables) {
|
||||||
|
m.set(
|
||||||
|
t.id,
|
||||||
|
new Set(t.kind === "promote" ? t.columns.filter((c) => c.suggested).map((c) => c.name) : [])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
const [openId, setOpenId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function toggleEnact(id: string) {
|
||||||
|
setEnacted((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleColumn(tableId: string, column: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const set = new Set(next.get(tableId) ?? []);
|
||||||
|
if (set.has(column)) set.delete(column);
|
||||||
|
else set.add(column);
|
||||||
|
next.set(tableId, set);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
const payload = tables.map((t) => {
|
||||||
|
const on = enacted.has(t.id);
|
||||||
|
if (on && t.kind === "promote") {
|
||||||
|
const sel = selected.get(t.id) ?? new Set<string>();
|
||||||
|
return { id: t.id, enacted: true, columns: t.columns.filter((c) => sel.has(c.name)).map((c) => c.name) };
|
||||||
|
}
|
||||||
|
return { id: t.id, enacted: on };
|
||||||
|
});
|
||||||
|
onRespond({ id: descriptor.id, kind: "schema-linking", tables: payload });
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTable = tables.find((t) => t.id === openId) ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||||
|
{descriptor.title && (
|
||||||
|
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">{descriptor.title}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{tables.map((t) => {
|
||||||
|
const on = enacted.has(t.id);
|
||||||
|
const count = selected.get(t.id)?.size ?? 0;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`rounded-lg border px-3 py-2.5 transition-colors ${on ? "border-border/70 bg-accent/40" : "border-transparent bg-muted/30"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 size-4 shrink-0 accent-[oklch(var(--primary))]"
|
||||||
|
checked={on}
|
||||||
|
onChange={() => toggleEnact(t.id)}
|
||||||
|
aria-label={t.name}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm font-semibold text-foreground">{t.name}</span>
|
||||||
|
<span className="thot-label">{t.kind === "promote" ? "Promuovi" : "Escludi"}</span>
|
||||||
|
</div>
|
||||||
|
{t.description && <p className="mt-0.5 text-sm text-muted-foreground">{t.description}</p>}
|
||||||
|
{t.rationale && <p className="mt-0.5 text-sm text-foreground/80">{t.rationale}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="shrink-0 self-center rounded-md border border-border/60 px-2.5 py-1 text-xs text-muted-foreground shadow-xs transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
onClick={() => setOpenId(t.id)}
|
||||||
|
>
|
||||||
|
{t.kind === "promote" ? `Colonne ${count}/${t.columns.length}` : `Colonne ${t.columns.length}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openTable && (
|
||||||
|
<SchemaColumnsDialog
|
||||||
|
table={openTable}
|
||||||
|
open
|
||||||
|
readOnly={openTable.kind === "exclude"}
|
||||||
|
selected={selected.get(openTable.id) ?? new Set()}
|
||||||
|
onToggle={(c) => toggleColumn(openTable.id, c)}
|
||||||
|
onClose={() => setOpenId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
|
||||||
|
onClick={confirm}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReservedControls
|
||||||
|
reserved={descriptor.reserved}
|
||||||
|
onControl={(c, t) => onRespond({ id: descriptor.id, control: c, ...(t !== undefined ? { text: t } : {}) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run src/widgets/SchemaLinkingGateWidget.test.tsx`
|
||||||
|
Expected: PASS (4 tests).
|
||||||
|
|
||||||
|
Note: the modal's X button (rendered by `DialogContent`, accessible name "Close") fires `onOpenChange(false)` → `onClose`; the widget's Confirm is only reachable once the modal is closed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Typecheck + commit**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx tsc -b`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/widgets/SchemaLinkingGateWidget.tsx frontend/src/widgets/SchemaLinkingGateWidget.test.tsx
|
||||||
|
git commit -m "feat(frontend): schema-linking gate widget with staged column curation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Register widget + response summary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/widgets/index.ts`
|
||||||
|
- Modify: `frontend/src/shell/WidgetHost.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `SchemaLinkingGateWidget` (Task 2), the `UiResponse.tables` field (Task 1).
|
||||||
|
- Produces: registry mapping `"schema-linking" → SchemaLinkingGateWidget`; a `lastUserEntry` summary for the new kind.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the widget**
|
||||||
|
|
||||||
|
In `frontend/src/widgets/index.ts`, add the import and registration (alongside the others):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget";
|
||||||
|
register("schema-linking", SchemaLinkingGateWidget);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing test for the registry**
|
||||||
|
|
||||||
|
Append to `frontend/src/widgets/registry.test.tsx` a case (adapt to that file's existing imports — it already imports `resolve` and the widgets via `./index`):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
test("resolves the schema-linking widget", async () => {
|
||||||
|
await import("./index");
|
||||||
|
const Comp = resolve("schema-linking");
|
||||||
|
expect(Comp.name).toBe("SchemaLinkingGateWidget");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run src/widgets/registry.test.tsx`
|
||||||
|
Expected: PASS (if `registry.test.tsx` imports `./index` already; if it imports widgets individually, mirror that pattern instead). If the file does not exist or the pattern differs, skip this micro-test and rely on the Task 4 replay check.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Summarize the response in WidgetHost**
|
||||||
|
|
||||||
|
In `frontend/src/shell/WidgetHost.tsx`, replace the `setLastUserEntry({ ... })` call with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
setLastUserEntry({
|
||||||
|
kind: "choice",
|
||||||
|
text:
|
||||||
|
r.kind === "schema-linking"
|
||||||
|
? `${r.tables?.filter((t) => t.enacted).length ?? 0} tables curated`
|
||||||
|
: r.text ?? r.choices?.join(", ") ?? r.decision?.type ?? r.control ?? "(choice)",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Full suite + typecheck**
|
||||||
|
|
||||||
|
Run: `cd frontend && npx vitest run && npx tsc -b`
|
||||||
|
Expected: whole suite green, no type errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/widgets/index.ts frontend/src/shell/WidgetHost.tsx frontend/src/widgets/registry.test.tsx
|
||||||
|
git commit -m "feat(frontend): register schema-linking widget + response summary"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Replay fixture + visual verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/replay/schema-linking-fixture.json` (generated, committed)
|
||||||
|
- Create: `tools/replay/augment-schema-linking.mjs`
|
||||||
|
- Modify: `tools/replay/replay.json` (regenerated by the script)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the real catalog `physical.yaml` and the session `schema_linking.json` (workspace, local only) to produce the fixture; the `schema-linking` descriptor shape (Task 1).
|
||||||
|
- Produces: replay gate 7 rewritten so the widget renders in the browser.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the fixture from the real catalog**
|
||||||
|
|
||||||
|
Run (uses the harness venv for a YAML parser; paths are the psd workspace):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mp/projects/ThothII && harness/.venv/bin/python - <<'PY'
|
||||||
|
import yaml, json
|
||||||
|
CAT = "/Users/mp/projects/tht-workspace-psd/artifacts/mschema/physical.yaml"
|
||||||
|
SL = "/Users/mp/projects/tht-workspace-psd/sessions/2026-07-03-170732-fammi-la-lista-dei-pazienti-che-negli-ul/schema_linking.json"
|
||||||
|
tables_meta = yaml.safe_load(open(CAT))["tables"]
|
||||||
|
sl = json.load(open(SL))
|
||||||
|
suggested = {}
|
||||||
|
for c in sl["candidates"]:
|
||||||
|
if c["kind"] == "column" and c.get("decision") == "promoted":
|
||||||
|
tbl, _, col = c["name"].partition(".")
|
||||||
|
suggested.setdefault(tbl, set()).add(col)
|
||||||
|
replay = json.load(open("tools/replay/replay.json"))
|
||||||
|
opts = replay["gates"][7]["descriptor"]["options"]
|
||||||
|
info = {o["decision"]["subject"]: o["decision"] for o in opts
|
||||||
|
if o["decision"]["type"] in ("table_promoted", "table_excluded")}
|
||||||
|
def build(tbl, kind, oid):
|
||||||
|
meta = tables_meta.get(tbl, {"comment": "", "columns": {}})
|
||||||
|
sug = suggested.get(tbl, set())
|
||||||
|
d = info.get(tbl, {})
|
||||||
|
return {
|
||||||
|
"id": oid, "name": tbl, "kind": kind, "recommended": True,
|
||||||
|
"description": meta.get("comment") or d.get("detail", ""),
|
||||||
|
"rationale": d.get("rationale", ""),
|
||||||
|
"columns": [
|
||||||
|
{"name": cn, "description": (cm.get("comment") or ""),
|
||||||
|
"type": cm.get("type"), "pk": bool(cm.get("pk")),
|
||||||
|
"suggested": cn in sug}
|
||||||
|
for cn, cm in meta.get("columns", {}).items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
tables = [
|
||||||
|
build("fact_studio_elettrofisiologico_endocavitario_ablazione", "promote", "t-ablazione"),
|
||||||
|
build("fact_impianto_defibrillatore_impiantabile", "promote", "t-impianto"),
|
||||||
|
build("dim_patient", "promote", "t-patient"),
|
||||||
|
build("dim_time", "promote", "t-time"),
|
||||||
|
build("fact_sostituzione_impianto_defibrillatore_impiantabile", "exclude", "x-sostituzione"),
|
||||||
|
build("fact_controllo_defibrillatore_impiantabile", "exclude", "x-controllo"),
|
||||||
|
]
|
||||||
|
json.dump(tables, open("tools/replay/schema-linking-fixture.json", "w"), ensure_ascii=False, indent=2)
|
||||||
|
print("wrote", len(tables), "tables;",
|
||||||
|
sum(len(t["columns"]) for t in tables), "columns;",
|
||||||
|
sum(1 for t in tables for c in t["columns"] if c["suggested"]), "suggested")
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: prints e.g. `wrote 6 tables; 21 columns; 15 suggested`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the augment script**
|
||||||
|
|
||||||
|
Create `tools/replay/augment-schema-linking.mjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Rewrite replay gate F4 "tabelle/colonne/esclusioni" as a `schema-linking`
|
||||||
|
// descriptor carrying the real catalog columns (schema-linking-fixture.json),
|
||||||
|
// so the SchemaLinkingGateWidget renders in replay. Run after editing the
|
||||||
|
// fixture: node tools/replay/augment-schema-linking.mjs
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const replayPath = join(HERE, "replay.json");
|
||||||
|
const replay = JSON.parse(readFileSync(replayPath, "utf8"));
|
||||||
|
const tables = JSON.parse(readFileSync(join(HERE, "schema-linking-fixture.json"), "utf8"));
|
||||||
|
|
||||||
|
const idx = replay.gates.findIndex(
|
||||||
|
(g) =>
|
||||||
|
/tabelle\/colonne\/esclusioni/.test(g.descriptor?.title ?? "") &&
|
||||||
|
(g.descriptor?.widget === "multiselect" || g.descriptor?.widget === "schema-linking")
|
||||||
|
);
|
||||||
|
if (idx < 0) {
|
||||||
|
console.error("F4 tables gate not found in replay.json");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const d = replay.gates[idx].descriptor;
|
||||||
|
replay.gates[idx].descriptor = {
|
||||||
|
type: "ui_request",
|
||||||
|
id: d.id,
|
||||||
|
phase: d.phase,
|
||||||
|
schema_version: d.schema_version,
|
||||||
|
widget: "schema-linking",
|
||||||
|
title: d.title,
|
||||||
|
tables,
|
||||||
|
reserved: ["back", "exit", "other"],
|
||||||
|
};
|
||||||
|
writeFileSync(replayPath, JSON.stringify(replay, null, 2) + "\n");
|
||||||
|
console.log(`Rewrote gate ${idx} -> schema-linking (${tables.length} tables)`);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the augment script and assert**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mp/projects/ThothII && node tools/replay/augment-schema-linking.mjs && \
|
||||||
|
node -e 'const j=require("./tools/replay/replay.json");const g=j.gates.find(x=>/tabelle\/colonne/.test(x.descriptor?.title||""));if(g.descriptor.widget!=="schema-linking"||!Array.isArray(g.descriptor.tables))throw new Error("not rewritten");console.log("OK",g.descriptor.tables.length,"tables")'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `Rewrote gate 7 -> schema-linking (6 tables)` then `OK 6 tables`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rebuild the replay bundle and start the server**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/mp/projects/ThothII && rm -rf tools/replay/web && bash scripts/replay.sh build && \
|
||||||
|
( lsof -ti tcp:5333 | xargs kill -9 2>/dev/null; true ) && nohup node tools/replay/server.mjs >/tmp/replay-5333.log 2>&1 &
|
||||||
|
sleep 2 && curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5333/
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `200`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Visually verify in the browser**
|
||||||
|
|
||||||
|
Open http://localhost:5333 → click the "Replay 170732" session → **Resume** → answer the 3 F1 clarifications (recommended option each) → the F4 gate now renders as table rows with description + rationale and "Colonne k/n" buttons. Open a promote table's modal: suggested columns are pre-checked and bold; toggling a column updates bold and the row count. Open an exclude table's modal: fields listed, no checkboxes. Confirm advances the replay.
|
||||||
|
|
||||||
|
Confirm each requirement:
|
||||||
|
- [ ] Promote rows show description + rationale under the name.
|
||||||
|
- [ ] Columns modal: suggested pre-selected + bold; toggle updates bold; no column rows in the list itself.
|
||||||
|
- [ ] Exclude modal: read-only (no checkboxes).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/replay/schema-linking-fixture.json tools/replay/augment-schema-linking.mjs tools/replay/replay.json
|
||||||
|
git commit -m "feat(replay): render F4 as schema-linking gate from real catalog columns"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage (Phase 1 slice of `2026-07-06-f4-schema-linking-column-curation-design.md`):**
|
||||||
|
- §1a descriptor `tables[]` → Task 1 types + Task 4 fixture. ✅
|
||||||
|
- §1b structured response → Task 2 `confirm()` + tests. ✅
|
||||||
|
- §2 interaction (inline gate, rows with desc+rationale, columns modal, bold rule, staged, exclude read-only, no column rows) → Task 2 + Task 1 + Task 4 verify. ✅
|
||||||
|
- §3 components (`SchemaLinkingGateWidget`, `SchemaColumnsDialog`, registry, WidgetHost) → Tasks 1-3. ✅
|
||||||
|
- §6 replay fixture from `physical.yaml` → Task 4. ✅
|
||||||
|
- §4/§5 harness + downstream → **Plan 2 (out of scope here)**, by design.
|
||||||
|
|
||||||
|
**Placeholder scan:** none — every step has concrete code/commands. The Task 3 registry micro-test has a documented fallback if `registry.test.tsx` differs.
|
||||||
|
|
||||||
|
**Type consistency:** `SchemaTable`/`SchemaColumn`/`UiResponse.tables` defined in Task 1 and used verbatim in Tasks 2-4. Response shape `{ id, enacted, columns? }` identical across widget, tests, and contract. `descriptor.tables` typed in Task 1, read in Task 2.
|
||||||
|
|
||||||
|
**Note for implementer:** Task 3 Step 2 depends on the exact contents of `frontend/src/widgets/registry.test.tsx`; read it first and mirror its import style, or rely on the Task 4 replay render as the integration check.
|
||||||
Reference in New Issue
Block a user