2
0

♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@ -0,0 +1,83 @@
import { Flex, HStack, StackProps, Text, Tooltip } from '@chakra-ui/react'
import { BlockType, DraggableBlockType } from 'models'
import { useBlockDnd } from '@/features/graph'
import React, { useEffect, useState } from 'react'
import { BlockIcon } from './BlockIcon'
import { BlockTypeLabel } from './BlockTypeLabel'
export const BlockCard = ({
type,
onMouseDown,
isDisabled = false,
}: {
type: DraggableBlockType
isDisabled?: boolean
onMouseDown: (e: React.MouseEvent, type: DraggableBlockType) => void
}) => {
const { draggedBlockType } = useBlockDnd()
const [isMouseDown, setIsMouseDown] = useState(false)
useEffect(() => {
setIsMouseDown(draggedBlockType === type)
}, [draggedBlockType, type])
const handleMouseDown = (e: React.MouseEvent) => onMouseDown(e, type)
return (
<Tooltip label="Coming soon!" isDisabled={!isDisabled}>
<Flex pos="relative">
<HStack
borderWidth="1px"
borderColor="gray.200"
rounded="lg"
flex="1"
cursor={'grab'}
opacity={isMouseDown || isDisabled ? '0.4' : '1'}
onMouseDown={handleMouseDown}
bgColor="gray.50"
px="4"
py="2"
_hover={{ shadow: 'md' }}
transition="box-shadow 200ms"
pointerEvents={isDisabled ? 'none' : 'auto'}
>
{!isMouseDown ? (
<>
<BlockIcon type={type} />
<BlockTypeLabel type={type} />
</>
) : (
<Text color="white" userSelect="none">
Placeholder
</Text>
)}
</HStack>
</Flex>
</Tooltip>
)
}
export const BlockCardOverlay = ({
type,
...props
}: StackProps & { type: BlockType }) => {
return (
<HStack
borderWidth="1px"
rounded="lg"
cursor={'grabbing'}
w="147px"
transition="none"
pointerEvents="none"
px="4"
py="2"
bgColor="white"
shadow="xl"
zIndex={2}
{...props}
>
<BlockIcon type={type} />
<BlockTypeLabel type={type} />
</HStack>
)
}

View File

@ -0,0 +1,100 @@
import { IconProps } from '@chakra-ui/react'
import {
BubbleBlockType,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
BlockType,
} from 'models'
import React from 'react'
import { TextBubbleIcon } from '@/features/blocks/bubbles/textBubble'
import { ImageBubbleIcon } from '@/features/blocks/bubbles/image'
import { VideoBubbleIcon } from '@/features/blocks/bubbles/video'
import { ChatwootLogo } from '@/features/blocks/integrations/chatwoot'
import { FlagIcon } from '@/components/icons'
import { SendEmailIcon } from '@/features/blocks/integrations/sendEmail'
import { PabblyConnectLogo } from '@/features/blocks/integrations/pabbly'
import { MakeComLogo } from '@/features/blocks/integrations/makeCom'
import { ZapierLogo } from '@/features/blocks/integrations/zapier'
import { WebhookIcon } from '@/features/blocks/integrations/webhook'
import { GoogleSheetsLogo } from '@/features/blocks/integrations/googleSheets'
import { TypebotLinkIcon } from '@/features/blocks/logic/typebotLink'
import { CodeIcon } from '@/features/blocks/logic/code'
import { RedirectIcon } from '@/features/blocks/logic/redirect'
import { ConditionIcon } from '@/features/blocks/logic/condition'
import { SetVariableIcon } from '@/features/blocks/logic/setVariable'
import { FileInputIcon } from '@/features/blocks/inputs/fileUpload'
import { RatingInputIcon } from '@/features/blocks/inputs/rating'
import { PaymentInputIcon } from '@/features/blocks/inputs/payment'
import { ButtonsInputIcon } from '@/features/blocks/inputs/buttons'
import { PhoneInputIcon } from '@/features/blocks/inputs/phone'
import { DateInputIcon } from '@/features/blocks/inputs/date'
import { UrlInputIcon } from '@/features/blocks/inputs/url'
import { EmailInputIcon } from '@/features/blocks/inputs/emailInput'
import { NumberInputIcon } from '@/features/blocks/inputs/number'
import { TextInputIcon } from '@/features/blocks/inputs/textInput'
import { EmbedBubbleIcon } from '@/features/blocks/bubbles/embed'
import { GoogleAnalyticsLogo } from '@/features/blocks/integrations/googleAnalytics'
type BlockIconProps = { type: BlockType } & IconProps
export const BlockIcon = ({ type, ...props }: BlockIconProps) => {
switch (type) {
case BubbleBlockType.TEXT:
return <TextBubbleIcon {...props} />
case BubbleBlockType.IMAGE:
return <ImageBubbleIcon {...props} />
case BubbleBlockType.VIDEO:
return <VideoBubbleIcon {...props} />
case BubbleBlockType.EMBED:
return <EmbedBubbleIcon color="blue.500" {...props} />
case InputBlockType.TEXT:
return <TextInputIcon color="orange.500" {...props} />
case InputBlockType.NUMBER:
return <NumberInputIcon color="orange.500" {...props} />
case InputBlockType.EMAIL:
return <EmailInputIcon color="orange.500" {...props} />
case InputBlockType.URL:
return <UrlInputIcon color="orange.500" {...props} />
case InputBlockType.DATE:
return <DateInputIcon color="orange.500" {...props} />
case InputBlockType.PHONE:
return <PhoneInputIcon color="orange.500" {...props} />
case InputBlockType.CHOICE:
return <ButtonsInputIcon color="orange.500" {...props} />
case InputBlockType.PAYMENT:
return <PaymentInputIcon color="orange.500" {...props} />
case InputBlockType.RATING:
return <RatingInputIcon color="orange.500" {...props} />
case InputBlockType.FILE:
return <FileInputIcon color="orange.500" {...props} />
case LogicBlockType.SET_VARIABLE:
return <SetVariableIcon color="purple.500" {...props} />
case LogicBlockType.CONDITION:
return <ConditionIcon color="purple.500" {...props} />
case LogicBlockType.REDIRECT:
return <RedirectIcon color="purple.500" {...props} />
case LogicBlockType.CODE:
return <CodeIcon color="purple.500" {...props} />
case LogicBlockType.TYPEBOT_LINK:
return <TypebotLinkIcon color="purple.500" {...props} />
case IntegrationBlockType.GOOGLE_SHEETS:
return <GoogleSheetsLogo {...props} />
case IntegrationBlockType.GOOGLE_ANALYTICS:
return <GoogleAnalyticsLogo {...props} />
case IntegrationBlockType.WEBHOOK:
return <WebhookIcon {...props} />
case IntegrationBlockType.ZAPIER:
return <ZapierLogo {...props} />
case IntegrationBlockType.MAKE_COM:
return <MakeComLogo {...props} />
case IntegrationBlockType.PABBLY_CONNECT:
return <PabblyConnectLogo {...props} />
case IntegrationBlockType.EMAIL:
return <SendEmailIcon {...props} />
case IntegrationBlockType.CHATWOOT:
return <ChatwootLogo {...props} />
case 'start':
return <FlagIcon {...props} />
}
}

View File

@ -0,0 +1,103 @@
import { HStack, Text, Tooltip } from '@chakra-ui/react'
import { useWorkspace } from '@/features/workspace'
import { Plan } from 'db'
import {
BubbleBlockType,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
BlockType,
} from 'models'
import React from 'react'
import { isFreePlan, LockTag } from '@/features/billing'
type Props = { type: BlockType }
export const BlockTypeLabel = ({ type }: Props): JSX.Element => {
const { workspace } = useWorkspace()
switch (type) {
case 'start':
return <Text>Start</Text>
case BubbleBlockType.TEXT:
case InputBlockType.TEXT:
return <Text>Text</Text>
case BubbleBlockType.IMAGE:
return <Text>Image</Text>
case BubbleBlockType.VIDEO:
return <Text>Video</Text>
case BubbleBlockType.EMBED:
return (
<Tooltip label="Embed a pdf, an iframe, a website...">
<Text>Embed</Text>
</Tooltip>
)
case InputBlockType.NUMBER:
return <Text>Number</Text>
case InputBlockType.EMAIL:
return <Text>Email</Text>
case InputBlockType.URL:
return <Text>Website</Text>
case InputBlockType.DATE:
return <Text>Date</Text>
case InputBlockType.PHONE:
return <Text>Phone</Text>
case InputBlockType.CHOICE:
return <Text>Button</Text>
case InputBlockType.PAYMENT:
return <Text>Payment</Text>
case InputBlockType.RATING:
return <Text>Rating</Text>
case InputBlockType.FILE:
return (
<Tooltip label="Upload Files">
<HStack>
<Text>File</Text>
{isFreePlan(workspace) && <LockTag plan={Plan.STARTER} />}
</HStack>
</Tooltip>
)
case LogicBlockType.SET_VARIABLE:
return <Text>Set variable</Text>
case LogicBlockType.CONDITION:
return <Text>Condition</Text>
case LogicBlockType.REDIRECT:
return <Text>Redirect</Text>
case LogicBlockType.CODE:
return (
<Tooltip label="Run Javascript code">
<Text>Code</Text>
</Tooltip>
)
case LogicBlockType.TYPEBOT_LINK:
return (
<Tooltip label="Link to another of your typebots">
<Text>Typebot</Text>
</Tooltip>
)
case IntegrationBlockType.GOOGLE_SHEETS:
return (
<Tooltip label="Google Sheets">
<Text>Sheets</Text>
</Tooltip>
)
case IntegrationBlockType.GOOGLE_ANALYTICS:
return (
<Tooltip label="Google Analytics">
<Text>Analytics</Text>
</Tooltip>
)
case IntegrationBlockType.WEBHOOK:
return <Text>Webhook</Text>
case IntegrationBlockType.ZAPIER:
return <Text>Zapier</Text>
case IntegrationBlockType.MAKE_COM:
return <Text>Make.com</Text>
case IntegrationBlockType.PABBLY_CONNECT:
return <Text>Pabbly</Text>
case IntegrationBlockType.EMAIL:
return <Text>Email</Text>
case IntegrationBlockType.CHATWOOT:
return <Text>Chatwoot</Text>
}
}

