♻️ Fix folder case issue

This commit is contained in:
Baptiste Arnaud
2023-03-15 12:50:08 +01:00
parent 3d3670d740
commit 3a6c096461
36 changed files with 15 additions and 15 deletions

View File

@@ -0,0 +1,21 @@
import { Flex, useColorModeValue } from '@chakra-ui/react'
import React from 'react'
type Props = {
isVisible: boolean
isExpanded: boolean
onRef: (ref: HTMLDivElement) => void
}
export const PlaceholderNode = ({ isVisible, isExpanded, onRef }: Props) => {
return (
<Flex
ref={onRef}
h={isExpanded ? '50px' : '2px'}
bgColor={useColorModeValue('gray.300', 'gray.700')}
visibility={isVisible ? 'visible' : 'hidden'}
rounded="lg"
transition={isVisible ? 'height 200ms' : 'none'}
/>
)
}

View File

@@ -0,0 +1,293 @@
import {
Flex,
HStack,
Popover,
PopoverTrigger,
useColorModeValue,
useDisclosure,
} from '@chakra-ui/react'
import React, { useEffect, useRef, useState } from 'react'
import {
BubbleBlock,
BubbleBlockContent,
DraggableBlock,
Block,
BlockWithOptions,
TextBubbleContent,
TextBubbleBlock,
LogicBlockType,
} from '@typebot.io/schemas'
import { isBubbleBlock, isDefined, isTextBubbleBlock } from '@typebot.io/lib'
import { BlockNodeContent } from './BlockNodeContent'
import { BlockSettings, SettingsPopoverContent } from './SettingsPopoverContent'
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
import { SourceEndpoint } from '../../endpoints/SourceEndpoint'
import { useRouter } from 'next/router'
import { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
import { ContextMenu } from '@/components/ContextMenu'
import { TextBubbleEditor } from '@/features/blocks/bubbles/textBubble/components/TextBubbleEditor'
import { BlockIcon } from '@/features/editor/components/BlockIcon'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import {
NodePosition,
useBlockDnd,
useDragDistance,
} from '@/features/graph/providers/GraphDndProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { ParentModalProvider } from '@/features/graph/providers/ParentModalProvider'
import { hasDefaultConnector } from '@/features/typebot/helpers/hasDefaultConnector'
import { setMultipleRefs } from '@/helpers/setMultipleRefs'
import { TargetEndpoint } from '../../endpoints/TargetEndpoint'
import { SettingsModal } from './SettingsModal'
export const BlockNode = ({
block,
isConnectable,
indices,
onMouseDown,
}: {
block: Block
isConnectable: boolean
indices: { blockIndex: number; groupIndex: number }
onMouseDown?: (blockNodePosition: NodePosition, block: DraggableBlock) => void
}) => {
const bg = useColorModeValue('gray.50', 'gray.850')
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
const borderColor = useColorModeValue('gray.200', 'gray.800')
const { query } = useRouter()
const {
setConnectingIds,
connectingIds,
openedBlockId,
setOpenedBlockId,
setFocusedGroupId,
previewingEdge,
isReadOnly,
previewingBlock,
} = useGraph()
const { mouseOverBlock, setMouseOverBlock } = useBlockDnd()
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 ||
previewingBlock?.id === 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)
}, [block.id, query, setOpenedBlockId])
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 (isReadOnly) return
if (mouseOverBlock?.id !== block.id && blockRef.current)
setMouseOverBlock({ id: block.id, element: blockRef.current })
if (connectingIds)
setConnectingIds({
...connectingIds,
target: { groupId: block.groupId, blockId: block.id },
})
}
const handleMouseLeave = () => {
if (mouseOverBlock) setMouseOverBlock(undefined)
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)
}, [block.id, openedBlockId])
useEffect(() => {
if (!blockRef.current) return
const blockElement = blockRef.current
blockElement.addEventListener('pointerdown', (e) => e.stopPropagation())
return () => {
blockElement.removeEventListener('pointerdown', (e) =>
e.stopPropagation()
)
}
}, [])
const hasIcomingEdge = typebot?.edges.some((edge) => {
return edge.to.blockId === block.id
})
return isEditing && isTextBubbleBlock(block) ? (
<TextBubbleEditor
id={block.id}
initialValue={block.content.richText}
onClose={handleCloseEditor}
/>
) : (
<ContextMenu<HTMLDivElement>
renderMenu={() => <BlockNodeContextMenu indices={indices} />}
>
{(ref, isContextMenuOpened) => (
<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={
isContextMenuOpened || isPreviewing ? '2px' : '1px'
}
borderColor={
isContextMenuOpened || isPreviewing
? previewingBorderColor
: borderColor
}
margin={isContextMenuOpened || isPreviewing ? '-1px' : 0}
rounded="lg"
cursor={'pointer'}
bg={bg}
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} />
{(hasIcomingEdge || isDefined(connectingIds)) && (
<TargetEndpoint
pos="absolute"
left="-34px"
top="16px"
blockId={block.id}
groupId={block.groupId}
/>
)}
{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}
/>
<ParentModalProvider>
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<BlockSettings
block={block}
onBlockChange={handleBlockUpdate}
/>
</SettingsModal>
</ParentModalProvider>
</>
)}
{typebot && isMediaBubbleBlock(block) && (
<MediaBubblePopoverContent
typebotId={typebot.id}
block={block}
onContentChange={handleContentChange}
/>
)}
</Popover>
)}
</ContextMenu>
)
}
const hasSettingsPopover = (block: Block): block is BlockWithOptions =>
!isBubbleBlock(block) && block.type !== LogicBlockType.CONDITION
const isMediaBubbleBlock = (
block: Block
): block is Exclude<BubbleBlock, TextBubbleBlock> =>
isBubbleBlock(block) && !isTextBubbleBlock(block)

