From e8e8c14e8ea74412b7ef8667660457235997e16a Mon Sep 17 00:00:00 2001 From: Steven Sojka Date: Thu, 2 Oct 2025 15:37:52 -0500 Subject: [PATCH 01/12] feature(design): add drag and drop support for regions --- src/design/messaging-api/domain-types.ts | 10 +- .../messaging-api/messenging-api.test.ts | 4 +- .../react/components/RegionDecorator.tsx | 14 ++- .../react/context/DesignStateContext.tsx | 25 ++++- .../react/hooks/useExternalDragHandler.ts | 59 ++++++++++ .../react/hooks/useExternalDragInteraction.ts | 103 ++++++++++++++++++ src/design/react/hooks/useInteraction.ts | 6 +- .../react/hooks/useRegionDecoratorClasses.ts | 27 +++++ 8 files changed, 235 insertions(+), 13 deletions(-) create mode 100644 src/design/react/hooks/useExternalDragHandler.ts create mode 100644 src/design/react/hooks/useExternalDragInteraction.ts create mode 100644 src/design/react/hooks/useRegionDecoratorClasses.ts diff --git a/src/design/messaging-api/domain-types.ts b/src/design/messaging-api/domain-types.ts index 2326ae11..efb34c54 100644 --- a/src/design/messaging-api/domain-types.ts +++ b/src/design/messaging-api/domain-types.ts @@ -421,14 +421,13 @@ export interface ComponentDeletedEvent extends WithBaseEvent, WithComponentId { */ export interface ComponentAddedToRegionEvent< TProps extends Record = Record -> extends WithBaseEvent, - WithComponentId { +> extends WithBaseEvent { eventType: 'ComponentAddedToRegion'; /** * The specifier of the component to add. * This will be used to lookup the component in the registry. */ - componentSpecifier: string; + componentType: string; /** * The properties of the component to add. * These will be used to initialize the component. @@ -450,9 +449,12 @@ export interface ComponentAddedToRegionEvent< */ export interface ComponentDragStartedEvent extends WithBaseEvent, - WithComponentId, Partial { eventType: 'ComponentDragStarted'; + /** + * The type of the component that is being dragged. + */ + componentType: string; } /** * Emits when an error occurs. diff --git a/src/design/messaging-api/messenging-api.test.ts b/src/design/messaging-api/messenging-api.test.ts index e853c75e..30fc46c2 100644 --- a/src/design/messaging-api/messenging-api.test.ts +++ b/src/design/messaging-api/messenging-api.test.ts @@ -356,7 +356,7 @@ describe('Messaging API', () => { describe.each` method | eventName | payload - ${'addComponentToRegion'} | ${'ComponentAddedToRegion'} | ${{componentId: 'test-component', componentSpecifier: 'test-specifier', componentProperties: {test: 'value'}, targetComponentId: 'target-component', targetRegionId: 'test-region'}} + ${'addComponentToRegion'} | ${'ComponentAddedToRegion'} | ${{componentId: 'test-component', componentType: 'test-specifier', componentProperties: {test: 'value'}, targetComponentId: 'target-component', targetRegionId: 'test-region'}} ${'moveComponentToRegion'} | ${'ComponentMovedToRegion'} | ${{componentId: 'test-component', targetComponentId: 'target-component', targetRegionId: 'target-region', sourceRegionId: 'source-region', sourceComponentId: 'source-component'}} ${'notifyClientReady'} | ${'ClientReady'} | ${{clientId: 'test-client'}} ${'startComponentDrag'} | ${'ComponentDragStarted'} | ${{componentId: 'test-component', x: 100, y: 200}} @@ -400,7 +400,7 @@ describe('Messaging API', () => { describe.each` method | eventName | payload - ${'addComponentToRegion'} | ${'ComponentAddedToRegion'} | ${{componentId: 'test-component', componentSpecifier: 'test-specifier', componentProperties: {test: 'value'}, targetComponentId: 'target-component', targetRegionId: 'test-region'}} + ${'addComponentToRegion'} | ${'ComponentAddedToRegion'} | ${{componentId: 'test-component', componentType: 'test-specifier', componentProperties: {test: 'value'}, targetComponentId: 'target-component', targetRegionId: 'test-region'}} ${'moveComponentToRegion'} | ${'ComponentMovedToRegion'} | ${{componentId: 'test-component', targetComponentId: 'target-component', targetRegionId: 'target-region', sourceRegionId: 'source-region', sourceComponentId: 'source-component'}} ${'startComponentDrag'} | ${'ComponentDragStarted'} | ${{componentId: 'test-component', x: 100, y: 200}} ${'hoverInToComponent'} | ${'ComponentHoveredIn'} | ${{componentId: 'test-component'}} diff --git a/src/design/react/components/RegionDecorator.tsx b/src/design/react/components/RegionDecorator.tsx index ddc746af..9b4ca472 100644 --- a/src/design/react/components/RegionDecorator.tsx +++ b/src/design/react/components/RegionDecorator.tsx @@ -7,13 +7,23 @@ import React from 'react'; import {useDesignContext} from '../context/DesignContext'; import {ComponentDecoratorProps} from './component.types'; +import {useExternalDragHandler} from '../hooks/useExternalDragHandler'; +import {useRegionDecoratorClasses} from '../hooks/useRegionDecoratorClasses'; export function createReactRegionDesignDecorator( Region: React.ComponentType ): (props: ComponentDecoratorProps) => JSX.Element { return (props: ComponentDecoratorProps) => { - const {children, ...componentProps} = props; + const {designMetadata, children, ...componentProps} = props; const {isDesignMode} = useDesignContext(); + const nodeRef = React.useRef(null); + const classes = useRegionDecoratorClasses({regionId: designMetadata.id}); + + useExternalDragHandler( + designMetadata.id, + designMetadata.parentId ?? '', + nodeRef + ); if (!isDesignMode) { // eslint-disable-next-line react/jsx-props-no-spreading @@ -21,7 +31,7 @@ export function createReactRegionDesignDecorator( } return ( -
+
{/* eslint-disable-next-line react/jsx-props-no-spreading */} {children}
diff --git a/src/design/react/context/DesignStateContext.tsx b/src/design/react/context/DesignStateContext.tsx index 40087e06..a9921e58 100644 --- a/src/design/react/context/DesignStateContext.tsx +++ b/src/design/react/context/DesignStateContext.tsx @@ -9,13 +9,17 @@ import {useSelectInteraction} from '../hooks/useSelectInteraction'; import {useHoverInteraction} from '../hooks/useHoverInteraction'; import {useDeleteInteraction} from '../hooks/useDeleteInteraction'; import {useFocusInteraction} from '../hooks/useFocusInteraction'; +import { + ExternalDragInteraction, + useExternalDragInteraction, +} from '../hooks/useExternalDragInteraction'; import {ComponentDeletedEvent, EventPayload} from '../../messaging-api'; const noop = () => { /* noop */ }; -export interface DesignState { +export interface DesignState extends ExternalDragInteraction { selectedComponentId: string | null; hoveredComponentId: string | null; setSelectedComponent: (componentId: string) => void; @@ -33,6 +37,15 @@ export const DesignStateContext = React.createContext({ deleteComponent: noop, focusComponent: noop, focusedComponentId: null, + externalDragState: { + isDragging: false, + componentType: '', + x: 0, + y: 0, + currentDropTarget: null, + }, + setCurrentDropTarget: noop, + commitCurrentDropTarget: noop, }); export const DesignStateProvider = ({ @@ -42,6 +55,7 @@ export const DesignStateProvider = ({ }): JSX.Element => { const selectInteraction = useSelectInteraction(); const hoverInteraction = useHoverInteraction(); + const externalDragInteraction = useExternalDragInteraction(); const deleteInteraction = useDeleteInteraction({ selectedComponentId: selectInteraction.selectedComponentId, setSelectedComponent: selectInteraction.setSelectedComponent, @@ -56,8 +70,15 @@ export const DesignStateProvider = ({ ...selectInteraction, ...hoverInteraction, ...focusInteraction, + ...externalDragInteraction, }), - [deleteInteraction, selectInteraction, hoverInteraction, focusInteraction] + [ + deleteInteraction, + selectInteraction, + hoverInteraction, + focusInteraction, + externalDragInteraction, + ] ); return ( diff --git a/src/design/react/hooks/useExternalDragHandler.ts b/src/design/react/hooks/useExternalDragHandler.ts new file mode 100644 index 00000000..89cd3fab --- /dev/null +++ b/src/design/react/hooks/useExternalDragHandler.ts @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useMemo, useEffect} from 'react'; +import {useDesignState} from './useDesignState'; + +export function useExternalDragHandler( + regionId: string, + componentId: string, + elementRef: React.RefObject +): void { + const { + externalDragState: { + isDragging, + x: dragX, + y: dragY, + currentDropTarget, + pendingTargetCommit, + }, + setCurrentDropTarget, + commitCurrentDropTarget, + } = useDesignState(); + + // When we start dragging, capture the element's coordinates. + const coordinates = useMemo( + () => + isDragging && elementRef.current + ? elementRef.current.getBoundingClientRect() + : null, + [elementRef.current, isDragging] + ); + + const isDraggingOverRegion = + isDragging && + coordinates && + dragX >= coordinates.x && + dragX <= coordinates.x + coordinates.width && + dragY >= coordinates.y && + dragY <= coordinates.y + coordinates.height; + + useEffect(() => { + if (isDraggingOverRegion) { + if (currentDropTarget !== regionId) { + setCurrentDropTarget(regionId); + } + } else if (currentDropTarget === regionId) { + setCurrentDropTarget(''); + } + }, [isDraggingOverRegion, currentDropTarget, regionId]); + + useEffect(() => { + if (pendingTargetCommit && currentDropTarget === regionId) { + commitCurrentDropTarget(componentId); + } + }, [pendingTargetCommit, currentDropTarget, regionId, componentId]); +} diff --git a/src/design/react/hooks/useExternalDragInteraction.ts b/src/design/react/hooks/useExternalDragInteraction.ts new file mode 100644 index 00000000..ccd9710e --- /dev/null +++ b/src/design/react/hooks/useExternalDragInteraction.ts @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useInteraction} from './useInteraction'; + +export interface ExternalDragInteraction { + externalDragState: { + isDragging: boolean; + componentType: string; + x: number; + y: number; + currentDropTarget: string | null; + pendingTargetCommit: boolean; + }; + setCurrentDropTarget: (regionId: string) => void; + commitCurrentDropTarget: (targetComponentId: string) => void; +} + +export function useExternalDragInteraction(): ExternalDragInteraction { + const { + state: dragState, + setCurrentDropTarget, + commitCurrentDropTarget, + } = useInteraction({ + initialState: { + isDragging: false, + componentType: '', + x: 0, + y: 0, + currentDropTarget: null as string | null, + pendingTargetCommit: false, + }, + eventHandlers: { + ComponentDragStarted: { + handler: (event, setState) => { + setState(prevState => ({ + ...prevState, + componentType: event.componentType, + x: event.x ?? 0, + y: event.y ?? 0, + isDragging: true, + currentDropTarget: null, + pendingTargetCommit: false, + })); + }, + }, + ClientWindowDragMoved: { + handler: (event, setState) => { + setState(prevState => ({ + ...prevState, + x: event.x, + y: event.y, + isDragging: true, + })); + }, + }, + ClientWindowDragDropped: { + handler: (event, setState) => { + setState(prevState => ({ + ...prevState, + x: event.x, + y: event.y, + isDragging: false, + pendingTargetCommit: true, + })); + }, + }, + }, + actions: (state, setState, clientApi) => ({ + setCurrentDropTarget: (regionId: string) => { + setState(prevState => ({ + ...prevState, + currentDropTarget: regionId, + })); + }, + commitCurrentDropTarget: (targetComponentId: string) => { + clientApi?.addComponentToRegion({ + componentProperties: {}, + componentType: state.componentType, + targetComponentId, + targetRegionId: state.currentDropTarget ?? '', + }); + setState(prevState => ({ + ...prevState, + x: 0, + y: 0, + componentType: '', + currentDropTarget: null, + pendingTargetCommit: false, + })); + }, + }), + }); + + return { + externalDragState: dragState, + setCurrentDropTarget, + commitCurrentDropTarget, + }; +} diff --git a/src/design/react/hooks/useInteraction.ts b/src/design/react/hooks/useInteraction.ts index 9c9e7608..e2431ccf 100644 --- a/src/design/react/hooks/useInteraction.ts +++ b/src/design/react/hooks/useInteraction.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import {useEffect, useState} from 'react'; +import React, {useEffect, useState} from 'react'; import {ClientApi, ClientEventNameMapping} from '../../messaging-api'; import {useDesignContext} from '../context/DesignContext'; @@ -14,7 +14,7 @@ export interface EventHandler< > { handler: ( event: ClientEventNameMapping[TName], - setState: (newState: TState) => void + setState: React.Dispatch> ) => void; } @@ -28,7 +28,7 @@ export interface InteractionConfig { /** Action creators that return functions to interact with the client API */ actions?: ( state: TState, - setState: (newState: TState) => void, + setState: React.Dispatch>, clientApi: ClientApi | null ) => TActions; } diff --git a/src/design/react/hooks/useRegionDecoratorClasses.ts b/src/design/react/hooks/useRegionDecoratorClasses.ts new file mode 100644 index 00000000..36828d0e --- /dev/null +++ b/src/design/react/hooks/useRegionDecoratorClasses.ts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useDesignState} from './useDesignState'; + +export function useRegionDecoratorClasses({ + regionId, +}: { + regionId: string; +}): string { + const { + externalDragState: {currentDropTarget}, + } = useDesignState(); + + const isHovered = currentDropTarget === regionId; + + return [ + 'pd-design--decorator', + 'pd-design__region', + isHovered && 'pd-design__region--hovered', + ] + .filter(Boolean) + .join(' '); +} From 9ad3c72051c4e6acc27ad25a913b6e34a8a8239d Mon Sep 17 00:00:00 2001 From: Steven Sojka Date: Wed, 15 Oct 2025 12:27:01 -0500 Subject: [PATCH 02/12] rework drag and drop --- src/design/messaging-api/domain-types.ts | 31 ++-- .../react/components/ComponentDecorator.tsx | 12 +- .../react/components/RegionDecorator.tsx | 17 ++- .../react/components/component.types.d.ts | 23 ++- .../react/context/DesignStateContext.tsx | 16 +- .../react/hooks/useExternalDragHandler.ts | 59 -------- .../react/hooks/useExternalDragInteraction.ts | 137 ++++++++++++++---- .../react/hooks/useNodeToTargetStore.ts | 34 +++++ .../react/hooks/useRegionDecoratorClasses.ts | 2 +- 9 files changed, 222 insertions(+), 109 deletions(-) delete mode 100644 src/design/react/hooks/useExternalDragHandler.ts create mode 100644 src/design/react/hooks/useNodeToTargetStore.ts diff --git a/src/design/messaging-api/domain-types.ts b/src/design/messaging-api/domain-types.ts index efb34c54..d47e6c2b 100644 --- a/src/design/messaging-api/domain-types.ts +++ b/src/design/messaging-api/domain-types.ts @@ -19,6 +19,13 @@ interface WithComponentId { componentId: string; } +interface WithComponentType { + /** + * The component type that the event is related to. + */ + componentType: string; +} + /** * @inline * @hidden @@ -196,8 +203,7 @@ export interface ClientAcknowledgedEvent extends WithBaseEvent { */ export interface ClientWindowDragEnteredEvent extends WithBaseEvent, - WithClientVector, - WithComponentId { + WithComponentType { eventType: 'ClientWindowDragEntered'; } /** @@ -208,7 +214,7 @@ export interface ClientWindowDragEnteredEvent export interface ClientWindowDragMovedEvent extends WithBaseEvent, WithClientVector, - WithComponentId { + WithComponentType { eventType: 'ClientWindowDragMoved'; } /** @@ -218,8 +224,7 @@ export interface ClientWindowDragMovedEvent */ export interface ClientWindowDragExitedEvent extends WithBaseEvent, - WithClientVector, - WithComponentId { + WithComponentType { eventType: 'ClientWindowDragExited'; } /** @@ -229,8 +234,7 @@ export interface ClientWindowDragExitedEvent */ export interface ClientWindowDragDroppedEvent extends WithBaseEvent, - WithClientVector, - WithComponentId { + WithComponentType { eventType: 'ClientWindowDragDropped'; } /** @@ -441,15 +445,22 @@ export interface ComponentAddedToRegionEvent< * The id of the region that the component is being added to. */ targetRegionId: string; + /** + * When an insertComponentId is provided, this will insert the new component before or after the component with that component id. + */ + insertType?: 'before' | 'after'; + /** + * The id of the component this component should be inserted before or after. + * If not provided, then it is up to the host to determine where in the target region this is inserted. + */ + insertComponentId?: string; } /** * Emits when a component drag starts from the host or client. * @target isomorphic * @group Events */ -export interface ComponentDragStartedEvent - extends WithBaseEvent, - Partial { +export interface ComponentDragStartedEvent extends WithBaseEvent { eventType: 'ComponentDragStarted'; /** * The type of the component that is being dragged. diff --git a/src/design/react/components/ComponentDecorator.tsx b/src/design/react/components/ComponentDecorator.tsx index aa341786..2b46937e 100644 --- a/src/design/react/components/ComponentDecorator.tsx +++ b/src/design/react/components/ComponentDecorator.tsx @@ -14,6 +14,7 @@ import {useDesignCallback} from '../hooks/useDesignCallback'; import {useDesignState} from '../hooks/useDesignState'; import {useFocusedComponentHandler} from '../hooks/useFocusedComponentHandler'; import {useComponentType} from '../hooks/useComponentType'; +import {useNodeToTargetStore} from '../hooks/useNodeToTargetStore'; /** * Creates a higher-order component that wraps React components with design-time functionality. @@ -29,7 +30,8 @@ export function createReactComponentDesignDecorator( ): (props: ComponentDecoratorProps) => JSX.Element { return (props: ComponentDecoratorProps) => { const {designMetadata, children, ...componentProps} = props; - const {id, name, isFragment, parentId, regionId} = designMetadata; + const {id, name, isFragment, parentId, regionId, regionDirection} = + designMetadata; const componentId = id; const componentName = name || 'Component'; const dragRef = useRef(null); @@ -46,6 +48,14 @@ export function createReactComponentDesignDecorator( const componentType = useComponentType(componentId); useFocusedComponentHandler(componentId, dragRef); + useNodeToTargetStore({ + type: 'component', + nodeRef: dragRef, + parentId, + regionId, + regionDirection, + componentId, + }); const handleMouseEnter = useDesignCallback( () => setHoveredComponent(componentId), diff --git a/src/design/react/components/RegionDecorator.tsx b/src/design/react/components/RegionDecorator.tsx index 9b4ca472..f26ad76d 100644 --- a/src/design/react/components/RegionDecorator.tsx +++ b/src/design/react/components/RegionDecorator.tsx @@ -7,8 +7,8 @@ import React from 'react'; import {useDesignContext} from '../context/DesignContext'; import {ComponentDecoratorProps} from './component.types'; -import {useExternalDragHandler} from '../hooks/useExternalDragHandler'; import {useRegionDecoratorClasses} from '../hooks/useRegionDecoratorClasses'; +import {useNodeToTargetStore} from '../hooks/useNodeToTargetStore'; export function createReactRegionDesignDecorator( Region: React.ComponentType @@ -19,11 +19,14 @@ export function createReactRegionDesignDecorator( const nodeRef = React.useRef(null); const classes = useRegionDecoratorClasses({regionId: designMetadata.id}); - useExternalDragHandler( - designMetadata.id, - designMetadata.parentId ?? '', - nodeRef - ); + useNodeToTargetStore({ + type: 'region', + nodeRef, + parentId: designMetadata.parentId, + componentId: designMetadata.parentId as string, + regionId: designMetadata.id, + regionDirection: designMetadata.regionDirection, + }); if (!isDesignMode) { // eslint-disable-next-line react/jsx-props-no-spreading @@ -33,7 +36,7 @@ export function createReactRegionDesignDecorator( return (
{/* eslint-disable-next-line react/jsx-props-no-spreading */} - {children} + {children}
); }; diff --git a/src/design/react/components/component.types.d.ts b/src/design/react/components/component.types.d.ts index d71d54c6..c988c623 100644 --- a/src/design/react/components/component.types.d.ts +++ b/src/design/react/components/component.types.d.ts @@ -9,11 +9,30 @@ import React from 'react'; export type ComponentDecoratorProps = React.PropsWithChildren< { designMetadata: { + /** + * The id of the component or region. + */ id: string; + /** + * The direction of the region or the region the component belongs to. + */ + regionDirection: 'row' | 'column'; + /** + * The region id of the region or the region this component belongs to. + */ + regionId: string; + /** + * Whether the component is a fragment. + */ + isFragment: boolean; + /** + * The name of the component or region. + */ name?: string; + /** + * The id of the parent component if it exists. + */ parentId?: string; - regionId?: string; - isFragment?: boolean; }; } & TProps >; diff --git a/src/design/react/context/DesignStateContext.tsx b/src/design/react/context/DesignStateContext.tsx index a9921e58..b7bfaf5c 100644 --- a/src/design/react/context/DesignStateContext.tsx +++ b/src/design/react/context/DesignStateContext.tsx @@ -19,6 +19,14 @@ const noop = () => { /* noop */ }; +export interface NodeToTargetMapEntry { + type: 'region' | 'component'; + parentId?: string; + componentId: string; + regionId: string; + regionDirection: 'row' | 'column'; +} + export interface DesignState extends ExternalDragInteraction { selectedComponentId: string | null; hoveredComponentId: string | null; @@ -27,6 +35,7 @@ export interface DesignState extends ExternalDragInteraction { deleteComponent: (event: EventPayload) => void; focusComponent: (node: Element) => void; focusedComponentId: string | null; + nodeToTargetMap: WeakMap; } export const DesignStateContext = React.createContext({ @@ -38,14 +47,15 @@ export const DesignStateContext = React.createContext({ focusComponent: noop, focusedComponentId: null, externalDragState: { + pendingTargetCommit: false, isDragging: false, componentType: '', x: 0, y: 0, currentDropTarget: null, }, - setCurrentDropTarget: noop, commitCurrentDropTarget: noop, + nodeToTargetMap: new WeakMap(), }); export const DesignStateProvider = ({ @@ -64,6 +74,8 @@ export const DesignStateProvider = ({ setSelectedComponent: selectInteraction.setSelectedComponent, }); + const nodeToTargetMap = React.useMemo(() => new WeakMap(), []); + const state = React.useMemo( () => ({ ...deleteInteraction, @@ -71,6 +83,7 @@ export const DesignStateProvider = ({ ...hoverInteraction, ...focusInteraction, ...externalDragInteraction, + nodeToTargetMap, }), [ deleteInteraction, @@ -78,6 +91,7 @@ export const DesignStateProvider = ({ hoverInteraction, focusInteraction, externalDragInteraction, + nodeToTargetMap, ] ); diff --git a/src/design/react/hooks/useExternalDragHandler.ts b/src/design/react/hooks/useExternalDragHandler.ts deleted file mode 100644 index 89cd3fab..00000000 --- a/src/design/react/hooks/useExternalDragHandler.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2025, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import {useMemo, useEffect} from 'react'; -import {useDesignState} from './useDesignState'; - -export function useExternalDragHandler( - regionId: string, - componentId: string, - elementRef: React.RefObject -): void { - const { - externalDragState: { - isDragging, - x: dragX, - y: dragY, - currentDropTarget, - pendingTargetCommit, - }, - setCurrentDropTarget, - commitCurrentDropTarget, - } = useDesignState(); - - // When we start dragging, capture the element's coordinates. - const coordinates = useMemo( - () => - isDragging && elementRef.current - ? elementRef.current.getBoundingClientRect() - : null, - [elementRef.current, isDragging] - ); - - const isDraggingOverRegion = - isDragging && - coordinates && - dragX >= coordinates.x && - dragX <= coordinates.x + coordinates.width && - dragY >= coordinates.y && - dragY <= coordinates.y + coordinates.height; - - useEffect(() => { - if (isDraggingOverRegion) { - if (currentDropTarget !== regionId) { - setCurrentDropTarget(regionId); - } - } else if (currentDropTarget === regionId) { - setCurrentDropTarget(''); - } - }, [isDraggingOverRegion, currentDropTarget, regionId]); - - useEffect(() => { - if (pendingTargetCommit && currentDropTarget === regionId) { - commitCurrentDropTarget(componentId); - } - }, [pendingTargetCommit, currentDropTarget, regionId, componentId]); -} diff --git a/src/design/react/hooks/useExternalDragInteraction.ts b/src/design/react/hooks/useExternalDragInteraction.ts index ccd9710e..42603670 100644 --- a/src/design/react/hooks/useExternalDragInteraction.ts +++ b/src/design/react/hooks/useExternalDragInteraction.ts @@ -4,7 +4,15 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import {useCallback, useEffect, useMemo} from 'react'; import {useInteraction} from './useInteraction'; +import {useDesignState} from './useDesignState'; +import type {NodeToTargetMapEntry} from '../context/DesignStateContext'; + +export interface DropTarget extends NodeToTargetMapEntry { + insertType?: 'before' | 'after'; + insertComponentId?: string; +} export interface ExternalDragInteraction { externalDragState: { @@ -12,25 +20,82 @@ export interface ExternalDragInteraction { componentType: string; x: number; y: number; - currentDropTarget: string | null; + currentDropTarget: DropTarget | null; pendingTargetCommit: boolean; }; - setCurrentDropTarget: (regionId: string) => void; - commitCurrentDropTarget: (targetComponentId: string) => void; + commitCurrentDropTarget: () => void; +} + +function getInsertionType({ + cache, + node, + x, + y, + direction, +}: { + cache: WeakMap; + node: Element; + x: number; + y: number; + direction: 'row' | 'column'; +}): 'before' | 'after' | undefined { + const rect = cache.get(node) ?? node.getBoundingClientRect(); + + cache.set(node, rect); + + if (direction === 'row') { + const midX = rect.left + rect.width / 2; + + return x < midX ? 'before' : 'after'; + } + + const midY = rect.top + rect.height / 2; + + return y < midY ? 'before' : 'after'; } export function useExternalDragInteraction(): ExternalDragInteraction { - const { - state: dragState, - setCurrentDropTarget, - commitCurrentDropTarget, - } = useInteraction({ + const {nodeToTargetMap} = useDesignState(); + const rectCache = useMemo(() => new WeakMap(), []); + const getCurrentDropTarget = useCallback( + (x: number, y: number): DropTarget | null => { + const nodeStack = document.elementsFromPoint(x, y); + + for (let i = 0; i < nodeStack.length; i += 1) { + const node = nodeStack[i]; + const entry = nodeToTargetMap.get(node); + + if (entry) { + const isComponent = entry.type === 'component'; + const insertComponentId = isComponent ? entry.componentId : undefined; + const insertType = getInsertionType({ + cache: rectCache, + node, + x, + y, + direction: entry.regionDirection, + }); + + return { + ...entry, + insertComponentId, + insertType, + }; + } + } + + return null; + }, + [nodeToTargetMap, rectCache] + ); + + const {state: dragState, commitCurrentDropTarget} = useInteraction({ initialState: { isDragging: false, componentType: '', x: 0, y: 0, - currentDropTarget: null as string | null, + currentDropTarget: null as DropTarget | null, pendingTargetCommit: false, }, eventHandlers: { @@ -39,14 +104,27 @@ export function useExternalDragInteraction(): ExternalDragInteraction { setState(prevState => ({ ...prevState, componentType: event.componentType, - x: event.x ?? 0, - y: event.y ?? 0, + x: 0, + y: 0, isDragging: true, currentDropTarget: null, pendingTargetCommit: false, })); }, }, + ClientWindowDragExited: { + handler: (_, setState) => { + setState(prevState => ({ + ...prevState, + componentType: '', + x: 0, + y: 0, + isDragging: false, + currentDropTarget: null, + pendingTargetCommit: false, + })); + }, + }, ClientWindowDragMoved: { handler: (event, setState) => { setState(prevState => ({ @@ -54,15 +132,14 @@ export function useExternalDragInteraction(): ExternalDragInteraction { x: event.x, y: event.y, isDragging: true, + currentDropTarget: getCurrentDropTarget(event.x, event.y), })); }, }, ClientWindowDragDropped: { - handler: (event, setState) => { + handler: (_, setState) => { setState(prevState => ({ ...prevState, - x: event.x, - y: event.y, isDragging: false, pendingTargetCommit: true, })); @@ -70,19 +147,18 @@ export function useExternalDragInteraction(): ExternalDragInteraction { }, }, actions: (state, setState, clientApi) => ({ - setCurrentDropTarget: (regionId: string) => { - setState(prevState => ({ - ...prevState, - currentDropTarget: regionId, - })); - }, - commitCurrentDropTarget: (targetComponentId: string) => { - clientApi?.addComponentToRegion({ - componentProperties: {}, - componentType: state.componentType, - targetComponentId, - targetRegionId: state.currentDropTarget ?? '', - }); + commitCurrentDropTarget: () => { + if (state.currentDropTarget) { + clientApi?.addComponentToRegion({ + insertType: state.currentDropTarget.insertType, + insertComponentId: state.currentDropTarget.insertComponentId, + componentProperties: {}, + componentType: state.componentType, + targetComponentId: state.currentDropTarget.componentId, + targetRegionId: state.currentDropTarget.regionId, + }); + } + setState(prevState => ({ ...prevState, x: 0, @@ -95,9 +171,14 @@ export function useExternalDragInteraction(): ExternalDragInteraction { }), }); + useEffect(() => { + if (dragState.pendingTargetCommit) { + commitCurrentDropTarget(); + } + }, [dragState.pendingTargetCommit]); + return { externalDragState: dragState, - setCurrentDropTarget, commitCurrentDropTarget, }; } diff --git a/src/design/react/hooks/useNodeToTargetStore.ts b/src/design/react/hooks/useNodeToTargetStore.ts new file mode 100644 index 00000000..da688df6 --- /dev/null +++ b/src/design/react/hooks/useNodeToTargetStore.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; +import {useDesignState} from './useDesignState'; +import {NodeToTargetMapEntry} from '../context/DesignStateContext'; + +export function useNodeToTargetStore({ + parentId, + componentId, + regionId, + regionDirection, + nodeRef, + type, +}: NodeToTargetMapEntry & { + nodeRef: React.RefObject; +}): void { + const {nodeToTargetMap} = useDesignState(); + + React.useEffect(() => { + if (nodeRef.current) { + nodeToTargetMap.set(nodeRef.current, { + parentId, + componentId, + regionId, + regionDirection, + type, + } as NodeToTargetMapEntry); + } + }, [nodeRef.current, parentId, componentId, regionId, type]); +} diff --git a/src/design/react/hooks/useRegionDecoratorClasses.ts b/src/design/react/hooks/useRegionDecoratorClasses.ts index 36828d0e..efccbd75 100644 --- a/src/design/react/hooks/useRegionDecoratorClasses.ts +++ b/src/design/react/hooks/useRegionDecoratorClasses.ts @@ -15,7 +15,7 @@ export function useRegionDecoratorClasses({ externalDragState: {currentDropTarget}, } = useDesignState(); - const isHovered = currentDropTarget === regionId; + const isHovered = currentDropTarget?.regionId === regionId; return [ 'pd-design--decorator', From 32aa217ca5471ce73ba5dd42f91dc453100a2df9 Mon Sep 17 00:00:00 2001 From: Steven Sojka Date: Thu, 16 Oct 2025 15:25:18 -0500 Subject: [PATCH 03/12] add local dragging logic --- src/design/messaging-api/domain-types.ts | 4 - .../react/components/ComponentDecorator.tsx | 1 + src/design/react/components/DesignApp.tsx | 19 ++++ src/design/react/context/DesignContext.tsx | 5 +- .../react/context/DesignStateContext.tsx | 13 ++- .../react/hooks/useExternalDragInteraction.ts | 91 +++++++++++++++---- .../react/hooks/useGlobalDragListener.ts | 26 ++++++ .../react/hooks/useNodeToTargetStore.ts | 2 +- .../react/hooks/useRegionDecoratorClasses.ts | 2 +- 9 files changed, 135 insertions(+), 28 deletions(-) create mode 100644 src/design/react/components/DesignApp.tsx create mode 100644 src/design/react/hooks/useGlobalDragListener.ts diff --git a/src/design/messaging-api/domain-types.ts b/src/design/messaging-api/domain-types.ts index d47e6c2b..5b546900 100644 --- a/src/design/messaging-api/domain-types.ts +++ b/src/design/messaging-api/domain-types.ts @@ -358,10 +358,6 @@ export interface ComponentMovedToRegionEvent * The region that the component is being moved from. */ sourceRegionId: string; - /** - * The id of the component that the component was moved from. - */ - sourceComponentId: string; } /** * Emits when a component is hovered over. diff --git a/src/design/react/components/ComponentDecorator.tsx b/src/design/react/components/ComponentDecorator.tsx index 2b46937e..8e371746 100644 --- a/src/design/react/components/ComponentDecorator.tsx +++ b/src/design/react/components/ComponentDecorator.tsx @@ -44,6 +44,7 @@ export function createReactComponentDesignDecorator( setSelectedComponent, setHoveredComponent, deleteComponent, + startComponentMove, } = useDesignState(); const componentType = useComponentType(componentId); diff --git a/src/design/react/components/DesignApp.tsx b/src/design/react/components/DesignApp.tsx new file mode 100644 index 00000000..77d58954 --- /dev/null +++ b/src/design/react/components/DesignApp.tsx @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; +import {useGlobalDragListener} from '../hooks/useGlobalDragListener'; + +/** + * Containes any global setup logic for the design layer. + */ +export const DesignApp = ({ + children, +}: React.PropsWithChildren): JSX.Element => { + useGlobalDragListener(); + + return <>{children}; +}; diff --git a/src/design/react/context/DesignContext.tsx b/src/design/react/context/DesignContext.tsx index 0f6517c4..6173fe02 100644 --- a/src/design/react/context/DesignContext.tsx +++ b/src/design/react/context/DesignContext.tsx @@ -15,6 +15,7 @@ import { } from '../../messaging-api'; import {isDesignModeActive} from '../../modeDetection'; import {DesignStateProvider} from './DesignStateContext'; +import {DesignApp} from '../components/DesignApp'; const noop = () => { /* noop */ @@ -123,7 +124,9 @@ export const DesignProvider = ({ return ( - {children} + + {children} + ); }; diff --git a/src/design/react/context/DesignStateContext.tsx b/src/design/react/context/DesignStateContext.tsx index b7bfaf5c..320f1cb4 100644 --- a/src/design/react/context/DesignStateContext.tsx +++ b/src/design/react/context/DesignStateContext.tsx @@ -10,8 +10,8 @@ import {useHoverInteraction} from '../hooks/useHoverInteraction'; import {useDeleteInteraction} from '../hooks/useDeleteInteraction'; import {useFocusInteraction} from '../hooks/useFocusInteraction'; import { - ExternalDragInteraction, - useExternalDragInteraction, + DragInteraction, + useDragInteraction, } from '../hooks/useExternalDragInteraction'; import {ComponentDeletedEvent, EventPayload} from '../../messaging-api'; @@ -27,7 +27,7 @@ export interface NodeToTargetMapEntry { regionDirection: 'row' | 'column'; } -export interface DesignState extends ExternalDragInteraction { +export interface DesignState extends DragInteraction { selectedComponentId: string | null; hoveredComponentId: string | null; setSelectedComponent: (componentId: string) => void; @@ -46,7 +46,7 @@ export const DesignStateContext = React.createContext({ deleteComponent: noop, focusComponent: noop, focusedComponentId: null, - externalDragState: { + dragState: { pendingTargetCommit: false, isDragging: false, componentType: '', @@ -55,6 +55,9 @@ export const DesignStateContext = React.createContext({ currentDropTarget: null, }, commitCurrentDropTarget: noop, + startComponentMove: noop, + updateComponentMove: noop, + dropComponent: noop, nodeToTargetMap: new WeakMap(), }); @@ -65,7 +68,6 @@ export const DesignStateProvider = ({ }): JSX.Element => { const selectInteraction = useSelectInteraction(); const hoverInteraction = useHoverInteraction(); - const externalDragInteraction = useExternalDragInteraction(); const deleteInteraction = useDeleteInteraction({ selectedComponentId: selectInteraction.selectedComponentId, setSelectedComponent: selectInteraction.setSelectedComponent, @@ -75,6 +77,7 @@ export const DesignStateProvider = ({ }); const nodeToTargetMap = React.useMemo(() => new WeakMap(), []); + const externalDragInteraction = useDragInteraction({nodeToTargetMap}); const state = React.useMemo( () => ({ diff --git a/src/design/react/hooks/useExternalDragInteraction.ts b/src/design/react/hooks/useExternalDragInteraction.ts index 42603670..ac036793 100644 --- a/src/design/react/hooks/useExternalDragInteraction.ts +++ b/src/design/react/hooks/useExternalDragInteraction.ts @@ -14,16 +14,21 @@ export interface DropTarget extends NodeToTargetMapEntry { insertComponentId?: string; } -export interface ExternalDragInteraction { - externalDragState: { +export interface DragInteraction { + dragState: { isDragging: boolean; - componentType: string; x: number; y: number; currentDropTarget: DropTarget | null; pendingTargetCommit: boolean; + componentType?: string; + sourceComponentId?: string; + sourceRegionId?: string; }; commitCurrentDropTarget: () => void; + startComponentMove: (componentId: string, regionId: string) => void; + updateComponentMove: (params: {x: number; y: number}) => void; + dropComponent: () => void; } function getInsertionType({ @@ -54,8 +59,11 @@ function getInsertionType({ return y < midY ? 'before' : 'after'; } -export function useExternalDragInteraction(): ExternalDragInteraction { - const {nodeToTargetMap} = useDesignState(); +export function useDragInteraction({ + nodeToTargetMap, +}: { + nodeToTargetMap: WeakMap; +}): DragInteraction { const rectCache = useMemo(() => new WeakMap(), []); const getCurrentDropTarget = useCallback( (x: number, y: number): DropTarget | null => { @@ -89,21 +97,31 @@ export function useExternalDragInteraction(): ExternalDragInteraction { [nodeToTargetMap, rectCache] ); - const {state: dragState, commitCurrentDropTarget} = useInteraction({ + const { + state: dragState, + commitCurrentDropTarget, + updateComponentMove, + startComponentMove, + dropComponent, + } = useInteraction({ initialState: { isDragging: false, componentType: '', + sourceComponentId: undefined as string | undefined, + sourceRegionId: undefined as string | undefined, x: 0, y: 0, currentDropTarget: null as DropTarget | null, pendingTargetCommit: false, - }, + } as DragInteraction['dragState'], eventHandlers: { ComponentDragStarted: { handler: (event, setState) => { setState(prevState => ({ ...prevState, componentType: event.componentType, + sourceComponentId: undefined, + sourceRegionId: undefined, x: 0, y: 0, isDragging: true, @@ -147,16 +165,52 @@ export function useExternalDragInteraction(): ExternalDragInteraction { }, }, actions: (state, setState, clientApi) => ({ + updateComponentMove: ({x, y}: {x: number; y: number}) => { + setState(prevState => ({ + ...prevState, + x, + y, + currentDropTarget: getCurrentDropTarget(x, y), + })); + }, + dropComponent: () => { + setState(prevState => ({ + ...prevState, + isDragging: false, + pendingTargetCommit: true, + })); + }, + startComponentMove: (componentId: string, regionId: string) => { + setState(prevState => ({ + ...prevState, + x: 0, + y: 0, + sourceComponentId: componentId, + sourceRegionId: regionId, + isDragging: true, + })); + }, commitCurrentDropTarget: () => { if (state.currentDropTarget) { - clientApi?.addComponentToRegion({ - insertType: state.currentDropTarget.insertType, - insertComponentId: state.currentDropTarget.insertComponentId, - componentProperties: {}, - componentType: state.componentType, - targetComponentId: state.currentDropTarget.componentId, - targetRegionId: state.currentDropTarget.regionId, - }); + // If we have a source component id, then we are moving a component to a different region. + if (state.sourceComponentId) { + clientApi?.moveComponentToRegion({ + componentId: state.sourceComponentId, + sourceRegionId: state.sourceRegionId ?? '', + targetComponentId: state.currentDropTarget.componentId, + targetRegionId: state.currentDropTarget.regionId, + }); + // If we have a component type, then we are adding a new component to a region. + } else if (state.componentType) { + clientApi?.addComponentToRegion({ + insertType: state.currentDropTarget.insertType, + insertComponentId: state.currentDropTarget.insertComponentId, + componentProperties: {}, + componentType: state.componentType, + targetComponentId: state.currentDropTarget.componentId, + targetRegionId: state.currentDropTarget.regionId, + }); + } } setState(prevState => ({ @@ -164,6 +218,8 @@ export function useExternalDragInteraction(): ExternalDragInteraction { x: 0, y: 0, componentType: '', + sourceComponentId: undefined, + sourceRegionId: undefined, currentDropTarget: null, pendingTargetCommit: false, })); @@ -178,7 +234,10 @@ export function useExternalDragInteraction(): ExternalDragInteraction { }, [dragState.pendingTargetCommit]); return { - externalDragState: dragState, + dragState, commitCurrentDropTarget, + startComponentMove, + updateComponentMove, + dropComponent, }; } diff --git a/src/design/react/hooks/useGlobalDragListener.ts b/src/design/react/hooks/useGlobalDragListener.ts new file mode 100644 index 00000000..83403a0e --- /dev/null +++ b/src/design/react/hooks/useGlobalDragListener.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useEffect} from 'react'; +import {useDesignState} from './useDesignState'; + +export function useGlobalDragListener(): void { + const {dropComponent, updateComponentMove} = useDesignState(); + + useEffect(() => { + const dragListener = (event: DragEvent) => + updateComponentMove({x: event.clientX, y: event.clientY}); + const dragEndListener = () => dropComponent(); + + window.addEventListener('drag', dragListener); + window.addEventListener('dragend', dragEndListener); + + return () => { + window.removeEventListener('drag', dragListener); + window.removeEventListener('dragend', dragEndListener); + }; + }, [updateComponentMove, dropComponent]); +} diff --git a/src/design/react/hooks/useNodeToTargetStore.ts b/src/design/react/hooks/useNodeToTargetStore.ts index da688df6..b8152684 100644 --- a/src/design/react/hooks/useNodeToTargetStore.ts +++ b/src/design/react/hooks/useNodeToTargetStore.ts @@ -30,5 +30,5 @@ export function useNodeToTargetStore({ type, } as NodeToTargetMapEntry); } - }, [nodeRef.current, parentId, componentId, regionId, type]); + }, [nodeRef.current, parentId, componentId, regionId, type, nodeToTargetMap]); } diff --git a/src/design/react/hooks/useRegionDecoratorClasses.ts b/src/design/react/hooks/useRegionDecoratorClasses.ts index efccbd75..fd8906d8 100644 --- a/src/design/react/hooks/useRegionDecoratorClasses.ts +++ b/src/design/react/hooks/useRegionDecoratorClasses.ts @@ -12,7 +12,7 @@ export function useRegionDecoratorClasses({ regionId: string; }): string { const { - externalDragState: {currentDropTarget}, + dragState: {currentDropTarget}, } = useDesignState(); const isHovered = currentDropTarget?.regionId === regionId; From 614f4cf2a6c299f40be9992a5f35f0c31d39aafc Mon Sep 17 00:00:00 2001 From: Steven Sojka Date: Fri, 17 Oct 2025 08:27:16 -0500 Subject: [PATCH 04/12] add drop target styles --- .../components/ComponentDecorator.test.tsx | 28 ++++---- .../react/components/ComponentDecorator.tsx | 38 ++-------- .../react/components/DeleteToolboxButton.tsx | 35 ++++++++++ src/design/react/components/DesignFrame.tsx | 70 +++++++++++++++++++ .../react/components/MoveToolboxButton.tsx | 35 ++++++++++ .../react/context/DesignStateContext.tsx | 40 ++--------- .../hooks/useComponentDecoratorClasses.ts | 24 +++++-- src/design/react/hooks/useDesignState.ts | 13 ++-- ...agInteraction.ts => useDragInteraction.ts} | 0 src/design/react/hooks/useLabels.ts | 13 ++++ .../react/hooks/useRegionDecoratorClasses.ts | 2 +- src/design/styles/base.css | 59 +++++++++++----- 12 files changed, 248 insertions(+), 109 deletions(-) create mode 100644 src/design/react/components/DeleteToolboxButton.tsx create mode 100644 src/design/react/components/DesignFrame.tsx create mode 100644 src/design/react/components/MoveToolboxButton.tsx rename src/design/react/hooks/{useExternalDragInteraction.ts => useDragInteraction.ts} (100%) create mode 100644 src/design/react/hooks/useLabels.ts diff --git a/src/design/react/components/ComponentDecorator.test.tsx b/src/design/react/components/ComponentDecorator.test.tsx index dd3b9394..5a401e21 100644 --- a/src/design/react/components/ComponentDecorator.test.tsx +++ b/src/design/react/components/ComponentDecorator.test.tsx @@ -90,7 +90,7 @@ describe('design/react/ComponentDecorator', () => { const finalResult = Object.assign(result, { host, - element: result.container.querySelector('.pd-design--decorator'), + element: result.container.querySelector('.pd-design__decorator'), }) as Result; return finalResult; @@ -153,8 +153,8 @@ describe('design/react/ComponentDecorator', () => { designMetadata: {id: 'test-1', isFragment: true}, }); - expect(element.classList.contains('pd-design--fragment')).toBe(true); - expect(element.classList.contains('pd-design--component')).toBe(false); + expect(element.classList.contains('pd-design__fragment')).toBe(true); + expect(element.classList.contains('pd-design__component')).toBe(false); }); }); @@ -164,8 +164,8 @@ describe('design/react/ComponentDecorator', () => { designMetadata: {id: 'test-1', isFragment: false}, }); - expect(element.classList.contains('pd-design--fragment')).toBe(false); - expect(element.classList.contains('pd-design--component')).toBe(true); + expect(element.classList.contains('pd-design__fragment')).toBe(false); + expect(element.classList.contains('pd-design__component')).toBe(true); }); }); @@ -182,8 +182,8 @@ describe('design/react/ComponentDecorator', () => { it('should show the frame', async () => { const {element} = await testBed.render(TestComponent); - expect(element.classList.contains('pd-design--show-frame')).toBe(true); - expect(element.classList.contains('pd-design--hovered')).toBe(true); + expect(element.classList.contains('pd-design__frame--visible')).toBe(true); + expect(element.classList.contains('pd-design__decorator--hovered')).toBe(true); }); it('should notify the host of the hover', async () => { @@ -203,7 +203,7 @@ describe('design/react/ComponentDecorator', () => { hoverOutSpy = jest.fn(); testBed.afterRender(async ({host, element}) => { await waitFor(() => { - expect(element.classList.contains('pd-design--hovered')).toBe( + expect(element.classList.contains('pd-design__decorator--hovered')).toBe( true ); }); @@ -226,10 +226,10 @@ describe('design/react/ComponentDecorator', () => { it('should not show the frame', async () => { const {element} = await testBed.render(TestComponent); - expect(element.classList.contains('pd-design--show-frame')).toBe( + expect(element.classList.contains('pd-design__frame--visible')).toBe( false ); - expect(element.classList.contains('pd-design--hovered')).toBe(false); + expect(element.classList.contains('pd-design__decorator--hovered')).toBe(false); }); }); }); @@ -240,8 +240,8 @@ describe('design/react/ComponentDecorator', () => { element.click(); - expect(element.classList.contains('pd-design--show-frame')).toBe(true); - expect(element.classList.contains('pd-design--selected')).toBe(true); + expect(element.classList.contains('pd-design__frame--visible')).toBe(true); + expect(element.classList.contains('pd-design__decorator--selected')).toBe(true); }); it('should notify the host of the selection', async () => { @@ -272,7 +272,7 @@ describe('design/react/ComponentDecorator', () => { fireEvent.click(element); await waitFor(() => { - expect(element.classList.contains('pd-design--show-frame')).toBe( + expect(element.classList.contains('pd-design__frame--visible')).toBe( true ); }); @@ -280,7 +280,7 @@ describe('design/react/ComponentDecorator', () => { host.on('ComponentDeleted', hostSpy); const deleteButton = await testBed.findBySelector( element, - '.pd-design__toolbox-button' + '.pd-design__frame__toolbox-button' ); fireEvent.click(deleteButton); }); diff --git a/src/design/react/components/ComponentDecorator.tsx b/src/design/react/components/ComponentDecorator.tsx index 8e371746..5b61755c 100644 --- a/src/design/react/components/ComponentDecorator.tsx +++ b/src/design/react/components/ComponentDecorator.tsx @@ -15,6 +15,7 @@ import {useDesignState} from '../hooks/useDesignState'; import {useFocusedComponentHandler} from '../hooks/useFocusedComponentHandler'; import {useComponentType} from '../hooks/useComponentType'; import {useNodeToTargetStore} from '../hooks/useNodeToTargetStore'; +import {DesignFrame} from './DesignFrame'; /** * Creates a higher-order component that wraps React components with design-time functionality. @@ -111,37 +112,12 @@ export function createReactComponentDesignDecorator( data-component-id={componentId} data-component-name={componentName}> {showFrame && ( -
- {componentType?.image && ( - - - - )} - - {componentName} ({componentId}) - -
- -
-
+ )} {/* eslint-disable-next-line react/jsx-props-no-spreading */} diff --git a/src/design/react/components/DeleteToolboxButton.tsx b/src/design/react/components/DeleteToolboxButton.tsx new file mode 100644 index 00000000..8f886a0f --- /dev/null +++ b/src/design/react/components/DeleteToolboxButton.tsx @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; + +export const DeleteToolboxButton = ({ + title, + onClick, +}: { + title: string; + onClick: (event: React.MouseEvent) => void; +}): JSX.Element => ( + +); diff --git a/src/design/react/components/DesignFrame.tsx b/src/design/react/components/DesignFrame.tsx new file mode 100644 index 00000000..d36c73d3 --- /dev/null +++ b/src/design/react/components/DesignFrame.tsx @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; +import {useComponentType} from '../hooks/useComponentType'; +import {DeleteToolboxButton} from './DeleteToolboxButton'; +import {MoveToolboxButton} from './MoveToolboxButton'; +import {useDesignState} from '../hooks/useDesignState'; +import {useLabels} from '../hooks/useLabels'; + +export const DesignFrame = ({ + componentId, + componentName, + parentId, + regionId, +}: { + componentId: string; + componentName: string; + parentId?: string; + regionId: string; +}): JSX.Element => { + const componentType = useComponentType(componentId); + const {deleteComponent, startComponentMove} = useDesignState(); + const labels = useLabels(); + + const handleDelete = React.useCallback(() => { + deleteComponent({ + componentId, + sourceComponentId: parentId ?? '', + sourceRegionId: regionId ?? '', + }); + }, [deleteComponent, componentId]); + + const handleDragStart = React.useCallback( + () => startComponentMove(componentId, regionId), + [startComponentMove, componentId, regionId] + ); + + return ( +
+
+ {componentType?.image && ( + + + + )} + + {componentName} ({componentId}) + +
+
+ + +
+
+ ); +}; + +DesignFrame.defaultProps = { + parentId: undefined, +}; diff --git a/src/design/react/components/MoveToolboxButton.tsx b/src/design/react/components/MoveToolboxButton.tsx new file mode 100644 index 00000000..0da51a01 --- /dev/null +++ b/src/design/react/components/MoveToolboxButton.tsx @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; + +export const MoveToolboxButton = ({ + title, + onDragStart, +}: { + title: string; + onDragStart: (event: React.MouseEvent) => void; +}): JSX.Element => ( + +); diff --git a/src/design/react/context/DesignStateContext.tsx b/src/design/react/context/DesignStateContext.tsx index 320f1cb4..5c5202e0 100644 --- a/src/design/react/context/DesignStateContext.tsx +++ b/src/design/react/context/DesignStateContext.tsx @@ -9,16 +9,9 @@ import {useSelectInteraction} from '../hooks/useSelectInteraction'; import {useHoverInteraction} from '../hooks/useHoverInteraction'; import {useDeleteInteraction} from '../hooks/useDeleteInteraction'; import {useFocusInteraction} from '../hooks/useFocusInteraction'; -import { - DragInteraction, - useDragInteraction, -} from '../hooks/useExternalDragInteraction'; +import {DragInteraction, useDragInteraction} from '../hooks/useDragInteraction'; import {ComponentDeletedEvent, EventPayload} from '../../messaging-api'; -const noop = () => { - /* noop */ -}; - export interface NodeToTargetMapEntry { type: 'region' | 'component'; parentId?: string; @@ -38,28 +31,9 @@ export interface DesignState extends DragInteraction { nodeToTargetMap: WeakMap; } -export const DesignStateContext = React.createContext({ - selectedComponentId: '', - hoveredComponentId: null, - setSelectedComponent: noop, - setHoveredComponent: noop, - deleteComponent: noop, - focusComponent: noop, - focusedComponentId: null, - dragState: { - pendingTargetCommit: false, - isDragging: false, - componentType: '', - x: 0, - y: 0, - currentDropTarget: null, - }, - commitCurrentDropTarget: noop, - startComponentMove: noop, - updateComponentMove: noop, - dropComponent: noop, - nodeToTargetMap: new WeakMap(), -}); +export const DesignStateContext = React.createContext( + null as unknown as DesignState +); export const DesignStateProvider = ({ children, @@ -77,7 +51,7 @@ export const DesignStateProvider = ({ }); const nodeToTargetMap = React.useMemo(() => new WeakMap(), []); - const externalDragInteraction = useDragInteraction({nodeToTargetMap}); + const dragInteraction = useDragInteraction({nodeToTargetMap}); const state = React.useMemo( () => ({ @@ -85,7 +59,7 @@ export const DesignStateProvider = ({ ...selectInteraction, ...hoverInteraction, ...focusInteraction, - ...externalDragInteraction, + ...dragInteraction, nodeToTargetMap, }), [ @@ -93,7 +67,7 @@ export const DesignStateProvider = ({ selectInteraction, hoverInteraction, focusInteraction, - externalDragInteraction, + dragInteraction, nodeToTargetMap, ] ); diff --git a/src/design/react/hooks/useComponentDecoratorClasses.ts b/src/design/react/hooks/useComponentDecoratorClasses.ts index 54887842..f639c6cb 100644 --- a/src/design/react/hooks/useComponentDecoratorClasses.ts +++ b/src/design/react/hooks/useComponentDecoratorClasses.ts @@ -13,18 +13,28 @@ export function useComponentDecoratorClasses({ componentId: string; isFragment: boolean; }): string { - const {selectedComponentId, hoveredComponentId} = useDesignState(); + const {selectedComponentId, hoveredComponentId, dragState} = useDesignState(); const isSelected = selectedComponentId === componentId; - const isHovered = hoveredComponentId === componentId; + const isHovered = !dragState.isDragging && hoveredComponentId === componentId; const showFrame = isSelected || isHovered; + const isMoving = + dragState.isDragging && dragState.sourceComponentId === componentId; + const isDropTarget = dragState.currentDropTarget?.componentId === componentId; + const dropTargetInsertType = dragState.currentDropTarget?.insertType; + const dropTargetDirection = dragState.currentDropTarget?.regionDirection; return [ - 'pd-design--decorator', - isFragment ? 'pd-design--fragment' : 'pd-design--component', - showFrame && 'pd-design--show-frame', - isSelected && 'pd-design--selected', - isHovered && 'pd-design--hovered', + 'pd-design__decorator', + isFragment ? 'pd-design__fragment' : 'pd-design__component', + showFrame && 'pd-design__frame--visible', + isSelected && 'pd-design__decorator--selected', + isHovered && 'pd-design__decorator--hovered', + isMoving && 'pd-design__decorator--moving', + isDropTarget && + `pd-design__drop-target__${dropTargetDirection === 'row' ? 'x' : 'y'}-${ + dropTargetInsertType as string + }`, ] .filter(Boolean) .join(' '); diff --git a/src/design/react/hooks/useDesignState.ts b/src/design/react/hooks/useDesignState.ts index e0d4e3be..7787c170 100644 --- a/src/design/react/hooks/useDesignState.ts +++ b/src/design/react/hooks/useDesignState.ts @@ -11,9 +11,14 @@ import {DesignStateContext, DesignState} from '../context/DesignStateContext'; * Custom hook that manages design-time component state by composing * individual interaction hooks for better maintainability and testability. * - * @param isDesignMode - Whether design mode is active - * @param clientApi - Client API for host communication * @returns Combined design state from all interactions */ -export const useDesignState = (): DesignState => - React.useContext(DesignStateContext); +export const useDesignState = (): DesignState => { + const context = React.useContext(DesignStateContext); + + if (!context) { + throw new Error('useDesignState must be used within a DesignStateProvider'); + } + + return context; +}; diff --git a/src/design/react/hooks/useExternalDragInteraction.ts b/src/design/react/hooks/useDragInteraction.ts similarity index 100% rename from src/design/react/hooks/useExternalDragInteraction.ts rename to src/design/react/hooks/useDragInteraction.ts diff --git a/src/design/react/hooks/useLabels.ts b/src/design/react/hooks/useLabels.ts new file mode 100644 index 00000000..da380b62 --- /dev/null +++ b/src/design/react/hooks/useLabels.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import {useDesignContext} from '../context/DesignContext'; + +export function useLabels(): Record { + const {pageDesignerConfig} = useDesignContext(); + + return pageDesignerConfig?.labels ?? {}; +} diff --git a/src/design/react/hooks/useRegionDecoratorClasses.ts b/src/design/react/hooks/useRegionDecoratorClasses.ts index fd8906d8..89e0355a 100644 --- a/src/design/react/hooks/useRegionDecoratorClasses.ts +++ b/src/design/react/hooks/useRegionDecoratorClasses.ts @@ -18,7 +18,7 @@ export function useRegionDecoratorClasses({ const isHovered = currentDropTarget?.regionId === regionId; return [ - 'pd-design--decorator', + 'pd-design__decorator', 'pd-design__region', isHovered && 'pd-design__region--hovered', ] diff --git a/src/design/styles/base.css b/src/design/styles/base.css index 810e3c41..8ee9a2bf 100644 --- a/src/design/styles/base.css +++ b/src/design/styles/base.css @@ -6,40 +6,42 @@ */ /* Base styles shared by both component and fragment */ -.pd-design--component, -.pd-design--fragment { +.pd-design__decorator { + /* Temporary color for drop targets */ + --pd-design-drop-target-color: green; +} + +.pd-design__component, +.pd-design__fragment { position: relative; transition: outline 0.2s ease-in-out; outline: 1px solid transparent; } -.pd-design--component { +.pd-design__component { --pd-design-color: #0070d2; } -.pd-design--fragment { +.pd-design__fragment { --pd-design-color: #8402ad; } /* Shared state styles */ -.pd-design--show-frame, -.pd-design--fragment.pd-design--show-frame { +.pd-design__frame--visible { outline: 1px solid var(--pd-design-color); } -.pd-design--selected, -.pd-design--fragment.pd-design--selected { +.pd-design__decorator--selected { outline-color: var(--pd-design-color); outline-width: 2px; } -.pd-design--hovered, -.pd-design--fragment.pd-design--hovered { +.pd-design__decorator--hovered { outline-color: var(--pd-design-color); outline-style: dashed; } -.pd-design__label { +.pd-design__frame__label { position: absolute; top: -32px; left: 50%; @@ -60,26 +62,28 @@ white-space: nowrap; } -.pd-design--show-frame .pd-design__label, -.pd-design--fragment.pd-design--show-frame .pd-design__label { +.pd-design__frame--visible .pd-design__frame__label { opacity: 1; visibility: visible; } -.pd-design__name { +.pd-design__frame__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-right: 8px; } -.pd-design__toolbox { +.pd-design__frame__toolbox { display: flex; align-items: center; gap: 4px; + position: absolute; + top: 0; + right: 0; } -.pd-design__toolbox-button { +.pd-design__frame__toolbox-button { display: flex; align-items: center; justify-content: center; @@ -93,17 +97,34 @@ padding: 0; } -.pd-design__toolbox-button:hover { +.pd-design__frame__toolbox-button:hover { background: rgba(255, 255, 255, 0.2); } -.pd-design__toolbox-button:focus { +.pd-design__frame__toolbox-button:focus { outline: 1px solid rgba(255, 255, 255, 0.5); outline-offset: 1px; } -.pd-design__delete-icon { +.pd-design__frame__delete-icon, +.pd-design__frame__move-icon { width: 12px; height: 12px; color: white; } + +.pd-design__drop-target__y-before { + box-shadow: 0 -2px solid var(--pd-design-drop-target-color); +} + +.pd-design__drop-target__y-after { + box-shadow: 0 2px solid var(--pd-design-drop-target-color); +} + +.pd-design__drop-target__x-before { + box-shadow: -2px 0 solid var(--pd-design-drop-target-color); +} + +.pd-design__drop-target__x-after { + box-shadow: 2px 0 solid var(--pd-design-drop-target-color); +} From cf1badd67f927a7be8a2c5195bfd75aa5f9e9abb Mon Sep 17 00:00:00 2001 From: Steven Sojka Date: Fri, 17 Oct 2025 12:29:33 -0500 Subject: [PATCH 05/12] fix styles --- .../react/components/ComponentDecorator.tsx | 2 +- src/design/react/components/DesignFrame.tsx | 57 +++++++++++-------- .../react/components/MoveToolboxButton.tsx | 1 - .../react/components/RegionDecorator.tsx | 25 ++++++-- .../react/hooks/useRegionDecoratorClasses.ts | 2 +- src/design/styles/base.css | 38 +++++++------ 6 files changed, 76 insertions(+), 49 deletions(-) diff --git a/src/design/react/components/ComponentDecorator.tsx b/src/design/react/components/ComponentDecorator.tsx index 5b61755c..48f04224 100644 --- a/src/design/react/components/ComponentDecorator.tsx +++ b/src/design/react/components/ComponentDecorator.tsx @@ -114,7 +114,7 @@ export function createReactComponentDesignDecorator( {showFrame && ( diff --git a/src/design/react/components/DesignFrame.tsx b/src/design/react/components/DesignFrame.tsx index d36c73d3..bc57485f 100644 --- a/src/design/react/components/DesignFrame.tsx +++ b/src/design/react/components/DesignFrame.tsx @@ -13,29 +13,34 @@ import {useLabels} from '../hooks/useLabels'; export const DesignFrame = ({ componentId, - componentName, + name, parentId, regionId, + showToolbox = true, }: { - componentId: string; - componentName: string; + componentId?: string; + name: string; parentId?: string; regionId: string; + showToolbox?: boolean; }): JSX.Element => { - const componentType = useComponentType(componentId); + const componentType = useComponentType(componentId ?? ''); const {deleteComponent, startComponentMove} = useDesignState(); const labels = useLabels(); - const handleDelete = React.useCallback(() => { - deleteComponent({ - componentId, - sourceComponentId: parentId ?? '', - sourceRegionId: regionId ?? '', - }); - }, [deleteComponent, componentId]); + const handleDelete = React.useCallback( + () => + componentId && + deleteComponent({ + componentId, + sourceComponentId: parentId ?? '', + sourceRegionId: regionId ?? '', + }), + [deleteComponent, componentId] + ); const handleDragStart = React.useCallback( - () => startComponentMove(componentId, regionId), + () => componentId && startComponentMove(componentId, regionId), [startComponentMove, componentId, regionId] ); @@ -47,24 +52,26 @@ export const DesignFrame = ({ )} - - {componentName} ({componentId}) - -
-
- - + {name}
+ {showToolbox && ( +
+ + +
+ )} ); }; DesignFrame.defaultProps = { parentId: undefined, + componentId: undefined, + showToolbox: true, }; diff --git a/src/design/react/components/MoveToolboxButton.tsx b/src/design/react/components/MoveToolboxButton.tsx index 0da51a01..2e478a96 100644 --- a/src/design/react/components/MoveToolboxButton.tsx +++ b/src/design/react/components/MoveToolboxButton.tsx @@ -21,7 +21,6 @@ export const MoveToolboxButton = ({ ( Region: React.ComponentType ): (props: ComponentDecoratorProps) => JSX.Element { return (props: ComponentDecoratorProps) => { const {designMetadata, children, ...componentProps} = props; + const { + name, + parentId, + regionDirection = 'column', + regionId, + } = designMetadata; const {isDesignMode} = useDesignContext(); const nodeRef = React.useRef(null); - const classes = useRegionDecoratorClasses({regionId: designMetadata.id}); + const classes = useRegionDecoratorClasses({regionId}); + const labels = useLabels(); useNodeToTargetStore({ type: 'region', nodeRef, - parentId: designMetadata.parentId, - componentId: designMetadata.parentId as string, - regionId: designMetadata.id, - regionDirection: designMetadata.regionDirection, + parentId, + componentId: parentId as string, + regionId, + regionDirection, }); if (!isDesignMode) { @@ -35,6 +44,12 @@ export function createReactRegionDesignDecorator( return (
+ {/* eslint-disable-next-line react/jsx-props-no-spreading */} {children}
diff --git a/src/design/react/hooks/useRegionDecoratorClasses.ts b/src/design/react/hooks/useRegionDecoratorClasses.ts index 89e0355a..fbf27a7d 100644 --- a/src/design/react/hooks/useRegionDecoratorClasses.ts +++ b/src/design/react/hooks/useRegionDecoratorClasses.ts @@ -15,7 +15,7 @@ export function useRegionDecoratorClasses({ dragState: {currentDropTarget}, } = useDesignState(); - const isHovered = currentDropTarget?.regionId === regionId; + const isHovered = regionId && currentDropTarget?.regionId === regionId; return [ 'pd-design__decorator', diff --git a/src/design/styles/base.css b/src/design/styles/base.css index 8ee9a2bf..0081afbe 100644 --- a/src/design/styles/base.css +++ b/src/design/styles/base.css @@ -8,7 +8,8 @@ /* Base styles shared by both component and fragment */ .pd-design__decorator { /* Temporary color for drop targets */ - --pd-design-drop-target-color: green; + --pd-design-drop-target-color: #008827; + --pd-design-selected-color: #005fb2; } .pd-design__component, @@ -32,13 +33,8 @@ } .pd-design__decorator--selected { - outline-color: var(--pd-design-color); outline-width: 2px; -} - -.pd-design__decorator--hovered { - outline-color: var(--pd-design-color); - outline-style: dashed; + --pd-design-color: var(--pd-design-selected-color); } .pd-design__frame__label { @@ -57,6 +53,8 @@ font-weight: 500; letter-spacing: 0.5px; opacity: 0; + border-top-left-radius: 4px; + border-top-right-radius: 4px; visibility: hidden; transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; white-space: nowrap; @@ -75,12 +73,13 @@ } .pd-design__frame__toolbox { + background: var(--pd-design-color); display: flex; align-items: center; - gap: 4px; position: absolute; top: 0; right: 0; + z-index: 1000; } .pd-design__frame__toolbox-button { @@ -89,30 +88,37 @@ justify-content: center; width: 20px; height: 20px; - background: rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0); border: none; - border-radius: 3px; cursor: pointer; transition: background-color 0.2s ease-in-out; padding: 0; } .pd-design__frame__toolbox-button:hover { - background: rgba(255, 255, 255, 0.2); -} - -.pd-design__frame__toolbox-button:focus { - outline: 1px solid rgba(255, 255, 255, 0.5); - outline-offset: 1px; + background: rgba(0, 0, 0, 0.2); } .pd-design__frame__delete-icon, .pd-design__frame__move-icon { width: 12px; height: 12px; + fill: white; color: white; } +.pd-design__region { + --pd-design-color: var(--pd-design-drop-target-color); + position: relative; + min-height: 50px; + min-width: 50px; +} + +.pd-design__region--hovered { + outline-color: var(--pd-design-color); + outline-style: solid; +} + .pd-design__drop-target__y-before { box-shadow: 0 -2px solid var(--pd-design-drop-target-color); } From 8853f874fc74d22c287f52af7321be4496a6dc71 Mon Sep 17 00:00:00 2001 From: Steven Sojka Date: Mon, 20 Oct 2025 07:57:59 -0400 Subject: [PATCH 06/12] fix decorator render order --- .../components/ComponentDecorator.test.tsx | 50 +++++--- .../react/components/ComponentDecorator.tsx | 107 ++---------------- .../react/components/DesignComponent.tsx | 94 +++++++++++++++ src/design/react/components/DesignFrame.tsx | 11 +- src/design/react/components/DesignRegion.tsx | 43 +++++++ .../react/components/RegionDecorator.tsx | 47 ++------ src/design/react/hooks/useDragInteraction.ts | 1 - 7 files changed, 201 insertions(+), 152 deletions(-) create mode 100644 src/design/react/components/DesignComponent.tsx create mode 100644 src/design/react/components/DesignRegion.tsx diff --git a/src/design/react/components/ComponentDecorator.test.tsx b/src/design/react/components/ComponentDecorator.test.tsx index 5a401e21..610ed9b4 100644 --- a/src/design/react/components/ComponentDecorator.test.tsx +++ b/src/design/react/components/ComponentDecorator.test.tsx @@ -150,7 +150,12 @@ describe('design/react/ComponentDecorator', () => { describe('when the component is a fragment', () => { it('should include the corresponding fragment class', async () => { const {element} = await testBed.render(TestComponent, { - designMetadata: {id: 'test-1', isFragment: true}, + designMetadata: { + id: 'test-1', + isFragment: true, + regionDirection: 'row', + regionId: 'test-region', + }, }); expect(element.classList.contains('pd-design__fragment')).toBe(true); @@ -161,7 +166,12 @@ describe('design/react/ComponentDecorator', () => { describe('when the component is a component', () => { it('should include the corresponding component class', async () => { const {element} = await testBed.render(TestComponent, { - designMetadata: {id: 'test-1', isFragment: false}, + designMetadata: { + id: 'test-1', + isFragment: false, + regionDirection: 'row', + regionId: 'test-region', + }, }); expect(element.classList.contains('pd-design__fragment')).toBe(false); @@ -182,8 +192,12 @@ describe('design/react/ComponentDecorator', () => { it('should show the frame', async () => { const {element} = await testBed.render(TestComponent); - expect(element.classList.contains('pd-design__frame--visible')).toBe(true); - expect(element.classList.contains('pd-design__decorator--hovered')).toBe(true); + expect(element.classList.contains('pd-design__frame--visible')).toBe( + true + ); + expect( + element.classList.contains('pd-design__decorator--hovered') + ).toBe(true); }); it('should notify the host of the hover', async () => { @@ -203,9 +217,9 @@ describe('design/react/ComponentDecorator', () => { hoverOutSpy = jest.fn(); testBed.afterRender(async ({host, element}) => { await waitFor(() => { - expect(element.classList.contains('pd-design__decorator--hovered')).toBe( - true - ); + expect( + element.classList.contains('pd-design__decorator--hovered') + ).toBe(true); }); host.on('ComponentHoveredOut', hoverOutSpy); @@ -229,7 +243,9 @@ describe('design/react/ComponentDecorator', () => { expect(element.classList.contains('pd-design__frame--visible')).toBe( false ); - expect(element.classList.contains('pd-design__decorator--hovered')).toBe(false); + expect( + element.classList.contains('pd-design__decorator--hovered') + ).toBe(false); }); }); }); @@ -240,8 +256,12 @@ describe('design/react/ComponentDecorator', () => { element.click(); - expect(element.classList.contains('pd-design__frame--visible')).toBe(true); - expect(element.classList.contains('pd-design__decorator--selected')).toBe(true); + expect(element.classList.contains('pd-design__frame--visible')).toBe( + true + ); + expect( + element.classList.contains('pd-design__decorator--selected') + ).toBe(true); }); it('should notify the host of the selection', async () => { @@ -272,15 +292,15 @@ describe('design/react/ComponentDecorator', () => { fireEvent.click(element); await waitFor(() => { - expect(element.classList.contains('pd-design__frame--visible')).toBe( - true - ); + expect( + element.classList.contains('pd-design__frame--visible') + ).toBe(true); }); host.on('ComponentDeleted', hostSpy); const deleteButton = await testBed.findBySelector( element, - '.pd-design__frame__toolbox-button' + '.pd-design__frame__delete-icon' ); fireEvent.click(deleteButton); }); @@ -292,6 +312,8 @@ describe('design/react/ComponentDecorator', () => { id: 'test-1', parentId: 'test-parent', regionId: 'test-region', + regionDirection: 'row', + isFragment: false, }, }); diff --git a/src/design/react/components/ComponentDecorator.tsx b/src/design/react/components/ComponentDecorator.tsx index 48f04224..aeab8073 100644 --- a/src/design/react/components/ComponentDecorator.tsx +++ b/src/design/react/components/ComponentDecorator.tsx @@ -6,16 +6,10 @@ */ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-static-element-interactions */ -import React, {useRef, useCallback} from 'react'; -import {useDesignContext} from '../context/DesignContext'; +import React from 'react'; import {ComponentDecoratorProps} from './component.types'; -import {useComponentDecoratorClasses} from '../hooks/useComponentDecoratorClasses'; -import {useDesignCallback} from '../hooks/useDesignCallback'; -import {useDesignState} from '../hooks/useDesignState'; -import {useFocusedComponentHandler} from '../hooks/useFocusedComponentHandler'; -import {useComponentType} from '../hooks/useComponentType'; -import {useNodeToTargetStore} from '../hooks/useNodeToTargetStore'; -import {DesignFrame} from './DesignFrame'; +import {DesignComponent} from './DesignComponent'; +import {usePageDesignerMode} from '../context/PageDesignerProvider'; /** * Creates a higher-order component that wraps React components with design-time functionality. @@ -31,99 +25,22 @@ export function createReactComponentDesignDecorator( ): (props: ComponentDecoratorProps) => JSX.Element { return (props: ComponentDecoratorProps) => { const {designMetadata, children, ...componentProps} = props; - const {id, name, isFragment, parentId, regionId, regionDirection} = - designMetadata; - const componentId = id; - const componentName = name || 'Component'; - const dragRef = useRef(null); // Only use design context if in design mode - const {isDesignMode} = useDesignContext(); - const { - selectedComponentId, - hoveredComponentId, - setSelectedComponent, - setHoveredComponent, - deleteComponent, - startComponentMove, - } = useDesignState(); - const componentType = useComponentType(componentId); + const {isDesignMode} = usePageDesignerMode(); - useFocusedComponentHandler(componentId, dragRef); - useNodeToTargetStore({ - type: 'component', - nodeRef: dragRef, - parentId, - regionId, - regionDirection, - componentId, - }); - - const handleMouseEnter = useDesignCallback( - () => setHoveredComponent(componentId), - [setHoveredComponent, componentId] - ); - - const handleMouseLeave = useDesignCallback( - () => setHoveredComponent(null), - [setHoveredComponent] - ); - - const handleClick = useDesignCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - setSelectedComponent(componentId); - }, - [setSelectedComponent, componentId] - ); - - const handleDelete = useCallback(() => { - deleteComponent({ - componentId, - sourceComponentId: parentId ?? '', - sourceRegionId: regionId ?? '', - }); - }, [deleteComponent, componentId]); - - const showFrame = [selectedComponentId, hoveredComponentId].includes( - componentId - ); - - const classes = useComponentDecoratorClasses({ - componentId, - isFragment: Boolean(isFragment), - }); - - if (!isDesignMode) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; - } - - // TODO: For the frame label, when there is not enough space above the component to display it, we - // need to display it inside the container instead. - - return ( -
- {showFrame && ( - - )} + return isDesignMode ? ( + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {children} -
+ + ) : ( + // eslint-disable-next-line react/jsx-props-no-spreading + + {children} + ); }; } diff --git a/src/design/react/components/DesignComponent.tsx b/src/design/react/components/DesignComponent.tsx new file mode 100644 index 00000000..eed3571c --- /dev/null +++ b/src/design/react/components/DesignComponent.tsx @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React, {useRef} from 'react'; +import {ComponentDecoratorProps} from './component.types'; +import {useComponentDecoratorClasses} from '../hooks/useComponentDecoratorClasses'; +import {useDesignCallback} from '../hooks/useDesignCallback'; +import {useDesignState} from '../hooks/useDesignState'; +import {useFocusedComponentHandler} from '../hooks/useFocusedComponentHandler'; +import {useNodeToTargetStore} from '../hooks/useNodeToTargetStore'; +import {DesignFrame} from './DesignFrame'; + +export function DesignComponent( + props: ComponentDecoratorProps +): JSX.Element { + const {designMetadata, children} = props; + const {id, name, isFragment, parentId, regionId, regionDirection} = + designMetadata; + const componentId = id; + const componentName = name || 'Component'; + const dragRef = useRef(null); + + const { + selectedComponentId, + hoveredComponentId, + setSelectedComponent, + setHoveredComponent, + dragState: {isDragging}, + } = useDesignState(); + + useFocusedComponentHandler(componentId, dragRef); + useNodeToTargetStore({ + type: 'component', + nodeRef: dragRef, + parentId, + regionId, + regionDirection, + componentId, + }); + + const handleMouseEnter = useDesignCallback( + () => setHoveredComponent(componentId), + [setHoveredComponent, componentId] + ); + + const handleMouseLeave = useDesignCallback( + () => setHoveredComponent(null), + [setHoveredComponent] + ); + + const handleClick = useDesignCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setSelectedComponent(componentId); + }, + [setSelectedComponent, componentId] + ); + + const showFrame = + [selectedComponentId, hoveredComponentId].includes(componentId) && + !isDragging; + + const classes = useComponentDecoratorClasses({ + componentId, + isFragment: Boolean(isFragment), + }); + + return ( + /* eslint-disable jsx-a11y/click-events-have-key-events */ + /* eslint-disable jsx-a11y/no-static-element-interactions */ +
+ {showFrame && ( + + )} + {children} +
+ ); +} diff --git a/src/design/react/components/DesignFrame.tsx b/src/design/react/components/DesignFrame.tsx index bc57485f..d12d5854 100644 --- a/src/design/react/components/DesignFrame.tsx +++ b/src/design/react/components/DesignFrame.tsx @@ -39,11 +39,14 @@ export const DesignFrame = ({ [deleteComponent, componentId] ); - const handleDragStart = React.useCallback( - () => componentId && startComponentMove(componentId, regionId), - [startComponentMove, componentId, regionId] - ); + const handleDragStart = React.useCallback(() => { + if (componentId) { + startComponentMove(componentId, regionId); + } + }, [startComponentMove, componentId, regionId]); + // TODO: For the frame label, when there is not enough space above the component to display it, we + // need to display it inside the container instead. return (
diff --git a/src/design/react/components/DesignRegion.tsx b/src/design/react/components/DesignRegion.tsx new file mode 100644 index 00000000..7ee86295 --- /dev/null +++ b/src/design/react/components/DesignRegion.tsx @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +import React from 'react'; +import {ComponentDecoratorProps} from './component.types'; +import {useRegionDecoratorClasses} from '../hooks/useRegionDecoratorClasses'; +import {useNodeToTargetStore} from '../hooks/useNodeToTargetStore'; +import {DesignFrame} from './DesignFrame'; +import {useLabels} from '../hooks/useLabels'; + +export function DesignRegion( + props: ComponentDecoratorProps +): JSX.Element { + const {designMetadata, children} = props; + const {name, parentId, regionDirection = 'column', regionId} = designMetadata; + const nodeRef = React.useRef(null); + const classes = useRegionDecoratorClasses({regionId}); + const labels = useLabels(); + + useNodeToTargetStore({ + type: 'region', + nodeRef, + parentId, + componentId: parentId as string, + regionId, + regionDirection, + }); + + return ( +
+ + {children} +
+ ); +} diff --git a/src/design/react/components/RegionDecorator.tsx b/src/design/react/components/RegionDecorator.tsx index 4befb9bd..c9758075 100644 --- a/src/design/react/components/RegionDecorator.tsx +++ b/src/design/react/components/RegionDecorator.tsx @@ -5,54 +5,25 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import React from 'react'; -import {useDesignContext} from '../context/DesignContext'; import {ComponentDecoratorProps} from './component.types'; -import {useRegionDecoratorClasses} from '../hooks/useRegionDecoratorClasses'; -import {useNodeToTargetStore} from '../hooks/useNodeToTargetStore'; -import {DesignFrame} from './DesignFrame'; -import {useLabels} from '../hooks/useLabels'; +import {DesignRegion} from './DesignRegion'; +import {usePageDesignerMode} from '../context/PageDesignerProvider'; export function createReactRegionDesignDecorator( Region: React.ComponentType ): (props: ComponentDecoratorProps) => JSX.Element { return (props: ComponentDecoratorProps) => { const {designMetadata, children, ...componentProps} = props; - const { - name, - parentId, - regionDirection = 'column', - regionId, - } = designMetadata; - const {isDesignMode} = useDesignContext(); - const nodeRef = React.useRef(null); - const classes = useRegionDecoratorClasses({regionId}); - const labels = useLabels(); + const isDesignMode = usePageDesignerMode(); - useNodeToTargetStore({ - type: 'region', - nodeRef, - parentId, - componentId: parentId as string, - regionId, - regionDirection, - }); - - if (!isDesignMode) { - // eslint-disable-next-line react/jsx-props-no-spreading - return ; - } - - return ( -
- + return isDesignMode ? ( + {/* eslint-disable-next-line react/jsx-props-no-spreading */} {children} -
+ + ) : ( + // eslint-disable-next-line react/jsx-props-no-spreading + {children} ); }; } diff --git a/src/design/react/hooks/useDragInteraction.ts b/src/design/react/hooks/useDragInteraction.ts index ac036793..0e846743 100644 --- a/src/design/react/hooks/useDragInteraction.ts +++ b/src/design/react/hooks/useDragInteraction.ts @@ -6,7 +6,6 @@ */ import {useCallback, useEffect, useMemo} from 'react'; import {useInteraction} from './useInteraction'; -import {useDesignState} from './useDesignState'; import type {NodeToTargetMapEntry} from '../context/DesignStateContext'; export interface DropTarget extends NodeToTargetMapEntry { From 274c37d4c4b1f6cddd540a40087ff0b52791ac18 Mon Sep 17 00:00:00 2001 From: Rita Ding Date: Thu, 9 Oct 2025 17:51:46 -0400 Subject: [PATCH 07/12] feat: add unit test + build fixes --- .../react/components/RegionDecorator.test.tsx | 293 ++++++++++++++++++ src/design/styles/base.css | 18 +- 2 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 src/design/react/components/RegionDecorator.test.tsx diff --git a/src/design/react/components/RegionDecorator.test.tsx b/src/design/react/components/RegionDecorator.test.tsx new file mode 100644 index 00000000..441b3657 --- /dev/null +++ b/src/design/react/components/RegionDecorator.test.tsx @@ -0,0 +1,293 @@ +/* + * Copyright (c) 2025, Salesforce, Inc. + * All rights reserved. + * SPDX-License-Identifier: BSD-3-Clause + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause + */ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { + render as tlRender, + RenderResult, + cleanup as tlCleanup, + act, + fireEvent, + waitFor, +} from '@testing-library/react'; +import {HostApi} from '../../messaging-api/api-types'; +import {createHostApi} from '../../messaging-api/host'; +import {createReactRegionDesignDecorator} from './RegionDecorator'; +import {ComponentDecoratorProps} from './component.types'; +import {PageDesignerProvider} from '../context/PageDesignerProvider'; +import {createTestBed} from '../../test/testBed'; + +// Test component to decorate +const TestRegion: React.FC = ({children}) => ( +
{children}
+); + +type Result = RenderResult & {element: HTMLElement; host: HostApi}; + +describe('design/react/RegionDecorator', () => { + const testBed = createTestBed({ + renderer: async ( + component: React.FC, + props: Partial> = {}, + { + mode = 'EDIT', + waitForHost = true, + }: { + mode?: 'EDIT' | 'PREVIEW' | null; + waitForHost?: boolean; + } = {} + ) => { + const DecoratedComponent = createReactRegionDesignDecorator(component); + const host = testBed.setupHost(); + + if (mode) { + const originalLocation = window.location; + + Reflect.deleteProperty(window, 'location'); + window.location = { + ...originalLocation, + search: `?mode=${mode}`, + } as string & Location; + + testBed.cleanup(() => { + window.location = originalLocation as string & Location; + }); + } + + const designMetadata = + props.designMetadata ?? + ({ + id: 'test-region-1', + } as unknown as ComponentDecoratorProps['designMetadata']); + + Object.assign(props, {designMetadata}); + + const connectionPromise = new Promise((resolve, reject) => { + host.connect({ + configFactory: () => + Promise.resolve({components: {}, componentTypes: {}, labels: {}}), + onClientConnected: () => resolve(), + onError: () => reject(), + }); + }); + + const result = tlRender( + + )} /> + + ); + + if (mode !== null && waitForHost) { + await act(() => connectionPromise); + } + + const finalResult = Object.assign(result, { + host, + element: result.container.querySelector('.pd-design--decorator'), + }) as Result; + + return finalResult; + }, + methods: { + findBySelector: (element: HTMLElement, selector: string) => + waitFor(() => { + const node = element.querySelector(selector); + + expect(node).toBeDefined(); + + return node as HTMLElement; + }), + setupHost: () => { + const emitter: Parameters[0]['emitter'] = { + postMessage: (message: any) => window.postMessage(message, '*'), + addEventListener: handler => { + const listener = (event: MessageEvent) => handler(event.data); + + window.parent.addEventListener('message', listener); + + return () => window.parent.removeEventListener('message', listener); + }, + }; + + const host = createHostApi({emitter, id: 'test-host'}); + + testBed.cleanup(() => host.disconnect()); + + return host; + }, + }, + }); + + beforeEach(() => { + testBed.cleanup(() => tlCleanup()); + }); + + describe('when decorating a region', () => { + it('should render the original region when not in design mode', async () => { + const {element, getByTestId} = await testBed.render( + TestRegion, + {}, + {mode: null} + ); + + expect(getByTestId('test-region')).toBeDefined(); + expect(element).toBeNull(); + }); + + it('should render with design wrapper when in design mode', async () => { + const {element} = await testBed.render(TestRegion); + + expect(element).toBeDefined(); + expect(element.classList.contains('pd-design--decorator')).toBe(true); + expect(element.classList.contains('pd-design__region')).toBe(true); + }); + + it('should render the region component inside the wrapper', async () => { + const {getByTestId} = await testBed.render(TestRegion); + + expect(getByTestId('test-region')).toBeDefined(); + }); + + describe('when external drag is active', () => { + /* + * DO NOT DELETE THIS COMMENT - This test was generated using AI. + * Model used: Claude Sonnet 4 + */ + it('should add hovered class when region becomes the current drop target', async () => { + const {element, host} = await testBed.render(TestRegion, { + designMetadata: {id: 'test-region-1'}, + }); + + // Initially, the hovered class should NOT be present + expect(element.classList.contains('pd-design__region--hovered')).toBe(false); + + // Mock getBoundingClientRect to simulate region position + const mockRect = { + x: 50, + y: 50, + width: 200, + height: 100, + top: 50, + left: 50, + bottom: 150, + right: 250, + toJSON: () => ({}), + }; + Object.defineProperty(element, 'getBoundingClientRect', { + value: () => mockRect, + writable: true, + }); + + // Simulate drag started event + act(() => { + host.startComponentDrag({ + componentType: 'dragged-component', + x: 100, + y: 100, + }); + }); + + // After drag starts, hovered class should still NOT be present + expect(element.classList.contains('pd-design__region--hovered')).toBe(false); + + // Simulate drag moved to coordinates within the region bounds + act(() => { + host.notifyClientWindowDragMoved({ + componentId: 'dragged-component', + x: 100, // Within region bounds + y: 75, // Within region bounds + }); + }); + + // Now the hovered class should be present + await waitFor(() => { + expect(element.classList.contains('pd-design__region--hovered')).toBe(true); + }); + }); + + /* + * DO NOT DELETE THIS COMMENT - This test was generated using AI. + * Model used: Claude Sonnet 4 + */ + it('should remove hovered class when drag moves outside region bounds', async () => { + const {element, host} = await testBed.render(TestRegion, { + designMetadata: {id: 'test-region-1'}, + }); + + // Mock getBoundingClientRect to simulate region position + const mockRect = { + x: 50, + y: 50, + width: 200, + height: 100, + top: 50, + left: 50, + bottom: 150, + right: 250, + toJSON: () => ({}), + }; + Object.defineProperty(element, 'getBoundingClientRect', { + value: () => mockRect, + writable: true, + }); + + // Start drag and move to region to set hovered state + act(() => { + host.startComponentDrag({ + componentType: 'dragged-component', + x: 100, + y: 100, + }); + }); + + act(() => { + host.notifyClientWindowDragMoved({ + componentId: 'dragged-component', + x: 100, // Within region bounds + y: 75, // Within region bounds + }); + }); + + // Verify hovered class is present + await waitFor(() => { + expect(element.classList.contains('pd-design__region--hovered')).toBe(true); + }); + + // Move drag outside region bounds + act(() => { + host.notifyClientWindowDragMoved({ + componentId: 'dragged-component', + x: 300, // Outside region bounds + y: 200, // Outside region bounds + }); + }); + + // Hovered class should be removed + await waitFor(() => { + expect(element.classList.contains('pd-design__region--hovered')).toBe(false); + }); + }); + }); + + + describe('region decorator classes', () => { + /* + * DO NOT DELETE THIS COMMENT - This test was generated using AI. + * Model used: Claude Sonnet 4 + */ + it('should include base decorator and region classes', async () => { + const {element} = await testBed.render(TestRegion); + + expect(element.classList.contains('pd-design--decorator')).toBe(true); + expect(element.classList.contains('pd-design__region')).toBe(true); + }); + + }); + }); +}); + diff --git a/src/design/styles/base.css b/src/design/styles/base.css index 0081afbe..71a20b62 100644 --- a/src/design/styles/base.css +++ b/src/design/styles/base.css @@ -16,7 +16,6 @@ .pd-design__fragment { position: relative; transition: outline 0.2s ease-in-out; - outline: 1px solid transparent; } .pd-design__component { @@ -27,6 +26,10 @@ --pd-design-color: #8402ad; } +.pd-design__region { + --pd-design-color: #008827; +} + /* Shared state styles */ .pd-design__frame--visible { outline: 1px solid var(--pd-design-color); @@ -46,17 +49,18 @@ background: var(--pd-design-color); color: white; display: inline-flex; - align-items: center; - padding: 0 12px; font-family: 'Salesforce Sans', sans-serif; font-size: 12px; font-weight: 500; + height: 32px; + left: 50%; letter-spacing: 0.5px; opacity: 0; border-top-left-radius: 4px; border-top-right-radius: 4px; visibility: hidden; transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; + visibility: hidden; white-space: nowrap; } @@ -69,7 +73,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - margin-right: 8px; + white-space: nowrap; } .pd-design__frame__toolbox { @@ -91,8 +95,12 @@ background: rgba(0, 0, 0, 0); border: none; cursor: pointer; - transition: background-color 0.2s ease-in-out; + display: flex; + height: 20px; + justify-content: center; padding: 0; + transition: background-color 0.2s ease-in-out; + width: 20px; } .pd-design__frame__toolbox-button:hover { From 9ad44cff759f4dd4e271dc52c4b3890f2b6b61b1 Mon Sep 17 00:00:00 2001 From: Rita Ding Date: Thu, 9 Oct 2025 18:27:26 -0400 Subject: [PATCH 08/12] fix: styling --- .../react/components/RegionDecorator.test.tsx | 28 +++++++++++-------- src/design/test/testBed.ts | 4 +-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/design/react/components/RegionDecorator.test.tsx b/src/design/react/components/RegionDecorator.test.tsx index 441b3657..ba8a87ef 100644 --- a/src/design/react/components/RegionDecorator.test.tsx +++ b/src/design/react/components/RegionDecorator.test.tsx @@ -12,7 +12,6 @@ import { RenderResult, cleanup as tlCleanup, act, - fireEvent, waitFor, } from '@testing-library/react'; import {HostApi} from '../../messaging-api/api-types'; @@ -164,7 +163,9 @@ describe('design/react/RegionDecorator', () => { }); // Initially, the hovered class should NOT be present - expect(element.classList.contains('pd-design__region--hovered')).toBe(false); + expect(element.classList.contains('pd-design__region--hovered')).toBe( + false + ); // Mock getBoundingClientRect to simulate region position const mockRect = { @@ -193,20 +194,24 @@ describe('design/react/RegionDecorator', () => { }); // After drag starts, hovered class should still NOT be present - expect(element.classList.contains('pd-design__region--hovered')).toBe(false); + expect(element.classList.contains('pd-design__region--hovered')).toBe( + false + ); // Simulate drag moved to coordinates within the region bounds act(() => { host.notifyClientWindowDragMoved({ componentId: 'dragged-component', x: 100, // Within region bounds - y: 75, // Within region bounds + y: 75, // Within region bounds }); }); // Now the hovered class should be present await waitFor(() => { - expect(element.classList.contains('pd-design__region--hovered')).toBe(true); + expect(element.classList.contains('pd-design__region--hovered')).toBe( + true + ); }); }); @@ -249,13 +254,15 @@ describe('design/react/RegionDecorator', () => { host.notifyClientWindowDragMoved({ componentId: 'dragged-component', x: 100, // Within region bounds - y: 75, // Within region bounds + y: 75, // Within region bounds }); }); // Verify hovered class is present await waitFor(() => { - expect(element.classList.contains('pd-design__region--hovered')).toBe(true); + expect(element.classList.contains('pd-design__region--hovered')).toBe( + true + ); }); // Move drag outside region bounds @@ -269,12 +276,13 @@ describe('design/react/RegionDecorator', () => { // Hovered class should be removed await waitFor(() => { - expect(element.classList.contains('pd-design__region--hovered')).toBe(false); + expect(element.classList.contains('pd-design__region--hovered')).toBe( + false + ); }); }); }); - describe('region decorator classes', () => { /* * DO NOT DELETE THIS COMMENT - This test was generated using AI. @@ -286,8 +294,6 @@ describe('design/react/RegionDecorator', () => { expect(element.classList.contains('pd-design--decorator')).toBe(true); expect(element.classList.contains('pd-design__region')).toBe(true); }); - }); }); }); - diff --git a/src/design/test/testBed.ts b/src/design/test/testBed.ts index 8ad05383..2b791b25 100644 --- a/src/design/test/testBed.ts +++ b/src/design/test/testBed.ts @@ -34,10 +34,10 @@ export interface TestBedConfig< * const testBed = createTestBed({ * . renderer: (props) => render() * }); - * + * * it('should render the component', async () => { * const { findByText } = await testBed.render({ name: 'test' }); - * + * * expect(findByText('test')).toBeDefined(); * } * }); From 0bef81bd07a833312ca4bbce718ea1c0ad48c356 Mon Sep 17 00:00:00 2001 From: Rita Ding Date: Thu, 16 Oct 2025 17:43:39 -0400 Subject: [PATCH 09/12] fix: nodeToTargetMap being empty + update tests --- .../react/components/RegionDecorator.test.tsx | 56 ++++++++++++++++--- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/src/design/react/components/RegionDecorator.test.tsx b/src/design/react/components/RegionDecorator.test.tsx index ba8a87ef..a866afb0 100644 --- a/src/design/react/components/RegionDecorator.test.tsx +++ b/src/design/react/components/RegionDecorator.test.tsx @@ -29,6 +29,20 @@ const TestRegion: React.FC = ({children}) => ( type Result = RenderResult & {element: HTMLElement; host: HostApi}; describe('design/react/RegionDecorator', () => { + // Mock document.elementsFromPoint for drag and drop tests + const mockElementsFromPoint = jest.fn(); + + beforeEach(() => { + // Reset the mock before each test + mockElementsFromPoint.mockClear(); + + // Mock document.elementsFromPoint + Object.defineProperty(document, 'elementsFromPoint', { + value: mockElementsFromPoint, + writable: true, + }); + }); + const testBed = createTestBed({ renderer: async ( component: React.FC, @@ -159,7 +173,12 @@ describe('design/react/RegionDecorator', () => { */ it('should add hovered class when region becomes the current drop target', async () => { const {element, host} = await testBed.render(TestRegion, { - designMetadata: {id: 'test-region-1'}, + designMetadata: { + id: 'test-region-1', + regionDirection: 'row', + regionId: 'test-region-1', + isFragment: false, + }, }); // Initially, the hovered class should NOT be present @@ -184,12 +203,19 @@ describe('design/react/RegionDecorator', () => { writable: true, }); + // Mock elementsFromPoint to return the region element when dragged over it + mockElementsFromPoint.mockImplementation((x: number, y: number) => { + // Return the region element when coordinates are within bounds + if (x >= 50 && x <= 250 && y >= 50 && y <= 150) { + return [element, document.body]; + } + return [document.body]; + }); + // Simulate drag started event act(() => { host.startComponentDrag({ componentType: 'dragged-component', - x: 100, - y: 100, }); }); @@ -201,7 +227,7 @@ describe('design/react/RegionDecorator', () => { // Simulate drag moved to coordinates within the region bounds act(() => { host.notifyClientWindowDragMoved({ - componentId: 'dragged-component', + componentType: 'dragged-component', x: 100, // Within region bounds y: 75, // Within region bounds }); @@ -221,7 +247,12 @@ describe('design/react/RegionDecorator', () => { */ it('should remove hovered class when drag moves outside region bounds', async () => { const {element, host} = await testBed.render(TestRegion, { - designMetadata: {id: 'test-region-1'}, + designMetadata: { + id: 'test-region-1', + regionDirection: 'row', + regionId: 'test-region-1', + isFragment: false, + }, }); // Mock getBoundingClientRect to simulate region position @@ -241,18 +272,25 @@ describe('design/react/RegionDecorator', () => { writable: true, }); + // Mock elementsFromPoint to return the region element when dragged over it + mockElementsFromPoint.mockImplementation((x: number, y: number) => { + // Return the region element when coordinates are within bounds + if (x >= 50 && x <= 250 && y >= 50 && y <= 150) { + return [element, document.body]; + } + return [document.body]; + }); + // Start drag and move to region to set hovered state act(() => { host.startComponentDrag({ componentType: 'dragged-component', - x: 100, - y: 100, }); }); act(() => { host.notifyClientWindowDragMoved({ - componentId: 'dragged-component', + componentType: 'dragged-component', x: 100, // Within region bounds y: 75, // Within region bounds }); @@ -268,7 +306,7 @@ describe('design/react/RegionDecorator', () => { // Move drag outside region bounds act(() => { host.notifyClientWindowDragMoved({ - componentId: 'dragged-component', + componentType: 'dragged-component', x: 300, // Outside region bounds y: 200, // Outside region bounds }); From 991229db204fd159b0c1cae1acd5ce80aa41ebb0 Mon Sep 17 00:00:00 2001 From: Rita Ding Date: Fri, 17 Oct 2025 12:34:57 -0400 Subject: [PATCH 10/12] fix: add back region green border --- src/design/styles/base.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/design/styles/base.css b/src/design/styles/base.css index 71a20b62..9749268c 100644 --- a/src/design/styles/base.css +++ b/src/design/styles/base.css @@ -28,6 +28,8 @@ .pd-design__region { --pd-design-color: #008827; + min-width: 50px; + min-height: 50px; } /* Shared state styles */ From 3ddf60bea721611b9c37d7618880f3ec4c12caa5 Mon Sep 17 00:00:00 2001 From: Rita Ding Date: Fri, 17 Oct 2025 16:24:59 -0400 Subject: [PATCH 11/12] fix: test + bad css merge --- src/design/styles/base.css | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/design/styles/base.css b/src/design/styles/base.css index 9749268c..0081afbe 100644 --- a/src/design/styles/base.css +++ b/src/design/styles/base.css @@ -16,6 +16,7 @@ .pd-design__fragment { position: relative; transition: outline 0.2s ease-in-out; + outline: 1px solid transparent; } .pd-design__component { @@ -26,12 +27,6 @@ --pd-design-color: #8402ad; } -.pd-design__region { - --pd-design-color: #008827; - min-width: 50px; - min-height: 50px; -} - /* Shared state styles */ .pd-design__frame--visible { outline: 1px solid var(--pd-design-color); @@ -51,18 +46,17 @@ background: var(--pd-design-color); color: white; display: inline-flex; + align-items: center; + padding: 0 12px; font-family: 'Salesforce Sans', sans-serif; font-size: 12px; font-weight: 500; - height: 32px; - left: 50%; letter-spacing: 0.5px; opacity: 0; border-top-left-radius: 4px; border-top-right-radius: 4px; visibility: hidden; transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; - visibility: hidden; white-space: nowrap; } @@ -75,7 +69,7 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; + margin-right: 8px; } .pd-design__frame__toolbox { @@ -97,12 +91,8 @@ background: rgba(0, 0, 0, 0); border: none; cursor: pointer; - display: flex; - height: 20px; - justify-content: center; - padding: 0; transition: background-color 0.2s ease-in-out; - width: 20px; + padding: 0; } .pd-design__frame__toolbox-button:hover { From 06af93d1371fc2103619424258aa614c95bfd13f Mon Sep 17 00:00:00 2001 From: Rita Ding Date: Mon, 20 Oct 2025 15:28:47 -0400 Subject: [PATCH 12/12] fix: region decorator tests --- src/design/react/components/RegionDecorator.test.tsx | 6 +++--- src/design/react/components/RegionDecorator.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/design/react/components/RegionDecorator.test.tsx b/src/design/react/components/RegionDecorator.test.tsx index a866afb0..d26c3228 100644 --- a/src/design/react/components/RegionDecorator.test.tsx +++ b/src/design/react/components/RegionDecorator.test.tsx @@ -101,7 +101,7 @@ describe('design/react/RegionDecorator', () => { const finalResult = Object.assign(result, { host, - element: result.container.querySelector('.pd-design--decorator'), + element: result.container.querySelector('.pd-design__decorator'), }) as Result; return finalResult; @@ -156,7 +156,7 @@ describe('design/react/RegionDecorator', () => { const {element} = await testBed.render(TestRegion); expect(element).toBeDefined(); - expect(element.classList.contains('pd-design--decorator')).toBe(true); + expect(element.classList.contains('pd-design__decorator')).toBe(true); expect(element.classList.contains('pd-design__region')).toBe(true); }); @@ -329,7 +329,7 @@ describe('design/react/RegionDecorator', () => { it('should include base decorator and region classes', async () => { const {element} = await testBed.render(TestRegion); - expect(element.classList.contains('pd-design--decorator')).toBe(true); + expect(element.classList.contains('pd-design__decorator')).toBe(true); expect(element.classList.contains('pd-design__region')).toBe(true); }); }); diff --git a/src/design/react/components/RegionDecorator.tsx b/src/design/react/components/RegionDecorator.tsx index c9758075..714dac23 100644 --- a/src/design/react/components/RegionDecorator.tsx +++ b/src/design/react/components/RegionDecorator.tsx @@ -14,7 +14,7 @@ export function createReactRegionDesignDecorator( ): (props: ComponentDecoratorProps) => JSX.Element { return (props: ComponentDecoratorProps) => { const {designMetadata, children, ...componentProps} = props; - const isDesignMode = usePageDesignerMode(); + const {isDesignMode} = usePageDesignerMode(); return isDesignMode ? (