fix(datasources): preserve governed CSV originals and fresh detail state
Module Package Release / publish-packages (push) Successful in 12s

Release v0.1.26. Coordinated integrity review: GovOPlaN/govoplan-core#298.
This commit is contained in:
2026-09-08 12:19:38 +02:00
parent 8b8c6c548e
commit 9d067f1bad
20 changed files with 825 additions and 91 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/datasources-webui",
"version": "0.1.25",
"version": "0.1.26",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -14,10 +14,11 @@
"./styles/datasources.css": "./src/styles/datasources.css"
},
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test:detail-refresh": "node --test scripts/test-detail-refresh.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.45",
"@govoplan/core-webui": "^0.1.46",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+258
View File
@@ -0,0 +1,258 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import test from "node:test";
const require = createRequire(new URL("../../../govoplan-core/webui/package.json", import.meta.url));
const { transformSync } = require("esbuild");
const page = readFileSync(new URL("../src/features/datasources/DatasourcesPage.tsx", import.meta.url), "utf8");
function extract(startMarker, endMarker) {
const start = page.indexOf(startMarker);
const end = page.indexOf(endMarker, start);
assert.ok(start >= 0 && end > start, "test the actual page closures");
return page.slice(start + startMarker.length, end);
}
const reloadBody = extract(" const reload = useCallback(async (preferredDatasourceRef?: string) => {", " }, [dialogScope]);");
const detailBody = extract(" useEffect(() => {\n if (!selectedDatasourceRef || loading || !catalogueReady)", " }, [selectedDatasourceRef, detailScope, loading, catalogueReady]);");
function evaluate(body, bindings) {
const code = transformSync(body, { loader: "ts", target: "es2022" }).code;
return new Function(...Object.keys(bindings), code)(...Object.values(bindings));
}
const deferred = () => {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
};
const settle = async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); };
function harness() {
const state = { selectedDatasourceRef: "source-1", loading: false, detailRevision: 0, preview: null, materializations: [], detailLoading: false, catalogueAuthority: 0 };
const settings = {};
const auth = {};
const bindings = {
settings, auth, reloadRequestId: { current: 0 }, authority: { current: "authority-A" }, authorityKey: "authority-A", dialogScope: 0, authorityEpoch: { current: { revision: 0 } },
currentDetailScope: { current: null }, apiErrorMessage: String,
listDatasources: async () => [{ ref: "source-1" }], listDatasourceStages: async () => [],
listDatasourceOrigins: async () => ({ origins: [], available: false })
};
bindings.isCurrentAuthority = () => bindings.authority.current === "authority-A" && bindings.authorityEpoch.current.revision === 0;
for (const key of ["loading", "error", "datasources", "stages", "origins", "originsAvailable", "detailRevision", "selectedDatasourceRef", "selectedStageRef", "selectedOriginRef", "preview", "materializations", "detailLoading", "detailResponseScope", "catalogueAuthority", "working", "success", "view", "addOpen", "freezeOpen", "freezeLabel", "retireOpen", "promoteOpen", "governanceOpen", "decisionOpen", "retentionOpen"]) {
bindings[`set${key[0].toUpperCase()}${key.slice(1)}`] = (value) => { state[key] = typeof value === "function" ? value(state[key]) : value; };
}
return {
state, bindings,
reload: (preferredDatasourceRef) => evaluate(`return (async () => {${reloadBody}})();`, { ...bindings, preferredDatasourceRef }),
startDetail: () => {
const preview = deferred();
const history = deferred();
const scope = {};
bindings.currentDetailScope.current = scope;
const cleanup = evaluate(`if (!selectedDatasourceRef || loading || !catalogueReady)${detailBody}`, {
...bindings, selectedDatasourceRef: state.selectedDatasourceRef, loading: state.loading, detailScope: scope,
catalogueReady: state.catalogueAuthority === bindings.authorityEpoch.current.revision,
previewDatasource: () => preview.promise, listDatasourceMaterializations: () => history.promise
});
return { preview, history, scope, cleanup };
}
};
}
test("same-reference reload invalidates preview/history once, including freeze with an unchanged current materialization", async () => {
const h = harness();
for (const operation of ["manual reload", "refresh", "freeze", "promotion"]) {
const previous = h.state.detailRevision;
await h.reload("source-1");
assert.equal(h.state.selectedDatasourceRef, "source-1", operation);
assert.equal(h.state.detailRevision, previous + 1, operation);
const detail = h.startDetail();
detail.preview.resolve({ rows: [{ value: operation }] });
detail.history.resolve([{ revision: h.state.detailRevision }]);
await settle();
assert.equal(h.state.preview.rows[0].value, operation);
assert.equal(h.state.materializations[0].revision, h.state.detailRevision);
assert.equal(h.state.detailResponseScope, detail.scope);
detail.cleanup();
}
});
test("a changed scope immediately suppresses old preview, history and errors, even before effect cleanup", async () => {
const h = harness();
const old = h.startDetail();
const current = h.startDetail();
current.preview.resolve({ rows: [{ value: "current" }] });
current.history.resolve([{ revision: 2 }]);
await settle();
old.preview.reject(new Error("stale error"));
old.history.resolve([{ revision: 1 }]);
await settle();
assert.equal(h.state.preview.rows[0].value, "current");
assert.equal(h.state.materializations[0].revision, 2);
assert.equal(h.state.error, undefined);
current.cleanup();
});
test("unmount cancellation and partial detail failures preserve allSettled behavior", async () => {
const h = harness();
const cancelled = h.startDetail();
cancelled.cleanup();
cancelled.preview.resolve({ rows: ["obsolete"] });
cancelled.history.resolve([]);
await settle();
assert.equal(h.state.preview, null);
const current = h.startDetail();
current.preview.reject(new Error("current preview denied"));
current.history.resolve([{ revision: 3 }]);
await settle();
assert.equal(h.state.preview, null);
assert.equal(h.state.materializations[0].revision, 3);
assert.match(h.state.error, /current preview denied/);
assert.equal(h.state.detailLoading, false);
});
test("initial/loading catalogue does not issue duplicate detail reads; scoped rendering cannot flash old rows", () => {
const h = harness();
h.state.loading = true;
const detail = h.startDetail();
assert.equal(detail.cleanup, undefined);
assert.equal(h.state.preview, null);
assert.match(page, /useMemo\(\(\) => \(\{\}\), \[selectedDatasourceRef, dialogScope, detailRevision\]\)/);
assert.match(page, /preview=\{!loading && detailResponseScope === detailScope \? preview : null\}/);
assert.match(page, /materializations=\{!loading && detailResponseScope === detailScope \? materializations : \[\]\}/);
});
test("older catalogue reloads and revoked authority cannot invalidate or replace the current catalogue", async () => {
const h = harness();
const older = deferred();
h.bindings.listDatasources = () => older.promise;
const pending = h.reload();
h.bindings.listDatasources = async () => [{ ref: "source-1", revision: 2 }];
await h.reload();
older.resolve([{ ref: "source-1", revision: 1 }]);
await pending;
assert.equal(h.state.datasources[0].revision, 2);
assert.equal(h.state.detailRevision, 1);
const revoked = deferred();
h.bindings.listDatasources = () => revoked.promise;
const revokedPending = h.reload();
h.bindings.authority.current = "authority-B";
revoked.resolve([{ ref: "source-1", revision: 3 }]);
await revokedPending;
assert.equal(h.state.datasources[0].revision, 2);
});
test("an old reload invoked only after B is active cannot issue requests or invalidate B state", async () => {
const h = harness();
const oldReload = h.reload;
h.bindings.authority.current = "authority-B";
h.bindings.authorityEpoch.current.revision = 1;
h.state.catalogueAuthority = 1;
h.state.datasources = [{ ref: "B-source" }];
h.state.detailRevision = 8;
let calls = 0;
h.bindings.listDatasources = async () => { calls += 1; return [{ ref: "A-source" }]; };
const requestId = h.bindings.reloadRequestId.current;
await oldReload();
assert.equal(calls, 0);
assert.equal(h.bindings.reloadRequestId.current, requestId);
assert.equal(h.state.datasources[0].ref, "B-source");
assert.equal(h.state.detailRevision, 8);
});
test("the first authority-transition render cannot request details from a stale catalogue", () => {
const h = harness();
h.bindings.authority.current = "authority-B";
h.bindings.authorityEpoch.current.revision = 1;
h.state.loading = false; // The new reload's effect has not committed loading=true yet.
const detail = h.startDetail();
assert.equal(detail.cleanup, undefined);
assert.equal(h.state.detailLoading, false);
assert.match(page, /const datasources = catalogueReady \? loadedDatasources : \[\]/);
assert.match(page, /const stages = catalogueReady \? loadedStages : \[\]/);
});
test("real-authority setup closes confirmations and clears freeze input; unmount blocks old callbacks", () => {
const h = harness();
const body = extract(" useEffect(() => {\n authority.current = authorityKey;", " }, [reload]);");
const flags = ["addOpen", "freezeOpen", "retireOpen", "promoteOpen", "governanceOpen", "decisionOpen", "retentionOpen"];
for (const flag of flags) {
h.state[flag] = true;
// Hide the old intent already in the first transition render, before the
// passive effect clears it; it cannot be clicked against a new selection.
assert.ok(page.includes(`open={${flag} && catalogueReady}`), flag);
}
h.state.freezeLabel = "Evidence from prior authority";
let reloads = 0;
const cleanup = evaluate(`authority.current = authorityKey;${body}`, {
...h.bindings, reload: () => { reloads += 1; }
});
assert.equal(reloads, 1);
for (const flag of flags) assert.equal(h.state[flag], false, flag);
assert.equal(h.state.freezeLabel, "");
assert.equal(h.bindings.isCurrentAuthority(), true);
cleanup();
assert.equal(h.bindings.isCurrentAuthority(), false);
// The reload callback/effect depend on the stable numeric authority epoch,
// not cosmetic settings/auth object identities.
assert.match(page, /\}, \[dialogScope\]\);\s*useEffect/);
});
test("stale mutation success/failure/finally cannot reload, select or overwrite B messages", async () => {
for (const operation of ["promoteStage", "refreshSelected", "freezeSelected", "retireSelected"]) {
for (const outcome of ["resolve", "reject"]) {
const h = harness();
const pending = deferred();
let reloads = 0;
const signature = operation === "freezeSelected" ? "async (): Promise<boolean>" : "async ()";
const body = extract(` const ${operation} = ${signature} => {`, "\n };");
const result = evaluate(`return (async () => {${body}})();`, {
...h.bindings,
selectedDatasource: { ref: "A-source", governance: { approval_policy: { required: false } } },
selectedStage: { ref: "A-stage", state: "ready" }, freezeLabel: "Freeze A",
reload: async () => { reloads += 1; },
promoteDatasourceStage: () => pending.promise, refreshDatasource: () => pending.promise,
freezeDatasource: () => pending.promise, retireDatasource: () => pending.promise
});
h.bindings.authority.current = "authority-B";
Object.assign(h.state, { success: "B success", error: "B error", view: "origins", working: true });
if (outcome === "reject") pending.reject(new Error("A error"));
else pending.resolve({ datasource: { ref: "A-source", name: "A" }, materialization: { revision: 1 }, revision: 1 });
await result;
assert.equal(reloads, 0, operation);
assert.equal(h.state.success, "B success", operation);
assert.equal(h.state.error, "B error", operation);
assert.equal(h.state.view, "origins", operation);
assert.equal(h.state.working, true, operation);
}
}
});
test("CSV stage dialog sends its explicit mode and exact text; JSON rows remain independent", async () => {
const dialog = page.slice(page.indexOf("function AddDatasourceDialog("));
const start = dialog.indexOf(" const create = async (): Promise<boolean> => {");
const end = dialog.indexOf("\n };", start);
assert.ok(start >= 0 && end > start);
const body = dialog.slice(start + " const create = async (): Promise<boolean> => {".length, end);
const csvText = 'code;value\r\n001;" text "\r\n';
for (const format of ["csv", "json"]) {
for (const csvValueMode of ["text", "legacy_typed"]) {
let payload;
const result = await evaluate(`return (async () => {${body}})();`, {
kind: "upload", settings: {}, format, csvValueMode, csvText, delimiter: ";",
mode: "static", name: "Fixture", sourceName: "fixture", description: "", targetRef: "",
rowsText: '[{"code":"001","value":" text "}]', parseRows: JSON.parse,
createDatasourceStage: async (_settings, value) => { payload = value; return {}; },
onCreated: async () => {}, setBusy: () => {}, setError: () => {}, apiErrorMessage: String
});
assert.equal(result, true);
if (format === "csv") {
assert.equal(payload.csv_value_mode, csvValueMode);
assert.equal(payload.csv_text, csvText);
} else {
assert.equal("csv_value_mode" in payload, false);
assert.deepEqual(payload.rows, [{ code: "001", value: " text " }]);
}
}
}
assert.match(dialog, /\[csvValueMode, setCsvValueMode\] = useState<"text" \| "legacy_typed">\("text"\)/);
assert.match(dialog, /setCsvValueMode\("text"\)/);
assert.match(dialog, /csvValueMode: "text"/);
});
+1 -1
View File
@@ -324,7 +324,7 @@ export function createDatasourceStage(
governance?: DatasourceGovernance | null;
} & (
{ format: "json"; rows: Record<string, unknown>[] }
| { format: "csv"; csv_text: string; delimiter: string }
| { format: "csv"; csv_text: string; delimiter: string; csv_value_mode?: "text" | "legacy_typed" }
)
): Promise<DatasourceStage> {
return apiFetch(settings, "/api/v1/datasources/stages", {
@@ -2,6 +2,7 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from "react";
import {
@@ -43,6 +44,7 @@ import { FormGrid, DialogSection, ActionToolbar,
WorkspaceFrame,
WorkspaceLayout,
hasScope,
authAuthorityKey,
isApiError,
useUnsavedChanges,
useUnsavedDraftGuard,
@@ -96,10 +98,10 @@ export default function DatasourcesPage({
auth: AuthInfo;
}) {
const [view, setView] = useState<CatalogueView>("catalogue");
const [datasources, setDatasources] = useState<Datasource[]>([]);
const [stages, setStages] = useState<DatasourceStage[]>([]);
const [origins, setOrigins] = useState<DatasourceOrigin[]>([]);
const [originsAvailable, setOriginsAvailable] = useState(false);
const [loadedDatasources, setDatasources] = useState<Datasource[]>([]);
const [loadedStages, setStages] = useState<DatasourceStage[]>([]);
const [loadedOrigins, setOrigins] = useState<DatasourceOrigin[]>([]);
const [loadedOriginsAvailable, setOriginsAvailable] = useState(false);
const [selectedDatasourceRef, setSelectedDatasourceRef] = useState(
initialDatasourceRef
);
@@ -110,6 +112,28 @@ export default function DatasourcesPage({
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [detailRevision, setDetailRevision] = useState(0);
const authorityKey = authAuthorityKey(auth, settings);
const authority = useRef(authorityKey);
authority.current = authorityKey;
const authorityEpoch = useRef({ key: authorityKey, revision: 0 });
if (authorityEpoch.current.key !== authorityKey) {
authorityEpoch.current = { key: authorityKey, revision: authorityEpoch.current.revision + 1 };
}
const dialogScope = authorityEpoch.current.revision;
const isCurrentAuthority = () => authority.current === authorityKey
&& authorityEpoch.current.revision === dialogScope;
const [catalogueAuthority, setCatalogueAuthority] = useState<number | null>(null);
const catalogueReady = catalogueAuthority === dialogScope;
const datasources = catalogueReady ? loadedDatasources : [];
const stages = catalogueReady ? loadedStages : [];
const origins = catalogueReady ? loadedOrigins : [];
const originsAvailable = catalogueReady && loadedOriginsAvailable;
const detailScope = useMemo(() => ({}), [selectedDatasourceRef, dialogScope, detailRevision]);
const currentDetailScope = useRef(detailScope);
currentDetailScope.current = detailScope;
const [detailResponseScope, setDetailResponseScope] = useState<object | null>(null);
const reloadRequestId = useRef(0);
const [working, setWorking] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
@@ -132,6 +156,12 @@ export default function DatasourcesPage({
const canAdmin = hasScope(auth, "datasources:source:admin");
const reload = useCallback(async (preferredDatasourceRef?: string) => {
// An old mutation callback can invoke this closure AFTER authority changed.
// Compare the closure's scope before any request, state write or generation bump.
if (!isCurrentAuthority()) return;
const requestId = ++reloadRequestId.current;
const isCurrent = () => requestId === reloadRequestId.current
&& isCurrentAuthority();
setLoading(true);
setError("");
try {
@@ -140,10 +170,15 @@ export default function DatasourcesPage({
listDatasourceStages(settings),
listDatasourceOrigins(settings)
]);
if (!isCurrent()) return;
setCatalogueAuthority(dialogScope);
setDatasources(nextDatasources);
setStages(nextStages);
setOrigins(originCatalogue.origins);
setOriginsAvailable(originCatalogue.available);
// Reload also invalidates history/preview when the selected reference did
// not change (refresh, freeze, promotion, governance and manual reload).
setDetailRevision((current) => current + 1);
setSelectedDatasourceRef((current) => {
const preferred = preferredDatasourceRef || current;
return nextDatasources.some((item) => item.ref === preferred)
@@ -157,20 +192,38 @@ export default function DatasourcesPage({
? current
: originCatalogue.origins[0]?.ref ?? "");
} catch (loadError) {
setError(apiErrorMessage(loadError));
if (isCurrent()) setError(apiErrorMessage(loadError));
} finally {
setLoading(false);
if (isCurrent()) setLoading(false);
}
}, [settings]);
}, [dialogScope]);
useEffect(() => {
authority.current = authorityKey;
setWorking(false);
setSuccess("");
// Confirmations and their input must not follow a selection into a new
// authority context. Equivalent session refreshes keep this effect stable.
setAddOpen(false);
setFreezeOpen(false);
setFreezeLabel("");
setRetireOpen(false);
setPromoteOpen(false);
setGovernanceOpen(false);
setDecisionOpen(false);
setRetentionOpen(false);
void reload();
return () => {
reloadRequestId.current += 1;
if (authority.current === authorityKey) authority.current = "";
};
}, [reload]);
useEffect(() => {
if (!selectedDatasourceRef) {
if (!selectedDatasourceRef || loading || !catalogueReady) {
setPreview(null);
setMaterializations([]);
setDetailLoading(false);
return;
}
let cancelled = false;
@@ -179,7 +232,8 @@ export default function DatasourcesPage({
previewDatasource(settings, selectedDatasourceRef),
listDatasourceMaterializations(settings, selectedDatasourceRef)
]).then(([previewResult, materializationResult]) => {
if (cancelled) return;
if (cancelled || currentDetailScope.current !== detailScope) return;
setDetailResponseScope(detailScope);
if (previewResult.status === "fulfilled") {
setPreview(previewResult.value);
} else {
@@ -196,7 +250,7 @@ export default function DatasourcesPage({
return () => {
cancelled = true;
};
}, [selectedDatasourceRef, settings]);
}, [selectedDatasourceRef, detailScope, loading, catalogueReady]);
const selectedDatasource = datasources.find((item) => item.ref === selectedDatasourceRef) ?? null;
const selectedStage = stages.find((item) => item.ref === selectedStageRef) ?? null;
@@ -219,46 +273,50 @@ export default function DatasourcesPage({
);
const promoteStage = async () => {
if (!selectedStage || selectedStage.state !== "ready") return;
if (!isCurrentAuthority() || !selectedStage || selectedStage.state !== "ready") return;
setWorking(true);
setError("");
try {
const result = await promoteDatasourceStage(settings, selectedStage.ref);
if (!isCurrentAuthority()) return;
setSuccess(`Promoted ${result.datasource.name} as revision ${result.materialization.revision}.`);
setView("catalogue");
await reload(result.datasource.ref);
} catch (operationError) {
setError(apiErrorMessage(operationError));
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
} finally {
setWorking(false);
if (isCurrentAuthority()) setWorking(false);
}
};
const refreshSelected = async () => {
if (!selectedDatasource) return;
if (!isCurrentAuthority() || !selectedDatasource) return;
setWorking(true);
setError("");
try {
if (selectedDatasource.governance.approval_policy.required === true) {
const stage = await prepareDatasourceRefresh(settings, selectedDatasource.ref);
if (!isCurrentAuthority()) return;
await reload(selectedDatasource.ref);
if (!isCurrentAuthority()) return;
setSelectedStageRef(stage.ref);
setView("staging");
setSuccess(`Prepared refresh stage ${stage.name}. It must be approved before promotion.`);
return;
}
const result = await refreshDatasource(settings, selectedDatasource.ref);
if (!isCurrentAuthority()) return;
setSuccess(`Refreshed ${result.datasource.name} as revision ${result.materialization.revision}.`);
await reload(result.datasource.ref);
} catch (operationError) {
setError(apiErrorMessage(operationError));
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
} finally {
setWorking(false);
if (isCurrentAuthority()) setWorking(false);
}
};
const freezeSelected = async (): Promise<boolean> => {
if (!selectedDatasource) return false;
if (!isCurrentAuthority() || !selectedDatasource) return false;
setWorking(true);
setError("");
try {
@@ -267,32 +325,34 @@ export default function DatasourcesPage({
selectedDatasource.ref,
freezeLabel
);
if (!isCurrentAuthority()) return false;
setSuccess(`Created frozen revision ${materialization.revision}.`);
setFreezeOpen(false);
setFreezeLabel("");
await reload(selectedDatasource.ref);
return true;
return isCurrentAuthority();
} catch (operationError) {
setError(apiErrorMessage(operationError));
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
return false;
} finally {
setWorking(false);
if (isCurrentAuthority()) setWorking(false);
}
};
const retireSelected = async () => {
if (!selectedDatasource) return;
if (!isCurrentAuthority() || !selectedDatasource) return;
setWorking(true);
setError("");
try {
await retireDatasource(settings, selectedDatasource.ref);
if (!isCurrentAuthority()) return;
setSuccess(`Retired ${selectedDatasource.name}.`);
setRetireOpen(false);
await reload();
} catch (operationError) {
setError(apiErrorMessage(operationError));
if (isCurrentAuthority()) setError(apiErrorMessage(operationError));
} finally {
setWorking(false);
if (isCurrentAuthority()) setWorking(false);
}
};
@@ -539,9 +599,9 @@ export default function DatasourcesPage({
selectedDatasource ? (
<DatasourceDetail
datasource={selectedDatasource}
preview={preview}
materializations={materializations}
loading={detailLoading}
preview={!loading && detailResponseScope === detailScope ? preview : null}
materializations={!loading && detailResponseScope === detailScope ? materializations : []}
loading={loading || detailLoading || detailResponseScope !== detailScope}
/>
) : <EmptyWorkspace icon={<DatabaseZap size={32} />} label="No datasource selected" />
) : null}
@@ -562,7 +622,8 @@ export default function DatasourcesPage({
</WorkspaceLayout>
<AddDatasourceDialog
open={addOpen}
key={`add:${dialogScope}`}
open={addOpen && catalogueReady}
settings={settings}
initialKind={view === "origins" ? "origin" : "upload"}
initialOrigin={view === "origins" ? selectedOrigin : null}
@@ -573,6 +634,7 @@ export default function DatasourcesPage({
canManage={canManage}
onClose={() => setAddOpen(false)}
onCreated={async (result) => {
if (!isCurrentAuthority()) return;
setAddOpen(false);
if ("state" in result) {
setView("staging");
@@ -587,40 +649,47 @@ export default function DatasourcesPage({
}}
/>
<GovernanceDialog
open={governanceOpen}
key={`governance:${dialogScope}`}
open={governanceOpen && catalogueReady}
settings={settings}
datasource={selectedDatasource}
onClose={() => setGovernanceOpen(false)}
onSaved={async (updated) => {
if (!isCurrentAuthority()) return;
setGovernanceOpen(false);
setSuccess(`Updated governance for ${updated.name}.`);
await reload(updated.ref);
}}
/>
<StageDecisionDialog
open={decisionOpen}
key={`decision:${dialogScope}`}
open={decisionOpen && catalogueReady}
settings={settings}
stage={selectedStage}
onClose={() => setDecisionOpen(false)}
onDecided={async (updated) => {
if (!isCurrentAuthority()) return;
setDecisionOpen(false);
await reload();
if (!isCurrentAuthority()) return;
setSelectedStageRef(updated.ref);
setSuccess(`Recorded ${updated.approval.state ?? "approval"} decision state for ${updated.name}.`);
}}
/>
<RetentionDialog
open={retentionOpen}
key={`retention:${dialogScope}`}
open={retentionOpen && catalogueReady}
settings={settings}
onClose={() => setRetentionOpen(false)}
onApplied={async (count) => {
if (!isCurrentAuthority()) return;
setRetentionOpen(false);
setSuccess(`Applied retention to ${count} eligible target${count === 1 ? "" : "s"}.`);
await reload(selectedDatasourceRef);
}}
/>
<Dialog
open={freezeOpen}
open={freezeOpen && catalogueReady}
title="Freeze datasource state"
onClose={closeFreeze}
footer={(
@@ -644,7 +713,7 @@ export default function DatasourcesPage({
</FormField>
</Dialog>
<ConfirmDialog
open={promoteOpen}
open={promoteOpen && catalogueReady}
title="i18n:govoplan-datasources.promote_title"
message="i18n:govoplan-datasources.promote_message"
confirmLabel="Promote"
@@ -656,7 +725,7 @@ export default function DatasourcesPage({
}}
/>
<ConfirmDialog
open={retireOpen}
open={retireOpen && catalogueReady}
title="Retire datasource"
message={`Retire ${selectedDatasource?.name ?? "this datasource"}? Existing materialization references remain in the database, but the datasource will no longer be available to new definitions.`}
confirmLabel="Retire"
@@ -686,7 +755,7 @@ function DatasourceDetail({
<MetricCard density="compact" label="Mode" value={datasource.mode} valueTitle={datasource.mode} />
<MetricCard density="compact" label="Rows" value={formatNumber(datasource.row_count)} />
<MetricCard density="compact" label="Fields" value={String(datasource.schema.length)} />
<MetricCard density="compact" label="Revisions" value={String(materializations.length)} />
<MetricCard density="compact" label="Revisions" value={loading ? "…" : String(materializations.length)} />
<MetricCard density="compact" label="Updated" value={formatDate(datasource.updated_at)} />
</MetricGrid>
{datasource.description ? (
@@ -764,7 +833,9 @@ function DatasourceDetail({
</td>
</tr>
))}
{!materializations.length ? (
{loading ? (
<tr><td colSpan={5}>Loading materializations...</td></tr>
) : !materializations.length ? (
<tr><td colSpan={5}>Live source without materializations</td></tr>
) : null}
</tbody>
@@ -1428,6 +1499,7 @@ function AddDatasourceDialog({
const [rowsText, setRowsText] = useState('[\n { "id": 1 }\n]');
const [csvText, setCsvText] = useState("");
const [delimiter, setDelimiter] = useState(";");
const [csvValueMode, setCsvValueMode] = useState<"text" | "legacy_typed">("text");
const [baselineKey, setBaselineKey] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -1446,6 +1518,7 @@ function AddDatasourceDialog({
setRowsText('[\n { "id": 1 }\n]');
setCsvText("");
setDelimiter(";");
setCsvValueMode("text");
setBaselineKey(addDatasourceDraftKey({
kind: initialKind,
format: "csv",
@@ -1457,7 +1530,8 @@ function AddDatasourceDialog({
description: initialOrigin?.description ?? "",
rowsText: '[\n { "id": 1 }\n]',
csvText: "",
delimiter: ";"
delimiter: ";",
csvValueMode: "text"
}));
setError("");
}, [initialKind, initialOrigin, open]);
@@ -1474,7 +1548,8 @@ function AddDatasourceDialog({
description,
rowsText,
csvText,
delimiter
delimiter,
csvValueMode
}) !== baselineKey);
const chooseOrigin = (ref: string) => {
@@ -1526,7 +1601,8 @@ function AddDatasourceDialog({
...common,
format: "csv",
csv_text: csvText,
delimiter
delimiter,
csv_value_mode: csvValueMode
})
: await createDatasourceStage(settings, {
...common,
@@ -1720,6 +1796,16 @@ function AddDatasourceDialog({
<option value="|">Pipe</option>
</select>
</FormField>
<FormField label="CSV values" documentation={DATASOURCE_FIELDS_DOCUMENTATION}>
<select
value={csvValueMode}
onChange={(event) => setCsvValueMode(event.target.value as "text" | "legacy_typed")}
disabled={busy}
>
<option value="text">Preserve text (no automatic conversion)</option>
<option value="legacy_typed">Infer types (legacy)</option>
</select>
</FormField>
<FormField label="CSV data">
<textarea
value={csvText}
@@ -1838,6 +1924,7 @@ function addDatasourceDraftKey(value: {
rowsText: string;
csvText: string;
delimiter: string;
csvValueMode: "text" | "legacy_typed";
}): string {
return JSON.stringify(value);
}
+8
View File
@@ -1,6 +1,10 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"CSV values": "CSV values",
"Preserve text (no automatic conversion)": "Preserve text (no automatic conversion)",
"Infer types (legacy)": "Infer types (legacy)",
"Loading materializations...": "Loading materializations...",
"i18n:govoplan-datasources.datasources": "Datasources",
"i18n:govoplan-datasources.catalogue": "Datasource catalogue",
"i18n:govoplan-datasources.staging": "Datasource staging",
@@ -78,6 +82,10 @@ const en = {
} as const;
const de: Record<keyof typeof en, string> = {
"CSV values": "CSV-Werte",
"Preserve text (no automatic conversion)": "Text erhalten (keine automatische Umwandlung)",
"Infer types (legacy)": "Typen ableiten (bisheriges Verhalten)",
"Loading materializations...": "Materialisierungen werden geladen...",
"i18n:govoplan-datasources.datasources": "Datenquellen",
"i18n:govoplan-datasources.catalogue": "Datenquellenkatalog",
"i18n:govoplan-datasources.staging": "Datenquellen-Staging",