♻️ Fix folder case issue
This commit is contained in:
@@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { GroupNode } from './GroupNode'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user