2
0

feat(integration): Add webhooks

This commit is contained in:
Baptiste Arnaud
2022-01-22 18:24:57 +01:00
parent 66f3e7ee7c
commit a58600a38a
78 changed files with 2399 additions and 800 deletions

View File

@@ -280,3 +280,9 @@ export const FilmIcon = (props: IconProps) => (
<line x1="17" y1="7" x2="22" y2="7"></line>
</Icon>
)
export const WebhookIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</Icon>
)

View File

@@ -0,0 +1,9 @@
.cm-editor {
height: 100%;
outline: 0px solid transparent !important;
}
.cm-scroller {
border-radius: 5px;
border: 1px solid #e5e7eb;
}

View File

@@ -14,6 +14,7 @@ import {
NumberIcon,
PhoneIcon,
TextIcon,
WebhookIcon,
} from 'assets/icons'
import { GoogleAnalyticsLogo, GoogleSheetsLogo } from 'assets/logos'
import {
@@ -59,6 +60,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return <GoogleSheetsLogo {...props} />
case IntegrationStepType.GOOGLE_ANALYTICS:
return <GoogleAnalyticsLogo {...props} />
case IntegrationStepType.WEBHOOK:
return <WebhookIcon />
case 'start':
return <FlagIcon {...props} />
default:

View File

@@ -49,6 +49,8 @@ export const StepTypeLabel = ({ type }: Props) => {
<Text>Analytics</Text>
</Tooltip>
)
case IntegrationStepType.WEBHOOK:
return <Text>Webhook</Text>
default:
return <></>
}

View File

