2
0

♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@ -0,0 +1,118 @@
import { useEventListener } from '@chakra-ui/react'
import assert from 'assert'
import {
useGraph,
ConnectingIds,
Coordinates,
useGroupsCoordinates,
} from '../../providers'
import { useTypebot } from '@/features/editor'
import { colors } from '@/lib/theme'
import React, { useMemo, useState } from 'react'
import {
computeConnectingEdgePath,
computeEdgePathToMouse,
getEndpointTopOffset,
} from '../../utils'
export const DrawingEdge = () => {
const {
graphPosition,
setConnectingIds,
connectingIds,
sourceEndpoints,
targetEndpoints,
} = useGraph()
const { groupsCoordinates } = useGroupsCoordinates()
const { createEdge } = useTypebot()
const [mousePosition, setMousePosition] = useState<Coordinates | null>(null)
const sourceGroupCoordinates =
groupsCoordinates && groupsCoordinates[connectingIds?.source.groupId ?? '']
const targetGroupCoordinates =
groupsCoordinates && groupsCoordinates[connectingIds?.target?.groupId ?? '']
const sourceTop = useMemo(() => {
if (!connectingIds) return 0
return getEndpointTopOffset({
endpoints: sourceEndpoints,
graphOffsetY: graphPosition.y,
endpointId: connectingIds.source.itemId ?? connectingIds.source.blockId,
graphScale: graphPosition.scale,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectingIds, sourceEndpoints])
const targetTop = useMemo(() => {
if (!connectingIds) return 0
return getEndpointTopOffset({
endpoints: targetEndpoints,
graphOffsetY: graphPosition.y,
endpointId: connectingIds.target?.blockId,
graphScale: graphPosition.scale,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectingIds, targetEndpoints])
const path = useMemo(() => {
if (!sourceTop || !sourceGroupCoordinates || !mousePosition) return ``
return targetGroupCoordinates
? computeConnectingEdgePath({
sourceGroupCoordinates,
targetGroupCoordinates,
sourceTop,
targetTop,
graphScale: graphPosition.scale,
})
: computeEdgePathToMouse({
sourceGroupCoordinates,
mousePosition,
sourceTop,
})
}, [
sourceTop,
sourceGroupCoordinates,
targetGroupCoordinates,
targetTop,
mousePosition,
graphPosition.scale,
])
const handleMouseMove = (e: MouseEvent) => {
if (!connectingIds) {
if (mousePosition) setMousePosition(null)
return
}
const coordinates = {
x: (e.clientX - graphPosition.x) / graphPosition.scale,
y: (e.clientY - graphPosition.y) / graphPosition.scale,
}
setMousePosition(coordinates)
}
useEventListener('mousemove', handleMouseMove)
useEventListener('mouseup', () => {
if (connectingIds?.target) createNewEdge(connectingIds)
setConnectingIds(null)
})
const createNewEdge = (connectingIds: ConnectingIds) => {
assert(connectingIds.target)
createEdge({ from: connectingIds.source, to: connectingIds.target })
}
if (
(mousePosition && mousePosition.x === 0 && mousePosition.y === 0) ||
!connectingIds
)
return <></>
return (
<path
d={path}
stroke={colors.blue[400]}
strokeWidth="2px"
markerEnd="url(#blue-arrow)"
fill="none"
/>
)
}

View File

@ -0,0 +1,136 @@
import { VStack, Tag, Text, Tooltip } from '@chakra-ui/react'
import { useGraph, useGroupsCoordinates } from '../../providers'
import { useTypebot } from '@/features/editor'
import { useWorkspace } from '@/features/workspace'
import React, { useMemo } from 'react'
import { byId, isDefined } from 'utils'
import { isProPlan } from '@/features/billing'
import { AnswersCount } from '@/features/analytics'
import {
getEndpointTopOffset,
computeSourceCoordinates,
computeDropOffPath,
} from '../../utils'
type Props = {
groupId: string
answersCounts: AnswersCount[]
onUnlockProPlanClick?: () => void
}
export const DropOffEdge = ({
answersCounts,
groupId,
onUnlockProPlanClick,
}: Props) => {
const { workspace } = useWorkspace()
const { groupsCoordinates } = useGroupsCoordinates()
const { sourceEndpoints, graphPosition } = useGraph()
const { publishedTypebot } = useTypebot()
const isWorkspaceProPlan = isProPlan(workspace)
const totalAnswers = useMemo(
() => answersCounts.find((a) => a.groupId === groupId)?.totalAnswers,
[answersCounts, groupId]
)
const { totalDroppedUser, dropOffRate } = useMemo(() => {
if (!publishedTypebot || totalAnswers === undefined)
return { previousTotal: undefined, dropOffRate: undefined }
const previousGroupIds = publishedTypebot.edges
.map((edge) =>
edge.to.groupId === groupId ? edge.from.groupId : undefined
)
.filter(isDefined)
const previousTotal = answersCounts
.filter((a) => previousGroupIds.includes(a.groupId))
.reduce((prev, acc) => acc.totalAnswers + prev, 0)
if (previousTotal === 0)
return { previousTotal: undefined, dropOffRate: undefined }
const totalDroppedUser = previousTotal - totalAnswers
return {
totalDroppedUser,
dropOffRate: Math.round((totalDroppedUser / previousTotal) * 100),
}
}, [answersCounts, groupId, totalAnswers, publishedTypebot])
const group = publishedTypebot?.groups.find(byId(groupId))
const sourceTop = useMemo(
() =>
getEndpointTopOffset({
endpoints: sourceEndpoints,
graphOffsetY: graphPosition.y,
endpointId: group?.blocks[group.blocks.length - 1].id,
graphScale: graphPosition.scale,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[group?.blocks, sourceEndpoints, groupsCoordinates]
)
const labelCoordinates = useMemo(() => {
if (!groupsCoordinates[groupId]) return
return computeSourceCoordinates(groupsCoordinates[groupId], sourceTop ?? 0)
}, [groupsCoordinates, groupId, sourceTop])
if (!labelCoordinates) return <></>
return (
<>
<path
d={computeDropOffPath(
{ x: labelCoordinates.x - 300, y: labelCoordinates.y },
sourceTop ?? 0
)}
stroke="#e53e3e"
strokeWidth="2px"
markerEnd="url(#red-arrow)"
fill="none"
/>
<foreignObject
width="100"
height="80"
x={labelCoordinates.x - 30}
y={labelCoordinates.y + 80}
>
<Tooltip
label="Unlock Drop-off rate by upgrading to Pro plan"
isDisabled={isWorkspaceProPlan}
>
<VStack
bgColor={'red.500'}
color="white"
rounded="md"
p="2"
justifyContent="center"
w="full"
h="full"
onClick={isWorkspaceProPlan ? undefined : onUnlockProPlanClick}
cursor={isWorkspaceProPlan ? 'auto' : 'pointer'}
>
<Text filter={isWorkspaceProPlan ? '' : 'blur(2px)'}>
{isWorkspaceProPlan ? (
dropOffRate
) : (
<Text as="span" filter="blur(2px)">
X
</Text>
)}
%
</Text>
<Tag colorScheme="red">
{isWorkspaceProPlan ? (
totalDroppedUser
) : (
<Text as="span" filter="blur(3px)" mr="1">
NN
</Text>
)}{' '}
users
</Tag>
</VStack>
</Tooltip>
</foreignObject>
</>
)
}

View File

@ -0,0 +1,160 @@
import { Coordinates, useGraph, useGroupsCoordinates } from '../../providers'
import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { Edge as EdgeProps } from 'models'
import { Portal, useDisclosure } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
import { EdgeMenu } from './EdgeMenu'
import { colors } from '@/lib/theme'
import {
getEndpointTopOffset,
getSourceEndpointId,
getAnchorsPosition,
computeEdgePath,
} from '../../utils'
export type AnchorsPositionProps = {
sourcePosition: Coordinates
targetPosition: Coordinates
sourceType: 'right' | 'left'
totalSegments: number
}
type Props = {
edge: EdgeProps
}
export const Edge = ({ edge }: Props) => {
const { deleteEdge } = useTypebot()
const {
previewingEdge,
sourceEndpoints,
targetEndpoints,
graphPosition,
isReadOnly,
setPreviewingEdge,
} = useGraph()
const { groupsCoordinates } = useGroupsCoordinates()
const [isMouseOver, setIsMouseOver] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
const [edgeMenuPosition, setEdgeMenuPosition] = useState({ x: 0, y: 0 })
const [refreshEdge, setRefreshEdge] = useState(false)
const isPreviewing = isMouseOver || previewingEdge?.id === edge.id
const sourceGroupCoordinates =
groupsCoordinates && groupsCoordinates[edge.from.groupId]
const targetGroupCoordinates =
groupsCoordinates && groupsCoordinates[edge.to.groupId]
const sourceTop = useMemo(
() =>
getEndpointTopOffset({
endpoints: sourceEndpoints,
graphOffsetY: graphPosition.y,
endpointId: getSourceEndpointId(edge),
graphScale: graphPosition.scale,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[sourceGroupCoordinates?.y, edge, sourceEndpoints, refreshEdge]
)
useEffect(() => {
setTimeout(() => setRefreshEdge(true), 50)
}, [])
const [targetTop, setTargetTop] = useState(
getEndpointTopOffset({
endpoints: targetEndpoints,
graphOffsetY: graphPosition.y,
endpointId: edge?.to.blockId,
graphScale: graphPosition.scale,
})
)
useLayoutEffect(() => {
setTargetTop(
getEndpointTopOffset({
endpoints: targetEndpoints,
graphOffsetY: graphPosition.y,
endpointId: edge?.to.blockId,
graphScale: graphPosition.scale,
})
)
}, [
targetGroupCoordinates?.y,
targetEndpoints,
graphPosition.y,
edge?.to.blockId,
graphPosition.scale,
])
const path = useMemo(() => {
if (!sourceGroupCoordinates || !targetGroupCoordinates || !sourceTop)
return ``
const anchorsPosition = getAnchorsPosition({
sourceGroupCoordinates,
targetGroupCoordinates,
sourceTop,
targetTop,
graphScale: graphPosition.scale,
})
return computeEdgePath(anchorsPosition)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
sourceGroupCoordinates?.x,
sourceGroupCoordinates?.y,
targetGroupCoordinates?.x,
targetGroupCoordinates?.y,
sourceTop,
])
const handleMouseEnter = () => setIsMouseOver(true)
const handleMouseLeave = () => setIsMouseOver(false)
const handleEdgeClick = () => {
setPreviewingEdge(edge)
}
const handleContextMenuTrigger = (e: React.MouseEvent) => {
if (isReadOnly) return
e.preventDefault()
setEdgeMenuPosition({ x: e.clientX, y: e.clientY })
onOpen()
}
const handleDeleteEdge = () => deleteEdge(edge.id)
return (
<>
<path
data-testid="clickable-edge"
d={path}
strokeWidth="18px"
stroke="white"
fill="none"
pointerEvents="stroke"
style={{ cursor: 'pointer', visibility: 'hidden' }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleEdgeClick}
onContextMenu={handleContextMenuTrigger}
/>
<path
data-testid="edge"
d={path}
stroke={isPreviewing ? colors.blue[400] : colors.gray[400]}
strokeWidth="2px"
markerEnd={isPreviewing ? 'url(#blue-arrow)' : 'url(#arrow)'}
fill="none"
pointerEvents="none"
/>
<Portal>
<EdgeMenu
isOpen={isOpen}
position={edgeMenuPosition}
onDeleteEdge={handleDeleteEdge}
onClose={onClose}
/>
</Portal>
</>
)
}

View File

@ -0,0 +1,37 @@
import { Menu, MenuButton, MenuList, MenuItem } from '@chakra-ui/react'
import { TrashIcon } from '@/components/icons'
import React from 'react'
type Props = {
isOpen: boolean
position: { x: number; y: number }
onDeleteEdge: () => void
onClose: () => void
}
export const EdgeMenu = ({
isOpen,
onClose,
position,
onDeleteEdge,
}: Props) => {
return (
<Menu isOpen={isOpen} gutter={0} onClose={onClose} isLazy>
<MenuButton
aria-hidden={true}
w={1}
h={1}
pos="absolute"
style={{
left: position.x,
top: position.y,
}}
/>
<MenuList>
<MenuItem icon={<TrashIcon />} onClick={onDeleteEdge}>
Delete
</MenuItem>
</MenuList>
</Menu>
)
}

View File

@ -0,0 +1,88 @@
import { chakra } from '@chakra-ui/react'
import { colors } from '@/lib/theme'
import { Edge as EdgeProps } from 'models'
import React from 'react'
import { DrawingEdge } from './DrawingEdge'
import { DropOffEdge } from './DropOffEdge'
import { Edge } from './Edge'
import { AnswersCount } from '@/features/analytics'
type Props = {
edges: EdgeProps[]
answersCounts?: AnswersCount[]
onUnlockProPlanClick?: () => void
}
export const Edges = ({
edges,
answersCounts,
onUnlockProPlanClick,
}: Props) => (
<chakra.svg
width="full"
height="full"
overflow="visible"
pos="absolute"
left="0"
top="0"
shapeRendering="geometricPrecision"
>
<DrawingEdge />
{edges.map((edge) => (
<Edge key={edge.id} edge={edge} />
))}
{answersCounts?.map((answerCount) => (
<DropOffEdge
key={answerCount.groupId}
answersCounts={answersCounts}
groupId={answerCount.groupId}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
))}
<marker
id={'arrow'}
refX="8"
refY="4"
orient="auto"
viewBox="0 0 20 20"
markerUnits="userSpaceOnUse"
markerWidth="20"
markerHeight="20"
>
<path
d="M7.07138888,5.50174526 L2.43017246,7.82235347 C1.60067988,8.23709976 0.592024983,7.90088146 0.177278692,7.07138888 C0.0606951226,6.83822174 0,6.58111307 0,6.32042429 L0,1.67920787 C0,0.751806973 0.751806973,0 1.67920787,0 C1.93989666,0 2.19700532,0.0606951226 2.43017246,0.177278692 L7,3 C7.82949258,3.41474629 8.23709976,3.92128809 7.82235347,4.75078067 C7.6598671,5.07575341 7.39636161,5.33925889 7.07138888,5.50174526 Z"
fill={colors.gray[400]}
/>
</marker>
<marker
id={'blue-arrow'}
refX="8"
refY="4"
orient="auto"
viewBox="0 0 20 20"
markerUnits="userSpaceOnUse"
markerWidth="20"
markerHeight="20"
>
<path
d="M7.07138888,5.50174526 L2.43017246,7.82235347 C1.60067988,8.23709976 0.592024983,7.90088146 0.177278692,7.07138888 C0.0606951226,6.83822174 0,6.58111307 0,6.32042429 L0,1.67920787 C0,0.751806973 0.751806973,0 1.67920787,0 C1.93989666,0 2.19700532,0.0606951226 2.43017246,0.177278692 L7,3 C7.82949258,3.41474629 8.23709976,3.92128809 7.82235347,4.75078067 C7.6598671,5.07575341 7.39636161,5.33925889 7.07138888,5.50174526 Z"
fill={colors.blue[400]}
/>
</marker>
<marker
id={'red-arrow'}
refX="8"
refY="4"
orient="auto"
viewBox="0 0 20 20"
markerUnits="userSpaceOnUse"
markerWidth="20"
markerHeight="20"
>
<path
d="M7.07138888,5.50174526 L2.43017246,7.82235347 C1.60067988,8.23709976 0.592024983,7.90088146 0.177278692,7.07138888 C0.0606951226,6.83822174 0,6.58111307 0,6.32042429 L0,1.67920787 C0,0.751806973 0.751806973,0 1.67920787,0 C1.93989666,0 2.19700532,0.0606951226 2.43017246,0.177278692 L7,3 C7.82949258,3.41474629 8.23709976,3.92128809 7.82235347,4.75078067 C7.6598671,5.07575341 7.39636161,5.33925889 7.07138888,5.50174526 Z"
fill="#e53e3e"
/>
</marker>
</chakra.svg>
)

View File

@ -0,0 +1 @@
export { Edges } from './Edges'

View File

@ -0,0 +1,71 @@
import { BoxProps, Flex } from '@chakra-ui/react'
import { useGraph, useGroupsCoordinates } from '../../providers'
import { Source } from 'models'
import React, { MouseEvent, useEffect, useRef, useState } from 'react'
export const SourceEndpoint = ({
source,
...props
}: BoxProps & {
source: Source
}) => {
const [ranOnce, setRanOnce] = useState(false)
const { setConnectingIds, addSourceEndpoint, previewingEdge } = useGraph()
const { groupsCoordinates } = useGroupsCoordinates()
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
setConnectingIds({ source })
}
useEffect(() => {
if (ranOnce || !ref.current || Object.keys(groupsCoordinates).length === 0)
return
const id = source.itemId ?? source.blockId
addSourceEndpoint({
id,
ref,
})
setRanOnce(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref.current, groupsCoordinates])
if (!groupsCoordinates) return <></>
return (
<Flex
ref={ref}
data-testid="endpoint"
boxSize="32px"
rounded="full"
onMouseDownCapture={handleMouseDown}
cursor="copy"
justify="center"
align="center"
pointerEvents="all"
{...props}
>
<Flex
boxSize="20px"
justify="center"
align="center"
bgColor="gray.100"
rounded="full"
>
<Flex
boxSize="13px"
rounded="full"
borderWidth="3.5px"
shadow={`sm`}
borderColor={
previewingEdge?.from.blockId === source.blockId &&
previewingEdge.from.itemId === source.itemId
? 'blue.300'
: 'blue.200'
}
/>
</Flex>
</Flex>
)
}

View File

@ -0,0 +1,36 @@
import { Box, BoxProps } from '@chakra-ui/react'
import { useGraph } from '../../providers'
import React, { useEffect, useRef } from 'react'
export const TargetEndpoint = ({
blockId,
isVisible,
...props
}: BoxProps & {
blockId: string
isVisible?: boolean
}) => {
const { addTargetEndpoint } = useGraph()
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!ref.current) return
addTargetEndpoint({
id: blockId,
ref,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref])
return (
<Box
ref={ref}
boxSize="15px"
rounded="full"
bgColor="blue.500"
cursor="pointer"
visibility={isVisible ? 'visible' : 'hidden'}
{...props}
/>
)
}

View File

@ -0,0 +1,2 @@
export { SourceEndpoint } from './SourceEndpoint'
export { TargetEndpoint } from './TargetEndpoint'

View File

@ -0,0 +1,266 @@
import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import React, { useRef, useMemo, useEffect, useState } from 'react'
import {
blockWidth,
Coordinates,
graphPositionDefaultValue,
useGraph,
useGroupsCoordinates,
useBlockDnd,
} from '../providers'
import { useTypebot } from '@/features/editor'
import { DraggableBlockType, PublicTypebot, Typebot } from 'models'
import { useDebounce } from 'use-debounce'
import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable'
import GraphElements from './GraphElements'
import cuid from 'cuid'
import { useUser } from '@/features/account'
import { GraphNavigation } from 'db'
import { ZoomButtons } from './ZoomButtons'
import { AnswersCount } from '@/features/analytics'
import { headerHeight } from '@/features/editor'
const maxScale = 1.5
const minScale = 0.1
const zoomButtonsScaleBlock = 0.2
export const Graph = ({
typebot,
answersCounts,
onUnlockProPlanClick,
...props
}: {
typebot: Typebot | PublicTypebot
answersCounts?: AnswersCount[]
onUnlockProPlanClick?: () => void
} & FlexProps) => {
const {
draggedBlockType,
setDraggedBlockType,
draggedBlock,
setDraggedBlock,
draggedItem,
setDraggedItem,
} = useBlockDnd()
const graphContainerRef = useRef<HTMLDivElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement | null>(null)
const { createGroup } = useTypebot()
const {
setGraphPosition: setGlobalGraphPosition,
setOpenedBlockId,
setPreviewingEdge,
connectingIds,
} = useGraph()
const { updateGroupCoordinates } = useGroupsCoordinates()
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
const [debouncedGraphPosition] = useDebounce(graphPosition, 200)
const transform = useMemo(
() =>
`translate(${graphPosition.x}px, ${graphPosition.y}px) scale(${graphPosition.scale})`,
[graphPosition]
)
const { user } = useUser()
const [autoMoveDirection, setAutoMoveDirection] = useState<
'top' | 'right' | 'bottom' | 'left' | undefined
>()
useAutoMoveBoard(autoMoveDirection, setGraphPosition)
useEffect(() => {
editorContainerRef.current = document.getElementById(
'editor-container'
) as HTMLDivElement
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!graphContainerRef.current) return
const { top, left } = graphContainerRef.current.getBoundingClientRect()
setGlobalGraphPosition({
x: left + debouncedGraphPosition.x,
y: top + debouncedGraphPosition.y,
scale: debouncedGraphPosition.scale,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedGraphPosition])
const handleMouseWheel = (e: WheelEvent) => {
e.preventDefault()
const isPinchingTrackpad = e.ctrlKey
user?.graphNavigation === GraphNavigation.MOUSE
? zoom(-e.deltaY * 0.001, { x: e.clientX, y: e.clientY })
: isPinchingTrackpad
? zoom(-e.deltaY * 0.01, { x: e.clientX, y: e.clientY })
: setGraphPosition({
...graphPosition,
x: graphPosition.x - e.deltaX,
y: graphPosition.y - e.deltaY,
})
}
const handleMouseUp = (e: MouseEvent) => {
if (!typebot) return
if (draggedItem) setDraggedItem(undefined)
if (!draggedBlock && !draggedBlockType) return
const coordinates = projectMouse(
{ x: e.clientX, y: e.clientY },
graphPosition
)
const id = cuid()
updateGroupCoordinates(id, coordinates)
createGroup({
id,
...coordinates,
block: draggedBlock ?? (draggedBlockType as DraggableBlockType),
indices: { groupIndex: typebot.groups.length, blockIndex: 0 },
})
setDraggedBlock(undefined)
setDraggedBlockType(undefined)
}
const handleCaptureMouseDown = (e: MouseEvent) => {
const isRightClick = e.button === 2
if (isRightClick) e.stopPropagation()
}
const handleClick = () => {
setOpenedBlockId(undefined)
setPreviewingEdge(undefined)
}
const onDrag = (_: DraggableEvent, draggableData: DraggableData) => {
const { deltaX, deltaY } = draggableData
setGraphPosition({
...graphPosition,
x: graphPosition.x + deltaX,
y: graphPosition.y + deltaY,
})
}
const zoom = (delta = zoomButtonsScaleBlock, mousePosition?: Coordinates) => {
const { x: mouseX, y } = mousePosition ?? { x: 0, y: 0 }
const mouseY = y - headerHeight
let scale = graphPosition.scale + delta
if (
(scale >= maxScale && graphPosition.scale === maxScale) ||
(scale <= minScale && graphPosition.scale === minScale)
)
return
scale = scale >= maxScale ? maxScale : scale <= minScale ? minScale : scale
const xs = (mouseX - graphPosition.x) / graphPosition.scale
const ys = (mouseY - graphPosition.y) / graphPosition.scale
setGraphPosition({
...graphPosition,
x: mouseX - xs * scale,
y: mouseY - ys * scale,
scale,
})
}
const handleMouseMove = (e: MouseEvent) => {
if (!connectingIds)
return autoMoveDirection ? setAutoMoveDirection(undefined) : undefined
if (e.clientX <= 50) return setAutoMoveDirection('left')
if (e.clientY <= 50 + headerHeight) return setAutoMoveDirection('top')
if (e.clientX >= window.innerWidth - 50)
return setAutoMoveDirection('right')
if (e.clientY >= window.innerHeight - 50)
return setAutoMoveDirection('bottom')
setAutoMoveDirection(undefined)
}
useEventListener('wheel', handleMouseWheel, graphContainerRef.current)
useEventListener('mousedown', handleCaptureMouseDown, undefined, {
capture: true,
})
useEventListener('mouseup', handleMouseUp, graphContainerRef.current)
useEventListener('click', handleClick, editorContainerRef.current)
useEventListener('mousemove', handleMouseMove)
const zoomIn = () => zoom(zoomButtonsScaleBlock)
const zoomOut = () => zoom(-zoomButtonsScaleBlock)
return (
<DraggableCore onDrag={onDrag} enableUserSelectHack={false}>
<Flex ref={graphContainerRef} position="relative" {...props}>
<ZoomButtons onZoomInClick={zoomIn} onZoomOutClick={zoomOut} />
<Flex
flex="1"
w="full"
h="full"
position="absolute"
data-testid="graph"
style={{
transform,
}}
willChange="transform"
transformOrigin="0px 0px 0px"
>
<GraphElements
edges={typebot.edges}
groups={typebot.groups}
answersCounts={answersCounts}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
</Flex>
</Flex>
</DraggableCore>
)
}
const projectMouse = (
mouseCoordinates: Coordinates,
graphPosition: Coordinates & { scale: number }
) => {
return {
x:
(mouseCoordinates.x -
graphPosition.x -
blockWidth / (3 / graphPosition.scale)) /
graphPosition.scale,
y:
(mouseCoordinates.y -
graphPosition.y -
(headerHeight + 20 * graphPosition.scale)) /
graphPosition.scale,
}
}
const useAutoMoveBoard = (
autoMoveDirection: 'top' | 'right' | 'bottom' | 'left' | undefined,
setGraphPosition: React.Dispatch<
React.SetStateAction<{
x: number
y: number
scale: number
}>
>
) =>
useEffect(() => {
if (!autoMoveDirection) return
const interval = setInterval(() => {
setGraphPosition((prev) => ({
...prev,
x:
autoMoveDirection === 'right'
? prev.x - 5
: autoMoveDirection === 'left'
? prev.x + 5
: prev.x,
y:
autoMoveDirection === 'bottom'
? prev.y - 5
: autoMoveDirection === 'top'
? prev.y + 5
: prev.y,
}))
}, 5)
return () => {
clearInterval(interval)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoMoveDirection])

View File

@ -0,0 +1,33 @@
import { AnswersCount } from '@/features/analytics'
import { Edge, Group } from 'models'
import React, { memo } from 'react'
import { Edges } from './Edges'
import { GroupNode } from './Nodes/GroupNode'
type Props = {
edges: Edge[]
groups: Group[]
answersCounts?: AnswersCount[]
onUnlockProPlanClick?: () => void
}
const GroupNodes = ({
edges,
groups,
answersCounts,
onUnlockProPlanClick,
}: Props) => {
return (
<>
<Edges
edges={edges}
answersCounts={answersCounts}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
{groups.map((group, idx) => (
<GroupNode group={group} groupIndex={idx} key={group.id} />
))}
</>
)
}
export default memo(GroupNodes)

View File

@ -0,0 +1,248 @@
import {
Flex,
HStack,
Popover,
PopoverTrigger,
useDisclosure,
} from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
import {
BubbleBlock,
BubbleBlockContent,
ConditionBlock,
DraggableBlock,
Block,
BlockWithOptions,
TextBubbleContent,
TextBubbleBlock,
} from 'models'
import { isBubbleBlock, isTextBubbleBlock } from 'utils'
import { BlockNodeContent } from './BlockNodeContent/BlockNodeContent'
import { BlockIcon, useTypebot } from '@/features/editor'
import { SettingsPopoverContent } from './SettingsPopoverContent'
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
import { useRouter } from 'next/router'
import { SettingsModal } from './SettingsPopoverContent/SettingsModal'
import { BlockSettings } from './SettingsPopoverContent/SettingsPopoverContent'
import { TextBubbleEditor } from '../../../../blocks/bubbles/textBubble/components/TextBubbleEditor'
import { TargetEndpoint } from '../../Endpoints'
import { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
import { NodePosition, useDragDistance, useGraph } from '../../../providers'
import { ContextMenu } from '@/components/ContextMenu'
import { setMultipleRefs } from '@/utils/helpers'
import { hasDefaultConnector } from '../../../utils'
export const BlockNode = ({
block,
isConnectable,
indices,
onMouseDown,
}: {
block: Block
isConnectable: boolean
indices: { blockIndex: number; groupIndex: number }
onMouseDown?: (blockNodePosition: NodePosition, block: DraggableBlock) => void
}) => {
const { query } = useRouter()
const {
setConnectingIds,
connectingIds,
openedBlockId,
setOpenedBlockId,
setFocusedGroupId,
previewingEdge,
} = useGraph()
const { typebot, updateBlock } = useTypebot()
const [isConnecting, setIsConnecting] = useState(false)
const [isPopoverOpened, setIsPopoverOpened] = useState(
openedBlockId === block.id
)
const [isEditing, setIsEditing] = useState<boolean>(
isTextBubbleBlock(block) && block.content.plainText === ''
)
const blockRef = useRef<HTMLDivElement | null>(null)
const isPreviewing = isConnecting || previewingEdge?.to.blockId === block.id
const onDrag = (position: NodePosition) => {
if (block.type === 'start' || !onMouseDown) return
onMouseDown(position, block)
}
useDragDistance({
ref: blockRef,
onDrag,
isDisabled: !onMouseDown || block.type === 'start',
})
const {
isOpen: isModalOpen,
onOpen: onModalOpen,
onClose: onModalClose,
} = useDisclosure()
useEffect(() => {
if (query.blockId?.toString() === block.id) setOpenedBlockId(block.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.groupId === block.groupId &&
connectingIds?.target?.blockId === block.id
)
}, [connectingIds, block.groupId, block.id])
const handleModalClose = () => {
updateBlock(indices, { ...block })
onModalClose()
}
const handleMouseEnter = () => {
if (connectingIds)
setConnectingIds({
...connectingIds,
target: { groupId: block.groupId, blockId: block.id },
})
}
const handleMouseLeave = () => {
if (connectingIds?.target)
setConnectingIds({
...connectingIds,
target: { ...connectingIds.target, blockId: undefined },
})
}
const handleCloseEditor = (content: TextBubbleContent) => {
const updatedBlock = { ...block, content } as Block
updateBlock(indices, updatedBlock)
setIsEditing(false)
}
const handleClick = (e: React.MouseEvent) => {
setFocusedGroupId(block.groupId)
e.stopPropagation()
if (isTextBubbleBlock(block)) setIsEditing(true)
setOpenedBlockId(block.id)
}
const handleExpandClick = () => {
setOpenedBlockId(undefined)
onModalOpen()
}
const handleBlockUpdate = (updates: Partial<Block>) =>
updateBlock(indices, { ...block, ...updates })
const handleContentChange = (content: BubbleBlockContent) =>
updateBlock(indices, { ...block, content } as Block)
useEffect(() => {
setIsPopoverOpened(openedBlockId === block.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openedBlockId])
return isEditing && isTextBubbleBlock(block) ? (
<TextBubbleEditor
id={block.id}
initialValue={block.content.richText}
onClose={handleCloseEditor}
/>
) : (
<ContextMenu<HTMLDivElement>
renderMenu={() => <BlockNodeContextMenu indices={indices} />}
>
{(ref, isOpened) => (
<Popover
placement="left"
isLazy
isOpen={isPopoverOpened}
closeOnBlur={false}
>
<PopoverTrigger>
<Flex
pos="relative"
ref={setMultipleRefs([ref, blockRef])}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
data-testid={`block`}
w="full"
>
<HStack
flex="1"
userSelect="none"
p="3"
borderWidth={isOpened || isPreviewing ? '2px' : '1px'}
borderColor={isOpened || isPreviewing ? 'blue.400' : 'gray.200'}
margin={isOpened || isPreviewing ? '-1px' : 0}
rounded="lg"
cursor={'pointer'}
bgColor="gray.50"
align="flex-start"
w="full"
transition="border-color 0.2s"
>
<BlockIcon
type={block.type}
mt="1"
data-testid={`${block.id}-icon`}
/>
<BlockNodeContent block={block} indices={indices} />
<TargetEndpoint
pos="absolute"
left="-32px"
top="19px"
blockId={block.id}
/>
{isConnectable && hasDefaultConnector(block) && (
<SourceEndpoint
source={{
groupId: block.groupId,
blockId: block.id,
}}
pos="absolute"
right="-34px"
bottom="10px"
/>
)}
</HStack>
</Flex>
</PopoverTrigger>
{hasSettingsPopover(block) && (
<>
<SettingsPopoverContent
block={block}
onExpandClick={handleExpandClick}
onBlockChange={handleBlockUpdate}
/>
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<BlockSettings
block={block}
onBlockChange={handleBlockUpdate}
/>
</SettingsModal>
</>
)}
{typebot && isMediaBubbleBlock(block) && (
<MediaBubblePopoverContent
typebotId={typebot.id}
block={block}
onContentChange={handleContentChange}
/>
)}
</Popover>
)}
</ContextMenu>
)
}
const hasSettingsPopover = (
block: Block
): block is BlockWithOptions | ConditionBlock => !isBubbleBlock(block)
const isMediaBubbleBlock = (
block: Block
): block is Exclude<BubbleBlock, TextBubbleBlock> =>
isBubbleBlock(block) && !isTextBubbleBlock(block)

View File

@ -0,0 +1,162 @@
import { Text } from '@chakra-ui/react'
import {
Block,
StartBlock,
BubbleBlockType,
InputBlockType,
LogicBlockType,
IntegrationBlockType,
BlockIndices,
} from 'models'
import { ItemNodesList } from '../../ItemNode'
import { TextBubbleContent } from '@/features/blocks/bubbles/textBubble'
import { ImageBubbleContent } from '@/features/blocks/bubbles/image'
import { VideoBubbleContent } from '@/features/blocks/bubbles/video'
import { EmbedBubbleContent } from '@/features/blocks/bubbles/embed'
import { TextInputNodeContent } from '@/features/blocks/inputs/textInput'
import { NumberNodeContent } from '@/features/blocks/inputs/number'
import { EmailInputNodeContent } from '@/features/blocks/inputs/emailInput'
import { UrlNodeContent } from '@/features/blocks/inputs/url'
import { PhoneNodeContent } from '@/features/blocks/inputs/phone'
import { DateNodeContent } from '@/features/blocks/inputs/date'
import { SetVariableContent } from '@/features/blocks/logic/setVariable'
import { WebhookContent } from '@/features/blocks/integrations/webhook'
import { ChatwootBlockNodeLabel } from '@/features/blocks/integrations/chatwoot'
import { RedirectNodeContent } from '@/features/blocks/logic/redirect'
import { CodeNodeContent } from '@/features/blocks/logic/code'
import { PabblyConnectNodeContent } from '@/features/blocks/integrations/pabbly'
import { WithVariableContent } from './WithVariableContent'
import { PaymentInputContent } from '@/features/blocks/inputs/payment'
import { RatingInputContent } from '@/features/blocks/inputs/rating'
import { FileInputContent } from '@/features/blocks/inputs/fileUpload'
import { TypebotLinkContent } from '@/features/blocks/logic/typebotLink'
import { GoogleSheetsNodeContent } from '@/features/blocks/integrations/googleSheets'
import { GoogleAnalyticsNodeContent } from '@/features/blocks/integrations/googleAnalytics/components/GoogleAnalyticsNodeContent'
import { ZapierContent } from '@/features/blocks/integrations/zapier'
import { SendEmailContent } from '@/features/blocks/integrations/sendEmail'
import { isInputBlock, isChoiceInput, blockHasItems } from 'utils'
import { MakeComNodeContent } from '@/features/blocks/integrations/makeCom'
type Props = {
block: Block | StartBlock
indices: BlockIndices
}
export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
if (blockHasItems(block))
return <ItemNodesList block={block} indices={indices} />
if (
isInputBlock(block) &&
!isChoiceInput(block) &&
block.options.variableId
) {
return <WithVariableContent block={block} />
}
switch (block.type) {
case BubbleBlockType.TEXT: {
return <TextBubbleContent block={block} />
}
case BubbleBlockType.IMAGE: {
return <ImageBubbleContent block={block} />
}
case BubbleBlockType.VIDEO: {
return <VideoBubbleContent block={block} />
}
case BubbleBlockType.EMBED: {
return <EmbedBubbleContent block={block} />
}
case InputBlockType.TEXT: {
return (
<TextInputNodeContent
placeholder={block.options.labels.placeholder}
isLong={block.options.isLong}
/>
)
}
case InputBlockType.NUMBER: {
return (
<NumberNodeContent placeholder={block.options.labels.placeholder} />
)
}
case InputBlockType.EMAIL: {
return (
<EmailInputNodeContent placeholder={block.options.labels.placeholder} />
)
}
case InputBlockType.URL: {
return <UrlNodeContent placeholder={block.options.labels.placeholder} />
}
case InputBlockType.PHONE: {
return <PhoneNodeContent placeholder={block.options.labels.placeholder} />
}
case InputBlockType.DATE: {
return <DateNodeContent />
}
case InputBlockType.PAYMENT: {
return <PaymentInputContent block={block} />
}
case InputBlockType.RATING: {
return <RatingInputContent block={block} />
}
case InputBlockType.FILE: {
return <FileInputContent options={block.options} />
}
case LogicBlockType.SET_VARIABLE: {
return <SetVariableContent block={block} />
}
case LogicBlockType.REDIRECT: {
return <RedirectNodeContent url={block.options.url} />
}
case LogicBlockType.CODE: {
return (
<CodeNodeContent
name={block.options.name}
content={block.options.content}
/>
)
}
case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkContent block={block} />
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<GoogleSheetsNodeContent
action={'action' in block.options ? block.options.action : undefined}
/>
)
}
case IntegrationBlockType.GOOGLE_ANALYTICS: {
return (
<GoogleAnalyticsNodeContent
action={
block.options?.action
? `Track "${block.options?.action}" `
: undefined
}
/>
)
}
case IntegrationBlockType.WEBHOOK: {
return <WebhookContent block={block} />
}
case IntegrationBlockType.ZAPIER: {
return <ZapierContent block={block} />
}
case IntegrationBlockType.PABBLY_CONNECT: {
return <PabblyConnectNodeContent block={block} />
}
case IntegrationBlockType.MAKE_COM: {
return <MakeComNodeContent block={block} />
}
case IntegrationBlockType.EMAIL: {
return <SendEmailContent block={block} />
}
case IntegrationBlockType.CHATWOOT: {
return <ChatwootBlockNodeLabel block={block} />
}
case 'start': {
return <Text>Start</Text>
}
}
}

View File

@ -0,0 +1,31 @@
import { InputBlock } from 'models'
import { chakra, Text } from '@chakra-ui/react'
import React from 'react'
import { useTypebot } from '@/features/editor'
import { byId } from 'utils'
type Props = {
block: InputBlock
}
export const WithVariableContent = ({ block }: Props) => {
const { typebot } = useTypebot()
const variableName = typebot?.variables.find(
byId(block.options.variableId)
)?.name
return (
<Text>
Collect{' '}
<chakra.span
bgColor="orange.400"
color="white"
rounded="md"
py="0.5"
px="1"
>
{variableName}
</chakra.span>
</Text>
)
}

View File

@ -0,0 +1 @@
export { BlockNodeContent } from './BlockNodeContent'

View File

@ -0,0 +1,24 @@
import { MenuList, MenuItem } from '@chakra-ui/react'
import { CopyIcon, TrashIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import { BlockIndices } from 'models'
type Props = { indices: BlockIndices }
export const BlockNodeContextMenu = ({ indices }: Props) => {
const { deleteBlock, duplicateBlock } = useTypebot()
const handleDuplicateClick = () => duplicateBlock(indices)
const handleDeleteClick = () => deleteBlock(indices)
return (
<MenuList>
<MenuItem icon={<CopyIcon />} onClick={handleDuplicateClick}>
Duplicate
</MenuItem>
<MenuItem icon={<TrashIcon />} onClick={handleDeleteClick}>
Delete
</MenuItem>
</MenuList>
)
}

View File

@ -0,0 +1,27 @@
import { BlockIcon } from '@/features/editor'
import { StackProps, HStack } from '@chakra-ui/react'
import { StartBlock, Block, BlockIndices } from 'models'
import { BlockNodeContent } from './BlockNodeContent/BlockNodeContent'
export const BlockNodeOverlay = ({
block,
indices,
...props
}: { block: Block | StartBlock; indices: BlockIndices } & StackProps) => {
return (
<HStack
p="3"
borderWidth="1px"
rounded="lg"
bgColor="white"
cursor={'grab'}
w="264px"
pointerEvents="none"
shadow="lg"
{...props}
>
<BlockIcon type={block.type} />
<BlockNodeContent block={block} indices={indices} />
</HStack>
)
}

View File

@ -0,0 +1,182 @@
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
import { DraggableBlock, DraggableBlockType, Block } from 'models'
import {
computeNearestPlaceholderIndex,
useBlockDnd,
Coordinates,
useGraph,
} from '../../../providers'
import { useEffect, useRef, useState } from 'react'
import { useTypebot } from '@/features/editor'
import { BlockNode } from './BlockNode'
import { BlockNodeOverlay } from './BlockNodeOverlay'
type Props = {
groupId: string
blocks: Block[]
groupIndex: number
groupRef: React.MutableRefObject<HTMLDivElement | null>
isStartGroup: boolean
}
export const BlockNodesList = ({
groupId,
blocks,
groupIndex,
groupRef,
isStartGroup,
}: Props) => {
const {
draggedBlock,
setDraggedBlock,
draggedBlockType,
mouseOverGroup,
setDraggedBlockType,
} = useBlockDnd()
const { typebot, createBlock, detachBlockFromGroup } = useTypebot()
const { isReadOnly, graphPosition } = useGraph()
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
number | undefined
>()
const placeholderRefs = useRef<HTMLDivElement[]>([])
const [position, setPosition] = useState({
x: 0,
y: 0,
})
const [mousePositionInElement, setMousePositionInElement] = useState({
x: 0,
y: 0,
})
const isDraggingOnCurrentGroup =
(draggedBlock || draggedBlockType) && mouseOverGroup?.id === groupId
const showSortPlaceholders =
!isStartGroup && (draggedBlock || draggedBlockType)
useEffect(() => {
if (mouseOverGroup?.id !== groupId) setExpandedPlaceholderIndex(undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mouseOverGroup?.id])
const handleMouseMoveGlobal = (event: MouseEvent) => {
if (!draggedBlock || draggedBlock.groupId !== groupId) return
const { clientX, clientY } = event
setPosition({
x: clientX - mousePositionInElement.x,
y: clientY - mousePositionInElement.y,
})
}
const handleMouseMoveOnGroup = (event: MouseEvent) => {
if (!isDraggingOnCurrentGroup) return
setExpandedPlaceholderIndex(
computeNearestPlaceholderIndex(event.pageY, placeholderRefs)
)
}
const handleMouseUpOnGroup = (e: MouseEvent) => {
setExpandedPlaceholderIndex(undefined)
if (!isDraggingOnCurrentGroup) return
const blockIndex = computeNearestPlaceholderIndex(
e.clientY,
placeholderRefs
)
createBlock(
groupId,
(draggedBlock || draggedBlockType) as DraggableBlock | DraggableBlockType,
{
groupIndex,
blockIndex,
}
)
setDraggedBlock(undefined)
setDraggedBlockType(undefined)
}
const handleBlockMouseDown =
(blockIndex: number) =>
(
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
block: DraggableBlock
) => {
if (isReadOnly) return
placeholderRefs.current.splice(blockIndex + 1, 1)
detachBlockFromGroup({ groupIndex, blockIndex })
setPosition(absolute)
setMousePositionInElement(relative)
setDraggedBlock(block)
}
const handlePushElementRef =
(idx: number) => (elem: HTMLDivElement | null) => {
elem && (placeholderRefs.current[idx] = elem)
}
useEventListener('mousemove', handleMouseMoveGlobal)
useEventListener('mousemove', handleMouseMoveOnGroup, groupRef.current)
useEventListener(
'mouseup',
handleMouseUpOnGroup,
mouseOverGroup?.ref.current,
{
capture: true,
}
)
return (
<Stack
spacing={1}
transition="none"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
>
<Flex
ref={handlePushElementRef(0)}
h={
showSortPlaceholders && expandedPlaceholderIndex === 0
? '50px'
: '2px'
}
bgColor={'gray.300'}
visibility={showSortPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showSortPlaceholders ? 'height 200ms' : 'none'}
/>
{typebot &&
blocks.map((block, idx) => (
<Stack key={block.id} spacing={1}>
<BlockNode
key={block.id}
block={block}
indices={{ groupIndex, blockIndex: idx }}
isConnectable={blocks.length - 1 === idx}
onMouseDown={handleBlockMouseDown(idx)}
/>
<Flex
ref={handlePushElementRef(idx + 1)}
h={
showSortPlaceholders && expandedPlaceholderIndex === idx + 1
? '50px'
: '2px'
}
bgColor={'gray.300'}
visibility={showSortPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showSortPlaceholders ? 'height 200ms' : 'none'}
/>
</Stack>
))}
{draggedBlock && draggedBlock.groupId === groupId && (
<Portal>
<BlockNodeOverlay
block={draggedBlock}
indices={{ groupIndex, blockIndex: 0 }}
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg) scale(${graphPosition.scale})`,
}}
transformOrigin="0 0 0"
/>
</Portal>
)}
</Stack>
)
}

View File

@ -0,0 +1,77 @@
import { ImageUploadContent } from '@/components/ImageUploadContent'
import { EmbedUploadContent } from '@/features/blocks/bubbles/embed'
import { VideoUploadContent } from '@/features/blocks/bubbles/video'
import {
Portal,
PopoverContent,
PopoverArrow,
PopoverBody,
} from '@chakra-ui/react'
import {
BubbleBlock,
BubbleBlockContent,
BubbleBlockType,
TextBubbleBlock,
} from 'models'
import { useRef } from 'react'
type Props = {
typebotId: string
block: Exclude<BubbleBlock, TextBubbleBlock>
onContentChange: (content: BubbleBlockContent) => void
}
export const MediaBubblePopoverContent = (props: Props) => {
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
return (
<Portal>
<PopoverContent
onMouseDown={handleMouseDown}
w={props.block.type === BubbleBlockType.IMAGE ? '500px' : '400px'}
>
<PopoverArrow />
<PopoverBody ref={ref} shadow="lg">
<MediaBubbleContent {...props} />
</PopoverBody>
</PopoverContent>
</Portal>
)
}
export const MediaBubbleContent = ({
typebotId,
block,
onContentChange,
}: Props) => {
const handleImageUrlChange = (url: string) => onContentChange({ url })
switch (block.type) {
case BubbleBlockType.IMAGE: {
return (
<ImageUploadContent
filePath={`typebots/${typebotId}/blocks/${block.id}`}
defaultUrl={block.content?.url}
onSubmit={handleImageUrlChange}
/>
)
}
case BubbleBlockType.VIDEO: {
return (
<VideoUploadContent
content={block.content}
onSubmit={onContentChange}
/>
)
}
case BubbleBlockType.EMBED: {
return (
<EmbedUploadContent
content={block.content}
onSubmit={onContentChange}
/>
)
}
}
}

View File

@ -0,0 +1,38 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
ModalBodyProps,
} from '@chakra-ui/react'
import React from 'react'
type Props = {
isOpen: boolean
onClose: () => void
}
export const SettingsModal = ({
isOpen,
onClose,
...props
}: Props & ModalBodyProps) => {
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent onMouseDown={handleMouseDown}>
<ModalHeader mb="2">
<ModalCloseButton />
</ModalHeader>
<ModalBody {...props}>{props.children}</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,287 @@
import {
PopoverContent,
PopoverArrow,
PopoverBody,
useEventListener,
Portal,
IconButton,
} from '@chakra-ui/react'
import { ExpandIcon } from '@/components/icons'
import {
ConditionItem,
ConditionBlock,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
Block,
BlockOptions,
BlockWithOptions,
Webhook,
} from 'models'
import { useRef } from 'react'
import { DateInputSettingsBody } from '@/features/blocks/inputs/date'
import { EmailInputSettingsBody } from '@/features/blocks/inputs/emailInput'
import { FileInputSettings } from '@/features/blocks/inputs/fileUpload'
import { NumberInputSettingsBody } from '@/features/blocks/inputs/number'
import { PaymentSettings } from '@/features/blocks/inputs/payment'
import { PhoneNumberSettingsBody } from '@/features/blocks/inputs/phone'
import { RatingInputSettings } from '@/features/blocks/inputs/rating'
import { TextInputSettingsBody } from '@/features/blocks/inputs/textInput'
import { UrlInputSettingsBody } from '@/features/blocks/inputs/url'
import { GoogleAnalyticsSettings } from '@/features/blocks/integrations/googleAnalytics'
import { GoogleSheetsSettingsBody } from '@/features/blocks/integrations/googleSheets'
import { SendEmailSettings } from '@/features/blocks/integrations/sendEmail'
import { WebhookSettings } from '@/features/blocks/integrations/webhook'
import { ZapierSettings } from '@/features/blocks/integrations/zapier'
import { CodeSettings } from '@/features/blocks/logic/code'
import { ConditionSettingsBody } from '@/features/blocks/logic/condition'
import { RedirectSettings } from '@/features/blocks/logic/redirect'
import { SetVariableSettings } from '@/features/blocks/logic/setVariable'
import { TypebotLinkSettingsForm } from '@/features/blocks/logic/typebotLink'
import { ButtonsOptionsForm } from '@/features/blocks/inputs/buttons'
import { ChatwootSettingsForm } from '@/features/blocks/integrations/chatwoot'
type Props = {
block: BlockWithOptions | ConditionBlock
webhook?: Webhook
onExpandClick: () => void
onBlockChange: (updates: Partial<Block>) => void
}
export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
const handleMouseWheel = (e: WheelEvent) => {
e.stopPropagation()
}
useEventListener('wheel', handleMouseWheel, ref.current)
return (
<Portal>
<PopoverContent onMouseDown={handleMouseDown} pos="relative">
<PopoverArrow />
<PopoverBody
pt="10"
pb="6"
overflowY="scroll"
maxH="400px"
ref={ref}
shadow="lg"
>
<BlockSettings {...props} />
</PopoverBody>
<IconButton
pos="absolute"
top="5px"
right="5px"
aria-label="expand"
icon={<ExpandIcon />}
size="xs"
onClick={onExpandClick}
/>
</PopoverContent>
</Portal>
)
}
export const BlockSettings = ({
block,
onBlockChange,
}: {
block: BlockWithOptions | ConditionBlock
webhook?: Webhook
onBlockChange: (block: Partial<Block>) => void
}): JSX.Element => {
const handleOptionsChange = (options: BlockOptions) => {
onBlockChange({ options } as Partial<Block>)
}
const handleItemChange = (updates: Partial<ConditionItem>) => {
onBlockChange({
items: [{ ...(block as ConditionBlock).items[0], ...updates }],
} as Partial<Block>)
}
switch (block.type) {
case InputBlockType.TEXT: {
return (
<TextInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.NUMBER: {
return (
<NumberInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.EMAIL: {
return (
<EmailInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.URL: {
return (
<UrlInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.DATE: {
return (
<DateInputSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.PHONE: {
return (
<PhoneNumberSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.CHOICE: {
return (
<ButtonsOptionsForm
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.PAYMENT: {
return (
<PaymentSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.RATING: {
return (
<RatingInputSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputBlockType.FILE: {
return (
<FileInputSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicBlockType.SET_VARIABLE: {
return (
<SetVariableSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicBlockType.CONDITION: {
return (
<ConditionSettingsBody block={block} onItemChange={handleItemChange} />
)
}
case LogicBlockType.REDIRECT: {
return (
<RedirectSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicBlockType.CODE: {
return (
<CodeSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicBlockType.TYPEBOT_LINK: {
return (
<TypebotLinkSettingsForm
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<GoogleSheetsSettingsBody
options={block.options}
onOptionsChange={handleOptionsChange}
blockId={block.id}
/>
)
}
case IntegrationBlockType.GOOGLE_ANALYTICS: {
return (
<GoogleAnalyticsSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case IntegrationBlockType.ZAPIER: {
return <ZapierSettings block={block} />
}
case IntegrationBlockType.MAKE_COM: {
return (
<WebhookSettings
block={block}
onOptionsChange={handleOptionsChange}
provider={{
name: 'Make.com',
url: 'https://eu1.make.com/app/invite/43fa76a621f67ea27f96cffc3a2477e1',
}}
/>
)
}
case IntegrationBlockType.PABBLY_CONNECT: {
return (
<WebhookSettings
block={block}
onOptionsChange={handleOptionsChange}
provider={{
name: 'Pabbly Connect',
url: 'https://www.pabbly.com/connect/integrations/typebot/',
}}
/>
)
}
case IntegrationBlockType.WEBHOOK: {
return (
<WebhookSettings block={block} onOptionsChange={handleOptionsChange} />
)
}
case IntegrationBlockType.EMAIL: {
return (
<SendEmailSettings
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case IntegrationBlockType.CHATWOOT: {
return (
<ChatwootSettingsForm
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
}
}

View File

@ -0,0 +1 @@
export { SettingsPopoverContent } from './SettingsPopoverContent'

View File

@ -0,0 +1 @@
export { BlockNodesList } from './BlockNodesList'

View File

@ -0,0 +1,242 @@
import {
Editable,
EditableInput,
EditablePreview,
IconButton,
Stack,
} from '@chakra-ui/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Group } from 'models'
import {
Coordinates,
useGraph,
useGroupsCoordinates,
useBlockDnd,
} from '../../../providers'
import { BlockNodesList } from '../BlockNode/BlockNodesList'
import { isDefined, isNotDefined } from 'utils'
import { useTypebot, RightPanel, useEditor } from '@/features/editor'
import { GroupNodeContextMenu } from './GroupNodeContextMenu'
import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable'
import { PlayIcon } from '@/components/icons'
import { useDebounce } from 'use-debounce'
import { ContextMenu } from '@/components/ContextMenu'
import { setMultipleRefs } from '@/utils/helpers'
type Props = {
group: Group
groupIndex: number
}
export const GroupNode = ({ group, groupIndex }: Props) => {
const { updateGroupCoordinates } = useGroupsCoordinates()
const handleGroupDrag = useCallback((newCoord: Coordinates) => {
updateGroupCoordinates(group.id, newCoord)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<DraggableGroupNode
group={group}
groupIndex={groupIndex}
onGroupDrag={handleGroupDrag}
/>
)
}
const DraggableGroupNode = memo(
({
group,
groupIndex,
onGroupDrag,
}: Props & { onGroupDrag: (newCoord: Coordinates) => void }) => {
const {
connectingIds,
setConnectingIds,
previewingEdge,
isReadOnly,
focusedGroupId,
setFocusedGroupId,
graphPosition,
} = useGraph()
const { typebot, updateGroup } = useTypebot()
const { setMouseOverGroup, mouseOverGroup } = useBlockDnd()
const { setRightPanel, setStartPreviewAtGroup } = useEditor()
const [isMouseDown, setIsMouseDown] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const [currentCoordinates, setCurrentCoordinates] = useState(
group.graphCoordinates
)
const [groupTitle, setGroupTitle] = useState(group.title)
// When the group is moved from external action (e.g. undo/redo), update the current coordinates
useEffect(() => {
setCurrentCoordinates({
x: group.graphCoordinates.x,
y: group.graphCoordinates.y,
})
}, [group.graphCoordinates.x, group.graphCoordinates.y])
// Same for group title
useEffect(() => {
setGroupTitle(group.title)
}, [group.title])
const isPreviewing =
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<HTMLDivElement | null>(null)
const [debouncedGroupPosition] = useDebounce(currentCoordinates, 100)
useEffect(() => {
if (!currentCoordinates || isReadOnly) return
if (
currentCoordinates?.x === group.graphCoordinates.x &&
currentCoordinates.y === group.graphCoordinates.y
)
return
updateGroup(groupIndex, { graphCoordinates: currentCoordinates })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedGroupPosition])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.groupId === group.id &&
isNotDefined(connectingIds.target?.blockId)
)
}, [connectingIds, group.id])
const handleTitleSubmit = (title: string) =>
title.length > 0 ? updateGroup(groupIndex, { title }) : undefined
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
const handleMouseEnter = () => {
if (isReadOnly) return
if (mouseOverGroup?.id !== group.id && !isStartGroup)
setMouseOverGroup({ id: group.id, ref: groupRef })
if (connectingIds)
setConnectingIds({ ...connectingIds, target: { groupId: group.id } })
}
const handleMouseLeave = () => {
if (isReadOnly) return
setMouseOverGroup(undefined)
if (connectingIds)
setConnectingIds({ ...connectingIds, target: undefined })
}
const onDrag = (_: DraggableEvent, draggableData: DraggableData) => {
const { deltaX, deltaY } = draggableData
const newCoord = {
x: currentCoordinates.x + deltaX / graphPosition.scale,
y: currentCoordinates.y + deltaY / graphPosition.scale,
}
setCurrentCoordinates(newCoord)
onGroupDrag(newCoord)
}
const onDragStart = () => {
setFocusedGroupId(group.id)
setIsMouseDown(true)
}
const startPreviewAtThisGroup = () => {
setStartPreviewAtGroup(group.id)
setRightPanel(RightPanel.PREVIEW)
}
const onDragStop = () => setIsMouseDown(false)
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <GroupNodeContextMenu groupIndex={groupIndex} />}
isDisabled={isReadOnly || isStartGroup}
>
{(ref, isOpened) => (
<DraggableCore
enableUserSelectHack={false}
onDrag={onDrag}
onStart={onDragStart}
onStop={onDragStop}
onMouseDown={(e) => e.stopPropagation()}
>
<Stack
ref={setMultipleRefs([ref, groupRef])}
data-testid="group"
p="4"
rounded="xl"
bgColor="#ffffff"
borderWidth="2px"
borderColor={
isConnecting || isOpened || isPreviewing
? 'blue.400'
: '#ffffff'
}
w="300px"
transition="border 300ms, box-shadow 200ms"
pos="absolute"
style={{
transform: `translate(${currentCoordinates?.x ?? 0}px, ${
currentCoordinates?.y ?? 0
}px)`,
}}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
cursor={isMouseDown ? 'grabbing' : 'pointer'}
shadow="md"
_hover={{ shadow: 'lg' }}
zIndex={focusedGroupId === group.id ? 10 : 1}
>
<Editable
value={groupTitle}
onChange={setGroupTitle}
onSubmit={handleTitleSubmit}
fontWeight="semibold"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
pr="8"
>
<EditablePreview
_hover={{ bgColor: 'gray.200' }}
px="1"
userSelect={'none'}
/>
<EditableInput
minW="0"
px="1"
onMouseDown={(e) => e.stopPropagation()}
/>
</Editable>
{typebot && (
<BlockNodesList
groupId={group.id}
blocks={group.blocks}
groupIndex={groupIndex}
groupRef={ref}
isStartGroup={isStartGroup}
/>
)}
<IconButton
icon={<PlayIcon />}
aria-label={'Preview bot from this group'}
pos="absolute"
right={2}
top={0}
size="sm"
variant="outline"
onClick={startPreviewAtThisGroup}
/>
</Stack>
</DraggableCore>
)}
</ContextMenu>
)
}
)

View File

@ -0,0 +1,26 @@
import { MenuList, MenuItem } from '@chakra-ui/react'
import { CopyIcon, TrashIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
export const GroupNodeContextMenu = ({
groupIndex,
}: {
groupIndex: number
}) => {
const { deleteGroup, duplicateGroup } = useTypebot()
const handleDeleteClick = () => deleteGroup(groupIndex)
const handleDuplicateClick = () => duplicateGroup(groupIndex)
return (
<MenuList>
<MenuItem icon={<CopyIcon />} onClick={handleDuplicateClick}>
Duplicate
</MenuItem>
<MenuItem icon={<TrashIcon />} onClick={handleDeleteClick}>
Delete
</MenuItem>
</MenuList>
)
}

View File

@ -0,0 +1 @@
export { GroupNode } from './GroupNode'

View File

@ -0,0 +1,104 @@
import { Flex } from '@chakra-ui/react'
import {
Coordinates,
useGraph,
NodePosition,
useDragDistance,
} from '../../../providers'
import { useTypebot } from '@/features/editor'
import {
ButtonItem,
ChoiceInputBlock,
Item,
ItemIndices,
ItemType,
} from 'models'
import React, { useRef, useState } from 'react'
import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
import { ItemNodeContent } from './ItemNodeContent'
import { ItemNodeContextMenu } from './ItemNodeContextMenu'
import { ContextMenu } from '@/components/ContextMenu'
import { setMultipleRefs } from '@/utils/helpers'
type Props = {
item: Item
indices: ItemIndices
onMouseDown?: (
blockNodePosition: { absolute: Coordinates; relative: Coordinates },
item: ButtonItem
) => void
}
export const ItemNode = ({ item, indices, onMouseDown }: Props) => {
const { typebot } = useTypebot()
const { previewingEdge } = useGraph()
const [isMouseOver, setIsMouseOver] = useState(false)
const itemRef = useRef<HTMLDivElement | null>(null)
const isPreviewing = previewingEdge?.from.itemId === item.id
const isConnectable = !(
typebot?.groups[indices.groupIndex].blocks[
indices.blockIndex
] as ChoiceInputBlock
)?.options?.isMultipleChoice
const isReadOnly = item.type === ItemType.CONDITION
const onDrag = (position: NodePosition) => {
if (!onMouseDown || item.type !== ItemType.BUTTON) return
onMouseDown(position, item)
}
useDragDistance({
ref: itemRef,
onDrag,
isDisabled: !onMouseDown || item.type !== ItemType.BUTTON,
})
const handleMouseEnter = () => setIsMouseOver(true)
const handleMouseLeave = () => setIsMouseOver(false)
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <ItemNodeContextMenu indices={indices} />}
>
{(ref, isOpened) => (
<Flex
data-testid="item"
pos="relative"
ref={setMultipleRefs([ref, itemRef])}
>
<Flex
align="center"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
shadow="sm"
_hover={isReadOnly ? {} : { shadow: 'md' }}
transition="box-shadow 200ms, border-color 200ms"
rounded="md"
borderWidth={isOpened || isPreviewing ? '2px' : '1px'}
borderColor={isOpened || isPreviewing ? 'blue.400' : 'gray.100'}
margin={isOpened || isPreviewing ? '-1px' : 0}
pointerEvents={isReadOnly ? 'none' : 'all'}
bgColor="white"
w="full"
>
<ItemNodeContent
item={item}
isMouseOver={isMouseOver}
indices={indices}
/>
{typebot && isConnectable && (
<SourceEndpoint
source={{
groupId: typebot.groups[indices.groupIndex].id,
blockId: item.blockId,
itemId: item.id,
}}
pos="absolute"
right="-49px"
pointerEvents="all"
/>
)}
</Flex>
</Flex>
)}
</ContextMenu>
)
}

View File

@ -0,0 +1,25 @@
import { ButtonNodeContent } from '@/features/blocks/inputs/buttons'
import { ConditionNodeContent } from '@/features/blocks/logic/condition'
import { Item, ItemIndices, ItemType } from 'models'
import React from 'react'
type Props = {
item: Item
indices: ItemIndices
isMouseOver: boolean
}
export const ItemNodeContent = ({ item, indices, isMouseOver }: Props) => {
switch (item.type) {
case ItemType.BUTTON:
return (
<ButtonNodeContent
item={item}
isMouseOver={isMouseOver}
indices={indices}
/>
)
case ItemType.CONDITION:
return <ConditionNodeContent item={item} />
}
}

View File

@ -0,0 +1 @@
export { ItemNodeContent } from './ItemNodeContent'

View File

@ -0,0 +1,21 @@
import { MenuList, MenuItem } from '@chakra-ui/react'
import { TrashIcon } from '@/components/icons'
import { useTypebot } from '@/features/editor'
import { ItemIndices } from 'models'
type Props = {
indices: ItemIndices
}
export const ItemNodeContextMenu = ({ indices }: Props) => {
const { deleteItem } = useTypebot()
const handleDeleteClick = () => deleteItem(indices)
return (
<MenuList>
<MenuItem icon={<TrashIcon />} onClick={handleDeleteClick}>
Delete
</MenuItem>
</MenuList>
)
}

View File

@ -0,0 +1,26 @@
import { Flex, FlexProps } from '@chakra-ui/react'
import { Item } from 'models'
import React, { ReactNode } from 'react'
type Props = {
item: Item
} & FlexProps
export const ItemNodeOverlay = ({ item, ...props }: Props) => {
return (
<Flex
px="4"
py="2"
rounded="md"
bgColor="white"
borderWidth="1px"
borderColor={'gray.300'}
w="212px"
pointerEvents="none"
shadow="lg"
{...props}
>
{(item.content ?? 'Click to edit') as ReactNode}
</Flex>
)
}

View File

@ -0,0 +1,198 @@
import { Flex, Portal, Stack, Text, useEventListener } from '@chakra-ui/react'
import {
computeNearestPlaceholderIndex,
useBlockDnd,
Coordinates,
useGraph,
} from '../../../providers'
import { useTypebot } from '@/features/editor'
import {
ButtonItem,
BlockIndices,
BlockWithItems,
LogicBlockType,
} from 'models'
import React, { useEffect, useRef, useState } from 'react'
import { ItemNode } from './ItemNode'
import { SourceEndpoint } from '../../Endpoints'
import { ItemNodeOverlay } from './ItemNodeOverlay'
type Props = {
block: BlockWithItems
indices: BlockIndices
}
export const ItemNodesList = ({
block,
indices: { groupIndex, blockIndex },
}: Props) => {
const { typebot, createItem, detachItemFromBlock } = useTypebot()
const { draggedItem, setDraggedItem, mouseOverGroup } = useBlockDnd()
const placeholderRefs = useRef<HTMLDivElement[]>([])
const { graphPosition } = useGraph()
const groupId = typebot?.groups[groupIndex].id
const isDraggingOnCurrentGroup =
(draggedItem && mouseOverGroup?.id === groupId) ?? false
const isReadOnly = block.type === LogicBlockType.CONDITION
const showPlaceholders = draggedItem && !isReadOnly
const isLastBlock =
typebot?.groups[groupIndex].blocks[blockIndex + 1] === undefined
const [position, setPosition] = useState({
x: 0,
y: 0,
})
const [relativeCoordinates, setRelativeCoordinates] = useState({ x: 0, y: 0 })
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
number | undefined
>()
const handleGlobalMouseMove = (event: MouseEvent) => {
if (!draggedItem || draggedItem.blockId !== block.id) return
const { clientX, clientY } = event
setPosition({
...position,
x: clientX - relativeCoordinates.x,
y: clientY - relativeCoordinates.y,
})
}
useEventListener('mousemove', handleGlobalMouseMove)
useEffect(() => {
if (mouseOverGroup?.id !== block.groupId)
setExpandedPlaceholderIndex(undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mouseOverGroup?.id])
const handleMouseMoveOnGroup = (event: MouseEvent) => {
if (!isDraggingOnCurrentGroup || isReadOnly) return
const index = computeNearestPlaceholderIndex(event.pageY, placeholderRefs)
setExpandedPlaceholderIndex(index)
}
useEventListener(
'mousemove',
handleMouseMoveOnGroup,
mouseOverGroup?.ref.current
)
const handleMouseUpOnGroup = (e: MouseEvent) => {
setExpandedPlaceholderIndex(undefined)
if (!isDraggingOnCurrentGroup) return
const itemIndex = computeNearestPlaceholderIndex(e.pageY, placeholderRefs)
e.stopPropagation()
setDraggedItem(undefined)
createItem(draggedItem as ButtonItem, {
groupIndex,
blockIndex,
itemIndex,
})
}
useEventListener(
'mouseup',
handleMouseUpOnGroup,
mouseOverGroup?.ref.current,
{
capture: true,
}
)
const handleBlockMouseDown =
(itemIndex: number) =>
(
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
item: ButtonItem
) => {
if (!typebot || isReadOnly) return
placeholderRefs.current.splice(itemIndex + 1, 1)
detachItemFromBlock({ groupIndex, blockIndex, itemIndex })
setPosition(absolute)
setRelativeCoordinates(relative)
setDraggedItem(item)
}
const stopPropagating = (e: React.MouseEvent) => e.stopPropagation()
const handlePushElementRef =
(idx: number) => (elem: HTMLDivElement | null) => {
elem && (placeholderRefs.current[idx] = elem)
}
return (
<Stack
flex={1}
spacing={1}
maxW="full"
onClick={stopPropagating}
pointerEvents={isReadOnly ? 'none' : 'all'}
>
<Flex
ref={handlePushElementRef(0)}
h={showPlaceholders && expandedPlaceholderIndex === 0 ? '50px' : '2px'}
bgColor={'gray.300'}
visibility={showPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showPlaceholders ? 'height 200ms' : 'none'}
/>
{block.items.map((item, idx) => (
<Stack key={item.id} spacing={1}>
<ItemNode
item={item}
indices={{ groupIndex, blockIndex, itemIndex: idx }}
onMouseDown={handleBlockMouseDown(idx)}
/>
<Flex
ref={handlePushElementRef(idx + 1)}
h={
showPlaceholders && expandedPlaceholderIndex === idx + 1
? '50px'
: '2px'
}
bgColor={'gray.300'}
visibility={showPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showPlaceholders ? 'height 200ms' : 'none'}
/>
</Stack>
))}
{isLastBlock && (
<Flex
px="4"
py="2"
borderWidth="1px"
borderColor="gray.300"
bgColor={isReadOnly ? '' : 'gray.50'}
rounded="md"
pos="relative"
align="center"
cursor={isReadOnly ? 'pointer' : 'not-allowed'}
>
<Text color={isReadOnly ? 'inherit' : 'gray.500'}>Default</Text>
<SourceEndpoint
source={{
groupId: block.groupId,
blockId: block.id,
}}
pos="absolute"
right="-49px"
/>
</Flex>
)}
{draggedItem && draggedItem.blockId === block.id && (
<Portal>
<ItemNodeOverlay
item={draggedItem}
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg) scale(${graphPosition.scale})`,
}}
transformOrigin="0 0 0"
/>
</Portal>
)}
</Stack>
)
}

View File

@ -0,0 +1 @@
export { ItemNodesList } from './ItemNodesList'

View File

@ -0,0 +1,40 @@
import { Stack, IconButton } from '@chakra-ui/react'
import { PlusIcon, MinusIcon } from '@/components/icons'
import { headerHeight } from '@/features/editor'
type Props = {
onZoomInClick: () => void
onZoomOutClick: () => void
}
export const ZoomButtons = ({
onZoomInClick: onZoomIn,
onZoomOutClick: onZoomOut,
}: Props) => (
<Stack
pos="fixed"
top={`calc(${headerHeight}px + 70px)`}
right="40px"
bgColor="white"
rounded="md"
zIndex={1}
spacing="0"
shadow="lg"
>
<IconButton
icon={<PlusIcon />}
aria-label={'Zoom in'}
size="sm"
onClick={onZoomIn}
bgColor="white"
borderBottomRadius={0}
/>
<IconButton
icon={<MinusIcon />}
aria-label={'Zoom out'}
size="sm"
onClick={onZoomOut}
bgColor="white"
borderTopRadius={0}
/>
</Stack>
)

View File

@ -0,0 +1 @@
export { Graph } from './Graph'

View File

@ -0,0 +1,10 @@
export { Graph } from './components/Graph'
export {
GraphProvider,
GroupsCoordinatesProvider,
GraphDndProvider,
type Coordinates,
useGraph,
useBlockDnd,
} from './providers'
export { parseNewBlock } from './utils'

View File

@ -0,0 +1,127 @@
import { useEventListener } from '@chakra-ui/react'
import { ButtonItem, DraggableBlock, DraggableBlockType } from 'models'
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useContext,
useRef,
useState,
} from 'react'
import { Coordinates } from './GraphProvider'
type GroupInfo = {
id: string
ref: React.MutableRefObject<HTMLDivElement | null>
}
const graphDndContext = createContext<{
draggedBlockType?: DraggableBlockType
setDraggedBlockType: Dispatch<SetStateAction<DraggableBlockType | undefined>>
draggedBlock?: DraggableBlock
setDraggedBlock: Dispatch<SetStateAction<DraggableBlock | undefined>>
draggedItem?: ButtonItem
setDraggedItem: Dispatch<SetStateAction<ButtonItem | undefined>>
mouseOverGroup?: GroupInfo
setMouseOverGroup: Dispatch<SetStateAction<GroupInfo | undefined>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export type NodePosition = { absolute: Coordinates; relative: Coordinates }
export const GraphDndProvider = ({ children }: { children: ReactNode }) => {
const [draggedBlock, setDraggedBlock] = useState<DraggableBlock>()
const [draggedBlockType, setDraggedBlockType] = useState<
DraggableBlockType | undefined
>()
const [draggedItem, setDraggedItem] = useState<ButtonItem | undefined>()
const [mouseOverGroup, setMouseOverGroup] = useState<GroupInfo>()
return (
<graphDndContext.Provider
value={{
draggedBlock,
setDraggedBlock,
draggedBlockType,
setDraggedBlockType,
draggedItem,
setDraggedItem,
mouseOverGroup,
setMouseOverGroup,
}}
>
{children}
</graphDndContext.Provider>
)
}
export const useDragDistance = ({
ref,
onDrag,
distanceTolerance = 20,
isDisabled = false,
}: {
ref: React.MutableRefObject<HTMLDivElement | null>
onDrag: (position: { absolute: Coordinates; relative: Coordinates }) => void
distanceTolerance?: number
isDisabled: boolean
}) => {
const mouseDownPosition = useRef<{
absolute: Coordinates
relative: Coordinates
}>()
const handleMouseUp = () => {
if (mouseDownPosition) mouseDownPosition.current = undefined
}
useEventListener('mouseup', handleMouseUp)
const handleMouseDown = (e: MouseEvent) => {
if (isDisabled || !ref.current) return
e.stopPropagation()
const { top, left } = ref.current.getBoundingClientRect()
mouseDownPosition.current = {
absolute: { x: e.clientX, y: e.clientY },
relative: {
x: e.clientX - left,
y: e.clientY - top,
},
}
}
useEventListener('mousedown', handleMouseDown, ref.current)
const handleMouseMove = (e: MouseEvent) => {
if (!mouseDownPosition.current) return
const { clientX, clientY } = e
if (
Math.abs(mouseDownPosition.current.absolute.x - clientX) >
distanceTolerance ||
Math.abs(mouseDownPosition.current.absolute.y - clientY) >
distanceTolerance
) {
onDrag(mouseDownPosition.current)
}
}
useEventListener('mousemove', handleMouseMove)
}
export const computeNearestPlaceholderIndex = (
offsetY: number,
placeholderRefs: React.MutableRefObject<HTMLDivElement[]>
) => {
const { closestIndex } = placeholderRefs.current.reduce(
(prev, elem, index) => {
const elementTop = elem.getBoundingClientRect().top
const mouseDistanceFromPlaceholder = Math.abs(offsetY - elementTop)
return mouseDistanceFromPlaceholder < prev.value
? { closestIndex: index, value: mouseDistanceFromPlaceholder }
: prev
},
{ closestIndex: 0, value: 999999999999 }
)
return closestIndex
}
export const useBlockDnd = () => useContext(graphDndContext)

View File

@ -0,0 +1,136 @@
import { Group, Edge, IdMap, Source, Block, Target } from 'models'
import {
createContext,
Dispatch,
MutableRefObject,
ReactNode,
SetStateAction,
useContext,
useState,
} from 'react'
export const stubLength = 20
export const blockWidth = 300
export const blockAnchorsOffset = {
left: {
x: 0,
y: 20,
},
top: {
x: blockWidth / 2,
y: 0,
},
right: {
x: blockWidth,
y: 20,
},
}
export type Coordinates = { x: number; y: number }
type Position = Coordinates & { scale: number }
export type Anchor = {
coordinates: Coordinates
}
export type Node = Omit<Group, 'blocks'> & {
blocks: (Block & {
sourceAnchorsPosition: { left: Coordinates; right: Coordinates }
})[]
}
export const graphPositionDefaultValue = { x: 400, y: 100, scale: 1 }
export type ConnectingIds = {
source: Source
target?: Target
}
type BlockId = string
type ButtonId = string
export type Endpoint = {
id: BlockId | ButtonId
ref: MutableRefObject<HTMLDivElement | null>
}
export type GroupsCoordinates = IdMap<Coordinates>
const graphContext = createContext<{
graphPosition: Position
setGraphPosition: Dispatch<SetStateAction<Position>>
connectingIds: ConnectingIds | null
setConnectingIds: Dispatch<SetStateAction<ConnectingIds | null>>
previewingEdge?: Edge
setPreviewingEdge: Dispatch<SetStateAction<Edge | undefined>>
sourceEndpoints: IdMap<Endpoint>
addSourceEndpoint: (endpoint: Endpoint) => void
targetEndpoints: IdMap<Endpoint>
addTargetEndpoint: (endpoint: Endpoint) => void
openedBlockId?: string
setOpenedBlockId: Dispatch<SetStateAction<string | undefined>>
isReadOnly: boolean
focusedGroupId?: string
setFocusedGroupId: Dispatch<SetStateAction<string | undefined>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({
graphPosition: graphPositionDefaultValue,
connectingIds: null,
})
export const GraphProvider = ({
children,
isReadOnly = false,
}: {
children: ReactNode
isReadOnly?: boolean
}) => {
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
const [connectingIds, setConnectingIds] = useState<ConnectingIds | null>(null)
const [previewingEdge, setPreviewingEdge] = useState<Edge>()
const [sourceEndpoints, setSourceEndpoints] = useState<IdMap<Endpoint>>({})
const [targetEndpoints, setTargetEndpoints] = useState<IdMap<Endpoint>>({})
const [openedBlockId, setOpenedBlockId] = useState<string>()
const [focusedGroupId, setFocusedGroupId] = useState<string>()
const addSourceEndpoint = (endpoint: Endpoint) => {
setSourceEndpoints((endpoints) => ({
...endpoints,
[endpoint.id]: endpoint,
}))
}
const addTargetEndpoint = (endpoint: Endpoint) => {
setTargetEndpoints((endpoints) => ({
...endpoints,
[endpoint.id]: endpoint,
}))
}
return (
<graphContext.Provider
value={{
graphPosition,
setGraphPosition,
connectingIds,
setConnectingIds,
previewingEdge,
setPreviewingEdge,
sourceEndpoints,
targetEndpoints,
addSourceEndpoint,
addTargetEndpoint,
openedBlockId,
setOpenedBlockId,
isReadOnly,
focusedGroupId,
setFocusedGroupId,
}}
>
{children}
</graphContext.Provider>
)
}
export const useGraph = () => useContext(graphContext)

View File

@ -0,0 +1,58 @@
import { Group } from 'models'
import {
ReactNode,
useState,
useEffect,
useContext,
createContext,
} from 'react'
import { GroupsCoordinates, Coordinates } from './GraphProvider'
const groupsCoordinatesContext = createContext<{
groupsCoordinates: GroupsCoordinates
updateGroupCoordinates: (groupId: string, newCoord: Coordinates) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const GroupsCoordinatesProvider = ({
children,
groups,
}: {
children: ReactNode
groups: Group[]
isReadOnly?: boolean
}) => {
const [groupsCoordinates, setGroupsCoordinates] = useState<GroupsCoordinates>(
{}
)
useEffect(() => {
setGroupsCoordinates(
groups.reduce(
(coords, block) => ({
...coords,
[block.id]: block.graphCoordinates,
}),
{}
)
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [groups])
const updateGroupCoordinates = (groupId: string, newCoord: Coordinates) =>
setGroupsCoordinates((groupsCoordinates) => ({
...groupsCoordinates,
[groupId]: newCoord,
}))
return (
<groupsCoordinatesContext.Provider
value={{ groupsCoordinates, updateGroupCoordinates }}
>
{children}
</groupsCoordinatesContext.Provider>
)
}
export const useGroupsCoordinates = () => useContext(groupsCoordinatesContext)

View File

@ -0,0 +1,3 @@
export * from './GraphDndProvider'
export * from './GraphProvider'
export * from './GroupsCoordinateProvider'

View File

@ -0,0 +1,453 @@
import {
Block,
BlockOptions,
BlockWithOptionsType,
BubbleBlockContent,
BubbleBlockType,
defaultChatwootOptions,
defaultChoiceInputOptions,
defaultCodeOptions,
defaultConditionContent,
defaultDateInputOptions,
defaultEmailInputOptions,
defaultEmbedBubbleContent,
defaultFileInputOptions,
defaultGoogleAnalyticsOptions,
defaultGoogleSheetsOptions,
defaultImageBubbleContent,
defaultNumberInputOptions,
defaultPaymentInputOptions,
defaultPhoneInputOptions,
defaultRatingInputOptions,
defaultRedirectOptions,
defaultSendEmailOptions,
defaultSetVariablesOptions,
defaultTextBubbleContent,
defaultTextInputOptions,
defaultUrlInputOptions,
defaultVideoBubbleContent,
defaultWebhookOptions,
DraggableBlock,
DraggableBlockType,
Edge,
IdMap,
InputBlockType,
IntegrationBlockType,
Item,
ItemType,
LogicBlockType,
} from 'models'
import {
stubLength,
blockWidth,
blockAnchorsOffset,
Endpoint,
Coordinates,
} from './providers'
import { roundCorners } from 'svg-round-corners'
import { AnchorsPositionProps } from './components/Edges/Edge'
import cuid from 'cuid'
import {
isBubbleBlockType,
blockTypeHasOption,
blockTypeHasWebhook,
blockTypeHasItems,
isChoiceInput,
isConditionBlock,
} from 'utils'
const roundSize = 20
export const computeDropOffPath = (
sourcePosition: Coordinates,
sourceTop: number
) => {
const sourceCoord = computeSourceCoordinates(sourcePosition, sourceTop)
const segments = computeTwoSegments(sourceCoord, {
x: sourceCoord.x + 20,
y: sourceCoord.y + 80,
})
return roundCorners(
`M${sourceCoord.x},${sourceCoord.y} ${segments}`,
roundSize
).path
}
export const computeSourceCoordinates = (
sourcePosition: Coordinates,
sourceTop: number
) => ({
x: sourcePosition.x + blockWidth,
y: sourceTop,
})
const getSegments = ({
sourcePosition,
targetPosition,
sourceType,
totalSegments,
}: AnchorsPositionProps) => {
switch (totalSegments) {
case 2:
return computeTwoSegments(sourcePosition, targetPosition)
case 3:
return computeThreeSegments(sourcePosition, targetPosition, sourceType)
case 4:
return computeFourSegments(sourcePosition, targetPosition, sourceType)
default:
return computeFiveSegments(sourcePosition, targetPosition, sourceType)
}
}
const computeTwoSegments = (
sourcePosition: Coordinates,
targetPosition: Coordinates
) => {
const segments = []
segments.push(`L${targetPosition.x},${sourcePosition.y}`)
segments.push(`L${targetPosition.x},${targetPosition.y}`)
return segments.join(' ')
}
const computeThreeSegments = (
sourcePosition: Coordinates,
targetPosition: Coordinates,
sourceType: 'right' | 'left'
) => {
const segments = []
const firstSegmentX =
sourceType === 'right'
? sourcePosition.x + (targetPosition.x - sourcePosition.x) / 2
: sourcePosition.x - (sourcePosition.x - targetPosition.x) / 2
segments.push(`L${firstSegmentX},${sourcePosition.y}`)
segments.push(`L${firstSegmentX},${targetPosition.y}`)
segments.push(`L${targetPosition.x},${targetPosition.y}`)
return segments.join(' ')
}
const computeFourSegments = (
sourcePosition: Coordinates,
targetPosition: Coordinates,
sourceType: 'right' | 'left'
) => {
const segments = []
const firstSegmentX =
sourcePosition.x + (sourceType === 'right' ? stubLength : -stubLength)
segments.push(`L${firstSegmentX},${sourcePosition.y}`)
const secondSegmentY =
sourcePosition.y + (targetPosition.y - sourcePosition.y) / 2
segments.push(`L${firstSegmentX},${secondSegmentY}`)
segments.push(`L${targetPosition.x},${secondSegmentY}`)
segments.push(`L${targetPosition.x},${targetPosition.y}`)
return segments.join(' ')
}
const computeFiveSegments = (
sourcePosition: Coordinates,
targetPosition: Coordinates,
sourceType: 'right' | 'left'
) => {
const segments = []
const firstSegmentX =
sourcePosition.x + (sourceType === 'right' ? stubLength : -stubLength)
segments.push(`L${firstSegmentX},${sourcePosition.y}`)
const firstSegmentY =
sourcePosition.y + (targetPosition.y - sourcePosition.y) / 2
segments.push(
`L${
sourcePosition.x + (sourceType === 'right' ? stubLength : -stubLength)
},${firstSegmentY}`
)
const secondSegmentX =
targetPosition.x - (sourceType === 'right' ? stubLength : -stubLength)
segments.push(`L${secondSegmentX},${firstSegmentY}`)
segments.push(`L${secondSegmentX},${targetPosition.y}`)
segments.push(`L${targetPosition.x},${targetPosition.y}`)
return segments.join(' ')
}
type GetAnchorsPositionParams = {
sourceGroupCoordinates: Coordinates
targetGroupCoordinates: Coordinates
sourceTop: number
targetTop?: number
graphScale: number
}
export const getAnchorsPosition = ({
sourceGroupCoordinates,
targetGroupCoordinates,
sourceTop,
targetTop,
}: GetAnchorsPositionParams): AnchorsPositionProps => {
const sourcePosition = computeSourceCoordinates(
sourceGroupCoordinates,
sourceTop
)
let sourceType: 'right' | 'left' = 'right'
if (sourceGroupCoordinates.x > targetGroupCoordinates.x) {
sourcePosition.x = sourceGroupCoordinates.x
sourceType = 'left'
}
const { targetPosition, totalSegments } = computeGroupTargetPosition(
sourceGroupCoordinates,
targetGroupCoordinates,
sourcePosition.y,
targetTop
)
return { sourcePosition, targetPosition, sourceType, totalSegments }
}
const computeGroupTargetPosition = (
sourceGroupPosition: Coordinates,
targetGroupPosition: Coordinates,
sourceOffsetY: number,
targetOffsetY?: number
): { targetPosition: Coordinates; totalSegments: number } => {
const isTargetGroupBelow =
targetGroupPosition.y > sourceOffsetY &&
targetGroupPosition.x < sourceGroupPosition.x + blockWidth + stubLength &&
targetGroupPosition.x > sourceGroupPosition.x - blockWidth - stubLength
const isTargetGroupToTheRight = targetGroupPosition.x < sourceGroupPosition.x
const isTargettingGroup = !targetOffsetY
if (isTargetGroupBelow && isTargettingGroup) {
const isExterior =
targetGroupPosition.x <
sourceGroupPosition.x - blockWidth / 2 - stubLength ||
targetGroupPosition.x >
sourceGroupPosition.x + blockWidth / 2 + stubLength
const targetPosition = parseGroupAnchorPosition(targetGroupPosition, 'top')
return { totalSegments: isExterior ? 2 : 4, targetPosition }
} else {
const isExterior =
targetGroupPosition.x < sourceGroupPosition.x - blockWidth ||
targetGroupPosition.x > sourceGroupPosition.x + blockWidth
const targetPosition = parseGroupAnchorPosition(
targetGroupPosition,
isTargetGroupToTheRight ? 'right' : 'left',
targetOffsetY
)
return { totalSegments: isExterior ? 3 : 5, targetPosition }
}
}
const parseGroupAnchorPosition = (
blockPosition: Coordinates,
anchor: 'left' | 'top' | 'right',
targetOffsetY?: number
): Coordinates => {
switch (anchor) {
case 'left':
return {
x: blockPosition.x + blockAnchorsOffset.left.x,
y: targetOffsetY ?? blockPosition.y + blockAnchorsOffset.left.y,
}
case 'top':
return {
x: blockPosition.x + blockAnchorsOffset.top.x,
y: blockPosition.y + blockAnchorsOffset.top.y,
}
case 'right':
return {
x: blockPosition.x + blockAnchorsOffset.right.x,
y: targetOffsetY ?? blockPosition.y + blockAnchorsOffset.right.y,
}
}
}
export const computeEdgePath = ({
sourcePosition,
targetPosition,
sourceType,
totalSegments,
}: AnchorsPositionProps) => {
const segments = getSegments({
sourcePosition,
targetPosition,
sourceType,
totalSegments,
})
return roundCorners(
`M${sourcePosition.x},${sourcePosition.y} ${segments}`,
roundSize
).path
}
export const computeConnectingEdgePath = ({
sourceGroupCoordinates,
targetGroupCoordinates,
sourceTop,
targetTop,
graphScale,
}: GetAnchorsPositionParams) => {
const anchorsPosition = getAnchorsPosition({
sourceGroupCoordinates,
targetGroupCoordinates,
sourceTop,
targetTop,
graphScale,
})
return computeEdgePath(anchorsPosition)
}
export const computeEdgePathToMouse = ({
sourceGroupCoordinates,
mousePosition,
sourceTop,
}: {
sourceGroupCoordinates: Coordinates
mousePosition: Coordinates
sourceTop: number
}): string => {
const sourcePosition = {
x:
mousePosition.x - sourceGroupCoordinates.x > blockWidth / 2
? sourceGroupCoordinates.x + blockWidth
: sourceGroupCoordinates.x,
y: sourceTop,
}
const sourceType =
mousePosition.x - sourceGroupCoordinates.x > blockWidth / 2
? 'right'
: 'left'
const segments = computeThreeSegments(
sourcePosition,
mousePosition,
sourceType
)
return roundCorners(
`M${sourcePosition.x},${sourcePosition.y} ${segments}`,
roundSize
).path
}
export const getEndpointTopOffset = ({
endpoints,
graphOffsetY,
endpointId,
graphScale,
}: {
endpoints: IdMap<Endpoint>
graphOffsetY: number
endpointId?: string
graphScale: number
}): number | undefined => {
if (!endpointId) return
const endpointRef = endpoints[endpointId]?.ref
if (!endpointRef?.current) return
const endpointHeight = 28 * graphScale
return (
(endpointRef.current.getBoundingClientRect().y +
endpointHeight / 2 -
graphOffsetY) /
graphScale
)
}
export const getSourceEndpointId = (edge?: Edge) =>
edge?.from.itemId ?? edge?.from.blockId
export const parseNewBlock = (
type: DraggableBlockType,
groupId: string
): DraggableBlock => {
const id = cuid()
return {
id,
groupId,
type,
content: isBubbleBlockType(type) ? parseDefaultContent(type) : undefined,
options: blockTypeHasOption(type)
? parseDefaultBlockOptions(type)
: undefined,
webhookId: blockTypeHasWebhook(type) ? cuid() : undefined,
items: blockTypeHasItems(type) ? parseDefaultItems(type, id) : undefined,
} as DraggableBlock
}
const parseDefaultItems = (
type: LogicBlockType.CONDITION | InputBlockType.CHOICE,
blockId: string
): Item[] => {
switch (type) {
case InputBlockType.CHOICE:
return [{ id: cuid(), blockId, type: ItemType.BUTTON }]
case LogicBlockType.CONDITION:
return [
{
id: cuid(),
blockId,
type: ItemType.CONDITION,
content: defaultConditionContent,
},
]
}
}
const parseDefaultContent = (type: BubbleBlockType): BubbleBlockContent => {
switch (type) {
case BubbleBlockType.TEXT:
return defaultTextBubbleContent
case BubbleBlockType.IMAGE:
return defaultImageBubbleContent
case BubbleBlockType.VIDEO:
return defaultVideoBubbleContent
case BubbleBlockType.EMBED:
return defaultEmbedBubbleContent
}
}
const parseDefaultBlockOptions = (type: BlockWithOptionsType): BlockOptions => {
switch (type) {
case InputBlockType.TEXT:
return defaultTextInputOptions
case InputBlockType.NUMBER:
return defaultNumberInputOptions
case InputBlockType.EMAIL:
return defaultEmailInputOptions
case InputBlockType.DATE:
return defaultDateInputOptions
case InputBlockType.PHONE:
return defaultPhoneInputOptions
case InputBlockType.URL:
return defaultUrlInputOptions
case InputBlockType.CHOICE:
return defaultChoiceInputOptions
case InputBlockType.PAYMENT:
return defaultPaymentInputOptions
case InputBlockType.RATING:
return defaultRatingInputOptions
case InputBlockType.FILE:
return defaultFileInputOptions
case LogicBlockType.SET_VARIABLE:
return defaultSetVariablesOptions
case LogicBlockType.REDIRECT:
return defaultRedirectOptions
case LogicBlockType.CODE:
return defaultCodeOptions
case LogicBlockType.TYPEBOT_LINK:
return {}
case IntegrationBlockType.GOOGLE_SHEETS:
return defaultGoogleSheetsOptions
case IntegrationBlockType.GOOGLE_ANALYTICS:
return defaultGoogleAnalyticsOptions
case IntegrationBlockType.ZAPIER:
case IntegrationBlockType.PABBLY_CONNECT:
case IntegrationBlockType.MAKE_COM:
case IntegrationBlockType.WEBHOOK:
return defaultWebhookOptions
case IntegrationBlockType.EMAIL:
return defaultSendEmailOptions
case IntegrationBlockType.CHATWOOT:
return defaultChatwootOptions
}
}
export const hasDefaultConnector = (block: Block) =>
!isChoiceInput(block) && !isConditionBlock(block)