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>
</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 {
BoxIcon,
CalendarIcon,
ChatIcon,
CheckSquareIcon,
@ -60,6 +61,8 @@ export const StepIcon = ({ type, ...props }: StepIconProps) => {
return <ExternalLinkIcon color="purple.500" {...props} />
case LogicStepType.CODE:
return <CodeIcon color="purple.500" {...props} />
case LogicStepType.TYPEBOT_LINK:
return <BoxIcon color="purple.500" {...props} />
case IntegrationStepType.GOOGLE_SHEETS:
return <GoogleSheetsLogo {...props} />
case IntegrationStepType.GOOGLE_ANALYTICS:

View File

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

View File

@ -2,13 +2,14 @@
/* eslint-disable react/jsx-key */
import { Button, chakra, Checkbox, Flex, HStack, Text } from '@chakra-ui/react'
import { AlignLeftTextIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { PublicTypebot } from 'models'
import React, { useEffect, useMemo, useRef } from 'react'
import { Hooks, useRowSelect, useTable } from 'react-table'
import { parseSubmissionsColumns } from 'services/publicTypebot'
import { LoadingRows } from './LoadingRows'
type SubmissionsTableProps = {
blocksAndVariables: Pick<PublicTypebot, 'blocks' | 'variables'>
data?: any
hasMore?: boolean
onNewSelection: (indices: number[]) => void
@ -17,16 +18,16 @@ type SubmissionsTableProps = {
}
export const SubmissionsTable = ({
blocksAndVariables,
data,
hasMore,
onNewSelection,
onScrollToBottom,
onLogOpenIndex,
}: SubmissionsTableProps) => {
const { publishedTypebot } = useTypebot()
const columns: any = useMemo(
() => (publishedTypebot ? parseSubmissionsColumns(publishedTypebot) : []),
[publishedTypebot]
() => parseSubmissionsColumns(blocksAndVariables),
[blocksAndVariables]
)
const bottomElement = 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 { html } from '@codemirror/lang-html'
import { useEffect, useRef, useState } from 'react'
import { useDebounce } from 'use-debounce'
type Props = {
value: string
@ -22,6 +23,10 @@ export const CodeEditor = ({
const editorContainer = useRef<HTMLDivElement | null>(null)
const editorView = useRef<EditorView | null>(null)
const [plainTextValue, setPlainTextValue] = useState(value)
const [debouncedValue] = useDebounce(
plainTextValue,
process.env.NEXT_PUBLIC_E2E_TEST ? 0 : 1000
)
useEffect(() => {
if (!editorView.current || !isReadOnly) return
@ -36,10 +41,10 @@ export const CodeEditor = ({
}, [value])
useEffect(() => {
if (!onChange || plainTextValue === value) return
onChange(plainTextValue)
if (!onChange || debouncedValue === value) return
onChange(debouncedValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [plainTextValue])
}, [debouncedValue])
useEffect(() => {
const editor = initEditor(value)

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { Coordinates, useGraph } from 'contexts/GraphContext'
import React, { useLayoutEffect, useMemo, useState } from 'react'
import React, { useEffect, useLayoutEffect, useMemo, useState } from 'react'
import {
getAnchorsPosition,
computeEdgePath,
@ -31,6 +31,7 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
const [isMouseOver, setIsMouseOver] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
const [edgeMenuPosition, setEdgeMenuPosition] = useState({ x: 0, y: 0 })
const [refreshEdge, setRefreshEdge] = useState(false)
const isPreviewing = isMouseOver || previewingEdge?.id === edge.id
@ -47,9 +48,13 @@ export const Edge = ({ edge }: { edge: EdgeProps }) => {
getSourceEndpointId(edge)
),
// 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(
getEndpointTopOffset(targetEndpoints, graphPosition.y, edge?.to.stepId)
)

View File

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

View File

@ -25,8 +25,9 @@ export const NumberInputSettingsBody = ({
onOptionsChange(removeUndefinedFields({ ...options, max }))
const handleStepChange = (step?: number) =>
onOptionsChange(removeUndefinedFields({ ...options, step }))
const handleVariableChange = (variable?: Variable) =>
const handleVariableChange = (variable?: Variable) => {
onOptionsChange({ ...options, variableId: variable?.id })
}
return (
<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 = {
step: WebhookStep
onOptionsChange: (options: WebhookOptions) => void
onTestRequestClick: () => void
}
export const WebhookSettings = ({
step: { options, blockId, id: stepId, webhookId },
onOptionsChange,
onTestRequestClick,
}: Props) => {
const { typebot, save, webhooks, updateWebhook } = useTypebot()
const [isTestResponseLoading, setIsTestResponseLoading] = useState(false)
@ -122,7 +120,6 @@ export const WebhookSettings = ({
const handleTestRequestClick = async () => {
if (!typebot || !localWebhook) return
setIsTestResponseLoading(true)
onTestRequestClick()
await Promise.all([updateWebhook(localWebhook.id, localWebhook), save()])
const { data, error } = await executeWebhook(
typebot.id,

View File

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

View File

@ -21,6 +21,7 @@ import { ConfigureContent } from './contents/ConfigureContent'
import { ImageBubbleContent } from './contents/ImageBubbleContent'
import { PlaceholderContent } from './contents/PlaceholderContent'
import { SendEmailContent } from './contents/SendEmailContent'
import { TypebotLinkContent } from './contents/TypebotLinkContent'
import { ZapierContent } from './contents/ZapierContent'
type Props = {
@ -87,6 +88,8 @@ export const StepNodeContent = ({ step, indices }: Props) => {
/>
)
}
case LogicStepType.TYPEBOT_LINK:
return <TypebotLinkContent step={step} />
case IntegrationStepType.GOOGLE_SHEETS: {
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) => {
const { onOpen, onClose, isOpen } = useDisclosure()
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([
...items
.filter((item) =>

View File

@ -6,7 +6,8 @@ import {
NumberIncrementStepper,
NumberDecrementStepper,
} from '@chakra-ui/react'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useDebounce } from 'use-debounce'
export const SmartNumberInput = ({
value,
@ -17,14 +18,26 @@ export const SmartNumberInput = ({
onValueChange: (value?: number) => void
} & NumberInputProps) => {
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) => {
setCurrentValue(value)
if (value.endsWith('.') || value.endsWith(',')) return
if (value === '') return onValueChange(undefined)
if (value === '') return setValueToReturn(undefined)
const newValue = parseFloat(value)
if (isNaN(newValue)) return
onValueChange(newValue)
setValueToReturn(newValue)
}
return (

View File

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

View File

@ -1,15 +1,22 @@
import { Editable, EditablePreview, EditableInput } from '@chakra-ui/editable'
import { Tooltip } from '@chakra-ui/tooltip'
import React from 'react'
import React, { useEffect, useState } from 'react'
type EditableProps = {
name: string
onNewName: (newName: string) => void
}
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 (
<Tooltip label="Rename">
<Editable defaultValue={name} onSubmit={onNewName}>
<Editable value={localName} onChange={setLocalName} onSubmit={onNewName}>
<EditablePreview
isTruncated
cursor="pointer"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ import { NextApiRequest, NextApiResponse } from 'next'
import { methodNotAllowed } from 'utils'
const handler = (req: NextApiRequest, res: NextApiResponse) => {
console.log(req.method)
if (req.method === 'POST') {
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)
try {
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({
where: {
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,
settings: defaultSettings,
publicId: null,
updatedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
publishedTypebotId: null,
customDomain: null,
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=Giphy')
await page.click('img >> nth=3')
await page.click('img >> nth=3', { force: true })
await expect(page.locator('img[alt="Step image"]')).toHaveAttribute(
'src',
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.fill('[data-testid="speed"] input', '350')
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,
})
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 { HStack, Text } from '@chakra-ui/react'
import { CalendarIcon, CodeIcon } from 'assets/icons'
@ -18,8 +18,8 @@ export const parseTypebotToPublicTypebot = (
theme: typebot.theme,
variables: typebot.variables,
customDomain: typebot.customDomain,
createdAt: new Date(),
updatedAt: new Date(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
export const parsePublicTypebotToTypebot = (
@ -64,10 +64,11 @@ type HeaderCell = {
accessor: string
}
export const parseSubmissionsColumns = (
typebot: PublicTypebot
): HeaderCell[] => {
const parsedBlocks = parseBlocksHeaders(typebot)
export const parseSubmissionsColumns = (blocksAndVariables: {
blocks: Block[]
variables: Variable[]
}): HeaderCell[] => {
const parsedBlocks = parseBlocksHeaders(blocksAndVariables)
return [
{
Header: (
@ -79,13 +80,19 @@ export const parseSubmissionsColumns = (
accessor: 'Submitted at',
},
...parsedBlocks,
...parseVariablesHeaders(typebot, parsedBlocks),
...parseVariablesHeaders(blocksAndVariables.variables, parsedBlocks),
]
}
const parseBlocksHeaders = (typebot: PublicTypebot) =>
typebot.blocks
.filter((block) => typebot && block.steps.some((step) => isInputStep(step)))
const parseBlocksHeaders = ({
blocks,
variables,
}: {
blocks: Block[]
variables: Variable[]
}) =>
blocks
.filter((block) => block.steps.some((step) => isInputStep(step)))
.reduce<HeaderCell[]>((headers, block) => {
const inputStep = block.steps.find((step) => isInputStep(step))
if (
@ -94,13 +101,13 @@ const parseBlocksHeaders = (typebot: PublicTypebot) =>
headers.find(
(h) =>
h.accessor ===
typebot.variables.find(byId(inputStep.options.variableId))?.name
variables.find(byId(inputStep.options.variableId))?.name
)
)
return headers
const matchedVariableName =
inputStep.options.variableId &&
typebot.variables.find(byId(inputStep.options.variableId))?.name
variables.find(byId(inputStep.options.variableId))?.name
return [
...headers,
{
@ -123,13 +130,13 @@ const parseBlocksHeaders = (typebot: PublicTypebot) =>
}, [])
const parseVariablesHeaders = (
typebot: PublicTypebot,
variables: Variable[],
parsedBlocks: {
Header: JSX.Element
accessor: string
}[]
) =>
typebot.variables.reduce<HeaderCell[]>((headers, v) => {
variables.reduce<HeaderCell[]>((headers, v) => {
if (parsedBlocks.find((b) => b.accessor === v.name)) return headers
return [
...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 { stringify } from 'qs'
import { Answer } from 'db'
@ -96,7 +96,7 @@ export const parseDateToReadable = (dateStr: string): string => {
}
export const convertResultsToTableData =
({ variables, blocks }: PublicTypebot) =>
({ variables, blocks }: { variables: Variable[]; blocks: Block[] }) =>
(results: ResultWithAnswers[] | undefined) =>
(results ?? []).map((result) => ({
'Submitted at': parseDateToReadable(result.createdAt),

View File

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

View File

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

View File

@ -44,7 +44,7 @@ const getTypebotFromPublicId = async (publicId?: string) => {
where: { publicId },
})
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) => {
@ -52,7 +52,7 @@ const getTypebotFromCustomDomain = async (customDomain: string) => {
where: { customDomain },
})
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) =>

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: {
actionTimeout: 0,
baseURL: process.env.NEXT_PUBLIC_VIEWER_HOST,
storageState: path.join(__dirname, 'playwright/proUser.json'),
trace: 'on-first-retry',
video: 'retain-on-failure',
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 () => {
try {
await prisma.user.delete({
where: { id: 'user' },
where: { id: 'proUser' },
})
} catch {}
} catch (err) {
console.error(err)
}
return
}
@ -24,7 +26,7 @@ export const setupDatabase = () => createUser()
export const createUser = () =>
prisma.user.create({
data: {
id: 'user',
id: 'proUser',
email: 'user@email.com',
name: 'User',
apiToken: 'userToken',
@ -73,13 +75,13 @@ const parseTestTypebot = (partialTypebot: Partial<Typebot>): Typebot => ({
id: partialTypebot.id ?? 'typebot',
folderId: null,
name: 'My typebot',
ownerId: 'user',
ownerId: 'proUser',
theme: defaultTheme,
settings: defaultSettings,
publicId: partialTypebot.id + '-public',
publishedTypebotId: null,
updatedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
customDomain: null,
variables: [{ id: 'var1', name: 'var1' }],
...partialTypebot,
@ -135,7 +137,7 @@ export const importTypebotInDatabase = async (
const typebot: any = {
...JSON.parse(readFileSync(path).toString()),
...updates,
ownerId: 'user',
ownerId: 'proUser',
}
await prisma.typebot.create({
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()
})