2
0

chore(editor): ♻️ Revert tables to arrays

Yet another refacto. I improved many many mechanisms on this one including dnd. It is now end 2 end tested 🎉
This commit is contained in:
Baptiste Arnaud
2022-02-04 19:00:08 +01:00
parent 8a350eee6c
commit 524ef0812c
123 changed files with 2998 additions and 3112 deletions

View File

@ -4,7 +4,7 @@ import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { ChatStep } from './ChatStep'
import { AvatarSideContainer } from './AvatarSideContainer'
import { HostAvatarsContext } from '../../contexts/HostAvatarsContext'
import { Step } from 'models'
import { PublicStep } from 'models'
import { useTypebot } from '../../contexts/TypebotContext'
import {
isBubbleStep,
@ -14,26 +14,28 @@ import {
isLogicStep,
} from 'utils'
import { executeLogic } from 'services/logic'
import { getSingleChoiceTargetId } from 'services/inputs'
import { executeIntegration } from 'services/integration'
type ChatBlockProps = {
stepIds: string[]
startStepId?: string
steps: PublicStep[]
startStepIndex: number
blockIndex: number
onBlockEnd: (edgeId?: string) => void
}
export const ChatBlock = ({
stepIds,
startStepId,
steps,
startStepIndex,
blockIndex,
onBlockEnd,
}: ChatBlockProps) => {
const { typebot, updateVariableValue } = useTypebot()
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([])
const [displayedSteps, setDisplayedSteps] = useState<PublicStep[]>([])
const currentStepIndex = displayedSteps.length - 1
useEffect(() => {
const nextStep =
typebot.steps.byId[startStepId ?? stepIds[displayedSteps.length]]
const nextStep = steps[startStepIndex]
if (nextStep) setDisplayedSteps([...displayedSteps, nextStep])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@ -60,6 +62,7 @@ export const ChatBlock = ({
typebot.typebotId,
currentStep,
typebot.variables,
{ blockIndex, stepIndex: currentStepIndex },
updateVariableValue
)
nextEdgeId ? onBlockEnd(nextEdgeId) : displayNextStep()
@ -85,18 +88,17 @@ export const ChatBlock = ({
}
const isSingleChoiceStep =
isChoiceInput(currentStep) && !currentStep.options.isMultipleChoice
if (isSingleChoiceStep)
return onBlockEnd(
getSingleChoiceTargetId(
currentStep,
typebot.choiceItems,
answerContent
)
if (isSingleChoiceStep) {
onBlockEnd(
currentStep.items.find((i) => i.content === answerContent)
?.outgoingEdgeId
)
if (currentStep?.edgeId || displayedSteps.length === stepIds.length)
return onBlockEnd(currentStep.edgeId)
}
if (currentStep?.outgoingEdgeId || displayedSteps.length === steps.length)
return onBlockEnd(currentStep.outgoingEdgeId)
}
const nextStep = typebot.steps.byId[stepIds[displayedSteps.length]]
const nextStep = steps[displayedSteps.length]
if (nextStep) setDisplayedSteps([...displayedSteps, nextStep])
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'
import { useAnswers } from '../../../contexts/AnswersContext'
import { useHostAvatars } from '../../../contexts/HostAvatarsContext'
import { InputStep, InputStepType, Step } from 'models'
import { InputStep, InputStepType, PublicStep, Step } from 'models'
import { GuestBubble } from './bubbles/GuestBubble'
import { TextForm } from './inputs/TextForm'
import { isBubbleStep, isInputStep } from 'utils'
@ -13,7 +13,7 @@ export const ChatStep = ({
step,
onTransitionEnd,
}: {
step: Step
step: PublicStep
onTransitionEnd: (answerContent?: string) => void
}) => {
const { addAnswer } = useAnswers()
@ -63,6 +63,6 @@ const InputChatStep = ({
case InputStepType.DATE:
return <DateForm options={step.options} onSubmit={handleSubmit} />
case InputStepType.CHOICE:
return <ChoiceForm options={step.options} onSubmit={handleSubmit} />
return <ChoiceForm step={step} onSubmit={handleSubmit} />
}
}

View File

@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useHostAvatars } from 'contexts/HostAvatarsContext'
import { useTypebot } from 'contexts/TypebotContext'
import {
Table,
Variable,
VideoBubbleContent,
VideoBubbleContentType,
@ -83,7 +82,7 @@ const VideoContent = ({
}: {
content?: VideoBubbleContent
isTyping: boolean
variables: Table<Variable>
variables: Variable[]
}) => {
const url = useMemo(
() => parseVariables({ text: content?.url, variables: variables }),

View File

@ -1,65 +1,61 @@
import { ChoiceInputOptions } from 'models'
import React, { useMemo, useState } from 'react'
import { filterTable } from 'utils'
import { useTypebot } from '../../../../contexts/TypebotContext'
import { ChoiceInputStep } from 'models'
import React, { useState } from 'react'
import { SendButton } from './SendButton'
type ChoiceFormProps = {
options?: ChoiceInputOptions
step: ChoiceInputStep
onSubmit: (value: string) => void
}
export const ChoiceForm = ({ options, onSubmit }: ChoiceFormProps) => {
const { typebot } = useTypebot()
const items = useMemo(
() => filterTable(options?.itemIds ?? [], typebot.choiceItems),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const [selectedIds, setSelectedIds] = useState<string[]>([])
export const ChoiceForm = ({ step, onSubmit }: ChoiceFormProps) => {
const [selectedIndices, setSelectedIndices] = useState<number[]>([])
const handleClick = (itemId: string) => (e: React.MouseEvent) => {
const handleClick = (itemIndex: number) => (e: React.MouseEvent) => {
e.preventDefault()
if (options?.isMultipleChoice) toggleSelectedItemId(itemId)
else onSubmit(items.byId[itemId].content ?? '')
if (step.options?.isMultipleChoice) toggleSelectedItemIndex(itemIndex)
else onSubmit(step.items[itemIndex].content ?? '')
}
const toggleSelectedItemId = (itemId: string) => {
const existingIndex = selectedIds.indexOf(itemId)
const toggleSelectedItemIndex = (itemIndex: number) => {
const existingIndex = selectedIndices.indexOf(itemIndex)
if (existingIndex !== -1) {
selectedIds.splice(existingIndex, 1)
setSelectedIds([...selectedIds])
selectedIndices.splice(existingIndex, 1)
setSelectedIndices([...selectedIndices])
} else {
setSelectedIds([...selectedIds, itemId])
setSelectedIndices([...selectedIndices, itemIndex])
}
}
const handleSubmit = () =>
onSubmit(selectedIds.map((itemId) => items.byId[itemId].content).join(', '))
onSubmit(
selectedIndices
.map((itemIndex) => step.items[itemIndex].content)
.join(', ')
)
return (
<form className="flex flex-col" onSubmit={handleSubmit}>
<div className="flex flex-wrap">
{options?.itemIds.map((itemId) => (
{step.items.map((item, idx) => (
<button
key={itemId}
role={options?.isMultipleChoice ? 'checkbox' : 'button'}
onClick={handleClick(itemId)}
key={item.id}
role={step.options?.isMultipleChoice ? 'checkbox' : 'button'}
onClick={handleClick(idx)}
className={
'py-2 px-4 font-semibold rounded-md transition-all filter hover:brightness-90 active:brightness-75 duration-100 focus:outline-none mr-2 mb-2 typebot-button ' +
(selectedIds.includes(itemId) || !options?.isMultipleChoice
(selectedIndices.includes(idx) || !step.options?.isMultipleChoice
? 'active'
: '')
}
data-testid="button"
>
{items.byId[itemId].content}
{item.content}
</button>
))}
</div>
<div className="flex">
{selectedIds.length > 0 && (
<SendButton label={options?.buttonLabel ?? 'Send'} />
{selectedIndices.length > 0 && (
<SendButton label={step.options?.buttonLabel ?? 'Send'} />
)}
</div>
</form>

View File

@ -5,11 +5,12 @@ import { useFrame } from 'react-frame-component'
import { setCssVariablesValue } from '../services/theme'
import { useAnswers } from '../contexts/AnswersContext'
import { deepEqual } from 'fast-equals'
import { Answer, Block, PublicTypebot } from 'models'
import { Answer, Edge, PublicBlock, PublicTypebot } from 'models'
import { byId } from 'utils'
type Props = {
typebot: PublicTypebot
onNewBlockVisible: (edgeId: string) => void
onNewBlockVisible: (edge: Edge) => void
onNewAnswer: (answer: Answer) => void
onCompleted: () => void
}
@ -21,30 +22,29 @@ export const ConversationContainer = ({
}: Props) => {
const { document: frameDocument } = useFrame()
const [displayedBlocks, setDisplayedBlocks] = useState<
{ block: Block; startStepId?: string }[]
{ block: PublicBlock; startStepIndex: number }[]
>([])
const [localAnswer, setLocalAnswer] = useState<Answer | undefined>()
const { answers } = useAnswers()
const bottomAnchor = useRef<HTMLDivElement | null>(null)
const displayNextBlock = (edgeId?: string) => {
const edge = typebot.edges.byId[edgeId ?? '']
if (!edge) return onCompleted()
const nextBlock = {
block: typebot.blocks.byId[edge.to.blockId],
startStepId: edge.to.stepId,
}
const nextEdge = typebot.edges.find(byId(edgeId))
if (!nextEdge) return onCompleted()
const nextBlock = typebot.blocks.find(byId(nextEdge.to.blockId))
if (!nextBlock) return onCompleted()
onNewBlockVisible(edge.id)
setDisplayedBlocks([...displayedBlocks, nextBlock])
const startStepIndex = nextEdge.to.stepId
? nextBlock.steps.findIndex(byId(nextEdge.to.stepId))
: 0
onNewBlockVisible(nextEdge)
setDisplayedBlocks([
...displayedBlocks,
{ block: nextBlock, startStepIndex },
])
}
useEffect(() => {
const blocks = typebot.blocks
const firstEdgeId =
typebot.steps.byId[blocks.byId[blocks.allIds[0]].stepIds[0]].edgeId
if (!firstEdgeId) return
displayNextBlock(firstEdgeId)
displayNextBlock(typebot.blocks[0].steps[0].outgoingEdgeId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@ -68,8 +68,9 @@ export const ConversationContainer = ({
{displayedBlocks.map((displayedBlock, idx) => (
<ChatBlock
key={displayedBlock.block.id + idx}
stepIds={displayedBlock.block.stepIds}
startStepId={displayedBlock.startStepId}
steps={displayedBlock.block.steps}
startStepIndex={displayedBlock.startStepIndex}
blockIndex={idx}
onBlockEnd={displayNextBlock}
/>
))}

View File

@ -10,11 +10,11 @@ import phoneNumberInputStyle from 'react-phone-number-input/style.css'
import phoneSyle from '../assets/phone.css'
import { ConversationContainer } from './ConversationContainer'
import { AnswersContext } from '../contexts/AnswersContext'
import { Answer, BackgroundType, PublicTypebot } from 'models'
import { Answer, BackgroundType, Edge, PublicTypebot } from 'models'
export type TypebotViewerProps = {
typebot: PublicTypebot
onNewBlockVisible?: (edgeId: string) => void
onNewBlockVisible?: (edge: Edge) => void
onNewAnswer?: (answer: Answer) => void
onCompleted?: () => void
}
@ -31,8 +31,8 @@ export const TypebotViewer = ({
: 'transparent',
[typebot?.theme?.general?.background]
)
const handleNewBlockVisible = (blockId: string) => {
if (onNewBlockVisible) onNewBlockVisible(blockId)
const handleNewBlockVisible = (edge: Edge) => {
if (onNewBlockVisible) onNewBlockVisible(edge)
}
const handleNewAnswer = (answer: Answer) => {
if (onNewAnswer) onNewAnswer(answer)

View File

@ -20,13 +20,9 @@ export const TypebotContext = ({
const updateVariableValue = (variableId: string, value: string) => {
setLocalTypebot((typebot) => ({
...typebot,
variables: {
...typebot.variables,
byId: {
...typebot.variables.byId,
[variableId]: { ...typebot.variables.byId[variableId], value },
},
},
variables: typebot.variables.map((v) =>
v.id === variableId ? { ...v, value } : v
),
}))
}
return (

View File

@ -1,13 +0,0 @@
import { ChoiceInputStep, ChoiceItem, Table } from 'models'
export const getSingleChoiceTargetId = (
currentStep: ChoiceInputStep,
choiceItems: Table<ChoiceItem>,
answerContent?: string
): string | undefined => {
const itemId = currentStep.options.itemIds.find(
(itemId) => choiceItems.byId[itemId].content === answerContent
)
if (!itemId) throw new Error('itemId should exist')
return choiceItems.byId[itemId].edgeId ?? currentStep.edgeId
}

View File

@ -5,7 +5,6 @@ import {
GoogleSheetsAction,
GoogleSheetsInsertRowOptions,
Variable,
Table,
GoogleSheetsUpdateRowOptions,
Cell,
GoogleSheetsGetOptions,
@ -19,10 +18,12 @@ import { parseVariables, parseVariablesInObject } from './variable'
const safeEval = eval
type Indices = { blockIndex: number; stepIndex: number }
export const executeIntegration = (
typebotId: string,
step: IntegrationStep,
variables: Table<Variable>,
variables: Variable[],
indices: Indices,
updateVariableValue: (variableId: string, value: string) => void
) => {
switch (step.type) {
@ -31,13 +32,19 @@ export const executeIntegration = (
case IntegrationStepType.GOOGLE_ANALYTICS:
return executeGoogleAnalyticsIntegration(step, variables)
case IntegrationStepType.WEBHOOK:
return executeWebhook(typebotId, step, variables, updateVariableValue)
return executeWebhook(
typebotId,
step,
variables,
indices,
updateVariableValue
)
}
}
export const executeGoogleAnalyticsIntegration = async (
step: GoogleAnalyticsStep,
variables: Table<Variable>
variables: Variable[]
) => {
if (!step.options?.trackingId) return
const { default: initGoogleAnalytics } = await import('../../lib/gtag')
@ -47,10 +54,10 @@ export const executeGoogleAnalyticsIntegration = async (
const executeGoogleSheetIntegration = async (
step: GoogleSheetsStep,
variables: Table<Variable>,
variables: Variable[],
updateVariableValue: (variableId: string, value: string) => void
) => {
if (!('action' in step.options)) return step.edgeId
if (!('action' in step.options)) return step.outgoingEdgeId
switch (step.options.action) {
case GoogleSheetsAction.INSERT_ROW:
await insertRowInGoogleSheets(step.options, variables)
@ -62,12 +69,12 @@ const executeGoogleSheetIntegration = async (
await getRowFromGoogleSheets(step.options, variables, updateVariableValue)
break
}
return step.edgeId
return step.outgoingEdgeId
}
const insertRowInGoogleSheets = async (
options: GoogleSheetsInsertRowOptions,
variables: Table<Variable>
variables: Variable[]
) => {
if (!options.cellsToInsert) return
return sendRequest({
@ -82,7 +89,7 @@ const insertRowInGoogleSheets = async (
const updateRowInGoogleSheets = async (
options: GoogleSheetsUpdateRowOptions,
variables: Table<Variable>
variables: Variable[]
) => {
if (!options.cellsToUpsert || !options.referenceCell) return
return sendRequest({
@ -104,7 +111,7 @@ const updateRowInGoogleSheets = async (
const getRowFromGoogleSheets = async (
options: GoogleSheetsGetOptions,
variables: Table<Variable>,
variables: Variable[],
updateVariableValue: (variableId: string, value: string) => void
) => {
if (!options.referenceCell || !options.cellsToExtract) return
@ -118,9 +125,7 @@ const getRowFromGoogleSheets = async (
variables,
}),
},
columns: options.cellsToExtract.allIds.map(
(id) => options.cellsToExtract?.byId[id].column
),
columns: options.cellsToExtract.map((cell) => cell.column),
},
{ indices: false }
)
@ -129,18 +134,15 @@ const getRowFromGoogleSheets = async (
method: 'GET',
})
if (!data) return
options.cellsToExtract.allIds.forEach((cellId) => {
const cell = options.cellsToExtract?.byId[cellId]
if (!cell) return
options.cellsToExtract.forEach((cell) =>
updateVariableValue(cell.variableId ?? '', data[cell.column ?? ''])
})
)
}
const parseCellValues = (
cells: Table<Cell>,
variables: Table<Variable>
cells: Cell[],
variables: Variable[]
): { [key: string]: string } =>
cells.allIds.reduce((row, id) => {
const cell = cells.byId[id]
cells.reduce((row, cell) => {
return !cell.column || !cell.value
? row
: {
@ -152,20 +154,21 @@ const parseCellValues = (
const executeWebhook = async (
typebotId: string,
step: WebhookStep,
variables: Table<Variable>,
variables: Variable[],
indices: Indices,
updateVariableValue: (variableId: string, value: string) => void
) => {
if (!step.options?.webhookId) return step.edgeId
if (!step.webhook) return step.outgoingEdgeId
const { blockIndex, stepIndex } = indices
const { data, error } = await sendRequest({
url: `http://localhost:3000/api/typebots/${typebotId}/webhooks/${step.options?.webhookId}/execute`,
url: `http://localhost:3000/api/typebots/${typebotId}/blocks/${blockIndex}/steps/${stepIndex}/executeWebhook`,
method: 'POST',
body: {
variables,
},
})
console.error(error)
step.options.responseVariableMapping?.allIds.forEach((varMappingId) => {
const varMapping = step.options?.responseVariableMapping?.byId[varMappingId]
step.options.responseVariableMapping.forEach((varMapping) => {
if (!varMapping?.bodyPath || !varMapping.variableId) return
const value = safeEval(`(${JSON.stringify(data)}).${varMapping?.bodyPath}`)
updateVariableValue(varMapping.variableId, value)

View File

@ -3,20 +3,21 @@ import {
LogicStepType,
LogicalOperator,
ConditionStep,
Table,
Variable,
ComparisonOperators,
SetVariableStep,
RedirectStep,
Comparison,
} from 'models'
import { isDefined, isNotDefined } from 'utils'
import { sanitizeUrl } from './utils'
import { isMathFormula, evaluateExpression, parseVariables } from './variable'
type EdgeId = string
export const executeLogic = (
step: LogicStep,
variables: Table<Variable>,
variables: Variable[],
updateVariableValue: (variableId: string, expression: string) => void
): EdgeId | undefined => {
switch (step.type) {
@ -31,40 +32,36 @@ export const executeLogic = (
const executeSetVariable = (
step: SetVariableStep,
variables: Table<Variable>,
variables: Variable[],
updateVariableValue: (variableId: string, expression: string) => void
): EdgeId | undefined => {
if (!step.options?.variableId || !step.options.expressionToEvaluate)
return step.edgeId
return step.outgoingEdgeId
const expression = step.options.expressionToEvaluate
const evaluatedExpression = isMathFormula(expression)
? evaluateExpression(parseVariables({ text: expression, variables }))
: expression
updateVariableValue(step.options.variableId, evaluatedExpression)
return step.edgeId
return step.outgoingEdgeId
}
const executeCondition = (
step: ConditionStep,
variables: Table<Variable>
variables: Variable[]
): EdgeId | undefined => {
const { content } = step.items[0]
const isConditionPassed =
step.options?.logicalOperator === LogicalOperator.AND
? step.options?.comparisons.allIds.every(
executeComparison(step, variables)
)
: step.options?.comparisons.allIds.some(
executeComparison(step, variables)
)
return isConditionPassed ? step.trueEdgeId : step.falseEdgeId
content.logicalOperator === LogicalOperator.AND
? content.comparisons.every(executeComparison(variables))
: content.comparisons.some(executeComparison(variables))
return isConditionPassed ? step.items[0].outgoingEdgeId : step.outgoingEdgeId
}
const executeComparison =
(step: ConditionStep, variables: Table<Variable>) =>
(comparisonId: string) => {
const comparison = step.options?.comparisons.byId[comparisonId]
(variables: Variable[]) => (comparison: Comparison) => {
if (!comparison?.variableId) return false
const inputValue = variables.byId[comparison.variableId].value ?? ''
const inputValue =
variables.find((v) => v.id === comparison.variableId)?.value ?? ''
const { value } = comparison
if (isNotDefined(value)) return false
switch (comparison.comparisonOperator) {
@ -91,12 +88,12 @@ const executeComparison =
const executeRedirect = (
step: RedirectStep,
variables: Table<Variable>
variables: Variable[]
): EdgeId | undefined => {
if (!step.options?.url) return step.edgeId
if (!step.options?.url) return step.outgoingEdgeId
window.open(
sanitizeUrl(parseVariables({ text: step.options?.url, variables })),
step.options.isNewTab ? '_blank' : '_self'
)
return step.edgeId
return step.outgoingEdgeId
}

View File

@ -1,4 +1,4 @@
import { Table, Variable } from 'models'
import { Variable } from 'models'
import { isDefined } from 'utils'
const safeEval = eval
@ -11,16 +11,16 @@ export const parseVariables = ({
variables,
}: {
text?: string
variables: Table<Variable>
variables: Variable[]
}): string => {
if (!text || text === '') return ''
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 ?? ''
return (
variables.find((v) => {
return matchedVarName === v.name && isDefined(v.value)
})?.value ?? ''
)
})
}
@ -50,7 +50,7 @@ const countDecimals = (value: number) => {
export const parseVariablesInObject = (
object: { [key: string]: string | number },
variables: Table<Variable>
variables: Variable[]
) =>
Object.keys(object).reduce((newObj, key) => {
const currentValue = object[key]