2
0

feat: Add collaboration

This commit is contained in:
Baptiste Arnaud
2022-02-24 11:13:19 +01:00
parent dd671a5d2c
commit b9dafa611e
63 changed files with 1932 additions and 148 deletions

View File

@ -10,7 +10,7 @@ export const SubscriptionTag = ({ plan }: { plan?: Plan }) => {
return <Tag colorScheme="yellow">Lifetime plan</Tag>
}
case Plan.OFFERED: {
return <Tag>Offered</Tag>
return <Tag colorScheme="yellow">Offered</Tag>
}
case Plan.PRO: {
return <Tag colorScheme="orange">Pro plan</Tag>

View File

@ -12,6 +12,7 @@ import {
Wrap,
} from '@chakra-ui/react'
import { useTypebotDnd } from 'contexts/TypebotDndContext'
import { useUser } from 'contexts/UserContext'
import React, { useEffect, useState } from 'react'
import { createFolder, useFolders } from 'services/folders'
import {
@ -19,11 +20,13 @@ import {
TypebotInDashboard,
useTypebots,
} from 'services/typebots'
import { useSharedTypebotsCount } from 'services/user/sharedTypebots'
import { AnnoucementModal } from './annoucements/AnnoucementModal'
import { BackButton } from './FolderContent/BackButton'
import { CreateBotButton } from './FolderContent/CreateBotButton'
import { CreateFolderButton } from './FolderContent/CreateFolderButton'
import { ButtonSkeleton, FolderButton } from './FolderContent/FolderButton'
import { SharedTypebotsButton } from './FolderContent/SharedTypebotsButton'
import { TypebotButton } from './FolderContent/TypebotButton'
import { TypebotCardOverlay } from './FolderContent/TypebotButtonOverlay'
@ -32,6 +35,7 @@ type Props = { folder: DashboardFolder | null }
const dragDistanceTolerance = 20
export const FolderContent = ({ folder }: Props) => {
const { user } = useUser()
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
const {
setDraggedTypebot,
@ -75,6 +79,17 @@ export const FolderContent = ({ folder }: Props) => {
},
})
const { totalSharedTypebots, isLoading: isSharedTypebotsCountLoading } =
useSharedTypebotsCount({
userId: folder === null ? user?.id : undefined,
onError: (error) => {
toast({
title: "Couldn't fetch shared typebots",
description: error.message,
})
},
})
useEffect(() => {
if (
typebots &&
@ -182,6 +197,8 @@ export const FolderContent = ({ folder }: Props) => {
folderId={folder?.id}
isLoading={isTypebotLoading}
/>
{isSharedTypebotsCountLoading && <ButtonSkeleton />}
{totalSharedTypebots > 0 && <SharedTypebotsButton />}
{isFolderLoading && <ButtonSkeleton />}
{folders &&
folders.map((folder) => (

View File

@ -0,0 +1,40 @@
import React from 'react'
import { Button, Flex, Text, VStack, WrapItem } from '@chakra-ui/react'
import { useRouter } from 'next/router'
import { UsersIcon } from 'assets/icons'
export const SharedTypebotsButton = () => {
const router = useRouter()
const handleTypebotClick = () => router.push(`/typebots/shared`)
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"
cursor="pointer"
>
<VStack spacing="4">
<Flex
boxSize="45px"
rounded="full"
justifyContent="center"
alignItems="center"
>
<UsersIcon fontSize="50" color="orange.300" />
</Flex>
<Text>Shared with me</Text>
</VStack>
</Button>
)
}

View File

@ -22,13 +22,15 @@ import { useDebounce } from 'use-debounce'
type ChatbotCardProps = {
typebot: Pick<Typebot, 'id' | 'publishedTypebotId' | 'name'>
onTypebotDeleted: () => void
onMouseDown: (e: React.MouseEvent<HTMLButtonElement>) => void
isReadOnly?: boolean
onTypebotDeleted?: () => void
onMouseDown?: (e: React.MouseEvent<HTMLButtonElement>) => void
}
export const TypebotButton = ({
typebot,
onTypebotDeleted,
isReadOnly = false,
onMouseDown,
}: ChatbotCardProps) => {
const router = useRouter()
@ -55,13 +57,14 @@ export const TypebotButton = ({
}
const handleDeleteTypebotClick = async () => {
if (isReadOnly) return
const { error } = await deleteTypebot(typebot.id)
if (error)
return toast({
title: "Couldn't delete typebot",
description: error.message,
})
onTypebotDeleted()
if (onTypebotDeleted) onTypebotDeleted()
}
const handleDuplicateClick = async (e: React.MouseEvent) => {
@ -98,28 +101,32 @@ export const TypebotButton = ({
onMouseDown={onMouseDown}
cursor="pointer"
>
<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>
{!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
boxSize="45px"
@ -137,20 +144,22 @@ export const TypebotButton = ({
</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}
/>
{!isReadOnly && (
<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

@ -17,7 +17,7 @@ import {
Tooltip,
} from '@chakra-ui/react'
import { useEffect, useRef, useState } from 'react'
import { createCustomDomain } from 'services/customDomains'
import { createCustomDomain } from 'services/user'
const hostnameRegex =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/

View File

@ -15,7 +15,7 @@ import { ChevronLeftIcon, PlusIcon, TrashIcon } from 'assets/icons'
import React, { useState } from 'react'
import { useUser } from 'contexts/UserContext'
import { CustomDomainModal } from './CustomDomainModal'
import { deleteCustomDomain, useCustomDomains } from 'services/customDomains'
import { deleteCustomDomain, useCustomDomains } from 'services/user'
type Props = Omit<MenuButtonProps, 'type'> & {
currentCustomDomain?: string

View File

@ -15,7 +15,7 @@ import React, { useEffect, useMemo, useState } from 'react'
import { useUser } from 'contexts/UserContext'
import { useRouter } from 'next/router'
import { CredentialsType } from 'models'
import { deleteCredentials, useCredentials } from 'services/credentials'
import { deleteCredentials, useCredentials } from 'services/user'
type Props = Omit<MenuButtonProps, 'type'> & {
type: CredentialsType

View File

@ -12,7 +12,7 @@ import {
import { useUser } from 'contexts/UserContext'
import { CredentialsType, SmtpCredentialsData } from 'models'
import React, { useState } from 'react'
import { createCredentials } from 'services/credentials'
import { createCredentials } from 'services/user'
import { testSmtpConfig } from 'services/integrations'
import { isNotDefined } from 'utils'
import { SmtpConfigForm } from './SmtpConfigForm'

View File

@ -0,0 +1,248 @@
import {
Stack,
HStack,
Input,
Button,
useToast,
Menu,
MenuButton,
MenuItem,
MenuList,
SkeletonCircle,
SkeletonText,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from 'assets/icons'
import { useTypebot } from 'contexts/TypebotContext'
import { useUser } from 'contexts/UserContext'
import { CollaborationType } from 'db'
import React, { FormEvent, useState } from 'react'
import {
deleteCollaborator,
updateCollaborator,
useCollaborators,
} from 'services/typebots/collaborators'
import {
useInvitations,
updateInvitation,
deleteInvitation,
sendInvitation,
} from 'services/typebots/invitations'
import {
CollaboratorIdentityContent,
CollaboratorItem,
} from './CollaboratorButton'
export const CollaborationList = () => {
const { user } = useUser()
const { typebot, owner } = useTypebot()
const [invitationType, setInvitationType] = useState<CollaborationType>(
CollaborationType.READ
)
const [invitationEmail, setInvitationEmail] = useState('')
const [isSendingInvitation, setIsSendingInvitation] = useState(false)
console.log(user, owner)
const isOwner = user?.email === owner?.email
const toast = useToast({
position: 'top-right',
status: 'error',
})
const {
collaborators,
isLoading: isCollaboratorsLoading,
mutate: mutateCollaborators,
} = useCollaborators({
typebotId: typebot?.id,
onError: (e) =>
toast({
title: "Couldn't fetch collaborators",
description: e.message,
}),
})
const {
invitations,
isLoading: isInvitationsLoading,
mutate: mutateInvitations,
} = useInvitations({
typebotId: typebot?.id,
onError: (e) =>
toast({ title: "Couldn't fetch collaborators", description: e.message }),
})
const handleChangeInvitationCollabType =
(email: string) => async (type: CollaborationType) => {
if (!typebot || !isOwner) return
const { error } = await updateInvitation(typebot?.id, email, { type })
if (error) return toast({ title: error.name, description: error.message })
mutateInvitations({
invitations: (invitations ?? []).map((i) =>
i.email === email ? { ...i, type } : i
),
})
}
const handleDeleteInvitation = (email: string) => async () => {
if (!typebot || !isOwner) return
const { error } = await deleteInvitation(typebot?.id, email)
if (error) return toast({ title: error.name, description: error.message })
mutateInvitations({
invitations: (invitations ?? []).filter((i) => i.email !== email),
})
}
const handleChangeCollaborationType =
(userId: string) => async (type: CollaborationType) => {
if (!typebot || !isOwner) return
const { error } = await updateCollaborator(typebot?.id, userId, { type })
if (error) return toast({ title: error.name, description: error.message })
mutateCollaborators({
collaborators: (collaborators ?? []).map((c) =>
c.userId === userId ? { ...c, type } : c
),
})
}
const handleDeleteCollaboration = (userId: string) => async () => {
if (!typebot || !isOwner) return
const { error } = await deleteCollaborator(typebot?.id, userId)
if (error) return toast({ title: error.name, description: error.message })
mutateCollaborators({
collaborators: (collaborators ?? []).filter((c) => c.userId !== userId),
})
}
const handleInvitationSubmit = async (e: FormEvent) => {
e.preventDefault()
if (!typebot || !isOwner) return
setIsSendingInvitation(true)
const { error } = await sendInvitation(typebot.id, {
email: invitationEmail,
type: invitationType,
})
setIsSendingInvitation(false)
mutateInvitations({ invitations: invitations ?? [] })
mutateCollaborators({ collaborators: collaborators ?? [] })
if (error) return toast({ title: error.name, description: error.message })
toast({ status: 'success', title: 'Invitation sent! 📧' })
setInvitationEmail('')
}
const hasNobody =
(collaborators ?? []).length > 0 ||
((invitations ?? []).length > 0 &&
!isInvitationsLoading &&
!isCollaboratorsLoading)
return (
<Stack spacing={2}>
{isOwner && (
<HStack
as="form"
onSubmit={handleInvitationSubmit}
pt="4"
px="4"
pb={hasNobody ? '0' : '4'}
>
<Input
size="sm"
placeholder="colleague@company.com"
name="inviteEmail"
value={invitationEmail}
onChange={(e) => setInvitationEmail(e.target.value)}
rounded="md"
/>
<CollaborationTypeMenuButton
type={invitationType}
onChange={setInvitationType}
/>
<Button
size="sm"
colorScheme="blue"
isLoading={isSendingInvitation}
flexShrink="0"
type="submit"
>
Invite
</Button>
</HStack>
)}
{owner && (collaborators ?? []).length > 0 && (
<CollaboratorIdentityContent
email={owner.email ?? ''}
name={owner.name ?? undefined}
image={owner.image ?? undefined}
tag="Owner"
/>
)}
{invitations?.map(({ email, type }) => (
<CollaboratorItem
key={email}
email={email}
type={type}
isOwner={isOwner}
onDeleteClick={handleDeleteInvitation(email)}
onChangeCollaborationType={handleChangeInvitationCollabType(email)}
isGuest
/>
))}
{collaborators?.map(({ user, type, userId }) => (
<CollaboratorItem
key={userId}
email={user.email ?? ''}
image={user.image ?? undefined}
name={user.name ?? undefined}
type={type}
isOwner={isOwner}
onDeleteClick={handleDeleteCollaboration(user.email ?? '')}
onChangeCollaborationType={handleChangeCollaborationType(userId)}
/>
))}
{(isCollaboratorsLoading || isInvitationsLoading) && (
<HStack p="4">
<SkeletonCircle boxSize="32px" />
<SkeletonText width="200px" noOfLines={2} />
</HStack>
)}
</Stack>
)
}
const CollaborationTypeMenuButton = ({
type,
onChange,
}: {
type: CollaborationType
onChange: (type: CollaborationType) => void
}) => {
return (
<Menu placement="bottom-end">
<MenuButton
flexShrink={0}
size="sm"
as={Button}
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
>
{convertCollaborationTypeEnumToReadable(type)}
</MenuButton>
<MenuList minW={0}>
<MenuItem onClick={() => onChange(CollaborationType.READ)}>
{convertCollaborationTypeEnumToReadable(CollaborationType.READ)}
</MenuItem>
<MenuItem onClick={() => onChange(CollaborationType.WRITE)}>
{convertCollaborationTypeEnumToReadable(CollaborationType.WRITE)}
</MenuItem>
</MenuList>
</Menu>
)
}
export const convertCollaborationTypeEnumToReadable = (
type: CollaborationType
) => {
switch (type) {
case CollaborationType.READ:
return 'Can view'
case CollaborationType.WRITE:
return 'Can edit'
}
}

View File

@ -0,0 +1,30 @@
import {
Popover,
PopoverTrigger,
PopoverContent,
IconButton,
Tooltip,
} from '@chakra-ui/react'
import { UsersIcon } from 'assets/icons'
import React from 'react'
import { CollaborationList } from './CollaborationList'
export const CollaborationMenuButton = () => {
return (
<Popover isLazy placement="bottom-end">
<PopoverTrigger>
<span>
<Tooltip label="Invite users to collaborate">
<IconButton
icon={<UsersIcon />}
aria-label="Show collaboration menu"
/>
</Tooltip>
</span>
</PopoverTrigger>
<PopoverContent shadow="lg" width="430px">
<CollaborationList />
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,101 @@
import {
Avatar,
HStack,
Menu,
MenuButton,
MenuItem,
MenuList,
Stack,
Tag,
Text,
} from '@chakra-ui/react'
import { CollaborationType } from 'db'
import React from 'react'
import { convertCollaborationTypeEnumToReadable } from './CollaborationList'
type Props = {
image?: string
name?: string
email: string
type: CollaborationType
isGuest?: boolean
isOwner: boolean
onDeleteClick: () => void
onChangeCollaborationType: (type: CollaborationType) => void
}
export const CollaboratorItem = ({
email,
name,
image,
type,
isGuest = false,
isOwner,
onDeleteClick,
onChangeCollaborationType,
}: Props) => {
const handleEditClick = () =>
onChangeCollaborationType(CollaborationType.WRITE)
const handleViewClick = () =>
onChangeCollaborationType(CollaborationType.READ)
return (
<Menu placement="bottom-end">
<MenuButton _hover={{ backgroundColor: 'gray.100' }} borderRadius="md">
<CollaboratorIdentityContent
email={email}
name={name}
image={image}
isGuest={isGuest}
tag={convertCollaborationTypeEnumToReadable(type)}
/>
</MenuButton>
{isOwner && (
<MenuList shadow="lg">
<MenuItem onClick={handleEditClick}>
{convertCollaborationTypeEnumToReadable(CollaborationType.WRITE)}
</MenuItem>
<MenuItem onClick={handleViewClick}>
{convertCollaborationTypeEnumToReadable(CollaborationType.READ)}
</MenuItem>
<MenuItem color="red.500" onClick={onDeleteClick}>
Remove
</MenuItem>
</MenuList>
)}
</Menu>
)
}
export const CollaboratorIdentityContent = ({
name,
tag,
isGuest = false,
image,
email,
}: {
name?: string
tag?: string
image?: string
isGuest?: boolean
email: string
}) => (
<HStack justifyContent="space-between" maxW="full" py="2" px="4">
<HStack minW={0}>
<Avatar name={name} src={image} size="sm" />
<Stack spacing={0} minW="0">
{name && (
<Text textAlign="left" fontSize="15px">
{name}
</Text>
)}
<Text color="gray.500" fontSize={name ? '14px' : 'inherit'} isTruncated>
{email}
</Text>
</Stack>
</HStack>
<HStack flexShrink={0}>
{isGuest && <Tag color="gray.400">Pending</Tag>}
<Tag>{tag}</Tag>
</HStack>
</HStack>
)

View File

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

View File

@ -14,6 +14,7 @@ import { useTypebot } from 'contexts/TypebotContext/TypebotContext'
import { useRouter } from 'next/router'
import React from 'react'
import { PublishButton } from '../buttons/PublishButton'
import { CollaborationMenuButton } from './CollaborationMenuButton'
import { EditableTypebotName } from './EditableTypebotName'
export const headerHeight = 56
@ -153,6 +154,7 @@ export const TypebotHeader = () => {
</HStack>
<HStack right="40px" pos="absolute">
<CollaborationMenuButton />
{router.pathname.includes('/edit') && (
<Button onClick={handlePreviewClick}>Preview</Button>
)}