@@ -89,7 +89,7 @@ export const BlockNode = ({ block }: Props) => {
borderColor={
isConnecting || isOpened || isPreviewing ? 'blue.400' : 'white'
}
minW="300px"
w="300px"
transition="border 300ms, box-shadow 200ms"
pos="absolute"
style={{

View File

@@ -16,6 +16,8 @@ import {
Step,
StepOptions,
TextBubbleStep,
Webhook,
WebhookStep,
} from 'models'
import { useRef } from 'react'
import {
@@ -32,6 +34,7 @@ import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { RedirectSettings } from './bodies/RedirectSettings'
import { SetVariableSettingsBody } from './bodies/SetVariableSettingsBody'
import { WebhookSettings } from './bodies/WebhookSettings'
type Props = {
step: Exclude<Step, TextBubbleStep>
@@ -51,7 +54,7 @@ export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => {
<PopoverContent onMouseDown={handleMouseDown} pos="relative">
<PopoverArrow />
<PopoverBody
p="6"
py="6"
overflowY="scroll"
maxH="400px"
ref={ref}
@@ -74,9 +77,16 @@ export const SettingsPopoverContent = ({ step, onExpandClick }: Props) => {
}
export const StepSettings = ({ step }: { step: Step }) => {
const { updateStep } = useTypebot()
const handleOptionsChange = (options: StepOptions) =>
const { updateStep, updateWebhook, typebot } = useTypebot()
const handleOptionsChange = (options: StepOptions) => {
updateStep(step.id, { options } as Partial<InputStep>)
}
const handleWebhookChange = (webhook: Partial<Webhook>) => {
const webhookId = (step as WebhookStep).options?.webhookId
if (!webhookId) return
updateWebhook(webhookId, webhook)
}
switch (step.type) {
case InputStepType.TEXT: {
@@ -176,6 +186,17 @@ export const StepSettings = ({ step }: { step: Step }) => {
/>
)
}
case IntegrationStepType.WEBHOOK: {
return (
<WebhookSettings
key={step.options?.webhookId}
options={step.options}
webhook={typebot?.webhooks.byId[step.options?.webhookId ?? '']}
onOptionsChange={handleOptionsChange}
onWebhookChange={handleWebhookChange}
/>
)
}
default: {
return <></>
}

View File

@@ -18,8 +18,8 @@ export const ChoiceInputSettingsBody = ({
options && onOptionsChange({ ...options, isMultipleChoice })
const handleButtonLabelChange = (buttonLabel: string) =>
options && onOptionsChange({ ...options, buttonLabel })
const handleVariableChange = (variable: Variable) =>
options && onOptionsChange({ ...options, variableId: variable.id })
const handleVariableChange = (variable?: Variable) =>
options && onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>

View File

@@ -0,0 +1,51 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { InputWithVariableButton } from 'components/shared/InputWithVariableButton'
import { TableListItemProps } from 'components/shared/TableList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { Comparison, Variable, ComparisonOperators } from 'models'
export const ComparisonItem = ({
item,
onItemChange,
}: TableListItemProps<Comparison>) => {
const handleSelectVariable = (variable?: Variable) => {
if (variable?.id === item.variableId) return
onItemChange({ ...item, variableId: variable?.id })
}
const handleSelectComparisonOperator = (
comparisonOperator: ComparisonOperators
) => {
if (comparisonOperator === item.comparisonOperator) return
onItemChange({ ...item, comparisonOperator })
}
const handleChangeValue = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<VariableSearchInput
initialVariableId={item.variableId}
onSelectVariable={handleSelectVariable}
placeholder="Search for a variable"
/>
<DropdownList<ComparisonOperators>
currentItem={item.comparisonOperator}
onItemSelect={handleSelectComparisonOperator}
items={Object.values(ComparisonOperators)}
placeholder="Select an operator"
/>
{item.comparisonOperator !== ComparisonOperators.IS_SET && (
<InputWithVariableButton
delay={100}
initialValue={item.value ?? ''}
onChange={handleChangeValue}
placeholder="Type a value..."
/>
)}
</Stack>
)
}

View File

@@ -1,160 +0,0 @@
import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import { PlusIcon, TrashIcon } from 'assets/icons'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { DropdownList } from 'components/shared/DropdownList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import {
Comparison,
ComparisonOperators,
LogicalOperator,
Table,
Variable,
} from 'models'
import React, { useEffect, useState } from 'react'
import { generate } from 'short-uuid'
import { useImmer } from 'use-immer'
type Props = {
initialComparisons: Table<Comparison>
logicalOperator: LogicalOperator
onLogicalOperatorChange: (logicalOperator: LogicalOperator) => void
onComparisonsChange: (comparisons: Table<Comparison>) => void
}
export const ComparisonsList = ({
initialComparisons,
logicalOperator,
onLogicalOperatorChange,
onComparisonsChange,
}: Props) => {
const [comparisons, setComparisons] = useImmer(initialComparisons)
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
useEffect(() => {
onComparisonsChange(comparisons)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [comparisons])
const createComparison = () => {
setComparisons((comparisons) => {
const id = generate()
comparisons.byId[id] = {
id,
comparisonOperator: ComparisonOperators.EQUAL,
}
comparisons.allIds.push(id)
})
}
const updateComparison = (
comparisonId: string,
updates: Partial<Omit<Comparison, 'id'>>
) =>
setComparisons((comparisons) => {
comparisons.byId[comparisonId] = {
...comparisons.byId[comparisonId],
...updates,
}
})
const deleteComparison = (comparisonId: string) => () => {
setComparisons((comparisons) => {
delete comparisons.byId[comparisonId]
const index = comparisons.allIds.indexOf(comparisonId)
if (index !== -1) comparisons.allIds.splice(index, 1)
})
}
const handleVariableSelected =
(comparisonId: string) => (variable: Variable) => {
updateComparison(comparisonId, { variableId: variable.id })
}
const handleComparisonOperatorSelected =
(comparisonId: string) => (dropdownItem: ComparisonOperators) =>
updateComparison(comparisonId, {
comparisonOperator: dropdownItem,
})
const handleLogicalOperatorSelected = (dropdownItem: LogicalOperator) =>
onLogicalOperatorChange(dropdownItem)
const handleValueChange = (comparisonId: string) => (value: string) =>
updateComparison(comparisonId, { value })
const handleMouseEnter = (comparisonId: string) => () => {
setShowDeleteId(comparisonId)
}
const handleMouseLeave = () => setShowDeleteId(undefined)
return (
<Stack spacing="4" py="4">
{comparisons.allIds.map((comparisonId, idx) => (
<>
{idx > 0 && (
<Flex justify="center">
<DropdownList<LogicalOperator>
currentItem={logicalOperator}
onItemSelect={handleLogicalOperatorSelected}
items={Object.values(LogicalOperator)}
/>
</Flex>
)}
<Flex
pos="relative"
onMouseEnter={handleMouseEnter(comparisonId)}
onMouseLeave={handleMouseLeave}
>
<Stack
key={comparisonId}
p="4"
rounded="md"
flex="1"
borderWidth="1px"
>
<VariableSearchInput
initialVariableId={comparisons.byId[comparisonId].variableId}
onSelectVariable={handleVariableSelected(comparisonId)}
placeholder="Search for a variable"
/>
<DropdownList<ComparisonOperators>
currentItem={comparisons.byId[comparisonId].comparisonOperator}
onItemSelect={handleComparisonOperatorSelected(comparisonId)}
items={Object.values(ComparisonOperators)}
/>
{comparisons.byId[comparisonId].comparisonOperator !==
ComparisonOperators.IS_SET && (
<DebouncedInput
delay={100}
initialValue={comparisons.byId[comparisonId].value ?? ''}
onChange={handleValueChange(comparisonId)}
placeholder="Type a value..."
/>
)}
</Stack>
<Fade in={showDeleteId === comparisonId}>
<IconButton
icon={<TrashIcon />}
aria-label="Remove comparison"
onClick={deleteComparison(comparisonId)}
pos="absolute"
left="-15px"
top="-15px"
size="sm"
shadow="md"
/>
</Fade>
</Flex>
</>
))}
<Button
leftIcon={<PlusIcon />}
onClick={createComparison}
flexShrink={0}
colorScheme="blue"
>
Add a comparison
</Button>
</Stack>
)
}

View File

@@ -1,6 +1,9 @@
import { Flex } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { TableList } from 'components/shared/TableList'
import { Comparison, ConditionOptions, LogicalOperator, Table } from 'models'
import React from 'react'
import { ComparisonsList } from './ComparisonsList'
import { ComparisonItem } from './ComparisonsItem'
type ConditionSettingsBodyProps = {
options: ConditionOptions
@@ -17,11 +20,19 @@ export const ConditionSettingsBody = ({
onOptionsChange({ ...options, logicalOperator })
return (
<ComparisonsList
initialComparisons={options.comparisons}
logicalOperator={options.logicalOperator ?? LogicalOperator.AND}
onLogicalOperatorChange={handleLogicalOperatorChange}
onComparisonsChange={handleComparisonsChange}
<TableList<Comparison>
onItemsChange={handleComparisonsChange}
Item={ComparisonItem}
ComponentBetweenItems={() => (
<Flex justify="center">
<DropdownList<LogicalOperator>
currentItem={options.logicalOperator}
onItemSelect={handleLogicalOperatorChange}
items={Object.values(LogicalOperator)}
/>
</Flex>
)}
addLabel="Add a comparison"
/>
)
}

View File

@@ -24,8 +24,8 @@ export const DateInputSettingsBody = ({
onOptionsChange({ ...options, isRange })
const handleHasTimeChange = (hasTime: boolean) =>
onOptionsChange({ ...options, hasTime })
const handleVariableChange = (variable: Variable) =>
onOptionsChange({ ...options, variableId: variable.id })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>

View File

@@ -17,8 +17,8 @@ export const EmailInputSettingsBody = ({
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleVariableChange = (variable: Variable) =>
onOptionsChange({ ...options, variableId: variable.id })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>

View File

@@ -0,0 +1,35 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { InputWithVariableButton } from 'components/shared/InputWithVariableButton'
import { TableListItemProps } from 'components/shared/TableList'
import { Cell } from 'models'
export const CellWithValueStack = ({
item,
onItemChange,
columns,
}: TableListItemProps<Cell> & { columns: string[] }) => {
const handleColumnSelect = (column: string) => {
if (item.column === column) return
onItemChange({ ...item, column })
}
const handleValueChange = (value: string) => {
if (item.value === value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<DropdownList<string>
currentItem={item.column}
onItemSelect={handleColumnSelect}
items={columns}
placeholder="Select a column"
/>
<InputWithVariableButton
initialValue={item.value ?? ''}
onChange={handleValueChange}
placeholder="Type a value..."
/>
</Stack>
)
}

View File

@@ -0,0 +1,37 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { TableListItemProps } from 'components/shared/TableList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { ExtractingCell, Variable } from 'models'
export const CellWithVariableIdStack = ({
item,
onItemChange,
columns,
}: TableListItemProps<ExtractingCell> & { columns: string[] }) => {
const handleColumnSelect = (column: string) => {
if (item.column === column) return
onItemChange({ ...item, column })
}
const handleVariableIdChange = (variable?: Variable) => {
if (item.variableId === variable?.id) return
onItemChange({ ...item, variableId: variable?.id })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<DropdownList<string>
currentItem={item.column}
onItemSelect={handleColumnSelect}
items={columns}
placeholder="Select a column"
/>
<VariableSearchInput
initialVariableId={item.variableId}
onSelectVariable={handleVariableIdChange}
placeholder="Select a variable"
/>
</Stack>
)
}

View File

@@ -1,141 +0,0 @@
import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import { PlusIcon, TrashIcon } from 'assets/icons'
import { DropdownList } from 'components/shared/DropdownList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { ExtractingCell, Table, Variable } from 'models'
import React, { useEffect, useState } from 'react'
import { Sheet } from 'services/integrations'
import { generate } from 'short-uuid'
import { useImmer } from 'use-immer'
type Props = {
sheet: Sheet
initialCells?: Table<ExtractingCell>
onCellsChange: (cells: Table<ExtractingCell>) => void
}
const id = generate()
const defaultCells: Table<ExtractingCell> = {
byId: { [id]: {} },
allIds: [id],
}
export const ExtractCellList = ({
sheet,
initialCells,
onCellsChange,
}: Props) => {
const [cells, setCells] = useImmer(initialCells ?? defaultCells)
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
useEffect(() => {
onCellsChange(cells)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cells])
const createCell = () => {
setCells((cells) => {
const id = generate()
cells.byId[id] = {}
cells.allIds.push(id)
})
}
const updateCell = (cellId: string, updates: Partial<ExtractingCell>) =>
setCells((cells) => {
cells.byId[cellId] = {
...cells.byId[cellId],
...updates,
}
})
const deleteCell = (cellId: string) => () => {
setCells((cells) => {
delete cells.byId[cellId]
const index = cells.allIds.indexOf(cellId)
if (index !== -1) cells.allIds.splice(index, 1)
})
}
const handleMouseEnter = (cellId: string) => () => {
setShowDeleteId(cellId)
}
const handleCellChange = (cellId: string) => (cell: ExtractingCell) =>
updateCell(cellId, cell)
const handleMouseLeave = () => setShowDeleteId(undefined)
return (
<Stack spacing="4">
{cells.allIds.map((cellId) => (
<>
<Flex
pos="relative"
onMouseEnter={handleMouseEnter(cellId)}
onMouseLeave={handleMouseLeave}
>
<CellWithVariableIdStack
key={cellId}
cell={cells.byId[cellId]}
columns={sheet.columns}
onCellChange={handleCellChange(cellId)}
/>
<Fade in={showDeleteId === cellId}>
<IconButton
icon={<TrashIcon />}
aria-label="Remove cell"
onClick={deleteCell(cellId)}
pos="absolute"
left="-15px"
top="-15px"
size="sm"
shadow="md"
/>
</Fade>
</Flex>
</>
))}
<Button
leftIcon={<PlusIcon />}
onClick={createCell}
flexShrink={0}
colorScheme="blue"
>
Add a value
</Button>
</Stack>
)
}
export const CellWithVariableIdStack = ({
cell,
columns,
onCellChange,
}: {
cell: ExtractingCell
columns: string[]
onCellChange: (cell: ExtractingCell) => void
}) => {
const handleColumnSelect = (column: string) => {
onCellChange({ ...cell, column })
}
const handleVariableIdChange = (variable: Variable) => {
onCellChange({ ...cell, variableId: variable.id })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<DropdownList<string>
currentItem={cell.column}
onItemSelect={handleColumnSelect}
items={columns}
placeholder="Select a column"
/>
<VariableSearchInput
initialVariableId={cell.variableId}
onSelectVariable={handleVariableIdChange}
placeholder="Select a variable"
/>
</Stack>
)
}

View File

@@ -1,6 +1,7 @@
import { Divider, Stack, Text } from '@chakra-ui/react'
import { CredentialsDropdown } from 'components/shared/CredentialsDropdown'
import { DropdownList } from 'components/shared/DropdownList'
import { TableList, TableListItemProps } from 'components/shared/TableList'
import { useTypebot } from 'contexts/TypebotContext'
import { CredentialsType } from 'db'
import {
@@ -17,10 +18,10 @@ import {
useSheets,
} from 'services/integrations'
import { isDefined } from 'utils'
import { ExtractCellList } from './ExtractCellList'
import { SheetsDropdown } from './SheetsDropdown'
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
import { CellWithValueStack, UpdateCellList } from './UpdateCellList'
import { CellWithValueStack } from './CellWithValueStack'
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
type Props = {
options?: GoogleSheetsOptions
@@ -132,13 +133,26 @@ const ActionOptions = ({
const handleExtractingCellsChange = (cellsToExtract: Table<ExtractingCell>) =>
onOptionsChange({ ...options, cellsToExtract } as GoogleSheetsOptions)
const UpdatingCellItem = useMemo(
() => (props: TableListItemProps<Cell>) =>
<CellWithValueStack {...props} columns={sheet.columns} />,
[sheet.columns]
)
const ExtractingCellItem = useMemo(
() => (props: TableListItemProps<ExtractingCell>) =>
<CellWithVariableIdStack {...props} columns={sheet.columns} />,
[sheet.columns]
)
switch (options.action) {
case GoogleSheetsAction.INSERT_ROW:
return (
<UpdateCellList
initialCells={options.cellsToInsert}
sheet={sheet}
onCellsChange={handleInsertColumnsChange}
<TableList<Cell>
initialItems={options.cellsToInsert}
onItemsChange={handleInsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
/>
)
case GoogleSheetsAction.UPDATE_ROW:
@@ -146,15 +160,17 @@ const ActionOptions = ({
<Stack>
<Text>Row to select</Text>
<CellWithValueStack
cell={options.referenceCell ?? {}}
id={'reference'}
columns={sheet.columns}
onCellChange={handleReferenceCellChange}
item={options.referenceCell ?? {}}
onItemChange={handleReferenceCellChange}
/>
<Text>Cells to update</Text>
<UpdateCellList
initialCells={options.cellsToUpsert}
sheet={sheet}
onCellsChange={handleUpsertColumnsChange}
<TableList<Cell>
initialItems={options.cellsToUpsert}
onItemsChange={handleUpsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
/>
</Stack>
)
@@ -163,15 +179,17 @@ const ActionOptions = ({
<Stack>
<Text>Row to select</Text>
<CellWithValueStack
cell={options.referenceCell ?? {}}
id={'reference'}
columns={sheet.columns}
onCellChange={handleReferenceCellChange}
item={options.referenceCell ?? {}}
onItemChange={handleReferenceCellChange}
/>
<Text>Cells to extract</Text>
<ExtractCellList
initialCells={options.cellsToExtract}
sheet={sheet}
onCellsChange={handleExtractingCellsChange}
<TableList<ExtractingCell>
initialItems={options.cellsToExtract}
onItemsChange={handleExtractingCellsChange}
Item={ExtractingCellItem}
addLabel="Add a value"
/>
</Stack>
)

View File

@@ -29,7 +29,7 @@ export const SheetsDropdown = ({
<SearchableDropdown
selectedItem={currentSheet?.name}
items={(sheets ?? []).map((s) => s.name)}
onSelectItem={handleSpreadsheetSelect}
onValueChange={handleSpreadsheetSelect}
placeholder={isLoading ? 'Loading...' : 'Select the sheet'}
isDisabled={isLoading}
/>

View File

@@ -27,7 +27,7 @@ export const SpreadsheetsDropdown = ({
<SearchableDropdown
selectedItem={currentSpreadsheet?.name}
items={(spreadsheets ?? []).map((s) => s.name)}
onSelectItem={handleSpreadsheetSelect}
onValueChange={handleSpreadsheetSelect}
placeholder={isLoading ? 'Loading...' : 'Search for spreadsheet'}
isDisabled={isLoading}
/>

View File

@@ -1,141 +0,0 @@
import { Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import { PlusIcon, TrashIcon } from 'assets/icons'
import { DropdownList } from 'components/shared/DropdownList'
import { InputWithVariableButton } from 'components/shared/InputWithVariableButton'
import { Cell, Table } from 'models'
import React, { useEffect, useState } from 'react'
import { Sheet } from 'services/integrations'
import { generate } from 'short-uuid'
import { useImmer } from 'use-immer'
type Props = {
sheet: Sheet
initialCells?: Table<Cell>
onCellsChange: (cells: Table<Cell>) => void
}
const id = generate()
const defaultCells: Table<Cell> = {
byId: { [id]: {} },
allIds: [id],
}
export const UpdateCellList = ({
sheet,
initialCells,
onCellsChange,
}: Props) => {
const [cells, setCells] = useImmer(initialCells ?? defaultCells)
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
useEffect(() => {
onCellsChange(cells)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cells])
const createCell = () => {
setCells((cells) => {
const id = generate()
cells.byId[id] = {}
cells.allIds.push(id)
})
}
const updateCell = (cellId: string, updates: Partial<Cell>) =>
setCells((cells) => {
cells.byId[cellId] = {
...cells.byId[cellId],
...updates,
}
})
const deleteCell = (cellId: string) => () => {
setCells((cells) => {
delete cells.byId[cellId]
const index = cells.allIds.indexOf(cellId)
if (index !== -1) cells.allIds.splice(index, 1)
})
}
const handleMouseEnter = (cellId: string) => () => {
setShowDeleteId(cellId)
}
const handleCellChange = (cellId: string) => (cell: Cell) =>
updateCell(cellId, cell)
const handleMouseLeave = () => setShowDeleteId(undefined)
return (
<Stack spacing="4">
{cells.allIds.map((cellId) => (
<>
<Flex
pos="relative"
onMouseEnter={handleMouseEnter(cellId)}
onMouseLeave={handleMouseLeave}
>
<CellWithValueStack
key={cellId}
cell={cells.byId[cellId]}
columns={sheet.columns}
onCellChange={handleCellChange(cellId)}
/>
<Fade in={showDeleteId === cellId}>
<IconButton
icon={<TrashIcon />}
aria-label="Remove cell"
onClick={deleteCell(cellId)}
pos="absolute"
left="-15px"
top="-15px"
size="sm"
shadow="md"
/>
</Fade>
</Flex>
</>
))}
<Button
leftIcon={<PlusIcon />}
onClick={createCell}
flexShrink={0}
colorScheme="blue"
>
Add a value
</Button>
</Stack>
)
}
export const CellWithValueStack = ({
cell,
columns,
onCellChange,
}: {
cell: Cell
columns: string[]
onCellChange: (column: Cell) => void
}) => {
const handleColumnSelect = (column: string) => {
onCellChange({ ...cell, column })
}
const handleValueChange = (value: string) => {
onCellChange({ ...cell, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<DropdownList<string>
currentItem={cell.column}
onItemSelect={handleColumnSelect}
items={columns}
placeholder="Select a column"
/>
<InputWithVariableButton
initialValue={cell.value ?? ''}
onChange={handleValueChange}
placeholder="Type a value..."
/>
</Stack>
)
}

View File

@@ -25,8 +25,8 @@ export const NumberInputSettingsBody = ({
onOptionsChange(removeUndefinedFields({ ...options, max }))
const handleStepChange = (step?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, step }))
const handleVariableChange = (variable: Variable) =>
onOptionsChange({ ...options, variableId: variable.id })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>

View File

@@ -17,8 +17,8 @@ export const PhoneNumberSettingsBody = ({
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleVariableChange = (variable: Variable) =>
onOptionsChange({ ...options, variableId: variable.id })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>

View File

@@ -13,8 +13,8 @@ export const SetVariableSettingsBody = ({
options,
onOptionsChange,
}: Props) => {
const handleVariableChange = (variable: Variable) =>
onOptionsChange({ ...options, variableId: variable.id })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleExpressionChange = (expressionToEvaluate: string) =>
onOptionsChange({ ...options, expressionToEvaluate })

View File

@@ -20,8 +20,8 @@ export const TextInputSettingsBody = ({
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleLongChange = (isLong: boolean) =>
onOptionsChange({ ...options, isLong })
const handleVariableChange = (variable: Variable) =>
onOptionsChange({ ...options, variableId: variable.id })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>

View File

@@ -17,8 +17,8 @@ export const UrlInputSettingsBody = ({
onOptionsChange({ ...options, labels: { ...options?.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleVariableChange = (variable: Variable) =>
onOptionsChange({ ...options, variableId: variable.id })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>

View File

@@ -0,0 +1,62 @@
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { InputWithVariableButton } from 'components/shared/InputWithVariableButton'
import { TableListItemProps } from 'components/shared/TableList'
import { KeyValue } from 'models'
export const QueryParamsInputs = (props: TableListItemProps<KeyValue>) => (
<KeyValueInputs
{...props}
keyPlaceholder="e.g. email"
valuePlaceholder="e.g. {{Email}}"
/>
)
export const HeadersInputs = (props: TableListItemProps<KeyValue>) => (
<KeyValueInputs
{...props}
keyPlaceholder="e.g. Content-Type"
valuePlaceholder="e.g. application/json"
/>
)
export const KeyValueInputs = ({
id,
item,
onItemChange,
keyPlaceholder,
valuePlaceholder,
}: TableListItemProps<KeyValue> & {
keyPlaceholder?: string
valuePlaceholder?: string
}) => {
const handleKeyChange = (key: string) => {
if (key === item.key) return
onItemChange({ ...item, key })
}
const handleValueChange = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<FormControl>
<FormLabel htmlFor={'key' + id}>Key:</FormLabel>
<InputWithVariableButton
id={'key' + id}
initialValue={item.key ?? ''}
onChange={handleKeyChange}
placeholder={keyPlaceholder}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor={'value' + id}>Value:</FormLabel>
<InputWithVariableButton
id={'value' + id}
initialValue={item.value ?? ''}
onChange={handleValueChange}
placeholder={valuePlaceholder}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,38 @@
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { TableListItemProps } from 'components/shared/TableList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { Variable, ResponseVariableMapping } from 'models'
export const DataVariableInputs = ({
item,
onItemChange,
dataItems,
}: TableListItemProps<ResponseVariableMapping> & { dataItems: string[] }) => {
const handleBodyPathChange = (bodyPath: string) =>
onItemChange({ ...item, bodyPath })
const handleVariableChange = (variable?: Variable) =>
onItemChange({ ...item, variableId: variable?.id })
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<FormControl>
<FormLabel htmlFor="name">Data:</FormLabel>
<SearchableDropdown
items={dataItems}
value={item.bodyPath}
onValueChange={handleBodyPathChange}
placeholder="Select the data"
/>
</FormControl>
<FormControl>
<FormLabel htmlFor="value">Set variable:</FormLabel>
<VariableSearchInput
onSelectVariable={handleVariableChange}
placeholder="Search for a variable"
initialVariableId={item.variableId}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,39 @@
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { TableListItemProps } from 'components/shared/TableList'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { VariableForTest, Variable } from 'models'
export const VariableForTestInputs = ({
id,
item,
onItemChange,
}: TableListItemProps<VariableForTest>) => {
const handleVariableSelect = (variable?: Variable) =>
onItemChange({ ...item, variableId: variable?.id })
const handleValueChange = (value: string) => {
if (value === item.value) return
onItemChange({ ...item, value })
}
return (
<Stack p="4" rounded="md" flex="1" borderWidth="1px">
<FormControl>
<FormLabel htmlFor={'name' + id}>Variable name:</FormLabel>
<VariableSearchInput
id={'name' + id}
initialVariableId={item.variableId}
onSelectVariable={handleVariableSelect}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor={'value' + id}>Test value:</FormLabel>
<DebouncedInput
id={'value' + id}
delay={100}
initialValue={item.value ?? ''}
onChange={handleValueChange}
/>
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,204 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Button,
Flex,
Stack,
useToast,
} from '@chakra-ui/react'
import { InputWithVariableButton } from 'components/shared/InputWithVariableButton'
import { useTypebot } from 'contexts/TypebotContext'
import {
HttpMethod,
KeyValue,
Table,
WebhookOptions,
VariableForTest,
Webhook,
ResponseVariableMapping,
} from 'models'
import { DropdownList } from 'components/shared/DropdownList'
import { generate } from 'short-uuid'
import { TableList, TableListItemProps } from 'components/shared/TableList'
import { CodeEditor } from 'components/shared/CodeEditor'
import {
convertVariableForTestToVariables,
executeWebhook,
getDeepKeys,
} from 'services/integrations'
import { HeadersInputs, QueryParamsInputs } from './KeyValueInputs'
import { VariableForTestInputs } from './VariableForTestInputs'
import { DataVariableInputs } from './ResponseMappingInputs'
type Props = {
options?: WebhookOptions
webhook?: Webhook
onOptionsChange: (options: WebhookOptions) => void
onWebhookChange: (webhook: Partial<Webhook>) => void
}
export const WebhookSettings = ({
options,
webhook,
onOptionsChange,
onWebhookChange,
}: Props) => {
const { createWebhook, typebot, save } = useTypebot()
const [testResponse, setTestResponse] = useState<string>()
const [responseKeys, setResponseKeys] = useState<string[]>([])
const toast = useToast({
position: 'top-right',
status: 'error',
})
useEffect(() => {
if (options?.webhookId) return
const webhookId = generate()
createWebhook({ id: webhookId })
onOptionsChange({ ...options, webhookId })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleUrlChange = (url?: string) => onWebhookChange({ url })
const handleMethodChange = (method?: HttpMethod) =>
onWebhookChange({ method })
const handleQueryParamsChange = (queryParams: Table<KeyValue>) =>
onWebhookChange({ queryParams })
const handleHeadersChange = (headers: Table<KeyValue>) =>
onWebhookChange({ headers })
const handleBodyChange = (body: string) => onWebhookChange({ body })
const handleVariablesChange = (variablesForTest: Table<VariableForTest>) =>
onOptionsChange({ ...options, variablesForTest })
const handleResponseMappingChange = (
responseVariableMapping: Table<ResponseVariableMapping>
) => onOptionsChange({ ...options, responseVariableMapping })
const handleTestRequestClick = async () => {
if (!typebot || !webhook) return
await save()
const { data, error } = await executeWebhook(
typebot.id,
webhook.id,
convertVariableForTestToVariables(
options?.variablesForTest,
typebot.variables
)
)
if (error) return toast({ title: error.name, description: error.message })
setTestResponse(JSON.stringify(data, undefined, 2))
setResponseKeys(getDeepKeys(data))
}
const ResponseMappingInputs = useMemo(
() => (props: TableListItemProps<ResponseVariableMapping>) =>
<DataVariableInputs {...props} dataItems={responseKeys} />,
[responseKeys]
)
return (
<Stack spacing={4}>
<Stack>
<Flex>
<DropdownList<HttpMethod>
currentItem={webhook?.method ?? HttpMethod.GET}
onItemSelect={handleMethodChange}
items={Object.values(HttpMethod)}
/>
</Flex>
<InputWithVariableButton
placeholder="Your Webhook URL..."
initialValue={webhook?.url ?? ''}
onChange={handleUrlChange}
/>
</Stack>
<Accordion allowToggle allowMultiple>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Query params
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={webhook?.queryParams}
onItemsChange={handleQueryParamsChange}
Item={QueryParamsInputs}
addLabel="Add a param"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Headers
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<KeyValue>
initialItems={webhook?.headers}
onItemsChange={handleHeadersChange}
Item={HeadersInputs}
addLabel="Add a value"
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Body
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<CodeEditor
value={webhook?.body ?? ''}
onChange={handleBodyChange}
/>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Variable values for test
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<VariableForTest>
initialItems={options?.variablesForTest}
onItemsChange={handleVariablesChange}
Item={VariableForTestInputs}
addLabel="Add an entry"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
<Button onClick={handleTestRequestClick} colorScheme="blue">
Test the request
</Button>
{testResponse && <CodeEditor isReadOnly value={testResponse} />}
{(testResponse || options?.responseVariableMapping) && (
<Accordion allowToggle allowMultiple>
<AccordionItem>
<AccordionButton justifyContent="space-between">
Save in variables
<AccordionIcon />
</AccordionButton>
<AccordionPanel pb={4} as={Stack} spacing="6">
<TableList<ResponseVariableMapping>
initialItems={options?.responseVariableMapping}
onItemsChange={handleResponseMappingChange}
Item={ResponseMappingInputs}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)}
</Stack>
)
}

View File

@@ -0,0 +1 @@
export { WebhookSettings } from './WebhookSettings'

View File

@@ -13,7 +13,7 @@ import { StepIcon } from 'components/board/StepTypesList/StepIcon'
import { isBubbleStep, isTextBubbleStep } from 'utils'
import { Coordinates } from '@dnd-kit/core/dist/types'
import { TextEditor } from './TextEditor/TextEditor'
import { StepNodeContent } from './StepNodeContent'
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
import { useTypebot } from 'contexts/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu'
import { SettingsPopoverContent } from './SettingsPopoverContent'
@@ -164,6 +164,7 @@ export const StepNode = ({
cursor={'pointer'}
bgColor="white"
align="flex-start"
w="full"
>
<StepIcon type={step.type} mt="1" />
<StepNodeContent step={step} />

View File

@@ -1,238 +0,0 @@
import { Box, Flex, HStack, Image, Stack, Tag, Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import {
Step,
StartStep,
BubbleStepType,
InputStepType,
LogicStepType,
SetVariableStep,
ConditionStep,
IntegrationStepType,
VideoBubbleStep,
VideoBubbleContentType,
} from 'models'
import { ChoiceItemsList } from './ChoiceInputStepNode/ChoiceItemsList'
import { SourceEndpoint } from './SourceEndpoint'
type Props = {
step: Step | StartStep
isConnectable?: boolean
}
export const StepNodeContent = ({ step }: Props) => {
switch (step.type) {
case BubbleStepType.TEXT: {
return (
<Flex
flexDir={'column'}
opacity={step.content.html === '' ? '0.5' : '1'}
className="slate-html-container"
dangerouslySetInnerHTML={{
__html:
step.content.html === ''
? `<p>Click to edit...</p>`
: step.content.html,
}}
/>
)
}
case BubbleStepType.IMAGE: {
return !step.content?.url ? (
<Text color={'gray.500'}>Click to edit...</Text>
) : (
<Box w="full">
<Image
src={step.content?.url}
alt="Step image"
rounded="md"
objectFit="cover"
/>
</Box>
)
}
case BubbleStepType.VIDEO: {
return <VideoStepNodeContent step={step} />
}
case InputStepType.TEXT: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case InputStepType.NUMBER: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case InputStepType.EMAIL: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your email...'}
</Text>
)
}
case InputStepType.URL: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your URL...'}
</Text>
)
}
case InputStepType.DATE: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.from ?? 'Pick a date...'}
</Text>
)
}
case InputStepType.PHONE: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Your phone number...'}
</Text>
)
}
case InputStepType.CHOICE: {
return <ChoiceItemsList step={step} />
}
case LogicStepType.SET_VARIABLE: {
return <SetVariableNodeContent step={step} />
}
case LogicStepType.CONDITION: {
return <ConditionNodeContent step={step} />
}
case LogicStepType.REDIRECT: {
if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
return <Text isTruncated>Redirect to {step.options?.url}</Text>
}
case IntegrationStepType.GOOGLE_SHEETS: {
if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
return <Text>{step.options?.action}</Text>
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
if (!step.options || !step.options.action)
return <Text color={'gray.500'}>Configure...</Text>
return <Text>Track "{step.options?.action}"</Text>
}
case 'start': {
return <Text>{step.label}</Text>
}
default: {
return <Text>No input</Text>
}
}
}
const SetVariableNodeContent = ({ step }: { step: SetVariableStep }) => {
const { typebot } = useTypebot()
const variableName =
typebot?.variables.byId[step.options?.variableId ?? '']?.name ?? ''
const expression = step.options?.expressionToEvaluate ?? ''
return (
<Text color={'gray.500'}>
{variableName === '' && expression === ''
? 'Click to edit...'
: `${variableName} = ${expression}`}
</Text>
)
}
const ConditionNodeContent = ({ step }: { step: ConditionStep }) => {
const { typebot } = useTypebot()
return (
<Flex>
<Stack color={'gray.500'}>
{step.options?.comparisons.allIds.map((comparisonId, idx) => {
const comparison = step.options?.comparisons.byId[comparisonId]
const variable = typebot?.variables.byId[comparison?.variableId ?? '']
return (
<HStack key={comparisonId} spacing={1}>
{idx > 0 && <Text>{step.options?.logicalOperator ?? ''}</Text>}
{variable?.name && (
<Tag bgColor="orange.400">{variable.name}</Tag>
)}
{comparison.comparisonOperator && (
<Text>{comparison?.comparisonOperator}</Text>
)}
{comparison?.value && (
<Tag bgColor={'green.400'}>{comparison.value}</Tag>
)}
</HStack>
)
})}
</Stack>
<SourceEndpoint
source={{
blockId: step.blockId,
stepId: step.id,
conditionType: 'true',
}}
pos="absolute"
top="7px"
right="15px"
/>
<SourceEndpoint
source={{
blockId: step.blockId,
stepId: step.id,
conditionType: 'false',
}}
pos="absolute"
bottom="7px"
right="15px"
/>
</Flex>
)
}
const VideoStepNodeContent = ({ step }: { step: VideoBubbleStep }) => {
if (!step.content?.url || !step.content.type)
return <Text color="gray.500">Click to edit...</Text>
switch (step.content.type) {
case VideoBubbleContentType.URL:
return (
<Box w="full" h="120px" pos="relative">
<video
key={step.content.url}
controls
style={{
width: '100%',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
borderRadius: '10px',
}}
>
<source src={step.content.url} />
</video>
</Box>
)
case VideoBubbleContentType.VIMEO:
case VideoBubbleContentType.YOUTUBE: {
const baseUrl =
step.content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
return (
<Box w="full" h="120px" pos="relative">
<iframe
src={`${baseUrl}/${step.content.id}`}
allowFullScreen
style={{
width: '100%',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
borderRadius: '10px',
}}
/>
</Box>
)
}
}
}

View File

@@ -0,0 +1,52 @@
import { Flex, Stack, HStack, Tag, Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { ConditionStep } from 'models'
import { SourceEndpoint } from '../SourceEndpoint'
export const ConditionNodeContent = ({ step }: { step: ConditionStep }) => {
const { typebot } = useTypebot()
return (
<Flex>
<Stack color={'gray.500'}>
{step.options?.comparisons.allIds.map((comparisonId, idx) => {
const comparison = step.options?.comparisons.byId[comparisonId]
const variable = typebot?.variables.byId[comparison?.variableId ?? '']
return (
<HStack key={comparisonId} spacing={1}>
{idx > 0 && <Text>{step.options?.logicalOperator ?? ''}</Text>}
{variable?.name && (
<Tag bgColor="orange.400">{variable.name}</Tag>
)}
{comparison.comparisonOperator && (
<Text>{comparison?.comparisonOperator}</Text>
)}
{comparison?.value && (
<Tag bgColor={'green.400'}>{comparison.value}</Tag>
)}
</HStack>
)
})}
</Stack>
<SourceEndpoint
source={{
blockId: step.blockId,
stepId: step.id,
conditionType: 'true',
}}
pos="absolute"
top="7px"
right="15px"
/>
<SourceEndpoint
source={{
blockId: step.blockId,
stepId: step.id,
conditionType: 'false',
}}
pos="absolute"
bottom="7px"
right="15px"
/>
</Flex>
)
}

View File

@@ -0,0 +1,17 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { SetVariableStep } from 'models'
export const SetVariableNodeContent = ({ step }: { step: SetVariableStep }) => {
const { typebot } = useTypebot()
const variableName =
typebot?.variables.byId[step.options?.variableId ?? '']?.name ?? ''
const expression = step.options?.expressionToEvaluate ?? ''
return (
<Text color={'gray.500'}>
{variableName === '' && expression === ''
? 'Click to edit...'
: `${variableName} = ${expression}`}
</Text>
)
}

View File

@@ -0,0 +1,128 @@
import { Box, Flex, Image, Text } from '@chakra-ui/react'
import {
Step,
StartStep,
BubbleStepType,
InputStepType,
LogicStepType,
IntegrationStepType,
} from 'models'
import { ChoiceItemsList } from '../ChoiceInputStepNode/ChoiceItemsList'
import { ConditionNodeContent } from './ConditionNodeContent'
import { SetVariableNodeContent } from './SetVariableNodeContent'
import { VideoStepNodeContent } from './VideoStepNodeContent'
import { WebhookContent } from './WebhookContent'
type Props = {
step: Step | StartStep
isConnectable?: boolean
}
export const StepNodeContent = ({ step }: Props) => {
switch (step.type) {
case BubbleStepType.TEXT: {
return (
<Flex
flexDir={'column'}
opacity={step.content.html === '' ? '0.5' : '1'}
className="slate-html-container"
dangerouslySetInnerHTML={{
__html:
step.content.html === ''
? `<p>Click to edit...</p>`
: step.content.html,
}}
/>
)
}
case BubbleStepType.IMAGE: {
return !step.content?.url ? (
<Text color={'gray.500'}>Click to edit...</Text>
) : (
<Box w="full">
<Image
src={step.content?.url}
alt="Step image"
rounded="md"
objectFit="cover"
/>
</Box>
)
}
case BubbleStepType.VIDEO: {
return <VideoStepNodeContent step={step} />
}
case InputStepType.TEXT: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case InputStepType.NUMBER: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your answer...'}
</Text>
)
}
case InputStepType.EMAIL: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your email...'}
</Text>
)
}
case InputStepType.URL: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Type your URL...'}
</Text>
)
}
case InputStepType.DATE: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.from ?? 'Pick a date...'}
</Text>
)
}
case InputStepType.PHONE: {
return (
<Text color={'gray.500'}>
{step.options?.labels?.placeholder ?? 'Your phone number...'}
</Text>
)
}
case InputStepType.CHOICE: {
return <ChoiceItemsList step={step} />
}
case LogicStepType.SET_VARIABLE: {
return <SetVariableNodeContent step={step} />
}
case LogicStepType.CONDITION: {
return <ConditionNodeContent step={step} />
}
case LogicStepType.REDIRECT: {
if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
return <Text isTruncated>Redirect to {step.options?.url}</Text>
}
case IntegrationStepType.GOOGLE_SHEETS: {
if (!step.options) return <Text color={'gray.500'}>Configure...</Text>
return <Text>{step.options?.action}</Text>
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
if (!step.options || !step.options.action)
return <Text color={'gray.500'}>Configure...</Text>
return <Text>Track "{step.options?.action}"</Text>
}
case IntegrationStepType.WEBHOOK: {
return <WebhookContent step={step} />
}
case 'start': {
return <Text>{step.label}</Text>
}
default: {
return <Text>No input</Text>
}
}
}

View File

@@ -0,0 +1,51 @@
import { Box, Text } from '@chakra-ui/react'
import { VideoBubbleStep, VideoBubbleContentType } from 'models'
export const VideoStepNodeContent = ({ step }: { step: VideoBubbleStep }) => {
if (!step.content?.url || !step.content.type)
return <Text color="gray.500">Click to edit...</Text>
switch (step.content.type) {
case VideoBubbleContentType.URL:
return (
<Box w="full" h="120px" pos="relative">
<video
key={step.content.url}
controls
style={{
width: '100%',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
borderRadius: '10px',
}}
>
<source src={step.content.url} />
</video>
</Box>
)
case VideoBubbleContentType.VIMEO:
case VideoBubbleContentType.YOUTUBE: {
const baseUrl =
step.content.type === VideoBubbleContentType.VIMEO
? 'https://player.vimeo.com/video'
: 'https://www.youtube.com/embed'
return (
<Box w="full" h="120px" pos="relative">
<iframe
src={`${baseUrl}/${step.content.id}`}
allowFullScreen
style={{
width: '100%',
height: '100%',
position: 'absolute',
left: '0',
top: '0',
borderRadius: '10px',
}}
/>
</Box>
)
}
}
}

View File

@@ -0,0 +1,22 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { WebhookStep } from 'models'
import { useMemo } from 'react'
type Props = {
step: WebhookStep
}
export const WebhookContent = ({ step }: Props) => {
const { typebot } = useTypebot()
const webhook = useMemo(
() => typebot?.webhooks.byId[step.options?.webhookId ?? ''],
[step.options?.webhookId, typebot?.webhooks.byId]
)
if (!webhook?.url) return <Text color="gray.500">Configure...</Text>
return (
<Text isTruncated pr="6">
{webhook.method} {webhook.url}
</Text>
)
}

View File

@@ -0,0 +1 @@
export { StepNodeContent } from './StepNodeContent'

View File

@@ -1,7 +1,7 @@
import { StackProps, HStack } from '@chakra-ui/react'
import { StartStep, Step } from 'models'
import { StepIcon } from 'components/board/StepTypesList/StepIcon'
import { StepNodeContent } from './StepNodeContent'
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
export const StepNodeOverlay = ({
step,

View File

@@ -94,9 +94,9 @@ export const TextEditor = ({
e.stopPropagation()
}
const handleVariableSelected = (variable: Variable) => {
const handleVariableSelected = (variable?: Variable) => {
setIsVariableDropdownOpen(false)
if (!rememberedSelection.current) return
if (!rememberedSelection.current || !variable) return
Transforms.select(editor, rememberedSelection.current)
Transforms.insertText(editor, '{{' + variable.name + '}}')
ReactEditor.focus(editor as unknown as ReactEditor)

View File

@@ -0,0 +1,58 @@
import { Box, BoxProps } from '@chakra-ui/react'
import { EditorState, EditorView, basicSetup } from '@codemirror/basic-setup'
import { json } from '@codemirror/lang-json'
import { useEffect, useRef } from 'react'
type Props = {
value: string
onChange?: (value: string) => void
isReadOnly?: boolean
}
export const CodeEditor = ({
value,
onChange,
isReadOnly = false,
...props
}: Props & Omit<BoxProps, 'onChange'>) => {
const editorContainer = useRef<HTMLDivElement | null>(null)
const editorView = useRef<EditorView | null>(null)
useEffect(() => {
if (!editorView.current || !isReadOnly) return
editorView.current.dispatch({
changes: { from: 0, insert: value },
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
useEffect(() => {
if (!editorContainer.current) return
const updateListenerExtension = EditorView.updateListener.of((update) => {
if (update.docChanged && onChange)
onChange(update.state.doc.toJSON().join(' '))
})
const editor = new EditorView({
state: EditorState.create({
extensions: [
updateListenerExtension,
basicSetup,
json(),
EditorState.readOnly.of(isReadOnly),
],
}),
parent: editorContainer.current,
})
editor.dispatch({
changes: { from: 0, insert: value },
})
editorView.current = editor
return () => {
editor.destroy()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Box ref={editorContainer} h="200px" data-testid="code-editor" {...props} />
)
}

View File

@@ -1,4 +1,5 @@
import {
Flex,
HStack,
IconButton,
Input,
@@ -6,6 +7,7 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
Tooltip,
} from '@chakra-ui/react'
import { UserIcon } from 'assets/icons'
import { Variable } from 'models'
@@ -29,12 +31,12 @@ export const InputWithVariableButton = ({
const [carretPosition, setCarretPosition] = useState<number>(0)
useEffect(() => {
onChange(debouncedValue)
if (debouncedValue !== initialValue) onChange(debouncedValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue])
const handleVariableSelected = (variable: Variable) => {
if (!inputRef.current) return
const handleVariableSelected = (variable?: Variable) => {
if (!inputRef.current || !variable) return
const cursorPosition = carretPosition
const textBeforeCursorPosition = inputRef.current.value.substring(
0,
@@ -67,7 +69,7 @@ export const InputWithVariableButton = ({
setValue(e.target.value)
return (
<HStack>
<HStack spacing={0}>
<Input
ref={inputRef}
onKeyUp={handleKeyUp}
@@ -79,12 +81,15 @@ export const InputWithVariableButton = ({
/>
<Popover matchWidth isLazy>
<PopoverTrigger>
<IconButton
aria-label="Insert a variable"
icon={<UserIcon />}
pos="relative"
ml="2"
/>
<Flex>
<Tooltip label="Insert a variable">
<IconButton
aria-label="Insert a variable"
icon={<UserIcon />}
pos="relative"
/>
</Tooltip>
</Flex>
</PopoverTrigger>
<PopoverContent w="full">
<VariableSearchInput

View File

@@ -11,20 +11,23 @@ import {
InputProps,
} from '@chakra-ui/react'
import { useState, useRef, useEffect, ChangeEvent } from 'react'
import { useDebounce } from 'use-debounce'
type Props = {
selectedItem?: string
items: string[]
onSelectItem: (value: string) => void
onValueChange?: (value: string) => void
} & InputProps
export const SearchableDropdown = ({
selectedItem,
items,
onSelectItem,
onValueChange,
...inputProps
}: Props) => {
const { onOpen, onClose, isOpen } = useDisclosure()
const [inputValue, setInputValue] = useState(selectedItem)
const [inputValue, setInputValue] = useState(selectedItem ?? '')
const [debouncedInputValue] = useDebounce(inputValue, 200)
const [filteredItems, setFilteredItems] = useState([
...items
.filter((item) =>
@@ -52,6 +55,13 @@ export const SearchableDropdown = ({
handler: onClose,
})
useEffect(() => {
onValueChange &&
debouncedInputValue !== selectedItem &&
onValueChange(debouncedInputValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedInputValue])
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
if (e.target.value === '') {
@@ -69,7 +79,6 @@ export const SearchableDropdown = ({
const handleItemClick = (item: string) => () => {
setInputValue(item)
onSelectItem(item)
onClose()
}

View File

@@ -0,0 +1,114 @@
import { Box, Button, Fade, Flex, IconButton, Stack } from '@chakra-ui/react'
import { TrashIcon, PlusIcon } from 'assets/icons'
import { Draft } from 'immer'
import { Table } from 'models'
import React, { useEffect, useState } from 'react'
import { generate } from 'short-uuid'
import { useImmer } from 'use-immer'
export type TableListItemProps<T> = {
id: string
item: T
onItemChange: (item: T) => void
}
type Props<T> = {
initialItems?: Table<T>
onItemsChange: (items: Table<T>) => void
addLabel?: string
Item: (props: TableListItemProps<T>) => JSX.Element
ComponentBetweenItems?: (props: unknown) => JSX.Element
}
export const TableList = <T,>({
initialItems,
onItemsChange,
addLabel = 'Add',
Item,
ComponentBetweenItems = () => <></>,
}: Props<T>) => {
const [items, setItems] = useImmer(initialItems ?? { byId: {}, allIds: [] })
const [showDeleteId, setShowDeleteId] = useState<string | undefined>()
useEffect(() => {
if (items.allIds.length === 0) createItem()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
onItemsChange(items)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items])
const createItem = () => {
setItems((items) => {
const id = generate()
items.byId[id] = { id } as unknown as Draft<T>
items.allIds.push(id)
})
}
const updateItem = (itemId: string, updates: Partial<T>) =>
setItems((items) => {
items.byId[itemId] = {
...items.byId[itemId],
...updates,
}
})
const deleteItem = (itemId: string) => () => {
setItems((items) => {
delete items.byId[itemId]
const index = items.allIds.indexOf(itemId)
if (index !== -1) items.allIds.splice(index, 1)
})
}
const handleMouseEnter = (itemId: string) => () => setShowDeleteId(itemId)
const handleCellChange = (itemId: string) => (item: T) =>
updateItem(itemId, item)
const handleMouseLeave = () => setShowDeleteId(undefined)
return (
<Stack spacing="4">
{items.allIds.map((itemId, idx) => (
<Box key={itemId}>
{idx !== 0 && <ComponentBetweenItems />}
<Flex
pos="relative"
onMouseEnter={handleMouseEnter(itemId)}
onMouseLeave={handleMouseLeave}
>
<Item
id={itemId}
item={items.byId[itemId]}
onItemChange={handleCellChange(itemId)}
/>
<Fade in={showDeleteId === itemId}>
<IconButton
icon={<TrashIcon />}
aria-label="Remove cell"
onClick={deleteItem(itemId)}
pos="absolute"
left="-15px"
top="-15px"
size="sm"
shadow="md"
/>
</Fade>
</Flex>
</Box>
))}
<Button
leftIcon={<PlusIcon />}
onClick={createItem}
flexShrink={0}
colorScheme="blue"
>
{addLabel}
</Button>
</Stack>
)
}

View File

@@ -15,11 +15,14 @@ import { useTypebot } from 'contexts/TypebotContext'
import { Variable } from 'models'
import React, { useState, useRef, ChangeEvent, useMemo, useEffect } from 'react'
import { generate } from 'short-uuid'
import { useDebounce } from 'use-debounce'
import { isDefined } from 'utils'
type Props = {
initialVariableId?: string
onSelectVariable: (variable: Pick<Variable, 'id' | 'name'>) => void
onSelectVariable: (
variable: Pick<Variable, 'id' | 'name'> | undefined
) => void
isDefaultOpen?: boolean
} & InputProps
@@ -39,6 +42,7 @@ export const VariableSearchInput = ({
const [inputValue, setInputValue] = useState(
typebot?.variables.byId[initialVariableId ?? '']?.name ?? ''
)
const [debouncedInputValue] = useDebounce(inputValue, 200)
const [filteredItems, setFilteredItems] = useState<Variable[]>(variables)
const dropdownRef = useRef(null)
const inputRef = useRef(null)
@@ -53,11 +57,18 @@ export const VariableSearchInput = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const variable = variables.find((v) => v.name === debouncedInputValue)
if (variable) onSelectVariable(variable)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedInputValue])
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
onOpen()
if (e.target.value === '') {
setFilteredItems([...variables.slice(0, 50)])
onSelectVariable(undefined)
return
}
setFilteredItems([

View File

@@ -27,18 +27,19 @@ export const FontSelector = ({
)
}
const handleFontSelected = (nextFont: string) => {
if (nextFont == currentFont) return
setCurrentFont(nextFont)
onSelectFont(nextFont)
}
return (
<Flex justify="space-between" align="center">
<Text>Font</Text>
<SearchableDropdown
selectedItem={activeFont}
items={googleFonts}
onSelectItem={(nextFont) => {
if (nextFont !== currentFont) {
setCurrentFont(nextFont)
onSelectFont(nextFont)
}
}}
onValueChange={handleFontSelected}
/>
</Flex>
)

View File

@@ -29,6 +29,7 @@ import { stepsAction, StepsActions } from './actions/steps'
import { choiceItemsAction, ChoiceItemsActions } from './actions/choiceItems'
import { variablesAction, VariablesActions } from './actions/variables'
import { edgesAction, EdgesActions } from './actions/edges'
import { webhooksAction, WebhooksAction } from './actions/webhooks'
type UpdateTypebotPayload = Partial<{
theme: Theme
@@ -52,7 +53,8 @@ const typebotContext = createContext<
StepsActions &
ChoiceItemsActions &
VariablesActions &
EdgesActions
EdgesActions &
WebhooksAction
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
>({})
@@ -78,9 +80,11 @@ export const TypebotContext = ({
description: error.message,
}),
})
const [localTypebot, setLocalTypebot] = useImmer<Typebot | undefined>(
undefined
)
const [localPublishedTypebot, setLocalPublishedTypebot] =
useState<PublicTypebot>()
const [isSavingLoading, setIsSavingLoading] = useState(false)
@@ -214,6 +218,7 @@ export const TypebotContext = ({
...choiceItemsAction(setLocalTypebot as Updater<Typebot>),
...variablesAction(setLocalTypebot as Updater<Typebot>),
...edgesAction(setLocalTypebot as Updater<Typebot>),
...webhooksAction(setLocalTypebot as Updater<Typebot>),
}}
>
{children}

View File

@@ -10,8 +10,9 @@ import { Updater } from 'use-immer'
import { removeEmptyBlocks } from './blocks'
import { WritableDraft } from 'immer/dist/types/types-external'
import { createChoiceItemDraft, deleteChoiceItemDraft } from './choiceItems'
import { isChoiceInput } from 'utils'
import { isChoiceInput, isWebhookStep } from 'utils'
import { deleteEdgeDraft } from './edges'
import { deleteWebhookDraft } from './webhooks'
export type StepsActions = {
createStep: (
@@ -51,6 +52,8 @@ export const stepsAction = (setTypebot: Updater<Typebot>): StepsActions => ({
setTypebot((typebot) => {
const step = typebot.steps.byId[stepId]
if (isChoiceInput(step)) deleteChoiceItemsInsideStep(typebot, step)
if (isWebhookStep(step))
deleteWebhookDraft(step.options?.webhookId)(typebot)
deleteAssociatedEdges(typebot, stepId)
removeStepIdFromBlock(typebot, stepId)
deleteStepDraft(typebot, stepId)

View File

@@ -1,10 +1,9 @@
import { Typebot, Variable } from 'models'
import { Updater } from 'use-immer'
import { WritableDraft } from 'immer/dist/types/types-external'
import { generate } from 'short-uuid'
export type VariablesActions = {
createVariable: (variable: Omit<Variable, 'id'> | Variable) => void
createVariable: (variable: Variable) => void
updateVariable: (
variableId: string,
updates: Partial<Omit<Variable, 'id'>>
@@ -15,10 +14,10 @@ export type VariablesActions = {
export const variablesAction = (
setTypebot: Updater<Typebot>
): VariablesActions => ({
createVariable: (variable: Omit<Variable, 'id'> | Variable) => {
createVariable: (newVariable: Variable) => {
setTypebot((typebot) => {
const id = createVariableDraft(typebot, variable)
return id
typebot.variables.byId[newVariable.id] = newVariable
typebot.variables.allIds.push(newVariable.id)
})
},
updateVariable: (
@@ -46,15 +45,3 @@ export const deleteVariableDraft = (
const index = typebot.variables.allIds.indexOf(variableId)
if (index !== -1) typebot.variables.allIds.splice(index, 1)
}
export const createVariableDraft = (
typebot: WritableDraft<Typebot>,
variable: Omit<Variable, 'id'> | Variable
) => {
const newVariable = {
...variable,
id: 'id' in variable ? variable.id : generate(),
}
typebot.variables.byId[newVariable.id] = newVariable
typebot.variables.allIds.push(newVariable.id)
}

View File

@@ -0,0 +1,41 @@
import { Typebot, Webhook } from 'models'
import { Updater } from 'use-immer'
import { WritableDraft } from 'immer/dist/internal'
export type WebhooksAction = {
createWebhook: (webook: Webhook) => void
updateWebhook: (
webhookId: string,
updates: Partial<Omit<Webhook, 'id'>>
) => void
deleteWebhook: (variableId: string) => void
}
export const webhooksAction = (
setTypebot: Updater<Typebot>
): WebhooksAction => ({
createWebhook: (newWebhook: Webhook) => {
setTypebot((typebot) => {
typebot.webhooks.byId[newWebhook.id] = newWebhook
typebot.webhooks.allIds.push(newWebhook.id)
})
},
updateWebhook: (webhookId: string, updates: Partial<Omit<Webhook, 'id'>>) =>
setTypebot((typebot) => {
typebot.webhooks.byId[webhookId] = {
...typebot.webhooks.byId[webhookId],
...updates,
}
}),
deleteWebhook: (webhookId: string) => {
setTypebot(deleteWebhookDraft(webhookId))
},
})
export const deleteWebhookDraft =
(webhookId?: string) => (typebot: WritableDraft<Typebot>) => {
if (!webhookId) return
delete typebot.webhooks.byId[webhookId]
const index = typebot.webhooks.allIds.indexOf(webhookId)
if (index !== -1) typebot.webhooks.allIds.splice(index, 1)
}

View File

@@ -6,6 +6,7 @@
"ownerId": "ckylsz8yy0335z31amvq0jwtt",
"publishedTypebotId": null,
"folderId": null,
"webhooks": { "byId": {}, "allIds": [] },
"blocks": {
"byId": {
"j24wz82YG3rjXMgrmCiTLy": {

View File

@@ -6,6 +6,7 @@
"ownerId": "ckyltekzq0533z31ad8opmacz",
"publishedTypebotId": null,
"folderId": null,
"webhooks": { "byId": {}, "allIds": [] },
"blocks": {
"byId": {
"kPupUcEn7TcBGKHUpgK2Q5": {

View File

@@ -0,0 +1,150 @@
{
"id": "typebot4",
"createdAt": "2022-01-21T07:55:14.727Z",
"updatedAt": "2022-01-21T07:55:14.727Z",
"name": "My typebot",
"ownerId": "user2",
"publishedTypebotId": null,
"folderId": null,
"webhooks": { "byId": {}, "allIds": [] },
"blocks": {
"byId": {
"3kH2sUjVThQDWmqdoKnGk5": {
"id": "3kH2sUjVThQDWmqdoKnGk5",
"title": "Start",
"stepIds": ["oxTsU2C1RX5QHuyY8qjHAM"],
"graphCoordinates": { "x": 42, "y": 13 }
},
"b9mSgu7RKmK4xuiTVQP5Me8": {
"id": "b9mSgu7RKmK4xuiTVQP5Me8",
"title": "Block #3",
"stepIds": ["ssLd2wjExS9qWRur4tZDU1Z"],
"graphCoordinates": { "x": 300, "y": 550 }
},
"bdFW2HHjMoEFmqHtFre9Xi8": {
"id": "bdFW2HHjMoEFmqHtFre9Xi8",
"title": "Block #2",
"stepIds": ["sgkADMK25y9P9V3vjwjBaac", "ssEiEECKSFkA44dGDceHxKw"],
"graphCoordinates": { "x": 121, "y": 227 }
}
},
"allIds": [
"3kH2sUjVThQDWmqdoKnGk5",
"bdFW2HHjMoEFmqHtFre9Xi8",
"b9mSgu7RKmK4xuiTVQP5Me8"
]
},
"steps": {
"byId": {
"oxTsU2C1RX5QHuyY8qjHAM": {
"id": "oxTsU2C1RX5QHuyY8qjHAM",
"type": "start",
"label": "Start",
"edgeId": "25yX9DnQgdafpdAjfAu5Fp",
"blockId": "3kH2sUjVThQDWmqdoKnGk5"
},
"sgkADMK25y9P9V3vjwjBaac": {
"id": "sgkADMK25y9P9V3vjwjBaac",
"type": "text",
"blockId": "bdFW2HHjMoEFmqHtFre9Xi8",
"content": {
"html": "<div>Ready?</div>",
"richText": [{ "type": "p", "children": [{ "text": "Ready?" }] }],
"plainText": "Ready?"
}
},
"ssEiEECKSFkA44dGDceHxKw": {
"id": "ssEiEECKSFkA44dGDceHxKw",
"type": "choice input",
"edgeId": "oxEEtym3NfDf34NCipzjRQ",
"blockId": "bdFW2HHjMoEFmqHtFre9Xi8",
"options": { "itemIds": ["q69Ex7LacPrH9QUMeosRnB"] }
},
"ssLd2wjExS9qWRur4tZDU1Z": {
"id": "ssLd2wjExS9qWRur4tZDU1Z",
"type": "Webhook",
"blockId": "b9mSgu7RKmK4xuiTVQP5Me8",
"options": { "webhookId": "4h4Kk3Q1qGy7gFzpZtWVpU" }
}
},
"allIds": [
"oxTsU2C1RX5QHuyY8qjHAM",
"sgkADMK25y9P9V3vjwjBaac",
"ssEiEECKSFkA44dGDceHxKw",
"ssLd2wjExS9qWRur4tZDU1Z"
]
},
"choiceItems": {
"byId": {
"q69Ex7LacPrH9QUMeosRnB": {
"id": "q69Ex7LacPrH9QUMeosRnB",
"stepId": "ssEiEECKSFkA44dGDceHxKw",
"content": "Go"
}
},
"allIds": ["q69Ex7LacPrH9QUMeosRnB"]
},
"variables": {
"byId": {
"oASkBtoLqkYNqeakcjZH4L": {
"id": "oASkBtoLqkYNqeakcjZH4L",
"name": "secret 1"
},
"4tvkRmf32wiTsXrYoqyhfr": {
"id": "4tvkRmf32wiTsXrYoqyhfr",
"name": "secret 2"
},
"jEg1FvkCU5S5owNAxXFsHL": {
"id": "jEg1FvkCU5S5owNAxXFsHL",
"name": "secret 3"
},
"rEoE1ehHzgx8X3d3UPGDHg": {
"id": "rEoE1ehHzgx8X3d3UPGDHg",
"name": "secret 4"
}
},
"allIds": [
"oASkBtoLqkYNqeakcjZH4L",
"4tvkRmf32wiTsXrYoqyhfr",
"jEg1FvkCU5S5owNAxXFsHL",
"rEoE1ehHzgx8X3d3UPGDHg"
]
},
"webhooks": {
"byId": {
"4h4Kk3Q1qGy7gFzpZtWVpU": { "id": "4h4Kk3Q1qGy7gFzpZtWVpU", "url": "" }
},
"allIds": ["4h4Kk3Q1qGy7gFzpZtWVpU"]
},
"edges": {
"byId": {
"25yX9DnQgdafpdAjfAu5Fp": {
"id": "25yX9DnQgdafpdAjfAu5Fp",
"to": { "blockId": "bdFW2HHjMoEFmqHtFre9Xi8" },
"from": {
"stepId": "oxTsU2C1RX5QHuyY8qjHAM",
"blockId": "3kH2sUjVThQDWmqdoKnGk5"
}
},
"oxEEtym3NfDf34NCipzjRQ": {
"id": "oxEEtym3NfDf34NCipzjRQ",
"to": { "blockId": "b9mSgu7RKmK4xuiTVQP5Me8" },
"from": {
"stepId": "ssEiEECKSFkA44dGDceHxKw",
"blockId": "bdFW2HHjMoEFmqHtFre9Xi8"
}
}
},
"allIds": ["25yX9DnQgdafpdAjfAu5Fp", "oxEEtym3NfDf34NCipzjRQ"]
},
"theme": {
"general": {
"font": "Open Sans",
"background": { "type": "None", "content": "#ffffff" }
}
},
"settings": {
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null
}

View File

@@ -0,0 +1,264 @@
{
"id": "typebot4",
"createdAt": "2022-01-21T07:55:14.727Z",
"updatedAt": "2022-01-21T07:55:14.727Z",
"name": "My typebot",
"ownerId": "user2",
"publishedTypebotId": null,
"folderId": null,
"blocks": {
"byId": {
"3kH2sUjVThQDWmqdoKnGk5": {
"id": "3kH2sUjVThQDWmqdoKnGk5",
"title": "Start",
"stepIds": ["oxTsU2C1RX5QHuyY8qjHAM"],
"graphCoordinates": { "x": 42, "y": 13 }
},
"b9mSgu7RKmK4xuiTVQP5Me8": {
"id": "b9mSgu7RKmK4xuiTVQP5Me8",
"title": "Block #3",
"stepIds": ["ssLd2wjExS9qWRur4tZDU1Z"],
"graphCoordinates": { "x": 300, "y": 550 }
},
"bdFW2HHjMoEFmqHtFre9Xi8": {
"id": "bdFW2HHjMoEFmqHtFre9Xi8",
"title": "Block #2",
"stepIds": ["sgkADMK25y9P9V3vjwjBaac", "ssEiEECKSFkA44dGDceHxKw"],
"graphCoordinates": { "x": 121, "y": 227 }
},
"bmz4rc8r19H2C6b7soxzby4": {
"id": "bmz4rc8r19H2C6b7soxzby4",
"title": "Block #4",
"graphCoordinates": { "x": 632, "y": 279 },
"stepIds": ["sgTWsRM1qF2YoYLuGo3Z3pU"]
}
},
"allIds": [
"3kH2sUjVThQDWmqdoKnGk5",
"bdFW2HHjMoEFmqHtFre9Xi8",
"b9mSgu7RKmK4xuiTVQP5Me8",
"bmz4rc8r19H2C6b7soxzby4"
]
},
"steps": {
"byId": {
"oxTsU2C1RX5QHuyY8qjHAM": {
"id": "oxTsU2C1RX5QHuyY8qjHAM",
"type": "start",
"label": "Start",
"edgeId": "25yX9DnQgdafpdAjfAu5Fp",
"blockId": "3kH2sUjVThQDWmqdoKnGk5"
},
"sgkADMK25y9P9V3vjwjBaac": {
"id": "sgkADMK25y9P9V3vjwjBaac",
"type": "text",
"blockId": "bdFW2HHjMoEFmqHtFre9Xi8",
"content": {
"html": "<div>Ready?</div>",
"richText": [{ "type": "p", "children": [{ "text": "Ready?" }] }],
"plainText": "Ready?"
}
},
"ssEiEECKSFkA44dGDceHxKw": {
"id": "ssEiEECKSFkA44dGDceHxKw",
"type": "choice input",
"edgeId": "oxEEtym3NfDf34NCipzjRQ",
"blockId": "bdFW2HHjMoEFmqHtFre9Xi8",
"options": { "itemIds": ["q69Ex7LacPrH9QUMeosRnB"] }
},
"ssLd2wjExS9qWRur4tZDU1Z": {
"id": "ssLd2wjExS9qWRur4tZDU1Z",
"type": "Webhook",
"blockId": "b9mSgu7RKmK4xuiTVQP5Me8",
"options": {
"webhookId": "4h4Kk3Q1qGy7gFzpZtWVpU",
"variablesForTest": {
"byId": {
"6pMn1xm1y3xWVSdJetMAJH": {
"id": "6pMn1xm1y3xWVSdJetMAJH",
"variableId": "oASkBtoLqkYNqeakcjZH4L",
"value": "secret1"
},
"ettAiB75uoFWnJyPS7gn5k": {
"id": "ettAiB75uoFWnJyPS7gn5k",
"variableId": "4tvkRmf32wiTsXrYoqyhfr",
"value": "secret2"
},
"kKpD3Q4YvFQ7CGWiZxJF4s": {
"id": "kKpD3Q4YvFQ7CGWiZxJF4s",
"variableId": "jEg1FvkCU5S5owNAxXFsHL",
"value": "secret3"
},
"xjUC5Q3msXCw9fwqpNdoSx": {
"id": "xjUC5Q3msXCw9fwqpNdoSx",
"variableId": "rEoE1ehHzgx8X3d3UPGDHg",
"value": "secret4"
}
},
"allIds": [
"6pMn1xm1y3xWVSdJetMAJH",
"ettAiB75uoFWnJyPS7gn5k",
"kKpD3Q4YvFQ7CGWiZxJF4s",
"xjUC5Q3msXCw9fwqpNdoSx"
]
},
"responseVariableMapping": {
"byId": {
"o53h6M1sgHJfDTY5C3YEaT": {
"id": "o53h6M1sgHJfDTY5C3YEaT",
"bodyPath": "data[0].name",
"variableId": "4kVx5uf8W1XP6WsfJEvt8v"
}
},
"allIds": ["o53h6M1sgHJfDTY5C3YEaT"]
}
},
"edgeId": "81SjKnxuUgrPmXvvJJihHM"
},
"sgTWsRM1qF2YoYLuGo3Z3pU": {
"id": "sgTWsRM1qF2YoYLuGo3Z3pU",
"blockId": "bmz4rc8r19H2C6b7soxzby4",
"type": "text",
"content": {
"html": "<div>His name is {{Name}}</div>",
"richText": [
{ "type": "p", "children": [{ "text": "His name is {{Name}}" }] }
],
"plainText": "His name is {{Name}}"
}
}
},
"allIds": [
"oxTsU2C1RX5QHuyY8qjHAM",
"sgkADMK25y9P9V3vjwjBaac",
"ssEiEECKSFkA44dGDceHxKw",
"ssLd2wjExS9qWRur4tZDU1Z",
"sgTWsRM1qF2YoYLuGo3Z3pU"
]
},
"choiceItems": {
"byId": {
"q69Ex7LacPrH9QUMeosRnB": {
"id": "q69Ex7LacPrH9QUMeosRnB",
"stepId": "ssEiEECKSFkA44dGDceHxKw",
"content": "Go"
}
},
"allIds": ["q69Ex7LacPrH9QUMeosRnB"]
},
"webhooks": { "byId": {}, "allIds": [] },
"variables": {
"byId": {
"4tvkRmf32wiTsXrYoqyhfr": {
"id": "4tvkRmf32wiTsXrYoqyhfr",
"name": "secret 2",
"value": "secret2"
},
"jEg1FvkCU5S5owNAxXFsHL": {
"id": "jEg1FvkCU5S5owNAxXFsHL",
"name": "secret 3",
"value": "secret3"
},
"oASkBtoLqkYNqeakcjZH4L": {
"id": "oASkBtoLqkYNqeakcjZH4L",
"name": "secret 1",
"value": "secret1"
},
"rEoE1ehHzgx8X3d3UPGDHg": {
"id": "rEoE1ehHzgx8X3d3UPGDHg",
"name": "secret 4",
"value": "secret4"
},
"4kVx5uf8W1XP6WsfJEvt8v": {
"id": "4kVx5uf8W1XP6WsfJEvt8v",
"name": "Name"
}
},
"allIds": [
"oASkBtoLqkYNqeakcjZH4L",
"4tvkRmf32wiTsXrYoqyhfr",
"jEg1FvkCU5S5owNAxXFsHL",
"rEoE1ehHzgx8X3d3UPGDHg",
"4kVx5uf8W1XP6WsfJEvt8v"
]
},
"webhooks": {
"byId": {
"4h4Kk3Q1qGy7gFzpZtWVpU": {
"id": "4h4Kk3Q1qGy7gFzpZtWVpU",
"url": "http://localhost:3000/api/mock/webhook",
"queryParams": {
"byId": {
"hwGB11cA7RaYnaqH7gYyuQ": {
"id": "hwGB11cA7RaYnaqH7gYyuQ",
"key": "firstParam",
"value": "{{secret 1}}"
},
"6ux2FZjhNc4vfqNUDuCkxn": {
"id": "6ux2FZjhNc4vfqNUDuCkxn",
"key": "secondParam",
"value": "{{secret 2}}"
}
},
"allIds": ["hwGB11cA7RaYnaqH7gYyuQ", "6ux2FZjhNc4vfqNUDuCkxn"]
},
"headers": {
"byId": {
"ayTB2cFRKMo6oH9t9KS8SA": {
"id": "ayTB2cFRKMo6oH9t9KS8SA",
"key": "Custom-Typebot",
"value": "{{secret 3}}"
}
},
"allIds": ["ayTB2cFRKMo6oH9t9KS8SA"]
},
"method": "POST",
"body": "{ \"customField\": \"{{secret 4}}\" }"
}
},
"allIds": ["4h4Kk3Q1qGy7gFzpZtWVpU"]
},
"edges": {
"byId": {
"25yX9DnQgdafpdAjfAu5Fp": {
"id": "25yX9DnQgdafpdAjfAu5Fp",
"to": { "blockId": "bdFW2HHjMoEFmqHtFre9Xi8" },
"from": {
"stepId": "oxTsU2C1RX5QHuyY8qjHAM",
"blockId": "3kH2sUjVThQDWmqdoKnGk5"
}
},
"oxEEtym3NfDf34NCipzjRQ": {
"id": "oxEEtym3NfDf34NCipzjRQ",
"to": { "blockId": "b9mSgu7RKmK4xuiTVQP5Me8" },
"from": {
"stepId": "ssEiEECKSFkA44dGDceHxKw",
"blockId": "bdFW2HHjMoEFmqHtFre9Xi8"
}
},
"81SjKnxuUgrPmXvvJJihHM": {
"from": {
"blockId": "b9mSgu7RKmK4xuiTVQP5Me8",
"stepId": "ssLd2wjExS9qWRur4tZDU1Z"
},
"to": { "blockId": "bmz4rc8r19H2C6b7soxzby4" },
"id": "81SjKnxuUgrPmXvvJJihHM"
}
},
"allIds": [
"25yX9DnQgdafpdAjfAu5Fp",
"oxEEtym3NfDf34NCipzjRQ",
"81SjKnxuUgrPmXvvJJihHM"
]
},
"theme": {
"general": {
"font": "Open Sans",
"background": { "type": "None", "content": "#ffffff" }
}
},
"settings": {
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 KiB

View File

@@ -6,6 +6,7 @@
"ownerId": "ckylsbdf60088z31ayqytest6",
"publishedTypebotId": null,
"folderId": null,
"webhooks": { "byId": {}, "allIds": [] },
"blocks": {
"byId": {
"2x83WHtEBkiv7pk7KgqJwZ": {

View File

@@ -6,6 +6,7 @@
"ownerId": "ckymkff1100362z1a85juyoa8",
"publishedTypebotId": null,
"folderId": null,
"webhooks": { "byId": {}, "allIds": [] },
"blocks": {
"byId": {
"bsVJfEW7EZrUnAi9s5ev17": {

View File

@@ -6,6 +6,7 @@
"ownerId": "ckylrpsmt0006fn1ah956d0z1",
"publishedTypebotId": null,
"folderId": null,
"webhooks": { "byId": {}, "allIds": [] },
"blocks": {
"byId": {
"kmUzhRFzSKjkaipYNcku9S": {

View File

@@ -6,6 +6,7 @@
"ownerId": "ckylsr4fi0220z31apbinpy9d",
"publishedTypebotId": null,
"folderId": null,
"webhooks": { "byId": {}, "allIds": [] },
"blocks": {
"byId": {
"weeBMMXxNKwEonMfDX8Z5k": {

View File

@@ -75,6 +75,7 @@ export const parseTestTypebot = ({
publishedTypebotId: null,
updatedAt: new Date(),
variables: { byId: {}, allIds: [] },
webhooks: { byId: {}, allIds: [] },
edges: {
byId: {
edge1: {

View File

@@ -52,7 +52,7 @@ describe('Image bubbles', () => {
.should('include', unsplashImageSrc)
})
it.only('should import giphy gifs correctly', () => {
it('should import giphy gifs correctly', () => {
cy.findByRole('button', { name: 'Giphy' }).click()
cy.findAllByRole('img').eq(3).click()
cy.findAllByRole('img')

View File

@@ -0,0 +1,96 @@
import { preventUserFromRefreshing } from 'cypress/plugins/utils'
import { getIframeBody } from 'cypress/support'
describe('Webhook step', () => {
beforeEach(() => {
cy.task('seed')
cy.signOut()
})
afterEach(() => {
cy.window().then((win) => {
win.removeEventListener('beforeunload', preventUserFromRefreshing)
})
})
describe('Configuration', () => {
it('configuration is working', () => {
cy.loadTypebotFixtureInDatabase('typebots/integrations/webhook.json')
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot4/edit')
cy.findByText('Configure...').click()
cy.findByRole('button', { name: 'GET' }).click()
cy.findByRole('menuitem', { name: 'POST' }).click({ force: true })
cy.findByPlaceholderText('Your Webhook URL...').type(
`${Cypress.env('SITE_NAME')}/api/mock/webhook`
)
cy.findByRole('button', { name: 'Query params' }).click()
cy.findByRole('textbox', { name: 'Key:' }).type('firstParam')
cy.findByRole('textbox', { name: 'Value:' }).type('{{secret 1}}', {
parseSpecialCharSequences: false,
})
cy.findByRole('button', { name: 'Add a param' }).click()
cy.findAllByRole('textbox', { name: 'Key:' }).last().type('secondParam')
cy.findAllByRole('textbox', { name: 'Value:' })
.last()
.type('{{secret 2}}', {
parseSpecialCharSequences: false,
})
cy.findByRole('button', { name: 'Headers' }).click()
cy.findAllByRole('textbox', { name: 'Key:' })
.last()
.type('Custom-Typebot')
cy.findAllByRole('textbox', { name: 'Value:' })
.last()
.type('{{secret 3}}', {
parseSpecialCharSequences: false,
})
cy.findByRole('button', { name: 'Body' }).click()
cy.findByTestId('code-editor').type('{ "customField": "{{secret 4}}" }', {
parseSpecialCharSequences: false,
waitForAnimations: false,
})
cy.findByRole('button', { name: 'Variable values for test' }).click()
addTestVariable('secret 1', 'secret1')
cy.findByRole('button', { name: 'Add an entry' }).click()
addTestVariable('secret 2', 'secret2')
cy.findByRole('button', { name: 'Add an entry' }).click()
addTestVariable('secret 3', 'secret3')
cy.findByRole('button', { name: 'Add an entry' }).click()
addTestVariable('secret 4', 'secret4')
cy.findByRole('button', { name: 'Test the request' }).click()
cy.findAllByTestId('code-editor')
.should('have.length', 2)
.last()
.should('contain.text', '"statusCode": 200')
cy.findByRole('button', { name: 'Save in variables' }).click()
cy.findByPlaceholderText('Select the data').click()
cy.findByRole('menuitem', { name: 'data[0].name' }).click()
})
})
describe('Preview', () => {
it('should correctly send the request', () => {
cy.loadTypebotFixtureInDatabase(
'typebots/integrations/webhookPreview.json'
)
cy.signIn('test2@gmail.com')
cy.visit('/typebots/typebot4/edit')
cy.findByRole('button', { name: 'Preview' }).click()
getIframeBody().findByRole('button', { name: 'Go' }).click()
getIframeBody().findByText('His name is John').should('exist')
})
})
})
const addTestVariable = (name: string, value: string) => {
cy.findAllByTestId('variables-input').last().click()
cy.findByRole('menuitem', { name }).click()
cy.findAllByRole('textbox', { name: 'Test value:' }).last().type(value)
}

View File

@@ -22,14 +22,14 @@ describe('Condition step', () => {
cy.findByTestId('variables-input').click()
cy.findByRole('menuitem', { name: 'Age' }).click()
cy.findByRole('button', { name: 'Equal to' }).click()
cy.findByRole('button', { name: 'Select an operator' }).click()
cy.findByRole('menuitem', { name: 'Greater than' }).click()
cy.findByPlaceholderText('Type a value...').type('80')
cy.findByRole('button', { name: 'Add a comparison' }).click()
cy.findAllByTestId('variables-input').last().click()
cy.findByRole('menuitem', { name: 'Age' }).click()
cy.findByRole('button', { name: 'Equal to' }).click()
cy.findByRole('button', { name: 'Select an operator' }).click()
cy.findByRole('menuitem', { name: 'Less than' }).click()
cy.findAllByPlaceholderText('Type a value...').last().type('100')
@@ -37,7 +37,7 @@ describe('Condition step', () => {
cy.findByTestId('variables-input').click()
cy.findByRole('menuitem', { name: 'Age' }).click()
cy.findByRole('button', { name: 'Equal to' }).click()
cy.findByRole('button', { name: 'Select an operator' }).click()
cy.findByRole('menuitem', { name: 'Greater than' }).click()
cy.findByPlaceholderText('Type a value...').type('20')

View File

@@ -12,6 +12,9 @@
"dependencies": {
"@chakra-ui/css-reset": "^1.1.1",
"@chakra-ui/react": "^1.7.4",
"@codemirror/basic-setup": "^0.19.1",
"@codemirror/lang-json": "^0.19.1",
"@codemirror/text": "^0.19.6",
"@dnd-kit/core": "^4.0.3",
"@dnd-kit/sortable": "^5.1.0",
"@emotion/react": "^11.7.1",
@@ -36,6 +39,7 @@
"framer-motion": "^4",
"google-auth-library": "^7.11.0",
"google-spreadsheet": "^3.2.0",
"got": "^12.0.1",
"htmlparser2": "^7.2.0",
"immer": "^9.0.7",
"js-video-url-parser": "^0.5.1",

View File

@@ -8,6 +8,7 @@ import 'assets/styles/routerProgressBar.css'
import 'assets/styles/plate.css'
import 'focus-visible/dist/focus-visible'
import 'assets/styles/submissionsTable.css'
import 'assets/styles/codeMirror.css'
const App = ({ Component, pageProps }: AppProps) => {
useRouterProgressBar()

View File

@@ -0,0 +1,26 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils'
const handler = (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const firstParam = req.query.firstParam.toString()
const secondParam = req.query.secondParam.toString()
const customHeader = req.headers['custom-typebot']
const { body } = req
if (
body.customField === 'secret4' &&
customHeader === 'secret3' &&
secondParam === 'secret2' &&
firstParam === 'secret1'
) {
return res.status(200).send([
{ name: 'John', age: 21 },
{ name: 'Tim', age: 52 },
])
}
return res.status(400).send({ message: 'Bad request' })
}
return methodNotAllowed(res)
}
export default handler

View File

@@ -0,0 +1,104 @@
import prisma from 'libs/prisma'
import {
KeyValue,
Table,
Typebot,
Variable,
Webhook,
WebhookResponse,
} from 'models'
import { parseVariables } from 'bot-engine'
import { NextApiRequest, NextApiResponse } from 'next'
import got, { Method, Headers, HTTPError } from 'got'
import { methodNotAllowed } from 'utils'
import { stringify } from 'qs'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'POST') {
const typebotId = req.query.typebotId.toString()
const webhookId = req.query.id.toString()
const variables = JSON.parse(req.body).variables as Table<Variable>
const typebot = await prisma.typebot.findUnique({
where: { id: typebotId },
})
const webhook = (typebot as Typebot).webhooks.byId[webhookId]
const result = await executeWebhook(webhook, variables)
return res.status(200).send(result)
}
return methodNotAllowed(res)
}
const executeWebhook = async (
webhook: Webhook,
variables: Table<Variable>
): Promise<WebhookResponse> => {
if (!webhook.url || !webhook.method)
return {
statusCode: 400,
data: { message: `Webhook doesn't have url or method` },
}
const headers = convertKeyValueTableToObject(webhook.headers, variables) as
| Headers
| undefined
const queryParams = stringify(
convertKeyValueTableToObject(webhook.queryParams, variables)
)
const contentType = headers ? headers['Content-Type'] : undefined
try {
const response = await got(
parseVariables({ text: webhook.url + `?${queryParams}`, variables }),
{
method: webhook.method as Method,
headers,
json:
contentType !== 'x-www-form-urlencoded' && webhook.body
? JSON.parse(parseVariables({ text: webhook.body, variables }))
: undefined,
form:
contentType === 'x-www-form-urlencoded' && webhook.body
? JSON.parse(parseVariables({ text: webhook.body, variables }))
: undefined,
}
)
return {
statusCode: response.statusCode,
data: parseBody(response.body),
}
} catch (error) {
if (error instanceof HTTPError) {
return {
statusCode: error.response.statusCode,
data: parseBody(error.response.body as string),
}
}
return {
statusCode: 500,
data: { message: `Error from Typebot server: ${error}` },
}
}
}
const parseBody = (body: string) => {
try {
return JSON.parse(body)
} catch (err) {
return body
}
}
const convertKeyValueTableToObject = (
keyValues: Table<KeyValue> | undefined,
variables: Table<Variable>
) => {
if (!keyValues) return
return keyValues.allIds.reduce((object, id) => {
const item = keyValues.byId[id]
if (!item.key) return {}
return {
...object,
[item.key]: parseVariables({ text: item.value, variables }),
}
}, {})
}
export default handler

View File

@@ -2,6 +2,7 @@ import { sendRequest } from 'utils'
import { stringify } from 'qs'
import useSWR from 'swr'
import { fetcher } from './utils'
import { Table, Variable, VariableForTest, WebhookResponse } from 'models'
export const getGoogleSheetsConsentScreenUrl = (
redirectUrl: string,
@@ -64,3 +65,68 @@ export const useSheets = ({
mutate,
}
}
export const executeWebhook = (
typebotId: string,
webhookId: string,
variables: Table<Variable>
) =>
sendRequest<WebhookResponse>({
url: `/api/typebots/${typebotId}/webhooks/${webhookId}/execute`,
method: 'POST',
body: {
variables,
},
})
export const convertVariableForTestToVariables = (
variablesForTest: Table<VariableForTest> | undefined,
variables: Table<Variable>
): Table<Variable> => {
if (!variablesForTest) return { byId: {}, allIds: [] }
return {
byId: {
...variables.byId,
...variablesForTest.allIds.reduce((obj, id) => {
const variableForTest = variablesForTest.byId[id]
if (!variableForTest.variableId) return {}
const variable = variables.byId[variableForTest.variableId ?? '']
return {
...obj,
[variableForTest.variableId]: {
...variable,
value: variableForTest.value,
},
}
}, {}),
},
allIds: variables.allIds,
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getDeepKeys = (obj: any): string[] => {
let keys: string[] = []
for (const key in obj) {
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
const subkeys = getDeepKeys(obj[key])
keys = keys.concat(
subkeys.map(function (subkey) {
return key + '.' + subkey
})
)
} else if (Array.isArray(obj[key])) {
for (let i = 0; i < obj[key].length; i++) {
const subkeys = getDeepKeys(obj[key][i])
keys = keys.concat(
subkeys.map(function (subkey) {
return key + '[' + i + ']' + '.' + subkey
})
)
}
} else {
keys.push(key)
}
}
return keys
}

View File

@@ -198,7 +198,10 @@ export const parseNewTypebot = ({
ownerId: string
folderId: string | null
name: string
}): Partial<Typebot> => {
}): Omit<
Typebot,
'createdAt' | 'updatedAt' | 'id' | 'publishedTypebotId' | 'publicId'
> => {
const startBlockId = shortId.generate()
const startStepId = shortId.generate()
const startStep: StartStep = {
@@ -235,6 +238,7 @@ export const parseNewTypebot = ({
choiceItems: { byId: {}, allIds: [] },
variables: { byId: {}, allIds: [] },
edges: { byId: {}, allIds: [] },
webhooks: { byId: {}, allIds: [] },
theme,
settings,
}