fix(site-builder): refuse Sitesmith page replace while editing a global master
A page-scope Sitesmith replace with a master zone open loaded the AI page onto the zone canvas. On Done, its first section became the new master and the sync rewrote every instance on every page with it, orphaning all overrides. replaceCurrentPage now returns false inside a master zone and the apply path reports an error asking the user to click Done first. Refs #57 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dd8b99be06
commit
dcc7e27be4
3 files changed
+68
-5
No files matched your search
@@ -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<typeof usePages>;
|
||||
let globals: ReturnType<typeof useGlobalSections>;
|
||||
let extras: ReturnType<typeof useProjectExtras>;
|
||||
let guard: ReturnType<typeof useSaveGuard>;
|
||||
let applyAi: ReturnType<typeof useApplyAiResponse>;
|
||||
|
||||
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();
|
||||
|
||||
@@ -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<PageContextValue>({
|
||||
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]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user