2
0

refactor(editor): ♻️ Undo / Redo buttons + structure refacto

Yet another huge refacto... While implementing undo and redo features I understood that I updated the stored typebot too many times (i.e. on each key input) so I had to rethink it entirely. I also moved around some files.
This commit is contained in:
Baptiste Arnaud
2022-02-02 08:05:02 +01:00
parent fc1d654772
commit 8a350eee6c
153 changed files with 1512 additions and 1352 deletions

View File

@ -0,0 +1,56 @@
import {
Portal,
PopoverContent,
PopoverArrow,
PopoverBody,
} from '@chakra-ui/react'
import { ImageUploadContent } from 'components/shared/ImageUploadContent'
import {
BubbleStep,
BubbleStepContent,
BubbleStepType,
TextBubbleStep,
} from 'models'
import { useRef } from 'react'
import { VideoUploadContent } from './VideoUploadContent'
type Props = {
step: Exclude<BubbleStep, TextBubbleStep>
onContentChange: (content: BubbleStepContent) => void
}
export const MediaBubblePopoverContent = (props: Props) => {
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
return (
<Portal>
<PopoverContent onMouseDown={handleMouseDown} w="500px">
<PopoverArrow />
<PopoverBody ref={ref} shadow="lg">
<MediaBubbleContent {...props} />
</PopoverBody>
</PopoverContent>
</Portal>
)
}
export const MediaBubbleContent = ({ step, onContentChange }: Props) => {
const handleImageUrlChange = (url: string) => onContentChange({ url })
switch (step.type) {
case BubbleStepType.IMAGE: {
return (
<ImageUploadContent
url={step.content?.url}
onSubmit={handleImageUrlChange}
/>
)
}
case BubbleStepType.VIDEO: {
return (
<VideoUploadContent content={step.content} onSubmit={onContentChange} />
)
}
}
}

View File

@ -0,0 +1,37 @@
import { Stack, Text } from '@chakra-ui/react'
import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton/InputWithVariableButton'
import { VideoBubbleContent, VideoBubbleContentType } from 'models'
import urlParser from 'js-video-url-parser/lib/base'
import 'js-video-url-parser/lib/provider/vimeo'
import 'js-video-url-parser/lib/provider/youtube'
import { isDefined } from 'utils'
type Props = {
content?: VideoBubbleContent
onSubmit: (content: VideoBubbleContent) => void
}
export const VideoUploadContent = ({ content, onSubmit }: Props) => {
const handleUrlChange = (url: string) => {
const info = urlParser.parse(url)
return isDefined(info) && info.provider && info.id
? onSubmit({
type: info.provider as VideoBubbleContentType,
url,
id: info.id,
})
: onSubmit({ type: VideoBubbleContentType.URL, url })
}
return (
<Stack p="2">
<InputWithVariableButton
placeholder="Paste the video link..."
initialValue={content?.url ?? ''}
onChange={handleUrlChange}
/>
<Text fontSize="sm" color="gray.400" textAlign="center">
Works with Youtube, Vimeo and others
</Text>
</Stack>
)
}

View File

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

View File

