♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
118
apps/builder/src/features/graph/components/Edges/DrawingEdge.tsx
Normal file
118
apps/builder/src/features/graph/components/Edges/DrawingEdge.tsx
Normal 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"
|
||||
/>
|
||||
)
|
||||
}
|
136
apps/builder/src/features/graph/components/Edges/DropOffEdge.tsx
Normal file
136
apps/builder/src/features/graph/components/Edges/DropOffEdge.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
160
apps/builder/src/features/graph/components/Edges/Edge.tsx
Normal file
160
apps/builder/src/features/graph/components/Edges/Edge.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
88
apps/builder/src/features/graph/components/Edges/Edges.tsx
Normal file
88
apps/builder/src/features/graph/components/Edges/Edges.tsx
Normal 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>
|
||||
)
|
@ -0,0 +1 @@
|
||||
export { Edges } from './Edges'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export { SourceEndpoint } from './SourceEndpoint'
|
||||
export { TargetEndpoint } from './TargetEndpoint'
|
266
apps/builder/src/features/graph/components/Graph.tsx
Normal file
266
apps/builder/src/features/graph/components/Graph.tsx
Normal 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])
|
33
apps/builder/src/features/graph/components/GraphElements.tsx
Normal file
33
apps/builder/src/features/graph/components/GraphElements.tsx
Normal 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)
|
@ -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)
|
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { BlockNodeContent } from './BlockNodeContent'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { SettingsPopoverContent } from './SettingsPopoverContent'
|
@ -0,0 +1 @@
|
||||
export { BlockNodesList } from './BlockNodesList'
|
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { GroupNode } from './GroupNode'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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} />
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ItemNodeContent } from './ItemNodeContent'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ItemNodesList } from './ItemNodesList'
|
40
apps/builder/src/features/graph/components/ZoomButtons.tsx
Normal file
40
apps/builder/src/features/graph/components/ZoomButtons.tsx
Normal 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>
|
||||
)
|
1
apps/builder/src/features/graph/components/index.tsx
Normal file
1
apps/builder/src/features/graph/components/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { Graph } from './Graph'
|
10
apps/builder/src/features/graph/index.ts
Normal file
10
apps/builder/src/features/graph/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export { Graph } from './components/Graph'
|
||||
export {
|
||||
GraphProvider,
|
||||
GroupsCoordinatesProvider,
|
||||
GraphDndProvider,
|
||||
type Coordinates,
|
||||
useGraph,
|
||||
useBlockDnd,
|
||||
} from './providers'
|
||||
export { parseNewBlock } from './utils'
|
127
apps/builder/src/features/graph/providers/GraphDndProvider.tsx
Normal file
127
apps/builder/src/features/graph/providers/GraphDndProvider.tsx
Normal 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)
|
136
apps/builder/src/features/graph/providers/GraphProvider.tsx
Normal file
136
apps/builder/src/features/graph/providers/GraphProvider.tsx
Normal 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)
|
@ -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)
|
3
apps/builder/src/features/graph/providers/index.ts
Normal file
3
apps/builder/src/features/graph/providers/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './GraphDndProvider'
|
||||
export * from './GraphProvider'
|
||||
export * from './GroupsCoordinateProvider'
|
453
apps/builder/src/features/graph/utils.ts
Normal file
453
apps/builder/src/features/graph/utils.ts
Normal 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)
|
Reference in New Issue
Block a user