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

@@ -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>
)}