fix(dataflow): preserve edits during saves and CSV staging evidence
Module Package Release / publish-packages (push) Successful in 15s
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:
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user