2
0

(setVariable) Add client-side set variable execution

Closes #461
This commit is contained in:
Baptiste Arnaud
2023-04-14 12:11:42 +02:00
parent 397a33afc6
commit 03cc067418
17 changed files with 207 additions and 69 deletions

View File

@ -42,7 +42,6 @@ export const UnsplashPicker = ({ imageSize, onImageSelect }: Props) => {
const [nextPage, setNextPage] = useState(0)
const fetchNewImages = useCallback(async (query: string, page: number) => {
console.log('Fetch images', query, page)
if (query === '') return searchRandomImages()
if (query.length <= 2) return
setError(null)
@ -76,7 +75,6 @@ export const UnsplashPicker = ({ imageSize, onImageSelect }: Props) => {
if (!bottomAnchor.current) return
const observer = new IntersectionObserver(
(entities: IntersectionObserverEntry[]) => {
console.log('Intersection observer', entities)
const target = entities[0]
if (target.isIntersecting) fetchNewImages(searchQuery, nextPage + 1)
},

View File

@ -4,6 +4,7 @@ import { SetVariableOptions, Variable } from '@typebot.io/schemas'
import React from 'react'
import { VariableSearchInput } from '@/components/inputs/VariableSearchInput'
import { Textarea } from '@/components/inputs'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
type Props = {
options: SetVariableOptions
@ -11,16 +12,24 @@ type Props = {
}
export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
const handleVariableChange = (variable?: Variable) =>
const updateVariableId = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleExpressionChange = (expressionToEvaluate: string) =>
const updateExpression = (expressionToEvaluate: string) =>
onOptionsChange({ ...options, expressionToEvaluate })
const handleValueTypeChange = () =>
const updateExpressionType = () =>
onOptionsChange({
...options,
isCode: options.isCode ? !options.isCode : true,
})
const updateClientExecution = (isExecutedOnClient: boolean) =>
onOptionsChange({
...options,
isExecutedOnClient,
})
return (
<Stack spacing={4}>
<Stack>
@ -28,7 +37,7 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
Search or create variable:
</FormLabel>
<VariableSearchInput
onSelectVariable={handleVariableChange}
onSelectVariable={updateVariableId}
initialVariableId={options.variableId}
id="variable-search"
/>
@ -43,7 +52,7 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
<Switch
size="sm"
isChecked={options.isCode ?? false}
onChange={handleValueTypeChange}
onChange={updateExpressionType}
/>
<Text fontSize="sm">Code</Text>
</HStack>
@ -52,17 +61,23 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
{options.isCode ?? false ? (
<CodeEditor
defaultValue={options.expressionToEvaluate ?? ''}
onChange={handleExpressionChange}
onChange={updateExpression}
lang="javascript"
/>
) : (
<Textarea
id="expression"
defaultValue={options.expressionToEvaluate ?? ''}
onChange={handleExpressionChange}
onChange={updateExpression}
/>
)}
</Stack>
<SwitchWithLabel
label="Execute on client?"
moreInfoContent="Check this if you need access to client-only variables like `window` or `document`."
initialValue={options.isExecutedOnClient ?? false}
onCheckChange={updateClientExecution}
/>
</Stack>
)
}

View File

@ -2,7 +2,7 @@ import { ExecuteLogicResponse } from '@/features/chat/types'
import { extractVariablesFromText } from '@/features/variables/extractVariablesFromText'
import { parseGuessedValueType } from '@/features/variables/parseGuessedValueType'
import { parseVariables } from '@/features/variables/parseVariables'
import { ScriptBlock, SessionState } from '@typebot.io/schemas'
import { ScriptBlock, SessionState, Variable } from '@typebot.io/schemas'
export const executeScript = (
{ typebot: { variables } }: SessionState,
@ -11,26 +11,37 @@ export const executeScript = (
): ExecuteLogicResponse => {
if (!block.options.content) return { outgoingEdgeId: block.outgoingEdgeId }
const content = parseVariables(variables, { fieldToParse: 'id' })(
const scriptToExecute = parseScriptToExecuteClientSideAction(
variables,
block.options.content
)
const args = extractVariablesFromText(variables)(block.options.content).map(
(variable) => ({
id: variable.id,
value: parseGuessedValueType(variable.value),
})
)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
scriptToExecute: {
content,
args,
},
scriptToExecute: scriptToExecute,
lastBubbleBlockId,
},
],
}
}
export const parseScriptToExecuteClientSideAction = (
variables: Variable[],
contentToEvaluate: string
) => {
const content = parseVariables(variables, { fieldToParse: 'id' })(
contentToEvaluate
)
const args = extractVariablesFromText(variables)(contentToEvaluate).map(
(variable) => ({
id: variable.id,
value: parseGuessedValueType(variable.value),
})
)
return {
content,
args,
}
}

View File

@ -4,16 +4,35 @@ import { ExecuteLogicResponse } from '@/features/chat/types'
import { updateVariables } from '@/features/variables/updateVariables'
import { parseVariables } from '@/features/variables/parseVariables'
import { parseGuessedValueType } from '@/features/variables/parseGuessedValueType'
import { parseScriptToExecuteClientSideAction } from '../script/executeScript'
export const executeSetVariable = async (
state: SessionState,
block: SetVariableBlock
block: SetVariableBlock,
lastBubbleBlockId?: string
): Promise<ExecuteLogicResponse> => {
const { variables } = state.typebot
if (!block.options?.variableId)
return {
outgoingEdgeId: block.outgoingEdgeId,
}
if (block.options.isExecutedOnClient && block.options.expressionToEvaluate) {
const scriptToExecute = parseScriptToExecuteClientSideAction(
state.typebot.variables,
block.options.expressionToEvaluate
)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
setVariable: {
scriptToExecute,
},
lastBubbleBlockId,
},
],
}
}
const evaluatedExpression = block.options.expressionToEvaluate
? evaluateSetVariableExpression(variables)(
block.options.expressionToEvaluate

View File

@ -84,8 +84,13 @@ export const sendMessage = publicProcedure
},
})
const containsSetVariableClientSideAction = clientSideActions?.some(
(action) => 'setVariable' in action
)
if (
!input &&
!containsSetVariableClientSideAction &&
session.state.result.answers.length > 0 &&
session.state.result.id
)
@ -149,42 +154,33 @@ const startSession = async (startParams?: StartParams, userId?: string) => {
dynamicTheme: parseDynamicThemeInState(typebot.theme),
}
const {
messages,
input,
clientSideActions,
newSessionState: newInitialState,
logs,
} = await startBotFlow(initialState, startParams.startGroupId)
const { messages, input, clientSideActions, newSessionState, logs } =
await startBotFlow(initialState, startParams.startGroupId)
if (!input)
const containsSetVariableClientSideAction = clientSideActions?.some(
(action) => 'setVariable' in action
)
if (!input && !containsSetVariableClientSideAction)
return {
messages,
clientSideActions,
typebot: {
id: typebot.id,
settings: deepParseVariables(newInitialState.typebot.variables)(
settings: deepParseVariables(newSessionState.typebot.variables)(
typebot.settings
),
theme: deepParseVariables(newInitialState.typebot.variables)(
theme: deepParseVariables(newSessionState.typebot.variables)(
typebot.theme
),
},
dynamicTheme: parseDynamicThemeReply(newInitialState),
dynamicTheme: parseDynamicThemeReply(newSessionState),
logs,
}
const sessionState: ChatSession['state'] = {
...newInitialState,
currentBlock: {
groupId: input.groupId,
blockId: input.id,
},
}
const session = (await prisma.chatSession.create({
data: {
state: sessionState,
state: newSessionState,
},
})) as ChatSession
@ -193,17 +189,17 @@ const startSession = async (startParams?: StartParams, userId?: string) => {
sessionId: session.id,
typebot: {
id: typebot.id,
settings: deepParseVariables(newInitialState.typebot.variables)(
settings: deepParseVariables(newSessionState.typebot.variables)(
typebot.settings
),
theme: deepParseVariables(newInitialState.typebot.variables)(
theme: deepParseVariables(newSessionState.typebot.variables)(
typebot.theme
),
},
messages,
input,
clientSideActions,
dynamicTheme: parseDynamicThemeReply(newInitialState),
dynamicTheme: parseDynamicThemeReply(newSessionState),
logs,
} satisfies ChatReply
}

View File

@ -9,10 +9,12 @@ import {
ChatReply,
InputBlock,
InputBlockType,
LogicBlockType,
ResultInSession,
SessionState,
SetVariableBlock,
} from '@typebot.io/schemas'
import { isInputBlock, isNotDefined } from '@typebot.io/lib'
import { isInputBlock, isNotDefined, byId, isDefined } from '@typebot.io/lib'
import { executeGroup } from './executeGroup'
import { getNextGroup } from './getNextGroup'
import { validateEmail } from '@/features/blocks/inputs/email/validateEmail'
@ -27,6 +29,7 @@ export const continueBotFlow =
async (
reply?: string
): Promise<ChatReply & { newSessionState?: SessionState }> => {
let newSessionState = { ...state }
const group = state.typebot.groups.find(
(group) => group.id === state.currentBlock?.groupId
)
@ -43,25 +46,37 @@ export const continueBotFlow =
message: 'Current block not found',
})
if (!isInputBlock(block))
if (block.type === LogicBlockType.SET_VARIABLE && isDefined(reply)) {
const existingVariable = state.typebot.variables.find(
byId(block.options.variableId)
)
if (existingVariable) {
const newVariable = {
...existingVariable,
value: reply,
}
newSessionState = await updateVariables(state)([newVariable])
}
} else if (!isInputBlock(block))
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Current block is not an input block',
})
if (reply && !isReplyValid(reply, block)) return parseRetryMessage(block)
let formattedReply = null
const formattedReply = formatReply(reply, block.type)
if (isInputBlock(block)) {
if (reply && !isReplyValid(reply, block)) return parseRetryMessage(block)
if (!formattedReply && !canSkip(block.type)) {
return parseRetryMessage(block)
formattedReply = formatReply(reply, block.type)
if (!formattedReply && !canSkip(block.type)) {
return parseRetryMessage(block)
}
newSessionState = await processAndSaveAnswer(state, block)(formattedReply)
}
const newSessionState = await processAndSaveAnswer(
state,
block
)(formattedReply)
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
const nextEdgeId = getOutgoingEdgeId(newSessionState)(block, formattedReply)
@ -221,7 +236,7 @@ const computeStorageUsed = async (reply: string) => {
const getOutgoingEdgeId =
({ typebot: { variables } }: Pick<SessionState, 'typebot'>) =>
(block: InputBlock, reply: string | null) => {
(block: InputBlock | SetVariableBlock, reply: string | null) => {
if (
block.type === InputBlockType.CHOICE &&
!block.options.isMultipleChoice &&

View File

@ -75,11 +75,31 @@ export const executeGroup =
if (
'clientSideActions' in executionResponse &&
executionResponse.clientSideActions
)
) {
clientSideActions = [
...(clientSideActions ?? []),
...executionResponse.clientSideActions,
]
if (
executionResponse.clientSideActions?.find(
(action) => 'setVariable' in action
)
) {
return {
messages,
newSessionState: {
...newSessionState,
currentBlock: {
groupId: group.id,
blockId: block.id,
},
},
clientSideActions,
logs,
}
}
}
if (executionResponse.logs)
logs = [...(logs ?? []), ...executionResponse.logs]
if (executionResponse.newSessionState)

View File

@ -13,7 +13,7 @@ export const executeLogic =
async (block: LogicBlock): Promise<ExecuteLogicResponse> => {
switch (block.type) {
case LogicBlockType.SET_VARIABLE:
return executeSetVariable(state, block)
return executeSetVariable(state, block, lastBubbleBlockId)
case LogicBlockType.CONDITION:
return executeCondition(state, block)
case LogicBlockType.REDIRECT:

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/js",
"version": "0.0.35",
"version": "0.0.36",
"description": "Javascript library to display typebots on your website",
"type": "module",
"main": "dist/index.js",

View File

@ -2,7 +2,7 @@ import { createSignal, onCleanup, onMount } from 'solid-js'
import { isMobile } from '@/utils/isMobileSignal'
import { Avatar } from '../avatars/Avatar'
type Props = { hostAvatarSrc?: string }
type Props = { hostAvatarSrc?: string; hideAvatar?: boolean }
export const AvatarSideContainer = (props: Props) => {
let avatarContainer: HTMLDivElement | undefined
@ -34,12 +34,13 @@ export const AvatarSideContainer = (props: Props) => {
>
<div
class={
'absolute mb-2 flex items-center top-0 ' +
(isMobile() ? 'w-6 h-6' : 'w-10 h-10')
'absolute mb-2 flex items-center top-0' +
(isMobile() ? ' w-6 h-6' : ' w-10 h-10') +
(props.hideAvatar ? ' opacity-0' : ' opacity-100')
}
style={{
top: `${top()}px`,
transition: 'top 350ms ease-out',
transition: 'top 350ms ease-out, opacity 250ms ease-out',
}}
>
<Avatar initialAvatarSrc={props.hostAvatarSrc} />

View File

@ -13,6 +13,7 @@ type Props = Pick<ChatReply, 'messages' | 'input'> & {
context: BotContext
isLoadingBubbleDisplayed: boolean
hasError: boolean
hideAvatar: boolean
onNewBubbleDisplayed: (blockId: string) => Promise<void>
onScrollToBottom: () => void
onSubmit: (input: string) => void
@ -56,6 +57,7 @@ export const ChatChunk = (props: Props) => {
>
<AvatarSideContainer
hostAvatarSrc={props.theme.chat.hostAvatar?.url}
hideAvatar={props.hideAvatar}
/>
</Show>
<div

View File

@ -70,7 +70,12 @@ export const ConversationContainer = (props: Props) => {
)
for (const action of actionsBeforeFirstBubble) {
const response = await executeClientSideAction(action)
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
if (response && 'replyToSend' in response) {
sendMessage(response.replyToSend)
return
}
if (response && 'blockedPopupUrl' in response)
setBlockedPopupUrl(response.blockedPopupUrl)
}
}
})()
@ -122,7 +127,12 @@ export const ConversationContainer = (props: Props) => {
)
for (const action of actionsBeforeFirstBubble) {
const response = await executeClientSideAction(action)
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
if (response && 'replyToSend' in response) {
sendMessage(response.replyToSend)
return
}
if (response && 'blockedPopupUrl' in response)
setBlockedPopupUrl(response.blockedPopupUrl)
}
}
setChatChunks((displayedChunks) => [
@ -159,7 +169,12 @@ export const ConversationContainer = (props: Props) => {
)
for (const action of actionsToExecute) {
const response = await executeClientSideAction(action)
if (response) setBlockedPopupUrl(response.blockedPopupUrl)
if (response && 'replyToSend' in response) {
sendMessage(response.replyToSend)
return
}
if (response && 'blockedPopupUrl' in response)
setBlockedPopupUrl(response.blockedPopupUrl)
}
}
}
@ -187,6 +202,7 @@ export const ConversationContainer = (props: Props) => {
onSkip={handleSkip}
context={props.context}
hasError={hasError() && index() === chatChunks().length - 1}
hideAvatar={!chatChunk.input && index() < chatChunks().length - 1}
/>
)}
</For>

View File

@ -0,0 +1,33 @@
import { isNotDefined } from '@typebot.io/lib'
import type { ScriptToExecute } from '@typebot.io/schemas'
export const executeSetVariable = async ({
content,
args,
}: ScriptToExecute): Promise<{ replyToSend: string | undefined }> => {
try {
const func = Function(
...args.map((arg) => arg.id),
content.includes('return ') ? content : `return ${content}`
)
const replyToSend = await func(...args.map((arg) => arg.value))
return {
replyToSend: safeStringify(replyToSend),
}
} catch (err) {
return {
replyToSend: safeStringify(content),
}
}
}
export const safeStringify = (val: unknown): string | undefined => {
if (isNotDefined(val)) return
if (typeof val === 'string') return val
try {
return JSON.stringify(val)
} catch {
console.warn('Failed to safely stringify variable value', val)
return
}
}

View File

@ -2,12 +2,15 @@ import { executeChatwoot } from '@/features/blocks/integrations/chatwoot'
import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics/utils/executeGoogleAnalytics'
import { executeRedirect } from '@/features/blocks/logic/redirect'
import { executeScript } from '@/features/blocks/logic/script/executeScript'
import { executeSetVariable } from '@/features/blocks/logic/setVariable/executeSetVariable'
import { executeWait } from '@/features/blocks/logic/wait/utils/executeWait'
import type { ChatReply } from '@typebot.io/schemas'
export const executeClientSideAction = async (
clientSideAction: NonNullable<ChatReply['clientSideActions']>[0]
): Promise<{ blockedPopupUrl: string } | void> => {
): Promise<
{ blockedPopupUrl: string } | { replyToSend: string | undefined } | void
> => {
if ('chatwoot' in clientSideAction) {
return executeChatwoot(clientSideAction.chatwoot)
}
@ -23,4 +26,7 @@ export const executeClientSideAction = async (
if ('wait' in clientSideAction) {
return executeWait(clientSideAction.wait)
}
if ('setVariable' in clientSideAction) {
return executeSetVariable(clientSideAction.setVariable.scriptToExecute)
}
}

View File

@ -1,6 +1,6 @@
{
"name": "@typebot.io/react",
"version": "0.0.35",
"version": "0.0.36",
"description": "React library to display typebots on your website",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@ -6,6 +6,7 @@ export const setVariableOptionsSchema = z.object({
variableId: z.string().optional(),
expressionToEvaluate: z.string().optional(),
isCode: z.boolean().optional(),
isExecutedOnClient: z.boolean().optional(),
})
export const setVariableBlockSchema = blockBaseSchema.merge(

View File

@ -220,6 +220,11 @@ const clientSideActionSchema = z
}),
})
)
.or(
z.object({
setVariable: z.object({ scriptToExecute: scriptToExecuteSchema }),
})
)
)
export const chatReplySchema = z.object({