2
0

(buttons) Allow dynamic buttons from variable

Closes #237
This commit is contained in:
Baptiste Arnaud
2023-02-23 14:44:37 +01:00
parent 84628109d0
commit 2ff6991ca7
28 changed files with 290 additions and 116 deletions

View File

@@ -17,6 +17,7 @@ import { useDebouncedCallback } from 'use-debounce'
import { env, isDefined } from 'utils' import { env, isDefined } from 'utils'
import { VariablesButton } from '@/features/variables' import { VariablesButton } from '@/features/variables'
import { useOutsideClick } from '@/hooks/useOutsideClick' import { useOutsideClick } from '@/hooks/useOutsideClick'
import { useParentModal } from '@/features/graph/providers/ParentModalProvider'
type Props = { type Props = {
selectedItem?: string selectedItem?: string
@@ -58,6 +59,7 @@ export const SearchableDropdown = ({
const dropdownRef = useRef(null) const dropdownRef = useRef(null)
const itemsRef = useRef<(HTMLButtonElement | null)[]>([]) const itemsRef = useRef<(HTMLButtonElement | null)[]>([])
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const { ref: parentModalRef } = useParentModal()
useEffect( useEffect(
() => () => { () => () => {
@@ -195,7 +197,7 @@ export const SearchableDropdown = ({
)} )}
</HStack> </HStack>
</PopoverAnchor> </PopoverAnchor>
<Portal> <Portal containerRef={parentModalRef}>
<PopoverContent <PopoverContent
maxH="35vh" maxH="35vh"
overflowY="scroll" overflowY="scroll"

View File

@@ -50,7 +50,7 @@ test.describe.parallel('Buttons input block', () => {
await page.click('[data-testid="block2-icon"]') await page.click('[data-testid="block2-icon"]')
await page.click('text=Multiple choice?') await page.click('text=Multiple choice?')
await page.fill('#button', 'Go') await page.fill('#button', 'Go')
await page.getByPlaceholder('Select a variable').click() await page.getByPlaceholder('Select a variable').nth(1).click()
await page.getByText('var1').click() await page.getByText('var1').click()
await expect(page.getByText('Collectsvar1')).toBeVisible() await expect(page.getByText('Collectsvar1')).toBeVisible()
await page.click('[data-testid="block2-icon"]') await page.click('[data-testid="block2-icon"]')

View File

@@ -0,0 +1,71 @@
import { BlockIndices, ChoiceInputBlock, Variable } from 'models'
import React from 'react'
import { ItemNodesList } from '@/features/graph/components/Nodes/ItemNode'
import {
HStack,
Stack,
Tag,
Text,
useColorModeValue,
Wrap,
} from '@chakra-ui/react'
import { useTypebot } from '@/features/editor'
type Props = {
block: ChoiceInputBlock
indices: BlockIndices
}
export const ButtonsBlockNode = ({ block, indices }: Props) => {
const { typebot } = useTypebot()
const dynamicVariableName = typebot?.variables.find(
(variable) => variable.id === block.options.dynamicVariableId
)?.name
return (
<Stack w="full">
{block.options.variableId ? (
<CollectVariableLabel
variableId={block.options.variableId}
variables={typebot?.variables}
/>
) : null}
{block.options.dynamicVariableId ? (
<Wrap spacing={1}>
<Text>Display</Text>
<Tag bg="orange.400" color="white">
{dynamicVariableName}
</Tag>
<Text>buttons</Text>
</Wrap>
) : (
<ItemNodesList block={block} indices={indices} />
)}
</Stack>
)
}
const CollectVariableLabel = ({
variableId,
variables,
}: {
variableId: string
variables?: Variable[]
}) => {
const textColor = useColorModeValue('gray.600', 'gray.400')
const variableName = variables?.find(
(variable) => variable.id === variableId
)?.name
if (!variableName) return null
return (
<HStack fontStyle="italic" spacing={1}>
<Text fontSize="sm" color={textColor}>
Collects
</Text>
<Tag bg="orange.400" color="white" size="sm">
{variableName}
</Tag>
</HStack>
)
}

View File

@@ -1,7 +1,8 @@
import { Input } from '@/components/inputs' import { Input } from '@/components/inputs'
import { MoreInfoTooltip } from '@/components/MoreInfoTooltip'
import { SwitchWithLabel } from '@/components/SwitchWithLabel' import { SwitchWithLabel } from '@/components/SwitchWithLabel'
import { VariableSearchInput } from '@/components/VariableSearchInput' import { VariableSearchInput } from '@/components/VariableSearchInput'
import { FormLabel, Stack } from '@chakra-ui/react' import { FormControl, FormLabel, Stack } from '@chakra-ui/react'
import { ChoiceInputOptions, Variable } from 'models' import { ChoiceInputOptions, Variable } from 'models'
import React from 'react' import React from 'react'
@@ -20,6 +21,8 @@ export const ButtonsOptionsForm = ({
options && onOptionsChange({ ...options, buttonLabel }) options && onOptionsChange({ ...options, buttonLabel })
const handleVariableChange = (variable?: Variable) => const handleVariableChange = (variable?: Variable) =>
options && onOptionsChange({ ...options, variableId: variable?.id }) options && onOptionsChange({ ...options, variableId: variable?.id })
const handleDynamicVariableChange = (variable?: Variable) =>
options && onOptionsChange({ ...options, dynamicVariableId: variable?.id })
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
@@ -40,6 +43,19 @@ export const ButtonsOptionsForm = ({
/> />
</Stack> </Stack>
)} )}
<FormControl>
<FormLabel>
Dynamic items from variable:{' '}
<MoreInfoTooltip>
If defined, buttons will be dynamically displayed based on what the
variable contains.
</MoreInfoTooltip>
</FormLabel>
<VariableSearchInput
initialVariableId={options?.dynamicVariableId}
onSelectVariable={handleDynamicVariableChange}
/>
</FormControl>
<Stack> <Stack>
<FormLabel mb="0" htmlFor="variable"> <FormLabel mb="0" htmlFor="variable">
Save answer in a variable: Save answer in a variable:

View File

@@ -1,3 +1,3 @@
export * from './ButtonsItemNode' export * from './ButtonsItemNode'
export * from './ButtonsIcon' export * from './ButtonsIcon'
export * from './ButtonsOptionsForm' export * from './ButtonsBlockSettings'

View File

@@ -6,21 +6,26 @@ export const getDeepKeys = (obj: any): string[] => {
const subkeys = getDeepKeys(obj[key]) const subkeys = getDeepKeys(obj[key])
keys = keys.concat( keys = keys.concat(
subkeys.map(function (subkey) { subkeys.map(function (subkey) {
return key + '.' + subkey return key + parseKey(subkey)
}) })
) )
} else if (Array.isArray(obj[key])) { } else if (Array.isArray(obj[key])) {
for (let i = 0; i < obj[key].length; i++) { const subkeys = getDeepKeys(obj[key][0])
const subkeys = getDeepKeys(obj[key][i]) keys = keys.concat(
keys = keys.concat( subkeys.map(function (subkey) {
subkeys.map(function (subkey) { return `${key}.map(item => item${parseKey(subkey)})`
return key + '[' + i + ']' + '.' + subkey })
}) )
)
}
} else { } else {
keys.push(key) keys.push(key)
} }
} }
return keys return keys
} }
const parseKey = (key: string) => {
if (key.includes(' ') && !key.includes('.map((item) => item')) {
return `['${key}']`
}
return `.${key}`
}

View File

@@ -89,7 +89,7 @@ test.describe('Builder', () => {
await page.click('text=Save in variables') await page.click('text=Save in variables')
await page.click('text=Add an entry >> nth=-1') await page.click('text=Add an entry >> nth=-1')
await page.click('input[placeholder="Select the data"]') await page.click('input[placeholder="Select the data"]')
await page.click('text=data[0].name') await page.click('text=data.map(item => item.name)')
}) })
}) })