View File

@ -0,0 +1,194 @@
import {
Stack,
Text,
SimpleGrid,
useEventListener,
Portal,
Flex,
IconButton,
Tooltip,
Fade,
} from '@chakra-ui/react'
import {
BubbleBlockType,
DraggableBlockType,
InputBlockType,
IntegrationBlockType,
LogicBlockType,
} from 'models'
import { useBlockDnd } from '@/features/graph'
import React, { useState } from 'react'
import { BlockCard, BlockCardOverlay } from './BlockCard'
import { LockedIcon, UnlockedIcon } from '@/components/icons'
import { headerHeight } from '../../constants'
export const BlocksSideBar = () => {
const { setDraggedBlockType, draggedBlockType } = useBlockDnd()
const [position, setPosition] = useState({
x: 0,
y: 0,
})
const [relativeCoordinates, setRelativeCoordinates] = useState({ x: 0, y: 0 })
const [isLocked, setIsLocked] = useState(true)
const [isExtended, setIsExtended] = useState(true)
const handleMouseMove = (event: MouseEvent) => {
if (!draggedBlockType) return
const { clientX, clientY } = event
setPosition({
...position,
x: clientX - relativeCoordinates.x,
y: clientY - relativeCoordinates.y,
})
}
useEventListener('mousemove', handleMouseMove)
const handleMouseDown = (e: React.MouseEvent, type: DraggableBlockType) => {
const element = e.currentTarget as HTMLDivElement
const rect = element.getBoundingClientRect()
setPosition({ x: rect.left, y: rect.top })
const x = e.clientX - rect.left
const y = e.clientY - rect.top
setRelativeCoordinates({ x, y })
setDraggedBlockType(type)
}
const handleMouseUp = () => {
if (!draggedBlockType) return
setDraggedBlockType(undefined)
setPosition({
x: 0,
y: 0,
})
}
useEventListener('mouseup', handleMouseUp)
const handleLockClick = () => setIsLocked(!isLocked)
const handleDockBarEnter = () => setIsExtended(true)
const handleMouseLeave = () => {
if (isLocked) return
setIsExtended(false)
}
return (
<Flex
w="360px"
pos="absolute"
left="0"
h={`calc(100vh - ${headerHeight}px)`}
zIndex="2"
pl="4"
py="4"
onMouseLeave={handleMouseLeave}
transform={isExtended ? 'translateX(0)' : 'translateX(-350px)'}
transition="transform 350ms cubic-bezier(0.075, 0.82, 0.165, 1) 0s"
>
<Stack
w="full"
rounded="lg"
shadow="xl"
borderWidth="1px"
pt="2"
pb="10"
px="4"
bgColor="white"
spacing={6}
userSelect="none"
overflowY="scroll"
className="hide-scrollbar"
>
<Flex justifyContent="flex-end">
<Tooltip label={isLocked ? 'Unlock sidebar' : 'Lock sidebar'}>
<IconButton
icon={isLocked ? <LockedIcon /> : <UnlockedIcon />}
aria-label={isLocked ? 'Unlock' : 'Lock'}
size="sm"
variant="outline"
onClick={handleLockClick}
/>
</Tooltip>
</Flex>
<Stack>
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
Bubbles
</Text>
<SimpleGrid columns={2} spacing="3">
{Object.values(BubbleBlockType).map((type) => (
<BlockCard key={type} type={type} onMouseDown={handleMouseDown} />
))}
</SimpleGrid>
</Stack>
<Stack>
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
Inputs
</Text>
<SimpleGrid columns={2} spacing="3">
{Object.values(InputBlockType).map((type) => (
<BlockCard key={type} type={type} onMouseDown={handleMouseDown} />
))}
</SimpleGrid>
</Stack>
<Stack>
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
Logic
</Text>
<SimpleGrid columns={2} spacing="3">
{Object.values(LogicBlockType).map((type) => (
<BlockCard key={type} type={type} onMouseDown={handleMouseDown} />
))}
</SimpleGrid>
</Stack>
<Stack>
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
Integrations
</Text>
<SimpleGrid columns={2} spacing="3">
{Object.values(IntegrationBlockType).map((type) => (
<BlockCard
key={type}
type={type}
onMouseDown={handleMouseDown}
isDisabled={type === IntegrationBlockType.MAKE_COM}
/>
))}
</SimpleGrid>
</Stack>
{draggedBlockType && (
<Portal>
<BlockCardOverlay
type={draggedBlockType}
onMouseUp={handleMouseUp}
pos="fixed"
top="0"
left="0"
style={{
transform: `translate(${position.x}px, ${position.y}px) rotate(-2deg)`,
}}
/>
</Portal>
)}
</Stack>
<Fade in={!isLocked} unmountOnExit>
<Flex
pos="absolute"
h="100%"
right="-50px"
w="50px"
top="0"
justify="center"
align="center"
onMouseEnter={handleDockBarEnter}
>
<Flex w="5px" h="20px" bgColor="gray.400" rounded="md" />
</Flex>
</Fade>
</Flex>
)
}

View File

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

View File

