fix(site-builder): one undo step per edit (throttle continuous inputs, single setProp for multi-prop toggles)
Refs #32 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9ab01021be
commit
ac490215ce
20 files changed
+209
-77
No files matched your search
@@ -0,0 +1,131 @@
|
||||
import { describe, test, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../editorHarness';
|
||||
import { useNodeProp, ColorPickerField } from '../../panels/right/styles/shared';
|
||||
import { AnimVisSection } from '../../panels/right/styles/containerBoxModel';
|
||||
import { AnimVisSection as MediaAnimVisSection } from '../../panels/right/styles/mediaBoxModel';
|
||||
|
||||
/**
|
||||
* Undo granularity against a REAL Craft.js history: one user edit = one undo
|
||||
* step. Uses the real style-panel controls (ColorPickerField, AnimVisSection)
|
||||
* wired through the real `useNodeProp`, so a regression to per-tick or
|
||||
* per-prop `setProp` calls shows up as extra undo steps here.
|
||||
*/
|
||||
const INITIAL_STATE = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { style: {}, tag: 'div' },
|
||||
displayName: 'Container',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: ['h-1'],
|
||||
linkedNodes: {},
|
||||
},
|
||||
'h-1': {
|
||||
type: { resolvedName: 'Heading' },
|
||||
isCanvas: false,
|
||||
props: { text: 'Hi', level: 'h2', style: { color: '#000000' } },
|
||||
displayName: 'Heading',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
parent: 'ROOT',
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
},
|
||||
});
|
||||
|
||||
let harness: EditorHarness | null = null;
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(1_000_000); });
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
harness?.unmount();
|
||||
harness = null;
|
||||
});
|
||||
|
||||
const props = () => harness!.query.node('h-1').get().data.props;
|
||||
|
||||
/** How many undos until nothing is left (ignores any pre-existing history). */
|
||||
function undoDepth(): number {
|
||||
let n = 0;
|
||||
while (harness!.query.history.canUndo() && n < 50) {
|
||||
harness!.act(() => { harness!.actions.history.undo(); });
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function setNative(input: HTMLInputElement, value: string) {
|
||||
Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function mountFresh(ui: React.ReactNode) {
|
||||
harness = renderEditorHarness({ initialState: INITIAL_STATE });
|
||||
harness.mountChild(ui);
|
||||
harness.act(() => { harness!.actions.history.clear(); });
|
||||
vi.setSystemTime(2_000_000); // well past the throttle window of any earlier edit
|
||||
}
|
||||
|
||||
const ColorProbe: React.FC = () => {
|
||||
const { setPropStyle } = useNodeProp('h-1');
|
||||
return <ColorPickerField label="Color" value={props().style.color} onChange={(v) => setPropStyle('color', v)} />;
|
||||
};
|
||||
|
||||
const AnimVisProbe: React.FC<{ media?: boolean }> = ({ media }) => {
|
||||
const { setProps } = useNodeProp('h-1');
|
||||
const Section = media ? MediaAnimVisSection : AnimVisSection;
|
||||
return <Section nodeProps={props()} setProps={setProps} />;
|
||||
};
|
||||
|
||||
function openSection() {
|
||||
const toggle = Array.from(harness!.container.querySelectorAll('button')).find((b) => /Animation & Visibility/.test(b.textContent || ''));
|
||||
harness!.act(() => { toggle!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
}
|
||||
const q = (id: string) => harness!.container.querySelector(`[data-testid="${id}"]`) as HTMLElement;
|
||||
const clickEl = (el: Element) => harness!.act(() => { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
|
||||
describe('undo granularity (real Craft history)', () => {
|
||||
test('a drag of 10 color changes is one undo step restoring the original', () => {
|
||||
mountFresh(<ColorProbe />);
|
||||
const colorInput = harness!.container.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
harness!.act(() => { setNative(colorInput, `#00${i.toString(16).padStart(2, '0')}00`); });
|
||||
vi.setSystemTime(2_000_000 + i * 20); // ~20ms ticks, all inside the 500ms window
|
||||
}
|
||||
expect(props().style.color).toBe('#000a00');
|
||||
expect(undoDepth()).toBe(1);
|
||||
expect(props().style.color).toBe('#000000');
|
||||
});
|
||||
|
||||
test('edits separated by more than the throttle window stay separate steps', () => {
|
||||
mountFresh(<ColorProbe />);
|
||||
const colorInput = harness!.container.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
harness!.act(() => { setNative(colorInput, '#111111'); });
|
||||
vi.setSystemTime(2_001_000);
|
||||
harness!.act(() => { setNative(colorInput, '#222222'); });
|
||||
expect(undoDepth()).toBe(2);
|
||||
});
|
||||
|
||||
test.each([false, true])('visibility toggle is one undo step (media=%s)', (media) => {
|
||||
mountFresh(<AnimVisProbe media={media} />);
|
||||
openSection();
|
||||
clickEl(q('visibility-hideOnDesktop'));
|
||||
expect(props().hideOnDesktop).toBe(true);
|
||||
expect(props().hideOnTablet).toBe(false);
|
||||
expect(undoDepth()).toBe(1);
|
||||
expect(props().hideOnDesktop).toBeFalsy();
|
||||
});
|
||||
|
||||
test.each([false, true])('animation change is one undo step (media=%s)', (media) => {
|
||||
mountFresh(<AnimVisProbe media={media} />);
|
||||
openSection();
|
||||
const btn = Array.from(harness!.container.querySelectorAll('[data-testid="animation-control"] .preset-btn'))
|
||||
.find((b) => /fade/i.test(b.textContent || ''))!;
|
||||
clickEl(btn);
|
||||
expect(props().animation).not.toBe('none');
|
||||
expect(undoDepth()).toBe(1);
|
||||
expect(props().animation).toBeFalsy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user