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,
+ })
}