View File

@@ -0,0 +1,201 @@
import { Text } from '@chakra-ui/react'
import {
Block,
StartBlock,
BubbleBlockType,
InputBlockType,
LogicBlockType,
IntegrationBlockType,
BlockIndices,
} from '@typebot.io/schemas'
import { WaitNodeContent } from '@/features/blocks/logic/wait/components/WaitNodeContent'
import { ScriptNodeContent } from '@/features/blocks/logic/script/components/ScriptNodeContent'
import { ButtonsBlockNode } from '@/features/blocks/inputs/buttons/components/ButtonsBlockNode'
import { JumpNodeBody } from '@/features/blocks/logic/jump/components/JumpNodeBody'
import { OpenAINodeBody } from '@/features/blocks/integrations/openai/components/OpenAINodeBody'
import { AudioBubbleNode } from '@/features/blocks/bubbles/audio/components/AudioBubbleNode'
import { EmbedBubbleContent } from '@/features/blocks/bubbles/embed/components/EmbedBubbleContent'
import { ImageBubbleContent } from '@/features/blocks/bubbles/image/components/ImageBubbleContent'
import { TextBubbleContent } from '@/features/blocks/bubbles/textBubble/components/TextBubbleContent'
import { VideoBubbleContent } from '@/features/blocks/bubbles/video/components/VideoBubbleContent'
import { DateNodeContent } from '@/features/blocks/inputs/date/components/DateNodeContent'
import { EmailInputNodeContent } from '@/features/blocks/inputs/emailInput/components/EmailInputNodeContent'
import { FileInputContent } from '@/features/blocks/inputs/fileUpload/components/FileInputContent'
import { NumberNodeContent } from '@/features/blocks/inputs/number/components/NumberNodeContent'
import { PaymentInputContent } from '@/features/blocks/inputs/payment/components/PaymentInputContent'
import { PhoneNodeContent } from '@/features/blocks/inputs/phone/components/PhoneNodeContent'
import { RatingInputContent } from '@/features/blocks/inputs/rating/components/RatingInputContent'
import { TextInputNodeContent } from '@/features/blocks/inputs/textInput/components/TextInputNodeContent'
import { UrlNodeContent } from '@/features/blocks/inputs/url/components/UrlNodeContent'
import { GoogleSheetsNodeContent } from '@/features/blocks/integrations/googleSheets/components/GoogleSheetsNodeContent'
import { MakeComContent } from '@/features/blocks/integrations/makeCom/components/MakeComContent'
import { PabblyConnectContent } from '@/features/blocks/integrations/pabbly/components/PabblyConnectContent'
import { SendEmailContent } from '@/features/blocks/integrations/sendEmail/components/SendEmailContent'
import { WebhookContent } from '@/features/blocks/integrations/webhook/components/WebhookContent'
import { ZapierContent } from '@/features/blocks/integrations/zapier/components/ZapierContent'
import { RedirectNodeContent } from '@/features/blocks/logic/redirect/components/RedirectNodeContent'
import { SetVariableContent } from '@/features/blocks/logic/setVariable/components/SetVariableContent'
import { TypebotLinkNode } from '@/features/blocks/logic/typebotLink/components/TypebotLinkNode'
import { ItemNodesList } from '../item/ItemNodesList'
import { GoogleAnalyticsNodeBody } from '@/features/blocks/integrations/googleAnalytics/components/GoogleAnalyticsNodeBody'
import { ChatwootNodeBody } from '@/features/blocks/integrations/chatwoot/components/ChatwootNodeBody'
type Props = {
block: Block | StartBlock
indices: BlockIndices
}
export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
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 BubbleBlockType.AUDIO: {
return <AudioBubbleNode url={block.content.url} />
}
case InputBlockType.TEXT: {
return (
<TextInputNodeContent
variableId={block.options.variableId}
placeholder={block.options.labels.placeholder}
isLong={block.options.isLong}
/>
)
}
case InputBlockType.NUMBER: {
return (
<NumberNodeContent
placeholder={block.options.labels.placeholder}
variableId={block.options.variableId}
/>
)
}
case InputBlockType.EMAIL: {
return (
<EmailInputNodeContent
placeholder={block.options.labels.placeholder}
variableId={block.options.variableId}
/>
)
}
case InputBlockType.URL: {
return (
<UrlNodeContent
placeholder={block.options.labels.placeholder}
variableId={block.options.variableId}
/>
)
}
case InputBlockType.CHOICE: {
return <ButtonsBlockNode block={block} indices={indices} />
}
case InputBlockType.PHONE: {
return (
<PhoneNodeContent
placeholder={block.options.labels.placeholder}
variableId={block.options.variableId}
/>
)
}
case InputBlockType.DATE: {
return <DateNodeContent variableId={block.options.variableId} />
}
case InputBlockType.PAYMENT: {
return <PaymentInputContent block={block} />
}
case InputBlockType.RATING: {
return (
<RatingInputContent
block={block}
variableId={block.options.variableId}
/>
)
}
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.SCRIPT: {
return (
<ScriptNodeContent
name={block.options.name}
content={block.options.content}
/>
)
}
case LogicBlockType.WAIT: {
return <WaitNodeContent options={block.options} />
}
case LogicBlockType.JUMP: {
return <JumpNodeBody options={block.options} />
}
case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkNode block={block} />
case LogicBlockType.CONDITION:
return <ItemNodesList block={block} indices={indices} />
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<GoogleSheetsNodeContent
action={'action' in block.options ? block.options.action : undefined}
/>
)
}
case IntegrationBlockType.GOOGLE_ANALYTICS: {
return (
<GoogleAnalyticsNodeBody
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 <PabblyConnectContent block={block} />
}
case IntegrationBlockType.MAKE_COM: {
return <MakeComContent block={block} />
}
case IntegrationBlockType.EMAIL: {
return <SendEmailContent block={block} />
}
case IntegrationBlockType.CHATWOOT: {
return <ChatwootNodeBody block={block} />
}
case IntegrationBlockType.OPEN_AI: {
return (
<OpenAINodeBody
task={block.options.task}
responseMapping={
'responseMapping' in block.options
? block.options.responseMapping
: []
}
/>
)
}
case 'start': {
return <Text>Start</Text>
}
}
}