@ -0,0 +1,79 @@
import {
IconButton,
Menu,
MenuButton,
MenuButtonProps,
MenuItem,
MenuList,
useDisclosure,
} from '@chakra-ui/react'
import assert from 'assert'
import {
DownloadIcon,
MoreVerticalIcon,
SettingsIcon,
} from '@/components/icons'
import { useTypebot } from '../providers/TypebotProvider'
import { useUser } from '@/features/account'
import { useRouter } from 'next/router'
import React, { useEffect, useState } from 'react'
import { isNotDefined } from 'utils'
import { EditorSettingsModal } from './EditorSettingsModal'
import { parseDefaultPublicId } from '@/features/publish'
export const BoardMenuButton = (props: MenuButtonProps) => {
const { query } = useRouter()
const { typebot } = useTypebot()
const { user } = useUser()
const [isDownloading, setIsDownloading] = useState(false)
const { isOpen, onOpen, onClose } = useDisclosure()
useEffect(() => {
if (
user &&
isNotDefined(user.graphNavigation) &&
isNotDefined(query.isFirstBot)
)
onOpen()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const downloadFlow = () => {
assert(typebot)
setIsDownloading(true)
const data =
'data:application/json;charset=utf-8,' +
encodeURIComponent(JSON.stringify(typebot))
const fileName = `typebot-export-${parseDefaultPublicId(
typebot.name,
typebot.id
)}.json`
const linkElement = document.createElement('a')
linkElement.setAttribute('href', data)
linkElement.setAttribute('download', fileName)
linkElement.click()
setIsDownloading(false)
}
return (
<Menu>
<MenuButton
as={IconButton}
bgColor="white"
icon={<MoreVerticalIcon transform={'rotate(90deg)'} />}
isLoading={isDownloading}
size="sm"
shadow="lg"
{...props}
/>
<MenuList>
<MenuItem icon={<SettingsIcon />} onClick={onOpen}>
Editor settings
</MenuItem>
<MenuItem icon={<DownloadIcon />} onClick={downloadFlow}>
Export flow
</MenuItem>
</MenuList>
<EditorSettingsModal isOpen={isOpen} onClose={onClose} />
</Menu>
)
}

View File

@ -0,0 +1,69 @@
import { Seo } from '@/components/Seo'
import {
Graph,
GraphDndProvider,
GraphProvider,
GroupsCoordinatesProvider,
} from '@/features/graph'
import { Flex, Spinner } from '@chakra-ui/react'
import {
EditorProvider,
useEditor,
RightPanel as RightPanelEnum,
} from '../providers/EditorProvider'
import { useTypebot } from '../providers/TypebotProvider'
import { BlocksSideBar } from './BlocksSideBar'
import { BoardMenuButton } from './BoardMenuButton'
import { GettingStartedModal } from './GettingStartedModal'
import { PreviewDrawer } from './PreviewDrawer'
import { TypebotHeader } from './TypebotHeader'
export const EditTypebotPage = () => {
const { typebot, isReadOnly } = useTypebot()
return (
<EditorProvider>
<Seo title="Editor" />
<Flex overflow="clip" h="100vh" flexDir="column" id="editor-container">
<GettingStartedModal />
<TypebotHeader />
<Flex
flex="1"
pos="relative"
h="full"
background="#f4f5f8"
backgroundImage="radial-gradient(#c6d0e1 1px, transparent 0)"
backgroundSize="40px 40px"
backgroundPosition="-19px -19px"
>
{typebot ? (
<GraphDndProvider>
<BlocksSideBar />
<GraphProvider isReadOnly={isReadOnly}>
<GroupsCoordinatesProvider groups={typebot.groups}>
<Graph flex="1" typebot={typebot} />
<BoardMenuButton pos="absolute" right="40px" top="20px" />
<RightPanel />
</GroupsCoordinatesProvider>
</GraphProvider>
</GraphDndProvider>
) : (
<Flex
justify="center"
align="center"
boxSize="full"
bgColor="rgba(255, 255, 255, 0.5)"
>
<Spinner color="gray" />
</Flex>
)}
</Flex>
</Flex>
</EditorProvider>
)
}
const RightPanel = () => {
const { rightPanel } = useEditor()
return rightPanel === RightPanelEnum.PREVIEW ? <PreviewDrawer /> : <></>
}

View File

@ -0,0 +1,76 @@
import {
Stack,
Heading,
HStack,
Text,
Radio,
RadioGroup,
VStack,
} from '@chakra-ui/react'
import { MouseIcon, LaptopIcon } from '@/components/icons'
import { useUser } from '@/features/account'
import { GraphNavigation } from 'db'
import React, { useEffect, useState } from 'react'
export const EditorSettingsForm = () => {
const { user, saveUser } = useUser()
const [value, setValue] = useState<string>(
user?.graphNavigation ?? GraphNavigation.TRACKPAD
)
useEffect(() => {
if (user?.graphNavigation === value) return
saveUser({ graphNavigation: value as GraphNavigation }).then()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
const options = [
{
value: GraphNavigation.MOUSE,
label: 'Mouse',
description:
'Move by dragging the board and zoom in/out using the scroll wheel',
icon: <MouseIcon boxSize="35px" />,
},
{
value: GraphNavigation.TRACKPAD,
label: 'Trackpad',
description: 'Move the board using 2 fingers and zoom in/out by pinching',
icon: <LaptopIcon boxSize="35px" />,
},
]
return (
<Stack spacing={6}>
<Heading size="md">Editor Navigation</Heading>
<RadioGroup onChange={setValue} value={value}>
<HStack spacing={4} w="full" align="stretch">
{options.map((option) => (
<VStack
key={option.value}
as="label"
htmlFor={option.label}
cursor="pointer"
borderWidth="1px"
borderRadius="md"
w="full"
p="6"
spacing={6}
justifyContent="space-between"
>
<VStack spacing={6}>
{option.icon}
<Stack>
<Text fontWeight="bold">{option.label}</Text>
<Text>{option.description}</Text>
</Stack>
</VStack>
<Radio value={option.value} id={option.label} />
</VStack>
))}
</HStack>
</RadioGroup>
</Stack>
)
}

View File

@ -0,0 +1,28 @@
import {
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalOverlay,
} from '@chakra-ui/react'
import React from 'react'
import { EditorSettingsForm } from './EditorSettingsForm'
type Props = {
isOpen: boolean
onClose: () => void
}
export const EditorSettingsModal = ({ isOpen, onClose }: Props) => {
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody pt="12" pb="8" px="8">
<EditorSettingsForm />
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,172 @@
import {
useDisclosure,
Modal,
ModalOverlay,
ModalContent,
ModalCloseButton,
ModalBody,
Stack,
Heading,
List,
ListItem,
Text,
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
HStack,
Flex,
} from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
export const GettingStartedModal = () => {
const { query } = useRouter()
const { isOpen, onOpen, onClose } = useDisclosure()
useEffect(() => {
if (query.isFirstBot) onOpen()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalCloseButton />
<ModalBody as={Stack} spacing="8" py="10">
<Stack spacing={4}>
<Heading fontSize="xl">Editor basics</Heading>
<List spacing={4}>
<HStack as={ListItem}>
<Flex
bgColor="blue.500"
rounded="full"
boxSize="25px"
justify="center"
align="center"
color="white"
fontWeight="bold"
flexShrink={0}
fontSize="13px"
>
1
</Flex>
<Text>
The left side bar contains blocks that you can drag and drop
to the board.
</Text>
</HStack>
<HStack as={ListItem}>
<Flex
bgColor="blue.500"
rounded="full"
boxSize="25px"
fontSize="13px"
justify="center"
align="center"
color="white"
fontWeight="bold"
flexShrink={0}
>
2
</Flex>
<Text>
You can group blocks together by dropping them below or above
each other
</Text>
</HStack>
<HStack as={ListItem}>
<Flex
bgColor="blue.500"
rounded="full"
boxSize="25px"
justify="center"
align="center"
color="white"
fontWeight="bold"
flexShrink={0}
fontSize="13px"
>
3
</Flex>
<Text>Connect the groups together</Text>
</HStack>
<HStack as={ListItem}>
<Flex
bgColor="blue.500"
rounded="full"
boxSize="25px"
justify="center"
align="center"
color="white"
fontWeight="bold"
flexShrink={0}
fontSize="13px"
>
4
</Flex>
<Text>
Preview your bot by clicking the preview button on the top
right
</Text>
</HStack>
</List>
</Stack>
<Text>
Feel free to use the bottom-right bubble to reach out if you have
any question. I usually answer within the next 24 hours. 😃
</Text>
<Stack spacing={4}>
<Heading fontSize="xl">See it in action ({`<`} 5 minutes)</Heading>
<iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/jp3ggg_42-M"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: '0.5rem' }}
/>
<Accordion allowToggle>
<AccordionItem>
<AccordionButton>
<Box flex="1" textAlign="left">
Other videos
</Box>
<AccordionIcon />
</AccordionButton>
<AccordionPanel py={10} as={Stack} spacing="10">
<iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/6BudIC4GYNk"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: '0.5rem' }}
/>
<iframe
width="100%"
height="315"
src="https://www.youtube.com/embed/ZuyDwFLRbfQ"
title="YouTube video player"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{ borderRadius: '0.5rem' }}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</Stack>
</ModalBody>
</ModalContent>
</Modal>
)
}

View File

@ -0,0 +1,134 @@
import {
Box,
Button,
CloseButton,
Fade,
Flex,
FlexProps,
useEventListener,
UseToastOptions,
VStack,
} from '@chakra-ui/react'
import { TypebotViewer } from 'bot-engine'
import { useToast } from '@/hooks/useToast'
import { useEditor } from '../providers/EditorProvider'
import { useGraph } from '@/features/graph'
import { useTypebot } from '../providers/TypebotProvider'
import { Log } from 'db'
import React, { useMemo, useState } from 'react'
import { getViewerUrl } from 'utils'
import { headerHeight } from '../constants'
import { parseTypebotToPublicTypebot } from '@/features/publish'
export const PreviewDrawer = () => {
const { typebot } = useTypebot()
const { setRightPanel, startPreviewAtGroup } = useEditor()
const { setPreviewingEdge } = useGraph()
const [isResizing, setIsResizing] = useState(false)
const [width, setWidth] = useState(500)
const [isResizeHandleVisible, setIsResizeHandleVisible] = useState(false)
const [restartKey, setRestartKey] = useState(0)
const publicTypebot = useMemo(
() => (typebot ? { ...parseTypebotToPublicTypebot(typebot) } : undefined),
[typebot]
)
const { showToast } = useToast()
const handleMouseDown = () => {
setIsResizing(true)
}
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return
setWidth(width - e.movementX)
}
useEventListener('mousemove', handleMouseMove)
const handleMouseUp = () => {
setIsResizing(false)
}
useEventListener('mouseup', handleMouseUp)
const handleRestartClick = () => setRestartKey((key) => key + 1)
const handleCloseClick = () => {
setPreviewingEdge(undefined)
setRightPanel(undefined)
}
const handleNewLog = (log: Omit<Log, 'id' | 'createdAt' | 'resultId'>) =>
showToast(log as UseToastOptions)
return (
<Flex
pos="absolute"
right="0"
top={`0`}
h={`100%`}
w={`${width}px`}
bgColor="white"
shadow="lg"
borderLeftRadius={'lg'}
onMouseOver={() => setIsResizeHandleVisible(true)}
onMouseLeave={() => setIsResizeHandleVisible(false)}
p="6"
zIndex={10}
>
<Fade in={isResizeHandleVisible}>
<ResizeHandle
pos="absolute"
left="-7.5px"
top={`calc(50% - ${headerHeight}px)`}
onMouseDown={handleMouseDown}
/>
</Fade>
<VStack w="full" spacing={4}>
<Flex justifyContent={'space-between'} w="full">
<Button onClick={handleRestartClick}>Restart</Button>
<CloseButton onClick={handleCloseClick} />
</Flex>
{publicTypebot && (
<Flex
borderWidth={'1px'}
borderRadius={'lg'}
h="full"
w="full"
key={restartKey + (startPreviewAtGroup ?? '')}
pointerEvents={isResizing ? 'none' : 'auto'}
>
<TypebotViewer
apiHost={getViewerUrl({ isBuilder: true })}
typebot={publicTypebot}
onNewGroupVisible={setPreviewingEdge}
onNewLog={handleNewLog}
startGroupId={startPreviewAtGroup}
isPreview
/>
</Flex>
)}
</VStack>
</Flex>
)
}
const ResizeHandle = (props: FlexProps) => {
return (
<Flex
w="15px"
h="50px"
borderWidth={'1px'}
bgColor={'white'}
cursor={'col-resize'}
justifyContent={'center'}
align={'center'}
{...props}
>
<Box w="2px" bgColor={'gray.300'} h="70%" mr="0.5" />
<Box w="2px" bgColor={'gray.300'} h="70%" />
</Flex>
)
}

View File

@ -0,0 +1,37 @@
import {
Editable,
EditablePreview,
EditableInput,
Tooltip,
} from '@chakra-ui/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 value={localName} onChange={setLocalName} onSubmit={onNewName}>
<EditablePreview
noOfLines={2}
cursor="pointer"
maxW="150px"
overflow="hidden"
display="flex"
alignItems="center"
fontSize="14px"
/>
<EditableInput fontSize="14px" />
</Editable>
</Tooltip>
)
}

View File

@ -0,0 +1,214 @@
import {
Flex,
HStack,
Button,
IconButton,
Tooltip,
Spinner,
Text,
} from '@chakra-ui/react'
import {
BuoyIcon,
ChevronLeftIcon,
RedoIcon,
UndoIcon,
} from '@/components/icons'
import { RightPanel, useEditor } from '../../providers/EditorProvider'
import { useTypebot } from '../../providers/TypebotProvider'
import { useRouter } from 'next/router'
import React from 'react'
import { isNotDefined } from 'utils'
import { EditableTypebotName } from './EditableTypebotName'
import { getBubbleActions } from 'typebot-js'
import Link from 'next/link'
import { isCloudProdInstance } from '@/utils/helpers'
import { headerHeight } from '../../constants'
import { EditableEmojiOrImageIcon } from '@/components/EditableEmojiOrImageIcon'
import { PublishButton } from '@/features/publish'
import { CollaborationMenuButton } from '@/features/collaboration'
export const TypebotHeader = () => {
const router = useRouter()
const {
typebot,
updateTypebot,
save,
undo,
redo,
canUndo,
canRedo,
isSavingLoading,
} = useTypebot()
const { setRightPanel, rightPanel, setStartPreviewAtGroup } = useEditor()
const handleNameSubmit = (name: string) => updateTypebot({ name })
const handleChangeIcon = (icon: string) => updateTypebot({ icon })
const handlePreviewClick = async () => {
setStartPreviewAtGroup(undefined)
save().then()
setRightPanel(RightPanel.PREVIEW)
}
const handleHelpClick = () => {
isCloudProdInstance()
? getBubbleActions().open()
: window.open('https://docs.typebot.io', '_blank')
}
return (
<Flex
w="full"
borderBottomWidth="1px"
justify="center"
align="center"
h={`${headerHeight}px`}
zIndex={100}
pos="relative"
bgColor="white"
flexShrink={0}
>
<HStack
display={['none', 'flex']}
pos={{ base: 'absolute', xl: 'static' }}
right={{ base: 280, xl: 0 }}
>
<Button
as={Link}
href={`/typebots/${typebot?.id}/edit`}
colorScheme={router.pathname.includes('/edit') ? 'blue' : 'gray'}
variant={router.pathname.includes('/edit') ? 'outline' : 'ghost'}
size="sm"
>
Flow
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/theme`}
colorScheme={router.pathname.endsWith('theme') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('theme') ? 'outline' : 'ghost'}
size="sm"
>
Theme
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/settings`}
colorScheme={router.pathname.endsWith('settings') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('settings') ? 'outline' : 'ghost'}
size="sm"
>
Settings
</Button>
<Button
as={Link}
href={`/typebots/${typebot?.id}/share`}
colorScheme={router.pathname.endsWith('share') ? 'blue' : 'gray'}
variant={router.pathname.endsWith('share') ? 'outline' : 'ghost'}
size="sm"
>
Share
</Button>
{typebot?.publishedTypebotId && (
<Button
as={Link}
href={`/typebots/${typebot?.id}/results`}
colorScheme={router.pathname.includes('results') ? 'blue' : 'gray'}
variant={router.pathname.includes('results') ? 'outline' : 'ghost'}
size="sm"
>
Results
</Button>
)}
</HStack>
<HStack
pos="absolute"
left="1rem"
justify="center"
align="center"
spacing="6"
>
<HStack alignItems="center" spacing={3}>
<IconButton
as={Link}
aria-label="Navigate back"
icon={<ChevronLeftIcon fontSize={25} />}
href={
router.query.parentId
? `/typebots/${router.query.parentId}/edit`
: typebot?.folderId
? `/typebots/folders/${typebot.folderId}`
: '/typebots'
}
size="sm"
/>
<HStack spacing={1}>
{typebot && (
<EditableEmojiOrImageIcon
uploadFilePath={`typebots/${typebot.id}/icon`}
icon={typebot?.icon}
onChangeIcon={handleChangeIcon}
/>
)}
{typebot?.name && (
<EditableTypebotName
name={typebot?.name}
onNewName={handleNameSubmit}
/>
)}
</HStack>
<HStack>
<Tooltip label="Undo">
<IconButton
display={['none', 'flex']}
icon={<UndoIcon />}
size="sm"
aria-label="Undo"
onClick={undo}
isDisabled={!canUndo}
/>
</Tooltip>
<Tooltip label="Redo">
<IconButton
display={['none', 'flex']}
icon={<RedoIcon />}
size="sm"
aria-label="Redo"
onClick={redo}
isDisabled={!canRedo}
/>
</Tooltip>
</HStack>
<Button leftIcon={<BuoyIcon />} onClick={handleHelpClick} size="sm">
Help
</Button>
</HStack>
{isSavingLoading && (
<HStack>
<Spinner speed="0.7s" size="sm" color="gray.400" />
<Text fontSize="sm" color="gray.400">
Saving...
</Text>
</HStack>
)}
</HStack>
<HStack right="40px" pos="absolute" display={['none', 'flex']}>
<CollaborationMenuButton isLoading={isNotDefined(typebot)} />
{router.pathname.includes('/edit') && isNotDefined(rightPanel) && (
<Button
onClick={handlePreviewClick}
isLoading={isNotDefined(typebot)}
size="sm"
>
Preview
</Button>
)}
<PublishButton size="sm" />
</HStack>
</Flex>
)
}

View File

@ -0,0 +1 @@
export * from './TypebotHeader'

View File

@ -0,0 +1 @@
export const headerHeight = 56

View File

@ -0,0 +1,242 @@
import test, { expect } from '@playwright/test'
import { defaultTextInputOptions, InputBlockType } from 'models'
import cuid from 'cuid'
import {
createTypebots,
importTypebotInDatabase,
} from 'utils/playwright/databaseActions'
import {
typebotViewer,
waitForSuccessfulDeleteRequest,
waitForSuccessfulPostRequest,
waitForSuccessfulPutRequest,
} from 'utils/playwright/testHelpers'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { getTestAsset } from '@/test/utils/playwright'
test.describe.configure({ mode: 'parallel' })
test('Edges connection should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await expect(page.locator("text='Start'")).toBeVisible()
await page.dragAndDrop('text=Button', '#editor-container', {
targetPosition: { x: 1000, y: 400 },
})
await page.dragAndDrop(
'text=Text >> nth=0',
'[data-testid="group"] >> nth=1',
{
targetPosition: { x: 100, y: 50 },
}
)
await page.dragAndDrop(
'[data-testid="endpoint"]',
'[data-testid="group"] >> nth=1',
{ targetPosition: { x: 100, y: 10 } }
)
await expect(page.locator('[data-testid="edge"]')).toBeVisible()
await page.dragAndDrop(
'[data-testid="endpoint"]',
'[data-testid="group"] >> nth=1'
)
await expect(page.locator('[data-testid="edge"]')).toBeVisible()
await page.dragAndDrop('text=Date', '#editor-container', {
targetPosition: { x: 1000, y: 800 },
})
await page.dragAndDrop(
'[data-testid="endpoint"] >> nth=2',
'[data-testid="group"] >> nth=2',
{
targetPosition: { x: 100, y: 10 },
}
)
await expect(page.locator('[data-testid="edge"] >> nth=0')).toBeVisible()
await expect(page.locator('[data-testid="edge"] >> nth=1')).toBeVisible()
await page.click('[data-testid="clickable-edge"] >> nth=0', {
force: true,
button: 'right',
})
await page.click('text=Delete')
const total = await page.locator('[data-testid="edge"]').count()
expect(total).toBe(1)
})
test('Drag and drop blocks and items should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/editor/buttonsDnd.json'),
{
id: typebotId,
}
)
// Blocks dnd
await page.goto(`/typebots/${typebotId}/edit`)
await expect(page.locator('[data-testid="block"] >> nth=1')).toHaveText(
'Hello!'
)
await page.dragAndDrop('text=Hello', '[data-testid="block"] >> nth=3', {
targetPosition: { x: 100, y: 0 },
})
await expect(page.locator('[data-testid="block"] >> nth=2')).toHaveText(
'Hello!'
)
await page.dragAndDrop('text=Hello', 'text=Group #2')
await expect(page.locator('[data-testid="block"] >> nth=3')).toHaveText(
'Hello!'
)
// Items dnd
await expect(page.locator('[data-testid="item"] >> nth=0')).toHaveText(
'Item 1'
)
await page.dragAndDrop('text=Item 1', 'text=Item 3')
await expect(page.locator('[data-testid="item"] >> nth=2')).toHaveText(
'Item 1'
)
await expect(page.locator('[data-testid="item"] >> nth=1')).toHaveText(
'Item 3'
)
await page.dragAndDrop('text=Item 3', 'text=Item 2-3')
await expect(page.locator('[data-testid="item"] >> nth=6')).toHaveText(
'Item 3'
)
})
test('Undo / Redo and Zoom buttons should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('text=Group #1', { button: 'right' })
await page.click('text=Duplicate')
await expect(page.locator('text="Group #1"')).toBeVisible()
await expect(page.locator('text="Group #1 copy"')).toBeVisible()
await page.click('text="Group #1"', { button: 'right' })
await page.click('text=Delete')
await expect(page.locator('text="Group #1"')).toBeHidden()
await page.click('button[aria-label="Undo"]')
await expect(page.locator('text="Group #1"')).toBeVisible()
await page.click('button[aria-label="Redo"]')
await expect(page.locator('text="Group #1"')).toBeHidden()
await page.getByRole('button', { name: 'Zoom in' }).click()
await expect(page.getByTestId('graph')).toHaveAttribute(
'style',
/scale\(1\.2\);$/
)
await page.getByRole('button', { name: 'Zoom in' }).click()
await expect(page.getByTestId('graph')).toHaveAttribute(
'style',
/scale\(1\.4\);$/
)
await page.getByRole('button', { name: 'Zoom out' }).dblclick()
await page.getByRole('button', { name: 'Zoom out' }).dblclick()
await expect(page.getByTestId('graph')).toHaveAttribute(
'style',
/scale\(0\.6\);$/
)
})
test('Rename and icon change should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
name: 'My awesome typebot',
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('[data-testid="editable-icon"]')
await expect(page.locator('text="My awesome typebot"')).toBeVisible()
await page.fill('input[placeholder="Search..."]', 'love')
await page.click('text="😍"')
await page.click('text="My awesome typebot"')
await page.fill('input[value="My awesome typebot"]', 'My superb typebot')
await page.press('input[value="My superb typebot"]', 'Enter')
await page.click('[aria-label="Navigate back"]')
await expect(page.locator('text="😍"')).toBeVisible()
await expect(page.locator('text="My superb typebot"')).toBeVisible()
})
test('Preview from group should work', async ({ page }) => {
const typebotId = cuid()
await importTypebotInDatabase(
getTestAsset('typebots/editor/previewFromGroup.json'),
{
id: typebotId,
}
)
await page.goto(`/typebots/${typebotId}/edit`)
await page.click('[aria-label="Preview bot from this group"] >> nth=1')
await expect(
typebotViewer(page).locator('text="Hello this is group 1"')
).toBeVisible()
await page.click('[aria-label="Preview bot from this group"] >> nth=2')
await expect(
typebotViewer(page).locator('text="Hello this is group 2"')
).toBeVisible()
await page.click('[aria-label="Close"]')
await page.click('text="Preview"')
await expect(
typebotViewer(page).locator('text="Hello this is group 1"')
).toBeVisible()
})
test('Published typebot menu should work', async ({ page }) => {
const typebotId = cuid()
await createTypebots([
{
id: typebotId,
name: 'My awesome typebot',
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: defaultTextInputOptions,
}),
},
])
await page.goto(`/typebots/${typebotId}/edit`)
await expect(page.locator("text='Start'")).toBeVisible()
await expect(page.locator('button >> text="Published"')).toBeVisible()
await page.click('[aria-label="Show published typebot menu"]')
await Promise.all([
waitForSuccessfulPutRequest(page),
page.click('text="Close typebot to new responses"'),
])
await expect(page.locator('button >> text="Closed"')).toBeDisabled()
await page.click('[aria-label="Show published typebot menu"]')
await Promise.all([
waitForSuccessfulPutRequest(page),
page.click('text="Reopen typebot to new responses"'),
])
await expect(page.locator('button >> text="Published"')).toBeDisabled()
await page.click('[aria-label="Show published typebot menu"]')
await Promise.all([
waitForSuccessfulDeleteRequest(page),
page.click('button >> text="Unpublish typebot"'),
])
await Promise.all([
waitForSuccessfulPostRequest(page),
page.click('button >> text="Publish"'),
])
await expect(page.locator('button >> text="Published"')).toBeVisible()
})

