2
0

feat(engine): Link typebot step

This commit is contained in:
Baptiste Arnaud
2022-03-09 15:12:00 +01:00
parent 1bcc8aee10
commit 7e61ab19eb
61 changed files with 1272 additions and 245 deletions

View File

@@ -371,3 +371,11 @@ export const AlignLeftTextIcon = (props: IconProps) => (
<line x1="17" y1="18" x2="3" y2="18"></line> <line x1="17" y1="18" x2="3" y2="18"></line>
</Icon> </Icon>
) )
export const BoxIcon = (props: IconProps) => (
<Icon viewBox="0 0 24 24" {...featherIconsBaseProps} {...props}>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
<line x1="12" y1="22.08" x2="12" y2="12"></line>
</Icon>
)

View File

@@ -1,5 +1,6 @@
import { IconProps } from '@chakra-ui/react' import { IconProps } from '@chakra-ui/react'
import { import {
BoxIcon,
CalendarIcon, CalendarIcon,
ChatIcon, ChatIcon,
CheckSquareIcon, CheckSquareIcon,
@@ -60,6 +61,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return <ExternalLinkIcon color="purple.500" {...props} /> return <ExternalLinkIcon color="purple.500" {...props} />
case LogicStepType.CODE: case LogicStepType.CODE:
return <CodeIcon color="purple.500" {...props} /> return <CodeIcon color="purple.500" {...props} />
case LogicStepType.TYPEBOT_LINK:
return <BoxIcon color="purple.500" {...props} />
case IntegrationStepType.GOOGLE_SHEETS: case IntegrationStepType.GOOGLE_SHEETS:
return <GoogleSheetsLogo {...props} /> return <GoogleSheetsLogo {...props} />
case IntegrationStepType.GOOGLE_ANALYTICS: case IntegrationStepType.GOOGLE_ANALYTICS:

View File

@@ -43,6 +43,12 @@ export const StepTypeLabel = ({ type }: Props) => {
<Text>Code</Text> <Text>Code</Text>
</Tooltip> </Tooltip>
) )
case LogicStepType.TYPEBOT_LINK:
return (
<Tooltip label="Link to another of your typebots">
<Text>Typebot</Text>
</Tooltip>
)
case IntegrationStepType.GOOGLE_SHEETS: case IntegrationStepType.GOOGLE_SHEETS:
return ( return (
<Tooltip label="Google Sheets"> <Tooltip label="Google Sheets">

View File

@@ -2,13 +2,14 @@
/* eslint-disable react/jsx-key */ /* eslint-disable react/jsx-key */
import { Button, chakra, Checkbox, Flex, HStack, Text } from '@chakra-ui/react' import { Button, chakra, Checkbox, Flex, HStack, Text } from '@chakra-ui/react'
import { AlignLeftTextIcon } from 'assets/icons' import { AlignLeftTextIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext' import { PublicTypebot } from 'models'
import React, { useEffect, useMemo, useRef } from 'react' import React, { useEffect, useMemo, useRef } from 'react'
import { Hooks, useRowSelect, useTable } from 'react-table' import { Hooks, useRowSelect, useTable } from 'react-table'
import { parseSubmissionsColumns } from 'services/publicTypebot' import { parseSubmissionsColumns } from 'services/publicTypebot'
import { LoadingRows } from './LoadingRows' import { LoadingRows } from './LoadingRows'
type SubmissionsTableProps = { type SubmissionsTableProps = {
blocksAndVariables: Pick<PublicTypebot, 'blocks' | 'variables'>
data?: any data?: any
hasMore?: boolean hasMore?: boolean
onNewSelection: (indices: number[]) => void onNewSelection: (indices: number[]) => void
@@ -17,16 +18,16 @@ type SubmissionsTableProps = {
} }
export const SubmissionsTable = ({ export const SubmissionsTable = ({
blocksAndVariables,
data, data,
hasMore, hasMore,
onNewSelection, onNewSelection,
onScrollToBottom, onScrollToBottom,
onLogOpenIndex, onLogOpenIndex,
}: SubmissionsTableProps) => { }: SubmissionsTableProps) => {
const { publishedTypebot } = useTypebot()
const columns: any = useMemo( const columns: any = useMemo(
() => (publishedTypebot ? parseSubmissionsColumns(publishedTypebot) : []), () => parseSubmissionsColumns(blocksAndVariables),
[publishedTypebot] [blocksAndVariables]
) )
const bottomElement = useRef<HTMLDivElement | null>(null) const bottomElement = useRef<HTMLDivElement | null>(null)
const tableWrapper = useRef<HTMLDivElement | null>(null) const tableWrapper = useRef<HTMLDivElement | null>(null)

View File

@@ -5,6 +5,7 @@ import { css } from '@codemirror/lang-css'
import { javascript } from '@codemirror/lang-javascript' import { javascript } from '@codemirror/lang-javascript'
import { html } from '@codemirror/lang-html' import { html } from '@codemirror/lang-html'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useDebounce } from 'use-debounce'
type Props = { type Props = {
value: string value: string
@@ -22,6 +23,10 @@ export const CodeEditor = ({
const editorContainer = useRef<HTMLDivElement | null>(null) const editorContainer = useRef<HTMLDivElement | null>(null)
const editorView = useRef<EditorView | null>(null) const editorView = useRef<EditorView | null>(null)
const [plainTextValue, setPlainTextValue] = useState(value) const [plainTextValue, setPlainTextValue] = useState(value)
const [debouncedValue] = useDebounce(
plainTextValue,
process.env.NEXT_PUBLIC_E2E_TEST ? 0 : 1000
)
useEffect(() => { useEffect(() => {
if (!editorView.current || !isReadOnly) return if (!editorView.current || !isReadOnly) return
@@ -36,10 +41,10 @@ export const CodeEditor = ({
}, [value]) }, [value])
useEffect(() => { useEffect(() => {
if (!onChange || plainTextValue === value) return if (!onChange || debouncedValue === value) return
onChange(plainTextValue) onChange(debouncedValue)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [plainTextValue]) }, [debouncedValue])
useEffect(() => { useEffect(() => {
const editor = initEditor(value) const editor = initEditor(value)

View File

@@ -6,6 +6,7 @@ import {
useEffect, useEffect,
useState, useState,
} from 'react' } from 'react'
import { useDebounce } from 'use-debounce'
type Props = Omit<InputProps, 'onChange' | 'value'> & { type Props = Omit<InputProps, 'onChange' | 'value'> & {
initialValue: string initialValue: string
@@ -18,16 +19,19 @@ export const DebouncedInput = forwardRef(
ref: ForwardedRef<HTMLInputElement> ref: ForwardedRef<HTMLInputElement>
) => { ) => {
const [currentValue, setCurrentValue] = useState(initialValue) const [currentValue, setCurrentValue] = useState(initialValue)
const [debouncedValue] = useDebounce(
currentValue,
process.env.NEXT_PUBLIC_E2E_TEST ? 0 : 1000
)
useEffect(() => { useEffect(() => {
if (currentValue === initialValue) return if (debouncedValue === initialValue) return
onChange(currentValue) onChange(debouncedValue)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentValue]) }, [debouncedValue])
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { const handleChange = (e: ChangeEvent<HTMLInputElement>) =>
setCurrentValue(e.target.value) setCurrentValue(e.target.value)
}
return ( return (
<Input <Input

View File

@@ -1,5 +1,6 @@
import { Textarea, TextareaProps } from '@chakra-ui/react' import { Textarea, TextareaProps } from '@chakra-ui/react'
import { ChangeEvent, useEffect, useState } from 'react' import { ChangeEvent, useEffect, useState } from 'react'
import { useDebounce } from 'use-debounce'
type Props = Omit<TextareaProps, 'onChange' | 'value'> & { type Props = Omit<TextareaProps, 'onChange' | 'value'> & {
initialValue: string initialValue: string
@@ -12,12 +13,16 @@ export const DebouncedTextarea = ({
...props ...props
}: Props) => { }: Props) => {
const [currentValue, setCurrentValue] = useState(initialValue) const [currentValue, setCurrentValue] = useState(initialValue)
const [debouncedValue] = useDebounce(
currentValue,
process.env.NEXT_PUBLIC_E2E_TEST ? 0 : 1000
)
useEffect(() => { useEffect(() => {
if (currentValue === initialValue) return if (debouncedValue === initialValue) return
onChange(currentValue) onChange(debouncedValue)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentValue]) }, [debouncedValue])
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
setCurrentValue(e.target.value) setCurrentValue(e.target.value)

View File

@@ -1,5 +1,5 @@
import { Coordinates, useGraph } from 'contexts/GraphContext' import { Coordinates, useGraph } from 'contexts/GraphContext'
import React, { useLayoutEffect, useMemo, useState } from 'react' import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { import {
getAnchorsPosition, getAnchorsPosition,
computeEdgePath, computeEdgePath,
@@ -31,6 +31,7 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
const [isMouseOver, setIsMouseOver] = useState(false) const [isMouseOver, setIsMouseOver] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
const [edgeMenuPosition, setEdgeMenuPosition] = useState({ x: 0, y: 0 }) const [edgeMenuPosition, setEdgeMenuPosition] = useState({ x: 0, y: 0 })
const [refreshEdge, setRefreshEdge] = useState(false)
const isPreviewing = isMouseOver || previewingEdge?.id === edge.id const isPreviewing = isMouseOver || previewingEdge?.id === edge.id
@@ -47,9 +48,13 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
getSourceEndpointId(edge) getSourceEndpointId(edge)
), ),
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[sourceBlockCoordinates?.y, edge, sourceEndpoints] [sourceBlockCoordinates?.y, edge, sourceEndpoints, refreshEdge]
) )
useEffect(() => {
setTimeout(() => setRefreshEdge(true), 50)
}, [])
const [targetTop, setTargetTop] = useState( const [targetTop, setTargetTop] = useState(
getEndpointTopOffset(targetEndpoints, graphPosition.y, edge?.to.stepId) getEndpointTopOffset(targetEndpoints, graphPosition.y, edge?.to.stepId)
) )

View File

@@ -33,8 +33,9 @@ import { GoogleAnalyticsSettings } from './bodies/GoogleAnalyticsSettings'
import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody' import { GoogleSheetsSettingsBody } from './bodies/GoogleSheetsSettingsBody'
import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody' import { PhoneNumberSettingsBody } from './bodies/PhoneNumberSettingsBody'
import { RedirectSettings } from './bodies/RedirectSettings' import { RedirectSettings } from './bodies/RedirectSettings'
import { SendEmailSettings } from './bodies/SendEmailSettings/SendEmailSettings' import { SendEmailSettings } from './bodies/SendEmailSettings'
import { SetVariableSettings } from './bodies/SetVariableSettings' import { SetVariableSettings } from './bodies/SetVariableSettings'
import { TypebotLinkSettingsForm } from './bodies/TypebotLinkSettingsForm'
import { WebhookSettings } from './bodies/WebhookSettings' import { WebhookSettings } from './bodies/WebhookSettings'
import { ZapierSettings } from './bodies/ZapierSettings' import { ZapierSettings } from './bodies/ZapierSettings'
@@ -43,7 +44,6 @@ type Props = {
webhook?: Webhook webhook?: Webhook
onExpandClick: () => void onExpandClick: () => void
onStepChange: (updates: Partial<Step>) => void onStepChange: (updates: Partial<Step>) => void
onTestRequestClick: () => void
} }
export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => { export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
@@ -85,12 +85,10 @@ export const SettingsPopoverContent = ({ onExpandClick, ...props }: Props) => {
export const StepSettings = ({ export const StepSettings = ({
step, step,
onStepChange, onStepChange,
onTestRequestClick,
}: { }: {
step: Step step: Step
webhook?: Webhook webhook?: Webhook
onStepChange: (step: Partial<Step>) => void onStepChange: (step: Partial<Step>) => void
onTestRequestClick: () => void
}) => { }) => {
const handleOptionsChange = (options: StepOptions) => { const handleOptionsChange = (options: StepOptions) => {
onStepChange({ options } as Partial<Step>) onStepChange({ options } as Partial<Step>)
@@ -186,6 +184,14 @@ export const StepSettings = ({
/> />
) )
} }
case LogicStepType.TYPEBOT_LINK: {
return (
<TypebotLinkSettingsForm
options={step.options}
onOptionsChange={handleOptionsChange}
/>
)
}
case IntegrationStepType.GOOGLE_SHEETS: { case IntegrationStepType.GOOGLE_SHEETS: {
return ( return (
<GoogleSheetsSettingsBody <GoogleSheetsSettingsBody
@@ -208,11 +214,7 @@ export const StepSettings = ({
} }
case IntegrationStepType.WEBHOOK: { case IntegrationStepType.WEBHOOK: {
return ( return (
<WebhookSettings <WebhookSettings step={step} onOptionsChange={handleOptionsChange} />
step={step}
onOptionsChange={handleOptionsChange}
onTestRequestClick={onTestRequestClick}
/>
) )
} }
case IntegrationStepType.EMAIL: { case IntegrationStepType.EMAIL: {

View File

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

View File

@@ -0,0 +1,41 @@
import { Input } from '@chakra-ui/react'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { Block } from 'models'
import { useMemo } from 'react'
import { byId } from 'utils'
type Props = {
blocks: Block[]
blockId?: string
onBlockIdSelected: (blockId: string) => void
isLoading?: boolean
}
export const BlocksDropdown = ({
blocks,
blockId,
onBlockIdSelected,
isLoading,
}: Props) => {
const currentBlock = useMemo(
() => blocks?.find(byId(blockId)),
[blockId, blocks]
)
const handleBlockSelect = (title: string) => {
const id = blocks?.find((b) => b.title === title)?.id
if (id) onBlockIdSelected(id)
}
if (isLoading) return <Input value="Loading..." isDisabled />
if (!blocks || blocks.length === 0)
return <Input value="No blocks found" isDisabled />
return (
<SearchableDropdown
selectedItem={currentBlock?.title}
items={(blocks ?? []).map((b) => b.title)}
onValueChange={handleBlockSelect}
placeholder={'Select a block'}
/>
)
}

View File

@@ -0,0 +1,46 @@
import { Stack } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { TypebotLinkOptions } from 'models'
import { byId } from 'utils'
import { BlocksDropdown } from './BlocksDropdown'
import { TypebotsDropdown } from './TypebotsDropdown'
type Props = {
options: TypebotLinkOptions
onOptionsChange: (options: TypebotLinkOptions) => void
}
export const TypebotLinkSettingsForm = ({
options,
onOptionsChange,
}: Props) => {
const { linkedTypebots, typebot } = useTypebot()
const handleTypebotIdChange = (typebotId: string) =>
onOptionsChange({ ...options, typebotId })
const handleBlockIdChange = (blockId: string) =>
onOptionsChange({ ...options, blockId })
return (
<Stack>
<TypebotsDropdown
typebotId={options.typebotId}
onSelectTypebotId={handleTypebotIdChange}
/>
<BlocksDropdown
blocks={
typebot && options.typebotId === typebot.id
? typebot.blocks
: linkedTypebots?.find(byId(options.typebotId))?.blocks ?? []
}
blockId={options.blockId}
onBlockIdSelected={handleBlockIdChange}
isLoading={
linkedTypebots === undefined &&
typebot &&
typebot.id !== options.typebotId
}
/>
</Stack>
)
}

View File

@@ -0,0 +1,56 @@
import { HStack, IconButton, Input, useToast } from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { SearchableDropdown } from 'components/shared/SearchableDropdown'
import { useRouter } from 'next/router'
import { useMemo } from 'react'
import { useTypebots } from 'services/typebots'
import { byId } from 'utils'
type Props = {
typebotId?: string
onSelectTypebotId: (typebotId: string) => void
}
export const TypebotsDropdown = ({ typebotId, onSelectTypebotId }: Props) => {
const { query } = useRouter()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const { typebots, isLoading } = useTypebots({
allFolders: true,
onError: (e) => toast({ title: e.name, description: e.message }),
})
const currentTypebot = useMemo(
() => typebots?.find(byId(typebotId)),
[typebotId, typebots]
)
const handleTypebotSelect = (name: string) => {
const id = typebots?.find((s) => s.name === name)?.id
if (id) onSelectTypebotId(id)
}
if (isLoading) return <Input value="Loading..." isDisabled />
if (!typebots || typebots.length === 0)
return <Input value="No typebots found" isDisabled />
return (
<HStack>
<SearchableDropdown
selectedItem={currentTypebot?.name}
items={(typebots ?? []).map((t) => t.name)}
onValueChange={handleTypebotSelect}
placeholder={'Select a typebot'}
/>
{currentTypebot?.id && (
<IconButton
aria-label="Navigate to typebot"
icon={<ExternalLinkIcon />}
as={NextChakraLink}
href={`/typebots/${currentTypebot?.id}/edit?parentId=${query.typebotId}`}
/>
)}
</HStack>
)
}

View File

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

View File

@@ -42,13 +42,11 @@ import { SwitchWithLabel } from 'components/shared/SwitchWithLabel'
type Props = { type Props = {
step: WebhookStep step: WebhookStep
onOptionsChange: (options: WebhookOptions) => void onOptionsChange: (options: WebhookOptions) => void
onTestRequestClick: () => void
} }
export const WebhookSettings = ({ export const WebhookSettings = ({
step: { options, blockId, id: stepId, webhookId }, step: { options, blockId, id: stepId, webhookId },
onOptionsChange, onOptionsChange,
onTestRequestClick,
}: Props) => { }: Props) => {
const { typebot, save, webhooks, updateWebhook } = useTypebot() const { typebot, save, webhooks, updateWebhook } = useTypebot()
const [isTestResponseLoading, setIsTestResponseLoading] = useState(false) const [isTestResponseLoading, setIsTestResponseLoading] = useState(false)
@@ -122,7 +120,6 @@ export const WebhookSettings = ({
const handleTestRequestClick = async () => { const handleTestRequestClick = async () => {
if (!typebot || !localWebhook) return if (!typebot || !localWebhook) return
setIsTestResponseLoading(true) setIsTestResponseLoading(true)
onTestRequestClick()
await Promise.all([updateWebhook(localWebhook.id, localWebhook), save()]) await Promise.all([updateWebhook(localWebhook.id, localWebhook), save()])
const { data, error } = await executeWebhook( const { data, error } = await executeWebhook(
typebot.id, typebot.id,

View File

@@ -48,7 +48,6 @@ export const StepNode = ({
const { setConnectingIds, connectingIds, openedStepId, setOpenedStepId } = const { setConnectingIds, connectingIds, openedStepId, setOpenedStepId } =
useGraph() useGraph()
const { updateStep } = useTypebot() const { updateStep } = useTypebot()
const [localStep, setLocalStep] = useState(step)
const [isConnecting, setIsConnecting] = useState(false) const [isConnecting, setIsConnecting] = useState(false)
const [isPopoverOpened, setIsPopoverOpened] = useState( const [isPopoverOpened, setIsPopoverOpened] = useState(
openedStepId === step.id openedStepId === step.id
@@ -74,10 +73,6 @@ export const StepNode = ({
onClose: onModalClose, onClose: onModalClose,
} = useDisclosure() } = useDisclosure()
useEffect(() => {
setLocalStep(step)
}, [step])
useEffect(() => { useEffect(() => {
if (query.stepId?.toString() === step.id) setOpenedStepId(step.id) if (query.stepId?.toString() === step.id) setOpenedStepId(step.id)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -91,7 +86,7 @@ export const StepNode = ({
}, [connectingIds, step.blockId, step.id]) }, [connectingIds, step.blockId, step.id])
const handleModalClose = () => { const handleModalClose = () => {
updateStep(indices, { ...localStep }) updateStep(indices, { ...step })
onModalClose() onModalClose()
} }
@@ -112,8 +107,7 @@ export const StepNode = ({
} }
const handleCloseEditor = (content: TextBubbleContent) => { const handleCloseEditor = (content: TextBubbleContent) => {
const updatedStep = { ...localStep, content } as Step const updatedStep = { ...step, content } as Step
setLocalStep(updatedStep)
updateStep(indices, updatedStep) updateStep(indices, updatedStep)
setIsEditing(false) setIsEditing(false)
} }
@@ -129,26 +123,20 @@ export const StepNode = ({
onModalOpen() onModalOpen()
} }
const updateOptions = () => { const handleStepUpdate = (updates: Partial<Step>) =>
updateStep(indices, { ...localStep }) updateStep(indices, { ...step, ...updates })
}
const handleStepChange = (updates: Partial<Step>) => {
setLocalStep({ ...localStep, ...updates } as Step)
}
const handleContentChange = (content: BubbleStepContent) => const handleContentChange = (content: BubbleStepContent) =>
setLocalStep({ ...localStep, content } as Step) updateStep(indices, { ...step, content } as Step)
useEffect(() => { useEffect(() => {
if (isPopoverOpened && openedStepId !== step.id) updateOptions()
setIsPopoverOpened(openedStepId === step.id) setIsPopoverOpened(openedStepId === step.id)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [openedStepId]) }, [openedStepId])
return isEditing && isTextBubbleStep(localStep) ? ( return isEditing && isTextBubbleStep(step) ? (
<TextBubbleEditor <TextBubbleEditor
initialValue={localStep.content.richText} initialValue={step.content.richText}
onClose={handleCloseEditor} onClose={handleCloseEditor}
/> />
) : ( ) : (
@@ -185,22 +173,22 @@ export const StepNode = ({
w="full" w="full"
> >
<StepIcon <StepIcon
type={localStep.type} type={step.type}
mt="1" mt="1"
data-testid={`${localStep.id}-icon`} data-testid={`${step.id}-icon`}
/> />
<StepNodeContent step={localStep} indices={indices} /> <StepNodeContent step={step} indices={indices} />
<TargetEndpoint <TargetEndpoint
pos="absolute" pos="absolute"
left="-32px" left="-32px"
top="19px" top="19px"
stepId={localStep.id} stepId={step.id}
/> />
{isConnectable && hasDefaultConnector(localStep) && ( {isConnectable && hasDefaultConnector(step) && (
<SourceEndpoint <SourceEndpoint
source={{ source={{
blockId: localStep.blockId, blockId: step.blockId,
stepId: localStep.id, stepId: step.id,
}} }}
pos="absolute" pos="absolute"
right="15px" right="15px"
@@ -210,26 +198,21 @@ export const StepNode = ({
</HStack> </HStack>
</Flex> </Flex>
</PopoverTrigger> </PopoverTrigger>
{hasSettingsPopover(localStep) && ( {hasSettingsPopover(step) && (
<SettingsPopoverContent <SettingsPopoverContent
step={localStep} step={step}
onExpandClick={handleExpandClick} onExpandClick={handleExpandClick}
onStepChange={handleStepChange} onStepChange={handleStepUpdate}
onTestRequestClick={updateOptions}
/> />
)} )}
{isMediaBubbleStep(localStep) && ( {isMediaBubbleStep(step) && (
<MediaBubblePopoverContent <MediaBubblePopoverContent
step={localStep} step={step}
onContentChange={handleContentChange} onContentChange={handleContentChange}
/> />
)} )}
<SettingsModal isOpen={isModalOpen} onClose={handleModalClose}> <SettingsModal isOpen={isModalOpen} onClose={handleModalClose}>
<StepSettings <StepSettings step={step} onStepChange={handleStepUpdate} />
step={localStep}
onStepChange={handleStepChange}
onTestRequestClick={updateOptions}
/>
</SettingsModal> </SettingsModal>
</Popover> </Popover>
)} )}

View File

@@ -21,6 +21,7 @@ import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent' import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PlaceholderContent } from './contents/PlaceholderContent' import { PlaceholderContent } from './contents/PlaceholderContent'
import { SendEmailContent } from './contents/SendEmailContent' import { SendEmailContent } from './contents/SendEmailContent'
import { TypebotLinkContent } from './contents/TypebotLinkContent'
import { ZapierContent } from './contents/ZapierContent' import { ZapierContent } from './contents/ZapierContent'
type Props = { type Props = {
@@ -87,6 +88,8 @@ export const StepNodeContent = ({ step, indices }: Props) => {
/> />
) )
} }
case LogicStepType.TYPEBOT_LINK:
return <TypebotLinkContent step={step} />
case IntegrationStepType.GOOGLE_SHEETS: { case IntegrationStepType.GOOGLE_SHEETS: {
return ( return (

View File

@@ -0,0 +1,40 @@
import { TypebotLinkStep } from 'models'
import React from 'react'
import { Tag, Text } from '@chakra-ui/react'
import { useTypebot } from 'contexts/TypebotContext'
import { byId } from 'utils'
type Props = {
step: TypebotLinkStep
}
export const TypebotLinkContent = ({ step }: Props) => {
const { linkedTypebots, typebot } = useTypebot()
const isCurrentTypebot = typebot && step.options.typebotId === typebot.id
const linkedTypebot = isCurrentTypebot
? typebot
: linkedTypebots?.find(byId(step.options.typebotId))
const blockTitle = linkedTypebot?.blocks.find(
byId(step.options.blockId)
)?.title
if (!step.options.typebotId) return <Text color="gray.500">Configure...</Text>
return (
<Text>
Jump{' '}
{blockTitle ? (
<>
to <Tag>{blockTitle}</Tag>
</>
) : (
<></>
)}{' '}
{!isCurrentTypebot ? (
<>
in <Tag colorScheme="blue">{linkedTypebot?.name}</Tag>
</>
) : (
<></>
)}
</Text>
)
}

View File

@@ -26,7 +26,10 @@ export const SearchableDropdown = ({
}: Props) => { }: Props) => {
const { onOpen, onClose, isOpen } = useDisclosure() const { onOpen, onClose, isOpen } = useDisclosure()
const [inputValue, setInputValue] = useState(selectedItem ?? '') const [inputValue, setInputValue] = useState(selectedItem ?? '')
const [debouncedInputValue] = useDebounce(inputValue, 200) const [debouncedInputValue] = useDebounce(
inputValue,
process.env.NEXT_PUBLIC_E2E_TEST ? 0 : 1000
)
const [filteredItems, setFilteredItems] = useState([ const [filteredItems, setFilteredItems] = useState([
...items ...items
.filter((item) => .filter((item) =>

View File

@@ -6,7 +6,8 @@ import {
NumberIncrementStepper, NumberIncrementStepper,
NumberDecrementStepper, NumberDecrementStepper,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useDebounce } from 'use-debounce'
export const SmartNumberInput = ({ export const SmartNumberInput = ({
value, value,
@@ -17,14 +18,26 @@ export const SmartNumberInput = ({
onValueChange: (value?: number) => void onValueChange: (value?: number) => void
} & NumberInputProps) => { } & NumberInputProps) => {
const [currentValue, setCurrentValue] = useState(value?.toString() ?? '') const [currentValue, setCurrentValue] = useState(value?.toString() ?? '')
const [valueToReturn, setValueToReturn] = useState<number | undefined>(
parseFloat(currentValue)
)
const [debouncedValue] = useDebounce(
valueToReturn,
process.env.NEXT_PUBLIC_E2E_TEST ? 0 : 1000
)
useEffect(() => {
onValueChange(debouncedValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedValue])
const handleValueChange = (value: string) => { const handleValueChange = (value: string) => {
setCurrentValue(value) setCurrentValue(value)
if (value.endsWith('.') || value.endsWith(',')) return if (value.endsWith('.') || value.endsWith(',')) return
if (value === '') return onValueChange(undefined) if (value === '') return setValueToReturn(undefined)
const newValue = parseFloat(value) const newValue = parseFloat(value)
if (isNaN(newValue)) return if (isNaN(newValue)) return
onValueChange(newValue) setValueToReturn(newValue)
} }
return ( return (

View File

@@ -13,6 +13,7 @@ import {
import { UserIcon } from 'assets/icons' import { UserIcon } from 'assets/icons'
import { Variable } from 'models' import { Variable } from 'models'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { useDebounce } from 'use-debounce'
import { VariableSearchInput } from '../VariableSearchInput' import { VariableSearchInput } from '../VariableSearchInput'
export type TextBoxWithVariableButtonProps = { export type TextBoxWithVariableButtonProps = {
@@ -33,12 +34,16 @@ export const TextBoxWithVariableButton = ({
null null
) )
const [value, setValue] = useState(initialValue) const [value, setValue] = useState(initialValue)
const [debouncedValue] = useDebounce(
value,
process.env.NEXT_PUBLIC_E2E_TEST ? 0 : 1000
)
const [carretPosition, setCarretPosition] = useState<number>(0) const [carretPosition, setCarretPosition] = useState<number>(0)
useEffect(() => { useEffect(() => {
onChange(value) onChange(value)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]) }, [debouncedValue])
const handleVariableSelected = (variable?: Variable) => { const handleVariableSelected = (variable?: Variable) => {
if (!textBoxRef.current || !variable) return if (!textBoxRef.current || !variable) return

View File

@@ -1,15 +1,22 @@
import { Editable, EditablePreview, EditableInput } from '@chakra-ui/editable' import { Editable, EditablePreview, EditableInput } from '@chakra-ui/editable'
import { Tooltip } from '@chakra-ui/tooltip' import { Tooltip } from '@chakra-ui/tooltip'
import React from 'react' import React, { useEffect, useState } from 'react'
type EditableProps = { type EditableProps = {
name: string name: string
onNewName: (newName: string) => void onNewName: (newName: string) => void
} }
export const EditableTypebotName = ({ name, onNewName }: EditableProps) => { export const EditableTypebotName = ({ name, onNewName }: EditableProps) => {
const [localName, setLocalName] = useState(name)
useEffect(() => {
if (name !== localName) setLocalName(name)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [name])
return ( return (
<Tooltip label="Rename"> <Tooltip label="Rename">
<Editable defaultValue={name} onSubmit={onNewName}> <Editable value={localName} onChange={setLocalName} onSubmit={onNewName}>
<EditablePreview <EditablePreview
isTruncated isTruncated
cursor="pointer" cursor="pointer"

View File

@@ -106,10 +106,12 @@ export const TypebotHeader = () => {
<HStack alignItems="center"> <HStack alignItems="center">
<IconButton <IconButton
as={NextChakraLink} as={NextChakraLink}
aria-label="Back" aria-label="Navigate back"
icon={<ChevronLeftIcon fontSize={30} />} icon={<ChevronLeftIcon fontSize={30} />}
href={ href={
typebot?.folderId router.query.parentId
? `/typebots/${router.query.parentId}/edit`
: typebot?.folderId
? `/typebots/folders/${typebot.folderId}` ? `/typebots/folders/${typebot.folderId}`
: '/typebots' : '/typebots'
} }

View File

@@ -38,7 +38,10 @@ export const VariableSearchInput = ({
const [inputValue, setInputValue] = useState( const [inputValue, setInputValue] = useState(
variables.find(byId(initialVariableId))?.name ?? '' variables.find(byId(initialVariableId))?.name ?? ''
) )
const [debouncedInputValue] = useDebounce(inputValue, 200) const [debouncedInputValue] = useDebounce(
inputValue,
process.env.NEXT_PUBLIC_E2E_TEST ? 0 : 1000
)
const [filteredItems, setFilteredItems] = useState<Variable[]>( const [filteredItems, setFilteredItems] = useState<Variable[]>(
variables ?? [] variables ?? []
) )
@@ -57,7 +60,7 @@ export const VariableSearchInput = ({
useEffect(() => { useEffect(() => {
const variable = variables.find((v) => v.name === debouncedInputValue) const variable = variables.find((v) => v.name === debouncedInputValue)
if (variable) onSelectVariable(variable) onSelectVariable(variable)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedInputValue]) }, [debouncedInputValue])
@@ -66,7 +69,6 @@ export const VariableSearchInput = ({
onOpen() onOpen()
if (e.target.value === '') { if (e.target.value === '') {
setFilteredItems([...variables.slice(0, 50)]) setFilteredItems([...variables.slice(0, 50)])
onSelectVariable(undefined)
return return
} }
setFilteredItems([ setFilteredItems([
@@ -80,15 +82,14 @@ export const VariableSearchInput = ({
const handleVariableNameClick = (variable: Variable) => () => { const handleVariableNameClick = (variable: Variable) => () => {
setInputValue(variable.name) setInputValue(variable.name)
onSelectVariable(variable)
onClose() onClose()
} }
const handleCreateNewVariableClick = () => { const handleCreateNewVariableClick = () => {
if (!inputValue || inputValue === '') return if (!inputValue || inputValue === '') return
const id = generate() const id = generate()
createVariable({ id, name: inputValue })
onSelectVariable({ id, name: inputValue }) onSelectVariable({ id, name: inputValue })
createVariable({ id, name: inputValue })
onClose() onClose()
} }

View File

@@ -1,5 +1,12 @@
import { useToast } from '@chakra-ui/react' import { useToast } from '@chakra-ui/react'
import { PublicTypebot, Settings, Theme, Typebot, Webhook } from 'models' import {
LogicStepType,
PublicTypebot,
Settings,
Theme,
Typebot,
Webhook,
} from 'models'
import { Router, useRouter } from 'next/router' import { Router, useRouter } from 'next/router'
import { import {
createContext, createContext,
@@ -23,7 +30,7 @@ import {
} from 'services/typebots/typebots' } from 'services/typebots/typebots'
import { fetcher, preventUserFromRefreshing } from 'services/utils' import { fetcher, preventUserFromRefreshing } from 'services/utils'
import useSWR from 'swr' import useSWR from 'swr'
import { isDefined, isNotDefined } from 'utils' import { isDefined, isNotDefined, omit } from 'utils'
import { BlocksActions, blocksActions } from './actions/blocks' import { BlocksActions, blocksActions } from './actions/blocks'
import { stepsAction, StepsActions } from './actions/steps' import { stepsAction, StepsActions } from './actions/steps'
import { variablesAction, VariablesActions } from './actions/variables' import { variablesAction, VariablesActions } from './actions/variables'
@@ -36,6 +43,7 @@ import { generate } from 'short-uuid'
import { deepEqual } from 'fast-equals' import { deepEqual } from 'fast-equals'
import { User } from 'db' import { User } from 'db'
import { saveWebhook } from 'services/webhook' import { saveWebhook } from 'services/webhook'
import { stringify } from 'qs'
const autoSaveTimeout = 10000 const autoSaveTimeout = 10000
type UpdateTypebotPayload = Partial<{ type UpdateTypebotPayload = Partial<{
@@ -46,11 +54,14 @@ type UpdateTypebotPayload = Partial<{
publishedTypebotId: string publishedTypebotId: string
}> }>
export type SetTypebot = (typebot: Typebot | undefined) => void export type SetTypebot = (
newPresent: Typebot | ((current: Typebot) => Typebot)
) => void
const typebotContext = createContext< const typebotContext = createContext<
{ {
typebot?: Typebot typebot?: Typebot
publishedTypebot?: PublicTypebot publishedTypebot?: PublicTypebot
linkedTypebots?: Typebot[]
owner?: User owner?: User
webhooks: Webhook[] webhooks: Webhook[]
isReadOnly?: boolean isReadOnly?: boolean
@@ -118,6 +129,7 @@ export const TypebotContext = ({
{ {
redo, redo,
undo, undo,
flush,
canRedo, canRedo,
canUndo, canUndo,
set: setLocalTypebot, set: setLocalTypebot,
@@ -125,10 +137,50 @@ export const TypebotContext = ({
}, },
] = useUndo<Typebot | undefined>(undefined) ] = useUndo<Typebot | undefined>(undefined)
const saveTypebot = async () => { const linkedTypebotIds = localTypebot?.blocks
const typebotToSave = currentTypebotRef.current .flatMap((b) => b.steps)
if (deepEqual(typebot, typebotToSave)) return .reduce<string[]>(
if (!typebotToSave) return (typebotIds, step) =>
step.type === LogicStepType.TYPEBOT_LINK &&
isDefined(step.options.typebotId)
? [...typebotIds, step.options.typebotId]
: typebotIds,
[]
)
const { typebots: linkedTypebots } = useLinkedTypebots({
typebotId,
typebotIds: linkedTypebotIds,
onError: (error) =>
toast({
title: 'Error while fetching linkedTypebots',
description: error.message,
}),
})
useEffect(() => {
if (!typebot || !currentTypebotRef.current) return
if (typebotId !== currentTypebotRef.current.id) {
setLocalTypebot({ ...typebot })
flush()
} else if (
new Date(typebot.updatedAt) >
new Date(currentTypebotRef.current.updatedAt)
) {
setLocalTypebot({ ...typebot })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typebot])
const saveTypebot = async (options?: { disableMutation: boolean }) => {
if (!currentTypebotRef.current || !typebot) return
const typebotToSave = {
...currentTypebotRef.current,
updatedAt: new Date().toISOString(),
}
if (deepEqual(omit(typebot, 'updatedAt'), omit(typebotToSave, 'updatedAt')))
return
setIsSavingLoading(true) setIsSavingLoading(true)
const { error } = await updateTypebot(typebotToSave.id, typebotToSave) const { error } = await updateTypebot(typebotToSave.id, typebotToSave)
setIsSavingLoading(false) setIsSavingLoading(false)
@@ -136,7 +188,12 @@ export const TypebotContext = ({
toast({ title: error.name, description: error.message }) toast({ title: error.name, description: error.message })
return return
} }
mutate({ typebot: typebotToSave, webhooks: webhooks ?? [] }) if (!options?.disableMutation)
mutate({
typebot: typebotToSave,
publishedTypebot,
webhooks: webhooks ?? [],
})
window.removeEventListener('beforeunload', preventUserFromRefreshing) window.removeEventListener('beforeunload', preventUserFromRefreshing)
} }
@@ -165,9 +222,10 @@ export const TypebotContext = ({
) )
useEffect(() => { useEffect(() => {
Router.events.on('routeChangeStart', saveTypebot) const save = () => saveTypebot({ disableMutation: true })
Router.events.on('routeChangeStart', save)
return () => { return () => {
Router.events.off('routeChangeStart', saveTypebot) Router.events.off('routeChangeStart', save)
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [typebot, publishedTypebot, webhooks]) }, [typebot, publishedTypebot, webhooks])
@@ -177,10 +235,10 @@ export const TypebotContext = ({
const isPublished = useMemo( const isPublished = useMemo(
() => () =>
isDefined(typebot) && isDefined(localTypebot) &&
isDefined(publishedTypebot) && isDefined(publishedTypebot) &&
checkIfPublished(typebot, publishedTypebot), checkIfPublished(localTypebot, publishedTypebot),
[typebot, publishedTypebot] [localTypebot, publishedTypebot]
) )
useEffect(() => { useEffect(() => {
@@ -310,6 +368,7 @@ export const TypebotContext = ({
value={{ value={{
typebot: localTypebot, typebot: localTypebot,
publishedTypebot, publishedTypebot,
linkedTypebots,
owner, owner,
webhooks: webhooks ?? [], webhooks: webhooks ?? [],
isReadOnly, isReadOnly,
@@ -326,11 +385,11 @@ export const TypebotContext = ({
restorePublishedTypebot, restorePublishedTypebot,
updateOnBothTypebots, updateOnBothTypebots,
updateWebhook, updateWebhook,
...blocksActions(localTypebot as Typebot, setLocalTypebot), ...blocksActions(setLocalTypebot as SetTypebot),
...stepsAction(localTypebot as Typebot, setLocalTypebot), ...stepsAction(setLocalTypebot as SetTypebot),
...variablesAction(localTypebot as Typebot, setLocalTypebot), ...variablesAction(setLocalTypebot as SetTypebot),
...edgesAction(localTypebot as Typebot, setLocalTypebot), ...edgesAction(setLocalTypebot as SetTypebot),
...itemsAction(localTypebot as Typebot, setLocalTypebot), ...itemsAction(setLocalTypebot as SetTypebot),
}} }}
> >
{children} {children}
@@ -356,7 +415,7 @@ export const useFetchedTypebot = ({
isReadOnly?: boolean isReadOnly?: boolean
}, },
Error Error
>(`/api/typebots/${typebotId}`, fetcher) >(`/api/typebots/${typebotId}`, fetcher, { dedupingInterval: 0 })
if (error) onError(error) if (error) onError(error)
return { return {
typebot: data?.typebot, typebot: data?.typebot,
@@ -369,6 +428,35 @@ export const useFetchedTypebot = ({
} }
} }
const useLinkedTypebots = ({
typebotId,
typebotIds,
onError,
}: {
typebotId?: string
typebotIds?: string[]
onError: (error: Error) => void
}) => {
const params = stringify({ typebotIds }, { indices: false })
const { data, error, mutate } = useSWR<
{
typebots: Typebot[]
},
Error
>(
typebotIds?.every((id) => typebotId === id)
? undefined
: `/api/typebots?${params}`,
fetcher
)
if (error) onError(error)
return {
typebots: data?.typebots,
isLoading: !error && !data,
mutate,
}
}
const useAutoSave = <T,>( const useAutoSave = <T,>(
{ {
handler, handler,
@@ -376,7 +464,7 @@ const useAutoSave = <T,>(
debounceTimeout, debounceTimeout,
}: { }: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
handler: (item?: T) => Promise<any> handler: () => Promise<any>
item?: T item?: T
debounceTimeout: number debounceTimeout: number
}, },
@@ -384,7 +472,7 @@ const useAutoSave = <T,>(
) => { ) => {
const [debouncedItem] = useDebounce(item, debounceTimeout) const [debouncedItem] = useDebounce(item, debounceTimeout)
useEffect(() => { useEffect(() => {
const save = () => handler(item) const save = () => handler()
document.addEventListener('visibilitychange', save) document.addEventListener('visibilitychange', save)
return () => { return () => {
document.removeEventListener('visibilitychange', save) document.removeEventListener('visibilitychange', save)
@@ -392,7 +480,7 @@ const useAutoSave = <T,>(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies) }, dependencies)
return useEffect(() => { return useEffect(() => {
handler(item) handler()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedItem]) }, [debouncedItem])
} }

View File

@@ -24,10 +24,7 @@ export type BlocksActions = {
deleteBlock: (blockIndex: number) => void deleteBlock: (blockIndex: number) => void
} }
const blocksActions = ( const blocksActions = (setTypebot: SetTypebot): BlocksActions => ({
typebot: Typebot,
setTypebot: SetTypebot
): BlocksActions => ({
createBlock: ({ createBlock: ({
id, id,
step, step,
@@ -37,8 +34,8 @@ const blocksActions = (
id: string id: string
step: DraggableStep | DraggableStepType step: DraggableStep | DraggableStepType
indices: StepIndices indices: StepIndices
}) => { }) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
const newBlock: Block = { const newBlock: Block = {
id, id,
@@ -49,17 +46,17 @@ const blocksActions = (
typebot.blocks.push(newBlock) typebot.blocks.push(newBlock)
createStepDraft(typebot, step, newBlock.id, indices) createStepDraft(typebot, step, newBlock.id, indices)
}) })
) ),
},
updateBlock: (blockIndex: number, updates: Partial<Omit<Block, 'id'>>) => updateBlock: (blockIndex: number, updates: Partial<Omit<Block, 'id'>>) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
const block = typebot.blocks[blockIndex] const block = typebot.blocks[blockIndex]
typebot.blocks[blockIndex] = { ...block, ...updates } typebot.blocks[blockIndex] = { ...block, ...updates }
}) })
), ),
deleteBlock: (blockIndex: number) => deleteBlock: (blockIndex: number) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
deleteBlockDraft(typebot)(blockIndex) deleteBlockDraft(typebot)(blockIndex)
}) })

View File

@@ -11,12 +11,9 @@ export type EdgesActions = {
deleteEdge: (edgeId: string) => void deleteEdge: (edgeId: string) => void
} }
export const edgesAction = ( export const edgesAction = (setTypebot: SetTypebot): EdgesActions => ({
typebot: Typebot, createEdge: (edge: Omit<Edge, 'id'>) =>
setTypebot: SetTypebot setTypebot((typebot) =>
): EdgesActions => ({
createEdge: (edge: Omit<Edge, 'id'>) => {
setTypebot(
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
const newEdge = { const newEdge = {
...edge, ...edge,
@@ -45,10 +42,9 @@ export const edgesAction = (
stepIndex, stepIndex,
}) })
}) })
) ),
},
updateEdge: (edgeIndex: number, updates: Partial<Omit<Edge, 'id'>>) => updateEdge: (edgeIndex: number, updates: Partial<Omit<Edge, 'id'>>) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
const currentEdge = typebot.edges[edgeIndex] const currentEdge = typebot.edges[edgeIndex]
typebot.edges[edgeIndex] = { typebot.edges[edgeIndex] = {
@@ -57,13 +53,12 @@ export const edgesAction = (
} }
}) })
), ),
deleteEdge: (edgeId: string) => { deleteEdge: (edgeId: string) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
deleteEdgeDraft(typebot, edgeId) deleteEdgeDraft(typebot, edgeId)
}) })
) ),
},
}) })
const addEdgeIdToStep = ( const addEdgeIdToStep = (

View File

@@ -1,5 +1,4 @@
import { import {
Typebot,
ItemIndices, ItemIndices,
Item, Item,
InputStepType, InputStepType,
@@ -18,15 +17,12 @@ export type ItemsActions = {
deleteItem: (indices: ItemIndices) => void deleteItem: (indices: ItemIndices) => void
} }
const itemsAction = ( const itemsAction = (setTypebot: SetTypebot): ItemsActions => ({
typebot: Typebot,
setTypebot: SetTypebot
): ItemsActions => ({
createItem: ( createItem: (
item: Omit<ButtonItem, 'id'>, item: Omit<ButtonItem, 'id'>,
{ blockIndex, stepIndex, itemIndex }: ItemIndices { blockIndex, stepIndex, itemIndex }: ItemIndices
) => { ) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
const step = typebot.blocks[blockIndex].steps[stepIndex] const step = typebot.blocks[blockIndex].steps[stepIndex]
if (step.type !== InputStepType.CHOICE) return if (step.type !== InputStepType.CHOICE) return
@@ -36,13 +32,12 @@ const itemsAction = (
id: generate(), id: generate(),
}) })
}) })
) ),
},
updateItem: ( updateItem: (
{ blockIndex, stepIndex, itemIndex }: ItemIndices, { blockIndex, stepIndex, itemIndex }: ItemIndices,
updates: Partial<Omit<Item, 'id'>> updates: Partial<Omit<Item, 'id'>>
) => ) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
const step = typebot.blocks[blockIndex].steps[stepIndex] const step = typebot.blocks[blockIndex].steps[stepIndex]
if (!stepHasItems(step)) return if (!stepHasItems(step)) return
@@ -54,8 +49,9 @@ const itemsAction = (
} as Item } as Item
}) })
), ),
deleteItem: ({ blockIndex, stepIndex, itemIndex }: ItemIndices) => {
setTypebot( deleteItem: ({ blockIndex, stepIndex, itemIndex }: ItemIndices) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
const step = typebot.blocks[blockIndex].steps[ const step = typebot.blocks[blockIndex].steps[
stepIndex stepIndex
@@ -64,8 +60,7 @@ const itemsAction = (
step.items.splice(itemIndex, 1) step.items.splice(itemIndex, 1)
cleanUpEdgeDraft(typebot, removingItem.id) cleanUpEdgeDraft(typebot, removingItem.id)
}) })
) ),
},
}) })
export { itemsAction } export { itemsAction }

View File

@@ -26,42 +26,36 @@ export type StepsActions = {
deleteStep: (indices: StepIndices) => void deleteStep: (indices: StepIndices) => void
} }
const stepsAction = ( const stepsAction = (setTypebot: SetTypebot): StepsActions => ({
typebot: Typebot,
setTypebot: SetTypebot
): StepsActions => ({
createStep: ( createStep: (
blockId: string, blockId: string,
step: DraggableStep | DraggableStepType, step: DraggableStep | DraggableStepType,
indices: StepIndices indices: StepIndices
) => { ) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
createStepDraft(typebot, step, blockId, indices) createStepDraft(typebot, step, blockId, indices)
}) })
) ),
},
updateStep: ( updateStep: (
{ blockIndex, stepIndex }: StepIndices, { blockIndex, stepIndex }: StepIndices,
updates: Partial<Omit<Step, 'id' | 'type'>> updates: Partial<Omit<Step, 'id' | 'type'>>
) => ) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
const step = typebot.blocks[blockIndex].steps[stepIndex] const step = typebot.blocks[blockIndex].steps[stepIndex]
typebot.blocks[blockIndex].steps[stepIndex] = { ...step, ...updates } typebot.blocks[blockIndex].steps[stepIndex] = { ...step, ...updates }
}) })
), ),
detachStepFromBlock: (indices: StepIndices) => { detachStepFromBlock: (indices: StepIndices) =>
setTypebot(produce(typebot, removeStepFromBlock(indices))) setTypebot((typebot) => produce(typebot, removeStepFromBlock(indices))),
}, deleteStep: ({ blockIndex, stepIndex }: StepIndices) =>
deleteStep: ({ blockIndex, stepIndex }: StepIndices) => { setTypebot((typebot) =>
setTypebot(
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
removeStepFromBlock({ blockIndex, stepIndex })(typebot) removeStepFromBlock({ blockIndex, stepIndex })(typebot)
removeEmptyBlocks(typebot) removeEmptyBlocks(typebot)
}) })
) ),
},
}) })
const removeStepFromBlock = const removeStepFromBlock =

View File

@@ -12,35 +12,30 @@ export type VariablesActions = {
deleteVariable: (variableId: string) => void deleteVariable: (variableId: string) => void
} }
export const variablesAction = ( export const variablesAction = (setTypebot: SetTypebot): VariablesActions => ({
typebot: Typebot, createVariable: (newVariable: Variable) =>
setTypebot: SetTypebot setTypebot((typebot) =>
): VariablesActions => ({
createVariable: (newVariable: Variable) => {
setTypebot(
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
typebot.variables.push(newVariable) typebot.variables.push(newVariable)
}) })
) ),
},
updateVariable: ( updateVariable: (
variableId: string, variableId: string,
updates: Partial<Omit<Variable, 'id'>> updates: Partial<Omit<Variable, 'id'>>
) => ) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
typebot.variables.map((v) => typebot.variables.map((v) =>
v.id === variableId ? { ...v, ...updates } : v v.id === variableId ? { ...v, ...updates } : v
) )
}) })
), ),
deleteVariable: (itemId: string) => { deleteVariable: (itemId: string) =>
setTypebot( setTypebot((typebot) =>
produce(typebot, (typebot) => { produce(typebot, (typebot) => {
deleteVariableDraft(typebot, itemId) deleteVariableDraft(typebot, itemId)
}) })
) ),
},
}) })
export const deleteVariableDraft = ( export const deleteVariableDraft = (

View File

@@ -13,6 +13,7 @@ import { unparse } from 'papaparse'
import { UnlockProPlanInfo } from 'components/shared/Info' import { UnlockProPlanInfo } from 'components/shared/Info'
import { LogsModal } from './LogsModal' import { LogsModal } from './LogsModal'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { isDefined } from 'utils'
type Props = { type Props = {
typebotId: string typebotId: string
@@ -26,7 +27,7 @@ export const SubmissionsContent = ({
totalHiddenResults, totalHiddenResults,
onDeleteResults, onDeleteResults,
}: Props) => { }: Props) => {
const { publishedTypebot } = useTypebot() const { publishedTypebot, linkedTypebots } = useTypebot()
const [selectedIndices, setSelectedIndices] = useState<number[]>([]) const [selectedIndices, setSelectedIndices] = useState<number[]>([])
const [isDeleteLoading, setIsDeleteLoading] = useState(false) const [isDeleteLoading, setIsDeleteLoading] = useState(false)
const [isExportLoading, setIsExportLoading] = useState(false) const [isExportLoading, setIsExportLoading] = useState(false)
@@ -37,6 +38,17 @@ export const SubmissionsContent = ({
status: 'error', status: 'error',
}) })
const blocksAndVariables = {
blocks: [
...(publishedTypebot?.blocks ?? []),
...(linkedTypebots?.flatMap((t) => t.blocks) ?? []),
].filter(isDefined),
variables: [
...(publishedTypebot?.variables ?? []),
...(linkedTypebots?.flatMap((t) => t.variables) ?? []),
].filter(isDefined),
}
const { data, mutate, setSize, hasMore } = useResults({ const { data, mutate, setSize, hasMore } = useResults({
typebotId, typebotId,
onError: (err) => toast({ title: err.name, description: err.message }), onError: (err) => toast({ title: err.name, description: err.message }),
@@ -105,13 +117,13 @@ export const SubmissionsContent = ({
if (!publishedTypebot) return [] if (!publishedTypebot) return []
const { data, error } = await getAllResults(typebotId) const { data, error } = await getAllResults(typebotId)
if (error) toast({ description: error.message, title: error.name }) if (error) toast({ description: error.message, title: error.name })
return convertResultsToTableData(publishedTypebot)(data?.results) return convertResultsToTableData(blocksAndVariables)(data?.results)
} }
const tableData: { [key: string]: string }[] = useMemo( const tableData: { [key: string]: string }[] = useMemo(
() => () =>
publishedTypebot publishedTypebot
? convertResultsToTableData(publishedTypebot)(results) ? convertResultsToTableData(blocksAndVariables)(results)
: [], : [],
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[results] [results]
@@ -147,6 +159,7 @@ export const SubmissionsContent = ({
</Flex> </Flex>
<SubmissionsTable <SubmissionsTable
blocksAndVariables={blocksAndVariables}
data={tableData} data={tableData}
onNewSelection={handleNewSelection} onNewSelection={handleNewSelection}
onScrollToBottom={handleScrolledToBottom} onScrollToBottom={handleScrolledToBottom}

View File

@@ -3,7 +3,6 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils' import { methodNotAllowed } from 'utils'
const handler = (req: NextApiRequest, res: NextApiResponse) => { const handler = (req: NextApiRequest, res: NextApiResponse) => {
console.log(req.method)
if (req.method === 'POST') { if (req.method === 'POST') {
return res.status(200).send(req.body) return res.status(200).send(req.body)
} }

View File

@@ -11,7 +11,35 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (!user) return notAuthenticated(res) if (!user) return notAuthenticated(res)
try { try {
if (req.method === 'GET') { if (req.method === 'GET') {
const folderId = req.query.folderId ? req.query.folderId.toString() : null const folderId = req.query.allFolders
? undefined
: req.query.folderId
? req.query.folderId.toString()
: null
const typebotIds = req.query.typebotIds as string[] | undefined
if (typebotIds) {
const typebots = await prisma.typebot.findMany({
where: {
OR: [
{
ownerId: user.id,
id: { in: typebotIds },
},
{
id: { in: typebotIds },
collaborators: {
some: {
userId: user.id,
},
},
},
],
},
orderBy: { createdAt: 'desc' },
select: { name: true, id: true, blocks: true },
})
return res.send({ typebots })
}
const typebots = await prisma.typebot.findMany({ const typebots = await prisma.typebot.findMany({
where: { where: {
ownerId: user.id, ownerId: user.id,

View File

@@ -0,0 +1,21 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { getAuthenticatedUser } from 'services/api/utils'
import { methodNotAllowed, notAuthenticated, notFound } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await getAuthenticatedUser(req)
if (!user) return notAuthenticated(res)
if (req.method === 'GET') {
const typebotId = req.query.typebotId as string
const typebot = await prisma.typebot.findUnique({
where: { id_ownerId: { id: typebotId, ownerId: user.id } },
})
if (!typebot) return notFound(res)
return res.send({ blocks: typebot.blocks })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -0,0 +1,102 @@
{
"id": "cl0j7q9el0032b51al3rx6jo7",
"createdAt": "2022-03-09T07:01:25.917Z",
"updatedAt": "2022-03-09T07:01:25.917Z",
"name": "My typebot",
"ownerId": "cl0cfi60r0000381a2bft9yis",
"publishedTypebotId": null,
"folderId": null,
"blocks": [
{
"id": "jPVDjQ5go4ZxmGCmApbcQf",
"steps": [
{
"id": "nrCKsAYzbCogJanfxUavUV",
"type": "start",
"label": "Start",
"blockId": "jPVDjQ5go4ZxmGCmApbcQf",
"outgoingEdgeId": "8MazLBx8HbfKeYLADQkA3z"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "nyD3H7h6tEZqDmGwGciGV4",
"graphCoordinates": { "x": 428, "y": 168 },
"title": "Block #1",
"steps": [
{
"id": "s8Pz7fPg4niG1JcvBS3CwAs",
"blockId": "nyD3H7h6tEZqDmGwGciGV4",
"type": "Typebot link",
"options": {}
}
]
},
{
"id": "jMbvgRQfXUaXg37LRNqRaJ",
"graphCoordinates": { "x": 423, "y": 386 },
"title": "Hello",
"steps": [
{
"id": "scE368YFYn9cWU1RkQDFLUW",
"blockId": "jMbvgRQfXUaXg37LRNqRaJ",
"type": "text",
"content": {
"html": "<div>Hello world</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Hello world" }] }
],
"plainText": "Hello world"
}
},
{
"id": "sem1do43KTkuvf49eqWcMgc",
"blockId": "jMbvgRQfXUaXg37LRNqRaJ",
"type": "text input",
"options": {
"isLong": false,
"labels": { "button": "Send", "placeholder": "Type your answer..." }
}
}
]
}
],
"variables": [],
"edges": [
{
"from": {
"blockId": "jPVDjQ5go4ZxmGCmApbcQf",
"stepId": "nrCKsAYzbCogJanfxUavUV"
},
"to": { "blockId": "nyD3H7h6tEZqDmGwGciGV4" },
"id": "8MazLBx8HbfKeYLADQkA3z"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
}

View File

@@ -0,0 +1,105 @@
{
"id": "cl0iecee90042961arm5kb0f0",
"createdAt": "2022-03-08T17:18:50.337Z",
"updatedAt": "2022-03-08T21:05:28.825Z",
"name": "Another typebot",
"ownerId": "cl0cfi60r0000381a2bft9yis",
"publishedTypebotId": null,
"folderId": null,
"blocks": [
{
"id": "p4ByLVoKiDRyRoPHKmcTfw",
"steps": [
{
"id": "rw6smEWEJzHKbiVKLUKFvZ",
"type": "start",
"label": "Start",
"blockId": "p4ByLVoKiDRyRoPHKmcTfw",
"outgoingEdgeId": "1z3pfiatTUHbraD2uSoA3E"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "bg4QEJseUsTP496H27j5k2",
"steps": [
{
"id": "s8ZeBL9p5za77eBmdKECLYq",
"type": "text input",
"blockId": "bg4QEJseUsTP496H27j5k2",
"options": {
"isLong": false,
"labels": { "button": "Send", "placeholder": "Type your answer..." }
},
"outgoingEdgeId": "aEBnubX4EMx4Cse6xPAR1m"
}
],
"title": "Block #1",
"graphCoordinates": { "x": 366, "y": 191 }
},
{
"id": "uhqCZSNbsYVFxop7Gc8xvn",
"steps": [
{
"id": "smyHyeS6yaFaHHU44BNmN4n",
"type": "text",
"blockId": "uhqCZSNbsYVFxop7Gc8xvn",
"content": {
"html": "<div>Second block</div>",
"richText": [
{ "type": "p", "children": [{ "text": "Second block" }] }
],
"plainText": "Second block"
}
}
],
"title": "Block #2",
"graphCoordinates": { "x": 793, "y": 99 }
}
],
"variables": [],
"edges": [
{
"id": "1z3pfiatTUHbraD2uSoA3E",
"to": { "blockId": "bg4QEJseUsTP496H27j5k2" },
"from": {
"stepId": "rw6smEWEJzHKbiVKLUKFvZ",
"blockId": "p4ByLVoKiDRyRoPHKmcTfw"
}
},
{
"id": "aEBnubX4EMx4Cse6xPAR1m",
"to": { "blockId": "uhqCZSNbsYVFxop7Gc8xvn" },
"from": {
"stepId": "s8ZeBL9p5za77eBmdKECLYq",
"blockId": "bg4QEJseUsTP496H27j5k2"
}
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
}

View File

@@ -169,8 +169,8 @@ const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
theme: defaultTheme, theme: defaultTheme,
settings: defaultSettings, settings: defaultSettings,
publicId: null, publicId: null,
updatedAt: new Date(), updatedAt: new Date().toISOString(),
createdAt: new Date(), createdAt: new Date().toISOString(),
publishedTypebotId: null, publishedTypebotId: null,
customDomain: null, customDomain: null,
variables: [{ id: 'var1', name: 'var1' }], variables: [{ id: 'var1', name: 'var1' }],

View File

@@ -80,7 +80,7 @@ test.describe.parallel('Image bubble step', () => {
await page.click('text=Click to edit...') await page.click('text=Click to edit...')
await page.click('text=Giphy') await page.click('text=Giphy')
await page.click('img >> nth=3') await page.click('img >> nth=3', { force: true })
await expect(page.locator('img[alt="Step image"]')).toHaveAttribute( await expect(page.locator('img[alt="Step image"]')).toHaveAttribute(
'src', 'src',
new RegExp('giphy.com/media', 'gm') new RegExp('giphy.com/media', 'gm')

View File

@@ -0,0 +1,62 @@
import test, { expect } from '@playwright/test'
import { typebotViewer } from '../../services/selectorUtils'
import { generate } from 'short-uuid'
import { importTypebotInDatabase } from '../../services/database'
import path from 'path'
test('should be configurable', async ({ page }) => {
const typebotId = generate()
const linkedTypebotId = generate()
await importTypebotInDatabase(
path.join(__dirname, '../../fixtures/typebots/logic/linkTypebots/1.json'),
{ id: typebotId }
)
await importTypebotInDatabase(
path.join(__dirname, '../../fixtures/typebots/logic/linkTypebots/2.json'),
{ id: linkedTypebotId }
)
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Configure...')
await page.click('input[placeholder="Select a typebot"]')
await page.click('text=Another typebot')
await expect(page.locator('input[value="Another typebot"]')).toBeVisible()
await page.click('[aria-label="Navigate to typebot"]')
await expect(page).toHaveURL(
`/typebots/${linkedTypebotId}/edit?parentId=${typebotId}`
)
await page.click('[aria-label="Navigate back"]')
await expect(page).toHaveURL(`/typebots/${typebotId}/edit`)
await page.click('text=Jump in Another typebot')
await expect(page.locator('input[value="Another typebot"]')).toBeVisible()
await page.click('input[placeholder="Select a block"]')
await page.click('text=Block #2')
await page.click('text=Preview')
await expect(typebotViewer(page).locator('text=Second block')).toBeVisible()
await page.click('[aria-label="Close"]')
await page.click('text=Jump to Block #2 in Another typebot')
await page.click('input[value="Block #2"]', { clickCount: 3 })
await page.press('input[value="Block #2"]', 'Backspace')
await page.click('button >> text=Start')
await page.click('text=Preview')
await typebotViewer(page).locator('input').fill('Hello there!')
await typebotViewer(page).locator('input').press('Enter')
await expect(typebotViewer(page).locator('text=Hello there!')).toBeVisible()
await page.click('[aria-label="Close"]')
await page.click('text=Jump to Start in Another typebot')
await page.click('input[value="Another typebot"]', { clickCount: 3 })
await page.press('input[value="Another typebot"]', 'Backspace')
await page.click('button >> text=My typebot')
await page.click('input[placeholder="Select a block"]', {
clickCount: 3,
})
await page.press('input[placeholder="Select a block"]', 'Backspace')
await page.click('button >> text=Hello')
await page.click('text=Preview')
await expect(typebotViewer(page).locator('text=Hello world')).toBeVisible()
})

View File

@@ -43,7 +43,7 @@ test.describe.parallel('Settings page', () => {
await page.click('button:has-text("Typing emulation")') await page.click('button:has-text("Typing emulation")')
await page.fill('[data-testid="speed"] input', '350') await page.fill('[data-testid="speed"] input', '350')
await page.fill('[data-testid="max-delay"] input', '1.5') await page.fill('[data-testid="max-delay"] input', '1.5')
await page.uncheck(':nth-match(input[type="checkbox"], 2)', { await page.uncheck('input[type="checkbox"] >> nth=-1', {
force: true, force: true,
}) })
await expect(page.locator('[data-testid="speed"]')).toBeHidden() await expect(page.locator('[data-testid="speed"]')).toBeHidden()

View File

@@ -1,4 +1,4 @@
import { PublicTypebot, Typebot } from 'models' import { Block, PublicTypebot, Typebot, Variable } from 'models'
import shortId from 'short-uuid' import shortId from 'short-uuid'
import { HStack, Text } from '@chakra-ui/react' import { HStack, Text } from '@chakra-ui/react'
import { CalendarIcon, CodeIcon } from 'assets/icons' import { CalendarIcon, CodeIcon } from 'assets/icons'
@@ -18,8 +18,8 @@ export const parseTypebotToPublicTypebot = (
theme: typebot.theme, theme: typebot.theme,
variables: typebot.variables, variables: typebot.variables,
customDomain: typebot.customDomain, customDomain: typebot.customDomain,
createdAt: new Date(), createdAt: new Date().toISOString(),
updatedAt: new Date(), updatedAt: new Date().toISOString(),
}) })
export const parsePublicTypebotToTypebot = ( export const parsePublicTypebotToTypebot = (
@@ -64,10 +64,11 @@ type HeaderCell = {
accessor: string accessor: string
} }
export const parseSubmissionsColumns = ( export const parseSubmissionsColumns = (blocksAndVariables: {
typebot: PublicTypebot blocks: Block[]
): HeaderCell[] => { variables: Variable[]
const parsedBlocks = parseBlocksHeaders(typebot) }): HeaderCell[] => {
const parsedBlocks = parseBlocksHeaders(blocksAndVariables)
return [ return [
{ {
Header: ( Header: (
@@ -79,13 +80,19 @@ export const parseSubmissionsColumns = (
accessor: 'Submitted at', accessor: 'Submitted at',
}, },
...parsedBlocks, ...parsedBlocks,
...parseVariablesHeaders(typebot, parsedBlocks), ...parseVariablesHeaders(blocksAndVariables.variables, parsedBlocks),
] ]
} }
const parseBlocksHeaders = (typebot: PublicTypebot) => const parseBlocksHeaders = ({
typebot.blocks blocks,
.filter((block) => typebot && block.steps.some((step) => isInputStep(step))) variables,
}: {
blocks: Block[]
variables: Variable[]
}) =>
blocks
.filter((block) => block.steps.some((step) => isInputStep(step)))
.reduce<HeaderCell[]>((headers, block) => { .reduce<HeaderCell[]>((headers, block) => {
const inputStep = block.steps.find((step) => isInputStep(step)) const inputStep = block.steps.find((step) => isInputStep(step))
if ( if (
@@ -94,13 +101,13 @@ const parseBlocksHeaders = (typebot: PublicTypebot) =>
headers.find( headers.find(
(h) => (h) =>
h.accessor === h.accessor ===
typebot.variables.find(byId(inputStep.options.variableId))?.name variables.find(byId(inputStep.options.variableId))?.name
) )
) )
return headers return headers
const matchedVariableName = const matchedVariableName =
inputStep.options.variableId && inputStep.options.variableId &&
typebot.variables.find(byId(inputStep.options.variableId))?.name variables.find(byId(inputStep.options.variableId))?.name
return [ return [
...headers, ...headers,
{ {
@@ -123,13 +130,13 @@ const parseBlocksHeaders = (typebot: PublicTypebot) =>
}, []) }, [])
const parseVariablesHeaders = ( const parseVariablesHeaders = (
typebot: PublicTypebot, variables: Variable[],
parsedBlocks: { parsedBlocks: {
Header: JSX.Element Header: JSX.Element
accessor: string accessor: string
}[] }[]
) => ) =>
typebot.variables.reduce<HeaderCell[]>((headers, v) => { variables.reduce<HeaderCell[]>((headers, v) => {
if (parsedBlocks.find((b) => b.accessor === v.name)) return headers if (parsedBlocks.find((b) => b.accessor === v.name)) return headers
return [ return [
...headers, ...headers,

View File

@@ -1,4 +1,4 @@
import { PublicTypebot, ResultWithAnswers, VariableWithValue } from 'models' import { Block, ResultWithAnswers, Variable, VariableWithValue } from 'models'
import useSWRInfinite from 'swr/infinite' import useSWRInfinite from 'swr/infinite'
import { stringify } from 'qs' import { stringify } from 'qs'
import { Answer } from 'db' import { Answer } from 'db'
@@ -96,7 +96,7 @@ export const parseDateToReadable = (dateStr: string): string => {
} }
export const convertResultsToTableData = export const convertResultsToTableData =
({ variables, blocks }: PublicTypebot) => ({ variables, blocks }: { variables: Variable[]; blocks: Block[] }) =>
(results: ResultWithAnswers[] | undefined) => (results: ResultWithAnswers[] | undefined) =>
(results ?? []).map((result) => ({ (results ?? []).map((result) => ({
'Submitted at': parseDateToReadable(result.createdAt), 'Submitted at': parseDateToReadable(result.createdAt),

View File

@@ -56,12 +56,14 @@ export type TypebotInDashboard = Pick<
> >
export const useTypebots = ({ export const useTypebots = ({
folderId, folderId,
allFolders,
onError, onError,
}: { }: {
folderId?: string folderId?: string
allFolders?: boolean
onError: (error: Error) => void onError: (error: Error) => void
}) => { }) => {
const params = stringify({ folderId }) const params = stringify({ folderId, allFolders })
const { data, error, mutate } = useSWR< const { data, error, mutate } = useSWR<
{ typebots: TypebotInDashboard[] }, { typebots: TypebotInDashboard[] },
Error Error
@@ -229,6 +231,8 @@ const parseDefaultStepOptions = (type: StepWithOptionsType): StepOptions => {
return defaultRedirectOptions return defaultRedirectOptions
case LogicStepType.CODE: case LogicStepType.CODE:
return defaultCodeOptions return defaultCodeOptions
case LogicStepType.TYPEBOT_LINK:
return {}
case IntegrationStepType.GOOGLE_SHEETS: case IntegrationStepType.GOOGLE_SHEETS:
return defaultGoogleSheetsOptions return defaultGoogleSheetsOptions
case IntegrationStepType.GOOGLE_ANALYTICS: case IntegrationStepType.GOOGLE_ANALYTICS:

View File

@@ -8,12 +8,14 @@ enum ActionType {
Undo = 'UNDO', Undo = 'UNDO',
Redo = 'REDO', Redo = 'REDO',
Set = 'SET', Set = 'SET',
Flush = 'FLUSH',
} }
export interface Actions<T> { export interface Actions<T> {
set: (newPresent: T) => void set: (newPresent: T | ((current: T) => T)) => void
undo: () => void undo: () => void
redo: () => void redo: () => void
flush: () => void
canUndo: boolean canUndo: boolean
canRedo: boolean canRedo: boolean
presentRef: React.MutableRefObject<T> presentRef: React.MutableRefObject<T>
@@ -85,7 +87,7 @@ const reducer = <T>(state: State<T>, action: Action<T>) => {
// console.log( // console.log(
// diff( // diff(
// JSON.parse(JSON.stringify(newPresent)), // JSON.parse(JSON.stringify(newPresent)),
// JSON.parse(JSON.stringify(present)) // present ? JSON.parse(JSON.stringify(present)) : {}
// ) // )
// ) // )
return { return {
@@ -94,6 +96,9 @@ const reducer = <T>(state: State<T>, action: Action<T>) => {
future: [], future: [],
} }
} }
case ActionType.Flush:
return { ...initialState, present }
} }
} }
@@ -106,22 +111,33 @@ const useUndo = <T>(initialPresent: T): [State<T>, Actions<T>] => {
const canUndo = state.past.length !== 0 const canUndo = state.past.length !== 0
const canRedo = state.future.length !== 0 const canRedo = state.future.length !== 0
const undo = useCallback(() => { const undo = useCallback(() => {
if (canUndo) { if (canUndo) {
dispatch({ type: ActionType.Undo }) dispatch({ type: ActionType.Undo })
} }
}, [canUndo]) }, [canUndo])
const redo = useCallback(() => { const redo = useCallback(() => {
if (canRedo) { if (canRedo) {
dispatch({ type: ActionType.Redo }) dispatch({ type: ActionType.Redo })
} }
}, [canRedo]) }, [canRedo])
const set = useCallback((newPresent: T) => {
presentRef.current = newPresent const set = useCallback((newPresent: T | ((current: T) => T)) => {
dispatch({ type: ActionType.Set, newPresent }) const updatedTypebot =
'id' in newPresent
? newPresent
: (newPresent as (current: T) => T)(presentRef.current)
presentRef.current = updatedTypebot
dispatch({ type: ActionType.Set, newPresent: updatedTypebot })
}, []) }, [])
return [state, { set, undo, redo, canUndo, canRedo, presentRef }] const flush = useCallback(() => {
dispatch({ type: ActionType.Flush })
}, [])
return [state, { set, undo, redo, flush, canUndo, canRedo, presentRef }]
} }
export default useUndo export default useUndo

View File

@@ -44,7 +44,7 @@ const getTypebotFromPublicId = async (publicId?: string) => {
where: { publicId }, where: { publicId },
}) })
if (isNotDefined(typebot)) return null if (isNotDefined(typebot)) return null
return omit(typebot as PublicTypebot, 'createdAt', 'updatedAt') return omit(typebot as unknown as PublicTypebot, 'createdAt', 'updatedAt')
} }
const getTypebotFromCustomDomain = async (customDomain: string) => { const getTypebotFromCustomDomain = async (customDomain: string) => {
@@ -52,7 +52,7 @@ const getTypebotFromCustomDomain = async (customDomain: string) => {
where: { customDomain }, where: { customDomain },
}) })
if (isNotDefined(typebot)) return null if (isNotDefined(typebot)) return null
return omit(typebot as PublicTypebot, 'createdAt', 'updatedAt') return omit(typebot as unknown as PublicTypebot, 'createdAt', 'updatedAt')
} }
const App = ({ typebot, ...props }: TypebotPageProps) => const App = ({ typebot, ...props }: TypebotPageProps) =>

View File

@@ -0,0 +1,18 @@
import { withSentry } from '@sentry/nextjs'
import prisma from 'libs/prisma'
import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed, notFound } from 'utils'
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === 'GET') {
const typebotId = req.query.typebotId as string
const typebot = await prisma.publicTypebot.findUnique({
where: { typebotId },
})
if (!typebot) return notFound(res)
return res.send({ typebot })
}
methodNotAllowed(res)
}
export default withSentry(handler)

View File

@@ -18,6 +18,7 @@ const config: PlaywrightTestConfig = {
use: { use: {
actionTimeout: 0, actionTimeout: 0,
baseURL: process.env.NEXT_PUBLIC_VIEWER_HOST, baseURL: process.env.NEXT_PUBLIC_VIEWER_HOST,
storageState: path.join(__dirname, 'playwright/proUser.json'),
trace: 'on-first-retry', trace: 'on-first-retry',
video: 'retain-on-failure', video: 'retain-on-failure',
locale: 'en-US', locale: 'en-US',

View File

@@ -0,0 +1,74 @@
{
"id": "cl0ibhi7s0018n21aarlmg0cm",
"createdAt": "2022-03-08T15:58:49.720Z",
"updatedAt": "2022-03-08T16:07:18.899Z",
"name": "My typebot",
"ownerId": "cl0cfi60r0000381a2bft9yis",
"publishedTypebotId": null,
"folderId": null,
"blocks": [
{
"id": "1qQrnsLzRim1LqCrhbj1MW",
"steps": [
{
"id": "8srsGhdBJK8v88Xo1RRS4C",
"type": "start",
"label": "Start",
"blockId": "1qQrnsLzRim1LqCrhbj1MW",
"outgoingEdgeId": "ovUHhwr6THMhqtn8QbkjtA"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "wSR4VCcDNDTTsD9Szi2xH8",
"steps": [
{
"id": "sw6nHJfkMsM4pxZxMBB6QqW",
"type": "Typebot link",
"blockId": "wSR4VCcDNDTTsD9Szi2xH8",
"options": { "typebotId": "cl0ibhv8d0130n21aw8doxhj5" }
}
],
"title": "Block #1",
"graphCoordinates": { "x": 363, "y": 199 }
}
],
"variables": [],
"edges": [
{
"id": "ovUHhwr6THMhqtn8QbkjtA",
"to": { "blockId": "wSR4VCcDNDTTsD9Szi2xH8" },
"from": {
"stepId": "8srsGhdBJK8v88Xo1RRS4C",
"blockId": "1qQrnsLzRim1LqCrhbj1MW"
}
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
}

View File

@@ -0,0 +1,77 @@
{
"id": "cl0ibhv8d0130n21aw8doxhj5",
"createdAt": "2022-03-08T15:59:06.589Z",
"updatedAt": "2022-03-08T15:59:10.498Z",
"name": "Another typebot",
"ownerId": "cl0cfi60r0000381a2bft9yis",
"publishedTypebotId": null,
"folderId": null,
"blocks": [
{
"id": "p4ByLVoKiDRyRoPHKmcTfw",
"steps": [
{
"id": "rw6smEWEJzHKbiVKLUKFvZ",
"type": "start",
"label": "Start",
"blockId": "p4ByLVoKiDRyRoPHKmcTfw",
"outgoingEdgeId": "1z3pfiatTUHbraD2uSoA3E"
}
],
"title": "Start",
"graphCoordinates": { "x": 0, "y": 0 }
},
{
"id": "bg4QEJseUsTP496H27j5k2",
"graphCoordinates": { "x": 366, "y": 191 },
"title": "Block #1",
"steps": [
{
"id": "s8ZeBL9p5za77eBmdKECLYq",
"blockId": "bg4QEJseUsTP496H27j5k2",
"type": "text input",
"options": {
"isLong": false,
"labels": { "button": "Send", "placeholder": "Type your answer..." }
}
}
]
}
],
"variables": [],
"edges": [
{
"from": {
"blockId": "p4ByLVoKiDRyRoPHKmcTfw",
"stepId": "rw6smEWEJzHKbiVKLUKFvZ"
},
"to": { "blockId": "bg4QEJseUsTP496H27j5k2" },
"id": "1z3pfiatTUHbraD2uSoA3E"
}
],
"theme": {
"chat": {
"inputs": {
"color": "#303235",
"backgroundColor": "#FFFFFF",
"placeholderColor": "#9095A0"
},
"buttons": { "color": "#FFFFFF", "backgroundColor": "#0042DA" },
"hostBubbles": { "color": "#303235", "backgroundColor": "#F7F8FF" },
"guestBubbles": { "color": "#FFFFFF", "backgroundColor": "#FF8E21" }
},
"general": { "font": "Open Sans", "background": { "type": "None" } }
},
"settings": {
"general": {
"isBrandingEnabled": true,
"isNewResultOnRefreshEnabled": false
},
"metadata": {
"description": "Build beautiful conversational forms and embed them directly in your applications without a line of code. Triple your response rate and collect answers that has more value compared to a traditional form."
},
"typingEmulation": { "speed": 300, "enabled": true, "maxDelay": 1.5 }
},
"publicId": null,
"customDomain": null
}

View File

@@ -0,0 +1,18 @@
{
"cookies": [],
"origins": [
{
"origin": "http://localhost:3000",
"localStorage": [
{
"name": "authenticatedUser",
"value": "{\"id\":\"proUser\",\"name\":\"Pro user\",\"email\":\"pro-user@email.com\",\"emailVerified\":null,\"image\":\"https://avatars.githubusercontent.com/u/16015833?v=4\",\"plan\":\"PRO\",\"stripeId\":null}"
},
{
"name": "typebot-20-modal",
"value": "hide"
}
]
}
]
}

View File

@@ -13,9 +13,11 @@ const prisma = new PrismaClient()
export const teardownDatabase = async () => { export const teardownDatabase = async () => {
try { try {
await prisma.user.delete({ await prisma.user.delete({
where: { id: 'user' }, where: { id: 'proUser' },
}) })
} catch {} } catch (err) {
console.error(err)
}
return return
} }
@@ -24,7 +26,7 @@ export const setupDatabase = () => createUser()
export const createUser = () => export const createUser = () =>
prisma.user.create({ prisma.user.create({
data: { data: {
id: 'user', id: 'proUser',
email: 'user@email.com', email: 'user@email.com',
name: 'User', name: 'User',
apiToken: 'userToken', apiToken: 'userToken',
@@ -73,13 +75,13 @@ const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
id: partialTypebot.id ?? 'typebot', id: partialTypebot.id ?? 'typebot',
folderId: null, folderId: null,
name: 'My typebot', name: 'My typebot',
ownerId: 'user', ownerId: 'proUser',
theme: defaultTheme, theme: defaultTheme,
settings: defaultSettings, settings: defaultSettings,
publicId: partialTypebot.id + '-public', publicId: partialTypebot.id + '-public',
publishedTypebotId: null, publishedTypebotId: null,
updatedAt: new Date(), updatedAt: new Date().toISOString(),
createdAt: new Date(), createdAt: new Date().toISOString(),
customDomain: null, customDomain: null,
variables: [{ id: 'var1', name: 'var1' }], variables: [{ id: 'var1', name: 'var1' }],
...partialTypebot, ...partialTypebot,
@@ -135,7 +137,7 @@ export const importTypebotInDatabase = async (
const typebot: any = { const typebot: any = {
...JSON.parse(readFileSync(path).toString()), ...JSON.parse(readFileSync(path).toString()),
...updates, ...updates,
ownerId: 'user', ownerId: 'proUser',
} }
await prisma.typebot.create({ await prisma.typebot.create({
data: typebot, data: typebot,

View File

@@ -0,0 +1,29 @@
import test, { expect } from '@playwright/test'
import path from 'path'
import { importTypebotInDatabase } from '../services/database'
import { typebotViewer } from '../services/selectorUtils'
test('should work as expected', async ({ page }) => {
const typebotId = 'cl0ibhi7s0018n21aarlmg0cm'
const linkedTypebotId = 'cl0ibhv8d0130n21aw8doxhj5'
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/linkTypebots/1.json'),
{ id: typebotId, publicId: `${typebotId}-public` }
)
await importTypebotInDatabase(
path.join(__dirname, '../fixtures/typebots/linkTypebots/2.json'),
{ id: linkedTypebotId, publicId: `${linkedTypebotId}-public` }
)
await page.goto(`/${typebotId}-public`)
await typebotViewer(page).locator('input').fill('Hello there!')
await typebotViewer(page).locator('input').press('Enter')
await page.waitForResponse(
(resp) =>
resp.request().url().includes(`/api/typebots/t/results`) &&
resp.status() === 200 &&
resp.request().method() === 'PUT'
)
await page.goto(`http://localhost:3000/typebots/${typebotId}/results`)
await expect(page.locator('text=Hello there!')).toBeVisible()
})

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { TransitionGroup, CSSTransition } from 'react-transition-group' import { TransitionGroup, CSSTransition } from 'react-transition-group'
import { AvatarSideContainer } from './AvatarSideContainer' import { AvatarSideContainer } from './AvatarSideContainer'
import { useTypebot } from '../../contexts/TypebotContext' import { LinkedTypebot, useTypebot } from '../../contexts/TypebotContext'
import { import {
isBubbleStep, isBubbleStep,
isBubbleStepType, isBubbleStepType,
@@ -14,18 +14,21 @@ import {
import { executeLogic } from 'services/logic' import { executeLogic } from 'services/logic'
import { executeIntegration } from 'services/integration' import { executeIntegration } from 'services/integration'
import { parseRetryStep, stepCanBeRetried } from 'services/inputs' import { parseRetryStep, stepCanBeRetried } from 'services/inputs'
import { parseVariables } from 'index' import { parseVariables } from '../../services/variable'
import { useAnswers } from 'contexts/AnswersContext' import { useAnswers } from 'contexts/AnswersContext'
import { BubbleStep, InputStep, Step } from 'models' import { BubbleStep, InputStep, PublicTypebot, Step } from 'models'
import { HostBubble } from './ChatStep/bubbles/HostBubble' import { HostBubble } from './ChatStep/bubbles/HostBubble'
import { InputChatStep } from './ChatStep/InputChatStep' import { InputChatStep } from './ChatStep/InputChatStep'
import { getLastChatStepType } from 'services/chat' import { getLastChatStepType } from '../../services/chat'
type ChatBlockProps = { type ChatBlockProps = {
steps: Step[] steps: Step[]
startStepIndex: number startStepIndex: number
onScroll: () => void onScroll: () => void
onBlockEnd: (edgeId?: string) => void onBlockEnd: (
edgeId?: string,
updatedTypebot?: PublicTypebot | LinkedTypebot
) => void
} }
type ChatDisplayChunk = { bubbles: BubbleStep[]; input?: InputStep } type ChatDisplayChunk = { bubbles: BubbleStep[]; input?: InputStep }
@@ -43,6 +46,8 @@ export const ChatBlock = ({
apiHost, apiHost,
isPreview, isPreview,
onNewLog, onNewLog,
injectLinkedTypebot,
linkedTypebots,
} = useTypebot() } = useTypebot()
const { resultValues } = useAnswers() const { resultValues } = useAnswers()
const [processedSteps, setProcessedSteps] = useState<Step[]>([]) const [processedSteps, setProcessedSteps] = useState<Step[]>([])
@@ -93,12 +98,17 @@ export const ChatBlock = ({
const currentStep = [...processedSteps].pop() const currentStep = [...processedSteps].pop()
if (!currentStep) return if (!currentStep) return
if (isLogicStep(currentStep)) { if (isLogicStep(currentStep)) {
const nextEdgeId = executeLogic( const { nextEdgeId, linkedTypebot } = await executeLogic(currentStep, {
currentStep, isPreview,
typebot.variables, apiHost,
updateVariableValue typebot,
) linkedTypebots,
nextEdgeId ? onBlockEnd(nextEdgeId) : displayNextStep() updateVariableValue,
injectLinkedTypebot,
onNewLog,
createEdge,
})
nextEdgeId ? onBlockEnd(nextEdgeId, linkedTypebot) : displayNextStep()
} }
if (isIntegrationStep(currentStep)) { if (isIntegrationStep(currentStep)) {
const nextEdgeId = await executeIntegration({ const nextEdgeId = await executeIntegration({
@@ -118,6 +128,7 @@ export const ChatBlock = ({
}) })
nextEdgeId ? onBlockEnd(nextEdgeId) : displayNextStep() nextEdgeId ? onBlockEnd(nextEdgeId) : displayNextStep()
} }
if (currentStep.type === 'start') onBlockEnd(currentStep.outgoingEdgeId)
} }
const displayNextStep = (answerContent?: string, isRetry?: boolean) => { const displayNextStep = (answerContent?: string, isRetry?: boolean) => {

View File

@@ -7,7 +7,7 @@ import { byId } from 'utils'
import { DateForm } from './inputs/DateForm' import { DateForm } from './inputs/DateForm'
import { ChoiceForm } from './inputs/ChoiceForm' import { ChoiceForm } from './inputs/ChoiceForm'
import { useTypebot } from 'contexts/TypebotContext' import { useTypebot } from 'contexts/TypebotContext'
import { parseVariables } from 'index' import { parseVariables } from '../../../services/variable'
import { isInputValid } from 'services/inputs' import { isInputValid } from 'services/inputs'
export const InputChatStep = ({ export const InputChatStep = ({

View File

@@ -4,10 +4,10 @@ import { ChatBlock } from './ChatBlock/ChatBlock'
import { useFrame } from 'react-frame-component' import { useFrame } from 'react-frame-component'
import { setCssVariablesValue } from '../services/theme' import { setCssVariablesValue } from '../services/theme'
import { useAnswers } from '../contexts/AnswersContext' import { useAnswers } from '../contexts/AnswersContext'
import { Block, Edge, Theme, VariableWithValue } from 'models' import { Block, Edge, PublicTypebot, Theme, VariableWithValue } from 'models'
import { byId, isNotDefined } from 'utils' import { byId, isNotDefined } from 'utils'
import { animateScroll as scroll } from 'react-scroll' import { animateScroll as scroll } from 'react-scroll'
import { useTypebot } from 'contexts/TypebotContext' import { LinkedTypebot, useTypebot } from 'contexts/TypebotContext'
type Props = { type Props = {
theme: Theme theme: Theme
@@ -30,10 +30,14 @@ export const ConversationContainer = ({
const bottomAnchor = useRef<HTMLDivElement | null>(null) const bottomAnchor = useRef<HTMLDivElement | null>(null)
const scrollableContainer = useRef<HTMLDivElement | null>(null) const scrollableContainer = useRef<HTMLDivElement | null>(null)
const displayNextBlock = (edgeId?: string) => { const displayNextBlock = (
const nextEdge = typebot.edges.find(byId(edgeId)) edgeId?: string,
updatedTypebot?: PublicTypebot | LinkedTypebot
) => {
const currentTypebot = updatedTypebot ?? typebot
const nextEdge = currentTypebot.edges.find(byId(edgeId))
if (!nextEdge) return onCompleted() if (!nextEdge) return onCompleted()
const nextBlock = typebot.blocks.find(byId(nextEdge.to.blockId)) const nextBlock = currentTypebot.blocks.find(byId(nextEdge.to.blockId))
if (!nextBlock) return onCompleted() if (!nextBlock) return onCompleted()
const startStepIndex = nextEdge.to.stepId const startStepIndex = nextEdge.to.stepId
? nextBlock.steps.findIndex(byId(nextEdge.to.stepId)) ? nextBlock.steps.findIndex(byId(nextEdge.to.stepId))

View File

@@ -1,5 +1,5 @@
import { Log } from 'db' import { Log } from 'db'
import { Edge, PublicTypebot } from 'models' import { Edge, PublicTypebot, Typebot } from 'models'
import React, { import React, {
createContext, createContext,
ReactNode, ReactNode,
@@ -8,12 +8,18 @@ import React, {
useState, useState,
} from 'react' } from 'react'
export type LinkedTypebot = Pick<
PublicTypebot | Typebot,
'id' | 'blocks' | 'variables' | 'edges'
>
const typebotContext = createContext<{ const typebotContext = createContext<{
typebot: PublicTypebot typebot: PublicTypebot
linkedTypebots: LinkedTypebot[]
apiHost: string apiHost: string
isPreview: boolean isPreview: boolean
updateVariableValue: (variableId: string, value: string) => void updateVariableValue: (variableId: string, value: string) => void
createEdge: (edge: Edge) => void createEdge: (edge: Edge) => void
injectLinkedTypebot: (typebot: Typebot | PublicTypebot) => LinkedTypebot
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore //@ts-ignore
@@ -33,6 +39,7 @@ export const TypebotContext = ({
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
}) => { }) => {
const [localTypebot, setLocalTypebot] = useState<PublicTypebot>(typebot) const [localTypebot, setLocalTypebot] = useState<PublicTypebot>(typebot)
const [linkedTypebots, setLinkedTypebots] = useState<LinkedTypebot[]>([])
useEffect(() => { useEffect(() => {
setLocalTypebot((localTypebot) => ({ setLocalTypebot((localTypebot) => ({
@@ -59,14 +66,34 @@ export const TypebotContext = ({
})) }))
} }
const injectLinkedTypebot = (typebot: Typebot | PublicTypebot) => {
const typebotToInject = {
id: typebot.id,
blocks: typebot.blocks,
edges: typebot.edges,
variables: typebot.variables,
}
setLinkedTypebots((typebots) => [...typebots, typebotToInject])
const updatedTypebot = {
...localTypebot,
blocks: [...localTypebot.blocks, ...typebotToInject.blocks],
variables: [...localTypebot.variables, ...typebotToInject.variables],
edges: [...localTypebot.edges, ...typebotToInject.edges],
}
setLocalTypebot(updatedTypebot)
return typebotToInject
}
return ( return (
<typebotContext.Provider <typebotContext.Provider
value={{ value={{
typebot: localTypebot, typebot: localTypebot,
linkedTypebots,
apiHost, apiHost,
isPreview, isPreview,
updateVariableValue, updateVariableValue,
createEdge, createEdge,
injectLinkedTypebot,
onNewLog, onNewLog,
}} }}
> >

View File

@@ -1,3 +1,5 @@
import { LinkedTypebot } from 'contexts/TypebotContext'
import { Log } from 'db'
import { import {
LogicStep, LogicStep,
LogicStepType, LogicStepType,
@@ -9,34 +11,52 @@ import {
RedirectStep, RedirectStep,
Comparison, Comparison,
CodeStep, CodeStep,
TypebotLinkStep,
PublicTypebot,
Typebot,
Edge,
} from 'models' } from 'models'
import { isDefined, isNotDefined } from 'utils' import { byId, isDefined, isNotDefined, sendRequest } from 'utils'
import { sanitizeUrl } from './utils' import { sanitizeUrl } from './utils'
import { evaluateExpression, parseVariables } from './variable' import { evaluateExpression, parseVariables } from './variable'
type EdgeId = string type EdgeId = string
export const executeLogic = ( type LogicContext = {
isPreview: boolean
apiHost: string
typebot: PublicTypebot
linkedTypebots: LinkedTypebot[]
updateVariableValue: (variableId: string, value: string) => void
injectLinkedTypebot: (typebot: Typebot | PublicTypebot) => LinkedTypebot
onNewLog: (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) => void
createEdge: (edge: Edge) => void
}
export const executeLogic = async (
step: LogicStep, step: LogicStep,
variables: Variable[], context: LogicContext
updateVariableValue: (variableId: string, expression: string) => void ): Promise<{
): EdgeId | undefined => { nextEdgeId?: EdgeId
linkedTypebot?: PublicTypebot | LinkedTypebot
}> => {
switch (step.type) { switch (step.type) {
case LogicStepType.SET_VARIABLE: case LogicStepType.SET_VARIABLE:
return executeSetVariable(step, variables, updateVariableValue) return { nextEdgeId: executeSetVariable(step, context) }
case LogicStepType.CONDITION: case LogicStepType.CONDITION:
return executeCondition(step, variables) return { nextEdgeId: executeCondition(step, context) }
case LogicStepType.REDIRECT: case LogicStepType.REDIRECT:
return executeRedirect(step, variables) return { nextEdgeId: executeRedirect(step, context) }
case LogicStepType.CODE: case LogicStepType.CODE:
return executeCode(step) return { nextEdgeId: executeCode(step) }
case LogicStepType.TYPEBOT_LINK:
return await executeTypebotLink(step, context)
} }
} }
const executeSetVariable = ( const executeSetVariable = (
step: SetVariableStep, step: SetVariableStep,
variables: Variable[], { typebot: { variables }, updateVariableValue }: LogicContext
updateVariableValue: (variableId: string, expression: string) => void
): EdgeId | undefined => { ): EdgeId | undefined => {
if (!step.options?.variableId || !step.options.expressionToEvaluate) if (!step.options?.variableId || !step.options.expressionToEvaluate)
return step.outgoingEdgeId return step.outgoingEdgeId
@@ -50,7 +70,7 @@ const executeSetVariable = (
const executeCondition = ( const executeCondition = (
step: ConditionStep, step: ConditionStep,
variables: Variable[] { typebot: { variables } }: LogicContext
): EdgeId | undefined => { ): EdgeId | undefined => {
const { content } = step.items[0] const { content } = step.items[0]
const isConditionPassed = const isConditionPassed =
@@ -91,7 +111,7 @@ const executeComparison =
const executeRedirect = ( const executeRedirect = (
step: RedirectStep, step: RedirectStep,
variables: Variable[] { typebot: { variables } }: LogicContext
): EdgeId | undefined => { ): EdgeId | undefined => {
if (!step.options?.url) return step.outgoingEdgeId if (!step.options?.url) return step.outgoingEdgeId
window.open( window.open(
@@ -106,3 +126,59 @@ const executeCode = (step: CodeStep) => {
Function(step.options.content)() Function(step.options.content)()
return step.outgoingEdgeId return step.outgoingEdgeId
} }
const executeTypebotLink = async (
step: TypebotLinkStep,
context: LogicContext
): Promise<{
nextEdgeId?: EdgeId
linkedTypebot?: PublicTypebot | LinkedTypebot
}> => {
const { typebot, linkedTypebots, onNewLog, createEdge } = context
const linkedTypebot =
[typebot, ...linkedTypebots].find(byId(step.options.typebotId)) ??
(await fetchAndInjectTypebot(step, context))
if (!linkedTypebot) {
onNewLog({
status: 'error',
description: 'Failed to link typebot',
details: '',
})
return { nextEdgeId: step.outgoingEdgeId }
}
const nextBlockId =
step.options.blockId ??
linkedTypebot.blocks.find((b) => b.steps.some((s) => s.type === 'start'))
?.id
if (!nextBlockId) return { nextEdgeId: step.outgoingEdgeId }
const newEdge: Edge = {
id: (Math.random() * 1000).toString(),
from: { stepId: '', blockId: '' },
to: {
blockId: nextBlockId,
},
}
createEdge(newEdge)
return {
nextEdgeId: newEdge.id,
linkedTypebot: {
...linkedTypebot,
edges: [...linkedTypebot.edges, newEdge],
},
}
}
const fetchAndInjectTypebot = async (
step: TypebotLinkStep,
{ apiHost, injectLinkedTypebot, isPreview }: LogicContext
): Promise<LinkedTypebot | undefined> => {
const { data, error } = isPreview
? await sendRequest<{ typebot: Typebot }>(
`/api/typebots/${step.options.typebotId}`
)
: await sendRequest<{ typebot: PublicTypebot }>(
`${apiHost}/api/publicTypebots/${step.options.typebotId}`
)
if (!data || error) return
return injectLinkedTypebot(data.typebot)
}

View File

@@ -20,7 +20,7 @@ export const parseVariables =
export const evaluateExpression = (str: string) => { export const evaluateExpression = (str: string) => {
try { try {
const evaluatedResult = Function('return' + str)() const evaluatedResult = Function('return ' + str)()
return isNotDefined(evaluatedResult) ? '' : evaluatedResult.toString() return isNotDefined(evaluatedResult) ? '' : evaluatedResult.toString()
} catch (err) { } catch (err) {
console.log(err) console.log(err)

View File

@@ -3,11 +3,19 @@ import { PublicTypebot as PublicTypebotFromPrisma } from 'db'
export type PublicTypebot = Omit< export type PublicTypebot = Omit<
PublicTypebotFromPrisma, PublicTypebotFromPrisma,
'blocks' | 'theme' | 'settings' | 'variables' | 'edges' | 'blocks'
| 'theme'
| 'settings'
| 'variables'
| 'edges'
| 'createdAt'
| 'updatedAt'
> & { > & {
blocks: Block[] blocks: Block[]
variables: Variable[] variables: Variable[]
edges: Edge[] edges: Edge[]
theme: Theme theme: Theme
settings: Settings settings: Settings
createdAt: string
updatedAt: string
} }

View File

@@ -6,17 +6,20 @@ export type LogicStep =
| ConditionStep | ConditionStep
| RedirectStep | RedirectStep
| CodeStep | CodeStep
| TypebotLinkStep
export type LogicStepOptions = export type LogicStepOptions =
| SetVariableOptions | SetVariableOptions
| RedirectOptions | RedirectOptions
| CodeOptions | CodeOptions
| TypebotLinkOptions
export enum LogicStepType { export enum LogicStepType {
SET_VARIABLE = 'Set variable', SET_VARIABLE = 'Set variable',
CONDITION = 'Condition', CONDITION = 'Condition',
REDIRECT = 'Redirect', REDIRECT = 'Redirect',
CODE = 'Code', CODE = 'Code',
TYPEBOT_LINK = 'Typebot link',
} }
export type SetVariableStep = StepBase & { export type SetVariableStep = StepBase & {
@@ -44,6 +47,11 @@ export type CodeStep = StepBase & {
options: CodeOptions options: CodeOptions
} }
export type TypebotLinkStep = StepBase & {
type: LogicStepType.TYPEBOT_LINK
options: TypebotLinkOptions
}
export enum LogicalOperator { export enum LogicalOperator {
OR = 'OR', OR = 'OR',
AND = 'AND', AND = 'AND',
@@ -85,6 +93,11 @@ export type CodeOptions = {
content?: string content?: string
} }
export type TypebotLinkOptions = {
typebotId?: string
blockId?: string
}
export const defaultSetVariablesOptions: SetVariableOptions = {} export const defaultSetVariablesOptions: SetVariableOptions = {}
export const defaultConditionContent: ConditionContent = { export const defaultConditionContent: ConditionContent = {
@@ -95,3 +108,5 @@ export const defaultConditionContent: ConditionContent = {
export const defaultRedirectOptions: RedirectOptions = { isNewTab: false } export const defaultRedirectOptions: RedirectOptions = { isNewTab: false }
export const defaultCodeOptions: CodeOptions = { name: 'Code snippet' } export const defaultCodeOptions: CodeOptions = { name: 'Code snippet' }
export const defaultTypebotLinkOptions: TypebotLinkOptions = {}

View File

@@ -6,13 +6,21 @@ import { Variable } from './variable'
export type Typebot = Omit< export type Typebot = Omit<
TypebotFromPrisma, TypebotFromPrisma,
'blocks' | 'theme' | 'settings' | 'variables' | 'edges' | 'blocks'
| 'theme'
| 'settings'
| 'variables'
| 'edges'
| 'createdAt'
| 'updatedAt'
> & { > & {
blocks: Block[] blocks: Block[]
variables: Variable[] variables: Variable[]
edges: Edge[] edges: Edge[]
theme: Theme theme: Theme
settings: Settings settings: Settings
createdAt: string
updatedAt: string
} }
export type Block = { export type Block = {