diff --git a/craft/src/hooks/useWhpApi.ts b/craft/src/hooks/useWhpApi.ts index a697b7f..795de9f 100644 --- a/craft/src/hooks/useWhpApi.ts +++ b/craft/src/hooks/useWhpApi.ts @@ -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 diff --git a/craft/src/state/GlobalSectionsContext.test.tsx b/craft/src/state/GlobalSectionsContext.test.tsx index 53de5ae..ca08456 100644 --- a/craft/src/state/GlobalSectionsContext.test.tsx +++ b/craft/src/state/GlobalSectionsContext.test.tsx @@ -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; let extras: ReturnType; let guard: ReturnType; let applyAi: ReturnType; +let api: ReturnType | 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) => ( + {tree} + ), + }); + h.mountChild(); + 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(); diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 4855637..3b8ad16 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -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({ 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(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,