View File

@ -0,0 +1,159 @@
import { isDefined } from '@udecode/plate-core'
import { dequal } from 'dequal'
// import { diff } from 'deep-object-diff'
import { useReducer, useCallback, useRef } from 'react'
import { isNotDefined } from 'utils'
enum ActionType {
Undo = 'UNDO',
Redo = 'REDO',
Set = 'SET',
Flush = 'FLUSH',
}
export interface Actions<T> {
set: (
newPresent: T | ((current: T) => T),
options?: { updateDate: boolean }
) => void
undo: () => void
redo: () => void
flush: () => void
canUndo: boolean
canRedo: boolean
presentRef: React.MutableRefObject<T>
}
interface Action<T> {
type: ActionType
newPresent?: T
updateDate?: boolean
}
export interface State<T> {
past: T[]
present: T
future: T[]
}
const initialState = {
past: [],
present: null,
future: [],
}
const reducer = <T>(state: State<T>, action: Action<T>) => {
const { past, present, future } = state
switch (action.type) {
case ActionType.Undo: {
if (past.length === 0) {
return state
}
const previous = past[past.length - 1]
const newPast = past.slice(0, past.length - 1)
return {
past: newPast,
present: previous,
future: [present, ...future],
}
}
case ActionType.Redo: {
if (future.length === 0) {
return state
}
const next = future[0]
const newFuture = future.slice(1)
return {
past: [...past, present],
present: next,
future: newFuture,
}
}
case ActionType.Set: {
const { newPresent, updateDate } = action
if (
isNotDefined(newPresent) ||
(present &&
dequal(
JSON.parse(JSON.stringify(newPresent)),
JSON.parse(JSON.stringify(present))
))
) {
return state
}
// Uncomment to debug history ⬇️
// console.log(
// diff(
// JSON.parse(JSON.stringify(newPresent)),
// present ? JSON.parse(JSON.stringify(present)) : {}
// )
// )
return {
past: [...past, present].filter(isDefined),
present: {
...newPresent,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
updatedAt: updateDate ? new Date() : newPresent.updatedAt,
},
future: [],
}
}
case ActionType.Flush:
return { ...initialState, present }
}
}
const useUndo = <T>(initialPresent: T): [State<T>, Actions<T>] => {
const [state, dispatch] = useReducer(reducer, {
...initialState,
present: initialPresent,
}) as [State<T>, React.Dispatch<Action<T>>]
const presentRef = useRef<T>(initialPresent)
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 | ((current: T) => T), options = { updateDate: true }) => {
const updatedTypebot =
'id' in newPresent
? newPresent
: (newPresent as (current: T) => T)(presentRef.current)
presentRef.current = updatedTypebot
dispatch({
type: ActionType.Set,
newPresent: updatedTypebot,
updateDate: options.updateDate,
})
},
[]
)
const flush = useCallback(() => {
dispatch({ type: ActionType.Flush })
}, [])
return [state, { set, undo, redo, flush, canUndo, canRedo, presentRef }]
}
export default useUndo

