2
0

Add Dashboard

This commit is contained in:
Baptiste Arnaud
2021-12-06 15:48:50 +01:00
parent 5e14a94dea
commit 54a641b819
47 changed files with 2002 additions and 168 deletions

View File

@ -0,0 +1,74 @@
import React from 'react'
import {
Menu,
MenuButton,
MenuList,
MenuItem,
Text,
HStack,
Flex,
Avatar,
SkeletonCircle,
Skeleton,
} from '@chakra-ui/react'
import { TypebotLogo } from 'assets/logos'
import { useUser } from 'services/user'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { LogOutIcon, SettingsIcon } from 'assets/icons'
import { signOut } from 'next-auth/react'
export const DashboardHeader = () => {
const user = useUser()
const handleLogOut = () => {
signOut()
}
return (
<Flex w="full" borderBottomWidth="1px" justify="center">
<Flex
justify="space-between"
alignItems="center"
h="16"
maxW="1000px"
flex="1"
>
<NextChakraLink
className="w-24"
href="/typebots"
data-testid="authenticated"
>
<TypebotLogo w="30px" />
</NextChakraLink>
<Menu>
<MenuButton>
<HStack>
<Skeleton isLoaded={user !== undefined}>
<Text>{user?.name}</Text>
</Skeleton>
<SkeletonCircle isLoaded={user !== undefined}>
<Avatar
boxSize="35px"
name={user?.name ?? undefined}
src={user?.image ?? undefined}
/>
</SkeletonCircle>
</HStack>
</MenuButton>
<MenuList>
<MenuItem
as={NextChakraLink}
href="/account"
icon={<SettingsIcon />}
>
My account
</MenuItem>
<MenuItem onClick={handleLogOut} icon={<LogOutIcon />}>
Log out
</MenuItem>
</MenuList>
</Menu>
</Flex>
</Flex>
)
}

View File

@ -0,0 +1,181 @@
import { DashboardFolder, Typebot } from '.prisma/client'
import {
Button,
Flex,
Heading,
HStack,
Skeleton,
Stack,
useToast,
Wrap,
} from '@chakra-ui/react'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
MouseSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import { FolderPlusIcon } from 'assets/icons'
import React, { useState } from 'react'
import { createFolder, useFolders } from 'services/folders'
import { updateTypebot, useTypebots } from 'services/typebots'
import { BackButton } from './FolderContent/BackButton'
import { CreateBotButton } from './FolderContent/CreateBotButton'
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
import { TypebotButton } from './FolderContent/TypebotButton'
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
type Props = { folder: DashboardFolder | null }
export const FolderContent = ({ folder }: Props) => {
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
const [draggedTypebot, setDraggedTypebot] = useState<Typebot | undefined>()
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
delay: 100,
tolerance: 300,
},
})
)
const toast = useToast({
position: 'top-right',
status: 'error',
})
const {
folders,
isLoading: isFolderLoading,
mutate: mutateFolders,
} = useFolders({
parentId: folder?.id,
onError: (error) => {
toast({ title: "Couldn't fetch folders", description: error.message })
},
})
const {
typebots,
isLoading: isTypebotLoading,
mutate: mutateTypebots,
} = useTypebots({
folderId: folder?.id,
onError: (error) => {
toast({ title: "Couldn't fetch typebots", description: error.message })
},
})
const handleDragStart = (event: DragStartEvent) => {
if (!typebots) return
setDraggedTypebot(typebots.find((c) => c.id === event.active.id))
}
const handleDragEnd = async (event: DragEndEvent) => {
if (!typebots) return
const { over } = event
if (over?.id && draggedTypebot?.id)
await moveTypebotToFolder(draggedTypebot.id, over.id)
setDraggedTypebot(undefined)
}
const moveTypebotToFolder = async (typebotId: string, folderId: string) => {
if (!typebots) return
const { error } = await updateTypebot(typebotId, {
folderId: folderId === 'root' ? null : folderId,
})
if (error) toast({ description: error.message })
mutateTypebots({ typebots: typebots.filter((t) => t.id !== typebotId) })
}
const handleCreateFolder = async () => {
if (!folders) return
setIsCreatingFolder(true)
const { error, data: newFolder } = await createFolder({
parentFolderId: folder?.id ?? null,
})
setIsCreatingFolder(false)
if (error)
return toast({ 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)),
})
}
return (
<Flex w="full" justify="center" align="center" pt={4}>
<DndContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
sensors={sensors}
>
<Stack w="1000px" spacing={6}>
<Skeleton isLoaded={folder?.name !== undefined}>
<Heading as="h1">{folder?.name}</Heading>
</Skeleton>
<Stack>
<HStack>
{folder && <BackButton id={folder.parentFolderId} />}
<Button
colorScheme="gray"
leftIcon={<FolderPlusIcon />}
onClick={handleCreateFolder}
isLoading={isCreatingFolder || isFolderLoading}
>
Create a folder
</Button>
</HStack>
<Wrap spacing={4}>
<CreateBotButton
folderId={folder?.id}
isLoading={isTypebotLoading}
/>
{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)}
/>
))}
<DragOverlay dropAnimation={null}>
{draggedTypebot && (
<TypebotCardOverlay typebot={draggedTypebot} />
)}
</DragOverlay>
</Wrap>
</Stack>
</Stack>
</DndContext>
</Flex>
)
}

