Files
site-builder/craft/src/state/GlobalSectionsContext.test.tsx
T
shadowdaoandClaude Opus 5.5 996c9e92ec fix(global-sections): save/autosave/publish inside a master zone persist the live master
The zone's live master was neither in extras nor in the stored pages until
Done, so a save from inside the zone succeeded without it and cleared dirty;
closing the tab lost the edit (publish shipped stale instances). The save
payload now carries the master committed into the library and every stored
page/header/footer synced, computed exactly as Done would without touching the
editor (PageContext.globalZoneSaveView).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:23:46 -07:00

566 lines
27 KiB
TypeScript

import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { RenderNode } from '../editor/RenderNode';
import { PageProvider, usePages } from './PageContext';
import { GlobalSectionsProvider, useGlobalSections } from './GlobalSectionsContext';
import { ProjectExtrasProvider, useProjectExtras } from './ProjectExtrasContext';
import { SaveGuardProvider, useSaveGuard } from './SaveGuardContext';
import { NoticeProvider } from './NoticeContext';
import { SiteDesignProvider, DEFAULT_SITE_DESIGN } from './SiteDesignContext';
import { buildNodeTree } from '../utils/craft-tree';
import { exportBodyHtml } from '../utils/html-export';
import { buildSavePayload, useWhpApi } from '../hooks/useWhpApi';
import { EditorConfigProvider } from './EditorConfigContext';
import { vi } from 'vitest';
import { GLOBAL_SECTIONS_KEY, instanceMeta, type GlobalLibrary, type SNodes } from '../utils/global-sections';
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 +
* ProjectExtrasProvider (no mocks): make global, place on a second page,
* edit the master once, per-page overrides, detach, remove guard.
*/
let h: EditorHarness | null = null;
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>;
let api: ReturnType<typeof useWhpApi> | null = null;
const ApiProbe: React.FC = () => { api = useWhpApi(); return null; };
const WHP_CFG = { user: 'u', apiUrl: '/api/sb', csrfToken: 't', siteId: 1, siteDomain: 'example.com', siteName: 'S', backUrl: '/', isRoot: false };
/** Like mount(), plus a real useWhpApi (fetch mocked by the caller). */
function mountWithApi(): EditorHarness {
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 /><ApiProbe /></GlobalSectionsProvider></PageProvider>);
h.act(() => { guard.setLoadState('loaded'); });
return h;
}
const Probe: React.FC = () => {
pages = usePages();
globals = useGlobalSections();
extras = useProjectExtras();
guard = useSaveGuard();
applyAi = useApplyAiResponse();
return null;
};
afterEach(() => { h?.unmount(); h = null; api = null; vi.unstubAllGlobals(); closeSectionLibrary(); document.body.innerHTML = ''; });
function mount(ui: React.ReactNode = null): EditorHarness {
h = renderEditorHarness({
onRender: RenderNode,
wrap: (tree) => (
<SaveGuardProvider><NoticeProvider><ProjectExtrasProvider><SiteDesignProvider>{tree}</SiteDesignProvider></ProjectExtrasProvider></NoticeProvider></SaveGuardProvider>
),
});
h.mountChild(<PageProvider><GlobalSectionsProvider><Probe />{ui}</GlobalSectionsProvider></PageProvider>);
return h;
}
/** Flush PageContext.loadState's setTimeout(0) deserialize. */
async function flush() {
await h!.act(async () => { await new Promise((r) => setTimeout(r, 0)); });
}
/** A Section holding Heading / Text / Button, appended to ROOT. */
function addPromo(): string {
let id = '';
h!.act(() => {
const tree = buildNodeTree(h!.query, {
type: { resolvedName: 'Section' },
props: {},
nodes: [
{ type: { resolvedName: 'Heading' }, props: { text: 'Welcome', styleMobile: { fontSize: '22px' } } },
{ type: { resolvedName: 'TextBlock' }, props: { text: 'Body copy' } },
{ type: { resolvedName: 'ButtonLink' }, props: { text: 'Go', href: '#a' } },
],
});
h!.actions.addNodeTree(tree, 'ROOT');
id = tree.rootNodeId;
});
h!.act(() => { h!.actions.history.clear(); });
return id;
}
const live = (): SNodes => JSON.parse(h!.getSerialized());
const lib = (): GlobalLibrary => extras.get<GlobalLibrary>(GLOBAL_SECTIONS_KEY, {});
const byType = (nodes: SNodes, name: string, under?: string) =>
Object.entries(nodes).filter(([id, n]) => {
if (n.type.resolvedName !== name) return false;
if (!under) return true;
for (let p: string | null = id; p; p = nodes[p]?.parent ?? null) if (p === under) return true;
return false;
}).map(([id]) => id);
const instanceRoots = (nodes: SNodes) => Object.keys(nodes).filter((id) => instanceMeta(nodes[id]));
function undoDepth(): number {
let n = 0;
while (h!.query.history.canUndo() && n < 50) { h!.act(() => { h!.actions.history.undo(); }); n++; }
return n;
}
describe('#57 global sections', () => {
test('make a section global: master with gkeys; the original becomes instance #1 (one undo step)', () => {
mount();
const sec = addPromo();
let libraryId: string | null = null;
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo'); });
expect(libraryId).toBeTruthy();
const master = lib()[libraryId!];
expect(master.name).toBe('Promo');
expect(master.rev).toBe(1);
const masterNodes = Object.values(master.nodes);
expect(masterNodes.length).toBe(5); // Section, inner Container, Heading, Text, Button
expect(masterNodes.every((n) => typeof n.custom.gkey === 'string')).toBe(true);
const nodes = live();
expect(instanceMeta(nodes[sec])).toEqual({ libraryId, rev: 1, overrides: {} });
// live gkeys are exactly the master's keys
const liveKeys = Object.keys(nodes).filter((id) => id !== 'ROOT').map((id) => nodes[id].custom.gkey).sort();
expect(liveKeys).toEqual(Object.keys(master.nodes).sort());
expect(globals.usage()).toEqual({ [libraryId!]: 1 });
expect(undoDepth()).toBe(1);
expect(instanceMeta(live()[sec])).toBeNull();
expect(Object.values(live()).some((n) => n.custom?.gkey)).toBe(false);
});
test('insert an instance on page 2: real inlined subtree, static export', async () => {
mount();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { pages.addPage('About', 'about'); });
await flush();
let rootId: string | null = null;
h!.act(() => { rootId = globals.insertInstance(libraryId); });
expect(rootId).toBeTruthy();
const nodes = live();
expect(nodes.ROOT.nodes).toEqual([rootId]);
expect(instanceMeta(nodes[rootId!])!.libraryId).toBe(libraryId);
// fresh ids: none shared with page 1's instance
h!.act(() => { pages.switchPage('home'); });
await flush();
const page2 = pages.pages[1].craftState!;
const p1 = Object.keys(live());
expect(Object.keys(JSON.parse(page2)).filter((id) => id !== 'ROOT' && p1.includes(id))).toEqual([]);
const html = exportBodyHtml(page2).html;
expect(html).toContain('Welcome');
expect(html).toContain('Body copy');
expect(html).not.toContain('gkey');
expect(globals.usage()).toEqual({ [libraryId]: 2 });
});
test('inline edit inside an instance writes an override and the prop, master unchanged, one undo step', () => {
mount();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { h!.actions.history.clear(); });
const [hid] = byType(live(), 'Heading', sec);
const gkey = live()[hid].custom.gkey as string;
const el = h!.query.node(hid).get().dom as HTMLElement;
h!.act(() => { h!.actions.selectNode(hid); });
h!.act(() => { el.innerHTML = 'Hi <b>page one</b>'; el.dispatchEvent(new Event('input', { bubbles: true })); });
h!.act(() => { el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
const nodes = live();
expect(nodes[hid].props.text).toBe('Hi page one');
expect(instanceMeta(nodes[sec])!.overrides[gkey]).toEqual({ text: 'Hi page one', html: 'Hi <b>page one</b>' });
expect(lib()[libraryId].nodes[gkey].props.text).toBe('Welcome');
h!.act(() => { h!.actions.selectNode(); });
expect(undoDepth()).toBe(1);
expect(live()[hid].props.text).toBe('Welcome');
expect(instanceMeta(live()[sec])!.overrides).toEqual({});
});
test('a plain node outside any instance commits without an override (no custom write)', () => {
mount();
const sec = addPromo();
const [hid] = byType(live(), 'Heading', sec);
const el = h!.query.node(hid).get().dom as HTMLElement;
h!.act(() => { h!.actions.selectNode(hid); });
h!.act(() => { el.innerHTML = 'Plain'; el.dispatchEvent(new Event('input', { bubbles: true })); });
h!.act(() => { el.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
expect(live()[hid].props.text).toBe('Plain');
expect(live()[sec].custom).toEqual({});
});
test('edit the master once: every instance updates, overrides survive, rev+1, dirty, ONE undo step on the canvas', 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)!; });
// page 2 overrides its button link through the panel path
const [btn2] = byType(live(), 'ButtonLink', inst2);
const btnKey = live()[btn2].custom.gkey as string;
h!.act(() => { globals.setOverride(btn2, { href: '/contact' }); });
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
// Edit the master (a zone switch: fresh history)
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
expect(pages.isEditingGlobal).toBe(true);
expect(pages.globalEditingId).toBe(libraryId);
expect(h!.query.history.canUndo()).toBe(false);
expect(live()[headKey].props.text).toBe('Welcome');
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Fresh headline'; }); });
h!.act(() => { h!.actions.setProp(btnKey, (p: any) => { p.text = 'Book now'; }); });
const v0 = guard.getVersion();
h!.act(() => { globals.finishEditingMaster(); });
await flush();
expect(pages.isEditingGlobal).toBe(false);
expect(pages.activePageId).toBe(pages.pages[1].id); // back where we came from
expect(lib()[libraryId].rev).toBe(2);
expect(lib()[libraryId].nodes[headKey].props.text).toBe('Fresh headline');
expect(guard.getVersion()).toBeGreaterThan(v0);
expect(guard.isDirty()).toBe(true);
// live canvas (page 2): new master content, page override kept, per-breakpoint style intact
const p2 = live();
const [root2] = instanceRoots(p2);
expect(root2).toBe(inst2); // the root id is stable
expect(instanceMeta(p2[root2])!.rev).toBe(2);
const head2 = byType(p2, 'Heading', root2)[0];
expect(p2[head2].props.text).toBe('Fresh headline');
expect(p2[head2].props.styleMobile).toEqual({ fontSize: '22px' });
const b2 = byType(p2, 'ButtonLink', root2)[0];
expect(p2[b2].props).toMatchObject({ text: 'Book now', href: '/contact' });
// stored page 1 got the update too
const p1 = JSON.parse(pages.pages[0].craftState!) as SNodes;
expect(p1[byType(p1, 'Heading', sec)[0]].props.text).toBe('Fresh headline');
expect(p1[byType(p1, 'ButtonLink', sec)[0]].props.href).toBe('#a');
expect(exportBodyHtml(pages.pages[0].craftState!).html).toContain('Fresh headline');
// exactly one undoable step: the sync on this page
expect(undoDepth()).toBe(1);
expect(live()[byType(live(), 'Heading', inst2)[0]].props.text).toBe('Welcome');
});
test('Done without changes: no rev bump, no sync, nothing to undo', async () => {
mount();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
const before = h!.getSerialized();
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
let r: any;
h!.act(() => { r = globals.finishEditingMaster(); });
await flush();
expect(r).toEqual({ changed: false, empty: false, dropped: 0 });
expect(lib()[libraryId].rev).toBe(1);
expect(h!.query.history.canUndo()).toBe(false);
expect(instanceRoots(live())).toEqual([sec]);
// same nodes back (deserialize may fill in component defaults; ids and content are untouched)
expect(Object.keys(live()).sort()).toEqual(Object.keys(JSON.parse(before)).sort());
expect(instanceMeta(live()[sec])!.rev).toBe(1);
});
test('switching pages out of the master zone also commits it; save never writes the master into a page slot', async () => {
mount();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { pages.addPage('About', 'about'); });
await flush();
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Via switch'; }); });
const payload = buildSavePayload({
siteId: 1, siteName: 'S', liveCraftState: h!.getSerialized(), pages: pages.pages,
headerPage: pages.headerPage, footerPage: pages.footerPage, activePageId: pages.activePageId,
isEditingHeader: pages.isEditingHeader, isEditingFooter: pages.isEditingFooter,
headCode: '', design: DEFAULT_SITE_DESIGN,
});
for (const p of payload.pages_craft_state) expect(p.craftState ?? '').not.toContain('Via switch');
expect(payload.craft_state ?? '').not.toContain('Via switch');
h!.act(() => { pages.switchPage('home'); });
await flush();
expect(pages.activePageId).toBe('home');
expect(lib()[libraryId].rev).toBe(2);
expect(live()[byType(live(), 'Heading', sec)[0]].props.text).toBe('Via switch');
});
test('save / autosave while the master zone is open persists the LIVE master and the synced instances', async () => {
const bodies: any[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
if (String(url).includes('action=save')) bodies.push(JSON.parse(init.body));
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, rev: 'r' + bodies.length }), text: async () => '' } as any;
}));
mountWithApi();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { pages.addPage('About', 'about'); });
await flush();
h!.act(() => { globals.insertInstance(libraryId); });
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Live master'; }); });
h!.act(() => { guard.markDirty(); });
let res: any;
await h!.act(async () => { res = await api!.save(); });
expect(res.success).toBe(true);
const body = bodies[bodies.length - 1];
// The library in the payload holds the live master (rev bumped as Done would).
const savedMaster = body.extras.globalSections[libraryId];
expect(savedMaster.rev).toBe(2);
expect(JSON.stringify(savedMaster.nodes)).toContain('Live master');
// Every stored instance is synced in the payload, on both pages, and in the published html.
expect(body.pages_craft_state).toHaveLength(2);
for (const p of body.pages_craft_state) expect(p.craftState).toContain('Live master');
for (const p of body.pages) expect(p.html).toContain('Live master');
// The zone itself never lands in a page slot.
for (const p of body.pages_craft_state) expect(JSON.parse(p.craftState).ROOT).toBeTruthy();
expect(guard.isDirty()).toBe(false);
// Still in the zone; the editor itself was not rewritten under the user.
expect(pages.isEditingGlobal).toBe(true);
// Done afterwards commits the same master: rev 2, instance on the page shows it.
h!.act(() => { globals.finishEditingMaster(); });
await flush();
expect(lib()[libraryId].rev).toBe(2);
expect(JSON.stringify(live())).toContain('Live master');
});
test('publish from inside the master zone saves the live master first', async () => {
const calls: Array<{ action: string; body: any }> = [];
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: any) => {
const action = /action=(\w+)/.exec(String(url))![1];
calls.push({ action, body: init?.body ? JSON.parse(init.body) : null });
return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => ({ success: true, rev: 'r' + calls.length }), text: async () => '' } as any;
}));
mountWithApi();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Publish me'; }); });
await h!.act(async () => { await api!.publish(); });
expect(calls.map((c) => c.action)).toEqual(['save', 'publish']);
expect(calls[0].body.pages[0].html).toContain('Publish me');
expect(JSON.stringify(calls[0].body.extras.globalSections[libraryId].nodes)).toContain('Publish me');
});
test('detach: plain copy (one undo step) that later master edits never touch', async () => {
mount();
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => { h!.actions.history.clear(); });
const [hid] = byType(live(), 'Heading', sec);
h!.act(() => { expect(globals.detach(hid)).toBe(true); });
expect(instanceMeta(live()[sec])).toBeNull();
expect(Object.values(live()).some((n) => n.custom?.gkey)).toBe(false);
expect(globals.usage()).toEqual({});
expect(undoDepth()).toBe(1);
expect(instanceMeta(live()[sec])!.libraryId).toBe(libraryId);
h!.act(() => { globals.detach(sec); });
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'Changed'; }); });
h!.act(() => { globals.finishEditingMaster(); });
await flush();
expect(lib()[libraryId].rev).toBe(2);
expect(live()[hid].props.text).toBe('Welcome');
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();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
let ok = true;
h!.act(() => { ok = globals.remove(libraryId); });
expect(ok).toBe(false);
expect(lib()[libraryId]).toBeTruthy();
h!.act(() => { h!.actions.delete(sec); });
h!.act(() => { ok = globals.remove(libraryId); });
expect(ok).toBe(true);
expect(lib()[libraryId]).toBeUndefined();
});
test('canvas marks instance roots and inner nodes with editor-only attributes', () => {
mount();
const sec = addPromo();
h!.act(() => { globals.makeGlobal(sec, 'Promo'); });
const [hid] = byType(live(), 'Heading', sec);
expect(h!.query.node(sec).get().dom.hasAttribute('data-wsb-global')).toBe(true);
expect(h!.query.node(hid).get().dom.hasAttribute('data-wsb-global-inner')).toBe(true);
expect(exportBodyHtml(h!.getSerialized()).html).not.toContain('data-wsb-global');
});
describe('UI', () => {
const $ = (sel: string) => document.body.querySelector(sel) as HTMLElement | null;
const btn = (label: string) =>
Array.from(document.body.querySelectorAll('button')).find((b) => b.textContent?.includes(label)) as HTMLButtonElement | undefined;
const typeInto = (el: HTMLInputElement, v: string) => h!.act(() => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(el, v);
el.dispatchEvent(new Event('input', { bubbles: true }));
});
test('GuidedStyles: instance nodes get the global panel instead of style panels; override field writes an override', () => {
mount(<GuidedStyles />);
const sec = addPromo();
h!.act(() => { globals.makeGlobal(sec, 'Promo'); });
const [bid] = byType(live(), 'ButtonLink', sec);
const gkey = live()[bid].custom.gkey as string;
h!.act(() => { h!.actions.selectNode(bid); });
expect($('[data-testid="global-instance-panel"]')).not.toBeNull();
expect($('[data-testid="global-instance-panel"]')!.textContent).toContain('Promo');
expect(btn('Edit master')).toBeTruthy();
expect(btn('Detach')).toBeTruthy();
const link = document.body.querySelector('input[aria-label="Override link"]') as HTMLInputElement;
typeInto(link, '/book');
h!.act(() => { link.dispatchEvent(new FocusEvent('focusout', { bubbles: true })); });
expect(live()[bid].props.href).toBe('/book');
expect(instanceMeta(live()[sec])!.overrides[gkey]).toEqual({ href: '/book' });
// Reset to master restores the master value and drops the override
h!.act(() => { btn('Reset to master')!.click(); });
expect(live()[bid].props.href).toBe('#a');
expect(instanceMeta(live()[sec])!.overrides[gkey]).toBeUndefined();
// a detached copy is styled normally again
h!.act(() => { btn('Detach')!.click(); });
expect($('[data-testid="global-instance-panel"]')).toBeNull();
});
test('ContextMenu: Make global section… on a top-level section; Edit master / Detach on an instance', () => {
mount();
const sec = addPromo();
const [hid] = byType(live(), 'Heading', sec);
const menu = (nodeId: string) => h!.mountChild(
<PageProvider><GlobalSectionsProvider><Probe /><ContextMenu visible x={10} y={10} nodeId={nodeId} onClose={() => {}} /></GlobalSectionsProvider></PageProvider>,
);
menu(hid);
expect(btn('Make global section')!.disabled).toBe(true); // not a top-level section
menu(sec);
expect(btn('Make global section')!.disabled).toBe(false);
h!.act(() => { btn('Make global section')!.click(); });
const name = document.body.querySelector('#wsb-global-name') as HTMLInputElement;
typeInto(name, 'Footer promo');
h!.act(() => { (document.body.querySelector('[aria-label="Make global section"] .wsb-presets-btn') as HTMLElement).click(); });
const ids = Object.keys(lib());
expect(ids).toHaveLength(1);
expect(lib()[ids[0]].name).toBe('Footer promo');
menu(hid);
expect(btn('Make global section')).toBeUndefined();
expect(btn('Paste style (merge)')!.disabled).toBe(true);
expect(btn('Detach from global')).toBeTruthy();
h!.act(() => { btn('Edit master')!.click(); });
expect(pages.isEditingGlobal).toBe(true);
});
test('Section library Global tab: usage counts, insert an instance, delete refused while used', () => {
mount(<SectionLibraryModal />);
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
h!.act(() => openSectionLibrary());
h!.act(() => { btn('Global')!.click(); });
const card = () => document.body.querySelector(`[data-global-id="${libraryId}"]`) as HTMLElement;
expect(card()).not.toBeNull();
expect(card().textContent).toContain('Used 1 time');
h!.act(() => { (card().querySelector('.section-lib-delete') as HTMLElement).click(); });
expect(lib()[libraryId]).toBeTruthy();
h!.act(() => { (card().querySelector('.section-lib-pick') as HTMLElement).click(); });
expect(instanceRoots(live())).toHaveLength(2);
expect(globals.usage()[libraryId]).toBe(2);
});
test('Canvas: banner while editing a master; Done updates the instances', async () => {
mount(<Canvas showGuides />);
const sec = addPromo();
let libraryId = '';
h!.act(() => { libraryId = globals.makeGlobal(sec, 'Promo')!; });
expect($('[data-testid="global-master-banner"]')).toBeNull();
h!.act(() => { pages.editGlobal(libraryId); });
await flush();
const banner = $('[data-testid="global-master-banner"]')!;
expect(banner.textContent).toContain('Editing global section: Promo');
expect(banner.textContent).toContain('Done - update 1 instance');
const headKey = Object.values(lib()[libraryId].nodes).find((n) => n.type.resolvedName === 'Heading')!.custom.gkey as string;
h!.act(() => { h!.actions.setProp(headKey, (p: any) => { p.text = 'From banner'; }); });
h!.act(() => { ($('[data-testid="global-master-done"]') as HTMLElement).click(); });
await flush();
expect($('[data-testid="global-master-banner"]')).toBeNull();
expect(live()[byType(live(), 'Heading', sec)[0]].props.text).toBe('From banner');
});
});
});