♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
@ -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>
|
||||
)
|
||||
}
|
@ -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} />
|
||||
}
|
||||
}
|
@ -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>
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { BlocksSideBar } from './BlocksSideBar'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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 /> : <></>
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
134
apps/builder/src/features/editor/components/PreviewDrawer.tsx
Normal file
134
apps/builder/src/features/editor/components/PreviewDrawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './TypebotHeader'
|
1
apps/builder/src/features/editor/constants.ts
Normal file
1
apps/builder/src/features/editor/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const headerHeight = 56
|
242
apps/builder/src/features/editor/editor.spec.ts
Normal file
242
apps/builder/src/features/editor/editor.spec.ts
Normal 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()
|
||||
})
|
159
apps/builder/src/features/editor/hooks/useUndo.ts
Normal file
159
apps/builder/src/features/editor/hooks/useUndo.ts
Normal 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
|
7
apps/builder/src/features/editor/index.ts
Normal file
7
apps/builder/src/features/editor/index.ts
Normal 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'
|
@ -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)
|
@ -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)
|
@ -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 }
|
@ -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
|
||||
)
|
||||
}
|
@ -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 }
|
@ -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 }
|
@ -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)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { TypebotProvider, useTypebot } from './TypebotProvider'
|
@ -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,
|
||||
})
|
Reference in New Issue
Block a user