Files
site-builder/craft/src/panels/right/GuidedStyles.multi.test.tsx
T
shadowdaoandClaude Sonnet 5.5 a80667dfe6 feat(site-builder): multi-select with bulk styling and mixed-type actions
Shift/cmd/ctrl-click multi-select via DefaultEventHandlers, same-type bulk
styling as one undo step, mixed-type Delete/Duplicate/Move block, grouped
undo for array-aware edit.* commands.

Refs #50

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-07 12:03:26 -07:00

142 lines
5.8 KiB
TypeScript

import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { SiteDesignProvider } from '../../state/SiteDesignContext';
import { PageProvider } from '../../state/PageContext';
import { SitesmithProvider } from '../../state/SitesmithContext';
import { DeviceProvider } from '../../state/DeviceContext';
import { ButtonLink } from '../../components/basic/ButtonLink';
import { Heading } from '../../components/basic/Heading';
import { isMultiSelectClick, craftHandlers } from '../../App';
import { GuidedStyles } from './GuidedStyles';
import { useNodeProp } from './styles/shared';
let h: EditorHarness;
afterEach(() => h?.unmount());
function mount(device: 'desktop' | 'tablet' = 'desktop') {
h = renderEditorHarness({
wrap: (tree) => <DeviceProvider initialDevice={device}>{tree}</DeviceProvider>,
});
h.mountChild(
<PageProvider><SiteDesignProvider><SitesmithProvider>
<GuidedStyles />
</SitesmithProvider></SiteDesignProvider></PageProvider>,
);
return h;
}
const add = (el: React.ReactElement): string => {
let id = '';
h.act(() => {
const tree = h.query.parseReactElement(el).toNodeTree();
h.actions.addNodeTree(tree, 'ROOT');
id = tree.rootNodeId;
});
return id;
};
const kids = () => h.query.node('ROOT').get().data.nodes as string[];
const props = (id: string) => h.query.node(id).get().data.props;
const select = (ids: string[]) => h.act(() => h.actions.selectNode(ids));
const header = () => h.container.querySelector('[data-testid="type-header"]')?.textContent?.trim();
describe('multi-select: same type', () => {
test('header counts the group; Button colour writes both; ONE undo reverts both', () => {
mount();
const b1 = add(<ButtonLink />);
const b2 = add(<ButtonLink />);
select([b1, b2]);
expect(header()).toContain('2 Buttons selected');
const before1 = props(b1).style?.backgroundColor;
const swatch = h.container.querySelector('.preset-swatch') as HTMLButtonElement;
h.act(() => swatch.click());
const color = props(b1).style.backgroundColor;
expect(color).toBeTruthy();
expect(color).not.toBe(before1);
expect(props(b2).style.backgroundColor).toBe(color);
h.act(() => h.actions.history.undo());
expect(props(b1).style?.backgroundColor).toBe(before1);
expect(props(b2).style?.backgroundColor).toBe(before1);
});
test('single selection is unchanged: only that node is written, header is the plain type', () => {
mount();
const b1 = add(<ButtonLink />);
const b2 = add(<ButtonLink />);
select([b1]);
expect(header()).toBe('Button');
const b2Before = props(b2).style?.backgroundColor;
const b1Before = props(b1).style?.backgroundColor;
h.act(() => (h.container.querySelector('.preset-swatch') as HTMLButtonElement).click());
expect(props(b1).style.backgroundColor).not.toBe(b1Before);
expect(props(b2).style?.backgroundColor).toBe(b2Before);
});
test('tablet device: bulk write goes to styleTablet on every node, never to style', () => {
mount('tablet');
const a = add(<Heading />);
const b = add(<Heading />);
const Probe: React.FC<{ ids: string[] }> = ({ ids }) => {
const { setPropStyle } = useNodeProp(ids);
return <button data-testid="go" onClick={() => setPropStyle('width', '50%')} />;
};
h.mountChild(<DeviceProvider initialDevice="tablet"><Probe ids={[a, b]} /></DeviceProvider>);
h.act(() => (h.container.querySelector('[data-testid="go"]') as HTMLButtonElement).click());
for (const id of [a, b]) {
expect(props(id).styleTablet?.width).toBe('50%');
expect(props(id).style?.width).toBeUndefined();
}
h.act(() => h.actions.history.undo());
for (const id of [a, b]) expect(props(id).styleTablet?.width).toBeUndefined();
});
});
describe('multi-select: mixed types', () => {
test('shows the actions block; Delete removes both in ONE undo step', () => {
mount();
const b = add(<ButtonLink />);
const t = add(<Heading />);
const keep = add(<Heading />);
select([b, t]);
expect(h.container.querySelector('[data-testid="multi-select-actions"]')).not.toBeNull();
h.act(() => (h.container.querySelector('[data-testid="multi-edit.delete"]') as HTMLButtonElement).click());
expect(kids()).toEqual([keep]);
h.act(() => h.actions.history.undo());
expect(kids()).toEqual([b, t, keep]);
});
test('Duplicate puts a copy after each original, in one undo step', () => {
mount();
const b = add(<ButtonLink />);
const t = add(<Heading />);
select([b, t]);
h.act(() => (h.container.querySelector('[data-testid="multi-edit.duplicate"]') as HTMLButtonElement).click());
const k = kids();
expect(k).toHaveLength(4);
expect(k[0]).toBe(b);
expect(k[2]).toBe(t);
expect(h.query.node(k[1]).get().data.displayName).toBe('Button');
expect(h.query.node(k[3]).get().data.displayName).toBe('Heading');
h.act(() => h.actions.history.undo());
expect(kids()).toEqual([b, t]);
});
});
describe('Editor multi-select option', () => {
test('shift / cmd / ctrl enable multi-select, a plain click does not', () => {
const ev = (init: MouseEventInit) => new MouseEvent('mousedown', init);
expect(isMultiSelectClick(ev({ shiftKey: true }))).toBe(true);
expect(isMultiSelectClick(ev({ metaKey: true }))).toBe(true);
expect(isMultiSelectClick(ev({ ctrlKey: true }))).toBe(true);
expect(isMultiSelectClick(ev({}))).toBe(false);
});
test('the Editor handlers carry the option', () => {
const handlers: any = craftHandlers({} as any);
expect(handlers.options.isMultiSelectEnabled(new MouseEvent('mousedown', { shiftKey: true }))).toBe(true);
expect(handlers.options.isMultiSelectEnabled(new MouseEvent('mousedown'))).toBe(false);
});
});