fix(dataflow): preserve edits during saves and CSV staging evidence
Module Package Release / publish-packages (push) Successful in 15s

Release v0.1.25. Coordinated integrity review: GovOPlaN/govoplan-core#298.
This commit is contained in:
2026-09-08 12:19:38 +02:00
parent 4175262b8b
commit a6c5bab3a4
14 changed files with 507 additions and 34 deletions
+76 -11
View File
@@ -56,6 +56,7 @@ import { DialogSection, ActionToolbar,
WorkspaceFrame,
WorkspaceLayout,
hasScope,
authAuthorityKey,
isApiError,
useUnsavedChanges,
useUnsavedDraftGuard,
@@ -129,6 +130,8 @@ import {
DATAFLOW_RUN_DOCUMENTATION
} from "./interfacePatterns";
import { reconcilePipelineSave } from "./saveCompletion";
type ResultTab = "preview" | "diagnostics";
type SnapshotFormat = "json" | "csv";
@@ -143,7 +146,29 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
[location.search]
);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [draft, setDraft] = useState<PipelineDraft | null>(null);
const [draft, setDraftValue] = useState<PipelineDraft | null>(null);
const draftSession = useRef<{ generation: number; value: PipelineDraft | null }>({ generation: 0, value: null });
const saveInFlight = useRef(false);
const authorityKey = authAuthorityKey(auth, settings);
const saveAuthorityEpoch = useRef({ key: authorityKey, revision: 0 });
if (saveAuthorityEpoch.current.key !== authorityKey) {
saveAuthorityEpoch.current = { key: authorityKey, revision: saveAuthorityEpoch.current.revision + 1 };
}
const authorityGeneration = saveAuthorityEpoch.current.revision;
const saveContext = useRef(authorityKey);
saveContext.current = authorityKey;
const unresolvedSaveGeneration = useRef<number | null>(null);
useEffect(() => {
saveContext.current = authorityKey;
return () => { if (saveContext.current === authorityKey) saveContext.current = ""; };
}, [authorityKey]);
// Replacement (selection, reload, discard, derive) is a different edit session,
// even when both unsaved drafts have a null identifier.
const setDraft = useCallback((next: PipelineDraft | null) => {
draftSession.current = { generation: draftSession.current.generation + 1, value: next };
setDraftValue(next);
}, []);
useEffect(() => () => { draftSession.current.generation += 1; }, []);
const [savedDraft, setSavedDraft] = useState<PipelineDraft | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [search, setSearch] = useState("");
@@ -322,15 +347,27 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
}, [savedDraft]);
const saveDraft = useCallback(async (): Promise<boolean> => {
if (saveInFlight.current) return false;
if (authorityKey !== saveContext.current || authorityGeneration !== saveAuthorityEpoch.current.revision) return false;
if (unresolvedSaveGeneration.current === draftSession.current.generation) {
setError("A prior save completed after authorization changed. Reload and review the server revision before saving again.");
return false;
}
if (!draft || !canEdit || !draft.name.trim()) {
setError(!draft?.name.trim() ? "Pipeline name is required." : "You cannot save this pipeline.");
return false;
}
saveInFlight.current = true;
const generation = draftSession.current.generation;
const context = authorityKey;
const isCurrent = () => generation === draftSession.current.generation
&& context === saveContext.current && authorityGeneration === saveAuthorityEpoch.current.revision;
setSaving(true);
setError("");
setSuccess("");
try {
const payload = pipelinePayload(draft);
const submitted = structuredClone(draft);
const payload = pipelinePayload(submitted);
const saved = draft.id && draft.currentRevision
? await updateDataflowPipeline(settings, draft.id, {
...payload,
@@ -338,22 +375,32 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
})
: await createDataflowPipeline(settings, payload);
const next = draftFromPipeline(saved);
setDraft(next);
if (!isCurrent() || !draftSession.current.value) {
if (generation === draftSession.current.generation) unresolvedSaveGeneration.current = generation;
return false;
}
const reconciled = reconcilePipelineSave(submitted, draftSession.current.value, next);
draftSession.current.value = reconciled;
setDraftValue(reconciled);
setSavedDraft(structuredClone(next));
setPipelines((current) => [saved, ...current.filter((item) => item.id !== saved.id)]);
setSelectedNodeId((current) => current && next.graph.nodes.some((node) => node.id === current)
setSelectedNodeId((current) => current && reconciled.graph.nodes.some((node) => node.id === current)
? current
: next.graph.nodes[0]?.id ?? null);
: reconciled.graph.nodes[0]?.id ?? null);
setDiagnostics([]);
setSuccess(`Saved revision ${saved.current_revision}.`);
return true;
const fullySaved = draftFingerprint(reconciled) === draftFingerprint(next);
setSuccess(fullySaved ? `Saved revision ${saved.current_revision}.`
: "The submitted revision was saved. Newer edits remain unsaved.");
// A navigation guard may proceed only if ALL current edits were accepted.
return fullySaved;
} catch (saveError) {
setError(apiErrorMessage(saveError));
if (isCurrent()) setError(apiErrorMessage(saveError));
return false;
} finally {
saveInFlight.current = false;
setSaving(false);
}
}, [canEdit, draft, settings]);
}, [canEdit, draft, settings, authorityKey, authorityGeneration]);
useUnsavedDraftGuard({
dirty,
@@ -402,7 +449,12 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
};
const updateDraft = (patch: Partial<PipelineDraft>) => {
setDraft((current) => current ? { ...current, ...patch } : current);
const current = draftSession.current.value;
if (current) {
const next = { ...current, ...patch };
draftSession.current.value = next;
setDraftValue(next);
}
setSuccess("");
};
@@ -2518,6 +2570,7 @@ function SourceSnapshotDialog({
const [rowsText, setRowsText] = useState("[]");
const [csvText, setCsvText] = useState("");
const [delimiter, setDelimiter] = useState(",");
const [csvValueMode, setCsvValueMode] = useState<"text" | "legacy_typed">("text");
const [fileInputKey, setFileInputKey] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -2531,6 +2584,7 @@ function SourceSnapshotDialog({
|| rowsText !== "[]"
|| csvText !== ""
|| delimiter !== ","
|| csvValueMode !== "text"
)
);
@@ -2542,6 +2596,7 @@ function SourceSnapshotDialog({
setRowsText("[]");
setCsvText("");
setDelimiter(",");
setCsvValueMode("text");
setFileInputKey((current) => current + 1);
setError("");
};
@@ -2577,7 +2632,7 @@ function SourceSnapshotDialog({
description: description.trim() || null,
...(format === "json"
? { format, rows }
: { format, csv_text: csvText, delimiter })
: { format, csv_text: csvText, delimiter, csv_value_mode: csvValueMode })
});
onCreated(source);
return true;
@@ -2682,6 +2737,16 @@ function SourceSnapshotDialog({
<option value="|">Pipe</option>
</select>
</FormField>
<FormField label="CSV values" documentation={DATAFLOW_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" documentation={DATAFLOW_FIELDS_DOCUMENTATION}>
<textarea
className="dataflow-json-editor"
+18
View File
@@ -0,0 +1,18 @@
import type { PipelineDraft } from "./model";
/** Reconcile one accepted save, never structurally merge/reorder graph data.
* Fields edited since submission stay local; identity, revision and authority
* always come from the accepted server response.
*/
export function reconcilePipelineSave(
submitted: PipelineDraft, current: PipelineDraft, accepted: PipelineDraft
): PipelineDraft {
const result = { ...current, ...accepted };
for (const key of Object.keys(submitted) as Array<keyof PipelineDraft>) {
if (key === "id" || key === "currentRevision" || key === "governance") continue;
if (JSON.stringify(current[key]) !== JSON.stringify(submitted[key])) {
Object.assign(result, { [key]: current[key] });
}
}
return result;
}