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:
@ -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} />
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { MediaBubblePopoverContent } from './MediaBubblePopoverContent'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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 <></>
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { ConditionSettingsBody } from './ConditonSettingsBody'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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 <></>
|
||||
}
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { GoogleSheetsSettingsBody } from './GoogleSheetsSettingsBody'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { WebhookSettings } from './WebhookSettings'
|
@ -0,0 +1,5 @@
|
||||
export * from './DateInputSettingsBody'
|
||||
export * from './EmailInputSettingsBody'
|
||||
export * from './NumberInputSettingsBody'
|
||||
export * from './TextInputSettingsBody'
|
||||
export * from './UrlInputSettingsBody'
|
@ -0,0 +1 @@
|
||||
export { SettingsPopoverContent } from './SettingsPopoverContent'
|
307
apps/builder/components/shared/Graph/Nodes/StepNode/StepNode.tsx
Normal file
307
apps/builder/components/shared/Graph/Nodes/StepNode/StepNode.tsx
Normal 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)
|
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
export * from './ConditionContent'
|
||||
export * from './SetVariableContent'
|
||||
export * from './WithVariableContent'
|
||||
export * from './VideoBubbleContent'
|
||||
export * from './WebhookContent'
|
||||
export * from './TextBubbleContent'
|
@ -0,0 +1 @@
|
||||
export { StepNodeContent } from './StepNodeContent'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { TextBubbleEditor } from './TextBubbleEditor'
|
@ -0,0 +1 @@
|
||||
export { StepNodesList } from './StepNodesList'
|
Reference in New Issue
Block a user