Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:26 +02:00
parent 1b32427813
commit c51fc180fb
111 changed files with 6905 additions and 980 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react";
import {
revisionConflictFromError,
threeWayMerge,
@@ -13,6 +13,8 @@ import {
} from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { clientCampaignEditorState } from "../utils/editorState";
import { campaignMailReferencesUnchanged } from "../utils/mailProfileReference";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
type StepValue = string | (() => string | null | undefined);
@@ -93,6 +95,16 @@ export function useCampaignDraftEditor({
const baseDraftRef = useRef<Record<string, unknown> | null>(null);
const baseRevisionRef = useRef<number | null>(null);
const baseEtagRef = useRef<string | null>(null);
const loadedVersionIdRef = useRef<string | null>(null);
const contextKey = JSON.stringify([campaignId, settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
const contextRef = useRef(contextKey);
const loadedContextRef = useRef(contextKey);
contextRef.current = contextKey;
const draftRef = useRef<Record<string, unknown> | null>(null);
const dirtyRef = useRef(false);
const editSequenceRef = useRef(0);
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
const discardGenerationRef = useRef(0);
const resolveConcurrencyConflict = useConcurrencyConflictResolver();
useEffect(() => {
@@ -102,13 +114,37 @@ export function useCampaignDraftEditor({
onLoadedRef.current = onLoaded;
}, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [draft, setDraftState] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirtyState] = useState(false);
const [saving, setSaving] = useState(false);
const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
const [localError, setLocalError] = useState("");
const setDraft = useCallback((update: SetStateAction<Record<string, unknown> | null>) => {
const next = typeof update === "function" ? update(draftRef.current) : update;
if (next === draftRef.current) return;
draftRef.current = next;
editSequenceRef.current += 1;
setDraftState(next);
}, []);
const setDirty = useCallback((update: SetStateAction<boolean>) => {
dirtyRef.current = typeof update === "function" ? update(dirtyRef.current) : update;
setDirtyState(dirtyRef.current);
}, []);
useEffect(() => {
if (!version) return;
if (!version) {
if (loadedContextRef.current !== contextKey) {
draftRef.current = null;
setDraftState(null);
setDirty(false);
loadedVersionIdRef.current = null;
}
return;
}
if (loadedContextRef.current === contextKey && loadedVersionIdRef.current === version.id && (
dirtyRef.current || saveInFlightRef.current || version.edit_revision < (baseRevisionRef.current ?? 0)
)) return;
const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
baseDraftRef.current = (
@@ -116,32 +152,51 @@ export function useCampaignDraftEditor({
);
baseRevisionRef.current = version.edit_revision;
baseEtagRef.current = version.strong_etag;
setDraft(loadedDraft);
loadedVersionIdRef.current = version.id;
loadedContextRef.current = contextKey;
draftRef.current = loadedDraft;
setDraftState(loadedDraft);
setDirty(false);
setLocalError("");
setSaveState(loadedLabelRef.current(version));
onLoadedRef.current?.(version, loadedDraft);
}, [version]);
}, [contextKey, version, setDirty]);
const markDirty = useCallback(() => {
editSequenceRef.current += 1;
setDirty(true);
setLocalError("");
}, []);
}, [setDirty]);
const patch = useCallback((path: string[], value: unknown) => {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
markDirty();
}, [locked, markDirty]);
}, [locked, markDirty, setDraft]);
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
if (!draft || !version || locked) return false;
const saveDraft = useCallback((_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
if (saveInFlightRef.current) return saveInFlightRef.current;
const submittedDraft = draftRef.current;
if (!submittedDraft || !version || locked) return Promise.resolve(false);
discardGenerationRef.current += 1;
const submittedSequence = editSequenceRef.current;
const submittedVersionId = version.id;
const stillCurrent = () => loadedVersionIdRef.current === submittedVersionId && contextRef.current === contextKey;
setSaving(true);
setSaveState("i18n:govoplan-campaign.saving.56a2285c");
setError("");
setLocalError("");
const operation = (async () => {
try {
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
const draftToSave = transformDraftBeforeSaveRef.current?.(submittedDraft) ?? submittedDraft;
const additionalPayload = extraPayload?.() ?? {};
if (version.mail_profile_migration_required && !additionalPayload.migrate_legacy_mail_settings
&& !campaignMailReferencesUnchanged(baseDraftRef.current ?? getCampaignJson(version), draftToSave)) {
// Unchanged Mail references allow independent content/archive repairs;
// the server retains legacy transport without migrating it. Changing
// the reference still needs the explicit audited Mail settings action.
throw new Error("i18n:govoplan-campaign.legacy_mail_migration_required");
}
let mutation = campaignVersionMutation(
version,
draftToSave,
@@ -181,6 +236,7 @@ export function useCampaignDraftEditor({
campaignId,
version.id
);
if (!stillCurrent()) return false;
const latestLoaded = transformLoadedDraftRef.current?.(
latest,
ensureCampaignDraft(latest)
@@ -211,10 +267,20 @@ export function useCampaignDraftEditor({
resourceLabel: `Campaign version ${latest.version_number}`,
merge
});
if (resolution.action === "cancel") return false;
if (!stillCurrent()) return false;
if (resolution.action === "cancel") {
setSaveState("i18n:govoplan-campaign.save_cancelled");
return false;
}
if (resolution.action === "reload") {
draftRef.current = latestLoaded;
setDraftState(latestLoaded);
baseDraftRef.current = latestDraft;
baseRevisionRef.current = latest.edit_revision;
baseEtagRef.current = latest.strong_etag;
setDirty(false);
await reload({ force: true });
setSaveState(loadedLabelRef.current(latest));
onLoadedRef.current?.(latest, latestLoaded);
return false;
}
mutation = resolution.value;
@@ -231,35 +297,83 @@ export function useCampaignDraftEditor({
"The campaign changed repeatedly while it was being saved. Reload and try again."
);
}
setDraft(getCampaignJson(saved));
baseDraftRef.current = getCampaignJson(saved);
baseRevisionRef.current = saved.edit_revision;
baseEtagRef.current = saved.strong_etag;
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
onSaved?.(saved);
await reload();
return true;
if (!stillCurrent()) return false;
const savedInitial = ensureCampaignDraft(saved);
const savedDraft = transformLoadedDraftRef.current?.(saved, savedInitial) ?? savedInitial;
const newerEdits = editSequenceRef.current !== submittedSequence;
if (!newerEdits) {
draftRef.current = savedDraft;
setDraftState(savedDraft);
baseDraftRef.current = transformDraftBeforeSaveRef.current?.(savedDraft) ?? savedDraft;
baseRevisionRef.current = saved.edit_revision;
baseEtagRef.current = saved.strong_etag;
setDirty(false);
}
// A late local edit keeps its original merge base and revision. The next
// explicit save reconciles against the acknowledged server revision,
// rather than silently overwriting concurrent changes merged into it.
if (newerEdits) setDirty(true);
setSaveState(newerEdits ? "i18n:govoplan-campaign.saved_newer_changes_pending"
: `Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
try {
if (!newerEdits) {
onSaved?.(saved);
await reload();
}
} catch {
// The POST acknowledgement is authoritative. A failed refresh or
// observer must not report a committed mutation as a failed save.
setError("i18n:govoplan-campaign.saved_refresh_failed");
}
return !newerEdits && !dirtyRef.current;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
if (stillCurrent()) {
setLocalError(text);
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
}
return false;
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setError, settings, version, workflowState]);
})();
const pending = operation.finally(() => {
saveInFlightRef.current = null;
setSaving(false);
});
saveInFlightRef.current = pending;
return pending;
}, [campaignId, contextKey, currentFlow, currentStep, extraPayload, isComplete, locked, onSaved, reload, resolveConcurrencyConflict, setDirty, setError, settings, version, workflowState]);
const discardDraft = useCallback(async () => {
if (version) {
const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
setDraft(loadedDraft);
if (saveInFlightRef.current || !version) return;
const generation = ++discardGenerationRef.current;
const sequence = editSequenceRef.current;
const stillCurrent = () => generation === discardGenerationRef.current
&& loadedVersionIdRef.current === version.id && contextRef.current === contextKey;
try {
// Do not discard local work until a fresh version was actually read.
const latest = await getCampaignVersion(settings, campaignId, version.id);
if (!stillCurrent()) return;
if (sequence !== editSequenceRef.current || saveInFlightRef.current
|| latest.edit_revision < (baseRevisionRef.current ?? 0)) {
setLocalError("i18n:govoplan-campaign.discard_superseded");
return;
}
const initialDraft = ensureCampaignDraft(latest);
const loadedDraft = transformLoadedDraftRef.current?.(latest, initialDraft) ?? initialDraft;
draftRef.current = loadedDraft;
setDraftState(loadedDraft);
baseDraftRef.current = transformDraftBeforeSaveRef.current?.(loadedDraft) ?? loadedDraft;
baseRevisionRef.current = latest.edit_revision;
baseEtagRef.current = latest.strong_etag;
setDirty(false);
setLocalError("");
setSaveState(loadedLabelRef.current(version));
onLoadedRef.current?.(version, loadedDraft);
setSaveState(loadedLabelRef.current(latest));
onLoadedRef.current?.(latest, loadedDraft);
await reload({ force: true });
} catch (err) {
if (stillCurrent()) setLocalError(err instanceof Error ? err.message : String(err));
}
await reload({ force: true });
}, [reload, version]);
}, [campaignId, contextKey, reload, setDirty, settings, version]);
const unsavedRegistration = useMemo(() => dirty && !locked ? {
title: unsavedTitle,
@@ -275,6 +389,7 @@ export function useCampaignDraftEditor({
setDraft,
displayDraft: draft ?? ensureCampaignDraft(null),
dirty,
saving,
setDirty,
saveState,
setSaveState,
@@ -298,7 +413,7 @@ function campaignVersionMutation(
current_step: overrides.current_step ?? version.current_step ?? null,
workflow_state: overrides.workflow_state ?? version.workflow_state ?? null,
is_complete: overrides.is_complete ?? version.is_complete ?? false,
editor_state: overrides.editor_state ?? version.editor_state ?? {},
editor_state: clientCampaignEditorState(overrides.editor_state ?? version.editor_state),
source_filename: overrides.source_filename ?? version.source_filename ?? null,
source_base_path: overrides.source_base_path ?? version.source_base_path ?? null,
migrate_legacy_mail_settings: (