feat(frontend): schema-linking gate widget with staged column curation
This commit is contained in:
@@ -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(<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" });
|
||||||
|
});
|
||||||
@@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user