commit
1d34d1b3b1
18 files changed
+1500
-98
No files matched your search
@@ -67,3 +67,43 @@ describe('Menu.toHtml XSS hardening (linkHoverColor into <style>)', () => {
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*#ff0000/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Menu.toHtml dropdowns (#53)', () => {
|
||||
const nested = [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'Services', href: '/services', children: [
|
||||
{ text: 'Web', href: '/web' },
|
||||
{ text: 'Bad', href: 'javascript:alert(1)' },
|
||||
] },
|
||||
{ text: 'Go', href: '#go', isCta: true },
|
||||
];
|
||||
|
||||
test('a link with children emits the dropdown wrapper, aria-* toggle, inline script and submenu', () => {
|
||||
const { html } = toHtml({ links: nested }, '', 'node-dd');
|
||||
expect(html).toMatch(/<div class="[a-z0-9_-]+-dd"><a href="\/services"[^>]*>Services<\/a><button type="button" class="[a-z0-9_-]+-dd-toggle" aria-haspopup="true" aria-expanded="false" aria-label="Services submenu" onclick="[^"]*aria-expanded[^"]*">/);
|
||||
expect(html).toMatch(/<ul class="[a-z0-9_-]+-sub"><li><a href="\/web"[^>]*>Web<\/a><\/li>/);
|
||||
expect(html).toMatch(/-dd:focus-within > \.[a-z0-9_-]+-sub/);
|
||||
expect(html).toMatch(/-dd:hover > \.[a-z0-9_-]+-sub/);
|
||||
});
|
||||
|
||||
test('safeUrl is applied to child hrefs', () => {
|
||||
const { html } = toHtml({ links: nested }, '', 'node-dd');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('no children -> no dropdown markup, CSS or script (legacy output unchanged)', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }, { text: 'About', href: '/about' }] }, '', 'node-flat');
|
||||
expect(html).not.toContain('aria-haspopup');
|
||||
expect(html).not.toContain('onclick');
|
||||
expect(html).not.toContain('-sub');
|
||||
});
|
||||
|
||||
test('default hover + CTA colours are the primary token', () => {
|
||||
const { html } = toHtml({}, '', 'node-tok');
|
||||
expect(html).toMatch(/:hover\s*\{\s*color:\s*var\(--wsb-primary\)/);
|
||||
expect(html).toContain('background-color:var(--wsb-primary)');
|
||||
expect((Menu as any).craft.props.linkHoverColor).toBe('var(--wsb-primary)');
|
||||
expect((Menu as any).craft.props.ctaBgColor).toBe('var(--wsb-primary)');
|
||||
expect((Menu as any).craft.props.followPages).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,20 +2,17 @@ import React, { CSSProperties, useState } from 'react';
|
||||
import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { escapeHtml, escapeAttr, safeUrl, scopeId, cssValue } from '../../utils/escape';
|
||||
import { tokenRef } from '../../utils/tokens';
|
||||
import { NavLink, DROPDOWN_TOGGLE_ONCLICK, hasDropdown } from '../../utils/nav-links';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface MenuLink {
|
||||
text: string;
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
|
||||
download?: boolean;
|
||||
}
|
||||
type MenuLink = NavLink;
|
||||
|
||||
interface MenuProps {
|
||||
links?: MenuLink[];
|
||||
/** #53: links follow the site's pages (materialised by PageContext). */
|
||||
followPages?: boolean;
|
||||
alignment?: 'left' | 'center' | 'right';
|
||||
linkColor?: string;
|
||||
linkHoverColor?: string;
|
||||
@@ -47,8 +44,8 @@ export const Menu: UserComponent<MenuProps> = ({
|
||||
links = defaultLinks,
|
||||
alignment = 'right',
|
||||
linkColor = '#3f3f46',
|
||||
linkHoverColor = '#3b82f6',
|
||||
ctaBgColor = '#3b82f6',
|
||||
linkHoverColor = tokenRef('primary'),
|
||||
ctaBgColor = tokenRef('primary'),
|
||||
ctaTextColor = '#ffffff',
|
||||
gap = '24px',
|
||||
orientation = 'horizontal',
|
||||
@@ -60,6 +57,7 @@ export const Menu: UserComponent<MenuProps> = ({
|
||||
} = useNode();
|
||||
|
||||
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
|
||||
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
|
||||
|
||||
const justifyMap = { left: 'flex-start', center: 'center', right: 'flex-end' };
|
||||
|
||||
@@ -76,7 +74,9 @@ export const Menu: UserComponent<MenuProps> = ({
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{links.map((link, i) => (
|
||||
{links.map((link, i) => {
|
||||
const kids = !link.isCta && link.children?.length ? link.children : null;
|
||||
const anchor = (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
@@ -102,21 +102,58 @@ export const Menu: UserComponent<MenuProps> = ({
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))}
|
||||
);
|
||||
if (!kids) return anchor;
|
||||
// #53: one-level dropdown, opened on hover in the editor (export: hover,
|
||||
// focus-within and a click toggle -- see toHtml).
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
onMouseEnter={() => setOpenDropdown(i)}
|
||||
onMouseLeave={() => setOpenDropdown(null)}
|
||||
>
|
||||
{anchor}
|
||||
<span aria-hidden="true" style={{ fontSize, color: linkColor }}>▾</span>
|
||||
{(openDropdown === i || orientation === 'vertical') && (
|
||||
<ul style={orientation === 'vertical' ? SUB_STATIC_STYLE : SUB_STYLE}>
|
||||
{kids.map((child, j) => (
|
||||
<li key={j}>
|
||||
<a
|
||||
href={child.href}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{ display: 'block', padding: '6px 16px', whiteSpace: 'nowrap', textDecoration: 'none', fontSize, color: linkColor }}
|
||||
>
|
||||
{child.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
const SUB_STYLE: CSSProperties = {
|
||||
position: 'absolute', top: '100%', left: 0, zIndex: 1001, minWidth: 180, margin: 0, padding: '8px 0',
|
||||
listStyle: 'none', background: 'var(--wsb-bg, #ffffff)', borderRadius: 6, boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
};
|
||||
const SUB_STATIC_STYLE: CSSProperties = { margin: 0, padding: '4px 0 0 12px', listStyle: 'none' };
|
||||
|
||||
/* ---------- Craft config ---------- */
|
||||
|
||||
Menu.craft = {
|
||||
displayName: 'Menu',
|
||||
props: {
|
||||
links: defaultLinks,
|
||||
followPages: false,
|
||||
alignment: 'right',
|
||||
linkColor: '#3f3f46',
|
||||
linkHoverColor: '#3b82f6',
|
||||
ctaBgColor: '#3b82f6',
|
||||
linkHoverColor: tokenRef('primary'),
|
||||
ctaBgColor: tokenRef('primary'),
|
||||
ctaTextColor: '#ffffff',
|
||||
gap: '24px',
|
||||
orientation: 'horizontal',
|
||||
@@ -143,8 +180,8 @@ Menu.craft = {
|
||||
// the worst case: </style> breakout -> arbitrary <script>), see
|
||||
// task-cssxss-brief.md.
|
||||
const linkCol = cssValue(props.linkColor) || '#3f3f46';
|
||||
const hoverCol = cssValue(props.linkHoverColor) || '#3b82f6';
|
||||
const ctaBg = cssValue(props.ctaBgColor) || '#3b82f6';
|
||||
const hoverCol = cssValue(props.linkHoverColor) || tokenRef('primary');
|
||||
const ctaBg = cssValue(props.ctaBgColor) || tokenRef('primary');
|
||||
const ctaText = cssValue(props.ctaTextColor) || '#ffffff';
|
||||
const gap = cssValue(props.gap) || '24px';
|
||||
const orientation = props.orientation || 'horizontal';
|
||||
@@ -173,7 +210,7 @@ Menu.craft = {
|
||||
// would let one instance's hover styling bleed into the other's).
|
||||
const scope = scopeId(nodeId, JSON.stringify(links) + orientation + alignment, 'menu');
|
||||
|
||||
const linksHtml = links.map((link) => {
|
||||
const anchorHtml = (link: MenuLink) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const downloadAttr = link.download ? ' download' : '';
|
||||
const cls = link.isCta ? `${scope}-cta` : `${scope}-link`;
|
||||
@@ -188,11 +225,30 @@ Menu.craft = {
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || '#'))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
};
|
||||
|
||||
// #53: a (non-CTA) link with children becomes a dropdown: the parent link
|
||||
// still navigates; a separate toggle button opens the submenu on touch /
|
||||
// click (hover and focus-within open it on desktop, see dropdownCss).
|
||||
const linksHtml = links.map((link) => {
|
||||
if (link.isCta || !link.children?.length) return anchorHtml(link);
|
||||
const kids = link.children.map((child) => `<li>${anchorHtml({ ...child, isCta: false })}</li>`).join('');
|
||||
return `<div class="${scope}-dd">${anchorHtml(link)}<button type="button" class="${scope}-dd-toggle" aria-haspopup="true" aria-expanded="false" aria-label="${escapeAttr(`${link.text} submenu`)}" onclick="${DROPDOWN_TOGGLE_ONCLICK}">▾</button><ul class="${scope}-sub">${kids}</ul></div>`;
|
||||
}).join('\n ');
|
||||
|
||||
const dropdownCss = hasDropdown(links) ? `
|
||||
.${scope}-dd { position: relative; display: inline-flex; align-items: center; gap: 2px; }
|
||||
.${scope}-dd-toggle { background: none; border: 0; margin: 0; padding: 2px 4px; cursor: pointer; color: ${linkCol}; font-size: ${fSize}; line-height: 1; }
|
||||
.${scope}-sub { display: none; position: absolute; top: 100%; left: 0; z-index: 1001; min-width: 180px; margin: 0; padding: 8px 0; list-style: none; background: ${tokenRef('bg')}; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
|
||||
.${scope}-dd:hover > .${scope}-sub, .${scope}-dd:focus-within > .${scope}-sub, .${scope}-dd.is-open > .${scope}-sub { display: block; }
|
||||
.${scope}-sub a { display: block !important; padding: 6px 16px !important; white-space: nowrap; }${orientation === 'vertical' ? `
|
||||
.${scope}-dd { flex-wrap: wrap; }
|
||||
.${scope}-sub { display: block; position: static; box-shadow: none; background: transparent; min-width: 0; padding: 4px 0 0 12px; flex-basis: 100%; }
|
||||
.${scope}-dd-toggle { display: none; }` : ''}` : '';
|
||||
|
||||
const hoverCss = `<style>
|
||||
.${scope}-link:hover { color: ${hoverCol} !important; }
|
||||
.${scope}-cta:hover { filter: brightness(1.1); }
|
||||
.${scope}-cta:hover { filter: brightness(1.1); }${dropdownCss}
|
||||
</style>`;
|
||||
|
||||
return {
|
||||
|
||||
@@ -140,3 +140,48 @@ describe('Navbar.toHtml XSS hardening (hoverColor/backgroundColor/ctaColor into
|
||||
expect(html).toContain('background-color:#123456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navbar.toHtml dropdowns (#53)', () => {
|
||||
const nested = [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'Services', href: '/services', source: 'page', pageId: 's', children: [
|
||||
{ text: 'Web', href: '/web', source: 'page', pageId: 'w' },
|
||||
{ text: 'Bad', href: 'javascript:alert(1)' },
|
||||
] },
|
||||
{ text: 'Go', href: '#go', isCta: true },
|
||||
];
|
||||
|
||||
test('a link with children emits the dropdown wrapper, aria-* toggle, inline script and submenu', () => {
|
||||
const { html } = toHtml({ links: nested }, '', 'node-dd');
|
||||
expect(html).toMatch(/<div class="navbar-dd"><a href="\/services" class="navbar-link"[^>]*>Services<\/a><button type="button" class="navbar-dd-toggle" aria-haspopup="true" aria-expanded="false" aria-label="Services submenu" onclick="[^"]*is-open[^"]*aria-expanded[^"]*">/);
|
||||
expect(html).toMatch(/<ul class="navbar-sub"><li><a href="\/web" class="navbar-link"[^>]*>Web<\/a><\/li>/);
|
||||
expect(html).toContain('.navbar-dd:focus-within > .navbar-sub');
|
||||
});
|
||||
|
||||
test('safeUrl is applied to child hrefs', () => {
|
||||
const { html } = toHtml({ links: nested }, '', 'node-dd');
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
test('hamburger layout lists children indented (static submenu in the mobile media query)', () => {
|
||||
const { html } = toHtml({ links: nested, showMobileMenu: true }, '', 'node-dd-m');
|
||||
const media = html.slice(html.indexOf('@media'));
|
||||
expect(media).toMatch(/\.navbar-sub \{ display: block; position: static;[^}]*padding: 4px 0 0 16px/);
|
||||
});
|
||||
|
||||
test('no children -> no dropdown markup, CSS or script', () => {
|
||||
const { html } = toHtml({ links: [{ text: 'Home', href: '/' }] }, '', 'node-flat');
|
||||
expect(html).not.toContain('aria-haspopup');
|
||||
expect(html).not.toContain('navbar-dd');
|
||||
expect(html).not.toContain('onclick');
|
||||
});
|
||||
|
||||
test('default hover + CTA colours are the primary token', () => {
|
||||
const { html } = toHtml({}, '', 'node-tok');
|
||||
expect(html).toMatch(/\.navbar-link:hover \{ color: var\(--wsb-primary\)/);
|
||||
expect(html).toContain('background-color:var(--wsb-primary)');
|
||||
expect((Navbar as any).craft.props.hoverColor).toBe('var(--wsb-primary)');
|
||||
expect((Navbar as any).craft.props.ctaColor).toBe('var(--wsb-primary)');
|
||||
expect((Navbar as any).craft.props.followPages).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,18 +3,11 @@ import { useNode, UserComponent } from '@craftjs/core';
|
||||
import { cssPropsToString } from '../../utils/style-helpers';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
import { escapeHtml, escapeAttr, safeUrl, safeImageUrl, cssValue, scopeId } from '../../utils/escape';
|
||||
import { tokenRef } from '../../utils/tokens';
|
||||
import { NavLink, DROPDOWN_TOGGLE_ONCLICK, hasDropdown } from '../../utils/nav-links';
|
||||
|
||||
/* ---------- Types ---------- */
|
||||
|
||||
interface NavLink {
|
||||
text: string;
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
|
||||
download?: boolean;
|
||||
}
|
||||
|
||||
interface NavbarProps {
|
||||
logoType?: 'text' | 'image';
|
||||
logoText?: string;
|
||||
@@ -25,6 +18,8 @@ interface NavbarProps {
|
||||
logoFontSize?: string;
|
||||
logoColor?: string;
|
||||
links?: NavLink[];
|
||||
/** #53: links follow the site's pages (materialised by PageContext). */
|
||||
followPages?: boolean;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
hoverColor?: string;
|
||||
@@ -72,8 +67,8 @@ export const Navbar: UserComponent<NavbarProps> = ({
|
||||
links = defaultLinks,
|
||||
backgroundColor = '#ffffff',
|
||||
textColor = '#3f3f46',
|
||||
hoverColor = '#3b82f6',
|
||||
ctaColor = '#3b82f6',
|
||||
hoverColor = tokenRef('primary'),
|
||||
ctaColor = tokenRef('primary'),
|
||||
ctaTextColor = '#ffffff',
|
||||
padding = '16px 24px',
|
||||
navAlignment = 'space-between',
|
||||
@@ -93,6 +88,7 @@ export const Navbar: UserComponent<NavbarProps> = ({
|
||||
const resolvedTextColor = textColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? '#3f3f46' : '#e4e4e7');
|
||||
|
||||
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
|
||||
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -150,7 +146,9 @@ export const Navbar: UserComponent<NavbarProps> = ({
|
||||
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
|
||||
</div>
|
||||
)}
|
||||
{links.map((link, i) => (
|
||||
{links.map((link, i) => {
|
||||
const kids = !link.isCta && link.children?.length ? link.children : null;
|
||||
const anchor = (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
@@ -176,7 +174,37 @@ export const Navbar: UserComponent<NavbarProps> = ({
|
||||
>
|
||||
{link.text}
|
||||
</a>
|
||||
))}
|
||||
);
|
||||
if (!kids) return anchor;
|
||||
// #53: one-level dropdown, opened on hover in the editor (export:
|
||||
// hover, focus-within and a click toggle -- see toHtml).
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
onMouseEnter={() => setOpenDropdown(i)}
|
||||
onMouseLeave={() => setOpenDropdown(null)}
|
||||
>
|
||||
{anchor}
|
||||
<span aria-hidden="true" style={{ fontSize: '14px', color: resolvedTextColor }}>▾</span>
|
||||
{openDropdown === i && (
|
||||
<ul style={{ position: 'absolute', top: '100%', left: 0, zIndex: 1001, minWidth: 180, margin: 0, padding: '8px 0', listStyle: 'none', background: backgroundColor, borderRadius: 6, boxShadow: '0 8px 24px rgba(0,0,0,0.12)' }}>
|
||||
{kids.map((child, j) => (
|
||||
<li key={j}>
|
||||
<a
|
||||
href={child.href}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
style={{ display: 'block', padding: '6px 16px', whiteSpace: 'nowrap', textDecoration: 'none', fontSize: '14px', color: resolvedTextColor }}
|
||||
>
|
||||
{child.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
@@ -196,10 +224,11 @@ Navbar.craft = {
|
||||
logoFontSize: '20px',
|
||||
logoColor: undefined,
|
||||
links: defaultLinks,
|
||||
followPages: false,
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#3f3f46',
|
||||
hoverColor: '#3b82f6',
|
||||
ctaColor: '#3b82f6',
|
||||
hoverColor: tokenRef('primary'),
|
||||
ctaColor: tokenRef('primary'),
|
||||
ctaTextColor: '#ffffff',
|
||||
padding: '16px 24px',
|
||||
navAlignment: 'space-between',
|
||||
@@ -229,8 +258,8 @@ Navbar.craft = {
|
||||
// breakout -> arbitrary <script>), see task-cssxss-brief.md.
|
||||
const bgColor = cssValue(props.backgroundColor) || '#ffffff';
|
||||
const textCol = cssValue(props.textColor) || '#3f3f46';
|
||||
const hoverCol = cssValue(props.hoverColor) || '#3b82f6';
|
||||
const ctaCol = cssValue(props.ctaColor) || '#3b82f6';
|
||||
const hoverCol = cssValue(props.hoverColor) || tokenRef('primary');
|
||||
const ctaCol = cssValue(props.ctaColor) || tokenRef('primary');
|
||||
const ctaTextCol = cssValue(props.ctaTextColor) || '#ffffff';
|
||||
const pad = cssValue(props.padding) || '16px 24px';
|
||||
const alignment = props.navAlignment || 'space-between';
|
||||
@@ -276,22 +305,8 @@ Navbar.craft = {
|
||||
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${escapeHtml(props.logoText || 'MySite')}</span></a>`;
|
||||
}
|
||||
|
||||
// Links HTML
|
||||
const links = props.links || defaultLinks;
|
||||
const linksHtml = links.map((link) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const linkStyle = cssPropsToString({
|
||||
textDecoration: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: link.isCta ? '600' : '400',
|
||||
color: link.isCta ? ctaTextCol : textCol,
|
||||
backgroundColor: link.isCta ? ctaCol : 'transparent',
|
||||
padding: link.isCta ? '8px 20px' : '0',
|
||||
borderRadius: link.isCta ? '6px' : '0',
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
}).join('\n ');
|
||||
const links = links0;
|
||||
const dropdown = hasDropdown(links);
|
||||
|
||||
// Hamburger HTML for mobile. The toggle needs an accessible name (there's
|
||||
// no visible text, just three bars) and must report its open/closed state
|
||||
@@ -310,16 +325,24 @@ Navbar.craft = {
|
||||
// into or get overridden by another Navbar instance's rules.
|
||||
const hoverCss = `<style>
|
||||
.${scope} .navbar-link:hover { color: ${hoverCol} !important; }
|
||||
.${scope} .navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
|
||||
.${scope} .navbar-cta:hover { filter: brightness(1.1); }${dropdown ? `
|
||||
.${scope} .navbar-dd { position: relative; display: inline-flex; align-items: center; gap: 2px; }
|
||||
.${scope} .navbar-dd-toggle { background: none; border: 0; margin: 0; padding: 2px 4px; cursor: pointer; color: ${textCol}; font-size: 14px; line-height: 1; }
|
||||
.${scope} .navbar-sub { display: none; position: absolute; top: 100%; left: 0; z-index: 1001; min-width: 180px; margin: 0; padding: 8px 0; list-style: none; background-color: ${bgColor}; border-radius: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
|
||||
.${scope} .navbar-dd:hover > .navbar-sub, .${scope} .navbar-dd:focus-within > .navbar-sub, .${scope} .navbar-dd.is-open > .navbar-sub { display: block; }
|
||||
.${scope} .navbar-sub a { display: block; padding: 6px 16px !important; white-space: nowrap; }` : ''}${mobile ? `
|
||||
@media (max-width: 768px) {
|
||||
.${scope} .navbar-hamburger { display: flex !important; }
|
||||
.${scope} .navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.${scope} .navbar-links.navbar-open { display: flex !important; }
|
||||
.${scope} .navbar-links.navbar-open { display: flex !important; }${dropdown ? `
|
||||
.${scope} .navbar-dd { flex-direction: column; align-items: flex-start; }
|
||||
.${scope} .navbar-dd-toggle { display: none; }
|
||||
.${scope} .navbar-sub { display: block; position: static; box-shadow: none; min-width: 0; padding: 4px 0 0 16px; }` : ''}
|
||||
}` : ''}
|
||||
</style>`;
|
||||
|
||||
// Add CSS class to each link for hover
|
||||
const linksHtmlWithClass = links.map((link) => {
|
||||
const anchorHtml = (link: NavLink) => {
|
||||
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
|
||||
const downloadAttr = link.download ? ' download' : '';
|
||||
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
|
||||
@@ -334,6 +357,16 @@ Navbar.craft = {
|
||||
transition: 'color 0.15s, background-color 0.15s',
|
||||
});
|
||||
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${downloadAttr}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
||||
};
|
||||
|
||||
// #53: a (non-CTA) link with children becomes a dropdown: the parent link
|
||||
// still navigates; a separate toggle button opens the submenu on touch /
|
||||
// click (hover and focus-within open it on desktop). In the hamburger
|
||||
// layout the children are listed indented (see the media query above).
|
||||
const linksHtmlWithClass = links.map((link) => {
|
||||
if (link.isCta || !link.children?.length) return anchorHtml(link);
|
||||
const kids = link.children.map((child) => `<li>${anchorHtml({ ...child, isCta: false })}</li>`).join('');
|
||||
return `<div class="navbar-dd">${anchorHtml(link)}<button type="button" class="navbar-dd-toggle" aria-haspopup="true" aria-expanded="false" aria-label="${escapeAttr(`${link.text} submenu`)}" onclick="${DROPDOWN_TOGGLE_ONCLICK}">▾</button><ul class="navbar-sub">${kids}</ul></div>`;
|
||||
}).join('\n ');
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||
import { PageProvider, usePages } from '../state/PageContext';
|
||||
import { SiteDesignProvider, DEFAULT_SITE_DESIGN } from '../state/SiteDesignContext';
|
||||
import { ProjectExtrasProvider } from '../state/ProjectExtrasContext';
|
||||
import { useWhpApi, buildSavePayload } from './useWhpApi';
|
||||
import { PageData, WhpConfig } from '../types';
|
||||
|
||||
/**
|
||||
* #53: `PageData.nav` round-trips through save (`pages_craft_state[].nav`)
|
||||
* and load(), and loading a LEGACY project (manual nav links, no
|
||||
* followPages) leaves its header byte-for-byte untouched. `useEditor` is
|
||||
* stubbed as in useWhpApi.load.test.tsx -- this drives load(), not the canvas.
|
||||
*/
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({
|
||||
query: { serialize: () => '{}', getNodes: () => ({}) },
|
||||
actions: { deserialize: vi.fn(), history: { clear: () => {} } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const whpConfig: WhpConfig = {
|
||||
user: 'u', apiUrl: '/panel/api/site-builder', csrfToken: 'tok', siteId: 42,
|
||||
siteDomain: 'example.com', siteName: 'Test Site', backUrl: '/', isRoot: false,
|
||||
};
|
||||
|
||||
const EMPTY = '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||
const home: PageData = { id: 'home', name: 'Home', slug: 'index', craftState: EMPTY };
|
||||
const about: PageData = { id: 'about', name: 'About', slug: 'about', craftState: EMPTY, nav: { label: 'Our story', show: true } };
|
||||
const team: PageData = { id: 'team', name: 'Team', slug: 'team', craftState: EMPTY, nav: { parentId: 'about', show: false } };
|
||||
|
||||
function payloadFor(pages: PageData[]) {
|
||||
const zone = (id: string): PageData => ({ id, name: id, slug: id, craftState: null });
|
||||
return buildSavePayload({
|
||||
siteId: 42, siteName: 'Test Site', liveCraftState: EMPTY, pages,
|
||||
headerPage: zone('__header__'), footerPage: zone('__footer__'),
|
||||
activePageId: '__header__', isEditingHeader: true, isEditingFooter: false,
|
||||
headCode: DEFAULT_SITE_DESIGN.headCode, design: DEFAULT_SITE_DESIGN,
|
||||
});
|
||||
}
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
type Captured = { load: ReturnType<typeof useWhpApi>['load']; pages: PageData[]; headerPage: PageData };
|
||||
|
||||
function render(): () => Captured {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
let captured: Captured | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
const { load } = useWhpApi();
|
||||
const { pages, headerPage } = usePages();
|
||||
captured = { load, pages, headerPage };
|
||||
return null;
|
||||
};
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<ProjectExtrasProvider>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider><Consumer /></PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</ProjectExtrasProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
return () => captured!;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root.unmount());
|
||||
container?.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stubLoad(project: unknown) {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, project }) }));
|
||||
}
|
||||
|
||||
describe('PageData.nav save/load round-trip (#53)', () => {
|
||||
test('save writes nav into pages_craft_state; absent nav stays absent', () => {
|
||||
const payload = payloadFor([home, about, team]);
|
||||
expect(payload.pages_craft_state.map((p) => p.nav)).toEqual([undefined, about.nav, team.nav]);
|
||||
expect('nav' in JSON.parse(JSON.stringify(payload.pages_craft_state[0]))).toBe(false);
|
||||
});
|
||||
|
||||
test('load restores nav from the saved payload', async () => {
|
||||
const saved = JSON.parse(JSON.stringify(payloadFor([home, about, team])));
|
||||
stubLoad({ design: null, header_craft_state: null, footer_craft_state: null, pages_craft_state: saved.pages_craft_state });
|
||||
const get = render();
|
||||
await act(async () => { await get().load(); });
|
||||
expect(get().pages.map((p) => p.nav)).toEqual([undefined, about.nav, team.nav]);
|
||||
});
|
||||
|
||||
test('a legacy header (manual links, no followPages) loads byte-for-byte unchanged', async () => {
|
||||
const legacyHeader = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'header' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['n'], linkedNodes: {} },
|
||||
n: { type: { resolvedName: 'Navbar' }, isCanvas: false, props: { links: [
|
||||
{ text: 'Home', href: '/' }, { text: 'Menu', href: 'menu' }, { text: 'Call us', href: 'tel:555', isCta: true },
|
||||
] }, displayName: 'Navbar', parent: 'ROOT', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
|
||||
});
|
||||
stubLoad({
|
||||
design: null, header_craft_state: legacyHeader, footer_craft_state: null,
|
||||
pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' }, { id: 'p2', name: 'Menu', slug: 'menu', craftState: '{"ROOT":{}}' }],
|
||||
});
|
||||
const get = render();
|
||||
await act(async () => { await get().load(); });
|
||||
expect(get().headerPage.craftState).toBe(legacyHeader);
|
||||
});
|
||||
|
||||
test('a follow-pages header is materialised against the loaded pages', async () => {
|
||||
const header = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'header' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['n'], linkedNodes: {} },
|
||||
n: { type: { resolvedName: 'Navbar' }, isCanvas: false, props: { followPages: true, links: [{ text: 'Home', href: '/', source: 'page', pageId: 'home' }] }, displayName: 'Navbar', parent: 'ROOT', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
|
||||
});
|
||||
stubLoad({
|
||||
design: null, header_craft_state: header, footer_craft_state: null,
|
||||
pages_craft_state: [{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{}}' }, { id: 'about', name: 'About', slug: 'about', craftState: '{"ROOT":{}}', nav: { label: 'Our story' } }],
|
||||
});
|
||||
const get = render();
|
||||
await act(async () => { await get().load(); });
|
||||
const links = JSON.parse(get().headerPage.craftState!).n.props.links;
|
||||
expect(links.map((l: { text: string }) => l.text)).toEqual(['Home', 'Our story']);
|
||||
});
|
||||
});
|
||||
@@ -216,6 +216,9 @@ export function buildSavePayload(input: BuildSavePayloadInput) {
|
||||
// PKG-H §5: round-trips PageData.seo through save -> load() so the
|
||||
// editor's SEO fields survive a reload (mirrors craftState above).
|
||||
seo: page.seo,
|
||||
// #53: menu settings (show / parentId / label); undefined when unset, so
|
||||
// legacy-shaped payloads round-trip unchanged.
|
||||
nav: page.nav,
|
||||
}));
|
||||
|
||||
for (const key of exportCache.keys()) {
|
||||
@@ -541,8 +544,8 @@ export function useWhpApi() {
|
||||
}
|
||||
}
|
||||
|
||||
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null; seo?: PageData['seo'] }) => ({
|
||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
|
||||
setPagesCraftState(proj.pages_craft_state.map((p: { id: string; name: string; slug: string; craftState: string | null; seo?: PageData['seo']; nav?: PageData['nav'] }) => ({
|
||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo, nav: p.nav,
|
||||
})));
|
||||
|
||||
// Load the first page (home) into the canvas. Routed through
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { PageSettingsModal } from './PageSettingsModal';
|
||||
import { PageData } from '../../types';
|
||||
|
||||
vi.mock('../../ui/AssetPicker', () => ({ AssetPicker: () => null }));
|
||||
|
||||
/** #53: the Menu section of Page Settings (show / label / nest under). */
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
|
||||
const pages: PageData[] = [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: null },
|
||||
{ id: 'services', name: 'Services', slug: 'services', craftState: null },
|
||||
{ id: 'web', name: 'Web', slug: 'web', craftState: null, nav: { parentId: 'services' } },
|
||||
{ id: 'about', name: 'About', slug: 'about', craftState: null, nav: { show: false } },
|
||||
];
|
||||
|
||||
function open(page: PageData, updatePageNav = vi.fn()) {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
act(() => {
|
||||
root = createRoot(host);
|
||||
root.render(<PageSettingsModal open onClose={() => {}} page={page} updatePageSeo={() => {}} pages={pages} updatePageNav={updatePageNav} />);
|
||||
});
|
||||
return updatePageNav;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const q = <T extends Element>(id: string) => document.body.querySelector(`[data-testid="${id}"]`) as unknown as T;
|
||||
|
||||
function change(el: HTMLInputElement | HTMLSelectElement, value: string) {
|
||||
const proto = el instanceof HTMLSelectElement ? HTMLSelectElement.prototype : HTMLInputElement.prototype;
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(el, value);
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('PageSettingsModal Menu section (#53)', () => {
|
||||
test('"Show in menu" reflects nav.show and writes it', () => {
|
||||
const spy = open(pages[3]);
|
||||
const box = q<HTMLInputElement>('page-nav-show');
|
||||
expect(box.checked).toBe(false);
|
||||
act(() => { box.click(); });
|
||||
expect(spy).toHaveBeenCalledWith('about', { show: true });
|
||||
});
|
||||
|
||||
test('"Menu label" writes nav.label', () => {
|
||||
const spy = open(pages[1]);
|
||||
change(q<HTMLInputElement>('page-nav-label'), 'What we do');
|
||||
expect(spy).toHaveBeenCalledWith('services', { label: 'What we do' });
|
||||
});
|
||||
|
||||
test('"Nest under" lists other top-level pages only (not itself, not nested pages) and writes parentId', () => {
|
||||
const spy = open(pages[3]);
|
||||
const select = q<HTMLSelectElement>('page-nav-parent');
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
expect(values).toEqual(['', 'home', 'services']);
|
||||
change(select, 'services');
|
||||
expect(spy).toHaveBeenCalledWith('about', { parentId: 'services' });
|
||||
change(select, '');
|
||||
expect(spy).toHaveBeenLastCalledWith('about', { parentId: null });
|
||||
});
|
||||
|
||||
test('the landing page cannot be nested', () => {
|
||||
open(pages[0]);
|
||||
expect(q<HTMLSelectElement>('page-nav-parent').disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,17 @@ import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Modal } from '../../ui/Modal';
|
||||
import { AssetPicker } from '../../ui/AssetPicker';
|
||||
import { PageData, PageSeo } from '../../types';
|
||||
import { PageData, PageSeo, PageNav } from '../../types';
|
||||
|
||||
interface PageSettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
page: PageData | null;
|
||||
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
|
||||
/** #53: all pages (for "Nest under") + the nav updater. The Menu section
|
||||
* is shown only when both are given. */
|
||||
pages?: PageData[];
|
||||
updatePageNav?: (pageId: string, nav: Partial<PageNav>) => void;
|
||||
}
|
||||
|
||||
const EMPTY_SEO: PageSeo = {
|
||||
@@ -34,8 +38,9 @@ const EMPTY_SEO: PageSeo = {
|
||||
* buffering to a "Save" button -- consistent with HeadCodeModal's live-edit
|
||||
* pattern for `design.headCode`.
|
||||
*/
|
||||
export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onClose, page, updatePageSeo }) => {
|
||||
export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onClose, page, updatePageSeo, pages, updatePageNav }) => {
|
||||
const [seo, setSeo] = useState<PageSeo>(EMPTY_SEO);
|
||||
const [nav, setNav] = useState<PageNav>({});
|
||||
|
||||
// Re-sync local form state from the target page's stored seo whenever the
|
||||
// modal opens (or the target page changes while open) -- mirrors the
|
||||
@@ -45,7 +50,8 @@ export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onCl
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSeo({ ...EMPTY_SEO, ...(page?.seo || {}) });
|
||||
}, [open, page?.id, page?.seo]);
|
||||
setNav({ ...(page?.nav || {}) });
|
||||
}, [open, page?.id, page?.seo, page?.nav]);
|
||||
|
||||
if (!page) return null;
|
||||
|
||||
@@ -54,6 +60,17 @@ export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onCl
|
||||
updatePageSeo(page.id, patch);
|
||||
};
|
||||
|
||||
const updateNav = (patch: Partial<PageNav>) => {
|
||||
setNav((prev) => ({ ...prev, ...patch }));
|
||||
updatePageNav?.(page.id, patch);
|
||||
};
|
||||
|
||||
// #53 "Nest under": one level only -- candidates are the other top-level
|
||||
// pages; the landing page itself never nests (it is always top-level).
|
||||
const isLanding = !!pages && pages[0]?.id === page.id;
|
||||
const parentOptions = (pages || []).filter((p) => p.id !== page.id && !p.nav?.parentId);
|
||||
const hasChildren = (pages || []).some((p) => p.nav?.parentId === page.id);
|
||||
|
||||
return createPortal(
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
@@ -66,7 +83,7 @@ export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onCl
|
||||
Page Settings — {page.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
SEO and social-sharing metadata for this page only.
|
||||
Menu, SEO and social-sharing settings for this page only.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,6 +94,56 @@ export const PageSettingsModal: React.FC<PageSettingsModalProps> = ({ open, onCl
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 20, flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{pages && updatePageNav && (
|
||||
<fieldset className="page-menu-settings" data-testid="page-menu-settings">
|
||||
<legend style={fieldLabelStyle}>Menu</legend>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, color: 'var(--color-text)', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={nav.show !== false}
|
||||
onChange={(e) => updateNav({ show: e.target.checked })}
|
||||
data-testid="page-nav-show"
|
||||
/>
|
||||
Show in menu
|
||||
</label>
|
||||
<div>
|
||||
<label style={fieldLabelStyle} htmlFor="page-nav-label">Menu label</label>
|
||||
<input
|
||||
id="page-nav-label"
|
||||
type="text"
|
||||
className="control-input"
|
||||
value={nav.label || ''}
|
||||
onChange={(e) => updateNav({ label: e.target.value })}
|
||||
placeholder={page.name}
|
||||
data-testid="page-nav-label"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={fieldLabelStyle} htmlFor="page-nav-parent">Nest under</label>
|
||||
<select
|
||||
id="page-nav-parent"
|
||||
className="control-select"
|
||||
value={isLanding ? '' : (nav.parentId || '')}
|
||||
disabled={isLanding || hasChildren}
|
||||
onChange={(e) => updateNav({ parentId: e.target.value || null })}
|
||||
data-testid="page-nav-parent"
|
||||
>
|
||||
<option value="">Top level (no dropdown)</option>
|
||||
{parentOptions.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p style={hintStyle}>
|
||||
{isLanding
|
||||
? 'The landing page is always a top-level menu item.'
|
||||
: hasChildren
|
||||
? 'Other pages are nested under this one, so it stays top-level.'
|
||||
: 'Shows this page in a dropdown under the chosen page. Applies to menus with "Follow pages" on.'}
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label style={fieldLabelStyle}>Meta Title</label>
|
||||
<input
|
||||
|
||||
@@ -20,6 +20,7 @@ export const PagesPanel: React.FC = () => {
|
||||
movePage,
|
||||
setLandingPage,
|
||||
updatePageSeo,
|
||||
updatePageNav,
|
||||
} = usePages();
|
||||
const { actions: editorActions } = useEditor();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
@@ -440,6 +441,15 @@ export const PagesPanel: React.FC = () => {
|
||||
Landing
|
||||
</span>
|
||||
)}
|
||||
{page.nav?.show === false && (
|
||||
<i
|
||||
className="fa fa-eye-slash page-menu-hidden-badge"
|
||||
title="Hidden from menu"
|
||||
aria-label="Hidden from menu"
|
||||
role="img"
|
||||
data-testid="page-menu-hidden"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -652,6 +662,8 @@ export const PagesPanel: React.FC = () => {
|
||||
onClose={() => setSeoSettingsPageId(null)}
|
||||
page={seoSettingsPage}
|
||||
updatePageSeo={updatePageSeo}
|
||||
pages={pages}
|
||||
updatePageNav={updatePageNav}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,14 +13,20 @@ 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 }[] = [];
|
||||
let mockPages: { id: string; name: string; slug: string; craftState: string | null; nav?: any }[] = [];
|
||||
/* #53: extra PageContext API for the Follow-pages tests (absent by default,
|
||||
i.e. the panel is not on the header and has no updatePageNav). */
|
||||
let mockPageApi: Record<string, unknown> = {};
|
||||
vi.mock('../../../state/PageContext', () => ({
|
||||
usePages: () => ({ pages: mockPages }),
|
||||
usePages: () => ({ pages: mockPages, ...mockPageApi }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../ui/AssetPicker', () => ({
|
||||
@@ -28,6 +34,7 @@ vi.mock('../../../ui/AssetPicker', () => ({
|
||||
}));
|
||||
|
||||
import { NavStylePanel, LinkPicker, pageHref } from './NavStylePanel';
|
||||
import { closeContinuousBurst } from './shared';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -67,6 +74,9 @@ 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 },
|
||||
{ id: 'about', name: 'About', slug: 'about', craftState: null },
|
||||
@@ -279,3 +289,142 @@ describe('NavStylePanel: box-model + animation + visibility controls always pres
|
||||
expect(lastProps.animation).toBe('fade-in');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NavStylePanel: Follow pages (#53)', () => {
|
||||
const updatePageNav = vi.fn();
|
||||
beforeEach(() => {
|
||||
updatePageNav.mockClear();
|
||||
mockPageApi = { updatePageNav, isEditingHeader: true };
|
||||
});
|
||||
|
||||
const toggle = () => container.querySelector('[data-testid="nav-follow-pages"] input') as HTMLInputElement;
|
||||
|
||||
test('switching it on converts matching manual links, keeps CTA + externals, carries custom text as the page label', () => {
|
||||
// A legacy (pre-#53) nav: manual links only, no followPages prop at all.
|
||||
lastProps = {
|
||||
links: [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About us', href: '/about' },
|
||||
{ text: 'Blog', href: 'https://blog.example.com', isExternal: true },
|
||||
{ text: 'Book now', href: '#book', isCta: true },
|
||||
],
|
||||
};
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
expect(toggle().checked).toBe(false);
|
||||
act(() => { toggle().click(); });
|
||||
|
||||
expect(lastProps.followPages).toBe(true);
|
||||
expect(lastProps.links).toEqual([
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
{ text: 'About us', href: '/about', source: 'page', pageId: 'about' },
|
||||
{ text: 'Blog', href: 'https://blog.example.com', isExternal: true },
|
||||
{ text: 'Book now', href: '#book', isCta: true },
|
||||
]);
|
||||
expect(updatePageNav).toHaveBeenCalledWith('about', { label: 'About us' });
|
||||
expect(updatePageNav).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('page-sourced rows are read-only and "Hide from menu" calls updatePageNav(show:false)', () => {
|
||||
lastProps = {
|
||||
followPages: true,
|
||||
links: [
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
|
||||
{ text: 'Go', href: '#', isCta: true },
|
||||
],
|
||||
};
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
const rows = container.querySelectorAll('[data-testid="nav-page-link"]');
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[1].textContent).toContain('From page: About');
|
||||
expect(rows[1].querySelector('input')).toBeNull();
|
||||
// The one-shot sync button is replaced by the toggle.
|
||||
expect(Array.from(container.querySelectorAll('button')).some((b) => b.textContent?.includes('Sync links with Pages'))).toBe(false);
|
||||
|
||||
click(rows[1].querySelector('button[aria-label="Hide About from menu"]'));
|
||||
expect(updatePageNav).toHaveBeenCalledWith('about', { show: false });
|
||||
});
|
||||
|
||||
test('the toggle is not offered outside the header (one-shot sync remains)', () => {
|
||||
mockPageApi = { updatePageNav, isEditingHeader: false };
|
||||
lastProps = { links: [{ text: 'Home', href: '/' }] };
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
expect(toggle()).toBeNull();
|
||||
expect(Array.from(container.querySelectorAll('button')).some((b) => b.textContent?.includes('Sync links with Pages'))).toBe(true);
|
||||
});
|
||||
|
||||
test('LinkPicker page mode records source:"page" + pageId on a manual link', () => {
|
||||
mockPageApi = { updatePageNav, isEditingHeader: false };
|
||||
lastProps = { links: [{ text: 'X', href: 'https://x.test' }] };
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
const modeSelect = container.querySelector('[data-testid="link-picker"] select') as HTMLSelectElement;
|
||||
setValue(modeSelect, 'page');
|
||||
rerender(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
const pageSelect = container.querySelectorAll('[data-testid="link-picker"] select')[1] as HTMLSelectElement;
|
||||
setValue(pageSelect, '/about');
|
||||
expect(lastProps.links[0]).toEqual({ text: 'X', href: '/about', source: 'page', pageId: 'about' });
|
||||
// Back to a custom URL: manual again.
|
||||
setValue(modeSelect, 'url');
|
||||
expect(lastProps.links[0]).toEqual({ text: 'X', href: '' });
|
||||
});
|
||||
|
||||
test('manual links get a sub-link (dropdown) editor', () => {
|
||||
lastProps = { links: [{ text: 'More', href: '#' }] };
|
||||
render(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
const add = Array.from(container.querySelectorAll('button')).find((b) => b.textContent?.includes('Add sub link'))!;
|
||||
click(add);
|
||||
expect(lastProps.links[0].children).toEqual([{ text: 'Sub link', href: '#' }]);
|
||||
rerender(<NavStylePanel selectedId="node1" nodeProps={lastProps} />);
|
||||
click(container.querySelector('button[aria-label="Remove sub link Sub link"]'));
|
||||
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,19 +24,17 @@ import {
|
||||
AnimationControl,
|
||||
VisibilityControl,
|
||||
continuousSetProp,
|
||||
discreteSetProp,
|
||||
} from './shared';
|
||||
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||
import { usePages } from '../../../state/PageContext';
|
||||
import { PageData } from '../../../types';
|
||||
import { pageHref, adoptPageLinks, mergeLinks, NavLink } from '../../../utils/nav-links';
|
||||
|
||||
/* ---------- Link-to-page helpers (F1/F2: link picker + Sync with Pages) ----------
|
||||
Mirrors the export convention used elsewhere: the first page is always the
|
||||
landing page and publishes to '/', every other page publishes to '/{slug}'.
|
||||
See PageContext.tsx's uniqueSlug/slugify and the landing-page-lock comment
|
||||
in renamePage() for why index 0 is special-cased this way. */
|
||||
export function pageHref(page: PageData, index: number): string {
|
||||
return index === 0 ? '/' : `/${page.slug}`;
|
||||
}
|
||||
`pageHref` lives in utils/nav-links.ts (#53: single source shared with the
|
||||
page-following menus); re-exported here for existing callers. */
|
||||
export { pageHref };
|
||||
|
||||
type LinkMode = 'page' | 'url' | 'anchor' | 'tel' | 'mailto';
|
||||
|
||||
@@ -59,19 +57,30 @@ function detectLinkMode(value: string, pageHrefs: string[]): LinkMode {
|
||||
interface LinkPickerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** #53: when given, used INSTEAD of `onChange` and also reports which page
|
||||
* was picked (null for every non-page mode), so a nav link can record
|
||||
* `source: 'page'` + `pageId`. */
|
||||
onChangeLink?: (href: string, pageId: string | null) => void;
|
||||
}
|
||||
export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
|
||||
export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange, onChangeLink }) => {
|
||||
const { pages } = usePages();
|
||||
const pageOptions = pages.map((p, i) => ({ id: p.id, name: p.name, href: pageHref(p, i) }));
|
||||
const mode = detectLinkMode(value || '', pageOptions.map((p) => p.href));
|
||||
|
||||
const emit = (href: string) => {
|
||||
if (onChangeLink) onChangeLink(href, pageOptions.find((p) => p.href === href)?.id ?? null);
|
||||
else onChange(href);
|
||||
};
|
||||
// Non-page modes never carry a pageId, even if the typed text matches a page href.
|
||||
const emitManual = (href: string) => (onChangeLink ? onChangeLink(href, null) : onChange(href));
|
||||
|
||||
const switchMode = (next: LinkMode) => {
|
||||
if (next === mode) return;
|
||||
if (next === 'page') onChange(pageOptions[0]?.href || '/');
|
||||
else if (next === 'anchor') onChange('#');
|
||||
else if (next === 'tel') onChange('tel:');
|
||||
else if (next === 'mailto') onChange('mailto:');
|
||||
else onChange('');
|
||||
if (next === 'page') emit(pageOptions[0]?.href || '/');
|
||||
else if (next === 'anchor') emitManual('#');
|
||||
else if (next === 'tel') emitManual('tel:');
|
||||
else if (next === 'mailto') emitManual('mailto:');
|
||||
else emitManual('');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -90,7 +99,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
|
||||
</select>
|
||||
{mode === 'page' && (
|
||||
pageOptions.length > 0 ? (
|
||||
<select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
|
||||
<select value={value} onChange={(e) => emit(e.target.value)} style={inputStyle}>
|
||||
{pageOptions.map((p) => (
|
||||
<option key={p.id} value={p.href}>{p.name}</option>
|
||||
))}
|
||||
@@ -103,7 +112,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`)}
|
||||
onChange={(e) => emitManual(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`)}
|
||||
placeholder="#section-id"
|
||||
style={inputStyle}
|
||||
/>
|
||||
@@ -112,7 +121,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
|
||||
<input
|
||||
type="text"
|
||||
value={(value || '').replace(/^tel:/, '')}
|
||||
onChange={(e) => onChange(`tel:${e.target.value}`)}
|
||||
onChange={(e) => emitManual(`tel:${e.target.value}`)}
|
||||
placeholder="+15551234567"
|
||||
style={inputStyle}
|
||||
/>
|
||||
@@ -121,7 +130,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
|
||||
<input
|
||||
type="text"
|
||||
value={(value || '').replace(/^mailto:/, '')}
|
||||
onChange={(e) => onChange(`mailto:${e.target.value}`)}
|
||||
onChange={(e) => emitManual(`mailto:${e.target.value}`)}
|
||||
placeholder="name@example.com"
|
||||
style={inputStyle}
|
||||
/>
|
||||
@@ -130,7 +139,7 @@ export const LinkPicker: React.FC<LinkPickerProps> = ({ value, onChange }) => {
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={(e) => emitManual(e.target.value)}
|
||||
placeholder="https://example.com or /page"
|
||||
style={inputStyle}
|
||||
/>
|
||||
@@ -152,13 +161,38 @@ function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
/* #53: a link owned by a page (Follow pages on) -- read-only here; its text,
|
||||
href and nesting are edited in the page's Page Settings. */
|
||||
const PageLinkRow: React.FC<{ link: NavLink; pages: PageData[]; onHide: (pageId: string) => void }> = ({ link, pages, onHide }) => {
|
||||
const pageName = (id?: string) => pages.find((p) => p.id === id)?.name || link.text;
|
||||
const row = (l: NavLink, nested: boolean) => (
|
||||
<div key={l.pageId} className={nested ? 'nav-page-link nav-page-link--child' : 'nav-page-link'} data-testid="nav-page-link">
|
||||
<i className="fa fa-file-o" aria-hidden="true" />
|
||||
<span className="nav-page-link__label">
|
||||
{l.text}
|
||||
<span className="nav-page-link__source">From page: {pageName(l.pageId)}</span>
|
||||
</span>
|
||||
<button type="button" className="nav-page-link__hide" onClick={() => l.pageId && onHide(l.pageId)} aria-label={`Hide ${l.text} from menu`} title="Hide from menu">
|
||||
<i className="fa fa-eye-slash" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{row(link, false)}
|
||||
{(link.children || []).map((c) => row(c, true))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------- NAV / MENU / LOGO / FOOTER ---------- */
|
||||
export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps }) => {
|
||||
const { actions } = useEditor();
|
||||
const { setProp, setPropStyle, setPropContinuous, setPropStyleContinuous } = useNodeProp(selectedId);
|
||||
const { pages } = usePages();
|
||||
const { pages, updatePageNav, isEditingHeader } = usePages();
|
||||
|
||||
const links: any[] = nodeProps.links || [];
|
||||
const links: NavLink[] = nodeProps.links || [];
|
||||
const followPages = !!nodeProps.followPages;
|
||||
|
||||
const updateLink = useCallback((index: number, field: string, value: any) => {
|
||||
continuousSetProp(actions, selectedId, `link:${index}:${field}`, (props: any) => {
|
||||
@@ -168,6 +202,80 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
/* #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) => {
|
||||
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
|
||||
Page Settings instead, so sub-links stay plain manual links. */
|
||||
const updateChild = useCallback((index: number, childIndex: number, field: string, value: any) => {
|
||||
continuousSetProp(actions, selectedId, `link:${index}:child:${childIndex}:${field}`, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
const children = [...(updated[index].children || [])];
|
||||
children[childIndex] = { ...children[childIndex], [field]: value };
|
||||
updated[index] = { ...updated[index], children };
|
||||
props.links = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const addChild = useCallback((index: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
updated[index] = { ...updated[index], children: [...(updated[index].children || []), { text: 'Sub link', href: '#' }] };
|
||||
props.links = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
const removeChild = useCallback((index: number, childIndex: number) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
const updated = [...(props.links || [])];
|
||||
const children = [...(updated[index].children || [])];
|
||||
children.splice(childIndex, 1);
|
||||
const { children: _c, ...rest } = updated[index];
|
||||
updated[index] = children.length ? { ...rest, children } : rest;
|
||||
props.links = updated;
|
||||
});
|
||||
}, [actions, selectedId]);
|
||||
|
||||
/* #53 "Follow pages": switching on adopts every manual link whose href is
|
||||
a page's href (so existing menus keep their entries), carries a custom
|
||||
link text over as that page's menu label (so the owner's wording
|
||||
survives), then materialises the page links once. Later page changes
|
||||
are kept in sync by PageContext. Switching off freezes the links as-is. */
|
||||
const toggleFollowPages = useCallback((on: boolean) => {
|
||||
if (!on) {
|
||||
actions.setProp(selectedId, (props: any) => { props.followPages = false; });
|
||||
return;
|
||||
}
|
||||
const adopted = adoptPageLinks(links, pages);
|
||||
const labels = new Map<string, string>();
|
||||
adopted.forEach((link) => {
|
||||
if (link.source !== 'page' || !link.pageId || labels.has(link.pageId)) return;
|
||||
const page = pages.find((p) => p.id === link.pageId);
|
||||
if (page && !page.nav?.label && link.text && link.text !== page.name) labels.set(page.id, link.text);
|
||||
});
|
||||
const labelled = pages.map((p) => (labels.has(p.id) ? { ...p, nav: { ...p.nav, label: labels.get(p.id) } } : p));
|
||||
const merged = mergeLinks(adopted, labelled);
|
||||
labels.forEach((label, pageId) => updatePageNav?.(pageId, { label }));
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.followPages = true;
|
||||
props.links = merged;
|
||||
});
|
||||
}, [actions, selectedId, links, pages, updatePageNav]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
props.links = [...(props.links || []), { text: 'New Link', href: '#' }];
|
||||
@@ -312,6 +420,16 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
Footer -- that don't carry a `links` array at all). */}
|
||||
{!isStandaloneLogo && nodeProps.links !== undefined && (
|
||||
<CollapsibleSection title="Links">
|
||||
{/* #53: only the site header's navs are kept in sync with pages
|
||||
(PageContext rewrites the header state), so the toggle is
|
||||
offered there; elsewhere the one-shot sync below applies. */}
|
||||
{(isEditingHeader || followPages) && (
|
||||
<label data-testid="nav-follow-pages" style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#e4e4e7', cursor: 'pointer', marginBottom: 8 }}>
|
||||
<input type="checkbox" checked={followPages} onChange={(e) => toggleFollowPages(e.target.checked)} />
|
||||
Follow pages (new pages appear automatically)
|
||||
</label>
|
||||
)}
|
||||
{!followPages && (
|
||||
<button
|
||||
onClick={syncWithPages}
|
||||
title="Replace these links with one per page (preserves any CTA link)"
|
||||
@@ -319,8 +437,11 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
>
|
||||
<i className="fa fa-refresh" /> Sync links with Pages
|
||||
</button>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{links.map((link, i) => (
|
||||
{links.map((link, i) => followPages && link.source === 'page' ? (
|
||||
<PageLinkRow key={i} link={link} pages={pages} onHide={(pageId) => updatePageNav?.(pageId, { show: false })} />
|
||||
) : (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 6, display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={link.text || ''} onChange={(e) => updateLink(i, 'text', e.target.value)} placeholder="Text" style={{ ...smallInputStyle, flex: 1 }} />
|
||||
@@ -328,11 +449,27 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<LinkPicker value={link.href || ''} onChange={(v) => updateLink(i, 'href', v)} />
|
||||
<LinkPicker value={link.href || ''} onChange={(v) => updateLink(i, 'href', v)} onChangeLink={(href, pageId) => updateLinkHref(i, href, pageId)} />
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, color: '#a1a1aa', cursor: 'pointer', marginTop: -6 }}>
|
||||
<input type="checkbox" checked={!!link.download} onChange={(e) => updateLink(i, 'download', e.target.checked)} />
|
||||
Download (points at a file)
|
||||
</label>
|
||||
{!link.isCta && (
|
||||
<div className="nav-sublinks" data-testid="nav-sublinks">
|
||||
{(link.children || []).map((child, j) => (
|
||||
<div key={j} className="nav-sublink">
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<input type="text" value={child.text || ''} onChange={(e) => updateChild(i, j, 'text', e.target.value)} placeholder="Sub link text" aria-label="Sub link text" style={{ ...smallInputStyle, flex: 1 }} />
|
||||
<button onClick={() => removeChild(i, j)} aria-label={`Remove sub link ${child.text}`} style={{ padding: '2px 6px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<LinkPicker value={child.href || ''} onChange={(v) => updateChild(i, j, 'href', v)} />
|
||||
</div>
|
||||
))}
|
||||
<button className="nav-sublink-add" onClick={() => addChild(i)}>+ Add sub link (dropdown)</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { uploadAsset } from '../../../utils/assets';
|
||||
import { getPref, setPref } from '../../../utils/local-prefs';
|
||||
import { useSiteDesign, DEFAULT_SITE_DESIGN, SiteDesign } from '../../../state/SiteDesignContext';
|
||||
import { COLOR_TOKENS, resolveTokenValue, tokenKeyOf } from '../../../utils/tokens';
|
||||
import { COLOR_TOKENS, resolveTokenValue, tokenKeyOf, tokenRef } from '../../../utils/tokens';
|
||||
import { TokenSwatchRow } from './TokenSwatchRow';
|
||||
import { useDevice } from '../../../state/DeviceContext';
|
||||
import { RESPONSIVE_STYLE_KEYS } from '../../../utils/responsive-style';
|
||||
@@ -414,15 +414,16 @@ export interface NavColorField {
|
||||
fallback: string;
|
||||
}
|
||||
const NAV_COLOR_FIELDS: NavColorField[] = [
|
||||
// #53: hover/CTA fallbacks mirror the Navbar/Menu defaults (primary token).
|
||||
// Navbar / Logo / Footer schema
|
||||
{ key: 'backgroundColor', label: 'Background', fallback: '#ffffff' },
|
||||
{ key: 'textColor', label: 'Text Color', fallback: '#18181b' },
|
||||
{ key: 'hoverColor', label: 'Hover Color', fallback: '#3b82f6' },
|
||||
{ key: 'ctaColor', label: 'CTA Color', fallback: '#3b82f6' },
|
||||
{ key: 'hoverColor', label: 'Hover Color', fallback: tokenRef('primary') },
|
||||
{ key: 'ctaColor', label: 'CTA Color', fallback: tokenRef('primary') },
|
||||
// Menu schema (distinct prop names)
|
||||
{ key: 'linkColor', label: 'Link Color', fallback: '#3f3f46' },
|
||||
{ key: 'linkHoverColor', label: 'Hover Color', fallback: '#3b82f6' },
|
||||
{ key: 'ctaBgColor', label: 'CTA Background', fallback: '#3b82f6' },
|
||||
{ key: 'linkHoverColor', label: 'Hover Color', fallback: tokenRef('primary') },
|
||||
{ key: 'ctaBgColor', label: 'CTA Background', fallback: tokenRef('primary') },
|
||||
// ctaTextColor is shared: Menu's CTA text AND Navbar's CTA text
|
||||
{ key: 'ctaTextColor', label: 'CTA Text', fallback: '#ffffff' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { PageProvider, usePages, DEFAULT_HEADER_STATE } from './PageContext';
|
||||
import type { NavLink } from '../utils/nav-links';
|
||||
|
||||
/**
|
||||
* #53 NavSyncEffect, against the REAL Craft editor + the real PageProvider:
|
||||
* page operations materialise page-following links into the header's
|
||||
* Navbar props -- in the stored header string when another page is on the
|
||||
* canvas, and on the live node while the header itself is being edited.
|
||||
*/
|
||||
|
||||
type Api = ReturnType<typeof usePages>;
|
||||
let api: Api;
|
||||
let harness: EditorHarness | null = null;
|
||||
|
||||
const Probe: React.FC = () => {
|
||||
api = usePages();
|
||||
return null;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
harness?.unmount();
|
||||
harness = null;
|
||||
});
|
||||
|
||||
/** Flush PageContext.loadState's setTimeout(0) deserialize. */
|
||||
async function flush(h: EditorHarness) {
|
||||
await h.act(async () => { await new Promise((r) => setTimeout(r, 0)); });
|
||||
}
|
||||
|
||||
function mount(): EditorHarness {
|
||||
const h = renderEditorHarness();
|
||||
h.mountChild(<PageProvider><Probe /></PageProvider>);
|
||||
harness = h;
|
||||
return h;
|
||||
}
|
||||
|
||||
function storedNavLinks(): NavLink[] {
|
||||
return JSON.parse(api.headerPage.craftState!)['header-navbar'].props.links;
|
||||
}
|
||||
|
||||
const hrefs = (links: NavLink[]) => links.map((l) => l.href);
|
||||
|
||||
describe('NavSyncEffect: stored header (a page is on the canvas)', () => {
|
||||
test('the default header follows pages: add / rename / hide / delete', async () => {
|
||||
const h = mount();
|
||||
expect(api.headerPage.craftState).toBe(DEFAULT_HEADER_STATE);
|
||||
|
||||
h.act(() => { api.addPage('Services', 'services'); });
|
||||
await flush(h);
|
||||
expect(hrefs(storedNavLinks())).toEqual(['/', '/services', '#']);
|
||||
const servicesId = api.pages[1].id;
|
||||
|
||||
h.act(() => { api.renamePage(servicesId, 'What we do', 'what-we-do'); });
|
||||
expect(storedNavLinks()[1]).toEqual({ text: 'What we do', href: '/what-we-do', source: 'page', pageId: servicesId });
|
||||
|
||||
h.act(() => { api.updatePageNav(servicesId, { show: false }); });
|
||||
expect(hrefs(storedNavLinks())).toEqual(['/', '#']);
|
||||
|
||||
h.act(() => { api.updatePageNav(servicesId, { show: true, label: 'Services' }); });
|
||||
expect(storedNavLinks()[1].text).toBe('Services');
|
||||
|
||||
h.act(() => { api.deletePage(servicesId); });
|
||||
await flush(h);
|
||||
expect(hrefs(storedNavLinks())).toEqual(['/', '#']);
|
||||
});
|
||||
|
||||
test('nesting via updatePageNav produces children; moving pages reorders', async () => {
|
||||
const h = mount();
|
||||
h.act(() => { api.addPage('Services', 'services'); });
|
||||
h.act(() => { api.addPage('Web', 'web'); });
|
||||
await flush(h);
|
||||
const [, services, web] = api.pages;
|
||||
h.act(() => { api.updatePageNav(web.id, { parentId: services.id }); });
|
||||
const links = storedNavLinks();
|
||||
expect(hrefs(links)).toEqual(['/', '/services', '#']);
|
||||
expect(links[1].children).toEqual([{ text: 'Web', href: '/web', source: 'page', pageId: web.id }]);
|
||||
|
||||
h.act(() => { api.updatePageNav(web.id, { parentId: null }); });
|
||||
h.act(() => { api.movePage(web.id, 'up'); });
|
||||
expect(hrefs(storedNavLinks())).toEqual(['/', '/web', '/services', '#']);
|
||||
});
|
||||
|
||||
test('a legacy header (no followPages) is never rewritten -- manual links survive exactly', async () => {
|
||||
const h = mount();
|
||||
const legacy = JSON.parse(DEFAULT_HEADER_STATE);
|
||||
delete legacy['header-navbar'].props.followPages;
|
||||
legacy['header-navbar'].props.links = [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About us', href: '/about' },
|
||||
{ text: 'Book', href: '#book', isCta: true },
|
||||
];
|
||||
const legacyState = JSON.stringify(legacy);
|
||||
h.act(() => { api.setHeaderCraftState(legacyState); });
|
||||
h.act(() => { api.addPage('Services', 'services'); });
|
||||
await flush(h);
|
||||
expect(api.headerPage.craftState).toBe(legacyState);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NavSyncEffect: live header on the canvas', () => {
|
||||
test('rename / updatePageNav while editing the header patch the live Navbar node', async () => {
|
||||
const h = mount();
|
||||
h.act(() => { api.addPage('Services', 'services'); });
|
||||
await flush(h);
|
||||
const servicesId = api.pages[1].id;
|
||||
|
||||
h.act(() => { api.editHeader(); });
|
||||
await flush(h);
|
||||
expect(api.isEditingHeader).toBe(true);
|
||||
const liveLinks = (): NavLink[] => h.query.node('header-navbar').get().data.props.links;
|
||||
expect(hrefs(liveLinks())).toEqual(['/', '/services', '#']);
|
||||
|
||||
h.act(() => { api.renamePage(servicesId, 'Offers', 'offers'); });
|
||||
expect(liveLinks()[1]).toEqual({ text: 'Offers', href: '/offers', source: 'page', pageId: servicesId });
|
||||
|
||||
h.act(() => { api.updatePageNav(servicesId, { show: false }); });
|
||||
expect(hrefs(liveLinks())).toEqual(['/', '#']);
|
||||
});
|
||||
|
||||
test('addPage from the header switches away; the serialized header still gains the page', async () => {
|
||||
const h = mount();
|
||||
h.act(() => { api.editHeader(); });
|
||||
await flush(h);
|
||||
h.act(() => { api.addPage('Services', 'services'); });
|
||||
await flush(h);
|
||||
expect(api.isEditingHeader).toBe(false);
|
||||
expect(hrefs(storedNavLinks())).toEqual(['/', '/services', '#']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('NavSyncEffect: no-op when the menu would not change (nothing marked dirty)', () => {
|
||||
// DirtyTracker marks dirty on any new headerPage object, and Craft's
|
||||
// onNodesChange on any setProp -- so "nothing changed" must mean the SAME
|
||||
// headerPage object and the SAME live links array.
|
||||
test('stored path: a nav-signature change that leaves links identical keeps the headerPage object', async () => {
|
||||
const h = mount();
|
||||
await flush(h);
|
||||
const before = api.headerPage;
|
||||
h.act(() => { api.updatePageNav('home', { show: true }); });
|
||||
expect(api.pages[0].nav).toEqual({ show: true });
|
||||
expect(api.headerPage).toBe(before);
|
||||
});
|
||||
|
||||
test('live path: no setProp when the merged links equal the current ones', async () => {
|
||||
const h = mount();
|
||||
h.act(() => { api.editHeader(); });
|
||||
await flush(h);
|
||||
const before = h.query.node('header-navbar').get().data.props.links;
|
||||
h.act(() => { api.updatePageNav('home', { show: true }); });
|
||||
expect(h.query.node('header-navbar').get().data.props.links).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo, ReactNode } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { PageData, PageSeo } from '../types';
|
||||
import { PageData, PageSeo, PageNav } from '../types';
|
||||
import { SerializedTreeNode } from '../types/sitesmith';
|
||||
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
||||
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
|
||||
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||
import { mergeLinks, rewriteHeaderLinks, isFollowPagesNav } from '../utils/nav-links';
|
||||
import { tokenRef } from '../utils/tokens';
|
||||
|
||||
interface PageContextValue {
|
||||
pages: PageData[];
|
||||
@@ -43,9 +45,11 @@ interface PageContextValue {
|
||||
setLandingPage: (pageId: string) => void;
|
||||
/** Merges `seo` fields onto the target page's existing `seo` (creating it if absent). */
|
||||
updatePageSeo: (pageId: string, seo: Partial<PageSeo>) => void;
|
||||
/** Merges `nav` fields onto the target page's existing `nav` (#53: show / parentId / label). */
|
||||
updatePageNav: (pageId: string, nav: Partial<PageNav>) => void;
|
||||
setHeaderCraftState: (craftState: string) => void;
|
||||
setFooterCraftState: (craftState: string) => void;
|
||||
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => void;
|
||||
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo; nav?: PageNav }[]) => void;
|
||||
/**
|
||||
* Bookkeeping-only: point `activePageId` at an already-loaded page without
|
||||
* re-serializing/deserializing the canvas (the caller -- e.g. useWhpApi's
|
||||
@@ -152,6 +156,12 @@ export function treeToCraftState(tree: SerializedTreeNode): string {
|
||||
// plus a CTA button, which is inert (`href: '#'`) rather than pointing at a
|
||||
// page that doesn't exist. Users add pages/links as their site grows.
|
||||
// Node shape matches treeToCraftState() / Craft's actions.deserialize().
|
||||
//
|
||||
// #53: the seed nav follows pages (`followPages: true`), so pages added later
|
||||
// appear in it automatically. Its Home link is already materialised exactly
|
||||
// as `mergeLinks` builds it for the default Home page, so the nav-sync effect
|
||||
// is a no-op on a fresh site (nothing marked dirty). Hover/CTA colours are
|
||||
// token refs so they track the site's primary colour.
|
||||
export const DEFAULT_HEADER_STATE = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
@@ -175,13 +185,14 @@ export const DEFAULT_HEADER_STATE = JSON.stringify({
|
||||
logoFontFamily: 'Inter, sans-serif',
|
||||
logoFontSize: '20px',
|
||||
links: [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
{ text: 'Get Started', href: '#', isCta: true },
|
||||
],
|
||||
followPages: true,
|
||||
backgroundColor: '#ffffff',
|
||||
textColor: '#3f3f46',
|
||||
hoverColor: '#3b82f6',
|
||||
ctaColor: '#3b82f6',
|
||||
hoverColor: tokenRef('primary'),
|
||||
ctaColor: tokenRef('primary'),
|
||||
ctaTextColor: '#ffffff',
|
||||
padding: '16px 24px',
|
||||
navAlignment: 'space-between',
|
||||
@@ -215,6 +226,7 @@ const PageContext = createContext<PageContextValue>({
|
||||
movePage: () => {},
|
||||
setLandingPage: () => {},
|
||||
updatePageSeo: () => {},
|
||||
updatePageNav: () => {},
|
||||
setHeaderCraftState: () => {},
|
||||
setFooterCraftState: () => {},
|
||||
setPagesCraftState: () => {},
|
||||
@@ -509,7 +521,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
const prev = pagesRef.current;
|
||||
if (prev.length <= 1) return;
|
||||
|
||||
const filtered = prev.filter((p) => p.id !== pageId);
|
||||
// #53: pages nested under the deleted page become top-level again.
|
||||
const filtered = prev
|
||||
.filter((p) => p.id !== pageId)
|
||||
.map((p) => (p.nav?.parentId === pageId ? { ...p, nav: { ...p.nav, parentId: null } } : p));
|
||||
setPages(filtered);
|
||||
|
||||
// If deleting the active page, switch to the first remaining. This
|
||||
@@ -567,6 +582,9 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
slug: copySlug,
|
||||
craftState: sourceCraftState,
|
||||
seo: source.seo ? { ...source.seo } : undefined,
|
||||
// #53: the copy sits where the source sits in the menu, but keeps its
|
||||
// own "<name> copy" label rather than the source's label override.
|
||||
nav: source.nav ? { show: source.nav.show, parentId: source.nav.parentId } : undefined,
|
||||
};
|
||||
|
||||
setPages((prev) => {
|
||||
@@ -643,6 +661,13 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Merges `nav` fields onto the target page's existing `nav` (creating it if absent). */
|
||||
const updatePageNav = useCallback((pageId: string, nav: Partial<PageNav>) => {
|
||||
setPages((prev) =>
|
||||
prev.map((p) => (p.id === pageId ? { ...p, nav: { ...p.nav, ...nav } } : p)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Allow external code (e.g., load from API) to set the header craft state */
|
||||
const setHeaderCraftState = useCallback((craftState: string) => {
|
||||
setHeaderPage((prev) => ({ ...prev, craftState }));
|
||||
@@ -664,7 +689,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}, []);
|
||||
|
||||
/** Allow external code (e.g., load from API) to restore pages with craft states */
|
||||
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo }[]) => {
|
||||
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null; seo?: PageSeo; nav?: PageNav }[]) => {
|
||||
setPages(pagesData.map((p, i) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
@@ -674,6 +699,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
slug: i === 0 ? 'index' : p.slug,
|
||||
craftState: p.craftState,
|
||||
seo: p.seo,
|
||||
nav: p.nav,
|
||||
})));
|
||||
}, []);
|
||||
|
||||
@@ -753,6 +779,46 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
}, [loadState]);
|
||||
|
||||
/* ---------- NavSyncEffect (#53) ----------
|
||||
`toHtml` is pure and never sees `pages`, so page-following nav links are
|
||||
MATERIALISED into the stored Navbar/Menu props whenever anything a menu
|
||||
shows about a page changes (order, id, name, slug, nav). Every page
|
||||
operation (add/rename/delete/move/setLanding/updatePageNav/load) flows
|
||||
through here. While the header is on the canvas the live nodes are
|
||||
patched with setProp (a full deserialize would reset selection/history);
|
||||
otherwise the stored header string is rewritten. Both paths are no-ops
|
||||
when nothing changed, so nothing is marked dirty. */
|
||||
const navSignature = useMemo(
|
||||
() => JSON.stringify(pages.map((p) => [p.id, p.name, p.slug, p.nav ?? null])),
|
||||
[pages],
|
||||
);
|
||||
// useEditor() hands back fresh query/actions wrappers on every render, so
|
||||
// they are read through a ref: as effect deps they would re-run it each render.
|
||||
const editorRef = useRef({ query, actions });
|
||||
editorRef.current = { query, actions };
|
||||
useEffect(() => {
|
||||
const currentPages = pagesRef.current;
|
||||
const { query, actions } = editorRef.current;
|
||||
if (activePageIdRef.current === HEADER_ID) {
|
||||
const nodes = query.getNodes();
|
||||
for (const [id, node] of Object.entries<any>(nodes)) {
|
||||
if (!isFollowPagesNav(node.data?.name, node.data?.props)) continue;
|
||||
const current = node.data.props.links || [];
|
||||
const merged = mergeLinks(current, currentPages);
|
||||
if (JSON.stringify(merged) === JSON.stringify(current)) continue;
|
||||
// Not an undo step of its own: undoing would only un-sync the menu.
|
||||
const target = typeof actions.history?.ignore === 'function' ? actions.history.ignore() : actions;
|
||||
target.setProp(id, (p: any) => { p.links = merged; });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setHeaderPage((prev) => {
|
||||
if (!prev.craftState) return prev;
|
||||
const next = rewriteHeaderLinks(prev.craftState, currentPages);
|
||||
return next === prev.craftState ? prev : { ...prev, craftState: next };
|
||||
});
|
||||
}, [navSignature, headerPage.craftState]);
|
||||
|
||||
return (
|
||||
<PageContext.Provider
|
||||
value={{
|
||||
@@ -772,6 +838,7 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
movePage,
|
||||
setLandingPage,
|
||||
updatePageSeo,
|
||||
updatePageNav,
|
||||
setHeaderCraftState,
|
||||
setFooterCraftState,
|
||||
setPagesCraftState,
|
||||
|
||||
@@ -3012,3 +3012,82 @@ body {
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* #53 menus that follow pages: read-only page-link rows, sub-link editor,
|
||||
page-settings Menu section, hidden-from-menu badge */
|
||||
.nav-page-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.nav-page-link--child { margin: 4px 0 0 16px; }
|
||||
|
||||
.nav-page-link > .fa { color: var(--color-text-muted); font-size: 10px; }
|
||||
|
||||
.nav-page-link__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-page-link__source { font-size: 10px; color: var(--color-text-muted); }
|
||||
|
||||
.nav-page-link__hide {
|
||||
padding: 2px 6px;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.nav-page-link__hide:hover { color: var(--color-text); border-color: var(--color-text-muted); }
|
||||
|
||||
.nav-sublinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-left: 12px;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.nav-sublink { display: flex; flex-direction: column; gap: 3px; }
|
||||
|
||||
.nav-sublink-add {
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.nav-sublink-add:hover { color: var(--color-text); }
|
||||
|
||||
.page-menu-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.page-menu-settings > legend { padding: 0 4px; margin-bottom: 0; }
|
||||
|
||||
.page-menu-hidden-badge { font-size: 10px; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
@@ -32,6 +32,20 @@ export interface PageSeo {
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-page menu settings (#53). All optional -- an absent `nav` means "shown,
|
||||
* top-level, labelled with the page name", which is how every page saved
|
||||
* before this field existed behaves.
|
||||
*/
|
||||
export interface PageNav {
|
||||
/** Default true; false hides the page from page-following menus. */
|
||||
show?: boolean;
|
||||
/** Nest under this (top-level) page -- rendered as a dropdown. */
|
||||
parentId?: string | null;
|
||||
/** Menu label override; falls back to the page name. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface PageData {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -39,6 +53,8 @@ export interface PageData {
|
||||
craftState: string | null;
|
||||
/** NEW -- optional; absent === no SEO overrides (back-compat). */
|
||||
seo?: PageSeo;
|
||||
/** NEW (#53) -- optional; absent === shown, top-level, page name. */
|
||||
nav?: PageNav;
|
||||
}
|
||||
|
||||
export interface AssetData {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { buildPageLinks, mergeLinks, adoptPageLinks, rewriteHeaderLinks, pageHref, NavLink } from './nav-links';
|
||||
import { PageData } from '../types';
|
||||
import { DEFAULT_HEADER_STATE } from '../state/PageContext';
|
||||
|
||||
const pg = (id: string, name: string, slug: string, nav?: PageData['nav']): PageData =>
|
||||
({ id, name, slug, craftState: null, ...(nav ? { nav } : {}) });
|
||||
|
||||
const home = pg('home', 'Home', 'index');
|
||||
const about = pg('about', 'About', 'about');
|
||||
const services = pg('services', 'Services', 'services');
|
||||
|
||||
describe('pageHref', () => {
|
||||
test('landing page is "/", others "/slug"', () => {
|
||||
expect(pageHref(home, 0)).toBe('/');
|
||||
expect(pageHref(about, 1)).toBe('/about');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPageLinks', () => {
|
||||
test('landing first, page order, label override, source/pageId', () => {
|
||||
const links = buildPageLinks([home, pg('about', 'About', 'about', { label: 'Our story' }), services]);
|
||||
expect(links).toEqual([
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
{ text: 'Our story', href: '/about', source: 'page', pageId: 'about' },
|
||||
{ text: 'Services', href: '/services', source: 'page', pageId: 'services' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('hidden pages are left out', () => {
|
||||
const links = buildPageLinks([home, pg('about', 'About', 'about', { show: false }), services]);
|
||||
expect(links.map((l) => l.pageId)).toEqual(['home', 'services']);
|
||||
});
|
||||
|
||||
test('nests by parentId (one level), children in page order', () => {
|
||||
const pages = [
|
||||
home,
|
||||
services,
|
||||
pg('web', 'Web', 'web', { parentId: 'services' }),
|
||||
about,
|
||||
pg('seo', 'SEO', 'seo', { parentId: 'services' }),
|
||||
];
|
||||
const links = buildPageLinks(pages);
|
||||
expect(links.map((l) => l.pageId)).toEqual(['home', 'services', 'about']);
|
||||
expect(links[1].children).toEqual([
|
||||
{ text: 'Web', href: '/web', source: 'page', pageId: 'web' },
|
||||
{ text: 'SEO', href: '/seo', source: 'page', pageId: 'seo' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a child of a hidden / missing / nested parent, and the landing page, stay top-level', () => {
|
||||
const pages = [
|
||||
pg('home', 'Home', 'index', { parentId: 'about' }),
|
||||
pg('about', 'About', 'about', { show: false }),
|
||||
pg('a', 'A', 'a', { parentId: 'about' }),
|
||||
pg('b', 'B', 'b', { parentId: 'gone' }),
|
||||
pg('c', 'C', 'c', { parentId: 'd' }),
|
||||
pg('d', 'D', 'd', { parentId: 'c' }),
|
||||
];
|
||||
const links = buildPageLinks(pages);
|
||||
expect(links.map((l) => l.pageId)).toEqual(['home', 'a', 'b', 'c', 'd']);
|
||||
expect(links.every((l) => !l.children)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeLinks', () => {
|
||||
const cta: NavLink = { text: 'Get Started', href: '#signup', isCta: true };
|
||||
const blog: NavLink = { text: 'Blog', href: 'https://blog.example.com', isExternal: true };
|
||||
|
||||
test('keeps manual links and the CTA (CTA last), rebuilds page links', () => {
|
||||
const existing: NavLink[] = [
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
cta,
|
||||
blog,
|
||||
];
|
||||
expect(mergeLinks(existing, [home, about])).toEqual([
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
|
||||
blog,
|
||||
cta,
|
||||
]);
|
||||
});
|
||||
|
||||
test('manual links that sat before the page links stay before them', () => {
|
||||
const shop: NavLink = { text: 'Shop', href: 'https://shop.example.com' };
|
||||
const existing: NavLink[] = [shop, { text: 'Home', href: '/', source: 'page', pageId: 'home' }, blog];
|
||||
expect(mergeLinks(existing, [home]).map((l) => l.text)).toEqual(['Shop', 'Home', 'Blog']);
|
||||
});
|
||||
|
||||
test("drops a deleted page's link", () => {
|
||||
const existing: NavLink[] = [
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
|
||||
cta,
|
||||
];
|
||||
expect(mergeLinks(existing, [home])).toEqual([
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
cta,
|
||||
]);
|
||||
});
|
||||
|
||||
test('dedupes page links by pageId', () => {
|
||||
const existing: NavLink[] = [
|
||||
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
|
||||
{ text: 'About again', href: '/about', source: 'page', pageId: 'about' },
|
||||
];
|
||||
expect(mergeLinks(existing, [home, about]).filter((l) => l.pageId === 'about')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adoptPageLinks', () => {
|
||||
test('converts a manual "/about" (and "/") into page links; leaves CTA and externals', () => {
|
||||
const links: NavLink[] = [
|
||||
{ text: 'Home', href: '/' },
|
||||
{ text: 'About', href: '/about' },
|
||||
{ text: 'Blog', href: 'https://blog.example.com' },
|
||||
{ text: 'About CTA', href: '/about', isCta: true },
|
||||
];
|
||||
expect(adoptPageLinks(links, [home, about])).toEqual([
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
{ text: 'About', href: '/about', source: 'page', pageId: 'about' },
|
||||
{ text: 'Blog', href: 'https://blog.example.com' },
|
||||
{ text: 'About CTA', href: '/about', isCta: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A serialized header holding a follow-pages Navbar, a non-following Menu and a Container. */
|
||||
function headerState(navProps: Record<string, unknown>, menuLinks: NavLink[] = [{ text: 'Legacy', href: '/about' }]): string {
|
||||
return JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'header' }, displayName: 'Container', custom: {}, hidden: false, nodes: ['nav', 'menu'], linkedNodes: {} },
|
||||
nav: { type: { resolvedName: 'Navbar' }, isCanvas: false, props: navProps, displayName: 'Navbar', parent: 'ROOT', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
|
||||
menu: { type: { resolvedName: 'Menu' }, isCanvas: false, props: { links: menuLinks }, displayName: 'Menu', parent: 'ROOT', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
|
||||
});
|
||||
}
|
||||
|
||||
describe('rewriteHeaderLinks', () => {
|
||||
test('returns the IDENTICAL string when nothing changed', () => {
|
||||
const pages = [home, about];
|
||||
const state = headerState({ followPages: true, links: mergeLinks([], pages) });
|
||||
expect(rewriteHeaderLinks(state, pages)).toBe(state);
|
||||
});
|
||||
|
||||
test('the default header seed is already materialised for the default Home page', () => {
|
||||
expect(rewriteHeaderLinks(DEFAULT_HEADER_STATE, [home])).toBe(DEFAULT_HEADER_STATE);
|
||||
});
|
||||
|
||||
test('renaming a page updates href AND text of the follow-pages nav only', () => {
|
||||
const before = [home, about];
|
||||
const state = headerState({ followPages: true, links: [...mergeLinks([], before), { text: 'Go', href: '#', isCta: true }] });
|
||||
const after = [home, pg('about', 'Team', 'team')];
|
||||
const next = JSON.parse(rewriteHeaderLinks(state, after));
|
||||
expect(next.nav.props.links).toEqual([
|
||||
{ text: 'Home', href: '/', source: 'page', pageId: 'home' },
|
||||
{ text: 'Team', href: '/team', source: 'page', pageId: 'about' },
|
||||
{ text: 'Go', href: '#', isCta: true },
|
||||
]);
|
||||
// The non-following Menu is never touched -- legacy manual links survive exactly.
|
||||
expect(next.menu.props.links).toEqual([{ text: 'Legacy', href: '/about' }]);
|
||||
});
|
||||
|
||||
test('a legacy nav without followPages is returned unchanged even when pages differ', () => {
|
||||
const legacy = headerState({ links: [{ text: 'Home', href: '/' }, { text: 'Old', href: '/old' }] });
|
||||
expect(rewriteHeaderLinks(legacy, [home, about, services])).toBe(legacy);
|
||||
});
|
||||
|
||||
test('unreadable state is returned as-is', () => {
|
||||
expect(rewriteHeaderLinks('not json', [home])).toBe('not json');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { PageData } from '../types';
|
||||
|
||||
/**
|
||||
* Shared link shape for Navbar and Menu (#53). `source` absent = a manual
|
||||
* link (every link saved before #53 is manual and is never rewritten). A
|
||||
* `source: 'page'` link is owned by its page: on a nav with `followPages`
|
||||
* on, its text/href/children are rebuilt from the pages list whenever pages
|
||||
* change (see `mergeLinks` / `rewriteHeaderLinks`).
|
||||
*/
|
||||
export interface NavLink {
|
||||
text: string;
|
||||
href: string;
|
||||
isExternal?: boolean;
|
||||
isCta?: boolean;
|
||||
/** Adds the `download` attribute to the exported anchor (F3: links to files). */
|
||||
download?: boolean;
|
||||
source?: 'page' | 'manual';
|
||||
pageId?: string;
|
||||
/** One level of nesting, rendered as a dropdown. */
|
||||
children?: NavLink[];
|
||||
}
|
||||
|
||||
/** Resolver names of the components that carry a `links` array + `followPages`. */
|
||||
const NAV_COMPONENTS = new Set(['Navbar', 'Menu']);
|
||||
|
||||
/* Mirrors the export convention: the first page is always the landing page
|
||||
and publishes to '/', every other page publishes to '/{slug}' (the
|
||||
published .htaccess rewrites /slug -> slug.html). Single source of truth
|
||||
for page hrefs -- NavStylePanel re-exports it. */
|
||||
export function pageHref(page: PageData, index: number): string {
|
||||
return index === 0 ? '/' : `/${page.slug}`;
|
||||
}
|
||||
|
||||
function isShown(page: PageData): boolean {
|
||||
return page.nav?.show !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent a page nests under, or null when it is top-level. One level
|
||||
* only: a parent that is itself nested makes the child top-level (this also
|
||||
* breaks A<->B cycles). The landing page never nests; a missing, hidden or
|
||||
* self parent is ignored.
|
||||
*/
|
||||
function parentOf(pages: PageData[], index: number): PageData | null {
|
||||
const raw = (i: number): PageData | null => {
|
||||
if (i <= 0) return null;
|
||||
const pid = pages[i].nav?.parentId;
|
||||
if (!pid || pid === pages[i].id) return null;
|
||||
const parent = pages.find((p) => p.id === pid);
|
||||
return parent && isShown(parent) ? parent : null;
|
||||
};
|
||||
const parent = raw(index);
|
||||
if (!parent) return null;
|
||||
return raw(pages.indexOf(parent)) ? null : parent;
|
||||
}
|
||||
|
||||
function pageLink(page: PageData, index: number): NavLink {
|
||||
return {
|
||||
text: page.nav?.label || page.name,
|
||||
href: pageHref(page, index),
|
||||
source: 'page',
|
||||
pageId: page.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** One link per shown page, in page order (landing first), children nested by `nav.parentId`. */
|
||||
export function buildPageLinks(pages: PageData[]): NavLink[] {
|
||||
const top: NavLink[] = [];
|
||||
const byId = new Map<string, NavLink>();
|
||||
pages.forEach((page, i) => {
|
||||
if (!isShown(page) || parentOf(pages, i)) return;
|
||||
const link = pageLink(page, i);
|
||||
byId.set(page.id, link);
|
||||
top.push(link);
|
||||
});
|
||||
pages.forEach((page, i) => {
|
||||
if (!isShown(page)) return;
|
||||
const parent = parentOf(pages, i);
|
||||
if (!parent) return;
|
||||
const host = byId.get(parent.id);
|
||||
if (!host) return;
|
||||
host.children = [...(host.children || []), pageLink(page, i)];
|
||||
});
|
||||
return top;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the page-sourced links of `existing` from `pages`, keeping every
|
||||
* manual link. Order: manual links that sat before the first page link,
|
||||
* then the page links (page order), then the remaining manual links, then
|
||||
* every CTA. Page links are deduped by pageId simply by being rebuilt -- any
|
||||
* stale page link (deleted/hidden page) drops out.
|
||||
*/
|
||||
export function mergeLinks(existing: NavLink[], pages: PageData[]): NavLink[] {
|
||||
const leading: NavLink[] = [];
|
||||
const trailing: NavLink[] = [];
|
||||
const ctas: NavLink[] = [];
|
||||
const hasPageLinks = existing.some((l) => l.source === 'page' && !l.isCta);
|
||||
let seenPage = false;
|
||||
for (const link of existing) {
|
||||
if (link.isCta) { ctas.push(link); continue; }
|
||||
if (link.source === 'page') { seenPage = true; continue; }
|
||||
(hasPageLinks && !seenPage ? leading : trailing).push(link);
|
||||
}
|
||||
return [...leading, ...buildPageLinks(pages), ...trailing, ...ctas];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts manual links whose href is exactly a page's href into page links
|
||||
* (used once, when the owner switches Follow pages on). CTAs stay manual.
|
||||
*/
|
||||
export function adoptPageLinks(links: NavLink[], pages: PageData[]): NavLink[] {
|
||||
const byHref = new Map<string, PageData>();
|
||||
pages.forEach((p, i) => { if (!byHref.has(pageHref(p, i))) byHref.set(pageHref(p, i), p); });
|
||||
return links.map((link) => {
|
||||
if (link.isCta || link.source === 'page') return link;
|
||||
const page = byHref.get(link.href);
|
||||
return page ? { ...link, source: 'page', pageId: page.id } : link;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialises page-following links into a serialized Craft state: every
|
||||
* Navbar/Menu node with `followPages` gets `props.links = mergeLinks(...)`.
|
||||
* Returns the IDENTICAL string when nothing changed (callers use string
|
||||
* equality to avoid marking the editor dirty) or when the state is unreadable.
|
||||
*/
|
||||
export function rewriteHeaderLinks(serializedState: string, pages: PageData[]): string {
|
||||
let nodes: Record<string, any>;
|
||||
try {
|
||||
nodes = JSON.parse(serializedState);
|
||||
} catch {
|
||||
return serializedState;
|
||||
}
|
||||
if (!nodes || typeof nodes !== 'object') return serializedState;
|
||||
let changed = false;
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!node || !NAV_COMPONENTS.has(node.type?.resolvedName) || !node.props?.followPages) continue;
|
||||
const current: NavLink[] = Array.isArray(node.props.links) ? node.props.links : [];
|
||||
const merged = mergeLinks(current, pages);
|
||||
if (JSON.stringify(merged) !== JSON.stringify(current)) {
|
||||
node.props.links = merged;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? JSON.stringify(nodes) : serializedState;
|
||||
}
|
||||
|
||||
/** True when the node (Craft resolver name) is a page-following nav. */
|
||||
export function isFollowPagesNav(resolvedName: string | undefined, props: any): boolean {
|
||||
return !!resolvedName && NAV_COMPONENTS.has(resolvedName) && !!props?.followPages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline click handler for an exported dropdown toggle button (same inline
|
||||
* pattern as the Navbar hamburger): flips `is-open` on the wrapper and keeps
|
||||
* `aria-expanded` in sync. Self-contained per button, so several navs on one
|
||||
* page never interfere and nothing global is installed. No double quotes --
|
||||
* it sits inside a double-quoted attribute.
|
||||
*/
|
||||
export const DROPDOWN_TOGGLE_ONCLICK =
|
||||
"var o=this.parentNode.classList.toggle('is-open');this.setAttribute('aria-expanded',o?'true':'false')";
|
||||
|
||||
/** True when some non-CTA link has children (i.e. the dropdown CSS is needed). */
|
||||
export function hasDropdown(links: NavLink[]): boolean {
|
||||
return links.some((l) => !l.isCta && !!l.children?.length);
|
||||
}
|
||||
Reference in new issue
Block a user