2
0

feat(inputs): Add Condition step

This commit is contained in:
Baptiste Arnaud
2022-01-15 17:30:20 +01:00
parent 4ccb7bca49
commit 2814a352b2
30 changed files with 1178 additions and 243 deletions

View File

@ -1,7 +1,13 @@
import { PopoverContent, PopoverArrow, PopoverBody } from '@chakra-ui/react'
import {
PopoverContent,
PopoverArrow,
PopoverBody,
useEventListener,
} from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import {
ChoiceInputOptions,
ConditionOptions,
InputStep,
InputStepType,
LogicStepType,
@ -9,6 +15,7 @@ import {
Step,
TextInputOptions,
} from 'models'
import { useRef } from 'react'
import {
TextInputSettingsBody,
NumberInputSettingsBody,
@ -17,6 +24,7 @@ import {
DateInputSettingsBody,
} from './bodies'
import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody'
import { ConditionSettingsBody } from './bodies/ConditionSettingsBody'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody'
@ -25,12 +33,17 @@ type Props = {
}
export const SettingsPopoverContent = ({ step }: Props) => {
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
const handleMouseWheel = (e: WheelEvent) => {
e.stopPropagation()
}
useEventListener('wheel', handleMouseWheel, ref.current)
return (
<PopoverContent onMouseDown={handleMouseDown}>
<PopoverArrow />
<PopoverBody p="6">
<PopoverBody p="6" overflowY="scroll" maxH="400px" ref={ref}>
<SettingsPopoverBodyContent step={step} />
</PopoverBody>
</PopoverContent>
@ -40,7 +53,11 @@ export const SettingsPopoverContent = ({ step }: Props) => {
const SettingsPopoverBodyContent = ({ step }: Props) => {
const { updateStep } = useTypebot()
const handleOptionsChange = (
options: TextInputOptions | ChoiceInputOptions | SetVariableOptions
options:
| TextInputOptions
| ChoiceInputOptions
| SetVariableOptions
| ConditionOptions
) => updateStep(step.id, { options } as Partial<InputStep>)
switch (step.type) {
@ -108,6 +125,14 @@ const SettingsPopoverBodyContent = ({ step }: Props) => {
/>
)
}
case LogicStepType.CONDITION: {
return (
<ConditionSettingsBody
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
default: {
return <></>
}

View File

@ -0,0 +1,157 @@
import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import { PlusIcon, TrashIcon } from 'assets/icons'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { DropdownList } from 'components/shared/DropdownList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import {
Comparison,
ComparisonOperators,
LogicalOperator,
Table,
Variable,
} from 'models'
import React, { useEffect, useState } from 'react'
import { generate } from 'short-uuid'
import { useImmer } from 'use-immer'
type Props = {
initialComparisons: Table<Comparison>
logicalOperator: LogicalOperator
onLogicalOperatorChange: (logicalOperator: LogicalOperator) => void
onComparisonsChange: (comparisons: Table<Comparison>) => void
}
export const ComparisonsList = ({
initialComparisons,
logicalOperator,
onLogicalOperatorChange,
onComparisonsChange,
}: Props) => {
const [comparisons, setComparisons] = useImmer(initialComparisons)
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
useEffect(() => {
onComparisonsChange(comparisons)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [comparisons])
const createComparison = () => {
setComparisons((comparisons) => {
const id = generate()
comparisons.byId[id] = {
id,
comparisonOperator: ComparisonOperators.EQUAL,
}
comparisons.allIds.push(id)
})
}
const updateComparison = (
comparisonId: string,
updates: Partial<Omit<Comparison, 'id'>>
) =>
setComparisons((comparisons) => {
comparisons.byId[comparisonId] = {
...comparisons.byId[comparisonId],
...updates,
}
})
const deleteComparison = (comparisonId: string) => () => {
setComparisons((comparisons) => {
delete comparisons.byId[comparisonId]
const index = comparisons.allIds.indexOf(comparisonId)
if (index !== -1) comparisons.allIds.splice(index, 1)
})
}
const handleVariableSelected =
(comparisonId: string) => (variable: Variable) => {
updateComparison(comparisonId, { variableId: variable.id })
}
const handleComparisonOperatorSelected =
(comparisonId: string) => (dropdownItem: ComparisonOperators) =>
updateComparison(comparisonId, {
comparisonOperator: dropdownItem,
})
const handleLogicalOperatorSelected = (dropdownItem: LogicalOperator) =>
onLogicalOperatorChange(dropdownItem)
const handleValueChange = (comparisonId: string) => (value: string) =>
updateComparison(comparisonId, { value })
const handleMouseEnter = (comparisonId: string) => () => {
setShowDeleteId(comparisonId)
}
const handleMouseLeave = () => setShowDeleteId(undefined)
return (
<Stack spacing="4" py="4">
{comparisons.allIds.map((comparisonId, idx) => (
<>
{idx > 0 && (
<Flex justify="center">
<DropdownList<LogicalOperator>
currentItem={logicalOperator}
onItemSelect={handleLogicalOperatorSelected}
items={Object.values(LogicalOperator)}
/>
</Flex>
)}
<Flex
pos="relative"
onMouseEnter={handleMouseEnter(comparisonId)}
onMouseLeave={handleMouseLeave}
>
<Stack
key={comparisonId}
bgColor="blue.50"
p="4"
rounded="md"
flex="1"
>
<VariableSearchInput
initialVariableId={comparisons.byId[comparisonId].variableId}
onSelectVariable={handleVariableSelected(comparisonId)}
bgColor="white"
placeholder="Search for a variable"
/>
<DropdownList<ComparisonOperators>
currentItem={comparisons.byId[comparisonId].comparisonOperator}
onItemSelect={handleComparisonOperatorSelected(comparisonId)}
items={Object.values(ComparisonOperators)}
bgColor="white"
/>
{comparisons.byId[comparisonId].comparisonOperator !==
ComparisonOperators.IS_SET && (
<DebouncedInput
delay={100}
initialValue={comparisons.byId[comparisonId].value ?? ''}
onChange={handleValueChange(comparisonId)}
bgColor="white"
placeholder="Type a value..."
/>
)}
</Stack>
<Fade in={showDeleteId === comparisonId}>
<IconButton
icon={<TrashIcon />}
aria-label="Remove comparison"
onClick={deleteComparison(comparisonId)}
pos="absolute"
left="-10px"
top="-10px"
size="sm"
/>
</Fade>
</Flex>
</>
))}
<Button leftIcon={<PlusIcon />} onClick={createComparison} flexShrink={0}>
Add
</Button>
</Stack>
)
}

View File

@ -0,0 +1,27 @@
import { Comparison, ConditionOptions, LogicalOperator, Table } from 'models'
import React from 'react'
import { ComparisonsList } from './ComparisonsList'
type ConditionSettingsBodyProps = {
options: ConditionOptions
onOptionsChange: (options: ConditionOptions) => void
}
export const ConditionSettingsBody = ({
options,
onOptionsChange,
}: ConditionSettingsBodyProps) => {
const handleComparisonsChange = (comparisons: Table<Comparison>) =>
onOptionsChange({ ...options, comparisons })
const handleLogicalOperatorChange = (logicalOperator: LogicalOperator) =>
onOptionsChange({ ...options, logicalOperator })
return (
<ComparisonsList
initialComparisons={options.comparisons}
logicalOperator={options.logicalOperator ?? LogicalOperator.AND}
onLogicalOperatorChange={handleLogicalOperatorChange}
onComparisonsChange={handleComparisonsChange}
/>
)
}

View File

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

View File

@ -1,6 +1,6 @@
import { Box, BoxProps } from '@chakra-ui/react'
import { ConnectingSourceIds, useGraph } from 'contexts/GraphContext'
import React, { MouseEvent } from 'react'
import React, { MouseEvent, useEffect, useRef } from 'react'
export const SourceEndpoint = ({
source,
@ -8,15 +8,28 @@ export const SourceEndpoint = ({
}: BoxProps & {
source: ConnectingSourceIds
}) => {
const { setConnectingIds } = useGraph()
const { setConnectingIds, addSourceEndpoint: addEndpoint } = useGraph()
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
setConnectingIds({ source })
}
useEffect(() => {
if (!ref.current) return
const id =
source.choiceItemId ?? source.stepId + (source.conditionType ?? '')
addEndpoint({
id,
ref,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref])
return (
<Box
ref={ref}
boxSize="15px"
rounded="full"
bgColor="gray.500"

View File

@ -10,13 +10,7 @@ import React, { useEffect, useMemo, useState } from 'react'
import { Block, Step } from 'models'
import { useGraph } from 'contexts/GraphContext'
import { StepIcon } from 'components/board/StepTypesList/StepIcon'
import {
isChoiceInput,
isDefined,
isInputStep,
isLogicStep,
isTextBubbleStep,
} from 'utils'
import { isDefined, isInputStep, isLogicStep, isTextBubbleStep } from 'utils'
import { Coordinates } from '@dnd-kit/core/dist/types'
import { TextEditor } from './TextEditor/TextEditor'
import { StepNodeContent } from './StepNodeContent'
@ -26,6 +20,8 @@ import { SettingsPopoverContent } from './SettingsPopoverContent'
import { DraggableStep } from 'contexts/DndContext'
import { StepNodeContextMenu } from './StepNodeContextMenu'
import { SourceEndpoint } from './SourceEndpoint'
import { hasDefaultConnector } from 'services/typebots'
import { TargetEndpoint } from './TargetEndpoint'
export const StepNode = ({
step,
@ -192,7 +188,13 @@ export const StepNode = ({
>
<StepIcon type={step.type} mt="1" />
<StepNodeContent step={step} />
{isConnectable && !isChoiceInput(step) && (
<TargetEndpoint
pos="absolute"
left="-32px"
top="19px"
stepId={step.id}
/>
{isConnectable && hasDefaultConnector(step) && (
<SourceEndpoint
source={{
blockId: step.blockId,
@ -205,17 +207,23 @@ export const StepNode = ({
)}
</HStack>
{isDefined(connectedStubPosition) && !isChoiceInput(step) && (
<Box
h="2px"
pos="absolute"
right={connectedStubPosition === 'left' ? undefined : '-18px'}
left={connectedStubPosition === 'left' ? '-18px' : undefined}
top="25px"
w="18px"
bgColor="gray.500"
/>
)}
{isDefined(connectedStubPosition) &&
hasDefaultConnector(step) &&
isConnectable && (
<Box
h="2px"
pos="absolute"
right={
connectedStubPosition === 'left' ? undefined : '-18px'
}
left={
connectedStubPosition === 'left' ? '-18px' : undefined
}
top="25px"
w="18px"
bgColor="gray.500"
/>
)}
</Flex>
</PopoverTrigger>
{(isInputStep(step) || isLogicStep(step)) && (

View File

@ -1,4 +1,4 @@
import { Flex, Text } from '@chakra-ui/react'
import { Flex, HStack, Stack, Tag, Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import {
Step,
@ -7,8 +7,10 @@ import {
InputStepType,
LogicStepType,
SetVariableStep,
ConditionStep,
} from 'models'
import { ChoiceItemsList } from './ChoiceInputStepNode/ChoiceItemsList'
import { SourceEndpoint } from './SourceEndpoint'
type Props = {
step: Step | StartStep
@ -79,6 +81,9 @@ export const StepNodeContent = ({ step }: Props) => {
case LogicStepType.SET_VARIABLE: {
return <SetVariableNodeContent step={step} />
}
case LogicStepType.CONDITION: {
return <ConditionNodeContent step={step} />
}
case 'start': {
return <Text>{step.label}</Text>
}
@ -101,3 +106,51 @@ const SetVariableNodeContent = ({ step }: { step: SetVariableStep }) => {
</Text>
)
}
const ConditionNodeContent = ({ step }: { step: ConditionStep }) => {
const { typebot } = useTypebot()
return (
<Flex>
<Stack color={'gray.500'}>
{step.options?.comparisons.allIds.map((comparisonId, idx) => {
const comparison = step.options?.comparisons.byId[comparisonId]
const variable = typebot?.variables.byId[comparison?.variableId ?? '']
return (
<HStack key={comparisonId} spacing={1}>
{idx > 0 && <Text>{step.options?.logicalOperator ?? ''}</Text>}
{variable?.name && (
<Tag bgColor="orange.400">{variable.name}</Tag>
)}
{comparison.comparisonOperator && (
<Text>{comparison?.comparisonOperator}</Text>
)}
{comparison?.value && (
<Tag bgColor={'green.400'}>{comparison.value}</Tag>
)}
</HStack>
)
})}
</Stack>
<SourceEndpoint
source={{
blockId: step.blockId,
stepId: step.id,
conditionType: 'true',
}}
pos="absolute"
top="7px"
right="15px"
/>
<SourceEndpoint
source={{
blockId: step.blockId,
stepId: step.id,
conditionType: 'false',
}}
pos="absolute"
bottom="7px"
right="15px"
/>
</Flex>
)
}

View File

@ -0,0 +1,36 @@
import { Box, BoxProps } from '@chakra-ui/react'
import { useGraph } from 'contexts/GraphContext'
import React, { useEffect, useRef } from 'react'
export const TargetEndpoint = ({
stepId,
isVisible,
...props
}: BoxProps & {
stepId: string
isVisible?: boolean
}) => {
const { addTargetEndpoint } = useGraph()
const ref = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!ref.current) return
addTargetEndpoint({
id: stepId,
ref,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref])
return (
<Box
ref={ref}
boxSize="15px"
rounded="full"
bgColor="blue.500"
cursor="pointer"
visibility={isVisible ? 'visible' : 'hidden'}
{...props}
/>
)
}

View File

@ -8,7 +8,6 @@ import {
withPlate,
} from '@udecode/plate-core'
import { editorStyle, platePlugins } from 'libs/plate'
import { useDebounce } from 'use-debounce'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { BaseSelection, createEditor, Transforms } from 'slate'
import { ToolBar } from './ToolBar'
@ -37,7 +36,6 @@ export const TextEditor = ({
)
const { updateStep } = useTypebot()
const [value, setValue] = useState(initialValue)
const [debouncedValue] = useDebounce(value, 500)
const varDropdownRef = useRef<HTMLDivElement | null>(null)
const rememberedSelection = useRef<BaseSelection | null>(null)
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
@ -45,16 +43,15 @@ export const TextEditor = ({
const textEditorRef = useRef<HTMLDivElement>(null)
useOutsideClick({
ref: textEditorRef,
handler: () => {
save(value)
onClose()
},
handler: onClose,
})
useEffect(() => {
save(debouncedValue)
return () => {
save(value)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue])
}, [value])
useEffect(() => {
if (!isVariableDropdownOpen) return
@ -107,6 +104,7 @@ export const TextEditor = ({
const handleChangeEditorContent = (val: unknown[]) => {
setValue(val)
save(val)
setIsVariableDropdownOpen(false)
}
return (