♻️ Introduce typebot v6 with events (#1013)

Closes #885
This commit is contained in:
Baptiste Arnaud
2023-11-08 15:34:16 +01:00
committed by GitHub
parent 68e4fc71fb
commit 35300eaf34
634 changed files with 58971 additions and 31449 deletions

View File

@@ -10,11 +10,10 @@ import React, { useEffect, useRef, useState } from 'react'
import {
BubbleBlock,
BubbleBlockContent,
DraggableBlock,
Block,
BlockWithOptions,
TextBubbleBlock,
LogicBlockType,
BlockV6,
} from '@typebot.io/schemas'
import {
isBubbleBlock,
@@ -25,7 +24,7 @@ import {
import { BlockNodeContent } from './BlockNodeContent'
import { BlockSettings, SettingsPopoverContent } from './SettingsPopoverContent'
import { BlockNodeContextMenu } from './BlockNodeContextMenu'
import { SourceEndpoint } from '../../endpoints/SourceEndpoint'
import { BlockSourceEndpoint } from '../../endpoints/BlockSourceEndpoint'
import { useRouter } from 'next/router'
import { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
import { ContextMenu } from '@/components/ContextMenu'
@@ -44,6 +43,7 @@ import { setMultipleRefs } from '@/helpers/setMultipleRefs'
import { TargetEndpoint } from '../../endpoints/TargetEndpoint'
import { SettingsModal } from './SettingsModal'
import { TElement } from '@udecode/plate-common'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
export const BlockNode = ({
block,
@@ -51,10 +51,10 @@ export const BlockNode = ({
indices,
onMouseDown,
}: {
block: Block
block: BlockV6
isConnectable: boolean
indices: { blockIndex: number; groupIndex: number }
onMouseDown?: (blockNodePosition: NodePosition, block: DraggableBlock) => void
onMouseDown?: (blockNodePosition: NodePosition, block: BlockV6) => void
}) => {
const bg = useColorModeValue('gray.50', 'gray.850')
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
@@ -78,7 +78,7 @@ export const BlockNode = ({
openedBlockId === block.id
)
const [isEditing, setIsEditing] = useState<boolean>(
isTextBubbleBlock(block) && block.content.richText.length === 0
isTextBubbleBlock(block) && (block.content?.richText?.length ?? 0) === 0
)
const blockRef = useRef<HTMLDivElement | null>(null)
@@ -87,15 +87,17 @@ export const BlockNode = ({
previewingEdge?.to.blockId === block.id ||
previewingBlock?.id === block.id
const groupId = typebot?.groups[indices.groupIndex].id
const onDrag = (position: NodePosition) => {
if (block.type === 'start' || !onMouseDown) return
if (!onMouseDown) return
onMouseDown(position, block)
}
useDragDistance({
ref: blockRef,
onDrag,
isDisabled: !onMouseDown || block.type === 'start',
isDisabled: !onMouseDown,
})
const {
@@ -110,10 +112,10 @@ export const BlockNode = ({
useEffect(() => {
setIsConnecting(
connectingIds?.target?.groupId === block.groupId &&
connectingIds?.target?.groupId === groupId &&
connectingIds?.target?.blockId === block.id
)
}, [connectingIds, block.groupId, block.id])
}, [connectingIds, block.id, groupId])
const handleModalClose = () => {
updateBlock(indices, { ...block })
@@ -124,10 +126,10 @@ export const BlockNode = ({
if (isReadOnly) return
if (mouseOverBlock?.id !== block.id && blockRef.current)
setMouseOverBlock({ id: block.id, element: blockRef.current })
if (connectingIds)
if (connectingIds && groupId)
setConnectingIds({
...connectingIds,
target: { groupId: block.groupId, blockId: block.id },
target: { groupId, blockId: block.id },
})
}
@@ -147,7 +149,7 @@ export const BlockNode = ({
}
const handleClick = (e: React.MouseEvent) => {
setFocusedGroupId(block.groupId)
setFocusedGroupId(groupId)
e.stopPropagation()
if (isTextBubbleBlock(block)) setIsEditing(true)
setOpenedBlockId(block.id)
@@ -187,7 +189,7 @@ export const BlockNode = ({
return isEditing && isTextBubbleBlock(block) ? (
<TextBubbleEditor
id={block.id}
initialValue={block.content.richText}
initialValue={block.content?.richText ?? []}
onClose={handleCloseEditor}
/>
) : (
@@ -244,18 +246,18 @@ export const BlockNode = ({
left="-34px"
top="16px"
blockId={block.id}
groupId={block.groupId}
groupId={groupId}
/>
)}
{(isConnectable ||
(pathname.endsWith('analytics') && isInputBlock(block))) &&
hasDefaultConnector(block) &&
block.type !== LogicBlockType.JUMP && (
<SourceEndpoint
<BlockSourceEndpoint
source={{
groupId: block.groupId,
blockId: block.id,
}}
groupId={groupId}
pos="absolute"
right="-34px"
bottom="10px"
@@ -269,6 +271,7 @@ export const BlockNode = ({
<>
<SettingsPopoverContent
block={block}
groupId={groupId}
onExpandClick={handleExpandClick}
onBlockChange={handleBlockUpdate}
/>
@@ -276,6 +279,7 @@ export const BlockNode = ({
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<BlockSettings
block={block}
groupId={groupId}
onBlockChange={handleBlockUpdate}
/>
</SettingsModal>
@@ -299,10 +303,10 @@ export const BlockNode = ({
)
}
const hasSettingsPopover = (block: Block): block is BlockWithOptions =>
const hasSettingsPopover = (block: BlockV6): block is BlockWithOptions =>
!isBubbleBlock(block) && block.type !== LogicBlockType.CONDITION
const isMediaBubbleBlock = (
block: Block
block: BlockV6
): block is Exclude<BubbleBlock, TextBubbleBlock> =>
isBubbleBlock(block) && !isTextBubbleBlock(block)

View File

@@ -1,13 +1,4 @@
import { Text } from '@chakra-ui/react'
import {
Block,
StartBlock,
BubbleBlockType,
InputBlockType,
LogicBlockType,
IntegrationBlockType,
BlockIndices,
} from '@typebot.io/schemas'
import { BlockIndices, BlockV6 } 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'
@@ -42,15 +33,17 @@ import { ChatwootNodeBody } from '@/features/blocks/integrations/chatwoot/compon
import { AbTestNodeBody } from '@/features/blocks/logic/abTest/components/AbTestNodeBody'
import { PictureChoiceNode } from '@/features/blocks/inputs/pictureChoice/components/PictureChoiceNode'
import { PixelNodeBody } from '@/features/blocks/integrations/pixel/components/PixelNodeBody'
import { useTranslate } from '@tolgee/react'
import { ZemanticAiNodeBody } from '@/features/blocks/integrations/zemanticAi/ZemanticAiNodeBody'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants'
type Props = {
block: Block | StartBlock
block: BlockV6
indices: BlockIndices
}
export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
const { t } = useTranslate()
switch (block.type) {
case BubbleBlockType.TEXT: {
return <TextBubbleContent block={block} />
@@ -65,40 +58,19 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
return <EmbedBubbleContent block={block} />
}
case BubbleBlockType.AUDIO: {
return <AudioBubbleNode url={block.content.url} />
return <AudioBubbleNode url={block.content?.url} />
}
case InputBlockType.TEXT: {
return (
<TextInputNodeContent
variableId={block.options.variableId}
placeholder={block.options.labels.placeholder}
isLong={block.options.isLong}
/>
)
return <TextInputNodeContent options={block.options} />
}
case InputBlockType.NUMBER: {
return (
<NumberNodeContent
placeholder={block.options.labels.placeholder}
variableId={block.options.variableId}
/>
)
return <NumberNodeContent options={block.options} />
}
case InputBlockType.EMAIL: {
return (
<EmailInputNodeContent
placeholder={block.options.labels.placeholder}
variableId={block.options.variableId}
/>
)
return <EmailInputNodeContent options={block.options} />
}
case InputBlockType.URL: {
return (
<UrlNodeContent
placeholder={block.options.labels.placeholder}
variableId={block.options.variableId}
/>
)
return <UrlNodeContent options={block.options} />
}
case InputBlockType.CHOICE: {
return <ButtonsBlockNode block={block} indices={indices} />
@@ -107,26 +79,16 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
return <PictureChoiceNode block={block} indices={indices} />
}
case InputBlockType.PHONE: {
return (
<PhoneNodeContent
placeholder={block.options.labels.placeholder}
variableId={block.options.variableId}
/>
)
return <PhoneNodeContent options={block.options} />
}
case InputBlockType.DATE: {
return <DateNodeContent variableId={block.options.variableId} />
return <DateNodeContent variableId={block.options?.variableId} />
}
case InputBlockType.PAYMENT: {
return <PaymentInputContent block={block} />
}
case InputBlockType.RATING: {
return (
<RatingInputContent
block={block}
variableId={block.options.variableId}
/>
)
return <RatingInputContent block={block} />
}
case InputBlockType.FILE: {
return <FileInputContent options={block.options} />
@@ -135,15 +97,10 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
return <SetVariableContent block={block} />
}
case LogicBlockType.REDIRECT: {
return <RedirectNodeContent url={block.options.url} />
return <RedirectNodeContent url={block.options?.url} />
}
case LogicBlockType.SCRIPT: {
return (
<ScriptNodeContent
name={block.options.name}
content={block.options.content}
/>
)
return <ScriptNodeContent options={block.options} />
}
case LogicBlockType.WAIT: {
return <WaitNodeContent options={block.options} />
@@ -185,9 +142,9 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
case IntegrationBlockType.OPEN_AI: {
return (
<OpenAINodeBody
task={block.options.task}
task={block.options?.task}
responseMapping={
'responseMapping' in block.options
block.options && 'responseMapping' in block.options
? block.options.responseMapping
: []
}
@@ -200,8 +157,5 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
case IntegrationBlockType.ZEMANTIC_AI: {
return <ZemanticAiNodeBody options={block.options} />
}
case 'start': {
return <Text>{t('editor.blocks.start.text')}</Text>
}
}
}

View File

@@ -1,13 +1,13 @@
import { BlockIcon } from '@/features/editor/components/BlockIcon'
import { StackProps, HStack, useColorModeValue } from '@chakra-ui/react'
import { StartBlock, Block, BlockIndices } from '@typebot.io/schemas'
import { BlockIndices, BlockV6 } from '@typebot.io/schemas'
import { BlockNodeContent } from './BlockNodeContent'
export const BlockNodeOverlay = ({
block,
indices,
...props
}: { block: Block | StartBlock; indices: BlockIndices } & StackProps) => {
}: { block: BlockV6; indices: BlockIndices } & StackProps) => {
return (
<HStack
p="3"

View File

@@ -1,5 +1,5 @@
import { useEventListener, Stack, Portal } from '@chakra-ui/react'
import { DraggableBlock, DraggableBlockType, Block } from '@typebot.io/schemas'
import { BlockV6 } from '@typebot.io/schemas'
import { useEffect, useRef, useState } from 'react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import { BlockNode } from './BlockNode'
@@ -14,19 +14,11 @@ import { useGraph } from '@/features/graph/providers/GraphProvider'
import { Coordinates } from '@dnd-kit/utilities'
type Props = {
groupId: string
blocks: Block[]
blocks: BlockV6[]
groupIndex: number
groupRef: React.MutableRefObject<HTMLDivElement | null>
isStartGroup: boolean
}
export const BlockNodesList = ({
groupId,
blocks,
groupIndex,
groupRef,
isStartGroup,
}: Props) => {
export const BlockNodesList = ({ blocks, groupIndex, groupRef }: Props) => {
const {
draggedBlock,
setDraggedBlock,
@@ -48,10 +40,10 @@ export const BlockNodesList = ({
x: 0,
y: 0,
})
const groupId = typebot?.groups[groupIndex].id
const isDraggingOnCurrentGroup =
(draggedBlock || draggedBlockType) && mouseOverGroup?.id === groupId
const showSortPlaceholders =
!isStartGroup && isDefined(draggedBlock || draggedBlockType)
const showSortPlaceholders = isDefined(draggedBlock || draggedBlockType)
useEffect(() => {
if (mouseOverGroup?.id !== groupId) setExpandedPlaceholderIndex(undefined)
@@ -75,14 +67,13 @@ export const BlockNodesList = ({
const handleMouseUpOnGroup = (e: MouseEvent) => {
setExpandedPlaceholderIndex(undefined)
if (!isDraggingOnCurrentGroup) return
if (!isDraggingOnCurrentGroup || !groupId) return
const blockIndex = computeNearestPlaceholderIndex(
e.clientY,
placeholderRefs
)
createBlock(
groupId,
(draggedBlock || draggedBlockType) as DraggableBlock | DraggableBlockType,
(draggedBlock || draggedBlockType) as BlockV6 | BlockV6['type'],
{
groupIndex,
blockIndex,
@@ -96,16 +87,16 @@ export const BlockNodesList = ({
(blockIndex: number) =>
(
{ relative, absolute }: { absolute: Coordinates; relative: Coordinates },
block: DraggableBlock
block: BlockV6
) => {
if (isReadOnly) return
if (isReadOnly || !groupId) return
placeholderRefs.current.splice(blockIndex + 1, 1)
setMousePositionInElement(relative)
setPosition({
x: absolute.x - relative.x,
y: absolute.y - relative.y,
})
setDraggedBlock(block)
setDraggedBlock({ ...block, groupId })
detachBlockFromGroup({ groupIndex, blockIndex })
}
@@ -124,7 +115,7 @@ export const BlockNodesList = ({
<Stack
spacing={1}
transition="none"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
pointerEvents={isReadOnly ? 'none' : 'auto'}
>
<PlaceholderNode
isVisible={showSortPlaceholders}

View File

@@ -12,9 +12,9 @@ import {
import {
BubbleBlock,
BubbleBlockContent,
BubbleBlockType,
TextBubbleBlock,
} from '@typebot.io/schemas'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { useRef } from 'react'
type Props = {

View File

@@ -9,14 +9,7 @@ import {
SlideFade,
Flex,
} from '@chakra-ui/react'
import {
InputBlockType,
IntegrationBlockType,
LogicBlockType,
Block,
BlockOptions,
BlockWithOptions,
} from '@typebot.io/schemas'
import { Block, BlockOptions, BlockWithOptions } from '@typebot.io/schemas'
import { useRef, useState } from 'react'
import { WaitSettings } from '@/features/blocks/logic/wait/components/WaitSettings'
import { ScriptSettings } from '@/features/blocks/logic/script/components/ScriptSettings'
@@ -48,9 +41,13 @@ import { PictureChoiceSettings } from '@/features/blocks/inputs/pictureChoice/co
import { SettingsHoverBar } from './SettingsHoverBar'
import { PixelSettings } from '@/features/blocks/integrations/pixel/components/PixelSettings'
import { ZemanticAiSettings } from '@/features/blocks/integrations/zemanticAi/ZemanticAiSettings'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { IntegrationBlockType } from '@typebot.io/schemas/features/blocks/integrations/constants'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
type Props = {
block: BlockWithOptions
groupId: string | undefined
onExpandClick: () => void
onBlockChange: (updates: Partial<Block>) => void
}
@@ -105,11 +102,13 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
export const BlockSettings = ({
block,
groupId,
onBlockChange,
}: {
block: BlockWithOptions
groupId: string | undefined
onBlockChange: (block: Partial<Block>) => void
}): JSX.Element => {
}): JSX.Element | null => {
const updateOptions = (options: BlockOptions) => {
onBlockChange({ options } as Partial<Block>)
}
@@ -241,12 +240,14 @@ export const BlockSettings = ({
)
}
case LogicBlockType.JUMP: {
return (
return groupId ? (
<JumpSettings
groupId={block.groupId}
groupId={groupId}
options={block.options}
onOptionsChange={updateOptions}
/>
) : (
<></>
)
}
case LogicBlockType.AB_TEST: {
@@ -320,5 +321,7 @@ export const BlockSettings = ({
<ZemanticAiSettings block={block} onOptionsChange={updateOptions} />
)
}
case LogicBlockType.CONDITION:
return null
}
}

View File

@@ -0,0 +1,68 @@
import { InfoIcon, PlayIcon, TrashIcon } from '@/components/icons'
import {
HStack,
IconButton,
Tooltip,
useClipboard,
useColorModeValue,
} from '@chakra-ui/react'
type Props = {
eventId: string
onPlayClick: () => void
onDeleteClick?: () => void
}
export const EventFocusToolbar = ({
eventId,
onPlayClick,
onDeleteClick,
}: Props) => {
const { hasCopied, onCopy } = useClipboard(eventId)
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"
/>
<Tooltip
label={hasCopied ? 'Copied!' : eventId}
closeOnClick={false}
placement="top"
>
<IconButton
icon={<InfoIcon />}
borderRightWidth="1px"
borderRightRadius="none"
borderLeftRadius="none"
aria-label={'Show group info'}
variant="ghost"
size="sm"
onClick={onCopy}
/>
</Tooltip>
{onDeleteClick ? (
<IconButton
aria-label="Delete"
borderLeftRadius="none"
icon={<TrashIcon />}
onClick={onDeleteClick}
variant="ghost"
size="sm"
/>
) : null}
</HStack>
)
}

View File

@@ -0,0 +1,208 @@
import { SlideFade, Stack, useColorModeValue } from '@chakra-ui/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { EventNodeContextMenu } from './EventNodeContextMenu'
import { useDebounce } from 'use-debounce'
import { ContextMenu } from '@/components/ContextMenu'
import { useDrag } from '@use-gesture/react'
import { EventFocusToolbar } from './EventFocusToolbar'
import { useOutsideClick } from '@/hooks/useOutsideClick'
import {
RightPanel,
useEditor,
} from '@/features/editor/providers/EditorProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { useEventsCoordinates } from '@/features/graph/providers/EventsCoordinateProvider'
import { setMultipleRefs } from '@/helpers/setMultipleRefs'
import { Coordinates } from '@/features/graph/types'
import { TEvent } from '@typebot.io/schemas'
import { EventNodeContent } from './EventNodeContent'
import { EventSourceEndpoint } from '../../endpoints/EventSourceEndpoint'
import { eventWidth } from '@/features/graph/constants'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
type Props = {
event: TEvent
eventIndex: number
}
export const EventNode = ({ event, eventIndex }: Props) => {
const { updateEventCoordinates } = useEventsCoordinates()
const handleEventDrag = useCallback(
(newCoord: Coordinates) => {
updateEventCoordinates(event.id, newCoord)
},
[event.id, updateEventCoordinates]
)
return (
<DraggableEventNode
event={event}
eventIndex={eventIndex}
onEventDrag={handleEventDrag}
/>
)
}
const NonMemoizedDraggableEventNode = ({
event,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
eventIndex,
onEventDrag,
}: Props & { onEventDrag: (newCoord: Coordinates) => void }) => {
const elementBgColor = useColorModeValue('white', 'gray.900')
const previewingBorderColor = useColorModeValue('blue.400', 'blue.300')
const { previewingEdge, isReadOnly, graphPosition } = useGraph()
const { updateEvent } = useTypebot()
const { setRightPanel, setStartPreviewAtEvent } = useEditor()
const [isMouseDown, setIsMouseDown] = useState(false)
const [currentCoordinates, setCurrentCoordinates] = useState(
event.graphCoordinates
)
const isPreviewing = previewingEdge
? 'eventId' in previewingEdge.from
? previewingEdge.from.eventId === event.id
: false
: false
const eventRef = useRef<HTMLDivElement | null>(null)
const [debouncedEventPosition] = useDebounce(currentCoordinates, 100)
const [isFocused, setIsFocused] = useState(false)
useOutsideClick({
handler: () => setIsFocused(false),
ref: eventRef,
capture: true,
isEnabled: isFocused,
})
// When the event is moved from external action (e.g. undo/redo), update the current coordinates
useEffect(() => {
setCurrentCoordinates({
x: event.graphCoordinates.x,
y: event.graphCoordinates.y,
})
}, [event.graphCoordinates.x, event.graphCoordinates.y])
useEffect(() => {
if (!currentCoordinates || isReadOnly) return
if (
currentCoordinates?.x === event.graphCoordinates.x &&
currentCoordinates.y === event.graphCoordinates.y
)
return
updateEvent(eventIndex, { graphCoordinates: currentCoordinates })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedEventPosition])
const startPreviewAtThisEvent = () => {
setStartPreviewAtEvent(event.id)
setRightPanel(RightPanel.PREVIEW)
}
useDrag(
({ first, last, offset: [offsetX, offsetY], event, target }) => {
event.stopPropagation()
if (
(target as HTMLElement)
.closest('.prevent-event-drag')
?.classList.contains('prevent-event-drag')
)
return
if (first) {
setIsFocused(true)
setIsMouseDown(true)
}
if (last) {
setIsMouseDown(false)
}
const newCoord = {
x: Number((offsetX / graphPosition.scale).toFixed(2)),
y: Number((offsetY / graphPosition.scale).toFixed(2)),
}
setCurrentCoordinates(newCoord)
onEventDrag(newCoord)
},
{
target: eventRef,
pointer: { keys: false },
from: () => [
currentCoordinates.x * graphPosition.scale,
currentCoordinates.y * graphPosition.scale,
],
}
)
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <EventNodeContextMenu />}
isDisabled={isReadOnly || event.type === 'start'}
>
{(ref, isContextMenuOpened) => (
<Stack
ref={setMultipleRefs([ref, eventRef])}
id={`event-${event.id}`}
data-testid="event"
py="2"
pl="3"
pr="3"
w={eventWidth}
rounded="xl"
bg={elementBgColor}
borderWidth="1px"
fontWeight="semibold"
borderColor={
isContextMenuOpened || isPreviewing || isFocused
? previewingBorderColor
: elementBgColor
}
transition="border 300ms, box-shadow 200ms"
pos="absolute"
style={{
transform: `translate(${currentCoordinates?.x ?? 0}px, ${
currentCoordinates?.y ?? 0
}px)`,
touchAction: 'none',
}}
cursor={isMouseDown ? 'grabbing' : 'pointer'}
shadow="md"
_hover={{ shadow: 'lg' }}
zIndex={isFocused ? 10 : 1}
>
<EventNodeContent event={event} />
<EventSourceEndpoint
source={{
eventId: event.id,
}}
pos="absolute"
right="-19px"
bottom="4px"
isHidden={false}
/>
{!isReadOnly && (
<SlideFade
in={isFocused}
style={{
position: 'absolute',
top: '-45px',
right: 0,
}}
unmountOnExit
>
<EventFocusToolbar
eventId={event.id}
onPlayClick={startPreviewAtThisEvent}
onDeleteClick={event.type !== 'start' ? () => {} : undefined}
/>
</SlideFade>
)}
</Stack>
)}
</ContextMenu>
)
}
export const DraggableEventNode = memo(NonMemoizedDraggableEventNode)

View File

@@ -0,0 +1,14 @@
import { StartEventNode } from '@/features/events/start/StartEventNode'
import { TEvent } from '@typebot.io/schemas'
type Props = {
event: TEvent
}
export const EventNodeContent = ({ event }: Props) => {
switch (event.type) {
case 'start':
return <StartEventNode />
default:
return null
}
}

View File

@@ -0,0 +1,24 @@
import { MenuList, MenuItem } from '@chakra-ui/react'
import { CopyIcon, TrashIcon } from '@/components/icons'
type Props = {
onDuplicateClick?: () => void
onDeleteClick?: () => void
}
export const EventNodeContextMenu = ({
onDuplicateClick,
onDeleteClick,
}: Props) => (
<MenuList>
{onDuplicateClick && (
<MenuItem icon={<CopyIcon />} onClick={onDuplicateClick}>
Duplicate
</MenuItem>
)}
{onDeleteClick && (
<MenuItem icon={<TrashIcon />} onClick={onDeleteClick}>
Delete
</MenuItem>
)}
</MenuList>
)

View File

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

View File

@@ -7,9 +7,9 @@ import {
useColorModeValue,
} from '@chakra-ui/react'
import React, { memo, useCallback, useEffect, useRef, useState } from 'react'
import { Group } from '@typebot.io/schemas'
import { GroupV6 } from '@typebot.io/schemas'
import { BlockNodesList } from '../block/BlockNodesList'
import { isDefined, isEmpty, isNotDefined } from '@typebot.io/lib'
import { isEmpty, isNotDefined } from '@typebot.io/lib'
import { GroupNodeContextMenu } from './GroupNodeContextMenu'
import { useDebounce } from 'use-debounce'
import { ContextMenu } from '@/components/ContextMenu'
@@ -26,9 +26,10 @@ 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'
import { groupWidth } from '@/features/graph/constants'
type Props = {
group: Group
group: GroupV6
groupIndex: number
}
@@ -81,12 +82,11 @@ const NonMemoizedDraggableGroupNode = ({
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'
(previewingEdge &&
(('groupId' in previewingEdge.from &&
previewingEdge.from.groupId === group.id) ||
(previewingEdge.to.groupId === group.id &&
isNotDefined(previewingEdge.to.blockId))))
const groupRef = useRef<HTMLDivElement | null>(null)
const [debouncedGroupPosition] = useDebounce(currentCoordinates, 100)
@@ -135,7 +135,7 @@ const NonMemoizedDraggableGroupNode = ({
const handleMouseEnter = () => {
if (isReadOnly) return
if (mouseOverGroup?.id !== group.id && !isStartGroup && groupRef.current)
if (mouseOverGroup?.id !== group.id && groupRef.current)
setMouseOverGroup({ id: group.id, element: groupRef.current })
if (connectingIds)
setConnectingIds({ ...connectingIds, target: { groupId: group.id } })
@@ -189,7 +189,7 @@ const NonMemoizedDraggableGroupNode = ({
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <GroupNodeContextMenu groupIndex={groupIndex} />}
isDisabled={isReadOnly || isStartGroup}
isDisabled={isReadOnly}
>
{(ref, isContextMenuOpened) => (
<Stack
@@ -205,7 +205,7 @@ const NonMemoizedDraggableGroupNode = ({
? previewingBorderColor
: borderColor
}
w="300px"
w={groupWidth}
transition="border 300ms, box-shadow 200ms"
pos="absolute"
style={{
@@ -227,7 +227,7 @@ const NonMemoizedDraggableGroupNode = ({
onChange={setGroupTitle}
onSubmit={handleTitleSubmit}
fontWeight="semibold"
pointerEvents={isReadOnly || isStartGroup ? 'none' : 'auto'}
pointerEvents={isReadOnly ? 'none' : 'auto'}
pr="8"
>
<EditablePreview
@@ -251,14 +251,12 @@ const NonMemoizedDraggableGroupNode = ({
</Editable>
{typebot && (
<BlockNodesList
groupId={group.id}
blocks={group.blocks}
groupIndex={groupIndex}
groupRef={ref}
isStartGroup={isStartGroup}
/>
)}
{!isReadOnly && !isStartGroup && (
{!isReadOnly && (
<SlideFade
in={isFocused}
style={{

View File

@@ -1,20 +1,15 @@
import { Flex, useColorModeValue, Stack } from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import {
ChoiceInputBlock,
Item,
ItemIndices,
ItemType,
} from '@typebot.io/schemas'
import { BlockWithItems, Item, ItemIndices } from '@typebot.io/schemas'
import React, { useRef, useState } from 'react'
import { SourceEndpoint } from '../../endpoints/SourceEndpoint'
import { BlockSourceEndpoint } from '../../endpoints/BlockSourceEndpoint'
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 {
DraggabbleItem,
DraggableItem,
NodePosition,
useDragDistance,
} from '@/features/graph/providers/GraphDndProvider'
@@ -22,19 +17,22 @@ import { useGraph } from '@/features/graph/providers/GraphProvider'
import { setMultipleRefs } from '@/helpers/setMultipleRefs'
import { ConditionContent } from '@/features/blocks/logic/condition/components/ConditionContent'
import { useRouter } from 'next/router'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
type Props = {
item: Item
block: BlockWithItems
indices: ItemIndices
onMouseDown?: (
blockNodePosition: { absolute: Coordinates; relative: Coordinates },
item: DraggabbleItem
item: DraggableItem
) => void
connectionDisabled?: boolean
}
export const ItemNode = ({
item,
block,
indices,
onMouseDown,
connectionDisabled,
@@ -47,18 +45,21 @@ export const ItemNode = ({
const { pathname } = useRouter()
const [isMouseOver, setIsMouseOver] = useState(false)
const itemRef = useRef<HTMLDivElement | null>(null)
const isPreviewing = previewingEdge?.from.itemId === item.id
const isPreviewing =
previewingEdge &&
'itemId' in previewingEdge.from &&
previewingEdge.from.itemId === item.id
const isConnectable =
isDefined(typebot) &&
!connectionDisabled &&
!(
typebot.groups[indices.groupIndex].blocks[indices.blockIndex] as
| ChoiceInputBlock
| undefined
)?.options?.isMultipleChoice
block.options &&
'isMultipleChoice' in block.options &&
block.options.isMultipleChoice
)
const onDrag = (position: NodePosition) => {
if (!onMouseDown || item.type === ItemType.AB_TEST) return
onMouseDown(position, item)
if (!onMouseDown || block.type === LogicBlockType.AB_TEST) return
onMouseDown(position, { ...item, type: block.type })
}
useDragDistance({
ref: itemRef,
@@ -108,20 +109,18 @@ export const ItemNode = ({
w="full"
>
<ItemNodeContent
blockType={block.type}
item={item}
isMouseOver={isMouseOver}
indices={indices}
/>
{typebot && (isConnectable || pathname.endsWith('analytics')) && (
<SourceEndpoint
<BlockSourceEndpoint
source={{
groupId: typebot.groups[indices.groupIndex].id,
blockId:
typebot.groups[indices.groupIndex]?.blocks[
indices.blockIndex
]?.id,
blockId: block.id,
itemId: item.id,
}}
groupId={typebot.groups[indices.groupIndex].id}
pos="absolute"
right="-49px"
bottom="9px"

View File

@@ -1,31 +1,41 @@
import { ButtonsItemNode } from '@/features/blocks/inputs/buttons/components/ButtonsItemNode'
import { PictureChoiceItemNode } from '@/features/blocks/inputs/pictureChoice/components/PictureChoiceItemNode'
import { ConditionItemNode } from '@/features/blocks/logic/condition/components/ConditionItemNode'
import { Item, ItemIndices, ItemType } from '@typebot.io/schemas'
import {
BlockWithItems,
ButtonItem,
ConditionItem,
Item,
ItemIndices,
} from '@typebot.io/schemas'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import React from 'react'
type Props = {
item: Item
blockType: BlockWithItems['type']
indices: ItemIndices
isMouseOver: boolean
}
export const ItemNodeContent = ({
item,
blockType,
indices,
isMouseOver,
}: Props): JSX.Element => {
switch (item.type) {
case ItemType.BUTTON:
switch (blockType) {
case InputBlockType.CHOICE:
return (
<ButtonsItemNode
key={`${item.id}-${item.content}`}
item={item}
item={item as ButtonItem}
key={`${item.id}-${(item as ButtonItem).content}`}
isMouseOver={isMouseOver}
indices={indices}
/>
)
case ItemType.PICTURE_CHOICE:
case InputBlockType.PICTURE_CHOICE:
return (
<PictureChoiceItemNode
item={item}
@@ -33,15 +43,15 @@ export const ItemNodeContent = ({
indices={indices}
/>
)
case ItemType.CONDITION:
case LogicBlockType.CONDITION:
return (
<ConditionItemNode
item={item}
item={item as ConditionItem}
isMouseOver={isMouseOver}
indices={indices}
/>
)
case ItemType.AB_TEST:
case LogicBlockType.AB_TEST:
return <></>
}
}

View File

@@ -7,11 +7,7 @@ import {
useEventListener,
} from '@chakra-ui/react'
import { useTypebot } from '@/features/editor/providers/TypebotProvider'
import {
BlockIndices,
BlockWithItems,
LogicBlockType,
} from '@typebot.io/schemas'
import { BlockIndices, BlockWithItems } from '@typebot.io/schemas'
import React, { useEffect, useRef, useState } from 'react'
import { ItemNode } from './ItemNode'
import { PlaceholderNode } from '../PlaceholderNode'
@@ -19,11 +15,13 @@ import { isDefined } from '@typebot.io/lib'
import {
useBlockDnd,
computeNearestPlaceholderIndex,
DraggabbleItem,
DraggableItem,
} from '@/features/graph/providers/GraphDndProvider'
import { useGraph } from '@/features/graph/providers/GraphProvider'
import { Coordinates } from '@dnd-kit/utilities'
import { SourceEndpoint } from '../../endpoints/SourceEndpoint'
import { BlockSourceEndpoint } from '../../endpoints/BlockSourceEndpoint'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
type Props = {
block: BlockWithItems
@@ -41,12 +39,18 @@ export const ItemNodesList = ({
const isDraggingOnCurrentBlock =
(draggedItem && mouseOverBlock?.id === block.id) ?? false
const showPlaceholders =
draggedItem !== undefined && block.items.at(0)?.type === draggedItem.type
draggedItem !== undefined && block.type === draggedItem.type
const isLastBlock =
isDefined(typebot) &&
typebot.groups[groupIndex]?.blocks?.[blockIndex + 1] === undefined
const someChoiceItemsAreNotConnected =
block.type === InputBlockType.CHOICE ||
block.type === InputBlockType.PICTURE_CHOICE
? block.items.some((item) => item.outgoingEdgeId === undefined)
: true
const [position, setPosition] = useState({
x: 0,
y: 0,
@@ -57,7 +61,7 @@ export const ItemNodesList = ({
>()
const handleGlobalMouseMove = (event: MouseEvent) => {
if (!draggedItem || draggedItem.blockId !== block.id) return
if (!draggedItem) return
const { clientX, clientY } = event
setPosition({
...position,
@@ -107,7 +111,7 @@ export const ItemNodesList = ({
(itemIndex: number) =>
(
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
item: DraggabbleItem
item: DraggableItem
) => {
if (!typebot || block.items.length <= 1) return
placeholderRefs.current.splice(itemIndex + 1, 1)
@@ -116,7 +120,6 @@ export const ItemNodesList = ({
setRelativeCoordinates(relative)
setDraggedItem({
...item,
blockId: block.id,
})
}
@@ -138,6 +141,7 @@ export const ItemNodesList = ({
<Stack key={item.id} spacing={1}>
<ItemNode
item={item}
block={block}
indices={{ groupIndex, blockIndex, itemIndex: idx }}
onMouseDown={handleBlockMouseDown(idx)}
/>
@@ -148,9 +152,14 @@ export const ItemNodesList = ({
/>
</Stack>
))}
{isLastBlock && <DefaultItemNode block={block} />}
{isLastBlock && someChoiceItemsAreNotConnected && (
<DefaultItemNode
block={block}
groupId={typebot.groups[groupIndex].id}
/>
)}
{draggedItem && draggedItem.blockId === block.id && (
{draggedItem && (
<Portal>
<Flex
pointerEvents="none"
@@ -165,6 +174,7 @@ export const ItemNodesList = ({
>
<ItemNode
item={draggedItem}
block={block}
indices={{ groupIndex, blockIndex, itemIndex: 0 }}
connectionDisabled
/>
@@ -175,7 +185,13 @@ export const ItemNodesList = ({
)
}
const DefaultItemNode = ({ block }: { block: BlockWithItems }) => {
const DefaultItemNode = ({
block,
groupId,
}: {
block: BlockWithItems
groupId: string
}) => {
return (
<Flex
px="4"
@@ -191,11 +207,11 @@ const DefaultItemNode = ({ block }: { block: BlockWithItems }) => {
<Text color="gray.500">
{block.type === LogicBlockType.CONDITION ? 'Else' : 'Default'}
</Text>
<SourceEndpoint
<BlockSourceEndpoint
source={{
groupId: block.groupId,
blockId: block.id,
}}
groupId={groupId}
pos="absolute"
right="-49px"
/>