refactor(editor): ♻️ Undo / Redo buttons + structure refacto
Yet another huge refacto... While implementing undo and redo features I understood that I updated the stored typebot too many times (i.e. on each key input) so I had to rethink it entirely. I also moved around some files.
This commit is contained in:
51
apps/builder/components/editor/BoardMenuButton.tsx
Normal file
51
apps/builder/components/editor/BoardMenuButton.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuButtonProps,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from '@chakra-ui/react'
|
||||
import { DownloadIcon, MoreVerticalIcon } from 'assets/icons'
|
||||
import { useTypebot } from 'contexts/TypebotContext'
|
||||
import React, { useState } from 'react'
|
||||
import { parseDefaultPublicId } from 'services/typebots'
|
||||
|
||||
export const BoardMenuButton = (props: MenuButtonProps) => {
|
||||
const { typebot } = useTypebot()
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
const downloadFlow = () => {
|
||||
if (!typebot) return
|
||||
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}
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
icon={<MoreVerticalIcon transform={'rotate(90deg)'} />}
|
||||
isLoading={isDownloading}
|
||||
{...props}
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem icon={<DownloadIcon />} onClick={downloadFlow}>
|
||||
Export flow
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)
|
||||
}
|
78
apps/builder/components/editor/StepsSideBar/StepCard.tsx
Normal file
78
apps/builder/components/editor/StepsSideBar/StepCard.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { Flex, HStack, StackProps, Text } from '@chakra-ui/react'
|
||||
import { StepType, DraggableStepType } from 'models'
|
||||
import { useStepDnd } from 'contexts/StepDndContext'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { StepIcon } from './StepIcon'
|
||||
import { StepTypeLabel } from './StepTypeLabel'
|
||||
|
||||
export const StepCard = ({
|
||||
type,
|
||||
onMouseDown,
|
||||
}: {
|
||||
type: DraggableStepType
|
||||
onMouseDown: (e: React.MouseEvent, type: DraggableStepType) => void
|
||||
}) => {
|
||||
const { draggedStepType } = useStepDnd()
|
||||
const [isMouseDown, setIsMouseDown] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsMouseDown(draggedStepType === type)
|
||||
}, [draggedStepType, type])
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => onMouseDown(e, type)
|
||||
|
||||
return (
|
||||
<Flex pos="relative">
|
||||
<HStack
|
||||
borderWidth="1px"
|
||||
rounded="lg"
|
||||
flex="1"
|
||||
cursor={'grab'}
|
||||
opacity={isMouseDown ? '0.4' : '1'}
|
||||
onMouseDown={handleMouseDown}
|
||||
bgColor="white"
|
||||
shadow="sm"
|
||||
px="4"
|
||||
py="2"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="box-shadow 200ms"
|
||||
>
|
||||
{!isMouseDown ? (
|
||||
<>
|
||||
<StepIcon type={type} />
|
||||
<StepTypeLabel type={type} />
|
||||
</>
|
||||
) : (
|
||||
<Text color="white" userSelect="none">
|
||||
Placeholder
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export const StepCardOverlay = ({
|
||||
type,
|
||||
...props
|
||||
}: StackProps & { type: StepType }) => {
|
||||
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}
|
||||
>
|
||||
<StepIcon type={type} />
|
||||
<StepTypeLabel type={type} />
|
||||
</HStack>
|
||||
)
|
||||
}
|
70
apps/builder/components/editor/StepsSideBar/StepIcon.tsx
Normal file
70
apps/builder/components/editor/StepsSideBar/StepIcon.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import { IconProps } from '@chakra-ui/react'
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChatIcon,
|
||||
CheckSquareIcon,
|
||||
EditIcon,
|
||||
EmailIcon,
|
||||
ExternalLinkIcon,
|
||||
FilmIcon,
|
||||
FilterIcon,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
ImageIcon,
|
||||
NumberIcon,
|
||||
PhoneIcon,
|
||||
TextIcon,
|
||||
WebhookIcon,
|
||||
} from 'assets/icons'
|
||||
import { GoogleAnalyticsLogo, GoogleSheetsLogo } from 'assets/logos'
|
||||
import {
|
||||
BubbleStepType,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
StepType,
|
||||
} from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type StepIconProps = { type: StepType } & IconProps
|
||||
|
||||
export const StepIcon = ({ type, ...props }: StepIconProps) => {
|
||||
switch (type) {
|
||||
case BubbleStepType.TEXT:
|
||||
return <ChatIcon color="blue.500" {...props} />
|
||||
case BubbleStepType.IMAGE:
|
||||
return <ImageIcon color="blue.500" {...props} />
|
||||
case BubbleStepType.VIDEO:
|
||||
return <FilmIcon color="blue.500" {...props} />
|
||||
case InputStepType.TEXT:
|
||||
return <TextIcon color="orange.500" {...props} />
|
||||
case InputStepType.NUMBER:
|
||||
return <NumberIcon color="orange.500" {...props} />
|
||||
case InputStepType.EMAIL:
|
||||
return <EmailIcon color="orange.500" {...props} />
|
||||
case InputStepType.URL:
|
||||
return <GlobeIcon color="orange.500" {...props} />
|
||||
case InputStepType.DATE:
|
||||
return <CalendarIcon color="orange.500" {...props} />
|
||||
case InputStepType.PHONE:
|
||||
return <PhoneIcon color="orange.500" {...props} />
|
||||
case InputStepType.CHOICE:
|
||||
return <CheckSquareIcon color="orange.500" {...props} />
|
||||
case LogicStepType.SET_VARIABLE:
|
||||
return <EditIcon color="purple.500" {...props} />
|
||||
case LogicStepType.CONDITION:
|
||||
return <FilterIcon color="purple.500" {...props} />
|
||||
case LogicStepType.REDIRECT:
|
||||
return <ExternalLinkIcon color="purple.500" {...props} />
|
||||
case IntegrationStepType.GOOGLE_SHEETS:
|
||||
return <GoogleSheetsLogo {...props} />
|
||||
case IntegrationStepType.GOOGLE_ANALYTICS:
|
||||
return <GoogleAnalyticsLogo {...props} />
|
||||
case IntegrationStepType.WEBHOOK:
|
||||
return <WebhookIcon />
|
||||
case 'start':
|
||||
return <FlagIcon {...props} />
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
187
apps/builder/components/editor/StepsSideBar/StepSideBar.tsx
Normal file
187
apps/builder/components/editor/StepsSideBar/StepSideBar.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import {
|
||||
Stack,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
useEventListener,
|
||||
Portal,
|
||||
Flex,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Fade,
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
BubbleStepType,
|
||||
DraggableStepType,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
} from 'models'
|
||||
import { useStepDnd } from 'contexts/StepDndContext'
|
||||
import React, { useState } from 'react'
|
||||
import { StepCard, StepCardOverlay } from './StepCard'
|
||||
import { LockedIcon, UnlockedIcon } from 'assets/icons'
|
||||
import { headerHeight } from 'components/shared/TypebotHeader'
|
||||
|
||||
export const StepsSideBar = () => {
|
||||
const { setDraggedStepType, draggedStepType } = useStepDnd()
|
||||
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 (!draggedStepType) return
|
||||
const { clientX, clientY } = event
|
||||
setPosition({
|
||||
...position,
|
||||
x: clientX - relativeCoordinates.x,
|
||||
y: clientY - relativeCoordinates.y,
|
||||
})
|
||||
}
|
||||
useEventListener('mousemove', handleMouseMove)
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent, type: DraggableStepType) => {
|
||||
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 })
|
||||
setDraggedStepType(type)
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!draggedStepType) return
|
||||
setDraggedStepType(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="340px"
|
||||
pos="absolute"
|
||||
left="0"
|
||||
h={`calc(100vh - ${headerHeight}px)`}
|
||||
zIndex="2"
|
||||
pl="4"
|
||||
py="4"
|
||||
onMouseLeave={handleMouseLeave}
|
||||
transform={isExtended ? 'translateX(0)' : 'translateX(-340px)'}
|
||||
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="4"
|
||||
px="2"
|
||||
bgColor="gray.50"
|
||||
spacing={6}
|
||||
userSelect="none"
|
||||
>
|
||||
<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="2">
|
||||
{Object.values(BubbleStepType).map((type) => (
|
||||
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
|
||||
Inputs
|
||||
</Text>
|
||||
<SimpleGrid columns={2} spacing="2">
|
||||
{Object.values(InputStepType).map((type) => (
|
||||
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
|
||||
Logic
|
||||
</Text>
|
||||
<SimpleGrid columns={2} spacing="2">
|
||||
{Object.values(LogicStepType).map((type) => (
|
||||
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<Text fontSize="sm" fontWeight="semibold" color="gray.600">
|
||||
Integrations
|
||||
</Text>
|
||||
<SimpleGrid columns={2} spacing="2">
|
||||
{Object.values(IntegrationStepType).map((type) => (
|
||||
<StepCard key={type} type={type} onMouseDown={handleMouseDown} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
{draggedStepType && (
|
||||
<Portal>
|
||||
<StepCardOverlay
|
||||
type={draggedStepType}
|
||||
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,57 @@
|
||||
import { Text, Tooltip } from '@chakra-ui/react'
|
||||
import {
|
||||
BubbleStepType,
|
||||
InputStepType,
|
||||
IntegrationStepType,
|
||||
LogicStepType,
|
||||
StepType,
|
||||
} from 'models'
|
||||
import React from 'react'
|
||||
|
||||
type Props = { type: StepType }
|
||||
|
||||
export const StepTypeLabel = ({ type }: Props) => {
|
||||
switch (type) {
|
||||
case BubbleStepType.TEXT:
|
||||
case InputStepType.TEXT:
|
||||
return <Text>Text</Text>
|
||||
case BubbleStepType.IMAGE:
|
||||
return <Text>Image</Text>
|
||||
case BubbleStepType.VIDEO:
|
||||
return <Text>Video</Text>
|
||||
case InputStepType.NUMBER:
|
||||
return <Text>Number</Text>
|
||||
case InputStepType.EMAIL:
|
||||
return <Text>Email</Text>
|
||||
case InputStepType.URL:
|
||||
return <Text>Website</Text>
|
||||
case InputStepType.DATE:
|
||||
return <Text>Date</Text>
|
||||
case InputStepType.PHONE:
|
||||
return <Text>Phone</Text>
|
||||
case InputStepType.CHOICE:
|
||||
return <Text>Button</Text>
|
||||
case LogicStepType.SET_VARIABLE:
|
||||
return <Text>Set variable</Text>
|
||||
case LogicStepType.CONDITION:
|
||||
return <Text>Condition</Text>
|
||||
case LogicStepType.REDIRECT:
|
||||
return <Text>Redirect</Text>
|
||||
case IntegrationStepType.GOOGLE_SHEETS:
|
||||
return (
|
||||
<Tooltip label="Google Sheets">
|
||||
<Text>Sheets</Text>
|
||||
</Tooltip>
|
||||
)
|
||||
case IntegrationStepType.GOOGLE_ANALYTICS:
|
||||
return (
|
||||
<Tooltip label="Google Analytics">
|
||||
<Text>Analytics</Text>
|
||||
</Tooltip>
|
||||
)
|
||||
case IntegrationStepType.WEBHOOK:
|
||||
return <Text>Webhook</Text>
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
1
apps/builder/components/editor/StepsSideBar/index.tsx
Normal file
1
apps/builder/components/editor/StepsSideBar/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { StepsSideBar } from './StepSideBar'
|
117
apps/builder/components/editor/preview/PreviewDrawer.tsx
Normal file
117
apps/builder/components/editor/preview/PreviewDrawer.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CloseButton,
|
||||
Fade,
|
||||
Flex,
|
||||
FlexProps,
|
||||
useEventListener,
|
||||
VStack,
|
||||
} from '@chakra-ui/react'
|
||||
import { TypebotViewer } from 'bot-engine'
|
||||
import { headerHeight } from 'components/shared/TypebotHeader'
|
||||
import { useEditor } from 'contexts/EditorContext'
|
||||
import { useGraph } from 'contexts/GraphContext'
|
||||
import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { parseTypebotToPublicTypebot } from 'services/publicTypebot'
|
||||
|
||||
export const PreviewDrawer = () => {
|
||||
const { typebot } = useTypebot()
|
||||
const { setRightPanel } = useEditor()
|
||||
const { setPreviewingEdgeId } = 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 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 handleNewBlockVisible = (edgeId: string) => setPreviewingEdgeId(edgeId)
|
||||
|
||||
const handleRestartClick = () => setRestartKey((key) => key + 1)
|
||||
|
||||
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"
|
||||
>
|
||||
<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={() => setRightPanel(undefined)} />
|
||||
</Flex>
|
||||
|
||||
{publicTypebot && (
|
||||
<Flex
|
||||
borderWidth={'1px'}
|
||||
borderRadius={'lg'}
|
||||
h="full"
|
||||
w="full"
|
||||
key={restartKey}
|
||||
pointerEvents={isResizing ? 'none' : 'auto'}
|
||||
>
|
||||
<TypebotViewer
|
||||
typebot={publicTypebot}
|
||||
onNewBlockVisible={handleNewBlockVisible}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user