fix(global-sections): save/autosave/publish inside a master zone persist the live master
The zone's live master was neither in extras nor in the stored pages until Done, so a save from inside the zone succeeded without it and cleared dirty; closing the tab lost the edit (publish shipped stale instances). The save payload now carries the master committed into the library and every stored page/header/footer synced, computed exactly as Done would without touching the editor (PageContext.globalZoneSaveView). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
37ed85bf42
commit
996c9e92ec
3 files changed
+137
-8
No files matched your search
@@ -8,7 +8,7 @@ import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||
import { PageData } from '../types';
|
||||
import { useSaveGuard } from '../state/SaveGuardContext';
|
||||
import { useProjectExtras } from '../state/ProjectExtrasContext';
|
||||
import { isGlobalZoneId } from '../utils/global-sections';
|
||||
import { GLOBAL_SECTIONS_KEY, isGlobalZoneId } from '../utils/global-sections';
|
||||
|
||||
/**
|
||||
* Per-state export memo. A craft-state string fully determines its exported
|
||||
@@ -348,6 +348,7 @@ export function useWhpApi() {
|
||||
setFooterCraftState,
|
||||
setPagesCraftState,
|
||||
setActivePageIdDirect,
|
||||
globalZoneSaveView,
|
||||
} = usePages();
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
const projectExtras = useProjectExtras();
|
||||
@@ -381,19 +382,26 @@ export function useWhpApi() {
|
||||
const forceEmpty = !!opts?.forceEmpty || forceEmptyNextSave.current;
|
||||
const forceOverwrite = !!opts?.forceOverwrite || forceOverwriteNextSave.current;
|
||||
|
||||
// #57: a master zone open on the canvas is not in extras or the stored
|
||||
// pages until Done. Save it as Done would (live master in the library,
|
||||
// every instance synced), or the save "succeeds" without the edits and
|
||||
// clears dirty -- and closing the tab loses them.
|
||||
const zone = isGlobalZoneId(activePageId) ? globalZoneSaveView(liveCraftState) : null;
|
||||
const allExtras = projectExtras.all();
|
||||
|
||||
const payload = buildSavePayload({
|
||||
siteId: whpConfig.siteId,
|
||||
siteName: whpConfig.siteName,
|
||||
liveCraftState,
|
||||
pages,
|
||||
headerPage,
|
||||
footerPage,
|
||||
pages: zone ? zone.pages : pages,
|
||||
headerPage: zone ? zone.headerPage : headerPage,
|
||||
footerPage: zone ? zone.footerPage : footerPage,
|
||||
activePageId,
|
||||
isEditingHeader,
|
||||
isEditingFooter,
|
||||
headCode: design.headCode,
|
||||
design,
|
||||
extras: projectExtras.all(),
|
||||
extras: zone ? { ...allExtras, [GLOBAL_SECTIONS_KEY]: zone.library } : allExtras,
|
||||
});
|
||||
|
||||
let result: ApiResult;
|
||||
@@ -450,7 +458,7 @@ export function useWhpApi() {
|
||||
} finally {
|
||||
endSave(ticket, succeeded);
|
||||
}
|
||||
}, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras]);
|
||||
}, [whpConfig, forceEmptyNextSave, forceOverwriteNextSave, getRev, setRev, setConflict, beginSave, endSave, query, projectExtras, globalZoneSaveView]);
|
||||
|
||||
/**
|
||||
* Save, one at a time. Every save (Save button, autosave, Keep mine, the
|
||||
|
||||
@@ -10,7 +10,9 @@ import { NoticeProvider } from './NoticeContext';
|
||||
import { SiteDesignProvider, DEFAULT_SITE_DESIGN } from './SiteDesignContext';
|
||||
import { buildNodeTree } from '../utils/craft-tree';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { buildSavePayload } from '../hooks/useWhpApi';
|
||||
import { buildSavePayload, useWhpApi } from '../hooks/useWhpApi';
|
||||
import { EditorConfigProvider } from './EditorConfigContext';
|
||||
import { vi } from 'vitest';
|
||||
import { GLOBAL_SECTIONS_KEY, instanceMeta, type GlobalLibrary, type SNodes } from '../utils/global-sections';
|
||||
import { GuidedStyles } from '../panels/right/GuidedStyles';
|
||||
import { ContextMenu } from '../panels/context-menu/ContextMenu';
|
||||
@@ -30,6 +32,22 @@ let globals: ReturnType<typeof useGlobalSections>;
|
||||
let extras: ReturnType<typeof useProjectExtras>;
|
||||
let guard: ReturnType<typeof useSaveGuard>;
|
||||
let applyAi: ReturnType<typeof useApplyAiResponse>;
|
||||
let api: ReturnType<typeof useWhpApi> | null = null;
|
||||
const ApiProbe: React.FC = () => { api = useWhpApi(); return null; };
|
||||
const WHP_CFG = { user: 'u', apiUrl: '/api/sb', csrfToken: 't', siteId: 1, siteDomain: 'example.com', siteName: 'S', backUrl: '/', isRoot: false };
|
||||
|
||||
/** Like mount(), plus a real useWhpApi (fetch mocked by the caller). */
|
||||
function mountWithApi(): EditorHarness {
|
||||
h = renderEditorHarness({
|
||||
onRender: RenderNode,
|
||||
wrap: (tree) => (
|
||||
<EditorConfigProvider config={WHP_CFG}><SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SiteDesignProvider>{tree}</SiteDesignProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider></EditorConfigProvider>
|
||||
),
|
||||
});
|
||||
h.mountChild(<PageProvider><GlobalSectionsProvider><Probe /><ApiProbe /></GlobalSectionsProvider></PageProvider>);
|
||||
h.act(() => { guard.setLoadState('loaded'); });
|
||||
return h;
|
||||
}
|
||||
|
||||
const Probe: React.FC = () => {
|
||||
pages = usePages();
|
||||
@@ -40,7 +58,7 @@ const Probe: React.FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
afterEach(() => { h?.unmount(); h = null; closeSectionLibrary(); document.body.innerHTML = ''; });
|
||||
afterEach(() => { h?.unmount(); h = null; api = null; vi.unstubAllGlobals(); closeSectionLibrary(); document.body.innerHTML = ''; });
|
||||
|
||||
function mount(ui: React.ReactNode = null): EditorHarness {
|
||||
h = renderEditorHarness({
|
||||
@@ -283,6 +301,71 @@ describe('#57 global sections', () => {
|
||||
expect(live()[byType(live(), 'Heading', sec)[0]].props.text).toBe('Via switch');
|
||||
});
|
||||
|
||||
test('save / autosave while the master zone is open persists the LIVE master and the synced instances', async () => {
|
||||
const bodies: any[] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
|
||||
if (String(url).includes('action=save')) bodies.push(JSON.parse(init.body));
|
||||
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, rev: 'r' + bodies.length }), text: async () => '' } as any;
|
||||
}));
|
||||
mountWithApi();
|
||||
const sec = addPromo();
|
||||
let libraryId = '';
|
||||
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
|
||||
h!.act(() => { pages.addPage('About', 'about'); });
|
||||
await flush();
|
||||
h!.act(() => { globals.insertInstance(libraryId); });
|
||||
h!.act(() => { pages.editGlobal(libraryId); });
|
||||
await flush();
|
||||
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
|
||||
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Live master'; }); });
|
||||
h!.act(() => { guard.markDirty(); });
|
||||
|
||||
let res: any;
|
||||
await h!.act(async () => { res = await api!.save(); });
|
||||
expect(res.success).toBe(true);
|
||||
const body = bodies[bodies.length - 1];
|
||||
// The library in the payload holds the live master (rev bumped as Done would).
|
||||
const savedMaster = body.extras.globalSections[libraryId];
|
||||
expect(savedMaster.rev).toBe(2);
|
||||
expect(JSON.stringify(savedMaster.nodes)).toContain('Live master');
|
||||
// Every stored instance is synced in the payload, on both pages, and in the published html.
|
||||
expect(body.pages_craft_state).toHaveLength(2);
|
||||
for (const p of body.pages_craft_state) expect(p.craftState).toContain('Live master');
|
||||
for (const p of body.pages) expect(p.html).toContain('Live master');
|
||||
// The zone itself never lands in a page slot.
|
||||
for (const p of body.pages_craft_state) expect(JSON.parse(p.craftState).ROOT).toBeTruthy();
|
||||
expect(guard.isDirty()).toBe(false);
|
||||
// Still in the zone; the editor itself was not rewritten under the user.
|
||||
expect(pages.isEditingGlobal).toBe(true);
|
||||
|
||||
// Done afterwards commits the same master: rev 2, instance on the page shows it.
|
||||
h!.act(() => { globals.finishEditingMaster(); });
|
||||
await flush();
|
||||
expect(lib()[libraryId].rev).toBe(2);
|
||||
expect(JSON.stringify(live())).toContain('Live master');
|
||||
});
|
||||
|
||||
test('publish from inside the master zone saves the live master first', async () => {
|
||||
const calls: Array<{ action: string; body: any }> = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
|
||||
const action = /action=(\w+)/.exec(String(url))![1];
|
||||
calls.push({ action, body: init?.body ? JSON.parse(init.body) : null });
|
||||
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, rev: 'r' + calls.length }), text: async () => '' } as any;
|
||||
}));
|
||||
mountWithApi();
|
||||
const sec = addPromo();
|
||||
let libraryId = '';
|
||||
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
|
||||
h!.act(() => { pages.editGlobal(libraryId); });
|
||||
await flush();
|
||||
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
|
||||
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Publish me'; }); });
|
||||
await h!.act(async () => { await api!.publish(); });
|
||||
expect(calls.map((c) => c.action)).toEqual(['save', 'publish']);
|
||||
expect(calls[0].body.pages[0].html).toContain('Publish me');
|
||||
expect(JSON.stringify(calls[0].body.extras.globalSections[libraryId].nodes)).toContain('Publish me');
|
||||
});
|
||||
|
||||
test('detach: plain copy (one undo step) that later master edits never touch', async () => {
|
||||
mount();
|
||||
const sec = addPromo();
|
||||
|
||||
@@ -13,6 +13,13 @@ import {
|
||||
masterFromZoneState, masterZoneState, syncInstances,
|
||||
} from '../utils/global-sections';
|
||||
|
||||
export interface GlobalZoneSaveView {
|
||||
library: GlobalLibrary;
|
||||
pages: PageData[];
|
||||
headerPage: PageData;
|
||||
footerPage: PageData;
|
||||
}
|
||||
|
||||
interface PageContextValue {
|
||||
pages: PageData[];
|
||||
headerPage: PageData;
|
||||
@@ -35,6 +42,14 @@ interface PageContextValue {
|
||||
editGlobal: (libraryId: string) => boolean;
|
||||
/** Leave the master zone back to where `editGlobal` was called from. */
|
||||
finishGlobal: () => GlobalCommitResult;
|
||||
/**
|
||||
* #57: what Save / autosave / Publish must persist while a master zone is
|
||||
* open: the live master (`liveState`) committed into the library and every
|
||||
* stored page/header/footer synced, exactly as Done would -- computed
|
||||
* WITHOUT touching editor state (Done still commits for real). Null when no
|
||||
* zone is open, or the master is unchanged or empty.
|
||||
*/
|
||||
globalZoneSaveView: (liveState: string) => GlobalZoneSaveView | null;
|
||||
switchPage: (pageId: string) => void;
|
||||
editHeader: () => void;
|
||||
editFooter: () => void;
|
||||
@@ -252,6 +267,7 @@ const PageContext = createContext<PageContextValue>({
|
||||
globalEditingId: null,
|
||||
editGlobal: () => false,
|
||||
finishGlobal: () => NO_COMMIT,
|
||||
globalZoneSaveView: () => null,
|
||||
switchPage: () => {},
|
||||
editHeader: () => {},
|
||||
editFooter: () => {},
|
||||
@@ -452,6 +468,27 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
return { transform, result: { changed: true, empty: false, dropped: res.dropped } };
|
||||
}, [extras, query]);
|
||||
|
||||
const globalZoneSaveView = useCallback((liveState: string): GlobalZoneSaveView | null => {
|
||||
const libraryId = libraryIdOfZone(activePageIdRef.current);
|
||||
const lib = extras.get<GlobalLibrary>(GLOBAL_SECTIONS_KEY, {});
|
||||
const prev = libraryId ? lib[libraryId] : undefined;
|
||||
if (!libraryId || !prev) return null;
|
||||
const res = masterFromZoneState(liveState, prev, Date.now(), zoneBaselineRef.current);
|
||||
if (!res || !res.changed) return null;
|
||||
const nextLib: GlobalLibrary = { ...lib, [libraryId]: res.master };
|
||||
const sync = (p: PageData): PageData => {
|
||||
if (!p.craftState) return p;
|
||||
const n = syncInstances(p.craftState, nextLib, lib);
|
||||
return n === p.craftState ? p : { ...p, craftState: n };
|
||||
};
|
||||
return {
|
||||
library: nextLib,
|
||||
pages: pagesRef.current.map(sync),
|
||||
headerPage: sync(headerPageRef.current),
|
||||
footerPage: sync(footerPageRef.current),
|
||||
};
|
||||
}, [extras]);
|
||||
|
||||
/** Save whatever is on the current Frame back to the right state slot */
|
||||
const saveCurrentState = useCallback(() => {
|
||||
const currentId = activePageIdRef.current;
|
||||
@@ -989,6 +1026,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
globalEditingId,
|
||||
editGlobal,
|
||||
finishGlobal,
|
||||
globalZoneSaveView,
|
||||
switchPage,
|
||||
editHeader,
|
||||
editFooter,
|
||||
|
||||
Reference in new issue
Block a user