initial commit after split
This commit is contained in:
143
webui/src/features/campaigns/hooks/useCampaignDraftEditor.ts
Normal file
143
webui/src/features/campaigns/hooks/useCampaignDraftEditor.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, 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>;
|
||||
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)}` : "Loaded";
|
||||
}
|
||||
|
||||
export function useCampaignDraftEditor({
|
||||
settings,
|
||||
campaignId,
|
||||
version,
|
||||
locked,
|
||||
reload,
|
||||
setError,
|
||||
currentStep,
|
||||
currentFlow = "manual",
|
||||
workflowState = "editing",
|
||||
isComplete = false,
|
||||
unsavedTitle,
|
||||
unsavedMessage,
|
||||
loadedLabel = defaultLoadedLabel,
|
||||
transformLoadedDraft,
|
||||
extraPayload,
|
||||
onLoaded,
|
||||
onSaved
|
||||
}: UseCampaignDraftEditorOptions) {
|
||||
const loadedLabelRef = useRef(loadedLabel);
|
||||
const transformLoadedDraftRef = useRef(transformLoadedDraft);
|
||||
const onLoadedRef = useRef(onLoaded);
|
||||
|
||||
useEffect(() => {
|
||||
loadedLabelRef.current = loadedLabel;
|
||||
transformLoadedDraftRef.current = transformLoadedDraft;
|
||||
onLoadedRef.current = onLoaded;
|
||||
}, [loadedLabel, transformLoadedDraft, onLoaded]);
|
||||
|
||||
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveState, setSaveState] = useState("Loaded");
|
||||
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("Saving…");
|
||||
setError("");
|
||||
setLocalError("");
|
||||
try {
|
||||
const saved = await autosaveCampaignVersion(settings, campaignId, version.id, {
|
||||
campaign_json: draft,
|
||||
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("Save failed");
|
||||
return false;
|
||||
}
|
||||
}, [campaignId, currentFlow, currentStep, draft, extraPayload, isComplete, locked, onSaved, reload, setError, settings, version, workflowState]);
|
||||
|
||||
useRegisterCampaignUnsavedChanges(dirty && !locked ? {
|
||||
title: unsavedTitle,
|
||||
message: unsavedMessage,
|
||||
onSave: () => saveDraft("manual"),
|
||||
onDiscard: () => setDirty(false)
|
||||
} : null);
|
||||
|
||||
return {
|
||||
draft,
|
||||
setDraft,
|
||||
displayDraft: draft ?? ensureCampaignDraft(null),
|
||||
dirty,
|
||||
setDirty,
|
||||
saveState,
|
||||
setSaveState,
|
||||
localError,
|
||||
setLocalError,
|
||||
patch,
|
||||
markDirty,
|
||||
saveDraft
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import type { ApiSettings } from "../../../types";
|
||||
import {
|
||||
getCampaign,
|
||||
getCampaignSummary,
|
||||
getCampaignVersion,
|
||||
listCampaignVersions,
|
||||
type CampaignSummary,
|
||||
type CampaignVersionDetail
|
||||
} from "../../../api/campaigns";
|
||||
import type { CampaignWorkspaceData } from "../utils/campaignView";
|
||||
|
||||
const initialData: CampaignWorkspaceData = {
|
||||
campaign: null,
|
||||
versions: [],
|
||||
currentVersion: null,
|
||||
summary: null
|
||||
};
|
||||
|
||||
type UseCampaignWorkspaceDataOptions = {
|
||||
includeCurrentVersion?: boolean;
|
||||
includeSummary?: boolean;
|
||||
includeVersions?: boolean;
|
||||
};
|
||||
|
||||
export function useCampaignWorkspaceData(
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
options: UseCampaignWorkspaceDataOptions = {}
|
||||
) {
|
||||
const {
|
||||
includeCurrentVersion = true,
|
||||
includeSummary = false,
|
||||
includeVersions = false
|
||||
} = options;
|
||||
const [searchParams] = useSearchParams();
|
||||
const selectedVersionId = searchParams.get("version");
|
||||
const [data, setData] = useState<CampaignWorkspaceData>(initialData);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!campaignId) return;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const needsCampaign = includeCurrentVersion || includeVersions;
|
||||
const shouldLoadVersions = includeCurrentVersion || includeVersions;
|
||||
const [campaign, versions] = await Promise.all([
|
||||
needsCampaign ? getCampaign(settings, campaignId) : Promise.resolve(null),
|
||||
shouldLoadVersions ? listCampaignVersions(settings, campaignId) : Promise.resolve([]),
|
||||
]);
|
||||
const wantedVersionId = selectedVersionId || campaign?.current_version_id || versions[0]?.id;
|
||||
|
||||
const [versionResult, summaryResult] = await Promise.allSettled([
|
||||
includeCurrentVersion && wantedVersionId ? getCampaignVersion(settings, campaignId, wantedVersionId) : Promise.resolve(null),
|
||||
includeSummary ? getCampaignSummary(settings, campaignId, wantedVersionId) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
setData({
|
||||
campaign,
|
||||
versions,
|
||||
currentVersion: versionResult.status === "fulfilled" ? (versionResult.value as CampaignVersionDetail | null) : null,
|
||||
summary: summaryResult.status === "fulfilled" ? (summaryResult.value as CampaignSummary | null) : null
|
||||
});
|
||||
} catch (err) {
|
||||
setData(initialData);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [settings, campaignId, includeCurrentVersion, includeSummary, includeVersions, selectedVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
return { data, loading, error, reload, setError };
|
||||
}
|
||||
Reference in New Issue
Block a user