feat: complete catalog sensitivity enhancements
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
startCatalogSync,
|
||||
startDescriptionGenerationRun,
|
||||
updateCatalogColumnMetadata,
|
||||
updateCatalogColumnSensitive,
|
||||
type CatalogColumn,
|
||||
type CatalogSyncRun,
|
||||
type CatalogTable,
|
||||
@@ -89,6 +90,76 @@ function SensitiveCell({ data, context }: ICellRendererParams<CatalogColumn, unk
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnMetadataFieldMatrix({
|
||||
column,
|
||||
description,
|
||||
generatedDescription,
|
||||
canManage,
|
||||
busy,
|
||||
onDescriptionChange,
|
||||
onGeneratedDescriptionChange,
|
||||
}: {
|
||||
column: CatalogColumn;
|
||||
description: string;
|
||||
generatedDescription: string;
|
||||
canManage: boolean;
|
||||
busy: boolean;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
onGeneratedDescriptionChange: (value: string) => void;
|
||||
}) {
|
||||
const fieldClass = [
|
||||
"thot-column-metadata-field flex min-h-0 flex-col gap-1.5",
|
||||
"text-sm font-semibold",
|
||||
].join(" ");
|
||||
const editableClass = [
|
||||
"min-h-28 flex-1 resize-none rounded-md border border-input bg-card px-3 py-2",
|
||||
"text-sm font-normal outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15",
|
||||
].join(" ");
|
||||
const readOnlyClass = [
|
||||
"min-h-28 flex-1 resize-none rounded-md border border-input bg-muted/35 px-3 py-2",
|
||||
"text-sm font-normal",
|
||||
].join(" ");
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Column metadata fields"
|
||||
className="thot-column-metadata-matrix mt-5 grid min-h-0 gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<label className={fieldClass}>
|
||||
Source comment
|
||||
<textarea className={readOnlyClass} value={column.sourceComment ?? ""} readOnly />
|
||||
</label>
|
||||
<label className={fieldClass}>
|
||||
Description
|
||||
<textarea
|
||||
className={editableClass}
|
||||
value={description}
|
||||
disabled={!canManage || busy}
|
||||
onChange={(event) => onDescriptionChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={fieldClass}>
|
||||
Generated description
|
||||
<textarea
|
||||
className={editableClass}
|
||||
value={generatedDescription}
|
||||
disabled={!canManage || busy}
|
||||
onChange={(event) => onGeneratedDescriptionChange(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={fieldClass}>
|
||||
Sensitive motivation
|
||||
<textarea
|
||||
className={readOnlyClass}
|
||||
value={column.sensitivityReason ?? ""}
|
||||
placeholder="No sensitivity motivation recorded."
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DatabaseColumns({
|
||||
databaseId,
|
||||
table,
|
||||
@@ -264,26 +335,38 @@ export function DatabaseColumns({
|
||||
setBusy(true);
|
||||
setSensitiveAction("save");
|
||||
try {
|
||||
const updated = await Promise.all(changedSensitiveColumns.map((column) => (
|
||||
updateCatalogColumnMetadata(
|
||||
databaseId,
|
||||
table.id,
|
||||
column.id,
|
||||
column.version,
|
||||
column.description,
|
||||
column.generatedDescription,
|
||||
sensitiveDrafts[column.id],
|
||||
)
|
||||
)));
|
||||
const updatedById = new Map(updated.map((column) => [column.id, column]));
|
||||
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map(
|
||||
(column) => updatedById.get(column.id) ?? column,
|
||||
));
|
||||
setSensitiveDrafts({});
|
||||
void onCatalogMetricsChanged?.();
|
||||
toast.success("Sensitive fields saved");
|
||||
} catch (error) {
|
||||
toast.error(apiErrorMessage(error));
|
||||
const saved: CatalogColumn[] = [];
|
||||
const failed: unknown[] = [];
|
||||
for (const column of changedSensitiveColumns) {
|
||||
try {
|
||||
saved.push(await updateCatalogColumnSensitive(
|
||||
databaseId,
|
||||
table.id,
|
||||
column.id,
|
||||
column.version,
|
||||
sensitiveDrafts[column.id]!,
|
||||
));
|
||||
} catch (error) {
|
||||
failed.push(error);
|
||||
}
|
||||
}
|
||||
if (saved.length > 0) {
|
||||
const savedById = new Map(saved.map((column) => [column.id, column]));
|
||||
queryClient.setQueryData<CatalogColumn[]>(queryKey, (current = []) => current.map(
|
||||
(column) => savedById.get(column.id) ?? column,
|
||||
));
|
||||
setSensitiveDrafts((current) => {
|
||||
const next = { ...current };
|
||||
for (const column of saved) delete next[column.id];
|
||||
return next;
|
||||
});
|
||||
void onCatalogMetricsChanged?.();
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
toast.error(`${failed.length} sensitive flag${failed.length === 1 ? "" : "s"} could not be saved: ${apiErrorMessage(failed[0])}`);
|
||||
} else {
|
||||
toast.success("Sensitive fields saved");
|
||||
}
|
||||
} finally {
|
||||
setSensitiveAction(null);
|
||||
setBusy(false);
|
||||
@@ -294,8 +377,7 @@ export function DatabaseColumns({
|
||||
| "sync-columns"
|
||||
| "generate-descriptions"
|
||||
| "consolidate-descriptions"
|
||||
| "suggest-sensitive"
|
||||
| "save-sensitive";
|
||||
| "suggest-sensitive";
|
||||
|
||||
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
|
||||
{
|
||||
@@ -365,30 +447,13 @@ export function DatabaseColumns({
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
id: "save-sensitive",
|
||||
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 to save."
|
||||
: catalogOperationActive
|
||||
? "Wait for the active catalog operation to finish."
|
||||
: busy
|
||||
? "Another action is running."
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const runFleetAction = async (action: FleetColumnAction) => {
|
||||
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();
|
||||
else await suggestSensitive();
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
@@ -454,15 +519,15 @@ export function DatabaseColumns({
|
||||
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
|
||||
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
|
||||
) : null}
|
||||
<div className="mt-5 grid gap-4 border-t border-border pt-5 sm:grid-cols-2">
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Physical column name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.name} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Native type<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.dataType} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Ordinal position<input className="h-9 rounded-md border border-input bg-muted/35 px-3" value={active.ordinalPosition} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Default expression<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.defaultExpression ?? ""} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={active.sourceComment ?? ""} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Generated description<textarea className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||
</div>
|
||||
<ColumnMetadataFieldMatrix
|
||||
column={active}
|
||||
description={description}
|
||||
generatedDescription={generatedDescription}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onDescriptionChange={setDescription}
|
||||
onGeneratedDescriptionChange={setGeneratedDescription}
|
||||
/>
|
||||
<div className="mt-5 flex justify-end gap-2 border-t border-border pt-4">
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
|
||||
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save />{busy ? "Saving…" : "Save metadata"}</Button>
|
||||
@@ -486,6 +551,7 @@ export function DatabaseColumns({
|
||||
onClear={clearSelection}
|
||||
label="Column action"
|
||||
/>
|
||||
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -519,6 +585,7 @@ export function DatabaseColumns({
|
||||
onClear={clearSelection}
|
||||
label="Column action"
|
||||
/>
|
||||
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || catalogOperationActive || busy} onClick={() => void saveSensitive()}><Save aria-hidden="true" />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
|
||||
<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={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
|
||||
@@ -574,6 +641,7 @@ export function DatabaseColumns({
|
||||
onClose={closeEditor}
|
||||
closeLabel="Close column metadata"
|
||||
busy={busy}
|
||||
bodyClassName="thot-column-metadata-drawer-body"
|
||||
footer={(
|
||||
<>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
|
||||
@@ -587,15 +655,15 @@ export function DatabaseColumns({
|
||||
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
|
||||
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
|
||||
) : null}
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Physical column name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.name} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Native type<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.dataType} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Ordinal position<input className="h-9 rounded-md border border-input bg-muted/35 px-3" value={active.ordinalPosition} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold">Default expression<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.defaultExpression ?? ""} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={active.sourceComment ?? ""} readOnly /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Generated description<textarea className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
|
||||
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy} onChange={(event) => setDescription(event.target.value)} /></label>
|
||||
</div>
|
||||
<ColumnMetadataFieldMatrix
|
||||
column={active}
|
||||
description={description}
|
||||
generatedDescription={generatedDescription}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onDescriptionChange={setDescription}
|
||||
onGeneratedDescriptionChange={setGeneratedDescription}
|
||||
/>
|
||||
</FleetLedgerDrawer>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user