diff --git a/craft/src/panels/history/HistoryModal.tsx b/craft/src/panels/history/HistoryModal.tsx index 1bf1bc4..503b25b 100644 --- a/craft/src/panels/history/HistoryModal.tsx +++ b/craft/src/panels/history/HistoryModal.tsx @@ -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(null); const [loading, setLoading] = useState(false); @@ -197,6 +202,7 @@ export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({
+ {isEditingGlobal &&

Click Done on the global section you are editing before restoring a version.

} {loading &&

Loading...

} {!loading && items && items.length === 0 &&

No earlier versions yet. One is kept each time you save.

} {days.map((g) => ( @@ -210,8 +216,8 @@ export const HistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ {relativeTime(it.at)} - - + +
{pending && pending.id === it.id && ( diff --git a/craft/src/panels/right/SiteDesignPanel.reset.test.tsx b/craft/src/panels/right/SiteDesignPanel.reset.test.tsx index 7a3b185..7bcf70e 100644 --- a/craft/src/panels/right/SiteDesignPanel.reset.test.tsx +++ b/craft/src/panels/right/SiteDesignPanel.reset.test.tsx @@ -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; + } + }); }); diff --git a/craft/src/panels/right/SiteDesignPanel.tsx b/craft/src/panels/right/SiteDesignPanel.tsx index 672b75b..efa7311 100644 --- a/craft/src/panels/right/SiteDesignPanel.tsx +++ b/craft/src/panels/right/SiteDesignPanel.tsx @@ -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 = () => {
+ {isEditingGlobal && ( +
+ Click Done on the global section you are editing before loading a template. +
+ )} + {/* Template Grid */}
@@ -313,7 +323,7 @@ export const TemplateModal: React.FC = ({ open, onClose }) = setConfirmTemplate(tpl)} + onSelect={() => { if (!isEditingGlobal) setConfirmTemplate(tpl); }} /> ))} {filtered.length === 0 && ( diff --git a/craft/src/panels/topbar/TopBar.test.tsx b/craft/src/panels/topbar/TopBar.test.tsx index e91d488..e35174f 100644 --- a/craft/src/panels/topbar/TopBar.test.tsx +++ b/craft/src/panels/topbar/TopBar.test.tsx @@ -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('button[aria-label="Templates"]')!.disabled).toBe(false); + act(() => { root.unmount(); }); + container.remove(); + checkFixture.editingGlobal = true; + render(); + expect(container.querySelector('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(); diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx index d0f5ea6..11a11a3 100644 --- a/craft/src/panels/topbar/TopBar.tsx +++ b/craft/src/panels/topbar/TopBar.tsx @@ -46,7 +46,7 @@ export const TopBar: React.FC = ({ 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 = ({ 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 = ({ showGuides, onToggleGuides }) => onPreview={handlePreview} sitesmithNode={ openSitesmith()} disabled={!canSave} />} editingDisabled={!canSave} + templatesDisabled={isEditingGlobal} /> setTemplateModalOpen(false)} /> @@ -531,7 +532,7 @@ export const TopBar: React.FC = ({ showGuides, onToggleGuides }) => -
-