Files
site-builder/craft/src/panels/sections/SectionLibraryModal.test.tsx
T

117 lines
5.7 KiB
TypeScript
Raw Normal View History

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);
});
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');
});
});