@ -0,0 +1,34 @@
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
ModalFooter,
ModalBodyProps,
} from '@chakra-ui/react'
import React from 'react'
type Props = {
isOpen: boolean
onClose: () => void
}
export const SettingsModal = ({
isOpen,
onClose,
...props
}: Props & ModalBodyProps) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader />
<ModalCloseButton />
<ModalBody {...props}>{props.children}</ModalBody>
<ModalFooter />
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,206 @@
import {
PopoverContent,
PopoverArrow,
PopoverBody,
useEventListener,
Portal,
IconButton,
} from '@chakra-ui/react'
import { ExpandIcon } from 'assets/icons'
import {
InputStepType,
IntegrationStepType,
LogicStepType,
Step,
StepOptions,
TextBubbleStep,
Webhook,
} from 'models'
import { useRef } from 'react'
import {
TextInputSettingsBody,
NumberInputSettingsBody,
EmailInputSettingsBody,
UrlInputSettingsBody,
DateInputSettingsBody,
} from './bodies'
import { ChoiceInputSettingsBody } from './bodies/ChoiceInputSettingsBody'
import { ConditionSettingsBody } from './bodies/ConditionSettingsBody'
import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { RedirectSettings } from './bodies/RedirectSettings'
import { SetVariableSettings } from './bodies/SetVariableSettings'
import { WebhookSettings } from './bodies/WebhookSettings'
type Props = {
step: Exclude<Step, TextBubbleStep>
webhook?: Webhook
onExpandClick: () => void
onOptionsChange: (options: StepOptions) => void
onWebhookChange: (updates: Partial<Webhook>) => void
onTestRequestClick: () => void
}
export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
const ref = useRef<HTMLDivElement | null>(null)
const handleMouseDown = (e: React.MouseEvent) => e.stopPropagation()
const handleMouseWheel = (e: WheelEvent) => {
e.stopPropagation()
}
useEventListener('wheel', handleMouseWheel, ref.current)
return (
<Portal>
<PopoverContent onMouseDown={handleMouseDown} pos="relative">
<PopoverArrow />
<PopoverBody
py="6"
overflowY="scroll"
maxH="400px"
ref={ref}
shadow="lg"
>
<StepSettings {...props} />
</PopoverBody>
<IconButton
pos="absolute"
top="5px"
right="5px"
aria-label="expand"
icon={<ExpandIcon />}
size="xs"
onClick={onExpandClick}
/>
</PopoverContent>
</Portal>
)
}
export const StepSettings = ({
step,
webhook,
onOptionsChange,
onWebhookChange,
onTestRequestClick,
}: {
step: Step
webhook?: Webhook
onOptionsChange: (options: StepOptions) => void
onWebhookChange: (updates: Partial<Webhook>) => void
onTestRequestClick: () => void
}) => {
switch (step.type) {
case InputStepType.TEXT: {
return (
<TextInputSettingsBody
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case InputStepType.NUMBER: {
return (
<NumberInputSettingsBody
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case InputStepType.EMAIL: {
return (
<EmailInputSettingsBody
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case InputStepType.URL: {
return (
<UrlInputSettingsBody
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case InputStepType.DATE: {
return (
<DateInputSettingsBody
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case InputStepType.PHONE: {
return (
<PhoneNumberSettingsBody
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case InputStepType.CHOICE: {
return (
<ChoiceInputSettingsBody
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case LogicStepType.SET_VARIABLE: {
return (
<SetVariableSettings
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case LogicStepType.CONDITION: {
return (
<ConditionSettingsBody
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case LogicStepType.REDIRECT: {
return (
<RedirectSettings
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case IntegrationStepType.GOOGLE_SHEETS: {
return (
<GoogleSheetsSettingsBody
options={step.options}
onOptionsChange={onOptionsChange}
stepId={step.id}
/>
)
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
return (
<GoogleAnalyticsSettings
options={step.options}
onOptionsChange={onOptionsChange}
/>
)
}
case IntegrationStepType.WEBHOOK: {
return (
<WebhookSettings
options={step.options}
webhook={webhook as Webhook}
onOptionsChange={onOptionsChange}
onWebhookChange={onWebhookChange}
onTestRequestClick={onTestRequestClick}
/>
)
}
default: {
return <></>
}
}
}

View File

@ -0,0 +1,55 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { ChoiceInputOptions, Variable } from 'models'
import React from 'react'
type ChoiceInputSettingsBodyProps = {
options?: ChoiceInputOptions
onOptionsChange: (options: ChoiceInputOptions) => void
}
export const ChoiceInputSettingsBody = ({
options,
onOptionsChange,
}: ChoiceInputSettingsBodyProps) => {
const handleIsMultipleChange = (isMultipleChoice: boolean) =>
options && onOptionsChange({ ...options, isMultipleChoice })
const handleButtonLabelChange = (buttonLabel: string) =>
options && onOptionsChange({ ...options, buttonLabel })
const handleVariableChange = (variable?: Variable) =>
options && onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<SwitchWithLabel
id={'is-multiple'}
label={'Multiple choice?'}
initialValue={options?.isMultipleChoice ?? false}
onCheckChange={handleIsMultipleChange}
/>
{options?.isMultipleChoice && (
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="button"
initialValue={options?.buttonLabel ?? 'Send'}
onChange={handleButtonLabelChange}
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options?.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,50 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton/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
initialValue={item.value ?? ''}
onChange={handleChangeValue}
placeholder="Type a value..."
/>
)}
</Stack>
)
}

View File

@ -0,0 +1,39 @@
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 { ComparisonItem } from './ComparisonsItem'
type ConditionSettingsBodyProps = {
options: ConditionOptions
onOptionsChange: (options: ConditionOptions) => void
}
export const ConditionSettingsBody = ({
options,
onOptionsChange,
}: ConditionSettingsBodyProps) => {
const handleComparisonsChange = (comparisons: Table<Comparison>) =>
onOptionsChange({ ...options, comparisons })
const handleLogicalOperatorChange = (logicalOperator: LogicalOperator) =>
onOptionsChange({ ...options, logicalOperator })
return (
<TableList<Comparison>
initialItems={options.comparisons}
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

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

View File

@ -0,0 +1,89 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { DateInputOptions, Variable } from 'models'
import React from 'react'
type DateInputSettingsBodyProps = {
options: DateInputOptions
onOptionsChange: (options: DateInputOptions) => void
}
export const DateInputSettingsBody = ({
options,
onOptionsChange,
}: DateInputSettingsBodyProps) => {
const handleFromChange = (from: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, from } })
const handleToChange = (to: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, to } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options?.labels, button } })
const handleIsRangeChange = (isRange: boolean) =>
onOptionsChange({ ...options, isRange })
const handleHasTimeChange = (hasTime: boolean) =>
onOptionsChange({ ...options, hasTime })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<SwitchWithLabel
id="is-range"
label={'Is range?'}
initialValue={options.isRange}
onCheckChange={handleIsRangeChange}
/>
<SwitchWithLabel
id="with-time"
label={'With time?'}
initialValue={options.isRange}
onCheckChange={handleHasTimeChange}
/>
{options.isRange && (
<Stack>
<FormLabel mb="0" htmlFor="from">
From label:
</FormLabel>
<DebouncedInput
id="from"
initialValue={options.labels.from}
onChange={handleFromChange}
/>
</Stack>
)}
{options?.isRange && (
<Stack>
<FormLabel mb="0" htmlFor="to">
To label:
</FormLabel>
<DebouncedInput
id="to"
initialValue={options.labels.to}
onChange={handleToChange}
/>
</Stack>
)}
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="button"
initialValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,56 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { EmailInputOptions, Variable } from 'models'
import React from 'react'
type EmailInputSettingsBodyProps = {
options: EmailInputOptions
onOptionsChange: (options: EmailInputOptions) => void
}
export const EmailInputSettingsBody = ({
options,
onOptionsChange,
}: EmailInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
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 })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="button"
initialValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,117 @@
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
FormLabel,
Stack,
Tag,
} from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton/InputWithVariableButton'
import { GoogleAnalyticsOptions } from 'models'
import React from 'react'
type Props = {
options?: GoogleAnalyticsOptions
onOptionsChange: (options: GoogleAnalyticsOptions) => void
}
export const GoogleAnalyticsSettings = ({
options,
onOptionsChange,
}: Props) => {
const handleTrackingIdChange = (trackingId: string) =>
onOptionsChange({ ...options, trackingId })
const handleCategoryChange = (category: string) =>
onOptionsChange({ ...options, category })
const handleActionChange = (action: string) =>
onOptionsChange({ ...options, action })
const handleLabelChange = (label: string) =>
onOptionsChange({ ...options, label })
const handleValueChange = (value?: string) =>
onOptionsChange({
...options,
value: value ? parseFloat(value) : undefined,
})
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="tracking-id">
Tracking ID:
</FormLabel>
<DebouncedInput
id="tracking-id"
initialValue={options?.trackingId ?? ''}
placeholder="G-123456..."
onChange={handleTrackingIdChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="category">
Event category:
</FormLabel>
<InputWithVariableButton
id="category"
initialValue={options?.category ?? ''}
placeholder="Example: Typebot"
onChange={handleCategoryChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="action">
Event action:
</FormLabel>
<InputWithVariableButton
id="action"
initialValue={options?.action ?? ''}
placeholder="Example: Submit email"
onChange={handleActionChange}
/>
</Stack>
<Accordion allowToggle>
<AccordionItem>
<h2>
<AccordionButton>
<Box flex="1" textAlign="left">
Advanced
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4} as={Stack} spacing="6">
<Stack>
<FormLabel mb="0" htmlFor="label">
Event label <Tag>Optional</Tag>:
</FormLabel>
<InputWithVariableButton
id="label"
initialValue={options?.label ?? ''}
placeholder="Example: Campaign Z"
onChange={handleLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="value">
Event value <Tag>Optional</Tag>:
</FormLabel>
<InputWithVariableButton
id="value"
initialValue={options?.value?.toString() ?? ''}
placeholder="Example: 0"
onChange={handleValueChange}
/>
</Stack>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
)
}

View File

@ -0,0 +1,35 @@
import { Stack } from '@chakra-ui/react'
import { DropdownList } from 'components/shared/DropdownList'
import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton/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

@ -0,0 +1,233 @@
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 {
Cell,
defaultTable,
ExtractingCell,
GoogleSheetsAction,
GoogleSheetsGetOptions,
GoogleSheetsInsertRowOptions,
GoogleSheetsOptions,
GoogleSheetsUpdateRowOptions,
Table,
} from 'models'
import React, { useMemo } from 'react'
import {
getGoogleSheetsConsentScreenUrl,
Sheet,
useSheets,
} from 'services/integrations'
import { isDefined } from 'utils'
import { SheetsDropdown } from './SheetsDropdown'
import { SpreadsheetsDropdown } from './SpreadsheetDropdown'
import { CellWithValueStack } from './CellWithValueStack'
import { CellWithVariableIdStack } from './CellWithVariableIdStack'
type Props = {
options: GoogleSheetsOptions
onOptionsChange: (options: GoogleSheetsOptions) => void
stepId: string
}
export const GoogleSheetsSettingsBody = ({
options,
onOptionsChange,
stepId,
}: Props) => {
const { save, hasUnsavedChanges } = useTypebot()
const { sheets, isLoading } = useSheets({
credentialsId: options?.credentialsId,
spreadsheetId: options?.spreadsheetId,
})
const sheet = useMemo(
() => sheets?.find((s) => s.id === options?.sheetId),
[sheets, options?.sheetId]
)
const handleCredentialsIdChange = (credentialsId: string) =>
onOptionsChange({ ...options, credentialsId })
const handleSpreadsheetIdChange = (spreadsheetId: string) =>
onOptionsChange({ ...options, spreadsheetId })
const handleSheetIdChange = (sheetId: string) =>
onOptionsChange({ ...options, sheetId })
const handleActionChange = (action: GoogleSheetsAction) => {
switch (action) {
case GoogleSheetsAction.GET: {
const newOptions: GoogleSheetsGetOptions = {
...options,
action,
cellsToExtract: defaultTable,
}
return onOptionsChange({ ...newOptions })
}
case GoogleSheetsAction.INSERT_ROW: {
const newOptions: GoogleSheetsInsertRowOptions = {
...options,
action,
cellsToInsert: defaultTable,
}
return onOptionsChange({ ...newOptions })
}
case GoogleSheetsAction.UPDATE_ROW: {
const newOptions: GoogleSheetsUpdateRowOptions = {
...options,
action,
cellsToUpsert: defaultTable,
}
return onOptionsChange({ ...newOptions })
}
}
}
const handleCreateNewClick = async () => {
if (hasUnsavedChanges) {
const errorToastId = await save()
if (errorToastId) return
}
const linkElement = document.createElement('a')
linkElement.href = getGoogleSheetsConsentScreenUrl(
window.location.href,
stepId
)
linkElement.click()
}
return (
<Stack>
<CredentialsDropdown
type={CredentialsType.GOOGLE_SHEETS}
currentCredentialsId={options?.credentialsId}
onCredentialsSelect={handleCredentialsIdChange}
onCreateNewClick={handleCreateNewClick}
/>
{options?.credentialsId && (
<SpreadsheetsDropdown
credentialsId={options.credentialsId}
spreadsheetId={options.spreadsheetId}
onSelectSpreadsheetId={handleSpreadsheetIdChange}
/>
)}
{options?.spreadsheetId && options.credentialsId && (
<SheetsDropdown
sheets={sheets ?? []}
isLoading={isLoading}
sheetId={options.sheetId}
onSelectSheetId={handleSheetIdChange}
/>
)}
{options?.spreadsheetId &&
options.credentialsId &&
isDefined(options.sheetId) && (
<>
<Divider />
<DropdownList<GoogleSheetsAction>
currentItem={'action' in options ? options.action : undefined}
onItemSelect={handleActionChange}
items={Object.values(GoogleSheetsAction)}
placeholder="Select an operation"
/>
</>
)}
{sheet && 'action' in options && (
<ActionOptions
options={options}
sheet={sheet}
onOptionsChange={onOptionsChange}
/>
)}
</Stack>
)
}
const ActionOptions = ({
options,
sheet,
onOptionsChange,
}: {
options:
| GoogleSheetsGetOptions
| GoogleSheetsInsertRowOptions
| GoogleSheetsUpdateRowOptions
sheet: Sheet
onOptionsChange: (options: GoogleSheetsOptions) => void
}) => {
const handleInsertColumnsChange = (cellsToInsert: Table<Cell>) =>
onOptionsChange({ ...options, cellsToInsert } as GoogleSheetsOptions)
const handleUpsertColumnsChange = (cellsToUpsert: Table<Cell>) =>
onOptionsChange({ ...options, cellsToUpsert } as GoogleSheetsOptions)
const handleReferenceCellChange = (referenceCell: Cell) =>
onOptionsChange({ ...options, referenceCell } as GoogleSheetsOptions)
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 (
<TableList<Cell>
initialItems={options.cellsToInsert}
onItemsChange={handleInsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
/>
)
case GoogleSheetsAction.UPDATE_ROW:
return (
<Stack>
<Text>Row to select</Text>
<CellWithValueStack
id={'reference'}
columns={sheet.columns}
item={options.referenceCell ?? {}}
onItemChange={handleReferenceCellChange}
/>
<Text>Cells to update</Text>
<TableList<Cell>
initialItems={options.cellsToUpsert}
onItemsChange={handleUpsertColumnsChange}
Item={UpdatingCellItem}
addLabel="Add a value"
/>
</Stack>
)
case GoogleSheetsAction.GET:
return (
<Stack>
<Text>Row to select</Text>
<CellWithValueStack
id={'reference'}
columns={sheet.columns}
item={options.referenceCell ?? {}}
onItemChange={handleReferenceCellChange}
/>
<Text>Cells to extract</Text>
<TableList<ExtractingCell>
initialItems={options.cellsToExtract}
onItemsChange={handleExtractingCellsChange}
Item={ExtractingCellItem}
addLabel="Add a value"
/>
</Stack>
)
default:
return <></>
}
}

View File

@ -0,0 +1,37 @@
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { useMemo } from 'react'
import { Sheet } from 'services/integrations'
import { isDefined } from 'utils'
type Props = {
sheets: Sheet[]
isLoading: boolean
sheetId?: string
onSelectSheetId: (id: string) => void
}
export const SheetsDropdown = ({
sheets,
isLoading,
sheetId,
onSelectSheetId,
}: Props) => {
const currentSheet = useMemo(
() => sheets?.find((s) => s.id === sheetId),
[sheetId, sheets]
)
const handleSpreadsheetSelect = (name: string) => {
const id = sheets?.find((s) => s.name === name)?.id
if (isDefined(id)) onSelectSheetId(id)
}
return (
<SearchableDropdown
selectedItem={currentSheet?.name}
items={(sheets ?? []).map((s) => s.name)}
onValueChange={handleSpreadsheetSelect}
placeholder={'Select the sheet'}
isLoading={isLoading}
/>
)
}

View File

@ -0,0 +1,35 @@
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { useMemo } from 'react'
import { useSpreadsheets } from 'services/integrations'
type Props = {
credentialsId: string
spreadsheetId?: string
onSelectSpreadsheetId: (id: string) => void
}
export const SpreadsheetsDropdown = ({
credentialsId,
spreadsheetId,
onSelectSpreadsheetId,
}: Props) => {
const { spreadsheets, isLoading } = useSpreadsheets({ credentialsId })
const currentSpreadsheet = useMemo(
() => spreadsheets?.find((s) => s.id === spreadsheetId),
[spreadsheetId, spreadsheets]
)
const handleSpreadsheetSelect = (name: string) => {
const id = spreadsheets?.find((s) => s.name === name)?.id
if (id) onSelectSpreadsheetId(id)
}
return (
<SearchableDropdown
selectedItem={currentSpreadsheet?.name}
items={(spreadsheets ?? []).map((s) => s.name)}
onValueChange={handleSpreadsheetSelect}
placeholder={'Search for spreadsheet'}
isLoading={isLoading}
/>
)
}

View File

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

View File

@ -0,0 +1,94 @@
import { FormLabel, HStack, Stack } from '@chakra-ui/react'
import { SmartNumberInput } from 'components/shared/SmartNumberInput'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { NumberInputOptions, Variable } from 'models'
import React from 'react'
import { removeUndefinedFields } from 'services/utils'
type NumberInputSettingsBodyProps = {
options: NumberInputOptions
onOptionsChange: (options: NumberInputOptions) => void
}
export const NumberInputSettingsBody = ({
options,
onOptionsChange,
}: NumberInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleMinChange = (min?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, min }))
const handleMaxChange = (max?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, max }))
const handleStepChange = (step?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, step }))
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="button"
initialValue={options?.labels?.button ?? 'Send'}
onChange={handleButtonLabelChange}
/>
</Stack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="min">
Min:
</FormLabel>
<SmartNumberInput
id="min"
value={options.min}
onValueChange={handleMinChange}
/>
</HStack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="max">
Max:
</FormLabel>
<SmartNumberInput
id="max"
value={options.max}
onValueChange={handleMaxChange}
/>
</HStack>
<HStack justifyContent="space-between">
<FormLabel mb="0" htmlFor="step">
Step:
</FormLabel>
<SmartNumberInput
id="step"
value={options.step}
onValueChange={handleStepChange}
/>
</HStack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,56 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { EmailInputOptions, Variable } from 'models'
import React from 'react'
type PhoneNumberSettingsBodyProps = {
options: EmailInputOptions
onOptionsChange: (options: EmailInputOptions) => void
}
export const PhoneNumberSettingsBody = ({
options,
onOptionsChange,
}: PhoneNumberSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
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 })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="button"
initialValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,40 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton'
import { RedirectOptions } from 'models'
import React from 'react'
type Props = {
options: RedirectOptions
onOptionsChange: (options: RedirectOptions) => void
}
export const RedirectSettings = ({ options, onOptionsChange }: Props) => {
const handleUrlChange = (url?: string) => onOptionsChange({ ...options, url })
const handleIsNewTabChange = (isNewTab: boolean) =>
onOptionsChange({ ...options, isNewTab })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="tracking-id">
Url:
</FormLabel>
<InputWithVariableButton
id="tracking-id"
initialValue={options.url ?? ''}
placeholder="Type a URL..."
onChange={handleUrlChange}
/>
</Stack>
<SwitchWithLabel
id="new-tab"
label="Open in new tab?"
initialValue={options.isNewTab}
onCheckChange={handleIsNewTabChange}
/>
</Stack>
)
}

View File

@ -0,0 +1,42 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedTextarea } from 'components/shared/DebouncedTextarea'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { SetVariableOptions, Variable } from 'models'
import React from 'react'
type Props = {
options: SetVariableOptions
onOptionsChange: (options: SetVariableOptions) => void
}
export const SetVariableSettings = ({ options, onOptionsChange }: Props) => {
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
const handleExpressionChange = (expressionToEvaluate: string) =>
onOptionsChange({ ...options, expressionToEvaluate })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="variable-search">
Search or create variable:
</FormLabel>
<VariableSearchInput
onSelectVariable={handleVariableChange}
initialVariableId={options.variableId}
id="variable-search"
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="expression">
Value / Expression:
</FormLabel>
<DebouncedTextarea
id="expression"
initialValue={options.expressionToEvaluate ?? ''}
onChange={handleExpressionChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,65 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { TextInputOptions, Variable } from 'models'
import React from 'react'
type TextInputSettingsBodyProps = {
options: TextInputOptions
onOptionsChange: (options: TextInputOptions) => void
}
export const TextInputSettingsBody = ({
options,
onOptionsChange,
}: TextInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, placeholder } })
const handleButtonLabelChange = (button: string) =>
onOptionsChange({ ...options, labels: { ...options.labels, button } })
const handleLongChange = (isLong: boolean) =>
onOptionsChange({ ...options, isLong })
const handleVariableChange = (variable?: Variable) =>
onOptionsChange({ ...options, variableId: variable?.id })
return (
<Stack spacing={4}>
<SwitchWithLabel
id="switch"
label="Long text?"
initialValue={options?.isLong ?? false}
onCheckChange={handleLongChange}
/>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="button"
initialValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,56 @@
import { FormLabel, Stack } from '@chakra-ui/react'
import { DebouncedInput } from 'components/shared/DebouncedInput'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { UrlInputOptions, Variable } from 'models'
import React from 'react'
type UrlInputSettingsBodyProps = {
options: UrlInputOptions
onOptionsChange: (options: UrlInputOptions) => void
}
export const UrlInputSettingsBody = ({
options,
onOptionsChange,
}: UrlInputSettingsBodyProps) => {
const handlePlaceholderChange = (placeholder: string) =>
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 })
return (
<Stack spacing={4}>
<Stack>
<FormLabel mb="0" htmlFor="placeholder">
Placeholder:
</FormLabel>
<DebouncedInput
id="placeholder"
initialValue={options.labels.placeholder}
onChange={handlePlaceholderChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="button">
Button label:
</FormLabel>
<DebouncedInput
id="button"
initialValue={options.labels.button}
onChange={handleButtonLabelChange}
/>
</Stack>
<Stack>
<FormLabel mb="0" htmlFor="variable">
Save answer in a variable:
</FormLabel>
<VariableSearchInput
initialVariableId={options.variableId}
onSelectVariable={handleVariableChange}
/>
</Stack>
</Stack>
)
}

View File

@ -0,0 +1,62 @@
import { Stack, FormControl, FormLabel } from '@chakra-ui/react'
import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton/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,38 @@
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}
initialValue={item.value ?? ''}
onChange={handleValueChange}
/>
</FormControl>
</Stack>
)
}

View File

@ -0,0 +1,212 @@
import React, { useMemo, useState } from 'react'
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Button,
Flex,
Stack,
useToast,
} from '@chakra-ui/react'
import { InputWithVariableButton } from 'components/shared/TextboxWithVariableButton/InputWithVariableButton'
import { useTypebot } from 'contexts/TypebotContext'
import {
HttpMethod,
KeyValue,
Table,
WebhookOptions,
VariableForTest,
Webhook,
ResponseVariableMapping,
} from 'models'
import { DropdownList } from 'components/shared/DropdownList'
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 = {
webhook: Webhook
options?: WebhookOptions
onOptionsChange: (options: WebhookOptions) => void
onWebhookChange: (updates: Partial<Webhook>) => void
onTestRequestClick: () => void
}
export const WebhookSettings = ({
options,
onOptionsChange,
webhook,
onWebhookChange,
onTestRequestClick,
}: Props) => {
const { typebot, save } = useTypebot()
const [isTestResponseLoading, setIsTestResponseLoading] = useState(false)
const [testResponse, setTestResponse] = useState<string>()
const [responseKeys, setResponseKeys] = useState<string[]>([])
const toast = useToast({
position: 'top-right',
status: 'error',
})
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>) =>
options && onOptionsChange({ ...options, variablesForTest })
const handleResponseMappingChange = (
responseVariableMapping: Table<ResponseVariableMapping>
) => options && onOptionsChange({ ...options, responseVariableMapping })
const handleTestRequestClick = async () => {
if (!typebot || !webhook) return
setIsTestResponseLoading(true)
onTestRequestClick()
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))
setIsTestResponseLoading(false)
}
const ResponseMappingInputs = useMemo(
() => (props: TableListItemProps<ResponseVariableMapping>) =>
<DataVariableInputs {...props} dataItems={responseKeys} />,
[responseKeys]
)
return (
<Stack spacing={4}>
<Stack>
<Flex>
<DropdownList<HttpMethod>
currentItem={webhook.method}
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={'test'}
lang="json"
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 ?? { byId: {}, allIds: [] }
}
onItemsChange={handleVariablesChange}
Item={VariableForTestInputs}
addLabel="Add an entry"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
<Button
onClick={handleTestRequestClick}
colorScheme="blue"
isLoading={isTestResponseLoading}
>
Test the request
</Button>
{testResponse && (
<CodeEditor isReadOnly lang="json" 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 ?? { byId: {}, allIds: [] }
}
onItemsChange={handleResponseMappingChange}
Item={ResponseMappingInputs}
addLabel="Add an entry"
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
)}
</Stack>
)
}

