2026-10-07 11:52:25 -07:00
|
|
|
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
|
|
|
|
|
import React from 'react';
|
|
|
|
|
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
|
|
|
|
|
import { ProjectExtrasProvider, useProjectExtras } from '../../state/ProjectExtrasContext';
|
|
|
|
|
import { SaveGuardProvider } from '../../state/SaveGuardContext';
|
|
|
|
|
import { SiteDesignProvider } from '../../state/SiteDesignContext';
|
|
|
|
|
import { __resetCommandsForTests, getCommand } from '../../editor/commands';
|
|
|
|
|
import { SectionLibraryModal, openSectionLibrary, closeSectionLibrary } from './SectionLibraryModal';
|
|
|
|
|
import { BlocksPanel } from '../left/BlocksPanel';
|
|
|
|
|
|
|
|
|
|
let h: EditorHarness;
|
|
|
|
|
let extrasApi: ReturnType<typeof useProjectExtras>;
|
|
|
|
|
const Grab: React.FC = () => { extrasApi = useProjectExtras(); return null; };
|
|
|
|
|
|
|
|
|
|
function mount() {
|
|
|
|
|
h = renderEditorHarness({
|
|
|
|
|
wrap: (tree) => (
|
|
|
|
|
<SaveGuardProvider><ProjectExtrasProvider><SiteDesignProvider>{tree}<Grab /></SiteDesignProvider></ProjectExtrasProvider></SaveGuardProvider>
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
h.mountChild(<><SectionLibraryModal /><BlocksPanel /></>);
|
|
|
|
|
}
|
|
|
|
|
const $ = (sel: string) => h.container.querySelector(sel) as HTMLElement | null;
|
|
|
|
|
const $$ = (sel: string) => [...h.container.querySelectorAll(sel)] as HTMLElement[];
|
|
|
|
|
const rootKids = () => h.query.node('ROOT').get().data.nodes as string[];
|
|
|
|
|
const type = (el: HTMLInputElement, v: string) => {
|
|
|
|
|
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
|
|
|
|
|
h.act(() => { setter.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
beforeEach(() => __resetCommandsForTests());
|
|
|
|
|
afterEach(() => { h?.unmount(); closeSectionLibrary(); });
|
|
|
|
|
|
|
|
|
|
describe('SectionLibraryModal', () => {
|
|
|
|
|
test('closed by default; Browse sections button in the Blocks panel opens it', () => {
|
|
|
|
|
mount();
|
|
|
|
|
expect($('.section-lib')).toBeNull();
|
|
|
|
|
h.act(() => { ($$('button').find((b) => b.textContent?.includes('Browse sections')))!.click(); });
|
|
|
|
|
expect($('.section-lib')).not.toBeNull();
|
|
|
|
|
expect($$('.section-lib-card').length).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('thumbnails are sandboxed, script-less iframes in the site palette', () => {
|
|
|
|
|
mount();
|
|
|
|
|
h.act(() => openSectionLibrary());
|
|
|
|
|
const frame = $('iframe.section-lib-frame') as HTMLIFrameElement;
|
|
|
|
|
expect(frame.getAttribute('sandbox')).toBe('');
|
|
|
|
|
expect(frame.getAttribute('srcdoc')).toContain('--wsb-primary:');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('family chip filters and search narrows', () => {
|
|
|
|
|
mount();
|
|
|
|
|
h.act(() => openSectionLibrary());
|
|
|
|
|
const chip = (l: string) => $$('.section-lib-chip').find((c) => c.textContent === l)!;
|
|
|
|
|
h.act(() => chip('Hero').click());
|
|
|
|
|
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toEqual(['hero-centered']);
|
|
|
|
|
h.act(() => chip('All').click());
|
|
|
|
|
type($('.section-lib-search') as HTMLInputElement, 'form');
|
|
|
|
|
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toContain('contact-form');
|
|
|
|
|
type($('.section-lib-search') as HTMLInputElement, 'zzzz');
|
|
|
|
|
expect($$('.section-lib-card')).toHaveLength(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('clicking a card inserts the section, selects it and closes', () => {
|
|
|
|
|
mount();
|
|
|
|
|
h.act(() => openSectionLibrary());
|
|
|
|
|
h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); });
|
|
|
|
|
expect(rootKids()).toHaveLength(1);
|
|
|
|
|
expect(h.query.getEvent('selected').all()).toEqual([rootKids()[0]]);
|
|
|
|
|
expect($('.section-lib')).toBeNull();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('registers insert.section-library and site.save-as-section (only for a top-level section)', () => {
|
|
|
|
|
mount();
|
|
|
|
|
expect(getCommand('insert.section-library')).toBeTruthy();
|
|
|
|
|
const save = getCommand('site.save-as-section')!;
|
|
|
|
|
const ctx = (ids: string[]) => ({ query: h.query, selectedIds: ids } as any);
|
|
|
|
|
expect(save.when!(ctx([]))).toBe(false);
|
|
|
|
|
h.act(() => openSectionLibrary());
|
|
|
|
|
h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); });
|
|
|
|
|
const id = rootKids()[0];
|
|
|
|
|
expect(save.when!(ctx([id]))).toBe(true);
|
|
|
|
|
const inner = h.query.node(id).decendants(true)[0];
|
|
|
|
|
expect(save.when!(ctx([inner]))).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('save current section -> extras.customSections, shown under My sections, deletable', () => {
|
|
|
|
|
mount();
|
|
|
|
|
h.act(() => openSectionLibrary());
|
|
|
|
|
h.act(() => { ($('[data-section-id="hero-centered"] .section-lib-pick') as HTMLElement).click(); });
|
|
|
|
|
h.act(() => openSectionLibrary());
|
|
|
|
|
h.act(() => { $$('button').find((b) => b.textContent?.includes('Save current section'))!.click(); });
|
|
|
|
|
type($('input[aria-label="Section name"]') as HTMLInputElement, 'My hero');
|
|
|
|
|
h.act(() => { $$('button').find((b) => b.textContent === 'Save')!.click(); });
|
|
|
|
|
const saved = extrasApi.get<any[]>('customSections', []);
|
|
|
|
|
expect(saved).toHaveLength(1);
|
|
|
|
|
expect(saved[0]).toMatchObject({ name: 'My hero', family: 'custom' });
|
|
|
|
|
expect(saved[0].tree.type.resolvedName).toBe('Section');
|
|
|
|
|
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toEqual([saved[0].id]);
|
|
|
|
|
h.act(() => { ($('.section-lib-delete') as HTMLElement).click(); });
|
|
|
|
|
expect(extrasApi.get<any[]>('customSections', [])).toHaveLength(0);
|
|
|
|
|
});
|
2026-10-07 11:54:53 -07:00
|
|
|
test('sections loaded silently after mount (replaceAll) appear in My sections on open', () => {
|
|
|
|
|
mount();
|
|
|
|
|
// Mirrors useWhpApi: the project load calls replaceAll, which does not re-render.
|
|
|
|
|
h.act(() => {
|
|
|
|
|
extrasApi.replaceAll({
|
|
|
|
|
customSections: [{
|
|
|
|
|
id: 'custom-loaded', name: 'Loaded hero', family: 'custom', createdAt: 1,
|
|
|
|
|
tree: { type: { resolvedName: 'Section' }, props: {} },
|
|
|
|
|
}],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
h.act(() => openSectionLibrary());
|
|
|
|
|
expect($$('.section-lib-card').map((c) => c.dataset.sectionId)).toContain('custom-loaded');
|
|
|
|
|
});
|
2026-10-07 11:52:25 -07:00
|
|
|
});
|