View File

@ -0,0 +1,7 @@
export { TypebotProvider, useTypebot } from './providers/TypebotProvider'
export { TypebotHeader } from './components/TypebotHeader'
export { EditTypebotPage } from './components/EditTypebotPage'
export { EditorSettingsForm } from './components/EditorSettingsForm'
export { headerHeight } from './constants'
export { BlockIcon } from './components/BlocksSideBar/BlockIcon'
export { RightPanel, useEditor } from './providers/EditorProvider'

View File

@ -0,0 +1,41 @@
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useContext,
useState,
} from 'react'
export enum RightPanel {
PREVIEW,
}
const editorContext = createContext<{
rightPanel?: RightPanel
setRightPanel: Dispatch<SetStateAction<RightPanel | undefined>>
startPreviewAtGroup: string | undefined
setStartPreviewAtGroup: Dispatch<SetStateAction<string | undefined>>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
export const EditorProvider = ({ children }: { children: ReactNode }) => {
const [rightPanel, setRightPanel] = useState<RightPanel>()
const [startPreviewAtGroup, setStartPreviewAtGroup] = useState<string>()
return (
<editorContext.Provider
value={{
rightPanel,
setRightPanel,
startPreviewAtGroup,
setStartPreviewAtGroup,
}}
>
{children}
</editorContext.Provider>
)
}
export const useEditor = () => useContext(editorContext)

View File

@ -0,0 +1,364 @@
import {
LogicBlockType,
PublicTypebot,
ResultsTablePreferences,
Settings,
Theme,
Typebot,
Webhook,
} from 'models'
import { Router, useRouter } from 'next/router'
import {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import { isDefined, isNotDefined, omit } from 'utils'
import { GroupsActions, groupsActions } from './actions/groups'
import { blocksAction, BlocksActions } from './actions/blocks'
import { variablesAction, VariablesActions } from './actions/variables'
import { edgesAction, EdgesActions } from './actions/edges'
import { itemsAction, ItemsActions } from './actions/items'
import { dequal } from 'dequal'
import cuid from 'cuid'
import { useToast } from '@/hooks/useToast'
import { useTypebotQuery } from '@/hooks/useTypebotQuery'
import useUndo from '../../hooks/useUndo'
import { useLinkedTypebots } from '@/hooks/useLinkedTypebots'
import { updateTypebotQuery } from '../../queries/updateTypebotQuery'
import { preventUserFromRefreshing } from '@/utils/helpers'
import { updatePublishedTypebotQuery } from '@/features/publish'
import { saveWebhookQuery } from '@/features/blocks/integrations/webhook/queries/saveWebhookQuery'
import {
createPublishedTypebotQuery,
deletePublishedTypebotQuery,
checkIfTypebotsAreEqual,
checkIfPublished,
parseTypebotToPublicTypebot,
parseDefaultPublicId,
parsePublicTypebotToTypebot,
} from '@/features/publish'
import { useAutoSave } from '@/hooks/useAutoSave'
const autoSaveTimeout = 10000
type UpdateTypebotPayload = Partial<{
theme: Theme
settings: Settings
publicId: string
name: string
publishedTypebotId: string
icon: string
customDomain: string
resultsTablePreferences: ResultsTablePreferences
isClosed: boolean
}>
export type SetTypebot = (
newPresent: Typebot | ((current: Typebot) => Typebot)
) => void
const typebotContext = createContext<
{
typebot?: Typebot
publishedTypebot?: PublicTypebot
linkedTypebots?: Typebot[]
webhooks: Webhook[]
isReadOnly?: boolean
isPublished: boolean
isPublishing: boolean
isSavingLoading: boolean
save: () => Promise<void>
undo: () => void
redo: () => void
canRedo: boolean
canUndo: boolean
updateWebhook: (
webhookId: string,
webhook: Partial<Webhook>
) => Promise<void>
updateTypebot: (updates: UpdateTypebotPayload) => void
publishTypebot: () => void
unpublishTypebot: () => void
restorePublishedTypebot: () => void
} & GroupsActions &
BlocksActions &
ItemsActions &
VariablesActions &
EdgesActions
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
>({})
export const TypebotProvider = ({
children,
typebotId,
}: {
children: ReactNode
typebotId: string
}) => {
const router = useRouter()
const { showToast } = useToast()
const { typebot, publishedTypebot, webhooks, isReadOnly, isLoading, mutate } =
useTypebotQuery({
typebotId,
})
const [
{ present: localTypebot },
{
redo,
undo,
flush,
canRedo,
canUndo,
set: setLocalTypebot,
presentRef: currentTypebotRef,
},
] = useUndo<Typebot | undefined>(undefined)
const linkedTypebotIds = localTypebot?.groups
.flatMap((b) => b.blocks)
.reduce<string[]>(
(typebotIds, block) =>
block.type === LogicBlockType.TYPEBOT_LINK &&
isDefined(block.options.typebotId)
? [...typebotIds, block.options.typebotId]
: typebotIds,
[]
)
const { typebots: linkedTypebots } = useLinkedTypebots({
workspaceId: localTypebot?.workspaceId ?? undefined,
typebotId,
typebotIds: linkedTypebotIds,
onError: (error) =>
showToast({
title: 'Error while fetching linkedTypebots',
description: error.message,
}),
})
useEffect(() => {
if (!typebot || !currentTypebotRef.current) return
if (typebotId !== currentTypebotRef.current.id) {
setLocalTypebot({ ...typebot }, { updateDate: false })
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 () => {
if (!currentTypebotRef.current || !typebot) return
const typebotToSave = { ...currentTypebotRef.current }
if (dequal(omit(typebot, 'updatedAt'), omit(typebotToSave, 'updatedAt')))
return
setIsSavingLoading(true)
const { error } = await updateTypebotQuery(typebotToSave.id, typebotToSave)
setIsSavingLoading(false)
if (error) {
showToast({ title: error.name, description: error.message })
return
}
mutate({
typebot: typebotToSave,
publishedTypebot,
webhooks: webhooks ?? [],
})
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
const savePublishedTypebot = async (newPublishedTypebot: PublicTypebot) => {
if (!localTypebot) return
setIsPublishing(true)
const { error } = await updatePublishedTypebotQuery(
newPublishedTypebot.id,
newPublishedTypebot,
localTypebot.workspaceId
)
setIsPublishing(false)
if (error)
return showToast({ title: error.name, description: error.message })
mutate({
typebot: currentTypebotRef.current as Typebot,
publishedTypebot: newPublishedTypebot,
webhooks: webhooks ?? [],
})
}
useAutoSave(
{
handler: saveTypebot,
item: localTypebot,
debounceTimeout: autoSaveTimeout,
},
[typebot, publishedTypebot, webhooks]
)
useEffect(() => {
Router.events.on('routeChangeStart', saveTypebot)
return () => {
Router.events.off('routeChangeStart', saveTypebot)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typebot, publishedTypebot, webhooks])
const [isSavingLoading, setIsSavingLoading] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
const isPublished = useMemo(
() =>
isDefined(localTypebot) &&
isDefined(publishedTypebot) &&
checkIfPublished(localTypebot, publishedTypebot),
[localTypebot, publishedTypebot]
)
useEffect(() => {
if (!localTypebot || !typebot) return
currentTypebotRef.current = localTypebot
if (!checkIfTypebotsAreEqual(localTypebot, typebot)) {
window.removeEventListener('beforeunload', preventUserFromRefreshing)
window.addEventListener('beforeunload', preventUserFromRefreshing)
} else {
window.removeEventListener('beforeunload', preventUserFromRefreshing)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localTypebot])
useEffect(() => {
if (isLoading) return
if (!typebot) {
showToast({ status: 'info', description: "Couldn't find typebot" })
router.replace('/typebots')
return
}
setLocalTypebot({ ...typebot })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading])
const updateLocalTypebot = (updates: UpdateTypebotPayload) =>
localTypebot && setLocalTypebot({ ...localTypebot, ...updates })
const publishTypebot = async () => {
if (!localTypebot) return
const publishedTypebotId = cuid()
const newLocalTypebot = { ...localTypebot }
if (publishedTypebot && isNotDefined(localTypebot.publishedTypebotId)) {
updateLocalTypebot({ publishedTypebotId: publishedTypebot.id })
await saveTypebot()
}
if (!publishedTypebot) {
const newPublicId = parseDefaultPublicId(
localTypebot.name,
localTypebot.id
)
updateLocalTypebot({ publicId: newPublicId, publishedTypebotId })
newLocalTypebot.publicId = newPublicId
await saveTypebot()
}
if (publishedTypebot) {
await savePublishedTypebot({
...parseTypebotToPublicTypebot(newLocalTypebot),
id: publishedTypebot.id,
})
} else {
setIsPublishing(true)
const { data, error } = await createPublishedTypebotQuery(
{
...parseTypebotToPublicTypebot(newLocalTypebot),
id: publishedTypebotId,
},
localTypebot.workspaceId
)
setIsPublishing(false)
if (error)
return showToast({ title: error.name, description: error.message })
mutate({
typebot: localTypebot,
publishedTypebot: data,
webhooks: webhooks ?? [],
})
}
}
const unpublishTypebot = async () => {
if (!publishedTypebot || !localTypebot) return
setIsPublishing(true)
const { error } = await deletePublishedTypebotQuery({
publishedTypebotId: publishedTypebot.id,
typebotId: localTypebot.id,
})
setIsPublishing(false)
if (error) showToast({ description: error.message })
mutate({
typebot: localTypebot,
webhooks: webhooks ?? [],
})
}
const restorePublishedTypebot = () => {
if (!publishedTypebot || !localTypebot) return
setLocalTypebot(parsePublicTypebotToTypebot(publishedTypebot, localTypebot))
return saveTypebot()
}
const updateWebhook = async (
webhookId: string,
updates: Partial<Webhook>
) => {
if (!typebot) return
const { data } = await saveWebhookQuery(webhookId, updates)
if (data)
mutate({
typebot,
publishedTypebot,
webhooks: (webhooks ?? []).map((w) =>
w.id === webhookId ? data.webhook : w
),
})
}
return (
<typebotContext.Provider
value={{
typebot: localTypebot,
publishedTypebot,
linkedTypebots,
webhooks: webhooks ?? [],
isReadOnly,
isSavingLoading,
save: saveTypebot,
undo,
redo,
canUndo,
canRedo,
publishTypebot,
unpublishTypebot,
isPublishing,
isPublished,
updateTypebot: updateLocalTypebot,
restorePublishedTypebot,
updateWebhook,
...groupsActions(setLocalTypebot as SetTypebot),
...blocksAction(setLocalTypebot as SetTypebot),
...variablesAction(setLocalTypebot as SetTypebot),
...edgesAction(setLocalTypebot as SetTypebot),
...itemsAction(setLocalTypebot as SetTypebot),
}}
>
{children}
</typebotContext.Provider>
)
}
export const useTypebot = () => useContext(typebotContext)

View File

@ -0,0 +1,165 @@
import {
Block,
Typebot,
DraggableBlock,
DraggableBlockType,
BlockIndices,
} from 'models'
import { removeEmptyGroups } from './groups'
import { WritableDraft } from 'immer/dist/types/types-external'
import { SetTypebot } from '../TypebotProvider'
import produce from 'immer'
import { cleanUpEdgeDraft, deleteEdgeDraft } from './edges'
import cuid from 'cuid'
import { byId, isWebhookBlock, blockHasItems } from 'utils'
import { duplicateItemDraft } from './items'
import { parseNewBlock } from '@/features/graph'
export type BlocksActions = {
createBlock: (
groupId: string,
block: DraggableBlock | DraggableBlockType,
indices: BlockIndices
) => void
updateBlock: (
indices: BlockIndices,
updates: Partial<Omit<Block, 'id' | 'type'>>
) => void
duplicateBlock: (indices: BlockIndices) => void
detachBlockFromGroup: (indices: BlockIndices) => void
deleteBlock: (indices: BlockIndices) => void
}
const blocksAction = (setTypebot: SetTypebot): BlocksActions => ({
createBlock: (
groupId: string,
block: DraggableBlock | DraggableBlockType,
indices: BlockIndices
) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
createBlockDraft(typebot, block, groupId, indices)
})
),
updateBlock: (
{ groupIndex, blockIndex }: BlockIndices,
updates: Partial<Omit<Block, 'id' | 'type'>>
) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const block = typebot.groups[groupIndex].blocks[blockIndex]
typebot.groups[groupIndex].blocks[blockIndex] = { ...block, ...updates }
})
),
duplicateBlock: ({ groupIndex, blockIndex }: BlockIndices) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const block = { ...typebot.groups[groupIndex].blocks[blockIndex] }
const newBlock = duplicateBlockDraft(block.groupId)(block)
typebot.groups[groupIndex].blocks.splice(blockIndex + 1, 0, newBlock)
})
),
detachBlockFromGroup: (indices: BlockIndices) =>
setTypebot((typebot) => produce(typebot, removeBlockFromGroup(indices))),
deleteBlock: ({ groupIndex, blockIndex }: BlockIndices) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const removingBlock = typebot.groups[groupIndex].blocks[blockIndex]
removeBlockFromGroup({ groupIndex, blockIndex })(typebot)
cleanUpEdgeDraft(typebot, removingBlock.id)
removeEmptyGroups(typebot)
})
),
})
const removeBlockFromGroup =
({ groupIndex, blockIndex }: BlockIndices) =>
(typebot: WritableDraft<Typebot>) => {
typebot.groups[groupIndex].blocks.splice(blockIndex, 1)
}
const createBlockDraft = (
typebot: WritableDraft<Typebot>,
block: DraggableBlock | DraggableBlockType,
groupId: string,
{ groupIndex, blockIndex }: BlockIndices
) => {
const blocks = typebot.groups[groupIndex].blocks
if (
blockIndex === blocks.length &&
blockIndex > 0 &&
blocks[blockIndex - 1].outgoingEdgeId
)
deleteEdgeDraft(typebot, blocks[blockIndex - 1].outgoingEdgeId as string)
typeof block === 'string'
? createNewBlock(typebot, block, groupId, { groupIndex, blockIndex })
: moveBlockToGroup(typebot, block, groupId, { groupIndex, blockIndex })
removeEmptyGroups(typebot)
}
const createNewBlock = async (
typebot: WritableDraft<Typebot>,
type: DraggableBlockType,
groupId: string,
{ groupIndex, blockIndex }: BlockIndices
) => {
const newBlock = parseNewBlock(type, groupId)
typebot.groups[groupIndex].blocks.splice(blockIndex ?? 0, 0, newBlock)
}
const moveBlockToGroup = (
typebot: WritableDraft<Typebot>,
block: DraggableBlock,
groupId: string,
{ groupIndex, blockIndex }: BlockIndices
) => {
const newBlock = { ...block, groupId }
const items = blockHasItems(block) ? block.items : []
items.forEach((item) => {
const edgeIndex = typebot.edges.findIndex(byId(item.outgoingEdgeId))
if (edgeIndex === -1) return
typebot.edges[edgeIndex].from.groupId = groupId
})
if (block.outgoingEdgeId) {
if (typebot.groups[groupIndex].blocks.length > blockIndex ?? 0) {
deleteEdgeDraft(typebot, block.outgoingEdgeId)
newBlock.outgoingEdgeId = undefined
} else {
const edgeIndex = typebot.edges.findIndex(byId(block.outgoingEdgeId))
edgeIndex !== -1
? (typebot.edges[edgeIndex].from.groupId = groupId)
: (newBlock.outgoingEdgeId = undefined)
}
}
typebot.groups[groupIndex].blocks.splice(blockIndex ?? 0, 0, newBlock)
}
const duplicateBlockDraft =
(groupId: string) =>
(block: Block): Block => {
const blockId = cuid()
if (blockHasItems(block))
return {
...block,
groupId,
id: blockId,
items: block.items.map(duplicateItemDraft(blockId)),
outgoingEdgeId: undefined,
} as Block
if (isWebhookBlock(block))
return {
...block,
groupId,
id: blockId,
webhookId: cuid(),
outgoingEdgeId: undefined,
}
return {
...block,
groupId,
id: blockId,
outgoingEdgeId: undefined,
}
}
export { blocksAction, createBlockDraft, duplicateBlockDraft }

