2
0

feat(inputs): Add number input

This commit is contained in:
Baptiste Arnaud
2022-01-08 07:40:55 +01:00
parent 2a040308db
commit d54ebc0cbe
33 changed files with 467 additions and 207 deletions

View File

@ -208,3 +208,12 @@ export const DownloadIcon = (props: IconProps) => (
<line x1="12" y1="15" x2="12" y2="3"></line> <line x1="12" y1="15" x2="12" y2="3"></line>
</Icon> </Icon>
) )
export const NumberIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<line x1="4" y1="9" x2="20" y2="9"></line>
<line x1="4" y1="15" x2="20" y2="15"></line>
<line x1="10" y1="3" x2="8" y2="21"></line>
<line x1="16" y1="3" x2="14" y2="21"></line>
</Icon>
)

View File

@ -1,16 +1,19 @@
import { Button, ButtonProps, Flex, HStack } from '@chakra-ui/react' import { Button, ButtonProps, Flex, HStack } from '@chakra-ui/react'
import { StepType } from 'models' import { BubbleStepType, InputStepType, StepType } from 'models'
import { useDnd } from 'contexts/DndContext' import { useDnd } from 'contexts/DndContext'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { StepIcon } from './StepIcon' import { StepIcon } from './StepIcon'
import { StepLabel } from './StepLabel' import { StepTypeLabel } from './StepTypeLabel'
export const StepCard = ({ export const StepCard = ({
type, type,
onMouseDown, onMouseDown,
}: { }: {
type: StepType type: BubbleStepType | InputStepType
onMouseDown: (e: React.MouseEvent, type: StepType) => void onMouseDown: (
e: React.MouseEvent,
type: BubbleStepType | InputStepType
) => void
}) => { }) => {
const { draggedStepType } = useDnd() const { draggedStepType } = useDnd()
const [isMouseDown, setIsMouseDown] = useState(false) const [isMouseDown, setIsMouseDown] = useState(false)
@ -35,7 +38,7 @@ export const StepCard = ({
{!isMouseDown && ( {!isMouseDown && (
<> <>
<StepIcon type={type} /> <StepIcon type={type} />
<StepLabel type={type} /> <StepTypeLabel type={type} />
</> </>
)} )}
</Button> </Button>
@ -62,7 +65,7 @@ export const StepCardOverlay = ({
{...props} {...props}
> >
<StepIcon type={type} /> <StepIcon type={type} />
<StepLabel type={type} /> <StepTypeLabel type={type} />
</Button> </Button>
) )
} }

View File

@ -1,22 +1,25 @@
import { ChatIcon, FlagIcon, TextIcon } from 'assets/icons' import { ChatIcon, FlagIcon, NumberIcon, TextIcon } from 'assets/icons'
import { StepType } from 'models' import { BubbleStepType, InputStepType, StepType } from 'models'
import React from 'react' import React from 'react'
type StepIconProps = { type: StepType } type StepIconProps = { type: StepType }
export const StepIcon = ({ type }: StepIconProps) => { export const StepIcon = ({ type }: StepIconProps) => {
switch (type) { switch (type) {
case StepType.TEXT: { case BubbleStepType.TEXT: {
return <ChatIcon /> return <ChatIcon />
} }
case StepType.TEXT: { case InputStepType.TEXT: {
return <TextIcon /> return <TextIcon />
} }
case StepType.START: { case InputStepType.NUMBER: {
return <NumberIcon />
}
case 'start': {
return <FlagIcon /> return <FlagIcon />
} }
default: { default: {
return <TextIcon /> return <></>
} }
} }
} }

View File

@ -1,19 +0,0 @@
import { Text } from '@chakra-ui/react'
import { StepType } from 'models'
import React from 'react'
type Props = { type: StepType }
export const StepLabel = ({ type }: Props) => {
switch (type) {
case StepType.TEXT: {
return <Text>Text</Text>
}
case StepType.TEXT_INPUT: {
return <Text>Text</Text>
}
default: {
return <></>
}
}
}

View File

@ -0,0 +1,20 @@
import { Text } from '@chakra-ui/react'
import { BubbleStepType, InputStepType, StepType } from 'models'
import React from 'react'
type Props = { type: StepType }
export const StepTypeLabel = ({ type }: Props) => {
switch (type) {
case BubbleStepType.TEXT:
case InputStepType.TEXT: {
return <Text>Text</Text>
}
case InputStepType.NUMBER: {
return <Text>Number</Text>
}
default: {
return <></>
}
}
}

View File

@ -5,19 +5,11 @@ import {
SimpleGrid, SimpleGrid,
useEventListener, useEventListener,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { StepType } from 'models' import { BubbleStepType, InputStepType } from 'models'
import { useDnd } from 'contexts/DndContext' import { useDnd } from 'contexts/DndContext'
import React, { useState } from 'react' import React, { useState } from 'react'
import { StepCard, StepCardOverlay } from './StepCard' import { StepCard, StepCardOverlay } from './StepCard'
export const stepListItems: {
bubbles: { type: StepType }[]
inputs: { type: StepType }[]
} = {
bubbles: [{ type: StepType.TEXT }],
inputs: [{ type: StepType.TEXT_INPUT }],
}
export const StepTypesList = () => { export const StepTypesList = () => {
const { setDraggedStepType, draggedStepType } = useDnd() const { setDraggedStepType, draggedStepType } = useDnd()
const [position, setPosition] = useState({ const [position, setPosition] = useState({
@ -37,7 +29,10 @@ export const StepTypesList = () => {
} }
useEventListener('mousemove', handleMouseMove) useEventListener('mousemove', handleMouseMove)
const handleMouseDown = (e: React.MouseEvent, type: StepType) => { const handleMouseDown = (
e: React.MouseEvent,
type: BubbleStepType | InputStepType
) => {
const element = e.currentTarget as HTMLDivElement const element = e.currentTarget as HTMLDivElement
const rect = element.getBoundingClientRect() const rect = element.getBoundingClientRect()
const relativeX = e.clientX - rect.left const relativeX = e.clientX - rect.left
@ -77,8 +72,8 @@ export const StepTypesList = () => {
Bubbles Bubbles
</Text> </Text>
<SimpleGrid columns={2} spacing="2"> <SimpleGrid columns={2} spacing="2">
{stepListItems.bubbles.map((props) => ( {Object.values(BubbleStepType).map((type) => (
<StepCard key={props.type} onMouseDown={handleMouseDown} {...props} /> <StepCard key={type} type={type} onMouseDown={handleMouseDown} />
))} ))}
</SimpleGrid> </SimpleGrid>
@ -86,8 +81,8 @@ export const StepTypesList = () => {
Inputs Inputs
</Text> </Text>
<SimpleGrid columns={2} spacing="2"> <SimpleGrid columns={2} spacing="2">
{stepListItems.inputs.map((props) => ( {Object.values(InputStepType).map((type) => (
<StepCard key={props.type} onMouseDown={handleMouseDown} {...props} /> <StepCard key={type} type={type} onMouseDown={handleMouseDown} />
))} ))}
</SimpleGrid> </SimpleGrid>
{draggedStepType && ( {draggedStepType && (

View File

@ -0,0 +1,84 @@
import { FormLabel, HStack, Stack } from '@chakra-ui/react'
import { SmartNumberInput } from 'components/settings/SmartNumberInput'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { NumberInputOptions } from 'models'
import React from 'react'
import { removeUndefinedFields } from 'services/utils'
type NumberInputSettingsBodyProps = {
options?: NumberInputOptions
onOptionsChange: (options: NumberInputOptions) => void
}
export const NumberInputSettingsBody = ({
options,
onOptionsChange,
}: NumberInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleMinChange = (min?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, min }))
const handleMaxChange = (max?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, max }))
const handleStepChange = (step?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, step }))
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<DebouncedInput
id="placeholder"
initialValue={options?.labels?.placeholder ?? 'Type your answer...'}
delay={100}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="button"
initialValue={options?.labels?.button ?? 'Send'}
delay={100}
onChange={handleButtonLabelChange}
/>
</Stack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="min">
Min:
</FormLabel>
<SmartNumberInput
id="min"
initialValue={options?.min}
onValueChange={handleMinChange}
/>
</HStack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="max">
Max:
</FormLabel>
<SmartNumberInput
id="max"
initialValue={options?.max}
onValueChange={handleMaxChange}
/>
</HStack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="step">
Step:
</FormLabel>
<SmartNumberInput
id="step"
initialValue={options?.step}
onValueChange={handleStepChange}
/>
</HStack>
</Stack>
)
}

View File

@ -1,6 +1,7 @@
import { PopoverContent, PopoverArrow, PopoverBody } from '@chakra-ui/react' import { PopoverContent, PopoverArrow, PopoverBody } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { Step, StepType, TextInputOptions } from 'models' import { InputStepType, Step, TextInputOptions } from 'models'
import { NumberInputSettingsBody } from './NumberInputSettingsBody'
import { TextInputSettingsBody } from './TextInputSettingsBody' import { TextInputSettingsBody } from './TextInputSettingsBody'
type Props = { type Props = {
@ -25,7 +26,7 @@ const SettingsPopoverBodyContent = ({ step }: Props) => {
updateStep(step.id, { options } as Partial<Step>) updateStep(step.id, { options } as Partial<Step>)
switch (step.type) { switch (step.type) {
case StepType.TEXT_INPUT: { case InputStepType.TEXT: {
return ( return (
<TextInputSettingsBody <TextInputSettingsBody
options={step.options} options={step.options}
@ -33,6 +34,14 @@ const SettingsPopoverBodyContent = ({ step }: Props) => {
/> />
) )
} }
case InputStepType.NUMBER: {
return (
<NumberInputSettingsBody
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
default: { default: {
return <></> return <></>
} }

View File

@ -1,31 +0,0 @@
import { Flex, Text } from '@chakra-ui/react'
import { Step, StartStep, StepType } from 'models'
export const StepContent = (props: Step | StartStep) => {
switch (props.type) {
case StepType.TEXT: {
return (
<Flex
flexDir={'column'}
opacity={props.content.html === '' ? '0.5' : '1'}
className="slate-html-container"
dangerouslySetInnerHTML={{
__html:
props.content.html === ''
? `<p>Click to edit...</p>`
: props.content.html,
}}
></Flex>
)
}
case StepType.TEXT_INPUT: {
return <Text color={'gray.500'}>Type your answer...</Text>
}
case StepType.START: {
return <Text>{props.label}</Text>
}
default: {
return <Text>No input</Text>
}
}
}

View File

@ -11,15 +11,15 @@ import { Block, Step } from 'models'
import { SourceEndpoint } from './SourceEndpoint' import { SourceEndpoint } from './SourceEndpoint'
import { useGraph } from 'contexts/GraphContext' import { useGraph } from 'contexts/GraphContext'
import { StepIcon } from 'components/board/StepTypesList/StepIcon' import { StepIcon } from 'components/board/StepTypesList/StepIcon'
import { isDefined } from 'utils' import { isDefined, isTextBubbleStep } from 'utils'
import { Coordinates } from '@dnd-kit/core/dist/types' import { Coordinates } from '@dnd-kit/core/dist/types'
import { TextEditor } from './TextEditor/TextEditor' import { TextEditor } from './TextEditor/TextEditor'
import { StepContent } from './StepContent' import { StepNodeLabel } from './StepNodeLabel'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu' import { ContextMenu } from 'components/shared/ContextMenu'
import { StepNodeContextMenu } from './RightClickMenu' import { StepNodeContextMenu } from './RightClickMenu'
import { SettingsPopoverContent } from './SettingsPopoverContent' import { SettingsPopoverContent } from './SettingsPopoverContent'
import { isStepText } from 'services/typebots' import { DraggableStep } from 'contexts/DndContext'
export const StepNode = ({ export const StepNode = ({
step, step,
@ -34,7 +34,7 @@ export const StepNode = ({
onMouseMoveTopOfElement?: () => void onMouseMoveTopOfElement?: () => void
onMouseDown?: ( onMouseDown?: (
stepNodePosition: { absolute: Coordinates; relative: Coordinates }, stepNodePosition: { absolute: Coordinates; relative: Coordinates },
step: Step step: DraggableStep
) => void ) => void
}) => { }) => {
const { setConnectingIds, connectingIds } = useGraph() const { setConnectingIds, connectingIds } = useGraph()
@ -43,7 +43,7 @@ export const StepNode = ({
const [mouseDownEvent, setMouseDownEvent] = const [mouseDownEvent, setMouseDownEvent] =
useState<{ absolute: Coordinates; relative: Coordinates }>() useState<{ absolute: Coordinates; relative: Coordinates }>()
const [isEditing, setIsEditing] = useState<boolean>( const [isEditing, setIsEditing] = useState<boolean>(
isStepText(step) && step.content.plainText === '' isTextBubbleStep(step) && step.content.plainText === ''
) )
useEffect(() => { useEffect(() => {
@ -102,8 +102,8 @@ export const StepNode = ({
mouseDownEvent && mouseDownEvent &&
onMouseDown && onMouseDown &&
(event.movementX > 0 || event.movementY > 0) (event.movementX > 0 || event.movementY > 0)
if (isMovingAndIsMouseDown) { if (isMovingAndIsMouseDown && step.type !== 'start') {
onMouseDown(mouseDownEvent, step as Step) onMouseDown(mouseDownEvent, step)
deleteStep(step.id) deleteStep(step.id)
setMouseDownEvent(undefined) setMouseDownEvent(undefined)
} }
@ -142,7 +142,7 @@ export const StepNode = ({
connectingIds?.target?.blockId, connectingIds?.target?.blockId,
]) ])
return isEditing && isStepText(step) ? ( return isEditing && isTextBubbleStep(step) ? (
<TextEditor <TextEditor
stepId={step.id} stepId={step.id}
initialValue={step.content.richText} initialValue={step.content.richText}
@ -186,7 +186,7 @@ export const StepNode = ({
bgColor="white" bgColor="white"
> >
<StepIcon type={step.type} /> <StepIcon type={step.type} />
<StepContent {...step} /> <StepNodeLabel {...step} />
{isConnectable && ( {isConnectable && (
<SourceEndpoint <SourceEndpoint
onConnectionDragStart={handleConnectionDragStart} onConnectionDragStart={handleConnectionDragStart}

View File

@ -0,0 +1,42 @@
import { Flex, Text } from '@chakra-ui/react'
import { Step, StartStep, BubbleStepType, InputStepType } from 'models'
export const StepNodeLabel = (props: Step | StartStep) => {
switch (props.type) {
case BubbleStepType.TEXT: {
return (
<Flex
flexDir={'column'}
opacity={props.content.html === '' ? '0.5' : '1'}
className="slate-html-container"
dangerouslySetInnerHTML={{
__html:
props.content.html === ''
? `<p>Click to edit...</p>`
: props.content.html,
}}
/>
)
}
case InputStepType.TEXT: {
return (
<Text color={'gray.500'}>
{props.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case InputStepType.NUMBER: {
return (
<Text color={'gray.500'}>
{props.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case 'start': {
return <Text>{props.label}</Text>
}
default: {
return <Text>No input</Text>
}
}
}

View File

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

View File

@ -1,6 +1,6 @@
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react' import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
import { Step, Table } from 'models' import { Step, Table } from 'models'
import { useDnd } from 'contexts/DndContext' import { DraggableStep, useDnd } from 'contexts/DndContext'
import { Coordinates } from 'contexts/GraphContext' import { Coordinates } from 'contexts/GraphContext'
import { useState } from 'react' import { useState } from 'react'
import { StepNode, StepNodeOverlay } from './StepNode' import { StepNode, StepNodeOverlay } from './StepNode'
@ -54,7 +54,7 @@ export const StepsList = ({
const handleStepMouseDown = ( const handleStepMouseDown = (
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates }, { absolute, relative }: { absolute: Coordinates; relative: Coordinates },
step: Step step: DraggableStep
) => { ) => {
setPosition(absolute) setPosition(absolute)
setRelativeCoordinates(relative) setRelativeCoordinates(relative)

View File

@ -2,11 +2,10 @@ import { Flex, FlexProps, useEventListener } from '@chakra-ui/react'
import React, { useRef, useMemo } from 'react' import React, { useRef, useMemo } from 'react'
import { blockWidth, useGraph } from 'contexts/GraphContext' import { blockWidth, useGraph } from 'contexts/GraphContext'
import { BlockNode } from './BlockNode/BlockNode' import { BlockNode } from './BlockNode/BlockNode'
import { useDnd } from 'contexts/DndContext' import { DraggableStepType, useDnd } from 'contexts/DndContext'
import { Edges } from './Edges' import { Edges } from './Edges'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader' import { headerHeight } from 'components/shared/TypebotHeader/TypebotHeader'
import { StepType } from 'models'
const Graph = ({ ...props }: FlexProps) => { const Graph = ({ ...props }: FlexProps) => {
const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } = const { draggedStepType, setDraggedStepType, draggedStep, setDraggedStep } =
@ -44,7 +43,7 @@ const Graph = ({ ...props }: FlexProps) => {
createBlock({ createBlock({
x: e.clientX - graphPosition.x - blockWidth / 3, x: e.clientX - graphPosition.x - blockWidth / 3,
y: e.clientY - graphPosition.y - 20 - headerHeight, y: e.clientY - graphPosition.y - 20 - headerHeight,
step: draggedStep ?? (draggedStepType as StepType), step: draggedStep ?? (draggedStepType as DraggableStepType),
}) })
setDraggedStep(undefined) setDraggedStep(undefined)
setDraggedStepType(undefined) setDraggedStepType(undefined)

View File

@ -13,14 +13,14 @@ export const SmartNumberInput = ({
onValueChange, onValueChange,
...props ...props
}: { }: {
initialValue: number initialValue?: number
onValueChange: (value: number) => void onValueChange: (value?: number) => void
} & NumberInputProps) => { } & NumberInputProps) => {
const [value, setValue] = useState(initialValue.toString()) const [value, setValue] = useState(initialValue?.toString() ?? '')
useEffect(() => { useEffect(() => {
if (value.endsWith('.') || value.endsWith(',')) return if (value.endsWith('.') || value.endsWith(',')) return
if (value === '') onValueChange(0) if (value === '') onValueChange(undefined)
const newValue = parseFloat(value) const newValue = parseFloat(value)
if (isNaN(newValue)) return if (isNaN(newValue)) return
onValueChange(newValue) onValueChange(newValue)

View File

@ -17,14 +17,14 @@ export const TypingEmulation = ({
onUpdate({ ...typingEmulation, enabled: !typingEmulation.enabled }) onUpdate({ ...typingEmulation, enabled: !typingEmulation.enabled })
} }
const handleSpeedChange = (speed: number) => { const handleSpeedChange = (speed?: number) => {
if (!typingEmulation) return if (!typingEmulation) return
onUpdate({ ...typingEmulation, speed }) onUpdate({ ...typingEmulation, speed: speed ?? 0 })
} }
const handleMaxDelayChange = (maxDelay: number) => { const handleMaxDelayChange = (maxDelay?: number) => {
if (!typingEmulation) return if (!typingEmulation) return
onUpdate({ ...typingEmulation, maxDelay: maxDelay }) onUpdate({ ...typingEmulation, maxDelay: maxDelay ?? 0 })
} }
return ( return (

View File

@ -1,4 +1,4 @@
import { Step, StepType } from 'models' import { BubbleStep, BubbleStepType, InputStep, InputStepType } from 'models'
import { import {
createContext, createContext,
Dispatch, Dispatch,
@ -8,19 +8,24 @@ import {
useState, useState,
} from 'react' } from 'react'
export type DraggableStep = BubbleStep | InputStep
export type DraggableStepType = BubbleStepType | InputStepType
const dndContext = createContext<{ const dndContext = createContext<{
draggedStepType?: StepType draggedStepType?: DraggableStepType
setDraggedStepType: Dispatch<SetStateAction<StepType | undefined>> setDraggedStepType: Dispatch<SetStateAction<DraggableStepType | undefined>>
draggedStep?: Step draggedStep?: DraggableStep
setDraggedStep: Dispatch<SetStateAction<Step | undefined>> setDraggedStep: Dispatch<SetStateAction<DraggableStep | undefined>>
}>({ }>({
setDraggedStep: () => console.log("I'm not implemented"), setDraggedStep: () => console.log("I'm not implemented"),
setDraggedStepType: () => console.log("I'm not implemented"), setDraggedStepType: () => console.log("I'm not implemented"),
}) })
export const DndContext = ({ children }: { children: ReactNode }) => { export const DndContext = ({ children }: { children: ReactNode }) => {
const [draggedStep, setDraggedStep] = useState<Step | undefined>() const [draggedStep, setDraggedStep] = useState<DraggableStep | undefined>()
const [draggedStepType, setDraggedStepType] = useState<StepType | undefined>() const [draggedStepType, setDraggedStepType] = useState<
DraggableStepType | undefined
>()
return ( return (
<dndContext.Provider <dndContext.Provider

View File

@ -1,20 +1,25 @@
import { Coordinates } from 'contexts/GraphContext' import { Coordinates } from 'contexts/GraphContext'
import { WritableDraft } from 'immer/dist/internal' import { WritableDraft } from 'immer/dist/internal'
import { Block, Step, StepType, Typebot } from 'models' import { Block, BubbleStepType, InputStepType, Step, Typebot } from 'models'
import { parseNewBlock } from 'services/typebots' import { parseNewBlock } from 'services/typebots'
import { Updater } from 'use-immer' import { Updater } from 'use-immer'
import { createStepDraft, deleteStepDraft } from './steps' import { createStepDraft, deleteStepDraft } from './steps'
export type BlocksActions = { export type BlocksActions = {
createBlock: (props: Coordinates & { step: StepType | Step }) => void createBlock: (
props: Coordinates & { step: BubbleStepType | InputStepType | Step }
) => void
updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) => void updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) => void
deleteBlock: (blockId: string) => void deleteBlock: (blockId: string) => void
} }
export const blocksActions = (setTypebot: Updater<Typebot>): BlocksActions => ({ export const blocksActions = (setTypebot: Updater<Typebot>): BlocksActions => ({
createBlock: ({ x, y, step }: Coordinates & { step: StepType | Step }) => { createBlock: ({
x,
y,
step,
}: Coordinates & { step: BubbleStepType | InputStepType | Step }) => {
setTypebot((typebot) => { setTypebot((typebot) => {
removeEmptyBlocks(typebot)
const newBlock = parseNewBlock({ const newBlock = parseNewBlock({
totalBlocks: typebot.blocks.allIds.length, totalBlocks: typebot.blocks.allIds.length,
initialCoordinates: { x, y }, initialCoordinates: { x, y },
@ -22,6 +27,7 @@ export const blocksActions = (setTypebot: Updater<Typebot>): BlocksActions => ({
typebot.blocks.byId[newBlock.id] = newBlock typebot.blocks.byId[newBlock.id] = newBlock
typebot.blocks.allIds.push(newBlock.id) typebot.blocks.allIds.push(newBlock.id)
createStepDraft(typebot, step, newBlock.id) createStepDraft(typebot, step, newBlock.id)
removeEmptyBlocks(typebot)
}) })
}, },
updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) => updateBlock: (blockId: string, updates: Partial<Omit<Block, 'id'>>) =>

View File

@ -1,11 +1,15 @@
import { Step, StepType, Typebot } from 'models' import { BubbleStepType, InputStepType, Step, Typebot } from 'models'
import { parseNewStep } from 'services/typebots' import { parseNewStep } from 'services/typebots'
import { Updater } from 'use-immer' import { Updater } from 'use-immer'
import { removeEmptyBlocks } from './blocks' import { removeEmptyBlocks } from './blocks'
import { WritableDraft } from 'immer/dist/types/types-external' import { WritableDraft } from 'immer/dist/types/types-external'
export type StepsActions = { export type StepsActions = {
createStep: (blockId: string, step: StepType | Step, index?: number) => void createStep: (
blockId: string,
step: BubbleStepType | InputStepType | Step,
index?: number
) => void
updateStep: ( updateStep: (
stepId: string, stepId: string,
updates: Partial<Omit<Step, 'id' | 'type'>> updates: Partial<Omit<Step, 'id' | 'type'>>
@ -14,10 +18,14 @@ export type StepsActions = {
} }
export const stepsAction = (setTypebot: Updater<Typebot>): StepsActions => ({ export const stepsAction = (setTypebot: Updater<Typebot>): StepsActions => ({
createStep: (blockId: string, step: StepType | Step, index?: number) => { createStep: (
blockId: string,
step: BubbleStepType | InputStepType | Step,
index?: number
) => {
setTypebot((typebot) => { setTypebot((typebot) => {
removeEmptyBlocks(typebot)
createStepDraft(typebot, step, blockId, index) createStepDraft(typebot, step, blockId, index)
removeEmptyBlocks(typebot)
}) })
}, },
updateStep: (stepId: string, updates: Partial<Omit<Step, 'id' | 'type'>>) => updateStep: (stepId: string, updates: Partial<Omit<Step, 'id' | 'type'>>) =>
@ -28,6 +36,7 @@ export const stepsAction = (setTypebot: Updater<Typebot>): StepsActions => ({
setTypebot((typebot) => { setTypebot((typebot) => {
removeStepIdFromBlock(typebot, stepId) removeStepIdFromBlock(typebot, stepId)
deleteStepDraft(typebot, stepId) deleteStepDraft(typebot, stepId)
removeEmptyBlocks(typebot)
}) })
}, },
}) })
@ -56,7 +65,7 @@ export const deleteStepDraft = (
export const createStepDraft = ( export const createStepDraft = (
typebot: WritableDraft<Typebot>, typebot: WritableDraft<Typebot>,
step: StepType | Step, step: BubbleStepType | InputStepType | Step,
blockId: string, blockId: string,
index?: number index?: number
) => { ) => {

View File

@ -1,4 +1,4 @@
import { PublicTypebot, StepType, Typebot } from 'models' import { InputStepType, PublicTypebot, Typebot } from 'models'
import { Plan, PrismaClient } from 'db' import { Plan, PrismaClient } from 'db'
import { parseTestTypebot } from './utils' import { parseTestTypebot } from './utils'
@ -58,7 +58,7 @@ const createTypebots = async () => {
byId: { byId: {
step1: { step1: {
id: 'step1', id: 'step1',
type: StepType.TEXT_INPUT, type: InputStepType.TEXT,
blockId: 'block1', blockId: 'block1',
}, },
}, },

View File

@ -1,6 +1,5 @@
import { import {
Block, Block,
StepType,
Theme, Theme,
BackgroundType, BackgroundType,
Settings, Settings,
@ -59,7 +58,7 @@ export const parseTestTypebot = ({
byId: { byId: {
step0: { step0: {
id: 'step0', id: 'step0',
type: StepType.START, type: 'start',
blockId: 'block0', blockId: 'block0',
label: 'Start', label: 'Start',
target: { blockId: 'block1' }, target: { blockId: 'block1' },

View File

@ -1,5 +1,5 @@
import { parseTestTypebot } from 'cypress/plugins/utils' import { parseTestTypebot } from 'cypress/plugins/utils'
import { StepType } from 'models' import { BubbleStepType } from 'models'
describe('Text bubbles', () => { describe('Text bubbles', () => {
beforeEach(() => { beforeEach(() => {
@ -15,7 +15,7 @@ describe('Text bubbles', () => {
step1: { step1: {
id: 'step1', id: 'step1',
blockId: 'block1', blockId: 'block1',
type: StepType.TEXT, type: BubbleStepType.TEXT,
content: { html: '', plainText: '', richText: [] }, content: { html: '', plainText: '', richText: [] },
}, },
}, },

View File

@ -1,42 +1,14 @@
import { parseTestTypebot } from 'cypress/plugins/utils' import { parseTestTypebot } from 'cypress/plugins/utils'
import { StepType } from 'models' import { InputStep, InputStepType } from 'models'
describe('Text input', () => { describe('Text input', () => {
beforeEach(() => { beforeEach(() => {
cy.task('seed') cy.task('seed')
cy.task( createTypebotWithStep({ type: InputStepType.TEXT })
'createTypebot',
parseTestTypebot({
id: 'typebot3',
name: 'Typebot #3',
ownerId: 'test2',
steps: {
byId: {
step1: {
id: 'step1',
blockId: 'block1',
type: StepType.TEXT_INPUT,
},
},
allIds: ['step1'],
},
blocks: {
byId: {
block1: {
id: 'block1',
graphCoordinates: { x: 400, y: 200 },
title: 'Block #1',
stepIds: ['step1'],
},
},
allIds: ['block1'],
},
})
)
cy.signOut() cy.signOut()
}) })
it('text input options should work', () => { it('options should work', () => {
cy.signIn('test2@gmail.com') cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot3/edit') cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click() cy.findByRole('button', { name: 'Preview' }).click()
@ -48,6 +20,7 @@ describe('Text input', () => {
.type('Your name...') .type('Your name...')
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go') cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.findByRole('button', { name: 'Restart' }).click() cy.findByRole('button', { name: 'Restart' }).click()
cy.findByTestId('step-step1').should('contain.text', 'Your name...')
getIframeBody().findByPlaceholderText('Your name...').should('exist') getIframeBody().findByPlaceholderText('Your name...').should('exist')
getIframeBody().findByRole('button', { name: 'Go' }) getIframeBody().findByRole('button', { name: 'Go' })
cy.findByTestId('step-step1').click({ force: true }) cy.findByTestId('step-step1').click({ force: true })
@ -57,6 +30,68 @@ describe('Text input', () => {
}) })
}) })
describe('Number input', () => {
beforeEach(() => {
cy.task('seed')
createTypebotWithStep({ type: InputStepType.NUMBER })
cy.signOut()
})
it.only('options should work', () => {
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot3/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByPlaceholderText('Type your answer...').should('exist')
getIframeBody().findByRole('button', { name: 'Send' })
cy.findByTestId('step-step1').click({ force: true })
cy.findByRole('textbox', { name: 'Placeholder:' })
.clear()
.type('Your name...')
cy.findByRole('textbox', { name: 'Button label:' }).clear().type('Go')
cy.findByRole('spinbutton', { name: 'Min:' }).type('0')
cy.findByRole('spinbutton', { name: 'Max:' }).type('100')
cy.findByRole('spinbutton', { name: 'Step:' }).type('10')
cy.findByRole('button', { name: 'Restart' }).click()
cy.findByTestId('step-step1').should('contain.text', 'Your name...')
getIframeBody()
.findByPlaceholderText('Your name...')
.should('exist')
.type('-1{enter}')
.clear()
.type('150{enter}')
getIframeBody().findByRole('button', { name: 'Go' })
cy.findByTestId('step-step1').click({ force: true })
})
})
const createTypebotWithStep = (step: Omit<InputStep, 'id' | 'blockId'>) => {
cy.task(
'createTypebot',
parseTestTypebot({
id: 'typebot3',
name: 'Typebot #3',
ownerId: 'test2',
steps: {
byId: {
step1: { ...step, id: 'step1', blockId: 'block1' },
},
allIds: ['step1'],
},
blocks: {
byId: {
block1: {
id: 'block1',
graphCoordinates: { x: 400, y: 200 },
title: 'Block #1',
stepIds: ['step1'],
},
},
allIds: ['block1'],
},
})
)
}
const getIframeBody = () => { const getIframeBody = () => {
return cy return cy
.get('#typebot-iframe') .get('#typebot-iframe')

View File

@ -1,9 +1,10 @@
import { InputStep, PublicTypebot, Step, StepType, Typebot } from 'models' import { PublicTypebot, Typebot } from 'models'
import { sendRequest } from './utils' import { sendRequest } from './utils'
import shortId from 'short-uuid' import shortId from 'short-uuid'
import { HStack, Text } from '@chakra-ui/react' import { HStack, Text } from '@chakra-ui/react'
import { CalendarIcon } from 'assets/icons' import { CalendarIcon } from 'assets/icons'
import { StepIcon } from 'components/board/StepTypesList/StepIcon' import { StepIcon } from 'components/board/StepTypesList/StepIcon'
import { isInputStep } from 'utils'
export const parseTypebotToPublicTypebot = ( export const parseTypebotToPublicTypebot = (
typebot: Typebot typebot: Typebot
@ -59,7 +60,7 @@ export const parseSubmissionsColumns = (
.map((blockId) => { .map((blockId) => {
const block = typebot.blocks.byId[blockId] const block = typebot.blocks.byId[blockId]
const inputStepId = block.stepIds.find((stepId) => const inputStepId = block.stepIds.find((stepId) =>
stepIsInput(typebot.steps.byId[stepId]) isInputStep(typebot.steps.byId[stepId])
) )
const inputStep = typebot.steps.byId[inputStepId as string] const inputStep = typebot.steps.byId[inputStepId as string]
return { return {
@ -80,8 +81,5 @@ const blockContainsInput = (
blockId: string blockId: string
) => ) =>
typebot.blocks.byId[blockId].stepIds.some((stepId) => typebot.blocks.byId[blockId].stepIds.some((stepId) =>
stepIsInput(typebot.steps.byId[stepId]) isInputStep(typebot.steps.byId[stepId])
) )
export const stepIsInput = (step: Step): step is InputStep =>
step.type === StepType.TEXT_INPUT

View File

@ -1,14 +1,15 @@
import { import {
Step,
StepType,
Block, Block,
TextStep, TextStep,
TextInputStep,
PublicTypebot, PublicTypebot,
BackgroundType, BackgroundType,
Settings, Settings,
StartStep, StartStep,
Theme, Theme,
BubbleStep,
InputStep,
BubbleStepType,
InputStepType,
} from 'models' } from 'models'
import shortId from 'short-uuid' import shortId from 'short-uuid'
import { Typebot } from 'models' import { Typebot } from 'models'
@ -104,10 +105,13 @@ export const parseNewBlock = ({
} }
} }
export const parseNewStep = (type: StepType, blockId: string): Step => { export const parseNewStep = (
type: BubbleStepType | InputStepType,
blockId: string
): BubbleStep | InputStep => {
const id = `s${shortId.generate()}` const id = `s${shortId.generate()}`
switch (type) { switch (type) {
case StepType.TEXT: { case BubbleStepType.TEXT: {
const textStep: Pick<TextStep, 'type' | 'content'> = { const textStep: Pick<TextStep, 'type' | 'content'> = {
type, type,
content: { html: '', richText: [], plainText: '' }, content: { html: '', richText: [], plainText: '' },
@ -118,22 +122,12 @@ export const parseNewStep = (type: StepType, blockId: string): Step => {
...textStep, ...textStep,
} }
} }
case StepType.TEXT_INPUT: {
const textStep: Pick<TextInputStep, 'type'> = {
type,
}
return {
id,
blockId,
...textStep,
}
}
default: { default: {
const textStep: Pick<TextStep, 'type' | 'content'> = { return {
type: StepType.TEXT, id,
content: { html: '', richText: [], plainText: '' }, blockId,
type,
} }
return { blockId, id, ...textStep }
} }
} }
} }
@ -180,7 +174,7 @@ export const parseNewTypebot = ({
blockId: startBlockId, blockId: startBlockId,
id: startStepId, id: startStepId,
label: 'Start', label: 'Start',
type: StepType.START, type: 'start',
} }
const startBlock: Block = { const startBlock: Block = {
id: startBlockId, id: startBlockId,
@ -211,6 +205,3 @@ export const parseNewTypebot = ({
settings, settings,
} }
} }
export const isStepText = (step: Step): step is TextStep =>
step.type === StepType.TEXT

View File

@ -106,3 +106,12 @@ export const uploadFile = async (file: File, key: string) => {
url: upload.ok ? `${url}/${key}` : null, url: upload.ok ? `${url}/${key}` : null,
} }
} }
export const removeUndefinedFields = <T>(obj: T): T =>
Object.keys(obj).reduce(
(acc, key) =>
obj[key as keyof T] === undefined
? { ...acc }
: { ...acc, [key]: obj[key as keyof T] },
{} as T
)

View File

@ -1,11 +1,12 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useAnswers } from '../../../contexts/AnswersContext' import { useAnswers } from '../../../contexts/AnswersContext'
import { useHostAvatars } from '../../../contexts/HostAvatarsContext' import { useHostAvatars } from '../../../contexts/HostAvatarsContext'
import { InputStep, Step } from 'models' import { InputStep, InputStepType, Step } from 'models'
import { isTextInputStep, isTextStep } from '../../../services/utils'
import { GuestBubble } from './bubbles/GuestBubble' import { GuestBubble } from './bubbles/GuestBubble'
import { HostMessageBubble } from './bubbles/HostMessageBubble' import { HostMessageBubble } from './bubbles/HostMessageBubble'
import { TextInput } from './inputs/TextInput' import { TextInput } from './inputs/TextInput'
import { isInputStep, isTextBubbleStep, isTextInputStep } from 'utils'
import { NumberInput } from './inputs/NumberInput'
export const ChatStep = ({ export const ChatStep = ({
step, step,
@ -21,9 +22,9 @@ export const ChatStep = ({
onTransitionEnd() onTransitionEnd()
} }
if (isTextStep(step)) if (isTextBubbleStep(step))
return <HostMessageBubble step={step} onTransitionEnd={onTransitionEnd} /> return <HostMessageBubble step={step} onTransitionEnd={onTransitionEnd} />
if (isTextInputStep(step)) if (isInputStep(step))
return <InputChatStep step={step} onSubmit={handleInputSubmit} /> return <InputChatStep step={step} onSubmit={handleInputSubmit} />
return <span>No step</span> return <span>No step</span>
} }
@ -50,5 +51,10 @@ const InputChatStep = ({
if (answer) { if (answer) {
return <GuestBubble message={answer} /> return <GuestBubble message={answer} />
} }
return <TextInput step={step} onSubmit={handleSubmit} /> switch (step.type) {
case InputStepType.TEXT:
return <TextInput step={step} onSubmit={handleSubmit} />
case InputStepType.NUMBER:
return <NumberInput step={step} onSubmit={handleSubmit} />
}
} }

View File

@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useHostAvatars } from '../../../../contexts/HostAvatarsContext' import { useHostAvatars } from '../../../../contexts/HostAvatarsContext'
import { useTypebot } from '../../../../contexts/TypebotContext' import { useTypebot } from '../../../../contexts/TypebotContext'
import { StepType, TextStep } from 'models' import { BubbleStepType, StepType, TextStep } from 'models'
import { computeTypingTimeout } from '../../../../services/chat' import { computeTypingTimeout } from '../../../../services/chat'
import { TypingContent } from './TypingContent' import { TypingContent } from './TypingContent'
@ -62,7 +62,7 @@ export const HostMessageBubble = ({
> >
{isTyping ? <TypingContent /> : <></>} {isTyping ? <TypingContent /> : <></>}
</div> </div>
{step.type === StepType.TEXT && ( {step.type === BubbleStepType.TEXT && (
<p <p
style={{ style={{
textOverflow: 'ellipsis', textOverflow: 'ellipsis',

View File

@ -0,0 +1,57 @@
import { NumberInputStep, TextInputStep } from 'models'
import React, { FormEvent, useRef, useState } from 'react'
import { SendIcon } from '../../../../assets/icons'
type NumberInputProps = {
step: NumberInputStep
onSubmit: (value: string) => void
}
export const NumberInput = ({ step, onSubmit }: NumberInputProps) => {
const inputRef = useRef(null)
const [inputValue, setInputValue] = useState('')
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
if (inputValue === '') return
onSubmit(inputValue)
}
return (
<div className="flex flex-col w-full lg:w-4/6">
<div className="flex items-center">
<form
className="flex items-end justify-between rounded-lg pr-2 typebot-input"
onSubmit={handleSubmit}
>
<input
ref={inputRef}
className="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full comp-input"
type="number"
placeholder={
step.options?.labels?.placeholder ?? 'Type your answer...'
}
onChange={(e) => setInputValue(e.target.value)}
style={{ appearance: 'auto' }}
min={step.options?.min}
max={step.options?.max}
step={step.options?.step}
required
/>
<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={inputValue === ''}
>
<span className="hidden xs:flex">
{step.options?.labels?.button ?? 'Send'}
</span>
<SendIcon className="send-icon flex xs:hidden" />
</button>
</form>
</div>
</div>
)
}

View File

@ -1 +1,3 @@
export * from './components/TypebotViewer' export * from './components/TypebotViewer'
export * from 'util'

View File

@ -1,7 +0,0 @@
import { Step, TextStep, StepType, TextInputStep } from 'models'
export const isTextStep = (step: Step): step is TextStep =>
step.type === StepType.TEXT
export const isTextInputStep = (step: Step): step is TextInputStep =>
step.type === StepType.TEXT_INPUT

View File

@ -2,34 +2,53 @@ export type Step = StartStep | BubbleStep | InputStep
export type BubbleStep = TextStep export type BubbleStep = TextStep
export type InputStep = TextInputStep export type InputStep = TextInputStep | NumberInputStep
export enum StepType { export type StepType = 'start' | BubbleStepType | InputStepType
START = 'start',
export enum BubbleStepType {
TEXT = 'text', TEXT = 'text',
TEXT_INPUT = 'text input', }
export enum InputStepType {
TEXT = 'text input',
NUMBER = 'number input',
} }
export type StepBase = { id: string; blockId: string; target?: Target } export type StepBase = { id: string; blockId: string; target?: Target }
export type StartStep = StepBase & { export type StartStep = StepBase & {
type: StepType.START type: 'start'
label: string label: string
} }
export type TextStep = StepBase & { export type TextStep = StepBase & {
type: StepType.TEXT type: BubbleStepType.TEXT
content: { html: string; richText: unknown[]; plainText: string } content: { html: string; richText: unknown[]; plainText: string }
} }
export type TextInputStep = StepBase & { export type TextInputStep = StepBase & {
type: StepType.TEXT_INPUT type: InputStepType.TEXT
options?: TextInputOptions options?: TextInputOptions
} }
export type TextInputOptions = { export type NumberInputStep = StepBase & {
type: InputStepType.NUMBER
options?: NumberInputOptions
}
type InputOptionsBase = {
labels?: { placeholder?: string; button?: string } labels?: { placeholder?: string; button?: string }
}
export type TextInputOptions = InputOptionsBase & {
isLong?: boolean isLong?: boolean
} }
export type NumberInputOptions = InputOptionsBase & {
min?: number
max?: number
step?: number
}
export type Target = { blockId: string; stepId?: string } export type Target = { blockId: string; stepId?: string }

View File

@ -1,4 +1,12 @@
import { Table } from 'models' import {
BubbleStepType,
InputStep,
InputStepType,
Step,
Table,
TextInputStep,
TextStep,
} from 'models'
export const sendRequest = async <ResponseData>({ export const sendRequest = async <ResponseData>({
url, url,
@ -32,3 +40,12 @@ export const filterTable = <T>(ids: string[], table: Table<T>): Table<T> => ({
byId: ids.reduce((acc, id) => ({ ...acc, [id]: table.byId[id] }), {}), byId: ids.reduce((acc, id) => ({ ...acc, [id]: table.byId[id] }), {}),
allIds: ids, allIds: ids,
}) })
export const isInputStep = (step: Step): step is InputStep =>
(Object.values(InputStepType) as string[]).includes(step.type)
export const isTextBubbleStep = (step: Step): step is TextStep =>
step.type === BubbleStepType.TEXT
export const isTextInputStep = (step: Step): step is TextInputStep =>
step.type === InputStepType.TEXT