feat: refine metadata catalog workflows
This commit is contained in:
@@ -777,7 +777,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
<>
|
||||
{activeSession?.question && (
|
||||
<header className="sticky top-0 z-10 -mx-6 -mt-8 border-b border-border/60 bg-background/95 px-6 pb-3 pt-8 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<p className="thot-label mb-0.5 text-muted-foreground">Domanda</p>
|
||||
<p className="thot-label mb-0.5 text-muted-foreground">Question</p>
|
||||
<h2 className="font-heading text-[0.95rem] font-semibold leading-snug text-foreground break-words">
|
||||
{activeSession.question}
|
||||
</h2>
|
||||
@@ -849,7 +849,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
<p className="thot-label mt-1.5">
|
||||
Datamart Builder with
|
||||
<br />
|
||||
Human In The Loop
|
||||
Human-in-the-loop review
|
||||
</p>
|
||||
{authenticatedUser && (
|
||||
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
|
||||
|
||||
@@ -321,7 +321,7 @@ test("shows the configured access type and endpoint in catalog status", async ()
|
||||
renderPage({ rows: [direct, rest, ssh], presentation: "fleet" });
|
||||
|
||||
const directRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
expect(within(directRow).getByText("Direct PostgreSQL")).toBeVisible();
|
||||
expect(within(directRow).getByText("Direct PostgreSQL connection")).toBeVisible();
|
||||
expect(within(directRow).getByText("db.internal:5432")).toBeVisible();
|
||||
|
||||
const restRow = screen.getByRole("row", { name: /REST laboratory/ });
|
||||
@@ -502,10 +502,10 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
||||
const metadataControls = screen.getByRole("group", { name: "Metadata description controls" });
|
||||
const actions = screen.getByRole("group", { name: "Database management actions" });
|
||||
const descriptionHistoryButton = within(metadataControls).getByRole("button", {
|
||||
name: "Run descriptions generation history",
|
||||
name: "View description generation history",
|
||||
});
|
||||
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
|
||||
name: "Run sensitive suggestions history",
|
||||
name: "View sensitive suggestion history",
|
||||
});
|
||||
|
||||
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
|
||||
@@ -513,11 +513,11 @@ test("keeps both run-history buttons visible beside the metadata-description sel
|
||||
expect(metadataControls).toContainElement(selector);
|
||||
expect(descriptionHistoryButton).toBeVisible();
|
||||
expect(descriptionHistoryButton).toBeEnabled();
|
||||
expect(descriptionHistoryButton).toHaveTextContent("Run descriptions generation history");
|
||||
expect(descriptionHistoryButton).toHaveTextContent("View description generation history");
|
||||
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
|
||||
expect(suggestionHistoryButton).toBeVisible();
|
||||
expect(suggestionHistoryButton).toBeEnabled();
|
||||
expect(suggestionHistoryButton).toHaveTextContent("Run sensitive suggestions history");
|
||||
expect(suggestionHistoryButton).toHaveTextContent("View sensitive suggestion history");
|
||||
expect(toolbar.lastElementChild).toBe(actions);
|
||||
expect(actions).toHaveClass("sm:justify-end");
|
||||
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
|
||||
@@ -675,7 +675,7 @@ test.each(synchronizationScopes)(
|
||||
},
|
||||
);
|
||||
|
||||
test("discloses source sampling before starting Generate Missing", async () => {
|
||||
test("discloses source sampling before starting Generate missing descriptions", async () => {
|
||||
const user = userEvent.setup();
|
||||
const queuedRun = makeDescriptionGenerationRun({ scope: "missing", total: 3 });
|
||||
let startBody: unknown;
|
||||
@@ -696,7 +696,7 @@ test("discloses source sampling before starting Generate Missing", async () => {
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
|
||||
|
||||
expect(screen.getByText("Generate missing descriptions for Policlinico San Donato?")).toBeVisible();
|
||||
expect(screen.getByRole("note", {
|
||||
@@ -706,7 +706,7 @@ test("discloses source sampling before starting Generate Missing", async () => {
|
||||
);
|
||||
expect(startBody).toBeUndefined();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Generate Missing" }));
|
||||
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
scope: "missing",
|
||||
@@ -715,7 +715,7 @@ test("discloses source sampling before starting Generate Missing", async () => {
|
||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps Fleet Generate Missing behind the source-data disclosure", async () => {
|
||||
test("keeps Fleet Generate missing descriptions behind the source-data disclosure", async () => {
|
||||
const user = userEvent.setup();
|
||||
let generationStarts = 0;
|
||||
server.use(
|
||||
@@ -753,7 +753,7 @@ test("keeps Fleet Generate Missing behind the source-data disclosure", async ()
|
||||
expect(generationStarts).toBe(0);
|
||||
});
|
||||
|
||||
test("confirms Generate All replacement, supports cancel, and sends the database-wide scope", async () => {
|
||||
test("confirms generating all descriptions, supports cancel, and sends the database-wide scope", async () => {
|
||||
const user = userEvent.setup();
|
||||
const queuedRun = makeDescriptionGenerationRun({ scope: "all", total: 6 });
|
||||
let startBody: unknown;
|
||||
@@ -774,7 +774,7 @@ test("confirms Generate All replacement, supports cancel, and sends the database
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate All" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate all descriptions" }));
|
||||
|
||||
expect(screen.getByText("Replace generated descriptions for Policlinico San Donato?")).toBeVisible();
|
||||
expect(screen.getByText(/existing generated descriptions for eligible tables and columns will be replaced/i)).toBeVisible();
|
||||
@@ -788,8 +788,8 @@ test("confirms Generate All replacement, supports cancel, and sends the database
|
||||
expect(startBody).toBeUndefined();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate All" }));
|
||||
await user.click(screen.getByRole("button", { name: "Generate All" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate all descriptions" }));
|
||||
await user.click(screen.getByRole("button", { name: "Generate all descriptions" }));
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
@@ -815,8 +815,8 @@ test("keeps the database selected and safely explains when no descriptions are e
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
|
||||
await user.click(screen.getByRole("button", { name: "Generate Missing" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
|
||||
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
||||
|
||||
expect(await screen.findByText(
|
||||
"No eligible catalog tables or columns need description generation.",
|
||||
@@ -876,10 +876,10 @@ test.each([
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", {
|
||||
name: scope === "all" ? "Generate All" : "Generate Missing",
|
||||
name: scope === "all" ? "Generate all descriptions" : "Generate missing descriptions",
|
||||
}));
|
||||
await user.click(screen.getByRole("button", {
|
||||
name: scope === "all" ? "Generate All" : "Generate Missing",
|
||||
name: scope === "all" ? "Generate all descriptions" : "Generate missing descriptions",
|
||||
}));
|
||||
|
||||
expect(await screen.findByRole("heading", {
|
||||
@@ -954,9 +954,9 @@ test.each([
|
||||
}
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate All" }))
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate all descriptions" }))
|
||||
.toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByRole("menuitem", { name: "Generate Missing" }))
|
||||
expect(screen.getByRole("menuitem", { name: "Generate missing descriptions" }))
|
||||
.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
@@ -983,16 +983,16 @@ test("disables database-wide generation while a description generation is active
|
||||
let databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
|
||||
await user.click(screen.getByRole("button", { name: "Generate Missing" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate missing descriptions" }));
|
||||
await user.click(screen.getByRole("button", { name: "Generate missing descriptions" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||
|
||||
databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate All" }))
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate all descriptions" }))
|
||||
.toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByRole("menuitem", { name: "Generate Missing" }))
|
||||
expect(screen.getByRole("menuitem", { name: "Generate missing descriptions" }))
|
||||
.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
@@ -1472,7 +1472,7 @@ test("filters catalog tables as the operator types", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("selected table Actions exposes cleanup commands and sends synchronization ids", async () => {
|
||||
test("selected table Actions expose table commands and send synchronization ids", async () => {
|
||||
const user = userEvent.setup();
|
||||
let startBody: unknown;
|
||||
const run = makeSyncRun("columns", [patientsTable.id]);
|
||||
@@ -1494,10 +1494,11 @@ test("selected table Actions exposes cleanup commands and sends synchronization
|
||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns" });
|
||||
const synchronizeColumns = await screen.findByRole("menuitem", { name: "Synchronize columns for selected tables" });
|
||||
expect(synchronizeColumns).toBeEnabled();
|
||||
expect(screen.getByRole("menuitem", { name: "Delete all columns" })).toBeEnabled();
|
||||
expect(screen.getByRole("menuitem", { name: "Delete all relationships" })).toBeEnabled();
|
||||
expect(screen.getByRole("menuitem", { name: "Synchronize database tables" })).toBeEnabled();
|
||||
expect(screen.getByRole("menuitem", { name: "Remove synchronized columns" })).toBeEnabled();
|
||||
expect(screen.queryByRole("menuitem", { name: "Delete all relationships" })).not.toBeInTheDocument();
|
||||
await user.click(synchronizeColumns);
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
@@ -1546,10 +1547,10 @@ test("starts description generation for multiple selected tables", async () => {
|
||||
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
|
||||
const generate = await screen.findByRole("menuitem", { name: "Generate descriptions" });
|
||||
const generate = await screen.findByRole("menuitem", { name: "Generate table description" });
|
||||
expect(generate).toBeEnabled();
|
||||
expect(screen.getByRole("menuitem", {
|
||||
name: "Move generated description to Description",
|
||||
name: "Copy generated descriptions to Description",
|
||||
})).toBeEnabled();
|
||||
await user.click(generate);
|
||||
|
||||
@@ -1628,9 +1629,9 @@ test("observes an active run from another browser and reopens a terminal run fro
|
||||
renderPage();
|
||||
|
||||
const observe = await screen.findByRole("button", {
|
||||
name: "Run descriptions generation history",
|
||||
name: "View description generation history",
|
||||
});
|
||||
expect(observe).toHaveTextContent("Run descriptions generation history");
|
||||
expect(observe).toHaveTextContent("View description generation history");
|
||||
expect(requestedLimit).toBe("50");
|
||||
await user.click(observe);
|
||||
|
||||
@@ -1654,9 +1655,9 @@ test("keeps the sensitive suggestion history label stable while showing active s
|
||||
renderPage();
|
||||
|
||||
const historyButton = await screen.findByRole("button", {
|
||||
name: "Run sensitive suggestions history",
|
||||
name: "View sensitive suggestion history",
|
||||
});
|
||||
expect(historyButton).toHaveTextContent("Run sensitive suggestions history");
|
||||
expect(historyButton).toHaveTextContent("View sensitive suggestion history");
|
||||
await waitFor(() => expect(historyButton).toHaveAttribute(
|
||||
"title",
|
||||
"Sensitive suggestion generation is active",
|
||||
@@ -1692,7 +1693,7 @@ test.each([
|
||||
client.setQueryData(unrelatedColumnKey, []);
|
||||
|
||||
await user.click(await screen.findByRole("button", {
|
||||
name: "Run descriptions generation history",
|
||||
name: "View description generation history",
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(client.getQueryState(tableKey)?.isInvalidated).toBe(true));
|
||||
@@ -1731,7 +1732,7 @@ test("keeps selected tables when description generation cannot start", async ()
|
||||
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
||||
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate table description" }));
|
||||
|
||||
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
||||
expect(screen.getByText("2 selected")).toBeVisible();
|
||||
@@ -1801,7 +1802,7 @@ test("refreshes Catalog Tables and Catalog Columns after table generation comple
|
||||
await user.click(within(await screen.findByRole("row", { name: /patients/ })).getByRole("checkbox"));
|
||||
await user.click(within(await screen.findByRole("row", { name: /visits/ })).getByRole("checkbox"));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate descriptions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate table description" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
|
||||
await waitFor(() => expect(tableReads).toBe(2));
|
||||
@@ -1857,7 +1858,7 @@ test("moves selected generated table descriptions and reports copied and skipped
|
||||
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", {
|
||||
name: "Move generated description to Description",
|
||||
name: "Copy generated descriptions to Description",
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(consolidationBody).toEqual({
|
||||
@@ -1945,8 +1946,9 @@ test("selects columns, moves generated descriptions, and refreshes only the affe
|
||||
await user.click(within(idRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(within(nameRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Synchronize columns for this table" })).toBeEnabled();
|
||||
await user.click(await screen.findByRole("menuitem", {
|
||||
name: "Move generated description to Description",
|
||||
name: "Copy generated descriptions to Description",
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(consolidationBody).toEqual({
|
||||
@@ -2000,7 +2002,7 @@ test("starts one selected column with the configured default model", async () =>
|
||||
expect(columnRow).toBeDefined();
|
||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||
|
||||
await waitFor(() => expect(startBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
@@ -2354,7 +2356,7 @@ test("polls a description run and renders its events in sequence order", async (
|
||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(within(drawer).getByText(/local-qwen/)).toBeVisible();
|
||||
@@ -2436,7 +2438,7 @@ test("refreshes Catalog Tables and Catalog Columns after column generation compl
|
||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Completed" })).toBeVisible();
|
||||
await waitFor(() => expect(columnReads).toBe(2));
|
||||
@@ -2469,7 +2471,7 @@ test("keeps the selected column and shows a safe message when generation cannot
|
||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||
|
||||
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
|
||||
expect(screen.getByText("1 selected")).toBeVisible();
|
||||
@@ -2536,7 +2538,7 @@ test("shows a basic failed run without exposing private model or provider fields
|
||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||
|
||||
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
|
||||
expect(await within(drawer).findByRole("heading", { name: "Failed" })).toBeVisible();
|
||||
@@ -2590,18 +2592,18 @@ test("offers generation and consolidation for multiple selected columns", async
|
||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(within(idRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate description" })).toBeEnabled();
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate column description" })).toBeEnabled();
|
||||
expect(screen.getByRole("menuitem", {
|
||||
name: "Move generated description to Description",
|
||||
name: "Copy generated descriptions to Description",
|
||||
})).toBeEnabled();
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
await user.click(within(nameRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
const generate = await screen.findByRole("menuitem", { name: "Generate descriptions" });
|
||||
const generate = await screen.findByRole("menuitem", { name: "Generate column descriptions" });
|
||||
expect(generate).toBeEnabled();
|
||||
expect(await screen.findByRole("menuitem", {
|
||||
name: "Move generated description to Description",
|
||||
name: "Copy generated descriptions to Description",
|
||||
})).toBeEnabled();
|
||||
await user.click(generate);
|
||||
|
||||
@@ -2637,7 +2639,7 @@ test.each([
|
||||
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }))
|
||||
.toHaveAttribute("aria-disabled", "true");
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
@@ -2647,7 +2649,7 @@ test.each([
|
||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate column description" }))
|
||||
.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
@@ -2680,11 +2682,11 @@ test("disables another generation start while the selected-column run is active"
|
||||
.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Generate column description" }));
|
||||
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate column description" }))
|
||||
.toHaveAttribute("aria-disabled", "true");
|
||||
expect(screen.getByText("1 selected")).toBeVisible();
|
||||
|
||||
@@ -2692,7 +2694,7 @@ test("disables another generation start while the selected-column run is active"
|
||||
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
|
||||
expect(await screen.findByRole("menuitem", { name: "Generate descriptions for all columns" }))
|
||||
.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
@@ -2720,7 +2722,7 @@ test("disables selected description consolidation without database.manage", asyn
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", {
|
||||
name: "Move generated description to Description",
|
||||
name: "Copy generated descriptions to Description",
|
||||
})).toHaveAttribute("aria-disabled", "true");
|
||||
expect(consolidationCalls).toBe(0);
|
||||
});
|
||||
@@ -2748,7 +2750,7 @@ test("shows an operation conflict without clearing selected descriptions or refr
|
||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", {
|
||||
name: "Move generated description to Description",
|
||||
name: "Copy generated descriptions to Description",
|
||||
}));
|
||||
|
||||
expect(await screen.findByText("A database operation is already in progress.")).toBeVisible();
|
||||
@@ -2756,52 +2758,22 @@ test("shows an operation conflict without clearing selected descriptions or refr
|
||||
expect(tableReads).toBe(1);
|
||||
});
|
||||
|
||||
test("selected table Actions confirms and deletes incoming and outgoing relationships", async () => {
|
||||
test("selected table Actions do not expose relationship cleanup", async () => {
|
||||
const user = userEvent.setup();
|
||||
let cleanupBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
|
||||
http.post("/api/catalog/databases/:databaseId/tables/metadata-cleanup", async ({ request }) => {
|
||||
cleanupBody = await request.json();
|
||||
return HttpResponse.json({ tables: 0, columns: 0, relationships: 2 });
|
||||
}),
|
||||
);
|
||||
const { client } = renderPage({
|
||||
renderPage({
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
const unselectedColumnQuery = [
|
||||
"catalog-columns",
|
||||
patientsTable.databaseId,
|
||||
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
] as const;
|
||||
client.setQueryData(unselectedColumnQuery, []);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
const tableRow = await screen.findByRole("row", { name: /patients/ });
|
||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" }));
|
||||
|
||||
expect(screen.getByText("Delete all relationships for 1 table?")).toBeVisible();
|
||||
expect(screen.getByText(/incoming and outgoing relationships/i)).toBeVisible();
|
||||
expect(cleanupBody).toBeUndefined();
|
||||
|
||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
expect(screen.queryByText("Delete all relationships for 1 table?")).not.toBeInTheDocument();
|
||||
expect(cleanupBody).toBeUndefined();
|
||||
await user.click(within(tableRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Delete all relationships" }));
|
||||
await user.click(screen.getByRole("button", { name: "Delete relationships" }));
|
||||
|
||||
await waitFor(() => expect(cleanupBody).toEqual({
|
||||
tableIds: [patientsTable.id],
|
||||
target: "relationships",
|
||||
}));
|
||||
await waitFor(() => expect(screen.queryByText("1 selected")).not.toBeInTheDocument());
|
||||
expect(client.getQueryState(unselectedColumnQuery)?.isInvalidated).toBe(true);
|
||||
await waitFor(() => expect(screen.getByRole("textbox", { name: "Search tables" })).toHaveFocus());
|
||||
expect(screen.queryByRole("menuitem", { name: "Delete all relationships" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: "Clear catalog relationships" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("navigates purely from a database to its tables and edits review metadata", async () => {
|
||||
@@ -2974,9 +2946,10 @@ test("opens the durable job drawer and confirms its exact destructive plan", asy
|
||||
rows: [makeDatabase({ connectionStatus: "reachable", testedVersion: 3 })],
|
||||
});
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
|
||||
await user.click(screen.getByRole("tab", { name: "Tables" }));
|
||||
await user.click(await screen.findByRole("button", { name: "Sync tables" }));
|
||||
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: "Synchronize tables" }));
|
||||
|
||||
const synchronizationDrawer = await screen.findByRole("dialog", {
|
||||
name: "Schema synchronization",
|
||||
|
||||
@@ -779,7 +779,7 @@ export function DatabaseManagementPage({
|
||||
scope,
|
||||
);
|
||||
rememberDescriptionGenerationRun(run);
|
||||
toast.success(`${scope === "all" ? "Generate All" : "Generate Missing"} started for ${database.workspaceName}`);
|
||||
toast.success(`${scope === "all" ? "Generate all descriptions" : "Generate missing descriptions"} started for ${database.workspaceName}`);
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
throw error;
|
||||
@@ -1274,13 +1274,13 @@ export function DatabaseManagementPage({
|
||||
onSelectedModelChange={setSelectedMetadataModel}
|
||||
/>
|
||||
{screen.kind === "list" ? <>
|
||||
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="Run descriptions generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
|
||||
<History /> Run descriptions generation history
|
||||
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View description generation history" disabled={!canManage} title="View description generation history" onClick={openDescriptionGenerationHistory}>
|
||||
<History /> View description generation history
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="Run sensitive suggestions history" disabled={!canManage} title={observedActiveSensitiveDataSuggestionRun ? "Sensitive suggestion generation is active" : "View sensitive suggestion history"} onClick={openSensitiveDataSuggestionHistory}>
|
||||
<Button type="button" variant="outline" className="whitespace-nowrap disabled:opacity-70" aria-label="View sensitive suggestion history" disabled={!canManage} title={observedActiveSensitiveDataSuggestionRun ? "Sensitive suggestion generation is active" : "View sensitive suggestion history"} onClick={openSensitiveDataSuggestionHistory}>
|
||||
<History />
|
||||
{observedActiveSensitiveDataSuggestionRun ? <span aria-hidden="true" className="size-2 rounded-full bg-primary" /> : null}
|
||||
Run sensitive suggestions history
|
||||
View sensitive suggestion history
|
||||
</Button>
|
||||
</> : null}
|
||||
</div>
|
||||
|
||||
@@ -356,7 +356,7 @@ test("shows an explicit recoverable incomplete state when no provider model is a
|
||||
renderManagement();
|
||||
|
||||
const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" });
|
||||
expect(incomplete).toHaveTextContent("No enabled provider and model choices are available");
|
||||
expect(incomplete).toHaveTextContent("No provider or model options are available");
|
||||
expect(incomplete).toHaveTextContent("Check the host-managed Pi model configuration");
|
||||
expect(screen.queryByText("Loading Pi management…")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save defaults" })).toBeDisabled();
|
||||
|
||||
@@ -78,7 +78,7 @@ function ReadinessRail({ ready, configured, credentials, smokeState }: {
|
||||
state={credentials === "present" ? "ready" : "attention"}
|
||||
/>
|
||||
<RailItem
|
||||
label="Saved-config test"
|
||||
label="Configuration test"
|
||||
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
|
||||
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "idle"}
|
||||
/>
|
||||
@@ -505,7 +505,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||
<p className="font-semibold">No enabled provider and model choices are available.</p>
|
||||
<p className="font-semibold">No provider or model options are available.</p>
|
||||
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
|
||||
@@ -296,7 +296,7 @@ export function ComposerFooter() {
|
||||
</FooterSelect>
|
||||
<span
|
||||
className="whitespace-nowrap font-mono text-[0.7rem] tabular-nums text-muted-foreground"
|
||||
aria-label="Input non-cached, cached, and output tokens"
|
||||
aria-label="Input, cached, and output tokens"
|
||||
>
|
||||
{formatTokenUsage(tokenUsage)}
|
||||
</span>
|
||||
|
||||
@@ -10,9 +10,11 @@ import { ApiError, apiErrorMessage } from "../../api/client";
|
||||
import {
|
||||
consolidateCatalogDescriptions,
|
||||
listCatalogColumns,
|
||||
startCatalogSync,
|
||||
startDescriptionGenerationRun,
|
||||
updateCatalogColumnMetadata,
|
||||
type CatalogColumn,
|
||||
type CatalogSyncRun,
|
||||
type CatalogTable,
|
||||
type DescriptionGenerationRun,
|
||||
type SensitiveDataSuggestionRequest,
|
||||
@@ -25,10 +27,12 @@ import { NO_METADATA_GENERATION_LLM_MODEL_MESSAGE } from "./MetadataGenerationMo
|
||||
interface Props {
|
||||
databaseId: string;
|
||||
table: CatalogTable;
|
||||
databaseVersion: number;
|
||||
canManage: boolean;
|
||||
selectedMetadataModel: string | null;
|
||||
descriptionGenerationActive: boolean;
|
||||
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
||||
onCatalogSyncRunStarted?: (run: CatalogSyncRun) => void;
|
||||
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
||||
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
|
||||
catalogOperationActive?: boolean;
|
||||
@@ -88,10 +92,12 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
|
||||
export function DatabaseColumns({
|
||||
databaseId,
|
||||
table,
|
||||
databaseVersion,
|
||||
canManage,
|
||||
selectedMetadataModel,
|
||||
descriptionGenerationActive,
|
||||
onDescriptionGenerationRunStarted,
|
||||
onCatalogSyncRunStarted,
|
||||
onNavigationStateChange,
|
||||
onSuggestSensitive,
|
||||
catalogOperationActive = false,
|
||||
@@ -285,15 +291,33 @@ export function DatabaseColumns({
|
||||
};
|
||||
|
||||
type FleetColumnAction =
|
||||
| "sync-columns"
|
||||
| "generate-descriptions"
|
||||
| "consolidate-descriptions"
|
||||
| "suggest-sensitive"
|
||||
| "save-sensitive";
|
||||
|
||||
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
|
||||
{
|
||||
id: "sync-columns",
|
||||
label: "Synchronize columns for this table",
|
||||
group: "Synchronization",
|
||||
scopeLabel: "Selected table",
|
||||
runLabel: "Synchronize",
|
||||
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to synchronize the catalog."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one column."
|
||||
: catalogOperationActive
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "generate-descriptions",
|
||||
label: "Generate descriptions",
|
||||
label: "Generate column descriptions",
|
||||
group: "Descriptions",
|
||||
runLabel: "Generate",
|
||||
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
|
||||
@@ -311,7 +335,7 @@ export function DatabaseColumns({
|
||||
},
|
||||
{
|
||||
id: "consolidate-descriptions",
|
||||
label: "Move generated to Description",
|
||||
label: "Copy generated descriptions to Description",
|
||||
group: "Descriptions",
|
||||
runLabel: "Move",
|
||||
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
|
||||
@@ -345,14 +369,14 @@ export function DatabaseColumns({
|
||||
},
|
||||
{
|
||||
id: "save-sensitive",
|
||||
label: "Save sensitive-field changes",
|
||||
label: "Save sensitive field changes",
|
||||
group: "Sensitive data",
|
||||
runLabel: "Save",
|
||||
disabled: !canManage || changedSensitiveColumns.length === 0 || catalogOperationActive || busy,
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to update sensitive fields."
|
||||
: changedSensitiveColumns.length === 0
|
||||
? "No sensitive-field changes are waiting to be saved."
|
||||
? "No sensitive field changes to save."
|
||||
: catalogOperationActive
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy
|
||||
@@ -362,7 +386,8 @@ export function DatabaseColumns({
|
||||
];
|
||||
|
||||
const runFleetAction = async (action: FleetColumnAction) => {
|
||||
if (action === "generate-descriptions") await generateDescriptions();
|
||||
if (action === "sync-columns") await synchronizeColumns();
|
||||
else if (action === "generate-descriptions") await generateDescriptions();
|
||||
else if (action === "consolidate-descriptions") await consolidateDescriptions();
|
||||
else if (action === "suggest-sensitive") await suggestSensitive();
|
||||
else await saveSensitive();
|
||||
@@ -373,6 +398,22 @@ export function DatabaseColumns({
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
const synchronizeColumns = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const run = await startCatalogSync(databaseId, databaseVersion, "columns", [table.id]);
|
||||
onCatalogSyncRunStarted?.(run);
|
||||
gridRef.current?.api.deselectAll();
|
||||
setSelectedIds([]);
|
||||
toast.success("Column synchronization started");
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
|
||||
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
|
||||
{ field: "sensitive", headerName: "Sensitive", headerTooltip: "Mark this column as sensitive; this does not select it for actions.", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
|
||||
@@ -398,7 +439,7 @@ export function DatabaseColumns({
|
||||
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
|
||||
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
|
||||
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
|
||||
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry columns
|
||||
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry loading columns
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,8 +497,9 @@ export function DatabaseColumns({
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
||||
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedIds.length || catalogOperationActive || busy} onClick={() => void synchronizeColumns()}>Synchronize columns for this table</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "column description" : "column descriptions"}</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
|
||||
@@ -110,6 +110,7 @@ test("Fleet columns distinguish a failed query from an empty catalog and retry i
|
||||
presentation="fleet"
|
||||
databaseId={database.id!}
|
||||
table={table}
|
||||
databaseVersion={database.version}
|
||||
canManage
|
||||
selectedMetadataModel={null}
|
||||
descriptionGenerationActive={false}
|
||||
@@ -124,7 +125,7 @@ test("Fleet columns distinguish a failed query from an empty catalog and retry i
|
||||
expect(alert).toHaveTextContent("The database catalog is unavailable.");
|
||||
expect(screen.queryByText(/No columns synchronized/i)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry columns" }));
|
||||
await user.click(screen.getByRole("button", { name: "Retry loading columns" }));
|
||||
await waitFor(() => expect(requests).toBe(2));
|
||||
await waitFor(() => expect(screen.queryByText("Catalog columns could not be loaded.")).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
@@ -281,7 +281,7 @@ export function DatabaseForm({
|
||||
disabled={readOnly || busy}
|
||||
onChange={(event) => onTransportChange(event.target.value as DatabaseTransport)}
|
||||
>
|
||||
<option value="postgres_direct">Direct PostgreSQL</option>
|
||||
<option value="postgres_direct">Direct PostgreSQL connection</option>
|
||||
<option value="rest_api">REST API</option>
|
||||
<option value="ssh_tunnel">SSH tunnel</option>
|
||||
</select>
|
||||
|
||||
@@ -149,13 +149,13 @@ function accessSummary(row: CatalogDatabase): { label: string; detail: string }
|
||||
return { label: "SSH", detail: endpoint(row.binding.sshHost, row.binding.sshPort) };
|
||||
}
|
||||
return {
|
||||
label: "Direct PostgreSQL",
|
||||
label: "Direct PostgreSQL connection",
|
||||
detail: endpoint(row.binding.host, row.binding.port),
|
||||
};
|
||||
}
|
||||
|
||||
function coverageStatus(total: number, complete: number): { label: string; detail: string; tone: StateTone } {
|
||||
if (total === 0) return { label: "Not started", detail: "No catalog items", tone: "danger" };
|
||||
if (total === 0) return { label: "Not started", detail: "No catalog objects", tone: "danger" };
|
||||
if (complete >= total) return { label: "Complete", detail: `${complete}/${total}`, tone: "success" };
|
||||
if (complete > 0) return { label: "Partial", detail: `${complete}/${total}`, tone: "warning" };
|
||||
return { label: "Not started", detail: `0/${total}`, tone: "danger" };
|
||||
@@ -172,7 +172,7 @@ function CatalogStatusCells({ row, metrics, sensitiveRun }: { row: CatalogDataba
|
||||
const descriptions = coverageStatus(metrics?.descriptionTargets ?? 0, metrics?.describedTargets ?? 0);
|
||||
const sensitiveProcessed = sensitiveRun ? sensitiveRun.suggestedSensitive + sensitiveRun.suggestedNonSensitive : 0;
|
||||
const sensitive = !sensitiveRun
|
||||
? { label: "Not started", detail: "No analysis run", tone: "danger" as const }
|
||||
? { label: "Not started", detail: "No analysis has been run", tone: "danger" as const }
|
||||
: sensitiveRun.status === "completed" && sensitiveProcessed >= sensitiveRun.total
|
||||
? { label: "Complete", detail: `${sensitiveProcessed}/${sensitiveRun.total}`, tone: "success" as const }
|
||||
: ["failed", "interrupted"].includes(sensitiveRun.status)
|
||||
@@ -608,7 +608,7 @@ export function DatabaseGrid({
|
||||
() => onGenerateDescriptions(selectedRows, pendingGenerationScope),
|
||||
)}
|
||||
>
|
||||
{pendingGenerationScope === "all" ? "Generate All" : "Generate Missing"}
|
||||
{pendingGenerationScope === "all" ? "Generate all descriptions" : "Generate missing descriptions"}
|
||||
</Button>
|
||||
</div>
|
||||
) : pendingDelete ? (
|
||||
@@ -688,14 +688,14 @@ export function DatabaseGrid({
|
||||
disabled={!canGenerateDescriptions}
|
||||
onClick={() => setPendingGenerationScope("all")}
|
||||
>
|
||||
Generate All
|
||||
Generate all descriptions
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
|
||||
disabled={!canGenerateDescriptions}
|
||||
onClick={() => setPendingGenerationScope("missing")}
|
||||
>
|
||||
Generate Missing
|
||||
Generate missing descriptions
|
||||
</Menu.Item>
|
||||
<Menu.Separator className="my-1 h-px bg-border" />
|
||||
<Menu.Item
|
||||
|
||||
@@ -389,7 +389,7 @@ export function DatabaseRelationships({
|
||||
</optgroup>
|
||||
<optgroup label="Synchronization">
|
||||
<option value="relationships">Synchronize physical relationships</option>
|
||||
<option value="all">Synchronize the full schema for this database</option>
|
||||
<option value="all">Synchronize the full database schema</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? selectedAction === "rebuild-generated" ? "Rebuilding…" : "Running…" : "Run action"}</Button>
|
||||
@@ -414,7 +414,7 @@ export function DatabaseRelationships({
|
||||
{addUnavailable ? <span id="relationship-add-reason" className="sr-only">{addUnavailable}</span> : null}
|
||||
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
|
||||
</div>
|
||||
) : <Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Sync relationships</Button>}
|
||||
) : <Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Synchronize relationships</Button>}
|
||||
</div>
|
||||
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">{presentation === "fleet" ? "Test the current database binding in database details before synchronizing relationships." : "Test the current database binding from Overview before synchronizing relationships."}</div> : null}
|
||||
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
||||
@@ -430,7 +430,7 @@ export function DatabaseRelationships({
|
||||
title="Add relationship"
|
||||
description="Connect one source column to one primary-key target column."
|
||||
onClose={closeAdd}
|
||||
closeLabel="Close add relationship"
|
||||
closeLabel="Close relationship form"
|
||||
busy={busy}
|
||||
footer={(
|
||||
<>
|
||||
|
||||
@@ -330,13 +330,12 @@ export function DatabaseTables({
|
||||
| "sync-columns"
|
||||
| "consolidate-descriptions"
|
||||
| "suggest-sensitive"
|
||||
| "clear-columns"
|
||||
| "clear-relationships";
|
||||
| "clear-columns";
|
||||
|
||||
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
|
||||
{
|
||||
id: "generate-descriptions",
|
||||
label: "Generate descriptions",
|
||||
label: "Generate table description",
|
||||
group: "Descriptions",
|
||||
runLabel: "Generate",
|
||||
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
|
||||
@@ -372,7 +371,7 @@ export function DatabaseTables({
|
||||
},
|
||||
{
|
||||
id: "consolidate-descriptions",
|
||||
label: "Move generated to Description",
|
||||
label: "Copy generated descriptions to Description",
|
||||
group: "Descriptions",
|
||||
runLabel: "Move",
|
||||
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
||||
@@ -386,22 +385,24 @@ export function DatabaseTables({
|
||||
},
|
||||
{
|
||||
id: "sync-tables",
|
||||
label: "Synchronize tables",
|
||||
label: "Synchronize database tables",
|
||||
group: "Synchronization",
|
||||
scopeLabel: "All tables in this database",
|
||||
scopeLabel: "Selected database",
|
||||
runLabel: "Synchronize",
|
||||
disabled: !canManage || !bindingReady || busy !== null || Boolean(currentRun),
|
||||
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to synchronize the catalog."
|
||||
: !bindingReady
|
||||
? "Test the current database binding first."
|
||||
: busy !== null || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: undefined,
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one table."
|
||||
: !bindingReady
|
||||
? "Test the current database binding first."
|
||||
: busy !== null || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "sync-columns",
|
||||
label: "Synchronize columns",
|
||||
label: "Synchronize columns for selected tables",
|
||||
group: "Synchronization",
|
||||
runLabel: "Synchronize",
|
||||
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
|
||||
@@ -435,22 +436,7 @@ export function DatabaseTables({
|
||||
},
|
||||
{
|
||||
id: "clear-columns",
|
||||
label: "Clear catalog columns",
|
||||
group: "Catalog cleanup",
|
||||
tone: "destructive",
|
||||
runLabel: "Review cleanup",
|
||||
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
|
||||
disabledReason: !canManage
|
||||
? "You do not have permission to clear catalog metadata."
|
||||
: selectedIds.length === 0
|
||||
? "Select at least one table."
|
||||
: busy !== null || Boolean(currentRun)
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "clear-relationships",
|
||||
label: "Clear catalog relationships",
|
||||
label: "Remove synchronized columns",
|
||||
group: "Catalog cleanup",
|
||||
tone: "destructive",
|
||||
runLabel: "Review cleanup",
|
||||
@@ -473,7 +459,6 @@ export function DatabaseTables({
|
||||
else if (action === "sync-columns") await synchronize("columns", selectedIds);
|
||||
else if (action === "suggest-sensitive") await suggestSensitive();
|
||||
else if (action === "clear-columns") setPendingDelete("columns");
|
||||
else setPendingDelete("relationships");
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
@@ -544,10 +529,12 @@ export function DatabaseTables({
|
||||
<DatabaseColumns
|
||||
databaseId={databaseId}
|
||||
table={activeTable}
|
||||
databaseVersion={database.version}
|
||||
canManage={canManage}
|
||||
selectedMetadataModel={selectedMetadataModel}
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
||||
onCatalogSyncRunStarted={onRunStarted}
|
||||
onNavigationStateChange={setColumnNavigation}
|
||||
onSuggestSensitive={onSuggestSensitive}
|
||||
catalogOperationActive={Boolean(currentRun)}
|
||||
@@ -594,7 +581,7 @@ export function DatabaseTables({
|
||||
rowHeight={44}
|
||||
headerHeight={38}
|
||||
animateRows={false}
|
||||
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
|
||||
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
@@ -655,10 +642,12 @@ export function DatabaseTables({
|
||||
<DatabaseColumns
|
||||
databaseId={databaseId}
|
||||
table={activeTable}
|
||||
databaseVersion={database.version}
|
||||
canManage={canManage}
|
||||
selectedMetadataModel={selectedMetadataModel}
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
||||
onCatalogSyncRunStarted={onRunStarted}
|
||||
onNavigationStateChange={setColumnNavigation}
|
||||
onSuggestSensitive={onSuggestSensitive}
|
||||
onCatalogMetricsChanged={onCatalogMetricsChanged}
|
||||
@@ -710,19 +699,11 @@ export function DatabaseTables({
|
||||
<div className="mr-auto min-w-56">
|
||||
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
|
||||
{presentation === "fleet"
|
||||
? `Clear catalog ${pendingDelete} for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
|
||||
: pendingDelete === "columns"
|
||||
? `Delete all catalog columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
|
||||
: `Delete all relationships for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
|
||||
? `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
|
||||
: `Remove synchronized columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{presentation === "fleet"
|
||||
? pendingDelete === "columns"
|
||||
? "Only synchronized catalog columns for these tables will be cleared. The source database is not modified."
|
||||
: "Only synchronized catalog relationships for these tables will be cleared. The source database is not modified."
|
||||
: pendingDelete === "columns"
|
||||
? "Only columns belonging to the selected catalog tables will be removed."
|
||||
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
|
||||
Only synchronized catalog columns for these tables will be removed. The source database is not modified.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
|
||||
@@ -733,7 +714,7 @@ export function DatabaseTables({
|
||||
disabled={busy !== null}
|
||||
onClick={() => void deleteMetadata(pendingDelete)}
|
||||
>
|
||||
<Trash2 />{presentation === "fleet" ? "Clear catalog" : pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
|
||||
<Trash2 />Remove synchronized columns
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -760,13 +741,13 @@ export function DatabaseTables({
|
||||
<Menu.Portal>
|
||||
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
|
||||
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate table description</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateColumnDescriptions()}>Generate descriptions for all columns</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("tables")}>Synchronize database tables</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns for selected tables</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Copy generated descriptions to Description</Menu.Item>
|
||||
<Menu.Separator className="my-1 h-px bg-border" />
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Delete all columns</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("relationships")}>Delete all relationships</Menu.Item>
|
||||
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Remove synchronized columns</Menu.Item>
|
||||
</Menu.Popup>
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
@@ -799,12 +780,11 @@ export function DatabaseTables({
|
||||
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
|
||||
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} title={!bindingReady ? "Test the current database binding before synchronizing tables" : undefined} onClick={() => void synchronize("tables")}><RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Sync tables</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing tables.</div> : null}
|
||||
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing columns.</div> : null}
|
||||
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
|
||||
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
|
||||
<AgGridReact<CatalogTable>
|
||||
@@ -825,7 +805,7 @@ export function DatabaseTables({
|
||||
rowHeight={44}
|
||||
headerHeight={38}
|
||||
animateRows={false}
|
||||
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
|
||||
overlayNoRowsTemplate="No catalog tables. Synchronize the schema from the database view."
|
||||
/>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
@@ -8,7 +8,7 @@ const actions = [
|
||||
{ id: "sync", label: "Synchronize tables", group: "Synchronization", scopeLabel: "All tables in this database" },
|
||||
{
|
||||
id: "generate",
|
||||
label: "Generate descriptions",
|
||||
label: "Generate column descriptions",
|
||||
group: "Descriptions",
|
||||
disabled: true,
|
||||
disabledReason: "Choose a metadata model first",
|
||||
|
||||
@@ -27,4 +27,10 @@ test("uses operational body text for every available-command list", () => {
|
||||
expect(styles).toMatch(
|
||||
/\.thot-fleet-action-selector__select\s*\{[^}]*font-size:\s*0\.9375rem;/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.thot-fleet-action-selector\s*\{[^}]*flex:\s*0 0 auto;/s,
|
||||
);
|
||||
expect(styles).toMatch(
|
||||
/\.thot-fleet-action-selector__select\s*\{[^}]*width:\s*max-content;/s,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -381,7 +381,8 @@
|
||||
.thot-fleet-action-selector {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
flex: 0 0 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
@@ -402,9 +403,10 @@
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__select {
|
||||
flex: 1 1 13rem;
|
||||
width: min(100%, 22rem);
|
||||
min-width: 13rem;
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 2rem;
|
||||
padding: 0 2rem 0 0.625rem;
|
||||
border: 1px solid oklch(var(--input));
|
||||
@@ -613,8 +615,8 @@
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.thot-fleet-grid-toolbar > .thot-fleet-action-selector {
|
||||
flex: 1 1 33rem;
|
||||
min-width: 33rem;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,9 +965,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.thot-fleet-action-selector__select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 44rem) and (min-width: 48rem) {
|
||||
|
||||
@@ -12,8 +12,13 @@ describe("history action placement", () => {
|
||||
expect(fleetHeader).not.toContain("Sensitive history");
|
||||
});
|
||||
|
||||
test("database views expose metadata histories beside database commands", () => {
|
||||
for (const file of ["DatabaseForm.tsx", "DatabaseTables.tsx", "DatabaseRelationships.tsx"]) {
|
||||
test("database tables do not own metadata history actions", () => {
|
||||
for (const file of ["DatabaseTables.tsx"]) {
|
||||
const source = componentSource(file);
|
||||
expect(source, file).not.toContain("Description history");
|
||||
expect(source, file).not.toContain("Sensitive history");
|
||||
}
|
||||
for (const file of ["DatabaseForm.tsx", "DatabaseRelationships.tsx"]) {
|
||||
const source = componentSource(file);
|
||||
expect(source, file).toContain("Description history");
|
||||
expect(source, file).toContain("Sensitive history");
|
||||
|
||||
Reference in New Issue
Block a user