Merge integration-followups
This commit is contained in:
commit
c35a73bb12
35 files changed
+1272
-107
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { EmptyContainerAdd } from './EmptyContainerAdd';
|
||||
import { EmptyContainerAdd, clampPopoverPosition } from './EmptyContainerAdd';
|
||||
import { insertBlock } from '../panels/left/blocks-registry';
|
||||
|
||||
let h: EditorHarness;
|
||||
@@ -86,3 +86,39 @@ describe('EmptyContainerAdd', () => {
|
||||
expect(plus()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EmptyContainerAdd popover viewport clamp (#41 follow-up)', () => {
|
||||
const vp = { width: 1000, height: 800 };
|
||||
const size = { width: 240, height: 96 };
|
||||
test('centred below the "+" when it fits', () => {
|
||||
expect(clampPopoverPosition({ x: 500, y: 300 }, size, vp)).toEqual({ left: 380, top: 322 });
|
||||
});
|
||||
test('clamped at the left and right edges', () => {
|
||||
expect(clampPopoverPosition({ x: 20, y: 300 }, size, vp).left).toBe(8);
|
||||
expect(clampPopoverPosition({ x: 990, y: 300 }, size, vp).left).toBe(1000 - 240 - 8);
|
||||
});
|
||||
test('flips above the "+" near the bottom edge', () => {
|
||||
expect(clampPopoverPosition({ x: 500, y: 760 }, size, vp).top).toBe(760 - 22 - 96);
|
||||
});
|
||||
test('never above the top edge on a tiny viewport', () => {
|
||||
const p = clampPopoverPosition({ x: 50, y: 40 }, size, { width: 200, height: 100 });
|
||||
expect(p.left).toBe(8);
|
||||
expect(p.top).toBe(8);
|
||||
});
|
||||
test('the rendered popover uses the clamped position (near the right edge)', () => {
|
||||
seed();
|
||||
const { innerId } = (() => {
|
||||
let secondId = '';
|
||||
h.act(() => { secondId = insertBlock(h.query, h.actions, 'section', { parentId: 'ROOT', select: false })!; });
|
||||
return { innerId: Object.values(h.query.node(secondId).get().data.linkedNodes as Record<string, string>)[0] };
|
||||
})();
|
||||
const dom = h.query.node(innerId).get().dom as HTMLElement;
|
||||
const right = window.innerWidth - 4;
|
||||
dom.getBoundingClientRect = () => ({ left: right - 20, top: 10, width: 20, height: 20, right, bottom: 30, x: right - 20, y: 10, toJSON() {} }) as DOMRect;
|
||||
h.act(() => { h.actions.selectNode(innerId); });
|
||||
h.act(() => { plus()!.click(); });
|
||||
const pop = document.querySelector('.empty-container-add-pop') as HTMLElement;
|
||||
expect(parseFloat(pop.style.left)).toBe(window.innerWidth - 240 - 8);
|
||||
expect(parseFloat(pop.style.left) + 240).toBeLessThanOrEqual(window.innerWidth);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,31 @@ import { BLOCKS, insertBlock } from '../panels/left/blocks-registry';
|
||||
const QUICK_BLOCK_IDS = ['heading', 'text', 'button', 'image', 'columns-2', 'spacer'];
|
||||
const QUICK_LABELS: Record<string, string> = { 'columns-2': 'Columns' };
|
||||
|
||||
/** Fallback popover size before it has been measured (3x2 grid). */
|
||||
const POP_FALLBACK = { width: 240, height: 96 };
|
||||
const POP_MARGIN = 8;
|
||||
const POP_GAP = 22;
|
||||
|
||||
/**
|
||||
* Where the block popover goes for a "+" centred at `anchor`: centred below
|
||||
* it, flipped above when it would run off the bottom, and clamped so it stays
|
||||
* inside the viewport (#41 follow-up).
|
||||
*/
|
||||
export function clampPopoverPosition(
|
||||
anchor: { x: number; y: number },
|
||||
size: { width: number; height: number },
|
||||
viewport: { width: number; height: number },
|
||||
): { left: number; top: number } {
|
||||
const maxLeft = Math.max(POP_MARGIN, viewport.width - size.width - POP_MARGIN);
|
||||
const left = Math.min(Math.max(anchor.x - size.width / 2, POP_MARGIN), maxLeft);
|
||||
let top = anchor.y + POP_GAP;
|
||||
if (top + size.height > viewport.height - POP_MARGIN) {
|
||||
const above = anchor.y - POP_GAP - size.height;
|
||||
top = above >= POP_MARGIN ? above : viewport.height - size.height - POP_MARGIN;
|
||||
}
|
||||
return { left, top: Math.max(POP_MARGIN, top) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline "+" for an empty container/section/column: shown for the hovered or
|
||||
* selected canvas node that has zero children (never ROOT). Click opens a
|
||||
@@ -32,6 +57,8 @@ export const EmptyContainerAdd: React.FC = () => {
|
||||
const [keepId, setKeepId] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [popSize, setPopSize] = useState(POP_FALLBACK);
|
||||
|
||||
const stillEmpty = (id: string | null): id is string => {
|
||||
if (!id) return false;
|
||||
@@ -79,6 +106,15 @@ export const EmptyContainerAdd: React.FC = () => {
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
// Measure the open popover so the clamp uses its real size.
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !popRef.current) return;
|
||||
const r = popRef.current.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0 && (r.width !== popSize.width || r.height !== popSize.height)) {
|
||||
setPopSize({ width: r.width, height: r.height });
|
||||
}
|
||||
}, [open, pos, popSize]);
|
||||
|
||||
// Target vanished (filled, dragged, deselected): drop popover state.
|
||||
useEffect(() => { if (!nodeId && open) setOpen(false); }, [nodeId, open]);
|
||||
|
||||
@@ -89,6 +125,8 @@ export const EmptyContainerAdd: React.FC = () => {
|
||||
close();
|
||||
};
|
||||
|
||||
const popPos = clampPopoverPosition(pos, popSize, { width: window.innerWidth, height: window.innerHeight });
|
||||
|
||||
const blocks = QUICK_BLOCK_IDS
|
||||
.map((id) => BLOCKS.find((b) => b.id === id))
|
||||
.filter((b): b is NonNullable<typeof b> => !!b);
|
||||
@@ -108,7 +146,7 @@ export const EmptyContainerAdd: React.FC = () => {
|
||||
<i className="fa fa-plus" aria-hidden />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="empty-container-add-pop" role="menu" style={{ left: pos.x - 120, top: pos.y + 22 }}>
|
||||
<div ref={popRef} className="empty-container-add-pop" role="menu" style={{ left: popPos.left, top: popPos.top }}>
|
||||
{blocks.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import type { DeviceMode } from '../types';
|
||||
import { useDevice } from '../state/DeviceContext';
|
||||
@@ -171,6 +171,22 @@ function focusOutsideCanvas(el: Element | null): boolean {
|
||||
return !!el.closest('button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"]');
|
||||
}
|
||||
|
||||
/** Anything that is a dialog or modal in the editor chrome. Matches inside the
|
||||
* canvas frame are page content (an HtmlBlock, a Gallery lightbox), not UI. */
|
||||
const DIALOG_SELECTOR = '[role="dialog"], [role="alertdialog"], [aria-modal="true"], dialog[open], .modal-backdrop';
|
||||
const isChromeDialog = (d: Element) => !d.closest('.canvas-device-frame');
|
||||
|
||||
/** A dialog/modal is open (modal or not), or focus is inside one. */
|
||||
export function editorDialogOpen(focused: Element | null = document.activeElement): boolean {
|
||||
const inside = focused && focused !== document.body ? focused.closest(DIALOG_SELECTOR) : null;
|
||||
if (inside && isChromeDialog(inside)) return true;
|
||||
return Array.from(document.querySelectorAll(DIALOG_SELECTOR)).some(isChromeDialog);
|
||||
}
|
||||
|
||||
/** Single printable key with no modifier ('1', '2', '3'): these belong to the
|
||||
* canvas only, never to an open dialog (#39 follow-up). */
|
||||
const isBareCharShortcut = (spec: string) => spec.length === 1;
|
||||
|
||||
function shortcutsOf(cmd: Command): string[] {
|
||||
return cmd.shortcut ? [cmd.shortcut, ...(cmd.shortcuts ?? [])] : [];
|
||||
}
|
||||
@@ -193,6 +209,7 @@ export function useCommandShortcuts(): void {
|
||||
const hit = shortcutsOf(cmd).find((spec) => matchShortcut(e, spec));
|
||||
if (!hit) continue;
|
||||
if ((hit === 'tab' || hit === 'shift+tab' || hit === 'escape') && focusOutsideCanvas(document.activeElement)) continue;
|
||||
if (isBareCharShortcut(hit) && editorDialogOpen(document.activeElement)) continue;
|
||||
if (typing && !cmd.allowWhileTyping) continue;
|
||||
if (!isEnabled(cmd, c)) continue;
|
||||
e.preventDefault();
|
||||
@@ -213,3 +230,21 @@ export function useCommandShortcuts(): void {
|
||||
export function useRegisterCommands(cmds: Command[], deps: unknown[]): void {
|
||||
useEffect(() => registerCommands(cmds), deps); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
|
||||
/**
|
||||
* One registry command as a UI affordance (context-menu entry, header button):
|
||||
* whether it is registered at all, whether its `when()` passes for the live
|
||||
* selection, and a runner. Re-renders when the registry or the selection changes.
|
||||
*/
|
||||
export function useCommandState(id: string): { registered: boolean; enabled: boolean; run: () => Promise<boolean> } {
|
||||
const ctx = useCommandContext();
|
||||
const runCommand = useRunCommand();
|
||||
const [, bump] = useReducer((n: number) => n + 1, 0);
|
||||
useEffect(() => subscribeCommands(bump), []);
|
||||
const cmd = getCommand(id);
|
||||
return {
|
||||
registered: !!cmd,
|
||||
enabled: !!cmd && isEnabled(cmd, liveContext(ctx)),
|
||||
run: () => runCommand(id),
|
||||
};
|
||||
}
|
||||
@@ -134,6 +134,51 @@ describe('device keys 1/2/3', () => {
|
||||
key('3', mod);
|
||||
expect(device).toBe('desktop');
|
||||
});
|
||||
|
||||
// #39 follow-up: bare keys never fire while any dialog/modal is open.
|
||||
test.each([
|
||||
['an aria-modal dialog', { role: 'dialog', 'aria-modal': 'true' }],
|
||||
['a non-modal role=dialog (e.g. a popover)', { role: 'dialog' }],
|
||||
['an alertdialog', { role: 'alertdialog' }],
|
||||
['a Modal backdrop without a role', { class: 'modal-backdrop' }],
|
||||
])('are ignored while %s is open, even with focus on the body', (_label, attrs) => {
|
||||
const d = document.createElement('div');
|
||||
for (const [k, v] of Object.entries(attrs)) d.setAttribute(k, v);
|
||||
document.body.appendChild(d);
|
||||
try {
|
||||
key('3');
|
||||
expect(device).toBe('desktop');
|
||||
} finally { d.remove(); }
|
||||
key('3');
|
||||
expect(device).toBe('mobile');
|
||||
});
|
||||
|
||||
test('are ignored while focus is inside a dialog', () => {
|
||||
const d = document.createElement('div');
|
||||
d.setAttribute('role', 'dialog');
|
||||
const btn = document.createElement('button');
|
||||
d.appendChild(btn);
|
||||
document.body.appendChild(d);
|
||||
btn.focus();
|
||||
try {
|
||||
key('2');
|
||||
expect(device).toBe('desktop');
|
||||
} finally { d.remove(); }
|
||||
});
|
||||
|
||||
test('a role=dialog inside the canvas frame (page content) does not block them', () => {
|
||||
const frame = document.createElement('div');
|
||||
frame.className = 'canvas-device-frame';
|
||||
const d = document.createElement('div');
|
||||
d.setAttribute('role', 'dialog');
|
||||
d.setAttribute('aria-modal', 'true');
|
||||
frame.appendChild(d);
|
||||
document.body.appendChild(frame);
|
||||
try {
|
||||
key('2');
|
||||
expect(device).toBe('tablet');
|
||||
} finally { frame.remove(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('site commands (mod+s)', () => {
|
||||
|
||||
@@ -89,6 +89,41 @@ describe('useStockPhotos.search', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStockPhotos: stale responses and 404 scope (#55 review)', () => {
|
||||
test('a cache-hit search aborts an older in-flight request and clears loading', async () => {
|
||||
fetchMock.mockReturnValueOnce(json({ success: true, results: [PHOTO], next_page: null }));
|
||||
await act(async () => { await api.search('two'); });
|
||||
const signals: AbortSignal[] = [];
|
||||
fetchMock.mockImplementation((_u: string, init: any) => {
|
||||
signals.push(init.signal);
|
||||
return new Promise(() => { /* never resolves */ });
|
||||
});
|
||||
act(() => { void api.search('one'); });
|
||||
expect(api.loading).toBe(true);
|
||||
let hit: any;
|
||||
await act(async () => { hit = await api.search('two'); });
|
||||
expect(hit.results).toEqual([PHOTO]);
|
||||
expect(signals).toHaveLength(1);
|
||||
expect(signals[0].aborted).toBe(true);
|
||||
expect(api.loading).toBe(false);
|
||||
});
|
||||
|
||||
test('a 404 on stock_import is an ordinary error, not "stock unavailable"', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: false, error: 'Photo not found' }, 404));
|
||||
let res: any = 'x';
|
||||
await act(async () => { res = await api.importPhoto(999); });
|
||||
expect(res).toBeNull();
|
||||
expect(api.availability).toBe('unknown');
|
||||
expect(api.error).toBe('Photo not found');
|
||||
});
|
||||
|
||||
test('an explicit invalid-action error on stock_import still disables the tab', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: false, error: 'Invalid action. Supported: get_sites, list' }, 400));
|
||||
await act(async () => { await api.importPhoto(7); });
|
||||
expect(api.availability).toBe('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useStockPhotos.importPhoto', () => {
|
||||
test('POSTs provider/id/size with the CSRF header and resolves the asset url', async () => {
|
||||
fetchMock.mockReturnValue(json({ success: true, url: '/api/assets-proxy/?site_id=5&filename=fox.jpg', name: 'fox.jpg', type: 'image/jpeg', credit: PHOTO.credit }));
|
||||
|
||||
@@ -55,9 +55,12 @@ function getCfg(): { apiUrl: string; siteId: number; csrfToken: string } | null
|
||||
}
|
||||
|
||||
/** The backend answers `stock_disabled` with no key; an old backend without
|
||||
* the action answers 404 or an "unknown action" error -- both hide the tab. */
|
||||
function isDisabledSignal(status: number, data: any): boolean {
|
||||
if (status === 404) return true;
|
||||
* the action answers an "unknown/invalid action" error -- both hide the tab.
|
||||
* A bare 404 only counts for the `stock_search` availability probe
|
||||
* (`probe`): on stock_import a 404 is an ordinary failure (e.g. a photo id
|
||||
* that no longer exists) and must not hide the tab. */
|
||||
function isDisabledSignal(status: number, data: any, probe: boolean): boolean {
|
||||
if (probe && status === 404) return true;
|
||||
const err = String(data?.error || '');
|
||||
return err === 'stock_disabled' || /unknown action|invalid action/i.test(err);
|
||||
}
|
||||
@@ -85,7 +88,13 @@ export function useStockPhotos() {
|
||||
if (!cfg || !query) return null;
|
||||
const key = `${query}\u0000${page}`;
|
||||
const hit = cache.current.get(key);
|
||||
if (hit) { setError(null); return hit; }
|
||||
if (hit) {
|
||||
// A cache hit is the newest request: drop any older in-flight search so
|
||||
// its (stale) response can never land after this one.
|
||||
if (abortRef.current) { abortRef.current.abort(); abortRef.current = null; setLoading(false); }
|
||||
setError(null);
|
||||
return hit;
|
||||
}
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
@@ -97,7 +106,7 @@ export function useStockPhotos() {
|
||||
const resp = await fetch(url, { headers: { 'X-CSRF-Token': cfg.csrfToken }, signal: ctrl.signal });
|
||||
let data: any = null;
|
||||
try { data = await resp.json(); } catch { /* non-JSON body */ }
|
||||
if (isDisabledSignal(resp.status, data)) { setSharedAvailability('disabled'); return null; }
|
||||
if (isDisabledSignal(resp.status, data, true)) { setSharedAvailability('disabled'); return null; }
|
||||
if (!resp.ok || !data?.success || !Array.isArray(data.results)) {
|
||||
throw new Error(data?.error || `Search failed: ${resp.status}`);
|
||||
}
|
||||
@@ -130,7 +139,7 @@ export function useStockPhotos() {
|
||||
});
|
||||
let data: any = null;
|
||||
try { data = await resp.json(); } catch { /* non-JSON body */ }
|
||||
if (isDisabledSignal(resp.status, data)) { setSharedAvailability('disabled'); return null; }
|
||||
if (isDisabledSignal(resp.status, data, false)) { setSharedAvailability('disabled'); return null; }
|
||||
if (!resp.ok || !data?.success || !data.url) throw new Error(data?.error || `Import failed: ${resp.status}`);
|
||||
return { url: data.url, name: data.name || '', type: data.type || 'image', credit: data.credit };
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/**
|
||||
* Integration follow-ups #4/#47 (items 11 + 12):
|
||||
* - a save / publish / load request that never completes is abandoned after
|
||||
* API_TIMEOUT_MS with a readable error, so it cannot block the save queue;
|
||||
* - a failed response whose JSON only has `error` (PHP's readable text)
|
||||
* shows that text instead of "server returned HTTP 503".
|
||||
*/
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collector?: (state: unknown, query: unknown) => Record<string, unknown>) => {
|
||||
const query = { serialize: () => '{}', history: { canUndo: () => false, canRedo: () => false } };
|
||||
const actions = { history: { undo: vi.fn(), redo: vi.fn(), clear: vi.fn() }, deserialize: vi.fn() };
|
||||
return { actions, query, ...(collector ? collector({}, query) : {}) };
|
||||
},
|
||||
}));
|
||||
|
||||
import { LoadGate } from '../editor/LoadGate';
|
||||
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||
import { SaveGuardProvider, useSaveGuard } from '../state/SaveGuardContext';
|
||||
import { PageProvider } from '../state/PageContext';
|
||||
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||
import { useWhpApi, API_TIMEOUT_MS } from './useWhpApi';
|
||||
import { WhpConfig } from '../types';
|
||||
|
||||
const whpConfig: WhpConfig = {
|
||||
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
|
||||
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
|
||||
};
|
||||
const jsonResp = (body: unknown, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body });
|
||||
const never = () => new Promise(() => { /* the server never answers */ });
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let api: ReturnType<typeof useWhpApi>;
|
||||
let guard: ReturnType<typeof useSaveGuard>;
|
||||
const Probe: React.FC = () => { api = useWhpApi(); guard = useSaveGuard(); return null; };
|
||||
const flush = () => act(async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); });
|
||||
const calls = (action: string) => fetchMock.mock.calls.filter(([u]) => String(u).includes(`action=${action}`));
|
||||
|
||||
async function mount(withLoadGate = true) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SaveGuardProvider><SiteDesignProvider><PageProvider>
|
||||
{withLoadGate && <LoadGate />}
|
||||
<Probe />
|
||||
</PageProvider></SiteDesignProvider></SaveGuardProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('request timeout (item 11)', () => {
|
||||
test('a hung save resolves with a readable timeout error and releases the save queue', async () => {
|
||||
let saveN = 0;
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (String(url).includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: 'r1' }));
|
||||
saveN += 1;
|
||||
return saveN === 1 ? never() : Promise.resolve(jsonResp({ success: true, rev: 'r2' }));
|
||||
});
|
||||
await mount();
|
||||
act(() => { guard.markDirty(); });
|
||||
|
||||
let first: any = 'pending';
|
||||
let second: any = 'pending';
|
||||
await act(async () => { void api.save().then((r) => { first = r; }); });
|
||||
await act(async () => { void api.save().then((r) => { second = r; }); });
|
||||
await flush();
|
||||
expect(calls('save')).toHaveLength(1); // the second waits behind the hung one
|
||||
const signal: AbortSignal = calls('save')[0][1].signal;
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
await act(async () => { vi.advanceTimersByTime(API_TIMEOUT_MS - 1); });
|
||||
await flush();
|
||||
expect(first).toBe('pending');
|
||||
|
||||
await act(async () => { vi.advanceTimersByTime(1); });
|
||||
await flush();
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(first).toMatchObject({ success: false, error: 'timeout' });
|
||||
expect(first.message).toMatch(/Save failed: the server did not respond within 30 seconds/);
|
||||
// Queue released: the waiting save went out and succeeded.
|
||||
expect(calls('save')).toHaveLength(2);
|
||||
expect(second).toMatchObject({ success: true, rev: 'r2' });
|
||||
});
|
||||
|
||||
test('a hung publish times out with a readable error', async () => {
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
const u = String(url);
|
||||
if (u.includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: 'r1' }));
|
||||
if (u.includes('action=save')) return Promise.resolve(jsonResp({ success: true, rev: 'r2' }));
|
||||
return never();
|
||||
});
|
||||
await mount();
|
||||
let res: any = 'pending';
|
||||
await act(async () => { void api.publish().then((r) => { res = r; }); });
|
||||
await flush();
|
||||
expect(calls('publish')).toHaveLength(1);
|
||||
await act(async () => { vi.advanceTimersByTime(API_TIMEOUT_MS); });
|
||||
await flush();
|
||||
expect(res).toMatchObject({ success: false, error: 'timeout' });
|
||||
expect(res.message).toMatch(/^Publish failed: the server did not respond/);
|
||||
});
|
||||
|
||||
test('a hung load rejects with a readable error', async () => {
|
||||
fetchMock.mockImplementation(() => never());
|
||||
await mount(false);
|
||||
let err: any = null;
|
||||
await act(async () => { void api.load().catch((e) => { err = e; }); });
|
||||
await act(async () => { vi.advanceTimersByTime(API_TIMEOUT_MS); });
|
||||
await flush();
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.message).toMatch(/^Load failed: the server did not respond within 30 seconds/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readApiResult error text (item 12)', () => {
|
||||
test('a 503 whose JSON has only `error` shows that text', async () => {
|
||||
const text = 'Another save of this site is still in progress. Please try again in a moment.';
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (String(url).includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: 'r1' }));
|
||||
return Promise.resolve(jsonResp({ success: false, error: text }, 503));
|
||||
});
|
||||
await mount();
|
||||
let res: any = null;
|
||||
await act(async () => { res = await api.save(); });
|
||||
expect(res).toMatchObject({ success: false, message: text });
|
||||
});
|
||||
|
||||
test('`message` still wins when present; neither -> the HTTP status text', async () => {
|
||||
let body: any = { success: false, error: 'code', message: 'Readable' };
|
||||
fetchMock.mockImplementation((url: string) => {
|
||||
if (String(url).includes('action=load')) return Promise.resolve(jsonResp({ success: true, project: null, rev: 'r1' }));
|
||||
return Promise.resolve(jsonResp(body, 500));
|
||||
});
|
||||
await mount();
|
||||
let res: any = null;
|
||||
await act(async () => { res = await api.save(); });
|
||||
expect(res.message).toBe('Readable');
|
||||
body = { success: false };
|
||||
await act(async () => { res = await api.save(); });
|
||||
expect(res.message).toBe('Save failed: server returned HTTP 500');
|
||||
});
|
||||
});
|
||||
+112
-55
@@ -263,6 +263,38 @@ export interface ApiResult {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** How long save / publish / load may take before the request is abandoned. */
|
||||
export const API_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** A request that got no complete answer within API_TIMEOUT_MS. */
|
||||
export class ApiTimeoutError extends Error {}
|
||||
|
||||
/**
|
||||
* Runs `fn` (a fetch plus reading its body) with an AbortSignal that fires
|
||||
* after `ms`. Racing the timeout as well means a stalled body read can't hang
|
||||
* the caller either: without this a request that never completes holds the
|
||||
* save queue forever.
|
||||
*/
|
||||
async function withTimeout<T>(action: string, fn: (signal: AbortSignal) => Promise<T>, ms = API_TIMEOUT_MS): Promise<T> {
|
||||
const ctrl = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
ctrl.abort();
|
||||
reject(new ApiTimeoutError(
|
||||
`${action} failed: the server did not respond within ${Math.round(ms / 1000)} seconds. Check your connection and try again.`,
|
||||
));
|
||||
}, ms);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([fn(ctrl.signal), timeout]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutResult = (e: ApiTimeoutError): ApiResult => ({ success: false, error: 'timeout', message: e.message });
|
||||
|
||||
/**
|
||||
* Reads a save/publish response without ever throwing on the body: a non-2xx
|
||||
* status or a non-JSON body (a 502 HTML page, a PHP fatal) becomes a
|
||||
@@ -278,7 +310,12 @@ async function readApiResult(resp: Response, action: 'Save' | 'Publish'): Promis
|
||||
const obj = body && typeof body === 'object' ? (body as ApiResult) : null;
|
||||
if (resp.ok && obj) return obj;
|
||||
if (obj) {
|
||||
return { ...obj, success: false, message: obj.message || `${action} failed: server returned HTTP ${resp.status}` };
|
||||
// PHP handlers often put their readable text in `error` only ("Another
|
||||
// save of this site is still in progress..."): prefer it over the status.
|
||||
const text = (typeof obj.message === 'string' && obj.message.trim())
|
||||
|| (typeof obj.error === 'string' && obj.error.trim())
|
||||
|| `${action} failed: server returned HTTP ${resp.status}`;
|
||||
return { ...obj, success: false, message: text };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
@@ -357,40 +394,49 @@ export function useWhpApi() {
|
||||
extras: projectExtras.all(),
|
||||
});
|
||||
|
||||
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
// Present on every save so the server can detect a newer copy; left
|
||||
// out only when load never reported a rev (an older backend).
|
||||
...(baseRev !== undefined ? { base_rev: baseRev } : {}),
|
||||
...(forceEmpty ? { force_empty: true } : {}),
|
||||
...(forceOverwrite ? { force_overwrite: true } : {}),
|
||||
}),
|
||||
});
|
||||
let result: ApiResult;
|
||||
try {
|
||||
result = await withTimeout('Save', async (signal) => {
|
||||
const resp = await fetch(`${whpConfig.apiUrl}?action=save`, {
|
||||
signal,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
// Present on every save so the server can detect a newer copy; left
|
||||
// out only when load never reported a rev (an older backend).
|
||||
...(baseRev !== undefined ? { base_rev: baseRev } : {}),
|
||||
...(forceEmpty ? { force_empty: true } : {}),
|
||||
...(forceOverwrite ? { force_overwrite: true } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
|
||||
// return it as a structured result instead of throwing.
|
||||
if (resp.status === 409) {
|
||||
const body = await resp.json().catch(() => null);
|
||||
if (body?.error === 'empty_overwrite_refused') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'empty_overwrite_refused',
|
||||
message: body.message || 'This would replace your saved site with an empty page.',
|
||||
};
|
||||
}
|
||||
// Opens the conflict modal (here, so the save inside publish raises it too).
|
||||
const message = body?.message || 'This site was changed in another tab or device since you opened it.';
|
||||
setConflict(message);
|
||||
return { ...(body ?? {}), success: false, error: 'conflict', message };
|
||||
// 409 empty_overwrite_refused is an expected outcome the UI handles, so
|
||||
// return it as a structured result instead of throwing.
|
||||
if (resp.status === 409) {
|
||||
const body = await resp.json().catch(() => null);
|
||||
if (body?.error === 'empty_overwrite_refused') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'empty_overwrite_refused',
|
||||
message: body.message || 'This would replace your saved site with an empty page.',
|
||||
};
|
||||
}
|
||||
// Opens the conflict modal (here, so the save inside publish raises it too).
|
||||
const message = body?.message || 'This site was changed in another tab or device since you opened it.';
|
||||
setConflict(message);
|
||||
return { ...(body ?? {}), success: false, error: 'conflict', message };
|
||||
}
|
||||
|
||||
return readApiResult(resp, 'Save');
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ApiTimeoutError) return timeoutResult(e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const result = await readApiResult(resp, 'Save');
|
||||
// The one-shot force_empty is spent only once a save actually went through.
|
||||
if (result.success) {
|
||||
if (forceEmpty) forceEmptyNextSave.current = false;
|
||||
@@ -466,33 +512,44 @@ export function useWhpApi() {
|
||||
}
|
||||
|
||||
// Then publish from staging to live
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=publish&site_id=${whpConfig.siteId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ site_id: whpConfig.siteId }),
|
||||
},
|
||||
);
|
||||
return readApiResult(resp, 'Publish');
|
||||
try {
|
||||
return await withTimeout('Publish', async (signal) => {
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=publish&site_id=${whpConfig.siteId}`,
|
||||
{
|
||||
signal,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ site_id: whpConfig.siteId }),
|
||||
},
|
||||
);
|
||||
return readApiResult(resp, 'Publish');
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ApiTimeoutError) return timeoutResult(e);
|
||||
throw e;
|
||||
}
|
||||
}, [isWHP, whpConfig, loadState, save]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!isWHP || !whpConfig) return null;
|
||||
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`,
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Load failed: HTTP ${resp.status}`);
|
||||
let data;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch {
|
||||
throw new Error('Load failed: response was not valid JSON');
|
||||
}
|
||||
// A timeout rejects with ApiTimeoutError (an Error with a readable message).
|
||||
const data = await withTimeout('Load', async (signal) => {
|
||||
const resp = await fetch(
|
||||
`${whpConfig.apiUrl}?action=load&site_id=${whpConfig.siteId}`,
|
||||
{ signal },
|
||||
);
|
||||
if (!resp.ok) throw new Error(`Load failed: HTTP ${resp.status}`);
|
||||
try {
|
||||
return await resp.json();
|
||||
} catch {
|
||||
throw new Error('Load failed: response was not valid JSON');
|
||||
}
|
||||
});
|
||||
if (!data || data.success !== true) {
|
||||
throw new Error(`Load failed: ${data?.message || data?.error || 'server reported failure'}`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { SaveGuardProvider } from '../../state/SaveGuardContext';
|
||||
import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext';
|
||||
import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle';
|
||||
import { setStyleClipboard, getStyleClipboard, extractStyle } from '../../utils/style-clipboard';
|
||||
import { registerCommand } from '../../editor/commands';
|
||||
import { isTopLevelSection } from '../../sections/catalog';
|
||||
|
||||
let h: EditorHarness;
|
||||
beforeEach(() => setStyleClipboard(null));
|
||||
@@ -122,3 +124,45 @@ describe('ContextMenu style items', () => {
|
||||
expect(document.querySelector('.wsb-presets-popover')!.textContent).toContain('No saved presets');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContextMenu section commands (#52 / #58)', () => {
|
||||
const offs: Array<() => void> = [];
|
||||
afterEach(() => { offs.splice(0).forEach((off) => off()); });
|
||||
const register = (id: string, run: (ctx: { selectedIds: string[] }) => void) =>
|
||||
offs.push(registerCommand({ id, title: id, group: 'site', when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]), run }));
|
||||
|
||||
test('entries are absent while the commands are not registered', () => {
|
||||
h = renderEditorHarness();
|
||||
const a = add(React.createElement(Heading, {}));
|
||||
h.act(() => { h.actions.selectNode(a); });
|
||||
mountMenu(a);
|
||||
expect(btn('Save as section')).toBeUndefined();
|
||||
expect(btn('Regenerate this section')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('"Save as section" and "Regenerate this section" invoke their registry commands for the selected top-level node', () => {
|
||||
const saved: string[][] = [];
|
||||
const regenerated: string[][] = [];
|
||||
register('site.save-as-section', (ctx) => { saved.push(ctx.selectedIds); });
|
||||
register('edit.regenerate-section', (ctx) => { regenerated.push(ctx.selectedIds); });
|
||||
h = renderEditorHarness();
|
||||
const a = add(React.createElement(Heading, {}));
|
||||
h.act(() => { h.actions.selectNode(a); });
|
||||
mountMenu(a);
|
||||
expect(btn('Save as section').disabled).toBe(false);
|
||||
expect(btn('Regenerate this section').disabled).toBe(false);
|
||||
click('Save as section');
|
||||
click('Regenerate this section');
|
||||
expect(saved).toEqual([[a]]);
|
||||
expect(regenerated).toEqual([[a]]);
|
||||
});
|
||||
|
||||
test('disabled when the command\'s when() is false (ROOT)', () => {
|
||||
register('site.save-as-section', () => {});
|
||||
register('edit.regenerate-section', () => {});
|
||||
h = renderEditorHarness();
|
||||
mountMenu('ROOT');
|
||||
expect(btn('Save as section').disabled).toBe(true);
|
||||
expect(btn('Regenerate this section').disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
||||
import { regenerateTreeIds } from '../../utils/craft-tree';
|
||||
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
|
||||
import { getCommand } from '../../editor/commands';
|
||||
import { getCommand, useCommandState } from '../../editor/commands';
|
||||
import { formatShortcut } from '../../utils/shortcuts';
|
||||
import { useNodeActions } from '../../hooks/useNodeActions';
|
||||
import { useNotices } from '../../state/NoticeContext';
|
||||
@@ -128,6 +128,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
// the exact same behavior. Wrapped here purely to also `onClose()` the
|
||||
// menu after each action, same as before the extraction.
|
||||
const nodeActions = useNodeActions(nodeId);
|
||||
// #52 / #58: section commands live in the registry; the menu only invokes them.
|
||||
const saveAsSection = useCommandState('site.save-as-section');
|
||||
const regenerateSection = useCommandState('edit.regenerate-section');
|
||||
|
||||
const duplicate = useCallback(() => {
|
||||
nodeActions.duplicate();
|
||||
@@ -361,6 +364,22 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
disabled: isRoot || !!instance,
|
||||
dividerAfter: true,
|
||||
},
|
||||
...(saveAsSection.registered
|
||||
? [{
|
||||
label: 'Save as section\u2026',
|
||||
icon: 'archive',
|
||||
action: () => { void saveAsSection.run(); onClose(); },
|
||||
disabled: isRoot || !saveAsSection.enabled,
|
||||
}]
|
||||
: []),
|
||||
...(regenerateSection.registered
|
||||
? [{
|
||||
label: 'Regenerate this section',
|
||||
icon: 'refresh',
|
||||
action: () => { void regenerateSection.run(); onClose(); },
|
||||
disabled: isRoot || !regenerateSection.enabled,
|
||||
}]
|
||||
: []),
|
||||
...(instance
|
||||
? [
|
||||
{ label: 'Edit master', icon: 'globe', action: editMaster },
|
||||
|
||||
@@ -271,6 +271,38 @@ describe('LayersPanel drag / rename / hide (#47)', () => {
|
||||
hn.unmount();
|
||||
});
|
||||
|
||||
test('a disallowed target keeps dropEffect "move" so the browser still fires drop (refusal notice shows)', () => {
|
||||
const hn = mount();
|
||||
const target = rowOf(hn, 'inner');
|
||||
target.getBoundingClientRect = () => ({ top: 0, height: 30, left: 0, right: 0, bottom: 30, width: 0, x: 0, y: 0, toJSON() {} }) as DOMRect;
|
||||
hn.act(() => { fire(rowOf(hn, 'sec'), 'dragstart'); });
|
||||
let over: any;
|
||||
hn.act(() => { over = fire(target, 'dragover', { clientY: 15 }); });
|
||||
expect(over.defaultPrevented).toBe(true);
|
||||
expect(over.dataTransfer.dropEffect).toBe('move');
|
||||
// ...but no drop line is drawn for the refused target.
|
||||
expect(hn.container.querySelector('[data-drop]')).toBeNull();
|
||||
hn.unmount();
|
||||
});
|
||||
|
||||
test('renaming to the unchanged default name stores no custom.label; to the default again clears one', () => {
|
||||
const hn = mount();
|
||||
const rename = (value: string) => {
|
||||
hn.act(() => { rowOf(hn, 'h').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); });
|
||||
const input = hn.container.querySelector('.layer-rename-input') as HTMLInputElement;
|
||||
input.value = value;
|
||||
hn.act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
|
||||
};
|
||||
rename('Heading');
|
||||
expect('label' in hn.query.node('h').get().data.custom).toBe(false);
|
||||
expect(JSON.parse(hn.getSerialized()).h.custom).toEqual({});
|
||||
rename('Pricing');
|
||||
expect(hn.query.node('h').get().data.custom.label).toBe('Pricing');
|
||||
rename(' Heading ');
|
||||
expect('label' in hn.query.node('h').get().data.custom).toBe(false);
|
||||
hn.unmount();
|
||||
});
|
||||
|
||||
test('linked slots are not draggable; normal rows are', () => {
|
||||
const hn = mount();
|
||||
expect(rowOf(hn, 'inner').draggable).toBe(false);
|
||||
@@ -357,3 +389,22 @@ describe('LayersPanel drag / rename / hide (#47)', () => {
|
||||
hn.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LayersPanel type icons for local-business blocks (#60)', () => {
|
||||
test.each([
|
||||
['OpeningHours', 'Opening Hours', 'fa-clock-o'],
|
||||
['StickyCallBar', 'Call Bar', 'fa-phone'],
|
||||
['ReviewsStrip', 'Reviews', 'fa-star'],
|
||||
['BookingEmbed', 'Booking', 'fa-calendar'],
|
||||
])('%s (%s) row uses %s', (resolvedName, displayName, icon) => {
|
||||
const n = {
|
||||
type: { resolvedName }, isCanvas: false, props: {}, displayName,
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||
};
|
||||
const harness = renderEditorHarness({ initialState: stateWith({ b: n }, ['b']) });
|
||||
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||
const row = harness.container.querySelector('.layer-node-row[data-layer-id="b"]')!;
|
||||
expect(row.querySelector(`i.${icon}`)).not.toBeNull();
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,11 @@ const TYPE_ICONS: Record<string, string> = {
|
||||
'Submit Button': 'fa-paper-plane',
|
||||
'Contact Form': 'fa-envelope',
|
||||
'Subscribe Form': 'fa-paper-plane',
|
||||
// Local business (#60)
|
||||
'Opening Hours': 'fa-clock-o',
|
||||
'Call Bar': 'fa-phone',
|
||||
Reviews: 'fa-star',
|
||||
Booking: 'fa-calendar',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON = 'fa-cube';
|
||||
@@ -294,12 +299,15 @@ const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
|
||||
const commitRename = useCallback((value: string) => {
|
||||
ui.set({ renamingId: null });
|
||||
const v = value.trim();
|
||||
const typed = value.trim();
|
||||
// Typing the default name back means "no custom label": never store a
|
||||
// redundant custom.label equal to the type name.
|
||||
const v = typed === displayName ? '' : typed;
|
||||
if (v === customLabel) return;
|
||||
actions.setCustom(nodeId, (c: any) => {
|
||||
if (v) c.label = v; else delete c.label;
|
||||
});
|
||||
}, [ui, actions, nodeId, customLabel]);
|
||||
}, [ui, actions, nodeId, customLabel, displayName]);
|
||||
|
||||
const toggleHidden = useCallback((e: React.SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -338,7 +346,10 @@ const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
e.preventDefault();
|
||||
const drop = resolveDrop(e);
|
||||
const ok = !!drop && dragId !== nodeId && moveLayerAllowed(query, dragId, drop.parentId);
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none';
|
||||
// Always 'move': with dropEffect 'none' the browser never fires `drop`, so
|
||||
// a refused target could not explain itself. Disallowed targets get no drop
|
||||
// line here and are refused (with a notice) in handleDrop via moveLayer.
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||
ui.set(ok && drop ? { dropTargetId: nodeId, dropWhere: drop.where } : { dropTargetId: null, dropWhere: null });
|
||||
};
|
||||
const handleDrop = (e: React.DragEvent<HTMLElement>) => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
|
||||
import { SiteDesignProvider } from '../../state/SiteDesignContext';
|
||||
import { PageProvider } from '../../state/PageContext';
|
||||
import { SitesmithProvider } from '../../state/SitesmithContext';
|
||||
import { insertBlock } from '../left/blocks-registry';
|
||||
import { Heading } from '../../components/basic/Heading';
|
||||
import { Container } from '../../components/layout/Container';
|
||||
import { registerCommand } from '../../editor/commands';
|
||||
import { isTopLevelSection } from '../../sections/catalog';
|
||||
import { GuidedStyles } from './GuidedStyles';
|
||||
|
||||
/** Integration follow-ups: #60 business-panel classification and the #58 Regenerate button. */
|
||||
let h: EditorHarness;
|
||||
const offs: Array<() => void> = [];
|
||||
afterEach(() => { h?.unmount(); offs.splice(0).forEach((off) => off()); });
|
||||
|
||||
function mount() {
|
||||
h = renderEditorHarness();
|
||||
h.mountChild(
|
||||
<PageProvider><SiteDesignProvider><SitesmithProvider>
|
||||
<GuidedStyles />
|
||||
</SitesmithProvider></SiteDesignProvider></PageProvider>,
|
||||
);
|
||||
}
|
||||
const select = (id: string) => h.act(() => h.actions.selectNode(id));
|
||||
const header = () => h.container.querySelector('[data-testid="type-header"]')?.textContent?.trim();
|
||||
const text = () => h.container.textContent || '';
|
||||
const regenBtn = () => h.container.querySelector<HTMLButtonElement>('button[aria-label="Regenerate this section"]');
|
||||
|
||||
describe('GuidedStyles: local-business blocks use BusinessStylePanel (#60)', () => {
|
||||
test.each([
|
||||
['opening-hours', 'Opening Hours', 'Label color'],
|
||||
['call-bar', 'Call Bar', 'Call button label'],
|
||||
['reviews-strip', 'Reviews', 'Card background'],
|
||||
['booking-embed', 'Booking', 'Booking URL (Calendly / Square)'],
|
||||
])('%s -> header "%s" and the business control "%s"', (blockId, displayName, label) => {
|
||||
mount();
|
||||
let id: string | null = null;
|
||||
h.act(() => { id = insertBlock(h.query, h.actions, blockId); });
|
||||
select(id!);
|
||||
expect(header()).toBe(displayName);
|
||||
expect(text()).toContain(label);
|
||||
expect(h.container.querySelector('.guided-type-header .fa-briefcase')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GuidedStyles: Regenerate button (#58)', () => {
|
||||
const add = (el: React.ReactElement, parent = 'ROOT'): string => {
|
||||
let id = '';
|
||||
h.act(() => {
|
||||
const tree = h.query.parseReactElement(el).toNodeTree();
|
||||
h.actions.addNodeTree(tree, parent);
|
||||
id = tree.rootNodeId;
|
||||
});
|
||||
return id;
|
||||
};
|
||||
const registerRegenerate = (runs: string[][]) =>
|
||||
offs.push(registerCommand({
|
||||
id: 'edit.regenerate-section', title: 'Regenerate this section', group: 'edit',
|
||||
when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]),
|
||||
run: (ctx) => { runs.push(ctx.selectedIds); },
|
||||
}));
|
||||
|
||||
test('shown for a top-level section and runs the registry command', () => {
|
||||
const runs: string[][] = [];
|
||||
registerRegenerate(runs);
|
||||
mount();
|
||||
const section = add(<Container />);
|
||||
select(section);
|
||||
expect(regenBtn()).not.toBeNull();
|
||||
h.act(() => { regenBtn()!.click(); });
|
||||
expect(runs).toEqual([[section]]);
|
||||
});
|
||||
|
||||
test('hidden when the command\'s when() is false (nested node)', () => {
|
||||
registerRegenerate([]);
|
||||
mount();
|
||||
const section = add(<Container />);
|
||||
const inner = add(<Heading />, section);
|
||||
select(inner);
|
||||
expect(header()).toBe('Heading');
|
||||
expect(regenBtn()).toBeNull();
|
||||
});
|
||||
|
||||
test('hidden when the command is not registered (standalone, no Sitesmith)', () => {
|
||||
mount();
|
||||
const section = add(<Container />);
|
||||
select(section);
|
||||
expect(regenBtn()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
BackgroundSectionStylePanel,
|
||||
GenericPropsEditor,
|
||||
HtmlStylePanel,
|
||||
BusinessStylePanel,
|
||||
} from './styles';
|
||||
import { DeviceBanner } from './styles/responsive-panel';
|
||||
import { setBulkSelection } from './styles/shared';
|
||||
import { CORE_COMMANDS } from '../../editor/core-commands';
|
||||
import { useCommandState } from '../../editor/commands';
|
||||
import { useGlobalSections } from '../../state/GlobalSectionsContext';
|
||||
import type { OverridePatch } from '../../utils/global-sections';
|
||||
|
||||
@@ -152,6 +154,8 @@ export const GuidedStyles: React.FC<{
|
||||
emptyFallback?: 'site' | 'placeholder';
|
||||
}> = ({ emptyFallback = 'site' }) => {
|
||||
const { open: openSitesmith } = useSitesmithModal();
|
||||
// #58: Regenerate lives in the registry (Sitesmith, WHP only); hidden when its when() is false.
|
||||
const regenerate = useCommandState('edit.regenerate-section');
|
||||
const { query } = useEditor();
|
||||
|
||||
const { selected, selectedType, nodeProps, selectedIds, mixedTypes, inInstance } = useEditor((state) => {
|
||||
@@ -245,6 +249,8 @@ export const GuidedStyles: React.FC<{
|
||||
// Utility types that need minimal controls. HTML is deliberately NOT here
|
||||
// -- it gets HtmlStylePanel, which shows the code editor and nothing else.
|
||||
const isUtility = /^divider$|^spacer$/i.test(typeName);
|
||||
// #60 local-business blocks (displayNames in components/business/*).
|
||||
const isBusiness = /^opening hours$|^call bar$|^reviews$|^booking$/i.test(typeName);
|
||||
|
||||
// Icon for the type badge
|
||||
const typeIcon = isText ? 'fa-font'
|
||||
@@ -260,6 +266,7 @@ export const GuidedStyles: React.FC<{
|
||||
: isSection ? 'fa-th-large'
|
||||
: isHtml ? 'fa-code'
|
||||
: isUtility ? 'fa-ellipsis-h'
|
||||
: isBusiness ? 'fa-briefcase'
|
||||
: 'fa-cube';
|
||||
|
||||
const handleAskSitesmith = () => {
|
||||
@@ -289,6 +296,17 @@ export const GuidedStyles: React.FC<{
|
||||
>
|
||||
<i className="fa fa-magic" /> Ask Sitesmith
|
||||
</button>
|
||||
{regenerate.enabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="guided-regenerate-btn"
|
||||
onClick={() => { void regenerate.run(); }}
|
||||
title="Regenerate this section with Sitesmith"
|
||||
aria-label="Regenerate this section"
|
||||
>
|
||||
<i className="fa fa-refresh" aria-hidden /> Regenerate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{inInstance && <GlobalInstancePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
{!inInstance && <>
|
||||
@@ -336,8 +354,11 @@ export const GuidedStyles: React.FC<{
|
||||
{/* UTILITY (Divider, Spacer) -- use generic but it works well for these */}
|
||||
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
||||
|
||||
{/* LOCAL BUSINESS (Opening Hours, Call Bar, Reviews, Booking) */}
|
||||
{isBusiness && <BusinessStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* FALLBACK: Anything not matched above */}
|
||||
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && (
|
||||
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && !isBusiness && (
|
||||
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
|
||||
)}
|
||||
</>}
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
|
||||
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
|
||||
const { device } = useDevice();
|
||||
|
||||
const { setProp, setPropStyle, setProps, setPropContinuous, setPropStyleContinuous, setPropStylesContinuous } = useNodeProp(selectedId);
|
||||
const { setProp, setPropStyle, setProps, setPropStyles, setPropContinuous, setPropStyleContinuous, setPropStylesContinuous } = useNodeProp(selectedId);
|
||||
|
||||
// ColumnLayout only ever carries `columns`/`split` props -- Container and
|
||||
// Section never set them -- so checking either alone distinguishes the
|
||||
@@ -153,8 +153,8 @@ export const ContainerStylePanel: React.FC<StylePanelProps> = ({ selectedId, nod
|
||||
)}
|
||||
|
||||
{/* Box model + border/effects + animation/visibility rollout */}
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} labelAddon={dot} />
|
||||
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} setPropStyles={setPropStylesContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
|
||||
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
|
||||
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -118,8 +118,8 @@ export const ImageStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
</div>
|
||||
|
||||
{/* Box model + animation/visibility rollout */}
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} />
|
||||
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
|
||||
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
|
||||
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -279,8 +279,8 @@ export const MediaStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePro
|
||||
|
||||
{/* Box model + animation/visibility rollout (Video/Gallery/Map/Slider
|
||||
all carry these props now, so it's safe to render unconditionally). */}
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} />
|
||||
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} />
|
||||
<BoxModelSection style={style} setPropStyle={setPropStyleContinuous} labelAddon={dot} setPropStylesDiscrete={setPropStyles} />
|
||||
<BorderEffectsSection style={style} setPropStyle={setPropStyleContinuous} setPropStyleDiscrete={setPropStyle} />
|
||||
<AnimVisSection nodeProps={nodeProps} setProps={setProps} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
|
||||
import { DeviceProvider } from '../../../state/DeviceContext';
|
||||
import type { DeviceMode } from '../../../types';
|
||||
import { NavStylePanel } from './NavStylePanel';
|
||||
import { closeContinuousBurst } from './shared';
|
||||
|
||||
/**
|
||||
* Integration follow-up (#45 review): NavStylePanel reads the per-device
|
||||
* resolved style and shows override dots on Margin / Padding, the same
|
||||
* conversion #45 applied to the other panels.
|
||||
*/
|
||||
let h: EditorHarness | null = null;
|
||||
afterEach(() => { closeContinuousBurst(); h?.unmount(); h = null; });
|
||||
|
||||
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
|
||||
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type,
|
||||
custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
|
||||
});
|
||||
const state = (extra: Record<string, unknown> = {}) => JSON.stringify({
|
||||
ROOT: node('Container', { tag: 'div', style: {} }, ['logo']),
|
||||
logo: node('Logo', {
|
||||
type: 'text', text: 'Acme',
|
||||
style: { marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0', paddingTop: '8px', paddingRight: '8px', paddingBottom: '8px', paddingLeft: '8px' },
|
||||
...extra,
|
||||
}, [], { parent: 'ROOT' }),
|
||||
});
|
||||
|
||||
const Host: React.FC = () => {
|
||||
const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes.logo?.data.props || {} }));
|
||||
return <NavStylePanel selectedId="logo" nodeProps={nodeProps} />;
|
||||
};
|
||||
function mount(device: DeviceMode, initialState = state()) {
|
||||
h = renderEditorHarness({ initialState, wrap: (t) => <DeviceProvider initialDevice={device}>{t}</DeviceProvider> });
|
||||
h.mountChild(<Host />);
|
||||
}
|
||||
const props = () => h!.query.node('logo').get().data.props;
|
||||
const control = (label: string) =>
|
||||
Array.from(h!.container.querySelectorAll('[data-testid="spacing-control"]'))
|
||||
.find((el) => (el.querySelector('.guided-section-label')?.textContent || '').startsWith(label)) as HTMLElement;
|
||||
const active = (label: string) => control(label).querySelector('button.preset-btn.active')?.textContent ?? null;
|
||||
const click = (el: Element) => h!.act(() => { (el as HTMLElement).click(); });
|
||||
|
||||
describe('NavStylePanel per-device spacing (#45 follow-up)', () => {
|
||||
test('mobile reads the mobile override, not the desktop style', () => {
|
||||
mount('mobile', state({ styleMobile: { marginTop: '16px', marginRight: '16px', marginBottom: '16px', marginLeft: '16px' } }));
|
||||
expect(active('Margin')).toBe('M');
|
||||
expect(control('Margin').querySelector('[data-testid="override-dot"]')).not.toBeNull();
|
||||
expect(control('Padding').querySelector('[data-testid="override-dot-inherited"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a mobile Margin edit lands in styleMobile, reads back, and its dot resets it', () => {
|
||||
mount('mobile');
|
||||
expect(active('Margin')).toBe('None');
|
||||
click(Array.from(control('Margin').querySelectorAll('button.preset-btn')).find((b) => b.textContent === 'M')!);
|
||||
expect(props().styleMobile?.marginTop).toBe('16px');
|
||||
expect(props().style.marginTop).toBe('0');
|
||||
expect(active('Margin')).toBe('M');
|
||||
click(control('Margin').querySelector('[data-testid="override-dot"]')!);
|
||||
expect(props().styleMobile?.marginTop).toBeUndefined();
|
||||
expect(active('Margin')).toBe('None');
|
||||
});
|
||||
|
||||
test('desktop shows no dots', () => {
|
||||
mount('desktop', state({ styleMobile: { marginTop: '16px' } }));
|
||||
expect(h!.container.querySelector('.wsb-override-dot')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
continuousSetProp,
|
||||
discreteSetProp,
|
||||
} from './shared';
|
||||
import { useResolvedStyle, MARGIN_KEYS, PADDING_KEYS } from './responsive-panel';
|
||||
import { OverrideDot } from './OverrideDot';
|
||||
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||
import { usePages } from '../../../state/PageContext';
|
||||
import { PageData } from '../../../types';
|
||||
@@ -329,7 +331,10 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
component's .craft.props), so -- unlike the Links/Colors sections above,
|
||||
which are shared across a genuinely disparate prop schema -- these
|
||||
sections render unconditionally rather than gating on presence. */
|
||||
const style = nodeProps.style || {};
|
||||
// #45: the per-device resolved style (desktop ⊕ tablet ⊕ mobile), so a
|
||||
// tablet/mobile edit reads back what it wrote, plus override dots.
|
||||
const style = useResolvedStyle(nodeProps) as Record<string, any>;
|
||||
const dot = (key: string | readonly string[]) => <OverrideDot nodeProps={nodeProps} selectedId={selectedId} styleKey={key} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -544,12 +549,12 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
||||
previous behavior exactly). */}
|
||||
<CollapsibleSection title="Spacing">
|
||||
<SpacingControl
|
||||
label="Margin"
|
||||
label={<>Margin{dot(MARGIN_KEYS)}</>}
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
onChange={(side, v) => setPropStyleContinuous(`margin${capitalize(side)}`, v)}
|
||||
/>
|
||||
<SpacingControl
|
||||
label="Padding"
|
||||
label={<>Padding{dot(PADDING_KEYS)}</>}
|
||||
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
||||
onChange={(side, v) => setPropStyleContinuous(`padding${capitalize(side)}`, v)}
|
||||
presets={SPACING_PRESETS}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { renderEditorHarness, EditorHarness } from '../../../test-utils/editorHarness';
|
||||
import { DeviceProvider } from '../../../state/DeviceContext';
|
||||
import { ContainerStylePanel } from './ContainerStylePanel';
|
||||
import { ImageStylePanel } from './ImageStylePanel';
|
||||
import { closeContinuousBurst } from './shared';
|
||||
|
||||
/**
|
||||
* #7 deferred minor: preset buttons in BoxModelSection / BorderEffectsSection
|
||||
* are discrete edits. A keyboard-activated preset (Enter/Space: no pointerup,
|
||||
* no focusout) must never merge into the previous edit of the same key.
|
||||
* `.click()` here is exactly that: a click with no pointer events around it.
|
||||
*/
|
||||
let h: EditorHarness | null = null;
|
||||
afterEach(() => { closeContinuousBurst(); h?.unmount(); h = null; });
|
||||
|
||||
const node = (type: string, props: Record<string, unknown>, nodes: string[] = [], extra: Record<string, unknown> = {}) => ({
|
||||
type: { resolvedName: type }, isCanvas: type === 'Container', props, displayName: type === 'ImageBlock' ? 'Image' : type,
|
||||
custom: {}, hidden: false, nodes, linkedNodes: {}, ...extra,
|
||||
});
|
||||
const state = () => JSON.stringify({
|
||||
ROOT: node('Container', { tag: 'div', style: {} }, ['box', 'img']),
|
||||
box: node('Container', { tag: 'div', style: { paddingTop: '4px', paddingRight: '4px', paddingBottom: '4px', paddingLeft: '4px', marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0' } }, [], { parent: 'ROOT' }),
|
||||
img: node('ImageBlock', { src: 'a.png', alt: 'a', style: { marginTop: '0', marginRight: '0', marginBottom: '0', marginLeft: '0' } }, [], { parent: 'ROOT' }),
|
||||
});
|
||||
|
||||
const Host: React.FC<{ id: string; Panel: React.FC<any> }> = ({ id, Panel }) => {
|
||||
const { nodeProps } = useEditor((s) => ({ nodeProps: s.nodes[id]?.data.props || {} }));
|
||||
return <Panel selectedId={id} nodeProps={nodeProps} />;
|
||||
};
|
||||
function mount(id: string, Panel: React.FC<any>) {
|
||||
h = renderEditorHarness({ initialState: state(), wrap: (t) => <DeviceProvider initialDevice="desktop">{t}</DeviceProvider> });
|
||||
h.mountChild(<Host id={id} Panel={Panel} />);
|
||||
h.act(() => { h!.actions.history.clear(); });
|
||||
}
|
||||
const style = (id: string) => h!.query.node(id).get().data.props.style;
|
||||
const control = (label: string) =>
|
||||
Array.from(h!.container.querySelectorAll('[data-testid="spacing-control"]'))
|
||||
.find((el) => (el.querySelector('.guided-section-label')?.textContent || '').startsWith(label)) as HTMLElement;
|
||||
const preset = (root: Element, text: string) =>
|
||||
Array.from(root.querySelectorAll('button.preset-btn')).find((b) => b.textContent === text) as HTMLButtonElement;
|
||||
const shadowSection = () => {
|
||||
if (!Array.from(h!.container.querySelectorAll('.guided-section-label')).some((l) => l.textContent === 'Shadow')) {
|
||||
const toggle = Array.from(h!.container.querySelectorAll('button')).find((b) => b.textContent?.includes('Border & Effects'))!;
|
||||
h!.act(() => { toggle.click(); });
|
||||
}
|
||||
const label = Array.from(h!.container.querySelectorAll('.guided-section-label')).find((l) => l.textContent === 'Shadow')!;
|
||||
return label.closest('.guided-section') as HTMLElement;
|
||||
};
|
||||
const press = (b: HTMLButtonElement) => h!.act(() => { b.click(); });
|
||||
const undo = () => h!.act(() => { h!.actions.history.undo(); });
|
||||
|
||||
describe('box-model / border-effects presets are discrete undo steps', () => {
|
||||
test('Container: two padding presets in a row are two undo steps', () => {
|
||||
mount('box', ContainerStylePanel);
|
||||
press(preset(control('Padding'), 'S'));
|
||||
press(preset(control('Padding'), 'L'));
|
||||
expect(style('box').paddingTop).toBe('24px');
|
||||
undo();
|
||||
expect(style('box').paddingTop).toBe('8px');
|
||||
expect(style('box').paddingLeft).toBe('8px');
|
||||
});
|
||||
|
||||
test('Container: two margin presets in a row are two undo steps, each one step for all sides', () => {
|
||||
mount('box', ContainerStylePanel);
|
||||
press(preset(control('Margin'), 'S'));
|
||||
press(preset(control('Margin'), 'M'));
|
||||
undo();
|
||||
expect([style('box').marginTop, style('box').marginRight, style('box').marginBottom, style('box').marginLeft]).toEqual(['8px', '8px', '8px', '8px']);
|
||||
undo();
|
||||
expect(style('box').marginTop).toBe('0');
|
||||
expect(h!.query.history.canUndo()).toBe(false);
|
||||
});
|
||||
|
||||
test('Image: a margin preset is ONE undo step for all four sides', () => {
|
||||
mount('img', ImageStylePanel);
|
||||
press(preset(control('Margin'), 'M'));
|
||||
expect(style('img').marginLeft).toBe('16px');
|
||||
undo();
|
||||
expect([style('img').marginTop, style('img').marginRight, style('img').marginBottom, style('img').marginLeft]).toEqual(['0', '0', '0', '0']);
|
||||
expect(h!.query.history.canUndo()).toBe(false);
|
||||
});
|
||||
|
||||
test('shadow presets in a row are separate undo steps (Container and Image)', () => {
|
||||
for (const [id, Panel] of [['box', ContainerStylePanel], ['img', ImageStylePanel]] as const) {
|
||||
mount(id, Panel);
|
||||
press(preset(shadowSection(), 'S'));
|
||||
press(preset(shadowSection(), 'L'));
|
||||
undo();
|
||||
expect(style(id).boxShadow).toBe('0 1px 2px rgba(0,0,0,0.08)');
|
||||
h!.unmount(); h = null; closeContinuousBurst();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -81,13 +81,23 @@ export interface BoxModelSectionProps {
|
||||
setPropStyles: (patch: Record<string, string>, controlKey: string) => void;
|
||||
/** Per-device marker after the Margin / Padding labels (#45 OverrideDot). */
|
||||
labelAddon?: (keys: readonly string[]) => React.ReactNode;
|
||||
/** DISCRETE multi-key write (one undo step, closes any open burst) for the
|
||||
* preset buttons, so a keyboard-activated preset never merges into the
|
||||
* previous edit of the same key. Falls back to the continuous setters. */
|
||||
setPropStylesDiscrete?: (patch: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
const sidesPatch = (kind: 'margin' | 'padding', v: SpacingSideValues): Record<string, string> => {
|
||||
const patch: Record<string, string> = {};
|
||||
SIDES.forEach((s) => { patch[`${kind}${capitalize(s)}`] = v[s] ?? ''; });
|
||||
return patch;
|
||||
};
|
||||
|
||||
/** Margin + Padding, per-side, via the shared SpacingControl. Padding is the
|
||||
* only padding control: a legacy shorthand `style.padding` (with no
|
||||
* longhands yet) is shown expanded and migrated to the four longhands on the
|
||||
* first edit, in the same history step. */
|
||||
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, setPropStyles, labelAddon }) => {
|
||||
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, setPropStyles, labelAddon, setPropStylesDiscrete }) => {
|
||||
const marginSetter = (side: SpacingSide, value: string) => setPropStyle(`margin${capitalize(side)}`, value);
|
||||
|
||||
const legacyShorthand = hasValue(style.padding) && PADDING_KEYS.every((k) => !hasValue(style[k]));
|
||||
@@ -111,12 +121,14 @@ export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setProp
|
||||
label={<>Margin{labelAddon?.(MARGIN_KEYS)}</>}
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
onChange={marginSetter}
|
||||
onPreset={setPropStylesDiscrete ? (all) => setPropStylesDiscrete(sidesPatch('margin', all)) : undefined}
|
||||
/>
|
||||
<SpacingControl
|
||||
label={<>Padding{labelAddon?.(PADDING_GROUP)}</>}
|
||||
value={padding}
|
||||
onChange={(side, value) => setPropStyles(paddingPatch({ ...padding, [side]: value }), `style.padding${capitalize(side)}`)}
|
||||
onChangeAll={(all) => setPropStyles(paddingPatch(all), 'style.padding*')}
|
||||
onPreset={setPropStylesDiscrete ? (all) => setPropStylesDiscrete(paddingPatch(all)) : undefined}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
@@ -134,10 +146,12 @@ function opacityPercent(v: unknown): number {
|
||||
export interface BorderEffectsSectionProps {
|
||||
style: Record<string, any>;
|
||||
setPropStyle: (prop: string, value: string) => void;
|
||||
/** DISCRETE single-key write for the shadow preset buttons (see BoxModelSection). */
|
||||
setPropStyleDiscrete?: (prop: string, value: string) => void;
|
||||
}
|
||||
|
||||
/** Border (width/style/color) + box-shadow preset + opacity slider. */
|
||||
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
|
||||
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle, setPropStyleDiscrete }) => (
|
||||
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
||||
<BorderControl
|
||||
value={parseBorderShorthand(style.border)}
|
||||
@@ -145,7 +159,7 @@ export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ styl
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
||||
|
||||
@@ -45,12 +45,25 @@ export interface BoxModelSectionProps {
|
||||
setPropStyle: (prop: string, value: string) => void;
|
||||
/** Per-device marker after the Margin / Padding labels (#45 OverrideDot). */
|
||||
labelAddon?: (keys: readonly string[]) => React.ReactNode;
|
||||
/** DISCRETE multi-key write (one undo step, closes any open burst) for the
|
||||
* preset buttons, so a keyboard-activated preset never merges into the
|
||||
* previous edit of the same key. Falls back to per-side setPropStyle. */
|
||||
setPropStylesDiscrete?: (patch: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
const SIDES: SpacingSide[] = ['top', 'right', 'bottom', 'left'];
|
||||
|
||||
/** Margin + Padding, per-side, via the shared SpacingControl. */
|
||||
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, labelAddon }) => {
|
||||
export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setPropStyle, labelAddon, setPropStylesDiscrete }) => {
|
||||
const sideSetter = (kind: 'margin' | 'padding') => (side: SpacingSide, value: string) =>
|
||||
setPropStyle(`${kind}${capitalize(side)}`, value);
|
||||
const presetSetter = (kind: 'margin' | 'padding') => (setPropStylesDiscrete
|
||||
? (all: Record<SpacingSide, string>) => {
|
||||
const patch: Record<string, string> = {};
|
||||
SIDES.forEach((s) => { patch[`${kind}${capitalize(s)}`] = all[s]; });
|
||||
setPropStylesDiscrete(patch);
|
||||
}
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<CollapsibleSection title="Spacing">
|
||||
@@ -58,11 +71,13 @@ export const BoxModelSection: React.FC<BoxModelSectionProps> = ({ style, setProp
|
||||
label={<>Margin{labelAddon?.(MARGIN_KEYS)}</>}
|
||||
value={{ top: style.marginTop, right: style.marginRight, bottom: style.marginBottom, left: style.marginLeft }}
|
||||
onChange={sideSetter('margin')}
|
||||
onPreset={presetSetter('margin')}
|
||||
/>
|
||||
<SpacingControl
|
||||
label={<>Padding{labelAddon?.(PADDING_KEYS)}</>}
|
||||
value={{ top: style.paddingTop, right: style.paddingRight, bottom: style.paddingBottom, left: style.paddingLeft }}
|
||||
onChange={sideSetter('padding')}
|
||||
onPreset={presetSetter('padding')}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
@@ -80,10 +95,12 @@ function opacityPercent(v: unknown): number {
|
||||
export interface BorderEffectsSectionProps {
|
||||
style: Record<string, any>;
|
||||
setPropStyle: (prop: string, value: string) => void;
|
||||
/** DISCRETE single-key write for the shadow preset buttons (see BoxModelSection). */
|
||||
setPropStyleDiscrete?: (prop: string, value: string) => void;
|
||||
}
|
||||
|
||||
/** Border (width/style/color) + box-shadow preset + opacity slider. */
|
||||
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle }) => (
|
||||
export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ style, setPropStyle, setPropStyleDiscrete }) => (
|
||||
<CollapsibleSection title="Border & Effects" defaultOpen={false}>
|
||||
<BorderControl
|
||||
value={parseBorderShorthand(style.border)}
|
||||
@@ -91,7 +108,7 @@ export const BorderEffectsSection: React.FC<BorderEffectsSectionProps> = ({ styl
|
||||
/>
|
||||
<div className="guided-section">
|
||||
<SectionLabel>Shadow</SectionLabel>
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => setPropStyle('boxShadow', v)} />
|
||||
<PresetButtonGrid presets={SHADOW_PRESETS} activeValue={style.boxShadow} onSelect={(v) => (setPropStyleDiscrete ?? setPropStyle)('boxShadow', v)} />
|
||||
</div>
|
||||
<div style={sectionGap}>
|
||||
<label style={labelStyle}>Opacity: {opacityPercent(style.opacity)}%</label>
|
||||
|
||||
@@ -925,10 +925,14 @@ export interface SpacingControlProps {
|
||||
/** Optional: linked/preset edits call this once with all four sides instead
|
||||
* of calling `onChange` four times, so a consumer can make them one undo step. */
|
||||
onChangeAll?: (values: Required<SpacingSideValues>) => void;
|
||||
/** Optional: a preset button pick, all four sides. Wire to a DISCRETE setter
|
||||
* so a keyboard-activated preset is always its own undo step; falls back
|
||||
* to the same path as the linked input when omitted. */
|
||||
onPreset?: (values: Required<SpacingSideValues>) => void;
|
||||
presets?: { label: string; value: string }[];
|
||||
units?: string[];
|
||||
}
|
||||
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, onChangeAll, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
|
||||
export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, onChange, onChangeAll, onPreset, presets = SPACING_PRESETS, units = ['px', '%', 'em', 'rem'] }) => {
|
||||
const [linked, setLinked] = useState(true);
|
||||
|
||||
const applyToAllSides = (v: string) => (onChangeAll
|
||||
@@ -950,7 +954,11 @@ export const SpacingControl: React.FC<SpacingControlProps> = ({ label, value, on
|
||||
<i className={`fa fa-link${linked ? '' : '-slash'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<PresetButtonGrid presets={presets} activeValue={isAllEqual ? linkedValue : undefined} onSelect={applyToAllSides} />
|
||||
<PresetButtonGrid
|
||||
presets={presets}
|
||||
activeValue={isAllEqual ? linkedValue : undefined}
|
||||
onSelect={onPreset ? (v) => onPreset({ top: v, right: v, bottom: v, left: v }) : applyToAllSides}
|
||||
/>
|
||||
{linked ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<NumericUnitInput value={linkedValue} onChange={applyToAllSides} units={units} testId="spacing-linked" />
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, test, expect, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
|
||||
import { TemplateModal } from './TemplateModal';
|
||||
|
||||
/** #44 integration follow-up: the "apply design" checkbox copy says what unchecked now means. */
|
||||
let h: EditorHarness | null = null;
|
||||
afterEach(() => { h?.unmount(); h = null; document.body.innerHTML = ''; });
|
||||
|
||||
describe('TemplateModal apply-design copy', () => {
|
||||
test('label and help text follow the checkbox state', () => {
|
||||
h = renderEditorHarness();
|
||||
h.mountChild(<TemplateModal open onClose={() => {}} />);
|
||||
const card = document.querySelector('[role="button"]') as HTMLElement;
|
||||
expect(card).not.toBeNull();
|
||||
h.act(() => { card.click(); });
|
||||
const help = () => document.querySelector('[data-testid="template-apply-design-help"]')?.textContent;
|
||||
expect(document.body.textContent).toContain("Use the template's colors and fonts");
|
||||
expect(help()).toBe("Replaces your site's colors and fonts with the template's.");
|
||||
const box = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
h.act(() => { box.click(); });
|
||||
expect(box.checked).toBe(false);
|
||||
expect(help()).toBe("Keeps your site's colors and fonts: the template's colors follow your site palette.");
|
||||
});
|
||||
});
|
||||
@@ -354,9 +354,17 @@ export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) =
|
||||
type="checkbox"
|
||||
checked={applyDesign}
|
||||
onChange={(e) => setApplyDesign(e.target.checked)}
|
||||
style={{ width: 16, height: 16, accentColor: '#3b82f6', cursor: 'pointer' }}
|
||||
style={{ width: 16, height: 16, accentColor: '#3b82f6', cursor: 'pointer', flexShrink: 0 }}
|
||||
/>
|
||||
Apply template colors and fonts to site design
|
||||
<span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span>Use the template's colors and fonts</span>
|
||||
{/* #44: templates reference site tokens, so unchecked = the site's own palette. */}
|
||||
<span data-testid="template-apply-design-help" style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
{applyDesign
|
||||
? "Replaces your site's colors and fonts with the template's."
|
||||
: "Keeps your site's colors and fonts: the template's colors follow your site palette."}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
|
||||
@@ -34,12 +34,12 @@ import { act } from 'react-dom/test-utils';
|
||||
|
||||
// #54: what the pre-publish checks see. Default (no pages) means no checks, so
|
||||
// every pre-existing test publishes directly.
|
||||
const checkFixture: { pages: any[]; liveState: string; activePageId: string } = { pages: [], liveState: '{}', activePageId: 'home' };
|
||||
const checkFixture: { pages: any[]; liveState: string; activePageId: string; footerState: string | null } = { pages: [], liveState: '{}', activePageId: 'home', footerState: null };
|
||||
vi.mock('../../state/PageContext', () => ({
|
||||
usePages: () => ({
|
||||
pages: checkFixture.pages,
|
||||
headerPage: { id: '__header__', name: 'Header', slug: '__header__', craftState: null },
|
||||
footerPage: { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: null },
|
||||
footerPage: { id: '__footer__', name: 'Footer', slug: '__footer__', craftState: checkFixture.footerState },
|
||||
activePageId: checkFixture.activePageId,
|
||||
switchPage: vi.fn(),
|
||||
}),
|
||||
@@ -71,6 +71,8 @@ vi.mock('../../hooks/useWhpApi', () => ({
|
||||
vi.mock('./TemplateModal', () => ({ TemplateModal: () => null }));
|
||||
vi.mock('./HeadCodeModal', () => ({ HeadCodeModal: () => null }));
|
||||
vi.mock('../sitesmith/SitesmithButton', () => ({ SitesmithButton: () => null }));
|
||||
const openPaletteMock = vi.fn();
|
||||
vi.mock('../command-palette/CommandPalette', () => ({ openCommandPalette: () => openPaletteMock() }));
|
||||
|
||||
import { TopBar } from './TopBar';
|
||||
import { EditorConfigProvider } from '../../state/EditorConfigContext';
|
||||
@@ -130,6 +132,8 @@ afterEach(() => {
|
||||
checkFixture.pages = [];
|
||||
checkFixture.liveState = '{}';
|
||||
checkFixture.activePageId = 'home';
|
||||
checkFixture.footerState = null;
|
||||
openPaletteMock.mockReset();
|
||||
});
|
||||
|
||||
describe('TopBar publish-warnings wiring', () => {
|
||||
@@ -315,3 +319,41 @@ describe('TopBar pre-publish checklist', () => {
|
||||
expect(container.querySelector('[data-testid="publish-warnings"]')!.textContent).toContain('Generated sitemap.xml and robots.txt.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TopBar search button (#49 integration)', () => {
|
||||
test('opens the command palette and carries the shortcut tooltip', () => {
|
||||
render();
|
||||
const btn = container.querySelector<HTMLButtonElement>('button[aria-label="Search"]');
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn!.getAttribute('data-tooltip')).toBe('Search (Ctrl/Cmd+K)');
|
||||
act(() => { btn!.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
expect(openPaletteMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TopBar Preview head parity (item 14)', () => {
|
||||
const rootState = (style: Record<string, string> = {}) => JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {} },
|
||||
});
|
||||
|
||||
test('scans the composed header+body+footer for preset fonts and trims metaTitle', async () => {
|
||||
checkFixture.liveState = rootState();
|
||||
checkFixture.footerState = rootState({ fontFamily: 'Merriweather, serif' });
|
||||
checkFixture.pages = [{ id: 'home', name: 'Home', slug: 'index', craftState: null, seo: { metaTitle: ' ' } }];
|
||||
let blob: Blob | null = null;
|
||||
const createUrl = vi.spyOn(URL, 'createObjectURL').mockImplementation((b) => { blob = b as Blob; return 'blob:x'; });
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
try {
|
||||
render();
|
||||
const btn = container.querySelector<HTMLButtonElement>('button[aria-label="Preview"]')!;
|
||||
await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })); });
|
||||
await vi.waitFor(() => expect(blob).not.toBeNull());
|
||||
const html = await blob!.text();
|
||||
expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
|
||||
expect(html).toContain('<title>Home</title>');
|
||||
} finally {
|
||||
createUrl.mockRestore();
|
||||
openSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||
import { useRegisterCommands } from '../../editor/commands';
|
||||
import { TopBarCommandMenu } from './TopBarCommandMenu';
|
||||
import { openCommandPalette } from '../command-palette/CommandPalette';
|
||||
|
||||
interface TopBarProps {
|
||||
/** Item 10: canvas dashed guide outlines, default ON. */
|
||||
@@ -306,7 +307,7 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
// html-export's buildSeoMeta/og:title fallback both key off it.
|
||||
const activePage = pages.find((p) => p.id === activePageId);
|
||||
const seo = activePage?.seo;
|
||||
const title = seo?.metaTitle || activePage?.name || whpConfig?.siteName || 'Preview';
|
||||
const title = seo?.metaTitle?.trim() || activePage?.name || whpConfig?.siteName || 'Preview';
|
||||
|
||||
const result = exportToHtml(serialized, {
|
||||
title,
|
||||
@@ -323,6 +324,8 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
? `https://${whpConfig.siteDomain}/${activePage.slug === 'index' ? '' : activePage.slug}`
|
||||
: undefined,
|
||||
design,
|
||||
// Fonts: scan header+body+footer like the published head does (head parity).
|
||||
fontScanHtml: composedBody,
|
||||
});
|
||||
|
||||
// Replace the body in the full document with our composed version.
|
||||
@@ -528,6 +531,14 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
|
||||
>
|
||||
<i className="fa fa-code" />
|
||||
</button>
|
||||
<button
|
||||
className="topbar-btn icon-only"
|
||||
aria-label="Search"
|
||||
data-tooltip="Search (Ctrl/Cmd+K)"
|
||||
onClick={() => openCommandPalette()}
|
||||
>
|
||||
<i className="fa fa-search" aria-hidden />
|
||||
</button>
|
||||
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
|
||||
<i className="fa fa-eye" />
|
||||
</button>
|
||||
|
||||
@@ -3324,3 +3324,14 @@ body {
|
||||
}
|
||||
.section-lib-chip-global i { margin-right: 3px; }
|
||||
.section-lib-usage { display: block; font-size: 11px; color: var(--color-text-muted); }
|
||||
|
||||
/* #followups -- integration follow-ups (#58 Regenerate button in the type header) */
|
||||
.guided-regenerate-btn {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 4px 8px; font-size: 11px; font-weight: 600;
|
||||
color: var(--color-text); background: transparent;
|
||||
border: 1px solid var(--color-border); border-radius: var(--radius-sm); cursor: pointer;
|
||||
}
|
||||
.guided-regenerate-btn:hover { background: var(--color-bg-hover); }
|
||||
.guided-regenerate-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 1px; }
|
||||
/* /#followups */
|
||||
@@ -101,3 +101,30 @@ describe('StockPhotoTab', () => {
|
||||
expect(q('stock-error')!.textContent).toBe('download failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('StockPhotoTab: stale responses (#55 review)', () => {
|
||||
test('an older search resolving after a newer cache-hit search does not replace or clear the grid', async () => {
|
||||
mount();
|
||||
// "fox" -> cached.
|
||||
await searchAndSelect();
|
||||
expect(q('stock-thumb-7')).not.toBeNull();
|
||||
// "cat" stays in flight; resolves (or rejects on abort) only when we say so.
|
||||
let release!: () => void;
|
||||
fetchMock.mockImplementationOnce((_u: string, init: any) => new Promise((resolve, reject) => {
|
||||
init.signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||
release = () => resolve({ ok: true, status: 200, json: () => Promise.resolve({ success: true, results: [{ ...PHOTO, id: 99, alt: 'cat' }], next_page: null }) } as Response);
|
||||
}));
|
||||
setValueIn('stock-search-input', 'cat');
|
||||
const form = container.querySelector('form')!;
|
||||
await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); });
|
||||
// Back to "fox" while "cat" is pending: a cache hit (form submit bypasses the disabled button).
|
||||
setValueIn('stock-search-input', 'fox');
|
||||
await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); });
|
||||
await flush();
|
||||
release?.();
|
||||
await flush();
|
||||
expect(q('stock-thumb-7')).not.toBeNull();
|
||||
expect(q('stock-thumb-99')).toBeNull();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { useStockPhotos, StockPhoto, StockCredit } from '../hooks/useStockPhotos';
|
||||
|
||||
export interface PickedAsset {
|
||||
@@ -25,20 +25,27 @@ export const StockPhotoTab: React.FC<Props> = ({ onPick }) => {
|
||||
const [selected, setSelected] = useState<StockPhoto | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [searched, setSearched] = useState(false);
|
||||
// Only the newest request may update the grid: an older (aborted or slow)
|
||||
// one resolving late must not clear or replace newer results.
|
||||
const seq = useRef(0);
|
||||
|
||||
const runSearch = useCallback(async () => {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
const mine = ++seq.current;
|
||||
setSubmitted(q);
|
||||
setSelected(null);
|
||||
const res = await search(q, 1);
|
||||
if (mine !== seq.current) return;
|
||||
if (res) { setPhotos(res.results); setNextPage(res.nextPage); setSearched(true); }
|
||||
else setPhotos([]);
|
||||
}, [query, search]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (nextPage == null) return;
|
||||
const mine = ++seq.current;
|
||||
const res = await search(submitted, nextPage);
|
||||
if (mine !== seq.current) return;
|
||||
if (res) { setPhotos((p) => [...p, ...res.results]); setNextPage(res.nextPage); }
|
||||
}, [nextPage, search, submitted]);
|
||||
|
||||
|
||||
@@ -113,4 +113,16 @@ describe('buildSnapshotPreviewHtml', () => {
|
||||
test('throws for a missing page index', () => {
|
||||
expect(() => buildSnapshotPreviewHtml(snap(), 9, 'https://x')).toThrow();
|
||||
});
|
||||
test('head parity: fonts are scanned from header+body+footer, and metaTitle is trimmed', () => {
|
||||
const footer = JSON.stringify({
|
||||
ROOT: { type: { resolvedName: 'Container' }, isCanvas: true, props: { style: { fontFamily: 'Merriweather, serif' } }, displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null },
|
||||
});
|
||||
const html = buildSnapshotPreviewHtml(
|
||||
{ pages_craft_state: [{ id: 'a', name: 'Page A', slug: 'index', craftState: state('Body'), seo: { metaTitle: ' ' } }], footer_craft_state: footer },
|
||||
0,
|
||||
'https://panel.example',
|
||||
);
|
||||
expect(html).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
|
||||
expect(html).toContain('<title>Page A</title>');
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,7 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number,
|
||||
const fullDesign = { ...DEFAULT_SITE_DESIGN, ...(design || {}) } as SiteDesign;
|
||||
const seo = page.seo;
|
||||
let html = exportToHtml(state, {
|
||||
title: seo?.metaTitle || page.name || 'Preview',
|
||||
title: seo?.metaTitle?.trim() || page.name || 'Preview',
|
||||
includeFonts: true,
|
||||
headCode: fullDesign.headCode,
|
||||
description: seo?.metaDescription,
|
||||
@@ -153,6 +153,8 @@ export function buildSnapshotPreviewHtml(snapshot: Snapshot, pageIndex: number,
|
||||
noindex: seo?.noindex,
|
||||
favicon: fullDesign.favicon,
|
||||
design: fullDesign,
|
||||
// Fonts: scan header+body+footer like the published head does (head parity).
|
||||
fontScanHtml: composedBody,
|
||||
}).html;
|
||||
|
||||
// Function replacer: user content may contain `$&` / `$1`.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Head parity, frontend half (backend-followups / integration-followups item 14).
|
||||
*
|
||||
* The PINNED_HEAD strings below are copied VERBATIM from WHP
|
||||
* scripts/test-site-builder-head-parity.php ($FE_HEAD_*), which asserts that the
|
||||
* backend generateCompiledHTML() emits exactly these bytes for the same inputs.
|
||||
* This test asserts that the frontend Preview head (exportToHtml/wrapInDocument)
|
||||
* emits the SAME bytes, so Preview head === Published head is pinned from both
|
||||
* sides. Never hand-edit these strings: if wrapInDocument changes, regenerate them
|
||||
* here AND in the WHP script together.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { exportToHtml, exportBodyHtml, buildFontsLink, type ExportOptions } from './html-export';
|
||||
import { DEFAULT_SITE_DESIGN, type SiteDesign } from '../state/SiteDesignContext';
|
||||
|
||||
const PINNED_HEAD: Record<string, string> = {
|
||||
A_defaults: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Joe's Pizza & Grill</title>\n <meta property=\"og:title\" content=\"Joe's Pizza & Grill\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
|
||||
B_full: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <meta name=\"robots\" content=\"noindex, nofollow\">\n <title>About "Us"</title>\n <meta name=\"description\" content=\"We're <the> best & "only" bakery\">\n <meta property=\"og:title\" content=\"About Joe's\">\n <meta property=\"og:description\" content=\"We're <the> best & "only" bakery\">\n <meta property=\"og:image\" content=\"https://cdn.example.com/og.png\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"icon\" href=\"https://cdn.example.com/favicon.ico\">\n <link rel=\"canonical\" href=\"https://example.com/about\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Roboto:wght@300;400;500;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}@keyframes fadeIn{from{opacity:0}to{opacity:1}}@keyframes slideUp{from{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}@keyframes slideLeft{from{opacity:0;transform:translateX(-30px)}to{opacity:1;transform:translateX(0)}}@keyframes slideRight{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}@keyframes zoomIn{from{opacity:0;transform:scale(.9)}to{opacity:1;transform:scale(1)}}@keyframes bounce{0%{opacity:0;transform:translateY(40px)}40%{opacity:1;transform:translateY(-12px)}60%{transform:translateY(6px)}80%{transform:translateY(-3px)}100%{opacity:1;transform:translateY(0)}}[data-animation]{opacity:0}[data-animation].animated{animation-duration:.6s;animation-fill-mode:both}[data-animation=\"fade-in\"].animated{animation-name:fadeIn}[data-animation=\"slide-up\"].animated{animation-name:slideUp}[data-animation=\"slide-left\"].animated{animation-name:slideLeft}[data-animation=\"slide-right\"].animated{animation-name:slideRight}[data-animation=\"zoom-in\"].animated{animation-name:zoomIn}[data-animation=\"bounce\"].animated{animation-name:bounce}:root{--wsb-primary:#ff0066;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:12px;--wsb-heading-font:Playfair Display, serif;--wsb-body-font:Roboto, sans-serif;--wsb-button-font:Roboto, sans-serif;--wsb-button-radius:999px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n <noscript><style>[data-animation]{opacity:1}</style></noscript>\n <script async src=\"https://www.googletagmanager.com/gtag/js?id=G-X\"></script>\n<meta name=\"google-site-verification\" content=\"abc123\">\n</head>",
|
||||
C_unknown_font: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Contact</title>\n <meta name=\"description\" content=\"Get in touch\">\n <meta property=\"og:title\" content=\"Contact\">\n <meta property=\"og:description\" content=\"Get in touch\">\n <meta property=\"og:type\" content=\"website\">\n <meta name=\"twitter:card\" content=\"summary\">\n <link rel=\"canonical\" href=\"https://example.com/contact\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#1f2937;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Comic Sans MS, cursive;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
|
||||
D_body_font_and_blank_tokens: "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Services</title>\n <meta property=\"og:title\" content=\"Services\">\n <meta property=\"og:type\" content=\"website\">\n <link rel=\"canonical\" href=\"https://example.com/services\">\n <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n <link href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap\" rel=\"stylesheet\">\n <style>*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}@media(max-width:767px){[style*=\"display: flex\"][style*=\"flex-direction: row\"],[style*=\"display:flex\"][style*=\"flex-direction:row\"]{flex-direction:column!important}}@media(min-width:992px){[data-hide-desktop]{display:none!important}}@media(min-width:768px) and (max-width:991px){[data-hide-tablet]{display:none!important}}@media(max-width:767px){[data-hide-mobile]{display:none!important}}:root{--wsb-primary:#3b82f6;--wsb-secondary:#8b5cf6;--wsb-accent:#10b981;--wsb-link:#3b82f6;--wsb-success:#10b981;--wsb-warning:#f59e0b;--wsb-error:#ef4444;--wsb-bg:#ffffff;--wsb-text:#111111;--wsb-muted:#6b7280;--wsb-border:#e5e7eb;--wsb-radius:8px;--wsb-heading-font:Inter, sans-serif;--wsb-body-font:Inter, sans-serif;--wsb-button-font:Inter, sans-serif;--wsb-button-radius:8px}body{font-family:var(--wsb-body-font);color:var(--wsb-text);background:var(--wsb-bg)}h1,h2,h3,h4,h5,h6{font-family:var(--wsb-heading-font)}a{color:var(--wsb-link)}a:focus-visible,button:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--wsb-accent);outline-offset:2px}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}</style>\n</head>",
|
||||
};
|
||||
|
||||
const PINNED_BODY: Record<string, string> = {
|
||||
A_defaults: "<div></div>",
|
||||
B_full: "<div data-animation=\"fade-in\" data-animation-delay=\"0.3\"></div>",
|
||||
C_unknown_font: "<div></div>",
|
||||
D_body_font_and_blank_tokens: "<div style=\"font-family:Montserrat, sans-serif\"></div>",
|
||||
};
|
||||
|
||||
|
||||
const root = (props: Record<string, unknown>) => JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {}, ...props },
|
||||
displayName: 'Container', custom: {}, hidden: false, nodes: [], linkedNodes: {},
|
||||
},
|
||||
});
|
||||
const U = 'https://example.com/';
|
||||
const merged = (partial: Partial<SiteDesign>): SiteDesign => ({ ...DEFAULT_SITE_DESIGN, ...partial });
|
||||
|
||||
// Same inputs as the WHP script's $cases. `title` is the RESOLVED title
|
||||
// (seo.metaTitle || page title), as the Preview callers pass it.
|
||||
const CASES: Record<string, { state: string; opts: ExportOptions }> = {
|
||||
A_defaults: {
|
||||
state: root({}),
|
||||
opts: { title: "Joe's Pizza & Grill", includeFonts: true, canonicalUrl: U },
|
||||
},
|
||||
B_full: {
|
||||
state: root({ animation: 'fade-in', animationDelay: '0.3' }),
|
||||
opts: {
|
||||
title: 'About "Us"',
|
||||
includeFonts: true,
|
||||
headCode: '<script async src="https://www.googletagmanager.com/gtag/js?id=G-X"></script>\n<meta name="google-site-verification" content="abc123">',
|
||||
description: "We're <the> best & \"only\" bakery",
|
||||
ogTitle: "About Joe's",
|
||||
ogImage: 'https://cdn.example.com/og.png',
|
||||
twitterCard: 'summary',
|
||||
noindex: true,
|
||||
favicon: 'https://cdn.example.com/favicon.ico',
|
||||
canonicalUrl: U + 'about',
|
||||
design: merged({
|
||||
headingFont: 'Playfair Display, serif', bodyFont: 'Roboto, sans-serif', buttonFont: 'Roboto, sans-serif',
|
||||
primaryColor: '#ff0066', borderRadius: '12px', buttonRadius: '999px',
|
||||
}),
|
||||
},
|
||||
},
|
||||
C_unknown_font: {
|
||||
state: root({}),
|
||||
opts: {
|
||||
title: 'Contact', includeFonts: true, description: 'Get in touch', canonicalUrl: U + 'contact',
|
||||
design: merged({ headingFont: 'Comic Sans MS, cursive' }),
|
||||
},
|
||||
},
|
||||
D_body_font_and_blank_tokens: {
|
||||
state: root({ style: { fontFamily: 'Montserrat, sans-serif' } }),
|
||||
opts: {
|
||||
title: 'Services', includeFonts: true, description: ' ', ogTitle: ' ', canonicalUrl: U + 'services',
|
||||
design: merged({ accentColor: '', textColor: ' #111111 ' }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const headOf = (doc: string) => doc.slice(0, doc.indexOf('</head>') + '</head>'.length);
|
||||
|
||||
describe('Preview head === pinned published head (WHP test-site-builder-head-parity.php)', () => {
|
||||
for (const [name, { state, opts }] of Object.entries(CASES)) {
|
||||
test(`${name}: body input matches the backend case`, () => {
|
||||
expect(exportBodyHtml(state).html).toBe(PINNED_BODY[name]);
|
||||
});
|
||||
test(`${name}: COMPLETE head is byte-identical`, () => {
|
||||
expect(headOf(exportToHtml(state, opts).html)).toBe(PINNED_HEAD[name]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('buildFontsLink per-element presets (item 14/15)', () => {
|
||||
const inter = merged({});
|
||||
test('a Montserrat element under an all-Inter design adds Montserrat to the subset', () => {
|
||||
const link = buildFontsLink(inter, '<p style="font-family:Montserrat, sans-serif">x</p>');
|
||||
expect(link).toContain('css2?family=Inter:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&display=swap');
|
||||
});
|
||||
test('fontScanHtml (composed header+body+footer) is what gets scanned, not just the body', () => {
|
||||
const state = root({});
|
||||
const footer = '<footer style="font-family:Merriweather, serif"></footer>';
|
||||
const bodyOnly = exportToHtml(state, { title: 'T', design: inter }).html;
|
||||
expect(bodyOnly).not.toContain('family=Merriweather');
|
||||
const composed = exportToHtml(state, { title: 'T', design: inter, fontScanHtml: '<div></div>' + footer }).html;
|
||||
expect(composed).toContain('family=Inter:wght@300;400;500;600;700&family=Merriweather:wght@300;400;700&display=swap');
|
||||
});
|
||||
test('an inherited Object.prototype name (constructor) is NOT a preset -> full link, like PHP isset', () => {
|
||||
const link = buildFontsLink(merged({ headingFont: 'constructor, serif' }));
|
||||
expect(link).toContain('family=Source+Code+Pro'); // the full 8-font link
|
||||
expect(link).not.toContain('undefined');
|
||||
});
|
||||
});
|
||||
@@ -579,11 +579,22 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
|
||||
expect(html.split(from).length).toBe(2);
|
||||
return html.replace(from, '@media(max-width:768px){[style*=');
|
||||
};
|
||||
// Head parity (backend-followups) added html,body{overflow-x:clip} to the reset.
|
||||
const undoClip = (html: string) => {
|
||||
const clip = 'html,body{overflow-x:clip}';
|
||||
expect(html.split(clip).length).toBe(2);
|
||||
return html.replace(clip, '');
|
||||
};
|
||||
|
||||
test('default design document hash matches the pre-move baseline', async () => {
|
||||
const html = undo767(exportToHtml(emptyRoot, { title: 'T' }).html);
|
||||
expect(html.length).toBe(2164);
|
||||
expect(await sha(html)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
|
||||
// Re-baselined for the head-parity reset rule html,body{overflow-x:clip}.
|
||||
expect(html.length).toBe(2190);
|
||||
expect(await sha(html)).toBe('d180b901f8e06ea7d23e195f24cc7f37658ba3cc31d16ecb941a88bbe3ad917b');
|
||||
// ...and that rule is the ONLY change: removing it gives the pre-move bytes.
|
||||
const pre = undoClip(html);
|
||||
expect(pre.length).toBe(2164);
|
||||
expect(await sha(pre)).toBe('f50f2940798bda064c11e65d111004355034acd240e03cfd7e6fd5cfe98bf3b6');
|
||||
});
|
||||
|
||||
test('custom + hostile design document hash matches the pre-move baseline', async () => {
|
||||
@@ -594,8 +605,11 @@ describe('export head unchanged by the TOKEN_VARS move (W0-tokens-core)', () =>
|
||||
accentColor: 'red}</style><script>',
|
||||
};
|
||||
const html = undo767(exportToHtml(emptyRoot, { title: 'T', design }).html);
|
||||
expect(html.length).toBe(2452);
|
||||
expect(await sha(html)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
|
||||
expect(html.length).toBe(2478);
|
||||
expect(await sha(html)).toBe('cb23ffb02d0e85085f31dce530a9c2bfc2328c63d9a8b9f67737d1b5624dae5d');
|
||||
const pre = undoClip(html);
|
||||
expect(pre.length).toBe(2452);
|
||||
expect(await sha(pre)).toBe('c561538b7ca64c8bac8b2cd02ff65053a866f7891ceeed97c956ea79153d1338');
|
||||
});
|
||||
|
||||
test('default :root token block is the exact pre-move string', () => {
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface ExportOptions {
|
||||
/** Absolute page URL for `<link rel="canonical">` (#54): `https://<fqdn>/` for the
|
||||
* landing page, `https://<fqdn>/<slug>` otherwise (no `.html`). Omitted => no tag. */
|
||||
canonicalUrl?: string;
|
||||
/** HTML scanned for per-element preset fonts (TextStylePanel fontFamily); defaults to
|
||||
* the body. Preview passes the composed header+body+footer so it matches the
|
||||
* published head, which scans all three (head parity). */
|
||||
fontScanHtml?: string;
|
||||
/** Full site design tokens -- drives the :root{} CSS-variable block (contract §2).
|
||||
* Falls back to DEFAULT_SITE_DESIGN when omitted so every export (including legacy
|
||||
* callers that don't pass it) still emits the token/base/a11y CSS -- see §7. */
|
||||
@@ -190,7 +194,7 @@ function renderNode(
|
||||
return { html: allChildrenHtml };
|
||||
}
|
||||
|
||||
const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`;
|
||||
const CSS_RESET = `*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}html,body{overflow-x:clip}body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;line-height:1.6;color:#1f2937;-webkit-font-smoothing:antialiased}img{max-width:100%;height:auto;display:block}a{color:inherit}`;
|
||||
|
||||
const CSS_RESET_PRETTY = `*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
@@ -198,6 +202,10 @@ const CSS_RESET_PRETTY = `*, *::before, *::after {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.6;
|
||||
@@ -250,14 +258,22 @@ function extractFontName(value: string | undefined): string {
|
||||
* display=swap -- whenever any of those three fonts isn't one of the 8 known
|
||||
* presets, so a font a component actually uses is never silently dropped.
|
||||
*/
|
||||
export function buildFontsLink(design?: SiteDesign): string {
|
||||
export function buildFontsLink(design?: SiteDesign, scanHtml = ''): string {
|
||||
if (!design) return GOOGLE_FONTS_LINK;
|
||||
const families = [design.headingFont, design.bodyFont, design.buttonFont]
|
||||
.map(extractFontName)
|
||||
.filter((f, i, arr) => f !== '' && arr.indexOf(f) === i);
|
||||
if (families.length === 0) return GOOGLE_FONTS_LINK;
|
||||
const params = families.map((f) => GOOGLE_FONT_PARAMS[f]).filter((p): p is string => !!p);
|
||||
if (params.length !== families.length) return GOOGLE_FONTS_LINK;
|
||||
// Own-key check (not truthiness) so a name like `constructor` is "not a preset",
|
||||
// exactly like the backend's isset(SB_GOOGLE_FONT_PARAMS[$f]). (Object.hasOwn
|
||||
// semantics; spelled via hasOwnProperty.call because tsconfig lib is ES2020.)
|
||||
if (families.some((f) => !Object.prototype.hasOwnProperty.call(GOOGLE_FONT_PARAMS, f))) return GOOGLE_FONTS_LINK;
|
||||
// Per-element presets (TextStylePanel fontFamily) the page actually uses, in
|
||||
// preset order -- mirrors sb_fonts_link() in WHP's SiteBuilderHead.php.
|
||||
for (const f of Object.keys(GOOGLE_FONT_PARAMS)) {
|
||||
if (!families.includes(f) && scanHtml.includes(f)) families.push(f);
|
||||
}
|
||||
const params = families.map((f) => GOOGLE_FONT_PARAMS[f]);
|
||||
return `<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?${params.join('&')}&display=swap" rel="stylesheet">`;
|
||||
@@ -286,7 +302,12 @@ const A11Y_PERF_CSS = `a:focus-visible,button:focus-visible,input:focus-visible,
|
||||
* element itself.
|
||||
*/
|
||||
export function buildTokenCss(design: SiteDesign): string {
|
||||
const vars = TOKEN_VARS.map(([varName, key]) => `${varName}:${cssValue(design[key] as string)}`).join(';');
|
||||
// A blank/whitespace token falls back to its default: an empty custom property
|
||||
// would invalidate every var() that reads it (mirrors the backend sb_token_css).
|
||||
const vars = TOKEN_VARS.map(([varName, key]) => {
|
||||
const raw = String(design[key] ?? '').trim();
|
||||
return `${varName}:${cssValue(raw !== '' ? raw : (DEFAULT_SITE_DESIGN[key] as string))}`;
|
||||
}).join(';');
|
||||
return `:root{${vars}}${TOKEN_BASE_CSS}${A11Y_PERF_CSS}`;
|
||||
}
|
||||
|
||||
@@ -312,12 +333,12 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
|
||||
|
||||
lines.push(`<title>${escapeHtml(title)}</title>`);
|
||||
|
||||
const description = options.description || '';
|
||||
const description = (options.description || '').trim();
|
||||
if (description) {
|
||||
lines.push(`<meta name="description" content="${escapeAttr(description)}">`);
|
||||
}
|
||||
|
||||
const ogTitle = options.ogTitle || title;
|
||||
const ogTitle = (options.ogTitle || '').trim() || title;
|
||||
lines.push(`<meta property="og:title" content="${escapeAttr(ogTitle)}">`);
|
||||
|
||||
if (description) {
|
||||
@@ -332,7 +353,7 @@ export function buildSeoMeta(options: ExportOptions, title: string): string {
|
||||
lines.push('<meta property="og:type" content="website">');
|
||||
|
||||
if (ogImage || description) {
|
||||
const card = options.twitterCard || (ogImage ? 'summary_large_image' : 'summary');
|
||||
const card = (options.twitterCard || '').trim() || (ogImage ? 'summary_large_image' : 'summary');
|
||||
lines.push(`<meta name="twitter:card" content="${escapeAttr(card)}">`);
|
||||
}
|
||||
|
||||
@@ -428,7 +449,7 @@ function wrapInDocument(bodyHtml: string, options: ExportOptions): string {
|
||||
// every project already has design defaults, so DEFAULT_SITE_DESIGN is the
|
||||
// correct stand-in rather than skipping the block entirely.
|
||||
const design = options.design || DEFAULT_SITE_DESIGN;
|
||||
const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design)}` : '';
|
||||
const fonts = options.includeFonts !== false ? `\n ${buildFontsLink(design, options.fontScanHtml ?? bodyHtml)}` : '';
|
||||
const headCode = options.headCode ? `\n ${options.headCode}` : '';
|
||||
const seoMeta = buildSeoMeta(options, title);
|
||||
const tokenCss = buildTokenCss(design);
|
||||
|
||||
Reference in new issue
Block a user