Release govoplan-campaign v0.1.28: stabilize saving, review and delivery recovery
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
@@ -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: (
|
||||
|
||||
Reference in New Issue
Block a user