diff --git a/craft/src/panels/right/styles/NavStylePanel.test.tsx b/craft/src/panels/right/styles/NavStylePanel.test.tsx index 818f2fa..b71ceef 100644 --- a/craft/src/panels/right/styles/NavStylePanel.test.tsx +++ b/craft/src/panels/right/styles/NavStylePanel.test.tsx @@ -13,9 +13,12 @@ const setPropSpy = vi.fn((_id: string, updater: (p: any) => void) => { updater(lastProps); }); let lastProps: any; +/* history.merge() is a spy so undo-burst behaviour is observable: a call to + it means the setProp folded into the previous undo entry. */ +const mergeSpy = vi.fn(() => ({ setProp: setPropSpy })); vi.mock('@craftjs/core', () => ({ - useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: () => ({ setProp: setPropSpy }) } } }), + useEditor: () => ({ actions: { setProp: setPropSpy, history: { merge: mergeSpy } } }), })); let mockPages: { id: string; name: string; slug: string; craftState: string | null; nav?: any }[] = []; @@ -31,6 +34,7 @@ vi.mock('../../../ui/AssetPicker', () => ({ })); import { NavStylePanel, LinkPicker, pageHref } from './NavStylePanel'; +import { closeContinuousBurst } from './shared'; let container: HTMLDivElement; let root: Root; @@ -70,6 +74,8 @@ function click(el: Element | null) { beforeEach(() => { try { window.localStorage.clear(); } catch { /* ignore */ } setPropSpy.mockClear(); + mergeSpy.mockClear(); + closeContinuousBurst(); mockPageApi = {}; mockPages = [ { id: 'home', name: 'Home', slug: 'index', craftState: null }, @@ -373,3 +379,52 @@ describe('NavStylePanel: Follow pages (#53)', () => { expect(lastProps.links[0].children).toBeUndefined(); }); }); + +describe('NavStylePanel: link href typing is one undo burst (#53 fix round 1)', () => { + const typeInto = (el: HTMLInputElement, v: string) => { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!; + act(() => { setter.call(el, v); el.dispatchEvent(new Event('input', { bubbles: true })); }); + }; + const hrefInput = () => container.querySelector('[data-testid="link-picker"] input[type="text"]') as HTMLInputElement; + + test('consecutive URL keystrokes fold into one history entry via history.merge()', () => { + lastProps = { links: [{ text: 'Docs', href: 'https://e' }] }; + render(); + for (const v of ['https://ex', 'https://exa', 'https://exam', 'https://example.com']) typeInto(hrefInput(), v); + expect(setPropSpy).toHaveBeenCalledTimes(4); + expect(mergeSpy).toHaveBeenCalledTimes(3); // first keystroke opens the entry, the rest merge + expect(lastProps.links[0]).toEqual({ text: 'Docs', href: 'https://example.com' }); + }); + + test('tel typing on a Follow-pages nav merges too, strips source/pageId and keeps the page merge', () => { + lastProps = { + followPages: true, + links: [ + { text: 'Home', href: '/', source: 'page', pageId: 'home' }, + { text: 'About', href: '/about', source: 'page', pageId: 'about' }, + { text: 'Call', href: 'tel:1' }, + ], + }; + render(); + const telInput = Array.from(container.querySelectorAll('[data-testid="link-picker"] input[type="text"]')) + .find((el) => (el as HTMLInputElement).placeholder === '+15551234567') as HTMLInputElement; + for (const v of ['15', '155', '1555']) typeInto(telInput, v); + expect(mergeSpy).toHaveBeenCalledTimes(2); + const call = lastProps.links.find((l: any) => l.text === 'Call'); + expect(call).toEqual({ text: 'Call', href: 'tel:1555' }); + // page links are still present (mergeLinks ran inside the updater) + expect(lastProps.links.filter((l: any) => l.source === 'page').map((l: any) => l.pageId)).toEqual(['home', 'about']); + }); + + test('a page pick after typing is its own undo step (no merge)', () => { + lastProps = { links: [{ text: 'Go', href: 'https://a' }] }; + render(); + typeInto(hrefInput(), 'https://ab'); + typeInto(hrefInput(), 'https://abc'); + expect(mergeSpy).toHaveBeenCalledTimes(1); + const modeSelect = container.querySelector('[data-testid="link-picker"] select') as HTMLSelectElement; + setValue(modeSelect, 'page'); + expect(mergeSpy).toHaveBeenCalledTimes(1); + expect(lastProps.links[0]).toEqual({ text: 'Go', href: '/', source: 'page', pageId: 'home' }); + }); +}); diff --git a/craft/src/panels/right/styles/NavStylePanel.tsx b/craft/src/panels/right/styles/NavStylePanel.tsx index 47f2b33..5209d75 100644 --- a/craft/src/panels/right/styles/NavStylePanel.tsx +++ b/craft/src/panels/right/styles/NavStylePanel.tsx @@ -24,6 +24,7 @@ import { AnimationControl, VisibilityControl, continuousSetProp, + discreteSetProp, } from './shared'; import { AssetPicker } from '../../../ui/AssetPicker'; import { usePages } from '../../../state/PageContext'; @@ -204,14 +205,18 @@ export const NavStylePanel: React.FC = ({ selectedId, nodeProps /* #53: LinkPicker reports the picked page; a page pick records source:'page' + pageId, any other mode makes the link manual again. */ const updateLinkHref = useCallback((index: number, href: string, pageId: string | null) => { - actions.setProp(selectedId, (props: any) => { + const updater = (props: any) => { const updated = [...(props.links || [])]; const { source: _s, pageId: _p, ...rest } = updated[index] || {}; updated[index] = pageId ? { ...rest, href, source: 'page', pageId } : { ...rest, href }; // On a page-following nav the write is re-merged, so a link re-pointed // at a page is materialised (and deduped) at once. props.links = props.followPages ? mergeLinks(updated, pages) : updated; - }); + }; + // A page pick is a discrete choice (its own undo step); URL / anchor / + // tel / mailto typing is one undo burst per control, as before #53. + if (pageId) discreteSetProp(actions, selectedId, updater); + else continuousSetProp(actions, selectedId, `link:${index}:href`, updater); }, [actions, selectedId, pages]); /* Nested (dropdown) links of a MANUAL link. Page nesting is set per page in