fix(site-builder): nav link href typing is one undo burst again

URL/anchor/tel/mailto edits from LinkPicker now go through
continuousSetProp (key link:<i>:href); a page pick stays a discrete
step via discreteSetProp. source/pageId stripping and the followPages
mergeLinks re-merge remain inside the updater.

Refs #53

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 12:07:58 -07:00
1 parent 52477bff45
commit 4379ffac64
2 files changed
+63 -3

No files matched your search

@@ -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(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
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(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
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(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
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' });
});
});
@@ -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<StylePanelProps> = ({ 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