fix(history): restore each page's menu settings (nav)

snapshotToPages mapped only id/name/slug/craftState/seo and
planPageRestore built/replaced pages without nav, so 'Restore site' and
'Restore one page' dropped every page's nav (show/parentId/label). The
NavSync effect then rewrote the follow-pages header: hidden pages
reappeared, dropdown nesting flattened, custom labels reverted.

SnapshotPage now carries `nav?: PageNav`, accepted only when it is a
plain non-null, non-array object (shallow-copied), else omitted.
planPageRestore restores it in id/slug replace mode (like seo) and on
append. HistoryModal already passes these rows straight to
setPagesCraftState, which keeps nav, so no wiring change was needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 13:20:24 -07:00
1 parent c00985a3a1
commit 4b18d68389
3 files changed
+114 -11

No files matched your search

@@ -147,6 +147,35 @@ describe('HistoryModal', () => {
expect(live.design.design.primaryColor).not.toBe('#abcdef');
});
test('Restore site and Restore one page carry each page\'s menu settings (nav) -- finding 3', async () => {
const nav = { show: false, parentId: 'home', label: 'Who we are' };
const navSnap = {
...snapshot,
pages_craft_state: [
snapshot.pages_craft_state[0],
{ ...snapshot.pages_craft_state[1], nav },
{ id: 'p3', name: 'Team', slug: 'team', craftState: state('Snapshot team'), nav: 'bogus' },
],
};
mockApi({ snap: navSnap });
mount(cfg, <HistoryModal open onClose={() => {}} />);
await flush();
await click(btn('Restore site…', row(0)));
await click(btn('Load this version'));
expect(live.pages.pages.map((p) => p.nav)).toEqual([undefined, nav, undefined]);
// Restore one page: current About has different menu settings.
await h.act(async () => {
live.pages.setPagesCraftState([
{ id: 'home', name: 'Home', slug: 'index', craftState: state('Current home') },
{ id: 'p2', name: 'About', slug: 'about', craftState: state('Current about'), nav: { show: true, label: 'Now' } },
]);
});
await click(btn('Restore one page…', row(0)));
await click(btn('About', document.body.querySelector('.wsb-history-pages')!));
expect(live.pages.pages[1].nav).toEqual(nav);
});
test('Restore one page on the active page puts it on the canvas', async () => {
mockApi();
mount(cfg, <HistoryModal open onClose={() => {}} />);
+61
View File
@@ -126,3 +126,64 @@ describe('buildSnapshotPreviewHtml', () => {
expect(html).toContain('<title>Page A</title>');
});
});
/**
* Finding 3: snapshots store each page's menu settings in `nav`
* (show / parentId / label). Restore used to drop them, so hidden pages
* reappeared, dropdown nesting flattened and custom labels reverted.
*/
describe('page nav survives restore (finding 3)', () => {
const nav = { show: false, parentId: 'home', label: 'Who we are' };
const withNav = (navValue: unknown) => ({
pages_craft_state: [
{ id: 'home', name: 'Home', slug: 'index', craftState: state('h') },
{ id: 'p2', name: 'About', slug: 'about', craftState: state('a'), nav: navValue },
],
});
test('snapshotToPages carries a plain-object nav', () => {
const pages = snapshotToPages(withNav(nav));
expect(pages[1].nav).toEqual(nav);
expect(pages[0]).not.toHaveProperty('nav');
});
test.each([['a string', 'x'], ['an array', [{ show: false }]], ['null', null], ['a number', 3], ['a boolean', true]])(
'snapshotToPages drops nav when it is %s',
(_label, bad) => {
const pages = snapshotToPages(withNav(bad));
expect(pages[1]).not.toHaveProperty('nav');
},
);
const current: PageData[] = [
{ id: 'home', name: 'Home', slug: 'index', craftState: 'cur-home' },
{ id: 'p2', name: 'About now', slug: 'about', craftState: 'cur-about', nav: { show: true, label: 'Current label' } },
];
test('planPageRestore replace-by-id restores the snapshot nav', () => {
const [snapPage] = snapshotToPages(withNav(nav)).slice(1);
const plan = planPageRestore(current, snapPage);
expect(plan.mode).toBe('id');
expect(plan.pages[1].nav).toEqual(nav);
expect(plan.pages[1].name).toBe('About now');
});
test('planPageRestore replace-by-slug restores the snapshot nav', () => {
const plan = planPageRestore(current, { id: 'old', name: 'About', slug: 'about', craftState: 'snap', nav });
expect(plan.mode).toBe('slug');
expect(plan.pages[1].nav).toEqual(nav);
});
test('planPageRestore append carries the snapshot nav onto the new page', () => {
const plan = planPageRestore(current, { id: 'gone', name: 'Deleted', slug: 'deleted', craftState: 'snap', nav });
expect(plan.mode).toBe('append');
expect(plan.pages[2].nav).toEqual(nav);
});
test('restored nav is a copy, not the snapshot object', () => {
const snapNav = { show: false, label: 'L' };
const [, p] = snapshotToPages(withNav(snapNav));
expect(p.nav).toEqual(snapNav);
expect(p.nav).not.toBe(snapNav);
});
});
+24 -11
View File
@@ -1,4 +1,4 @@
import type { PageData, PageSeo } from '../types';
import type { PageData, PageNav, PageSeo } from '../types';
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
import { exportBodyHtml, exportToHtml, buildAnimationScript } from './html-export';
@@ -15,6 +15,8 @@ export interface SnapshotPage {
slug: string;
craftState: string | null;
seo?: PageSeo;
/** Menu settings (#53: show / parentId / label); absent when the snapshot has none. */
nav?: PageNav;
}
export interface Snapshot {
@@ -33,19 +35,29 @@ const asState = (v: unknown): string | null => {
return null;
};
/** A snapshot page's `nav`, only when it is a plain non-null, non-array object
* (shallow-copied so the restored page never aliases the snapshot); else undefined. */
const asNav = (v: unknown): PageNav | undefined =>
v && typeof v === 'object' && !Array.isArray(v) ? { ...(v as PageNav) } : undefined;
/** The snapshot's pages as editor PageData-shaped rows (craftState always a string or null). */
export function snapshotToPages(snapshot: Snapshot): SnapshotPage[] {
const raw = snapshot?.pages_craft_state;
if (!Array.isArray(raw)) return [];
return raw
.filter((p) => p && typeof p === 'object')
.map((p: any, i) => ({
id: typeof p.id === 'string' && p.id ? p.id : `restored_${i}`,
name: typeof p.name === 'string' ? p.name : `Page ${i + 1}`,
slug: typeof p.slug === 'string' ? p.slug : `page-${i + 1}`,
craftState: asState(p.craftState),
seo: p.seo && typeof p.seo === 'object' ? (p.seo as PageSeo) : undefined,
}));
.map((p: any, i) => {
const row: SnapshotPage = {
id: typeof p.id === 'string' && p.id ? p.id : `restored_${i}`,
name: typeof p.name === 'string' ? p.name : `Page ${i + 1}`,
slug: typeof p.slug === 'string' ? p.slug : `page-${i + 1}`,
craftState: asState(p.craftState),
seo: p.seo && typeof p.seo === 'object' ? (p.seo as PageSeo) : undefined,
};
const nav = asNav(p.nav);
if (nav) row.nav = nav;
return row;
});
}
export function snapshotExtras(snapshot: Snapshot): Record<string, unknown> {
@@ -95,7 +107,8 @@ export interface PageRestorePlan {
/**
* Replace one page with its snapshot version: match by id, else slug, else
* append as a new page. A matched page keeps its identity (id, name, slug) so
* links and the landing-page rule stay put; only content and SEO come back.
* links and the landing-page rule stay put; content, SEO and menu settings
* (`nav`) come back.
*/
export function planPageRestore(currentPages: PageData[], snapshotPage: SnapshotPage): PageRestorePlan {
let idx = currentPages.findIndex((p) => p.id === snapshotPage.id);
@@ -105,10 +118,10 @@ export function planPageRestore(currentPages: PageData[], snapshotPage: Snapshot
mode = 'slug';
}
if (idx === -1) {
const added: PageData = { id: snapshotPage.id, name: snapshotPage.name, slug: snapshotPage.slug, craftState: snapshotPage.craftState, seo: snapshotPage.seo };
const added: PageData = { id: snapshotPage.id, name: snapshotPage.name, slug: snapshotPage.slug, craftState: snapshotPage.craftState, seo: snapshotPage.seo, nav: snapshotPage.nav };
return { pages: [...currentPages, added], targetId: added.id, mode: 'append' };
}
const pages = currentPages.map((p, i) => (i === idx ? { ...p, craftState: snapshotPage.craftState, seo: snapshotPage.seo } : p));
const pages = currentPages.map((p, i) => (i === idx ? { ...p, craftState: snapshotPage.craftState, seo: snapshotPage.seo, nav: snapshotPage.nav } : p));
return { pages, targetId: currentPages[idx].id, mode };
}