Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69e61ab4b2 | ||
|
|
3dd6b54a35 | ||
|
|
d7eeff3a68 | ||
|
|
f43a1ef872 | ||
|
|
fd7f883d6a | ||
|
|
4cfcccd272 | ||
|
|
024f9fdd46 | ||
|
|
fc8918c1f9 | ||
|
|
9be71e8fd1 | ||
|
|
aba0d187d7 | ||
|
|
1a01834068 | ||
|
|
bfcf6278e9 | ||
|
|
e5fd74d63d | ||
|
|
cf38fdb245 | ||
|
|
2438777462 | ||
|
|
f9561c8c54 | ||
|
|
f30fc6efec | ||
|
|
eb4290a45e | ||
|
|
38c8d22e5d | ||
|
|
9885b37af5 | ||
|
|
86aacbe1a8 | ||
|
|
f0a1508acd | ||
|
|
66db1db507 | ||
|
|
b670b436c3 | ||
|
|
d89930e218 | ||
|
|
45ee004672 | ||
|
|
536e4e9f86 | ||
|
|
51f3fe81b6 | ||
|
|
321a193b83 | ||
|
|
4ac57e1c4c | ||
|
|
0d0d722dd7 | ||
|
|
a30e82accf | ||
|
|
6791345f77 | ||
|
|
fb68fa6485 |
@@ -0,0 +1,51 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useNode: (collect?: (node: any) => any) => {
|
||||
const node = { events: { selected: false } };
|
||||
return {
|
||||
connectors: { connect: (el: any) => el, drag: (el: any) => el },
|
||||
actions: { setProp: vi.fn() },
|
||||
...(collect ? collect(node) : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
import { HtmlBlock } from './HtmlBlock';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
describe('HtmlBlock render ignores the style prop (matches toHtml)', () => {
|
||||
test('a stored backgroundColor/color/padding is NOT applied to the wrapper', () => {
|
||||
render(
|
||||
React.createElement(HtmlBlock as any, {
|
||||
code: '<p>hello</p>',
|
||||
style: { backgroundColor: 'rgb(255, 0, 0)', color: 'rgb(0, 0, 255)', padding: '40px' },
|
||||
}),
|
||||
);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
expect(wrapper.style.backgroundColor).toBe('');
|
||||
expect(wrapper.style.color).toBe('');
|
||||
expect(wrapper.style.padding).toBe('');
|
||||
});
|
||||
|
||||
test('the editor affordances survive: minHeight is kept, content still renders', () => {
|
||||
render(React.createElement(HtmlBlock as any, { code: '<p>hello</p>', style: {} }));
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
expect(wrapper.style.minHeight).toBe('40px');
|
||||
expect(wrapper.innerHTML).toContain('hello');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,41 @@ describe('purifyHtml', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml markup path (C1 review finding)', () => {
|
||||
test('a style attribute survives sanitization (colour picker output must not be silently dropped)', () => {
|
||||
const out = purifyHtml('<p style="color: #ff0000">red text</p>');
|
||||
expect(out).toBe('<p style="color: #ff0000">red text</p>');
|
||||
});
|
||||
|
||||
test('an id attribute survives sanitization (anchor targets)', () => {
|
||||
const out = purifyHtml('<a href="#section" id="section">link</a>');
|
||||
expect(out).toContain('id="section"');
|
||||
});
|
||||
|
||||
test('a pasted table survives sanitization', () => {
|
||||
const input = '<table><thead><tr><th>Head</th></tr></thead><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||
expect(purifyHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
test('script tags still do not survive alongside a style attribute', () => {
|
||||
const out = purifyHtml('<p style="color:#ff0000">ok</p><script>alert(1)</script>');
|
||||
expect(out).not.toContain('<script');
|
||||
expect(out).toContain('style="color:#ff0000"');
|
||||
});
|
||||
|
||||
test('on* handlers still do not survive on an element that also carries style', () => {
|
||||
const out = purifyHtml('<p style="color:#ff0000" onclick="bad()">x</p>');
|
||||
expect(out).not.toContain('onclick');
|
||||
expect(out).toContain('style="color:#ff0000"');
|
||||
});
|
||||
|
||||
test('javascript: URLs still do not survive on an element that also carries style', () => {
|
||||
const out = purifyHtml('<a style="color:#ff0000" href="javascript:void(0)">x</a>');
|
||||
expect(out).not.toContain('javascript:');
|
||||
expect(out).toContain('style="color:#ff0000"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('purifyHtml iframe sandboxing (M-6)', () => {
|
||||
test('forces a restrictive sandbox attribute onto every iframe', () => {
|
||||
const out = purifyHtml('<iframe src="https://example.com/"></iframe>');
|
||||
|
||||
@@ -23,3 +23,26 @@ describe('HtmlBlock.toHtml sanitizes raw code (A4.1)', () => {
|
||||
expect(html).toBe('<p>hi</p>');
|
||||
});
|
||||
});
|
||||
|
||||
test('toHtml never emits the style prop (the other half of the render/export contract)', () => {
|
||||
const out = (HtmlBlock as any).toHtml(
|
||||
{ code: '<p>hi</p>', style: { backgroundColor: '#ff0000', padding: '40px' } },
|
||||
'',
|
||||
);
|
||||
expect(out.html).toBe('<p>hi</p>');
|
||||
expect(out.html).not.toContain('background');
|
||||
expect(out.html).not.toContain('40px');
|
||||
});
|
||||
|
||||
describe('HtmlBlock.toHtml markup path (C1 review finding)', () => {
|
||||
test('a style attribute inside `code` (e.g. from the toolbar colour picker) reaches exported output', () => {
|
||||
const { html } = toHtml({ code: '<p style="color: #ff0000">red text</p>' }, '');
|
||||
expect(html).toBe('<p style="color: #ff0000">red text</p>');
|
||||
});
|
||||
|
||||
test('a table inside `code` reaches exported output', () => {
|
||||
const code = '<table><tbody><tr><td>Cell</td></tr></tbody></table>';
|
||||
const { html } = toHtml({ code }, '');
|
||||
expect(html).toBe(code);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,17 @@ const PURIFY_CONFIG = {
|
||||
'blockquote','code','pre',
|
||||
'img','figure','figcaption',
|
||||
'iframe',
|
||||
// Tables: pasted content commonly includes these; dropping them
|
||||
// silently ate customer-pasted tables (see C1 review finding).
|
||||
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
|
||||
],
|
||||
// NOTE: supplying ALLOWED_ATTR replaces DOMPurify's own default attribute
|
||||
// allowlist rather than extending it, so anything the product needs
|
||||
// (style, id, ...) must be listed explicitly here even though DOMPurify
|
||||
// would allow it by default.
|
||||
ALLOWED_ATTR: [
|
||||
'href','src','alt','title','target','rel',
|
||||
'width','height','class',
|
||||
'width','height','class','id','style',
|
||||
'allowfullscreen','allow','frameborder',
|
||||
'sandbox','referrerpolicy',
|
||||
],
|
||||
@@ -62,16 +69,21 @@ export function purifyHtml(input: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
|
||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '' }) => {
|
||||
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
||||
const clean = useMemo(() => purifyHtml(code), [code]);
|
||||
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
||||
// The `style` prop is deliberately NOT applied. `toHtml()` emits only the
|
||||
// purified `code`, so anything styled here would show in the editor and
|
||||
// vanish on the live page -- the exact bug this block was reported for.
|
||||
// Wrapper styling belongs in the user's own markup (see the Edit HTML
|
||||
// toolbar's colour control). `style` stays on the props interface so
|
||||
// already-saved sites keep deserializing cleanly.
|
||||
return React.createElement('div', {
|
||||
ref: setRef,
|
||||
style: {
|
||||
minHeight: '40px',
|
||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||
...style,
|
||||
},
|
||||
dangerouslySetInnerHTML: { __html: clean },
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useContextMenu } from '../hooks/useContextMenu';
|
||||
import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
|
||||
import { DeviceMode } from '../types';
|
||||
|
||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||
@@ -83,6 +84,7 @@ export const EditorShell: React.FC = () => {
|
||||
// doesn't render MobilePanelBar and TopBar's desktop branch behaves
|
||||
// identically to before (same booleans, just sourced from context).
|
||||
<MobileChromeProvider>
|
||||
<LayerFocusProvider>
|
||||
<div className="editor-app">
|
||||
<TopBar
|
||||
device={device}
|
||||
@@ -107,6 +109,7 @@ export const EditorShell: React.FC = () => {
|
||||
onClose={hideMenu}
|
||||
/>
|
||||
</div>
|
||||
</LayerFocusProvider>
|
||||
</MobileChromeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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';
|
||||
import { EditorConfigProvider } from '../state/EditorConfigContext';
|
||||
import { EMPTY_CANVAS, PageProvider, usePages } from '../state/PageContext';
|
||||
import { SiteDesignProvider } from '../state/SiteDesignContext';
|
||||
import { useWhpApi } from './useWhpApi';
|
||||
import { WhpConfig } from '../types';
|
||||
|
||||
/**
|
||||
* Task 13b: `PageContext.loadState` runs every stored craft state through
|
||||
* `repairOrphanNodes` before handing it to `actions.deserialize` -- that
|
||||
* covers every page SWITCH (see `PageContext.orphan-repair-wiring.test.tsx`,
|
||||
* the model for this file). But the INITIAL load -- `useWhpApi`'s `load()`,
|
||||
* fired once on mount by `TopBar.tsx` -- used to call
|
||||
* `actions.deserialize(state)` directly on the first page's stored
|
||||
* `craftState`, bypassing repair entirely. A site whose saved state
|
||||
* contains a node unreachable from ROOT would get it silently repaired on
|
||||
* the NEXT page switch but not on the load that actually renders it first.
|
||||
*
|
||||
* This mocks `@craftjs/core` the same way `useWhpApi.load.test.tsx` and
|
||||
* `PageContext.orphan-repair-wiring.test.tsx` do, and asserts on the exact
|
||||
* string handed to the mocked `actions.deserialize` -- the orphan must be
|
||||
* reattached to ROOT and a `console.error` must fire, exactly like the
|
||||
* page-switch path (I5 review: this was `console.warn`, which
|
||||
* console-buffer.ts -- feeding the in-builder issue reporter -- does not
|
||||
* capture).
|
||||
*/
|
||||
const deserializeMock = vi.fn();
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({
|
||||
query: { serialize: () => '{}' },
|
||||
actions: { deserialize: deserializeMock },
|
||||
}),
|
||||
}));
|
||||
|
||||
const whpConfig: WhpConfig = {
|
||||
user: 'testuser',
|
||||
apiUrl: '/panel/api/site-builder',
|
||||
csrfToken: 'tok',
|
||||
siteId: 42,
|
||||
siteDomain: 'example.com',
|
||||
siteName: 'Test Site',
|
||||
backUrl: '/panel/sites',
|
||||
isRoot: false,
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
interface Captured {
|
||||
load: ReturnType<typeof useWhpApi>['load'];
|
||||
pages: ReturnType<typeof usePages>['pages'];
|
||||
}
|
||||
|
||||
function render(): { get: () => Captured } {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
let captured: Captured | null = null;
|
||||
|
||||
const Consumer: React.FC = () => {
|
||||
const { load } = useWhpApi();
|
||||
const { pages } = usePages();
|
||||
captured = { load, pages };
|
||||
return null;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<EditorConfigProvider config={whpConfig}>
|
||||
<SiteDesignProvider>
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>
|
||||
</SiteDesignProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
return { get: () => captured! };
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
/** Same shape as `PageContext.orphan-repair-wiring.test.tsx`'s ORPHAN_STATE:
|
||||
* a ROOT with no children plus an orphan ('stray') whose `parent` points at
|
||||
* an id that doesn't exist in the tree, and which no node's `nodes`/
|
||||
* `linkedNodes` lists -- unreachable by BFS from ROOT. */
|
||||
const ORPHAN_STATE = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { style: {}, tag: 'div' },
|
||||
displayName: 'Container',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
|
||||
},
|
||||
stray: {
|
||||
type: { resolvedName: 'HtmlBlock' },
|
||||
isCanvas: false,
|
||||
props: { code: '<p>stranded</p>', style: {} },
|
||||
displayName: 'HTML',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||
},
|
||||
});
|
||||
|
||||
describe('useWhpApi load() repairs an orphaned node on the FIRST page before deserializing', () => {
|
||||
beforeEach(() => {
|
||||
deserializeMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('initial load with an orphaned first-page craftState reattaches it and warns', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
design: null,
|
||||
header_craft_state: null,
|
||||
footer_craft_state: null,
|
||||
pages_craft_state: [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: ORPHAN_STATE },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const harness = render();
|
||||
|
||||
await act(async () => {
|
||||
await harness.get().load();
|
||||
});
|
||||
|
||||
expect(deserializeMock).toHaveBeenCalled();
|
||||
const passedState = deserializeMock.mock.calls[deserializeMock.mock.calls.length - 1][0];
|
||||
const parsed = JSON.parse(passedState);
|
||||
// The orphan is now an ordinary, reachable child of ROOT.
|
||||
expect(parsed.ROOT.nodes).toContain('stray');
|
||||
expect(parsed.stray.parent).toBe('ROOT');
|
||||
|
||||
// The observable signal that repair actually ran, not just that the
|
||||
// orphan happened to be absent for some unrelated reason. (React's own
|
||||
// act()-environment warnings also go through console.error in this
|
||||
// harness, so search all calls rather than assuming index 0.)
|
||||
expect(errorSpy.mock.calls.some((call) => String(call[0]).includes('reattached'))).toBe(true);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* I5 (review): `PageContext.loadState`'s deserialize failure path logs via
|
||||
* `console.error` AND falls back to `EMPTY_CANVAS` so the user always ends
|
||||
* up with a working (if blank) editor. `useWhpApi.load()`'s equivalent path
|
||||
* used to be `console.warn` with NO fallback deserialize -- the initial
|
||||
* load, which decides whether the user sees a working editor at all, both
|
||||
* failed harder (silently leaving the Frame undeserialized) and reported
|
||||
* quieter than every subsequent page switch. This pins the aligned
|
||||
* behaviour.
|
||||
*/
|
||||
describe('useWhpApi load() falls back to EMPTY_CANVAS when the first page state cannot be deserialized', () => {
|
||||
beforeEach(() => {
|
||||
deserializeMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test('a deserialize failure on the first page logs console.error and retries with EMPTY_CANVAS', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
json: async () => ({
|
||||
success: true,
|
||||
project: {
|
||||
design: null,
|
||||
header_craft_state: null,
|
||||
footer_craft_state: null,
|
||||
pages_craft_state: [
|
||||
{ id: 'home', name: 'Home', slug: 'index', craftState: '{"ROOT":{"broken":true}}' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
deserializeMock.mockImplementationOnce(() => {
|
||||
throw new Error('malformed state');
|
||||
});
|
||||
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const harness = render();
|
||||
|
||||
await act(async () => {
|
||||
await harness.get().load();
|
||||
});
|
||||
|
||||
// First call (the broken state) threw; the second call is the fallback.
|
||||
expect(deserializeMock).toHaveBeenCalledTimes(2);
|
||||
expect(deserializeMock.mock.calls[1][0]).toBe(EMPTY_CANVAS);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('Failed to load page state:', expect.any(Error));
|
||||
|
||||
errorSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||
import { usePages } from '../state/PageContext';
|
||||
import { usePages, EMPTY_CANVAS } from '../state/PageContext';
|
||||
import { useSiteDesign, SiteDesign } from '../state/SiteDesignContext';
|
||||
import { exportBodyHtml } from '../utils/html-export';
|
||||
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||
import { PageData } from '../types';
|
||||
|
||||
export interface BuildSavePayloadInput {
|
||||
@@ -305,15 +306,42 @@ export function useWhpApi() {
|
||||
id: p.id, name: p.name, slug: p.slug, craftState: p.craftState || null, seo: p.seo,
|
||||
})));
|
||||
|
||||
// Load the first page (home) into the canvas
|
||||
// Load the first page (home) into the canvas. Routed through
|
||||
// `repairOrphanNodes` first -- same as `PageContext.loadState` does
|
||||
// for every subsequent page switch -- so a node that's unreachable
|
||||
// from ROOT (invisible to Layers/selection) gets reattached here
|
||||
// too, on the load that actually puts it on screen, rather than only
|
||||
// on the next page switch. `repairOrphanNodes` never throws and
|
||||
// returns the original string reference when nothing needed fixing,
|
||||
// so this is cheap to run unconditionally.
|
||||
const firstPage = proj.pages_craft_state[0];
|
||||
if (firstPage.craftState) {
|
||||
try {
|
||||
const state = typeof firstPage.craftState === 'string'
|
||||
const rawState = typeof firstPage.craftState === 'string'
|
||||
? firstPage.craftState : JSON.stringify(firstPage.craftState);
|
||||
const { state, repaired } = repairOrphanNodes(rawState);
|
||||
if (repaired.length > 0) {
|
||||
// I5: console-buffer.ts only patches console.error, and this
|
||||
// reattach signal is the single most diagnostic clue for the
|
||||
// still-unreproduced "elements drop off the canvas" report --
|
||||
// it must reach the in-builder issue reporter's console buffer.
|
||||
console.error(
|
||||
`[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`,
|
||||
repaired.join(', '),
|
||||
);
|
||||
}
|
||||
actions.deserialize(state);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load page state:', e);
|
||||
// I5: this is the initial load that decides whether the user
|
||||
// sees a working editor at all -- align with `loadState`'s
|
||||
// behaviour (console.error + a known-safe fallback) instead of
|
||||
// warning quietly and leaving the Frame on whatever it last had.
|
||||
console.error('Failed to load page state:', e);
|
||||
try {
|
||||
actions.deserialize(EMPTY_CANVAS);
|
||||
} catch (_e2) {
|
||||
// give up
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,24 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { WhpConfig } from './types';
|
||||
import { installConsoleErrorBuffer } from './utils/console-buffer';
|
||||
import { editorBuild } from './utils/build-stamp';
|
||||
import './styles/editor.css';
|
||||
|
||||
// Installed before React mounts so errors thrown during the first render are
|
||||
// captured too.
|
||||
installConsoleErrorBuffer();
|
||||
|
||||
// Exposed on window (not logged -- no need to print this on every load for
|
||||
// every customer) so support can ask someone to type __WHP_EDITOR_BUILD__
|
||||
// in the console on request. This call is also load-bearing for bundling:
|
||||
// it is currently the only reference to `editorBuild()`, which keeps the
|
||||
// __EDITOR_BUILD__-reading module from being tree-shaken out of the bundle
|
||||
// before Task 19 wires it into the report payload. Do not remove this line
|
||||
// as a "stray global" cleanup -- doing so silently reverts every future
|
||||
// bug report to showing 'dev' instead of a real build stamp.
|
||||
(window as any).__WHP_EDITOR_BUILD__ = editorBuild();
|
||||
|
||||
// Read WHP_CONFIG injected by PHP wrapper (or null for standalone dev)
|
||||
const whpConfig: WhpConfig | null = (window as any).WHP_CONFIG || null;
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { LayerFocusProvider, useLayerFocus } from './LayerFocusContext';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const Probe: React.FC = () => {
|
||||
const { focus, requestFocus } = useLayerFocus();
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => requestFocus('n1', 'features', 2)}>request</button>
|
||||
<span data-testid="state">{focus ? `${focus.nodeId}:${focus.prop}:${focus.index}:${focus.nonce}` : 'none'}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function render() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(<LayerFocusProvider><Probe /></LayerFocusProvider>);
|
||||
});
|
||||
}
|
||||
|
||||
describe('LayerFocusContext', () => {
|
||||
test('starts with no focus request', () => {
|
||||
render();
|
||||
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('none');
|
||||
});
|
||||
|
||||
test('requestFocus publishes the target', () => {
|
||||
render();
|
||||
act(() => { (container.querySelector('button') as HTMLButtonElement).click(); });
|
||||
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:1');
|
||||
});
|
||||
|
||||
test('repeating the same request bumps the nonce so consumers re-fire', () => {
|
||||
render();
|
||||
const btn = container.querySelector('button') as HTMLButtonElement;
|
||||
act(() => { btn.click(); });
|
||||
act(() => { btn.click(); });
|
||||
expect(container.querySelector('[data-testid="state"]')!.textContent).toBe('n1:features:2:2');
|
||||
});
|
||||
|
||||
test('useLayerFocus outside a provider is inert rather than a crash', () => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
expect(() => {
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(<Probe />);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Carries "the user clicked a virtual Layers row -- scroll that array item's
|
||||
* card into view" from the Layers panel (left) to the array editors (right).
|
||||
*
|
||||
* Deliberately a request, not a command: consumers that don't implement
|
||||
* scrolling simply ignore it. Selecting the parent node always happens in
|
||||
* LayersPanel itself, so a click is useful even with no consumer at all.
|
||||
*/
|
||||
|
||||
export interface LayerFocusRequest {
|
||||
nodeId: string;
|
||||
prop: string;
|
||||
index: number;
|
||||
/** Bumped on every request so an identical repeat still re-fires effects. */
|
||||
nonce: number;
|
||||
}
|
||||
|
||||
export interface LayerFocusValue {
|
||||
focus: LayerFocusRequest | null;
|
||||
requestFocus(nodeId: string, prop: string, index: number): void;
|
||||
}
|
||||
|
||||
const LayerFocusContext = createContext<LayerFocusValue>({
|
||||
focus: null,
|
||||
requestFocus: () => {},
|
||||
});
|
||||
|
||||
export const useLayerFocus = (): LayerFocusValue => useContext(LayerFocusContext);
|
||||
|
||||
export const LayerFocusProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [focus, setFocus] = useState<LayerFocusRequest | null>(null);
|
||||
const nonceRef = useRef(0);
|
||||
|
||||
const requestFocus = useCallback((nodeId: string, prop: string, index: number) => {
|
||||
nonceRef.current += 1;
|
||||
setFocus({ nodeId, prop, index, nonce: nonceRef.current });
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ focus, requestFocus }), [focus, requestFocus]);
|
||||
|
||||
return <LayerFocusContext.Provider value={value}>{children}</LayerFocusContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness } from '../../test-utils/editorHarness';
|
||||
import { LayersPanel } from './LayersPanel';
|
||||
import { LayerFocusProvider } from './LayerFocusContext';
|
||||
|
||||
function stateWith(extra: Record<string, any>, rootChildren: string[]) {
|
||||
return JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' }, isCanvas: true,
|
||||
props: { style: {}, tag: 'div' }, displayName: 'Container',
|
||||
custom: {}, hidden: false, nodes: rootChildren, linkedNodes: {}, parent: null,
|
||||
},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
const featuresNode = {
|
||||
type: { resolvedName: 'FeaturesGrid' }, isCanvas: false,
|
||||
props: { features: [{ title: 'Fast' }, { title: 'Secure' }] },
|
||||
displayName: 'Features Grid',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||
};
|
||||
|
||||
describe('LayersPanel', () => {
|
||||
test('shows virtual rows for a Features Grid array prop', () => {
|
||||
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
|
||||
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||
|
||||
// Scoped to `.layer-virtual-row` rather than the whole container:
|
||||
// `FeaturesGrid` itself renders `feat.title` on the live canvas (which
|
||||
// is also mounted inside `harness.container`, alongside the panel), so
|
||||
// asserting on `container.textContent` alone would pass even if
|
||||
// LayersPanel never rendered a single virtual row -- the "Fast"/"Secure"
|
||||
// text would already be there from the canvas. Scoping to the virtual
|
||||
// row elements themselves makes the assertion actually exercise
|
||||
// LayersPanel's own rendering.
|
||||
const virtualRows = harness.container.querySelectorAll('.layer-virtual-row');
|
||||
expect(virtualRows).toHaveLength(2);
|
||||
const virtualText = Array.from(virtualRows).map((el) => el.textContent).join(' | ');
|
||||
expect(virtualText).toContain('Fast');
|
||||
expect(virtualText).toContain('Secure');
|
||||
|
||||
// The real node row for the parent is still shown too.
|
||||
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
|
||||
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
|
||||
expect(nodeText).toContain('Features Grid');
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('virtual rows are not rendered for a component with no registry entry', () => {
|
||||
const heading = {
|
||||
type: { resolvedName: 'Heading' }, isCanvas: false,
|
||||
props: { text: 'Title', level: 2 }, displayName: 'Heading',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||
};
|
||||
const harness = renderEditorHarness({ initialState: stateWith({ h1: heading }, ['h1']) });
|
||||
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||
expect(harness.container.querySelectorAll('.layer-virtual-row')).toHaveLength(0);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('the Unplaced group does not render for a healthy tree', () => {
|
||||
const harness = renderEditorHarness({ initialState: stateWith({ f1: featuresNode }, ['f1']) });
|
||||
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||
expect(harness.container.textContent).not.toContain('Unplaced');
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('the Unplaced group appears and lists a node unreachable from ROOT', () => {
|
||||
// 'stray' is a real node in the deserialized state, but no node's
|
||||
// `nodes`/`linkedNodes` list references it -- exactly the "dropped
|
||||
// outside the page" scenario findUnreachableNodeIds exists to catch.
|
||||
const heading = {
|
||||
type: { resolvedName: 'Heading' }, isCanvas: false,
|
||||
props: { text: 'Title', level: 2 }, displayName: 'Heading',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||
};
|
||||
const stray = {
|
||||
type: { resolvedName: 'HtmlBlock' }, isCanvas: false,
|
||||
props: { code: '<p>stranded</p>', style: {} }, displayName: 'HTML',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||
};
|
||||
const harness = renderEditorHarness({
|
||||
initialState: stateWith({ h1: heading, stray }, ['h1']),
|
||||
});
|
||||
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
|
||||
|
||||
expect(harness.container.textContent).toContain('Unplaced');
|
||||
expect(harness.container.textContent).toContain('Unplaced (1)');
|
||||
|
||||
// The orphan itself is rendered as a selectable/deletable LayerNode row
|
||||
// (displayName 'HTML') underneath the Unplaced heading, not silently
|
||||
// dropped from the tree.
|
||||
const nodeRows = harness.container.querySelectorAll('.layer-node-row');
|
||||
const nodeText = Array.from(nodeRows).map((el) => el.textContent).join(' | ');
|
||||
expect(nodeText).toContain('HTML');
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { clickableProps } from '../../utils/a11y';
|
||||
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
|
||||
import { useLayerFocus } from './LayerFocusContext';
|
||||
import { findUnreachableNodeIds } from '../../utils/orphan-repair';
|
||||
|
||||
/**
|
||||
* Per-type icon lookup keyed by the component's `craft.displayName` (the
|
||||
@@ -62,12 +65,51 @@ const TYPE_ICONS: Record<string, string> = {
|
||||
const DEFAULT_ICON = 'fa-cube';
|
||||
const ROOT_ICON = 'fa-desktop';
|
||||
|
||||
/**
|
||||
* A display-only row for one item of a composite's array prop (a Features
|
||||
* Grid feature, a Tabs tab, ...). Not a Craft node: it can't be dragged or
|
||||
* deleted. Clicking it selects the PARENT and asks the array editor to
|
||||
* scroll that item's card into view.
|
||||
*/
|
||||
const VirtualRowNode: React.FC<{
|
||||
parentId: string;
|
||||
prop: string;
|
||||
index: number;
|
||||
label: string;
|
||||
depth: number;
|
||||
onActivate: () => void;
|
||||
}> = ({ index, label, depth, onActivate }) => (
|
||||
<div
|
||||
{...clickableProps(onActivate)}
|
||||
className="layer-virtual-row"
|
||||
title={label}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '4px 8px',
|
||||
paddingLeft: `${8 + depth * 16}px`,
|
||||
fontSize: 11,
|
||||
color: 'var(--color-text-dim)',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ marginRight: 6, fontSize: 8, flexShrink: 0 }} aria-hidden="true">▪</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{label}</span>
|
||||
<span style={{ marginLeft: 'auto', paddingLeft: 6, opacity: 0.6, flexShrink: 0 }}>{index + 1}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface LayerNodeProps {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
const { requestFocus } = useLayerFocus();
|
||||
const { node, selectedId, actions, query } = useEditor((state) => {
|
||||
const n = state.nodes[nodeId];
|
||||
const selectedIds = state.events.selected;
|
||||
@@ -140,6 +182,10 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
const isRoot = nodeId === 'ROOT';
|
||||
const icon = isRoot ? ROOT_ICON : TYPE_ICONS[displayName] || DEFAULT_ICON;
|
||||
|
||||
const virtualSpec = VIRTUAL_CHILD_PROPS[displayName];
|
||||
const virtualRows = virtualSpec ? deriveVirtualRows(displayName, node.data.props || {}) : [];
|
||||
const hasDisclosure = allChildren.length + virtualRows.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -187,7 +233,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
))}
|
||||
|
||||
{/* Indentation/disclosure indicator */}
|
||||
{allChildren.length > 0 ? (
|
||||
{hasDisclosure ? (
|
||||
<span style={{ marginRight: 4, fontSize: 8, color: 'var(--color-text-dim)', flexShrink: 0 }}>
|
||||
▼
|
||||
</span>
|
||||
@@ -217,6 +263,23 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Virtual rows: array-prop items (composite content Craft doesn't see
|
||||
as child nodes), rendered before real children. */}
|
||||
{virtualSpec && virtualRows.map((row) => (
|
||||
<VirtualRowNode
|
||||
key={`${nodeId}:${virtualSpec.prop}:${row.index}`}
|
||||
parentId={nodeId}
|
||||
prop={virtualSpec.prop}
|
||||
index={row.index}
|
||||
label={row.label}
|
||||
depth={depth + 1}
|
||||
onActivate={() => {
|
||||
actions.selectNode(nodeId);
|
||||
requestFocus(nodeId, virtualSpec.prop, row.index);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Render children */}
|
||||
{allChildren.map((childId) => (
|
||||
<LayerNode key={childId} nodeId={childId} depth={depth + 1} />
|
||||
@@ -226,9 +289,17 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
||||
};
|
||||
|
||||
export const LayersPanel: React.FC = () => {
|
||||
const { nodeIds } = useEditor((state) => {
|
||||
const { nodeIds, unplacedIds } = useEditor((state) => {
|
||||
const serializable: Record<string, any> = {};
|
||||
for (const [id, n] of Object.entries(state.nodes)) {
|
||||
serializable[id] = {
|
||||
nodes: n.data.nodes || [],
|
||||
linkedNodes: n.data.linkedNodes || {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
nodeIds: Object.keys(state.nodes),
|
||||
unplacedIds: findUnreachableNodeIds(serializable),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -243,13 +314,7 @@ export const LayersPanel: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: '-12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', margin: '-12px', minHeight: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
@@ -259,11 +324,44 @@ export const LayersPanel: React.FC = () => {
|
||||
letterSpacing: '0.5px',
|
||||
color: 'var(--color-text-muted)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
Component Tree
|
||||
</div>
|
||||
|
||||
{/* Own scroll container: a deep or long tree must stay fully reachable
|
||||
regardless of how the parent tab panel is sized. */}
|
||||
<div className="layers-tree-scroll" style={{ overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
||||
<LayerNode nodeId="ROOT" depth={0} />
|
||||
|
||||
{/* Unplaced: nodes no parent lists. `repairOrphanNodes` reattaches
|
||||
these on load, so this should stay empty -- it exists so an
|
||||
element stranded mid-session is still selectable and deletable
|
||||
rather than invisible. */}
|
||||
{unplacedIds.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
marginTop: 8,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.5px',
|
||||
color: 'var(--color-warning, #f59e0b)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
}}
|
||||
title="These elements are not attached to the page. Select one to delete it."
|
||||
>
|
||||
Unplaced ({unplacedIds.length})
|
||||
</div>
|
||||
{unplacedIds.map((id) => (
|
||||
<LayerNode key={id} nodeId={id} depth={1} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness } from '../../test-utils/editorHarness';
|
||||
import { PageProvider, usePages } from '../../state/PageContext';
|
||||
import { PagesPanel } from './PagesPanel';
|
||||
|
||||
function clickByLabel(container: HTMLElement, label: string): HTMLButtonElement {
|
||||
const btn = container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement | null;
|
||||
if (!btn) throw new Error(`No button found with aria-label "${label}"`);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function clickByText(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const btn = Array.from(container.querySelectorAll('button')).find(
|
||||
(b) => b.textContent === text,
|
||||
) as HTMLButtonElement | undefined;
|
||||
if (!btn) throw new Error(`No button found with text "${text}"`);
|
||||
return btn;
|
||||
}
|
||||
|
||||
/**
|
||||
* I3 (review): `deleteConfirmId`, `resetConfirmId` and `editingId` are three
|
||||
* independent bits of local state, but the row rendering treats them as
|
||||
* mutually exclusive (edit -> delete -> reset -> normal, first match wins).
|
||||
* Before this fix, none of the three setters cleared the other two, so if
|
||||
* both `resetConfirmId` and `deleteConfirmId` were ever set to the same page
|
||||
* id, the delete-confirm view would win (it's checked first) and cancelling
|
||||
* it would fall through to reveal the reset-confirm view "unbidden" -- a
|
||||
* destructive prompt the user never asked for.
|
||||
*
|
||||
* This is reachable any time two arm-clicks land in the same render pass
|
||||
* (e.g. a fast double click / synthetic dual dispatch before React commits
|
||||
* the first click's re-render) -- reproduced below by issuing both clicks
|
||||
* inside a single `act()` batch, which is exactly what removes the re-render
|
||||
* that would otherwise make the second button disappear before it can be
|
||||
* clicked.
|
||||
*/
|
||||
describe('PagesPanel confirmation-state isolation (I3 review finding)', () => {
|
||||
async function setupTwoPages() {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Probe: React.FC = () => { ctx = usePages(); return null; };
|
||||
const harness = renderEditorHarness();
|
||||
harness.mountChild(
|
||||
<PageProvider>
|
||||
<Probe />
|
||||
<PagesPanel />
|
||||
</PageProvider>,
|
||||
);
|
||||
harness.act(() => { ctx!.addPage('About', 'about'); });
|
||||
await harness.act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); });
|
||||
return harness;
|
||||
}
|
||||
|
||||
test('arming Reset then Delete for the same page in one batch shows Delete (render precedence), and cancelling Delete does NOT reveal a leftover Reset prompt', async () => {
|
||||
const harness = await setupTwoPages();
|
||||
|
||||
// Both arm-clicks land before any re-render commits.
|
||||
harness.act(() => {
|
||||
clickByLabel(harness.container, 'Reset About to blank').click();
|
||||
clickByLabel(harness.container, 'Delete About').click();
|
||||
});
|
||||
expect(harness.container.textContent).toContain('Delete "About"?');
|
||||
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
|
||||
|
||||
harness.act(() => {
|
||||
clickByText(harness.container, 'Cancel').click();
|
||||
});
|
||||
|
||||
// The fix: arming Delete clears any pending Reset confirmation for the
|
||||
// same page, so cancelling Delete returns to the normal row, not a
|
||||
// surprise Reset prompt.
|
||||
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
|
||||
expect(harness.container.textContent).not.toContain('Delete "About"?');
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('arming Delete then Reset for the same page in one batch shows Reset, and cancelling Reset does NOT reveal a leftover Delete prompt', async () => {
|
||||
const harness = await setupTwoPages();
|
||||
|
||||
harness.act(() => {
|
||||
clickByLabel(harness.container, 'Delete About').click();
|
||||
clickByLabel(harness.container, 'Reset About to blank').click();
|
||||
});
|
||||
expect(harness.container.textContent).toContain('Clear every element from "About"?');
|
||||
|
||||
harness.act(() => {
|
||||
clickByText(harness.container, 'Cancel').click();
|
||||
});
|
||||
|
||||
expect(harness.container.textContent).not.toContain('Delete "About"?');
|
||||
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('starting a Rename while a Reset confirmation is pending for the same page clears the pending Reset', async () => {
|
||||
const harness = await setupTwoPages();
|
||||
|
||||
// Arm Reset, then (same batch) start editing the same row -- edit wins
|
||||
// in render precedence, but the fix also clears resetConfirmId so
|
||||
// cancelling the rename doesn't fall through to a stale Reset prompt.
|
||||
harness.act(() => {
|
||||
clickByLabel(harness.container, 'Reset About to blank').click();
|
||||
clickByLabel(harness.container, 'Rename About').click();
|
||||
});
|
||||
const nameInput = harness.container.querySelector('input.control-input') as HTMLInputElement | null;
|
||||
expect(nameInput).toBeTruthy();
|
||||
expect(nameInput!.value).toBe('About');
|
||||
|
||||
harness.act(() => {
|
||||
clickByText(harness.container, 'Cancel').click();
|
||||
});
|
||||
|
||||
expect(harness.container.textContent).not.toContain('Clear every element from "About"?');
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness } from '../../test-utils/editorHarness';
|
||||
import { EMPTY_CANVAS, PageProvider, usePages } from '../../state/PageContext';
|
||||
import { PagesPanel } from './PagesPanel';
|
||||
|
||||
const withHeading = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' }, isCanvas: true,
|
||||
props: { style: {}, tag: 'div' }, displayName: 'Container',
|
||||
custom: {}, hidden: false, nodes: ['h1'], linkedNodes: {}, parent: null,
|
||||
},
|
||||
h1: {
|
||||
type: { resolvedName: 'Heading' }, isCanvas: false,
|
||||
props: { text: 'Keep me', level: 2 }, displayName: 'Heading',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
|
||||
},
|
||||
});
|
||||
|
||||
/** Same real-timer-flush pattern as `PageContext.orphan-repair-wiring.test.tsx`
|
||||
* -- `PageContext.loadState`/this feature's own reset handler both schedule
|
||||
* their `actions.deserialize()` calls via `setTimeout(..., 0)` rather than
|
||||
* applying them synchronously, so tests that exercise either path need to
|
||||
* let a real macrotask turn run before asserting on the live Frame. */
|
||||
async function flushTimers(harness: ReturnType<typeof renderEditorHarness>): Promise<void> {
|
||||
await harness.act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
|
||||
function clickByLabel(container: HTMLElement, label: string): HTMLButtonElement {
|
||||
const btn = container.querySelector(`[aria-label="${label}"]`) as HTMLButtonElement | null;
|
||||
if (!btn) throw new Error(`No button found with aria-label "${label}"`);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function clickByText(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const btn = Array.from(container.querySelectorAll('button')).find(
|
||||
(b) => b.textContent === text,
|
||||
) as HTMLButtonElement | undefined;
|
||||
if (!btn) throw new Error(`No button found with text "${text}"`);
|
||||
return btn;
|
||||
}
|
||||
|
||||
describe('resetting a page to EMPTY_CANVAS (Craft.js characterisation)', () => {
|
||||
test('clears the canvas', () => {
|
||||
const harness = renderEditorHarness({ initialState: withHeading });
|
||||
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
|
||||
|
||||
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
|
||||
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('the reset is undoable through Craft history', () => {
|
||||
const harness = renderEditorHarness({ initialState: withHeading });
|
||||
harness.act(() => { harness.actions.deserialize(EMPTY_CANVAS); });
|
||||
harness.act(() => { harness.actions.history.undo(); });
|
||||
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PagesPanel Reset Page control', () => {
|
||||
test('a Reset button is rendered for every page row, and the dialog names the page', () => {
|
||||
const harness = renderEditorHarness();
|
||||
harness.mountChild(
|
||||
<PageProvider>
|
||||
<PagesPanel />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
// Fresh PageProvider starts with exactly one page: "Home".
|
||||
const erasers = harness.container.querySelectorAll('.fa-eraser');
|
||||
expect(erasers.length).toBe(1);
|
||||
expect(clickByLabel(harness.container, 'Reset Home to blank')).toBeTruthy();
|
||||
|
||||
harness.act(() => {
|
||||
clickByLabel(harness.container, 'Reset Home to blank').click();
|
||||
});
|
||||
expect(harness.container.textContent).toContain('Clear every element from "Home"?');
|
||||
expect(harness.container.textContent).toContain('Ctrl+Z undoes this.');
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('the Header and Footer zone rows never grow a Reset control', () => {
|
||||
const harness = renderEditorHarness();
|
||||
harness.mountChild(
|
||||
<PageProvider>
|
||||
<PagesPanel />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
const zoneRows = harness.container.querySelectorAll('.zone-row');
|
||||
expect(zoneRows.length).toBe(2); // Header, Footer
|
||||
zoneRows.forEach((row) => {
|
||||
expect(row.querySelector('.fa-eraser')).toBeNull();
|
||||
});
|
||||
// No aria-label anywhere in the panel offers to reset the header/footer.
|
||||
expect(harness.container.querySelector('[aria-label="Reset Header to blank"]')).toBeNull();
|
||||
expect(harness.container.querySelector('[aria-label="Reset Footer to blank"]')).toBeNull();
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('clicking Reset then Cancel leaves the active page untouched', () => {
|
||||
const harness = renderEditorHarness({ initialState: withHeading });
|
||||
harness.mountChild(
|
||||
<PageProvider>
|
||||
<PagesPanel />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
harness.act(() => {
|
||||
clickByLabel(harness.container, 'Reset Home to blank').click();
|
||||
});
|
||||
harness.act(() => {
|
||||
clickByText(harness.container, 'Cancel').click();
|
||||
});
|
||||
|
||||
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual(['h1']);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('clicking Reset then confirming blanks the ACTIVE page', async () => {
|
||||
const harness = renderEditorHarness({ initialState: withHeading });
|
||||
harness.mountChild(
|
||||
<PageProvider>
|
||||
<PagesPanel />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
harness.act(() => {
|
||||
clickByLabel(harness.container, 'Reset Home to blank').click();
|
||||
});
|
||||
harness.act(() => {
|
||||
clickByText(harness.container, 'Reset page').click();
|
||||
});
|
||||
await flushTimers(harness);
|
||||
|
||||
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
|
||||
harness.unmount();
|
||||
});
|
||||
|
||||
test('resetting a page that is NOT on screen switches to it first, then blanks it -- ' +
|
||||
'the switch-in load must resolve BEFORE the blank, or the blank would just get ' +
|
||||
'overwritten by the page\'s real content', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Probe: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
|
||||
const harness = renderEditorHarness({ initialState: withHeading });
|
||||
harness.mountChild(
|
||||
<PageProvider>
|
||||
<Probe />
|
||||
<PagesPanel />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
// addPage() saves the live Frame (withHeading) into Home's slot, adds
|
||||
// "About", and switches the live Frame to About's (empty) canvas.
|
||||
harness.act(() => { ctx!.addPage('About', 'about'); });
|
||||
await flushTimers(harness);
|
||||
expect(ctx!.activePageId).not.toBe('home');
|
||||
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]); // About is blank
|
||||
|
||||
// Now reset Home while About is the active/on-screen page.
|
||||
harness.act(() => {
|
||||
clickByLabel(harness.container, 'Reset Home to blank').click();
|
||||
});
|
||||
harness.act(() => {
|
||||
clickByText(harness.container, 'Reset page').click();
|
||||
});
|
||||
await flushTimers(harness);
|
||||
|
||||
// The live Frame now shows Home (switchPage ran) and it's blank (reset ran
|
||||
// after the switch-in load, not before it).
|
||||
expect(ctx!.activePageId).toBe('home');
|
||||
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
|
||||
|
||||
// And the blank state actually persisted into Home's stored slot, not
|
||||
// just transiently on the Frame: switch away and back, still blank.
|
||||
const aboutId = ctx!.pages.find((p) => p.id !== 'home')!.id;
|
||||
harness.act(() => { ctx!.switchPage(aboutId); });
|
||||
await flushTimers(harness);
|
||||
harness.act(() => { ctx!.switchPage('home'); });
|
||||
await flushTimers(harness);
|
||||
expect(JSON.parse(harness.getSerialized()).ROOT.nodes).toEqual([]);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { usePages, EMPTY_CANVAS } from '../../state/PageContext';
|
||||
import { clickableProps } from '../../utils/a11y';
|
||||
import { PageSettingsModal } from './PageSettingsModal';
|
||||
|
||||
@@ -20,6 +21,7 @@ export const PagesPanel: React.FC = () => {
|
||||
setLandingPage,
|
||||
updatePageSeo,
|
||||
} = usePages();
|
||||
const { actions: editorActions } = useEditor();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newSlug, setNewSlug] = useState('');
|
||||
@@ -27,6 +29,7 @@ export const PagesPanel: React.FC = () => {
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editSlug, setEditSlug] = useState('');
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [resetConfirmId, setResetConfirmId] = useState<string | null>(null);
|
||||
const [seoSettingsPageId, setSeoSettingsPageId] = useState<string | null>(null);
|
||||
const seoSettingsPage = pages.find((p) => p.id === seoSettingsPageId) || null;
|
||||
|
||||
@@ -49,11 +52,32 @@ export const PagesPanel: React.FC = () => {
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Blanks `pageId`'s canvas to `EMPTY_CANVAS`. `actions.deserialize()` acts
|
||||
* on the LIVE `<Frame>`, so resetting a page that isn't currently on
|
||||
* screen requires switching to it first. `switchPage` itself defers its
|
||||
* own `actions.deserialize(targetState)` via `setTimeout(..., 0)` (see
|
||||
* `PageContext.loadState`) rather than applying it synchronously -- so the
|
||||
* blanking `setTimeout` scheduled here must run AFTER that one, or it
|
||||
* would blank the frame and then have `switchPage`'s own deferred load
|
||||
* immediately overwrite the blank with the target page's real content.
|
||||
* Since `switchPage(pageId)` runs synchronously above (registering its
|
||||
* internal setTimeout first) before this function schedules its own,
|
||||
* same-delay `setTimeout` callbacks fire in registration order -- the
|
||||
* switch's load always resolves before this reset does.
|
||||
*/
|
||||
const handleResetPage = (pageId: string): void => {
|
||||
if (pageId !== activePageId) switchPage(pageId);
|
||||
setTimeout(() => editorActions.deserialize(EMPTY_CANVAS), 0);
|
||||
setResetConfirmId(null);
|
||||
};
|
||||
|
||||
const startEditing = (page: { id: string; name: string; slug: string }) => {
|
||||
setEditingId(page.id);
|
||||
setEditName(page.name);
|
||||
setEditSlug(page.slug);
|
||||
setDeleteConfirmId(null);
|
||||
setResetConfirmId(null);
|
||||
};
|
||||
|
||||
const autoSlug = (name: string): string => {
|
||||
@@ -301,6 +325,60 @@ export const PagesPanel: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : resetConfirmId === page.id ? (
|
||||
/* Reset-to-blank confirmation -- mirrors the delete confirmation
|
||||
* above, since both are destructive per-page actions. */
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: 'var(--color-bg-elevated)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text)' }}>
|
||||
Clear every element from "{page.name}"? Your header, footer and other
|
||||
pages are untouched, and the published site doesn't change until you
|
||||
publish again. Ctrl+Z undoes this.
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => handleResetPage(page.id)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: 'var(--color-danger)',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reset page
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResetConfirmId(null)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '5px 10px',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-muted)',
|
||||
background: 'var(--color-bg-base)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Normal page item */
|
||||
<div
|
||||
@@ -437,9 +515,23 @@ export const PagesPanel: React.FC = () => {
|
||||
>
|
||||
<i className="fa fa-pencil" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setResetConfirmId(page.id);
|
||||
setDeleteConfirmId(null);
|
||||
}}
|
||||
data-tooltip="Reset to blank"
|
||||
aria-label={`Reset ${page.name} to blank`}
|
||||
style={pageActionBtnStyle({ danger: true })}
|
||||
>
|
||||
<i className="fa fa-eraser" aria-hidden="true" />
|
||||
</button>
|
||||
{pages.length > 1 && !isLanding && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirmId(page.id)}
|
||||
onClick={() => {
|
||||
setDeleteConfirmId(page.id);
|
||||
setResetConfirmId(null);
|
||||
}}
|
||||
data-tooltip="Delete"
|
||||
aria-label={`Delete ${page.name}`}
|
||||
style={pageActionBtnStyle({ danger: true })}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { deriveVirtualRows, VIRTUAL_CHILD_PROPS } from './layers-virtual-rows';
|
||||
|
||||
describe('deriveVirtualRows', () => {
|
||||
test('returns one row per item, labelled by the registered field', () => {
|
||||
const rows = deriveVirtualRows('Features Grid', {
|
||||
features: [{ title: 'Fast' }, { title: 'Secure' }],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ index: 0, label: 'Fast' },
|
||||
{ index: 1, label: 'Secure' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to "<Fallback> N" when the label field is missing or blank', () => {
|
||||
const rows = deriveVirtualRows('Features Grid', {
|
||||
features: [{ title: '' }, { description: 'no title key' }],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ index: 0, label: 'Feature 1' },
|
||||
{ index: 1, label: 'Feature 2' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('trims and truncates a long label to 40 characters with an ellipsis', () => {
|
||||
const long = 'x'.repeat(60);
|
||||
const rows = deriveVirtualRows('Features Grid', { features: [{ title: ` ${long} ` }] });
|
||||
expect(rows[0].label).toHaveLength(41);
|
||||
expect(rows[0].label.endsWith('…')).toBe(true);
|
||||
});
|
||||
|
||||
test('an unregistered component yields no rows', () => {
|
||||
expect(deriveVirtualRows('Heading', { text: 'hi' })).toEqual([]);
|
||||
});
|
||||
|
||||
test('a missing or non-array prop yields no rows instead of throwing', () => {
|
||||
expect(deriveVirtualRows('Tabs', {})).toEqual([]);
|
||||
expect(deriveVirtualRows('Tabs', { tabs: 'not an array' })).toEqual([]);
|
||||
expect(deriveVirtualRows('Tabs', { tabs: null })).toEqual([]);
|
||||
});
|
||||
|
||||
test('a non-object item still gets a fallback label', () => {
|
||||
expect(deriveVirtualRows('Menu', { links: ['raw string'] })).toEqual([
|
||||
{ index: 0, label: 'Link 1' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('Hero, Call to Action and CTA Section derive rows from their shared ctas prop', () => {
|
||||
const ctas = [
|
||||
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||
{ text: 'Learn More', href: '#learn', variant: 'outline' },
|
||||
];
|
||||
expect(deriveVirtualRows('Hero', { ctas })).toEqual([
|
||||
{ index: 0, label: 'Get Started' },
|
||||
{ index: 1, label: 'Learn More' },
|
||||
]);
|
||||
expect(deriveVirtualRows('Call to Action', { ctas })).toEqual([
|
||||
{ index: 0, label: 'Get Started' },
|
||||
{ index: 1, label: 'Learn More' },
|
||||
]);
|
||||
expect(deriveVirtualRows('CTA Section', { ctas })).toEqual([
|
||||
{ index: 0, label: 'Get Started' },
|
||||
{ index: 1, label: 'Learn More' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a CTA with no text falls back to "Button N"', () => {
|
||||
const rows = deriveVirtualRows('Hero', {
|
||||
ctas: [{ href: '#' }, { text: '', href: '#empty' }],
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ index: 0, label: 'Button 1' },
|
||||
{ index: 1, label: 'Button 2' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('every registry entry has a non-empty prop, label and fallback', () => {
|
||||
for (const [name, spec] of Object.entries(VIRTUAL_CHILD_PROPS)) {
|
||||
expect(spec.prop, `${name}.prop`).toBeTruthy();
|
||||
expect(spec.label, `${name}.label`).toBeTruthy();
|
||||
expect(spec.fallback, `${name}.fallback`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Virtual Layers rows for components whose content lives in ARRAY PROPS
|
||||
* rather than Craft child nodes.
|
||||
*
|
||||
* FeaturesGrid, Tabs, Accordion, PricingTable, Testimonials, Gallery,
|
||||
* ContentSlider, NumberCounter, Menu, SocialLinks, Navbar and ContactForm all
|
||||
* render their items from a prop array, so Craft sees them as leaf nodes and
|
||||
* the Layers tree showed nothing underneath them -- the "Layers doesn't show
|
||||
* everything" report. These rows are display-only: they are not Craft nodes,
|
||||
* cannot be dragged, and selecting one selects the PARENT node (plus asks the
|
||||
* array editor to scroll that item into view -- see LayerFocusContext).
|
||||
*
|
||||
* ColumnLayout is deliberately absent: it uses real `<Element canvas>`
|
||||
* children, which LayersPanel already nests correctly.
|
||||
*/
|
||||
|
||||
export interface VirtualRow {
|
||||
index: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface VirtualChildSpec {
|
||||
/** Name of the array prop holding the items. */
|
||||
prop: string;
|
||||
/** Per-item field used as the row label. */
|
||||
label: string;
|
||||
/** Used as "<fallback> <n>" when the label field is missing or blank. */
|
||||
fallback: string;
|
||||
}
|
||||
|
||||
/** Keyed by the component's craft `displayName` -- the same string
|
||||
* `LayerNode` already resolves and shows as the row text.
|
||||
*
|
||||
* Verified against each component's actual item interface and
|
||||
* `craft.props` defaults (see task-9-report.md, Step 1):
|
||||
* - Tabs items are `{ label, content }`, not `{ title }` -- registry
|
||||
* corrected from `title` to `label`.
|
||||
* - ContentSlider items are `{ heading, text, ... }`, not `{ title }` --
|
||||
* registry corrected from `title` to `heading`.
|
||||
* Every other entry (prop name and label field) matched the component as
|
||||
* originally drafted.
|
||||
*
|
||||
* Hero, Call to Action and CTA Section were added after a follow-up sweep
|
||||
* of every component under src/components/ for top-level array props (not
|
||||
* just the 12 files initially listed) turned up three more leaf components
|
||||
* with the identical pattern: all three render a `ctas?: CtaButton[]` prop
|
||||
* (see sections/_cta-helpers.tsx) whose items are `{ text, href, variant?,
|
||||
* target? }`, so `text` is the label field. Verified against each file's
|
||||
* `craft.displayName` and `craft.props.ctas` defaults. That sweep found no
|
||||
* further candidates -- the only other array fields in the tree are nested
|
||||
* one level down inside already-covered items (ContactFormField.options,
|
||||
* PricingPlan.features), not top-level component props. */
|
||||
export const VIRTUAL_CHILD_PROPS: Record<string, VirtualChildSpec> = {
|
||||
'Features Grid': { prop: 'features', label: 'title', fallback: 'Feature' },
|
||||
Tabs: { prop: 'tabs', label: 'label', fallback: 'Tab' },
|
||||
Accordion: { prop: 'items', label: 'title', fallback: 'Item' },
|
||||
'Pricing Table': { prop: 'plans', label: 'name', fallback: 'Plan' },
|
||||
Testimonials: { prop: 'testimonials', label: 'name', fallback: 'Testimonial' },
|
||||
Gallery: { prop: 'images', label: 'alt', fallback: 'Image' },
|
||||
'Content Slider': { prop: 'slides', label: 'heading', fallback: 'Slide' },
|
||||
'Number Counter': { prop: 'counters', label: 'label', fallback: 'Counter' },
|
||||
Menu: { prop: 'links', label: 'text', fallback: 'Link' },
|
||||
'Social Links': { prop: 'links', label: 'platform', fallback: 'Link' },
|
||||
Navbar: { prop: 'links', label: 'text', fallback: 'Link' },
|
||||
'Contact Form': { prop: 'fields', label: 'label', fallback: 'Field' },
|
||||
Hero: { prop: 'ctas', label: 'text', fallback: 'Button' },
|
||||
'Call to Action': { prop: 'ctas', label: 'text', fallback: 'Button' },
|
||||
'CTA Section': { prop: 'ctas', label: 'text', fallback: 'Button' },
|
||||
};
|
||||
|
||||
const MAX_LABEL = 40;
|
||||
|
||||
export function deriveVirtualRows(displayName: string, props: Record<string, any>): VirtualRow[] {
|
||||
const spec = VIRTUAL_CHILD_PROPS[displayName];
|
||||
if (!spec) return [];
|
||||
|
||||
const items = props?.[spec.prop];
|
||||
if (!Array.isArray(items)) return [];
|
||||
|
||||
return items.map((item, index) => {
|
||||
const raw = item && typeof item === 'object' ? item[spec.label] : undefined;
|
||||
const text = typeof raw === 'string' ? raw.trim() : '';
|
||||
if (!text) return { index, label: `${spec.fallback} ${index + 1}` };
|
||||
const label = text.length > MAX_LABEL ? `${text.slice(0, MAX_LABEL)}…` : text;
|
||||
return { index, label };
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
PricingStylePanel,
|
||||
BackgroundSectionStylePanel,
|
||||
GenericPropsEditor,
|
||||
HtmlStylePanel,
|
||||
} from './styles';
|
||||
|
||||
/* ================================================================
|
||||
@@ -75,8 +76,10 @@ export const GuidedStyles: React.FC = () => {
|
||||
const isSocial = /^social links$|^icon$|^star rating$/i.test(typeName);
|
||||
const isPricing = /^pricing/i.test(typeName);
|
||||
const isSection = /^accordion$|^tabs$|^testimonial|^countdown$|^number counter$|^cta section$|^call to action$|^features grid$/i.test(typeName);
|
||||
// Utility types that need minimal controls
|
||||
const isUtility = /^divider$|^spacer$|^html$/i.test(typeName);
|
||||
const isHtml = /^html$/i.test(typeName);
|
||||
// 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);
|
||||
|
||||
// Icon for the type badge
|
||||
const typeIcon = isText ? 'fa-font'
|
||||
@@ -90,6 +93,7 @@ export const GuidedStyles: React.FC = () => {
|
||||
: isForm ? 'fa-wpforms'
|
||||
: isSocial ? 'fa-share-alt'
|
||||
: isSection ? 'fa-th-large'
|
||||
: isHtml ? 'fa-code'
|
||||
: isUtility ? 'fa-ellipsis-h'
|
||||
: 'fa-cube';
|
||||
|
||||
@@ -158,11 +162,14 @@ export const GuidedStyles: React.FC = () => {
|
||||
{/* SECTION-TYPE (Accordion, Tabs, Testimonials, Countdown, Counter, CTA, Features) */}
|
||||
{isSection && <SectionTypePanel selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
||||
|
||||
{/* UTILITY (Divider, Spacer, HTML) -- use generic but it works well for these */}
|
||||
{/* HTML -- code editor only */}
|
||||
{isHtml && <HtmlStylePanel selectedId={selected} nodeProps={nodeProps} />}
|
||||
|
||||
{/* UTILITY (Divider, Spacer) -- use generic but it works well for these */}
|
||||
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
|
||||
|
||||
{/* FALLBACK: Anything not matched above */}
|
||||
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && (
|
||||
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && (
|
||||
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import React from 'react';
|
||||
import { renderEditorHarness } from '../../test-utils/editorHarness';
|
||||
import { EditorConfigProvider } from '../../state/EditorConfigContext';
|
||||
import { PageProvider, usePages } from '../../state/PageContext';
|
||||
import { SiteDesignProvider, useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
||||
import { SiteDesignPanel } from './SiteDesignPanel';
|
||||
|
||||
/** React tracks a controlled `<input>`'s value via a wrapped native setter --
|
||||
* a plain `input.value = x` assignment doesn't go through it, so React never
|
||||
* sees the change and skips onChange. */
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-provider integration test -- NO mocks. Exercises the actual
|
||||
* `replaceAllPages`/`setHeader`/`setFooter`/`resetToDefaults` implementations,
|
||||
* which route the component's blank trees through the real
|
||||
* `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft` pipeline (see
|
||||
* `state/PageContext.tsx` and `utils/craft-tree.ts`). This is the check the
|
||||
* task brief calls out specifically: a tree shape that fails sanitisation
|
||||
* would silently fall back to an empty canvas (masking a bug) rather than
|
||||
* throwing, so asserting only on mock call arguments (as
|
||||
* `SiteDesignPanel.reset.test.tsx` does) would not catch a bad tree shape --
|
||||
* the mocked assertions there only prove the component PASSED a
|
||||
* `{type:{resolvedName:'Container'}, nodes:[]}`-shaped object; they can't
|
||||
* prove that object is actually valid input to the real pipeline.
|
||||
*/
|
||||
describe('Reset Entire Site -- real PageContext/SiteDesignContext integration', () => {
|
||||
test('confirming produces a real, valid single blank Home page + header + footer + default design', () => {
|
||||
let pageCtx: ReturnType<typeof usePages> | null = null;
|
||||
let designCtx: ReturnType<typeof useSiteDesign> | null = null;
|
||||
const Probe: React.FC = () => {
|
||||
pageCtx = usePages();
|
||||
designCtx = useSiteDesign();
|
||||
return null;
|
||||
};
|
||||
|
||||
const harness = renderEditorHarness();
|
||||
harness.mountChild(
|
||||
<EditorConfigProvider config={{
|
||||
user: 'u', apiUrl: '/api', csrfToken: 't', siteId: 1,
|
||||
siteDomain: 'example.com', siteName: 'Example', backUrl: '/', isRoot: false,
|
||||
}}>
|
||||
<PageProvider>
|
||||
<SiteDesignProvider>
|
||||
<Probe />
|
||||
<SiteDesignPanel />
|
||||
</SiteDesignProvider>
|
||||
</PageProvider>
|
||||
</EditorConfigProvider>,
|
||||
);
|
||||
|
||||
// Dirty the design tokens first so resetToDefaults has something to undo.
|
||||
harness.act(() => { designCtx!.updateDesign({ primaryColor: '#000000' }); });
|
||||
expect(designCtx!.design.primaryColor).toBe('#000000');
|
||||
|
||||
harness.act(() => {
|
||||
(harness.container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click();
|
||||
});
|
||||
const input = harness.container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||
harness.act(() => { setInputValue(input, 'example.com'); });
|
||||
harness.act(() => {
|
||||
(harness.container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click();
|
||||
});
|
||||
|
||||
// Exactly one page, "Home", landing-page slug -- and its stored
|
||||
// craftState is real, parseable Craft.js state with a Container ROOT
|
||||
// and no children (i.e. sanitizeAiTree did NOT reject the tree and fall
|
||||
// back to a silent empty canvas for the wrong reason -- it's genuinely
|
||||
// ROOT with zero nodes because that's what we asked for).
|
||||
expect(pageCtx!.pages).toHaveLength(1);
|
||||
expect(pageCtx!.pages[0].name).toBe('Home');
|
||||
expect(pageCtx!.pages[0].slug).toBe('index');
|
||||
const homeState = JSON.parse(pageCtx!.pages[0].craftState!);
|
||||
expect(homeState.ROOT.type.resolvedName).toBe('Container');
|
||||
expect(homeState.ROOT.nodes).toEqual([]);
|
||||
|
||||
const headerState = JSON.parse(pageCtx!.headerPage.craftState!);
|
||||
expect(headerState.ROOT.type.resolvedName).toBe('Container');
|
||||
expect(headerState.ROOT.nodes).toEqual([]);
|
||||
expect(headerState.ROOT.props.tag).toBe('header');
|
||||
|
||||
const footerState = JSON.parse(pageCtx!.footerPage.craftState!);
|
||||
expect(footerState.ROOT.type.resolvedName).toBe('Container');
|
||||
expect(footerState.ROOT.nodes).toEqual([]);
|
||||
expect(footerState.ROOT.props.tag).toBe('footer');
|
||||
|
||||
// Design tokens are back to the defaults.
|
||||
expect(designCtx!.design).toEqual(DEFAULT_SITE_DESIGN);
|
||||
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Mocked-hook tests -- pure UI-logic coverage: the domain-match guard, that
|
||||
* confirming calls the right context functions with tree-shaped arguments,
|
||||
* and that standalone mode (empty siteDomain) hides the entry point
|
||||
* entirely. The real `replaceAllPages`/`setHeader`/`setFooter`/
|
||||
* `resetToDefaults` implementations (and whether the blank trees this
|
||||
* component builds actually survive `treeToCraftState`/`sanitizeAiTree`) are
|
||||
* covered separately, WITHOUT mocks, in
|
||||
* `SiteDesignPanel.reset.integration.test.tsx` -- `vi.mock` is hoisted and
|
||||
* file-scoped, so it can't be selectively "undone" partway through one file
|
||||
* for a real-provider test.
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
const replaceAllPages = vi.fn();
|
||||
const setHeader = vi.fn();
|
||||
const setFooter = vi.fn();
|
||||
const resetToDefaults = vi.fn();
|
||||
let mockSiteDomain = 'example.com';
|
||||
|
||||
vi.mock('../../state/PageContext', () => ({
|
||||
usePages: () => ({ replaceAllPages, setHeader, setFooter, pages: [], siteDesign: {} }),
|
||||
}));
|
||||
vi.mock('../../state/SiteDesignContext', () => ({
|
||||
useSiteDesign: () => ({ design: {}, updateDesign: vi.fn(), resetToDefaults }),
|
||||
DEFAULT_SITE_DESIGN: {},
|
||||
}));
|
||||
vi.mock('../../state/EditorConfigContext', () => ({
|
||||
useEditorConfig: () => ({ whpConfig: mockSiteDomain ? { siteDomain: mockSiteDomain } : null, isWHP: !!mockSiteDomain }),
|
||||
}));
|
||||
|
||||
import { SiteDesignPanel, siteResetConfirmMatches } from './SiteDesignPanel';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render() {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(<SiteDesignPanel />);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => { root.unmount(); });
|
||||
container.remove();
|
||||
}
|
||||
|
||||
/** React tracks a controlled `<input>`'s value via a wrapped native setter --
|
||||
* a plain `input.value = x` assignment doesn't go through it, so React never
|
||||
* sees the change and skips onChange. Using the real native setter (same
|
||||
* pattern as `MediaStylePanel.slides.test.tsx`) makes the subsequent
|
||||
* `input` event register as a genuine value change. */
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reach past React's OWN disabled-button protection to grab the `onClick`
|
||||
* function it attached to a DOM node, and call it directly.
|
||||
*
|
||||
* Verified empirically (see task-15 follow-up investigation) that neither
|
||||
* `el.disabled = false; el.click()` nor a raw `dispatchEvent(new
|
||||
* MouseEvent('click', ...))` actually invokes a React `onClick` handler once
|
||||
* React has rendered the element with `disabled` truthy: react-dom's event
|
||||
* system special-cases form controls and refuses to dispatch synthetic
|
||||
* click/similar events against its own last-rendered `disabled` prop,
|
||||
* regardless of what the live DOM property says. That's a second, redundant
|
||||
* safety net on top of the native browser behavior -- but it also means a
|
||||
* pure DOM-level "disabled bypass" can't reach `handleResetSite` at all
|
||||
* through the button, and so can't exercise the handler's OWN guard the way
|
||||
* this test needs to. Pulling `onClick` off the fiber's stashed props
|
||||
* (`__reactProps$...`, the same object React itself calls into) and
|
||||
* invoking it directly is what actually reaches `handleResetSite` -- the
|
||||
* same code path a differently-wired future trigger (a keyboard shortcut, a
|
||||
* second button, a copy-paste bug) would also go through without passing
|
||||
* back through the `disabled` gate at all.
|
||||
*/
|
||||
function invokeReactOnClick(el: HTMLElement): void {
|
||||
const propsKey = Object.keys(el).find((k) => k.startsWith('__reactProps$'));
|
||||
const onClick = propsKey ? (el as unknown as Record<string, { onClick?: () => void }>)[propsKey].onClick : undefined;
|
||||
if (!onClick) throw new Error('no React onClick prop found on element');
|
||||
onClick();
|
||||
}
|
||||
|
||||
describe('siteResetConfirmMatches -- the guard predicate itself', () => {
|
||||
test('matches only an exact (trimmed) domain match', () => {
|
||||
expect(siteResetConfirmMatches('example.com', 'example.com')).toBe(true);
|
||||
expect(siteResetConfirmMatches('example.co', 'example.com')).toBe(false);
|
||||
expect(siteResetConfirmMatches('', 'example.com')).toBe(false);
|
||||
expect(siteResetConfirmMatches(' example.com ', 'example.com')).toBe(true);
|
||||
});
|
||||
|
||||
test('an empty domain never matches, even against an empty typed value -- ' +
|
||||
'this is the guard that must hold even if the (currently hidden) entry ' +
|
||||
'point were ever reachable in standalone mode', () => {
|
||||
expect(siteResetConfirmMatches('', '')).toBe(false);
|
||||
expect(siteResetConfirmMatches(' ', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset Entire Site -- guard + wiring (mocked hooks)', () => {
|
||||
test('the confirm button is disabled until the domain is typed exactly', () => {
|
||||
mockSiteDomain = 'example.com';
|
||||
render();
|
||||
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
|
||||
|
||||
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
|
||||
expect(confirm.disabled).toBe(true);
|
||||
|
||||
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||
act(() => { setInputValue(input, 'example.co'); });
|
||||
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
act(() => { setInputValue(input, 'example.com'); });
|
||||
expect((container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('confirming blanks pages, header, footer and design tokens', () => {
|
||||
mockSiteDomain = 'example.com';
|
||||
replaceAllPages.mockClear();
|
||||
setHeader.mockClear();
|
||||
setFooter.mockClear();
|
||||
resetToDefaults.mockClear();
|
||||
|
||||
render();
|
||||
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
|
||||
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||
act(() => { setInputValue(input, 'example.com'); });
|
||||
act(() => { (container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement).click(); });
|
||||
|
||||
expect(replaceAllPages).toHaveBeenCalledTimes(1);
|
||||
const pagesArg = replaceAllPages.mock.calls[0][0];
|
||||
expect(pagesArg).toHaveLength(1);
|
||||
expect(pagesArg[0].name).toBe('Home');
|
||||
// The replacement is a SerializedTreeNode (recursive tree), not a flat
|
||||
// craft-state entry: a real Container root with no children.
|
||||
expect(pagesArg[0].tree.type.resolvedName).toBe('Container');
|
||||
expect(pagesArg[0].tree.nodes).toEqual([]);
|
||||
|
||||
expect(setHeader).toHaveBeenCalledTimes(1);
|
||||
expect(setHeader.mock.calls[0][0].type.resolvedName).toBe('Container');
|
||||
expect(setFooter).toHaveBeenCalledTimes(1);
|
||||
expect(setFooter.mock.calls[0][0].type.resolvedName).toBe('Container');
|
||||
expect(resetToDefaults).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The panel closes its own dialog back up after confirming.
|
||||
expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeNull();
|
||||
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('the handler itself refuses a non-matching value, even when its onClick is invoked ' +
|
||||
'directly (bypassing `disabled` and React\'s own disabled-click suppression) -- proves ' +
|
||||
'the guard is load-bearing in handleResetSite, not just a UI affordance a different ' +
|
||||
'trigger path could route around', () => {
|
||||
mockSiteDomain = 'example.com';
|
||||
replaceAllPages.mockClear();
|
||||
setHeader.mockClear();
|
||||
setFooter.mockClear();
|
||||
resetToDefaults.mockClear();
|
||||
|
||||
render();
|
||||
act(() => { (container.querySelector('[data-action="open-site-reset"]') as HTMLButtonElement).click(); });
|
||||
const input = container.querySelector('[data-testid="site-reset-domain"]') as HTMLInputElement;
|
||||
act(() => { setInputValue(input, 'not-the-domain'); });
|
||||
|
||||
const confirm = container.querySelector('[data-action="confirm-site-reset"]') as HTMLButtonElement;
|
||||
expect(confirm.disabled).toBe(true); // sanity: the UI affordance is still doing its job too
|
||||
|
||||
act(() => { invokeReactOnClick(confirm); });
|
||||
|
||||
expect(replaceAllPages).not.toHaveBeenCalled();
|
||||
expect(setHeader).not.toHaveBeenCalled();
|
||||
expect(setFooter).not.toHaveBeenCalled();
|
||||
expect(resetToDefaults).not.toHaveBeenCalled();
|
||||
// The dialog is still open -- handleResetSite returned early before
|
||||
// reaching its own close-the-dialog cleanup.
|
||||
expect(container.querySelector('[data-action="confirm-site-reset"]')).toBeTruthy();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
test('standalone mode (no WHP_CONFIG, siteDomain "") hides the entry point entirely, ' +
|
||||
'while non-standalone mode shows it -- an empty typed input must never trivially ' +
|
||||
'satisfy the guard. Asserted as a contrast within one test (not "absence" alone) ' +
|
||||
'so this fails if the whole feature -- not just the guard -- were ever removed.', () => {
|
||||
mockSiteDomain = '';
|
||||
render();
|
||||
expect(container.querySelector('[data-action="open-site-reset"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="site-reset-domain"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain('Danger zone');
|
||||
expect(container.textContent).not.toContain('Reset Entire Site');
|
||||
unmount();
|
||||
|
||||
mockSiteDomain = 'example.com';
|
||||
render();
|
||||
expect(container.querySelector('[data-action="open-site-reset"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain('Danger zone');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSiteDesign, DEFAULT_SITE_DESIGN } from '../../state/SiteDesignContext';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
import { SerializedTreeNode } from '../../types/sitesmith';
|
||||
import { FONT_FAMILIES } from '../../constants/presets';
|
||||
import { AssetPicker } from '../../ui/AssetPicker';
|
||||
import { inputStyle } from './styles/shared';
|
||||
|
||||
type DesignTab = 'basic' | 'advanced';
|
||||
|
||||
@@ -156,12 +160,94 @@ const NavStyleField: React.FC<NavStyleFieldProps> = ({ value, onChange }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ---------- Reset Entire Site (danger zone) ---------- */
|
||||
|
||||
// Blank replacement trees for the "Reset Entire Site" escape hatch. Shaped
|
||||
// as a `SerializedTreeNode` (type + props + nodes), NOT a flat Craft.js
|
||||
// state entry -- this is what `replaceAllPages`/`setHeader`/`setFooter`
|
||||
// feed into `treeToCraftState` -> `sanitizeAiTree`/`flattenTreeForCraft`
|
||||
// (see `state/PageContext.tsx`/`utils/craft-tree.ts`). A single `Container`
|
||||
// root with no children mirrors the (unexported) EMPTY_CANVAS/EMPTY_HEADER/
|
||||
// EMPTY_FOOTER constants already used elsewhere for "blank".
|
||||
const BLANK_PAGE_TREE: SerializedTreeNode = {
|
||||
type: { resolvedName: 'Container' },
|
||||
props: { style: { minHeight: '100vh', backgroundColor: '#ffffff' }, tag: 'div' },
|
||||
nodes: [],
|
||||
};
|
||||
|
||||
const BLANK_HEADER_TREE: SerializedTreeNode = {
|
||||
type: { resolvedName: 'Container' },
|
||||
props: {
|
||||
style: { minHeight: '60px', backgroundColor: '#ffffff', padding: '12px 24px', display: 'flex', alignItems: 'center' },
|
||||
tag: 'header',
|
||||
},
|
||||
nodes: [],
|
||||
};
|
||||
|
||||
const BLANK_FOOTER_TREE: SerializedTreeNode = {
|
||||
type: { resolvedName: 'Container' },
|
||||
props: {
|
||||
style: { minHeight: '60px', backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 24px', textAlign: 'center' },
|
||||
tag: 'footer',
|
||||
},
|
||||
nodes: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Single source of truth for "has the user typed enough to arm the confirm
|
||||
* button" -- used for the `disabled` attribute/cursor/opacity AND, more
|
||||
* importantly, inside `handleResetSite` itself. `disabled` is a UI
|
||||
* affordance, not a safety mechanism (it only stops a plain mouse click);
|
||||
* for the one action in this app that irreversibly wipes a user's whole
|
||||
* site draft, the real guard has to live in the handler, checked against
|
||||
* this exact same predicate rather than a second hand-rolled comparison
|
||||
* that could drift out of sync with it.
|
||||
*
|
||||
* An empty/falsy `domain` always returns `false`, even if `typed` is also
|
||||
* empty -- `''.trim() === ''.trim()` would otherwise "match" trivially.
|
||||
* Standalone mode (no `WHP_CONFIG`) has `siteDomain === ''`, and while the
|
||||
* entry point that would let a user reach this code is hidden in that case
|
||||
* (see the `siteDomain &&` guard around the whole danger zone below), this
|
||||
* function must not depend on that -- it has to fail safe on its own if
|
||||
* ever reached with no configured domain.
|
||||
*
|
||||
* Exported so it's directly unit-testable without needing a way to render
|
||||
* the (deliberately unreachable-when-`siteDomain`-is-empty) confirm UI.
|
||||
*/
|
||||
export function siteResetConfirmMatches(typed: string, domain: string): boolean {
|
||||
return !!domain && typed.trim() === domain.trim();
|
||||
}
|
||||
|
||||
/* ---------- Main SiteDesignPanel ---------- */
|
||||
|
||||
export const SiteDesignPanel: React.FC = () => {
|
||||
const { design, updateDesign, resetToDefaults } = useSiteDesign();
|
||||
const { replaceAllPages, setHeader, setFooter } = usePages();
|
||||
const { whpConfig } = useEditorConfig();
|
||||
// Standalone mode (no WHP_CONFIG) has no site domain to confirm against --
|
||||
// an empty typed input would then trivially "match" an empty siteDomain,
|
||||
// arming the destructive confirm button with no guard at all. The entry
|
||||
// point itself is hidden in that case (see the `siteDomain &&` guard below).
|
||||
const siteDomain = whpConfig?.siteDomain ?? '';
|
||||
const [siteResetOpen, setSiteResetOpen] = useState(false);
|
||||
const [siteResetTyped, setSiteResetTyped] = useState('');
|
||||
const [tab, setTab] = useState<DesignTab>('basic');
|
||||
|
||||
const handleResetSite = (): void => {
|
||||
// Load-bearing guard -- see `siteResetConfirmMatches`'s docstring. Not
|
||||
// just a UI nicety: this must hold even if the confirm button's
|
||||
// `disabled` attribute were ever bypassed (a synthetic click, or a
|
||||
// future refactor that drops it).
|
||||
if (!siteResetConfirmMatches(siteResetTyped, siteDomain)) return;
|
||||
|
||||
replaceAllPages([{ name: 'Home', tree: BLANK_PAGE_TREE }]);
|
||||
setHeader(BLANK_HEADER_TREE);
|
||||
setFooter(BLANK_FOOTER_TREE);
|
||||
resetToDefaults();
|
||||
setSiteResetOpen(false);
|
||||
setSiteResetTyped('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{/* Header */}
|
||||
@@ -371,6 +457,85 @@ export const SiteDesignPanel: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reset Entire Site (danger zone) -- hidden entirely in standalone
|
||||
mode, where siteDomain is '' and typing nothing would trivially
|
||||
satisfy an empty-string match. */}
|
||||
{siteDomain && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 18, padding: 12,
|
||||
border: '1px solid rgba(239,68,68,0.35)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'rgba(239,68,68,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: '#ef4444', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 6 }}>
|
||||
Danger zone
|
||||
</div>
|
||||
|
||||
{!siteResetOpen ? (
|
||||
<button
|
||||
data-action="open-site-reset"
|
||||
onClick={() => setSiteResetOpen(true)}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', fontSize: 11, fontWeight: 600,
|
||||
color: '#ef4444', background: 'transparent',
|
||||
border: '1px solid rgba(239,68,68,0.5)', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reset Entire Site
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<p style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5, margin: '0 0 8px' }}>
|
||||
This blanks <strong>every page</strong>, the header, the footer, all
|
||||
design tokens (colors, fonts, radii), and your custom head code and
|
||||
favicon, leaving one empty Home page. Uploaded images are kept.
|
||||
<strong> This cannot be undone.</strong> Your published site stays as it
|
||||
is until you publish again — but the editor auto-saves roughly every 30
|
||||
seconds, so the blank version becomes your saved draft shortly after.
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: 'var(--color-text-muted)', margin: '0 0 4px' }}>
|
||||
Type <code>{siteDomain}</code> to confirm:
|
||||
</p>
|
||||
<input
|
||||
data-testid="site-reset-domain"
|
||||
value={siteResetTyped}
|
||||
onChange={(e) => setSiteResetTyped(e.target.value)}
|
||||
placeholder={siteDomain}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<button
|
||||
data-action="confirm-site-reset"
|
||||
disabled={!siteResetConfirmMatches(siteResetTyped, siteDomain)}
|
||||
onClick={handleResetSite}
|
||||
style={{
|
||||
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
|
||||
color: '#fff', background: '#ef4444', border: 'none',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
cursor: siteResetConfirmMatches(siteResetTyped, siteDomain) ? 'pointer' : 'not-allowed',
|
||||
opacity: siteResetConfirmMatches(siteResetTyped, siteDomain) ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
Reset everything
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSiteResetOpen(false); setSiteResetTyped(''); }}
|
||||
style={{
|
||||
flex: 1, padding: '7px 10px', fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--color-text-muted)', background: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { CollapsibleSection, ArrayPropEditor, smallInputStyle } from './shared';
|
||||
import { useLayerFocus } from '../../left/LayerFocusContext';
|
||||
|
||||
/* ---------- Shared array-item field editor ----------
|
||||
Extracted from SectionTypePanel and GenericPropsEditor, which both had a
|
||||
@@ -18,7 +19,26 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
|
||||
const sampleItem = arrayItems[0] || {};
|
||||
const itemFields = typeof sampleItem === 'object' && sampleItem !== null ? Object.keys(sampleItem) : [];
|
||||
|
||||
// Layers panel -> array editor "scroll to this item" hookup. The outer
|
||||
// <div ref={rootRef}> wraps ArrayPropEditor's rendered cards (the actual
|
||||
// per-item background box lives in shared.tsx's ArrayPropEditor, which is
|
||||
// also used by MediaStylePanel/FormStylePanel -- rather than touch that
|
||||
// shared component for one consumer, the data-array-item tag below goes
|
||||
// on the content renderItem returns, which is enough for scrollIntoView
|
||||
// to bring the right card into the viewport.
|
||||
const { focus } = useLayerFocus();
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focus || focus.prop !== propKey) return;
|
||||
const card = rootRef.current?.querySelector(`[data-array-item="${propKey}:${focus.index}"]`);
|
||||
card?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
|
||||
// `focus.nonce` is in the dep list so clicking the SAME row twice
|
||||
// re-scrolls (the request object is otherwise identical).
|
||||
}, [focus?.nonce, focus?.prop, focus?.index, propKey]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef}>
|
||||
<CollapsibleSection title={propKey.replace(/([A-Z])/g, ' $1').trim()}>
|
||||
<ArrayPropEditor
|
||||
selectedId={selectedId}
|
||||
@@ -29,6 +49,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
data-array-item={`${propKey}:${index}`}
|
||||
value={String(item)}
|
||||
onChange={(e) => {
|
||||
actions.setProp(selectedId, (props: any) => {
|
||||
@@ -42,7 +63,7 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<div data-array-item={`${propKey}:${index}`} style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{itemFields.map((field) => {
|
||||
const fieldVal = item[field];
|
||||
if (typeof fieldVal === 'boolean') {
|
||||
@@ -123,5 +144,6 @@ export const ArrayItemFieldsEditor: React.FC<{ selectedId: string; propKey: stri
|
||||
}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { labelStyle, inputStyle, sectionGap } from './shared';
|
||||
import { AssetPicker } from '../../../ui/AssetPicker';
|
||||
import { useLayerFocus } from '../../left/LayerFocusContext';
|
||||
|
||||
interface Feature {
|
||||
title?: string; description?: string; icon?: string;
|
||||
@@ -27,10 +28,24 @@ export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }>
|
||||
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
|
||||
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
|
||||
|
||||
// Layers panel -> array editor "scroll to this item" hookup. See
|
||||
// ArrayItemFields.tsx for the generic-editor counterpart; this component
|
||||
// keeps its own per-feature cards, so it tags/scrolls them directly.
|
||||
const { focus } = useLayerFocus();
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focus || focus.prop !== 'features') return;
|
||||
const card = rootRef.current?.querySelector(`[data-array-item="features:${focus.index}"]`);
|
||||
card?.scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
|
||||
// `focus.nonce` is in the dep list so clicking the SAME row twice
|
||||
// re-scrolls (the request object is otherwise identical).
|
||||
}, [focus?.nonce, focus?.prop, focus?.index]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div ref={rootRef} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{list.map((feat, i) => (
|
||||
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div key={i} data-array-item={`features:${i}`} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<input type="text" value={feat.title || ''} onChange={(e) => update(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button onClick={() => remove(i)} title="Remove" style={{ padding: '2px 8px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}>×</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
TEXT_COLORS,
|
||||
BG_COLORS,
|
||||
@@ -17,79 +17,7 @@ import {
|
||||
useNodeProp,
|
||||
} from './shared';
|
||||
import { ArrayItemFieldsEditor } from './ArrayItemFields';
|
||||
import { Modal } from '../../../ui/Modal';
|
||||
import { CodeEditor } from '../../../ui/CodeEditor';
|
||||
|
||||
/* ---------- "Edit HTML" modal for the HtmlBlock `code` prop ----------
|
||||
`code` is raw HTML (potentially many lines, embedded <style>/<script>),
|
||||
so it gets a dedicated syntax-highlighted CodeEditor in a modal instead
|
||||
of falling into the generic single-line/textarea string-prop rendering
|
||||
below (see GenericPropsEditor's SKIP of the `code` key). */
|
||||
const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<CollapsibleSection title="HTML Code">
|
||||
<div style={sectionGap}>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', fontSize: 12, fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46',
|
||||
borderRadius: 6, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-code" /> Edit HTML
|
||||
</button>
|
||||
</div>
|
||||
<Modal open={open} onClose={() => setOpen(false)} width="min(720px, 90vw)">
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Edit HTML</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: 16 }}>
|
||||
<CodeEditor value={value} onChange={onChange} language="html" height={420} />
|
||||
</div>
|
||||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
||||
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
import { HtmlCodeField } from './HtmlCodeField';
|
||||
|
||||
/* ---------- SMART GENERIC PROPS EDITOR (Fallback) ---------- */
|
||||
export const GenericPropsEditor: React.FC<{ selectedId: string; nodeProps: Record<string, any>; typeName: string }> = ({
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { CollapsibleSection, sectionGap } from './shared';
|
||||
import { Modal } from '../../../ui/Modal';
|
||||
import { CodeEditor, type CodeEditorHandle } from '../../../ui/CodeEditor';
|
||||
import { HtmlToolbar } from './HtmlToolbar';
|
||||
import { formatHtml } from '../../../utils/format-html';
|
||||
|
||||
/* "Edit HTML" modal for the HtmlBlock `code` prop. `code` is raw HTML
|
||||
(potentially many lines, embedded <style>/<script>), so it gets a
|
||||
dedicated syntax-highlighted CodeEditor in a modal rather than the
|
||||
generic single-line/textarea string-prop rendering. */
|
||||
export const HtmlCodeField: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const editorRef = useRef<CodeEditorHandle>(null);
|
||||
const handleFormat = (): void => {
|
||||
const current = editorRef.current?.getValue() ?? value;
|
||||
onChange(formatHtml(current));
|
||||
};
|
||||
return (
|
||||
<CollapsibleSection title="HTML Code">
|
||||
<div style={sectionGap}>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
style={{
|
||||
width: '100%', padding: '8px 12px', fontSize: 12, fontWeight: 600,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46',
|
||||
borderRadius: 6, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-code" /> Edit HTML
|
||||
</button>
|
||||
</div>
|
||||
<Modal open={open} onClose={() => setOpen(false)} width="min(720px, 90vw)">
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Edit HTML</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: 16 }}>
|
||||
<HtmlToolbar editorRef={editorRef} onFormat={handleFormat} />
|
||||
<CodeEditor ref={editorRef} value={value} onChange={onChange} language="html" height={420} />
|
||||
</div>
|
||||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
||||
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({ actions: { setProp: vi.fn() }, query: {} }),
|
||||
}));
|
||||
|
||||
import { HtmlStylePanel } from './HtmlStylePanel';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
describe('HtmlStylePanel', () => {
|
||||
test('renders the Edit HTML control', () => {
|
||||
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
|
||||
expect(container.textContent).toContain('Edit HTML');
|
||||
});
|
||||
|
||||
test('renders NO colour or style controls (they never reach the live page)', () => {
|
||||
render(<HtmlStylePanel selectedId="n1" nodeProps={{ code: '<p>x</p>', style: {} }} />);
|
||||
expect(container.querySelector('input[type="color"]')).toBeNull();
|
||||
expect(container.textContent).not.toContain('Background');
|
||||
expect(container.textContent).not.toContain('Text Color');
|
||||
expect(container.textContent).not.toContain('Padding');
|
||||
expect(container.textContent).not.toContain('Border Radius');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { useNodeProp } from './shared';
|
||||
import { HtmlCodeField } from './HtmlCodeField';
|
||||
|
||||
/**
|
||||
* The HTML block's entire style panel.
|
||||
*
|
||||
* Deliberately ONLY the code editor. `HtmlBlock.toHtml()` emits nothing but
|
||||
* the purified `code`, so every colour/padding/alignment control the generic
|
||||
* editor used to offer here was dead on the published page. Styling an HTML
|
||||
* block is done inside the user's own markup.
|
||||
*/
|
||||
export const HtmlStylePanel: React.FC<{ selectedId: string; nodeProps: Record<string, any> }> = ({
|
||||
selectedId,
|
||||
nodeProps,
|
||||
}) => {
|
||||
const { setProp: setPropValue } = useNodeProp(selectedId);
|
||||
return (
|
||||
<>
|
||||
<HtmlCodeField
|
||||
value={typeof nodeProps.code === 'string' ? nodeProps.code : ''}
|
||||
onChange={(v) => setPropValue('code', v)}
|
||||
/>
|
||||
<p style={{ fontSize: 10, color: 'var(--color-text-dim)', lineHeight: 1.4, padding: '0 2px' }}>
|
||||
Style this block inside your own markup — a wrapper set here would show
|
||||
in the editor but not on the published page.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, test, expect, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { HtmlToolbar, SNIPPETS } from './HtmlToolbar';
|
||||
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function fakeHandle() {
|
||||
return { insertAtCursor: vi.fn(), getValue: vi.fn(() => '') } as unknown as CodeEditorHandle;
|
||||
}
|
||||
|
||||
describe('HtmlToolbar', () => {
|
||||
test('every snippet button inserts its snippet with its caret offset', () => {
|
||||
const handle = fakeHandle();
|
||||
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
|
||||
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
|
||||
|
||||
for (const snippet of SNIPPETS) {
|
||||
const btn = container.querySelector(`[data-snippet="${snippet.label}"]`) as HTMLButtonElement;
|
||||
expect(btn, `missing button for ${snippet.label}`).not.toBeNull();
|
||||
act(() => { btn.click(); });
|
||||
expect(handle.insertAtCursor).toHaveBeenCalledWith(snippet.text, snippet.caret);
|
||||
}
|
||||
});
|
||||
|
||||
test('the colour input inserts a style attribute at the caret', () => {
|
||||
const handle = fakeHandle();
|
||||
const ref = { current: handle } as React.RefObject<CodeEditorHandle>;
|
||||
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
|
||||
|
||||
const colour = container.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
colour.value = '#ff8800';
|
||||
act(() => { colour.dispatchEvent(new Event('input', { bubbles: true })); });
|
||||
|
||||
expect(handle.insertAtCursor).toHaveBeenCalledWith(' style="color: #ff8800"');
|
||||
});
|
||||
|
||||
test('Format calls onFormat', () => {
|
||||
const onFormat = vi.fn();
|
||||
const ref = { current: fakeHandle() } as React.RefObject<CodeEditorHandle>;
|
||||
render(<HtmlToolbar editorRef={ref} onFormat={onFormat} />);
|
||||
const btn = container.querySelector('[data-action="format"]') as HTMLButtonElement;
|
||||
act(() => { btn.click(); });
|
||||
expect(onFormat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('a null editor ref is a no-op, not a crash', () => {
|
||||
const ref = { current: null } as React.RefObject<CodeEditorHandle>;
|
||||
render(<HtmlToolbar editorRef={ref} onFormat={vi.fn()} />);
|
||||
const btn = container.querySelector('[data-snippet="div"]') as HTMLButtonElement;
|
||||
expect(() => act(() => { btn.click(); })).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import type { CodeEditorHandle } from '../../../ui/CodeEditor';
|
||||
|
||||
/**
|
||||
* Snippet buttons for the Edit HTML modal. `caret` is the offset from the
|
||||
* start of the inserted text where the caret should land -- i.e. between the
|
||||
* open and close tags, so the next keystroke types content rather than
|
||||
* landing after the closing tag.
|
||||
*/
|
||||
export const SNIPPETS: { label: string; icon: string; title: string; text: string; caret: number }[] = [
|
||||
{ label: 'div', icon: 'fa-square-o', title: 'Insert a div', text: '<div></div>', caret: 5 },
|
||||
{ label: 'section', icon: 'fa-window-maximize', title: 'Insert a section', text: '<section></section>', caret: 9 },
|
||||
{ label: 'h2', icon: 'fa-header', title: 'Insert a heading', text: '<h2></h2>', caret: 4 },
|
||||
{ label: 'p', icon: 'fa-paragraph', title: 'Insert a paragraph', text: '<p></p>', caret: 3 },
|
||||
{ label: 'a', icon: 'fa-link', title: 'Insert a link', text: '<a href="#"></a>', caret: 12 },
|
||||
{ label: 'ul', icon: 'fa-list-ul', title: 'Insert a list', text: '<ul>\n <li></li>\n</ul>', caret: 11 },
|
||||
{ label: 'img', icon: 'fa-image', title: 'Insert an image', text: '<img src="" alt="">', caret: 10 },
|
||||
];
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
minWidth: 28, height: 28, padding: '0 7px',
|
||||
background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 5,
|
||||
fontSize: 11, cursor: 'pointer',
|
||||
};
|
||||
|
||||
export const HtmlToolbar: React.FC<{
|
||||
editorRef: React.RefObject<CodeEditorHandle>;
|
||||
onFormat: () => void;
|
||||
}> = ({ editorRef, onFormat }) => {
|
||||
const insert = (text: string, caret?: number): void => {
|
||||
// The ref is null until CodeEditor mounts; clicking early must no-op.
|
||||
// Only forward a second argument when a caret offset was actually
|
||||
// given -- `insertAtCursor(text, undefined)` is a distinct call from
|
||||
// `insertAtCursor(text)` (an explicit undefined still occupies the
|
||||
// argument list), and the colour control relies on the latter so the
|
||||
// caret lands at the end of the inserted attribute, not mid-string.
|
||||
if (caret === undefined) {
|
||||
editorRef.current?.insertAtCursor(text);
|
||||
} else {
|
||||
editorRef.current?.insertAtCursor(text, caret);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="HTML editing tools"
|
||||
style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 10, alignItems: 'center' }}
|
||||
>
|
||||
{SNIPPETS.map((s) => (
|
||||
<button
|
||||
key={s.label}
|
||||
type="button"
|
||||
data-snippet={s.label}
|
||||
title={s.title}
|
||||
aria-label={s.title}
|
||||
style={btnStyle}
|
||||
onClick={() => insert(s.text, s.caret)}
|
||||
>
|
||||
<i className={`fa ${s.icon}`} aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
<span style={{ width: 1, height: 20, background: '#3f3f46', margin: '0 3px' }} aria-hidden="true" />
|
||||
|
||||
<label
|
||||
title="Insert a colour style attribute at the cursor"
|
||||
style={{ ...btnStyle, padding: 0, overflow: 'hidden', position: 'relative' }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Insert colour"
|
||||
defaultValue="#3b82f6"
|
||||
onInput={(e) => insert(` style="color: ${(e.target as HTMLInputElement).value}"`)}
|
||||
style={{ width: 40, height: 34, border: 'none', background: 'none', cursor: 'pointer', padding: 0 }}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-action="format"
|
||||
title="Re-indent the markup"
|
||||
style={{ ...btnStyle, marginLeft: 'auto', fontWeight: 600, gap: 5 }}
|
||||
onClick={onFormat}
|
||||
>
|
||||
<i className="fa fa-indent" aria-hidden="true" /> Format
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,3 +11,4 @@ export { SectionTypePanel } from './SectionTypePanel';
|
||||
export { PricingStylePanel } from './PricingStylePanel';
|
||||
export { BackgroundSectionStylePanel } from './BackgroundSectionStylePanel';
|
||||
export { GenericPropsEditor } from './GenericPropsEditor';
|
||||
export { HtmlStylePanel } from './HtmlStylePanel';
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: (collect?: (state: any) => any) => {
|
||||
const state = { events: { selected: new Set<string>() }, nodes: {} };
|
||||
return {
|
||||
query: { serialize: () => '{"ROOT":{}}' },
|
||||
actions: {},
|
||||
...(collect ? collect(state) : {}),
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock('../../state/EditorConfigContext', () => ({
|
||||
useEditorConfig: () => ({
|
||||
whpConfig: {
|
||||
apiUrl: '/panel/api/site-builder',
|
||||
csrfToken: 'tok',
|
||||
siteId: 42,
|
||||
siteDomain: 'example.com',
|
||||
},
|
||||
isWHP: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock('../../state/PageContext', () => ({
|
||||
usePages: () => ({
|
||||
activePageId: 'home',
|
||||
pages: [{ id: 'home', name: 'Home', slug: 'index', craftState: null }],
|
||||
}),
|
||||
}));
|
||||
// Mocked (rather than using the real ring buffer) only so the added
|
||||
// oversized-payload test below can force an over-cap consoleErrors array --
|
||||
// every other test gets a plain empty array, same as a fresh page load.
|
||||
vi.mock('../../utils/console-buffer', () => ({ getRecentConsoleErrors: vi.fn() }));
|
||||
|
||||
import { ReportIssueModal } from './ReportIssueModal';
|
||||
import { getRecentConsoleErrors } from '../../utils/console-buffer';
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(open = true) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(<ReportIssueModal open={open} onClose={vi.fn()} device="desktop" />);
|
||||
});
|
||||
}
|
||||
|
||||
// The textarea is a controlled input (`onChange`), so React's DOM value
|
||||
// tracker patches its `value` setter to detect "did this really change".
|
||||
// A plain `ta.value = text` assignment goes through that same patched
|
||||
// setter, which updates the tracker's own record of "current value" as a
|
||||
// side effect -- so by the time the dispatched 'input' event is handled,
|
||||
// the tracker sees no difference and the synthetic onChange never fires.
|
||||
// Bypassing the patched setter via the native prototype descriptor (same
|
||||
// idiom as HeadCodeModal.test.tsx / SiteDesignPanel.reset.test.tsx /
|
||||
// shared-controls.test.tsx / MediaStylePanel.*.test.tsx) sets the DOM value
|
||||
// without touching the tracker, so the dispatched event is correctly seen
|
||||
// as a real change.
|
||||
function typeDescription(text: string) {
|
||||
const ta = document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement;
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')!.set!;
|
||||
setter.call(ta, text);
|
||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const btn = document.querySelector('[data-action="submit-report"]') as HTMLButtonElement;
|
||||
act(() => { btn.click(); });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.innerHTML = '';
|
||||
vi.mocked(getRecentConsoleErrors).mockReturnValue([]);
|
||||
});
|
||||
|
||||
describe('ReportIssueModal', () => {
|
||||
test('submit is disabled until a description is entered', () => {
|
||||
render();
|
||||
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(true);
|
||||
typeDescription('something is wrong');
|
||||
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test('posts the payload to the report_issue action with the CSRF header', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render();
|
||||
typeDescription('colours do nothing');
|
||||
await act(async () => { submit(); });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toContain('action=report_issue');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers['X-CSRF-Token']).toBe('tok');
|
||||
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.description).toBe('colours do nothing');
|
||||
expect(body.category).toBe('bug');
|
||||
expect(body.site_id).toBe(42);
|
||||
expect(body.canvas_state).toBe('{"ROOT":{}}');
|
||||
});
|
||||
|
||||
test('unchecking include-contents omits the canvas state', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, reference: 'SB-2', id: 2 }),
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render();
|
||||
typeDescription('no canvas please');
|
||||
const cb = document.querySelector('[data-testid="report-include-canvas"]') as HTMLInputElement;
|
||||
act(() => { cb.click(); });
|
||||
await act(async () => { submit(); });
|
||||
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.canvas_state).toBeNull();
|
||||
expect(body.canvas_state_omitted).toBe('opt-out');
|
||||
});
|
||||
|
||||
test('shows the returned reference on success', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, reference: 'SB-1234', id: 1234 }),
|
||||
}));
|
||||
render();
|
||||
typeDescription('x');
|
||||
await act(async () => { submit(); });
|
||||
expect(document.body.textContent).toContain('SB-1234');
|
||||
});
|
||||
|
||||
test('keeps the text and shows an error when the request fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ success: false, error: 'Rate limited' }),
|
||||
}));
|
||||
render();
|
||||
typeDescription('keep me');
|
||||
await act(async () => { submit(); });
|
||||
|
||||
expect(document.body.textContent).toContain('Rate limited');
|
||||
expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe('keep me');
|
||||
});
|
||||
|
||||
test('a payload still too large after dropping canvas_state shows an error instead of hanging, and never calls fetch', async () => {
|
||||
// buildReportPayload throws when the body is still over MAX_PAYLOAD_BYTES
|
||||
// even with canvas_state dropped -- see report-payload.ts `finalize()`.
|
||||
// A huge *description* alone can't reach that path (it's truncated to
|
||||
// MAX_DESCRIPTION_CHARS before the size check ever runs -- see
|
||||
// report-payload.test.ts's "description size bounding" suite), so this
|
||||
// forces the same defensive scenario report-payload.test.ts's "honest
|
||||
// size markers" test uses: an oversized consoleErrors array, standing in
|
||||
// for whatever future bug would let that much data through in practice.
|
||||
// The point under test here is purely the modal's reaction to the throw,
|
||||
// not how the oversized condition arises.
|
||||
vi.mocked(getRecentConsoleErrors).mockReturnValue(
|
||||
Array.from({ length: 5000 }, (_, i) => ({ ts: i, message: 'x'.repeat(200) })),
|
||||
);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
render();
|
||||
typeDescription('this report has a huge console-error backlog attached');
|
||||
await act(async () => { submit(); });
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toMatch(/too large/i);
|
||||
// The user's text must survive a rejected submission just as much as a
|
||||
// server-side failure does.
|
||||
expect((document.querySelector('[data-testid="report-description"]') as HTMLTextAreaElement).value).toBe(
|
||||
'this report has a huge console-error backlog attached',
|
||||
);
|
||||
// Not stuck on "Sending..." -- the submit button is usable again.
|
||||
expect((document.querySelector('[data-action="submit-report"]') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import React, { useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { Modal } from '../../ui/Modal';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { buildReportPayload, MAX_DESCRIPTION_CHARS, type ReportCategory } from '../../utils/report-payload';
|
||||
import { getRecentConsoleErrors } from '../../utils/console-buffer';
|
||||
import { editorBuild } from '../../utils/build-stamp';
|
||||
|
||||
export interface ReportIssueModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
device: string;
|
||||
}
|
||||
|
||||
const CATEGORIES: { value: ReportCategory; label: string }[] = [
|
||||
{ value: 'bug', label: 'Something is broken' },
|
||||
{ value: 'confusing', label: 'Something is confusing' },
|
||||
{ value: 'feature', label: 'I wish it could…' },
|
||||
];
|
||||
|
||||
/** Only start showing the running character count once it's actually useful
|
||||
* -- i.e. once the user is close enough to MAX_DESCRIPTION_CHARS that
|
||||
* losing text is a real possibility, not on every keystroke from zero. */
|
||||
const COUNTER_THRESHOLD = MAX_DESCRIPTION_CHARS - 500;
|
||||
|
||||
export const ReportIssueModal: React.FC<ReportIssueModalProps> = ({ open, onClose, device }) => {
|
||||
const { whpConfig } = useEditorConfig();
|
||||
const { activePageId, pages } = usePages();
|
||||
// Guarded with optional chaining: some hosts around this component (e.g.
|
||||
// TopBar's own test harness) stub `useEditor` with a minimal collector
|
||||
// state that has no `events`/`nodes` at all -- this must degrade to "no
|
||||
// selection known" rather than throw and take the whole topbar down.
|
||||
const { query, selectedType } = useEditor((state: any) => {
|
||||
const sel = state?.events?.selected;
|
||||
const id = sel && sel.size > 0 ? (Array.from(sel)[0] as string) : null;
|
||||
return { selectedType: id ? (state?.nodes?.[id]?.data?.displayName ?? null) : null };
|
||||
});
|
||||
|
||||
const [category, setCategory] = useState<ReportCategory>('bug');
|
||||
const [description, setDescription] = useState('');
|
||||
const [includeCanvas, setIncludeCanvas] = useState(true);
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
||||
const [reference, setReference] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const activePage = pages.find((p) => p.id === activePageId);
|
||||
|
||||
const reset = (): void => {
|
||||
setDescription('');
|
||||
setStatus('idle');
|
||||
setReference('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!description.trim() || !whpConfig) return;
|
||||
setStatus('sending');
|
||||
setError('');
|
||||
|
||||
let canvasState: string | null = null;
|
||||
try {
|
||||
canvasState = query.serialize();
|
||||
} catch {
|
||||
// A serialize failure must not block the report -- it is often the
|
||||
// very thing being reported.
|
||||
canvasState = null;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = buildReportPayload({
|
||||
category,
|
||||
description,
|
||||
includeCanvas,
|
||||
siteId: whpConfig.siteId ?? null,
|
||||
siteDomain: whpConfig.siteDomain ?? '',
|
||||
pageId: activePageId,
|
||||
pageSlug: activePage?.slug ?? '',
|
||||
editorVersion: editorBuild(),
|
||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
|
||||
viewport: typeof window !== 'undefined' ? `${window.innerWidth}x${window.innerHeight}` : '',
|
||||
deviceMode: device,
|
||||
selectedType,
|
||||
consoleErrors: getRecentConsoleErrors(),
|
||||
canvasState,
|
||||
});
|
||||
} catch {
|
||||
// buildReportPayload throws (rather than returning an oversized body)
|
||||
// when the payload is still over the cap even after dropping
|
||||
// canvas_state -- e.g. an enormous description. The thrown value is a
|
||||
// plain Error with no discriminator, so treat ANY throw here as "too
|
||||
// large" rather than string-matching the message. The user's text is
|
||||
// left untouched in the textarea (state.description is never reset on
|
||||
// this path) so nothing is lost -- they just need to shorten it or
|
||||
// untick "include this page's contents".
|
||||
setError(
|
||||
'This report is too large to send, even without the page contents. ' +
|
||||
'Try unchecking "Include this page\'s contents" below, or shortening your description.',
|
||||
);
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${whpConfig.apiUrl}?action=report_issue`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': whpConfig.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.success) {
|
||||
setError(data.error || 'Could not send the report. Please try again.');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setReference(data.reference || `SB-${data.id}`);
|
||||
setStatus('sent');
|
||||
} catch (e) {
|
||||
setError('Could not reach the server. Your text is still here — try again.');
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = (): void => {
|
||||
if (status === 'sent') reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<Modal open={open} onClose={handleClose} width="min(560px, 92vw)">
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 12,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '14px 16px', borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-text)' }}>Report an issue</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
style={{
|
||||
width: 28, height: 28, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: '1px solid var(--color-border)', borderRadius: 6,
|
||||
color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === 'sent' ? (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<i className="fa fa-check-circle" style={{ fontSize: 32, color: '#10b981' }} aria-hidden="true" />
|
||||
<p style={{ fontSize: 14, color: 'var(--color-text)', margin: '12px 0 4px' }}>
|
||||
Thanks — that's been sent.
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--color-text-muted)', margin: 0 }}>
|
||||
Your reference is <strong>{reference}</strong>. Quote it if you open a support ticket.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
marginTop: 16, padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
||||
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
|
||||
What kind of issue is it?
|
||||
</label>
|
||||
<select
|
||||
data-testid="report-category"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as ReportCategory)}
|
||||
style={{
|
||||
width: '100%', padding: '6px 8px', fontSize: 12,
|
||||
background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 11, color: 'var(--color-text-muted)', display: 'block', marginBottom: 4 }}>
|
||||
What happened?
|
||||
</label>
|
||||
<textarea
|
||||
data-testid="report-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={MAX_DESCRIPTION_CHARS}
|
||||
placeholder="What were you doing, and what did you expect to happen instead?"
|
||||
style={{
|
||||
width: '100%', padding: '8px 10px', fontSize: 12, lineHeight: 1.5,
|
||||
background: '#27272a', color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46', borderRadius: 4,
|
||||
resize: 'vertical', boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{description.length >= COUNTER_THRESHOLD && (
|
||||
<div
|
||||
data-testid="report-description-count"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: description.length >= MAX_DESCRIPTION_CHARS ? '#fca5a5' : 'var(--color-text-muted)',
|
||||
textAlign: 'right',
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{description.length} / {MAX_DESCRIPTION_CHARS}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
<input
|
||||
data-testid="report-include-canvas"
|
||||
type="checkbox"
|
||||
checked={includeCanvas}
|
||||
onChange={(e) => setIncludeCanvas(e.target.checked)}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
|
||||
Include this page's contents to help debugging. This sends the text and
|
||||
layout of the page you're editing along with your report. Uncheck it and
|
||||
we'll still get your description, the page name, your browser details, and
|
||||
any recent console errors -- but not the page's text or layout.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{status === 'error' && (
|
||||
<div style={{
|
||||
fontSize: 11, color: '#fca5a5', background: 'rgba(239,68,68,0.1)',
|
||||
border: '1px solid rgba(239,68,68,0.35)', borderRadius: 4, padding: '8px 10px',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '10px 16px', borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex', justifyContent: 'flex-end', gap: 8,
|
||||
}}>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
padding: '7px 16px', fontSize: 13,
|
||||
background: 'var(--color-bg-elevated)', color: 'var(--color-text-muted)',
|
||||
border: '1px solid var(--color-border)', borderRadius: 6, cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
data-action="submit-report"
|
||||
disabled={!description.trim() || status === 'sending'}
|
||||
onClick={handleSubmit}
|
||||
style={{
|
||||
padding: '7px 20px', fontSize: 13, fontWeight: 600,
|
||||
background: 'var(--color-accent)', color: '#fff', border: 'none', borderRadius: 6,
|
||||
cursor: description.trim() && status !== 'sending' ? 'pointer' : 'not-allowed',
|
||||
opacity: description.trim() && status !== 'sending' ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{status === 'sending' ? 'Sending…' : 'Send report'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { useMobileChrome } from '../../state/MobileChromeContext';
|
||||
import { DeviceMode } from '../../types';
|
||||
import { TemplateModal } from './TemplateModal';
|
||||
import { HeadCodeModal } from './HeadCodeModal';
|
||||
import { ReportIssueModal } from './ReportIssueModal';
|
||||
import { TopBarOverflowMenu } from './TopBarOverflowMenu';
|
||||
import { PublishWarnings } from './PublishWarnings';
|
||||
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||
@@ -40,6 +41,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
// opening a mobile sheet can close these modals (item 3) -- behavior is
|
||||
// otherwise identical for both the desktop and mobile branches below.
|
||||
const { templateModalOpen, setTemplateModalOpen, headCodeModalOpen, setHeadCodeModalOpen, overflowOpen, setOverflowOpen } = useMobileChrome();
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const { open: openSitesmith } = useSitesmithModal();
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -303,12 +305,14 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
onToggleGuides={onToggleGuides}
|
||||
onOpenTemplates={() => setTemplateModalOpen(true)}
|
||||
onOpenHeadCode={() => setHeadCodeModalOpen(true)}
|
||||
onOpenReportIssue={() => setReportOpen(true)}
|
||||
onPreview={handlePreview}
|
||||
sitesmithNode={<SitesmithButton onClick={() => openSitesmith()} />}
|
||||
/>
|
||||
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -377,6 +381,15 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
<button className="topbar-btn icon-only" aria-label="Preview" data-tooltip="Preview" onClick={handlePreview}>
|
||||
<i className="fa fa-eye" />
|
||||
</button>
|
||||
<button
|
||||
className="topbar-btn icon-only"
|
||||
aria-label="Report an issue"
|
||||
data-tooltip="Report an issue"
|
||||
title="Report an issue"
|
||||
onClick={() => setReportOpen(true)}
|
||||
>
|
||||
<i className="fa fa-bug" />
|
||||
</button>
|
||||
|
||||
{/* Draft/Published status badge */}
|
||||
{isWHP && isDraft && publishStatus !== 'published' && (
|
||||
@@ -441,6 +454,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange, showGuid
|
||||
</div>
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||
<ReportIssueModal open={reportOpen} onClose={() => setReportOpen(false)} device={device} />
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface TopBarOverflowMenuProps {
|
||||
onToggleGuides: () => void;
|
||||
onOpenTemplates: () => void;
|
||||
onOpenHeadCode: () => void;
|
||||
onOpenReportIssue: () => void;
|
||||
onPreview: () => void;
|
||||
/** Rendered `<SitesmithButton onClick={...} />` -- passed in rather than
|
||||
* re-implemented here so the mobile menu reuses the exact same
|
||||
@@ -33,6 +34,7 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
||||
onToggleGuides,
|
||||
onOpenTemplates,
|
||||
onOpenHeadCode,
|
||||
onOpenReportIssue,
|
||||
onPreview,
|
||||
sitesmithNode,
|
||||
}) => {
|
||||
@@ -91,6 +93,9 @@ export const TopBarOverflowMenu: React.FC<TopBarOverflowMenuProps> = ({
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenHeadCode)}>
|
||||
<i className="fa fa-code" aria-hidden="true" /> Head Code
|
||||
</button>
|
||||
<button type="button" className="topbar-overflow-item" role="menuitem" onClick={runAndClose(onOpenReportIssue)}>
|
||||
<i className="fa fa-bug" aria-hidden="true" /> Report an issue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`topbar-overflow-item${showGuides ? ' active' : ''}`}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { PageProvider, usePages } from './PageContext';
|
||||
|
||||
/**
|
||||
* Task 8 review finding: `PageContext.orphan-repair.test.ts` (the brief's
|
||||
* prescribed test) drives `renderEditorHarness()` + `repairOrphanNodes()`
|
||||
* directly -- it never mounts `PageProvider`, so nothing in it actually
|
||||
* exercises `loadState`. If the `repairOrphanNodes` call were deleted from
|
||||
* `loadState` outright, that file would still pass in full.
|
||||
*
|
||||
* This file closes that gap: it mounts a real `PageProvider` (same
|
||||
* `vi.mock('@craftjs/core', ...)` + `deserializeMock` pattern as
|
||||
* `PageContext.pages-productivity.test.tsx`) and drives `switchPage` --
|
||||
* `loadState`'s only reachable-from-the-UI caller for an already-stored
|
||||
* page -- against a page whose stored `craftState` contains a node with no
|
||||
* path back to ROOT. It asserts on what `loadState` actually handed to
|
||||
* `actions.deserialize` (mocked here, same as the sibling suite) rather than
|
||||
* on Craft.js's own reconciliation, which `PageContext.orphan-repair.test.ts`
|
||||
* already covers via the real editor.
|
||||
*/
|
||||
|
||||
let serializeReturn = '{}';
|
||||
const deserializeMock = vi.fn();
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({
|
||||
query: { serialize: () => serializeReturn },
|
||||
actions: { deserialize: deserializeMock },
|
||||
}),
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
function render(ui: React.ReactElement) {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
act(() => {
|
||||
root = createRoot(container);
|
||||
root.render(ui);
|
||||
});
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
|
||||
async function flushTimers() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
serializeReturn = '{}';
|
||||
deserializeMock.mockClear();
|
||||
});
|
||||
|
||||
/** A ROOT with no children plus an orphan ('stray') whose `parent` points at
|
||||
* an id that doesn't exist in the tree, and which no node's `nodes`/
|
||||
* `linkedNodes` lists -- unreachable by BFS from ROOT. */
|
||||
const ORPHAN_STATE = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { style: {}, tag: 'div' },
|
||||
displayName: 'Container',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
|
||||
},
|
||||
stray: {
|
||||
type: { resolvedName: 'HtmlBlock' },
|
||||
isCanvas: false,
|
||||
props: { code: '<p>stranded</p>', style: {} },
|
||||
displayName: 'HTML',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||
},
|
||||
});
|
||||
|
||||
describe('loadState (via switchPage) repairs an orphaned node before handing it to Craft', () => {
|
||||
test('switching to a page whose stored state has an orphan reattaches it and warns', async () => {
|
||||
let ctx: ReturnType<typeof usePages> | null = null;
|
||||
const Consumer: React.FC = () => {
|
||||
ctx = usePages();
|
||||
return null;
|
||||
};
|
||||
render(
|
||||
<PageProvider>
|
||||
<Consumer />
|
||||
</PageProvider>,
|
||||
);
|
||||
|
||||
// pages: [Home]. Add "About" -- addPage switches the live canvas to it.
|
||||
act(() => ctx!.addPage('About', 'about'));
|
||||
await flushTimers();
|
||||
const aboutId = ctx!.pages[1].id;
|
||||
|
||||
// Switch back to Home so About is no longer the active page -- switching
|
||||
// TO an already-active page is a documented no-op in `switchPage`, and
|
||||
// this test needs a real switch-INTO event to fire `loadState`.
|
||||
act(() => ctx!.switchPage('home'));
|
||||
await flushTimers();
|
||||
|
||||
// Seed About's STORED craftState directly with the orphaned tree, the
|
||||
// same way a loaded project's saved state reaches PageContext (e.g. via
|
||||
// `setPagesCraftState` from `useWhpApi`'s `load()`), bypassing the live
|
||||
// canvas entirely so nothing but `loadState` itself can repair it.
|
||||
act(() =>
|
||||
ctx!.setPagesCraftState(
|
||||
ctx!.pages.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
slug: p.slug,
|
||||
craftState: p.id === aboutId ? ORPHAN_STATE : p.craftState,
|
||||
seo: p.seo,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
// I5 (review): the orphan-repair log must be console.error, not
|
||||
// console.warn -- console-buffer.ts (feeding the in-builder issue
|
||||
// reporter) only patches console.error, and this reattach signal is the
|
||||
// single most diagnostic clue for the still-unreproduced "elements drop
|
||||
// off the canvas" report.
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
deserializeMock.mockClear();
|
||||
|
||||
// The real switch-into-a-stored-page path.
|
||||
act(() => ctx!.switchPage(aboutId));
|
||||
await flushTimers();
|
||||
|
||||
expect(deserializeMock).toHaveBeenCalled();
|
||||
const passedState = deserializeMock.mock.calls[deserializeMock.mock.calls.length - 1][0];
|
||||
const parsed = JSON.parse(passedState);
|
||||
// The orphan is now an ordinary, reachable child of ROOT.
|
||||
expect(parsed.ROOT.nodes).toContain('stray');
|
||||
expect(parsed.stray.parent).toBe('ROOT');
|
||||
|
||||
// The observable signal that repair actually ran, not just that the
|
||||
// orphan happened to be absent for some unrelated reason. (React's own
|
||||
// act()-environment warnings also go through console.error in this
|
||||
// harness, so search all calls rather than assuming index 0.)
|
||||
expect(errorSpy.mock.calls.some((call) => String(call[0]).includes('reattached'))).toBe(true);
|
||||
|
||||
errorSpy.mockRestore();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { renderEditorHarness } from '../test-utils/editorHarness';
|
||||
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||
import { EMPTY_CANVAS } from './PageContext';
|
||||
|
||||
describe('EMPTY_CANVAS is exported and loadable', () => {
|
||||
test('deserializing EMPTY_CANVAS gives a ROOT with no children', () => {
|
||||
const harness = renderEditorHarness({ initialState: EMPTY_CANVAS });
|
||||
const nodes = JSON.parse(harness.getSerialized());
|
||||
expect(nodes.ROOT.nodes).toEqual([]);
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('repaired state survives a real Craft deserialize', () => {
|
||||
test('an orphaned HTML block becomes a selectable child of ROOT', () => {
|
||||
const broken = JSON.stringify({
|
||||
ROOT: {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: true,
|
||||
props: { style: {}, tag: 'div' },
|
||||
displayName: 'Container',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: null,
|
||||
},
|
||||
stray: {
|
||||
type: { resolvedName: 'HtmlBlock' },
|
||||
isCanvas: false,
|
||||
props: { code: '<p>stranded</p>', style: {} },
|
||||
displayName: 'HTML',
|
||||
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ghost',
|
||||
},
|
||||
});
|
||||
|
||||
const { state, repaired } = repairOrphanNodes(broken);
|
||||
expect(repaired).toEqual(['stray']);
|
||||
|
||||
const harness = renderEditorHarness({ initialState: state });
|
||||
const nodes = JSON.parse(harness.getSerialized());
|
||||
expect(nodes.ROOT.nodes).toContain('stray');
|
||||
expect(harness.container.textContent).toContain('stranded');
|
||||
harness.unmount();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { PageData, PageSeo } from '../types';
|
||||
import { SerializedTreeNode } from '../types/sitesmith';
|
||||
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
||||
import { sanitizeAiTree, flattenTreeForCraft, FlatCraftNode } from '../utils/craft-tree';
|
||||
import { repairOrphanNodes } from '../utils/orphan-repair';
|
||||
|
||||
interface PageContextValue {
|
||||
pages: PageData[];
|
||||
@@ -81,7 +82,7 @@ export function nextPageId(): string {
|
||||
return 'page_' + Date.now().toString(36) + '_' + (++pageIdCounter).toString(36);
|
||||
}
|
||||
|
||||
const EMPTY_CANVAS =
|
||||
export const EMPTY_CANVAS =
|
||||
'{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"100vh","backgroundColor":"#ffffff"},"tag":"div"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}';
|
||||
|
||||
const EMPTY_HEADER =
|
||||
@@ -368,15 +369,46 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
/** Load a craft state into the Frame */
|
||||
/** Load a craft state into the Frame.
|
||||
*
|
||||
* Every state goes through `repairOrphanNodes` first: a node present in
|
||||
* the serialized state but not reachable from ROOT via `nodes`/
|
||||
* `linkedNodes` is never instantiated by Craft.js's `<Frame>` at all --
|
||||
* it doesn't render, so it isn't merely unselectable, it's invisible and
|
||||
* otherwise unrecoverable. Reattaching it to the end of ROOT makes it an
|
||||
* ordinary child the user can see, select and delete. Cheap (single JSON
|
||||
* round-trip) and a no-op -- returning the identical string -- for the
|
||||
* overwhelmingly common healthy case.
|
||||
*
|
||||
* Note this orphan-node repair is a different mechanism from the
|
||||
* originally-reported symptom (a *visible* element on the canvas that
|
||||
* can't be selected or deleted) -- that report is still unreproduced;
|
||||
* see `orphan-repair.ts` for detail. */
|
||||
const loadState = useCallback(
|
||||
(craftState: string | null, fallback: string) => {
|
||||
setTimeout(() => {
|
||||
const source = craftState || fallback;
|
||||
const { state, repaired } = repairOrphanNodes(source);
|
||||
if (repaired.length > 0) {
|
||||
// I5: console-buffer.ts only patches console.error, and this
|
||||
// reattach signal is the single most diagnostic clue for the
|
||||
// still-unreproduced "elements drop off the canvas" report -- it
|
||||
// must reach the in-builder issue reporter's console buffer.
|
||||
console.error(
|
||||
`[site-builder] reattached ${repaired.length} unreachable node(s) to the page root:`,
|
||||
repaired.join(', '),
|
||||
);
|
||||
}
|
||||
try {
|
||||
actions.deserialize(craftState || fallback);
|
||||
actions.deserialize(state);
|
||||
} catch (e) {
|
||||
console.error('Failed to deserialize state:', e);
|
||||
try {
|
||||
// NOT run through repairOrphanNodes: `fallback` must always be
|
||||
// one of the module's own known-safe constants (EMPTY_CANVAS /
|
||||
// EMPTY_HEADER / EMPTY_FOOTER -- true at all current call sites),
|
||||
// never untrusted/stored data, since this is the last line of
|
||||
// defense before giving up silently below.
|
||||
actions.deserialize(fallback);
|
||||
} catch (_e2) {
|
||||
// give up
|
||||
|
||||
@@ -1919,3 +1919,13 @@ body {
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Layers: display-only rows derived from a composite's array props
|
||||
(Features Grid features, Tabs tabs, ...). Dimmer than real node rows and
|
||||
with no disclosure column, so they don't read as draggable Craft nodes.
|
||||
-------------------------------------------------------------------------- */
|
||||
.layer-virtual-row:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import React from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { CodeEditor } from './CodeEditor';
|
||||
import { CodeEditor, type CodeEditorHandle } from './CodeEditor';
|
||||
|
||||
/* ---------- DOM test harness (no @testing-library/react in this repo; see
|
||||
src/ui/AssetPicker.test.tsx / src/ui/Modal.test.tsx for the same
|
||||
@@ -88,3 +88,106 @@ describe('CodeEditor', () => {
|
||||
expect((container.firstElementChild as HTMLElement).style.height).toBe('480px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodeEditor imperative handle (textarea fallback mode)', () => {
|
||||
test('insertAtCursor replaces the selection and emits onChange', async () => {
|
||||
const onChange = vi.fn();
|
||||
const ref = React.createRef<CodeEditorHandle>();
|
||||
render(<CodeEditor ref={ref} value="<div></div>" onChange={onChange} />);
|
||||
|
||||
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
|
||||
expect(ta).not.toBeNull();
|
||||
ta.selectionStart = 5;
|
||||
ta.selectionEnd = 5;
|
||||
|
||||
act(() => {
|
||||
ref.current!.insertAtCursor('<p></p>');
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('<div><p></p></div>');
|
||||
});
|
||||
|
||||
test('getValue returns the current document', () => {
|
||||
const ref = React.createRef<CodeEditorHandle>();
|
||||
render(<CodeEditor ref={ref} value="<span>x</span>" onChange={vi.fn()} />);
|
||||
expect(ref.current!.getValue()).toBe('<span>x</span>');
|
||||
});
|
||||
|
||||
test('caretOffset positions the caret inside the inserted snippet', () => {
|
||||
const ref = React.createRef<CodeEditorHandle>();
|
||||
render(<CodeEditor ref={ref} value="" onChange={vi.fn()} />);
|
||||
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
|
||||
act(() => {
|
||||
ref.current!.insertAtCursor('<p></p>', 3);
|
||||
});
|
||||
expect(ta.selectionStart).toBe(3);
|
||||
});
|
||||
|
||||
test('insertAtCursor replaces a non-empty selection (not just an empty caret)', () => {
|
||||
const onChange = vi.fn();
|
||||
const ref = React.createRef<CodeEditorHandle>();
|
||||
render(<CodeEditor ref={ref} value="<div>old</div>" onChange={onChange} />);
|
||||
|
||||
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
|
||||
// "<div>old</div>" -- select "old" (indices 5-8).
|
||||
ta.selectionStart = 5;
|
||||
ta.selectionEnd = 8;
|
||||
|
||||
act(() => {
|
||||
ref.current!.insertAtCursor('new');
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('<div>new</div>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodeEditor imperative handle: fallback insert survives a later CodeMirror mount', () => {
|
||||
// Regression test for a data-loss bug: insertAtCursor's textarea-fallback
|
||||
// branch used to advance lastEmittedRef to the post-insertion value. The
|
||||
// mount effect's dynamic import() closes over `value` as of the initial
|
||||
// render, so if CodeMirror finishes loading *after* a fallback-mode
|
||||
// insertion, it mounts with the pre-insertion doc. The only thing that
|
||||
// repairs that is the value-sync effect, which is gated on `value !==
|
||||
// lastEmittedRef.current` -- advancing lastEmittedRef made that gate see
|
||||
// them as already equal and skip the repair, silently dropping the
|
||||
// insertion. This test drives the component through that exact sequence
|
||||
// using the real @codemirror/* packages (no mocks, no fake timers) to
|
||||
// prove the fix holds.
|
||||
test('insertAtCursor in fallback mode is not lost once CodeMirror mounts', async () => {
|
||||
const handleRef = React.createRef<CodeEditorHandle>();
|
||||
function Harness() {
|
||||
const [value, setValue] = React.useState('<div></div>');
|
||||
return <CodeEditor ref={handleRef} value={value} onChange={setValue} />;
|
||||
}
|
||||
render(<Harness />);
|
||||
|
||||
// First tick: the dynamic import() chain is always async, so this is
|
||||
// still the textarea fallback (see the file-level comment above).
|
||||
const ta = container.querySelector('[data-testid="code-editor-fallback"]') as HTMLTextAreaElement;
|
||||
expect(ta).not.toBeNull();
|
||||
ta.selectionStart = 5;
|
||||
ta.selectionEnd = 5;
|
||||
act(() => {
|
||||
handleRef.current!.insertAtCursor('<p></p>');
|
||||
});
|
||||
|
||||
// Let the real dynamic import() of @codemirror/* actually resolve and
|
||||
// the view mount (real elapsed time, not mocked/faked).
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => { setTimeout(resolve, 50); });
|
||||
});
|
||||
}
|
||||
|
||||
// Confirm CodeMirror actually mounted (fallback textarea gone, replaced
|
||||
// by the CodeMirror root) -- otherwise this assertion would trivially
|
||||
// pass by reading back the fallback textarea's own value and wouldn't
|
||||
// exercise the bug at all.
|
||||
expect(container.querySelector('[data-testid="code-editor-fallback"]')).toBeNull();
|
||||
|
||||
// The value-sync effect must have pushed the post-insertion value into
|
||||
// the freshly-mounted doc -- the insertion must not have been dropped.
|
||||
expect(handleRef.current!.getValue()).toBe('<div><p></p></div>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||
import type { EditorView as EditorViewType } from '@codemirror/view';
|
||||
|
||||
export type CodeEditorLanguage = 'html' | 'css' | 'javascript' | 'auto';
|
||||
@@ -14,6 +14,15 @@ export interface CodeEditorProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface CodeEditorHandle {
|
||||
/** Replace the current selection (or insert at the caret) with `text`.
|
||||
* Emits onChange. If `caretOffset` is given, the caret lands that many
|
||||
* characters after the insertion start instead of at its end. */
|
||||
insertAtCursor(text: string, caretOffset?: number): void;
|
||||
/** Current document text. */
|
||||
getValue(): string;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------
|
||||
Lazy-loaded CodeMirror 6.
|
||||
|
||||
@@ -100,6 +109,15 @@ function loadCodeMirror(): Promise<CmModules> {
|
||||
return cmModulesPromise;
|
||||
}
|
||||
|
||||
// Guards against a caller passing a `caretOffset` outside [0, text.length]
|
||||
// (e.g. a stale offset computed against different snippet text), which
|
||||
// would otherwise let `insertAtCursor` compute a caret position past the
|
||||
// text it just inserted.
|
||||
function clampCaretOffset(caretOffset: number | undefined, textLength: number): number {
|
||||
if (caretOffset === undefined) return textLength;
|
||||
return Math.min(Math.max(caretOffset, 0), textLength);
|
||||
}
|
||||
|
||||
function languageExtension(mods: CmModules, language: CodeEditorLanguage) {
|
||||
switch (language) {
|
||||
case 'css':
|
||||
@@ -133,15 +151,16 @@ const fallbackStyle: React.CSSProperties = {
|
||||
boxSizing: 'border-box',
|
||||
};
|
||||
|
||||
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
language = 'html',
|
||||
height = 320,
|
||||
placeholder,
|
||||
}) => {
|
||||
}: CodeEditorProps, ref) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorViewType | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
// Tracks the last value this component itself emitted, so the
|
||||
@@ -226,6 +245,51 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
lastEmittedRef.current = value;
|
||||
}, [value, status]);
|
||||
|
||||
useImperativeHandle(ref, (): CodeEditorHandle => ({
|
||||
getValue: () => {
|
||||
const view = viewRef.current;
|
||||
if (view) return view.state.doc.toString();
|
||||
return textareaRef.current?.value ?? value;
|
||||
},
|
||||
insertAtCursor: (text: string, caretOffset?: number) => {
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
const caret = from + clampCaretOffset(caretOffset, text.length);
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: text },
|
||||
selection: { anchor: caret },
|
||||
});
|
||||
view.focus();
|
||||
return;
|
||||
}
|
||||
// Textarea fallback: CodeMirror never mounted yet (still loading, or
|
||||
// its lazy chunks failed). The toolbar must keep working either way.
|
||||
//
|
||||
// Deliberately do NOT touch lastEmittedRef here -- same as the plain
|
||||
// textarea onChange handler below, which never touches it either.
|
||||
// The mount effect's dynamic import() closes over `value` at the time
|
||||
// it started, so CodeMirror can finish loading with a stale doc if it
|
||||
// resolves after this insertion. The only thing that catches that is
|
||||
// the value-sync effect above, which is gated on `value !==
|
||||
// lastEmittedRef.current`. If this insertion advanced lastEmittedRef
|
||||
// to `next`, that effect would see value === lastEmittedRef.current
|
||||
// once the parent re-renders and silently skip pushing the insertion
|
||||
// into the freshly-mounted (stale) doc -- losing it for good.
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const from = ta.selectionStart ?? ta.value.length;
|
||||
const to = ta.selectionEnd ?? from;
|
||||
const next = ta.value.slice(0, from) + text + ta.value.slice(to);
|
||||
const caret = from + clampCaretOffset(caretOffset, text.length);
|
||||
onChangeRef.current(next);
|
||||
ta.value = next;
|
||||
ta.selectionStart = caret;
|
||||
ta.selectionEnd = caret;
|
||||
ta.focus();
|
||||
},
|
||||
}), [value]);
|
||||
|
||||
const showFallback = status !== 'ready';
|
||||
|
||||
return (
|
||||
@@ -243,6 +307,7 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
/>
|
||||
{showFallback && (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
data-testid="code-editor-fallback"
|
||||
data-language={language}
|
||||
value={value}
|
||||
@@ -254,4 +319,4 @@ export const CodeEditor: React.FC<CodeEditorProps> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { editorBuild } from './build-stamp';
|
||||
|
||||
describe('editorBuild', () => {
|
||||
it("returns 'dev' when __EDITOR_BUILD__ is undefined (vitest does not apply Vite's define)", () => {
|
||||
expect(editorBuild()).toBe('dev');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Reads the compile-time `__EDITOR_BUILD__` define.
|
||||
*
|
||||
* vitest does not apply Vite's `define`, and `npm run dev` in a non-git
|
||||
* checkout may not either, so every read goes through here. `typeof` on an
|
||||
* undeclared identifier is safe in JS -- it does not throw.
|
||||
*/
|
||||
export function editorBuild(): string {
|
||||
return typeof __EDITOR_BUILD__ !== 'undefined' ? __EDITOR_BUILD__ : 'dev';
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, test, expect, vi, afterEach } from 'vitest';
|
||||
import {
|
||||
installConsoleErrorBuffer,
|
||||
getRecentConsoleErrors,
|
||||
__resetConsoleErrorBuffer,
|
||||
} from './console-buffer';
|
||||
|
||||
afterEach(() => {
|
||||
__resetConsoleErrorBuffer();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('console error buffer', () => {
|
||||
test('captures console.error calls', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
installConsoleErrorBuffer();
|
||||
console.error('boom', 42);
|
||||
const entries = getRecentConsoleErrors();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].message).toBe('boom 42');
|
||||
expect(typeof entries[0].ts).toBe('number');
|
||||
});
|
||||
|
||||
test('always chains to the original console.error', () => {
|
||||
const original = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
installConsoleErrorBuffer();
|
||||
console.error('passed through');
|
||||
expect(original).toHaveBeenCalledWith('passed through');
|
||||
});
|
||||
|
||||
test('retains only the last 20 entries, oldest first', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
installConsoleErrorBuffer();
|
||||
for (let i = 0; i < 25; i++) console.error(`e${i}`);
|
||||
const entries = getRecentConsoleErrors();
|
||||
expect(entries).toHaveLength(20);
|
||||
expect(entries[0].message).toBe('e5');
|
||||
expect(entries[19].message).toBe('e24');
|
||||
});
|
||||
|
||||
test('truncates a long message to 500 characters', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
installConsoleErrorBuffer();
|
||||
console.error('x'.repeat(900));
|
||||
expect(getRecentConsoleErrors()[0].message).toHaveLength(500);
|
||||
});
|
||||
|
||||
test('installing twice does not double-capture', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
installConsoleErrorBuffer();
|
||||
installConsoleErrorBuffer();
|
||||
console.error('once');
|
||||
expect(getRecentConsoleErrors()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('captures window error events', () => {
|
||||
installConsoleErrorBuffer();
|
||||
window.dispatchEvent(new ErrorEvent('error', { message: 'window blew up' }));
|
||||
expect(getRecentConsoleErrors().some((e) => e.message.includes('window blew up'))).toBe(true);
|
||||
});
|
||||
|
||||
test('getRecentConsoleErrors returns a copy, not the live array', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
installConsoleErrorBuffer();
|
||||
console.error('a');
|
||||
const first = getRecentConsoleErrors();
|
||||
first.push({ ts: 0, message: 'injected' });
|
||||
expect(getRecentConsoleErrors()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('reinstalling after external code wraps console.error does not double-record, and reset restores the true original', () => {
|
||||
const trueOriginal = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
installConsoleErrorBuffer();
|
||||
|
||||
// External code wraps our patch without knowing anything about our
|
||||
// marker convention -- this is exactly what a third-party script or
|
||||
// another monitor might do.
|
||||
const ourPatch = console.error;
|
||||
const external = vi.fn((...args: unknown[]) => {
|
||||
ourPatch(...args);
|
||||
});
|
||||
console.error = external;
|
||||
|
||||
installConsoleErrorBuffer();
|
||||
console.error('dup-check');
|
||||
|
||||
expect(getRecentConsoleErrors().filter((e) => e.message === 'dup-check')).toHaveLength(1);
|
||||
|
||||
__resetConsoleErrorBuffer();
|
||||
expect(console.error).toBe(trueOriginal);
|
||||
});
|
||||
|
||||
test('surviving module re-execution: reinstalling after the module reloads does not re-wrap an already-patched console.error', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const mod1 = await import('./console-buffer');
|
||||
mod1.installConsoleErrorBuffer();
|
||||
const patchedAfterFirstInstall = console.error;
|
||||
|
||||
// Simulate HMR: the module graph re-evaluates, producing a fresh module
|
||||
// instance with its own reset top-level state, while the *global*
|
||||
// console.error is still whatever the previous instance patched it to.
|
||||
vi.resetModules();
|
||||
const mod2 = await import('./console-buffer');
|
||||
mod2.installConsoleErrorBuffer();
|
||||
|
||||
// The marker on the live console.error -- not module-local state -- is
|
||||
// what installConsoleErrorBuffer() consults, so the reloaded instance
|
||||
// must recognize the existing patch and leave it alone rather than
|
||||
// wrapping it a second time.
|
||||
expect(console.error).toBe(patchedAfterFirstInstall);
|
||||
|
||||
console.error('once-across-reload');
|
||||
expect(
|
||||
mod1.getRecentConsoleErrors().filter((e) => e.message === 'once-across-reload')
|
||||
).toHaveLength(1);
|
||||
|
||||
mod1.__resetConsoleErrorBuffer();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* A tiny ring buffer of the most recent errors, attached to the issue
|
||||
* reports users file from inside the builder. Without it a report says
|
||||
* "it broke" and nothing else.
|
||||
*
|
||||
* Message text only -- no stack traces. Production stacks are minified into
|
||||
* uselessness and leak bundle paths for no diagnostic gain.
|
||||
*
|
||||
* The console.error patch ALWAYS chains to the original. A monitor that
|
||||
* swallows diagnostics is worse than no monitor.
|
||||
*
|
||||
* Idempotency and the "true original":
|
||||
*
|
||||
* `installConsoleErrorBuffer()` treats console.error's *live* MARKER stamp
|
||||
* (not module-scoped state) as the source of truth for "am I already the
|
||||
* active patch". That alone survives React 18 StrictMode / hot module
|
||||
* reload re-running this module's top level while the previous instance's
|
||||
* patch is still installed on the global -- a plain `let installed = false`
|
||||
* would reset on re-execution and treat that already-patched function as
|
||||
* virgin, wrapping it again and building a chain that grows on every
|
||||
* reload.
|
||||
*
|
||||
* The marker alone isn't sufficient once something *other* than us has
|
||||
* touched console.error since our last install, though. If external code
|
||||
* wraps our patch (`console.error = L` where L internally calls our patched
|
||||
* function), console.error is unmarked again from our point of view, so a
|
||||
* naive "unmarked == virgin" re-install would capture L -- an intermediate
|
||||
* wrapper, not the true original -- as "the original" to chain to. That
|
||||
* would (a) leave our OLD patch still reachable inside L's closure, so one
|
||||
* console.error() call records twice (once via the new patch, once via the
|
||||
* old one still buried inside L), and (b) make __resetConsoleErrorBuffer()
|
||||
* restore console.error to L instead of the real original, permanently
|
||||
* losing the reference to it.
|
||||
*
|
||||
* The fix is to never re-derive "the original" from whatever the live
|
||||
* console.error happens to be at install time. Instead, the true original
|
||||
* is captured exactly once and stashed as a hidden property directly on the
|
||||
* `console` object (not in module scope, so it also survives module
|
||||
* re-execution) the first time we ever patch. Every subsequent install,
|
||||
* whether triggered by our own idempotent re-install, HMR, or a reinstall
|
||||
* after external code has wrapped or replaced console.error, reuses that
|
||||
* stashed reference and re-wraps it directly -- guaranteeing exactly one
|
||||
* patch layer chains straight to the real original, and that reset can
|
||||
* always find it.
|
||||
*
|
||||
* Trade-off this implies: if install() is called again after some external
|
||||
* code has wrapped console.error, that external wrapper is discarded (we
|
||||
* re-wrap the true original directly, not the external wrapper) rather than
|
||||
* preserved. We accept that over the alternative of chaining through an
|
||||
* unknown wrapper, which cannot be done safely -- there is no way to detect
|
||||
* whether that wrapper still calls through to our old patch (risking double
|
||||
* recording if we also wrap it) or has fully replaced it (risking losing
|
||||
* capture entirely if we don't). An external wrapper installed *after* us
|
||||
* and left alone (i.e. install() is not called again) is completely
|
||||
* unaffected -- it just sits on top of our patch and both continue to work
|
||||
* as normal JS monkey-patch layering.
|
||||
*
|
||||
* Known residual gap (not fixed, documented instead): if this module is hot
|
||||
* reloaded while console.error stays patched from the previous instance,
|
||||
* the live patch's closure still points at the *previous* module
|
||||
* instance's `buffer` array. The marker check correctly stops the new
|
||||
* instance from re-wrapping, but that also means the new instance's own
|
||||
* `getRecentConsoleErrors()` reads its own (empty) buffer forever while
|
||||
* capture silently continues into the orphaned previous instance's buffer.
|
||||
* This only matters across an actual HMR reload during development --
|
||||
* production has exactly one module instance for the lifetime of the page.
|
||||
*/
|
||||
|
||||
export interface ConsoleErrorEntry {
|
||||
ts: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 20;
|
||||
const MAX_MESSAGE = 500;
|
||||
const MARKER = '__whpConsoleErrorBufferPatched';
|
||||
const TRUE_ORIGINAL_KEY = '__whpConsoleErrorBufferTrueOriginal';
|
||||
|
||||
type MarkedConsoleError = typeof console.error & { [MARKER]?: true };
|
||||
type ConsoleWithStash = typeof console & { [TRUE_ORIGINAL_KEY]?: typeof console.error };
|
||||
|
||||
function isPatched(fn: typeof console.error): fn is MarkedConsoleError {
|
||||
return typeof fn === 'function' && (fn as MarkedConsoleError)[MARKER] === true;
|
||||
}
|
||||
|
||||
function getTrueOriginal(): typeof console.error | undefined {
|
||||
return (console as ConsoleWithStash)[TRUE_ORIGINAL_KEY];
|
||||
}
|
||||
|
||||
function setTrueOriginal(fn: typeof console.error): void {
|
||||
(console as ConsoleWithStash)[TRUE_ORIGINAL_KEY] = fn;
|
||||
}
|
||||
|
||||
function clearTrueOriginal(): void {
|
||||
delete (console as ConsoleWithStash)[TRUE_ORIGINAL_KEY];
|
||||
}
|
||||
|
||||
// Module-scoped: not consulted for correctness (see doc comment above), only
|
||||
// used to avoid re-adding window listeners within a single module instance.
|
||||
let buffer: ConsoleErrorEntry[] = [];
|
||||
let errorListener: ((e: ErrorEvent) => void) | null = null;
|
||||
let rejectionListener: ((e: PromiseRejectionEvent) => void) | null = null;
|
||||
|
||||
function record(message: string): void {
|
||||
const text = message.length > MAX_MESSAGE ? message.slice(0, MAX_MESSAGE) : message;
|
||||
buffer.push({ ts: Date.now(), message: text });
|
||||
if (buffer.length > MAX_ENTRIES) buffer = buffer.slice(buffer.length - MAX_ENTRIES);
|
||||
}
|
||||
|
||||
function stringifyArg(arg: unknown): string {
|
||||
if (typeof arg === 'string') return arg;
|
||||
if (arg instanceof Error) return `${arg.name}: ${arg.message}`;
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
|
||||
/** Idempotent. Patches console.error and adds window error listeners. */
|
||||
export function installConsoleErrorBuffer(): void {
|
||||
// We're already the live, active patch -- nothing to do.
|
||||
if (isPatched(console.error)) return;
|
||||
|
||||
// Reuse the stashed true original if we've ever patched before (covers
|
||||
// HMR re-execution and reinstall-after-external-wrap); otherwise this is
|
||||
// a genuinely virgin install and the current console.error IS the true
|
||||
// original.
|
||||
const trueOriginal = getTrueOriginal() ?? console.error;
|
||||
setTrueOriginal(trueOriginal);
|
||||
|
||||
const patched: MarkedConsoleError = (...args: unknown[]): void => {
|
||||
try {
|
||||
record(args.map(stringifyArg).join(' '));
|
||||
} catch {
|
||||
// Recording must never break logging.
|
||||
}
|
||||
trueOriginal.call(console, ...args);
|
||||
};
|
||||
patched[MARKER] = true;
|
||||
console.error = patched;
|
||||
|
||||
if (typeof window !== 'undefined' && !errorListener) {
|
||||
errorListener = (e: ErrorEvent) => {
|
||||
try {
|
||||
record(`window.onerror: ${e.message}`);
|
||||
} catch {
|
||||
// Recording must never break the page's own error handling.
|
||||
}
|
||||
};
|
||||
rejectionListener = (e: PromiseRejectionEvent) => {
|
||||
try {
|
||||
record(`unhandledrejection: ${stringifyArg(e.reason)}`);
|
||||
} catch {
|
||||
// Recording must never break the page's own rejection handling.
|
||||
}
|
||||
};
|
||||
window.addEventListener('error', errorListener);
|
||||
window.addEventListener('unhandledrejection', rejectionListener);
|
||||
}
|
||||
}
|
||||
|
||||
/** Oldest-first copy of the retained entries (at most 20). */
|
||||
export function getRecentConsoleErrors(): ConsoleErrorEntry[] {
|
||||
return buffer.slice();
|
||||
}
|
||||
|
||||
/** Test-only: clear the buffer and un-patch. */
|
||||
export function __resetConsoleErrorBuffer(): void {
|
||||
buffer = [];
|
||||
const trueOriginal = getTrueOriginal();
|
||||
if (trueOriginal) {
|
||||
console.error = trueOriginal;
|
||||
}
|
||||
clearTrueOriginal();
|
||||
if (typeof window !== 'undefined') {
|
||||
if (errorListener) window.removeEventListener('error', errorListener);
|
||||
if (rejectionListener) window.removeEventListener('unhandledrejection', rejectionListener);
|
||||
}
|
||||
errorListener = null;
|
||||
rejectionListener = null;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { formatHtml } from './format-html';
|
||||
|
||||
describe('formatHtml', () => {
|
||||
test('indents nested block elements two spaces per level', () => {
|
||||
expect(formatHtml('<div><section><p>hi</p></section></div>')).toBe(
|
||||
'<div>\n <section>\n <p>hi</p>\n </section>\n</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves inline tags on the same line as their text', () => {
|
||||
expect(formatHtml('<p>hello <strong>world</strong> now</p>')).toBe(
|
||||
'<p>hello <strong>world</strong> now</p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('void elements do not open an indent level', () => {
|
||||
expect(formatHtml('<div><img src="a.png"><br><p>x</p></div>')).toBe(
|
||||
'<div>\n <img src="a.png">\n <br>\n <p>x</p>\n</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves <pre> contents verbatim', () => {
|
||||
const src = '<div><pre> keep\n this</pre></div>';
|
||||
expect(formatHtml(src)).toBe('<div>\n <pre> keep\n this</pre>\n</div>');
|
||||
});
|
||||
|
||||
test('is idempotent', () => {
|
||||
const once = formatHtml('<div><section><p>hi</p></section></div>');
|
||||
expect(formatHtml(once)).toBe(once);
|
||||
});
|
||||
|
||||
test('empty and whitespace-only input round-trip to an empty string', () => {
|
||||
expect(formatHtml('')).toBe('');
|
||||
expect(formatHtml(' \n ')).toBe('');
|
||||
});
|
||||
|
||||
test('unbalanced markup never produces negative indent', () => {
|
||||
expect(formatHtml('</div><p>x</p>')).toBe('</div>\n<p>x</p>');
|
||||
});
|
||||
});
|
||||
|
||||
// Regression coverage from code review: only <pre> was originally exempted
|
||||
// from the naive '<'/'>' tag-boundary scan, which let a '>' inside a quoted
|
||||
// attribute value corrupt output, and let '<'/'>' inside <script>/<style>
|
||||
// content be misparsed as tag boundaries.
|
||||
describe('formatHtml - raw content and quoted attributes', () => {
|
||||
test('a ">" inside a quoted attribute value does not split the tag', () => {
|
||||
const src = '<div title="a>b"><p>hi</p></div><footer>bye</footer>';
|
||||
expect(formatHtml(src)).toBe(
|
||||
'<div title="a>b">\n <p>hi</p>\n</div>\n<footer>bye</footer>',
|
||||
);
|
||||
});
|
||||
|
||||
test('"<" and ">" inside <script> content do not desync sibling nesting', () => {
|
||||
const src = '<div><script>a < b;</script></div><div><script>c < d;</script></div>';
|
||||
expect(formatHtml(src)).toBe(
|
||||
'<div>\n <script>a < b;</script>\n</div>\n<div>\n <script>c < d;</script>\n</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('a CSS child combinator inside <style> content is not treated as markup', () => {
|
||||
const src = '<div><style>div > p { color: red; }</style></div>';
|
||||
expect(formatHtml(src)).toBe(
|
||||
'<div>\n <style>div > p { color: red; }</style>\n</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('is idempotent across a quoted ">" attribute and <script> content', () => {
|
||||
const quotedAttr = formatHtml('<div title="a>b"><p>hi</p></div><footer>bye</footer>');
|
||||
expect(formatHtml(quotedAttr)).toBe(quotedAttr);
|
||||
|
||||
const scriptSrc = '<div><script>a < b;</script></div><div><script>c < d;</script></div>';
|
||||
const scripted = formatHtml(scriptSrc);
|
||||
expect(formatHtml(scripted)).toBe(scripted);
|
||||
});
|
||||
|
||||
test('an unclosed <pre> is swallowed verbatim to the end of the document', () => {
|
||||
expect(formatHtml('<div><pre>no closing tag here')).toBe(
|
||||
'<div>\n <pre>no closing tag here',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression coverage from round 2 of code review: a close tag whose
|
||||
// innermost open frame doesn't match it must not wedge the stack for the
|
||||
// rest of the document. An optional end tag skipped by the author (e.g.
|
||||
// an unclosed <p>) must self-drain against its real ancestor close tag,
|
||||
// while a close tag with no opener anywhere on the stack still has nothing
|
||||
// to pair with and is left exactly where it is.
|
||||
describe('formatHtml - mismatched close recovery', () => {
|
||||
test('an unclosed <p> before </div> drains against the ancestor close instead of wedging the stack', () => {
|
||||
const src = '<div><p>one<p>two</p></div><p>three</p>';
|
||||
expect(formatHtml(src)).toBe(
|
||||
'<div>\n <p>\n one\n <p>two</p>\n</div>\n<p>three</p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('a close tag with no opener anywhere on the stack is left in place', () => {
|
||||
const src = '<div><p>x</p></footer></div>';
|
||||
expect(formatHtml(src)).toBe('<div>\n <p>x</p>\n </footer>\n</div>');
|
||||
});
|
||||
|
||||
test('is idempotent across an unclosed <p> and a stray close with no opener', () => {
|
||||
const unclosedP = formatHtml('<div><p>one<p>two</p></div><p>three</p>');
|
||||
expect(formatHtml(unclosedP)).toBe(unclosedP);
|
||||
|
||||
const strayClose = formatHtml('<div><p>x</p></footer></div>');
|
||||
expect(formatHtml(strayClose)).toBe(strayClose);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression coverage from round 3 of code review: the previous fix for the
|
||||
// phantom-space idempotency bug used "does this boundary whitespace contain
|
||||
// a newline" as its drop/keep rule, which also stripped hand-wrapped text
|
||||
// like "hello\n<strong>" -- a single-pass content change, not a
|
||||
// repeated-pass artifact. The correct discriminator is what the whitespace
|
||||
// borders: whitespace between two inline-level things (text, <strong>, ...)
|
||||
// is always significant and must survive regardless of newlines; whitespace
|
||||
// touching a block-tag boundary carries no rendered meaning and is always
|
||||
// dropped, regardless of newlines.
|
||||
describe('formatHtml - inline whitespace vs block-boundary whitespace', () => {
|
||||
test('a newline between text and an inline tag is kept as a single space', () => {
|
||||
expect(formatHtml('<p>hello\n<strong>world</strong></p>')).toBe(
|
||||
'<p>hello <strong>world</strong></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('a literal space in the same position is kept unchanged (pin)', () => {
|
||||
expect(formatHtml('<p>hello <strong>world</strong></p>')).toBe(
|
||||
'<p>hello <strong>world</strong></p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('a newline at a block-tag boundary is still dropped', () => {
|
||||
expect(formatHtml('<div>\n</div>')).toBe('<div></div>');
|
||||
});
|
||||
|
||||
test('is idempotent across inline-boundary whitespace, block-boundary whitespace, and the case that originally exposed the phantom space', () => {
|
||||
const wrapped = formatHtml('<p>hello\n<strong>world</strong></p>');
|
||||
expect(formatHtml(wrapped)).toBe(wrapped);
|
||||
|
||||
const sameLine = formatHtml('<p>hello <strong>world</strong></p>');
|
||||
expect(formatHtml(sameLine)).toBe(sameLine);
|
||||
|
||||
const blockGap = formatHtml('<div>\n</div>');
|
||||
expect(formatHtml(blockGap)).toBe(blockGap);
|
||||
|
||||
const phantomSpaceCase = formatHtml('<div><p>one<p>two</p></div><p>three</p>');
|
||||
expect(formatHtml(phantomSpaceCase)).toBe(phantomSpaceCase);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Indent-only HTML prettifier for the Edit HTML modal's Format button.
|
||||
*
|
||||
* Deliberately small and dependency-free: it re-indents BLOCK-level tags onto
|
||||
* their own lines and nests them two spaces per level. It does not reflow
|
||||
* text, reorder attributes, or normalise quoting -- a formatter that rewrites
|
||||
* user markup is a formatter people stop trusting.
|
||||
*
|
||||
* Inline tags (<strong>, <a>, <span>, ...) are left exactly where they sit,
|
||||
* and <pre>/<script>/<style> contents are copied through verbatim -- their
|
||||
* content is scanned only for the literal closing tag, never treated as
|
||||
* markup, so `<` / `>` inside JS comparisons or CSS combinators can't be
|
||||
* mistaken for tag boundaries.
|
||||
*
|
||||
* The tag-boundary scan itself is quote-aware: a `>` inside a single- or
|
||||
* double-quoted attribute value (e.g. `title="a>b"`) does not end the tag.
|
||||
*
|
||||
* Design note: a block-open tag is not committed to its own output line the
|
||||
* moment it is seen. It stays "pending" on a stack frame; if only inline
|
||||
* content follows before its matching close (e.g. `<p>hi</p>`), the open
|
||||
* tag, the inline content, and the close tag are merged onto a single line.
|
||||
* The pending open is only forced onto its own line ("committed") once a
|
||||
* nested block-level token (open/void/verbatim) proves the element spans
|
||||
* more than one line.
|
||||
*/
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
'html', 'head', 'body', 'div', 'section', 'article', 'aside', 'header', 'footer',
|
||||
'main', 'nav', 'form', 'fieldset', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'blockquote', 'figure', 'figcaption', 'pre', 'hr', 'br', 'img', 'iframe', 'video',
|
||||
'audio', 'source', 'canvas', 'script', 'style', 'select', 'option', 'textarea',
|
||||
]);
|
||||
|
||||
const VOID_TAGS = new Set([
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
||||
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
||||
]);
|
||||
|
||||
/** Elements whose content is never markup -- read verbatim to the literal closing tag. */
|
||||
const RAW_TEXT_TAGS = new Set(['pre', 'script', 'style']);
|
||||
|
||||
const INDENT = ' ';
|
||||
|
||||
/** True for a real, known block-level tag name (see BLOCK_TAGS); '' (no tag) is not block. */
|
||||
function isBlockTag(tag: string): boolean {
|
||||
return tag !== '' && BLOCK_TAGS.has(tag);
|
||||
}
|
||||
|
||||
interface Token {
|
||||
/** Raw text of the token. Text runs have interior whitespace collapsed. */
|
||||
text: string;
|
||||
/** Lowercased tag name, or '' for a text run. */
|
||||
tag: string;
|
||||
kind: 'open' | 'close' | 'void' | 'text' | 'verbatim';
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw run of text (between two tags) into a text token, collapsing
|
||||
* interior whitespace to single spaces. A run that is nothing but
|
||||
* whitespace is dropped entirely UNLESS it sits between two inline-level
|
||||
* things, in which case it's a real (if content-free) word gap -- e.g.
|
||||
* `<span>a</span> <span>b</span>` -- and collapses to one significant space.
|
||||
*
|
||||
* For a run with real content, its leading/trailing whitespace is boiled
|
||||
* down to at most one space each, kept only on sides that border something
|
||||
* inline. The discriminator is deliberately NOT "does this whitespace
|
||||
* contain a newline" -- that would also strip a hand-wrapped
|
||||
* `"hello\n<strong>"`, turning it into "helloworld" and rewriting the
|
||||
* user's markup. It's "what does this boundary sit next to": whitespace
|
||||
* between two inline-level things (text, `<strong>`, `<a>`, ...) is always
|
||||
* significant in HTML and must survive regardless of newlines; whitespace
|
||||
* touching a block-tag boundary carries no rendered meaning and is always
|
||||
* dropped, regardless of newlines. Because this formatter's own emitted
|
||||
* indentation always sits at a block boundary (an element's own line is
|
||||
* only ever created next to another block tag), that side of the rule is
|
||||
* also what keeps repeated formatting from accumulating phantom spaces.
|
||||
*
|
||||
* `prevIsBlock`/`nextIsBlock` describe whatever sits immediately before/
|
||||
* after this run: true for a block tag or "nothing there" (start/end of
|
||||
* document, or an unterminated tag), false for an inline tag.
|
||||
*/
|
||||
function pushTextToken(tokens: Token[], raw: string, prevIsBlock: boolean, nextIsBlock: boolean): void {
|
||||
const core = raw.trim();
|
||||
|
||||
if (!core) {
|
||||
if (!prevIsBlock && !nextIsBlock) {
|
||||
tokens.push({ text: ' ', tag: '', kind: 'text' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const leadSpace = /^\s/.test(raw) && !prevIsBlock ? ' ' : '';
|
||||
const trailSpace = /\s$/.test(raw) && !nextIsBlock ? ' ' : '';
|
||||
const text = leadSpace + core.replace(/\s+/g, ' ') + trailSpace;
|
||||
|
||||
tokens.push({ text, tag: '', kind: 'text' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the '>' that closes the tag opened at `lt` (the index of its '<'),
|
||||
* without being fooled by a '>' inside a single- or double-quoted
|
||||
* attribute value. Returns -1 if the tag is never closed.
|
||||
*/
|
||||
function findTagEnd(src: string, lt: number): number {
|
||||
let i = lt + 1;
|
||||
let quote: string | null = null;
|
||||
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
} else if (ch === '>') {
|
||||
return i;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** Split source into tags and text runs, treating raw-text element bodies as one atom. */
|
||||
function tokenize(src: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
// Whatever tag most recently landed in `tokens` (undefined at the very
|
||||
// start of the document, which counts as a block-like boundary).
|
||||
const prevIsBlock = (): boolean =>
|
||||
tokens.length === 0 || isBlockTag(tokens[tokens.length - 1].tag);
|
||||
|
||||
while (i < src.length) {
|
||||
const lt = src.indexOf('<', i);
|
||||
|
||||
if (lt === -1) {
|
||||
// Nothing more to tokenize after this -- end of document is a
|
||||
// block-like boundary too.
|
||||
pushTextToken(tokens, src.slice(i), prevIsBlock(), true);
|
||||
break;
|
||||
}
|
||||
|
||||
// Peek the upcoming tag's name once, up front: it decides both the
|
||||
// trailing-space behaviour of the text run before it (if any) and,
|
||||
// below, how this tag itself is tokenized -- no need to re-scan it.
|
||||
const gt = findTagEnd(src, lt);
|
||||
const raw = gt === -1 ? '' : src.slice(lt, gt + 1);
|
||||
const nameMatch = raw ? /^<\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw) : null;
|
||||
const tag = nameMatch ? nameMatch[1].toLowerCase() : '';
|
||||
// An unterminated tag never really opens/closes anything, so treat it
|
||||
// like the end of the document for the preceding text run's purposes.
|
||||
const nextIsBlock = gt === -1 || isBlockTag(tag);
|
||||
|
||||
if (lt > i) {
|
||||
pushTextToken(tokens, src.slice(i, lt), prevIsBlock(), nextIsBlock);
|
||||
}
|
||||
|
||||
if (gt === -1) {
|
||||
// Unterminated '<' -- emit the remainder as text rather than looping.
|
||||
pushTextToken(tokens, src.slice(lt), prevIsBlock(), true);
|
||||
break;
|
||||
}
|
||||
|
||||
// <pre>/<script>/<style> swallow everything up to their closing tag,
|
||||
// untouched -- their content is never scanned as markup. If no closing
|
||||
// tag exists, swallow to the end of the document rather than risk
|
||||
// misparsing raw JS/CSS as tags.
|
||||
if (RAW_TEXT_TAGS.has(tag) && !raw.startsWith('</')) {
|
||||
const closeRe = new RegExp(`</${tag}\\s*>`, 'i');
|
||||
const rest = src.slice(gt + 1);
|
||||
const match = closeRe.exec(rest);
|
||||
const end = match ? gt + 1 + match.index + match[0].length : src.length;
|
||||
tokens.push({ text: src.slice(lt, end), tag, kind: 'verbatim' });
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isClose = raw.startsWith('</');
|
||||
const selfClosing = /\/>\s*$/.test(raw);
|
||||
const kind: Token['kind'] = isClose
|
||||
? 'close'
|
||||
: selfClosing || VOID_TAGS.has(tag)
|
||||
? 'void'
|
||||
: 'open';
|
||||
|
||||
tokens.push({ text: raw, tag, kind });
|
||||
i = gt + 1;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** A pending block-level element: may still merge onto a single line. */
|
||||
interface Frame {
|
||||
/** Raw text of the open tag. */
|
||||
text: string;
|
||||
/** Lowercased tag name, used to pair this frame with its real close tag. */
|
||||
tag: string;
|
||||
/** Indent depth at which this element's tags render. */
|
||||
depth: number;
|
||||
/** Inline content accumulated directly under this element since it opened
|
||||
* (or since it was last committed). */
|
||||
inline: string;
|
||||
/** Whether the open tag has already been written to its own line. */
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit whatever a still-open frame needs, without ever fabricating a
|
||||
* closing tag for it: its own open-tag line if it was never committed, and
|
||||
* any inline content it was holding. Used both for elements that are
|
||||
* genuinely never closed anywhere in the document, and for elements
|
||||
* implicitly closed by an ancestor's close tag (e.g. an optional end tag
|
||||
* like `<p>` that the author skipped) -- either way, no synthetic close tag
|
||||
* is written; only indentation for content that really was opened.
|
||||
*/
|
||||
function implicitlyClose(lines: string[], frame: Frame): void {
|
||||
if (!frame.committed) {
|
||||
lines.push(INDENT.repeat(frame.depth) + frame.text);
|
||||
}
|
||||
if (frame.inline) {
|
||||
lines.push(INDENT.repeat(frame.depth + 1) + frame.inline);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatHtml(src: string): string {
|
||||
if (!src || !src.trim()) return '';
|
||||
|
||||
const tokens = tokenize(src);
|
||||
const lines: string[] = [];
|
||||
const stack: Frame[] = [];
|
||||
/** Inline/text content seen while no block frame is open. */
|
||||
let rootInline = '';
|
||||
|
||||
const flushRootInline = (): void => {
|
||||
if (!rootInline) return;
|
||||
lines.push(rootInline);
|
||||
rootInline = '';
|
||||
};
|
||||
|
||||
/** Force the innermost pending frame onto its own line, if not already. */
|
||||
const commitTop = (): void => {
|
||||
if (stack.length === 0) return;
|
||||
const top = stack[stack.length - 1];
|
||||
if (top.committed) return;
|
||||
lines.push(INDENT.repeat(top.depth) + top.text);
|
||||
if (top.inline) {
|
||||
lines.push(INDENT.repeat(top.depth + 1) + top.inline);
|
||||
top.inline = '';
|
||||
}
|
||||
top.committed = true;
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
const isBlock = isBlockTag(token.tag);
|
||||
|
||||
if (!isBlock) {
|
||||
// Inline tag or text -- accumulate against the innermost open element.
|
||||
if (stack.length) {
|
||||
stack[stack.length - 1].inline += token.text;
|
||||
} else {
|
||||
rootInline += token.text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.kind === 'close') {
|
||||
// Find the nearest still-open frame with this tag name, anywhere on
|
||||
// the stack -- not just the top. HTML permits skipping optional end
|
||||
// tags (e.g. `<p>`), so the element a close tag pairs with is not
|
||||
// always the innermost open element.
|
||||
let matchIndex = -1;
|
||||
for (let k = stack.length - 1; k >= 0; k -= 1) {
|
||||
if (stack[k].tag === token.tag) {
|
||||
matchIndex = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchIndex === -1) {
|
||||
// Genuinely stray: no frame anywhere was opened with this tag
|
||||
// name, so there is nothing to pair it with. Don't fabricate a
|
||||
// pairing -- emit it in place and leave the stack untouched.
|
||||
if (stack.length === 0) flushRootInline();
|
||||
lines.push(INDENT.repeat(stack.length) + token.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything above the match was opened but never explicitly closed
|
||||
// in the source (e.g. a skipped `</p>`). Draining them here -- with
|
||||
// no synthetic close tag -- keeps the stack from staying wedged for
|
||||
// the rest of the document, restoring the self-draining property
|
||||
// without fabricating markup.
|
||||
while (stack.length - 1 > matchIndex) {
|
||||
implicitlyClose(lines, stack.pop()!);
|
||||
}
|
||||
|
||||
const top = stack.pop()!;
|
||||
if (!top.committed) {
|
||||
// Nothing block-level ever interrupted this element: merge the
|
||||
// open tag, its inline content, and the close tag onto one line.
|
||||
lines.push(INDENT.repeat(top.depth) + top.text + top.inline + token.text);
|
||||
} else {
|
||||
if (top.inline) {
|
||||
lines.push(INDENT.repeat(top.depth + 1) + top.inline);
|
||||
}
|
||||
lines.push(INDENT.repeat(top.depth) + token.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// token.kind is 'open' | 'void' | 'verbatim': a block-level element is
|
||||
// about to render at the current depth, so any still-pending parent
|
||||
// frame can no longer merge onto a single line.
|
||||
commitTop();
|
||||
if (stack.length === 0) flushRootInline();
|
||||
const depth = stack.length;
|
||||
|
||||
if (token.kind === 'open') {
|
||||
stack.push({ text: token.text, tag: token.tag, depth, inline: '', committed: false });
|
||||
continue;
|
||||
}
|
||||
|
||||
// void or verbatim: renders on its own line, opens no new frame.
|
||||
lines.push(INDENT.repeat(depth) + token.text);
|
||||
}
|
||||
|
||||
// Unbalanced opens: nothing ever closed them. Flush what's left rather
|
||||
// than silently dropping content.
|
||||
while (stack.length) {
|
||||
implicitlyClose(lines, stack.pop()!);
|
||||
}
|
||||
|
||||
flushRootInline();
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { findUnreachableNodeIds, repairOrphanNodes } from './orphan-repair';
|
||||
|
||||
/** Minimal Craft-shaped node. */
|
||||
function node(over: Record<string, any> = {}) {
|
||||
return {
|
||||
type: { resolvedName: 'Container' },
|
||||
isCanvas: false,
|
||||
props: {},
|
||||
displayName: 'Container',
|
||||
custom: {},
|
||||
hidden: false,
|
||||
nodes: [],
|
||||
linkedNodes: {},
|
||||
parent: 'ROOT',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const healthy = JSON.stringify({
|
||||
ROOT: node({ isCanvas: true, parent: null, nodes: ['a'] }),
|
||||
a: node({ parent: 'ROOT' }),
|
||||
});
|
||||
|
||||
describe('findUnreachableNodeIds', () => {
|
||||
test('a healthy tree has no unreachable nodes', () => {
|
||||
expect(findUnreachableNodeIds(JSON.parse(healthy))).toEqual([]);
|
||||
});
|
||||
|
||||
test('a node ROOT does not list is unreachable even when its parent says ROOT', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.stray = node({ parent: 'ROOT', displayName: 'HTML' });
|
||||
expect(findUnreachableNodeIds(nodes)).toEqual(['stray']);
|
||||
});
|
||||
|
||||
test('a node whose parent no longer exists is unreachable', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.stray = node({ parent: 'ghost' });
|
||||
expect(findUnreachableNodeIds(nodes)).toEqual(['stray']);
|
||||
});
|
||||
|
||||
test('children of an unreachable node are also unreachable', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
||||
nodes.strayChild = node({ parent: 'stray' });
|
||||
expect(findUnreachableNodeIds(nodes).sort()).toEqual(['stray', 'strayChild']);
|
||||
});
|
||||
|
||||
test('linkedNodes children count as reachable', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.ROOT.linkedNodes = { inner: 'linked' };
|
||||
nodes.linked = node({ parent: 'ROOT' });
|
||||
expect(findUnreachableNodeIds(nodes)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a cycle among orphans terminates instead of hanging', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
||||
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
||||
expect(findUnreachableNodeIds(nodes).sort()).toEqual(['x', 'y']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairOrphanNodes', () => {
|
||||
test('a healthy tree is returned byte-identical with nothing repaired', () => {
|
||||
const out = repairOrphanNodes(healthy);
|
||||
expect(out.repaired).toEqual([]);
|
||||
expect(out.state).toBe(healthy);
|
||||
});
|
||||
|
||||
test('an orphan is appended to the end of ROOT.nodes and reparented', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.stray = node({ parent: 'ghost', displayName: 'HTML' });
|
||||
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||
|
||||
expect(out.repaired).toEqual(['stray']);
|
||||
const parsed = JSON.parse(out.state);
|
||||
expect(parsed.ROOT.nodes).toEqual(['a', 'stray']);
|
||||
expect(parsed.stray.parent).toBe('ROOT');
|
||||
});
|
||||
|
||||
test('only the top of an orphan subtree is reattached; its children ride along', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
||||
nodes.strayChild = node({ parent: 'stray' });
|
||||
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||
|
||||
expect(out.repaired).toEqual(['stray']);
|
||||
const parsed = JSON.parse(out.state);
|
||||
expect(parsed.ROOT.nodes).toEqual(['a', 'stray']);
|
||||
expect(parsed.strayChild.parent).toBe('stray');
|
||||
});
|
||||
|
||||
test('malformed JSON comes back unchanged rather than throwing', () => {
|
||||
const out = repairOrphanNodes('{not json');
|
||||
expect(out.state).toBe('{not json');
|
||||
expect(out.repaired).toEqual([]);
|
||||
});
|
||||
|
||||
test('state with no ROOT comes back unchanged', () => {
|
||||
const orphanOnly = JSON.stringify({ a: node({ parent: null }) });
|
||||
const out = repairOrphanNodes(orphanOnly);
|
||||
expect(out.state).toBe(orphanOnly);
|
||||
expect(out.repaired).toEqual([]);
|
||||
});
|
||||
|
||||
// A cluster of orphans whose `parent` fields (and child lists) reference
|
||||
// only each other has no member pointing "outside" the cluster, so the
|
||||
// simple "reattach the top" rule finds no top at all. Regression coverage
|
||||
// for the invariant: after repair, nothing in the returned state may still
|
||||
// be unreachable -- see the loop comment in the implementation.
|
||||
|
||||
test('a 2-node orphan cycle is fully reachable after repair', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
||||
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
||||
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||
|
||||
expect(out.repaired.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(out.state);
|
||||
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a 3-node orphan cycle is fully reachable after repair', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.p = node({ parent: 'r', nodes: ['q'] });
|
||||
nodes.q = node({ parent: 'p', nodes: ['r'] });
|
||||
nodes.r = node({ parent: 'q', nodes: ['p'] });
|
||||
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||
|
||||
expect(out.repaired.length).toBeGreaterThan(0);
|
||||
const parsed = JSON.parse(out.state);
|
||||
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an ordinary orphan subtree and a separate orphan cycle in the same document are both repaired', () => {
|
||||
const nodes = JSON.parse(healthy);
|
||||
nodes.stray = node({ parent: 'ghost', nodes: ['strayChild'] });
|
||||
nodes.strayChild = node({ parent: 'stray' });
|
||||
nodes.x = node({ parent: 'y', nodes: ['y'] });
|
||||
nodes.y = node({ parent: 'x', nodes: ['x'] });
|
||||
const out = repairOrphanNodes(JSON.stringify(nodes));
|
||||
|
||||
const parsed = JSON.parse(out.state);
|
||||
expect(findUnreachableNodeIds(parsed)).toEqual([]);
|
||||
// The ordinary subtree keeps its established "only the top is
|
||||
// reattached" behaviour: stray is reparented, strayChild rides along
|
||||
// untouched.
|
||||
expect(out.repaired).toContain('stray');
|
||||
expect(parsed.strayChild.parent).toBe('stray');
|
||||
// Exactly one representative per component is force-reattached: stray
|
||||
// (found via the normal rule) plus one of x/y (via the cycle fallback).
|
||||
expect(out.repaired.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Editor-state integrity: every node must be reachable from ROOT.
|
||||
*
|
||||
* A node that exists in `SerializedNodes` but appears in no parent's `nodes`
|
||||
* or `linkedNodes` list is invisible to the Layers tree AND to Craft's own
|
||||
* selection machinery -- but not because it "renders but can't be selected".
|
||||
* Craft.js's `<Frame>` only instantiates nodes it can actually walk to via
|
||||
* `data.nodes`/`linkedNodes` starting from ROOT, so an unreachable node is
|
||||
* never rendered at all: it doesn't appear on the canvas, it's just inert
|
||||
* data sitting in the serialized state. Reachability is computed from the
|
||||
* PARENT'S child lists, not from each node's own `parent` pointer: a stale
|
||||
* `parent: 'ROOT'` on a node ROOT never lists is precisely the broken case
|
||||
* we're looking for.
|
||||
*
|
||||
* This is a DIFFERENT mechanism from the originally-reported symptom -- a
|
||||
* *visible* element on the canvas that can't be selected or deleted. That
|
||||
* symptom requires the node to be both rendered AND excluded from Craft's
|
||||
* selection/interaction machinery, which is not what an unreachable-from-
|
||||
* ROOT node produces (it isn't rendered at all). This repair fixes the
|
||||
* "node is present in state but invisible/unrecoverable" case; the
|
||||
* originally-reported "visible but unselectable" case is still
|
||||
* unreproduced and is presumed to be a different bug.
|
||||
*
|
||||
* Pure functions over serialized state -- no React, no Craft instance.
|
||||
*/
|
||||
|
||||
const ROOT_ID = 'ROOT';
|
||||
|
||||
function childIdsOf(node: any): string[] {
|
||||
const nodes: string[] = Array.isArray(node?.nodes) ? node.nodes : [];
|
||||
const linked: string[] = node?.linkedNodes ? Object.values(node.linkedNodes) : [];
|
||||
return [...nodes, ...linked];
|
||||
}
|
||||
|
||||
/** Ids of nodes present in `nodes` whose parent chain does not reach 'ROOT'. */
|
||||
export function findUnreachableNodeIds(nodes: Record<string, any>): string[] {
|
||||
if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) return [];
|
||||
|
||||
const reachable = new Set<string>([ROOT_ID]);
|
||||
const queue: string[] = [ROOT_ID];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!;
|
||||
for (const childId of childIdsOf(nodes[id])) {
|
||||
// The `reachable` guard also terminates on a cycle among real nodes.
|
||||
if (typeof childId === 'string' && nodes[childId] && !reachable.has(childId)) {
|
||||
reachable.add(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(nodes).filter((id) => !reachable.has(id));
|
||||
}
|
||||
|
||||
/** Reattach every unreachable node to the end of ROOT.nodes. Returns the
|
||||
* possibly-rewritten serialized state and the ids that were moved. Never
|
||||
* throws: malformed input comes back unchanged with an empty `repaired`.
|
||||
*
|
||||
* Invariant: for any input that parses and has a ROOT, calling
|
||||
* `findUnreachableNodeIds` on the returned `state` (parsed) always yields
|
||||
* `[]` -- there is no orphan configuration this leaves half-repaired. */
|
||||
export function repairOrphanNodes(serialized: string): { state: string; repaired: string[] } {
|
||||
let nodes: Record<string, any>;
|
||||
try {
|
||||
nodes = JSON.parse(serialized);
|
||||
} catch {
|
||||
// A page must never fail to load because the repair pass couldn't parse
|
||||
// it -- hand the original string straight back to deserialize().
|
||||
return { state: serialized, repaired: [] };
|
||||
}
|
||||
|
||||
if (!nodes || typeof nodes !== 'object' || !nodes[ROOT_ID]) {
|
||||
return { state: serialized, repaired: [] };
|
||||
}
|
||||
|
||||
let unreachable = findUnreachableNodeIds(nodes);
|
||||
if (unreachable.length === 0) return { state: serialized, repaired: [] };
|
||||
|
||||
if (!Array.isArray(nodes[ROOT_ID].nodes)) nodes[ROOT_ID].nodes = [];
|
||||
const repaired: string[] = [];
|
||||
|
||||
// Loop because a single pass can leave orphan CYCLES untouched: if every
|
||||
// member of a cluster points only at other members of that same cluster,
|
||||
// none of them has a parent pointing "out", so nothing qualifies as a top
|
||||
// and a one-shot pass would report `repaired: []` while the cluster is
|
||||
// still unreachable. Each iteration re-derives `unreachable` from the
|
||||
// current (partially repaired) state and terminates once it's empty --
|
||||
// this is what makes the invariant hold rather than just being hoped for.
|
||||
while (unreachable.length > 0) {
|
||||
const orphanSet = new Set(unreachable);
|
||||
|
||||
// Reattach the TOP of each orphan subtree: a node whose `parent` points
|
||||
// outside the current orphan set (to something real, to nothing, or is
|
||||
// null). Its existing child list carries the rest of its subtree along
|
||||
// for free once BFS can reach it again.
|
||||
let tops = unreachable.filter((id) => {
|
||||
const parent = nodes[id]?.parent;
|
||||
return !(typeof parent === 'string' && orphanSet.has(parent));
|
||||
});
|
||||
|
||||
// No such node exists only when every remaining orphan's `parent`
|
||||
// points at another orphan -- i.e. a closed cycle (2+ nodes referencing
|
||||
// only each other). There is no legitimate "outside" anchor to prefer,
|
||||
// so break the cycle by force-reattaching one representative member
|
||||
// (first in iteration order, for determinism). Because a cycle is
|
||||
// strongly connected via the actual nodes/linkedNodes edges, reattaching
|
||||
// any single member pulls the rest of that cycle in on the next
|
||||
// `findUnreachableNodeIds` pass without touching their `parent` fields.
|
||||
if (tops.length === 0) {
|
||||
tops = [unreachable[0]];
|
||||
}
|
||||
|
||||
for (const id of tops) {
|
||||
nodes[id].parent = ROOT_ID;
|
||||
nodes[ROOT_ID].nodes.push(id);
|
||||
repaired.push(id);
|
||||
}
|
||||
|
||||
unreachable = findUnreachableNodeIds(nodes);
|
||||
}
|
||||
|
||||
return { state: JSON.stringify(nodes), repaired };
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { buildReportPayload, MAX_PAYLOAD_BYTES } from './report-payload';
|
||||
|
||||
const base = {
|
||||
category: 'bug' as const,
|
||||
description: 'The HTML block colours do nothing',
|
||||
includeCanvas: true,
|
||||
siteId: 42,
|
||||
siteDomain: 'example.com',
|
||||
pageId: 'home',
|
||||
pageSlug: 'index',
|
||||
editorVersion: 'abc1234-2026-08-08',
|
||||
userAgent: 'Mozilla/5.0 test',
|
||||
viewport: '1920x1080',
|
||||
deviceMode: 'desktop',
|
||||
selectedType: 'HTML',
|
||||
consoleErrors: [{ ts: 1, message: 'oops' }],
|
||||
canvasState: '{"ROOT":{}}',
|
||||
};
|
||||
|
||||
describe('buildReportPayload', () => {
|
||||
test('carries every context field through', () => {
|
||||
const p = buildReportPayload(base);
|
||||
expect(p.category).toBe('bug');
|
||||
expect(p.description).toBe('The HTML block colours do nothing');
|
||||
expect(p.site_id).toBe(42);
|
||||
expect(p.site_domain).toBe('example.com');
|
||||
expect(p.page_slug).toBe('index');
|
||||
expect(p.editor_version).toBe('abc1234-2026-08-08');
|
||||
expect(p.device_mode).toBe('desktop');
|
||||
expect(p.selected_type).toBe('HTML');
|
||||
expect(p.console_errors).toEqual([{ ts: 1, message: 'oops' }]);
|
||||
expect(p.canvas_state).toBe('{"ROOT":{}}');
|
||||
expect(p.canvas_state_omitted).toBeUndefined();
|
||||
});
|
||||
|
||||
test('includeCanvas=false drops the canvas and records why', () => {
|
||||
const p = buildReportPayload({ ...base, includeCanvas: false });
|
||||
expect(p.canvas_state).toBeNull();
|
||||
expect(p.canvas_state_omitted).toBe('opt-out');
|
||||
expect(p.console_errors).toHaveLength(1);
|
||||
expect(p.site_domain).toBe('example.com');
|
||||
});
|
||||
|
||||
test('an oversized canvas is dropped rather than truncated', () => {
|
||||
const huge = 'x'.repeat(MAX_PAYLOAD_BYTES + 1000);
|
||||
const p = buildReportPayload({ ...base, canvasState: huge });
|
||||
expect(p.canvas_state).toBeNull();
|
||||
expect(p.canvas_state_omitted).toBe('size');
|
||||
});
|
||||
|
||||
test('the resulting payload always fits under the cap', () => {
|
||||
const huge = 'x'.repeat(MAX_PAYLOAD_BYTES * 2);
|
||||
const p = buildReportPayload({ ...base, canvasState: huge });
|
||||
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
|
||||
});
|
||||
|
||||
test('description is trimmed', () => {
|
||||
expect(buildReportPayload({ ...base, description: ' spaced ' }).description).toBe('spaced');
|
||||
});
|
||||
|
||||
test('a null canvasState is reported as opt-out-free but still null', () => {
|
||||
const p = buildReportPayload({ ...base, canvasState: null });
|
||||
expect(p.canvas_state).toBeNull();
|
||||
expect(p.canvas_state_omitted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('description size bounding', () => {
|
||||
const hugeDescription = 'y'.repeat(600 * 1024);
|
||||
|
||||
test('oversized description does not blow the cap with canvas included', () => {
|
||||
const p = buildReportPayload({ ...base, description: hugeDescription });
|
||||
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
|
||||
});
|
||||
|
||||
test('oversized description does not blow the cap when opted out', () => {
|
||||
const p = buildReportPayload({ ...base, description: hugeDescription, includeCanvas: false });
|
||||
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
|
||||
expect(p.canvas_state).toBeNull();
|
||||
expect(p.canvas_state_omitted).toBe('opt-out');
|
||||
});
|
||||
|
||||
test('oversized description does not blow the cap with no canvas state', () => {
|
||||
const p = buildReportPayload({ ...base, description: hugeDescription, canvasState: null });
|
||||
expect(new Blob([JSON.stringify(p)]).size).toBeLessThanOrEqual(MAX_PAYLOAD_BYTES);
|
||||
expect(p.canvas_state).toBeNull();
|
||||
expect(p.canvas_state_omitted).toBeUndefined();
|
||||
});
|
||||
|
||||
test('description is truncated to 5000 characters', () => {
|
||||
const p = buildReportPayload({ ...base, description: hugeDescription });
|
||||
expect(p.description.length).toBeLessThanOrEqual(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('honest size markers', () => {
|
||||
test('the size marker never appears on a payload that is still oversized after dropping canvas_state', () => {
|
||||
// consoleErrors is normally bounded upstream (console-buffer.ts caps it at
|
||||
// 20 entries x 500 chars), but this function must not trust that -- it's
|
||||
// just an array of the exported type as far as the signature is concerned.
|
||||
const massiveErrors = Array.from({ length: 5000 }, (_, i) => ({ ts: i, message: 'x'.repeat(200) }));
|
||||
expect(() => buildReportPayload({ ...base, consoleErrors: massiveErrors })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ConsoleErrorEntry } from './console-buffer';
|
||||
|
||||
/**
|
||||
* Assembles the JSON body for an in-builder issue report.
|
||||
*
|
||||
* Pure: every environment value (user agent, viewport, serialized canvas)
|
||||
* is passed IN rather than read from globals, so the whole thing is testable
|
||||
* without a DOM and the caller decides what it is willing to send.
|
||||
*
|
||||
* The canvas state is the only field that can be large. When it would push
|
||||
* the body over the cap it is DROPPED WHOLE and flagged -- a truncated craft
|
||||
* state is not merely useless, it is misleading (it looks like a valid tree
|
||||
* that lost nodes).
|
||||
*/
|
||||
|
||||
export const MAX_PAYLOAD_BYTES = 512 * 1024;
|
||||
|
||||
/**
|
||||
* Matches the limit the server-side validator enforces (a later task) so the
|
||||
* client never builds a body the server would reject outright. Truncated
|
||||
* silently, not flagged: unlike canvas_state (a serialized tree, where a cut
|
||||
* mid-structure looks like a smaller-but-still-valid tree and actively
|
||||
* misleads whoever reads it), a truncated free-text description is exactly
|
||||
* what it looks like -- text that stops partway through. Nothing about the
|
||||
* cut invents false structure, and the limit mirrors what the server would
|
||||
* have discarded anyway.
|
||||
*/
|
||||
export const MAX_DESCRIPTION_CHARS = 5000;
|
||||
|
||||
export type ReportCategory = 'bug' | 'confusing' | 'feature';
|
||||
|
||||
export interface BuildReportPayloadInput {
|
||||
category: ReportCategory;
|
||||
description: string;
|
||||
includeCanvas: boolean;
|
||||
siteId: number | null;
|
||||
siteDomain: string;
|
||||
pageId: string;
|
||||
pageSlug: string;
|
||||
editorVersion: string;
|
||||
userAgent: string;
|
||||
viewport: string;
|
||||
deviceMode: string;
|
||||
selectedType: string | null;
|
||||
consoleErrors: ConsoleErrorEntry[];
|
||||
canvasState: string | null;
|
||||
}
|
||||
|
||||
export interface ReportPayload {
|
||||
category: ReportCategory;
|
||||
description: string;
|
||||
site_id: number | null;
|
||||
site_domain: string;
|
||||
page_id: string;
|
||||
page_slug: string;
|
||||
editor_version: string;
|
||||
user_agent: string;
|
||||
viewport: string;
|
||||
device_mode: string;
|
||||
selected_type: string | null;
|
||||
console_errors: ConsoleErrorEntry[];
|
||||
canvas_state: string | null;
|
||||
/** Present only when the canvas state was dropped. */
|
||||
canvas_state_omitted?: 'size' | 'opt-out';
|
||||
}
|
||||
|
||||
function byteLength(value: string): number {
|
||||
// TextEncoder is standard in every environment this module actually runs
|
||||
// in (Node/vitest and every real browser target). The fallback below is
|
||||
// unreachable by design -- kept only so a missing global degrades to a
|
||||
// conservative-ish count rather than throwing, not because it's expected
|
||||
// to fire. (It undercounts multi-byte UTF-8, so treat it as dead code.)
|
||||
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(value).length;
|
||||
return value.length;
|
||||
}
|
||||
|
||||
function payloadBytes(payload: ReportPayload): number {
|
||||
return byteLength(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every return path funnels through here so the cap is checked
|
||||
* unconditionally, not just on the canvas-included branch. If the payload
|
||||
* is still over budget after every available reduction (dropping
|
||||
* canvas_state, truncating description), there is nothing left to cut --
|
||||
* returning it anyway would ship an oversized body that, if it carries
|
||||
* `canvas_state_omitted: 'size'`, falsely claims the drop fixed things.
|
||||
* Throwing surfaces that as a distinct, honest failure instead.
|
||||
*/
|
||||
function finalize(payload: ReportPayload): ReportPayload {
|
||||
if (payloadBytes(payload) > MAX_PAYLOAD_BYTES) {
|
||||
throw new Error(
|
||||
`Report payload is ${payloadBytes(payload)} bytes, over the ${MAX_PAYLOAD_BYTES}-byte cap, ` +
|
||||
'even with canvas_state dropped. Nothing left to reduce.'
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildReportPayload(input: BuildReportPayloadInput): ReportPayload {
|
||||
const description = input.description.trim().slice(0, MAX_DESCRIPTION_CHARS);
|
||||
|
||||
const base: ReportPayload = {
|
||||
category: input.category,
|
||||
description,
|
||||
site_id: input.siteId,
|
||||
site_domain: input.siteDomain,
|
||||
page_id: input.pageId,
|
||||
page_slug: input.pageSlug,
|
||||
editor_version: input.editorVersion,
|
||||
user_agent: input.userAgent,
|
||||
viewport: input.viewport,
|
||||
device_mode: input.deviceMode,
|
||||
selected_type: input.selectedType,
|
||||
console_errors: input.consoleErrors,
|
||||
canvas_state: null,
|
||||
};
|
||||
|
||||
if (!input.includeCanvas) {
|
||||
// The opt-out promise is inviolable: canvas_state is never populated
|
||||
// from input.canvasState on this path, no matter what else changes
|
||||
// below it. Only the cap is checked here, not whether to include canvas.
|
||||
return finalize({ ...base, canvas_state_omitted: 'opt-out' });
|
||||
}
|
||||
|
||||
if (!input.canvasState) return finalize(base);
|
||||
|
||||
const withCanvas: ReportPayload = { ...base, canvas_state: input.canvasState };
|
||||
if (payloadBytes(withCanvas) <= MAX_PAYLOAD_BYTES) return withCanvas;
|
||||
|
||||
// canvas_state alone pushed this over budget: drop it whole (never
|
||||
// truncate -- a partial Craft tree looks valid but is missing nodes,
|
||||
// which is worse than no tree) and re-measure. The 'size' marker is only
|
||||
// attached once we've confirmed dropping the canvas actually brought the
|
||||
// payload back under the cap; finalize() throws instead of returning it
|
||||
// if it didn't.
|
||||
return finalize({ ...base, canvas_state: null, canvas_state_omitted: 'size' });
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** Injected by vite.config.ts `define`. Absent under vitest -- always read
|
||||
* it through `utils/build-stamp.ts`'s `editorBuild()`, never directly. */
|
||||
declare const __EDITOR_BUILD__: string;
|
||||
@@ -1,6 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { execSync } from 'child_process'
|
||||
|
||||
/** Short git SHA + build date, injected as __EDITOR_BUILD__ so an issue
|
||||
* report identifies exactly which bundle produced it. package.json's
|
||||
* version is hand-maintained and never changes between builds. */
|
||||
const editorBuild = (() => {
|
||||
let sha = 'nogit'
|
||||
try {
|
||||
sha = execSync('git rev-parse --short HEAD', {
|
||||
cwd: __dirname,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).toString().trim()
|
||||
} catch {
|
||||
// Building outside a git checkout (release tarball) -- keep 'nogit'.
|
||||
}
|
||||
return `${sha}-${new Date().toISOString().slice(0, 10)}`
|
||||
})()
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -10,6 +27,9 @@ export default defineConfig({
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__EDITOR_BUILD__: JSON.stringify(editorBuild),
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
rollupOptions: {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,296 @@
|
||||
# Site Builder — Five User-Reported Issues
|
||||
|
||||
**Date:** 2026-08-08
|
||||
**Status:** Approved design, ready for implementation planning
|
||||
**Repos touched:** `cloud-hosting-platform/site-builder` (`craft/`) and `cloud-hosting-platform/whp` (`web-files/`, `sql/`, `scripts/`)
|
||||
|
||||
## Background
|
||||
|
||||
Five issues came in from people using the WHP site builder:
|
||||
|
||||
1. Elements can be dropped outside the main page; once there they cannot be selected or deleted.
|
||||
2. The HTML element exposes colour options that show in the builder but do not reach the live page.
|
||||
3. Users want a way to reset a page to blank.
|
||||
4. The Layers tab sometimes does not show everything.
|
||||
5. There is no way to report a builder problem from inside the builder.
|
||||
|
||||
Two of these are already explained by the source:
|
||||
|
||||
- **#2 is a render/export mismatch, not just a stray control.** `HtmlBlock`'s React render applies its `style` prop, but `HtmlBlock.toHtml()` returns only the DOMPurify-cleaned `code` and ignores `style` entirely. Every style control the panel offers is therefore dead on export. The controls themselves come from `GenericPropsEditor`, the catch-all panel that `GuidedStyles` routes HTML to via its `isUtility` branch (shared with Divider and Spacer).
|
||||
- **#4 is a data-model consequence.** `FeaturesGrid`, `Tabs`, `Accordion`, `PricingTable`, `Testimonials`, `Gallery`, `ContentSlider`, `NumberCounter`, `Menu`, `SocialLinks`, `Navbar` and `ContactForm` all hold their content in **array props**, not Craft child nodes, so they are genuinely leaf nodes in the Craft tree and `LayersPanel` has nothing to nest under them. `ColumnLayout` is the exception — it uses real `<Element canvas>` children, which is why columns nest correctly and nothing else does.
|
||||
|
||||
#1 is **not** diagnosed. Reproducing it is the first task of implementation, and the prevention fix follows the evidence rather than this document.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: the five items above, across the Craft.js editor, the WHP site-builder API, one new database table, and one new root-only admin page.
|
||||
|
||||
Out of scope: converting composite components from array props to real Craft children; any change to publish/export output beyond `HtmlBlock` no longer applying `style` in the editor; CSRF enforcement anywhere other than the one new endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Item 1 — Elements dropped outside the page
|
||||
|
||||
### Invariant
|
||||
|
||||
**Every node in editor state is reachable from `ROOT`.** Enforced at three independent points so no single failure strands a block.
|
||||
|
||||
### 1a. Prevention (drop time)
|
||||
|
||||
A drop whose resolved target is not a `ROOT` descendant is rejected: a new block from `BlocksPanel` is never created, and a moved block returns to its origin.
|
||||
|
||||
The mechanism is deliberately left open pending a live reproduction. Two structural candidates, both to be tested against a real editor session before anything is written:
|
||||
|
||||
- Craft.js's `DefaultEventHandlers` retains the **last valid drop indicator** when the pointer leaves every droppable region. The gutter beside `.canvas-device-frame` (visible at tablet/mobile device widths) and the `pointer-events: none` header/footer `ZonePreview` bands are both places where the pointer is over the canvas area but over no Craft node.
|
||||
- `Canvas.tsx` wraps `<Frame>` in a plain positioned `<div>`; drops landing on that wrapper resolve to no node.
|
||||
|
||||
**The plan's first task is reproduction, and its acceptance criterion is a written description of the actual mechanism.** No fix is written before that.
|
||||
|
||||
**Status (2026-08-08, final review pass): NOT implemented.** The reproduction task did not land during this branch — the drop-time mechanism described above (stale drop indicator vs. wrapper-`<div>` drop target, or something else) was never confirmed against a live editor session, so no prevention fix was written, per the acceptance criterion above. What shipped instead is 1b (repair) and 1c (recovery), plus the in-builder issue reporter from Item 5, whose plan was to capture a real repro of this specific bug from customers going forward. A code-review pass on this branch found that `useWhpApi.ts` and `PageContext.tsx`'s orphan-repair log lines used `console.warn`, which the reporter's console buffer does not capture (it patches `console.error` only) — fixed to `console.error` so a future in-the-wild repro of this exact symptom is actually captured. Reproducing the drop-time mechanism and writing the 1a prevention fix remains open work.
|
||||
|
||||
### 1b. Repair (load time)
|
||||
|
||||
`PageContext`'s deserialization path runs an orphan sweep before handing state to Craft:
|
||||
|
||||
- Parse the serialized craft state, walk every node's parent chain.
|
||||
- Any node whose chain does not terminate at `ROOT` is reattached to the end of `ROOT`'s children.
|
||||
- The repair is logged to the console with node id and type.
|
||||
|
||||
This is a **pure function** over serialized state — `repairOrphanNodes(serialized: string): { state: string; repaired: string[] }` — unit-testable with no browser and no Craft instance, following the precedent of `PageContext.treeToState.test.ts`.
|
||||
|
||||
### 1c. Recovery (always available)
|
||||
|
||||
`LayersPanel` gains an **Unplaced** group, rendered after the `ROOT` tree, listing any node in `state.nodes` not reachable from `ROOT`. Rows behave like normal layer rows: click to select, and the existing delete paths (context menu, Delete key) then work on them.
|
||||
|
||||
The group renders only when the set is non-empty, so ordinary sessions see no new UI.
|
||||
|
||||
### Error handling
|
||||
|
||||
If `repairOrphanNodes` throws on malformed state, it returns the input unchanged and logs — a page must never fail to load because the repair pass could not parse it.
|
||||
|
||||
---
|
||||
|
||||
## Item 2 — HTML block
|
||||
|
||||
### 2a. Only the Edit HTML control
|
||||
|
||||
- `GuidedStyles` gains an `isHtml` branch (`/^html$/i`) evaluated **before** `isUtility`, and `html` is removed from the `isUtility` regex so the two cannot both match.
|
||||
- New `panels/right/styles/HtmlStylePanel.tsx` renders the Edit HTML button and nothing else.
|
||||
- `HtmlCodeField` moves out of `GenericPropsEditor.tsx` into `panels/right/styles/HtmlCodeField.tsx` and is imported by `HtmlStylePanel`. `GenericPropsEditor` keeps `code` in `SKIP_PROPS` (harmless, and guards any future component carrying a `code` prop).
|
||||
|
||||
### 2b. Render must match export
|
||||
|
||||
`HtmlBlock`'s render stops applying the `style` prop. It keeps the selection outline and `minHeight: 40px` — both are editor affordances, not content.
|
||||
|
||||
This is the fix for the actual complaint. Removing the controls alone would leave already-styled blocks still rendering their dead styling in the editor and still not on the live page.
|
||||
|
||||
Existing stored `style` props are left in place: they are already ignored by `toHtml()`, so they have no output effect, and stripping them would mean touching every saved site.
|
||||
|
||||
### 2c. Smarter HTML editor
|
||||
|
||||
`ui/CodeEditor.tsx` already provides CodeMirror 6 with autocompletion, auto-closing tags and brackets, bracket matching, a fold gutter, `indentWithTab` and the one-dark theme. Nothing new is needed for tab-complete — but the component **lazy-loads CodeMirror and silently falls back to a plain `<textarea>`** when its chunks fail to load, which is a failure mode this project has shipped before. Verification is therefore a required task, not an assumption.
|
||||
|
||||
Additions:
|
||||
|
||||
- **Imperative handle on `CodeEditor`.** `insertAtCursor(text: string)` and `getValue()`, exposed via `forwardRef` + `useImperativeHandle`, dispatching a CodeMirror transaction at the current selection. In textarea-fallback mode the same handle operates on the textarea's `selectionStart`/`selectionEnd`, so the toolbar keeps working when CodeMirror is unavailable.
|
||||
- **Toolbar** above the editor in the Edit HTML modal: insert `div`, `section`, heading, paragraph, link, unordered list, image. Each inserts a snippet at the cursor and places the caret inside it.
|
||||
- **Colour control** in the toolbar: a swatch picker that inserts `style="color: #rrggbb"` at the cursor, so colour becomes part of the user's own markup — which does survive to the live page — instead of a dead panel prop.
|
||||
- **Format button** running a small built-in prettifier: `formatHtml(src: string): string`, a pure indent-only formatter (block-level tags on their own lines, two-space indent, inline tags untouched, contents of `<pre>` preserved verbatim). No `js-beautify` dependency; unit-tested directly.
|
||||
|
||||
The Head Code modal is **not** changed by this work. It uses the same `CodeEditor`, so it inherits the imperative handle harmlessly, but no toolbar is added there.
|
||||
|
||||
---
|
||||
|
||||
## Item 3 — Reset
|
||||
|
||||
Both actions affect the editor/staging state only. The live site changes on **Publish**, and both confirmation dialogs say so.
|
||||
|
||||
### 3a. Reset Page
|
||||
|
||||
- Entry point: the per-page row in `PagesPanel`, alongside the existing rename/delete controls. The TopBar is already crowded and collapses to an overflow menu at ≤768px.
|
||||
- Confirmation dialog naming the page.
|
||||
- Implementation: `actions.deserialize(EMPTY_PAGE_STATE)` where `EMPTY_PAGE_STATE` is a serialized single empty `Container` matching what `Canvas.tsx` mounts. Going through `deserialize` puts the reset in Craft's history, so **Ctrl+Z restores the page**. The dialog says so.
|
||||
- Only the active page's canvas is cleared. Header, footer, other pages, per-page SEO and design tokens are untouched.
|
||||
- The control is offered on **page rows only**. The Header and Footer entries in `PagesPanel` do not get it — a blank header or footer is what the existing zone editing already allows, and a reset there would silently change every page on the site.
|
||||
|
||||
### 3b. Reset Entire Site
|
||||
|
||||
- Entry point: a danger zone at the bottom of `SiteDesignPanel`.
|
||||
- Guard: the user types the site domain to enable the button, matching the pattern used elsewhere in WHP for destructive actions.
|
||||
- Effect: all pages replaced by a single empty page named Home (slug `index`), header and footer cleared, design tokens restored to `SiteDesignContext` defaults, `headCode` cleared.
|
||||
- **Not undoable.** The dialog states this explicitly and states that the live site remains as-published until the user publishes again.
|
||||
- Per-page SEO is cleared with its page. Uploaded assets are **not** deleted — they are shared, referenced by URL, and deleting them is a separate destructive action with its own blast radius.
|
||||
|
||||
Auto-save runs every 30 seconds, so a reset persists to staging shortly after it happens. This is stated in the Reset Entire Site dialog.
|
||||
|
||||
---
|
||||
|
||||
## Item 4 — Layers tab completeness
|
||||
|
||||
### 4a. Virtual rows for array-prop content
|
||||
|
||||
A registry maps a component `displayName` to the prop holding its items and the per-item field to use as a label:
|
||||
|
||||
```ts
|
||||
// panels/left/layers-virtual-rows.ts
|
||||
export const VIRTUAL_CHILD_PROPS: Record<string, { prop: string; label: string; fallback: string }> = {
|
||||
'Features Grid': { prop: 'features', label: 'title', fallback: 'Feature' },
|
||||
Tabs: { prop: 'tabs', label: 'title', fallback: 'Tab' },
|
||||
Accordion: { prop: 'items', label: 'title', fallback: 'Item' },
|
||||
'Pricing Table': { prop: 'plans', label: 'name', fallback: 'Plan' },
|
||||
Testimonials: { prop: 'testimonials', label: 'name', fallback: 'Testimonial' },
|
||||
Gallery: { prop: 'images', label: 'alt', fallback: 'Image' },
|
||||
'Content Slider': { prop: 'slides', label: 'title', fallback: 'Slide' },
|
||||
'Number Counter': { prop: 'counters', label: 'label', fallback: 'Counter' },
|
||||
Menu: { prop: 'links', label: 'text', fallback: 'Link' },
|
||||
'Social Links': { prop: 'links', label: 'platform', fallback: 'Link' },
|
||||
Navbar: { prop: 'links', label: 'text', fallback: 'Link' },
|
||||
'Contact Form': { prop: 'fields', label: 'label', fallback: 'Field' },
|
||||
};
|
||||
```
|
||||
|
||||
Exact prop and label keys are **verified against each component's `craft.props` defaults during implementation**; the table above is the starting point, not the contract. A registry entry whose prop is absent or not an array yields no rows rather than throwing.
|
||||
|
||||
`deriveVirtualRows(displayName, props)` is a pure function returning `{ index, label }[]`, unit-tested independently of React.
|
||||
|
||||
Rendering: virtual rows appear as children of their node, visually distinguished from real nodes (dimmer text, an item glyph rather than a component icon, no disclosure triangle) so they do not read as separately draggable or deletable.
|
||||
|
||||
### 4b. Selecting a virtual row
|
||||
|
||||
Clicking one selects the parent node (so the right panel opens the correct array editor) **and** requests that item's card be scrolled into view. The request travels through a small React context (`{ nodeId, prop, index }`), which the array editors (`ArrayItemFields`, `FeaturesEditor`, `SectionTypePanel`) consume to `scrollIntoView` the matching card. If an editor does not consume it, the click still selects the parent — the scroll is an enhancement, never a dependency.
|
||||
|
||||
### 4c. Unplaced group
|
||||
|
||||
As described in Item 1c.
|
||||
|
||||
### 4d. Panel scrolling
|
||||
|
||||
`LayersPanel`'s tree gets an explicit scroll container so deep or long trees remain fully reachable. This was a secondary hypothesis for the report; it is cheap and correct regardless.
|
||||
|
||||
### Explicitly not doing
|
||||
|
||||
Converting composites to real Craft children. That is a stored-state migration across every existing customer site, for cosmetic tree parity, with real risk to published output.
|
||||
|
||||
---
|
||||
|
||||
## Item 5 — In-builder issue reporting
|
||||
|
||||
### 5a. Editor UI
|
||||
|
||||
- A bug icon in the TopBar (and in `TopBarOverflowMenu` for ≤768px) opens a Report an Issue modal.
|
||||
- Fields: category (`bug` / `confusing` / `feature`), a required description, and a default-on checkbox **"Include this page's contents to help debugging"** with a one-line plain-language note about what that sends. Unchecking it omits the canvas state; everything else in the payload still goes.
|
||||
- On success the modal shows **`Report #SB-1234 submitted`** and closes on acknowledgement.
|
||||
- On failure it keeps the user's text and offers retry — a report must never be silently lost.
|
||||
|
||||
### 5b. Captured context
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| `username` | server-side from `AUTH_USER`; never accepted from the client |
|
||||
| `site_id`, `site_domain` | `WHP_CONFIG` |
|
||||
| `page_id`, `page_slug` | `PageContext` |
|
||||
| `editor_version` | build stamp (below) |
|
||||
| `user_agent`, `viewport` | `navigator` / `window` |
|
||||
| `device_mode` | current `DeviceMode` |
|
||||
| `selected_type` | `displayName` of the selected node, or null |
|
||||
| `console_errors` | last 20 entries from a ring buffer |
|
||||
| `canvas_state` | `query.serialize()` of the current page, omitted if the checkbox is unchecked |
|
||||
|
||||
**Build stamp.** `vite.config.ts` gains a `define` for `__EDITOR_BUILD__`, set from the short git SHA plus build date at build time, so a report identifies exactly which bundle produced it. `craft/package.json`'s `2.0.0` is not build-specific and is not sufficient.
|
||||
|
||||
**Console error ring buffer.** Installed in `main.tsx` at boot: patches `console.error` and adds `window.onerror` / `unhandledrejection` listeners, retaining the last 20 entries as `{ ts, message }` with each message truncated to 500 characters. It always chains to the original handler. Message text only — no stack traces, which are minified and would leak file paths for little benefit.
|
||||
|
||||
**Cap.** The whole JSON payload is capped at 512 KB. When `canvas_state` would breach it, the canvas state is dropped and a `canvas_state_omitted: "size"` marker is sent so the report is not silently truncated into something misleading.
|
||||
|
||||
### 5c. Endpoint
|
||||
|
||||
`action=report_issue` added to `web-files/api/site-builder.php`, handled by `handleReportIssue($pdo, $isRoot)`:
|
||||
|
||||
- POST only.
|
||||
- Session auth via the existing `AUTH_USER` / `HOME_DIR` gate at the top of the file.
|
||||
- **CSRF validated** with `validate_csrf_token()` from `auto-prepend.php`. `WHP_CONFIG` already ships `csrfToken` to the editor. This is a new state-changing endpoint, so it validates now rather than waiting on the fleet-wide CSRF rollout.
|
||||
- Ownership: a non-root user may only file against a site they own, checked the same way the other handlers in this file check site ownership.
|
||||
- **Rate limit:** at most 5 reports per user per hour, enforced by counting recent rows in the reports table itself (the pattern `Sitesmith.php` uses). Over the limit returns HTTP 429 with a plain message.
|
||||
- Validation: description 1–5000 characters after trimming; category must be one of the three; oversized `canvas_state` rejected rather than truncated.
|
||||
- Response: `{ success: true, reference: "SB-1234", id: 1234 }`.
|
||||
|
||||
### 5d. Schema
|
||||
|
||||
New table, idempotent migration in `sql/migrations/staging/`, canonical schema regenerated per the `whp-add-migration` skill:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS whp.site_builder_reports (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
username VARCHAR(64) NOT NULL,
|
||||
site_id INT NULL,
|
||||
site_domain VARCHAR(255) NULL,
|
||||
page_slug VARCHAR(255) NULL,
|
||||
category ENUM('bug','confusing','feature') NOT NULL DEFAULT 'bug',
|
||||
description TEXT NOT NULL,
|
||||
editor_version VARCHAR(64) NULL,
|
||||
user_agent VARCHAR(512) NULL,
|
||||
viewport VARCHAR(32) NULL,
|
||||
device_mode VARCHAR(16) NULL,
|
||||
selected_type VARCHAR(64) NULL,
|
||||
console_errors JSON NULL,
|
||||
canvas_state LONGTEXT NULL,
|
||||
status ENUM('new','triaged','fixed','wontfix') NOT NULL DEFAULT 'new',
|
||||
admin_notes TEXT NULL,
|
||||
INDEX idx_status_created (status, created_at),
|
||||
INDEX idx_user_created (username, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
`AUTO_INCREMENT` and the `PRIMARY KEY` are stated explicitly and must survive any future migration of this table.
|
||||
|
||||
### 5e. Admin page
|
||||
|
||||
`web-files/pages/site-builder-reports.php`, registered per the `whp-admin-page-registration` skill: `$allowed_pages`, `$page_permissions` (root only), a sidebar entry under the existing site-builder grouping, and a documentation entry.
|
||||
|
||||
- List view: reference, date, user, site, category, status, first line of the description. Filter by status and category.
|
||||
- Detail view: full description and all captured context.
|
||||
- Status and notes are editable inline.
|
||||
- **"Copy for Claude"** button emitting one compact JSON object (context plus description, canvas state included only when present) for pasting straight into a session. This is what makes the feature pay off operationally.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
**Vitest (`craft/`), pure functions first:**
|
||||
|
||||
- `repairOrphanNodes` — orphan reattached, healthy state unchanged, malformed input returns input.
|
||||
- `deriveVirtualRows` — each registry entry, missing prop, non-array prop, missing label field falls back.
|
||||
- `formatHtml` — nesting, inline tags untouched, `<pre>` preserved, idempotent on already-formatted input.
|
||||
- `buildReportPayload` — checkbox off omits canvas state, oversize drops canvas state and sets the marker, all context fields present.
|
||||
- Console ring buffer — retains 20, truncates long messages, chains to the original handler.
|
||||
|
||||
**React component tests:** `HtmlStylePanel` renders only the Edit HTML control; `GuidedStyles` routes HTML to it and not to `GenericPropsEditor`; Reset Page dialog wiring; Unplaced group appears only when orphans exist; virtual rows select their parent.
|
||||
|
||||
**Export test:** an `HtmlBlock.toHtml` case asserting `style` never reaches output, and a render test asserting the editor no longer applies it — the two sides of the reported mismatch, locked down together.
|
||||
|
||||
**PHP:** a validation test script for `handleReportIssue` in the style of `scripts/test-site-builder-asset-urls.php` — category validation, description bounds, size cap, rate-limit counting. Follows the project's "verify through the web path, not bare `php -r`" rule for anything touching `open_basedir`-sensitive paths.
|
||||
|
||||
**Manual, on the canary (192.168.1.148) before any prod host:**
|
||||
|
||||
- `curl -s -o /dev/null -w '%{http_code}' <host>/site-builder/js/index2.js` returns 200 — CodeMirror chunks reachable, editor not degraded to the textarea fallback.
|
||||
- File a report end to end and confirm it lands in `whp.site_builder_reports` and renders on the admin page.
|
||||
- Reproduce the original off-canvas drop and confirm it is now impossible.
|
||||
|
||||
## Deployment
|
||||
|
||||
Standard path, owned by the `whp-deploy` skill:
|
||||
|
||||
1. `cd /workspace/site-builder/craft && npm run build`
|
||||
2. Copy `dist/index.html`, `dist/css/editor.css` and **all** `dist/js/*.js` into `web-files/site-builder/` — copying only `editor.js` breaks the code editor at runtime.
|
||||
3. Commit both repos (site-builder source, whp bundle).
|
||||
4. `build-release.sh` on the build server.
|
||||
5. Roll out 192.168.1.148 → whp02 → sdbees → whp01, verifying each before the next.
|
||||
|
||||
Published sites are static snapshots. None of these changes alter published output, so no site needs re-publishing as a result of this work.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Item 1 root cause is unknown at design time.** If reproduction fails, prevention cannot be written responsibly. Repair (1b) and recovery (1c) still ship and still solve the user-visible problem — a stranded element becomes deletable — so the item is not blocked, but the plan must not pretend prevention landed if it did not.
|
||||
- **`canvas_state` is customer content.** It is stored in the WHP database and readable by root only. The checkbox and its note exist so this is never a surprise.
|
||||
- **Ring buffer patches `console.error` globally.** It must always chain to the original, or it silently swallows diagnostics for everyone.
|
||||
Reference in New Issue
Block a user