2
0

feat(engine): Improve variables in executed codes

This commit is contained in:
Baptiste Arnaud
2022-03-31 16:41:18 +02:00
parent 82f7bf0ed6
commit db10f1ee89
11 changed files with 155 additions and 60 deletions

View File

@ -1,4 +1,4 @@
import { Box, BoxProps } from '@chakra-ui/react' import { Box, BoxProps, HStack } from '@chakra-ui/react'
import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup' import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'
import { json, jsonParseLinter } from '@codemirror/lang-json' import { json, jsonParseLinter } from '@codemirror/lang-json'
import { css } from '@codemirror/lang-css' import { css } from '@codemirror/lang-css'
@ -7,6 +7,8 @@ import { html } from '@codemirror/lang-html'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce' import { useDebouncedCallback } from 'use-debounce'
import { linter } from '@codemirror/lint' import { linter } from '@codemirror/lint'
import { VariablesButton } from './buttons/VariablesButton'
import { Variable } from 'models'
const linterExtension = linter(jsonParseLinter()) const linterExtension = linter(jsonParseLinter())
@ -15,12 +17,14 @@ type Props = {
lang?: 'css' | 'json' | 'js' | 'html' lang?: 'css' | 'json' | 'js' | 'html'
isReadOnly?: boolean isReadOnly?: boolean
debounceTimeout?: number debounceTimeout?: number
withVariableButton?: boolean
onChange?: (value: string) => void onChange?: (value: string) => void
} }
export const CodeEditor = ({ export const CodeEditor = ({
value, value,
lang, lang,
onChange, onChange,
withVariableButton = true,
isReadOnly = false, isReadOnly = false,
debounceTimeout = 1000, debounceTimeout = 1000,
...props ...props
@ -28,6 +32,9 @@ export const CodeEditor = ({
const editorContainer = useRef<HTMLDivElement | null>(null) const editorContainer = useRef<HTMLDivElement | null>(null)
const editorView = useRef<EditorView | null>(null) const editorView = useRef<EditorView | null>(null)
const [, setPlainTextValue] = useState(value) const [, setPlainTextValue] = useState(value)
const [carretPosition, setCarretPosition] = useState<number>(0)
const isVariableButtonDisplayed = withVariableButton && !isReadOnly
const debounced = useDebouncedCallback( const debounced = useDebouncedCallback(
(value) => { (value) => {
setPlainTextValue(value) setPlainTextValue(value)
@ -101,5 +108,35 @@ export const CodeEditor = ({
return editor return editor
} }
return <Box ref={editorContainer} data-testid="code-editor" {...props} /> const handleVariableSelected = (variable?: Pick<Variable, 'id' | 'name'>) => {
editorView.current?.focus()
const insert = `{{${variable?.name}}}`
editorView.current?.dispatch({
changes: {
from: carretPosition,
insert,
},
selection: { anchor: carretPosition + insert.length },
})
}
const handleKeyUp = () => {
if (!editorContainer.current) return
setCarretPosition(editorView.current?.state.selection.main.from ?? 0)
}
return (
<HStack align="flex-end" spacing={0}>
<Box
w={isVariableButtonDisplayed ? 'calc(100% - 32px)' : '100%'}
ref={editorContainer}
data-testid="code-editor"
{...props}
onKeyUp={handleKeyUp}
/>
{isVariableButtonDisplayed && (
<VariablesButton onSelectVariable={handleVariableSelected} size="sm" />
)}
</HStack>
)
} }

View File

@ -144,7 +144,7 @@ export const BlockNode = ({ block, blockIndex }: Props) => {
pointerEvents={isReadOnly || isStartBlock ? 'none' : 'auto'} pointerEvents={isReadOnly || isStartBlock ? 'none' : 'auto'}
> >
<EditablePreview <EditablePreview
_hover={{ bgColor: 'gray.300' }} _hover={{ bgColor: 'gray.200' }}
px="1" px="1"
userSelect={'none'} userSelect={'none'}
/> />

View File

@ -1,4 +1,5 @@
import { FormLabel, Stack } from '@chakra-ui/react' import { FormLabel, HStack, Stack, Switch, Text } from '@chakra-ui/react'
import { CodeEditor } from 'components/shared/CodeEditor'
import { Textarea } from 'components/shared/Textbox' import { Textarea } from 'components/shared/Textbox'
import { VariableSearchInput } from 'components/shared/VariableSearchInput' import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { SetVariableOptions, Variable } from 'models' import { SetVariableOptions, Variable } from 'models'
@ -14,6 +15,11 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
onOptionsChange({ ...options, variableId: variable?.id }) onOptionsChange({ ...options, variableId: variable?.id })
const handleExpressionChange = (expressionToEvaluate: string) => const handleExpressionChange = (expressionToEvaluate: string) =>
onOptionsChange({ ...options, expressionToEvaluate }) onOptionsChange({ ...options, expressionToEvaluate })
const handleValueTypeChange = () =>
onOptionsChange({
...options,
isCode: options.isCode ? !options.isCode : true,
})
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
@ -28,14 +34,34 @@ export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
/> />
</Stack> </Stack>
<Stack> <Stack>
<FormLabel mb="0" htmlFor="expression"> <HStack justify="space-between">
Value / Expression: <FormLabel mb="0" htmlFor="expression">
</FormLabel> Value:
<Textarea </FormLabel>
id="expression" <HStack>
defaultValue={options.expressionToEvaluate ?? ''} <Text fontSize="sm">Text</Text>
onChange={handleExpressionChange} <Switch
/> size="sm"
isChecked={options.isCode ?? false}
onChange={handleValueTypeChange}
/>
<Text fontSize="sm">Code</Text>
</HStack>
</HStack>
{options.isCode ?? false ? (
<CodeEditor
value={options.expressionToEvaluate ?? ''}
onChange={handleExpressionChange}
lang="js"
/>
) : (
<Textarea
id="expression"
defaultValue={options.expressionToEvaluate ?? ''}
onChange={handleExpressionChange}
/>
)}
</Stack> </Stack>
</Stack> </Stack>
) )

View File

@ -23,7 +23,6 @@ export const BlocksDropdown = ({
) )
const handleBlockSelect = (title: string) => { const handleBlockSelect = (title: string) => {
console.log(title)
const id = blocks?.find((b) => b.title === title)?.id const id = blocks?.find((b) => b.title === title)?.id
if (id) onBlockIdSelected(id) if (id) onBlockIdSelected(id)
} }

View File

@ -1,20 +1,13 @@
import { import {
ComponentWithAs, ComponentWithAs,
Flex,
HStack, HStack,
IconButton,
InputProps, InputProps,
Popover,
PopoverContent,
PopoverTrigger,
TextareaProps, TextareaProps,
Tooltip,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { UserIcon } from 'assets/icons'
import { Variable } from 'models' import { Variable } from 'models'
import React, { ChangeEvent, useEffect, useRef, useState } from 'react' import React, { ChangeEvent, useEffect, useRef, useState } from 'react'
import { useDebouncedCallback } from 'use-debounce' import { useDebouncedCallback } from 'use-debounce'
import { VariableSearchInput } from '../VariableSearchInput' import { VariablesButton } from '../buttons/VariablesButton'
export type TextBoxProps = { export type TextBoxProps = {
onChange: (value: string) => void onChange: (value: string) => void
@ -120,27 +113,7 @@ export const TextBox = ({
bgColor={'white'} bgColor={'white'}
{...props} {...props}
/> />
<Popover matchWidth isLazy> <VariablesButton onSelectVariable={handleVariableSelected} />
<PopoverTrigger>
<Flex>
<Tooltip label="Insert a variable">
<IconButton
aria-label="Insert a variable"
icon={<UserIcon />}
pos="relative"
/>
</Tooltip>
</Flex>
</PopoverTrigger>
<PopoverContent w="full">
<VariableSearchInput
onSelectVariable={handleVariableSelected}
placeholder="Search for a variable"
shadow="lg"
isDefaultOpen
/>
</PopoverContent>
</Popover>
</HStack> </HStack>
) )
} }

View File

@ -0,0 +1,46 @@
import {
Popover,
PopoverTrigger,
Flex,
Tooltip,
IconButton,
PopoverContent,
IconButtonProps,
} from '@chakra-ui/react'
import { UserIcon } from 'assets/icons'
import { Variable } from 'models'
import React from 'react'
import { VariableSearchInput } from '../VariableSearchInput'
type Props = {
onSelectVariable: (
variable: Pick<Variable, 'name' | 'id'> | undefined
) => void
} & Omit<IconButtonProps, 'aria-label'>
export const VariablesButton = ({ onSelectVariable, ...props }: Props) => {
return (
<Popover matchWidth isLazy>
<PopoverTrigger>
<Flex>
<Tooltip label="Insert a variable">
<IconButton
aria-label={'Insert a variable'}
icon={<UserIcon />}
pos="relative"
{...props}
/>
</Tooltip>
</Flex>
</PopoverTrigger>
<PopoverContent w="full">
<VariableSearchInput
onSelectVariable={onSelectVariable}
placeholder="Search for a variable"
shadow="lg"
isDefaultOpen
/>
</PopoverContent>
</Popover>
)
}

View File

@ -98,7 +98,7 @@
"blockId": "cl1267q1z000d2e6d949f2ge4", "blockId": "cl1267q1z000d2e6d949f2ge4",
"options": { "options": {
"name": "Store Name in DB", "name": "Store Name in DB",
"content": "postMessage({from: \"typebot\", action: \"storeName\", content: \"{{Name}}\"}, \"*\")" "content": "postMessage({from: \"typebot\", action: \"storeName\", content: {{Name}}}, \"*\")"
}, },
"outgoingEdgeId": "cl12bk56s000d2e69oll3nqxm" "outgoingEdgeId": "cl12bk56s000d2e69oll3nqxm"
} }
@ -239,7 +239,7 @@
"blockId": "cl126jioj000u2e6dqssno3hv", "blockId": "cl126jioj000u2e6dqssno3hv",
"options": { "options": {
"name": "Store company in DB", "name": "Store company in DB",
"content": "postMessage({from: \"typebot\", action: \"storeCompany\", content: \"{{Company}}\"}, \"*\")" "content": "postMessage({from: \"typebot\", action: \"storeCompany\", content: {{Company}}}, \"*\")"
}, },
"outgoingEdgeId": "cl128ag8i00162e6dufv3tgo0" "outgoingEdgeId": "cl128ag8i00162e6dufv3tgo0"
} }
@ -335,7 +335,7 @@
"blockId": "cl126krbp00102e6dnjelmfa1", "blockId": "cl126krbp00102e6dnjelmfa1",
"options": { "options": {
"name": "Store categories in DB", "name": "Store categories in DB",
"content": "postMessage({from: \"typebot\", action: \"storeCategories\", content: \"{{Categories}}\"}, \"*\")" "content": "postMessage({from: \"typebot\", action: \"storeCategories\", content: {{Categories}}}, \"*\")"
}, },
"outgoingEdgeId": "cl128azam00182e6dct61k7v5" "outgoingEdgeId": "cl128azam00182e6dct61k7v5"
} }
@ -458,7 +458,7 @@
"blockId": "cl126pv6w001n2e6dp0qkvthu", "blockId": "cl126pv6w001n2e6dp0qkvthu",
"options": { "options": {
"name": "Store Other categories in DB", "name": "Store Other categories in DB",
"content": "postMessage({from: \"typebot\", action: \"storeOtherCategories\", content: \"{{Other categories}}\"}, \"*\")" "content": "postMessage({from: \"typebot\", action: \"storeOtherCategories\", content: {{Other categories}}}, \"*\")"
}, },
"outgoingEdgeId": "cl128c0fu001a2e6droq69g6z" "outgoingEdgeId": "cl128c0fu001a2e6droq69g6z"
} }

View File

@ -247,9 +247,8 @@ const executeWebhook = async (
if (!varMapping?.bodyPath || !varMapping.variableId) return newVariables if (!varMapping?.bodyPath || !varMapping.variableId) return newVariables
const existingVariable = variables.find(byId(varMapping.variableId)) const existingVariable = variables.find(byId(varMapping.variableId))
if (!existingVariable) return newVariables if (!existingVariable) return newVariables
const value = Function( const func = Function('data', `return data.${varMapping?.bodyPath}`)
`return (${JSON.stringify(data)}).${varMapping?.bodyPath}` const value = func(JSON.stringify(data))
)()
updateVariableValue(existingVariable?.id, value) updateVariableValue(existingVariable?.id, value)
return [...newVariables, { ...existingVariable, value }] return [...newVariables, { ...existingVariable, value }]
}, []) }, [])

