424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from "react";
|
|
import {
|
|
revisionConflictFromError,
|
|
threeWayMerge,
|
|
useConcurrencyConflictResolver
|
|
} from "@govoplan/core-webui";
|
|
import type { ApiSettings } from "../../../types";
|
|
import {
|
|
autosaveCampaignVersion,
|
|
getCampaignVersion,
|
|
type CampaignVersionDetail,
|
|
type CampaignVersionUpdatePayload
|
|
} 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);
|
|
|
|
type UseCampaignDraftEditorOptions = {
|
|
settings: ApiSettings;
|
|
campaignId: string;
|
|
version: CampaignVersionDetail | null;
|
|
locked: boolean;
|
|
reload: (options?: {force?: boolean;}) => Promise<void>;
|
|
setError: (message: string) => void;
|
|
currentStep: StepValue;
|
|
currentFlow?: string;
|
|
workflowState?: string;
|
|
isComplete?: boolean;
|
|
unsavedTitle: string;
|
|
unsavedMessage: string;
|
|
loadedLabel?: (version: CampaignVersionDetail) => string;
|
|
transformLoadedDraft?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => Record<string, unknown>;
|
|
transformDraftBeforeSave?: (draft: Record<string, unknown>) => Record<string, unknown>;
|
|
extraPayload?: () => Partial<CampaignVersionUpdatePayload>;
|
|
onLoaded?: (version: CampaignVersionDetail, draft: Record<string, unknown>) => void;
|
|
onSaved?: (version: CampaignVersionDetail) => void;
|
|
};
|
|
|
|
function resolveStep(value: StepValue): string | null | undefined {
|
|
return typeof value === "function" ? value() : value;
|
|
}
|
|
|
|
function defaultLoadedLabel(version: CampaignVersionDetail): string {
|
|
return version.autosaved_at ? `Loaded saved draft ${formatDateTime(version.autosaved_at)}` : "i18n:govoplan-campaign.loaded.6db90a0a";
|
|
}
|
|
|
|
const CAMPAIGN_PROTECTED_MERGE_PATHS = [
|
|
"/current_flow",
|
|
"/current_step",
|
|
"/workflow_state",
|
|
"/is_complete",
|
|
"/editor_state",
|
|
"/source_filename",
|
|
"/source_base_path",
|
|
"/migrate_legacy_mail_settings",
|
|
"/campaign_json/delivery",
|
|
"/campaign_json/server",
|
|
"/campaign_json/security",
|
|
"/campaign_json/signatures",
|
|
"/campaign_json/encryption",
|
|
"/campaign_json/_retention",
|
|
"/campaign_json/campaign/owner_user_id",
|
|
"/campaign_json/campaign/owner_group_id",
|
|
"/campaign_json/campaign/status"
|
|
] as const;
|
|
|
|
export function useCampaignDraftEditor({
|
|
settings,
|
|
campaignId,
|
|
version,
|
|
locked,
|
|
reload,
|
|
setError,
|
|
currentStep,
|
|
currentFlow = "manual",
|
|
workflowState = "editing",
|
|
isComplete = false,
|
|
unsavedTitle,
|
|
unsavedMessage,
|
|
loadedLabel = defaultLoadedLabel,
|
|
transformLoadedDraft,
|
|
transformDraftBeforeSave,
|
|
extraPayload,
|
|
onLoaded,
|
|
onSaved
|
|
}: UseCampaignDraftEditorOptions) {
|
|
const loadedLabelRef = useRef(loadedLabel);
|
|
const transformLoadedDraftRef = useRef(transformLoadedDraft);
|
|
const transformDraftBeforeSaveRef = useRef(transformDraftBeforeSave);
|
|
const onLoadedRef = useRef(onLoaded);
|
|
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(() => {
|
|
loadedLabelRef.current = loadedLabel;
|
|
transformLoadedDraftRef.current = transformLoadedDraft;
|
|
transformDraftBeforeSaveRef.current = transformDraftBeforeSave;
|
|
onLoadedRef.current = onLoaded;
|
|
}, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
|
|
|
|
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) {
|
|
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 = (
|
|
transformDraftBeforeSaveRef.current?.(loadedDraft) ?? loadedDraft
|
|
);
|
|
baseRevisionRef.current = version.edit_revision;
|
|
baseEtagRef.current = version.strong_etag;
|
|
loadedVersionIdRef.current = version.id;
|
|
loadedContextRef.current = contextKey;
|
|
draftRef.current = loadedDraft;
|
|
setDraftState(loadedDraft);
|
|
setDirty(false);
|
|
setLocalError("");
|
|
setSaveState(loadedLabelRef.current(version));
|
|
onLoadedRef.current?.(version, loadedDraft);
|
|
}, [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, setDraft]);
|
|
|
|
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?.(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,
|
|
{
|
|
current_flow: currentFlow,
|
|
current_step: resolveStep(currentStep),
|
|
workflow_state: workflowState,
|
|
is_complete: isComplete,
|
|
...additionalPayload
|
|
}
|
|
);
|
|
let mergeBase = campaignVersionMutation(
|
|
version,
|
|
baseDraftRef.current ?? draftToSave
|
|
);
|
|
let expectedRevision = baseRevisionRef.current ?? version.edit_revision;
|
|
let expectedEtag = baseEtagRef.current ?? version.strong_etag;
|
|
let reconciliationKind: "none" | "auto_merge" | "manual" = "none";
|
|
let resolvedConflictPaths: string[] = [];
|
|
let saved: CampaignVersionDetail | null = null;
|
|
|
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
try {
|
|
saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
|
|
...mutation,
|
|
base_revision: expectedRevision,
|
|
reconciliation_kind: reconciliationKind,
|
|
resolved_conflict_paths: resolvedConflictPaths
|
|
}, expectedEtag);
|
|
break;
|
|
} catch (error) {
|
|
const conflict = revisionConflictFromError(error);
|
|
if (!conflict) throw error;
|
|
|
|
const latest = await getCampaignVersion(
|
|
settings,
|
|
campaignId,
|
|
version.id
|
|
);
|
|
if (!stillCurrent()) return false;
|
|
const latestLoaded = transformLoadedDraftRef.current?.(
|
|
latest,
|
|
ensureCampaignDraft(latest)
|
|
) ?? ensureCampaignDraft(latest);
|
|
const latestDraft = (
|
|
transformDraftBeforeSaveRef.current?.(latestLoaded) ?? latestLoaded
|
|
);
|
|
const latestMutation = campaignVersionMutation(
|
|
latest,
|
|
latestDraft
|
|
);
|
|
const merge = threeWayMerge(
|
|
mergeBase,
|
|
mutation,
|
|
latestMutation,
|
|
{
|
|
protectedPaths: CAMPAIGN_PROTECTED_MERGE_PATHS,
|
|
stableIdFields: ["id", "entry_id"]
|
|
}
|
|
);
|
|
|
|
if (merge.conflicts.length === 0) {
|
|
mutation = merge.value;
|
|
reconciliationKind = "auto_merge";
|
|
resolvedConflictPaths = merge.appliedPaths;
|
|
} else {
|
|
const resolution = await resolveConcurrencyConflict({
|
|
resourceLabel: `Campaign version ${latest.version_number}`,
|
|
merge
|
|
});
|
|
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);
|
|
setSaveState(loadedLabelRef.current(latest));
|
|
onLoadedRef.current?.(latest, latestLoaded);
|
|
return false;
|
|
}
|
|
mutation = resolution.value;
|
|
reconciliationKind = "manual";
|
|
resolvedConflictPaths = resolution.resolvedPaths;
|
|
}
|
|
mergeBase = latestMutation;
|
|
expectedRevision = latest.edit_revision;
|
|
expectedEtag = latest.strong_etag;
|
|
}
|
|
}
|
|
if (!saved) {
|
|
throw new Error(
|
|
"The campaign changed repeatedly while it was being saved. Reload and try again."
|
|
);
|
|
}
|
|
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);
|
|
if (stillCurrent()) {
|
|
setLocalError(text);
|
|
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
|
|
}
|
|
return false;
|
|
}
|
|
})();
|
|
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 (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(latest));
|
|
onLoadedRef.current?.(latest, loadedDraft);
|
|
await reload({ force: true });
|
|
} catch (err) {
|
|
if (stillCurrent()) setLocalError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}, [campaignId, contextKey, reload, setDirty, settings, version]);
|
|
|
|
const unsavedRegistration = useMemo(() => dirty && !locked ? {
|
|
title: unsavedTitle,
|
|
message: unsavedMessage,
|
|
onSave: () => saveDraft("manual"),
|
|
onDiscard: () => { void discardDraft(); }
|
|
} : null, [dirty, discardDraft, locked, saveDraft, unsavedMessage, unsavedTitle]);
|
|
|
|
useRegisterCampaignUnsavedChanges(unsavedRegistration);
|
|
|
|
return {
|
|
draft,
|
|
setDraft,
|
|
displayDraft: draft ?? ensureCampaignDraft(null),
|
|
dirty,
|
|
saving,
|
|
setDirty,
|
|
saveState,
|
|
setSaveState,
|
|
localError,
|
|
setLocalError,
|
|
patch,
|
|
markDirty,
|
|
discardDraft,
|
|
saveDraft
|
|
};
|
|
}
|
|
|
|
function campaignVersionMutation(
|
|
version: CampaignVersionDetail,
|
|
campaignJson: Record<string, unknown>,
|
|
overrides: Partial<CampaignVersionUpdatePayload> = {}
|
|
): CampaignVersionUpdatePayload {
|
|
return {
|
|
campaign_json: campaignJson,
|
|
current_flow: overrides.current_flow ?? version.current_flow ?? null,
|
|
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: 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: (
|
|
overrides.migrate_legacy_mail_settings ?? false
|
|
)
|
|
};
|
|
}
|