feat(site-builder): section library catalog, modal and insertion
Catalog skeleton with per-family files (one seed each for hero, services, contact), live token-styled thumbnails, insert-after-selection, and save-as-section into project extras. Refs #52 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8860349c9b
commit
6e819f1ef2
23 files changed
+903
-10
No files matched your search
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user