View File

@ -0,0 +1,149 @@
import {
Typebot,
Edge,
BlockWithItems,
BlockIndices,
ItemIndices,
Block,
} from 'models'
import { WritableDraft } from 'immer/dist/types/types-external'
import { SetTypebot } from '../TypebotProvider'
import { produce } from 'immer'
import { byId, isDefined, blockHasItems } from 'utils'
import cuid from 'cuid'
export type EdgesActions = {
createEdge: (edge: Omit<Edge, 'id'>) => void
updateEdge: (edgeIndex: number, updates: Partial<Omit<Edge, 'id'>>) => void
deleteEdge: (edgeId: string) => void
}
export const edgesAction = (setTypebot: SetTypebot): EdgesActions => ({
createEdge: (edge: Omit<Edge, 'id'>) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const newEdge = {
...edge,
id: cuid(),
}
removeExistingEdge(typebot, edge)
typebot.edges.push(newEdge)
const groupIndex = typebot.groups.findIndex(byId(edge.from.groupId))
const blockIndex = typebot.groups[groupIndex].blocks.findIndex(
byId(edge.from.blockId)
)
const itemIndex = edge.from.itemId
? (
typebot.groups[groupIndex].blocks[blockIndex] as BlockWithItems
).items.findIndex(byId(edge.from.itemId))
: null
isDefined(itemIndex) && itemIndex !== -1
? addEdgeIdToItem(typebot, newEdge.id, {
groupIndex,
blockIndex,
itemIndex,
})
: addEdgeIdToBlock(typebot, newEdge.id, {
groupIndex,
blockIndex,
})
})
),
updateEdge: (edgeIndex: number, updates: Partial<Omit<Edge, 'id'>>) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const currentEdge = typebot.edges[edgeIndex]
typebot.edges[edgeIndex] = {
...currentEdge,
...updates,
}
})
),
deleteEdge: (edgeId: string) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
deleteEdgeDraft(typebot, edgeId)
})
),
})
const addEdgeIdToBlock = (
typebot: WritableDraft<Typebot>,
edgeId: string,
{ groupIndex, blockIndex }: BlockIndices
) => {
typebot.groups[groupIndex].blocks[blockIndex].outgoingEdgeId = edgeId
}
const addEdgeIdToItem = (
typebot: WritableDraft<Typebot>,
edgeId: string,
{ groupIndex, blockIndex, itemIndex }: ItemIndices
) =>
((typebot.groups[groupIndex].blocks[blockIndex] as BlockWithItems).items[
itemIndex
].outgoingEdgeId = edgeId)
export const deleteEdgeDraft = (
typebot: WritableDraft<Typebot>,
edgeId: string
) => {
const edgeIndex = typebot.edges.findIndex(byId(edgeId))
if (edgeIndex === -1) return
deleteOutgoingEdgeIdProps(typebot, edgeId)
typebot.edges.splice(edgeIndex, 1)
}
const deleteOutgoingEdgeIdProps = (
typebot: WritableDraft<Typebot>,
edgeId: string
) => {
const edge = typebot.edges.find(byId(edgeId))
if (!edge) return
const fromGroupIndex = typebot.groups.findIndex(byId(edge.from.groupId))
const fromBlockIndex = typebot.groups[fromGroupIndex].blocks.findIndex(
byId(edge.from.blockId)
)
const block = typebot.groups[fromGroupIndex].blocks[fromBlockIndex] as
| Block
| undefined
const fromItemIndex =
edge.from.itemId && block && blockHasItems(block)
? block.items.findIndex(byId(edge.from.itemId))
: -1
if (fromBlockIndex !== -1)
typebot.groups[fromGroupIndex].blocks[fromBlockIndex].outgoingEdgeId =
undefined
if (fromItemIndex !== -1)
(
typebot.groups[fromGroupIndex].blocks[fromBlockIndex] as BlockWithItems
).items[fromItemIndex].outgoingEdgeId = undefined
}
export const cleanUpEdgeDraft = (
typebot: WritableDraft<Typebot>,
deletedNodeId: string
) => {
const edgesToDelete = typebot.edges.filter((edge) =>
[
edge.from.groupId,
edge.from.blockId,
edge.from.itemId,
edge.to.groupId,
edge.to.blockId,
].includes(deletedNodeId)
)
edgesToDelete.forEach((edge) => deleteEdgeDraft(typebot, edge.id))
}
const removeExistingEdge = (
typebot: WritableDraft<Typebot>,
edge: Omit<Edge, 'id'>
) => {
typebot.edges = typebot.edges.filter((e) =>
edge.from.itemId
? e.from.itemId !== edge.from.itemId
: isDefined(e.from.itemId) || e.from.blockId !== edge.from.blockId
)
}

