2
0

feat(inputs): Add buttons input

This commit is contained in:
Baptiste Arnaud
2022-01-12 09:10:59 +01:00
parent b20bcb1408
commit c02c61cd8b
47 changed files with 1109 additions and 243 deletions

View File

@ -230,3 +230,10 @@ export const PhoneIcon = (props: IconProps) => (
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>
</Icon>
)
export const CheckSquareIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polyline points="9 11 12 14 22 4"></polyline>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</Icon>
)

View File

@ -28,12 +28,12 @@ export const Edge = ({ stepId }: Props) => {
const path = useMemo(() => {
if (!sourceBlock || !targetBlock) return ``
const anchorsPosition = getAnchorsPosition(
const anchorsPosition = getAnchorsPosition({
sourceBlock,
targetBlock,
sourceBlock.stepIds.indexOf(stepId),
targetStepIndex
)
sourceStepIndex: sourceBlock.stepIds.indexOf(stepId),
sourceChoiceItemIndex: targetStepIndex,
})
return computeFlowChartConnectorPath(anchorsPosition)
}, [sourceBlock, stepId, targetBlock, targetStepIndex])

View File

@ -1,6 +1,8 @@
import { IconProps } from '@chakra-ui/react'
import {
CalendarIcon,
ChatIcon,
CheckSquareIcon,
EmailIcon,
FlagIcon,
GlobeIcon,
@ -11,33 +13,36 @@ import {
import { BubbleStepType, InputStepType, StepType } from 'models'
import React from 'react'
type StepIconProps = { type: StepType }
type StepIconProps = { type: StepType } & IconProps
export const StepIcon = ({ type }: StepIconProps) => {
export const StepIcon = ({ type, ...props }: StepIconProps) => {
switch (type) {
case BubbleStepType.TEXT: {
return <ChatIcon />
return <ChatIcon {...props} />
}
case InputStepType.TEXT: {
return <TextIcon />
return <TextIcon {...props} />
}
case InputStepType.NUMBER: {
return <NumberIcon />
return <NumberIcon {...props} />
}
case InputStepType.EMAIL: {
return <EmailIcon />
return <EmailIcon {...props} />
}
case InputStepType.URL: {
return <GlobeIcon />
return <GlobeIcon {...props} />
}
case InputStepType.DATE: {
return <CalendarIcon />
return <CalendarIcon {...props} />
}
case InputStepType.PHONE: {
return <PhoneIcon />
return <PhoneIcon {...props} />
}
case InputStepType.CHOICE: {
return <CheckSquareIcon {...props} />
}
case 'start': {
return <FlagIcon />
return <FlagIcon {...props} />
}
default: {
return <></>

View File

@ -25,6 +25,9 @@ export const StepTypeLabel = ({ type }: Props) => {
case InputStepType.PHONE: {
return <Text>Phone</Text>
}
case InputStepType.CHOICE: {
return <Text>Button</Text>
}
default: {
return <></>
}

View File

@ -21,12 +21,11 @@ type Props = {
export const BlockNode = ({ block }: Props) => {
const { connectingIds, setConnectingIds, previewingIds } = useGraph()
const { typebot, updateBlock, createStep } = useTypebot()
const { draggedStep, draggedStepType, setDraggedStepType, setDraggedStep } =
useDnd()
const { typebot, updateBlock } = useTypebot()
const { setMouseOverBlockId } = useDnd()
const { draggedStep, draggedStepType } = useDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
const [titleValue, setTitleValue] = useState(block.title)
const [showSortPlaceholders, setShowSortPlaceholders] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const isPreviewing = useMemo(
() =>
@ -66,28 +65,16 @@ export const BlockNode = ({ block }: Props) => {
useEventListener('mousemove', handleMouseMove)
const handleMouseEnter = () => {
if (draggedStepType || draggedStep) setShowSortPlaceholders(true)
if (draggedStepType || draggedStep) setMouseOverBlockId(block.id)
if (connectingIds)
setConnectingIds({ ...connectingIds, target: { blockId: block.id } })
}
const handleMouseLeave = () => {
setShowSortPlaceholders(false)
setMouseOverBlockId(undefined)
if (connectingIds) setConnectingIds({ ...connectingIds, target: undefined })
}
const handleStepDrop = (index: number) => {
setShowSortPlaceholders(false)
if (draggedStepType) {
createStep(block.id, draggedStepType, index)
setDraggedStepType(undefined)
}
if (draggedStep) {
createStep(block.id, draggedStep, index)
setDraggedStep(undefined)
}
}
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <BlockNodeContextMenu blockId={block.id} />}
@ -125,8 +112,6 @@ export const BlockNode = ({ block }: Props) => {
<StepsList
blockId={block.id}
steps={filterTable(block.stepIds, typebot?.steps)}
showSortPlaceholders={showSortPlaceholders}
onMouseUp={handleStepDrop}
/>
)}
</Stack>

View File

@ -0,0 +1,148 @@
import {
EditablePreview,
EditableInput,
Editable,
useEventListener,
Flex,
Fade,
IconButton,
} from '@chakra-ui/react'
import { PlusIcon } from 'assets/icons'
import { ContextMenu } from 'components/shared/ContextMenu'
import { Coordinates } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext'
import { ChoiceInputStep, ChoiceItem } from 'models'
import React, { useState } from 'react'
import { isDefined, isSingleChoiceInput } from 'utils'
import { SourceEndpoint } from '../SourceEndpoint'
import { ChoiceItemNodeContextMenu } from './ChoiceItemNodeContextMenu'
type ChoiceItemNodeProps = {
item: ChoiceItem
onMouseMoveBottomOfElement?: () => void
onMouseMoveTopOfElement?: () => void
onMouseDown?: (
stepNodePosition: { absolute: Coordinates; relative: Coordinates },
item: ChoiceItem
) => void
}
export const ChoiceItemNode = ({
item,
onMouseDown,
onMouseMoveBottomOfElement,
onMouseMoveTopOfElement,
}: ChoiceItemNodeProps) => {
const { deleteChoiceItem, updateChoiceItem, typebot, createChoiceItem } =
useTypebot()
const [mouseDownEvent, setMouseDownEvent] =
useState<{ absolute: Coordinates; relative: Coordinates }>()
const [isMouseOver, setIsMouseOver] = useState(false)
const handleMouseDown = (e: React.MouseEvent) => {
if (!onMouseDown) return
e.stopPropagation()
const element = e.currentTarget as HTMLDivElement
const rect = element.getBoundingClientRect()
const relativeX = e.clientX - rect.left
const relativeY = e.clientY - rect.top
setMouseDownEvent({
absolute: { x: e.clientX + relativeX, y: e.clientY + relativeY },
relative: { x: relativeX, y: relativeY },
})
}
const handleGlobalMouseUp = () => {
setMouseDownEvent(undefined)
}
useEventListener('mouseup', handleGlobalMouseUp)
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
if (!onMouseMoveBottomOfElement || !onMouseMoveTopOfElement) return
const isMovingAndIsMouseDown =
mouseDownEvent &&
onMouseDown &&
(event.movementX > 0 || event.movementY > 0)
if (isMovingAndIsMouseDown) {
onMouseDown(mouseDownEvent, item)
deleteChoiceItem(item.id)
setMouseDownEvent(undefined)
}
const element = event.currentTarget as HTMLDivElement
const rect = element.getBoundingClientRect()
const y = event.clientY - rect.top
if (y > rect.height / 2) onMouseMoveBottomOfElement()
else onMouseMoveTopOfElement()
}
const handleInputSubmit = (content: string) =>
updateChoiceItem(item.id, { content: content === '' ? undefined : content })
const handlePlusClick = () => {
const nextIndex =
(
typebot?.steps.byId[item.stepId] as ChoiceInputStep
).options.itemIds.indexOf(item.id) + 1
createChoiceItem({ stepId: item.stepId }, nextIndex)
}
const handleMouseEnter = () => setIsMouseOver(true)
const handleMouseLeave = () => setIsMouseOver(false)
return (
<ContextMenu<HTMLDivElement>
renderMenu={() => <ChoiceItemNodeContextMenu itemId={item.id} />}
>
{(ref, isOpened) => (
<Flex
align="center"
pos="relative"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
justifyContent="center"
>
<Editable
ref={ref}
px="4"
py="2"
rounded="md"
bgColor="green.200"
borderWidth="2px"
borderColor={isOpened ? 'blue.400' : 'gray.400'}
defaultValue={item.content ?? 'Click to edit'}
flex="1"
startWithEditView={!isDefined(item.content)}
onSubmit={handleInputSubmit}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
>
<EditablePreview />
<EditableInput />
</Editable>
{typebot && isSingleChoiceInput(typebot.steps.byId[item.stepId]) && (
<SourceEndpoint
source={{
blockId: typebot.steps.byId[item.stepId].blockId,
stepId: item.stepId,
choiceItemId: item.id,
}}
pos="absolute"
right="15px"
/>
)}
<Fade
in={isMouseOver}
style={{ position: 'absolute', bottom: '-15px', zIndex: 3 }}
unmountOnExit
>
<IconButton
aria-label="Add item"
icon={<PlusIcon />}
size="xs"
onClick={handlePlusClick}
/>
</Fade>
</Flex>
)}
</ContextMenu>
)
}

View File

@ -0,0 +1,17 @@
import { MenuList, MenuItem } from '@chakra-ui/react'
import { TrashIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
export const ChoiceItemNodeContextMenu = ({ itemId }: { itemId: string }) => {
const { deleteChoiceItem } = useTypebot()
const handleDeleteClick = () => deleteChoiceItem(itemId)
return (
<MenuList>
<MenuItem icon={<TrashIcon />} onClick={handleDeleteClick}>
Delete
</MenuItem>
</MenuList>
)
}

View File

@ -0,0 +1,28 @@
import { Flex, FlexProps } from '@chakra-ui/react'
import { ChoiceItem } from 'models'
import React from 'react'
type ChoiceItemNodeOverlayProps = {
item: ChoiceItem
} & FlexProps
export const ChoiceItemNodeOverlay = ({
item,
...props
}: ChoiceItemNodeOverlayProps) => {
return (
<Flex
px="4"
py="2"
rounded="md"
bgColor="green.200"
borderWidth="2px"
borderColor={'gray.400'}
w="212px"
pointerEvents="none"
{...props}
>
{item.content ?? 'Click to edit'}
</Flex>
)
}

View File

@ -0,0 +1,155 @@
import { Flex, Portal, Stack, Text, useEventListener } from '@chakra-ui/react'
import { useDnd } from 'contexts/DndContext'
import { Coordinates } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext'
import { ChoiceInputStep, ChoiceItem } from 'models'
import React, { useMemo, useState } from 'react'
import { SourceEndpoint } from '../SourceEndpoint'
import { ChoiceItemNode } from './ChoiceItemNode'
import { ChoiceItemNodeOverlay } from './ChoiceItemNodeOverlay'
type ChoiceItemsListProps = {
step: ChoiceInputStep
}
export const ChoiceItemsList = ({ step }: ChoiceItemsListProps) => {
const { typebot, createChoiceItem } = useTypebot()
const {
draggedChoiceItem,
mouseOverBlockId,
setDraggedChoiceItem,
setMouseOverBlockId,
} = useDnd()
const showSortPlaceholders = useMemo(
() => mouseOverBlockId === step.blockId && draggedChoiceItem,
[draggedChoiceItem, mouseOverBlockId, step.blockId]
)
const [position, setPosition] = useState({
x: 0,
y: 0,
})
const [relativeCoordinates, setRelativeCoordinates] = useState({ x: 0, y: 0 })
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
number | undefined
>()
const handleStepMove = (event: MouseEvent) => {
if (!draggedChoiceItem) return
const { clientX, clientY } = event
setPosition({
...position,
x: clientX - relativeCoordinates.x,
y: clientY - relativeCoordinates.y,
})
}
useEventListener('mousemove', handleStepMove)
const handleMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
if (expandedPlaceholderIndex === undefined || !draggedChoiceItem) return
e.stopPropagation()
setMouseOverBlockId(undefined)
setExpandedPlaceholderIndex(undefined)
setDraggedChoiceItem(undefined)
createChoiceItem(draggedChoiceItem, expandedPlaceholderIndex)
}
const handleStepMouseDown = (
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
item: ChoiceItem
) => {
setPosition(absolute)
setRelativeCoordinates(relative)
setMouseOverBlockId(typebot?.steps.byId[item.stepId].blockId)
setDraggedChoiceItem(item)
}
const handleMouseOnTopOfStep = (stepIndex: number) => {
if (!draggedChoiceItem) return
setExpandedPlaceholderIndex(stepIndex === 0 ? 0 : stepIndex)
}
const handleMouseOnBottomOfStep = (stepIndex: number) => {
if (!draggedChoiceItem) return
setExpandedPlaceholderIndex(stepIndex + 1)
}
const stopPropagating = (e: React.MouseEvent) => e.stopPropagation()
return (
<Stack
flex={1}
spacing={1}
onMouseUpCapture={handleMouseUp}
onClick={stopPropagating}
>
<Flex
h={expandedPlaceholderIndex === 0 ? '50px' : '2px'}
bgColor={'gray.400'}
visibility={showSortPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showSortPlaceholders ? 'height 200ms' : 'none'}
/>
{step.options.itemIds.map((itemId, idx) => (
<Stack key={itemId} spacing={1}>
{typebot?.choiceItems.byId[itemId] && (
<ChoiceItemNode
item={typebot?.choiceItems.byId[itemId]}
onMouseMoveTopOfElement={() => handleMouseOnTopOfStep(idx)}
onMouseMoveBottomOfElement={() => {
handleMouseOnBottomOfStep(idx)
}}
onMouseDown={handleStepMouseDown}
/>
)}
<Flex
h={
showSortPlaceholders && expandedPlaceholderIndex === idx + 1
? '50px'
: '2px'
}
bgColor={'gray.400'}
visibility={showSortPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showSortPlaceholders ? 'height 200ms' : 'none'}
/>
</Stack>
))}
<Stack>
<Flex
px="4"
py="2"
bgColor="gray.200"
borderWidth="2px"
rounded="md"
pos="relative"
align="center"
>
<Text>Default</Text>
<SourceEndpoint
source={{
blockId: step.blockId,
stepId: step.id,
}}
pos="absolute"
right="15px"
/>
</Flex>
</Stack>
{draggedChoiceItem && draggedChoiceItem.stepId === step.id && (
<Portal>
<ChoiceItemNodeOverlay
item={draggedChoiceItem}
onMouseUp={handleMouseUp}
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg)`,
}}
/>
</Portal>
)}
</Stack>
)
}

View File

@ -0,0 +1 @@
export { ChoiceItemsList as ChoiceInputStepNodeContent } from './ChoiceItemsList'

View File

@ -1,6 +1,11 @@
import { PopoverContent, PopoverArrow, PopoverBody } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { InputStepType, Step, TextInputOptions } from 'models'
import {
ChoiceInputOptions,
InputStep,
InputStepType,
TextInputOptions,
} from 'models'
import {
TextInputSettingsBody,
NumberInputSettingsBody,
@ -8,10 +13,11 @@ import {
UrlInputSettingsBody,
DateInputSettingsBody,
} from './bodies'
import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
type Props = {
step: Step
step: InputStep
}
export const SettingsPopoverContent = ({ step }: Props) => {
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
@ -28,8 +34,9 @@ export const SettingsPopoverContent = ({ step }: Props) => {
const SettingsPopoverBodyContent = ({ step }: Props) => {
const { updateStep } = useTypebot()
const handleOptionsChange = (options: TextInputOptions) =>
updateStep(step.id, { options } as Partial<Step>)
const handleOptionsChange = (
options: TextInputOptions | ChoiceInputOptions
) => updateStep(step.id, { options } as Partial<InputStep>)
switch (step.type) {
case InputStepType.TEXT: {
@ -80,6 +87,14 @@ const SettingsPopoverBodyContent = ({ step }: Props) => {
/>
)
}
case InputStepType.CHOICE: {
return (
<ChoiceInputSettingsBody
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
default: {
return <></>
}

View File

@ -0,0 +1,44 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { ChoiceInputOptions } from 'models'
import React from 'react'
type ChoiceInputSettingsBodyProps = {
options?: ChoiceInputOptions
onOptionsChange: (options: ChoiceInputOptions) => void
}
export const ChoiceInputSettingsBody = ({
options,
onOptionsChange,
}: ChoiceInputSettingsBodyProps) => {
const handleIsMultipleChange = (isMultipleChoice: boolean) =>
options && onOptionsChange({ ...options, isMultipleChoice })
const handleButtonLabelChange = (buttonLabel: string) =>
options && onOptionsChange({ ...options, buttonLabel })
return (
<Stack spacing={4}>
<SwitchWithLabel
id={'is-multiple'}
label={'Multiple choice?'}
initialValue={options?.isMultipleChoice ?? false}
onCheckChange={handleIsMultipleChange}
/>
{options?.isMultipleChoice && (
<Stack>
<FormLabel mb="0" htmlFor="send">
Button label:
</FormLabel>
<DebouncedInput
id="send"
initialValue={options?.buttonLabel ?? 'Send'}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>
)}
</Stack>
)
}

View File

@ -1,16 +1,18 @@
import { Box, BoxProps } from '@chakra-ui/react'
import { ConnectingSourceIds, useGraph } from 'contexts/GraphContext'
import React, { MouseEvent } from 'react'
export const SourceEndpoint = ({
onConnectionDragStart,
source,
...props
}: BoxProps & {
onConnectionDragStart?: () => void
source: ConnectingSourceIds
}) => {
const { setConnectingIds } = useGraph()
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
if (!onConnectionDragStart) return
e.stopPropagation()
onConnectionDragStart()
setConnectingIds({ source })
}
return (

View File

@ -8,18 +8,18 @@ import {
} from '@chakra-ui/react'
import React, { useEffect, useMemo, useState } from 'react'
import { Block, Step } from 'models'
import { SourceEndpoint } from './SourceEndpoint'
import { useGraph } from 'contexts/GraphContext'
import { StepIcon } from 'components/board/StepTypesList/StepIcon'
import { isDefined, isTextBubbleStep } from 'utils'
import { isChoiceInput, isDefined, isInputStep, isTextBubbleStep } from 'utils'
import { Coordinates } from '@dnd-kit/core/dist/types'
import { TextEditor } from './TextEditor/TextEditor'
import { StepNodeLabel } from './StepNodeLabel'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { StepNodeContent } from './StepNodeContent'
import { useTypebot } from 'contexts/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu'
import { StepNodeContextMenu } from './RightClickMenu'
import { SettingsPopoverContent } from './SettingsPopoverContent'
import { DraggableStep } from 'contexts/DndContext'
import { StepNodeContextMenu } from './StepNodeContextMenu'
import { SourceEndpoint } from './SourceEndpoint'
export const StepNode = ({
step,
@ -38,7 +38,7 @@ export const StepNode = ({
) => void
}) => {
const { setConnectingIds, connectingIds } = useGraph()
const { deleteStep, typebot } = useTypebot()
const { moveStep, typebot } = useTypebot()
const [isConnecting, setIsConnecting] = useState(false)
const [mouseDownEvent, setMouseDownEvent] =
useState<{ absolute: Coordinates; relative: Coordinates }>()
@ -69,9 +69,6 @@ export const StepNode = ({
})
}
const handleConnectionDragStart = () =>
setConnectingIds({ source: { blockId: step.blockId, stepId: step.id } })
const handleMouseDown = (e: React.MouseEvent) => {
if (!onMouseDown) return
e.stopPropagation()
@ -104,7 +101,7 @@ export const StepNode = ({
(event.movementX > 0 || event.movementY > 0)
if (isMovingAndIsMouseDown && step.type !== 'start') {
onMouseDown(mouseDownEvent, step)
deleteStep(step.id)
moveStep(step.id)
setMouseDownEvent(undefined)
}
const element = event.currentTarget as HTMLDivElement
@ -164,6 +161,7 @@ export const StepNode = ({
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
data-testid={`step-${step.id}`}
w="full"
>
{connectedStubPosition === 'left' && (
<Box
@ -184,19 +182,24 @@ export const StepNode = ({
rounded="lg"
cursor={'pointer'}
bgColor="white"
align="flex-start"
>
<StepIcon type={step.type} />
<StepNodeLabel {...step} />
{isConnectable && (
<StepIcon type={step.type} mt="1" />
<StepNodeContent step={step} />
{isConnectable && !isChoiceInput(step) && (
<SourceEndpoint
onConnectionDragStart={handleConnectionDragStart}
source={{
blockId: step.blockId,
stepId: step.id,
}}
pos="absolute"
right="20px"
right="15px"
top="19px"
/>
)}
</HStack>
{isDefined(connectedStubPosition) && (
{isDefined(connectedStubPosition) && !isChoiceInput(step) && (
<Box
h="2px"
pos="absolute"
@ -209,7 +212,7 @@ export const StepNode = ({
)}
</Flex>
</PopoverTrigger>
<SettingsPopoverContent step={step} />
{isInputStep(step) && <SettingsPopoverContent step={step} />}
</Popover>
)}
</ContextMenu>

View File

@ -1,19 +1,24 @@
import { Flex, Text } from '@chakra-ui/react'
import { Step, StartStep, BubbleStepType, InputStepType } from 'models'
import { ChoiceItemsList } from './ChoiceInputStepNode/ChoiceItemsList'
export const StepNodeLabel = (props: Step | StartStep) => {
switch (props.type) {
type Props = {
step: Step | StartStep
isConnectable?: boolean
}
export const StepNodeContent = ({ step }: Props) => {
switch (step.type) {
case BubbleStepType.TEXT: {
return (
<Flex
flexDir={'column'}
opacity={props.content.html === '' ? '0.5' : '1'}
opacity={step.content.html === '' ? '0.5' : '1'}
className="slate-html-container"
dangerouslySetInnerHTML={{
__html:
props.content.html === ''
step.content.html === ''
? `<p>Click to edit...</p>`
: props.content.html,
: step.content.html,
}}
/>
)
@ -21,47 +26,50 @@ export const StepNodeLabel = (props: Step | StartStep) => {
case InputStepType.TEXT: {
return (
<Text color={'gray.500'}>
{props.options?.labels?.placeholder ?? 'Type your answer...'}
{step.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case InputStepType.NUMBER: {
return (
<Text color={'gray.500'}>
{props.options?.labels?.placeholder ?? 'Type your answer...'}
{step.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case InputStepType.EMAIL: {
return (
<Text color={'gray.500'}>
{props.options?.labels?.placeholder ?? 'Type your email...'}
{step.options?.labels?.placeholder ?? 'Type your email...'}
</Text>
)
}
case InputStepType.URL: {
return (
<Text color={'gray.500'}>
{props.options?.labels?.placeholder ?? 'Type your URL...'}
{step.options?.labels?.placeholder ?? 'Type your URL...'}
</Text>
)
}
case InputStepType.DATE: {
return (
<Text color={'gray.500'}>
{props.options?.labels?.from ?? 'Pick a date...'}
{step.options?.labels?.from ?? 'Pick a date...'}
</Text>
)
}
case InputStepType.PHONE: {
return (
<Text color={'gray.500'}>
{props.options?.labels?.placeholder ?? 'Your phone number...'}
{step.options?.labels?.placeholder ?? 'Your phone number...'}
</Text>
)
}
case InputStepType.CHOICE: {
return <ChoiceItemsList step={step} />
}
case 'start': {
return <Text>{props.label}</Text>
return <Text>{step.label}</Text>
}
default: {
return <Text>No input</Text>

View File

@ -1,7 +1,7 @@
import { StackProps, HStack } from '@chakra-ui/react'
import { StartStep, Step } from 'models'
import { StepIcon } from 'components/board/StepTypesList/StepIcon'
import { StepNodeLabel } from './StepNodeLabel'
import { StepNodeContent } from './StepNodeContent'
export const StepNodeOverlay = ({
step,
@ -19,7 +19,7 @@ export const StepNodeOverlay = ({
{...props}
>
<StepIcon type={step.type} />
<StepNodeLabel {...step} />
<StepNodeContent step={step} />
</HStack>
)
}

View File

@ -2,24 +2,33 @@ import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
import { Step, Table } from 'models'
import { DraggableStep, useDnd } from 'contexts/DndContext'
import { Coordinates } from 'contexts/GraphContext'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { StepNode, StepNodeOverlay } from './StepNode'
import { useTypebot } from 'contexts/TypebotContext'
export const StepsList = ({
blockId,
steps,
showSortPlaceholders,
onMouseUp,
}: {
blockId: string
steps: Table<Step>
showSortPlaceholders: boolean
onMouseUp: (index: number) => void
}) => {
const { draggedStep, setDraggedStep, draggedStepType } = useDnd()
const {
draggedStep,
setDraggedStep,
draggedStepType,
mouseOverBlockId,
setDraggedStepType,
setMouseOverBlockId,
} = useDnd()
const { createStep } = useTypebot()
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
number | undefined
>()
const showSortPlaceholders = useMemo(
() => mouseOverBlockId === blockId && (draggedStep || draggedStepType),
[mouseOverBlockId, blockId, draggedStep, draggedStepType]
)
const [position, setPosition] = useState({
x: 0,
y: 0,
@ -48,8 +57,16 @@ export const StepsList = ({
const handleMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
if (expandedPlaceholderIndex === undefined) return
e.stopPropagation()
setMouseOverBlockId(undefined)
setExpandedPlaceholderIndex(undefined)
onMouseUp(expandedPlaceholderIndex)
if (draggedStepType) {
createStep(blockId, draggedStepType, expandedPlaceholderIndex)
setDraggedStepType(undefined)
}
if (draggedStep) {
createStep(blockId, draggedStep, expandedPlaceholderIndex)
setDraggedStep(undefined)
}
}
const handleStepMouseDown = (
@ -58,6 +75,7 @@ export const StepsList = ({
) => {
setPosition(absolute)
setRelativeCoordinates(relative)
setMouseOverBlockId(blockId)
setDraggedStep(step)
}
@ -118,7 +136,6 @@ export const StepsList = ({
<Portal>
<StepNodeOverlay
step={draggedStep}
onMouseUp={handleMouseUp}
pos="fixed"
top="0"
left="0"

View File

@ -1,24 +1,17 @@
import { useEventListener } from '@chakra-ui/hooks'
import { Coordinates } from '@dnd-kit/core/dist/types'
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
import {
blockWidth,
firstStepOffsetY,
spaceBetweenSteps,
stubLength,
useGraph,
} from 'contexts/GraphContext'
import { useGraph, ConnectingIds } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { Target } from 'models'
import React, { useMemo, useState } from 'react'
import {
computeFlowChartConnectorPath,
getAnchorsPosition,
computeDrawingConnectedPath,
computeDrawingPathToMouse,
} from 'services/graph'
import { roundCorners } from 'svg-round-corners'
export const DrawingEdge = () => {
const { graphPosition, setConnectingIds, connectingIds } = useGraph()
const { typebot, updateStep } = useTypebot()
const { typebot, updateStep, updateChoiceItem } = useTypebot()
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 })
const sourceBlock = useMemo(
@ -28,33 +21,21 @@ export const DrawingEdge = () => {
)
const path = useMemo(() => {
if (!sourceBlock || !typebot) return ``
if (connectingIds?.target) {
const targetedBlock = typebot?.blocks.byId[connectingIds.target.blockId]
const targetedStepIndex = connectingIds.target.stepId
? targetedBlock.stepIds.findIndex(
(stepId) => stepId === connectingIds.target?.stepId
)
: undefined
const anchorsPosition = getAnchorsPosition(
sourceBlock,
targetedBlock,
sourceBlock?.stepIds.findIndex(
(stepId) => stepId === connectingIds?.source.stepId
),
targetedStepIndex
)
return computeFlowChartConnectorPath(anchorsPosition)
}
return computeConnectingEdgePath(
sourceBlock?.graphCoordinates,
mousePosition,
sourceBlock.stepIds.findIndex(
(stepId) => stepId === connectingIds?.source.stepId
)
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sourceBlock, mousePosition])
if (!sourceBlock || !typebot || !connectingIds) return ``
return connectingIds?.target
? computeDrawingConnectedPath(
connectingIds as Omit<ConnectingIds, 'target'> & { target: Target },
sourceBlock,
typebot
)
: computeDrawingPathToMouse(
sourceBlock,
connectingIds,
mousePosition,
typebot.steps
)
}, [sourceBlock, typebot, connectingIds, mousePosition])
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({
@ -64,11 +45,19 @@ export const DrawingEdge = () => {
}
useEventListener('mousemove', handleMouseMove)
useEventListener('mouseup', () => {
if (connectingIds?.target)
updateStep(connectingIds.source.stepId, { target: connectingIds.target })
if (connectingIds?.target) createNewEdge(connectingIds)
setConnectingIds(null)
})
const createNewEdge = (connectingIds: ConnectingIds) =>
connectingIds.source.choiceItemId
? updateChoiceItem(connectingIds.source.choiceItemId, {
target: connectingIds.target,
})
: updateStep(connectingIds.source.stepId, {
target: connectingIds.target,
})
if ((mousePosition.x === 0 && mousePosition.y === 0) || !connectingIds)
return <></>
return (
@ -81,44 +70,3 @@ export const DrawingEdge = () => {
/>
)
}
const computeConnectingEdgePath = (
blockPosition: Coordinates,
mousePosition: Coordinates,
stepIndex: number
): string => {
const sourcePosition = {
x:
mousePosition.x - blockPosition.x > blockWidth / 2
? blockPosition.x + blockWidth - 40
: blockPosition.x + 40,
y: blockPosition.y + firstStepOffsetY + stepIndex * spaceBetweenSteps,
}
const sourceType =
mousePosition.x - blockPosition.x > blockWidth / 2 ? 'right' : 'left'
const segments = computeThreeSegments(
sourcePosition,
mousePosition,
sourceType
)
return roundCorners(
`M${sourcePosition.x},${sourcePosition.y} ${segments}`,
10
).path
}
const computeThreeSegments = (
sourcePosition: Coordinates,
targetPosition: Coordinates,
sourceType: 'right' | 'left'
) => {
const segments = []
const firstSegmentX =
sourceType === 'right'
? sourcePosition.x + stubLength + 40
: sourcePosition.x - stubLength - 40
segments.push(`L${firstSegmentX},${sourcePosition.y}`)
segments.push(`L${firstSegmentX},${targetPosition.y}`)
segments.push(`L${targetPosition.x},${targetPosition.y}`)
return segments.join(' ')
}

View File

@ -1,10 +1,15 @@
import { isDefined } from '@udecode/plate-core'
import assert from 'assert'
import { Coordinates, useGraph } from 'contexts/GraphContext'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { ChoiceItem } from 'models'
import React, { useMemo } from 'react'
import {
getAnchorsPosition,
computeFlowChartConnectorPath,
getSourceChoiceItemIndex,
} from 'services/graph'
import { isChoiceInput } from 'utils'
export type AnchorsPositionProps = {
sourcePosition: Coordinates
@ -13,7 +18,13 @@ export type AnchorsPositionProps = {
totalSegments: number
}
export const Edge = ({ stepId }: { stepId: string }) => {
export const Edge = ({
stepId,
item,
}: {
stepId: string
item?: ChoiceItem
}) => {
const { typebot } = useTypebot()
const { previewingIds } = useGraph()
const step = typebot?.steps.byId[stepId]
@ -27,29 +38,35 @@ export const Edge = ({ stepId }: { stepId: string }) => {
const { sourceBlock, targetBlock, targetStepIndex } = useMemo(() => {
if (!typebot) return {}
const step = typebot.steps.byId[stepId]
if (!step.target) return {}
const sourceBlock = typebot.blocks.byId[step.blockId]
const targetBlock = typebot.blocks.byId[step.target.blockId]
const targetStepIndex = step.target.stepId
? targetBlock.stepIds.indexOf(step.target.stepId)
const targetBlockId = item?.target?.blockId ?? step.target?.blockId
assert(isDefined(targetBlockId))
const targetBlock = typebot.blocks.byId[targetBlockId]
const targetStepId = item?.target?.stepId ?? step.target?.stepId
const targetStepIndex = targetStepId
? targetBlock.stepIds.indexOf(targetStepId)
: undefined
return {
sourceBlock,
targetBlock,
targetStepIndex,
}
}, [stepId, typebot])
}, [item?.target?.blockId, item?.target?.stepId, stepId, typebot])
const path = useMemo(() => {
if (!sourceBlock || !targetBlock) return ``
const anchorsPosition = getAnchorsPosition(
if (!sourceBlock || !targetBlock || !step) return ``
const sourceChoiceItemIndex = isChoiceInput(step)
? getSourceChoiceItemIndex(step, item?.id)
: undefined
const anchorsPosition = getAnchorsPosition({
sourceBlock,
targetBlock,
sourceBlock.stepIds.indexOf(stepId),
targetStepIndex
)
sourceStepIndex: sourceBlock.stepIds.indexOf(stepId),
targetStepIndex,
sourceChoiceItemIndex,
})
return computeFlowChartConnectorPath(anchorsPosition)
}, [sourceBlock, stepId, targetBlock, targetStepIndex])
}, [item, sourceBlock, step, stepId, targetBlock, targetStepIndex])
return (
<path

View File

@ -1,7 +1,8 @@
import { chakra } from '@chakra-ui/system'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { ChoiceItem } from 'models'
import React, { useMemo } from 'react'
import { isDefined } from 'utils'
import { isDefined, isSingleChoiceInput } from 'utils'
import { DrawingEdge } from './DrawingEdge'
import { Edge } from './Edge'
@ -13,6 +14,18 @@ export const Edges = () => {
isDefined(typebot.steps.byId[stepId].target)
)
}, [typebot])
const singleChoiceItemsWithTarget: ChoiceItem[] = useMemo(() => {
if (!typebot) return []
return typebot.choiceItems.allIds
.filter(
(itemId) =>
isDefined(typebot.choiceItems.byId[itemId].target) &&
isSingleChoiceInput(
typebot.steps.byId[typebot.choiceItems.byId[itemId].stepId]
)
)
.map((itemId) => typebot.choiceItems.byId[itemId])
}, [typebot])
return (
<chakra.svg
@ -27,6 +40,9 @@ export const Edges = () => {
{stepIdsWithTarget.map((stepId) => (
<Edge key={stepId} stepId={stepId} />
))}
{singleChoiceItemsWithTarget.map((item) => (
<Edge key={item.id} stepId={item.stepId} item={item} />
))}
<marker
id={'arrow'}
refX="8"

View File

@ -1,4 +1,10 @@
import { BubbleStep, BubbleStepType, InputStep, InputStepType } from 'models'
import {
BubbleStep,
BubbleStepType,
ChoiceItem,
InputStep,
InputStepType,
} from 'models'
import {
createContext,
Dispatch,
@ -16,16 +22,23 @@ const dndContext = createContext<{
setDraggedStepType: Dispatch<SetStateAction<DraggableStepType | undefined>>
draggedStep?: DraggableStep
setDraggedStep: Dispatch<SetStateAction<DraggableStep | undefined>>
}>({
setDraggedStep: () => console.log("I'm not implemented"),
setDraggedStepType: () => console.log("I'm not implemented"),
})
draggedChoiceItem?: ChoiceItem
setDraggedChoiceItem: Dispatch<SetStateAction<ChoiceItem | undefined>>
mouseOverBlockId?: string
setMouseOverBlockId: Dispatch<SetStateAction<string | undefined>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const DndContext = ({ children }: { children: ReactNode }) => {
const [draggedStep, setDraggedStep] = useState<DraggableStep | undefined>()
const [draggedStep, setDraggedStep] = useState<DraggableStep>()
const [draggedStepType, setDraggedStepType] = useState<
DraggableStepType | undefined
>()
const [draggedChoiceItem, setDraggedChoiceItem] = useState<
ChoiceItem | undefined
>()
const [mouseOverBlockId, setMouseOverBlockId] = useState<string>()
return (
<dndContext.Provider
@ -34,6 +47,10 @@ export const DndContext = ({ children }: { children: ReactNode }) => {
setDraggedStep,
draggedStepType,
setDraggedStepType,
draggedChoiceItem,
setDraggedChoiceItem,
mouseOverBlockId,
setMouseOverBlockId,
}}
>
{children}

View File

@ -27,6 +27,8 @@ export const blockAnchorsOffset = {
export const firstStepOffsetY = 88
export const spaceBetweenSteps = 62
export const firstChoiceItemOffsetY = 20
export type Coordinates = { x: number; y: number }
type Position = Coordinates & { scale: number }
@ -43,18 +45,24 @@ export type Node = Omit<Block, 'steps'> & {
const graphPositionDefaultValue = { x: 400, y: 100, scale: 1 }
type ConnectingIdsProps = {
source: { blockId: string; stepId: string }
export type ConnectingIds = {
source: ConnectingSourceIds
target?: Target
} | null
}
export type ConnectingSourceIds = {
blockId: string
stepId: string
choiceItemId?: string
}
type PreviewingIdsProps = { sourceId?: string; targetId?: string }
const graphContext = createContext<{
graphPosition: Position
setGraphPosition: Dispatch<SetStateAction<Position>>
connectingIds: ConnectingIdsProps
setConnectingIds: Dispatch<SetStateAction<ConnectingIdsProps>>
connectingIds: ConnectingIds | null
setConnectingIds: Dispatch<SetStateAction<ConnectingIds | null>>
previewingIds: PreviewingIdsProps
setPreviewingIds: Dispatch<SetStateAction<PreviewingIdsProps>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -66,7 +74,7 @@ const graphContext = createContext<{
export const GraphProvider = ({ children }: { children: ReactNode }) => {
const [graphPosition, setGraphPosition] = useState(graphPositionDefaultValue)
const [connectingIds, setConnectingIds] = useState<ConnectingIdsProps>(null)
const [connectingIds, setConnectingIds] = useState<ConnectingIds | null>(null)
const [previewingIds, setPreviewingIds] = useState<PreviewingIdsProps>({})
return (

View File

@ -27,6 +27,7 @@ import { isDefined } from 'utils'
import { BlocksActions, blocksActions } from './actions/blocks'
import { useImmer, Updater } from 'use-immer'
import { stepsAction, StepsActions } from './actions/steps'
import { choiceItemsAction, ChoiceItemsActions } from './actions/choiceItems'
type UpdateTypebotPayload = Partial<{
theme: Theme
@ -46,7 +47,8 @@ const typebotContext = createContext<
updateTypebot: (updates: UpdateTypebotPayload) => void
publishTypebot: () => void
} & BlocksActions &
StepsActions
StepsActions &
ChoiceItemsActions
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
>({})
@ -202,6 +204,7 @@ export const TypebotContext = ({
updateTypebot: updateLocalTypebot,
...blocksActions(setLocalTypebot as Updater<Typebot>),
...stepsAction(setLocalTypebot as Updater<Typebot>),
...choiceItemsAction(setLocalTypebot as Updater<Typebot>),
}}
>
{children}

View File

@ -0,0 +1,79 @@
import { ChoiceItem, InputStepType, Typebot } from 'models'
import { Updater } from 'use-immer'
import { WritableDraft } from 'immer/dist/types/types-external'
import { generate } from 'short-uuid'
import assert from 'assert'
export type ChoiceItemsActions = {
createChoiceItem: (
item: ChoiceItem | Pick<ChoiceItem, 'stepId'>,
index?: number
) => void
updateChoiceItem: (
itemId: string,
updates: Partial<Omit<ChoiceItem, 'id'>>
) => void
deleteChoiceItem: (itemId: string) => void
}
export const choiceItemsAction = (
setTypebot: Updater<Typebot>
): ChoiceItemsActions => ({
createChoiceItem: (
item: ChoiceItem | Pick<ChoiceItem, 'stepId'>,
index?: number
) => {
setTypebot((typebot) => {
createChoiceItemDraft(typebot, item, index)
})
},
updateChoiceItem: (
itemId: string,
updates: Partial<Omit<ChoiceItem, 'id'>>
) =>
setTypebot((typebot) => {
typebot.choiceItems.byId[itemId] = {
...typebot.choiceItems.byId[itemId],
...updates,
}
}),
deleteChoiceItem: (itemId: string) => {
setTypebot((typebot) => {
removeChoiceItemFromStep(typebot, itemId)
deleteChoiceItemDraft(typebot, itemId)
})
},
})
const removeChoiceItemFromStep = (
typebot: WritableDraft<Typebot>,
itemId: string
) => {
const containerStepId = typebot.choiceItems.byId[itemId].stepId
const step = typebot.steps.byId[containerStepId]
assert(step.type === InputStepType.CHOICE)
step.options?.itemIds.splice(step.options.itemIds.indexOf(itemId), 1)
}
export const deleteChoiceItemDraft = (
typebot: WritableDraft<Typebot>,
itemId: string
) => {
delete typebot.choiceItems.byId[itemId]
const index = typebot.choiceItems.allIds.indexOf(itemId)
if (index !== -1) typebot.choiceItems.allIds.splice(index, 1)
}
export const createChoiceItemDraft = (
typebot: WritableDraft<Typebot>,
item: ChoiceItem | Pick<ChoiceItem, 'stepId'>,
index?: number
) => {
const step = typebot.steps.byId[item.stepId]
assert(step.type === InputStepType.CHOICE)
const newItem: ChoiceItem =
'id' in item ? { ...item } : { id: generate(), stepId: item.stepId }
typebot.choiceItems.byId[newItem.id] = newItem
typebot.choiceItems.allIds.push(newItem.id)
step.options.itemIds.splice(index ?? 0, 0, newItem.id)
}

View File

@ -1,8 +1,16 @@
import { BubbleStepType, InputStepType, Step, Typebot } from 'models'
import {
BubbleStepType,
ChoiceInputStep,
InputStepType,
Step,
Typebot,
} from 'models'
import { parseNewStep } from 'services/typebots'
import { Updater } from 'use-immer'
import { removeEmptyBlocks } from './blocks'
import { WritableDraft } from 'immer/dist/types/types-external'
import { createChoiceItemDraft, deleteChoiceItemDraft } from './choiceItems'
import { isChoiceInput } from 'utils'
export type StepsActions = {
createStep: (
@ -14,6 +22,7 @@ export type StepsActions = {
stepId: string,
updates: Partial<Omit<Step, 'id' | 'type'>>
) => void
moveStep: (stepId: string) => void
deleteStep: (stepId: string) => void
}
@ -32,11 +41,17 @@ export const stepsAction = (setTypebot: Updater<Typebot>): StepsActions => ({
setTypebot((typebot) => {
typebot.steps.byId[stepId] = { ...typebot.steps.byId[stepId], ...updates }
}),
deleteStep: (stepId: string) => {
moveStep: (stepId: string) => {
setTypebot((typebot) => {
removeStepIdFromBlock(typebot, stepId)
})
},
deleteStep: (stepId: string) => {
setTypebot((typebot) => {
const step = typebot.steps.byId[stepId]
if (isChoiceInput(step)) deleteChoiceItemsInsideStep(typebot, step)
removeStepIdFromBlock(typebot, stepId)
deleteStepDraft(typebot, stepId)
removeEmptyBlocks(typebot)
})
},
})
@ -45,13 +60,8 @@ const removeStepIdFromBlock = (
typebot: WritableDraft<Typebot>,
stepId: string
) => {
const containerBlockId = typebot.blocks.allIds.find((blockId) =>
typebot.blocks.byId[blockId].stepIds.includes(stepId)
) as string
typebot.blocks.byId[containerBlockId].stepIds.splice(
typebot.blocks.byId[containerBlockId].stepIds.indexOf(stepId),
1
)
const containerBlock = typebot.blocks.byId[typebot.steps.byId[stepId].blockId]
containerBlock.stepIds.splice(containerBlock.stepIds.indexOf(stepId), 1)
}
export const deleteStepDraft = (
@ -74,6 +84,16 @@ export const createStepDraft = (
? parseNewStep(step, blockId)
: { ...step, blockId }
typebot.steps.byId[newStep.id] = newStep
if (isChoiceInput(newStep) && newStep.options.itemIds.length === 0)
createChoiceItemDraft(typebot, { stepId: newStep.id })
typebot.steps.allIds.push(newStep.id)
typebot.blocks.byId[blockId].stepIds.splice(index ?? 0, 0, newStep.id)
}
const deleteChoiceItemsInsideStep = (
typebot: WritableDraft<Typebot>,
step: ChoiceInputStep
) =>
step.options?.itemIds.forEach((itemId) =>
deleteChoiceItemDraft(typebot, itemId)
)

View File

@ -1 +1 @@
export { TypebotContext } from './TypebotContext'
export { TypebotContext, useTypebot } from './TypebotContext'

View File

@ -127,4 +127,5 @@ const parseTypebotToPublicTypebot = (
theme: typebot.theme,
settings: typebot.settings,
publicId: typebot.publicId,
choiceItems: typebot.choiceItems,
})

View File

@ -6,6 +6,7 @@ import {
Typebot,
Table,
Step,
ChoiceItem,
} from 'models'
export const parseTestTypebot = ({
@ -14,12 +15,14 @@ export const parseTestTypebot = ({
name,
blocks,
steps,
choiceItems,
}: {
id: string
ownerId: string
name: string
blocks: Table<Block>
steps: Table<Step>
choiceItems?: Table<ChoiceItem>
}): Typebot => {
const theme: Theme = {
general: {
@ -67,6 +70,7 @@ export const parseTestTypebot = ({
},
allIds: ['step0', ...steps.allIds],
},
choiceItems: choiceItems ?? { byId: {}, allIds: [] },
publicId: null,
publishedTypebotId: null,
updatedAt: new Date(),

View File

@ -175,7 +175,6 @@ describe('Date input', () => {
describe('Phone number input', () => {
beforeEach(() => {
cy.task('seed')
cy.log(JSON.stringify({ type: InputStepType.PHONE }))
createTypebotWithStep({ type: InputStepType.PHONE })
cy.signOut()
})
@ -207,6 +206,53 @@ describe('Phone number input', () => {
})
})
describe('Button input', () => {
beforeEach(() => {
cy.task('seed')
createTypebotWithStep({ type: InputStepType.CHOICE })
cy.signOut()
})
it('Can edit choice items', () => {
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot3/edit')
cy.findByDisplayValue('Click to edit').type('Item 1{enter}')
cy.findByText('Item 1').trigger('mouseover')
cy.findByRole('button', { name: 'Add item' }).click()
cy.findByDisplayValue('Click to edit').type('Item 2{enter}')
cy.findByRole('button', { name: 'Add item' }).click()
cy.findByDisplayValue('Click to edit').type('Item 3{enter}')
cy.findByText('Item 2').rightclick()
cy.findByRole('menuitem', { name: 'Delete' }).click()
cy.findByText('Item 2').should('not.exist')
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('button', { name: 'Item 3' }).click()
getIframeBody().findByRole('button', { name: 'Item 3' }).should('not.exist')
getIframeBody().findByText('Item 3')
cy.findByRole('button', { name: 'Close' }).click()
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('checkbox', { name: 'Multiple choice?' }).check({
force: true,
})
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.wait(200)
cy.findByTestId('step-step1').click({ force: true })
cy.findByText('Item 1').trigger('mouseover')
cy.findByRole('button', { name: 'Add item' }).click()
cy.findByDisplayValue('Click to edit').type('Item 2{enter}')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('checkbox', { name: 'Item 3' }).click()
getIframeBody().findByRole('checkbox', { name: 'Item 1' }).click()
getIframeBody().findByRole('button', { name: 'Go' }).click()
getIframeBody().findByText('Item 3, Item 1').should('exist')
})
it('Single choice targets should work', () => {
//TO-DO
})
})
const createTypebotWithStep = (step: Omit<InputStep, 'id' | 'blockId'>) => {
cy.task(
'createTypebot',
@ -216,7 +262,17 @@ const createTypebotWithStep = (step: Omit<InputStep, 'id' | 'blockId'>) => {
ownerId: 'test2',
steps: {
byId: {
step1: { ...step, id: 'step1', blockId: 'block1' },
step1: {
...step,
id: 'step1',
blockId: 'block1',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
options:
step.type === InputStepType.CHOICE
? { itemIds: ['item1'] }
: undefined,
},
},
allIds: ['step1'],
},
@ -231,6 +287,13 @@ const createTypebotWithStep = (step: Omit<InputStep, 'id' | 'blockId'>) => {
},
allIds: ['block1'],
},
choiceItems:
step.type === InputStepType.CHOICE
? {
byId: { item1: { stepId: 'step1', id: 'item1' } },
allIds: ['item1'],
}
: undefined,
})
)
}

View File

@ -1,5 +1,5 @@
import { Coordinates } from '@dnd-kit/core/dist/types'
import { Block } from 'models'
import { Block, ChoiceInputStep, Step, Table, Target, Typebot } from 'models'
import { AnchorsPositionProps } from 'components/board/graph/Edges/Edge'
import {
stubLength,
@ -7,9 +7,11 @@ import {
blockAnchorsOffset,
spaceBetweenSteps,
firstStepOffsetY,
firstChoiceItemOffsetY,
ConnectingIds,
} from 'contexts/GraphContext'
import { roundCorners } from 'svg-round-corners'
import { isDefined } from 'utils'
import { isChoiceInput, isDefined } from 'utils'
export const computeDropOffPath = (
sourcePosition: Coordinates,
@ -25,13 +27,18 @@ export const computeDropOffPath = (
export const computeSourceCoordinates = (
sourcePosition: Coordinates,
sourceStepIndex: number
sourceStepIndex: number,
sourceChoiceItemIndex?: number
) => ({
x: sourcePosition.x + blockWidth,
y:
(sourcePosition.y ?? 0) +
firstStepOffsetY +
spaceBetweenSteps * sourceStepIndex,
spaceBetweenSteps * sourceStepIndex +
(isDefined(sourceChoiceItemIndex)
? firstChoiceItemOffsetY +
(sourceChoiceItemIndex ?? 0) * spaceBetweenSteps
: 0),
})
export const computeFlowChartConnectorPath = ({
@ -142,12 +149,20 @@ const computeFiveSegments = (
return segments.join(' ')
}
export const getAnchorsPosition = (
sourceBlock: Block,
targetBlock: Block,
sourceStepIndex: number,
type GetAnchorsPositionParams = {
sourceBlock: Block
targetBlock: Block
sourceStepIndex: number
sourceChoiceItemIndex?: number
targetStepIndex?: number
): AnchorsPositionProps => {
}
export const getAnchorsPosition = ({
sourceBlock,
targetBlock,
sourceStepIndex,
sourceChoiceItemIndex,
targetStepIndex,
}: GetAnchorsPositionParams): AnchorsPositionProps => {
const targetOffsetY = isDefined(targetStepIndex)
? (targetBlock.graphCoordinates.y ?? 0) +
firstStepOffsetY +
@ -156,7 +171,8 @@ export const getAnchorsPosition = (
const sourcePosition = computeSourceCoordinates(
sourceBlock.graphCoordinates,
sourceStepIndex
sourceStepIndex,
sourceChoiceItemIndex
)
let sourceType: 'right' | 'left' = 'right'
if (sourceBlock.graphCoordinates.x > targetBlock.graphCoordinates.x) {
@ -230,3 +246,95 @@ const parseBlockAnchorPosition = (
}
}
}
export const computeDrawingConnectedPath = (
connectingIds: Omit<ConnectingIds, 'target'> & { target: Target },
sourceBlock: Block,
typebot: Typebot
) => {
if (!sourceBlock) return ``
const targetBlock = typebot.blocks.byId[connectingIds.target.blockId]
const targetStepIndex = connectingIds.target.stepId
? targetBlock.stepIds.findIndex(
(stepId) => stepId === connectingIds.target?.stepId
)
: undefined
const sourceStepIndex = sourceBlock?.stepIds.indexOf(
connectingIds?.source.stepId
)
const sourceStep = typebot.steps.byId[connectingIds?.source.stepId]
const sourceChoiceItemIndex = isChoiceInput(sourceStep)
? getSourceChoiceItemIndex(sourceStep, connectingIds.source.choiceItemId)
: undefined
const anchorsPosition = getAnchorsPosition({
sourceBlock,
targetBlock,
sourceStepIndex,
sourceChoiceItemIndex,
targetStepIndex,
})
return computeFlowChartConnectorPath(anchorsPosition)
}
export const computeDrawingPathToMouse = (
sourceBlock: Block,
connectingIds: ConnectingIds,
mousePosition: Coordinates,
steps: Table<Step>
) => {
const sourceStep = steps.byId[connectingIds?.source.stepId ?? '']
return computeConnectingEdgePath({
blockPosition: sourceBlock?.graphCoordinates,
mousePosition,
stepIndex: sourceBlock.stepIds.findIndex(
(stepId) => stepId === connectingIds?.source.stepId
),
choiceItemIndex: isChoiceInput(sourceStep)
? getSourceChoiceItemIndex(sourceStep, connectingIds?.source.choiceItemId)
: undefined,
})
}
const computeConnectingEdgePath = ({
blockPosition,
mousePosition,
stepIndex,
choiceItemIndex,
}: {
blockPosition: Coordinates
mousePosition: Coordinates
stepIndex: number
choiceItemIndex?: number
}): string => {
const sourcePosition = {
x:
mousePosition.x - blockPosition.x > blockWidth / 2
? blockPosition.x + blockWidth - 40
: blockPosition.x + 40,
y:
blockPosition.y +
firstStepOffsetY +
stepIndex * spaceBetweenSteps +
(isDefined(choiceItemIndex)
? firstChoiceItemOffsetY + (choiceItemIndex ?? 0) * spaceBetweenSteps
: 0),
}
const sourceType =
mousePosition.x - blockPosition.x > blockWidth / 2 ? 'right' : 'left'
const segments = computeThreeSegments(
sourcePosition,
mousePosition,
sourceType
)
return roundCorners(
`M${sourcePosition.x},${sourcePosition.y} ${segments}`,
10
).path
}
export const getSourceChoiceItemIndex = (
step: ChoiceInputStep,
itemId?: string
) =>
itemId ? step.options.itemIds.indexOf(itemId) : step.options.itemIds.length

View File

@ -17,6 +17,7 @@ export const parseTypebotToPublicTypebot = (
theme: typebot.theme,
settings: typebot.settings,
publicId: typebot.publicId,
choiceItems: typebot.choiceItems,
})
export const createPublishedTypebot = async (

View File

@ -10,6 +10,7 @@ import {
InputStep,
BubbleStepType,
InputStepType,
ChoiceInputStep,
} from 'models'
import shortId from 'short-uuid'
import { Typebot } from 'models'
@ -122,6 +123,17 @@ export const parseNewStep = (
...textStep,
}
}
case InputStepType.CHOICE: {
const choiceInput: Pick<ChoiceInputStep, 'type' | 'options'> = {
type,
options: { itemIds: [] },
}
return {
id,
blockId,
...choiceInput,
}
}
default: {
return {
id,
@ -201,6 +213,7 @@ export const parseNewTypebot = ({
ownerId,
blocks: { byId: { [startBlockId]: startBlock }, allIds: [startBlockId] },
steps: { byId: { [startStepId]: startStep }, allIds: [startStepId] },
choiceItems: { byId: {}, allIds: [] },
theme,
settings,
}

View File

@ -9,7 +9,7 @@
"db": "*",
"fast-equals": "^2.0.4",
"models": "*",
"react-frame-component": "^5.2.1",
"react-frame-component": "5.2.2-alpha.0",
"react-phone-number-input": "^3.1.44",
"react-scroll": "^1.8.4",
"react-transition-group": "^4.4.2",

View File

@ -4,18 +4,21 @@ import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { ChatStep } from './ChatStep'
import { AvatarSideContainer } from './AvatarSideContainer'
import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
import { Step, Table } from 'models'
import { ChoiceInputStep, Step } from 'models'
import { useTypebot } from '../../contexts/TypebotContext'
import { isChoiceInput } from 'utils'
type ChatBlockProps = {
steps: Table<Step>
stepIds: string[]
onBlockEnd: (nextBlockId?: string) => void
}
export const ChatBlock = ({ steps, onBlockEnd }: ChatBlockProps) => {
export const ChatBlock = ({ stepIds, onBlockEnd }: ChatBlockProps) => {
const { typebot } = useTypebot()
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([])
useEffect(() => {
setDisplayedSteps([steps.byId[steps.allIds[0]]])
setDisplayedSteps([typebot.steps.byId[stepIds[0]]])
}, [])
useEffect(() => {
@ -29,17 +32,36 @@ export const ChatBlock = ({ steps, onBlockEnd }: ChatBlockProps) => {
})
}
const displayNextStep = () => {
const displayNextStep = (answerContent?: string) => {
const currentStep = [...displayedSteps].pop()
if (!currentStep) throw new Error('currentStep should exist')
const isSingleChoiceStep =
isChoiceInput(currentStep) && !currentStep.options.isMultipleChoice
if (isSingleChoiceStep)
return onBlockEnd(getSingleChoiceTargetId(currentStep, answerContent))
if (
currentStep?.target?.blockId ||
displayedSteps.length === steps.allIds.length
displayedSteps.length === stepIds.length
)
return onBlockEnd(currentStep?.target?.blockId)
const nextStep = steps.byId[displayedSteps.length]
const nextStep = typebot.steps.byId[stepIds[displayedSteps.length]]
if (nextStep) setDisplayedSteps([...displayedSteps, nextStep])
}
const getSingleChoiceTargetId = (
currentStep: ChoiceInputStep,
answerContent?: string
) => {
const itemId = currentStep.options.itemIds.find(
(itemId) => typebot.choiceItems.byId[itemId].content === answerContent
)
if (!itemId) throw new Error('itemId should exist')
const targetId =
typebot.choiceItems.byId[itemId].target?.blockId ??
currentStep.target?.blockId
return targetId
}
return (
<div className="flex">
<HostAvatarsContext>

View File

@ -7,19 +7,20 @@ import { HostMessageBubble } from './bubbles/HostMessageBubble'
import { TextForm } from './inputs/TextForm'
import { isInputStep, isTextBubbleStep } from 'utils'
import { DateForm } from './inputs/DateForm'
import { ChoiceForm } from './inputs/ChoiceForm'
export const ChatStep = ({
step,
onTransitionEnd,
}: {
step: Step
onTransitionEnd: () => void
onTransitionEnd: (answerContent?: string) => void
}) => {
const { addAnswer } = useAnswers()
const handleInputSubmit = (content: string) => {
addAnswer({ stepId: step.id, blockId: step.blockId, content })
onTransitionEnd()
onTransitionEnd(content)
}
if (isTextBubbleStep(step))
@ -60,5 +61,7 @@ const InputChatStep = ({
return <TextForm step={step} onSubmit={handleSubmit} />
case InputStepType.DATE:
return <DateForm options={step.options} onSubmit={handleSubmit} />
case InputStepType.CHOICE:
return <ChoiceForm options={step.options} onSubmit={handleSubmit} />
}
}

View File

@ -0,0 +1,64 @@
import { ChoiceInputOptions } from 'models'
import React, { useMemo, useState } from 'react'
import { filterTable } from 'utils'
import { useTypebot } from '../../../../contexts/TypebotContext'
import { SendButton } from './SendButton'
type ChoiceFormProps = {
options?: ChoiceInputOptions
onSubmit: (value: string) => void
}
export const ChoiceForm = ({ options, onSubmit }: ChoiceFormProps) => {
const { typebot } = useTypebot()
const items = useMemo(
() => filterTable(options?.itemIds ?? [], typebot.choiceItems),
[]
)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const handleClick = (itemId: string) => (e: React.MouseEvent) => {
e.preventDefault()
if (options?.isMultipleChoice) toggleSelectedItemId(itemId)
else onSubmit(items.byId[itemId].content ?? '')
}
const toggleSelectedItemId = (itemId: string) => {
const existingIndex = selectedIds.indexOf(itemId)
if (existingIndex !== -1) {
selectedIds.splice(existingIndex, 1)
setSelectedIds([...selectedIds])
} else {
setSelectedIds([...selectedIds, itemId])
}
}
const handleSubmit = () =>
onSubmit(selectedIds.map((itemId) => items.byId[itemId].content).join(', '))
return (
<form className="flex flex-col" onSubmit={handleSubmit}>
<div className="flex flex-wrap">
{options?.itemIds.map((itemId) => (
<button
role={options?.isMultipleChoice ? 'checkbox' : 'button'}
onClick={handleClick(itemId)}
className={
'py-2 px-4 font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none mr-2 mb-2 typebot-button ' +
(selectedIds.includes(itemId) || !options?.isMultipleChoice
? 'active'
: '')
}
>
{items.byId[itemId].content}
</button>
))}
</div>
<div className="flex">
{selectedIds.length > 0 && (
<SendButton label={options?.buttonLabel ?? 'Send'} />
)}
</div>
</form>
)
}

View File

@ -62,6 +62,7 @@ export const DateForm = ({
<SendButton
label={labels?.button ?? 'Send'}
isDisabled={inputValues.to === '' && inputValues.from === ''}
className="my-2 ml-2"
/>
</form>
</div>

View File

@ -3,7 +3,7 @@ import { SendIcon } from '../../../../assets/icons'
type SendButtonProps = {
label: string
isDisabled: boolean
isDisabled?: boolean
} & React.ButtonHTMLAttributes<HTMLButtonElement>
export const SendButton = ({
@ -14,11 +14,12 @@ export const SendButton = ({
return (
<button
type="submit"
className={
'my-2 ml-2 py-2 px-4 font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button active'
}
disabled={isDisabled}
{...props}
className={
'py-2 px-4 font-semibold rounded-md text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 typebot-button active ' +
props.className
}
>
<span className="hidden xs:flex">{label}</span>
<SendIcon className="send-icon flex xs:hidden" />

View File

@ -41,6 +41,7 @@ export const TextForm = ({ step, onSubmit }: TextFormProps) => {
<SendButton
label={step.options?.labels?.button ?? 'Send'}
isDisabled={inputValue === ''}
className="my-2 ml-2"
/>
</form>
</div>

View File

@ -61,7 +61,7 @@ export const ConversationContainer = ({
{displayedBlocks.map((block, idx) => (
<ChatBlock
key={block.id + idx}
steps={filterTable(block.stepIds, typebot.steps)}
stepIds={block.stepIds}
onBlockEnd={displayNextBlock}
/>
))}

View File

@ -92,21 +92,23 @@ model Typebot {
folder DashboardFolder? @relation(fields: [folderId], references: [id])
blocks Json
steps Json
choiceItems Json
theme Json
settings Json
publicId String? @unique
}
model PublicTypebot {
id String @id @default(cuid())
typebotId String @unique
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
name String
blocks Json
steps Json
theme Json
settings Json
publicId String? @unique
id String @id @default(cuid())
typebotId String @unique
typebot Typebot @relation(fields: [typebotId], references: [id], onDelete: Cascade)
name String
blocks Json
steps Json
choiceItems Json
theme Json
settings Json
publicId String? @unique
}
model Result {

View File

@ -1,5 +1,5 @@
import { PublicTypebot as PublicTypebotFromPrisma } from 'db'
import { Block, Settings, Step, Theme } from './typebot'
import { Block, ChoiceItem, Settings, Step, Theme } from './typebot'
import { Table } from './utils'
export type PublicTypebot = Omit<
@ -8,6 +8,7 @@ export type PublicTypebot = Omit<
> & {
blocks: Table<Block>
steps: Table<Step>
choiceItems: Table<ChoiceItem>
theme: Theme
settings: Settings
}

View File

@ -1,3 +1,4 @@
import { Target } from '.'
import { StepBase } from './steps'
export type InputStep =
@ -7,6 +8,7 @@ export type InputStep =
| UrlInputStep
| DateInputStep
| PhoneNumberInputStep
| ChoiceInputStep
export enum InputStepType {
TEXT = 'text input',
@ -15,6 +17,7 @@ export enum InputStepType {
URL = 'url input',
DATE = 'date input',
PHONE = 'phone number input',
CHOICE = 'choice input',
}
export type TextInputStep = StepBase & {
@ -44,7 +47,25 @@ export type DateInputStep = StepBase & {
export type PhoneNumberInputStep = StepBase & {
type: InputStepType.PHONE
options?: InputOptionsBase
options?: InputTextOptionsBase
}
export type ChoiceInputStep = StepBase & {
type: InputStepType.CHOICE
options: ChoiceInputOptions
}
export type ChoiceInputOptions = {
itemIds: string[]
isMultipleChoice?: boolean
buttonLabel?: string
}
export type ChoiceItem = {
id: string
stepId: string
content?: string
target?: Target
}
export type DateInputOptions = {
@ -53,19 +74,19 @@ export type DateInputOptions = {
isRange?: boolean
}
export type EmailInputOptions = InputOptionsBase
export type EmailInputOptions = InputTextOptionsBase
export type UrlInputOptions = InputOptionsBase
export type UrlInputOptions = InputTextOptionsBase
type InputOptionsBase = {
type InputTextOptionsBase = {
labels?: { placeholder?: string; button?: string }
}
export type TextInputOptions = InputOptionsBase & {
export type TextInputOptions = InputTextOptionsBase & {
isLong?: boolean
}
export type NumberInputOptions = InputOptionsBase & {
export type NumberInputOptions = InputTextOptionsBase & {
min?: number
max?: number
step?: number

View File

@ -1,4 +1,5 @@
import { Typebot as TypebotFromPrisma } from 'db'
import { ChoiceItem } from './steps/inputs'
import { Table } from '../utils'
import { Settings } from './settings'
import { Step } from './steps/steps'
@ -10,6 +11,7 @@ export type Typebot = Omit<
> & {
blocks: Table<Block>
steps: Table<Step>
choiceItems: Table<ChoiceItem>
theme: Theme
settings: Settings
}

View File

@ -1,5 +1,6 @@
import {
BubbleStepType,
ChoiceInputStep,
InputStep,
InputStepType,
Step,
@ -49,3 +50,9 @@ export const isTextBubbleStep = (step: Step): step is TextStep =>
export const isTextInputStep = (step: Step): step is TextInputStep =>
step.type === InputStepType.TEXT
export const isChoiceInput = (step: Step): step is ChoiceInputStep =>
step.type === InputStepType.CHOICE
export const isSingleChoiceInput = (step: Step): step is ChoiceInputStep =>
step.type === InputStepType.CHOICE && !step.options.isMultipleChoice

View File

@ -6187,6 +6187,11 @@ react-focus-lock@2.5.2:
use-callback-ref "^1.2.5"
use-sidecar "^1.0.5"
react-frame-component@5.2.2-alpha.0:
version "5.2.2-alpha.0"
resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-5.2.2-alpha.0.tgz#33d2743ee6f559ac9763490b15e38dbb102c2cb8"
integrity sha512-WCbYSTm6JxOrtw4jE30qvHCUv3lFB6DZVNE9npLxNcLxRi3LYbgG0bGOterDk/399MERnYppLr9LmGM0bzmQDw==
react-frame-component@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/react-frame-component/-/react-frame-component-5.2.1.tgz#6bd5ec73ef7d720f57ee8f259546ed926a941267"