View File

@@ -100,8 +100,7 @@ export const BlockNode = ({
useEffect(() => { useEffect(() => {
if (query.blockId?.toString() === block.id) setOpenedBlockId(block.id) if (query.blockId?.toString() === block.id) setOpenedBlockId(block.id)
// eslint-disable-next-line react-hooks/exhaustive-deps }, [block.id, query, setOpenedBlockId])
}, [query])
useEffect(() => { useEffect(() => {
setIsConnecting( setIsConnecting(

View File

@@ -33,20 +33,18 @@ import { GoogleSheetsNodeContent } from '@/features/blocks/integrations/googleSh
import { GoogleAnalyticsNodeContent } from '@/features/blocks/integrations/googleAnalytics/components/GoogleAnalyticsNodeContent' import { GoogleAnalyticsNodeContent } from '@/features/blocks/integrations/googleAnalytics/components/GoogleAnalyticsNodeContent'
import { ZapierContent } from '@/features/blocks/integrations/zapier' import { ZapierContent } from '@/features/blocks/integrations/zapier'
import { SendEmailContent } from '@/features/blocks/integrations/sendEmail' import { SendEmailContent } from '@/features/blocks/integrations/sendEmail'
import { isInputBlock, isChoiceInput, blockHasItems } from 'utils' import { isInputBlock, isChoiceInput } from 'utils'
import { MakeComContent } from '@/features/blocks/integrations/makeCom' import { MakeComContent } from '@/features/blocks/integrations/makeCom'
import { AudioBubbleNode } from '@/features/blocks/bubbles/audio' import { AudioBubbleNode } from '@/features/blocks/bubbles/audio'
import { WaitNodeContent } from '@/features/blocks/logic/wait/components/WaitNodeContent' import { WaitNodeContent } from '@/features/blocks/logic/wait/components/WaitNodeContent'
import { ScriptNodeContent } from '@/features/blocks/logic/script/components/ScriptNodeContent' import { ScriptNodeContent } from '@/features/blocks/logic/script/components/ScriptNodeContent'
import { ButtonsBlockNode } from '@/features/blocks/inputs/buttons/components/ButtonsBlockNode'
type Props = { type Props = {
block: Block | StartBlock block: Block | StartBlock
indices: BlockIndices indices: BlockIndices
} }
export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => { export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
if (blockHasItems(block))
return <ItemNodesList block={block} indices={indices} />
if ( if (
isInputBlock(block) && isInputBlock(block) &&
!isChoiceInput(block) && !isChoiceInput(block) &&
@@ -92,6 +90,9 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
case InputBlockType.URL: { case InputBlockType.URL: {
return <UrlNodeContent placeholder={block.options.labels.placeholder} /> return <UrlNodeContent placeholder={block.options.labels.placeholder} />
} }
case InputBlockType.CHOICE: {
return <ButtonsBlockNode block={block} indices={indices} />
}
case InputBlockType.PHONE: { case InputBlockType.PHONE: {
return <PhoneNodeContent placeholder={block.options.labels.placeholder} /> return <PhoneNodeContent placeholder={block.options.labels.placeholder} />
} }
@@ -126,7 +127,8 @@ export const BlockNodeContent = ({ block, indices }: Props): JSX.Element => {
} }
case LogicBlockType.TYPEBOT_LINK: case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkNode block={block} /> return <TypebotLinkNode block={block} />
case LogicBlockType.CONDITION:
return <ItemNodesList block={block} indices={indices} />
case IntegrationBlockType.GOOGLE_SHEETS: { case IntegrationBlockType.GOOGLE_SHEETS: {
return ( return (
<GoogleSheetsNodeContent <GoogleSheetsNodeContent

View File

@@ -1,9 +1,7 @@
import { import {
Flex, Flex,
HStack,
Portal, Portal,
Stack, Stack,
Tag,
Text, Text,
useColorModeValue, useColorModeValue,
useEventListener, useEventListener,
@@ -15,13 +13,7 @@ import {
useGraph, useGraph,
} from '../../../providers' } from '../../../providers'
import { useTypebot } from '@/features/editor' import { useTypebot } from '@/features/editor'
import { import { BlockIndices, BlockWithItems, LogicBlockType, Item } from 'models'
BlockIndices,
BlockWithItems,
LogicBlockType,
Item,
Variable,
} from 'models'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { ItemNode } from './ItemNode' import { ItemNode } from './ItemNode'
import { SourceEndpoint } from '../../Endpoints' import { SourceEndpoint } from '../../Endpoints'
@@ -137,17 +129,8 @@ export const ItemNodesList = ({
elem && (placeholderRefs.current[idx] = elem) elem && (placeholderRefs.current[idx] = elem)
} }
const collectedVariableId =
'options' in block && block.options && block.options.variableId
return ( return (
<Stack flex={1} spacing={1} maxW="full" onClick={stopPropagating}> <Stack flex={1} spacing={1} maxW="full" onClick={stopPropagating}>
{collectedVariableId && (
<CollectVariableLabel
variableId={collectedVariableId}
variables={typebot?.variables ?? []}
/>
)}
<PlaceholderNode <PlaceholderNode
isVisible={showPlaceholders} isVisible={showPlaceholders}
isExpanded={expandedPlaceholderIndex === 0} isExpanded={expandedPlaceholderIndex === 0}
@@ -221,28 +204,3 @@ const DefaultItemNode = ({ block }: { block: BlockWithItems }) => {
</Flex> </Flex>
) )
} }
const CollectVariableLabel = ({
variableId,
variables,
}: {
variableId: string
variables: Variable[]
}) => {
const textColor = useColorModeValue('gray.600', 'gray.400')
const variableName = variables.find(
(variable) => variable.id === variableId
)?.name
if (!variableName) return null
return (
<HStack fontStyle="italic" spacing={1}>
<Text fontSize="sm" color={textColor}>
Collects
</Text>
<Tag bg="orange.400" color="white" size="sm">
{variableName}
</Tag>
</HStack>
)
}

View File

@@ -1152,7 +1152,17 @@
"type": "string" "type": "string"
}, },
"value": { "value": {
"type": "string" "anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
]
} }
}, },
"required": [ "required": [

View File

@@ -1092,6 +1092,9 @@
}, },
"buttonLabel": { "buttonLabel": {
"type": "string" "type": "string"
},
"dynamicVariableId": {
"type": "string"
} }
}, },
"required": [ "required": [
@@ -2910,7 +2913,17 @@
"type": "string" "type": "string"
}, },
"value": { "value": {
"type": "string", "anyOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"type": "string"
}
}
],
"nullable": true "nullable": true
} }
}, },
@@ -4100,6 +4113,9 @@
}, },
"buttonLabel": { "buttonLabel": {
"type": "string" "type": "string"
},
"dynamicVariableId": {
"type": "string"
} }
}, },
"required": [ "required": [
@@ -4522,15 +4538,25 @@
{ {
"anyOf": [ "anyOf": [
{ {
"type": "string" "anyOf": [
{
"type": "string"
},
{
"type": "number"
}
]
}, },
{ {
"type": "number" "type": "boolean"
} }
] ]
}, },
{ {
"type": "boolean" "type": "array",
"items": {
"type": "string"
}
} }
], ],
"nullable": true "nullable": true
@@ -4606,15 +4632,25 @@
{ {
"anyOf": [ "anyOf": [
{ {
"type": "string" "anyOf": [
{
"type": "string"
},
{
"type": "number"
}
]
}, },
{ {
"type": "number" "type": "boolean"
} }
] ]
}, },
{ {
"type": "boolean" "type": "array",
"items": {
"type": "string"
}
} }
], ],
"nullable": true "nullable": true