View File

@ -0,0 +1,102 @@
import cuid from 'cuid'
import { produce } from 'immer'
import { WritableDraft } from 'immer/dist/internal'
import {
Group,
DraggableBlock,
DraggableBlockType,
BlockIndices,
Typebot,
} from 'models'
import { SetTypebot } from '../TypebotProvider'
import { cleanUpEdgeDraft } from './edges'
import { createBlockDraft, duplicateBlockDraft } from './blocks'
import { Coordinates } from '@/features/graph'
export type GroupsActions = {
createGroup: (
props: Coordinates & {
id: string
block: DraggableBlock | DraggableBlockType
indices: BlockIndices
}
) => void
updateGroup: (groupIndex: number, updates: Partial<Omit<Group, 'id'>>) => void
duplicateGroup: (groupIndex: number) => void
deleteGroup: (groupIndex: number) => void
}
const groupsActions = (setTypebot: SetTypebot): GroupsActions => ({
createGroup: ({
id,
block,
indices,
...graphCoordinates
}: Coordinates & {
id: string
block: DraggableBlock | DraggableBlockType
indices: BlockIndices
}) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const newGroup: Group = {
id,
graphCoordinates,
title: `Group #${typebot.groups.length}`,
blocks: [],
}
typebot.groups.push(newGroup)
createBlockDraft(typebot, block, newGroup.id, indices)
})
),
updateGroup: (groupIndex: number, updates: Partial<Omit<Group, 'id'>>) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const block = typebot.groups[groupIndex]
typebot.groups[groupIndex] = { ...block, ...updates }
})
),
duplicateGroup: (groupIndex: number) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const group = typebot.groups[groupIndex]
const id = cuid()
const newGroup: Group = {
...group,
title: `${group.title} copy`,
id,
blocks: group.blocks.map(duplicateBlockDraft(id)),
graphCoordinates: {
x: group.graphCoordinates.x + 200,
y: group.graphCoordinates.y + 100,
},
}
typebot.groups.splice(groupIndex + 1, 0, newGroup)
})
),
deleteGroup: (groupIndex: number) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
deleteGroupDraft(typebot)(groupIndex)
})
),
})
const deleteGroupDraft =
(typebot: WritableDraft<Typebot>) => (groupIndex: number) => {
cleanUpEdgeDraft(typebot, typebot.groups[groupIndex].id)
typebot.groups.splice(groupIndex, 1)
}
const removeEmptyGroups = (typebot: WritableDraft<Typebot>) => {
const emptyGroupsIndices = typebot.groups.reduce<number[]>(
(arr, group, idx) => {
group.blocks.length === 0 && arr.push(idx)
return arr
},
[]
)
emptyGroupsIndices.forEach(deleteGroupDraft(typebot))
}
export { groupsActions, removeEmptyGroups }

