diff --git a/craft/src/state/GlobalSectionsContext.test.tsx b/craft/src/state/GlobalSectionsContext.test.tsx index c30c1b5..53de5ae 100644 --- a/craft/src/state/GlobalSectionsContext.test.tsx +++ b/craft/src/state/GlobalSectionsContext.test.tsx @@ -16,6 +16,7 @@ import { GuidedStyles } from '../panels/right/GuidedStyles'; import { ContextMenu } from '../panels/context-menu/ContextMenu'; import { SectionLibraryModal, openSectionLibrary, closeSectionLibrary } from '../panels/sections/SectionLibraryModal'; import { Canvas } from '../editor/Canvas'; +import { useApplyAiResponse } from '../utils/apply-ai-response'; /** * #57 global sections against the REAL Craft editor + PageProvider + @@ -28,12 +29,14 @@ let pages: ReturnType; let globals: ReturnType; let extras: ReturnType; let guard: ReturnType; +let applyAi: ReturnType; const Probe: React.FC = () => { pages = usePages(); globals = useGlobalSections(); extras = useProjectExtras(); guard = useSaveGuard(); + applyAi = useApplyAiResponse(); return null; }; @@ -306,6 +309,55 @@ describe('#57 global sections', () => { expect(h!.query.history.canUndo()).toBe(false); }); + test('Sitesmith page-scope replace is refused while a master is open: no cross-page rewrite', async () => { + mount(); + const sec = addPromo(); + let libraryId = ''; + h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; }); + h!.act(() => { pages.addPage('About', 'about'); }); + await flush(); + let inst2 = ''; + h!.act(() => { inst2 = globals.insertInstance(libraryId)!; }); + const aiPage = { + name: 'AI page', + tree: { + type: { resolvedName: 'Container' }, props: {}, + nodes: [{ type: { resolvedName: 'Section' }, props: {}, nodes: [ + { type: { resolvedName: 'Heading' }, props: { text: 'AI takeover' } }, + ] }], + }, + } as any; + + h!.act(() => { pages.editGlobal(libraryId); }); + await flush(); + const zoneBefore = h!.getSerialized(); + let r: any; + await h!.act(async () => { r = await applyAi({ type: 'replace', scope: 'page', pages: [aiPage] } as any); }); + await flush(); + expect(r.ok).toBe(false); + expect(r.message).toMatch(/global section/i); + expect(pages.isEditingGlobal).toBe(true); + expect(h!.getSerialized()).toBe(zoneBefore); + expect(pages.pages.map((p) => p.name)).toEqual(['Home', 'About']); + + h!.act(() => { globals.finishEditingMaster(); }); + await flush(); + expect(lib()[libraryId].rev).toBe(1); + const p2 = live(); + expect(instanceRoots(p2)).toEqual([inst2]); + expect(JSON.stringify(p2)).not.toContain('AI takeover'); + expect(JSON.stringify(p2)).toContain('Welcome'); + const p1 = pages.pages[0].craftState!; + expect(p1).not.toContain('AI takeover'); + expect(instanceMeta((JSON.parse(p1) as SNodes)[sec])!.rev).toBe(1); + + // control: the same response on a normal page still replaces it + await h!.act(async () => { r = await applyAi({ type: 'replace', scope: 'page', pages: [aiPage] } as any); }); + await flush(); + expect(r.ok).toBe(true); + expect(h!.getSerialized()).toContain('AI takeover'); + }); + test('remove is refused while instances exist, allowed once none are left', () => { mount(); const sec = addPromo(); diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 62462dc..4855637 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -83,7 +83,8 @@ interface PageContextValue { setActivePageIdDirect: (pageId: string) => void; /** AI helpers — replace entire site or page with a new tree */ replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void; - replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void; + /** False (nothing changed) while a global-section master is on the canvas (#57). */ + replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => boolean; setHeader: (tree: SerializedTreeNode) => void; setFooter: (tree: SerializedTreeNode) => void; siteDesign: SiteDesign; @@ -267,7 +268,7 @@ const PageContext = createContext({ setPagesCraftState: () => {}, setActivePageIdDirect: () => {}, replaceAllPages: () => {}, - replaceCurrentPage: () => {}, + replaceCurrentPage: () => false, setHeader: () => {}, setFooter: () => {}, siteDesign: {} as SiteDesign, @@ -889,9 +890,13 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => * AI helper: replace the current page's tree. * Deserializes the new tree into the live Craft.js canvas and persists it. */ - const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }) => { - const craftState = treeToCraftState(page.tree); + const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }): boolean => { const currentId = activePageIdRef.current; + // #57: a master zone is not a page. Loading a page tree here would make + // its first section the new master on Done, and the sync would then + // rewrite every instance on every page with it. Refuse instead. + if (isGlobalZoneId(currentId)) return false; + const craftState = treeToCraftState(page.tree); if (currentId === HEADER_ID) { setHeaderPage((prev) => ({ ...prev, name: page.name, craftState })); } else if (currentId === FOOTER_ID) { @@ -902,6 +907,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) => ); } loadState(craftState, EMPTY_CANVAS, { keepHistory: true }); + return true; }, [loadState]); /** diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts index 1b2b555..0fe283e 100644 --- a/craft/src/utils/apply-ai-response.ts +++ b/craft/src/utils/apply-ai-response.ts @@ -65,7 +65,12 @@ export function useApplyAiResponse() { } if (resp.scope === 'page') { - pages.replaceCurrentPage(resp.pages[0]); + if (!pages.replaceCurrentPage(resp.pages[0])) { + return { + ok: false, + message: 'You are editing a global section. Click Done, then ask again to replace the page.', + }; + } return { ok: true }; }