feat(editor): wire business panel, section commands and Layers follow-ups

- GuidedStyles classifies Opening Hours / Call Bar / Reviews / Booking to
  BusinessStylePanel (#60) and shows a Regenerate button in the type
  header, hidden when edit.regenerate-section's when() is false (#58)
- LayersPanel TYPE_ICONS for the four business blocks (FA 4.7) (#60)
- Context menu entries 'Save as section…' and 'Regenerate this section'
  invoke site.save-as-section / edit.regenerate-section (#52, #58)
- commands: useCommandState(id) hook (registered/enabled/run)
- Layers: keep dropEffect 'move' on refused targets so drop fires and the
  refusal notice shows; renaming to the default name stores no
  custom.label (#47)

Refs #60
Refs #58
Refs #52
Refs #47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 12:45:16 -07:00
1 parent ebf75ebfe0
commit 9697dc95a3
8 files changed
+274 -6

No files matched your search

+19 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import type { DeviceMode } from '../types';
import { useDevice } from '../state/DeviceContext';
@@ -213,3 +213,21 @@ export function useCommandShortcuts(): void {
export function useRegisterCommands(cmds: Command[], deps: unknown[]): void {
useEffect(() => registerCommands(cmds), deps); // eslint-disable-line react-hooks/exhaustive-deps
}
/**
* One registry command as a UI affordance (context-menu entry, header button):
* whether it is registered at all, whether its `when()` passes for the live
* selection, and a runner. Re-renders when the registry or the selection changes.
*/
export function useCommandState(id: string): { registered: boolean; enabled: boolean; run: () => Promise<boolean> } {
const ctx = useCommandContext();
const runCommand = useRunCommand();
const [, bump] = useReducer((n: number) => n + 1, 0);
useEffect(() => subscribeCommands(bump), []);
const cmd = getCommand(id);
return {
registered: !!cmd,
enabled: !!cmd && isEnabled(cmd, liveContext(ctx)),
run: () => runCommand(id),
};
}
@@ -10,6 +10,8 @@ import { SaveGuardProvider } from '../../state/SaveGuardContext';
import { SiteDesignProvider, useSiteDesign, SiteDesignContextValue } from '../../state/SiteDesignContext';
import { CanvasTokenStyle } from '../../editor/CanvasTokenStyle';
import { setStyleClipboard, getStyleClipboard, extractStyle } from '../../utils/style-clipboard';
import { registerCommand } from '../../editor/commands';
import { isTopLevelSection } from '../../sections/catalog';
let h: EditorHarness;
beforeEach(() => setStyleClipboard(null));
@@ -122,3 +124,45 @@ describe('ContextMenu style items', () => {
expect(document.querySelector('.wsb-presets-popover')!.textContent).toContain('No saved presets');
});
});
describe('ContextMenu section commands (#52 / #58)', () => {
const offs: Array<() => void> = [];
afterEach(() => { offs.splice(0).forEach((off) => off()); });
const register = (id: string, run: (ctx: { selectedIds: string[] }) => void) =>
offs.push(registerCommand({ id, title: id, group: 'site', when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]), run }));
test('entries are absent while the commands are not registered', () => {
h = renderEditorHarness();
const a = add(React.createElement(Heading, {}));
h.act(() => { h.actions.selectNode(a); });
mountMenu(a);
expect(btn('Save as section')).toBeUndefined();
expect(btn('Regenerate this section')).toBeUndefined();
});
test('"Save as section" and "Regenerate this section" invoke their registry commands for the selected top-level node', () => {
const saved: string[][] = [];
const regenerated: string[][] = [];
register('site.save-as-section', (ctx) => { saved.push(ctx.selectedIds); });
register('edit.regenerate-section', (ctx) => { regenerated.push(ctx.selectedIds); });
h = renderEditorHarness();
const a = add(React.createElement(Heading, {}));
h.act(() => { h.actions.selectNode(a); });
mountMenu(a);
expect(btn('Save as section').disabled).toBe(false);
expect(btn('Regenerate this section').disabled).toBe(false);
click('Save as section');
click('Regenerate this section');
expect(saved).toEqual([[a]]);
expect(regenerated).toEqual([[a]]);
});
test('disabled when the command\'s when() is false (ROOT)', () => {
register('site.save-as-section', () => {});
register('edit.regenerate-section', () => {});
h = renderEditorHarness();
mountMenu('ROOT');
expect(btn('Save as section').disabled).toBe(true);
expect(btn('Regenerate this section').disabled).toBe(true);
});
});
+20 -1
View File
@@ -4,7 +4,7 @@ import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
import { getCommand } from '../../editor/commands';
import { getCommand, useCommandState } from '../../editor/commands';
import { formatShortcut } from '../../utils/shortcuts';
import { useNodeActions } from '../../hooks/useNodeActions';
import { useNotices } from '../../state/NoticeContext';
@@ -128,6 +128,9 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
// the exact same behavior. Wrapped here purely to also `onClose()` the
// menu after each action, same as before the extraction.
const nodeActions = useNodeActions(nodeId);
// #52 / #58: section commands live in the registry; the menu only invokes them.
const saveAsSection = useCommandState('site.save-as-section');
const regenerateSection = useCommandState('edit.regenerate-section');
const duplicate = useCallback(() => {
nodeActions.duplicate();
@@ -361,6 +364,22 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
disabled: isRoot || !!instance,
dividerAfter: true,
},
...(saveAsSection.registered
? [{
label: 'Save as section\u2026',
icon: 'archive',
action: () => { void saveAsSection.run(); onClose(); },
disabled: isRoot || !saveAsSection.enabled,
}]
: []),
...(regenerateSection.registered
? [{
label: 'Regenerate this section',
icon: 'refresh',
action: () => { void regenerateSection.run(); onClose(); },
disabled: isRoot || !regenerateSection.enabled,
}]
: []),
...(instance
? [
{ label: 'Edit master', icon: 'globe', action: editMaster },
@@ -271,6 +271,38 @@ describe('LayersPanel drag / rename / hide (#47)', () => {
hn.unmount();
});
test('a disallowed target keeps dropEffect "move" so the browser still fires drop (refusal notice shows)', () => {
const hn = mount();
const target = rowOf(hn, 'inner');
target.getBoundingClientRect = () => ({ top: 0, height: 30, left: 0, right: 0, bottom: 30, width: 0, x: 0, y: 0, toJSON() {} }) as DOMRect;
hn.act(() => { fire(rowOf(hn, 'sec'), 'dragstart'); });
let over: any;
hn.act(() => { over = fire(target, 'dragover', { clientY: 15 }); });
expect(over.defaultPrevented).toBe(true);
expect(over.dataTransfer.dropEffect).toBe('move');
// ...but no drop line is drawn for the refused target.
expect(hn.container.querySelector('[data-drop]')).toBeNull();
hn.unmount();
});
test('renaming to the unchanged default name stores no custom.label; to the default again clears one', () => {
const hn = mount();
const rename = (value: string) => {
hn.act(() => { rowOf(hn, 'h').dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); });
const input = hn.container.querySelector('.layer-rename-input') as HTMLInputElement;
input.value = value;
hn.act(() => { input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
};
rename('Heading');
expect('label' in hn.query.node('h').get().data.custom).toBe(false);
expect(JSON.parse(hn.getSerialized()).h.custom).toEqual({});
rename('Pricing');
expect(hn.query.node('h').get().data.custom.label).toBe('Pricing');
rename(' Heading ');
expect('label' in hn.query.node('h').get().data.custom).toBe(false);
hn.unmount();
});
test('linked slots are not draggable; normal rows are', () => {
const hn = mount();
expect(rowOf(hn, 'inner').draggable).toBe(false);
@@ -357,3 +389,22 @@ describe('LayersPanel drag / rename / hide (#47)', () => {
hn.unmount();
});
});
describe('LayersPanel type icons for local-business blocks (#60)', () => {
test.each([
['OpeningHours', 'Opening Hours', 'fa-clock-o'],
['StickyCallBar', 'Call Bar', 'fa-phone'],
['ReviewsStrip', 'Reviews', 'fa-star'],
['BookingEmbed', 'Booking', 'fa-calendar'],
])('%s (%s) row uses %s', (resolvedName, displayName, icon) => {
const n = {
type: { resolvedName }, isCanvas: false, props: {}, displayName,
custom: {}, hidden: false, nodes: [], linkedNodes: {}, parent: 'ROOT',
};
const harness = renderEditorHarness({ initialState: stateWith({ b: n }, ['b']) });
harness.mountChild(<LayerFocusProvider><LayersPanel /></LayerFocusProvider>);
const row = harness.container.querySelector('.layer-node-row[data-layer-id="b"]')!;
expect(row.querySelector(`i.${icon}`)).not.toBeNull();
harness.unmount();
});
});
+14 -3
View File
@@ -63,6 +63,11 @@ const TYPE_ICONS: Record<string, string> = {
'Submit Button': 'fa-paper-plane',
'Contact Form': 'fa-envelope',
'Subscribe Form': 'fa-paper-plane',
// Local business (#60)
'Opening Hours': 'fa-clock-o',
'Call Bar': 'fa-phone',
Reviews: 'fa-star',
Booking: 'fa-calendar',
};
const DEFAULT_ICON = 'fa-cube';
@@ -294,12 +299,15 @@ const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
const commitRename = useCallback((value: string) => {
ui.set({ renamingId: null });
const v = value.trim();
const typed = value.trim();
// Typing the default name back means "no custom label": never store a
// redundant custom.label equal to the type name.
const v = typed === displayName ? '' : typed;
if (v === customLabel) return;
actions.setCustom(nodeId, (c: any) => {
if (v) c.label = v; else delete c.label;
});
}, [ui, actions, nodeId, customLabel]);
}, [ui, actions, nodeId, customLabel, displayName]);
const toggleHidden = useCallback((e: React.SyntheticEvent) => {
e.stopPropagation();
@@ -338,7 +346,10 @@ const LayerNodeImpl: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
e.preventDefault();
const drop = resolveDrop(e);
const ok = !!drop && dragId !== nodeId && moveLayerAllowed(query, dragId, drop.parentId);
if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'move' : 'none';
// Always 'move': with dropEffect 'none' the browser never fires `drop`, so
// a refused target could not explain itself. Disallowed targets get no drop
// line here and are refused (with a notice) in handleDrop via moveLayer.
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
ui.set(ok && drop ? { dropTargetId: nodeId, dropWhere: drop.where } : { dropTargetId: null, dropWhere: null });
};
const handleDrop = (e: React.DragEvent<HTMLElement>) => {
@@ -0,0 +1,93 @@
import { describe, test, expect, afterEach } from 'vitest';
import React from 'react';
import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness';
import { SiteDesignProvider } from '../../state/SiteDesignContext';
import { PageProvider } from '../../state/PageContext';
import { SitesmithProvider } from '../../state/SitesmithContext';
import { insertBlock } from '../left/blocks-registry';
import { Heading } from '../../components/basic/Heading';
import { Container } from '../../components/layout/Container';
import { registerCommand } from '../../editor/commands';
import { isTopLevelSection } from '../../sections/catalog';
import { GuidedStyles } from './GuidedStyles';
/** Integration follow-ups: #60 business-panel classification and the #58 Regenerate button. */
let h: EditorHarness;
const offs: Array<() => void> = [];
afterEach(() => { h?.unmount(); offs.splice(0).forEach((off) => off()); });
function mount() {
h = renderEditorHarness();
h.mountChild(
<PageProvider><SiteDesignProvider><SitesmithProvider>
<GuidedStyles />
</SitesmithProvider></SiteDesignProvider></PageProvider>,
);
}
const select = (id: string) => h.act(() => h.actions.selectNode(id));
const header = () => h.container.querySelector('[data-testid="type-header"]')?.textContent?.trim();
const text = () => h.container.textContent || '';
const regenBtn = () => h.container.querySelector<HTMLButtonElement>('button[aria-label="Regenerate this section"]');
describe('GuidedStyles: local-business blocks use BusinessStylePanel (#60)', () => {
test.each([
['opening-hours', 'Opening Hours', 'Label color'],
['call-bar', 'Call Bar', 'Call button label'],
['reviews-strip', 'Reviews', 'Card background'],
['booking-embed', 'Booking', 'Booking URL (Calendly / Square)'],
])('%s -> header "%s" and the business control "%s"', (blockId, displayName, label) => {
mount();
let id: string | null = null;
h.act(() => { id = insertBlock(h.query, h.actions, blockId); });
select(id!);
expect(header()).toBe(displayName);
expect(text()).toContain(label);
expect(h.container.querySelector('.guided-type-header .fa-briefcase')).not.toBeNull();
});
});
describe('GuidedStyles: Regenerate button (#58)', () => {
const add = (el: React.ReactElement, parent = 'ROOT'): string => {
let id = '';
h.act(() => {
const tree = h.query.parseReactElement(el).toNodeTree();
h.actions.addNodeTree(tree, parent);
id = tree.rootNodeId;
});
return id;
};
const registerRegenerate = (runs: string[][]) =>
offs.push(registerCommand({
id: 'edit.regenerate-section', title: 'Regenerate this section', group: 'edit',
when: (ctx) => ctx.selectedIds.length === 1 && isTopLevelSection(ctx.query, ctx.selectedIds[0]),
run: (ctx) => { runs.push(ctx.selectedIds); },
}));
test('shown for a top-level section and runs the registry command', () => {
const runs: string[][] = [];
registerRegenerate(runs);
mount();
const section = add(<Container />);
select(section);
expect(regenBtn()).not.toBeNull();
h.act(() => { regenBtn()!.click(); });
expect(runs).toEqual([[section]]);
});
test('hidden when the command\'s when() is false (nested node)', () => {
registerRegenerate([]);
mount();
const section = add(<Container />);
const inner = add(<Heading />, section);
select(inner);
expect(header()).toBe('Heading');
expect(regenBtn()).toBeNull();
});
test('hidden when the command is not registered (standalone, no Sitesmith)', () => {
mount();
const section = add(<Container />);
select(section);
expect(regenBtn()).toBeNull();
});
});
+22 -1
View File
@@ -18,10 +18,12 @@ import {
BackgroundSectionStylePanel,
GenericPropsEditor,
HtmlStylePanel,
BusinessStylePanel,
} from './styles';
import { DeviceBanner } from './styles/responsive-panel';
import { setBulkSelection } from './styles/shared';
import { CORE_COMMANDS } from '../../editor/core-commands';
import { useCommandState } from '../../editor/commands';
import { useGlobalSections } from '../../state/GlobalSectionsContext';
import type { OverridePatch } from '../../utils/global-sections';
@@ -152,6 +154,8 @@ export const GuidedStyles: React.FC<{
emptyFallback?: 'site' | 'placeholder';
}> = ({ emptyFallback = 'site' }) => {
const { open: openSitesmith } = useSitesmithModal();
// #58: Regenerate lives in the registry (Sitesmith, WHP only); hidden when its when() is false.
const regenerate = useCommandState('edit.regenerate-section');
const { query } = useEditor();
const { selected, selectedType, nodeProps, selectedIds, mixedTypes, inInstance } = useEditor((state) => {
@@ -245,6 +249,8 @@ export const GuidedStyles: React.FC<{
// Utility types that need minimal controls. HTML is deliberately NOT here
// -- it gets HtmlStylePanel, which shows the code editor and nothing else.
const isUtility = /^divider$|^spacer$/i.test(typeName);
// #60 local-business blocks (displayNames in components/business/*).
const isBusiness = /^opening hours$|^call bar$|^reviews$|^booking$/i.test(typeName);
// Icon for the type badge
const typeIcon = isText ? 'fa-font'
@@ -260,6 +266,7 @@ export const GuidedStyles: React.FC<{
: isSection ? 'fa-th-large'
: isHtml ? 'fa-code'
: isUtility ? 'fa-ellipsis-h'
: isBusiness ? 'fa-briefcase'
: 'fa-cube';
const handleAskSitesmith = () => {
@@ -289,6 +296,17 @@ export const GuidedStyles: React.FC<{
>
<i className="fa fa-magic" /> Ask Sitesmith
</button>
{regenerate.enabled && (
<button
type="button"
className="guided-regenerate-btn"
onClick={() => { void regenerate.run(); }}
title="Regenerate this section with Sitesmith"
aria-label="Regenerate this section"
>
<i className="fa fa-refresh" aria-hidden /> Regenerate
</button>
)}
</div>
{inInstance && <GlobalInstancePanel selectedId={selected} nodeProps={nodeProps} />}
{!inInstance && <>
@@ -336,8 +354,11 @@ export const GuidedStyles: React.FC<{
{/* UTILITY (Divider, Spacer) -- use generic but it works well for these */}
{isUtility && <GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />}
{/* LOCAL BUSINESS (Opening Hours, Call Bar, Reviews, Booking) */}
{isBusiness && <BusinessStylePanel selectedId={selected} nodeProps={nodeProps} />}
{/* FALLBACK: Anything not matched above */}
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && (
{!isText && !isButton && !isImage && !isBgSection && !isContainer && !isHero && !isNav && !isMedia && !isForm && !isSocial && !isPricing && !isSection && !isUtility && !isHtml && !isBusiness && (
<GenericPropsEditor selectedId={selected} nodeProps={nodeProps} typeName={typeName} />
)}
</>}
+11
View File
@@ -3324,3 +3324,14 @@ body {
}
.section-lib-chip-global i { margin-right: 3px; }
.section-lib-usage { display: block; font-size: 11px; color: var(--color-text-muted); }
/* #followups -- integration follow-ups (#58 Regenerate button in the type header) */
.guided-regenerate-btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 8px; font-size: 11px; font-weight: 600;
color: var(--color-text); background: transparent;
border: 1px solid var(--color-border); border-radius: var(--radius-sm); cursor: pointer;
}
.guided-regenerate-btn:hover { background: var(--color-bg-hover); }
.guided-regenerate-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 1px; }
/* /#followups */