View File

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

View File

@@ -0,0 +1,28 @@
import { BlockIcon } from '@/features/editor/components/BlockIcon'
import { StackProps, HStack, useColorModeValue } from '@chakra-ui/react'
import { StartBlock, Block, BlockIndices } from '@typebot.io/schemas'
import { BlockNodeContent } from './BlockNodeContent'
export const BlockNodeOverlay = ({
block,
indices,
...props
}: { block: Block | StartBlock; indices: BlockIndices } & StackProps) => {
return (
<HStack
p="3"
borderWidth="1px"
rounded="lg"
borderColor={useColorModeValue('gray.200', 'gray.800')}
bgColor={useColorModeValue('gray.50', 'gray.850')}
cursor={'grab'}
w="264px"
pointerEvents="none"
shadow="lg"
{...props}
>
<BlockIcon type={block.type} />
<BlockNodeContent block={block} indices={indices} />
</HStack>
)
}

View File

@@ -0,0 +1,168 @@
import { useEventListener, Stack, Portal } from '@chakra-ui/react'
import { DraggableBlock, DraggableBlockType, Block } from '@typebot.io/schemas'
import { useEffect, useRef, useState } from 'react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { BlockNode } from './BlockNode'
import { BlockNodeOverlay } from './BlockNodeOverlay'
import { PlaceholderNode } from '../PlaceholderNode'
import { isDefined } from '@typebot.io/lib'
import {
useBlockDnd,
computeNearestPlaceholderIndex,
} from '@/features/graph/providers/GraphDndProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { Coordinates } from '@dnd-kit/utilities'
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 && isDefined(draggedBlock || draggedBlockType)
useEffect(() => {
if (mouseOverGroup?.id !== groupId) setExpandedPlaceholderIndex(undefined)
}, [groupId, 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) =>
(
{ relative, absolute }: { absolute: Coordinates; relative: Coordinates },
block: DraggableBlock
) => {
if (isReadOnly) return
placeholderRefs.current.splice(blockIndex + 1, 1)
setMousePositionInElement(relative)
setPosition({
x: absolute.x - relative.x,
y: absolute.y - relative.y,
})
setDraggedBlock(block)
detachBlockFromGroup({ groupIndex, blockIndex })
}
const handlePushElementRef =
(idx: number) => (elem: HTMLDivElement | null) => {
elem && (placeholderRefs.current[idx] = elem)
}
useEventListener('mousemove', handleMouseMoveGlobal)
useEventListener('mousemove', handleMouseMoveOnGroup, groupRef.current)
useEventListener('mouseup', handleMouseUpOnGroup, mouseOverGroup?.element, {
capture: true,
})
return (
<Stack
spacing={1}
transition="none"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
>
<PlaceholderNode
isVisible={showSortPlaceholders}
isExpanded={expandedPlaceholderIndex === 0}
onRef={handlePushElementRef(0)}
/>
{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)}
/>
<PlaceholderNode
isVisible={showSortPlaceholders}
isExpanded={expandedPlaceholderIndex === idx + 1}
onRef={handlePushElementRef(idx + 1)}
/>
</Stack>
))}
{draggedBlock && draggedBlock.groupId === groupId && (
<Portal>
<BlockNodeOverlay
block={draggedBlock}
indices={{ groupIndex, blockIndex: 0 }}
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg) scale(${graphPosition.scale})`,
}}
transformOrigin="0 0 0"
/>
</Portal>
)}
</Stack>
)
}

View File

@@ -0,0 +1,82 @@
import { BuoyIcon } from '@/components/icons'
import { Button, Link } from '@chakra-ui/react'
import {
BlockWithOptions,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
} from '@typebot.io/schemas'
import React from 'react'
type HelpDocButtonProps = {
blockType: BlockWithOptions['type']
}
export const HelpDocButton = ({ blockType }: HelpDocButtonProps) => {
const helpDocUrl = getHelpDocUrl(blockType)
if (helpDocUrl === null) return null
return (
<Button
as={Link}
leftIcon={<BuoyIcon />}
size="xs"
href={helpDocUrl}
isExternal
>
Help
</Button>
)
}
const getHelpDocUrl = (blockType: BlockWithOptions['type']): string | null => {
switch (blockType) {
case LogicBlockType.TYPEBOT_LINK:
return 'https://docs.typebot.io/editor/blocks/logic/typebot-link'
case LogicBlockType.SET_VARIABLE:
return 'https://docs.typebot.io/editor/blocks/logic/set-variable'
case LogicBlockType.REDIRECT:
return 'https://docs.typebot.io/editor/blocks/logic/redirect'
case LogicBlockType.SCRIPT:
return 'https://docs.typebot.io/editor/blocks/logic/script'
case LogicBlockType.WAIT:
return 'https://docs.typebot.io/editor/blocks/logic/wait'
case InputBlockType.TEXT:
return 'https://docs.typebot.io/editor/blocks/inputs/text'
case InputBlockType.NUMBER:
return 'https://docs.typebot.io/editor/blocks/inputs/number'
case InputBlockType.EMAIL:
return 'https://docs.typebot.io/editor/blocks/inputs/email'
case InputBlockType.URL:
return 'https://docs.typebot.io/editor/blocks/inputs/website'
case InputBlockType.DATE:
return 'https://docs.typebot.io/editor/blocks/inputs/date'
case InputBlockType.PHONE:
return 'https://docs.typebot.io/editor/blocks/inputs/phone-number'
case InputBlockType.CHOICE:
return 'https://docs.typebot.io/editor/blocks/inputs/buttons'
case InputBlockType.PAYMENT:
return 'https://docs.typebot.io/editor/blocks/inputs/payment'
case InputBlockType.RATING:
return 'https://docs.typebot.io/editor/blocks/inputs/rating'
case InputBlockType.FILE:
return 'https://docs.typebot.io/editor/blocks/inputs/file-upload'
case IntegrationBlockType.EMAIL:
return 'https://docs.typebot.io/editor/blocks/integrations/email'
case IntegrationBlockType.CHATWOOT:
return 'https://docs.typebot.io/editor/blocks/integrations/chatwoot'
case IntegrationBlockType.GOOGLE_ANALYTICS:
return 'https://docs.typebot.io/editor/blocks/integrations/ga'
case IntegrationBlockType.GOOGLE_SHEETS:
return 'https://docs.typebot.io/editor/blocks/integrations/google-sheets'
case IntegrationBlockType.ZAPIER:
return 'https://docs.typebot.io/editor/blocks/integrations/zapier'
case IntegrationBlockType.PABBLY_CONNECT:
return 'https://docs.typebot.io/editor/blocks/integrations/pabbly'
case IntegrationBlockType.WEBHOOK:
return 'https://docs.typebot.io/editor/blocks/integrations/webhook'
default:
return null
}
}

View File

@@ -0,0 +1,87 @@
import { ImageUploadContent } from '@/components/ImageUploadContent'
import { AudioBubbleForm } from '@/features/blocks/bubbles/audio/components/AudioBubbleForm'
import { EmbedUploadContent } from '@/features/blocks/bubbles/embed/components/EmbedUploadContent'
import { VideoUploadContent } from '@/features/blocks/bubbles/video/components/VideoUploadContent'
import {
Portal,
PopoverContent,
PopoverArrow,
PopoverBody,
} from '@chakra-ui/react'
import {
BubbleBlock,
BubbleBlockContent,
BubbleBlockType,
TextBubbleBlock,
} from '@typebot.io/schemas'
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}
/>
)
}
case BubbleBlockType.AUDIO: {
return (
<AudioBubbleForm
content={block.content}
fileUploadPath={`typebots/${typebotId}/blocks/${block.id}`}
onSubmit={onContentChange}
/>
)
}
}
}

View File

@@ -0,0 +1,40 @@
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
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 { ref } = useParentModal()
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
return (
<Modal isOpen={isOpen} onClose={onClose} size="3xl">
<ModalOverlay />
<ModalContent onMouseDown={handleMouseDown} ref={ref}>
<ModalHeader mb="2">
<ModalCloseButton />
</ModalHeader>
<ModalBody {...props}>{props.children}</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@@ -0,0 +1,288 @@
import {
PopoverContent,
PopoverArrow,
PopoverBody,
useEventListener,
Portal,
IconButton,
HStack,
Stack,
useColorModeValue,
} from '@chakra-ui/react'
import { ExpandIcon } from '@/components/icons'
import {
InputBlockType,
IntegrationBlockType,
LogicBlockType,
Block,
BlockOptions,
BlockWithOptions,
} from '@typebot.io/schemas'
import { useRef } from 'react'
import { HelpDocButton } from './HelpDocButton'
import { WaitSettings } from '@/features/blocks/logic/wait/components/WaitSettings'
import { ScriptSettings } from '@/features/blocks/logic/script/components/ScriptSettings'
import { JumpSettings } from '@/features/blocks/logic/jump/components/JumpSettings'
import { MakeComSettings } from '@/features/blocks/integrations/makeCom/components/MakeComSettings'
import { PabblyConnectSettings } from '@/features/blocks/integrations/pabbly/components/PabblyConnectSettings'
import { OpenAISettings } from '@/features/blocks/integrations/openai/components/OpenAISettings'
import { ButtonsBlockSettings } from '@/features/blocks/inputs/buttons/components/ButtonsBlockSettings'
import { FileInputSettings } from '@/features/blocks/inputs/fileUpload/components/FileInputSettings'
import { PaymentSettings } from '@/features/blocks/inputs/payment/components/PaymentSettings'
import { RatingInputSettings } from '@/features/blocks/inputs/rating/components/RatingInputSettings'
import { TextInputSettings } from '@/features/blocks/inputs/textInput/components/TextInputSettings'
import { GoogleAnalyticsSettings } from '@/features/blocks/integrations/googleAnalytics/components/GoogleAnalyticsSettings'
import { SendEmailSettings } from '@/features/blocks/integrations/sendEmail/components/SendEmailSettings'
import { WebhookSettings } from '@/features/blocks/integrations/webhook/components/WebhookSettings'
import { ZapierSettings } from '@/features/blocks/integrations/zapier/components/ZapierSettings'
import { RedirectSettings } from '@/features/blocks/logic/redirect/components/RedirectSettings'
import { SetVariableSettings } from '@/features/blocks/logic/setVariable/components/SetVariableSettings'
import { TypebotLinkForm } from '@/features/blocks/logic/typebotLink/components/TypebotLinkForm'
import { NumberInputSettings } from '@/features/blocks/inputs/number/components/NumberInputSettings'
import { EmailInputSettings } from '@/features/blocks/inputs/emailInput/components/EmailInputSettings'
import { UrlInputSettings } from '@/features/blocks/inputs/url/components/UrlInputSettings'
import { DateInputSettings } from '@/features/blocks/inputs/date/components/DateInputSettings'
import { PhoneInputSettings } from '@/features/blocks/inputs/phone/components/PhoneInputSettings'
import { GoogleSheetsSettings } from '@/features/blocks/integrations/googleSheets/components/GoogleSheetsSettings'
import { ChatwootSettings } from '@/features/blocks/integrations/chatwoot/components/ChatwootSettings'
type Props = {
block: BlockWithOptions
onExpandClick: () => void
onBlockChange: (updates: Partial<Block>) => void
}
export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
const arrowColor = useColorModeValue('white', 'gray.800')
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 bgColor={arrowColor} />
<PopoverBody
pt="3"
pb="6"
overflowY="scroll"
maxH="400px"
ref={ref}
shadow="lg"
>
<Stack spacing={3}>
<HStack justifyContent="flex-end">
<HelpDocButton blockType={props.block.type} />
<IconButton
aria-label="expand"
icon={<ExpandIcon />}
size="xs"
onClick={onExpandClick}
/>
</HStack>
<BlockSettings {...props} />
</Stack>
</PopoverBody>
</PopoverContent>
</Portal>
)
}
export const BlockSettings = ({
block,
onBlockChange,
}: {
block: BlockWithOptions
onBlockChange: (block: Partial<Block>) => void
}): JSX.Element => {
const updateOptions = (options: BlockOptions) => {
onBlockChange({ options } as Partial<Block>)
}
switch (block.type) {
case InputBlockType.TEXT: {
return (
<TextInputSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.NUMBER: {
return (
<NumberInputSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.EMAIL: {
return (
<EmailInputSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.URL: {
return (
<UrlInputSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.DATE: {
return (
<DateInputSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.PHONE: {
return (
<PhoneInputSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.CHOICE: {
return (
<ButtonsBlockSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.PAYMENT: {
return (
<PaymentSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.RATING: {
return (
<RatingInputSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case InputBlockType.FILE: {
return (
<FileInputSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case LogicBlockType.SET_VARIABLE: {
return (
<SetVariableSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case LogicBlockType.REDIRECT: {
return (
<RedirectSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case LogicBlockType.SCRIPT: {
return (
<ScriptSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case LogicBlockType.TYPEBOT_LINK: {
return (
<TypebotLinkForm
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case LogicBlockType.WAIT: {
return (
<WaitSettings options={block.options} onOptionsChange={updateOptions} />
)
}
case LogicBlockType.JUMP: {
return (
<JumpSettings
groupId={block.groupId}
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case IntegrationBlockType.GOOGLE_SHEETS: {
return (
<GoogleSheetsSettings
options={block.options}
onOptionsChange={updateOptions}
blockId={block.id}
/>
)
}
case IntegrationBlockType.GOOGLE_ANALYTICS: {
return (
<GoogleAnalyticsSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case IntegrationBlockType.ZAPIER: {
return <ZapierSettings block={block} onOptionsChange={updateOptions} />
}
case IntegrationBlockType.MAKE_COM: {
return <MakeComSettings block={block} onOptionsChange={updateOptions} />
}
case IntegrationBlockType.PABBLY_CONNECT: {
return (
<PabblyConnectSettings block={block} onOptionsChange={updateOptions} />
)
}
case IntegrationBlockType.WEBHOOK: {
return <WebhookSettings block={block} onOptionsChange={updateOptions} />
}
case IntegrationBlockType.EMAIL: {
return (
<SendEmailSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case IntegrationBlockType.CHATWOOT: {
return (
<ChatwootSettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
case IntegrationBlockType.OPEN_AI: {
return (
<OpenAISettings
options={block.options}
onOptionsChange={updateOptions}
/>
)
}
}
}

View File

@@ -0,0 +1,28 @@
import { chakra, Text, TextProps } from '@chakra-ui/react'
import React from 'react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { byId } from '@typebot.io/lib'
type Props = {
variableId: string
} & TextProps
export const WithVariableContent = ({ variableId, ...props }: Props) => {
const { typebot } = useTypebot()
const variableName = typebot?.variables.find(byId(variableId))?.name
return (
<Text w="calc(100% - 25px)" {...props}>
Collect{' '}
<chakra.span
bgColor="orange.400"
color="white"
rounded="md"
py="0.5"
px="1"
>
{variableName}
</chakra.span>
</Text>
)
}

View File

@@ -0,0 +1,55 @@
import { CopyIcon, PlayIcon, TrashIcon } from '@/components/icons'
import { HStack, IconButton, useColorModeValue } from '@chakra-ui/react'
type Props = {
onPlayClick: () => void
onDuplicateClick: () => void
onDeleteClick: () => void
}
export const GroupFocusToolbar = ({
onPlayClick,
onDuplicateClick,
onDeleteClick,
}: Props) => {
return (
<HStack
rounded="md"
spacing={0}
borderWidth="1px"
bgColor={useColorModeValue('white', 'gray.800')}
shadow="md"
>
<IconButton
icon={<PlayIcon />}
borderRightWidth="1px"
borderRightRadius="none"
aria-label={'Preview bot from this group'}
variant="ghost"
onClick={onPlayClick}
size="sm"
/>
<IconButton
icon={<CopyIcon />}
borderRightWidth="1px"
borderRightRadius="none"
borderLeftRadius="none"
aria-label={'Duplicate group'}
variant="ghost"
onClick={(e) => {
e.stopPropagation()
onDuplicateClick()
}}
size="sm"
/>
<IconButton
aria-label="Delete"
borderLeftRadius="none"
icon={<TrashIcon />}
onClick={onDeleteClick}
variant="ghost"
size="sm"
/>
</HStack>
)
}

View File

@@ -0,0 +1,280 @@
import {
Editable,
EditableInput,
EditablePreview,
SlideFade,
Stack,
useColorModeValue,
} from '@chakra-ui/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Group } from '@typebot.io/schemas'
import { BlockNodesList } from '../block/BlockNodesList'
import { isDefined, isEmpty, isNotDefined } from '@typebot.io/lib'
import { GroupNodeContextMenu } from './GroupNodeContextMenu'
import { useDebounce } from 'use-debounce'
import { ContextMenu } from '@/components/ContextMenu'
import { useDrag } from '@use-gesture/react'
import { GroupFocusToolbar } from './GroupFocusToolbar'
import { useOutsideClick } from '@/hooks/useOutsideClick'
import {
RightPanel,
useEditor,
} from '@/features/editor/providers/EditorProvider'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { useBlockDnd } from '@/features/graph/providers/GraphDndProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { useGroupsCoordinates } from '@/features/graph/providers/GroupsCoordinateProvider'
import { setMultipleRefs } from '@/helpers/setMultipleRefs'
import { Coordinates } from '@/features/graph/types'
type Props = {
group: Group
groupIndex: number
}
export const GroupNode = ({ group, groupIndex }: Props) => {
const { updateGroupCoordinates } = useGroupsCoordinates()
const handleGroupDrag = useCallback(
(newCoord: Coordinates) => {
updateGroupCoordinates(group.id, newCoord)
},
[group.id, updateGroupCoordinates]
)
return (
<DraggableGroupNode
group={group}
groupIndex={groupIndex}
onGroupDrag={handleGroupDrag}
/>
)
}
const NonMemoizedDraggableGroupNode = ({
group,
groupIndex,
onGroupDrag,
}: Props & { onGroupDrag: (newCoord: Coordinates) => void }) => {
const bg = useColorModeValue('white', 'gray.900')
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
const borderColor = useColorModeValue('white', 'gray.800')
const editableHoverBg = useColorModeValue('gray.100', 'gray.700')
const {
connectingIds,
setConnectingIds,
previewingEdge,
previewingBlock,
isReadOnly,
graphPosition,
} = useGraph()
const { typebot, updateGroup, deleteGroup, duplicateGroup } = 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)
const isPreviewing =
previewingBlock?.groupId === group.id ||
previewingEdge?.from.groupId === group.id ||
(previewingEdge?.to.groupId === group.id &&
isNotDefined(previewingEdge.to.blockId))
const isStartGroup =
isDefined(group.blocks[0]) && group.blocks[0].type === 'start'
const groupRef = useRef<HTMLDivElement | null>(null)
const [debouncedGroupPosition] = useDebounce(currentCoordinates, 100)
const [isFocused, setIsFocused] = useState(false)
useOutsideClick({
handler: () => setIsFocused(false),
ref: groupRef,
capture: true,
})
// When the group is moved from external action (e.g. undo/redo), update the current coordinates
useEffect(() => {
setCurrentCoordinates({
x: group.graphCoordinates.x,
y: group.graphCoordinates.y,
})
}, [group.graphCoordinates.x, group.graphCoordinates.y])
// Same for group title
useEffect(() => {
setGroupTitle(group.title)
}, [group.title])
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) =>
updateGroup(groupIndex, { title })
const handleMouseEnter = () => {
if (isReadOnly) return
if (mouseOverGroup?.id !== group.id && !isStartGroup && groupRef.current)
setMouseOverGroup({ id: group.id, element: groupRef.current })
if (connectingIds)
setConnectingIds({ ...connectingIds, target: { groupId: group.id } })
}
const handleMouseLeave = () => {
if (isReadOnly) return
setMouseOverGroup(undefined)
if (connectingIds) setConnectingIds({ ...connectingIds, target: undefined })
}
const startPreviewAtThisGroup = () => {
setStartPreviewAtGroup(group.id)
setRightPanel(RightPanel.PREVIEW)
}
useDrag(
({ first, last, offset: [offsetX, offsetY], event, target }) => {
event.stopPropagation()
if ((target as HTMLElement).classList.contains('prevent-group-drag'))
return
if (first) {
setIsFocused(true)
setIsMouseDown(true)
}
if (last) {
setIsMouseDown(false)
}
const newCoord = {
x: offsetX / graphPosition.scale,
y: offsetY / graphPosition.scale,
}
setCurrentCoordinates(newCoord)
onGroupDrag(newCoord)
},
{
target: groupRef,
pointer: { keys: false },
from: () => [
currentCoordinates.x * graphPosition.scale,
currentCoordinates.y * graphPosition.scale,
],
}
)
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <GroupNodeContextMenu groupIndex={groupIndex} />}
isDisabled={isReadOnly || isStartGroup}
>
{(ref, isContextMenuOpened) => (
<Stack
ref={setMultipleRefs([ref, groupRef])}
id={`group-${group.id}`}
data-testid="group"
p="4"
rounded="xl"
bg={bg}
borderWidth="1px"
borderColor={
isConnecting || isContextMenuOpened || isPreviewing || isFocused
? previewingBorderColor
: borderColor
}
w="300px"
transition="border 300ms, box-shadow 200ms"
pos="absolute"
style={{
transform: `translate(${currentCoordinates?.x ?? 0}px, ${
currentCoordinates?.y ?? 0
}px)`,
touchAction: 'none',
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
cursor={isMouseDown ? 'grabbing' : 'pointer'}
shadow="md"
_hover={{ shadow: 'lg' }}
zIndex={isFocused ? 10 : 1}
>
<Editable
value={groupTitle}
onChange={setGroupTitle}
onSubmit={handleTitleSubmit}
fontWeight="semibold"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
pr="8"
>
<EditablePreview
_hover={{
bg: editableHoverBg,
}}
px="1"
userSelect={'none'}
style={
isEmpty(groupTitle)
? {
display: 'block',
position: 'absolute',
top: '10px',
width: '50px',
}
: undefined
}
/>
<EditableInput minW="0" px="1" className="prevent-group-drag" />
</Editable>
{typebot && (
<BlockNodesList
groupId={group.id}
blocks={group.blocks}
groupIndex={groupIndex}
groupRef={ref}
isStartGroup={isStartGroup}
/>
)}
{!isReadOnly && (
<SlideFade
in={isFocused}
style={{
position: 'absolute',
top: '-50px',
right: 0,
}}
unmountOnExit
>
<GroupFocusToolbar
onPlayClick={startPreviewAtThisGroup}
onDuplicateClick={() => {
setIsFocused(false)
duplicateGroup(groupIndex)
}}
onDeleteClick={() => deleteGroup(groupIndex)}
/>
</SlideFade>
)}
</Stack>
)}
</ContextMenu>
)
}
export const DraggableGroupNode = memo(NonMemoizedDraggableGroupNode)

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
import { Flex, useColorModeValue } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { ChoiceInputBlock, Item, ItemIndices } from '@typebot.io/schemas'
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 { isDefined } from '@typebot.io/lib'
import { Coordinates } from '@/features/graph/types'
import {
NodePosition,
useDragDistance,
} from '@/features/graph/providers/GraphDndProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { setMultipleRefs } from '@/helpers/setMultipleRefs'
type Props = {
item: Item
indices: ItemIndices
onMouseDown?: (
blockNodePosition: { absolute: Coordinates; relative: Coordinates },
item: Item
) => void
connectionDisabled?: boolean
}
export const ItemNode = ({
item,
indices,
onMouseDown,
connectionDisabled,
}: Props) => {
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
const borderColor = useColorModeValue('gray.200', 'gray.700')
const bg = useColorModeValue('white', undefined)
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 =
isDefined(typebot) &&
!connectionDisabled &&
!(
typebot.groups[indices.groupIndex].blocks[indices.blockIndex] as
| ChoiceInputBlock
| undefined
)?.options?.isMultipleChoice
const onDrag = (position: NodePosition) => {
if (!onMouseDown) return
onMouseDown(position, item)
}
useDragDistance({
ref: itemRef,
onDrag,
isDisabled: !onMouseDown,
})
const handleMouseEnter = () => setIsMouseOver(true)
const handleMouseLeave = () => setIsMouseOver(false)
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <ItemNodeContextMenu indices={indices} />}
>
{(ref, isContextMenuOpened) => (
<Flex
data-testid="item"
pos="relative"
ref={setMultipleRefs([ref, itemRef])}
w="full"
>
<Flex
align="center"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
shadow="sm"
_hover={{ shadow: 'md' }}
transition="box-shadow 200ms, border-color 200ms"
rounded="md"
bg={bg}
borderWidth={isContextMenuOpened || isPreviewing ? '2px' : '1px'}
borderColor={
isContextMenuOpened || isPreviewing
? previewingBorderColor
: borderColor
}
margin={isContextMenuOpened || isPreviewing ? '-1px' : 0}
w="full"
>
<ItemNodeContent
item={item}
isMouseOver={isMouseOver}
indices={indices}
/>
{typebot && isConnectable && (
<SourceEndpoint
source={{
groupId: typebot.groups[indices.groupIndex].id,
blockId: item.blockId,
itemId: item.id,
}}
pos="absolute"
right="-49px"
pointerEvents="all"
/>
)}
</Flex>
</Flex>
)}
</ContextMenu>
)
}

View File

@@ -0,0 +1,32 @@
import { ButtonsItemNode } from '@/features/blocks/inputs/buttons/components/ButtonsItemNode'
import { ConditionItemNode } from '@/features/blocks/logic/condition/components/ConditionItemNode'
import { Item, ItemIndices, ItemType } from '@typebot.io/schemas'
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 (
<ButtonsItemNode
key={`${item.id}-${item.content}`}
item={item}
isMouseOver={isMouseOver}
indices={indices}
/>
)
case ItemType.CONDITION:
return (
<ConditionItemNode
item={item}
isMouseOver={isMouseOver}
indices={indices}
/>
)
}
}

View File

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

View File

@@ -0,0 +1,201 @@
import {
Flex,
Portal,
Stack,
Text,
useColorModeValue,
useEventListener,
} from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import {
BlockIndices,
BlockWithItems,
LogicBlockType,
Item,
} from '@typebot.io/schemas'
import React, { useEffect, useRef, useState } from 'react'
import { ItemNode } from './ItemNode'
import { PlaceholderNode } from '../PlaceholderNode'
import { isDefined } from '@typebot.io/lib'
import {
useBlockDnd,
computeNearestPlaceholderIndex,
} from '@/features/graph/providers/GraphDndProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { Coordinates } from '@dnd-kit/utilities'
import { SourceEndpoint } from '../../endpoints/SourceEndpoint'
type Props = {
block: BlockWithItems
indices: BlockIndices
}
export const ItemNodesList = ({
block,
indices: { groupIndex, blockIndex },
}: Props) => {
const { typebot, createItem, detachItemFromBlock } = useTypebot()
const { draggedItem, setDraggedItem, mouseOverBlock } = useBlockDnd()
const placeholderRefs = useRef<HTMLDivElement[]>([])
const { graphPosition } = useGraph()
const isDraggingOnCurrentBlock =
(draggedItem && mouseOverBlock?.id === block.id) ?? false
const showPlaceholders =
draggedItem !== undefined && block.items[0].type === draggedItem.type
const isLastBlock =
isDefined(typebot) &&
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 (!showPlaceholders) return
if (mouseOverBlock?.id !== block.id) {
setExpandedPlaceholderIndex(undefined)
}
}, [block.id, mouseOverBlock?.id, showPlaceholders])
const handleMouseMoveOnBlock = (event: MouseEvent) => {
if (!isDraggingOnCurrentBlock) return
const index = computeNearestPlaceholderIndex(event.pageY, placeholderRefs)
setExpandedPlaceholderIndex(index)
}
useEventListener('mousemove', handleMouseMoveOnBlock, mouseOverBlock?.element)
const handleMouseUpOnGroup = (e: MouseEvent) => {
if (
!showPlaceholders ||
!isDraggingOnCurrentBlock ||
!draggedItem ||
mouseOverBlock?.id !== block.id
)
return
setExpandedPlaceholderIndex(undefined)
const itemIndex = computeNearestPlaceholderIndex(e.pageY, placeholderRefs)
e.stopPropagation()
setDraggedItem(undefined)
createItem(draggedItem, {
groupIndex,
blockIndex,
itemIndex,
})
}
useEventListener('mouseup', handleMouseUpOnGroup, mouseOverBlock?.element, {
capture: true,
})
const handleBlockMouseDown =
(itemIndex: number) =>
(
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
item: Item
) => {
if (!typebot || block.items.length <= 1) 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}>
<PlaceholderNode
isVisible={showPlaceholders}
isExpanded={expandedPlaceholderIndex === 0}
onRef={handlePushElementRef(0)}
/>
{block.items.map((item, idx) => (
<Stack key={item.id} spacing={1}>
<ItemNode
item={item}
indices={{ groupIndex, blockIndex, itemIndex: idx }}
onMouseDown={handleBlockMouseDown(idx)}
/>
<PlaceholderNode
isVisible={showPlaceholders}
isExpanded={expandedPlaceholderIndex === idx + 1}
onRef={handlePushElementRef(idx + 1)}
/>
</Stack>
))}
{isLastBlock && <DefaultItemNode block={block} />}
{draggedItem && draggedItem.blockId === block.id && (
<Portal>
<Flex
pointerEvents="none"
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg) scale(${graphPosition.scale})`,
}}
w="220px"
transformOrigin="0 0 0"
>
<ItemNode
item={draggedItem}
indices={{ groupIndex, blockIndex, itemIndex: 0 }}
connectionDisabled
/>
</Flex>
</Portal>
)}
</Stack>
)
}
const DefaultItemNode = ({ block }: { block: BlockWithItems }) => {
return (
<Flex
px="4"
py="2"
borderWidth="1px"
borderColor={useColorModeValue('gray.300', undefined)}
bgColor={useColorModeValue('gray.50', 'gray.850')}
rounded="md"
pos="relative"
align="center"
cursor="not-allowed"
>
<Text color="gray.500">
{block.type === LogicBlockType.CONDITION ? 'Else' : 'Default'}
</Text>
<SourceEndpoint
source={{
groupId: block.groupId,
blockId: block.id,
}}
pos="absolute"
right="-49px"
/>
</Flex>
)
}