View File

@ -63,9 +63,8 @@ const executeSetVariable = (
): EdgeId | undefined => { ): EdgeId | undefined => {
if (!step.options?.variableId || !step.options.expressionToEvaluate) if (!step.options?.variableId || !step.options.expressionToEvaluate)
return step.outgoingEdgeId return step.outgoingEdgeId
const expression = step.options.expressionToEvaluate const evaluatedExpression = evaluateExpression(variables)(
const evaluatedExpression = evaluateExpression( step.options.expressionToEvaluate
parseVariables(variables)(expression)
) )
const existingVariable = variables.find(byId(step.options.variableId)) const existingVariable = variables.find(byId(step.options.variableId))
if (!existingVariable) return step.outgoingEdgeId if (!existingVariable) return step.outgoingEdgeId
@ -132,7 +131,11 @@ const executeCode = async (
{ typebot: { variables } }: LogicContext { typebot: { variables } }: LogicContext
) => { ) => {
if (!step.options.content) return if (!step.options.content) return
await Function(parseVariables(variables)(step.options.content))() const func = Function(
...variables.map((v) => v.id),
parseVariables(variables, { fieldToParse: 'id' })(step.options.content)
)
await func(...variables.map((v) => v.value))
return step.outgoingEdgeId return step.outgoingEdgeId
} }