View File

@ -0,0 +1,96 @@
import {
ItemIndices,
Item,
InputBlockType,
BlockWithItems,
ButtonItem,
} from 'models'
import { SetTypebot } from '../TypebotProvider'
import produce from 'immer'
import { cleanUpEdgeDraft } from './edges'
import { byId, blockHasItems } from 'utils'
import cuid from 'cuid'
export type ItemsActions = {
createItem: (
item: ButtonItem | Omit<ButtonItem, 'id'>,
indices: ItemIndices
) => void
updateItem: (indices: ItemIndices, updates: Partial<Omit<Item, 'id'>>) => void
detachItemFromBlock: (indices: ItemIndices) => void
deleteItem: (indices: ItemIndices) => void
}
const itemsAction = (setTypebot: SetTypebot): ItemsActions => ({
createItem: (
item: ButtonItem | Omit<ButtonItem, 'id'>,
{ groupIndex, blockIndex, itemIndex }: ItemIndices
) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const block = typebot.groups[groupIndex].blocks[blockIndex]
if (block.type !== InputBlockType.CHOICE) return
const newItem = {
...item,
blockId: block.id,
id: 'id' in item ? item.id : cuid(),
}
if (item.outgoingEdgeId) {
const edgeIndex = typebot.edges.findIndex(byId(item.outgoingEdgeId))
edgeIndex !== -1
? (typebot.edges[edgeIndex].from = {
groupId: block.groupId,
blockId: block.id,
itemId: newItem.id,
})
: (newItem.outgoingEdgeId = undefined)
}
block.items.splice(itemIndex, 0, newItem)
})
),
updateItem: (
{ groupIndex, blockIndex, itemIndex }: ItemIndices,
updates: Partial<Omit<Item, 'id'>>
) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const block = typebot.groups[groupIndex].blocks[blockIndex]
if (!blockHasItems(block)) return
;(
typebot.groups[groupIndex].blocks[blockIndex] as BlockWithItems
).items[itemIndex] = {
...block.items[itemIndex],
...updates,
} as Item
})
),
detachItemFromBlock: ({ groupIndex, blockIndex, itemIndex }: ItemIndices) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const block = typebot.groups[groupIndex].blocks[
blockIndex
] as BlockWithItems
block.items.splice(itemIndex, 1)
})
),
deleteItem: ({ groupIndex, blockIndex, itemIndex }: ItemIndices) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
const block = typebot.groups[groupIndex].blocks[
blockIndex
] as BlockWithItems
const removingItem = block.items[itemIndex]
block.items.splice(itemIndex, 1)
cleanUpEdgeDraft(typebot, removingItem.id)
})
),
})
const duplicateItemDraft = (blockId: string) => (item: Item) => ({
...item,
id: cuid(),
blockId,
outgoingEdgeId: undefined,
})
export { itemsAction, duplicateItemDraft }

View File

@ -0,0 +1,47 @@
import { Typebot, Variable } from 'models'
import { WritableDraft } from 'immer/dist/types/types-external'
import { SetTypebot } from '../TypebotProvider'
import { produce } from 'immer'
export type VariablesActions = {
createVariable: (variable: Variable) => void
updateVariable: (
variableId: string,
updates: Partial<Omit<Variable, 'id'>>
) => void
deleteVariable: (variableId: string) => void
}
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((typebot) =>
produce(typebot, (typebot) => {
typebot.variables = typebot.variables.map((v) =>
v.id === variableId ? { ...v, ...updates } : v
)
})
),
deleteVariable: (itemId: string) =>
setTypebot((typebot) =>
produce(typebot, (typebot) => {
deleteVariableDraft(typebot, itemId)
})
),
})
export const deleteVariableDraft = (
typebot: WritableDraft<Typebot>,
variableId: string
) => {
const index = typebot.variables.findIndex((v) => v.id === variableId)
typebot.variables.splice(index, 1)
}

View File

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

View File

@ -0,0 +1,9 @@
import { Typebot } from 'models'
import { sendRequest } from 'utils'
export const updateTypebotQuery = async (id: string, typebot: Typebot) =>
sendRequest({
url: `/api/typebots/${id}`,
method: 'PUT',
body: typebot,
})