2
0

feat(inputs): Add Set variable step

This commit is contained in:
Baptiste Arnaud
2022-01-14 07:49:24 +01:00
parent 13f72f5ff7
commit 4ccb7bca49
55 changed files with 1024 additions and 223 deletions

View File

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

View File

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

View File

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

View 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
}

View File

@@ -13,6 +13,7 @@
"outDir": "dist",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"emitDeclarationOnly": true
"emitDeclarationOnly": true,
"baseUrl": "./src"
}
}

View File

@@ -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[]
}

View File

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

View File

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

View File

@@ -2,3 +2,4 @@ export * from './typebot'
export * from './steps'
export * from './theme'
export * from './settings'
export * from './variable'

View File

@@ -1,2 +1,3 @@
export * from './steps'
export * from './inputs'
export * from './logic'

View File

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

View 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
}

View File

@@ -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',

View File

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

View File

@@ -0,0 +1,5 @@
export type Variable = {
id: string
name: string
value?: string
}

View File

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