View File

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

View File

@ -0,0 +1,5 @@
export * from './DateInputSettingsBody'
export * from './EmailInputSettingsBody'
export * from './NumberInputSettingsBody'
export * from './TextInputSettingsBody'
export * from './UrlInputSettingsBody'

View File

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

View File

@ -0,0 +1,307 @@
import {
Flex,
HStack,
Popover,
PopoverTrigger,
useDisclosure,
useEventListener,
} from '@chakra-ui/react'
import React, { useEffect, useState } from 'react'
import {
BubbleStep,
BubbleStepContent,
DraggableStep,
Step,
StepOptions,
TextBubbleStep,
Webhook,
} from 'models'
import { Coordinates, useGraph } from 'contexts/GraphContext'
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
import { isBubbleStep, isTextBubbleStep, isWebhookStep } from 'utils'
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
import { useTypebot } from 'contexts/TypebotContext'
import { ContextMenu } from 'components/shared/ContextMenu'
import { SettingsPopoverContent } from './SettingsPopoverContent'
import { StepNodeContextMenu } from './StepNodeContextMenu'
import { SourceEndpoint } from '../../Endpoints/SourceEndpoint'
import { hasDefaultConnector } from 'services/typebots'
import { useRouter } from 'next/router'
import { SettingsModal } from './SettingsPopoverContent/SettingsModal'
import { StepSettings } from './SettingsPopoverContent/SettingsPopoverContent'
import { TextBubbleEditor } from './TextBubbleEditor'
import { TargetEndpoint } from '../../Endpoints'
import { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
export const StepNode = ({
step,
isConnectable,
onMouseMoveBottomOfElement,
onMouseMoveTopOfElement,
onMouseDown,
}: {
step: Step
isConnectable: boolean
onMouseMoveBottomOfElement?: () => void
onMouseMoveTopOfElement?: () => void
onMouseDown?: (
stepNodePosition: { absolute: Coordinates; relative: Coordinates },
step: DraggableStep
) => void
}) => {
const { query } = useRouter()
const {
setConnectingIds,
connectingIds,
openedStepId,
setOpenedStepId,
blocksCoordinates,
} = useGraph()
const { detachStepFromBlock, updateStep, typebot, updateWebhook } =
useTypebot()
const [localStep, setLocalStep] = useState(step)
const [localWebhook, setLocalWebhook] = useState(
isWebhookStep(step)
? typebot?.webhooks.byId[step.options.webhookId ?? '']
: undefined
)
const [isConnecting, setIsConnecting] = useState(false)
const [isPopoverOpened, setIsPopoverOpened] = useState(
openedStepId === step.id
)
const [mouseDownEvent, setMouseDownEvent] =
useState<{ absolute: Coordinates; relative: Coordinates }>()
const [isEditing, setIsEditing] = useState<boolean>(
isTextBubbleStep(step) && step.content.plainText === ''
)
const {
isOpen: isModalOpen,
onOpen: onModalOpen,
onClose: onModalClose,
} = useDisclosure()
useEffect(() => {
setLocalStep(step)
}, [step])
useEffect(() => {
if (query.stepId?.toString() === step.id) setOpenedStepId(step.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
useEffect(() => {
setIsConnecting(
connectingIds?.target?.blockId === step.blockId &&
connectingIds?.target?.stepId === step.id
)
}, [connectingIds, step.blockId, step.id])
const handleModalClose = () => {
updateStep(localStep.id, { ...localStep })
onModalClose()
}
const handleMouseEnter = () => {
if (connectingIds?.target)
setConnectingIds({
...connectingIds,
target: { ...connectingIds.target, stepId: step.id },
})
}
const handleMouseLeave = () => {
if (connectingIds?.target)
setConnectingIds({
...connectingIds,
target: { ...connectingIds.target, stepId: undefined },
})
}
const handleMouseDown = (e: React.MouseEvent) => {
if (!onMouseDown) return
e.stopPropagation()
const element = e.currentTarget as HTMLDivElement
const rect = element.getBoundingClientRect()
const relativeX = e.clientX - rect.left
const relativeY = e.clientY - rect.top
setMouseDownEvent({
absolute: { x: e.clientX + relativeX, y: e.clientY + relativeY },
relative: { x: relativeX, y: relativeY },
})
}
const handleGlobalMouseUp = () => {
setMouseDownEvent(undefined)
}
useEventListener('mouseup', handleGlobalMouseUp)
const handleMouseUp = () => {
if (mouseDownEvent) {
setIsEditing(true)
}
}
const handleMouseMove = (event: React.MouseEvent<HTMLDivElement>) => {
if (!onMouseMoveBottomOfElement || !onMouseMoveTopOfElement) return
const isMovingAndIsMouseDown =
mouseDownEvent &&
onMouseDown &&
(event.movementX > 0 || event.movementY > 0)
if (isMovingAndIsMouseDown && step.type !== 'start') {
onMouseDown(mouseDownEvent, step)
detachStepFromBlock(step.id)
setMouseDownEvent(undefined)
}
const element = event.currentTarget as HTMLDivElement
const rect = element.getBoundingClientRect()
const y = event.clientY - rect.top
if (y > rect.height / 2) onMouseMoveBottomOfElement()
else onMouseMoveTopOfElement()
}
const handleCloseEditor = () => {
setIsEditing(false)
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
setOpenedStepId(step.id)
}
const handleExpandClick = () => {
setOpenedStepId(undefined)
onModalOpen()
}
const updateOptions = () => {
updateStep(localStep.id, { ...localStep })
if (localWebhook) updateWebhook(localWebhook.id, { ...localWebhook })
}
const handleOptionsChange = (options: StepOptions) => {
setLocalStep({ ...localStep, options } as Step)
}
const handleContentChange = (content: BubbleStepContent) =>
setLocalStep({ ...localStep, content } as Step)
const handleWebhookChange = (updates: Partial<Webhook>) => {
if (!localWebhook) return
setLocalWebhook({ ...localWebhook, ...updates })
}
useEffect(() => {
if (isPopoverOpened && openedStepId !== step.id) updateOptions()
setIsPopoverOpened(openedStepId === step.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openedStepId])
return isEditing && isTextBubbleStep(localStep) ? (
<TextBubbleEditor
stepId={localStep.id}
initialValue={localStep.content.richText}
onClose={handleCloseEditor}
/>
) : (
<ContextMenu<HTMLDivElement>
renderMenu={() => <StepNodeContextMenu stepId={step.id} />}
>
{(ref, isOpened) => (
<Popover
placement="left"
isLazy
isOpen={isPopoverOpened}
closeOnBlur={false}
>
<PopoverTrigger>
<Flex
pos="relative"
ref={ref}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onMouseUp={handleMouseUp}
onClick={handleClick}
data-testid={`step-${step.id}`}
w="full"
>
<HStack
flex="1"
userSelect="none"
p="3"
borderWidth="1px"
borderColor={isConnecting || isOpened ? 'blue.400' : 'gray.300'}
rounded="lg"
cursor={'pointer'}
bgColor="white"
align="flex-start"
w="full"
>
<StepIcon
type={localStep.type}
mt="1"
data-testid={`${localStep.id}-icon`}
/>
<StepNodeContent step={localStep} />
<TargetEndpoint
pos="absolute"
left="-32px"
top="19px"
stepId={localStep.id}
/>
{blocksCoordinates &&
isConnectable &&
hasDefaultConnector(localStep) && (
<SourceEndpoint
source={{
blockId: localStep.blockId,
stepId: localStep.id,
}}
pos="absolute"
right="15px"
bottom="18px"
/>
)}
</HStack>
</Flex>
</PopoverTrigger>
{hasSettingsPopover(localStep) && (
<SettingsPopoverContent
step={localStep}
webhook={localWebhook}
onExpandClick={handleExpandClick}
onOptionsChange={handleOptionsChange}
onWebhookChange={handleWebhookChange}
onTestRequestClick={updateOptions}
/>
)}
{isMediaBubbleStep(localStep) && (
<MediaBubblePopoverContent
step={localStep}
onContentChange={handleContentChange}
/>
)}
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<StepSettings
step={localStep}
webhook={localWebhook}
onOptionsChange={handleOptionsChange}
onWebhookChange={handleWebhookChange}
onTestRequestClick={updateOptions}
/>
</SettingsModal>
</Popover>
)}
</ContextMenu>
)
}
const hasSettingsPopover = (step: Step): step is Exclude<Step, BubbleStep> =>
!isBubbleStep(step)
const isMediaBubbleStep = (
step: Step
): step is Exclude<BubbleStep, TextBubbleStep> =>
isBubbleStep(step) && !isTextBubbleStep(step)

View File

@ -0,0 +1,104 @@
import { Text } from '@chakra-ui/react'
import {
Step,
StartStep,
BubbleStepType,
InputStepType,
LogicStepType,
IntegrationStepType,
} from 'models'
import { isInputStep } from 'utils'
import { ButtonNodesList } from '../../ButtonNode'
import {
ConditionContent,
SetVariableContent,
TextBubbleContent,
VideoBubbleContent,
WebhookContent,
WithVariableContent,
} from './contents'
import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PlaceholderContent } from './contents/PlaceholderContent'
type Props = {
step: Step | StartStep
isConnectable?: boolean
}
export const StepNodeContent = ({ step }: Props) => {
if (isInputStep(step) && step.options.variableId) {
return <WithVariableContent step={step} />
}
switch (step.type) {
case BubbleStepType.TEXT: {
return <TextBubbleContent step={step} />
}
case BubbleStepType.IMAGE: {
return <ImageBubbleContent step={step} />
}
case BubbleStepType.VIDEO: {
return <VideoBubbleContent step={step} />
}
case InputStepType.TEXT:
case InputStepType.NUMBER:
case InputStepType.EMAIL:
case InputStepType.URL:
case InputStepType.PHONE: {
return (
<PlaceholderContent placeholder={step.options.labels.placeholder} />
)
}
case InputStepType.DATE: {
return <Text color={'gray.500'}>Pick a date...</Text>
}
case InputStepType.CHOICE: {
return <ButtonNodesList step={step} />
}
case LogicStepType.SET_VARIABLE: {
return <SetVariableContent step={step} />
}
case LogicStepType.CONDITION: {
return <ConditionContent step={step} />
}
case LogicStepType.REDIRECT: {
return (
<ConfigureContent
label={
step.options?.url ? `Redirect to ${step.options?.url}` : undefined
}
/>
)
}
case IntegrationStepType.GOOGLE_SHEETS: {
return (
<ConfigureContent
label={
step.options && 'action' in step.options
? step.options.action
: undefined
}
/>
)
}
case IntegrationStepType.GOOGLE_ANALYTICS: {
return (
<ConfigureContent
label={
step.options?.action
? `Track "${step.options?.action}" `
: undefined
}
/>
)
}
case IntegrationStepType.WEBHOOK: {
return <WebhookContent step={step} />
}
case 'start': {
return <Text>Start</Text>
}
default: {
return <Text>No input</Text>
}
}
}

View File

@ -0,0 +1,57 @@
import { Flex, Stack, HStack, Tag, Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { ConditionStep } from 'models'
import { SourceEndpoint } from '../../../../Endpoints/SourceEndpoint'
export const ConditionContent = ({ step }: { step: ConditionStep }) => {
const { typebot } = useTypebot()
return (
<Flex>
{step.options?.comparisons.allIds.length === 0 ? (
<Text color={'gray.500'}>Configure...</Text>
) : (
<Stack>
{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,10 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
type Props = { label?: string }
export const ConfigureContent = ({ label }: Props) => (
<Text color={label ? 'currentcolor' : 'gray.500'} isTruncated>
{label ?? 'Configure...'}
</Text>
)

View File

@ -0,0 +1,16 @@
import { Box, Text, Image } from '@chakra-ui/react'
import { ImageBubbleStep } from 'models'
export const ImageBubbleContent = ({ step }: { step: ImageBubbleStep }) =>
!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>
)

View File

@ -0,0 +1,8 @@
import React from 'react'
import { Text } from '@chakra-ui/react'
type Props = { placeholder: string }
export const PlaceholderContent = ({ placeholder }: Props) => (
<Text color={'gray.500'}>{placeholder}</Text>
)

View File

@ -0,0 +1,17 @@
import { Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { SetVariableStep } from 'models'
export const SetVariableContent = ({ 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,26 @@
import { Flex } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { TextBubbleStep } from 'models'
import React from 'react'
import { parseVariableHighlight } from 'services/utils'
type Props = {
step: TextBubbleStep
}
export const TextBubbleContent = ({ step }: Props) => {
const { typebot } = useTypebot()
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>`
: parseVariableHighlight(step.content.html, typebot),
}}
/>
)
}

View File

@ -0,0 +1,51 @@
import { Box, Text } from '@chakra-ui/react'
import { VideoBubbleStep, VideoBubbleContentType } from 'models'
export const VideoBubbleContent = ({ 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,28 @@
import { InputStep } from 'models'
import { chakra, Text } from '@chakra-ui/react'
import React from 'react'
import { useTypebot } from 'contexts/TypebotContext'
type Props = {
step: InputStep
}
export const WithVariableContent = ({ step }: Props) => {
const { typebot } = useTypebot()
const variableName =
typebot?.variables.byId[step.options.variableId as string].name
return (
<Text>
Collect{' '}
<chakra.span
bgColor="orange.400"
color="white"
rounded="md"
py="0.5"
px="1"
>
{variableName}
</chakra.span>
</Text>
)
}

View File

@ -0,0 +1,6 @@
export * from './ConditionContent'
export * from './SetVariableContent'
export * from './WithVariableContent'
export * from './VideoBubbleContent'
export * from './WebhookContent'
export * from './TextBubbleContent'

View File

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

View File

@ -0,0 +1,17 @@
import { MenuList, MenuItem } from '@chakra-ui/react'
import { TrashIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
export const StepNodeContextMenu = ({ stepId }: { stepId: string }) => {
const { deleteStep } = useTypebot()
const handleDeleteClick = () => deleteStep(stepId)
return (
<MenuList>
<MenuItem icon={<TrashIcon />} onClick={handleDeleteClick}>
Delete
</MenuItem>
</MenuList>
)
}

View File

@ -0,0 +1,26 @@
import { StackProps, HStack } from '@chakra-ui/react'
import { StartStep, Step } from 'models'
import { StepIcon } from 'components/editor/StepsSideBar/StepIcon'
import { StepNodeContent } from './StepNodeContent/StepNodeContent'
export const StepNodeOverlay = ({
step,
...props
}: { step: Step | StartStep } & StackProps) => {
return (
<HStack
p="3"
borderWidth="1px"
rounded="lg"
bgColor="white"
cursor={'grab'}
w="264px"
pointerEvents="none"
shadow="lg"
{...props}
>
<StepIcon type={step.type} />
<StepNodeContent step={step} />
</HStack>
)
}

View File

@ -0,0 +1,152 @@
import { useEventListener, Stack, Flex, Portal } from '@chakra-ui/react'
import { DraggableStep } from 'models'
import { useStepDnd } from 'contexts/StepDndContext'
import { Coordinates, useGraph } from 'contexts/GraphContext'
import { useMemo, useState } from 'react'
import { useTypebot } from 'contexts/TypebotContext'
import { StepNode } from './StepNode'
import { StepNodeOverlay } from './StepNodeOverlay'
export const StepNodesList = ({
blockId,
stepIds,
}: {
blockId: string
stepIds: string[]
}) => {
const {
draggedStep,
setDraggedStep,
draggedStepType,
mouseOverBlockId,
setDraggedStepType,
setMouseOverBlockId,
} = useStepDnd()
const { typebot, createStep } = useTypebot()
const { isReadOnly } = useGraph()
const [expandedPlaceholderIndex, setExpandedPlaceholderIndex] = useState<
number | undefined
>()
const showSortPlaceholders = useMemo(
() => mouseOverBlockId === blockId && (draggedStep || draggedStepType),
[mouseOverBlockId, blockId, draggedStep, draggedStepType]
)
const [position, setPosition] = useState({
x: 0,
y: 0,
})
const [relativeCoordinates, setRelativeCoordinates] = useState({ x: 0, y: 0 })
const handleStepMove = (event: MouseEvent) => {
if (!draggedStep) return
const { clientX, clientY } = event
setPosition({
...position,
x: clientX - relativeCoordinates.x,
y: clientY - relativeCoordinates.y,
})
}
useEventListener('mousemove', handleStepMove)
const handleMouseMove = (event: React.MouseEvent) => {
if (!draggedStep) return
const element = event.currentTarget as HTMLDivElement
const rect = element.getBoundingClientRect()
const y = event.clientY - rect.top
if (y < 20) setExpandedPlaceholderIndex(0)
}
const handleMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
if (expandedPlaceholderIndex === undefined) return
e.stopPropagation()
setMouseOverBlockId(undefined)
setExpandedPlaceholderIndex(undefined)
if (!draggedStep && !draggedStepType) return
createStep(
blockId,
draggedStep || draggedStepType,
expandedPlaceholderIndex
)
setDraggedStep(undefined)
setDraggedStepType(undefined)
}
const handleStepMouseDown = (
{ absolute, relative }: { absolute: Coordinates; relative: Coordinates },
step: DraggableStep
) => {
if (isReadOnly) return
setPosition(absolute)
setRelativeCoordinates(relative)
setMouseOverBlockId(blockId)
setDraggedStep(step)
}
const handleMouseOnTopOfStep = (stepIndex: number) => () => {
if (!draggedStep && !draggedStepType) return
setExpandedPlaceholderIndex(stepIndex === 0 ? 0 : stepIndex)
}
const handleMouseOnBottomOfStep = (stepIndex: number) => () => {
if (!draggedStep && !draggedStepType) return
setExpandedPlaceholderIndex(stepIndex + 1)
}
return (
<Stack
spacing={1}
onMouseUpCapture={handleMouseUp}
onMouseMove={handleMouseMove}
transition="none"
>
<Flex
h={
showSortPlaceholders && expandedPlaceholderIndex === 0
? '50px'
: '2px'
}
bgColor={'gray.300'}
visibility={showSortPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showSortPlaceholders ? 'height 200ms' : 'none'}
/>
{typebot &&
stepIds.map((stepId, idx) => (
<Stack key={stepId} spacing={1}>
<StepNode
key={stepId}
step={typebot.steps.byId[stepId]}
isConnectable={!isReadOnly && stepIds.length - 1 === idx}
onMouseMoveTopOfElement={handleMouseOnTopOfStep(idx)}
onMouseMoveBottomOfElement={handleMouseOnBottomOfStep(idx)}
onMouseDown={handleStepMouseDown}
/>
<Flex
h={
showSortPlaceholders && expandedPlaceholderIndex === idx + 1
? '50px'
: '2px'
}
bgColor={'gray.300'}
visibility={showSortPlaceholders ? 'visible' : 'hidden'}
rounded="lg"
transition={showSortPlaceholders ? 'height 200ms' : 'none'}
/>
</Stack>
))}
{draggedStep && draggedStep.blockId === blockId && (
<Portal>
<StepNodeOverlay
step={draggedStep}
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg)`,
}}
/>
</Portal>
)}
</Stack>
)
}

View File

@ -0,0 +1,156 @@
import { Flex, Stack, useOutsideClick } from '@chakra-ui/react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
Plate,
selectEditor,
serializeHtml,
TDescendant,
withPlate,
} from '@udecode/plate-core'
import { editorStyle, platePlugins } from 'libs/plate'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { BaseSelection, createEditor, Transforms } from 'slate'
import { ToolBar } from './ToolBar'
import { parseHtmlStringToPlainText } from 'services/utils'
import { TextBubbleStep, Variable } from 'models'
import { VariableSearchInput } from 'components/shared/VariableSearchInput'
import { ReactEditor } from 'slate-react'
type Props = {
stepId: string
initialValue: TDescendant[]
onClose: () => void
}
export const TextBubbleEditor = ({ initialValue, stepId, onClose }: Props) => {
const randomEditorId = useMemo(() => Math.random().toString(), [])
const editor = useMemo(
() =>
withPlate(createEditor(), { id: randomEditorId, plugins: platePlugins }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const { updateStep } = useTypebot()
const [value, setValue] = useState(initialValue)
const varDropdownRef = useRef<HTMLDivElement | null>(null)
const rememberedSelection = useRef<BaseSelection | null>(null)
const [isVariableDropdownOpen, setIsVariableDropdownOpen] = useState(false)
const textEditorRef = useRef<HTMLDivElement>(null)
useOutsideClick({
ref: textEditorRef,
handler: () => {
save(value)
onClose()
},
})
useEffect(() => {
if (!isVariableDropdownOpen) return
const el = varDropdownRef.current
if (!el) return
const { top, left } = computeTargetCoord()
el.style.top = `${top}px`
el.style.left = `${left}px`
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVariableDropdownOpen])
const computeTargetCoord = () => {
const selection = window.getSelection()
const relativeParent = textEditorRef.current
if (!selection || !relativeParent) return { top: 0, left: 0 }
const range = selection.getRangeAt(0)
const selectionBoundingRect = range.getBoundingClientRect()
const relativeRect = relativeParent.getBoundingClientRect()
return {
top: selectionBoundingRect.bottom - relativeRect.top,
left: selectionBoundingRect.left - relativeRect.left,
}
}
const save = (value: unknown[]) => {
if (value.length === 0) return
const html = serializeHtml(editor, {
nodes: value,
})
updateStep(stepId, {
content: {
html,
richText: value,
plainText: parseHtmlStringToPlainText(html),
},
} as TextBubbleStep)
}
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation()
}
const handleVariableSelected = (variable?: Variable) => {
setIsVariableDropdownOpen(false)
if (!rememberedSelection.current || !variable) return
Transforms.select(editor, rememberedSelection.current)
Transforms.insertText(editor, '{{' + variable.name + '}}')
ReactEditor.focus(editor as unknown as ReactEditor)
}
const handleChangeEditorContent = (val: unknown[]) => {
setValue(val)
setIsVariableDropdownOpen(false)
}
return (
<Stack
flex="1"
ref={textEditorRef}
borderWidth="2px"
borderColor="blue.500"
rounded="md"
onMouseDown={handleMouseDown}
pos="relative"
spacing={0}
>
<ToolBar onVariablesButtonClick={() => setIsVariableDropdownOpen(true)} />
<Plate
id={randomEditorId}
editableProps={{
style: editorStyle,
autoFocus: true,
onFocus: () => {
if (editor.children.length === 0) return
selectEditor(editor, {
edge: 'end',
})
},
'aria-label': 'Text editor',
onBlur: () => {
rememberedSelection.current = editor.selection
},
}}
initialValue={
initialValue.length === 0
? [{ type: 'p', children: [{ text: '' }] }]
: initialValue
}
onChange={handleChangeEditorContent}
editor={editor}
/>
{isVariableDropdownOpen && (
<Flex
pos="absolute"
ref={varDropdownRef}
shadow="lg"
rounded="md"
bgColor="white"
w="250px"
>
<VariableSearchInput
onSelectVariable={handleVariableSelected}
placeholder="Search for a variable"
isDefaultOpen
/>
</Flex>
)}
</Stack>
)
}

View File

@ -0,0 +1,54 @@
import { StackProps, HStack, Button } from '@chakra-ui/react'
import {
MARK_BOLD,
MARK_ITALIC,
MARK_UNDERLINE,
} from '@udecode/plate-basic-marks'
import { usePlateEditorRef, getPluginType } from '@udecode/plate-core'
import { LinkToolbarButton } from '@udecode/plate-ui-link'
import { MarkToolbarButton } from '@udecode/plate-ui-toolbar'
import { BoldIcon, ItalicIcon, UnderlineIcon, LinkIcon } from 'assets/icons'
type Props = { onVariablesButtonClick: () => void } & StackProps
export const ToolBar = (props: Props) => {
const editor = usePlateEditorRef()
const handleVariablesButtonMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
props.onVariablesButtonClick()
}
return (
<HStack
bgColor={'white'}
borderTopRadius="md"
p={2}
w="full"
boxSizing="border-box"
borderBottomWidth={1}
{...props}
>
<Button size="sm" onMouseDown={handleVariablesButtonMouseDown}>
Variables
</Button>
<span data-testid="bold-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_BOLD)}
icon={<BoldIcon />}
/>
</span>
<span data-testid="italic-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_ITALIC)}
icon={<ItalicIcon />}
/>
</span>
<span data-testid="underline-button">
<MarkToolbarButton
type={getPluginType(editor, MARK_UNDERLINE)}
icon={<UnderlineIcon />}
/>
</span>
<LinkToolbarButton icon={<LinkIcon />} />
</HStack>
)
}

View File

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

View File

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