fix(global-sections): Templates, Sitesmith site, Reset site and history Restore refused while a master is open
Each replaced site content under an open master zone: Templates cleared the zone and committed the template's first section as the new master (rewriting every instance); replaceAllPages left the zone without committing; history Restore loaded page content around it. Templates button/command/overflow item and the history Restore buttons are disabled with a 'Click Done first' notice, and the handlers (TemplateModal.handleLoad, replaceAllPages -> false, Sitesmith site, Reset site, restoreSite/restorePage) refuse as a hard guard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
996c9e92ec
commit
ca500d36a9
11 files changed
+171
-33
No files matched your search
@@ -66,6 +66,9 @@ export function useRestoreSnapshot() {
|
||||
}, [actions]);
|
||||
|
||||
const restoreSite = useCallback((snapshot: Snapshot, at: string): boolean => {
|
||||
// #57: a restore while a master zone is open would load page content into
|
||||
// the zone (or strand it); Done first.
|
||||
if (pagesRef.current.isEditingGlobal) { notify({ kind: 'error', message: 'Click Done on the global section you are editing before restoring a version.' }); return false; }
|
||||
const valid = validateSnapshot(snapshot);
|
||||
if (!valid.ok) { notify({ kind: 'error', message: valid.reason }); return false; }
|
||||
const snapPages = snapshotToPages(snapshot);
|
||||
@@ -93,6 +96,7 @@ export function useRestoreSnapshot() {
|
||||
}, [extras, updateDesign, markDirty, notify, putOnCanvas]);
|
||||
|
||||
const restorePage = useCallback((snapshot: Snapshot, pageIndex: number, at: string): boolean => {
|
||||
if (pagesRef.current.isEditingGlobal) { notify({ kind: 'error', message: 'Click Done on the global section you are editing before restoring a version.' }); return false; }
|
||||
const valid = validateSnapshot(snapshot);
|
||||
if (!valid.ok) { notify({ kind: 'error', message: valid.reason }); return false; }
|
||||
const snapPage = snapshotToPages(snapshot)[pageIndex];
|
||||
@@ -124,6 +128,7 @@ interface Pending { id: string; mode: Mode; snapshot: Snapshot; at: string }
|
||||
export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
const { listHistory, getSnapshot } = useHistoryApi();
|
||||
const { restoreSite, restorePage } = useRestoreSnapshot();
|
||||
const { isEditingGlobal } = usePages();
|
||||
const { notify } = useNotices();
|
||||
const [items, setItems] = useState<HistoryItem[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -197,6 +202,7 @@ export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
</button>
|
||||
</div>
|
||||
<div className="wsb-history-body">
|
||||
{isEditingGlobal && <p className="wsb-history-empty" role="status">Click Done on the global section you are editing before restoring a version.</p>}
|
||||
{loading && <p className="wsb-history-empty">Loading...</p>}
|
||||
{!loading && items && items.length === 0 && <p className="wsb-history-empty">No earlier versions yet. One is kept each time you save.</p>}
|
||||
{days.map((g) => (
|
||||
@@ -210,8 +216,8 @@ export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
|
||||
<span className="wsb-history-time" title={fmtExact(it.at)}>{relativeTime(it.at)}</span>
|
||||
<span className="wsb-history-actions">
|
||||
<button type="button" disabled={busyId === it.id} onClick={() => begin(it, 'preview')}>Preview</button>
|
||||
<button type="button" disabled={busyId === it.id} onClick={() => begin(it, 'restore-page')}>Restore one page…</button>
|
||||
<button type="button" disabled={busyId === it.id} onClick={() => begin(it, 'restore-site')}>Restore site…</button>
|
||||
<button type="button" disabled={busyId === it.id || isEditingGlobal} onClick={() => begin(it, 'restore-page')}>Restore one page…</button>
|
||||
<button type="button" disabled={busyId === it.id || isEditingGlobal} onClick={() => begin(it, 'restore-site')}>Restore site…</button>
|
||||
</span>
|
||||
</div>
|
||||
{pending && pending.id === it.id && (
|
||||
|
||||
@@ -16,14 +16,16 @@ import { act } from 'react-dom/test-utils';
|
||||
* for a real-provider test.
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
const replaceAllPages = vi.fn();
|
||||
// Real contract: true when replaced, false when refused (a global-section master is open).
|
||||
const replaceAllPages = vi.fn((_pages: { name: string; tree: any }[]) => true);
|
||||
let mockEditingGlobal = false;
|
||||
const setHeader = vi.fn();
|
||||
const setFooter = vi.fn();
|
||||
const resetToDefaults = vi.fn();
|
||||
let mockSiteDomain = 'example.com';
|
||||
|
||||
vi.mock('../../state/PageContext', () => ({
|
||||
usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {} }),
|
||||
usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {}, isEditingGlobal: mockEditingGlobal }),
|
||||
}));
|
||||
vi.mock('../../state/SiteDesignContext', () => ({
|
||||
useSiteDesign: () => ({ design: {}, updateDesign: vi.fn(), resetToDefaults }),
|
||||
@@ -210,4 +212,24 @@ describe('Reset Entire Site -- guard + wiring (mocked hooks)', () => {
|
||||
expect(container.textContent).toContain('Danger zone');
|
||||
unmount();
|
||||
});
|
||||
test('#57: refused while a global-section master is open (button disabled AND handler guarded)', () => {
|
||||
mockSiteDomain = 'example.com';
|
||||
mockEditingGlobal = true;
|
||||
replaceAllPages.mockClear(); setHeader.mockClear(); setFooter.mockClear(); resetToDefaults.mockClear();
|
||||
try {
|
||||
render();
|
||||
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
|
||||
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||
act(() => { setInputValue(input, 'example.com'); });
|
||||
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
|
||||
expect(confirm.disabled).toBe(true);
|
||||
act(() => { invokeReactOnClick(confirm); });
|
||||
expect(replaceAllPages).not.toHaveBeenCalled();
|
||||
expect(setHeader).not.toHaveBeenCalled();
|
||||
expect(setFooter).not.toHaveBeenCalled();
|
||||
expect(resetToDefaults).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
mockEditingGlobal = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,7 @@ export function siteResetConfirmMatches(typed: string, domain: string): boolean
|
||||
|
||||
export const SiteDesignPanel: React.FC = () => {
|
||||
const { design, updateDesign, resetToDefaults } = useSiteDesign();
|
||||
const { replaceAllPages, setHeader, setFooter } = usePages();
|
||||
const { replaceAllPages, setHeader, setFooter, isEditingGlobal } = usePages();
|
||||
const { whpConfig } = useEditorConfig();
|
||||
const { armForceEmptyNextSave } = useSaveGuard();
|
||||
// Standalone mode (no WHP_CONFIG) has no site domain to confirm against --
|
||||
@@ -223,11 +223,13 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
// `disabled` attribute were ever bypassed (a synthetic click, or a
|
||||
// future refactor that drops it).
|
||||
if (!siteResetConfirmMatches(siteResetTyped, siteDomain)) return;
|
||||
// #57: never wipe the site out from under an open global-section master.
|
||||
if (isEditingGlobal) return;
|
||||
|
||||
// The user explicitly confirmed wiping the site, so the next save must
|
||||
// be allowed to replace the saved site with an empty one.
|
||||
if (!replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }])) return;
|
||||
armForceEmptyNextSave();
|
||||
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
|
||||
setHeader(BLANK_HEADER_TREE);
|
||||
setFooter(BLANK_FOOTER_TREE);
|
||||
resetToDefaults();
|
||||
@@ -499,7 +501,7 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<button
|
||||
data-action="confirm-site-reset"
|
||||
disabled={!siteResetConfirmMatches(siteResetTyped, siteDomain)}
|
||||
disabled={isEditingGlobal || !siteResetConfirmMatches(siteResetTyped, siteDomain)}
|
||||
onClick={handleResetSite}
|
||||
style={{
|
||||
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
|
||||
|
||||
@@ -47,7 +47,7 @@ const CATEGORY_COLORS: Record<TemplateCategory, string> = {
|
||||
|
||||
export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) => {
|
||||
const { actions, query } = useEditor();
|
||||
const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages();
|
||||
const { pages, addPage, switchPage, deletePage, editHeader, editFooter, isEditingGlobal } = usePages();
|
||||
const { updateDesign } = useSiteDesign();
|
||||
const { armForceEmptyNextSave } = useSaveGuard();
|
||||
|
||||
@@ -180,6 +180,10 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
// while the sequence is still mutating a canvas the dialog just tore down.
|
||||
const handleLoad = useCallback(async () => {
|
||||
if (!confirmTemplate) return;
|
||||
// #57: the live canvas is a global-section master. Clearing it and
|
||||
// switching away would commit the template's first section as the new
|
||||
// master and rewrite every instance on the site with it.
|
||||
if (isEditingGlobal) { setConfirmTemplate(null); return; }
|
||||
setLoading(true);
|
||||
|
||||
const tpl = confirmTemplate;
|
||||
@@ -245,7 +249,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
if (mountedRef.current) setLoading(false);
|
||||
onClose();
|
||||
}
|
||||
}, [confirmTemplate, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose, armForceEmptyNextSave]);
|
||||
}, [confirmTemplate, isEditingGlobal, applyDesign, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, wait, onClose, armForceEmptyNextSave]);
|
||||
|
||||
// Mobile-A2: portaled to `document.body` -- TopBar.tsx mounts this modal
|
||||
// as a child of `.topbar`, which (as a flex item with its own z-index)
|
||||
@@ -306,6 +310,12 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isEditingGlobal && (
|
||||
<div role="status" className="template-modal-global-notice" style={{ margin: '0 20px', padding: '10px 12px', borderRadius: 6, background: 'rgba(245,158,11,0.12)', border: '1px solid rgba(245,158,11,0.4)', color: '#fbbf24', fontSize: 12 }}>
|
||||
Click Done on the global section you are editing before loading a template.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template Grid */}
|
||||
<div style={gridContainerStyle}>
|
||||
<div style={gridStyle}>
|
||||
@@ -313,7 +323,7 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
<TemplateCard
|
||||
key={tpl.id}
|
||||
template={tpl}
|
||||
onSelect={() => setConfirmTemplate(tpl)}
|
||||
onSelect={() => { if (!isEditingGlobal) setConfirmTemplate(tpl); }}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
|
||||
@@ -34,13 +34,14 @@ import { act } from 'react-dom/test-utils';
|
||||
|
||||
// #54: what the pre-publish checks see. Default (no pages) means no checks, so
|
||||
// every pre-existing test publishes directly.
|
||||
const checkFixture: { pages: any[]; liveState: string; activePageId: string; footerState: string | null } = { pages: [], liveState: '{}', activePageId: 'home', footerState: null };
|
||||
const checkFixture: { pages: any[]; liveState: string; activePageId: string; footerState: string | null; editingGlobal?: boolean } = { pages: [], liveState: '{}', activePageId: 'home', footerState: null };
|
||||
vi.mock('../../state/PageContext', () => ({
|
||||
usePages: () => ({
|
||||
pages: checkFixture.pages,
|
||||
headerPage: { id: '__header__', name: 'Header', slug: '__header__', craftState: null },
|
||||
footerPage: { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: checkFixture.footerState },
|
||||
activePageId: checkFixture.activePageId,
|
||||
isEditingGlobal: !!checkFixture.editingGlobal,
|
||||
switchPage: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
@@ -146,6 +147,7 @@ afterEach(() => {
|
||||
checkFixture.liveState = '{}';
|
||||
checkFixture.activePageId = 'home';
|
||||
checkFixture.footerState = null;
|
||||
checkFixture.editingGlobal = false;
|
||||
openPaletteMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -344,6 +346,18 @@ describe('TopBar search button (#49 integration)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TopBar Templates while a global-section master is open (#57)', () => {
|
||||
test('the Templates button is disabled (and enabled otherwise)', () => {
|
||||
render();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[aria-label="Templates"]')!.disabled).toBe(false);
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
checkFixture.editingGlobal = true;
|
||||
render();
|
||||
expect(container.querySelector<HTMLButtonElement>('button[aria-label="Templates"]')!.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TopBar slow publish', () => {
|
||||
test('after 30s the button reads "Still publishing..." instead of failing', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -46,7 +46,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
neverSaved,
|
||||
} = useSaveGuard();
|
||||
const canSave = loadState === 'loaded';
|
||||
const { headerPage, footerPage, pages, activePageId } = usePages();
|
||||
const { headerPage, footerPage, pages, activePageId, isEditingGlobal } = usePages();
|
||||
const { design } = useSiteDesign();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -372,10 +372,10 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
{ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, when: () => canSave, run: () => handleSave() },
|
||||
...(isWHP ? [{ id: 'site.publish', title: 'Publish', group: 'site' as const, shortcut: 'mod+shift+p', allowWhileTyping: true, when: () => canSave, run: () => handlePublish() }] : []),
|
||||
{ id: 'site.preview', title: 'Preview', group: 'site', shortcut: 'mod+p', allowWhileTyping: true, run: () => handlePreview() },
|
||||
{ id: 'site.templates', title: 'Templates', group: 'site', when: () => canSave, run: ({ openTemplates }) => openTemplates() },
|
||||
{ id: 'site.templates', title: 'Templates', group: 'site', when: () => canSave && !isEditingGlobal, run: ({ openTemplates }) => openTemplates() },
|
||||
{ id: 'site.head-code', title: 'Custom head code', group: 'site', when: () => canSave, run: ({ openHeadCode }) => openHeadCode() },
|
||||
{ id: 'site.report-issue', title: 'Report an issue', group: 'site', run: () => setReportOpen(true) },
|
||||
], [canSave, isWHP, handleSave, handlePublish, handlePreview]);
|
||||
], [canSave, isEditingGlobal, isWHP, handleSave, handlePublish, handlePreview]);
|
||||
|
||||
// Phase A: at ≤768px the topbar collapses to only the essentials --
|
||||
// back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith,
|
||||
@@ -468,6 +468,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
onPreview={handlePreview}
|
||||
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} disabled={!canSave} />}
|
||||
editingDisabled={!canSave}
|
||||
templatesDisabled={isEditingGlobal}
|
||||
/>
|
||||
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
@@ -531,7 +532,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
<i className="fa fa-repeat" />
|
||||
</button>
|
||||
<span className="topbar-divider" />
|
||||
<button className="topbar-btn" disabled={!canSave} title="Templates" aria-label="Templates" onClick={() => setTemplateModalOpen(true)}>
|
||||
<button className="topbar-btn" disabled={!canSave || isEditingGlobal} title={isEditingGlobal ? 'Click Done on the global section first' : 'Templates'} aria-label="Templates" onClick={() => setTemplateModalOpen(true)}>
|
||||
<i className="fa fa-th-large" /> Templates
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface TopBarOverflowMenuProps {
|
||||
sitesmithNode: React.ReactNode;
|
||||
/** Canvas-changing items (Templates, Head Code) are disabled until the site has loaded. */
|
||||
editingDisabled?: boolean;
|
||||
/** #57: Templates replace the site; refused while a global-section master is open. */
|
||||
templatesDisabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +39,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
||||
onPreview,
|
||||
sitesmithNode,
|
||||
editingDisabled,
|
||||
templatesDisabled,
|
||||
}) => {
|
||||
const { device, setDevice: onDeviceChange } = useDevice();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -85,7 +88,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" disabled={editingDisabled} onClick={runAndClose(onOpenTemplates)}>
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" disabled={editingDisabled || templatesDisabled} onClick={runAndClose(onOpenTemplates)}>
|
||||
<i className="fa fa-th-large" aria-hidden="true" /> Templates
|
||||
</button>
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onPreview)}>
|
||||
|
||||
@@ -19,6 +19,8 @@ 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';
|
||||
import { TemplateModal } from '../panels/topbar/TemplateModal';
|
||||
import { HistoryModal } from '../panels/history/HistoryModal';
|
||||
|
||||
/**
|
||||
* #57 global sections against the REAL Craft editor + PageProvider +
|
||||
@@ -441,6 +443,77 @@ describe('#57 global sections', () => {
|
||||
expect(h!.getSerialized()).toContain('AI takeover');
|
||||
});
|
||||
|
||||
test('Templates are refused while a master is open: master and instances untouched', async () => {
|
||||
mount(<TemplateModal open onClose={() => {}} />);
|
||||
const sec = addPromo();
|
||||
let libraryId = '';
|
||||
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
|
||||
h!.act(() => { pages.editGlobal(libraryId); });
|
||||
await flush();
|
||||
const zoneBefore = h!.getSerialized();
|
||||
expect(document.body.textContent).toContain('Click Done on the global section');
|
||||
const card = document.body.querySelector<HTMLElement>('[role="button"][tabindex="0"]');
|
||||
expect(card).not.toBeNull();
|
||||
h!.act(() => { card!.click(); });
|
||||
// No confirm dialog -> nothing can be applied to the zone.
|
||||
expect(Array.from(document.body.querySelectorAll('button')).some((b) => b.textContent === 'Load Template')).toBe(false);
|
||||
expect(h!.getSerialized()).toBe(zoneBefore);
|
||||
expect(lib()[libraryId].rev).toBe(1);
|
||||
});
|
||||
|
||||
test('Sitesmith site-scope replace is refused while a master is open', async () => {
|
||||
mount();
|
||||
const sec = addPromo();
|
||||
let libraryId = '';
|
||||
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
|
||||
h!.act(() => { pages.editGlobal(libraryId); });
|
||||
await flush();
|
||||
const before = pages.pages.map((p) => p.craftState);
|
||||
let r: any;
|
||||
const tree = { type: { resolvedName: 'Container' }, props: {}, nodes: [{ type: { resolvedName: 'Heading' }, props: { text: 'AI site' } }] } as any;
|
||||
await h!.act(async () => { r = await applyAi({ type: 'replace', scope: 'site', pages: [{ name: 'Home', tree }], header: { tree } } as any); });
|
||||
await flush();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.message).toMatch(/global section/i);
|
||||
expect(pages.isEditingGlobal).toBe(true);
|
||||
expect(pages.pages.map((p) => p.craftState)).toEqual(before);
|
||||
expect(JSON.stringify(pages.headerPage.craftState ?? '')).not.toContain('AI site');
|
||||
expect(lib()[libraryId].rev).toBe(1);
|
||||
});
|
||||
|
||||
test('history Restore (site and one page) is refused while a master is open; Preview stays available', async () => {
|
||||
const snap = {
|
||||
pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: JSON.stringify({ ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: {}, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null } }) }],
|
||||
};
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
|
||||
const u = String(url);
|
||||
const body = u.includes('history_list')
|
||||
? { success: true, items: [{ id: 'v1', kind: 'save', at: '2026-10-07T17:15:01.042Z', size: 1 }] }
|
||||
: { success: true, project: snap };
|
||||
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => body, text: async () => '' } as any;
|
||||
}));
|
||||
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 /><HistoryModal open onClose={() => {}} /></GlobalSectionsProvider></PageProvider>);
|
||||
h.act(() => { guard.setLoadState('loaded'); });
|
||||
const sec = addPromo();
|
||||
let libraryId = '';
|
||||
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
|
||||
h!.act(() => { pages.editGlobal(libraryId); });
|
||||
await flush();
|
||||
await flush();
|
||||
const buttons = () => Array.from(document.body.querySelectorAll<HTMLButtonElement>('.wsb-history-row button'));
|
||||
const byText = (t: string) => buttons().find((b) => b.textContent === t)!;
|
||||
expect(byText('Preview').disabled).toBe(false);
|
||||
expect(byText('Restore site…').disabled).toBe(true);
|
||||
expect(byText('Restore one page…').disabled).toBe(true);
|
||||
expect(document.body.textContent).toContain('Click Done on the global section');
|
||||
});
|
||||
|
||||
test('remove is refused while instances exist, allowed once none are left', () => {
|
||||
mount();
|
||||
const sec = addPromo();
|
||||
|
||||
@@ -97,7 +97,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;
|
||||
/** False (nothing replaced) while a global-section master is open (#57). */
|
||||
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => boolean;
|
||||
/** False (nothing changed) while a global-section master is on the canvas (#57). */
|
||||
replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => boolean;
|
||||
setHeader: (tree: SerializedTreeNode) => void;
|
||||
@@ -283,7 +284,7 @@ const PageContext = createContext<PageContextValue>({
|
||||
setFooterCraftState: () => {},
|
||||
setPagesCraftState: () => {},
|
||||
setActivePageIdDirect: () => {},
|
||||
replaceAllPages: () => {},
|
||||
replaceAllPages: () => false,
|
||||
replaceCurrentPage: () => false,
|
||||
setHeader: () => {},
|
||||
setFooter: () => {},
|
||||
@@ -897,8 +898,12 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
* Stores each page's serialized state without touching the live canvas
|
||||
* (the canvas still shows the currently active page — call switchPage() if needed).
|
||||
*/
|
||||
const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]) => {
|
||||
if (newPages.length === 0) return;
|
||||
const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]): boolean => {
|
||||
if (newPages.length === 0) return false;
|
||||
// #57: leaving a master zone without committing it would silently drop
|
||||
// the master edits (and strand the zone's return target). Refuse; the
|
||||
// caller tells the user to click Done first.
|
||||
if (isGlobalZoneId(activePageIdRef.current)) return false;
|
||||
// Track slugs as they're assigned so later pages dedupe against earlier
|
||||
// ones in the same batch (e.g. the AI generating two "About" pages).
|
||||
const seenSlugs: string[] = [];
|
||||
@@ -921,6 +926,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
setActivePageId(built[0].id);
|
||||
activePageIdRef.current = built[0].id;
|
||||
loadState(firstState, EMPTY_CANVAS);
|
||||
return true;
|
||||
}, [loadState]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -58,7 +58,14 @@ export function useApplyAiResponse() {
|
||||
|
||||
if (resp.type === 'replace') {
|
||||
if (resp.scope === 'site') {
|
||||
pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree })));
|
||||
if (!pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree })))) {
|
||||
return {
|
||||
ok: false,
|
||||
message: pages.isEditingGlobal
|
||||
? 'You are editing a global section. Click Done, then ask again to replace the site.'
|
||||
: 'Nothing to apply.',
|
||||
};
|
||||
}
|
||||
if (resp.header) pages.setHeader(resp.header.tree);
|
||||
if (resp.footer) pages.setFooter(resp.footer.tree);
|
||||
return { ok: true };
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { openSandboxedPreview, PREVIEW_SANDBOX } from './sandboxed-preview';
|
||||
|
||||
/** A real (detached) document standing in for the about:blank popup. */
|
||||
@@ -52,16 +50,12 @@ describe('openSandboxedPreview (customer head code never runs in the panel origi
|
||||
});
|
||||
|
||||
describe('no same-origin path for composed-page HTML remains in the source', () => {
|
||||
const root = join(__dirname, '..');
|
||||
const files: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p);
|
||||
else if (/\.(ts|tsx)$/.test(name) && !/\.test\.(ts|tsx)$/.test(name) && !p.includes('test-utils')) files.push(p);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
// Every non-test source file under src/, as raw text (Vite glob; no @types/node here).
|
||||
const sources = import.meta.glob(['../**/*.{ts,tsx}', '!../**/*.test.{ts,tsx}', '!../test-utils/**'], { query: '?raw', import: 'default', eager: true }) as Record<string, string>;
|
||||
const files = Object.keys(sources);
|
||||
const readFileSync = (f: string, _enc: string) => sources[f];
|
||||
const root = '..';
|
||||
expect(files.length).toBeGreaterThan(100);
|
||||
|
||||
test('nothing builds a text/html blob, document.writes, or opens a blob: URL', () => {
|
||||
const offenders = files.filter((f) => {
|
||||
|
||||
Reference in new issue
Block a user