fix(datasources): preserve governed CSV originals and fresh detail state
Module Package Release / publish-packages (push) Successful in 12s
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:
+4
-3
@@ -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",
|
||||
|
||||
Executable
+258
@@ -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"/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user