From 40a0395a700e8af7f1439a94a704e0a940915712 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 6 Jul 2026 18:22:34 +0200 Subject: [PATCH] feat(frontend): schema-linking gate widget with staged column curation --- .../widgets/SchemaLinkingGateWidget.test.tsx | 88 ++++++++++++ .../src/widgets/SchemaLinkingGateWidget.tsx | 127 ++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 frontend/src/widgets/SchemaLinkingGateWidget.test.tsx create mode 100644 frontend/src/widgets/SchemaLinkingGateWidget.tsx diff --git a/frontend/src/widgets/SchemaLinkingGateWidget.test.tsx b/frontend/src/widgets/SchemaLinkingGateWidget.test.tsx new file mode 100644 index 00000000..5c40efcd --- /dev/null +++ b/frontend/src/widgets/SchemaLinkingGateWidget.test.tsx @@ -0,0 +1,88 @@ +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(); + 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(); + // 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(); + 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(); + await userEvent.click(screen.getByRole("button", { name: /go back/i })); + expect(onRespond).toHaveBeenCalledWith({ id: "u7", control: "back" }); +}); diff --git a/frontend/src/widgets/SchemaLinkingGateWidget.tsx b/frontend/src/widgets/SchemaLinkingGateWidget.tsx new file mode 100644 index 00000000..4481d533 --- /dev/null +++ b/frontend/src/widgets/SchemaLinkingGateWidget.tsx @@ -0,0 +1,127 @@ +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>( + () => new Set(tables.filter((t) => t.recommended).map((t) => t.id)) + ); + const [selected, setSelected] = useState>>(() => { + const m = new Map>(); + 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(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(); + 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 ( +
+ {descriptor.title && ( +

{descriptor.title}

+ )} + +
+ {tables.map((t) => { + const on = enacted.has(t.id); + const count = selected.get(t.id)?.size ?? 0; + return ( +
+
+ toggleEnact(t.id)} + aria-label={t.name} + /> +
+
+ {t.name} + {t.kind === "promote" ? "Promuovi" : "Escludi"} +
+ {t.description &&

{t.description}

} + {t.rationale &&

{t.rationale}

} +
+ +
+
+ ); + })} +
+ + {openTable && ( + toggleColumn(openTable.id, c)} + onClose={() => setOpenId(null)} + /> + )} + +
+ +
+ + onRespond({ id: descriptor.id, control: c, ...(t !== undefined ? { text: t } : {}) })} + /> +
+ ); +}