feat(editor): ✨ Team workspaces
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
HStack,
|
||||
Input,
|
||||
Button,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
Stack,
|
||||
MenuItem,
|
||||
} from '@chakra-ui/react'
|
||||
import { ChevronLeftIcon } from 'assets/icons'
|
||||
import { WorkspaceInvitation, WorkspaceRole } from 'db'
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { Member, sendInvitation } from 'services/workspace'
|
||||
|
||||
type Props = {
|
||||
workspaceId: string
|
||||
onNewMember: (member: Member) => void
|
||||
onNewInvitation: (invitation: WorkspaceInvitation) => void
|
||||
isLoading: boolean
|
||||
isLocked: boolean
|
||||
}
|
||||
export const AddMemberForm = ({
|
||||
workspaceId,
|
||||
onNewMember,
|
||||
onNewInvitation,
|
||||
isLoading,
|
||||
isLocked,
|
||||
}: Props) => {
|
||||
const [invitationEmail, setInvitationEmail] = useState('')
|
||||
const [invitationRole, setInvitationRole] = useState<WorkspaceRole>(
|
||||
WorkspaceRole.MEMBER
|
||||
)
|
||||
|
||||
const [isSendingInvitation, setIsSendingInvitation] = useState(false)
|
||||
|
||||
const handleInvitationSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSendingInvitation(true)
|
||||
const { data } = await sendInvitation({
|
||||
email: invitationEmail,
|
||||
type: invitationRole,
|
||||
workspaceId,
|
||||
})
|
||||
if (data?.member) onNewMember(data.member)
|
||||
if (data?.invitation) onNewInvitation(data.invitation)
|
||||
setInvitationEmail('')
|
||||
setIsSendingInvitation(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack as="form" onSubmit={handleInvitationSubmit} pb="4">
|
||||
<Input
|
||||
placeholder="colleague@company.com"
|
||||
name="inviteEmail"
|
||||
value={invitationEmail}
|
||||
onChange={(e) => setInvitationEmail(e.target.value)}
|
||||
rounded="md"
|
||||
isDisabled={isLocked}
|
||||
/>
|
||||
|
||||
{!isLocked && (
|
||||
<WorkspaceRoleMenuButton
|
||||
role={invitationRole}
|
||||
onChange={setInvitationRole}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
colorScheme={'blue'}
|
||||
isLoading={isSendingInvitation}
|
||||
flexShrink={0}
|
||||
type="submit"
|
||||
isDisabled={isLoading || isLocked}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceRoleMenuButton = ({
|
||||
role,
|
||||
onChange,
|
||||
}: {
|
||||
role: WorkspaceRole
|
||||
onChange: (role: WorkspaceRole) => void
|
||||
}) => {
|
||||
return (
|
||||
<Menu placement="bottom-end" isLazy matchWidth>
|
||||
<MenuButton
|
||||
flexShrink={0}
|
||||
as={Button}
|
||||
rightIcon={<ChevronLeftIcon transform={'rotate(-90deg)'} />}
|
||||
>
|
||||
{convertWorkspaceRoleToReadable(role)}
|
||||
</MenuButton>
|
||||
<MenuList minW={0}>
|
||||
<Stack maxH={'35vh'} overflowY="scroll" spacing="0">
|
||||
<MenuItem onClick={() => onChange(WorkspaceRole.ADMIN)}>
|
||||
{convertWorkspaceRoleToReadable(WorkspaceRole.ADMIN)}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => onChange(WorkspaceRole.MEMBER)}>
|
||||
{convertWorkspaceRoleToReadable(WorkspaceRole.MEMBER)}
|
||||
</MenuItem>
|
||||
</Stack>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export const convertWorkspaceRoleToReadable = (role: WorkspaceRole) => {
|
||||
switch (role) {
|
||||
case WorkspaceRole.ADMIN:
|
||||
return 'Admin'
|
||||
case WorkspaceRole.MEMBER:
|
||||
return 'Member'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
Avatar,
|
||||
HStack,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Stack,
|
||||
Tag,
|
||||
Text,
|
||||
} from '@chakra-ui/react'
|
||||
import { WorkspaceRole } from 'db'
|
||||
import React from 'react'
|
||||
import { convertWorkspaceRoleToReadable } from './AddMemberForm'
|
||||
|
||||
type Props = {
|
||||
image?: string
|
||||
name?: string
|
||||
email: string
|
||||
role: WorkspaceRole
|
||||
isGuest?: boolean
|
||||
isMe?: boolean
|
||||
canEdit: boolean
|
||||
onDeleteClick: () => void
|
||||
onSelectNewRole: (role: WorkspaceRole) => void
|
||||
}
|
||||
|
||||
export const MemberItem = ({
|
||||
email,
|
||||
name,
|
||||
image,
|
||||
role,
|
||||
isGuest = false,
|
||||
isMe = false,
|
||||
canEdit,
|
||||
onDeleteClick,
|
||||
onSelectNewRole,
|
||||
}: Props) => {
|
||||
const handleAdminClick = () => onSelectNewRole(WorkspaceRole.ADMIN)
|
||||
const handleMemberClick = () => onSelectNewRole(WorkspaceRole.MEMBER)
|
||||
return (
|
||||
<Menu placement="bottom-end" isLazy>
|
||||
<MenuButton _hover={{ backgroundColor: 'gray.100' }} borderRadius="md">
|
||||
<MemberIdentityContent
|
||||
email={email}
|
||||
name={name}
|
||||
image={image}
|
||||
isGuest={isGuest}
|
||||
tag={convertWorkspaceRoleToReadable(role)}
|
||||
/>
|
||||
</MenuButton>
|
||||
{!isMe && canEdit && (
|
||||
<MenuList shadow="lg">
|
||||
<MenuItem onClick={handleAdminClick}>
|
||||
{convertWorkspaceRoleToReadable(WorkspaceRole.ADMIN)}
|
||||
</MenuItem>
|
||||
<MenuItem onClick={handleMemberClick}>
|
||||
{convertWorkspaceRoleToReadable(WorkspaceRole.MEMBER)}
|
||||
</MenuItem>
|
||||
<MenuItem color="red.500" onClick={onDeleteClick}>
|
||||
Remove
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
)}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
export const MemberIdentityContent = ({
|
||||
name,
|
||||
tag,
|
||||
isGuest = false,
|
||||
image,
|
||||
email,
|
||||
}: {
|
||||
name?: string
|
||||
tag?: string
|
||||
image?: string
|
||||
isGuest?: boolean
|
||||
email: string
|
||||
}) => (
|
||||
<HStack justifyContent="space-between" maxW="full" p="2">
|
||||
<HStack minW={0} spacing="4">
|
||||
<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'}
|
||||
noOfLines={0}
|
||||
>
|
||||
{email}
|
||||
</Text>
|
||||
</Stack>
|
||||
</HStack>
|
||||
<HStack flexShrink={0}>
|
||||
{isGuest && (
|
||||
<Tag color="gray.400" data-testid="tag">
|
||||
Pending
|
||||
</Tag>
|
||||
)}
|
||||
<Tag data-testid="tag">{tag}</Tag>
|
||||
</HStack>
|
||||
</HStack>
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
import { HStack, SkeletonCircle, SkeletonText, Stack } from '@chakra-ui/react'
|
||||
import { UnlockPlanInfo } from 'components/shared/Info'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { Plan, WorkspaceInvitation, WorkspaceRole } from 'db'
|
||||
import React from 'react'
|
||||
import {
|
||||
deleteInvitation,
|
||||
deleteMember,
|
||||
Member,
|
||||
updateInvitation,
|
||||
updateMember,
|
||||
useMembers,
|
||||
} from 'services/workspace'
|
||||
import { AddMemberForm } from './AddMemberForm'
|
||||
import { MemberItem } from './MemberItem'
|
||||
|
||||
export const MembersList = () => {
|
||||
const { user } = useUser()
|
||||
const { workspace, canEdit } = useWorkspace()
|
||||
const { members, invitations, isLoading, mutate } = useMembers({
|
||||
workspaceId: workspace?.id,
|
||||
})
|
||||
|
||||
const handleDeleteMemberClick = (memberId: string) => async () => {
|
||||
if (!workspace || !members || !invitations) return
|
||||
await deleteMember(workspace.id, memberId)
|
||||
mutate({
|
||||
members: members.filter((m) => m.userId !== memberId),
|
||||
invitations,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectNewRole =
|
||||
(memberId: string) => async (role: WorkspaceRole) => {
|
||||
if (!workspace || !members || !invitations) return
|
||||
await updateMember(workspace.id, { userId: memberId, role })
|
||||
mutate({
|
||||
members: members.map((m) =>
|
||||
m.userId === memberId ? { ...m, role } : m
|
||||
),
|
||||
invitations,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteInvitationClick = (id: string) => async () => {
|
||||
if (!workspace || !members || !invitations) return
|
||||
await deleteInvitation({ workspaceId: workspace.id, id })
|
||||
mutate({
|
||||
invitations: invitations.filter((i) => i.id !== id),
|
||||
members,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectNewInvitationRole =
|
||||
(id: string) => async (type: WorkspaceRole) => {
|
||||
if (!workspace || !members || !invitations) return
|
||||
await updateInvitation({ workspaceId: workspace.id, id, type })
|
||||
mutate({
|
||||
invitations: invitations.map((i) => (i.id === id ? { ...i, type } : i)),
|
||||
members,
|
||||
})
|
||||
}
|
||||
|
||||
const handleNewInvitation = (invitation: WorkspaceInvitation) => {
|
||||
if (!members || !invitations) return
|
||||
mutate({
|
||||
members,
|
||||
invitations: [...invitations, invitation],
|
||||
})
|
||||
}
|
||||
|
||||
const handleNewMember = (member: Member) => {
|
||||
if (!members || !invitations) return
|
||||
mutate({
|
||||
members: [...members, member],
|
||||
invitations,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack w="full">
|
||||
{workspace?.plan !== Plan.TEAM && (
|
||||
<UnlockPlanInfo
|
||||
contentLabel={
|
||||
'Upgrade to team plan for a collaborative workspace, unlimited team members, and advanced permissions.'
|
||||
}
|
||||
plan={Plan.TEAM}
|
||||
/>
|
||||
)}
|
||||
{workspace?.id && canEdit && (
|
||||
<AddMemberForm
|
||||
workspaceId={workspace.id}
|
||||
onNewInvitation={handleNewInvitation}
|
||||
onNewMember={handleNewMember}
|
||||
isLoading={isLoading}
|
||||
isLocked={workspace.plan !== Plan.TEAM}
|
||||
/>
|
||||
)}
|
||||
{members?.map((member) => (
|
||||
<MemberItem
|
||||
key={member.userId}
|
||||
email={member.email ?? ''}
|
||||
image={member.image ?? undefined}
|
||||
name={member.name ?? undefined}
|
||||
role={member.role}
|
||||
isMe={member.userId === user?.id}
|
||||
onDeleteClick={handleDeleteMemberClick(member.userId)}
|
||||
onSelectNewRole={handleSelectNewRole(member.userId)}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
{invitations?.map((invitation) => (
|
||||
<MemberItem
|
||||
key={invitation.email}
|
||||
email={invitation.email ?? ''}
|
||||
role={invitation.type}
|
||||
onDeleteClick={handleDeleteInvitationClick(invitation.id)}
|
||||
onSelectNewRole={handleSelectNewInvitationRole(invitation.id)}
|
||||
isGuest
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
))}
|
||||
{isLoading && (
|
||||
<HStack py="4">
|
||||
<SkeletonCircle boxSize="32px" />
|
||||
<SkeletonText width="200px" noOfLines={2} />
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MembersList } from './MembersList'
|
||||
Reference in New Issue
Block a user