View File

@ -0,0 +1,24 @@
import { Button } from '@chakra-ui/react'
import { useDroppable } from '@dnd-kit/core'
import { ChevronLeftIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import React from 'react'
export const BackButton = ({ id }: { id: string | null }) => {
const { setNodeRef, isOver } = useDroppable({
id: id?.toString() ?? 'root',
})
return (
<Button
as={NextChakraLink}
href={id ? `/typebots/folders/${id}` : '/typebots'}
leftIcon={<ChevronLeftIcon />}
variant={'outline'}
colorScheme={isOver ? 'blue' : 'gray'}
borderWidth={isOver ? '3px' : '1px'}
ref={setNodeRef}
>
Back
</Button>
)
}

View File

@ -0,0 +1,41 @@
import { Button, ButtonProps, Text, VStack } from '@chakra-ui/react'
import { PlusIcon } from 'assets/icons'
import { useRouter } from 'next/router'
import React from 'react'
export const CreateBotButton = ({
folderId,
...props
}: { folderId?: string } & ButtonProps) => {
const router = useRouter()
const handleClick = () =>
folderId
? router.push(`/typebots/create?folderId=${folderId}`)
: router.push('/typebots/create')
return (
<Button
mr={{ sm: 6 }}
mb={6}
style={{ width: '225px', height: '270px' }}
onClick={handleClick}
paddingX={6}
whiteSpace={'normal'}
{...props}
>
<VStack spacing="6">
<PlusIcon fontSize="40px" />
<Text
fontSize={18}
fontWeight="medium"
maxW={40}
textAlign="center"
mt="6"
>
Create a typebot
</Text>
</VStack>
</Button>
)
}

View File

@ -0,0 +1,158 @@
import { DashboardFolder } from '.prisma/client'
import {
Button,
Editable,
EditableInput,
EditablePreview,
MenuItem,
useDisclosure,
Text,
VStack,
IconButton,
Menu,
MenuButton,
MenuList,
useToast,
SkeletonText,
SkeletonCircle,
WrapItem,
} from '@chakra-ui/react'
import { useDroppable } from '@dnd-kit/core'
import { FolderIcon, MoreVerticalIcon } from 'assets/icons'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { useRouter } from 'next/router'
import React from 'react'
import { deleteFolder, updateFolder } from 'services/folders'
export const FolderButton = ({
folder,
onFolderDeleted,
onFolderRenamed,
}: {
folder: DashboardFolder
onFolderDeleted: () => void
onFolderRenamed: (newName: string) => void
}) => {
const router = useRouter()
const { setNodeRef, isOver } = useDroppable({
id: folder.id.toString(),
})
const { isOpen, onOpen, onClose } = useDisclosure()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const onDeleteClick = async () => {
const { error } = await deleteFolder(folder.id)
return error
? toast({
title: "Couldn't delete the folder",
description: error.message,
})
: onFolderDeleted()
}
const onRenameSubmit = async (newName: string) => {
if (newName === '' || newName === folder.name) return
const { error } = await updateFolder(folder.id, { name: newName })
return error
? toast({ title: 'An error occured', description: error.message })
: onFolderRenamed(newName)
}
const handleClick = () => {
router.push(`/typebots/folders/${folder.id}`)
}
return (
<Button
as={WrapItem}
ref={setNodeRef}
style={{ width: '225px', height: '270px' }}
paddingX={6}
whiteSpace={'normal'}
pos="relative"
cursor="pointer"
variant="outline"
colorScheme={isOver ? 'blue' : 'gray'}
borderWidth={isOver ? '3px' : '1px'}
justifyContent="center"
onClick={handleClick}
data-testid="folder-button"
>
<Menu>
<MenuButton
as={IconButton}
icon={<MoreVerticalIcon />}
aria-label="Show folder menu"
onClick={(e) => e.stopPropagation()}
colorScheme="blue"
variant="ghost"
size="lg"
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" />
<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"
colorScheme={'gray'}
>
<VStack spacing="6" w="full">
<SkeletonCircle boxSize="45px" />
<SkeletonText noOfLines={2} w="full" />
</VStack>
</Button>
)

View File

@ -0,0 +1,133 @@
import React from 'react'
import {
Button,
Flex,
MenuItem,
Text,
useDisclosure,
useToast,
VStack,
WrapItem,
} from '@chakra-ui/react'
import { useDraggable } from '@dnd-kit/core'
import { useRouter } from 'next/router'
import { Typebot } from '@typebot/prisma'
import { isMobile } from 'services/utils'
import { MoreButton } from 'components/MoreButton'
import { ConfirmModal } from 'components/modals/ConfirmModal'
import { GlobeIcon, ToolIcon } from 'assets/icons'
import { deleteTypebot, duplicateTypebot } from 'services/typebots'
type ChatbotCardProps = {
typebot: Typebot
onTypebotDeleted: () => void
}
export const TypebotButton = ({
typebot,
onTypebotDeleted,
}: ChatbotCardProps) => {
const router = useRouter()
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: typebot.id.toString(),
})
const {
isOpen: isDeleteOpen,
onOpen: onDeleteOpen,
onClose: onDeleteClose,
} = useDisclosure()
const toast = useToast({
position: 'top-right',
status: 'error',
})
const handleTypebotClick = () => {
router.push(
isMobile
? `/typebots/${typebot.id}/results/responses`
: `/typebots/${typebot.id}/edit`
)
}
const handleDeleteTypebotClick = async () => {
const { error } = await deleteTypebot(typebot.id)
if (error)
return toast({
title: "Couldn't delete typebot",
description: error.message,
})
onTypebotDeleted()
}
const handleDuplicateClick = async () => {
const { data: createdTypebot, error } = await duplicateTypebot(typebot)
if (error)
return toast({
title: "Couldn't duplicate typebot",
description: error.message,
})
if (createdTypebot) router.push(`/typebots/${createdTypebot?.id}`)
}
return (
<Button
as={WrapItem}
onClick={handleTypebotClick}
display="flex"
flexDir="column"
variant="outline"
colorScheme="gray"
color="gray.800"
w="225px"
h="270px"
mr={{ sm: 6 }}
mb={6}
rounded="lg"
whiteSpace="normal"
data-testid="typebot-button"
opacity={isDragging ? 0.2 : 1}
ref={setNodeRef}
{...listeners}
{...attributes}
>
<MoreButton pos="absolute" top="10px" right="10px">
<MenuItem onClick={handleDuplicateClick}>Duplicate</MenuItem>
<MenuItem color="red" onClick={onDeleteOpen}>
Delete
</MenuItem>
</MoreButton>
<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>
<ConfirmModal
message={
<Text>
Are you sure you want to delete your Typebot &quot;{typebot.name}
&quot;.
<br />
All associated data will be lost.
</Text>
}
confirmButtonLabel="Delete"
onConfirm={handleDeleteTypebotClick}
isOpen={isDeleteOpen}
onClose={onDeleteClose}
/>
</Button>
)
}

View File

@ -0,0 +1,44 @@
import { Button, Flex, Text, VStack } from '@chakra-ui/react'
import { Typebot } from '.prisma/client'
import { GlobeIcon, ToolIcon } from 'assets/icons'
type Props = {
typebot: Typebot
}
export const TypebotCardOverlay = ({ typebot }: Props) => {
return (
<div
className="sm:mr-6 mb-6 focus:outline-none rounded-lg "
style={{ width: '225px', height: '270px' }}
>
<Button
display="flex"
flexDir="column"
variant="outline"
colorScheme="gray"
w="full"
h="full"
whiteSpace="normal"
>
<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>
</Button>
</div>
)
}