2
0

refactor: ♻️ Rename step to block

This commit is contained in:
Baptiste Arnaud
2022-06-11 07:27:38 +02:00
parent 8751766d0e
commit 2df8338505
297 changed files with 4292 additions and 3989 deletions

View File

@ -17,22 +17,22 @@ export const DrawingEdge = () => {
connectingIds,
sourceEndpoints,
targetEndpoints,
blocksCoordinates,
groupsCoordinates,
} = useGraph()
const { createEdge } = useTypebot()
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
const sourceBlockCoordinates =
blocksCoordinates && blocksCoordinates[connectingIds?.source.blockId ?? '']
const targetBlockCoordinates =
blocksCoordinates && blocksCoordinates[connectingIds?.target?.blockId ?? '']
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.stepId,
endpointId: connectingIds.source.itemId ?? connectingIds.source.blockId,
graphScale: graphPosition.scale,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -43,32 +43,32 @@ export const DrawingEdge = () => {
return getEndpointTopOffset({
endpoints: targetEndpoints,
graphOffsetY: graphPosition.y,
endpointId: connectingIds.target?.stepId,
endpointId: connectingIds.target?.blockId,
graphScale: graphPosition.scale,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectingIds, targetEndpoints])
const path = useMemo(() => {
if (!sourceTop || !sourceBlockCoordinates) return ``
if (!sourceTop || !sourceGroupCoordinates) return ``
return targetBlockCoordinates
return targetGroupCoordinates
? computeConnectingEdgePath({
sourceBlockCoordinates,
targetBlockCoordinates,
sourceGroupCoordinates,
targetGroupCoordinates,
sourceTop,
targetTop,
graphScale: graphPosition.scale,
})
: computeEdgePathToMouse({
sourceBlockCoordinates,
sourceGroupCoordinates,
mousePosition,
sourceTop,
})
}, [
sourceTop,
sourceBlockCoordinates,
targetBlockCoordinates,
sourceGroupCoordinates,
targetGroupCoordinates,
targetTop,
mousePosition,
graphPosition.scale,

View File

@ -13,37 +13,37 @@ import { isFreePlan } from 'services/workspace'
import { byId, isDefined } from 'utils'
type Props = {
blockId: string
groupId: string
answersCounts: AnswersCount[]
onUnlockProPlanClick?: () => void
}
export const DropOffEdge = ({
answersCounts,
blockId,
groupId,
onUnlockProPlanClick,
}: Props) => {
const { workspace } = useWorkspace()
const { sourceEndpoints, blocksCoordinates, graphPosition } = useGraph()
const { sourceEndpoints, groupsCoordinates, graphPosition } = useGraph()
const { publishedTypebot } = useTypebot()
const isUserOnFreePlan = isFreePlan(workspace)
const totalAnswers = useMemo(
() => answersCounts.find((a) => a.blockId === blockId)?.totalAnswers,
[answersCounts, blockId]
() => answersCounts.find((a) => a.groupId === groupId)?.totalAnswers,
[answersCounts, groupId]
)
const { totalDroppedUser, dropOffRate } = useMemo(() => {
if (!publishedTypebot || totalAnswers === undefined)
return { previousTotal: undefined, dropOffRate: undefined }
const previousBlockIds = publishedTypebot.edges
const previousGroupIds = publishedTypebot.edges
.map((edge) =>
edge.to.blockId === blockId ? edge.from.blockId : undefined
edge.to.groupId === groupId ? edge.from.groupId : undefined
)
.filter(isDefined)
const previousTotal = answersCounts
.filter((a) => previousBlockIds.includes(a.blockId))
.filter((a) => previousGroupIds.includes(a.groupId))
.reduce((prev, acc) => acc.totalAnswers + prev, 0)
if (previousTotal === 0)
return { previousTotal: undefined, dropOffRate: undefined }
@ -53,25 +53,25 @@ export const DropOffEdge = ({
totalDroppedUser,
dropOffRate: Math.round((totalDroppedUser / previousTotal) * 100),
}
}, [answersCounts, blockId, totalAnswers, publishedTypebot])
}, [answersCounts, groupId, totalAnswers, publishedTypebot])
const block = publishedTypebot?.blocks.find(byId(blockId))
const group = publishedTypebot?.groups.find(byId(groupId))
const sourceTop = useMemo(
() =>
getEndpointTopOffset({
endpoints: sourceEndpoints,
graphOffsetY: graphPosition.y,
endpointId: block?.steps[block.steps.length - 1].id,
endpointId: group?.blocks[group.blocks.length - 1].id,
graphScale: graphPosition.scale,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[block?.steps, sourceEndpoints, blocksCoordinates]
[group?.blocks, sourceEndpoints, groupsCoordinates]
)
const labelCoordinates = useMemo(() => {
if (!blocksCoordinates[blockId]) return
return computeSourceCoordinates(blocksCoordinates[blockId], sourceTop ?? 0)
}, [blocksCoordinates, blockId, sourceTop])
if (!groupsCoordinates[groupId]) return
return computeSourceCoordinates(groupsCoordinates[groupId], sourceTop ?? 0)
}, [groupsCoordinates, groupId, sourceTop])
if (!labelCoordinates) return <></>
return (

View File

@ -25,7 +25,7 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
previewingEdge,
sourceEndpoints,
targetEndpoints,
blocksCoordinates,
groupsCoordinates,
graphPosition,
isReadOnly,
setPreviewingEdge,
@ -37,10 +37,10 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
const isPreviewing = isMouseOver || previewingEdge?.id === edge.id
const sourceBlockCoordinates =
blocksCoordinates && blocksCoordinates[edge.from.blockId]
const targetBlockCoordinates =
blocksCoordinates && blocksCoordinates[edge.to.blockId]
const sourceGroupCoordinates =
groupsCoordinates && groupsCoordinates[edge.from.groupId]
const targetGroupCoordinates =
groupsCoordinates && groupsCoordinates[edge.to.groupId]
const sourceTop = useMemo(
() =>
@ -51,7 +51,7 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
graphScale: graphPosition.scale,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[sourceBlockCoordinates?.y, edge, sourceEndpoints, refreshEdge]
[sourceGroupCoordinates?.y, edge, sourceEndpoints, refreshEdge]
)
useEffect(() => {
@ -62,7 +62,7 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
getEndpointTopOffset({
endpoints: targetEndpoints,
graphOffsetY: graphPosition.y,
endpointId: edge?.to.stepId,
endpointId: edge?.to.blockId,
graphScale: graphPosition.scale,
})
)
@ -71,24 +71,24 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
getEndpointTopOffset({
endpoints: targetEndpoints,
graphOffsetY: graphPosition.y,
endpointId: edge?.to.stepId,
endpointId: edge?.to.blockId,
graphScale: graphPosition.scale,
})
)
}, [
targetBlockCoordinates?.y,
targetGroupCoordinates?.y,
targetEndpoints,
graphPosition.y,
edge?.to.stepId,
edge?.to.blockId,
graphPosition.scale,
])
const path = useMemo(() => {
if (!sourceBlockCoordinates || !targetBlockCoordinates || !sourceTop)
if (!sourceGroupCoordinates || !targetGroupCoordinates || !sourceTop)
return ``
const anchorsPosition = getAnchorsPosition({
sourceBlockCoordinates,
targetBlockCoordinates,
sourceGroupCoordinates,
targetGroupCoordinates,
sourceTop,
targetTop,
graphScale: graphPosition.scale,
@ -96,10 +96,10 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
return computeEdgePath(anchorsPosition)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
sourceBlockCoordinates?.x,
sourceBlockCoordinates?.y,
targetBlockCoordinates?.x,
targetBlockCoordinates?.y,
sourceGroupCoordinates?.x,
sourceGroupCoordinates?.y,
targetGroupCoordinates?.x,
targetGroupCoordinates?.y,
sourceTop,
])

View File

@ -34,9 +34,9 @@ export const Edges = ({
))}
{answersCounts?.slice(1)?.map((answerCount) => (
<DropOffEdge
key={answerCount.blockId}
key={answerCount.groupId}
answersCounts={answersCounts}
blockId={answerCount.blockId}
groupId={answerCount.groupId}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
))}

View File

@ -13,7 +13,7 @@ export const SourceEndpoint = ({
const {
setConnectingIds,
addSourceEndpoint,
blocksCoordinates,
groupsCoordinates,
previewingEdge,
} = useGraph()
const ref = useRef<HTMLDivElement | null>(null)
@ -24,18 +24,18 @@ export const SourceEndpoint = ({
}
useEffect(() => {
if (ranOnce || !ref.current || Object.keys(blocksCoordinates).length === 0)
if (ranOnce || !ref.current || Object.keys(groupsCoordinates).length === 0)
return
const id = source.itemId ?? source.stepId
const id = source.itemId ?? source.blockId
addSourceEndpoint({
id,
ref,
})
setRanOnce(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref.current, blocksCoordinates])
}, [ref.current, groupsCoordinates])
if (!blocksCoordinates) return <></>
if (!groupsCoordinates) return <></>
return (
<Flex
ref={ref}
@ -62,7 +62,7 @@ export const SourceEndpoint = ({
borderWidth="3.5px"
shadow={`sm`}
borderColor={
previewingEdge?.from.stepId === source.stepId &&
previewingEdge?.from.blockId === source.blockId &&
previewingEdge.from.itemId === source.itemId
? 'blue.300'
: 'blue.200'

View File

@ -3,11 +3,11 @@ import { useGraph } from 'contexts/GraphContext'
import React, { useEffect, useRef } from 'react'
export const TargetEndpoint = ({
stepId,
blockId,
isVisible,
...props
}: BoxProps & {
stepId: string
blockId: string
isVisible?: boolean
}) => {
const { addTargetEndpoint } = useGraph()
@ -16,7 +16,7 @@ export const TargetEndpoint = ({
useEffect(() => {
if (!ref.current) return
addTargetEndpoint({
id: stepId,
id: blockId,
ref,
})
// eslint-disable-next-line react-hooks/exhaustive-deps

View File

@ -6,9 +6,9 @@ import {
graphPositionDefaultValue,
useGraph,
} from 'contexts/GraphContext'
import { useStepDnd } from 'contexts/GraphDndContext'
import { useBlockDnd } from 'contexts/GraphDndContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { DraggableStepType, PublicTypebot, Typebot } from 'models'
import { DraggableBlockType, PublicTypebot, Typebot } from 'models'
import { AnswersCount } from 'services/analytics'
import { useDebounce } from 'use-debounce'
import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable'
@ -21,7 +21,7 @@ import { ZoomButtons } from './ZoomButtons'
const maxScale = 1.5
const minScale = 0.1
const zoomButtonsScaleStep = 0.2
const zoomButtonsScaleBlock = 0.2
export const Graph = ({
typebot,
@ -34,20 +34,20 @@ export const Graph = ({
onUnlockProPlanClick?: () => void
} & FlexProps) => {
const {
draggedStepType,
setDraggedStepType,
draggedStep,
setDraggedStep,
draggedBlockType,
setDraggedBlockType,
draggedBlock,
setDraggedBlock,
draggedItem,
setDraggedItem,
} = useStepDnd()
} = useBlockDnd()
const graphContainerRef = useRef<HTMLDivElement | null>(null)
const editorContainerRef = useRef<HTMLDivElement | null>(null)
const { createBlock } = useTypebot()
const { createGroup } = useTypebot()
const {
setGraphPosition: setGlobalGraphPosition,
setOpenedStepId,
updateBlockCoordinates,
setOpenedBlockId,
updateGroupCoordinates,
setPreviewingEdge,
connectingIds,
} = useGraph()
@ -100,22 +100,22 @@ export const Graph = ({
const handleMouseUp = (e: MouseEvent) => {
if (!typebot) return
if (draggedItem) setDraggedItem(undefined)
if (!draggedStep && !draggedStepType) return
if (!draggedBlock && !draggedBlockType) return
const coordinates = projectMouse(
{ x: e.clientX, y: e.clientY },
graphPosition
)
const id = cuid()
updateBlockCoordinates(id, coordinates)
createBlock({
updateGroupCoordinates(id, coordinates)
createGroup({
id,
...coordinates,
step: draggedStep ?? (draggedStepType as DraggableStepType),
indices: { blockIndex: typebot.blocks.length, stepIndex: 0 },
block: draggedBlock ?? (draggedBlockType as DraggableBlockType),
indices: { groupIndex: typebot.groups.length, blockIndex: 0 },
})
setDraggedStep(undefined)
setDraggedStepType(undefined)
setDraggedBlock(undefined)
setDraggedBlockType(undefined)
}
const handleCaptureMouseDown = (e: MouseEvent) => {
@ -124,7 +124,7 @@ export const Graph = ({
}
const handleClick = () => {
setOpenedStepId(undefined)
setOpenedBlockId(undefined)
setPreviewingEdge(undefined)
}
@ -137,7 +137,7 @@ export const Graph = ({
})
}
const zoom = (delta = zoomButtonsScaleStep, mousePosition?: Coordinates) => {
const zoom = (delta = zoomButtonsScaleBlock, mousePosition?: Coordinates) => {
const { x: mouseX, y } = mousePosition ?? { x: 0, y: 0 }
const mouseY = y - headerHeight
let scale = graphPosition.scale + delta
@ -181,8 +181,8 @@ export const Graph = ({
<DraggableCore onDrag={onDrag} enableUserSelectHack={false}>
<Flex ref={graphContainerRef} position="relative" {...props}>
<ZoomButtons
onZoomIn={() => zoom(zoomButtonsScaleStep)}
onZoomOut={() => zoom(-zoomButtonsScaleStep)}
onZoomIn={() => zoom(zoomButtonsScaleBlock)}
onZoomOut={() => zoom(-zoomButtonsScaleBlock)}
/>
<Flex
flex="1"

View File

@ -1,9 +1,9 @@
import { useTypebot } from 'contexts/TypebotContext'
import { Block } from 'models'
import { Group } from 'models'
import React from 'react'
import { AnswersCount } from 'services/analytics'
import { Edges } from './Edges'
import { BlockNode } from './Nodes/BlockNode'
import { GroupNode } from './Nodes/GroupNode'
type Props = {
answersCounts?: AnswersCount[]
@ -18,8 +18,8 @@ const MyComponent = ({ answersCounts, onUnlockProPlanClick }: Props) => {
answersCounts={answersCounts}
onUnlockProPlanClick={onUnlockProPlanClick}
/>
{typebot?.blocks.map((block, idx) => (
<BlockNode block={block as Block} blockIndex={idx} key={block.id} />
{typebot?.groups.map((group, idx) => (
<GroupNode group={group as Group} groupIndex={idx} key={group.id} />
))}
</>
)

View File

@ -1,193 +1,248 @@
import {
Editable,
EditableInput,
EditablePreview,
IconButton,
Stack,
Flex,
HStack,
Popover,
PopoverTrigger,
useDisclosure,
} from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
import { Block } from 'models'
import {
BubbleBlock,
BubbleBlockContent,
ConditionBlock,
DraggableBlock,
Block,
BlockWithOptions,
TextBubbleContent,
TextBubbleBlock,
} from 'models'
import { useGraph } from 'contexts/GraphContext'
import { useStepDnd } from 'contexts/GraphDndContext'
import { StepNodesList } from '../StepNode/StepNodesList'
import { isDefined, isNotDefined } from 'utils'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { BlockIcon } from 'components/editor/BlocksSideBar/BlockIcon'
import { isBubbleBlock, isTextBubbleBlock } from 'utils'
import { BlockNodeContent } from './BlockNodeContent/BlockNodeContent'
import { useTypebot } from 'contexts/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu'
import { SettingsPopoverContent } from './SettingsPopoverContent'
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
import { useDebounce } from 'use-debounce'
import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
import { hasDefaultConnector } from 'services/typebots'
import { useRouter } from 'next/router'
import { SettingsModal } from './SettingsPopoverContent/SettingsModal'
import { BlockSettings } from './SettingsPopoverContent/SettingsPopoverContent'
import { TextBubbleEditor } from './TextBubbleEditor'
import { TargetEndpoint } from '../../Endpoints'
import { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
import { NodePosition, useDragDistance } from 'contexts/GraphDndContext'
import { setMultipleRefs } from 'services/utils'
import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable'
import { PlayIcon } from 'assets/icons'
import { RightPanel, useEditor } from 'contexts/EditorContext'
type Props = {
export const BlockNode = ({
block,
isConnectable,
indices,
onMouseDown,
}: {
block: Block
blockIndex: number
}
export const BlockNode = ({ block, blockIndex }: Props) => {
isConnectable: boolean
indices: { blockIndex: number; groupIndex: number }
onMouseDown?: (blockNodePosition: NodePosition, block: DraggableBlock) => void
}) => {
const { query } = useRouter()
const {
connectingIds,
setConnectingIds,
connectingIds,
openedBlockId,
setOpenedBlockId,
setFocusedGroupId,
previewingEdge,
blocksCoordinates,
updateBlockCoordinates,
isReadOnly,
focusedBlockId,
setFocusedBlockId,
graphPosition,
} = useGraph()
const { typebot, updateBlock } = useTypebot()
const { setMouseOverBlock, mouseOverBlock } = useStepDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
const { updateBlock } = useTypebot()
const [isConnecting, setIsConnecting] = useState(false)
const { setRightPanel, setStartPreviewAtBlock } = useEditor()
const isPreviewing =
previewingEdge?.from.blockId === block.id ||
(previewingEdge?.to.blockId === block.id &&
isNotDefined(previewingEdge.to.stepId))
const isStartBlock =
isDefined(block.steps[0]) && block.steps[0].type === 'start'
const blockCoordinates = blocksCoordinates[block.id]
const [isPopoverOpened, setIsPopoverOpened] = useState(
openedBlockId === block.id
)
const [isEditing, setIsEditing] = useState<boolean>(
isTextBubbleBlock(block) && block.content.plainText === ''
)
const blockRef = useRef<HTMLDivElement | null>(null)
const [debouncedBlockPosition] = useDebounce(blockCoordinates, 100)
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 (!debouncedBlockPosition || isReadOnly) return
if (
debouncedBlockPosition?.x === block.graphCoordinates.x &&
debouncedBlockPosition.y === block.graphCoordinates.y
)
return
updateBlock(blockIndex, { graphCoordinates: debouncedBlockPosition })
if (query.blockId?.toString() === block.id) setOpenedBlockId(block.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedBlockPosition])
}, [query])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.blockId === block.id &&
isNotDefined(connectingIds.target?.stepId)
connectingIds?.target?.groupId === block.groupId &&
connectingIds?.target?.blockId === block.id
)
}, [block.id, connectingIds])
}, [connectingIds, block.groupId, block.id])
const handleTitleSubmit = (title: string) =>
updateBlock(blockIndex, { title })
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
const handleModalClose = () => {
updateBlock(indices, { ...block })
onModalClose()
}
const handleMouseEnter = () => {
if (isReadOnly) return
if (mouseOverBlock?.id !== block.id && !isStartBlock)
setMouseOverBlock({ id: block.id, ref: blockRef })
if (connectingIds)
setConnectingIds({ ...connectingIds, target: { blockId: block.id } })
setConnectingIds({
...connectingIds,
target: { groupId: block.groupId, blockId: block.id },
})
}
const handleMouseLeave = () => {
if (isReadOnly) return
setMouseOverBlock(undefined)
if (connectingIds) setConnectingIds({ ...connectingIds, target: undefined })
if (connectingIds?.target)
setConnectingIds({
...connectingIds,
target: { ...connectingIds.target, blockId: undefined },
})
}
const onDrag = (_: DraggableEvent, draggableData: DraggableData) => {
const { deltaX, deltaY } = draggableData
updateBlockCoordinates(block.id, {
x: blockCoordinates.x + deltaX / graphPosition.scale,
y: blockCoordinates.y + deltaY / graphPosition.scale,
})
const handleCloseEditor = (content: TextBubbleContent) => {
const updatedBlock = { ...block, content } as Block
updateBlock(indices, updatedBlock)
setIsEditing(false)
}
const onDragStart = () => {
setFocusedBlockId(block.id)
setIsMouseDown(true)
const handleClick = (e: React.MouseEvent) => {
setFocusedGroupId(block.groupId)
e.stopPropagation()
if (isTextBubbleBlock(block)) setIsEditing(true)
setOpenedBlockId(block.id)
}
const startPreviewAtThisBlock = () => {
setStartPreviewAtBlock(block.id)
setRightPanel(RightPanel.PREVIEW)
const handleExpandClick = () => {
setOpenedBlockId(undefined)
onModalOpen()
}
const onDragStop = () => setIsMouseDown(false)
return (
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
initialValue={block.content.richText}
onClose={handleCloseEditor}
/>
) : (
<ContextMenu<HTMLDivElement>
renderMenu={() => <BlockNodeContextMenu blockIndex={blockIndex} />}
isDisabled={isReadOnly || isStartBlock}
renderMenu={() => <BlockNodeContextMenu indices={indices} />}
>
{(ref, isOpened) => (
<DraggableCore
enableUserSelectHack={false}
onDrag={onDrag}
onStart={onDragStart}
onStop={onDragStop}
onMouseDown={(e) => e.stopPropagation()}
<Popover
placement="left"
isLazy
isOpen={isPopoverOpened}
closeOnBlur={false}
>
<Stack
ref={setMultipleRefs([ref, blockRef])}
data-testid="block"
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(${blockCoordinates?.x ?? 0}px, ${
blockCoordinates?.y ?? 0
}px)`,
}}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
cursor={isMouseDown ? 'grabbing' : 'pointer'}
shadow="md"
_hover={{ shadow: 'lg' }}
zIndex={focusedBlockId === block.id ? 10 : 1}
>
<Editable
defaultValue={block.title}
onSubmit={handleTitleSubmit}
fontWeight="semibold"
pointerEvents={isReadOnly || isStartBlock ? 'none' : 'auto'}
<PopoverTrigger>
<Flex
pos="relative"
ref={setMultipleRefs([ref, blockRef])}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
data-testid={`block`}
w="full"
>
<EditablePreview
_hover={{ bgColor: 'gray.200' }}
px="1"
userSelect={'none'}
<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}
/>
<EditableInput
minW="0"
px="1"
onMouseDown={(e) => e.stopPropagation()}
/>
</Editable>
{typebot && (
<StepNodesList
blockId={block.id}
steps={block.steps}
blockIndex={blockIndex}
blockRef={ref}
isStartBlock={isStartBlock}
/>
)}
<IconButton
icon={<PlayIcon />}
aria-label={'Preview bot from this group'}
pos="absolute"
right={2}
top={0}
size="sm"
variant="outline"
onClick={startPreviewAtThisBlock}
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<BlockSettings
block={block}
onBlockChange={handleBlockUpdate}
/>
</SettingsModal>
</>
)}
{isMediaBubbleBlock(block) && (
<MediaBubblePopoverContent
block={block}
onContentChange={handleContentChange}
/>
</Stack>
</DraggableCore>
)}
</Popover>
)}
</ContextMenu>
)
}
const hasSettingsPopover = (
block: Block
): block is BlockWithOptions | ConditionBlock => !isBubbleBlock(block)
const isMediaBubbleBlock = (
block: Block
): block is Exclude<BubbleBlock, TextBubbleBlock> =>
isBubbleBlock(block) && !isTextBubbleBlock(block)

View File

@ -0,0 +1,156 @@
import { Text } from '@chakra-ui/react'
import {
Block,
StartBlock,
BubbleBlockType,
InputBlockType,
LogicBlockType,
IntegrationBlockType,
BlockIndices,
} from 'models'
import { isChoiceInput, isInputBlock } from 'utils'
import { ItemNodesList } from '../../ItemNode'
import {
EmbedBubbleContent,
SetVariableContent,
TextBubbleContent,
VideoBubbleContent,
WebhookContent,
WithVariableContent,
} from './contents'
import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PaymentInputContent } from './contents/PaymentInputContent'
import { PlaceholderContent } from './contents/PlaceholderContent'
import { RatingInputContent } from './contents/RatingInputContent'
import { SendEmailContent } from './contents/SendEmailContent'
import { TypebotLinkContent } from './contents/TypebotLinkContent'
import { ProviderWebhookContent } from './contents/ZapierContent'
type Props = {
block: Block | StartBlock
indices: BlockIndices
}
export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
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 (
<PlaceholderContent
placeholder={block.options.labels.placeholder}
isLong={block.options.isLong}
/>
)
}
case InputBlockType.NUMBER:
case InputBlockType.EMAIL:
case InputBlockType.URL:
case InputBlockType.PHONE: {
return (
<PlaceholderContent placeholder={block.options.labels.placeholder} />
)
}
case InputBlockType.DATE: {
return <Text color={'gray.500'}>Pick a date...</Text>
}
case InputBlockType.CHOICE: {
return <ItemNodesList block={block} indices={indices} />
}
case InputBlockType.PAYMENT: {
return <PaymentInputContent block={block} />
}
case InputBlockType.RATING: {
return <RatingInputContent block={block} />
}
case LogicBlockType.SET_VARIABLE: {
return <SetVariableContent block={block} />
}
case LogicBlockType.CONDITION: {
return <ItemNodesList block={block} indices={indices} isReadOnly />
}
case LogicBlockType.REDIRECT: {
return (
<ConfigureContent
label={
block.options?.url ? `Redirect to ${block.options?.url}` : undefined
}
/>
)
}
case LogicBlockType.CODE: {
return (
<ConfigureContent
label={
block.options?.content ? `Run ${block.options?.name}` : undefined
}
/>
)
}
case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkContent block={block} />
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<ConfigureContent
label={
block.options && 'action' in block.options
? block.options.action
: undefined
}
/>
)
}
case IntegrationBlockType.GOOGLE_ANALYTICS: {
return (
<ConfigureContent
label={
block.options?.action
? `Track "${block.options?.action}" `
: undefined
}
/>
)
}
case IntegrationBlockType.WEBHOOK: {
return <WebhookContent block={block} />
}
case IntegrationBlockType.ZAPIER: {
return (
<ProviderWebhookContent block={block} configuredLabel="Trigger zap" />
)
}
case IntegrationBlockType.PABBLY_CONNECT:
case IntegrationBlockType.MAKE_COM: {
return (
<ProviderWebhookContent
block={block}
configuredLabel="Trigger scenario"
/>
)
}
case IntegrationBlockType.EMAIL: {
return <SendEmailContent block={block} />
}
case 'start': {
return <Text>Start</Text>
}
}
}

View File

@ -1,13 +1,13 @@
import { Box, Text } from '@chakra-ui/react'
import { EmbedBubbleStep } from 'models'
import { EmbedBubbleBlock } from 'models'
export const EmbedBubbleContent = ({ step }: { step: EmbedBubbleStep }) => {
if (!step.content?.url) return <Text color="gray.500">Click to edit...</Text>
export const EmbedBubbleContent = ({ block }: { block: EmbedBubbleBlock }) => {
if (!block.content?.url) return <Text color="gray.500">Click to edit...</Text>
return (
<Box w="full" h="120px" pos="relative">
<iframe
id="embed-bubble-content"
src={step.content.url}
src={block.content.url}
style={{
width: '100%',
height: '100%',

View File

@ -0,0 +1,21 @@
import { Box, Text, Image } from '@chakra-ui/react'
import { ImageBubbleBlock } from 'models'
export const ImageBubbleContent = ({ block }: { block: ImageBubbleBlock }) => {
const containsVariables =
block.content?.url?.includes('{{') && block.content.url.includes('}}')
return !block.content?.url ? (
<Text color={'gray.500'}>Click to edit...</Text>
) : (
<Box w="full">
<Image
src={
containsVariables ? '/images/dynamic-image.png' : block.content?.url
}
alt="Group image"
rounded="md"
objectFit="cover"
/>
</Box>
)
}

View File

@ -0,0 +1,20 @@
import { Text } from '@chakra-ui/react'
import { PaymentInputBlock } from 'models'
type Props = {
block: PaymentInputBlock
}
export const PaymentInputContent = ({ block }: Props) => {
if (
!block.options.amount ||
!block.options.credentialsId ||
!block.options.currency
)
return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={0} pr="6">
Collect {block.options.amount} {block.options.currency}
</Text>
)
}

View File

@ -0,0 +1,12 @@
import { Text } from '@chakra-ui/react'
import { RatingInputBlock } from 'models'
type Props = {
block: RatingInputBlock
}
export const RatingInputContent = ({ block }: Props) => (
<Text noOfLines={0} pr="6">
Rate from 1 to {block.options.length}
</Text>
)

View File

@ -1,19 +1,19 @@
import { Tag, Text, Wrap, WrapItem } from '@chakra-ui/react'
import { SendEmailStep } from 'models'
import { SendEmailBlock } from 'models'
type Props = {
step: SendEmailStep
block: SendEmailBlock
}
export const SendEmailContent = ({ step }: Props) => {
if (step.options.recipients.length === 0)
export const SendEmailContent = ({ block }: Props) => {
if (block.options.recipients.length === 0)
return <Text color="gray.500">Configure...</Text>
return (
<Wrap noOfLines={2} pr="6">
<WrapItem>
<Text>Send email to</Text>
</WrapItem>
{step.options.recipients.map((to) => (
{block.options.recipients.map((to) => (
<WrapItem key={to}>
<Tag>{to}</Tag>
</WrapItem>

View File

@ -1,13 +1,13 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { SetVariableStep } from 'models'
import { SetVariableBlock } from 'models'
import { byId } from 'utils'
export const SetVariableContent = ({ step }: { step: SetVariableStep }) => {
export const SetVariableContent = ({ block }: { block: SetVariableBlock }) => {
const { typebot } = useTypebot()
const variableName =
typebot?.variables.find(byId(step.options.variableId))?.name ?? ''
const expression = step.options.expressionToEvaluate ?? ''
typebot?.variables.find(byId(block.options.variableId))?.name ?? ''
const expression = block.options.expressionToEvaluate ?? ''
return (
<Text color={'gray.500'} noOfLines={2}>
{variableName === '' && expression === ''

View File

@ -1,27 +1,27 @@
import { Flex } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { TextBubbleStep } from 'models'
import { TextBubbleBlock } from 'models'
import React from 'react'
import { parseVariableHighlight } from 'services/utils'
type Props = {
step: TextBubbleStep
block: TextBubbleBlock
}
export const TextBubbleContent = ({ step }: Props) => {
export const TextBubbleContent = ({ block }: Props) => {
const { typebot } = useTypebot()
if (!typebot) return <></>
return (
<Flex
w="90%"
flexDir={'column'}
opacity={step.content.html === '' ? '0.5' : '1'}
opacity={block.content.html === '' ? '0.5' : '1'}
className="slate-html-container"
dangerouslySetInnerHTML={{
__html:
step.content.html === ''
block.content.html === ''
? `<p>Click to edit...</p>`
: parseVariableHighlight(step.content.html, typebot),
: parseVariableHighlight(block.content.html, typebot),
}}
/>
)

View File

@ -1,26 +1,27 @@
import { TypebotLinkStep } from 'models'
import { TypebotLinkBlock } from 'models'
import React from 'react'
import { Tag, Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { byId } from 'utils'
type Props = {
step: TypebotLinkStep
block: TypebotLinkBlock
}
export const TypebotLinkContent = ({ step }: Props) => {
export const TypebotLinkContent = ({ block }: Props) => {
const { linkedTypebots, typebot } = useTypebot()
const isCurrentTypebot =
typebot &&
(step.options.typebotId === typebot.id ||
step.options.typebotId === 'current')
(block.options.typebotId === typebot.id ||
block.options.typebotId === 'current')
const linkedTypebot = isCurrentTypebot
? typebot
: linkedTypebots?.find(byId(step.options.typebotId))
const blockTitle = linkedTypebot?.blocks.find(
byId(step.options.blockId)
: linkedTypebots?.find(byId(block.options.typebotId))
const blockTitle = linkedTypebot?.groups.find(
byId(block.options.groupId)
)?.title
if (!step.options.typebotId) return <Text color="gray.500">Configure...</Text>
if (!block.options.typebotId)
return <Text color="gray.500">Configure...</Text>
return (
<Text>
Jump{' '}

View File

@ -1,15 +1,15 @@
import { Box, Text } from '@chakra-ui/react'
import { VideoBubbleStep, VideoBubbleContentType } from 'models'
import { VideoBubbleBlock, VideoBubbleContentType } from 'models'
export const VideoBubbleContent = ({ step }: { step: VideoBubbleStep }) => {
if (!step.content?.url || !step.content.type)
export const VideoBubbleContent = ({ block }: { block: VideoBubbleBlock }) => {
if (!block.content?.url || !block.content.type)
return <Text color="gray.500">Click to edit...</Text>
switch (step.content.type) {
switch (block.content.type) {
case VideoBubbleContentType.URL:
return (
<Box w="full" h="120px" pos="relative">
<video
key={step.content.url}
key={block.content.url}
controls
style={{
width: '100%',
@ -20,20 +20,20 @@ export const VideoBubbleContent = ({ step }: { step: VideoBubbleStep }) => {
borderRadius: '10px',
}}
>
<source src={step.content.url} />
<source src={block.content.url} />
</video>
</Box>
)
case VideoBubbleContentType.VIMEO:
case VideoBubbleContentType.YOUTUBE: {
const baseUrl =
step.content.type === VideoBubbleContentType.VIMEO
block.content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
return (
<Box w="full" h="120px" pos="relative">
<iframe
src={`${baseUrl}/${step.content.id}`}
src={`${baseUrl}/${block.content.id}`}
allowFullScreen
style={{
width: '100%',

View File

@ -1,13 +1,13 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { WebhookStep } from 'models'
import { WebhookBlock } from 'models'
import { byId } from 'utils'
type Props = {
step: WebhookStep
block: WebhookBlock
}
export const WebhookContent = ({ step: { webhookId } }: Props) => {
export const WebhookContent = ({ block: { webhookId } }: Props) => {
const { webhooks } = useTypebot()
const webhook = webhooks.find(byId(webhookId))

View File

@ -1,17 +1,17 @@
import { InputStep } from 'models'
import { InputBlock } from 'models'
import { chakra, Text } from '@chakra-ui/react'
import React from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { byId } from 'utils'
type Props = {
step: InputStep
block: InputBlock
}
export const WithVariableContent = ({ step }: Props) => {
export const WithVariableContent = ({ block }: Props) => {
const { typebot } = useTypebot()
const variableName = typebot?.variables.find(
byId(step.options.variableId)
byId(block.options.variableId)
)?.name
return (

View File

@ -2,27 +2,27 @@ import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import {
defaultWebhookAttributes,
MakeComStep,
PabblyConnectStep,
MakeComBlock,
PabblyConnectBlock,
Webhook,
ZapierStep,
ZapierBlock,
} from 'models'
import { useEffect } from 'react'
import { byId, isNotDefined } from 'utils'
type Props = {
step: ZapierStep | MakeComStep | PabblyConnectStep
block: ZapierBlock | MakeComBlock | PabblyConnectBlock
configuredLabel: string
}
export const ProviderWebhookContent = ({ step, configuredLabel }: Props) => {
export const ProviderWebhookContent = ({ block, configuredLabel }: Props) => {
const { webhooks, typebot, updateWebhook } = useTypebot()
const webhook = webhooks.find(byId(step.webhookId))
const webhook = webhooks.find(byId(block.webhookId))
useEffect(() => {
if (!typebot) return
if (!webhook) {
const { webhookId } = step
const { webhookId } = block
const newWebhook = {
id: webhookId,
...defaultWebhookAttributes,

View File

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

View File

@ -1,17 +1,15 @@
import { MenuList, MenuItem } from '@chakra-ui/react'
import { CopyIcon, TrashIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { BlockIndices } from 'models'
export const BlockNodeContextMenu = ({
blockIndex,
}: {
blockIndex: number
}) => {
type Props = { indices: BlockIndices }
export const BlockNodeContextMenu = ({ indices }: Props) => {
const { deleteBlock, duplicateBlock } = useTypebot()
const handleDeleteClick = () => deleteBlock(blockIndex)
const handleDuplicateClick = () => duplicateBlock(indices)
const handleDuplicateClick = () => duplicateBlock(blockIndex)
const handleDeleteClick = () => deleteBlock(indices)
return (
<MenuList>

View File

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

View File

@ -1,37 +1,37 @@
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
import { DraggableStep, DraggableStepType, Step } from 'models'
import { DraggableBlock, DraggableBlockType, Block } from 'models'
import {
computeNearestPlaceholderIndex,
useStepDnd,
useBlockDnd,
} from 'contexts/GraphDndContext'
import { Coordinates, useGraph } from 'contexts/GraphContext'
import { useEffect, useRef, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { StepNode } from './StepNode'
import { StepNodeOverlay } from './StepNodeOverlay'
import { BlockNode } from './BlockNode'
import { BlockNodeOverlay } from './BlockNodeOverlay'
type Props = {
blockId: string
steps: Step[]
blockIndex: number
blockRef: React.MutableRefObject<HTMLDivElement | null>
isStartBlock: boolean
groupId: string
blocks: Block[]
groupIndex: number
groupRef: React.MutableRefObject<HTMLDivElement | null>
isStartGroup: boolean
}
export const StepNodesList = ({
blockId,
steps,
blockIndex,
blockRef,
isStartBlock,
export const BlockNodesList = ({
groupId,
blocks,
groupIndex,
groupRef,
isStartGroup,
}: Props) => {
const {
draggedStep,
setDraggedStep,
draggedStepType,
mouseOverBlock,
setDraggedStepType,
} = useStepDnd()
const { typebot, createStep, detachStepFromBlock } = useTypebot()
draggedBlock,
setDraggedBlock,
draggedBlockType,
mouseOverGroup,
setDraggedBlockType,
} = useBlockDnd()
const { typebot, createBlock, detachBlockFromGroup } = useTypebot()
const { isReadOnly, graphPosition } = useGraph()
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
number | undefined
@ -45,17 +45,18 @@ export const StepNodesList = ({
x: 0,
y: 0,
})
const isDraggingOnCurrentBlock =
(draggedStep || draggedStepType) && mouseOverBlock?.id === blockId
const showSortPlaceholders = !isStartBlock && (draggedStep || draggedStepType)
const isDraggingOnCurrentGroup =
(draggedBlock || draggedBlockType) && mouseOverGroup?.id === groupId
const showSortPlaceholders =
!isStartGroup && (draggedBlock || draggedBlockType)
useEffect(() => {
if (mouseOverBlock?.id !== blockId) setExpandedPlaceholderIndex(undefined)
if (mouseOverGroup?.id !== groupId) setExpandedPlaceholderIndex(undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mouseOverBlock?.id])
}, [mouseOverGroup?.id])
const handleMouseMoveGlobal = (event: MouseEvent) => {
if (!draggedStep || draggedStep.blockId !== blockId) return
if (!draggedBlock || draggedBlock.groupId !== groupId) return
const { clientX, clientY } = event
setPosition({
x: clientX - mousePositionInElement.x,
@ -63,41 +64,44 @@ export const StepNodesList = ({
})
}
const handleMouseMoveOnBlock = (event: MouseEvent) => {
if (!isDraggingOnCurrentBlock) return
const handleMouseMoveOnGroup = (event: MouseEvent) => {
if (!isDraggingOnCurrentGroup) return
setExpandedPlaceholderIndex(
computeNearestPlaceholderIndex(event.pageY, placeholderRefs)
)
}
const handleMouseUpOnBlock = (e: MouseEvent) => {
const handleMouseUpOnGroup = (e: MouseEvent) => {
setExpandedPlaceholderIndex(undefined)
if (!isDraggingOnCurrentBlock) return
const stepIndex = computeNearestPlaceholderIndex(e.clientY, placeholderRefs)
createStep(
blockId,
(draggedStep || draggedStepType) as DraggableStep | DraggableStepType,
if (!isDraggingOnCurrentGroup) return
const blockIndex = computeNearestPlaceholderIndex(
e.clientY,
placeholderRefs
)
createBlock(
groupId,
(draggedBlock || draggedBlockType) as DraggableBlock | DraggableBlockType,
{
groupIndex,
blockIndex,
stepIndex,
}
)
setDraggedStep(undefined)
setDraggedStepType(undefined)
setDraggedBlock(undefined)
setDraggedBlockType(undefined)
}
const handleStepMouseDown =
(stepIndex: number) =>
const handleBlockMouseDown =
(blockIndex: number) =>
(
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
step: DraggableStep
block: DraggableBlock
) => {
if (isReadOnly) return
placeholderRefs.current.splice(stepIndex + 1, 1)
detachStepFromBlock({ blockIndex, stepIndex })
placeholderRefs.current.splice(blockIndex + 1, 1)
detachBlockFromGroup({ groupIndex, blockIndex })
setPosition(absolute)
setMousePositionInElement(relative)
setDraggedStep(step)
setDraggedBlock(block)
}
const handlePushElementRef =
@ -106,11 +110,11 @@ export const StepNodesList = ({
}
useEventListener('mousemove', handleMouseMoveGlobal)
useEventListener('mousemove', handleMouseMoveOnBlock, blockRef.current)
useEventListener('mousemove', handleMouseMoveOnGroup, groupRef.current)
useEventListener(
'mouseup',
handleMouseUpOnBlock,
mouseOverBlock?.ref.current,
handleMouseUpOnGroup,
mouseOverGroup?.ref.current,
{
capture: true,
}
@ -119,7 +123,7 @@ export const StepNodesList = ({
<Stack
spacing={1}
transition="none"
pointerEvents={isReadOnly || isStartBlock ? 'none' : 'auto'}
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
>
<Flex
ref={handlePushElementRef(0)}
@ -134,14 +138,14 @@ export const StepNodesList = ({
transition={showSortPlaceholders ? 'height 200ms' : 'none'}
/>
{typebot &&
steps.map((step, idx) => (
<Stack key={step.id} spacing={1}>
<StepNode
key={step.id}
step={step}
indices={{ blockIndex, stepIndex: idx }}
isConnectable={steps.length - 1 === idx}
onMouseDown={handleStepMouseDown(idx)}
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)}
@ -157,11 +161,11 @@ export const StepNodesList = ({
/>
</Stack>
))}
{draggedStep && draggedStep.blockId === blockId && (
{draggedBlock && draggedBlock.groupId === groupId && (
<Portal>
<StepNodeOverlay
step={draggedStep}
indices={{ blockIndex, stepIndex: 0 }}
<BlockNodeOverlay
block={draggedBlock}
indices={{ groupIndex, blockIndex: 0 }}
pos="fixed"
top="0"
left="0"

View File

@ -6,18 +6,18 @@ import {
} from '@chakra-ui/react'
import { ImageUploadContent } from 'components/shared/ImageUploadContent'
import {
BubbleStep,
BubbleStepContent,
BubbleStepType,
TextBubbleStep,
BubbleBlock,
BubbleBlockContent,
BubbleBlockType,
TextBubbleBlock,
} from 'models'
import { useRef } from 'react'
import { EmbedUploadContent } from './EmbedUploadContent'
import { VideoUploadContent } from './VideoUploadContent'
type Props = {
step: Exclude<BubbleStep, TextBubbleStep>
onContentChange: (content: BubbleStepContent) => void
block: Exclude<BubbleBlock, TextBubbleBlock>
onContentChange: (content: BubbleBlockContent) => void
}
export const MediaBubblePopoverContent = (props: Props) => {
@ -28,7 +28,7 @@ export const MediaBubblePopoverContent = (props: Props) => {
<Portal>
<PopoverContent
onMouseDown={handleMouseDown}
w={props.step.type === BubbleStepType.IMAGE ? '500px' : '400px'}
w={props.block.type === BubbleBlockType.IMAGE ? '500px' : '400px'}
>
<PopoverArrow />
<PopoverBody ref={ref} shadow="lg">
@ -39,26 +39,32 @@ export const MediaBubblePopoverContent = (props: Props) => {
)
}
export const MediaBubbleContent = ({ step, onContentChange }: Props) => {
export const MediaBubbleContent = ({ block, onContentChange }: Props) => {
const handleImageUrlChange = (url: string) => onContentChange({ url })
switch (step.type) {
case BubbleStepType.IMAGE: {
switch (block.type) {
case BubbleBlockType.IMAGE: {
return (
<ImageUploadContent
url={step.content?.url}
url={block.content?.url}
onSubmit={handleImageUrlChange}
/>
)
}
case BubbleStepType.VIDEO: {
case BubbleBlockType.VIDEO: {
return (
<VideoUploadContent content={step.content} onSubmit={onContentChange} />
<VideoUploadContent
content={block.content}
onSubmit={onContentChange}
/>
)
}
case BubbleStepType.EMBED: {
case BubbleBlockType.EMBED: {
return (
<EmbedUploadContent content={step.content} onSubmit={onContentChange} />
<EmbedUploadContent
content={block.content}
onSubmit={onContentChange}
/>
)
}
}

View File

@ -9,13 +9,13 @@ import {
import { ExpandIcon } from 'assets/icons'
import {
ConditionItem,
ConditionStep,
InputStepType,
IntegrationStepType,
LogicStepType,
Step,
StepOptions,
StepWithOptions,
ConditionBlock,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
Block,
BlockOptions,
BlockWithOptions,
Webhook,
} from 'models'
import { useRef } from 'react'
@ -42,10 +42,10 @@ import { WebhookSettings } from './bodies/WebhookSettings'
import { ZapierSettings } from './bodies/ZapierSettings'
type Props = {
step: StepWithOptions | ConditionStep
block: BlockWithOptions | ConditionBlock
webhook?: Webhook
onExpandClick: () => void
onStepChange: (updates: Partial<Step>) => void
onBlockChange: (updates: Partial<Block>) => void
}
export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
@ -68,7 +68,7 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
ref={ref}
shadow="lg"
>
<StepSettings {...props} />
<BlockSettings {...props} />
</PopoverBody>
<IconButton
pos="absolute"
@ -84,156 +84,156 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
)
}
export const StepSettings = ({
step,
onStepChange,
export const BlockSettings = ({
block,
onBlockChange,
}: {
step: StepWithOptions | ConditionStep
block: BlockWithOptions | ConditionBlock
webhook?: Webhook
onStepChange: (step: Partial<Step>) => void
onBlockChange: (block: Partial<Block>) => void
}): JSX.Element => {
const handleOptionsChange = (options: StepOptions) => {
onStepChange({ options } as Partial<Step>)
const handleOptionsChange = (options: BlockOptions) => {
onBlockChange({ options } as Partial<Block>)
}
const handleItemChange = (updates: Partial<ConditionItem>) => {
onStepChange({
items: [{ ...(step as ConditionStep).items[0], ...updates }],
} as Partial<Step>)
onBlockChange({
items: [{ ...(block as ConditionBlock).items[0], ...updates }],
} as Partial<Block>)
}
switch (step.type) {
case InputStepType.TEXT: {
switch (block.type) {
case InputBlockType.TEXT: {
return (
<TextInputSettingsBody
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputStepType.NUMBER: {
case InputBlockType.NUMBER: {
return (
<NumberInputSettingsBody
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputStepType.EMAIL: {
case InputBlockType.EMAIL: {
return (
<EmailInputSettingsBody
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputStepType.URL: {
case InputBlockType.URL: {
return (
<UrlInputSettingsBody
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputStepType.DATE: {
case InputBlockType.DATE: {
return (
<DateInputSettingsBody
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputStepType.PHONE: {
case InputBlockType.PHONE: {
return (
<PhoneNumberSettingsBody
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputStepType.CHOICE: {
case InputBlockType.CHOICE: {
return (
<ChoiceInputSettingsBody
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputStepType.PAYMENT: {
case InputBlockType.PAYMENT: {
return (
<PaymentSettings
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case InputStepType.RATING: {
case InputBlockType.RATING: {
return (
<RatingInputSettings
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicStepType.SET_VARIABLE: {
case LogicBlockType.SET_VARIABLE: {
return (
<SetVariableSettings
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicStepType.CONDITION: {
case LogicBlockType.CONDITION: {
return (
<ConditionSettingsBody step={step} onItemChange={handleItemChange} />
<ConditionSettingsBody block={block} onItemChange={handleItemChange} />
)
}
case LogicStepType.REDIRECT: {
case LogicBlockType.REDIRECT: {
return (
<RedirectSettings
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicStepType.CODE: {
case LogicBlockType.CODE: {
return (
<CodeSettings
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case LogicStepType.TYPEBOT_LINK: {
case LogicBlockType.TYPEBOT_LINK: {
return (
<TypebotLinkSettingsForm
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case IntegrationStepType.GOOGLE_SHEETS: {
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<GoogleSheetsSettingsBody
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
stepId={step.id}
blockId={block.id}
/>
)
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
case IntegrationBlockType.GOOGLE_ANALYTICS: {
return (
<GoogleAnalyticsSettings
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case IntegrationStepType.ZAPIER: {
return <ZapierSettings step={step} />
case IntegrationBlockType.ZAPIER: {
return <ZapierSettings block={block} />
}
case IntegrationStepType.MAKE_COM: {
case IntegrationBlockType.MAKE_COM: {
return (
<WebhookSettings
step={step}
block={block}
onOptionsChange={handleOptionsChange}
provider={{
name: 'Make.com',
@ -242,10 +242,10 @@ export const StepSettings = ({
/>
)
}
case IntegrationStepType.PABBLY_CONNECT: {
case IntegrationBlockType.PABBLY_CONNECT: {
return (
<WebhookSettings
step={step}
block={block}
onOptionsChange={handleOptionsChange}
provider={{
name: 'Pabbly Connect',
@ -254,15 +254,15 @@ export const StepSettings = ({
/>
)
}
case IntegrationStepType.WEBHOOK: {
case IntegrationBlockType.WEBHOOK: {
return (
<WebhookSettings step={step} onOptionsChange={handleOptionsChange} />
<WebhookSettings block={block} onOptionsChange={handleOptionsChange} />
)
}
case IntegrationStepType.EMAIL: {
case IntegrationBlockType.EMAIL: {
return (
<SendEmailSettings
options={step.options}
options={block.options}
onOptionsChange={handleOptionsChange}
/>
)

View File

@ -4,22 +4,22 @@ import { TableList } from 'components/shared/TableList'
import {
Comparison,
ConditionItem,
ConditionStep,
ConditionBlock,
LogicalOperator,
} from 'models'
import React from 'react'
import { ComparisonItem } from './ComparisonsItem'
type ConditionSettingsBodyProps = {
step: ConditionStep
block: ConditionBlock
onItemChange: (updates: Partial<ConditionItem>) => void
}
export const ConditionSettingsBody = ({
step,
block,
onItemChange,
}: ConditionSettingsBodyProps) => {
const itemContent = step.items[0].content
const itemContent = block.items[0].content
const handleComparisonsChange = (comparisons: Comparison[]) =>
onItemChange({ content: { ...itemContent, comparisons } })

View File

@ -21,11 +21,15 @@ import { getGoogleSheetsConsentScreenUrl } from 'services/integrations'
type Props = {
isOpen: boolean
stepId: string
blockId: string
onClose: () => void
}
export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => {
export const GoogleSheetConnectModal = ({
blockId,
isOpen,
onClose,
}: Props) => {
const { workspace } = useWorkspace()
return (
<Modal isOpen={isOpen} onClose={onClose} size="lg">
@ -56,7 +60,7 @@ export const GoogleSheetConnectModal = ({ stepId, isOpen, onClose }: Props) => {
variant="outline"
href={getGoogleSheetsConsentScreenUrl(
window.location.href,
stepId,
blockId,
workspace?.id
)}
mx="auto"

View File

@ -25,13 +25,13 @@ import { GoogleSheetConnectModal } from './GoogleSheetsConnectModal'
type Props = {
options: GoogleSheetsOptions
onOptionsChange: (options: GoogleSheetsOptions) => void
stepId: string
blockId: string
}
export const GoogleSheetsSettingsBody = ({
options,
onOptionsChange,
stepId,
blockId,
}: Props) => {
const { save } = useTypebot()
const { sheets, isLoading } = useSheets({
@ -93,7 +93,7 @@ export const GoogleSheetsSettingsBody = ({
onCreateNewClick={handleCreateNewClick}
/>
<GoogleSheetConnectModal
stepId={stepId}
blockId={blockId}
isOpen={isOpen}
onClose={onClose}
/>

View File

@ -23,8 +23,8 @@ export const NumberInputSettingsBody = ({
onOptionsChange(removeUndefinedFields({ ...options, min }))
const handleMaxChange = (max?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, max }))
const handleStepChange = (step?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, step }))
const handleBlockChange = (block?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, block }))
const handleVariableChange = (variable?: Variable) => {
onOptionsChange({ ...options, variableId: variable?.id })
}
@ -78,7 +78,7 @@ export const NumberInputSettingsBody = ({
<SmartNumberInput
id="step"
value={options.step}
onValueChange={handleStepChange}
onValueChange={handleBlockChange}
/>
</HStack>
<Stack>

View File

@ -0,0 +1,41 @@
import { Input } from '@chakra-ui/react'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { Group } from 'models'
import { useMemo } from 'react'
import { byId } from 'utils'
type Props = {
groups: Group[]
groupId?: string
onGroupIdSelected: (groupId: string) => void
isLoading?: boolean
}
export const GroupsDropdown = ({
groups,
groupId,
onGroupIdSelected,
isLoading,
}: Props) => {
const currentGroup = useMemo(
() => groups?.find(byId(groupId)),
[groupId, groups]
)
const handleGroupSelect = (title: string) => {
const id = groups?.find((b) => b.title === title)?.id
if (id) onGroupIdSelected(id)
}
if (isLoading) return <Input value="Loading..." isDisabled />
if (!groups || groups.length === 0)
return <Input value="No groups found" isDisabled />
return (
<SearchableDropdown
selectedItem={currentGroup?.title}
items={(groups ?? []).map((b) => b.title)}
onValueChange={handleGroupSelect}
placeholder={'Select a block'}
/>
)
}

View File

@ -2,7 +2,7 @@ import { Stack } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { TypebotLinkOptions } from 'models'
import { byId } from 'utils'
import { BlocksDropdown } from './BlocksDropdown'
import { GroupsDropdown } from './GroupsDropdown'
import { TypebotsDropdown } from './TypebotsDropdown'
type Props = {
@ -18,8 +18,8 @@ export const TypebotLinkSettingsForm = ({
const handleTypebotIdChange = (typebotId: string | 'current') =>
onOptionsChange({ ...options, typebotId })
const handleBlockIdChange = (blockId: string) =>
onOptionsChange({ ...options, blockId })
const handleGroupIdChange = (groupId: string) =>
onOptionsChange({ ...options, groupId })
return (
<Stack>
@ -30,15 +30,15 @@ export const TypebotLinkSettingsForm = ({
currentWorkspaceId={typebot.workspaceId as string}
/>
)}
<BlocksDropdown
blocks={
<GroupsDropdown
groups={
typebot &&
(options.typebotId === typebot.id || options.typebotId === 'current')
? typebot.blocks
: linkedTypebots?.find(byId(options.typebotId))?.blocks ?? []
? typebot.groups
: linkedTypebots?.find(byId(options.typebotId))?.groups ?? []
}
blockId={options.blockId}
onBlockIdSelected={handleBlockIdChange}
groupId={options.groupId}
onGroupIdSelected={handleGroupIdChange}
isLoading={
linkedTypebots === undefined &&
typebot &&

View File

@ -22,11 +22,11 @@ import {
WebhookOptions,
VariableForTest,
ResponseVariableMapping,
WebhookStep,
WebhookBlock,
defaultWebhookAttributes,
Webhook,
MakeComStep,
PabblyConnectStep,
MakeComBlock,
PabblyConnectBlock,
} from 'models'
import { DropdownList } from 'components/shared/DropdownList'
import { TableList, TableListItemProps } from 'components/shared/TableList'
@ -49,13 +49,13 @@ type Provider = {
url: string
}
type Props = {
step: WebhookStep | MakeComStep | PabblyConnectStep
block: WebhookBlock | MakeComBlock | PabblyConnectBlock
onOptionsChange: (options: WebhookOptions) => void
provider?: Provider
}
export const WebhookSettings = ({
step: { options, blockId, id: stepId, webhookId },
block: { options, id: blockId, webhookId },
onOptionsChange,
provider,
}: Props) => {
@ -135,7 +135,7 @@ export const WebhookSettings = ({
options.variablesForTest,
typebot.variables
),
{ blockId, stepId }
{ blockId }
)
if (error)
return showToast({ title: error.name, description: error.message })

View File

@ -9,17 +9,17 @@ import {
} from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext'
import { ZapierStep } from 'models'
import { ZapierBlock } from 'models'
import React from 'react'
import { byId } from 'utils'
type Props = {
step: ZapierStep
block: ZapierBlock
}
export const ZapierSettings = ({ step }: Props) => {
export const ZapierSettings = ({ block }: Props) => {
const { webhooks } = useTypebot()
const webhook = webhooks.find(byId(step.webhookId))
const webhook = webhooks.find(byId(block.webhookId))
return (
<Stack spacing={4}>
@ -33,7 +33,7 @@ export const ZapierSettings = ({ step }: Props) => {
<>Your zap is correctly configured 🚀</>
) : (
<Stack>
<Text>Head up to Zapier to configure this step:</Text>
<Text>Head up to Zapier to configure this block:</Text>
<Button
as={Link}
href="https://zapier.com/apps/typebot/integrations"

View File

@ -40,7 +40,7 @@ export const TextBubbleEditor = ({ initialValue, onClose }: Props) => {
const textEditorRef = useRef<HTMLDivElement>(null)
const closeEditor = () => onClose(convertValueToStepContent(value))
const closeEditor = () => onClose(convertValueToBlockContent(value))
useOutsideClick({
ref: textEditorRef,
@ -70,7 +70,7 @@ export const TextBubbleEditor = ({ initialValue, onClose }: Props) => {
}
}
const convertValueToStepContent = (value: TElement[]): TextBubbleContent => {
const convertValueToBlockContent = (value: TElement[]): TextBubbleContent => {
if (value.length === 0) defaultTextBubbleContent
const html = serializeHtml(editor, {
nodes: value,

View File

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

View File

@ -0,0 +1,193 @@
import {
Editable,
EditableInput,
EditablePreview,
IconButton,
Stack,
} from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
import { Group } from 'models'
import { useGraph } from 'contexts/GraphContext'
import { useBlockDnd } from 'contexts/GraphDndContext'
import { BlockNodesList } from '../BlockNode/BlockNodesList'
import { isDefined, isNotDefined } from 'utils'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu'
import { GroupNodeContextMenu } from './GroupNodeContextMenu'
import { useDebounce } from 'use-debounce'
import { setMultipleRefs } from 'services/utils'
import { DraggableCore, DraggableData, DraggableEvent } from 'react-draggable'
import { PlayIcon } from 'assets/icons'
import { RightPanel, useEditor } from 'contexts/EditorContext'
type Props = {
group: Group
groupIndex: number
}
export const GroupNode = ({ group, groupIndex }: Props) => {
const {
connectingIds,
setConnectingIds,
previewingEdge,
groupsCoordinates,
updateGroupCoordinates,
isReadOnly,
focusedGroupId,
setFocusedGroupId,
graphPosition,
} = useGraph()
const { typebot, updateGroup } = useTypebot()
const { setMouseOverGroup, mouseOverGroup } = useBlockDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const { setRightPanel, setStartPreviewAtGroup } = useEditor()
const isPreviewing =
previewingEdge?.from.groupId === group.id ||
(previewingEdge?.to.groupId === group.id &&
isNotDefined(previewingEdge.to.groupId))
const isStartGroup =
isDefined(group.blocks[0]) && group.blocks[0].type === 'start'
const groupCoordinates = groupsCoordinates[group.id]
const groupRef = useRef<HTMLDivElement | null>(null)
const [debouncedGroupPosition] = useDebounce(groupCoordinates, 100)
useEffect(() => {
if (!debouncedGroupPosition || isReadOnly) return
if (
debouncedGroupPosition?.x === group.graphCoordinates.x &&
debouncedGroupPosition.y === group.graphCoordinates.y
)
return
updateGroup(groupIndex, { graphCoordinates: debouncedGroupPosition })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedGroupPosition])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.groupId === group.id &&
isNotDefined(connectingIds.target?.groupId)
)
}, [connectingIds, group.id])
const handleTitleSubmit = (title: string) =>
updateGroup(groupIndex, { title })
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
updateGroupCoordinates(group.id, {
x: groupCoordinates.x + deltaX / graphPosition.scale,
y: groupCoordinates.y + deltaY / graphPosition.scale,
})
}
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(${groupCoordinates?.x ?? 0}px, ${
groupCoordinates?.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
defaultValue={group.title}
onSubmit={handleTitleSubmit}
fontWeight="semibold"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
>
<EditablePreview
_hover={{ bgColor: 'gray.200' }}
px="1"
userSelect={'none'}
/>
<EditableInput
minW="0"
px="1"
onMouseDown={(e) => e.stopPropagation()}
/>
</Editable>
{typebot && (
<BlockNodesList
groupId={group.id}
blocks={group.blocks}
groupIndex={groupIndex}
groupRef={ref}
isStartGroup={isStartGroup}
/>
)}
<IconButton
icon={<PlayIcon />}
aria-label={'Preview bot from this group'}
pos="absolute"
right={2}
top={0}
size="sm"
variant="outline"
onClick={startPreviewAtThisGroup}
/>
</Stack>
</DraggableCore>
)}
</ContextMenu>
)
}

View File

@ -1,15 +1,17 @@
import { MenuList, MenuItem } from '@chakra-ui/react'
import { CopyIcon, TrashIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { StepIndices } from 'models'
type Props = { indices: StepIndices }
export const StepNodeContextMenu = ({ indices }: Props) => {
const { deleteStep, duplicateStep } = useTypebot()
export const GroupNodeContextMenu = ({
groupIndex,
}: {
groupIndex: number
}) => {
const { deleteGroup, duplicateGroup } = useTypebot()
const handleDuplicateClick = () => duplicateStep(indices)
const handleDeleteClick = () => deleteGroup(groupIndex)
const handleDeleteClick = () => deleteStep(indices)
const handleDuplicateClick = () => duplicateGroup(groupIndex)
return (
<MenuList>

View File

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

View File

@ -5,7 +5,7 @@ import { NodePosition, useDragDistance } from 'contexts/GraphDndContext'
import { useTypebot } from 'contexts/TypebotContext'
import {
ButtonItem,
ChoiceInputStep,
ChoiceInputBlock,
Item,
ItemIndices,
ItemType,
@ -21,7 +21,7 @@ type Props = {
indices: ItemIndices
isReadOnly: boolean
onMouseDown?: (
stepNodePosition: { absolute: Coordinates; relative: Coordinates },
blockNodePosition: { absolute: Coordinates; relative: Coordinates },
item: ButtonItem
) => void
}
@ -33,9 +33,9 @@ export const ItemNode = ({ item, indices, isReadOnly, onMouseDown }: Props) => {
const itemRef = useRef<HTMLDivElement | null>(null)
const isPreviewing = previewingEdge?.from.itemId === item.id
const isConnectable = !(
typebot?.blocks[indices.blockIndex].steps[
indices.stepIndex
] as ChoiceInputStep
typebot?.groups[indices.groupIndex].blocks[
indices.blockIndex
] as ChoiceInputBlock
)?.options?.isMultipleChoice
const onDrag = (position: NodePosition) => {
if (!onMouseDown || item.type !== ItemType.BUTTON) return
@ -83,8 +83,8 @@ export const ItemNode = ({ item, indices, isReadOnly, onMouseDown }: Props) => {
{typebot && isConnectable && (
<SourceEndpoint
source={{
blockId: typebot.blocks[indices.blockIndex].id,
stepId: item.stepId,
groupId: typebot.groups[indices.groupIndex].id,
blockId: item.blockId,
itemId: item.id,
}}
pos="absolute"

View File

@ -45,7 +45,7 @@ export const ButtonNodeContent = ({ item, indices, isMouseOver }: Props) => {
const handlePlusClick = () => {
const itemIndex = indices.itemIndex + 1
createItem(
{ stepId: item.stepId, type: ItemType.BUTTON },
{ blockId: item.blockId, type: ItemType.BUTTON },
{ ...indices, itemIndex }
)
}

View File

@ -1,38 +1,38 @@
import { Flex, Portal, Stack, Text, useEventListener } from '@chakra-ui/react'
import {
computeNearestPlaceholderIndex,
useStepDnd,
useBlockDnd,
} from 'contexts/GraphDndContext'
import { Coordinates, useGraph } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext'
import { ButtonItem, StepIndices, StepWithItems } from 'models'
import { ButtonItem, BlockIndices, BlockWithItems } from 'models'
import React, { useEffect, useRef, useState } from 'react'
import { ItemNode } from './ItemNode'
import { SourceEndpoint } from '../../Endpoints'
import { ItemNodeOverlay } from './ItemNodeOverlay'
type Props = {
step: StepWithItems
indices: StepIndices
block: BlockWithItems
indices: BlockIndices
isReadOnly?: boolean
}
export const ItemNodesList = ({
step,
indices: { blockIndex, stepIndex },
block,
indices: { groupIndex, blockIndex },
isReadOnly = false,
}: Props) => {
const { typebot, createItem, detachItemFromStep } = useTypebot()
const { draggedItem, setDraggedItem, mouseOverBlock } = useStepDnd()
const { typebot, createItem, detachItemFromBlock } = useTypebot()
const { draggedItem, setDraggedItem, mouseOverGroup } = useBlockDnd()
const placeholderRefs = useRef<HTMLDivElement[]>([])
const { graphPosition } = useGraph()
const blockId = typebot?.blocks[blockIndex].id
const isDraggingOnCurrentBlock =
(draggedItem && mouseOverBlock?.id === blockId) ?? false
const groupId = typebot?.groups[groupIndex].id
const isDraggingOnCurrentGroup =
(draggedItem && mouseOverGroup?.id === groupId) ?? false
const showPlaceholders = draggedItem && !isReadOnly
const isLastStep =
typebot?.blocks[blockIndex].steps[stepIndex + 1] === undefined
const isLastBlock =
typebot?.groups[groupIndex].blocks[blockIndex + 1] === undefined
const [position, setPosition] = useState({
x: 0,
@ -44,7 +44,7 @@ export const ItemNodesList = ({
>()
const handleGlobalMouseMove = (event: MouseEvent) => {
if (!draggedItem || draggedItem.stepId !== step.id) return
if (!draggedItem || draggedItem.blockId !== block.id) return
const { clientX, clientY } = event
setPosition({
...position,
@ -55,44 +55,44 @@ export const ItemNodesList = ({
useEventListener('mousemove', handleGlobalMouseMove)
useEffect(() => {
if (mouseOverBlock?.id !== step.blockId)
if (mouseOverGroup?.id !== block.groupId)
setExpandedPlaceholderIndex(undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mouseOverBlock?.id])
}, [mouseOverGroup?.id])
const handleMouseMoveOnBlock = (event: MouseEvent) => {
if (!isDraggingOnCurrentBlock || isReadOnly) return
const handleMouseMoveOnGroup = (event: MouseEvent) => {
if (!isDraggingOnCurrentGroup || isReadOnly) return
const index = computeNearestPlaceholderIndex(event.pageY, placeholderRefs)
setExpandedPlaceholderIndex(index)
}
useEventListener(
'mousemove',
handleMouseMoveOnBlock,
mouseOverBlock?.ref.current
handleMouseMoveOnGroup,
mouseOverGroup?.ref.current
)
const handleMouseUpOnBlock = (e: MouseEvent) => {
const handleMouseUpOnGroup = (e: MouseEvent) => {
setExpandedPlaceholderIndex(undefined)
if (!isDraggingOnCurrentBlock) return
if (!isDraggingOnCurrentGroup) return
const itemIndex = computeNearestPlaceholderIndex(e.pageY, placeholderRefs)
e.stopPropagation()
setDraggedItem(undefined)
createItem(draggedItem as ButtonItem, {
groupIndex,
blockIndex,
stepIndex,
itemIndex,
})
}
useEventListener(
'mouseup',
handleMouseUpOnBlock,
mouseOverBlock?.ref.current,
handleMouseUpOnGroup,
mouseOverGroup?.ref.current,
{
capture: true,
}
)
const handleStepMouseDown =
const handleBlockMouseDown =
(itemIndex: number) =>
(
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
@ -100,7 +100,7 @@ export const ItemNodesList = ({
) => {
if (!typebot || isReadOnly) return
placeholderRefs.current.splice(itemIndex + 1, 1)
detachItemFromStep({ blockIndex, stepIndex, itemIndex })
detachItemFromBlock({ groupIndex, blockIndex, itemIndex })
setPosition(absolute)
setRelativeCoordinates(relative)
setDraggedItem(item)
@ -129,12 +129,12 @@ export const ItemNodesList = ({
rounded="lg"
transition={showPlaceholders ? 'height 200ms' : 'none'}
/>
{step.items.map((item, idx) => (
{block.items.map((item, idx) => (
<Stack key={item.id} spacing={1}>
<ItemNode
item={item}
indices={{ blockIndex, stepIndex, itemIndex: idx }}
onMouseDown={handleStepMouseDown(idx)}
indices={{ groupIndex, blockIndex, itemIndex: idx }}
onMouseDown={handleBlockMouseDown(idx)}
isReadOnly={isReadOnly}
/>
<Flex
@ -151,7 +151,7 @@ export const ItemNodesList = ({
/>
</Stack>
))}
{isLastStep && (
{isLastBlock && (
<Flex
px="4"
py="2"
@ -166,8 +166,8 @@ export const ItemNodesList = ({
<Text color={isReadOnly ? 'inherit' : 'gray.500'}>Default</Text>
<SourceEndpoint
source={{
blockId: step.blockId,
stepId: step.id,
groupId: block.groupId,
blockId: block.id,
}}
pos="absolute"
right="-49px"
@ -175,7 +175,7 @@ export const ItemNodesList = ({
</Flex>
)}
{draggedItem && draggedItem.stepId === step.id && (
{draggedItem && draggedItem.blockId === block.id && (
<Portal>
<ItemNodeOverlay
item={draggedItem}

View File

@ -1,41 +0,0 @@
import { Input } from '@chakra-ui/react'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { Block } from 'models'
import { useMemo } from 'react'
import { byId } from 'utils'
type Props = {
blocks: Block[]
blockId?: string
onBlockIdSelected: (blockId: string) => void
isLoading?: boolean
}
export const BlocksDropdown = ({
blocks,
blockId,
onBlockIdSelected,
isLoading,
}: Props) => {
const currentBlock = useMemo(
() => blocks?.find(byId(blockId)),
[blockId, blocks]
)
const handleBlockSelect = (title: string) => {
const id = blocks?.find((b) => b.title === title)?.id
if (id) onBlockIdSelected(id)
}
if (isLoading) return <Input value="Loading..." isDisabled />
if (!blocks || blocks.length === 0)
return <Input value="No blocks found" isDisabled />
return (
<SearchableDropdown
selectedItem={currentBlock?.title}
items={(blocks ?? []).map((b) => b.title)}
onValueChange={handleBlockSelect}
placeholder={'Select a block'}
/>
)
}

View File

@ -1,245 +0,0 @@
import {
Flex,
HStack,
Popover,
PopoverTrigger,
useDisclosure,
} from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
import {
BubbleStep,
BubbleStepContent,
ConditionStep,
DraggableStep,
Step,
StepWithOptions,
TextBubbleContent,
TextBubbleStep,
} from 'models'
import { useGraph } from 'contexts/GraphContext'
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
import { isBubbleStep, isTextBubbleStep } from 'utils'
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
import { useTypebot } from 'contexts/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu'
import { SettingsPopoverContent } from './SettingsPopoverContent'
import { StepNodeContextMenu } from './StepNodeContextMenu'
import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
import { hasDefaultConnector } from 'services/typebots'
import { useRouter } from 'next/router'
import { SettingsModal } from './SettingsPopoverContent/SettingsModal'
import { StepSettings } from './SettingsPopoverContent/SettingsPopoverContent'
import { TextBubbleEditor } from './TextBubbleEditor'
import { TargetEndpoint } from '../../Endpoints'
import { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
import { NodePosition, useDragDistance } from 'contexts/GraphDndContext'
import { setMultipleRefs } from 'services/utils'
export const StepNode = ({
step,
isConnectable,
indices,
onMouseDown,
}: {
step: Step
isConnectable: boolean
indices: { stepIndex: number; blockIndex: number }
onMouseDown?: (stepNodePosition: NodePosition, step: DraggableStep) => void
}) => {
const { query } = useRouter()
const {
setConnectingIds,
connectingIds,
openedStepId,
setOpenedStepId,
setFocusedBlockId,
previewingEdge,
} = useGraph()
const { updateStep } = useTypebot()
const [isConnecting, setIsConnecting] = useState(false)
const [isPopoverOpened, setIsPopoverOpened] = useState(
openedStepId === step.id
)
const [isEditing, setIsEditing] = useState<boolean>(
isTextBubbleStep(step) && step.content.plainText === ''
)
const stepRef = useRef<HTMLDivElement | null>(null)
const isPreviewing = isConnecting || previewingEdge?.to.stepId === step.id
const onDrag = (position: NodePosition) => {
if (step.type === 'start' || !onMouseDown) return
onMouseDown(position, step)
}
useDragDistance({
ref: stepRef,
onDrag,
isDisabled: !onMouseDown || step.type === 'start',
})
const {
isOpen: isModalOpen,
onOpen: onModalOpen,
onClose: onModalClose,
} = useDisclosure()
useEffect(() => {
if (query.stepId?.toString() === step.id) setOpenedStepId(step.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.blockId === step.blockId &&
connectingIds?.target?.stepId === step.id
)
}, [connectingIds, step.blockId, step.id])
const handleModalClose = () => {
updateStep(indices, { ...step })
onModalClose()
}
const handleMouseEnter = () => {
if (connectingIds)
setConnectingIds({
...connectingIds,
target: { blockId: step.blockId, stepId: step.id },
})
}
const handleMouseLeave = () => {
if (connectingIds?.target)
setConnectingIds({
...connectingIds,
target: { ...connectingIds.target, stepId: undefined },
})
}
const handleCloseEditor = (content: TextBubbleContent) => {
const updatedStep = { ...step, content } as Step
updateStep(indices, updatedStep)
setIsEditing(false)
}
const handleClick = (e: React.MouseEvent) => {
setFocusedBlockId(step.blockId)
e.stopPropagation()
if (isTextBubbleStep(step)) setIsEditing(true)
setOpenedStepId(step.id)
}
const handleExpandClick = () => {
setOpenedStepId(undefined)
onModalOpen()
}
const handleStepUpdate = (updates: Partial<Step>) =>
updateStep(indices, { ...step, ...updates })
const handleContentChange = (content: BubbleStepContent) =>
updateStep(indices, { ...step, content } as Step)
useEffect(() => {
setIsPopoverOpened(openedStepId === step.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openedStepId])
return isEditing && isTextBubbleStep(step) ? (
<TextBubbleEditor
initialValue={step.content.richText}
onClose={handleCloseEditor}
/>
) : (
<ContextMenu<HTMLDivElement>
renderMenu={() => <StepNodeContextMenu indices={indices} />}
>
{(ref, isOpened) => (
<Popover
placement="left"
isLazy
isOpen={isPopoverOpened}
closeOnBlur={false}
>
<PopoverTrigger>
<Flex
pos="relative"
ref={setMultipleRefs([ref, stepRef])}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleClick}
data-testid={`step`}
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"
>
<StepIcon
type={step.type}
mt="1"
data-testid={`${step.id}-icon`}
/>
<StepNodeContent step={step} indices={indices} />
<TargetEndpoint
pos="absolute"
left="-32px"
top="19px"
stepId={step.id}
/>
{isConnectable && hasDefaultConnector(step) && (
<SourceEndpoint
source={{
blockId: step.blockId,
stepId: step.id,
}}
pos="absolute"
right="-34px"
bottom="10px"
/>
)}
</HStack>
</Flex>
</PopoverTrigger>
{hasSettingsPopover(step) && (
<>
<SettingsPopoverContent
step={step}
onExpandClick={handleExpandClick}
onStepChange={handleStepUpdate}
/>
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<StepSettings step={step} onStepChange={handleStepUpdate} />
</SettingsModal>
</>
)}
{isMediaBubbleStep(step) && (
<MediaBubblePopoverContent
step={step}
onContentChange={handleContentChange}
/>
)}
</Popover>
)}
</ContextMenu>
)
}
const hasSettingsPopover = (
step: Step
): step is StepWithOptions | ConditionStep => !isBubbleStep(step)
const isMediaBubbleStep = (
step: Step
): step is Exclude<BubbleStep, TextBubbleStep> =>
isBubbleStep(step) && !isTextBubbleStep(step)

View File

@ -1,152 +0,0 @@
import { Text } from '@chakra-ui/react'
import {
Step,
StartStep,
BubbleStepType,
InputStepType,
LogicStepType,
IntegrationStepType,
StepIndices,
} from 'models'
import { isChoiceInput, isInputStep } from 'utils'
import { ItemNodesList } from '../../ItemNode'
import {
EmbedBubbleContent,
SetVariableContent,
TextBubbleContent,
VideoBubbleContent,
WebhookContent,
WithVariableContent,
} from './contents'
import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PaymentInputContent } from './contents/PaymentInputContent'
import { PlaceholderContent } from './contents/PlaceholderContent'
import { RatingInputContent } from './contents/RatingInputContent'
import { SendEmailContent } from './contents/SendEmailContent'
import { TypebotLinkContent } from './contents/TypebotLinkContent'
import { ProviderWebhookContent } from './contents/ZapierContent'
type Props = {
step: Step | StartStep
indices: StepIndices
}
export const StepNodeContent = ({ step, indices }: Props): JSX.Element => {
if (isInputStep(step) && !isChoiceInput(step) && step.options.variableId) {
return <WithVariableContent step={step} />
}
switch (step.type) {
case BubbleStepType.TEXT: {
return <TextBubbleContent step={step} />
}
case BubbleStepType.IMAGE: {
return <ImageBubbleContent step={step} />
}
case BubbleStepType.VIDEO: {
return <VideoBubbleContent step={step} />
}
case BubbleStepType.EMBED: {
return <EmbedBubbleContent step={step} />
}
case InputStepType.TEXT: {
return (
<PlaceholderContent
placeholder={step.options.labels.placeholder}
isLong={step.options.isLong}
/>
)
}
case InputStepType.NUMBER:
case InputStepType.EMAIL:
case InputStepType.URL:
case InputStepType.PHONE: {
return (
<PlaceholderContent placeholder={step.options.labels.placeholder} />
)
}
case InputStepType.DATE: {
return <Text color={'gray.500'}>Pick a date...</Text>
}
case InputStepType.CHOICE: {
return <ItemNodesList step={step} indices={indices} />
}
case InputStepType.PAYMENT: {
return <PaymentInputContent step={step} />
}
case InputStepType.RATING: {
return <RatingInputContent step={step} />
}
case LogicStepType.SET_VARIABLE: {
return <SetVariableContent step={step} />
}
case LogicStepType.CONDITION: {
return <ItemNodesList step={step} indices={indices} isReadOnly />
}
case LogicStepType.REDIRECT: {
return (
<ConfigureContent
label={
step.options?.url ? `Redirect to ${step.options?.url}` : undefined
}
/>
)
}
case LogicStepType.CODE: {
return (
<ConfigureContent
label={
step.options?.content ? `Run ${step.options?.name}` : undefined
}
/>
)
}
case LogicStepType.TYPEBOT_LINK:
return <TypebotLinkContent step={step} />
case IntegrationStepType.GOOGLE_SHEETS: {
return (
<ConfigureContent
label={
step.options && 'action' in step.options
? step.options.action
: undefined
}
/>
)
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
return (
<ConfigureContent
label={
step.options?.action
? `Track "${step.options?.action}" `
: undefined
}
/>
)
}
case IntegrationStepType.WEBHOOK: {
return <WebhookContent step={step} />
}
case IntegrationStepType.ZAPIER: {
return (
<ProviderWebhookContent step={step} configuredLabel="Trigger zap" />
)
}
case IntegrationStepType.PABBLY_CONNECT:
case IntegrationStepType.MAKE_COM: {
return (
<ProviderWebhookContent
step={step}
configuredLabel="Trigger scenario"
/>
)
}
case IntegrationStepType.EMAIL: {
return <SendEmailContent step={step} />
}
case 'start': {
return <Text>Start</Text>
}
}
}

View File

@ -1,21 +0,0 @@
import { Box, Text, Image } from '@chakra-ui/react'
import { ImageBubbleStep } from 'models'
export const ImageBubbleContent = ({ step }: { step: ImageBubbleStep }) => {
const containsVariables =
step.content?.url?.includes('{{') && step.content.url.includes('}}')
return !step.content?.url ? (
<Text color={'gray.500'}>Click to edit...</Text>
) : (
<Box w="full">
<Image
src={
containsVariables ? '/images/dynamic-image.png' : step.content?.url
}
alt="Block image"
rounded="md"
objectFit="cover"
/>
</Box>
)
}

View File

@ -1,20 +0,0 @@
import { Text } from '@chakra-ui/react'
import { PaymentInputStep } from 'models'
type Props = {
step: PaymentInputStep
}
export const PaymentInputContent = ({ step }: Props) => {
if (
!step.options.amount ||
!step.options.credentialsId ||
!step.options.currency
)
return <Text color="gray.500">Configure...</Text>
return (
<Text noOfLines={0} pr="6">
Collect {step.options.amount} {step.options.currency}
</Text>
)
}

View File

@ -1,12 +0,0 @@
import { Text } from '@chakra-ui/react'
import { RatingInputStep } from 'models'
type Props = {
step: RatingInputStep
}
export const RatingInputContent = ({ step }: Props) => (
<Text noOfLines={0} pr="6">
Rate from 1 to {step.options.length}
</Text>
)

View File

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

View File

@ -1,27 +0,0 @@
import { StackProps, HStack } from '@chakra-ui/react'
import { StartStep, Step, StepIndices } from 'models'
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
export const StepNodeOverlay = ({
step,
indices,
...props
}: { step: Step | StartStep; indices: StepIndices } & StackProps) => {
return (
<HStack
p="3"
borderWidth="1px"
rounded="lg"
bgColor="white"
cursor={'grab'}
w="264px"
pointerEvents="none"
shadow="lg"
{...props}
>
<StepIcon type={step.type} />
<StepNodeContent step={step} indices={indices} />
</HStack>
)
}

View File

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