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

@ -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>
)
}