fix: scope and batch sensitive suggestions
This commit is contained in:
@@ -99,6 +99,11 @@ export interface CatalogColumn {
|
||||
|
||||
export interface SensitiveDataSuggestion {
|
||||
columnId: string;
|
||||
tableId: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
version: number;
|
||||
currentSensitive: boolean;
|
||||
sensitive: boolean;
|
||||
}
|
||||
|
||||
@@ -106,6 +111,11 @@ export interface SensitiveDataSuggestions {
|
||||
suggestions: SensitiveDataSuggestion[];
|
||||
}
|
||||
|
||||
export type SensitiveDataSuggestionRequest =
|
||||
| { scope: "all" }
|
||||
| { scope: "selected_tables"; targetIds: string[] }
|
||||
| { scope: "selected_columns"; targetIds: string[] };
|
||||
|
||||
export interface CatalogRelationshipColumn {
|
||||
position: number;
|
||||
sourceColumnId: string;
|
||||
@@ -357,10 +367,25 @@ export const updateCatalogColumnMetadata = (
|
||||
{ method: "PATCH", body: JSON.stringify({ version, description, generatedDescription, sensitive }) },
|
||||
);
|
||||
|
||||
export const suggestSensitiveFields = (databaseId: string, modelId: string) =>
|
||||
export const updateCatalogColumnSensitive = (
|
||||
databaseId: string,
|
||||
tableId: string,
|
||||
columnId: string,
|
||||
version: number,
|
||||
sensitive: boolean,
|
||||
) => apiFetch<CatalogColumn>(
|
||||
`/catalog/databases/${encodeURIComponent(databaseId)}/tables/${encodeURIComponent(tableId)}/columns/${encodeURIComponent(columnId)}`,
|
||||
{ method: "PATCH", body: JSON.stringify({ version, sensitive }) },
|
||||
);
|
||||
|
||||
export const suggestSensitiveFields = (
|
||||
databaseId: string,
|
||||
modelId: string,
|
||||
selection: SensitiveDataSuggestionRequest,
|
||||
) =>
|
||||
apiFetch<SensitiveDataSuggestions>(
|
||||
`/catalog/databases/${encodeURIComponent(databaseId)}/sensitive-data-suggestions`,
|
||||
{ method: "POST", body: JSON.stringify({ modelId }) },
|
||||
{ method: "POST", body: JSON.stringify({ modelId, ...selection }) },
|
||||
);
|
||||
|
||||
export const listCatalogRelationships = (databaseId: string) =>
|
||||
|
||||
@@ -147,7 +147,9 @@ test.each([
|
||||
["description_generation_target_ids_duplicate", "Description generation target IDs must be unique."],
|
||||
["description_generation_no_eligible_targets", "No eligible catalog tables or columns need description generation."],
|
||||
["catalog_table_not_found", "One or more selected catalog tables were not found."],
|
||||
])("maps the description-generation error code %s to safe local copy", async (code, message) => {
|
||||
["sensitive_data_suggestion_invalid_response", "The model returned an incomplete or invalid classification. No suggestions were applied."],
|
||||
["sensitive_data_suggestion_provider_unavailable", "The selected model could not complete the request. No suggestions were applied."],
|
||||
])("maps the catalog error code %s to safe local copy", async (code, message) => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(JSON.stringify({ code, message: "provider detail must not be trusted" }), {
|
||||
status: 400,
|
||||
|
||||
@@ -23,6 +23,13 @@ const safeErrorCodes = new Set([
|
||||
"description_generation_target_ids_duplicate", "metadata_generation_model_unavailable",
|
||||
"catalog_column_not_found", "catalog_table_not_found",
|
||||
"workspace_configuration_unavailable",
|
||||
"sensitive_data_suggestion_request_invalid",
|
||||
"sensitive_data_suggestion_target_ids_duplicate",
|
||||
"sensitive_data_suggestion_no_columns",
|
||||
"sensitive_data_suggestion_payload_too_large",
|
||||
"sensitive_data_suggestion_invalid_response",
|
||||
"sensitive_data_suggestion_provider_unavailable",
|
||||
"sensitive_data_suggestion_failed",
|
||||
"schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid",
|
||||
"schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale",
|
||||
]);
|
||||
@@ -77,6 +84,13 @@ const localCodeMessages: Record<string, string> = {
|
||||
catalog_column_not_found: "The selected catalog column was not found.",
|
||||
catalog_table_not_found: "One or more selected catalog tables were not found.",
|
||||
workspace_configuration_unavailable: "The database workspace configuration is unavailable.",
|
||||
sensitive_data_suggestion_request_invalid: "Select a database, one or more tables, or one or more columns before requesting sensitive-field suggestions.",
|
||||
sensitive_data_suggestion_target_ids_duplicate: "Each selected table or column can be included only once.",
|
||||
sensitive_data_suggestion_no_columns: "The selected scope contains no catalog columns to classify.",
|
||||
sensitive_data_suggestion_payload_too_large: "The selected structural metadata cannot be divided into safe model requests.",
|
||||
sensitive_data_suggestion_invalid_response: "The model returned an incomplete or invalid classification. No suggestions were applied.",
|
||||
sensitive_data_suggestion_provider_unavailable: "The selected model could not complete the request. No suggestions were applied.",
|
||||
sensitive_data_suggestion_failed: "Sensitive-field suggestions failed before review. No changes were applied.",
|
||||
schema_sync_conflict: "A schema synchronization is already active or no longer current.",
|
||||
schema_introspection_failed: "The database schema could not be read safely.",
|
||||
schema_request_invalid: "The schema request is invalid.",
|
||||
|
||||
@@ -1571,6 +1571,125 @@ test("starts one selected column with the configured default model", async () =>
|
||||
expect(await screen.findByText("Description generation started for 1 column")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows database sensitive suggestions only for a selection and rejects multiple databases clearly", async () => {
|
||||
const user = userEvent.setup();
|
||||
let suggestionCalls = 0;
|
||||
const radiology = makeDatabase({
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
workspaceId: "radiology",
|
||||
workspaceName: "Radiology",
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
})),
|
||||
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => {
|
||||
suggestionCalls += 1;
|
||||
return HttpResponse.json({ suggestions: [] });
|
||||
}),
|
||||
);
|
||||
renderPage({ rows: [makeDatabase(), radiology] });
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Suggest sensitive fields" })).not.toBeInTheDocument();
|
||||
const psdRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
|
||||
const radiologyRow = await screen.findByRole("row", { name: /Radiology/ });
|
||||
await user.click(within(psdRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
expect(screen.getByRole("button", { name: "Suggest sensitive fields" })).toBeVisible();
|
||||
await user.click(within(radiologyRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
|
||||
expect(await screen.findByText("Sensitive-field suggestions can be requested for only one database at a time. Select one database and try again.")).toBeVisible();
|
||||
expect(suggestionCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("requests database-level sensitive suggestions for the only selected database", async () => {
|
||||
const user = userEvent.setup();
|
||||
let suggestionBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
})),
|
||||
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", async ({ request }) => {
|
||||
suggestionBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
suggestions: [{
|
||||
columnId: patientIdColumn.id,
|
||||
tableId: patientsTable.id,
|
||||
tableName: patientsTable.name,
|
||||
columnName: patientIdColumn.name,
|
||||
version: patientIdColumn.version,
|
||||
currentSensitive: false,
|
||||
sensitive: true,
|
||||
}],
|
||||
});
|
||||
}),
|
||||
);
|
||||
renderPage({ rows: [makeDatabase()] });
|
||||
|
||||
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: "Suggest sensitive fields" }));
|
||||
|
||||
await waitFor(() => expect(suggestionBody).toEqual({ modelId: "local-qwen", scope: "all" }));
|
||||
expect(await screen.findByRole("complementary", { name: "Sensitive field review" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("requests sensitive suggestions only for selected tables", async () => {
|
||||
const user = userEvent.setup();
|
||||
const visitsTable: CatalogTable = {
|
||||
...patientsTable,
|
||||
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
|
||||
name: "visits",
|
||||
};
|
||||
const visitColumn = {
|
||||
...patientIdColumn,
|
||||
id: "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee",
|
||||
tableId: visitsTable.id,
|
||||
name: "clinical_note",
|
||||
};
|
||||
let suggestionBody: unknown;
|
||||
server.use(
|
||||
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
|
||||
models: [{ id: "local-qwen", label: "Local Qwen" }],
|
||||
default: "local-qwen",
|
||||
})),
|
||||
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([
|
||||
patientsTable,
|
||||
visitsTable,
|
||||
])),
|
||||
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", async ({ request }) => {
|
||||
suggestionBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
suggestions: [{
|
||||
columnId: visitColumn.id,
|
||||
tableId: visitsTable.id,
|
||||
tableName: visitsTable.name,
|
||||
columnName: visitColumn.name,
|
||||
version: visitColumn.version,
|
||||
currentSensitive: false,
|
||||
sensitive: true,
|
||||
}],
|
||||
});
|
||||
}),
|
||||
);
|
||||
renderPage({ 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" }));
|
||||
const visitsRow = await screen.findByRole("row", { name: /visits/ });
|
||||
await user.click(within(visitsRow).getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
|
||||
await waitFor(() => expect(suggestionBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
scope: "selected_tables",
|
||||
targetIds: [visitsTable.id],
|
||||
}));
|
||||
expect(await screen.findByRole("complementary", { name: "Sensitive field review" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("reviews AI-sensitive-field suggestions as an editable draft and saves only changed columns", async () => {
|
||||
const user = userEvent.setup();
|
||||
const idColumn = { ...patientIdColumn, sensitive: false };
|
||||
@@ -1585,7 +1704,18 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
generatedDescription: "Name of the patient",
|
||||
sensitive: false,
|
||||
};
|
||||
let columns = [idColumn, nameColumn];
|
||||
const unselectedColumn = {
|
||||
...patientIdColumn,
|
||||
id: "ffffffff-ffff-4fff-8fff-ffffffffffff",
|
||||
name: "address",
|
||||
ordinalPosition: 3,
|
||||
primaryKeyPosition: null,
|
||||
isPrimaryKey: false,
|
||||
description: "Patient address",
|
||||
generatedDescription: "Address of the patient",
|
||||
sensitive: false,
|
||||
};
|
||||
let columns = [idColumn, nameColumn, unselectedColumn];
|
||||
let suggestionBody: unknown;
|
||||
const patches: Array<{ columnId: string; body: unknown }> = [];
|
||||
server.use(
|
||||
@@ -1604,9 +1734,24 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
suggestionBody = await request.json();
|
||||
return HttpResponse.json({
|
||||
suggestions: [
|
||||
{ columnId: idColumn.id, sensitive: true },
|
||||
{ columnId: nameColumn.id, sensitive: true },
|
||||
{ columnId: "ffffffff-ffff-4fff-8fff-ffffffffffff", sensitive: true },
|
||||
{
|
||||
columnId: idColumn.id,
|
||||
tableId: patientsTable.id,
|
||||
tableName: patientsTable.name,
|
||||
columnName: idColumn.name,
|
||||
version: idColumn.version,
|
||||
currentSensitive: false,
|
||||
sensitive: true,
|
||||
},
|
||||
{
|
||||
columnId: nameColumn.id,
|
||||
tableId: patientsTable.id,
|
||||
tableName: patientsTable.name,
|
||||
columnName: nameColumn.name,
|
||||
version: nameColumn.version,
|
||||
currentSensitive: false,
|
||||
sensitive: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
@@ -1616,8 +1761,6 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
async ({ params, request }) => {
|
||||
const body = await request.json() as {
|
||||
version: number;
|
||||
description: string | null;
|
||||
generatedDescription: string | null;
|
||||
sensitive: boolean;
|
||||
};
|
||||
patches.push({ columnId: String(params.columnId), body });
|
||||
@@ -1638,31 +1781,41 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
|
||||
const nameSensitive = await screen.findByRole("checkbox", { name: "Sensitive data for name" });
|
||||
expect(idSensitive).not.toBeChecked();
|
||||
expect(nameSensitive).not.toBeChecked();
|
||||
expect(screen.queryByRole("button", { name: "Suggest sensitive fields" })).not.toBeInTheDocument();
|
||||
|
||||
const selectableRow = async (name: RegExp) => {
|
||||
const rows = await screen.findAllByRole("row", { name });
|
||||
return rows.find((row) => within(row).queryByRole("checkbox", { name: /toggle row selection/i }))!;
|
||||
};
|
||||
await user.click(within(await selectableRow(/Patient identifier/))
|
||||
.getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(within(await selectableRow(/Patient name/))
|
||||
.getByRole("checkbox", { name: /toggle row selection/i }));
|
||||
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
|
||||
|
||||
await waitFor(() => expect(suggestionBody).toEqual({ modelId: "local-qwen" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked();
|
||||
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).toBeChecked();
|
||||
});
|
||||
await waitFor(() => expect(suggestionBody).toEqual({
|
||||
modelId: "local-qwen",
|
||||
scope: "selected_columns",
|
||||
targetIds: [idColumn.id, nameColumn.id],
|
||||
}));
|
||||
const review = await screen.findByRole("complementary", { name: "Sensitive field review" });
|
||||
expect(within(review).getByRole("checkbox", { name: "Protect patients.id" })).toBeChecked();
|
||||
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).toBeChecked();
|
||||
expect(patches).toHaveLength(0);
|
||||
|
||||
await user.click(screen.getByRole("checkbox", { name: "Sensitive data for name" }));
|
||||
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
|
||||
await user.click(screen.getByRole("button", { name: "Save sensitive fields" }));
|
||||
await user.click(within(review).getByRole("checkbox", { name: "Protect patients.name" }));
|
||||
await user.click(within(review).getByRole("button", { name: "Save 1" }));
|
||||
|
||||
await waitFor(() => expect(patches).toEqual([{
|
||||
columnId: idColumn.id,
|
||||
body: {
|
||||
version: idColumn.version,
|
||||
description: idColumn.description,
|
||||
generatedDescription: idColumn.generatedDescription,
|
||||
sensitive: true,
|
||||
},
|
||||
}]));
|
||||
expect(await screen.findByText("Sensitive fields saved")).toBeVisible();
|
||||
expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked();
|
||||
expect(await screen.findByText("Saved 1 sensitive flag")).toBeVisible();
|
||||
await waitFor(() => expect(screen.queryByRole("complementary", { name: "Sensitive field review" })).not.toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked());
|
||||
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
replaceCatalogDatabaseSecrets,
|
||||
startCatalogSync,
|
||||
startDescriptionGenerationRun,
|
||||
suggestSensitiveFields,
|
||||
testCatalogDatabase,
|
||||
updateCatalogDatabase,
|
||||
type CatalogDatabase,
|
||||
type CatalogColumn,
|
||||
type CatalogDatabaseMetadataDeleteTarget,
|
||||
type CatalogSecretName,
|
||||
type CatalogSyncScope,
|
||||
@@ -30,6 +32,8 @@ import {
|
||||
type DatabaseBinding,
|
||||
type DatabaseTransport,
|
||||
type DescriptionGenerationRun,
|
||||
type SensitiveDataSuggestion,
|
||||
type SensitiveDataSuggestionRequest,
|
||||
} from "../api/catalog-databases";
|
||||
import { DatabaseGrid } from "./database-management/DatabaseGrid";
|
||||
import { DatabaseForm } from "./database-management/DatabaseForm";
|
||||
@@ -38,6 +42,7 @@ import { DatabaseRelationships } from "./database-management/DatabaseRelationshi
|
||||
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
|
||||
import { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
|
||||
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
|
||||
import { SensitiveDataReviewDrawer } from "./database-management/SensitiveDataReviewDrawer";
|
||||
import {
|
||||
configurationFingerprint,
|
||||
configurationFromDraft,
|
||||
@@ -141,6 +146,11 @@ export function DatabaseManagementPage({
|
||||
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
|
||||
const [activeDescriptionGenerationRun, setActiveDescriptionGenerationRun] = useState<DescriptionGenerationRun | null>(null);
|
||||
const [descriptionGenerationDrawerOpen, setDescriptionGenerationDrawerOpen] = useState(false);
|
||||
const [sensitiveReview, setSensitiveReview] = useState<{
|
||||
databaseId: string;
|
||||
scopeLabel: string;
|
||||
suggestions: SensitiveDataSuggestion[];
|
||||
} | null>(null);
|
||||
|
||||
const originRef = useRef<HTMLElement | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -683,6 +693,67 @@ export function DatabaseManagementPage({
|
||||
}
|
||||
}, [rememberDescriptionGenerationRun, selectedMetadataModel]);
|
||||
|
||||
const requestSensitiveSuggestions = useCallback(async (
|
||||
database: CatalogDatabase,
|
||||
selection: SensitiveDataSuggestionRequest,
|
||||
scopeLabel: string,
|
||||
) => {
|
||||
if (!database.id) {
|
||||
toast.error("The selected database is not configured, so sensitive-field suggestions were not requested.");
|
||||
throw new Error("database is not configured");
|
||||
}
|
||||
if (!selectedMetadataModel) {
|
||||
toast.error("Select a metadata-generation model before requesting sensitive-field suggestions.");
|
||||
throw new Error("metadata-generation model is not selected");
|
||||
}
|
||||
try {
|
||||
const result = await suggestSensitiveFields(database.id, selectedMetadataModel, selection);
|
||||
setSensitiveReview({ databaseId: database.id, scopeLabel, suggestions: result.suggestions });
|
||||
toast.success(`Prepared ${result.suggestions.length} sensitive-field suggestion${result.suggestions.length === 1 ? "" : "s"} for review`);
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
throw error;
|
||||
}
|
||||
}, [selectedMetadataModel]);
|
||||
|
||||
const suggestDatabaseSensitiveFields = useCallback(async (selected: CatalogDatabase[]) => {
|
||||
if (selected.length !== 1) {
|
||||
toast.error("Sensitive-field suggestions can be requested for only one database at a time. Select one database and try again.");
|
||||
throw new Error("more than one database selected");
|
||||
}
|
||||
const database = selected[0]!;
|
||||
await requestSensitiveSuggestions(database, { scope: "all" }, `Entire database ${database.workspaceName}`);
|
||||
}, [requestSensitiveSuggestions]);
|
||||
|
||||
const suggestActiveDatabaseSensitiveFields = useCallback(async (
|
||||
selection: SensitiveDataSuggestionRequest,
|
||||
scopeLabel: string,
|
||||
) => {
|
||||
if (!activeRow) {
|
||||
toast.error("The database is no longer available, so sensitive-field suggestions were not requested.");
|
||||
throw new Error("database is no longer available");
|
||||
}
|
||||
await requestSensitiveSuggestions(activeRow, selection, scopeLabel);
|
||||
}, [activeRow, requestSensitiveSuggestions]);
|
||||
|
||||
const sensitiveColumnsSaved = useCallback((columns: CatalogColumn[]) => {
|
||||
const savedById = new Map(columns.map((column) => [column.id, column]));
|
||||
setSensitiveReview((current) => current ? {
|
||||
...current,
|
||||
suggestions: current.suggestions.map((suggestion) => {
|
||||
const saved = savedById.get(suggestion.columnId);
|
||||
return saved ? {
|
||||
...suggestion,
|
||||
version: saved.version,
|
||||
currentSensitive: saved.sensitive,
|
||||
sensitive: saved.sensitive,
|
||||
} : suggestion;
|
||||
}),
|
||||
} : null);
|
||||
const databaseId = sensitiveReview?.databaseId;
|
||||
if (databaseId) void queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] });
|
||||
}, [queryClient, sensitiveReview?.databaseId]);
|
||||
|
||||
const deleteSelectedMetadata = useCallback(async (
|
||||
selected: CatalogDatabase[],
|
||||
target: CatalogDatabaseMetadataDeleteTarget,
|
||||
@@ -832,6 +903,7 @@ export function DatabaseManagementPage({
|
||||
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
|
||||
descriptionGenerationActive={descriptionGenerationActive}
|
||||
onGenerateDescriptions={generateDatabaseDescriptions}
|
||||
onSuggestSensitive={suggestDatabaseSensitiveFields}
|
||||
onDeleteMetadataSelected={deleteSelectedMetadata}
|
||||
/>
|
||||
)}
|
||||
@@ -886,6 +958,7 @@ export function DatabaseManagementPage({
|
||||
onRunStarted={rememberSyncRun}
|
||||
onOpenSync={() => openSync(activeRow)}
|
||||
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
|
||||
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -920,6 +993,15 @@ export function DatabaseManagementPage({
|
||||
onRunUpdate={setActiveDescriptionGenerationRun}
|
||||
onTerminal={(run) => void descriptionGenerationTerminated(run)}
|
||||
/>
|
||||
<SensitiveDataReviewDrawer
|
||||
open={Boolean(sensitiveReview)}
|
||||
databaseId={sensitiveReview?.databaseId ?? null}
|
||||
scopeLabel={sensitiveReview?.scopeLabel ?? ""}
|
||||
suggestions={sensitiveReview?.suggestions ?? []}
|
||||
canManage={canManage}
|
||||
onClose={() => setSensitiveReview(null)}
|
||||
onSaved={sensitiveColumnsSaved}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
consolidateCatalogDescriptions,
|
||||
listCatalogColumns,
|
||||
startDescriptionGenerationRun,
|
||||
suggestSensitiveFields,
|
||||
updateCatalogColumnMetadata,
|
||||
type CatalogColumn,
|
||||
type CatalogTable,
|
||||
type DescriptionGenerationRun,
|
||||
type SensitiveDataSuggestionRequest,
|
||||
} from "../../api/catalog-databases";
|
||||
import type { DatabaseNavigationState } from "./model";
|
||||
|
||||
@@ -28,6 +28,7 @@ interface Props {
|
||||
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
||||
onNavigationStateChange: (state: DatabaseNavigationState) => void;
|
||||
onSync: () => void;
|
||||
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface GridContext {
|
||||
@@ -84,6 +85,7 @@ export function DatabaseColumns({
|
||||
onDescriptionGenerationRunStarted,
|
||||
onNavigationStateChange,
|
||||
onSync,
|
||||
onSuggestSensitive,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = ["catalog-columns", databaseId, table.id] as const;
|
||||
@@ -222,23 +224,16 @@ export function DatabaseColumns({
|
||||
};
|
||||
|
||||
const suggestSensitive = async () => {
|
||||
if (!selectedMetadataModel) return;
|
||||
if (selectedIds.length === 0) return;
|
||||
setBusy(true);
|
||||
setSensitiveAction("suggest");
|
||||
try {
|
||||
const result = await suggestSensitiveFields(databaseId, selectedMetadataModel);
|
||||
const currentById = new Map(data.map((column) => [column.id, column]));
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const suggestion of result.suggestions) {
|
||||
const column = currentById.get(suggestion.columnId);
|
||||
if (column && suggestion.sensitive !== column.sensitive) {
|
||||
next[column.id] = suggestion.sensitive;
|
||||
}
|
||||
}
|
||||
setSensitiveDrafts(next);
|
||||
toast.success("Sensitive field suggestions ready for review");
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
await onSuggestSensitive(
|
||||
{ scope: "selected_columns", targetIds: selectedIds },
|
||||
`${selectedIds.length} selected column${selectedIds.length === 1 ? "" : "s"} in ${table.name}`,
|
||||
);
|
||||
} catch {
|
||||
// The page-level request reports the safe, specific reason and preserves the selection.
|
||||
} finally {
|
||||
setSensitiveAction(null);
|
||||
setBusy(false);
|
||||
@@ -351,6 +346,20 @@ export function DatabaseColumns({
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canManage || descriptionGenerationActive || busy}
|
||||
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
|
||||
onClick={() => void suggestSensitive()}
|
||||
>
|
||||
<Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
|
||||
</Button>
|
||||
{changedSensitiveColumns.length > 0 ? (
|
||||
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
|
||||
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -358,14 +367,6 @@ export function DatabaseColumns({
|
||||
<span className="thot-label whitespace-nowrap">Catalog columns</span>
|
||||
<input 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 columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy}
|
||||
onClick={() => void suggestSensitive()}
|
||||
>
|
||||
<Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
|
||||
</Button>
|
||||
{changedSensitiveColumns.length > 0 ? (
|
||||
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
|
||||
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "ag-grid-community";
|
||||
import "ag-grid-community/styles/ag-grid.css";
|
||||
import "ag-grid-community/styles/ag-theme-alpine.css";
|
||||
import { ChevronDown, Eye, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
||||
import { ChevronDown, Eye, Pencil, RefreshCw, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import type {
|
||||
CatalogDatabase,
|
||||
@@ -42,6 +42,7 @@ interface DatabaseGridProps {
|
||||
rows: CatalogDatabase[],
|
||||
scope: Extract<DescriptionGenerationScope, "all" | "missing">,
|
||||
) => Promise<void>;
|
||||
onSuggestSensitive: (rows: CatalogDatabase[]) => Promise<void>;
|
||||
onDeleteMetadataSelected: (
|
||||
rows: CatalogDatabase[],
|
||||
target: CatalogDatabaseMetadataDeleteTarget,
|
||||
@@ -164,13 +165,14 @@ export function DatabaseGrid({
|
||||
selectedMetadataModel,
|
||||
descriptionGenerationActive,
|
||||
onGenerateDescriptions,
|
||||
onSuggestSensitive,
|
||||
onDeleteMetadataSelected,
|
||||
}: DatabaseGridProps) {
|
||||
const compact = useCompactViewport();
|
||||
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
|
||||
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<CatalogDatabase[]>([]);
|
||||
const [action, setAction] = useState<"test" | "sync" | "generate" | "delete" | null>(null);
|
||||
const [action, setAction] = useState<"test" | "sync" | "generate" | "suggest" | "delete" | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<CatalogDatabaseMetadataDeleteTarget | null>(null);
|
||||
const [pendingGenerateAll, setPendingGenerateAll] = useState(false);
|
||||
const context = useMemo<DatabaseGridContext>(
|
||||
@@ -283,12 +285,18 @@ export function DatabaseGrid({
|
||||
setPendingGenerateAll(false);
|
||||
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
|
||||
};
|
||||
const perform = async (kind: "test" | "sync" | "generate" | "delete", operation: () => Promise<void>) => {
|
||||
const perform = async (
|
||||
kind: "test" | "sync" | "generate" | "suggest" | "delete",
|
||||
operation: () => Promise<void>,
|
||||
clearSelection = true,
|
||||
) => {
|
||||
setAction(kind);
|
||||
try {
|
||||
await operation();
|
||||
gridRef.current?.api.deselectAll();
|
||||
setSelectedRows([]);
|
||||
if (clearSelection) {
|
||||
gridRef.current?.api.deselectAll();
|
||||
setSelectedRows([]);
|
||||
}
|
||||
setPendingDelete(null);
|
||||
setPendingGenerateAll(false);
|
||||
if (kind === "delete") window.setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
@@ -422,6 +430,15 @@ export function DatabaseGrid({
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canManage || action !== null || descriptionGenerationActive}
|
||||
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
|
||||
onClick={() => void perform("suggest", () => onSuggestSensitive(selectedRows), false)}
|
||||
>
|
||||
<Sparkles />{action === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" disabled={action !== null} onClick={() => { gridRef.current?.api.deselectAll(); setSelectedRows([]); }}><X />Clear</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Menu } from "@base-ui/react/menu";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AgGridReact } from "ag-grid-react";
|
||||
import type { ColDef, ICellRendererParams } from "ag-grid-community";
|
||||
import { ArrowLeft, ChevronDown, Columns3, Pencil, RefreshCw, Save, Trash2, X } from "lucide-react";
|
||||
import { ArrowLeft, ChevronDown, Columns3, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { ApiError, apiErrorMessage } from "../../api/client";
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type CatalogTable,
|
||||
type CatalogTableMetadataDeleteTarget,
|
||||
type DescriptionGenerationRun,
|
||||
type SensitiveDataSuggestionRequest,
|
||||
} from "../../api/catalog-databases";
|
||||
import type { DatabaseNavigationState } from "./model";
|
||||
import { DatabaseColumns } from "./DatabaseColumns";
|
||||
@@ -36,6 +37,7 @@ interface Props {
|
||||
onRunStarted: (run: CatalogSyncRun) => void;
|
||||
onOpenSync: () => void;
|
||||
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
|
||||
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface TableGridContext {
|
||||
@@ -70,6 +72,7 @@ export function DatabaseTables({
|
||||
onRunStarted,
|
||||
onOpenSync,
|
||||
onDescriptionGenerationRunStarted,
|
||||
onSuggestSensitive,
|
||||
}: Props) {
|
||||
const databaseId = database.id!;
|
||||
const queryClient = useQueryClient();
|
||||
@@ -86,7 +89,7 @@ export function DatabaseTables({
|
||||
const [editorVersion, setEditorVersion] = useState<number | null>(null);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [staleBannerOpen, setStaleBannerOpen] = useState(true);
|
||||
const [busy, setBusy] = useState<"sync" | "save" | "delete" | "consolidate" | "generate" | null>(null);
|
||||
const [busy, setBusy] = useState<"sync" | "save" | "delete" | "consolidate" | "generate" | "suggest" | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<CatalogTableMetadataDeleteTarget | null>(null);
|
||||
const [columnNavigation, setColumnNavigation] = useState<DatabaseNavigationState>({ dirty: false, busy: false });
|
||||
const gridRef = useRef<AgGridReact<CatalogTable>>(null);
|
||||
@@ -240,6 +243,20 @@ export function DatabaseTables({
|
||||
toast.error(apiErrorMessage(error));
|
||||
} finally { setBusy(null); }
|
||||
};
|
||||
const suggestSensitive = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
setBusy("suggest");
|
||||
try {
|
||||
await onSuggestSensitive(
|
||||
{ scope: "selected_tables", targetIds: selectedIds },
|
||||
`${selectedIds.length} selected table${selectedIds.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch {
|
||||
// The page-level request reports the safe, specific reason and preserves the selection.
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<ColDef<CatalogTable>[]>(() => [
|
||||
{ field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
|
||||
@@ -303,6 +320,7 @@ export function DatabaseTables({
|
||||
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
|
||||
onNavigationStateChange={setColumnNavigation}
|
||||
onSync={() => void synchronize("columns", [activeTable.id])}
|
||||
onSuggestSensitive={onSuggestSensitive}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-0 overflow-y-auto px-4 py-5">
|
||||
@@ -420,6 +438,15 @@ export function DatabaseTables({
|
||||
</Menu.Positioner>
|
||||
</Menu.Portal>
|
||||
</Menu.Root>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!canManage || descriptionGenerationActive || busy !== null}
|
||||
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
|
||||
onClick={() => void suggestSensitive()}
|
||||
>
|
||||
<Sparkles />{busy === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Save, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { apiErrorMessage } from "../../api/client";
|
||||
import {
|
||||
updateCatalogColumnSensitive,
|
||||
type CatalogColumn,
|
||||
type SensitiveDataSuggestion,
|
||||
} from "../../api/catalog-databases";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
databaseId: string | null;
|
||||
scopeLabel: string;
|
||||
suggestions: SensitiveDataSuggestion[];
|
||||
canManage: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (columns: CatalogColumn[]) => void;
|
||||
}
|
||||
|
||||
export function SensitiveDataReviewDrawer({
|
||||
open,
|
||||
databaseId,
|
||||
scopeLabel,
|
||||
suggestions,
|
||||
canManage,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: Props) {
|
||||
const [drafts, setDrafts] = useState<Record<string, boolean>>({});
|
||||
const [search, setSearch] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDrafts(Object.fromEntries(suggestions.map((suggestion) => [
|
||||
suggestion.columnId,
|
||||
suggestion.sensitive,
|
||||
])));
|
||||
setSearch("");
|
||||
setShowAll(false);
|
||||
}, [open, suggestions]);
|
||||
|
||||
const changed = useMemo(() => suggestions.filter((suggestion) => (
|
||||
drafts[suggestion.columnId] !== undefined
|
||||
&& drafts[suggestion.columnId] !== suggestion.currentSensitive
|
||||
)), [drafts, suggestions]);
|
||||
const visible = useMemo(() => {
|
||||
const term = search.trim().toLocaleLowerCase();
|
||||
return suggestions.filter((suggestion) => (
|
||||
(showAll || drafts[suggestion.columnId] !== suggestion.currentSensitive)
|
||||
&& (!term || `${suggestion.tableName}.${suggestion.columnName}`.toLocaleLowerCase().includes(term))
|
||||
));
|
||||
}, [drafts, search, showAll, suggestions]);
|
||||
|
||||
const close = () => {
|
||||
if (saving) return;
|
||||
if (changed.length > 0 && !window.confirm("Discard the sensitive-field review?")) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!databaseId || changed.length === 0) return;
|
||||
setSaving(true);
|
||||
const results = await Promise.allSettled(changed.map((suggestion) => (
|
||||
updateCatalogColumnSensitive(
|
||||
databaseId,
|
||||
suggestion.tableId,
|
||||
suggestion.columnId,
|
||||
suggestion.version,
|
||||
drafts[suggestion.columnId]!,
|
||||
)
|
||||
)));
|
||||
const saved = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
const failed = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
|
||||
if (saved.length > 0) onSaved(saved);
|
||||
if (failed.length > 0) {
|
||||
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[0])}`);
|
||||
} else {
|
||||
toast.success(`Saved ${saved.length} sensitive flag${saved.length === 1 ? "" : "s"}`);
|
||||
onClose();
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
if (!open || !databaseId) return null;
|
||||
return (
|
||||
<aside
|
||||
aria-label="Sensitive field review"
|
||||
className="fixed inset-y-2 right-0 z-50 flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<p className="thot-label">Sensitive data</p>
|
||||
<h2 className="mt-1 font-heading text-xl font-semibold">Review suggested flags</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{scopeLabel}. The model proposed values, but only your save changes the catalog.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close sensitive field review" disabled={saving} onClick={close}>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
|
||||
aria-label="Search sensitive field suggestions"
|
||||
placeholder="Search table or column"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
|
||||
{changed.length} change{changed.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
<label className="mt-3 inline-flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
checked={showAll}
|
||||
onChange={(event) => setShowAll(event.target.checked)}
|
||||
/>
|
||||
Show all {suggestions.length} classified columns
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{visible.length === 0 ? (
|
||||
<div className="rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
|
||||
<p className="font-semibold">No proposed changes in this view.</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Show all classified columns to inspect unchanged flags, or close this review.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border" aria-label="Sensitive field suggestions">
|
||||
{visible.map((suggestion) => {
|
||||
const proposed = drafts[suggestion.columnId] ?? suggestion.sensitive;
|
||||
const changedFromCurrent = proposed !== suggestion.currentSensitive;
|
||||
return (
|
||||
<li key={suggestion.columnId} className="flex items-start gap-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 size-4 shrink-0 accent-primary outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||
aria-label={`Protect ${suggestion.tableName}.${suggestion.columnName}`}
|
||||
checked={proposed}
|
||||
disabled={!canManage || saving}
|
||||
onChange={(event) => setDrafts((current) => ({
|
||||
...current,
|
||||
[suggestion.columnId]: event.target.checked,
|
||||
}))}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-mono text-xs font-semibold">
|
||||
{suggestion.tableName}.{suggestion.columnName}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Current: {suggestion.currentSensitive ? "protected" : "allowed"}. Proposed: {proposed ? "protected" : "allowed"}.
|
||||
</p>
|
||||
</div>
|
||||
<span className={`rounded px-2 py-0.5 text-[11px] font-semibold ${changedFromCurrent ? "bg-amber-500/12 text-amber-800 dark:text-amber-300" : "bg-muted text-muted-foreground"}`}>
|
||||
{changedFromCurrent ? "Change" : "No change"}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4">
|
||||
<p className="text-xs text-muted-foreground">Unsaved suggestions never change the catalog.</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" disabled={saving} onClick={close}>Cancel</Button>
|
||||
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
|
||||
<Save />{saving ? "Saving…" : `Save ${changed.length}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user