♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
45
apps/builder/src/features/folders/TypebotDndProvider.tsx
Normal file
45
apps/builder/src/features/folders/TypebotDndProvider.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { TypebotInDashboard } from '@/features/dashboard'
|
||||
|
||||
const typebotDndContext = createContext<{
|
||||
draggedTypebot?: TypebotInDashboard
|
||||
setDraggedTypebot: Dispatch<SetStateAction<TypebotInDashboard | undefined>>
|
||||
mouseOverFolderId?: string | null
|
||||
setMouseOverFolderId: Dispatch<SetStateAction<string | undefined | null>>
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
}>({})
|
||||
|
||||
export const TypebotDndProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [draggedTypebot, setDraggedTypebot] = useState<TypebotInDashboard>()
|
||||
const [mouseOverFolderId, setMouseOverFolderId] = useState<string | null>()
|
||||
|
||||
useEffect(() => {
|
||||
draggedTypebot
|
||||
? document.body.classList.add('grabbing')
|
||||
: document.body.classList.remove('grabbing')
|
||||
}, [draggedTypebot])
|
||||
|
||||
return (
|
||||
<typebotDndContext.Provider
|
||||
value={{
|
||||
draggedTypebot,
|
||||
setDraggedTypebot,
|
||||
mouseOverFolderId,
|
||||
setMouseOverFolderId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</typebotDndContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTypebotDnd = () => useContext(typebotDndContext)
|
32
apps/builder/src/features/folders/components/BackButton.tsx
Normal file
32
apps/builder/src/features/folders/components/BackButton.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Button } from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from '@/components/icons'
|
||||
import { useTypebotDnd } from '../TypebotDndProvider'
|
||||
import Link from 'next/link'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
export const BackButton = ({ id }: { id: string | null }) => {
|
||||
const { draggedTypebot, setMouseOverFolderId, mouseOverFolderId } =
|
||||
useTypebotDnd()
|
||||
|
||||
const isTypebotOver = useMemo(
|
||||
() => draggedTypebot && mouseOverFolderId === id,
|
||||
[draggedTypebot, id, mouseOverFolderId]
|
||||
)
|
||||
|
||||
const handleMouseEnter = () => setMouseOverFolderId(id)
|
||||
const handleMouseLeave = () => setMouseOverFolderId(undefined)
|
||||
return (
|
||||
<Button
|
||||
as={Link}
|
||||
href={id ? `/typebots/folders/${id}` : '/typebots'}
|
||||
leftIcon={<ChevronLeftIcon />}
|
||||
variant={'outline'}
|
||||
colorScheme={isTypebotOver ? 'blue' : 'gray'}
|
||||
borderWidth={isTypebotOver ? '3px' : '1px'}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
)
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
import { Button, ButtonProps, Text, VStack } from '@chakra-ui/react'
|
||||
import { PlusIcon } from '@/components/icons'
|
||||
import { useRouter } from 'next/router'
|
||||
import { stringify } from 'qs'
|
||||
import React from 'react'
|
||||
|
||||
export const CreateBotButton = ({
|
||||
folderId,
|
||||
isFirstBot,
|
||||
...props
|
||||
}: { folderId?: string; isFirstBot: boolean } & ButtonProps) => {
|
||||
const router = useRouter()
|
||||
|
||||
const handleClick = () =>
|
||||
router.push(
|
||||
`/typebots/create?${stringify({
|
||||
isFirstBot: !isFirstBot ? undefined : isFirstBot,
|
||||
folderId,
|
||||
})}`
|
||||
)
|
||||
|
||||
return (
|
||||
<Button
|
||||
mr={{ sm: 6 }}
|
||||
mb={6}
|
||||
style={{ width: '225px', height: '270px' }}
|
||||
onClick={handleClick}
|
||||
paddingX={6}
|
||||
whiteSpace={'normal'}
|
||||
colorScheme="blue"
|
||||
{...props}
|
||||
>
|
||||
<VStack spacing="6">
|
||||
<PlusIcon fontSize="40px" />
|
||||
<Text
|
||||
fontSize={18}
|
||||
fontWeight="medium"
|
||||
maxW={40}
|
||||
textAlign="center"
|
||||
mt="6"
|
||||
>
|
||||
Create a typebot
|
||||
</Text>
|
||||
</VStack>
|
||||
</Button>
|
||||
)
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { Button, HStack, useDisclosure, Text } from '@chakra-ui/react'
|
||||
import { FolderPlusIcon } from '@/components/icons'
|
||||
import {
|
||||
LimitReached,
|
||||
ChangePlanModal,
|
||||
LockTag,
|
||||
isFreePlan,
|
||||
} from '@/features/billing'
|
||||
import { useWorkspace } from '@/features/workspace'
|
||||
import { Plan } from 'db'
|
||||
import React from 'react'
|
||||
|
||||
type Props = { isLoading: boolean; onClick: () => void }
|
||||
|
||||
export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
||||
const { workspace } = useWorkspace()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const handleClick = () => {
|
||||
if (isFreePlan(workspace)) return onOpen()
|
||||
onClick()
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
leftIcon={<FolderPlusIcon />}
|
||||
onClick={handleClick}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<HStack>
|
||||
<Text>Create a folder</Text>
|
||||
{isFreePlan(workspace) && <LockTag plan={Plan.STARTER} />}
|
||||
</HStack>
|
||||
<ChangePlanModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
type={LimitReached.FOLDER}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
164
apps/builder/src/features/folders/components/FolderButton.tsx
Normal file
164
apps/builder/src/features/folders/components/FolderButton.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { DashboardFolder } from 'db'
|
||||
import {
|
||||
Button,
|
||||
Editable,
|
||||
EditableInput,
|
||||
EditablePreview,
|
||||
MenuItem,
|
||||
useDisclosure,
|
||||
Text,
|
||||
VStack,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
SkeletonText,
|
||||
SkeletonCircle,
|
||||
WrapItem,
|
||||
} from '@chakra-ui/react'
|
||||
import { FolderIcon, MoreVerticalIcon } from '@/components/icons'
|
||||
import { ConfirmModal } from '@/components/ConfirmModal'
|
||||
import { useTypebotDnd } from '../TypebotDndProvider'
|
||||
import { useRouter } from 'next/router'
|
||||
import React, { useMemo } from 'react'
|
||||
import { deleteFolderQuery } from '../queries/deleteFolderQuery'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { updateFolderQuery } from '../queries/updateFolderQuery'
|
||||
|
||||
export const FolderButton = ({
|
||||
folder,
|
||||
onFolderDeleted,
|
||||
onFolderRenamed,
|
||||
}: {
|
||||
folder: DashboardFolder
|
||||
onFolderDeleted: () => void
|
||||
onFolderRenamed: (newName: string) => void
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { draggedTypebot, setMouseOverFolderId, mouseOverFolderId } =
|
||||
useTypebotDnd()
|
||||
const isTypebotOver = useMemo(
|
||||
() => draggedTypebot && mouseOverFolderId === folder.id,
|
||||
[draggedTypebot, folder.id, mouseOverFolderId]
|
||||
)
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
const { showToast } = useToast()
|
||||
|
||||
const onDeleteClick = async () => {
|
||||
const { error } = await deleteFolderQuery(folder.id)
|
||||
return error
|
||||
? showToast({
|
||||
title: "Couldn't delete the folder",
|
||||
description: error.message,
|
||||
})
|
||||
: onFolderDeleted()
|
||||
}
|
||||
|
||||
const onRenameSubmit = async (newName: string) => {
|
||||
if (newName === '' || newName === folder.name) return
|
||||
const { error } = await updateFolderQuery(folder.id, { name: newName })
|
||||
return error
|
||||
? showToast({ title: 'An error occured', description: error.message })
|
||||
: onFolderRenamed(newName)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/typebots/folders/${folder.id}`)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => setMouseOverFolderId(folder.id)
|
||||
const handleMouseLeave = () => setMouseOverFolderId(undefined)
|
||||
return (
|
||||
<Button
|
||||
as={WrapItem}
|
||||
style={{ width: '225px', height: '270px' }}
|
||||
paddingX={6}
|
||||
whiteSpace={'normal'}
|
||||
pos="relative"
|
||||
cursor="pointer"
|
||||
variant="outline"
|
||||
colorScheme={isTypebotOver ? 'blue' : 'gray'}
|
||||
borderWidth={isTypebotOver ? '3px' : '1px'}
|
||||
justifyContent="center"
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
icon={<MoreVerticalIcon />}
|
||||
aria-label={`Show ${folder.name} menu`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
colorScheme="gray"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top="5"
|
||||
right="5"
|
||||
/>
|
||||
<MenuList>
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpen()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
<VStack spacing="4">
|
||||
<FolderIcon fontSize={50} color="blue.500" fill="blue.500" />
|
||||
<Editable
|
||||
defaultValue={folder.name}
|
||||
fontSize="18"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onSubmit={onRenameSubmit}
|
||||
>
|
||||
<EditablePreview
|
||||
_hover={{ bgColor: 'gray.300' }}
|
||||
px="2"
|
||||
textAlign="center"
|
||||
/>
|
||||
<EditableInput textAlign="center" />
|
||||
</Editable>
|
||||
</VStack>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
confirmButtonLabel={'Delete'}
|
||||
message={
|
||||
<Text>
|
||||
Are you sure you want to delete <strong>{folder.name}</strong>{' '}
|
||||
folder? (Everything inside will be move to your dashboard)
|
||||
</Text>
|
||||
}
|
||||
title={`Delete ${folder.name}?`}
|
||||
onConfirm={onDeleteClick}
|
||||
confirmButtonColor="red"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export const ButtonSkeleton = () => (
|
||||
<Button
|
||||
as={VStack}
|
||||
mr={{ sm: 6 }}
|
||||
mb={6}
|
||||
style={{ width: '225px', height: '270px' }}
|
||||
paddingX={6}
|
||||
whiteSpace={'normal'}
|
||||
pos="relative"
|
||||
cursor="pointer"
|
||||
variant="outline"
|
||||
>
|
||||
<VStack spacing="6" w="full">
|
||||
<SkeletonCircle boxSize="45px" />
|
||||
<SkeletonText noOfLines={2} w="full" />
|
||||
</VStack>
|
||||
</Button>
|
||||
)
|
232
apps/builder/src/features/folders/components/FolderContent.tsx
Normal file
232
apps/builder/src/features/folders/components/FolderContent.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
import { DashboardFolder, WorkspaceRole } from 'db'
|
||||
import { env } from 'utils'
|
||||
import {
|
||||
Flex,
|
||||
Heading,
|
||||
HStack,
|
||||
Portal,
|
||||
Skeleton,
|
||||
Stack,
|
||||
useEventListener,
|
||||
Wrap,
|
||||
} from '@chakra-ui/react'
|
||||
import { useTypebotDnd } from '../TypebotDndProvider'
|
||||
import { useUser } from '@/features/account'
|
||||
import React, { useState } from 'react'
|
||||
import { BackButton } from './BackButton'
|
||||
import { OnboardingModal } from '../../dashboard/components/OnboardingModal'
|
||||
import { useWorkspace } from '@/features/workspace'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { TypebotInDashboard, useTypebots } from '@/features/dashboard'
|
||||
import { useFolders } from '../hooks/useFolders'
|
||||
import { patchTypebotQuery } from '../queries/patchTypebotQuery'
|
||||
import { createFolderQuery } from '../queries/createFolderQuery'
|
||||
import { CreateBotButton } from './CreateBotButton'
|
||||
import { CreateFolderButton } from './CreateFolderButton'
|
||||
import { ButtonSkeleton, FolderButton } from './FolderButton'
|
||||
import { TypebotButton } from './TypebotButton'
|
||||
import { TypebotCardOverlay } from './TypebotButtonOverlay'
|
||||
|
||||
type Props = { folder: DashboardFolder | null }
|
||||
|
||||
const dragDistanceTolerance = 20
|
||||
|
||||
export const FolderContent = ({ folder }: Props) => {
|
||||
const { user } = useUser()
|
||||
const { workspace, currentRole } = useWorkspace()
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
|
||||
const {
|
||||
setDraggedTypebot,
|
||||
draggedTypebot,
|
||||
mouseOverFolderId,
|
||||
setMouseOverFolderId,
|
||||
} = useTypebotDnd()
|
||||
const [mouseDownPosition, setMouseDownPosition] = useState({ x: 0, y: 0 })
|
||||
const [draggablePosition, setDraggablePosition] = useState({ x: 0, y: 0 })
|
||||
const [relativeDraggablePosition, setRelativeDraggablePosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
})
|
||||
const [typebotDragCandidate, setTypebotDragCandidate] =
|
||||
useState<TypebotInDashboard>()
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
const {
|
||||
folders,
|
||||
isLoading: isFolderLoading,
|
||||
mutate: mutateFolders,
|
||||
} = useFolders({
|
||||
workspaceId: workspace?.id,
|
||||
parentId: folder?.id,
|
||||
onError: (error) => {
|
||||
showToast({ title: "Couldn't fetch folders", description: error.message })
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
typebots,
|
||||
isLoading: isTypebotLoading,
|
||||
mutate: mutateTypebots,
|
||||
} = useTypebots({
|
||||
workspaceId: workspace?.id,
|
||||
folderId: folder?.id,
|
||||
onError: (error) => {
|
||||
showToast({
|
||||
title: "Couldn't fetch typebots",
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const moveTypebotToFolder = async (typebotId: string, folderId: string) => {
|
||||
if (!typebots) return
|
||||
const { error } = await patchTypebotQuery(typebotId, {
|
||||
folderId: folderId === 'root' ? null : folderId,
|
||||
})
|
||||
if (error) showToast({ description: error.message })
|
||||
mutateTypebots({ typebots: typebots.filter((t) => t.id !== typebotId) })
|
||||
}
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!folders || !workspace) return
|
||||
setIsCreatingFolder(true)
|
||||
const { error, data: newFolder } = await createFolderQuery(workspace.id, {
|
||||
parentFolderId: folder?.id ?? null,
|
||||
})
|
||||
setIsCreatingFolder(false)
|
||||
if (error)
|
||||
return showToast({
|
||||
title: 'An error occured',
|
||||
description: error.message,
|
||||
})
|
||||
if (newFolder) mutateFolders({ folders: [...folders, newFolder] })
|
||||
}
|
||||
|
||||
const handleTypebotDeleted = (deletedId: string) => {
|
||||
if (!typebots) return
|
||||
mutateTypebots({ typebots: typebots.filter((t) => t.id !== deletedId) })
|
||||
}
|
||||
|
||||
const handleFolderDeleted = (deletedId: string) => {
|
||||
if (!folders) return
|
||||
mutateFolders({ folders: folders.filter((f) => f.id !== deletedId) })
|
||||
}
|
||||
|
||||
const handleFolderRenamed = (folderId: string, name: string) => {
|
||||
if (!folders) return
|
||||
mutateFolders({
|
||||
folders: folders.map((f) => (f.id === folderId ? { ...f, name } : f)),
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseUp = async () => {
|
||||
if (mouseOverFolderId !== undefined && draggedTypebot)
|
||||
await moveTypebotToFolder(draggedTypebot.id, mouseOverFolderId ?? 'root')
|
||||
setTypebotDragCandidate(undefined)
|
||||
setMouseOverFolderId(undefined)
|
||||
setDraggedTypebot(undefined)
|
||||
}
|
||||
useEventListener('mouseup', handleMouseUp)
|
||||
|
||||
const handleMouseDown =
|
||||
(typebot: TypebotInDashboard) => (e: React.MouseEvent) => {
|
||||
const element = e.currentTarget as HTMLDivElement
|
||||
const rect = element.getBoundingClientRect()
|
||||
setDraggablePosition({ x: rect.left, y: rect.top })
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
setRelativeDraggablePosition({ x, y })
|
||||
setMouseDownPosition({ x: e.screenX, y: e.screenY })
|
||||
setTypebotDragCandidate(typebot)
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!typebotDragCandidate) return
|
||||
const { clientX, clientY, screenX, screenY } = e
|
||||
if (
|
||||
Math.abs(mouseDownPosition.x - screenX) > dragDistanceTolerance ||
|
||||
Math.abs(mouseDownPosition.y - screenY) > dragDistanceTolerance
|
||||
)
|
||||
setDraggedTypebot(typebotDragCandidate)
|
||||
setDraggablePosition({
|
||||
...draggablePosition,
|
||||
x: clientX - relativeDraggablePosition.x,
|
||||
y: clientY - relativeDraggablePosition.y,
|
||||
})
|
||||
}
|
||||
useEventListener('mousemove', handleMouseMove)
|
||||
|
||||
return (
|
||||
<Flex w="full" flex="1" justify="center">
|
||||
{typebots &&
|
||||
!isTypebotLoading &&
|
||||
user &&
|
||||
folder === null &&
|
||||
env('E2E_TEST') !== 'true' && (
|
||||
<OnboardingModal totalTypebots={typebots.length} />
|
||||
)}
|
||||
<Stack w="1000px" spacing={6}>
|
||||
<Skeleton isLoaded={folder?.name !== undefined}>
|
||||
<Heading as="h1">{folder?.name}</Heading>
|
||||
</Skeleton>
|
||||
<Stack>
|
||||
<HStack>
|
||||
{folder && <BackButton id={folder.parentFolderId} />}
|
||||
{currentRole !== WorkspaceRole.GUEST && (
|
||||
<CreateFolderButton
|
||||
onClick={handleCreateFolder}
|
||||
isLoading={isCreatingFolder || isFolderLoading}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
<Wrap spacing={4}>
|
||||
{currentRole !== WorkspaceRole.GUEST && (
|
||||
<CreateBotButton
|
||||
folderId={folder?.id}
|
||||
isLoading={isTypebotLoading}
|
||||
isFirstBot={typebots?.length === 0 && folder === null}
|
||||
/>
|
||||
)}
|
||||
{isFolderLoading && <ButtonSkeleton />}
|
||||
{folders &&
|
||||
folders.map((folder) => (
|
||||
<FolderButton
|
||||
key={folder.id.toString()}
|
||||
folder={folder}
|
||||
onFolderDeleted={() => handleFolderDeleted(folder.id)}
|
||||
onFolderRenamed={(newName: string) =>
|
||||
handleFolderRenamed(folder.id, newName)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{isTypebotLoading && <ButtonSkeleton />}
|
||||
{typebots &&
|
||||
typebots.map((typebot) => (
|
||||
<TypebotButton
|
||||
key={typebot.id.toString()}
|
||||
typebot={typebot}
|
||||
onTypebotDeleted={() => handleTypebotDeleted(typebot.id)}
|
||||
onMouseDown={handleMouseDown(typebot)}
|
||||
/>
|
||||
))}
|
||||
</Wrap>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{draggedTypebot && (
|
||||
<Portal>
|
||||
<TypebotCardOverlay
|
||||
typebot={draggedTypebot}
|
||||
onMouseUp={handleMouseUp}
|
||||
pos="fixed"
|
||||
top="0"
|
||||
left="0"
|
||||
style={{
|
||||
transform: `translate(${draggablePosition.x}px, ${draggablePosition.y}px) rotate(-2deg)`,
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
40
apps/builder/src/features/folders/components/FolderPage.tsx
Normal file
40
apps/builder/src/features/folders/components/FolderPage.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { Seo } from '@/components/Seo'
|
||||
import { DashboardHeader } from '@/features/dashboard'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { Stack, Flex, Spinner } from '@chakra-ui/react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useFolder } from '../hooks/useFolder'
|
||||
import { TypebotDndProvider } from '../TypebotDndProvider'
|
||||
import { FolderContent } from './FolderContent'
|
||||
|
||||
export const FolderPage = () => {
|
||||
const router = useRouter()
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
const { folder } = useFolder({
|
||||
folderId: router.query.id?.toString(),
|
||||
onError: (error) => {
|
||||
showToast({
|
||||
title: "Couldn't fetch folder content",
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack minH="100vh">
|
||||
<Seo title="My typebots" />
|
||||
<DashboardHeader />
|
||||
<TypebotDndProvider>
|
||||
{!folder ? (
|
||||
<Flex flex="1">
|
||||
<Spinner mx="auto" />
|
||||
</Flex>
|
||||
) : (
|
||||
<FolderContent folder={folder} />
|
||||
)}
|
||||
</TypebotDndProvider>
|
||||
</Stack>
|
||||
)
|
||||
}
|
29
apps/builder/src/features/folders/components/MoreButton.tsx
Normal file
29
apps/builder/src/features/folders/components/MoreButton.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import {
|
||||
ButtonProps,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
} from '@chakra-ui/react'
|
||||
import { MoreVerticalIcon } from '@/components/icons'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
type Props = { children: ReactNode } & ButtonProps
|
||||
|
||||
export const MoreButton = ({ children, ...props }: Props) => {
|
||||
return (
|
||||
<Menu isLazy>
|
||||
<MenuButton
|
||||
data-testid="more-button"
|
||||
as={IconButton}
|
||||
icon={<MoreVerticalIcon />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
colorScheme="gray"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
<MenuList>{children}</MenuList>
|
||||
</Menu>
|
||||
)
|
||||
}
|
183
apps/builder/src/features/folders/components/TypebotButton.tsx
Normal file
183
apps/builder/src/features/folders/components/TypebotButton.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Tag,
|
||||
Text,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
WrapItem,
|
||||
} from '@chakra-ui/react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { ConfirmModal } from '@/components/ConfirmModal'
|
||||
import { GripIcon } from '@/components/icons'
|
||||
import { Typebot } from 'models'
|
||||
import { useTypebotDnd } from '../TypebotDndProvider'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { Plan } from 'db'
|
||||
import { useWorkspace } from '@/features/workspace'
|
||||
import { useToast } from '@/hooks/useToast'
|
||||
import { isMobile } from '@/utils/helpers'
|
||||
import {
|
||||
getTypebotQuery,
|
||||
deleteTypebotQuery,
|
||||
importTypebotQuery,
|
||||
} from '@/features/dashboard'
|
||||
import { MoreButton } from './MoreButton'
|
||||
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
|
||||
|
||||
type ChatbotCardProps = {
|
||||
typebot: Pick<Typebot, 'id' | 'publishedTypebotId' | 'name' | 'icon'>
|
||||
isReadOnly?: boolean
|
||||
onTypebotDeleted?: () => void
|
||||
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
export const TypebotButton = ({
|
||||
typebot,
|
||||
onTypebotDeleted,
|
||||
isReadOnly = false,
|
||||
onMouseDown,
|
||||
}: ChatbotCardProps) => {
|
||||
const router = useRouter()
|
||||
const { workspace } = useWorkspace()
|
||||
const { draggedTypebot } = useTypebotDnd()
|
||||
const [draggedTypebotDebounced] = useDebounce(draggedTypebot, 200)
|
||||
const {
|
||||
isOpen: isDeleteOpen,
|
||||
onOpen: onDeleteOpen,
|
||||
onClose: onDeleteClose,
|
||||
} = useDisclosure()
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
const handleTypebotClick = () => {
|
||||
if (draggedTypebotDebounced) return
|
||||
router.push(
|
||||
isMobile
|
||||
? `/typebots/${typebot.id}/results`
|
||||
: `/typebots/${typebot.id}/edit`
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeleteTypebotClick = async () => {
|
||||
if (isReadOnly) return
|
||||
const { error } = await deleteTypebotQuery(typebot.id)
|
||||
if (error)
|
||||
return showToast({
|
||||
title: "Couldn't delete typebot",
|
||||
description: error.message,
|
||||
})
|
||||
if (onTypebotDeleted) onTypebotDeleted()
|
||||
}
|
||||
|
||||
const handleDuplicateClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const { data } = await getTypebotQuery(typebot.id)
|
||||
const typebotToDuplicate = data?.typebot
|
||||
if (!typebotToDuplicate) return { error: new Error('Typebot not found') }
|
||||
const { data: createdTypebot, error } = await importTypebotQuery(
|
||||
data.typebot,
|
||||
workspace?.plan ?? Plan.FREE
|
||||
)
|
||||
if (error)
|
||||
return showToast({
|
||||
title: "Couldn't duplicate typebot",
|
||||
description: error.message,
|
||||
})
|
||||
if (createdTypebot) router.push(`/typebots/${createdTypebot?.id}/edit`)
|
||||
}
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDeleteOpen()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
as={WrapItem}
|
||||
onClick={handleTypebotClick}
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
variant="outline"
|
||||
color="gray.800"
|
||||
w="225px"
|
||||
h="270px"
|
||||
mr={{ sm: 6 }}
|
||||
mb={6}
|
||||
rounded="lg"
|
||||
whiteSpace="normal"
|
||||
opacity={draggedTypebot?.id === typebot.id ? 0.2 : 1}
|
||||
onMouseDown={onMouseDown}
|
||||
cursor="pointer"
|
||||
>
|
||||
{typebot.publishedTypebotId && (
|
||||
<Tag
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
rounded="full"
|
||||
pos="absolute"
|
||||
top="27px"
|
||||
size="sm"
|
||||
>
|
||||
Live
|
||||
</Tag>
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<IconButton
|
||||
icon={<GripIcon />}
|
||||
pos="absolute"
|
||||
top="20px"
|
||||
left="20px"
|
||||
aria-label="Drag"
|
||||
cursor="grab"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
/>
|
||||
<MoreButton
|
||||
pos="absolute"
|
||||
top="20px"
|
||||
right="20px"
|
||||
aria-label={`Show ${typebot.name} menu`}
|
||||
>
|
||||
<MenuItem onClick={handleDuplicateClick}>Duplicate</MenuItem>
|
||||
<MenuItem color="red" onClick={handleDeleteClick}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MoreButton>
|
||||
</>
|
||||
)}
|
||||
<VStack spacing="4">
|
||||
<Flex
|
||||
rounded="full"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
fontSize={'4xl'}
|
||||
>
|
||||
{<EmojiOrImageIcon icon={typebot.icon} boxSize={'35px'} />}
|
||||
</Flex>
|
||||
<Text textAlign="center">{typebot.name}</Text>
|
||||
</VStack>
|
||||
{!isReadOnly && (
|
||||
<ConfirmModal
|
||||
message={
|
||||
<Text>
|
||||
Are you sure you want to delete your Typebot "{typebot.name}
|
||||
".
|
||||
<br />
|
||||
All associated data will be lost.
|
||||
</Text>
|
||||
}
|
||||
confirmButtonLabel="Delete"
|
||||
onConfirm={handleDeleteTypebotClick}
|
||||
isOpen={isDeleteOpen}
|
||||
onClose={onDeleteClose}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
import { Box, BoxProps, Flex, Text, VStack } from '@chakra-ui/react'
|
||||
import { GlobeIcon, ToolIcon } from '@/components/icons'
|
||||
import { TypebotInDashboard } from '@/features/dashboard'
|
||||
|
||||
type Props = {
|
||||
typebot: TypebotInDashboard
|
||||
} & BoxProps
|
||||
|
||||
export const TypebotCardOverlay = ({ typebot, ...props }: Props) => {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
variant="outline"
|
||||
justifyContent="center"
|
||||
w="225px"
|
||||
h="270px"
|
||||
whiteSpace="normal"
|
||||
transition="none"
|
||||
pointerEvents="none"
|
||||
borderWidth={1}
|
||||
rounded="md"
|
||||
bgColor="white"
|
||||
shadow="lg"
|
||||
opacity={0.7}
|
||||
{...props}
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Flex
|
||||
boxSize="45px"
|
||||
rounded="full"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
bgColor={typebot.publishedTypebotId ? 'blue.500' : 'gray.400'}
|
||||
color="white"
|
||||
>
|
||||
{typebot.publishedTypebotId ? (
|
||||
<GlobeIcon fill="white" fontSize="20px" />
|
||||
) : (
|
||||
<ToolIcon fill="white" fontSize="20px" />
|
||||
)}
|
||||
</Flex>
|
||||
<Text>{typebot.name}</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)
|
||||
}
|
22
apps/builder/src/features/folders/hooks/useFolder.ts
Normal file
22
apps/builder/src/features/folders/hooks/useFolder.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { fetcher } from '@/utils/helpers'
|
||||
import { DashboardFolder } from 'db'
|
||||
import useSWR from 'swr'
|
||||
|
||||
export const useFolder = ({
|
||||
folderId,
|
||||
onError,
|
||||
}: {
|
||||
folderId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const { data, error, mutate } = useSWR<{ folder: DashboardFolder }, Error>(
|
||||
`/api/folders/${folderId}`,
|
||||
fetcher
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
folder: data?.folder,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
30
apps/builder/src/features/folders/hooks/useFolders.ts
Normal file
30
apps/builder/src/features/folders/hooks/useFolders.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { fetcher } from '@/utils/helpers'
|
||||
import { DashboardFolder } from 'db'
|
||||
import { stringify } from 'qs'
|
||||
import useSWR from 'swr'
|
||||
import { env } from 'utils'
|
||||
|
||||
export const useFolders = ({
|
||||
parentId,
|
||||
workspaceId,
|
||||
onError,
|
||||
}: {
|
||||
workspaceId?: string
|
||||
parentId?: string
|
||||
onError: (error: Error) => void
|
||||
}) => {
|
||||
const params = stringify({ parentId, workspaceId })
|
||||
const { data, error, mutate } = useSWR<{ folders: DashboardFolder[] }, Error>(
|
||||
workspaceId ? `/api/folders?${params}` : null,
|
||||
fetcher,
|
||||
{
|
||||
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
|
||||
}
|
||||
)
|
||||
if (error) onError(error)
|
||||
return {
|
||||
folders: data?.folders,
|
||||
isLoading: !error && !data,
|
||||
mutate,
|
||||
}
|
||||
}
|
3
apps/builder/src/features/folders/index.ts
Normal file
3
apps/builder/src/features/folders/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { FolderContent } from './components/FolderContent'
|
||||
export { TypebotDndProvider } from './TypebotDndProvider'
|
||||
export { FolderPage } from './components/FolderPage'
|
@ -0,0 +1,12 @@
|
||||
import { DashboardFolder } from 'db'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const createFolderQuery = async (
|
||||
workspaceId: string,
|
||||
folder: Pick<DashboardFolder, 'parentFolderId'>
|
||||
) =>
|
||||
sendRequest<DashboardFolder>({
|
||||
url: `/api/folders`,
|
||||
method: 'POST',
|
||||
body: { ...folder, workspaceId },
|
||||
})
|
@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const deleteFolderQuery = async (id: string) =>
|
||||
sendRequest({
|
||||
url: `/api/folders/${id}`,
|
||||
method: 'DELETE',
|
||||
})
|
@ -0,0 +1,12 @@
|
||||
import { Typebot } from 'db'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const patchTypebotQuery = async (
|
||||
id: string,
|
||||
typebot: Partial<Typebot>
|
||||
) =>
|
||||
sendRequest({
|
||||
url: `/api/typebots/${id}`,
|
||||
method: 'PATCH',
|
||||
body: typebot,
|
||||
})
|
@ -0,0 +1,12 @@
|
||||
import { DashboardFolder } from 'db'
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const updateFolderQuery = async (
|
||||
id: string,
|
||||
folder: Partial<DashboardFolder>
|
||||
) =>
|
||||
sendRequest({
|
||||
url: `/api/folders/${id}`,
|
||||
method: 'PATCH',
|
||||
body: folder,
|
||||
})
|
Reference in New Issue
Block a user