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

@ -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>
)