feat(inputs): ✨ Add Set variable step
This commit is contained in:
@ -1,5 +1,5 @@
|
|||||||
import { Button, ButtonProps, Flex, HStack } from '@chakra-ui/react'
|
import { Button, ButtonProps, Flex, HStack } from '@chakra-ui/react'
|
||||||
import { BubbleStepType, InputStepType, StepType } from 'models'
|
import { BubbleStepType, InputStepType, StepType, LogicStepType } 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'
|
||||||
@ -9,10 +9,10 @@ export const StepCard = ({
|
|||||||
type,
|
type,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
}: {
|
}: {
|
||||||
type: BubbleStepType | InputStepType
|
type: BubbleStepType | InputStepType | LogicStepType
|
||||||
onMouseDown: (
|
onMouseDown: (
|
||||||
e: React.MouseEvent,
|
e: React.MouseEvent,
|
||||||
type: BubbleStepType | InputStepType
|
type: BubbleStepType | InputStepType | LogicStepType
|
||||||
) => void
|
) => void
|
||||||
}) => {
|
}) => {
|
||||||
const { draggedStepType } = useDnd()
|
const { draggedStepType } = useDnd()
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ChatIcon,
|
ChatIcon,
|
||||||
CheckSquareIcon,
|
CheckSquareIcon,
|
||||||
|
EditIcon,
|
||||||
EmailIcon,
|
EmailIcon,
|
||||||
FlagIcon,
|
FlagIcon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
@ -10,7 +11,7 @@ import {
|
|||||||
PhoneIcon,
|
PhoneIcon,
|
||||||
TextIcon,
|
TextIcon,
|
||||||
} from 'assets/icons'
|
} from 'assets/icons'
|
||||||
import { BubbleStepType, InputStepType, StepType } from 'models'
|
import { BubbleStepType, InputStepType, LogicStepType, StepType } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type StepIconProps = { type: StepType } & IconProps
|
type StepIconProps = { type: StepType } & IconProps
|
||||||
@ -41,6 +42,9 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
|
|||||||
case InputStepType.CHOICE: {
|
case InputStepType.CHOICE: {
|
||||||
return <CheckSquareIcon {...props} />
|
return <CheckSquareIcon {...props} />
|
||||||
}
|
}
|
||||||
|
case LogicStepType.SET_VARIABLE: {
|
||||||
|
return <EditIcon {...props} />
|
||||||
|
}
|
||||||
case 'start': {
|
case 'start': {
|
||||||
return <FlagIcon {...props} />
|
return <FlagIcon {...props} />
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Text } from '@chakra-ui/react'
|
import { Text } from '@chakra-ui/react'
|
||||||
import { BubbleStepType, InputStepType, StepType } from 'models'
|
import { BubbleStepType, InputStepType, LogicStepType, StepType } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type Props = { type: StepType }
|
type Props = { type: StepType }
|
||||||
@ -28,6 +28,9 @@ export const StepTypeLabel = ({ type }: Props) => {
|
|||||||
case InputStepType.CHOICE: {
|
case InputStepType.CHOICE: {
|
||||||
return <Text>Button</Text>
|
return <Text>Button</Text>
|
||||||
}
|
}
|
||||||
|
case LogicStepType.SET_VARIABLE: {
|
||||||
|
return <Text>Set variable</Text>
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
useEventListener,
|
useEventListener,
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { BubbleStepType, InputStepType } from 'models'
|
import { BubbleStepType, InputStepType, LogicStepType } 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'
|
||||||
@ -31,7 +31,7 @@ export const StepTypesList = () => {
|
|||||||
|
|
||||||
const handleMouseDown = (
|
const handleMouseDown = (
|
||||||
e: React.MouseEvent,
|
e: React.MouseEvent,
|
||||||
type: BubbleStepType | InputStepType
|
type: BubbleStepType | InputStepType | LogicStepType
|
||||||
) => {
|
) => {
|
||||||
const element = e.currentTarget as HTMLDivElement
|
const element = e.currentTarget as HTMLDivElement
|
||||||
const rect = element.getBoundingClientRect()
|
const rect = element.getBoundingClientRect()
|
||||||
@ -85,6 +85,15 @@ export const StepTypesList = () => {
|
|||||||
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
|
||||||
|
Logic
|
||||||
|
</Text>
|
||||||
|
<SimpleGrid columns={2} spacing="2">
|
||||||
|
{Object.values(LogicStepType).map((type) => (
|
||||||
|
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
{draggedStepType && (
|
{draggedStepType && (
|
||||||
<StepCardOverlay
|
<StepCardOverlay
|
||||||
type={draggedStepType}
|
type={draggedStepType}
|
||||||
|
@ -4,6 +4,9 @@ import {
|
|||||||
ChoiceInputOptions,
|
ChoiceInputOptions,
|
||||||
InputStep,
|
InputStep,
|
||||||
InputStepType,
|
InputStepType,
|
||||||
|
LogicStepType,
|
||||||
|
SetVariableOptions,
|
||||||
|
Step,
|
||||||
TextInputOptions,
|
TextInputOptions,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import {
|
import {
|
||||||
@ -15,10 +18,12 @@ import {
|
|||||||
} from './bodies'
|
} from './bodies'
|
||||||
import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody'
|
import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody'
|
||||||
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
|
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
|
||||||
|
import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
step: InputStep
|
step: Step
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsPopoverContent = ({ step }: Props) => {
|
export const SettingsPopoverContent = ({ step }: Props) => {
|
||||||
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
|
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
|
||||||
|
|
||||||
@ -35,7 +40,7 @@ export const SettingsPopoverContent = ({ step }: Props) => {
|
|||||||
const SettingsPopoverBodyContent = ({ step }: Props) => {
|
const SettingsPopoverBodyContent = ({ step }: Props) => {
|
||||||
const { updateStep } = useTypebot()
|
const { updateStep } = useTypebot()
|
||||||
const handleOptionsChange = (
|
const handleOptionsChange = (
|
||||||
options: TextInputOptions | ChoiceInputOptions
|
options: TextInputOptions | ChoiceInputOptions | SetVariableOptions
|
||||||
) => updateStep(step.id, { options } as Partial<InputStep>)
|
) => updateStep(step.id, { options } as Partial<InputStep>)
|
||||||
|
|
||||||
switch (step.type) {
|
switch (step.type) {
|
||||||
@ -95,6 +100,14 @@ const SettingsPopoverBodyContent = ({ step }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
case LogicStepType.SET_VARIABLE: {
|
||||||
|
return (
|
||||||
|
<SetVariableSettingsBody
|
||||||
|
options={step.options}
|
||||||
|
onOptionsChange={handleOptionsChange}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
return <></>
|
return <></>
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { FormLabel, Stack } from '@chakra-ui/react'
|
import { FormLabel, Stack } from '@chakra-ui/react'
|
||||||
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
||||||
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
||||||
import { ChoiceInputOptions } from 'models'
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
|
import { ChoiceInputOptions, Variable } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type ChoiceInputSettingsBodyProps = {
|
type ChoiceInputSettingsBodyProps = {
|
||||||
@ -17,6 +18,8 @@ export const ChoiceInputSettingsBody = ({
|
|||||||
options && onOptionsChange({ ...options, isMultipleChoice })
|
options && onOptionsChange({ ...options, isMultipleChoice })
|
||||||
const handleButtonLabelChange = (buttonLabel: string) =>
|
const handleButtonLabelChange = (buttonLabel: string) =>
|
||||||
options && onOptionsChange({ ...options, buttonLabel })
|
options && onOptionsChange({ ...options, buttonLabel })
|
||||||
|
const handleVariableChange = (variable: Variable) =>
|
||||||
|
options && onOptionsChange({ ...options, variableId: variable.id })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@ -39,6 +42,15 @@ export const ChoiceInputSettingsBody = ({
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
<Stack>
|
||||||
|
<FormLabel mb="0" htmlFor="variable">
|
||||||
|
Save answer in a variable:
|
||||||
|
</FormLabel>
|
||||||
|
<VariableSearchInput
|
||||||
|
initialVariableId={options?.variableId}
|
||||||
|
onSelectVariable={handleVariableChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { FormLabel, Stack } from '@chakra-ui/react'
|
import { FormLabel, Stack } from '@chakra-ui/react'
|
||||||
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
||||||
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
||||||
import { DateInputOptions } from 'models'
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
|
import { DateInputOptions, Variable } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type DateInputSettingsBodyProps = {
|
type DateInputSettingsBodyProps = {
|
||||||
@ -23,6 +24,8 @@ export const DateInputSettingsBody = ({
|
|||||||
onOptionsChange({ ...options, isRange })
|
onOptionsChange({ ...options, isRange })
|
||||||
const handleHasTimeChange = (hasTime: boolean) =>
|
const handleHasTimeChange = (hasTime: boolean) =>
|
||||||
onOptionsChange({ ...options, hasTime })
|
onOptionsChange({ ...options, hasTime })
|
||||||
|
const handleVariableChange = (variable: Variable) =>
|
||||||
|
onOptionsChange({ ...options, variableId: variable.id })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@ -75,6 +78,15 @@ export const DateInputSettingsBody = ({
|
|||||||
onChange={handleButtonLabelChange}
|
onChange={handleButtonLabelChange}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<FormLabel mb="0" htmlFor="variable">
|
||||||
|
Save answer in a variable:
|
||||||
|
</FormLabel>
|
||||||
|
<VariableSearchInput
|
||||||
|
initialVariableId={options?.variableId}
|
||||||
|
onSelectVariable={handleVariableChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { FormLabel, Stack } from '@chakra-ui/react'
|
import { FormLabel, Stack } from '@chakra-ui/react'
|
||||||
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
||||||
import { EmailInputOptions } from 'models'
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
|
import { EmailInputOptions, Variable } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type EmailInputSettingsBodyProps = {
|
type EmailInputSettingsBodyProps = {
|
||||||
@ -16,6 +17,8 @@ export const EmailInputSettingsBody = ({
|
|||||||
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
|
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
|
||||||
const handleButtonLabelChange = (button: string) =>
|
const handleButtonLabelChange = (button: string) =>
|
||||||
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
|
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
|
||||||
|
const handleVariableChange = (variable: Variable) =>
|
||||||
|
onOptionsChange({ ...options, variableId: variable.id })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@ -41,6 +44,15 @@ export const EmailInputSettingsBody = ({
|
|||||||
onChange={handleButtonLabelChange}
|
onChange={handleButtonLabelChange}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<FormLabel mb="0" htmlFor="variable">
|
||||||
|
Save answer in a variable:
|
||||||
|
</FormLabel>
|
||||||
|
<VariableSearchInput
|
||||||
|
initialVariableId={options?.variableId}
|
||||||
|
onSelectVariable={handleVariableChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { FormLabel, HStack, Stack } from '@chakra-ui/react'
|
import { FormLabel, HStack, Stack } from '@chakra-ui/react'
|
||||||
import { SmartNumberInput } from 'components/settings/SmartNumberInput'
|
import { SmartNumberInput } from 'components/settings/SmartNumberInput'
|
||||||
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
||||||
import { NumberInputOptions } from 'models'
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
|
import { NumberInputOptions, Variable } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { removeUndefinedFields } from 'services/utils'
|
import { removeUndefinedFields } from 'services/utils'
|
||||||
|
|
||||||
@ -24,6 +25,8 @@ export const NumberInputSettingsBody = ({
|
|||||||
onOptionsChange(removeUndefinedFields({ ...options, max }))
|
onOptionsChange(removeUndefinedFields({ ...options, max }))
|
||||||
const handleStepChange = (step?: number) =>
|
const handleStepChange = (step?: number) =>
|
||||||
onOptionsChange(removeUndefinedFields({ ...options, step }))
|
onOptionsChange(removeUndefinedFields({ ...options, step }))
|
||||||
|
const handleVariableChange = (variable: Variable) =>
|
||||||
|
onOptionsChange({ ...options, variableId: variable.id })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@ -79,6 +82,15 @@ export const NumberInputSettingsBody = ({
|
|||||||
onValueChange={handleStepChange}
|
onValueChange={handleStepChange}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
<Stack>
|
||||||
|
<FormLabel mb="0" htmlFor="variable">
|
||||||
|
Save answer in a variable:
|
||||||
|
</FormLabel>
|
||||||
|
<VariableSearchInput
|
||||||
|
initialVariableId={options?.variableId}
|
||||||
|
onSelectVariable={handleVariableChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { FormLabel, Stack } from '@chakra-ui/react'
|
import { FormLabel, Stack } from '@chakra-ui/react'
|
||||||
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
||||||
import { EmailInputOptions } from 'models'
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
|
import { EmailInputOptions, Variable } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type PhoneNumberSettingsBodyProps = {
|
type PhoneNumberSettingsBodyProps = {
|
||||||
@ -16,6 +17,8 @@ export const PhoneNumberSettingsBody = ({
|
|||||||
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
|
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
|
||||||
const handleButtonLabelChange = (button: string) =>
|
const handleButtonLabelChange = (button: string) =>
|
||||||
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
|
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
|
||||||
|
const handleVariableChange = (variable: Variable) =>
|
||||||
|
onOptionsChange({ ...options, variableId: variable.id })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@ -41,6 +44,15 @@ export const PhoneNumberSettingsBody = ({
|
|||||||
onChange={handleButtonLabelChange}
|
onChange={handleButtonLabelChange}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<FormLabel mb="0" htmlFor="variable">
|
||||||
|
Save answer in a variable:
|
||||||
|
</FormLabel>
|
||||||
|
<VariableSearchInput
|
||||||
|
initialVariableId={options?.variableId}
|
||||||
|
onSelectVariable={handleVariableChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
import { FormLabel, Stack } from '@chakra-ui/react'
|
||||||
|
import { DebouncedTextarea } from 'components/shared/DebouncedTextarea'
|
||||||
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
|
import { SetVariableOptions, Variable } from 'models'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
options?: SetVariableOptions
|
||||||
|
onOptionsChange: (options: SetVariableOptions) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SetVariableSettingsBody = ({
|
||||||
|
options,
|
||||||
|
onOptionsChange,
|
||||||
|
}: Props) => {
|
||||||
|
const handleVariableChange = (variable: Variable) =>
|
||||||
|
onOptionsChange({ ...options, variableId: variable.id })
|
||||||
|
const handleExpressionChange = (expressionToEvaluate: string) =>
|
||||||
|
onOptionsChange({ ...options, expressionToEvaluate })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Stack>
|
||||||
|
<FormLabel mb="0" htmlFor="variable-search">
|
||||||
|
Search or create variable:
|
||||||
|
</FormLabel>
|
||||||
|
<VariableSearchInput
|
||||||
|
onSelectVariable={handleVariableChange}
|
||||||
|
initialVariableId={options?.variableId}
|
||||||
|
id="variable-search"
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<FormLabel mb="0" htmlFor="expression">
|
||||||
|
Value / Expression:
|
||||||
|
</FormLabel>
|
||||||
|
<DebouncedTextarea
|
||||||
|
id="expression"
|
||||||
|
initialValue={options?.expressionToEvaluate ?? ''}
|
||||||
|
delay={100}
|
||||||
|
onChange={handleExpressionChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
import { FormLabel, Stack } from '@chakra-ui/react'
|
import { FormLabel, Stack } from '@chakra-ui/react'
|
||||||
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
||||||
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
|
||||||
import { TextInputOptions } from 'models'
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
|
import { TextInputOptions, Variable } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type TextInputSettingsBodyProps = {
|
type TextInputSettingsBodyProps = {
|
||||||
@ -19,6 +20,8 @@ export const TextInputSettingsBody = ({
|
|||||||
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
|
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
|
||||||
const handleLongChange = (isLong: boolean) =>
|
const handleLongChange = (isLong: boolean) =>
|
||||||
onOptionsChange({ ...options, isLong })
|
onOptionsChange({ ...options, isLong })
|
||||||
|
const handleVariableChange = (variable: Variable) =>
|
||||||
|
onOptionsChange({ ...options, variableId: variable.id })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@ -50,6 +53,15 @@ export const TextInputSettingsBody = ({
|
|||||||
onChange={handleButtonLabelChange}
|
onChange={handleButtonLabelChange}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<FormLabel mb="0" htmlFor="variable">
|
||||||
|
Save answer in a variable:
|
||||||
|
</FormLabel>
|
||||||
|
<VariableSearchInput
|
||||||
|
initialVariableId={options?.variableId}
|
||||||
|
onSelectVariable={handleVariableChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { FormLabel, Stack } from '@chakra-ui/react'
|
import { FormLabel, Stack } from '@chakra-ui/react'
|
||||||
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
import { DebouncedInput } from 'components/shared/DebouncedInput'
|
||||||
import { UrlInputOptions } from 'models'
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
|
import { UrlInputOptions, Variable } from 'models'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
type UrlInputSettingsBodyProps = {
|
type UrlInputSettingsBodyProps = {
|
||||||
@ -16,6 +17,8 @@ export const UrlInputSettingsBody = ({
|
|||||||
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
|
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
|
||||||
const handleButtonLabelChange = (button: string) =>
|
const handleButtonLabelChange = (button: string) =>
|
||||||
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
|
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
|
||||||
|
const handleVariableChange = (variable: Variable) =>
|
||||||
|
onOptionsChange({ ...options, variableId: variable.id })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@ -41,6 +44,15 @@ export const UrlInputSettingsBody = ({
|
|||||||
onChange={handleButtonLabelChange}
|
onChange={handleButtonLabelChange}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<Stack>
|
||||||
|
<FormLabel mb="0" htmlFor="variable">
|
||||||
|
Save answer in a variable:
|
||||||
|
</FormLabel>
|
||||||
|
<VariableSearchInput
|
||||||
|
initialVariableId={options?.variableId}
|
||||||
|
onSelectVariable={handleVariableChange}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,13 @@ import React, { useEffect, useMemo, useState } from 'react'
|
|||||||
import { Block, Step } from 'models'
|
import { Block, Step } from 'models'
|
||||||
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 { isChoiceInput, isDefined, isInputStep, isTextBubbleStep } from 'utils'
|
import {
|
||||||
|
isChoiceInput,
|
||||||
|
isDefined,
|
||||||
|
isInputStep,
|
||||||
|
isLogicStep,
|
||||||
|
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 { StepNodeContent } from './StepNodeContent'
|
import { StepNodeContent } from './StepNodeContent'
|
||||||
@ -212,7 +218,9 @@ export const StepNode = ({
|
|||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
{isInputStep(step) && <SettingsPopoverContent step={step} />}
|
{(isInputStep(step) || isLogicStep(step)) && (
|
||||||
|
<SettingsPopoverContent step={step} />
|
||||||
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
@ -1,5 +1,13 @@
|
|||||||
import { Flex, Text } from '@chakra-ui/react'
|
import { Flex, Text } from '@chakra-ui/react'
|
||||||
import { Step, StartStep, BubbleStepType, InputStepType } from 'models'
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
|
import {
|
||||||
|
Step,
|
||||||
|
StartStep,
|
||||||
|
BubbleStepType,
|
||||||
|
InputStepType,
|
||||||
|
LogicStepType,
|
||||||
|
SetVariableStep,
|
||||||
|
} from 'models'
|
||||||
import { ChoiceItemsList } from './ChoiceInputStepNode/ChoiceItemsList'
|
import { ChoiceItemsList } from './ChoiceInputStepNode/ChoiceItemsList'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -68,6 +76,9 @@ export const StepNodeContent = ({ step }: Props) => {
|
|||||||
case InputStepType.CHOICE: {
|
case InputStepType.CHOICE: {
|
||||||
return <ChoiceItemsList step={step} />
|
return <ChoiceItemsList step={step} />
|
||||||
}
|
}
|
||||||
|
case LogicStepType.SET_VARIABLE: {
|
||||||
|
return <SetVariableNodeContent step={step} />
|
||||||
|
}
|
||||||
case 'start': {
|
case 'start': {
|
||||||
return <Text>{step.label}</Text>
|
return <Text>{step.label}</Text>
|
||||||
}
|
}
|
||||||
@ -76,3 +87,17 @@ export const StepNodeContent = ({ step }: Props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SetVariableNodeContent = ({ step }: { step: SetVariableStep }) => {
|
||||||
|
const { typebot } = useTypebot()
|
||||||
|
const variableName =
|
||||||
|
typebot?.variables.byId[step.options?.variableId ?? '']?.name ?? ''
|
||||||
|
const expression = step.options?.expressionToEvaluate ?? ''
|
||||||
|
return (
|
||||||
|
<Text color={'gray.500'}>
|
||||||
|
{variableName === '' && expression === ''
|
||||||
|
? 'Click to edit...'
|
||||||
|
: `${variableName} = ${expression}`}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Stack, useOutsideClick } from '@chakra-ui/react'
|
import { Flex, Stack, useOutsideClick } from '@chakra-ui/react'
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Plate,
|
Plate,
|
||||||
@ -10,10 +10,12 @@ import {
|
|||||||
import { editorStyle, platePlugins } from 'libs/plate'
|
import { editorStyle, platePlugins } from 'libs/plate'
|
||||||
import { useDebounce } from 'use-debounce'
|
import { useDebounce } from 'use-debounce'
|
||||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||||
import { createEditor } from 'slate'
|
import { BaseSelection, createEditor, Transforms } from 'slate'
|
||||||
import { ToolBar } from './ToolBar'
|
import { ToolBar } from './ToolBar'
|
||||||
import { parseHtmlStringToPlainText } from 'services/utils'
|
import { parseHtmlStringToPlainText } from 'services/utils'
|
||||||
import { TextStep } from 'models'
|
import { TextStep, Variable } from 'models'
|
||||||
|
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
|
||||||
|
import { ReactEditor } from 'slate-react'
|
||||||
|
|
||||||
type TextEditorProps = {
|
type TextEditorProps = {
|
||||||
stepId: string
|
stepId: string
|
||||||
@ -26,14 +28,20 @@ export const TextEditor = ({
|
|||||||
stepId,
|
stepId,
|
||||||
onClose,
|
onClose,
|
||||||
}: TextEditorProps) => {
|
}: TextEditorProps) => {
|
||||||
|
const randomEditorId = useMemo(() => Math.random().toString(), [])
|
||||||
const editor = useMemo(
|
const editor = useMemo(
|
||||||
() => withPlate(createEditor(), { id: stepId, plugins: platePlugins }),
|
() =>
|
||||||
|
withPlate(createEditor(), { id: randomEditorId, plugins: platePlugins }),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
const { updateStep } = useTypebot()
|
const { updateStep } = useTypebot()
|
||||||
const [value, setValue] = useState(initialValue)
|
const [value, setValue] = useState(initialValue)
|
||||||
const [debouncedValue] = useDebounce(value, 500)
|
const [debouncedValue] = useDebounce(value, 500)
|
||||||
|
const varDropdownRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const rememberedSelection = useRef<BaseSelection | null>(null)
|
||||||
|
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
|
||||||
|
|
||||||
const textEditorRef = useRef<HTMLDivElement>(null)
|
const textEditorRef = useRef<HTMLDivElement>(null)
|
||||||
useOutsideClick({
|
useOutsideClick({
|
||||||
ref: textEditorRef,
|
ref: textEditorRef,
|
||||||
@ -48,6 +56,29 @@ export const TextEditor = ({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [debouncedValue])
|
}, [debouncedValue])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVariableDropdownOpen) return
|
||||||
|
const el = varDropdownRef.current
|
||||||
|
if (!el) return
|
||||||
|
const { top, left } = computeTargetCoord()
|
||||||
|
el.style.top = `${top}px`
|
||||||
|
el.style.left = `${left}px`
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isVariableDropdownOpen])
|
||||||
|
|
||||||
|
const computeTargetCoord = () => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
const relativeParent = textEditorRef.current
|
||||||
|
if (!selection || !relativeParent) return { top: 0, left: 0 }
|
||||||
|
const range = selection.getRangeAt(0)
|
||||||
|
const selectionBoundingRect = range.getBoundingClientRect()
|
||||||
|
const relativeRect = relativeParent.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
top: selectionBoundingRect.bottom - relativeRect.top,
|
||||||
|
left: selectionBoundingRect.left - relativeRect.left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const save = (value: unknown[]) => {
|
const save = (value: unknown[]) => {
|
||||||
if (value.length === 0) return
|
if (value.length === 0) return
|
||||||
const html = serializeHtml(editor, {
|
const html = serializeHtml(editor, {
|
||||||
@ -65,6 +96,19 @@ export const TextEditor = ({
|
|||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleVariableSelected = (variable: Variable) => {
|
||||||
|
setIsVariableDropdownOpen(false)
|
||||||
|
if (!rememberedSelection.current) return
|
||||||
|
Transforms.select(editor, rememberedSelection.current)
|
||||||
|
Transforms.insertText(editor, '{{' + variable.name + '}}')
|
||||||
|
ReactEditor.focus(editor as unknown as ReactEditor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangeEditorContent = (val: unknown[]) => {
|
||||||
|
setValue(val)
|
||||||
|
setIsVariableDropdownOpen(false)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
flex="1"
|
flex="1"
|
||||||
@ -73,11 +117,12 @@ export const TextEditor = ({
|
|||||||
borderColor="blue.500"
|
borderColor="blue.500"
|
||||||
rounded="md"
|
rounded="md"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
pos="relative"
|
||||||
spacing={0}
|
spacing={0}
|
||||||
>
|
>
|
||||||
<ToolBar />
|
<ToolBar onVariablesButtonClick={() => setIsVariableDropdownOpen(true)} />
|
||||||
<Plate
|
<Plate
|
||||||
id={stepId}
|
id={randomEditorId}
|
||||||
editableProps={{
|
editableProps={{
|
||||||
style: editorStyle,
|
style: editorStyle,
|
||||||
autoFocus: true,
|
autoFocus: true,
|
||||||
@ -88,15 +133,34 @@ export const TextEditor = ({
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
'aria-label': 'Text editor',
|
'aria-label': 'Text editor',
|
||||||
|
onBlur: () => {
|
||||||
|
rememberedSelection.current = editor.selection
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
initialValue={
|
initialValue={
|
||||||
initialValue.length === 0
|
initialValue.length === 0
|
||||||
? [{ type: 'p', children: [{ text: '' }] }]
|
? [{ type: 'p', children: [{ text: '' }] }]
|
||||||
: initialValue
|
: initialValue
|
||||||
}
|
}
|
||||||
onChange={setValue}
|
onChange={handleChangeEditorContent}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
/>
|
/>
|
||||||
|
{isVariableDropdownOpen && (
|
||||||
|
<Flex
|
||||||
|
pos="absolute"
|
||||||
|
ref={varDropdownRef}
|
||||||
|
shadow="lg"
|
||||||
|
rounded="md"
|
||||||
|
bgColor="white"
|
||||||
|
w="250px"
|
||||||
|
>
|
||||||
|
<VariableSearchInput
|
||||||
|
onSelectVariable={handleVariableSelected}
|
||||||
|
placeholder="Search for a variable"
|
||||||
|
isDefaultOpen
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -9,8 +9,14 @@ import { LinkToolbarButton } from '@udecode/plate-ui-link'
|
|||||||
import { MarkToolbarButton } from '@udecode/plate-ui-toolbar'
|
import { MarkToolbarButton } from '@udecode/plate-ui-toolbar'
|
||||||
import { BoldIcon, ItalicIcon, UnderlineIcon, LinkIcon } from 'assets/icons'
|
import { BoldIcon, ItalicIcon, UnderlineIcon, LinkIcon } from 'assets/icons'
|
||||||
|
|
||||||
export const ToolBar = (props: StackProps) => {
|
type Props = { onVariablesButtonClick: () => void } & StackProps
|
||||||
|
export const ToolBar = (props: Props) => {
|
||||||
const editor = usePlateEditorRef()
|
const editor = usePlateEditorRef()
|
||||||
|
|
||||||
|
const handleVariablesButtonMouseDown = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
props.onVariablesButtonClick()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
bgColor={'white'}
|
bgColor={'white'}
|
||||||
@ -21,7 +27,9 @@ export const ToolBar = (props: StackProps) => {
|
|||||||
borderBottomWidth={1}
|
borderBottomWidth={1}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Button size="sm">Variables</Button>
|
<Button size="sm" onMouseDown={handleVariablesButtonMouseDown}>
|
||||||
|
Variables
|
||||||
|
</Button>
|
||||||
<span data-testid="bold-button">
|
<span data-testid="bold-button">
|
||||||
<MarkToolbarButton
|
<MarkToolbarButton
|
||||||
type={getPluginType(editor, MARK_BOLD)}
|
type={getPluginType(editor, MARK_BOLD)}
|
||||||
|
@ -26,7 +26,7 @@ export const PreviewDrawer = () => {
|
|||||||
const [restartKey, setRestartKey] = useState(0)
|
const [restartKey, setRestartKey] = useState(0)
|
||||||
|
|
||||||
const publicTypebot = useMemo(
|
const publicTypebot = useMemo(
|
||||||
() => (typebot ? parseTypebotToPublicTypebot(typebot) : undefined),
|
() => (typebot ? { ...parseTypebotToPublicTypebot(typebot) } : undefined),
|
||||||
[typebot]
|
[typebot]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
38
apps/builder/components/shared/DebouncedTextarea.tsx
Normal file
38
apps/builder/components/shared/DebouncedTextarea.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Textarea, TextareaProps } from '@chakra-ui/react'
|
||||||
|
import { ChangeEvent, useEffect, useState } from 'react'
|
||||||
|
import { useDebounce } from 'use-debounce'
|
||||||
|
|
||||||
|
type Props = Omit<TextareaProps, 'onChange' | 'value'> & {
|
||||||
|
delay: number
|
||||||
|
initialValue: string
|
||||||
|
onChange: (debouncedValue: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DebouncedTextarea = ({
|
||||||
|
delay,
|
||||||
|
onChange,
|
||||||
|
initialValue,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
|
const [currentValue, setCurrentValue] = useState(initialValue)
|
||||||
|
const [currentValueDebounced] = useDebounce(currentValue, delay)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentValueDebounced === initialValue) return
|
||||||
|
onChange(currentValueDebounced)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentValueDebounced])
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setCurrentValue(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
{...props}
|
||||||
|
value={currentValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
resize={'vertical'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
170
apps/builder/components/shared/VariableSearchInput.tsx
Normal file
170
apps/builder/components/shared/VariableSearchInput.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
useDisclosure,
|
||||||
|
useOutsideClick,
|
||||||
|
Flex,
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
Input,
|
||||||
|
PopoverContent,
|
||||||
|
Button,
|
||||||
|
InputProps,
|
||||||
|
IconButton,
|
||||||
|
} from '@chakra-ui/react'
|
||||||
|
import { PlusIcon, TrashIcon } from 'assets/icons'
|
||||||
|
import { useTypebot } from 'contexts/TypebotContext'
|
||||||
|
import { Variable } from 'models'
|
||||||
|
import React, { useState, useRef, ChangeEvent, useMemo, useEffect } from 'react'
|
||||||
|
import { generate } from 'short-uuid'
|
||||||
|
import { isDefined } from 'utils'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialVariableId?: string
|
||||||
|
onSelectVariable: (variable: Pick<Variable, 'id' | 'name'>) => void
|
||||||
|
isDefaultOpen?: boolean
|
||||||
|
} & InputProps
|
||||||
|
|
||||||
|
export const VariableSearchInput = ({
|
||||||
|
initialVariableId,
|
||||||
|
onSelectVariable,
|
||||||
|
isDefaultOpen,
|
||||||
|
...inputProps
|
||||||
|
}: Props) => {
|
||||||
|
const { onOpen, onClose, isOpen } = useDisclosure()
|
||||||
|
const { typebot, createVariable, deleteVariable } = useTypebot()
|
||||||
|
const variables = useMemo(
|
||||||
|
() =>
|
||||||
|
typebot?.variables.allIds.map((id) => typebot.variables.byId[id]) ?? [],
|
||||||
|
[typebot?.variables]
|
||||||
|
)
|
||||||
|
const [inputValue, setInputValue] = useState(
|
||||||
|
typebot?.variables.byId[initialVariableId ?? '']?.name ?? ''
|
||||||
|
)
|
||||||
|
const [filteredItems, setFilteredItems] = useState<Variable[]>(variables)
|
||||||
|
const dropdownRef = useRef(null)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
useOutsideClick({
|
||||||
|
ref: dropdownRef,
|
||||||
|
handler: onClose,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDefaultOpen) onOpen()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setInputValue(e.target.value)
|
||||||
|
onOpen()
|
||||||
|
if (e.target.value === '') {
|
||||||
|
setFilteredItems([...variables.slice(0, 50)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFilteredItems([
|
||||||
|
...variables
|
||||||
|
.filter((item) =>
|
||||||
|
item.name.toLowerCase().includes((e.target.value ?? '').toLowerCase())
|
||||||
|
)
|
||||||
|
.slice(0, 50),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVariableNameClick = (variable: Variable) => () => {
|
||||||
|
setInputValue(variable.name)
|
||||||
|
onSelectVariable(variable)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreateNewVariableClick = () => {
|
||||||
|
if (!inputValue || inputValue === '') return
|
||||||
|
const id = generate()
|
||||||
|
createVariable({ id, name: inputValue })
|
||||||
|
onSelectVariable({ id, name: inputValue })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteVariableClick =
|
||||||
|
(variable: Variable) => (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
deleteVariable(variable.id)
|
||||||
|
setFilteredItems(filteredItems.filter((item) => item.id !== variable.id))
|
||||||
|
if (variable.name === inputValue) setInputValue('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex ref={dropdownRef} w="full">
|
||||||
|
<Popover
|
||||||
|
isOpen={isOpen}
|
||||||
|
initialFocusRef={inputRef}
|
||||||
|
matchWidth
|
||||||
|
offset={[0, 0]}
|
||||||
|
isLazy
|
||||||
|
>
|
||||||
|
<PopoverTrigger>
|
||||||
|
<Input
|
||||||
|
data-testid="variables-input"
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onChange={onInputChange}
|
||||||
|
onClick={onOpen}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
maxH="35vh"
|
||||||
|
overflowY="scroll"
|
||||||
|
spacing="0"
|
||||||
|
role="menu"
|
||||||
|
w="inherit"
|
||||||
|
shadow="lg"
|
||||||
|
>
|
||||||
|
{(inputValue?.length ?? 0) > 0 &&
|
||||||
|
!isDefined(variables.find((v) => v.name === inputValue)) && (
|
||||||
|
<Button
|
||||||
|
role="menuitem"
|
||||||
|
minH="40px"
|
||||||
|
onClick={handleCreateNewVariableClick}
|
||||||
|
fontSize="16px"
|
||||||
|
fontWeight="normal"
|
||||||
|
rounded="none"
|
||||||
|
colorScheme="gray"
|
||||||
|
variant="ghost"
|
||||||
|
justifyContent="flex-start"
|
||||||
|
leftIcon={<PlusIcon />}
|
||||||
|
>
|
||||||
|
Create "{inputValue}"
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{filteredItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
{filteredItems.map((item, idx) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
role="menuitem"
|
||||||
|
minH="40px"
|
||||||
|
key={idx}
|
||||||
|
onClick={handleVariableNameClick(item)}
|
||||||
|
fontSize="16px"
|
||||||
|
fontWeight="normal"
|
||||||
|
rounded="none"
|
||||||
|
colorScheme="gray"
|
||||||
|
variant="ghost"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
<IconButton
|
||||||
|
icon={<TrashIcon />}
|
||||||
|
aria-label="Remove variable"
|
||||||
|
size="xs"
|
||||||
|
onClick={handleDeleteVariableClick(item)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { Text, Flex } from '@chakra-ui/react'
|
import { Text, Flex } from '@chakra-ui/react'
|
||||||
import { SearchableDropdown } from './SearchableDropdown'
|
import { SearchableDropdown } from '../../../shared/SearchableDropdown'
|
||||||
|
|
||||||
type FontSelectorProps = {
|
type FontSelectorProps = {
|
||||||
activeFont?: string
|
activeFont?: string
|
||||||
|
@ -4,6 +4,8 @@ import {
|
|||||||
ChoiceItem,
|
ChoiceItem,
|
||||||
InputStep,
|
InputStep,
|
||||||
InputStepType,
|
InputStepType,
|
||||||
|
LogicStepType,
|
||||||
|
LogicStep,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
@ -14,8 +16,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
|
|
||||||
export type DraggableStep = BubbleStep | InputStep
|
export type DraggableStep = BubbleStep | InputStep | LogicStep
|
||||||
export type DraggableStepType = BubbleStepType | InputStepType
|
export type DraggableStepType = BubbleStepType | InputStepType | LogicStepType
|
||||||
|
|
||||||
const dndContext = createContext<{
|
const dndContext = createContext<{
|
||||||
draggedStepType?: DraggableStepType
|
draggedStepType?: DraggableStepType
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { useToast } from '@chakra-ui/react'
|
import { useToast } from '@chakra-ui/react'
|
||||||
import { deepEqual } from 'fast-equals'
|
|
||||||
import { PublicTypebot, Settings, Theme, Typebot } from 'models'
|
import { PublicTypebot, Settings, Theme, Typebot } from 'models'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import {
|
import {
|
||||||
@ -28,6 +27,7 @@ import { BlocksActions, blocksActions } from './actions/blocks'
|
|||||||
import { useImmer, Updater } from 'use-immer'
|
import { useImmer, Updater } from 'use-immer'
|
||||||
import { stepsAction, StepsActions } from './actions/steps'
|
import { stepsAction, StepsActions } from './actions/steps'
|
||||||
import { choiceItemsAction, ChoiceItemsActions } from './actions/choiceItems'
|
import { choiceItemsAction, ChoiceItemsActions } from './actions/choiceItems'
|
||||||
|
import { variablesAction, VariablesActions } from './actions/variables'
|
||||||
|
|
||||||
type UpdateTypebotPayload = Partial<{
|
type UpdateTypebotPayload = Partial<{
|
||||||
theme: Theme
|
theme: Theme
|
||||||
@ -48,7 +48,8 @@ const typebotContext = createContext<
|
|||||||
publishTypebot: () => void
|
publishTypebot: () => void
|
||||||
} & BlocksActions &
|
} & BlocksActions &
|
||||||
StepsActions &
|
StepsActions &
|
||||||
ChoiceItemsActions
|
ChoiceItemsActions &
|
||||||
|
VariablesActions
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
>({})
|
>({})
|
||||||
@ -86,9 +87,10 @@ export const TypebotContext = ({
|
|||||||
() =>
|
() =>
|
||||||
isDefined(typebot) &&
|
isDefined(typebot) &&
|
||||||
isDefined(localTypebot) &&
|
isDefined(localTypebot) &&
|
||||||
!deepEqual(localTypebot, typebot),
|
!checkIfTypebotsAreEqual(localTypebot, typebot),
|
||||||
[typebot, localTypebot]
|
[typebot, localTypebot]
|
||||||
)
|
)
|
||||||
|
|
||||||
const isPublished = useMemo(
|
const isPublished = useMemo(
|
||||||
() =>
|
() =>
|
||||||
isDefined(typebot) &&
|
isDefined(typebot) &&
|
||||||
@ -205,6 +207,7 @@ export const TypebotContext = ({
|
|||||||
...blocksActions(setLocalTypebot as Updater<Typebot>),
|
...blocksActions(setLocalTypebot as Updater<Typebot>),
|
||||||
...stepsAction(setLocalTypebot as Updater<Typebot>),
|
...stepsAction(setLocalTypebot as Updater<Typebot>),
|
||||||
...choiceItemsAction(setLocalTypebot as Updater<Typebot>),
|
...choiceItemsAction(setLocalTypebot as Updater<Typebot>),
|
||||||
|
...variablesAction(setLocalTypebot as Updater<Typebot>),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,13 +1,22 @@
|
|||||||
import { Coordinates } from 'contexts/GraphContext'
|
import { Coordinates } from 'contexts/GraphContext'
|
||||||
import { WritableDraft } from 'immer/dist/internal'
|
import { WritableDraft } from 'immer/dist/internal'
|
||||||
import { Block, BubbleStepType, InputStepType, Step, Typebot } from 'models'
|
import {
|
||||||
|
Block,
|
||||||
|
BubbleStepType,
|
||||||
|
InputStepType,
|
||||||
|
LogicStepType,
|
||||||
|
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: (
|
createBlock: (
|
||||||
props: Coordinates & { step: BubbleStepType | InputStepType | Step }
|
props: Coordinates & {
|
||||||
|
step: BubbleStepType | InputStepType | LogicStepType | Step
|
||||||
|
}
|
||||||
) => void
|
) => 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
|
||||||
@ -18,7 +27,9 @@ export const blocksActions = (setTypebot: Updater<Typebot>): BlocksActions => ({
|
|||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
step,
|
step,
|
||||||
}: Coordinates & { step: BubbleStepType | InputStepType | Step }) => {
|
}: Coordinates & {
|
||||||
|
step: BubbleStepType | InputStepType | LogicStepType | Step
|
||||||
|
}) => {
|
||||||
setTypebot((typebot) => {
|
setTypebot((typebot) => {
|
||||||
const newBlock = parseNewBlock({
|
const newBlock = parseNewBlock({
|
||||||
totalBlocks: typebot.blocks.allIds.length,
|
totalBlocks: typebot.blocks.allIds.length,
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
InputStepType,
|
InputStepType,
|
||||||
Step,
|
Step,
|
||||||
Typebot,
|
Typebot,
|
||||||
|
LogicStepType,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import { parseNewStep } from 'services/typebots'
|
import { parseNewStep } from 'services/typebots'
|
||||||
import { Updater } from 'use-immer'
|
import { Updater } from 'use-immer'
|
||||||
@ -15,7 +16,7 @@ import { isChoiceInput } from 'utils'
|
|||||||
export type StepsActions = {
|
export type StepsActions = {
|
||||||
createStep: (
|
createStep: (
|
||||||
blockId: string,
|
blockId: string,
|
||||||
step: BubbleStepType | InputStepType | Step,
|
step: BubbleStepType | InputStepType | LogicStepType | Step,
|
||||||
index?: number
|
index?: number
|
||||||
) => void
|
) => void
|
||||||
updateStep: (
|
updateStep: (
|
||||||
@ -29,7 +30,7 @@ export type StepsActions = {
|
|||||||
export const stepsAction = (setTypebot: Updater<Typebot>): StepsActions => ({
|
export const stepsAction = (setTypebot: Updater<Typebot>): StepsActions => ({
|
||||||
createStep: (
|
createStep: (
|
||||||
blockId: string,
|
blockId: string,
|
||||||
step: BubbleStepType | InputStepType | Step,
|
step: BubbleStepType | InputStepType | LogicStepType | Step,
|
||||||
index?: number
|
index?: number
|
||||||
) => {
|
) => {
|
||||||
setTypebot((typebot) => {
|
setTypebot((typebot) => {
|
||||||
@ -75,7 +76,7 @@ export const deleteStepDraft = (
|
|||||||
|
|
||||||
export const createStepDraft = (
|
export const createStepDraft = (
|
||||||
typebot: WritableDraft<Typebot>,
|
typebot: WritableDraft<Typebot>,
|
||||||
step: BubbleStepType | InputStepType | Step,
|
step: BubbleStepType | InputStepType | LogicStepType | Step,
|
||||||
blockId: string,
|
blockId: string,
|
||||||
index?: number
|
index?: number
|
||||||
) => {
|
) => {
|
||||||
|
60
apps/builder/contexts/TypebotContext/actions/variables.ts
Normal file
60
apps/builder/contexts/TypebotContext/actions/variables.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Typebot, Variable } from 'models'
|
||||||
|
import { Updater } from 'use-immer'
|
||||||
|
import { WritableDraft } from 'immer/dist/types/types-external'
|
||||||
|
import { generate } from 'short-uuid'
|
||||||
|
|
||||||
|
export type VariablesActions = {
|
||||||
|
createVariable: (variable: Omit<Variable, 'id'> | Variable) => void
|
||||||
|
updateVariable: (
|
||||||
|
variableId: string,
|
||||||
|
updates: Partial<Omit<Variable, 'id'>>
|
||||||
|
) => void
|
||||||
|
deleteVariable: (variableId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const variablesAction = (
|
||||||
|
setTypebot: Updater<Typebot>
|
||||||
|
): VariablesActions => ({
|
||||||
|
createVariable: (variable: Omit<Variable, 'id'> | Variable) => {
|
||||||
|
setTypebot((typebot) => {
|
||||||
|
const id = createVariableDraft(typebot, variable)
|
||||||
|
return id
|
||||||
|
})
|
||||||
|
},
|
||||||
|
updateVariable: (
|
||||||
|
variableId: string,
|
||||||
|
updates: Partial<Omit<Variable, 'id'>>
|
||||||
|
) =>
|
||||||
|
setTypebot((typebot) => {
|
||||||
|
typebot.variables.byId[variableId] = {
|
||||||
|
...typebot.variables.byId[variableId],
|
||||||
|
...updates,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
deleteVariable: (itemId: string) => {
|
||||||
|
setTypebot((typebot) => {
|
||||||
|
deleteVariableDraft(typebot, itemId)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteVariableDraft = (
|
||||||
|
typebot: WritableDraft<Typebot>,
|
||||||
|
variableId: string
|
||||||
|
) => {
|
||||||
|
delete typebot.variables.byId[variableId]
|
||||||
|
const index = typebot.variables.allIds.indexOf(variableId)
|
||||||
|
if (index !== -1) typebot.variables.allIds.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createVariableDraft = (
|
||||||
|
typebot: WritableDraft<Typebot>,
|
||||||
|
variable: Omit<Variable, 'id'> | Variable
|
||||||
|
) => {
|
||||||
|
const newVariable = {
|
||||||
|
...variable,
|
||||||
|
id: 'id' in variable ? variable.id : generate(),
|
||||||
|
}
|
||||||
|
typebot.variables.byId[newVariable.id] = newVariable
|
||||||
|
typebot.variables.allIds.push(newVariable.id)
|
||||||
|
}
|
129
apps/builder/cypress/fixtures/typebots/logic/setVariable.json
Normal file
129
apps/builder/cypress/fixtures/typebots/logic/setVariable.json
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
{
|
||||||
|
"id": "typebot4",
|
||||||
|
"createdAt": "2022-01-13T08:10:06.705Z",
|
||||||
|
"updatedAt": "2022-01-13T08:10:06.705Z",
|
||||||
|
"name": "My typebot",
|
||||||
|
"ownerId": "ckybcurfh1612li1a62gqojvj",
|
||||||
|
"publishedTypebotId": null,
|
||||||
|
"folderId": null,
|
||||||
|
"blocks": {
|
||||||
|
"byId": {
|
||||||
|
"eXpkU5dMsjRPeY3WverF45": {
|
||||||
|
"id": "eXpkU5dMsjRPeY3WverF45",
|
||||||
|
"title": "Start",
|
||||||
|
"stepIds": ["vWwzs6EmChn2PJcJQMr2gT"],
|
||||||
|
"graphCoordinates": { "x": 0, "y": 0 }
|
||||||
|
},
|
||||||
|
"b87iNgPqGvKrybzHcTD4Sze": {
|
||||||
|
"id": "b87iNgPqGvKrybzHcTD4Sze",
|
||||||
|
"title": "Block #4",
|
||||||
|
"stepIds": ["s8a2ASVaM3PoaD3y9amHrtT", "sc7FNu4BUCmmhmP14hk2Hij"],
|
||||||
|
"graphCoordinates": { "x": 584, "y": 389 }
|
||||||
|
},
|
||||||
|
"bg22czQixaBBY8M8LJ9wEm1": {
|
||||||
|
"id": "bg22czQixaBBY8M8LJ9wEm1",
|
||||||
|
"title": "Block #3",
|
||||||
|
"stepIds": ["set-var-1", "set-var-2"],
|
||||||
|
"graphCoordinates": { "x": 581, "y": 97 }
|
||||||
|
},
|
||||||
|
"bvGtcdRjZtbbaimKyUD3NAW": {
|
||||||
|
"id": "bvGtcdRjZtbbaimKyUD3NAW",
|
||||||
|
"title": "Block #2",
|
||||||
|
"stepIds": ["sgFoKA5Y4MXBkqFzCGCWGeg", "number-step"],
|
||||||
|
"graphCoordinates": { "x": 190, "y": 222 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allIds": [
|
||||||
|
"eXpkU5dMsjRPeY3WverF45",
|
||||||
|
"bvGtcdRjZtbbaimKyUD3NAW",
|
||||||
|
"bg22czQixaBBY8M8LJ9wEm1",
|
||||||
|
"b87iNgPqGvKrybzHcTD4Sze"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"steps": {
|
||||||
|
"byId": {
|
||||||
|
"vWwzs6EmChn2PJcJQMr2gT": {
|
||||||
|
"id": "vWwzs6EmChn2PJcJQMr2gT",
|
||||||
|
"type": "start",
|
||||||
|
"label": "Start",
|
||||||
|
"target": { "blockId": "bvGtcdRjZtbbaimKyUD3NAW" },
|
||||||
|
"blockId": "eXpkU5dMsjRPeY3WverF45"
|
||||||
|
},
|
||||||
|
"set-var-2": {
|
||||||
|
"id": "set-var-2",
|
||||||
|
"type": "Set variable",
|
||||||
|
"target": { "blockId": "b87iNgPqGvKrybzHcTD4Sze" },
|
||||||
|
"blockId": "bg22czQixaBBY8M8LJ9wEm1"
|
||||||
|
},
|
||||||
|
"s8a2ASVaM3PoaD3y9amHrtT": {
|
||||||
|
"id": "s8a2ASVaM3PoaD3y9amHrtT",
|
||||||
|
"type": "text",
|
||||||
|
"blockId": "b87iNgPqGvKrybzHcTD4Sze",
|
||||||
|
"content": {
|
||||||
|
"html": "<div>Total: {{Total}}</div>",
|
||||||
|
"richText": [
|
||||||
|
{ "type": "p", "children": [{ "text": "Total: {{Total}}" }] }
|
||||||
|
],
|
||||||
|
"plainText": "Total: {{Total}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sgFoKA5Y4MXBkqFzCGCWGeg": {
|
||||||
|
"id": "sgFoKA5Y4MXBkqFzCGCWGeg",
|
||||||
|
"type": "text",
|
||||||
|
"blockId": "bvGtcdRjZtbbaimKyUD3NAW",
|
||||||
|
"content": {
|
||||||
|
"html": "<div>1000 * ?</div>",
|
||||||
|
"richText": [{ "type": "p", "children": [{ "text": "1000 * ?" }] }],
|
||||||
|
"plainText": "1000 * ?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"number-step": {
|
||||||
|
"id": "number-step",
|
||||||
|
"type": "number input",
|
||||||
|
"target": { "blockId": "bg22czQixaBBY8M8LJ9wEm1" },
|
||||||
|
"blockId": "bvGtcdRjZtbbaimKyUD3NAW"
|
||||||
|
},
|
||||||
|
"set-var-1": {
|
||||||
|
"id": "set-var-1",
|
||||||
|
"blockId": "bg22czQixaBBY8M8LJ9wEm1",
|
||||||
|
"type": "Set variable"
|
||||||
|
},
|
||||||
|
"sc7FNu4BUCmmhmP14hk2Hij": {
|
||||||
|
"id": "sc7FNu4BUCmmhmP14hk2Hij",
|
||||||
|
"blockId": "b87iNgPqGvKrybzHcTD4Sze",
|
||||||
|
"type": "text",
|
||||||
|
"content": {
|
||||||
|
"html": "<div>Custom var: {{Custom var}}</div>",
|
||||||
|
"richText": [
|
||||||
|
{
|
||||||
|
"type": "p",
|
||||||
|
"children": [{ "text": "Custom var: {{Custom var}}" }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"plainText": "Custom var: {{Custom var}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"allIds": [
|
||||||
|
"vWwzs6EmChn2PJcJQMr2gT",
|
||||||
|
"s8a2ASVaM3PoaD3y9amHrtT",
|
||||||
|
"set-var-2",
|
||||||
|
"sgFoKA5Y4MXBkqFzCGCWGeg",
|
||||||
|
"number-step",
|
||||||
|
"set-var-1",
|
||||||
|
"sc7FNu4BUCmmhmP14hk2Hij"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"choiceItems": { "byId": {}, "allIds": [] },
|
||||||
|
"variables": { "byId": {}, "allIds": [] },
|
||||||
|
"theme": {
|
||||||
|
"general": {
|
||||||
|
"font": "Open Sans",
|
||||||
|
"background": { "type": "None", "content": "#ffffff" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
||||||
|
},
|
||||||
|
"publicId": null
|
||||||
|
}
|
@ -126,5 +126,6 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
|
||||||
},
|
},
|
||||||
|
"variables": { "byId": {}, "allIds": [] },
|
||||||
"publicId": null
|
"publicId": null
|
||||||
}
|
}
|
||||||
|
@ -129,6 +129,7 @@ const parseTypebotToPublicTypebot = (
|
|||||||
settings: typebot.settings,
|
settings: typebot.settings,
|
||||||
publicId: typebot.publicId,
|
publicId: typebot.publicId,
|
||||||
choiceItems: typebot.choiceItems,
|
choiceItems: typebot.choiceItems,
|
||||||
|
variables: typebot.variables,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadRawTypebotInDatabase = (typebot: Typebot) =>
|
export const loadRawTypebotInDatabase = (typebot: Typebot) =>
|
||||||
|
@ -74,6 +74,7 @@ export const parseTestTypebot = ({
|
|||||||
publicId: null,
|
publicId: null,
|
||||||
publishedTypebotId: null,
|
publishedTypebotId: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
|
variables: { byId: {}, allIds: [] },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +45,14 @@ Cypress.on('uncaught:exception', (err) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const getIframeBody = () => {
|
||||||
|
return cy
|
||||||
|
.get('#typebot-iframe')
|
||||||
|
.its('0.contentDocument.body')
|
||||||
|
.should('not.be.empty')
|
||||||
|
.then(cy.wrap)
|
||||||
|
}
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
// Import commands.js using ES2015 syntax:
|
||||||
import '@testing-library/cypress/add-commands'
|
import '@testing-library/cypress/add-commands'
|
||||||
import 'cypress-file-upload'
|
import 'cypress-file-upload'
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
parseTestTypebot,
|
parseTestTypebot,
|
||||||
preventUserFromRefreshing,
|
preventUserFromRefreshing,
|
||||||
} from 'cypress/plugins/utils'
|
} from 'cypress/plugins/utils'
|
||||||
|
import { getIframeBody } from 'cypress/support'
|
||||||
import { InputStep, InputStepType } from 'models'
|
import { InputStep, InputStepType } from 'models'
|
||||||
|
|
||||||
describe('Text input', () => {
|
describe('Text input', () => {
|
||||||
@ -214,7 +215,7 @@ describe('Button input', () => {
|
|||||||
cy.signOut()
|
cy.signOut()
|
||||||
})
|
})
|
||||||
|
|
||||||
it.only('Can edit choice items', () => {
|
it('Can edit choice items', () => {
|
||||||
cy.signIn('test2@gmail.com')
|
cy.signIn('test2@gmail.com')
|
||||||
cy.visit('/typebots/typebot3/edit')
|
cy.visit('/typebots/typebot3/edit')
|
||||||
cy.findByDisplayValue('Click to edit').type('Item 1{enter}')
|
cy.findByDisplayValue('Click to edit').type('Item 1{enter}')
|
||||||
@ -275,11 +276,11 @@ const createTypebotWithStep = (step: Omit<InputStep, 'id' | 'blockId'>) => {
|
|||||||
...step,
|
...step,
|
||||||
id: 'step1',
|
id: 'step1',
|
||||||
blockId: 'block1',
|
blockId: 'block1',
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
//@ts-ignore
|
|
||||||
options:
|
options:
|
||||||
step.type === InputStepType.CHOICE
|
step.type === InputStepType.CHOICE
|
||||||
? { itemIds: ['item1'] }
|
? // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
//@ts-ignore
|
||||||
|
{ itemIds: ['item1'] }
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -306,11 +307,3 @@ const createTypebotWithStep = (step: Omit<InputStep, 'id' | 'blockId'>) => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getIframeBody = () => {
|
|
||||||
return cy
|
|
||||||
.get('#typebot-iframe')
|
|
||||||
.its('0.contentDocument.body')
|
|
||||||
.should('not.be.empty')
|
|
||||||
.then(cy.wrap)
|
|
||||||
}
|
|
||||||
|
45
apps/builder/cypress/tests/logic.ts
Normal file
45
apps/builder/cypress/tests/logic.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { preventUserFromRefreshing } from 'cypress/plugins/utils'
|
||||||
|
import { getIframeBody } from 'cypress/support'
|
||||||
|
|
||||||
|
describe('Set variables', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.task('seed')
|
||||||
|
cy.signOut()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cy.window().then((win) => {
|
||||||
|
win.removeEventListener('beforeunload', preventUserFromRefreshing)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('options should work', () => {
|
||||||
|
cy.loadTypebotFixtureInDatabase('typebots/logic/setVariable.json')
|
||||||
|
cy.signIn('test2@gmail.com')
|
||||||
|
cy.visit('/typebots/typebot4/edit')
|
||||||
|
cy.findByTestId('step-number-step').click()
|
||||||
|
createNewVar('Num')
|
||||||
|
cy.findByTestId('step-set-var-1').click()
|
||||||
|
createNewVar('Total')
|
||||||
|
cy.findByRole('textbox', { name: 'Value / Expression:' }).type(
|
||||||
|
'1000 * {{Num}}',
|
||||||
|
{ parseSpecialCharSequences: false }
|
||||||
|
)
|
||||||
|
cy.findByTestId('step-set-var-2').click()
|
||||||
|
createNewVar('Custom var')
|
||||||
|
cy.findByRole('textbox', { name: 'Value / Expression:' }).type(
|
||||||
|
'Custom value'
|
||||||
|
)
|
||||||
|
cy.findByRole('button', { name: 'Preview' }).click()
|
||||||
|
getIframeBody()
|
||||||
|
.findByPlaceholderText('Type your answer...')
|
||||||
|
.type('365{enter}')
|
||||||
|
getIframeBody().findByText('Total: 365000').should('exist')
|
||||||
|
getIframeBody().findByText('Custom var: Custom value')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const createNewVar = (name: string) => {
|
||||||
|
cy.findByTestId('variables-input').type(name)
|
||||||
|
cy.findByRole('menuitem', { name: `Create "${name}"` }).click()
|
||||||
|
}
|
@ -60,6 +60,11 @@ const components = {
|
|||||||
focusBorderColor: 'blue.200',
|
focusBorderColor: 'blue.200',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Textarea: {
|
||||||
|
defaultProps: {
|
||||||
|
focusBorderColor: 'blue.200',
|
||||||
|
},
|
||||||
|
},
|
||||||
Popover: {
|
Popover: {
|
||||||
baseStyle: {
|
baseStyle: {
|
||||||
popper: {
|
popper: {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Link } from '@chakra-ui/react'
|
||||||
import {
|
import {
|
||||||
AutoformatRule,
|
AutoformatRule,
|
||||||
createAutoformatPlugin,
|
createAutoformatPlugin,
|
||||||
@ -11,7 +12,7 @@ import {
|
|||||||
createUnderlinePlugin,
|
createUnderlinePlugin,
|
||||||
} from '@udecode/plate-basic-marks'
|
} from '@udecode/plate-basic-marks'
|
||||||
import { createPlugins } from '@udecode/plate-core'
|
import { createPlugins } from '@udecode/plate-core'
|
||||||
import { createLinkPlugin } from '@udecode/plate-link'
|
import { createLinkPlugin, ELEMENT_LINK } from '@udecode/plate-link'
|
||||||
|
|
||||||
export const editorStyle: React.CSSProperties = {
|
export const editorStyle: React.CSSProperties = {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@ -43,14 +44,17 @@ export const autoFormatRules: AutoformatRule[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const platePlugins = createPlugins([
|
export const platePlugins = createPlugins(
|
||||||
createBoldPlugin(),
|
[
|
||||||
createItalicPlugin(),
|
createBoldPlugin(),
|
||||||
createUnderlinePlugin(),
|
createItalicPlugin(),
|
||||||
createLinkPlugin(),
|
createUnderlinePlugin(),
|
||||||
createAutoformatPlugin({
|
createLinkPlugin(),
|
||||||
options: {
|
createAutoformatPlugin({
|
||||||
rules: autoFormatRules,
|
options: {
|
||||||
},
|
rules: autoFormatRules,
|
||||||
}),
|
},
|
||||||
])
|
}),
|
||||||
|
],
|
||||||
|
{ components: { [ELEMENT_LINK]: Link } }
|
||||||
|
)
|
||||||
|
@ -18,6 +18,7 @@ export const parseTypebotToPublicTypebot = (
|
|||||||
settings: typebot.settings,
|
settings: typebot.settings,
|
||||||
publicId: typebot.publicId,
|
publicId: typebot.publicId,
|
||||||
choiceItems: typebot.choiceItems,
|
choiceItems: typebot.choiceItems,
|
||||||
|
variables: typebot.variables,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createPublishedTypebot = async (
|
export const createPublishedTypebot = async (
|
||||||
|
@ -11,6 +11,8 @@ import {
|
|||||||
BubbleStepType,
|
BubbleStepType,
|
||||||
InputStepType,
|
InputStepType,
|
||||||
ChoiceInputStep,
|
ChoiceInputStep,
|
||||||
|
LogicStepType,
|
||||||
|
LogicStep,
|
||||||
} from 'models'
|
} from 'models'
|
||||||
import shortId from 'short-uuid'
|
import shortId from 'short-uuid'
|
||||||
import { Typebot } from 'models'
|
import { Typebot } from 'models'
|
||||||
@ -107,9 +109,9 @@ export const parseNewBlock = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const parseNewStep = (
|
export const parseNewStep = (
|
||||||
type: BubbleStepType | InputStepType,
|
type: BubbleStepType | InputStepType | LogicStepType,
|
||||||
blockId: string
|
blockId: string
|
||||||
): BubbleStep | InputStep => {
|
): BubbleStep | InputStep | LogicStep => {
|
||||||
const id = `s${shortId.generate()}`
|
const id = `s${shortId.generate()}`
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case BubbleStepType.TEXT: {
|
case BubbleStepType.TEXT: {
|
||||||
@ -144,17 +146,10 @@ export const parseNewStep = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const checkIfTypebotsAreEqual = (
|
export const checkIfTypebotsAreEqual = (typebotA: Typebot, typebotB: Typebot) =>
|
||||||
firstChatbot: Typebot,
|
|
||||||
secondChatbot: Typebot
|
|
||||||
) =>
|
|
||||||
deepEqual(
|
deepEqual(
|
||||||
{
|
JSON.parse(JSON.stringify(typebotA)),
|
||||||
...firstChatbot,
|
JSON.parse(JSON.stringify(typebotB))
|
||||||
},
|
|
||||||
{
|
|
||||||
...secondChatbot,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
export const checkIfPublished = (
|
export const checkIfPublished = (
|
||||||
@ -214,6 +209,7 @@ export const parseNewTypebot = ({
|
|||||||
blocks: { byId: { [startBlockId]: startBlock }, allIds: [startBlockId] },
|
blocks: { byId: { [startBlockId]: startBlock }, allIds: [startBlockId] },
|
||||||
steps: { byId: { [startStepId]: startStep }, allIds: [startStepId] },
|
steps: { byId: { [startStepId]: startStep }, allIds: [startStepId] },
|
||||||
choiceItems: { byId: {}, allIds: [] },
|
choiceItems: { byId: {}, allIds: [] },
|
||||||
|
variables: { byId: {}, allIds: [] },
|
||||||
theme,
|
theme,
|
||||||
settings,
|
settings,
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Parser } from 'htmlparser2'
|
import { Parser } from 'htmlparser2'
|
||||||
|
import { Step } from 'models'
|
||||||
|
|
||||||
export const fetcher = async (input: RequestInfo, init?: RequestInit) => {
|
export const fetcher = async (input: RequestInfo, init?: RequestInit) => {
|
||||||
const res = await fetch(input, init)
|
const res = await fetch(input, init)
|
||||||
@ -115,3 +116,5 @@ export const removeUndefinedFields = <T>(obj: T): T =>
|
|||||||
: { ...acc, [key]: obj[key as keyof T] },
|
: { ...acc, [key]: obj[key as keyof T] },
|
||||||
{} as T
|
{} as T
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const stepHasOptions = (step: Step) => 'options' in step
|
||||||
|
@ -4,9 +4,19 @@ import { TransitionGroup, CSSTransition } from 'react-transition-group'
|
|||||||
import { ChatStep } from './ChatStep'
|
import { ChatStep } from './ChatStep'
|
||||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||||
import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
|
import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
|
||||||
import { ChoiceInputStep, Step } from 'models'
|
import { ChoiceInputStep, LogicStep, Step } from 'models'
|
||||||
import { useTypebot } from '../../contexts/TypebotContext'
|
import { useTypebot } from '../../contexts/TypebotContext'
|
||||||
import { isChoiceInput } from 'utils'
|
import {
|
||||||
|
isChoiceInput,
|
||||||
|
isInputStep,
|
||||||
|
isLogicStep,
|
||||||
|
isTextBubbleStep,
|
||||||
|
} from 'utils'
|
||||||
|
import {
|
||||||
|
evaluateExpression,
|
||||||
|
isMathFormula,
|
||||||
|
parseVariables,
|
||||||
|
} from 'services/variable'
|
||||||
|
|
||||||
type ChatBlockProps = {
|
type ChatBlockProps = {
|
||||||
stepIds: string[]
|
stepIds: string[]
|
||||||
@ -14,15 +24,20 @@ type ChatBlockProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ChatBlock = ({ stepIds, onBlockEnd }: ChatBlockProps) => {
|
export const ChatBlock = ({ stepIds, onBlockEnd }: ChatBlockProps) => {
|
||||||
const { typebot } = useTypebot()
|
const { typebot, updateVariableValue } = useTypebot()
|
||||||
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([])
|
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayedSteps([typebot.steps.byId[stepIds[0]]])
|
displayNextStep()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
autoScrollToBottom()
|
autoScrollToBottom()
|
||||||
|
const currentStep = [...displayedSteps].pop()
|
||||||
|
if (currentStep && isLogicStep(currentStep)) {
|
||||||
|
executeLogic(currentStep)
|
||||||
|
displayNextStep()
|
||||||
|
}
|
||||||
}, [displayedSteps])
|
}, [displayedSteps])
|
||||||
|
|
||||||
const autoScrollToBottom = () => {
|
const autoScrollToBottom = () => {
|
||||||
@ -34,20 +49,37 @@ export const ChatBlock = ({ stepIds, onBlockEnd }: ChatBlockProps) => {
|
|||||||
|
|
||||||
const displayNextStep = (answerContent?: string) => {
|
const displayNextStep = (answerContent?: string) => {
|
||||||
const currentStep = [...displayedSteps].pop()
|
const currentStep = [...displayedSteps].pop()
|
||||||
if (!currentStep) throw new Error('currentStep should exist')
|
if (currentStep) {
|
||||||
const isSingleChoiceStep =
|
if (
|
||||||
isChoiceInput(currentStep) && !currentStep.options.isMultipleChoice
|
isInputStep(currentStep) &&
|
||||||
if (isSingleChoiceStep)
|
currentStep.options?.variableId &&
|
||||||
return onBlockEnd(getSingleChoiceTargetId(currentStep, answerContent))
|
answerContent
|
||||||
if (
|
) {
|
||||||
currentStep?.target?.blockId ||
|
updateVariableValue(currentStep.options.variableId, answerContent)
|
||||||
displayedSteps.length === stepIds.length
|
}
|
||||||
)
|
const isSingleChoiceStep =
|
||||||
return onBlockEnd(currentStep?.target?.blockId)
|
isChoiceInput(currentStep) && !currentStep.options.isMultipleChoice
|
||||||
|
if (isSingleChoiceStep)
|
||||||
|
return onBlockEnd(getSingleChoiceTargetId(currentStep, answerContent))
|
||||||
|
if (
|
||||||
|
currentStep?.target?.blockId ||
|
||||||
|
displayedSteps.length === stepIds.length
|
||||||
|
)
|
||||||
|
return onBlockEnd(currentStep?.target?.blockId)
|
||||||
|
}
|
||||||
const nextStep = typebot.steps.byId[stepIds[displayedSteps.length]]
|
const nextStep = typebot.steps.byId[stepIds[displayedSteps.length]]
|
||||||
if (nextStep) setDisplayedSteps([...displayedSteps, nextStep])
|
if (nextStep) setDisplayedSteps([...displayedSteps, nextStep])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const executeLogic = (step: LogicStep) => {
|
||||||
|
if (!step.options?.variableId || !step.options.expressionToEvaluate) return
|
||||||
|
const expression = step.options.expressionToEvaluate
|
||||||
|
const evaluatedExpression = isMathFormula(expression)
|
||||||
|
? evaluateExpression(parseVariables(expression, typebot.variables))
|
||||||
|
: expression
|
||||||
|
updateVariableValue(step.options.variableId, evaluatedExpression)
|
||||||
|
}
|
||||||
|
|
||||||
const getSingleChoiceTargetId = (
|
const getSingleChoiceTargetId = (
|
||||||
currentStep: ChoiceInputStep,
|
currentStep: ChoiceInputStep,
|
||||||
answerContent?: string
|
answerContent?: string
|
||||||
@ -68,16 +100,18 @@ export const ChatBlock = ({ stepIds, onBlockEnd }: ChatBlockProps) => {
|
|||||||
<AvatarSideContainer />
|
<AvatarSideContainer />
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
<TransitionGroup>
|
<TransitionGroup>
|
||||||
{displayedSteps.map((step) => (
|
{displayedSteps
|
||||||
<CSSTransition
|
.filter((step) => isInputStep(step) || isTextBubbleStep(step))
|
||||||
key={step.id}
|
.map((step) => (
|
||||||
classNames="bubble"
|
<CSSTransition
|
||||||
timeout={500}
|
key={step.id}
|
||||||
unmountOnExit
|
classNames="bubble"
|
||||||
>
|
timeout={500}
|
||||||
<ChatStep step={step} onTransitionEnd={displayNextStep} />
|
unmountOnExit
|
||||||
</CSSTransition>
|
>
|
||||||
))}
|
<ChatStep step={step} onTransitionEnd={displayNextStep} />
|
||||||
|
</CSSTransition>
|
||||||
|
))}
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</div>
|
</div>
|
||||||
</HostAvatarsContext>
|
</HostAvatarsContext>
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useMemo, 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 { BubbleStepType, StepType, TextStep } from 'models'
|
import { BubbleStepType, TextStep } from 'models'
|
||||||
import { computeTypingTimeout } from '../../../../services/chat'
|
import { computeTypingTimeout } from 'services/chat'
|
||||||
import { TypingContent } from './TypingContent'
|
import { TypingContent } from './TypingContent'
|
||||||
|
import { parseVariables } from 'services/variable'
|
||||||
|
|
||||||
type HostMessageBubbleProps = {
|
type HostMessageBubbleProps = {
|
||||||
step: TextStep
|
step: TextStep
|
||||||
@ -24,6 +25,11 @@ export const HostMessageBubble = ({
|
|||||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||||
const [isTyping, setIsTyping] = useState(true)
|
const [isTyping, setIsTyping] = useState(true)
|
||||||
|
|
||||||
|
const content = useMemo(
|
||||||
|
() => parseVariables(step.content.html, typebot.variables),
|
||||||
|
[typebot.variables]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sendAvatarOffset()
|
sendAvatarOffset()
|
||||||
const typingTimeout = computeTypingTimeout(
|
const typingTimeout = computeTypingTimeout(
|
||||||
@ -72,7 +78,7 @@ export const HostMessageBubble = ({
|
|||||||
(isTyping ? 'opacity-0 h-6' : 'opacity-100 h-full')
|
(isTyping ? 'opacity-0 h-6' : 'opacity-100 h-full')
|
||||||
}
|
}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: step.content.html,
|
__html: content,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { PublicTypebot } from 'models'
|
import { PublicTypebot } from 'models'
|
||||||
import React, { createContext, ReactNode, useContext } from 'react'
|
import React, { createContext, ReactNode, useContext, useState } from 'react'
|
||||||
|
|
||||||
const typebotContext = createContext<{
|
const typebotContext = createContext<{
|
||||||
typebot: PublicTypebot
|
typebot: PublicTypebot
|
||||||
|
updateVariableValue: (variableId: string, value: string) => void
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
}>({})
|
}>({})
|
||||||
|
|
||||||
@ -13,10 +14,25 @@ export const TypebotContext = ({
|
|||||||
children: ReactNode
|
children: ReactNode
|
||||||
typebot: PublicTypebot
|
typebot: PublicTypebot
|
||||||
}) => {
|
}) => {
|
||||||
|
const [localTypebot, setLocalTypebot] = useState<PublicTypebot>(typebot)
|
||||||
|
|
||||||
|
const updateVariableValue = (variableId: string, value: string) => {
|
||||||
|
setLocalTypebot((typebot) => ({
|
||||||
|
...typebot,
|
||||||
|
variables: {
|
||||||
|
...typebot.variables,
|
||||||
|
byId: {
|
||||||
|
...typebot.variables.byId,
|
||||||
|
[variableId]: { ...typebot.variables.byId[variableId], value },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<typebotContext.Provider
|
<typebotContext.Provider
|
||||||
value={{
|
value={{
|
||||||
typebot,
|
typebot: localTypebot,
|
||||||
|
updateVariableValue,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
46
packages/bot-engine/src/services/variable.ts
Normal file
46
packages/bot-engine/src/services/variable.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Table, Variable } from 'models'
|
||||||
|
import { isDefined } from 'utils'
|
||||||
|
|
||||||
|
const safeEval = eval
|
||||||
|
|
||||||
|
export const stringContainsVariable = (str: string): boolean =>
|
||||||
|
/\{\{(.*?)\}\}/g.test(str)
|
||||||
|
|
||||||
|
export const parseVariables = (
|
||||||
|
text: string,
|
||||||
|
variables: Table<Variable>
|
||||||
|
): string => {
|
||||||
|
if (text === '') return text
|
||||||
|
return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => {
|
||||||
|
const matchedVarName = fullVariableString.replace(/{{|}}/g, '')
|
||||||
|
const matchedVariableId = variables.allIds.find((variableId) => {
|
||||||
|
const variable = variables.byId[variableId]
|
||||||
|
return matchedVarName === variable.name && isDefined(variable.value)
|
||||||
|
})
|
||||||
|
return variables.byId[matchedVariableId ?? '']?.value ?? ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isMathFormula = (str?: string) =>
|
||||||
|
['*', '/', '+', '-'].some((val) => str && str.includes(val))
|
||||||
|
|
||||||
|
export const evaluateExpression = (str: string) => {
|
||||||
|
let result = replaceCommasWithDots(str)
|
||||||
|
try {
|
||||||
|
const evaluatedNumber = safeEval(result) as number
|
||||||
|
if (countDecimals(evaluatedNumber) > 2) {
|
||||||
|
return evaluatedNumber.toFixed(2)
|
||||||
|
}
|
||||||
|
return evaluatedNumber.toString()
|
||||||
|
} catch (err) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const replaceCommasWithDots = (str: string) =>
|
||||||
|
str.replace(new RegExp(/(\d+)(,)(\d+)/, 'g'), '$1.$3')
|
||||||
|
|
||||||
|
const countDecimals = (value: number) => {
|
||||||
|
if (value % 1 != 0) return value.toString().split('.')[1].length
|
||||||
|
return 0
|
||||||
|
}
|
@ -13,6 +13,7 @@
|
|||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"emitDeclarationOnly": true
|
"emitDeclarationOnly": true,
|
||||||
|
"baseUrl": "./src"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,87 +0,0 @@
|
|||||||
datasource db {
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
provider = "postgresql"
|
|
||||||
}
|
|
||||||
|
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
email String @unique
|
|
||||||
name String?
|
|
||||||
avatarUrl String?
|
|
||||||
redeemedCoupon Boolean?
|
|
||||||
oAuthCredentials Json?
|
|
||||||
referralId String?
|
|
||||||
domains String[]
|
|
||||||
onboarding_data Json?
|
|
||||||
settings Json
|
|
||||||
typebots Typebot[] @relation("Owner")
|
|
||||||
sharedTypebots Typebot[] @relation("Collaborators")
|
|
||||||
dashboardFolders DashboardFolder[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model DashboardFolder {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
name String
|
|
||||||
owner User @relation(fields: [ownerId], references: [id])
|
|
||||||
ownerId String
|
|
||||||
parentFolderId BigInt
|
|
||||||
parentFolder DashboardFolder @relation("ParentChild", fields: [parentFolderId], references: [id])
|
|
||||||
childrenFolder DashboardFolder[] @relation("ParentChild")
|
|
||||||
}
|
|
||||||
|
|
||||||
model Typebot {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @default(now())
|
|
||||||
steps Json[]
|
|
||||||
publishedTypebotId BigInt @unique
|
|
||||||
publishedTypebot PublicTypebot @relation(fields: [publishedTypebotId], references: [id])
|
|
||||||
connectors Json[]
|
|
||||||
name String
|
|
||||||
ownerId String
|
|
||||||
owner User @relation("Owner", fields: [ownerId], references: [id])
|
|
||||||
conditions Json
|
|
||||||
startConditions Json
|
|
||||||
theme Json
|
|
||||||
settings Json
|
|
||||||
collaborators User[] @relation("Collaborators")
|
|
||||||
customDomains String[]
|
|
||||||
shareSettings Json
|
|
||||||
variables Json
|
|
||||||
checkedConversionRules String[]
|
|
||||||
results Result[]
|
|
||||||
httpRequests Json[]
|
|
||||||
credentials Json[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model PublicTypebot {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
typebot Typebot?
|
|
||||||
steps Json[]
|
|
||||||
name String
|
|
||||||
conditions Json
|
|
||||||
startConditions Json
|
|
||||||
theme Json
|
|
||||||
settings Json
|
|
||||||
connectors Json
|
|
||||||
customDomains String[]
|
|
||||||
shareSettings Json
|
|
||||||
variables Json
|
|
||||||
}
|
|
||||||
|
|
||||||
model Result {
|
|
||||||
id BigInt @id @default(autoincrement())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @default(now())
|
|
||||||
typebotId BigInt
|
|
||||||
typebot Typebot @relation(fields: [typebotId], references: [id])
|
|
||||||
variables Json[]
|
|
||||||
isCompleted Boolean
|
|
||||||
answers Json[]
|
|
||||||
}
|
|
@ -93,6 +93,7 @@ model Typebot {
|
|||||||
blocks Json
|
blocks Json
|
||||||
steps Json
|
steps Json
|
||||||
choiceItems Json
|
choiceItems Json
|
||||||
|
variables Json
|
||||||
theme Json
|
theme Json
|
||||||
settings Json
|
settings Json
|
||||||
publicId String? @unique
|
publicId String? @unique
|
||||||
@ -106,6 +107,7 @@ model PublicTypebot {
|
|||||||
blocks Json
|
blocks Json
|
||||||
steps Json
|
steps Json
|
||||||
choiceItems Json
|
choiceItems Json
|
||||||
|
variables Json
|
||||||
theme Json
|
theme Json
|
||||||
settings Json
|
settings Json
|
||||||
publicId String? @unique
|
publicId String? @unique
|
||||||
|
@ -1,14 +1,22 @@
|
|||||||
import { PublicTypebot as PublicTypebotFromPrisma } from 'db'
|
import { PublicTypebot as PublicTypebotFromPrisma } from 'db'
|
||||||
import { Block, ChoiceItem, Settings, Step, Theme } from './typebot'
|
import { Block, ChoiceItem, Settings, Step, Theme } from './typebot'
|
||||||
|
import { Variable } from './typebot/variable'
|
||||||
import { Table } from './utils'
|
import { Table } from './utils'
|
||||||
|
|
||||||
export type PublicTypebot = Omit<
|
export type PublicTypebot = Omit<
|
||||||
PublicTypebotFromPrisma,
|
PublicTypebotFromPrisma,
|
||||||
'blocks' | 'startBlock' | 'theme' | 'settings' | 'steps'
|
| 'blocks'
|
||||||
|
| 'startBlock'
|
||||||
|
| 'theme'
|
||||||
|
| 'settings'
|
||||||
|
| 'steps'
|
||||||
|
| 'choiceItems'
|
||||||
|
| 'variables'
|
||||||
> & {
|
> & {
|
||||||
blocks: Table<Block>
|
blocks: Table<Block>
|
||||||
steps: Table<Step>
|
steps: Table<Step>
|
||||||
choiceItems: Table<ChoiceItem>
|
choiceItems: Table<ChoiceItem>
|
||||||
|
variables: Table<Variable>
|
||||||
theme: Theme
|
theme: Theme
|
||||||
settings: Settings
|
settings: Settings
|
||||||
}
|
}
|
||||||
|
@ -2,3 +2,4 @@ export * from './typebot'
|
|||||||
export * from './steps'
|
export * from './steps'
|
||||||
export * from './theme'
|
export * from './theme'
|
||||||
export * from './settings'
|
export * from './settings'
|
||||||
|
export * from './variable'
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from './steps'
|
export * from './steps'
|
||||||
export * from './inputs'
|
export * from './inputs'
|
||||||
|
export * from './logic'
|
||||||
|
@ -47,7 +47,7 @@ export type DateInputStep = StepBase & {
|
|||||||
|
|
||||||
export type PhoneNumberInputStep = StepBase & {
|
export type PhoneNumberInputStep = StepBase & {
|
||||||
type: InputStepType.PHONE
|
type: InputStepType.PHONE
|
||||||
options?: InputTextOptionsBase
|
options?: OptionBase & InputTextOptionsBase
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChoiceInputStep = StepBase & {
|
export type ChoiceInputStep = StepBase & {
|
||||||
@ -55,12 +55,6 @@ export type ChoiceInputStep = StepBase & {
|
|||||||
options: ChoiceInputOptions
|
options: ChoiceInputOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChoiceInputOptions = {
|
|
||||||
itemIds: string[]
|
|
||||||
isMultipleChoice?: boolean
|
|
||||||
buttonLabel?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ChoiceItem = {
|
export type ChoiceItem = {
|
||||||
id: string
|
id: string
|
||||||
stepId: string
|
stepId: string
|
||||||
@ -68,26 +62,35 @@ export type ChoiceItem = {
|
|||||||
target?: Target
|
target?: Target
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DateInputOptions = {
|
type OptionBase = { variableId?: string }
|
||||||
|
type InputTextOptionsBase = {
|
||||||
|
labels?: { placeholder?: string; button?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChoiceInputOptions = OptionBase & {
|
||||||
|
itemIds: string[]
|
||||||
|
isMultipleChoice?: boolean
|
||||||
|
buttonLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateInputOptions = OptionBase & {
|
||||||
labels?: { button?: string; from?: string; to?: string }
|
labels?: { button?: string; from?: string; to?: string }
|
||||||
hasTime?: boolean
|
hasTime?: boolean
|
||||||
isRange?: boolean
|
isRange?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EmailInputOptions = InputTextOptionsBase
|
export type EmailInputOptions = OptionBase & InputTextOptionsBase
|
||||||
|
|
||||||
export type UrlInputOptions = InputTextOptionsBase
|
export type UrlInputOptions = OptionBase & InputTextOptionsBase
|
||||||
|
|
||||||
type InputTextOptionsBase = {
|
export type TextInputOptions = OptionBase &
|
||||||
labels?: { placeholder?: string; button?: string }
|
InputTextOptionsBase & {
|
||||||
}
|
isLong?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type TextInputOptions = InputTextOptionsBase & {
|
export type NumberInputOptions = OptionBase &
|
||||||
isLong?: boolean
|
InputTextOptionsBase & {
|
||||||
}
|
min?: number
|
||||||
|
max?: number
|
||||||
export type NumberInputOptions = InputTextOptionsBase & {
|
step?: number
|
||||||
min?: number
|
}
|
||||||
max?: number
|
|
||||||
step?: number
|
|
||||||
}
|
|
||||||
|
17
packages/models/src/typebot/steps/logic.ts
Normal file
17
packages/models/src/typebot/steps/logic.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { StepBase } from '.'
|
||||||
|
|
||||||
|
export type LogicStep = SetVariableStep
|
||||||
|
|
||||||
|
export enum LogicStepType {
|
||||||
|
SET_VARIABLE = 'Set variable',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetVariableStep = StepBase & {
|
||||||
|
type: LogicStepType.SET_VARIABLE
|
||||||
|
options?: SetVariableOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SetVariableOptions = {
|
||||||
|
variableId?: string
|
||||||
|
expressionToEvaluate?: string
|
||||||
|
}
|
@ -1,10 +1,11 @@
|
|||||||
import { InputStep, InputStepType } from './inputs'
|
import { InputStep, InputStepType } from './inputs'
|
||||||
|
import { LogicStep, LogicStepType } from './logic'
|
||||||
|
|
||||||
export type Step = StartStep | BubbleStep | InputStep
|
export type Step = StartStep | BubbleStep | InputStep | LogicStep
|
||||||
|
|
||||||
export type BubbleStep = TextStep
|
export type BubbleStep = TextStep
|
||||||
|
|
||||||
export type StepType = 'start' | BubbleStepType | InputStepType
|
export type StepType = 'start' | BubbleStepType | InputStepType | LogicStepType
|
||||||
|
|
||||||
export enum BubbleStepType {
|
export enum BubbleStepType {
|
||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
|
@ -4,14 +4,16 @@ import { Table } from '../utils'
|
|||||||
import { Settings } from './settings'
|
import { Settings } from './settings'
|
||||||
import { Step } from './steps/steps'
|
import { Step } from './steps/steps'
|
||||||
import { Theme } from './theme'
|
import { Theme } from './theme'
|
||||||
|
import { Variable } from './variable'
|
||||||
|
|
||||||
export type Typebot = Omit<
|
export type Typebot = Omit<
|
||||||
TypebotFromPrisma,
|
TypebotFromPrisma,
|
||||||
'blocks' | 'theme' | 'settings' | 'steps'
|
'blocks' | 'theme' | 'settings' | 'steps' | 'choiceItems' | 'variables'
|
||||||
> & {
|
> & {
|
||||||
blocks: Table<Block>
|
blocks: Table<Block>
|
||||||
steps: Table<Step>
|
steps: Table<Step>
|
||||||
choiceItems: Table<ChoiceItem>
|
choiceItems: Table<ChoiceItem>
|
||||||
|
variables: Table<Variable>
|
||||||
theme: Theme
|
theme: Theme
|
||||||
settings: Settings
|
settings: Settings
|
||||||
}
|
}
|
||||||
|
5
packages/models/src/typebot/variable.ts
Normal file
5
packages/models/src/typebot/variable.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export type Variable = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
value?: string
|
||||||
|
}
|
@ -1,8 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
BubbleStep,
|
||||||
BubbleStepType,
|
BubbleStepType,
|
||||||
ChoiceInputStep,
|
ChoiceInputStep,
|
||||||
InputStep,
|
InputStep,
|
||||||
InputStepType,
|
InputStepType,
|
||||||
|
LogicStep,
|
||||||
|
LogicStepType,
|
||||||
Step,
|
Step,
|
||||||
Table,
|
Table,
|
||||||
TextInputStep,
|
TextInputStep,
|
||||||
@ -45,6 +48,12 @@ export const filterTable = <T>(ids: string[], table: Table<T>): Table<T> => ({
|
|||||||
export const isInputStep = (step: Step): step is InputStep =>
|
export const isInputStep = (step: Step): step is InputStep =>
|
||||||
(Object.values(InputStepType) as string[]).includes(step.type)
|
(Object.values(InputStepType) as string[]).includes(step.type)
|
||||||
|
|
||||||
|
export const isBubbleStep = (step: Step): step is BubbleStep =>
|
||||||
|
(Object.values(BubbleStepType) as string[]).includes(step.type)
|
||||||
|
|
||||||
|
export const isLogicStep = (step: Step): step is LogicStep =>
|
||||||
|
(Object.values(LogicStepType) as string[]).includes(step.type)
|
||||||
|
|
||||||
export const isTextBubbleStep = (step: Step): step is TextStep =>
|
export const isTextBubbleStep = (step: Step): step is TextStep =>
|
||||||
step.type === BubbleStepType.TEXT
|
step.type === BubbleStepType.TEXT
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user