View File

@ -5,22 +5,33 @@ export const stringContainsVariable = (str: string): boolean =>
/\{\{(.*?)\}\}/g.test(str) /\{\{(.*?)\}\}/g.test(str)
export const parseVariables = export const parseVariables =
(variables: Variable[]) => (
(text?: string): string => { variables: Variable[],
options: { fieldToParse: 'value' | 'id' } = { fieldToParse: 'value' }
) =>
(text: string | undefined): string => {
if (!text || text === '') return '' if (!text || text === '') return ''
return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => { return text.replace(/\{\{(.*?)\}\}/g, (_, fullVariableString) => {
const matchedVarName = fullVariableString.replace(/{{|}}/g, '') const matchedVarName = fullVariableString.replace(/{{|}}/g, '')
const variable = variables.find((v) => {
return matchedVarName === v.name && isDefined(v.value)
})
if (!variable) return ''
return ( return (
variables.find((v) => { (options.fieldToParse === 'value' ? variable.value : variable.id) || ''
return matchedVarName === v.name && isDefined(v.value)
})?.value ?? ''
) )
}) })
} }
export const evaluateExpression = (str: string) => { export const evaluateExpression = (variables: Variable[]) => (str: string) => {
try { try {
const evaluatedResult = Function('return ' + str)() const func = Function(
...variables.map((v) => v.id),
parseVariables(variables, { fieldToParse: 'id' })(
str.includes('return ') ? str : `return ${str}`
)
)
const evaluatedResult = func(...variables.map((v) => v.value))
return isNotDefined(evaluatedResult) ? '' : evaluatedResult.toString() return isNotDefined(evaluatedResult) ? '' : evaluatedResult.toString()
} catch (err) { } catch (err) {
console.log(err) console.log(err)

View File

@ -81,6 +81,7 @@ export type Comparison = {
export type SetVariableOptions = { export type SetVariableOptions = {
variableId?: string variableId?: string
expressionToEvaluate?: string expressionToEvaluate?: string
isCode?: boolean
} }
export type RedirectOptions = { export type RedirectOptions = {