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