Files
govoplan-campaign/webui/src/features/campaigns/hooks/useCampaignDraftEditor.ts

150 lines
5.3 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ApiSettings } from "../../../types";
import { autosaveCampaignVersion, type CampaignVersionDetail, type CampaignVersionUpdatePayload } from "../../../api/campaigns";
import { formatDateTime, getCampaignJson } from "../utils/campaignView";
import { ensureCampaignDraft, updateNested } from "../utils/draftEditor";
import { useRegisterCampaignUnsavedChanges } from "../context/UnsavedChangesContext";
type StepValue = string | (() => string | null | undefined);
type UseCampaignDraftEditorOptions = {
settings: ApiSettings;
campaignId: string;
version: CampaignVersionDetail | null;
locked: boolean;
reload: () => 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";
}
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);
useEffect(() => {
loadedLabelRef.current = loadedLabel;
transformLoadedDraftRef.current = transformLoadedDraft;
transformDraftBeforeSaveRef.current = transformDraftBeforeSave;
onLoadedRef.current = onLoaded;
}, [loadedLabel, transformLoadedDraft, transformDraftBeforeSave, onLoaded]);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const [dirty, setDirty] = useState(false);
const [saveState, setSaveState] = useState("i18n:govoplan-campaign.loaded.6db90a0a");
const [localError, setLocalError] = useState("");
useEffect(() => {
if (!version) return;
const initialDraft = ensureCampaignDraft(version);
const loadedDraft = transformLoadedDraftRef.current?.(version, initialDraft) ?? initialDraft;
setDraft(loadedDraft);
setDirty(false);
setLocalError("");
setSaveState(loadedLabelRef.current(version));
onLoadedRef.current?.(version, loadedDraft);
}, [version]);
const markDirty = useCallback(() => {
setDirty(true);
setLocalError("");
}, []);
const patch = useCallback((path: string[], value: unknown) => {
if (locked) return;
setDraft((current) => updateNested(current ?? {}, path, value));
markDirty();
}, [locked, markDirty]);
const saveDraft = useCallback(async (_mode: "auto" | "manual" = "manual"): Promise<boolean> => {
if (!draft || !version || locked) return false;
setSaveState("i18n:govoplan-campaign.saving.56a2285c");
setError("");
setLocalError("");
try {
const draftToSave = transformDraftBeforeSaveRef.current?.(draft) ?? draft;
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
campaign_json: draftToSave,
current_flow: currentFlow,
current_step: resolveStep(currentStep),
workflow_state: workflowState,
is_complete: isComplete,
...(extraPayload?.() ?? {})
});
setDraft(getCampaignJson(saved));
setDirty(false);
setSaveState(`Saved ${formatDateTime(saved.autosaved_at ?? saved.updated_at)}`);
onSaved?.(saved);
await reload();
return true;
} catch (err) {
const text = err instanceof Error ? err.message : String(err);
setLocalError(text);
setSaveState("i18n:govoplan-campaign.save_failed.0a444467");
return false;
}
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
const unsavedRegistration = useMemo(() => dirty && !locked ? {
title: unsavedTitle,
message: unsavedMessage,
onSave: () => saveDraft("manual"),
onDiscard: () => setDirty(false)
} : null, [dirty, locked, saveDraft, unsavedMessage, unsavedTitle]);
useRegisterCampaignUnsavedChanges(unsavedRegistration);
return {
draft,
setDraft,
displayDraft: draft ?? ensureCampaignDraft(null),
dirty,
setDirty,
saveState,
setSaveState,
localError,
setLocalError,
patch,
markDirty,
saveDraft
};
}