View File

@@ -0,0 +1,31 @@
import { deepParseVariable } from '@/features/variables/utils'
import {
SessionState,
VariableWithValue,
ChoiceInputBlock,
ItemType,
} from 'models'
import { isDefined } from 'utils'
export const injectVariableValuesInButtonsInputBlock =
(variables: SessionState['typebot']['variables']) =>
(block: ChoiceInputBlock): ChoiceInputBlock => {
if (block.options.dynamicVariableId) {
const variable = variables.find(
(variable) =>
variable.id === block.options.dynamicVariableId &&
isDefined(variable.value)
) as VariableWithValue | undefined
if (!variable || typeof variable.value === 'string') return block
return {
...block,
items: variable.value.map((item, idx) => ({
id: idx.toString(),
type: ItemType.BUTTON,
blockId: block.id,
content: item,
})),
}
}
return deepParseVariable(variables)(block)
}

View File

@@ -11,11 +11,12 @@ import Stripe from 'stripe'
import { decrypt } from 'utils/api/encryption' import { decrypt } from 'utils/api/encryption'
export const computePaymentInputRuntimeOptions = export const computePaymentInputRuntimeOptions =
(state: SessionState) => (options: PaymentInputOptions) => (state: Pick<SessionState, 'isPreview' | 'typebot'>) =>
(options: PaymentInputOptions) =>
createStripePaymentIntent(state)(options) createStripePaymentIntent(state)(options)
const createStripePaymentIntent = const createStripePaymentIntent =
(state: SessionState) => (state: Pick<SessionState, 'isPreview' | 'typebot'>) =>
async (options: PaymentInputOptions): Promise<PaymentInputRuntimeOptions> => { async (options: PaymentInputOptions): Promise<PaymentInputRuntimeOptions> => {
const { const {
isPreview, isPreview,

View File

@@ -53,24 +53,21 @@ export const getRow = async (
}) })
return { outgoingEdgeId, logs: log ? [log] : undefined } return { outgoingEdgeId, logs: log ? [log] : undefined }
} }
const randomIndex = Math.floor(Math.random() * filteredRows.length)
const extractingColumns = cellsToExtract const extractingColumns = cellsToExtract
.map((cell) => cell.column) .map((cell) => cell.column)
.filter(isNotEmpty) .filter(isNotEmpty)
const selectedRow = filteredRows const selectedRows = filteredRows.map((row) =>
.map((row) => extractingColumns.reduce<{ [key: string]: string }>(
extractingColumns.reduce<{ [key: string]: string }>( (obj, column) => ({ ...obj, [column]: row[column] }),
(obj, column) => ({ ...obj, [column]: row[column] }), {}
{}
)
) )
.at(randomIndex) )
if (!selectedRow) return { outgoingEdgeId } if (!selectedRows) return { outgoingEdgeId }
const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>( const newVariables = options.cellsToExtract.reduce<VariableWithValue[]>(
(newVariables, cell) => { (newVariables, cell) => {
const existingVariable = variables.find(byId(cell.variableId)) const existingVariable = variables.find(byId(cell.variableId))
const value = selectedRow[cell.column ?? ''] ?? null const value = selectedRows.map((row) => row[cell.column ?? ''])
if (!existingVariable) return newVariables if (!existingVariable) return newVariables
return [ return [
...newVariables, ...newVariables,

View File

@@ -71,7 +71,7 @@ const sendEmail = async ({
}: SendEmailOptions & { }: SendEmailOptions & {
typebotId: string typebotId: string
resultId?: string resultId?: string
fileUrls?: string fileUrls?: string | string[]
}) => { }) => {
const { name: replyToName } = parseEmailRecipient(replyTo) const { name: replyToName } = parseEmailRecipient(replyTo)
@@ -121,7 +121,11 @@ const sendEmail = async ({
to: recipients, to: recipients,
replyTo, replyTo,
subject, subject,
attachments: fileUrls?.split(', ').map((url) => ({ path: url })), attachments: fileUrls
? (typeof fileUrls === 'string' ? fileUrls.split(', ') : fileUrls).map(
(url) => ({ path: url })
)
: undefined,
...emailBody, ...emailBody,
} }
try { try {

View File

@@ -50,8 +50,8 @@ export const executeWebhookBlock = async (
return { outgoingEdgeId: block.outgoingEdgeId, logs: [log] } return { outgoingEdgeId: block.outgoingEdgeId, logs: [log] }
} }
const preparedWebhook = prepareWebhookAttributes(webhook, block.options) const preparedWebhook = prepareWebhookAttributes(webhook, block.options)
const resultValues = result && (await getResultValues(result.id)) const resultValues =
if (!resultValues) return { outgoingEdgeId: block.outgoingEdgeId } (result && (await getResultValues(result.id))) ?? undefined
const webhookResponse = await executeWebhook({ typebot })( const webhookResponse = await executeWebhook({ typebot })(
preparedWebhook, preparedWebhook,
typebot.variables, typebot.variables,
@@ -139,8 +139,8 @@ export const executeWebhook =
webhook: Webhook, webhook: Webhook,
variables: Variable[], variables: Variable[],
groupId: string, groupId: string,
resultValues: ResultValues, resultValues?: ResultValues,
resultId: string resultId?: string
): Promise<WebhookResponse> => { ): Promise<WebhookResponse> => {
if (!webhook.url || !webhook.method) if (!webhook.url || !webhook.method)
return { return {

View File

@@ -83,7 +83,7 @@ const parseResultSample = (
headerCells: ResultHeaderCell[], headerCells: ResultHeaderCell[],
variables: Variable[] variables: Variable[]
) => ) =>
headerCells.reduce<Record<string, string | boolean | undefined>>( headerCells.reduce<Record<string, string | string[] | undefined>>(
(resultSample, cell) => { (resultSample, cell) => {
const inputBlock = inputBlocks.find((inputBlock) => const inputBlock = inputBlocks.find((inputBlock) =>
cell.blocks?.some((block) => block.id === inputBlock.id) cell.blocks?.some((block) => block.id === inputBlock.id)

View File

@@ -34,7 +34,9 @@ const executeComparison =
if (!comparison?.variableId) return false if (!comparison?.variableId) return false
const inputValue = ( const inputValue = (
variables.find((v) => v.id === comparison.variableId)?.value ?? '' variables.find((v) => v.id === comparison.variableId)?.value ?? ''
).trim() )
.toString()
.trim()
const value = parseVariables(variables)(comparison.value).trim() const value = parseVariables(variables)(comparison.value).trim()
if (isNotDefined(value)) return false if (isNotDefined(value)) return false
switch (comparison.comparisonOperator) { switch (comparison.comparisonOperator) {

View File

@@ -232,7 +232,8 @@ export const isReplyValid = (inputValue: string, block: Block): boolean => {
case InputBlockType.URL: case InputBlockType.URL:
return validateUrl(inputValue) return validateUrl(inputValue)
case InputBlockType.CHOICE: case InputBlockType.CHOICE:
if (block.options.isMultipleChoice) return true if (block.options.isMultipleChoice || block.options.dynamicVariableId)
return true
return validateButtonInput(block, inputValue) return validateButtonInput(block, inputValue)
} }
return true return true

View File

@@ -20,6 +20,7 @@ import { executeLogic } from './executeLogic'
import { getNextGroup } from './getNextGroup' import { getNextGroup } from './getNextGroup'
import { executeIntegration } from './executeIntegration' import { executeIntegration } from './executeIntegration'
import { computePaymentInputRuntimeOptions } from '@/features/blocks/inputs/payment/api' import { computePaymentInputRuntimeOptions } from '@/features/blocks/inputs/payment/api'
import { injectVariableValuesInButtonsInputBlock } from '@/features/blocks/inputs/buttons/api/utils/injectVariableValuesInButtonsInputBlock'
export const executeGroup = export const executeGroup =
(state: SessionState, currentReply?: ChatReply) => (state: SessionState, currentReply?: ChatReply) =>
@@ -49,13 +50,7 @@ export const executeGroup =
if (isInputBlock(block)) if (isInputBlock(block))
return { return {
messages, messages,
input: deepParseVariable(newSessionState.typebot.variables)({ input: await injectVariablesValueInBlock(newSessionState)(block),
...block,
runtimeOptions: await computeRuntimeOptions(newSessionState)(block),
prefilledValue: getPrefilledInputValue(
newSessionState.typebot.variables
)(block),
}),
newSessionState: { newSessionState: {
...newSessionState, ...newSessionState,
currentBlock: { currentBlock: {
@@ -110,7 +105,7 @@ export const executeGroup =
} }
const computeRuntimeOptions = const computeRuntimeOptions =
(state: SessionState) => (state: Pick<SessionState, 'isPreview' | 'typebot'>) =>
(block: InputBlock): Promise<RuntimeOptions> | undefined => { (block: InputBlock): Promise<RuntimeOptions> | undefined => {
switch (block.type) { switch (block.type) {
case InputBlockType.PAYMENT: { case InputBlockType.PAYMENT: {
@@ -122,10 +117,13 @@ const computeRuntimeOptions =
const getPrefilledInputValue = const getPrefilledInputValue =
(variables: SessionState['typebot']['variables']) => (block: InputBlock) => { (variables: SessionState['typebot']['variables']) => (block: InputBlock) => {
return ( return (
variables.find( variables
(variable) => .find(
variable.id === block.options.variableId && isDefined(variable.value) (variable) =>
)?.value ?? undefined variable.id === block.options.variableId &&
isDefined(variable.value)
)
?.value?.toString() ?? undefined
) )
} }
@@ -150,3 +148,24 @@ const parseBubbleBlock =
return deepParseVariable(variables)(block) return deepParseVariable(variables)(block)
} }
} }
const injectVariablesValueInBlock =
(state: Pick<SessionState, 'isPreview' | 'typebot'>) =>
async (block: InputBlock): Promise<ChatReply['input']> => {
switch (block.type) {
case InputBlockType.CHOICE: {
return injectVariableValuesInButtonsInputBlock(state.typebot.variables)(
block
)
}
default: {
return deepParseVariable(state.typebot.variables)({
...block,
runtimeOptions: await computeRuntimeOptions(state)(block),
prefilledValue: getPrefilledInputValue(state.typebot.variables)(
block
),
})
}
}
}

View File

@@ -33,7 +33,10 @@ export const parseVariables =
if (!variable) return '' if (!variable) return ''
if (options.fieldToParse === 'id') return variable.id if (options.fieldToParse === 'id') return variable.id
const { value } = variable const { value } = variable
if (options.escapeForJson) return jsonParse(value) if (options.escapeForJson)
return jsonParse(
typeof value !== 'string' ? JSON.stringify(value) : value
)
const parsedValue = safeStringify(value) const parsedValue = safeStringify(value)
if (!parsedValue) return '' if (!parsedValue) return ''
return parsedValue return parsedValue
@@ -67,9 +70,10 @@ export const safeStringify = (val: unknown): string | null => {
export const parseCorrectValueType = ( export const parseCorrectValueType = (
value: Variable['value'] value: Variable['value']
): string | boolean | number | null | undefined => { ): string | string[] | boolean | number | null | undefined => {
if (value === null) return null if (value === null) return null
if (value === undefined) return undefined if (value === undefined) return undefined
if (typeof value !== 'string') return value
const isNumberStartingWithZero = const isNumberStartingWithZero =
value.startsWith('0') && !value.startsWith('0.') && value.length > 1 value.startsWith('0') && !value.startsWith('0.') && value.length > 1
if (typeof value === 'string' && isNumberStartingWithZero) return value if (typeof value === 'string' && isNumberStartingWithZero) return value
@@ -152,7 +156,9 @@ const updateResultVariables =
if (!result) return [] if (!result) return []
const serializedNewVariables = newVariables.map((variable) => ({ const serializedNewVariables = newVariables.map((variable) => ({
...variable, ...variable,
value: safeStringify(variable.value), value: Array.isArray(variable.value)
? variable.value.map(safeStringify).filter(isDefined)
: safeStringify(variable.value),
})) }))
const updatedVariables = [ const updatedVariables = [
@@ -181,7 +187,9 @@ const updateTypebotVariables =
(newVariables: VariableWithUnknowValue[]): Variable[] => { (newVariables: VariableWithUnknowValue[]): Variable[] => {
const serializedNewVariables = newVariables.map((variable) => ({ const serializedNewVariables = newVariables.map((variable) => ({
...variable, ...variable,
value: safeStringify(variable.value), value: Array.isArray(variable.value)
? variable.value.map(safeStringify).filter(isDefined)
: safeStringify(variable.value),
})) }))
return [ return [

View File

@@ -29,7 +29,9 @@ const executeComparison =
if (!comparison?.variableId) return false if (!comparison?.variableId) return false
const inputValue = ( const inputValue = (
variables.find((v) => v.id === comparison.variableId)?.value ?? '' variables.find((v) => v.id === comparison.variableId)?.value ?? ''
).trim() )
.toString()
.trim()
const value = parseVariables(variables)(comparison.value).trim() const value = parseVariables(variables)(comparison.value).trim()
if (isNotDefined(value) || !comparison.comparisonOperator) return false if (isNotDefined(value) || !comparison.comparisonOperator) return false
return matchComparison(inputValue, comparison.comparisonOperator, value) return matchComparison(inputValue, comparison.comparisonOperator, value)

View File

@@ -25,7 +25,10 @@ export const parseVariables =
if (!variable) return '' if (!variable) return ''
if (options.fieldToParse === 'id') return variable.id if (options.fieldToParse === 'id') return variable.id
const { value } = variable const { value } = variable
if (options.escapeForJson) return jsonParse(value) if (options.escapeForJson)
return typeof value === 'string'
? jsonParse(value)
: jsonParse(JSON.stringify(value))
const parsedValue = safeStringify(value) const parsedValue = safeStringify(value)
if (!parsedValue) return '' if (!parsedValue) return ''
return parsedValue return parsedValue
@@ -45,9 +48,10 @@ export const safeStringify = (val: unknown): string | null => {
export const parseCorrectValueType = ( export const parseCorrectValueType = (
value: Variable['value'] value: Variable['value']
): string | boolean | number | null | undefined => { ): string | string[] | boolean | number | null | undefined => {
if (value === null) return null if (value === null) return null
if (value === undefined) return undefined if (value === undefined) return undefined
if (Array.isArray(value)) return value
if (typeof value === 'number') return value if (typeof value === 'number') return value
if (value === 'true') return true if (value === 'true') return true
if (value === 'false') return false if (value === 'false') return false

View File

@@ -9,6 +9,7 @@ export const choiceInputOptionsSchema = optionBaseSchema.and(
z.object({ z.object({
isMultipleChoice: z.boolean(), isMultipleChoice: z.boolean(),
buttonLabel: z.string(), buttonLabel: z.string(),
dynamicVariableId: z.string().optional(),
}) })
) )

View File

@@ -106,7 +106,12 @@ const scriptToExecuteSchema = z.object({
args: z.array( args: z.array(
z.object({ z.object({
id: z.string(), id: z.string(),
value: z.string().or(z.number()).or(z.boolean()).nullish(), value: z
.string()
.or(z.number())
.or(z.boolean())
.or(z.array(z.string()))
.nullish(),
}) })
), ),
}) })

View File

@@ -3,7 +3,7 @@ import { z } from 'zod'
export const variableSchema = z.object({ export const variableSchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
value: z.string().nullish(), value: z.string().or(z.array(z.string())).nullish(),
}) })
/** /**
@@ -12,7 +12,7 @@ export const variableSchema = z.object({
export const variableWithValueSchema = z.object({ export const variableWithValueSchema = z.object({
id: z.string(), id: z.string(),
name: z.string(), name: z.string(),
value: z.string(), value: z.string().or(z.array(z.string())),
}) })
/** /**

View File

@@ -235,7 +235,7 @@ export const parseAnswers =
if (isVariable) { if (isVariable) {
const variable = answerOrVariable as VariableWithValue const variable = answerOrVariable as VariableWithValue
if (variable.value === null) return o if (variable.value === null) return o
return { ...o, [variable.name]: variable.value } return { ...o, [variable.name]: variable.value.toString() }
} }
const answer = answerOrVariable as Answer const answer = answerOrVariable as Answer
const key = answer.variableId const key = answer.variableId