diff --git a/apps/builder/src/features/editor/editor.spec.ts b/apps/builder/src/features/editor/editor.spec.ts index b923abfe5..3cc45a9dd 100644 --- a/apps/builder/src/features/editor/editor.spec.ts +++ b/apps/builder/src/features/editor/editor.spec.ts @@ -197,11 +197,19 @@ test('Preview from group should work', async ({ page }) => { ) await page.goto(`/typebots/${typebotId}/edit`) - await page.click('[aria-label="Preview bot from this group"] >> nth=1') + await page + .getByTestId('group') + .nth(1) + .click({ position: { x: 100, y: 10 } }) + await page.click('[aria-label="Preview bot from this group"]') await expect( page.locator('typebot-standard').locator('text="Hello this is group 1"') ).toBeVisible() - await page.click('[aria-label="Preview bot from this group"] >> nth=2') + await page + .getByTestId('group') + .nth(2) + .click({ position: { x: 100, y: 10 } }) + await page.click('[aria-label="Preview bot from this group"]') await expect( page.locator('typebot-standard').locator('text="Hello this is group 2"') ).toBeVisible() diff --git a/apps/builder/src/features/graph/components/Nodes/GroupNode/GroupFocusToolbar.tsx b/apps/builder/src/features/graph/components/Nodes/GroupNode/GroupFocusToolbar.tsx new file mode 100644 index 000000000..8c507d697 --- /dev/null +++ b/apps/builder/src/features/graph/components/Nodes/GroupNode/GroupFocusToolbar.tsx @@ -0,0 +1,55 @@ +import { CopyIcon, PlayIcon, TrashIcon } from '@/components/icons' +import { HStack, IconButton, useColorModeValue } from '@chakra-ui/react' + +type Props = { + onPlayClick: () => void + onDuplicateClick: () => void + onDeleteClick: () => void +} + +export const GroupFocusToolbar = ({ + onPlayClick, + onDuplicateClick, + onDeleteClick, +}: Props) => { + return ( + + } + borderRightWidth="1px" + borderRightRadius="none" + aria-label={'Preview bot from this group'} + variant="ghost" + onClick={onPlayClick} + size="sm" + /> + } + borderRightWidth="1px" + borderRightRadius="none" + borderLeftRadius="none" + aria-label={'Duplicate group'} + variant="ghost" + onClick={(e) => { + e.stopPropagation() + onDuplicateClick() + }} + size="sm" + /> + } + onClick={onDeleteClick} + variant="ghost" + size="sm" + /> + + ) +} diff --git a/apps/builder/src/features/graph/components/Nodes/GroupNode/GroupNode.tsx b/apps/builder/src/features/graph/components/Nodes/GroupNode/GroupNode.tsx index 85c379f53..f039814e5 100644 --- a/apps/builder/src/features/graph/components/Nodes/GroupNode/GroupNode.tsx +++ b/apps/builder/src/features/graph/components/Nodes/GroupNode/GroupNode.tsx @@ -2,7 +2,7 @@ import { Editable, EditableInput, EditablePreview, - IconButton, + SlideFade, Stack, useColorModeValue, } from '@chakra-ui/react' @@ -18,11 +18,12 @@ import { BlockNodesList } from '../BlockNode/BlockNodesList' import { isDefined, isNotDefined } from 'utils' import { useTypebot, RightPanel, useEditor } from '@/features/editor' import { GroupNodeContextMenu } from './GroupNodeContextMenu' -import { PlayIcon } from '@/components/icons' import { useDebounce } from 'use-debounce' import { ContextMenu } from '@/components/ContextMenu' import { setMultipleRefs } from '@/utils/helpers' import { useDrag } from '@use-gesture/react' +import { GroupFocusToolbar } from './GroupFocusToolbar' +import { useOutsideClick } from '@/hooks/useOutsideClick' type Props = { group: Group @@ -63,11 +64,9 @@ const NonMemoizedDraggableGroupNode = ({ previewingEdge, previewingBlock, isReadOnly, - focusedGroupId, - setFocusedGroupId, graphPosition, } = useGraph() - const { typebot, updateGroup } = useTypebot() + const { typebot, updateGroup, deleteGroup, duplicateGroup } = useTypebot() const { setMouseOverGroup, mouseOverGroup } = useBlockDnd() const { setRightPanel, setStartPreviewAtGroup } = useEditor() @@ -78,6 +77,27 @@ const NonMemoizedDraggableGroupNode = ({ ) const [groupTitle, setGroupTitle] = useState(group.title) + const isPreviewing = + previewingBlock?.groupId === group.id || + previewingEdge?.from.groupId === group.id || + (previewingEdge?.to.groupId === group.id && + isNotDefined(previewingEdge.to.blockId)) + + const isStartGroup = + isDefined(group.blocks[0]) && group.blocks[0].type === 'start' + + const groupRef = useRef(null) + const [debouncedGroupPosition] = useDebounce(currentCoordinates, 100) + const [isFocused, setIsFocused] = useState(false) + + const [ignoreNextFocusIntent, setIgnoreNextFocusIntent] = useState(false) + + useOutsideClick({ + handler: () => setIsFocused(false), + ref: groupRef, + capture: true, + }) + // When the group is moved from external action (e.g. undo/redo), update the current coordinates useEffect(() => { setCurrentCoordinates({ @@ -90,17 +110,6 @@ const NonMemoizedDraggableGroupNode = ({ useEffect(() => { setGroupTitle(group.title) }, [group.title]) - - const isPreviewing = - previewingBlock?.groupId === group.id || - previewingEdge?.from.groupId === group.id || - (previewingEdge?.to.groupId === group.id && - isNotDefined(previewingEdge.to.blockId)) - const isStartGroup = - isDefined(group.blocks[0]) && group.blocks[0].type === 'start' - - const groupRef = useRef(null) - const [debouncedGroupPosition] = useDebounce(currentCoordinates, 100) useEffect(() => { if (!currentCoordinates || isReadOnly) return if ( @@ -142,15 +151,15 @@ const NonMemoizedDraggableGroupNode = ({ } useDrag( - ({ first, last, offset: [offsetX, offsetY], event, target }) => { + ({ first, last, offset: [offsetX, offsetY], distance, event, target }) => { event.stopPropagation() if ((target as HTMLElement).classList.contains('prevent-group-drag')) return if (first) { - setFocusedGroupId(group.id) setIsMouseDown(true) } if (last) { + if (distance[0] > 1 || distance[1] > 1) setIgnoreNextFocusIntent(true) setIsMouseDown(false) } const newCoord = { @@ -170,6 +179,14 @@ const NonMemoizedDraggableGroupNode = ({ } ) + const focusGroup = () => { + if (ignoreNextFocusIntent) { + setIgnoreNextFocusIntent(false) + return + } + setIsFocused(true) + } + return ( renderMenu={() => } @@ -178,6 +195,7 @@ const NonMemoizedDraggableGroupNode = ({ {(ref, isContextMenuOpened) => ( )} - } - aria-label={'Preview bot from this group'} - pos="absolute" - right={2} - top={0} - size="sm" - variant="outline" - onClick={startPreviewAtThisGroup} - /> + + { + setIsFocused(false) + duplicateGroup(groupIndex) + }} + onDeleteClick={() => deleteGroup(groupIndex)} + /> + )} diff --git a/apps/builder/src/hooks/useOutsideClick.ts b/apps/builder/src/hooks/useOutsideClick.ts index c3db8f782..c1a66042f 100644 --- a/apps/builder/src/hooks/useOutsideClick.ts +++ b/apps/builder/src/hooks/useOutsideClick.ts @@ -6,11 +6,13 @@ type Handler = (event: MouseEvent) => void type Props = { ref: RefObject handler: Handler + capture?: boolean } export const useOutsideClick = ({ ref, handler, + capture, }: Props): void => { const triggerHandlerIfOutside = (event: MouseEvent) => { const el = ref?.current @@ -20,5 +22,7 @@ export const useOutsideClick = ({ handler(event) } - useEventListener('pointerdown', triggerHandlerIfOutside) + useEventListener('pointerdown', triggerHandlerIfOutside, null, { + capture, + }) }