feat(inputs): ✨ Add Set variable step
This commit is contained in:
@@ -4,9 +4,19 @@ import { TransitionGroup, CSSTransition } from 'react-transition-group'
|
||||
import { ChatStep } from './ChatStep'
|
||||
import { AvatarSideContainer } from './AvatarSideContainer'
|
||||
import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
|
||||
import { ChoiceInputStep, Step } from 'models'
|
||||
import { ChoiceInputStep, LogicStep, Step } from 'models'
|
||||
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 = {
|
||||
stepIds: string[]
|
||||
@@ -14,15 +24,20 @@ type ChatBlockProps = {
|
||||
}
|
||||
|
||||
export const ChatBlock = ({ stepIds, onBlockEnd }: ChatBlockProps) => {
|
||||
const { typebot } = useTypebot()
|
||||
const { typebot, updateVariableValue } = useTypebot()
|
||||
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedSteps([typebot.steps.byId[stepIds[0]]])
|
||||
displayNextStep()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
autoScrollToBottom()
|
||||
const currentStep = [...displayedSteps].pop()
|
||||
if (currentStep && isLogicStep(currentStep)) {
|
||||
executeLogic(currentStep)
|
||||
displayNextStep()
|
||||
}
|
||||
}, [displayedSteps])
|
||||
|
||||
const autoScrollToBottom = () => {
|
||||
@@ -34,20 +49,37 @@ export const ChatBlock = ({ stepIds, onBlockEnd }: ChatBlockProps) => {
|
||||
|
||||
const displayNextStep = (answerContent?: string) => {
|
||||
const currentStep = [...displayedSteps].pop()
|
||||
if (!currentStep) throw new Error('currentStep should exist')
|
||||
const isSingleChoiceStep =
|
||||
isChoiceInput(currentStep) && !currentStep.options.isMultipleChoice
|
||||
if (isSingleChoiceStep)
|
||||
return onBlockEnd(getSingleChoiceTargetId(currentStep, answerContent))
|
||||
if (
|
||||
currentStep?.target?.blockId ||
|
||||
displayedSteps.length === stepIds.length
|
||||
)
|
||||
return onBlockEnd(currentStep?.target?.blockId)
|
||||
if (currentStep) {
|
||||
if (
|
||||
isInputStep(currentStep) &&
|
||||
currentStep.options?.variableId &&
|
||||
answerContent
|
||||
) {
|
||||
updateVariableValue(currentStep.options.variableId, answerContent)
|
||||
}
|
||||
const isSingleChoiceStep =
|
||||
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]]
|
||||
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 = (
|
||||
currentStep: ChoiceInputStep,
|
||||
answerContent?: string
|
||||
@@ -68,16 +100,18 @@ export const ChatBlock = ({ stepIds, onBlockEnd }: ChatBlockProps) => {
|
||||
<AvatarSideContainer />
|
||||
<div className="flex flex-col w-full">
|
||||
<TransitionGroup>
|
||||
{displayedSteps.map((step) => (
|
||||
<CSSTransition
|
||||
key={step.id}
|
||||
classNames="bubble"
|
||||
timeout={500}
|
||||
unmountOnExit
|
||||
>
|
||||
<ChatStep step={step} onTransitionEnd={displayNextStep} />
|
||||
</CSSTransition>
|
||||
))}
|
||||
{displayedSteps
|
||||
.filter((step) => isInputStep(step) || isTextBubbleStep(step))
|
||||
.map((step) => (
|
||||
<CSSTransition
|
||||
key={step.id}
|
||||
classNames="bubble"
|
||||
timeout={500}
|
||||
unmountOnExit
|
||||
>
|
||||
<ChatStep step={step} onTransitionEnd={displayNextStep} />
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</HostAvatarsContext>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useHostAvatars } from '../../../../contexts/HostAvatarsContext'
|
||||
import { useTypebot } from '../../../../contexts/TypebotContext'
|
||||
import { BubbleStepType, StepType, TextStep } from 'models'
|
||||
import { computeTypingTimeout } from '../../../../services/chat'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHostAvatars } from 'contexts/HostAvatarsContext'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import { BubbleStepType, TextStep } from 'models'
|
||||
import { computeTypingTimeout } from 'services/chat'
|
||||
import { TypingContent } from './TypingContent'
|
||||
import { parseVariables } from 'services/variable'
|
||||
|
||||
type HostMessageBubbleProps = {
|
||||
step: TextStep
|
||||
@@ -24,6 +25,11 @@ export const HostMessageBubble = ({
|
||||
const messageContainer = useRef<HTMLDivElement | null>(null)
|
||||
const [isTyping, setIsTyping] = useState(true)
|
||||
|
||||
const content = useMemo(
|
||||
() => parseVariables(step.content.html, typebot.variables),
|
||||
[typebot.variables]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
sendAvatarOffset()
|
||||
const typingTimeout = computeTypingTimeout(
|
||||
@@ -72,7 +78,7 @@ export const HostMessageBubble = ({
|
||||
(isTyping ? 'opacity-0 h-6' : 'opacity-100 h-full')
|
||||
}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: step.content.html,
|
||||
__html: content,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { PublicTypebot } from 'models'
|
||||
import React, { createContext, ReactNode, useContext } from 'react'
|
||||
import React, { createContext, ReactNode, useContext, useState } from 'react'
|
||||
|
||||
const typebotContext = createContext<{
|
||||
typebot: PublicTypebot
|
||||
updateVariableValue: (variableId: string, value: string) => void
|
||||
//@ts-ignore
|
||||
}>({})
|
||||
|
||||
@@ -13,10 +14,25 @@ export const TypebotContext = ({
|
||||
children: ReactNode
|
||||
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 (
|
||||
<typebotContext.Provider
|
||||
value={{
|
||||
typebot,
|
||||
typebot: localTypebot,
|
||||
updateVariableValue,
|
||||
}}
|
||||
>
|
||||
{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",
|
||||
"moduleResolution": "node",
|
||||
"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
|
||||
steps Json
|
||||
choiceItems Json
|
||||
variables Json
|
||||
theme Json
|
||||
settings Json
|
||||
publicId String? @unique
|
||||
@@ -106,6 +107,7 @@ model PublicTypebot {
|
||||
blocks Json
|
||||
steps Json
|
||||
choiceItems Json
|
||||
variables Json
|
||||
theme Json
|
||||
settings Json
|
||||
publicId String? @unique
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { PublicTypebot as PublicTypebotFromPrisma } from 'db'
|
||||
import { Block, ChoiceItem, Settings, Step, Theme } from './typebot'
|
||||
import { Variable } from './typebot/variable'
|
||||
import { Table } from './utils'
|
||||
|
||||
export type PublicTypebot = Omit<
|
||||
PublicTypebotFromPrisma,
|
||||
'blocks' | 'startBlock' | 'theme' | 'settings' | 'steps'
|
||||
| 'blocks'
|
||||
| 'startBlock'
|
||||
| 'theme'
|
||||
| 'settings'
|
||||
| 'steps'
|
||||
| 'choiceItems'
|
||||
| 'variables'
|
||||
> & {
|
||||
blocks: Table<Block>
|
||||
steps: Table<Step>
|
||||
choiceItems: Table<ChoiceItem>
|
||||
variables: Table<Variable>
|
||||
theme: Theme
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './typebot'
|
||||
export * from './steps'
|
||||
export * from './theme'
|
||||
export * from './settings'
|
||||
export * from './variable'
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './steps'
|
||||
export * from './inputs'
|
||||
export * from './logic'
|
||||
|
||||
@@ -47,7 +47,7 @@ export type DateInputStep = StepBase & {
|
||||
|
||||
export type PhoneNumberInputStep = StepBase & {
|
||||
type: InputStepType.PHONE
|
||||
options?: InputTextOptionsBase
|
||||
options?: OptionBase & InputTextOptionsBase
|
||||
}
|
||||
|
||||
export type ChoiceInputStep = StepBase & {
|
||||
@@ -55,12 +55,6 @@ export type ChoiceInputStep = StepBase & {
|
||||
options: ChoiceInputOptions
|
||||
}
|
||||
|
||||
export type ChoiceInputOptions = {
|
||||
itemIds: string[]
|
||||
isMultipleChoice?: boolean
|
||||
buttonLabel?: string
|
||||
}
|
||||
|
||||
export type ChoiceItem = {
|
||||
id: string
|
||||
stepId: string
|
||||
@@ -68,26 +62,35 @@ export type ChoiceItem = {
|
||||
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 }
|
||||
hasTime?: boolean
|
||||
isRange?: boolean
|
||||
}
|
||||
|
||||
export type EmailInputOptions = InputTextOptionsBase
|
||||
export type EmailInputOptions = OptionBase & InputTextOptionsBase
|
||||
|
||||
export type UrlInputOptions = InputTextOptionsBase
|
||||
export type UrlInputOptions = OptionBase & InputTextOptionsBase
|
||||
|
||||
type InputTextOptionsBase = {
|
||||
labels?: { placeholder?: string; button?: string }
|
||||
}
|
||||
export type TextInputOptions = OptionBase &
|
||||
InputTextOptionsBase & {
|
||||
isLong?: boolean
|
||||
}
|
||||
|
||||
export type TextInputOptions = InputTextOptionsBase & {
|
||||
isLong?: boolean
|
||||
}
|
||||
|
||||
export type NumberInputOptions = InputTextOptionsBase & {
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}
|
||||
export type NumberInputOptions = OptionBase &
|
||||
InputTextOptionsBase & {
|
||||
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 { LogicStep, LogicStepType } from './logic'
|
||||
|
||||
export type Step = StartStep | BubbleStep | InputStep
|
||||
export type Step = StartStep | BubbleStep | InputStep | LogicStep
|
||||
|
||||
export type BubbleStep = TextStep
|
||||
|
||||
export type StepType = 'start' | BubbleStepType | InputStepType
|
||||
export type StepType = 'start' | BubbleStepType | InputStepType | LogicStepType
|
||||
|
||||
export enum BubbleStepType {
|
||||
TEXT = 'text',
|
||||
|
||||
@@ -4,14 +4,16 @@ import { Table } from '../utils'
|
||||
import { Settings } from './settings'
|
||||
import { Step } from './steps/steps'
|
||||
import { Theme } from './theme'
|
||||
import { Variable } from './variable'
|
||||
|
||||
export type Typebot = Omit<
|
||||
TypebotFromPrisma,
|
||||
'blocks' | 'theme' | 'settings' | 'steps'
|
||||
'blocks' | 'theme' | 'settings' | 'steps' | 'choiceItems' | 'variables'
|
||||
> & {
|
||||
blocks: Table<Block>
|
||||
steps: Table<Step>
|
||||
choiceItems: Table<ChoiceItem>
|
||||
variables: Table<Variable>
|
||||
theme: Theme
|
||||
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 {
|
||||
BubbleStep,
|
||||
BubbleStepType,
|
||||
ChoiceInputStep,
|
||||
InputStep,
|
||||
InputStepType,
|
||||
LogicStep,
|
||||
LogicStepType,
|
||||
Step,
|
||||
Table,
|
||||
TextInputStep,
|
||||
@@ -45,6 +48,12 @@ export const filterTable = <T>(ids: string[], table: Table<T>): Table<T> => ({
|
||||
export const isInputStep = (step: Step): step is InputStep =>
|
||||
(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 =>
|
||||
step.type === BubbleStepType.TEXT
|
||||
|
||||
|
||||
Reference in New Issue
Block a user