2
0

feat(editor): Team workspaces

This commit is contained in:
Baptiste Arnaud
2022-05-13 15:22:44 -07:00
parent 6c2986590b
commit f0fdf08b00
132 changed files with 3354 additions and 1228 deletions

View File

@@ -0,0 +1,53 @@
import { Stack, HStack, Button, Text, Tag } from '@chakra-ui/react'
import { ExternalLinkIcon } from 'assets/icons'
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { Plan } from 'db'
import React from 'react'
export const BillingForm = () => {
const { workspace } = useWorkspace()
return (
<Stack spacing="6">
<HStack>
<Text>Workspace subscription: </Text>
<PlanTag plan={workspace?.plan} />
</HStack>
{workspace?.stripeId && (
<>
<Text>
To manage your subscription and download invoices, head over to your
Stripe portal:
</Text>
<Button
as={NextChakraLink}
href={`/api/stripe/customer-portal?workspaceId=${workspace.id}`}
isExternal
colorScheme="blue"
rightIcon={<ExternalLinkIcon />}
>
Stripe Portal
</Button>
</>
)}
</Stack>
)
}
const PlanTag = ({ plan }: { plan?: Plan }) => {
switch (plan) {
case Plan.TEAM: {
return <Tag colorScheme="purple">Team</Tag>
}
case Plan.LIFETIME:
case Plan.OFFERED:
case Plan.PRO: {
return <Tag colorScheme="orange">Personal Pro</Tag>
}
default: {
return <Tag colorScheme="gray">Free</Tag>
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { MembersList } from './MembersList'

View File

@@ -0,0 +1,127 @@
import {
Stack,
HStack,
Avatar,
Button,
FormControl,
FormLabel,
Input,
Tooltip,
Flex,
Text,
InputRightElement,
InputGroup,
} from '@chakra-ui/react'
import { UploadIcon } from 'assets/icons'
import { UploadButton } from 'components/shared/buttons/UploadButton'
import { useUser } from 'contexts/UserContext'
import React, { ChangeEvent, useState } from 'react'
import { isDefined } from 'utils'
export const MyAccountForm = () => {
const {
user,
updateUser,
saveUser,
hasUnsavedChanges,
isSaving,
isOAuthProvider,
} = useUser()
const [reloadParam, setReloadParam] = useState('')
const [isApiTokenVisible, setIsApiTokenVisible] = useState(false)
const handleFileUploaded = async (url: string) => {
setReloadParam(Date.now().toString())
updateUser({ image: url })
}
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
updateUser({ name: e.target.value })
}
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
updateUser({ email: e.target.value })
}
const toggleTokenVisibility = () => setIsApiTokenVisible(!isApiTokenVisible)
return (
<Stack spacing="6" w="full">
<HStack spacing={6}>
<Avatar
size="lg"
src={user?.image ? `${user.image}?${reloadParam}` : undefined}
name={user?.name ?? undefined}
/>
<Stack>
<UploadButton
size="sm"
filePath={`public/users/${user?.id}/avatar`}
leftIcon={<UploadIcon />}
onFileUploaded={handleFileUploaded}
>
Change photo
</UploadButton>
<Text color="gray.500" fontSize="sm">
.jpg or.png, max 1MB
</Text>
</Stack>
</HStack>
<FormControl>
<FormLabel htmlFor="name">Name</FormLabel>
<Input id="name" value={user?.name ?? ''} onChange={handleNameChange} />
</FormControl>
{isDefined(user?.email) && (
<Tooltip
label="Updating email is not available."
placement="left"
hasArrow
>
<FormControl>
<FormLabel
htmlFor="email"
color={isOAuthProvider ? 'gray.500' : 'current'}
>
Email address
</FormLabel>
<Input
id="email"
type="email"
isDisabled
value={user?.email ?? ''}
onChange={handleEmailChange}
/>
</FormControl>
</Tooltip>
)}
<FormControl>
<FormLabel htmlFor="name">API token</FormLabel>
<InputGroup>
<Input
id="token"
value={user?.apiToken ?? ''}
type={isApiTokenVisible ? 'text' : 'password'}
/>
<InputRightElement mr="3">
<Button size="xs" onClick={toggleTokenVisibility}>
{isApiTokenVisible ? 'Hide' : 'Show'}
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
{hasUnsavedChanges && (
<Flex justifyContent="flex-end">
<Button
colorScheme="blue"
onClick={() => saveUser()}
isLoading={isSaving}
>
Save
</Button>
</Flex>
)}
</Stack>
)
}

View File

@@ -0,0 +1,76 @@
import {
Stack,
Heading,
HStack,
Text,
Radio,
RadioGroup,
VStack,
} from '@chakra-ui/react'
import { MouseIcon, LaptopIcon } from 'assets/icons'
import { useUser } from 'contexts/UserContext'
import { GraphNavigation } from 'db'
import React, { useEffect, useState } from 'react'
export const EditorSettings = () => {
const { user, saveUser } = useUser()
const [value, setValue] = useState<string>(
user?.graphNavigation ?? GraphNavigation.TRACKPAD
)
useEffect(() => {
if (user?.graphNavigation === value) return
saveUser({ graphNavigation: value as GraphNavigation }).then()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
const options = [
{
value: GraphNavigation.MOUSE,
label: 'Mouse',
description:
'Move by dragging the board and zoom in/out using the scroll wheel',
icon: <MouseIcon boxSize="35px" />,
},
{
value: GraphNavigation.TRACKPAD,
label: 'Trackpad',
description: 'Move the board using 2 fingers and zoom in/out by pinching',
icon: <LaptopIcon boxSize="35px" />,
},
]
return (
<Stack spacing={6}>
<Heading size="md">Editor Navigation</Heading>
<RadioGroup onChange={setValue} value={value}>
<HStack spacing={4} w="full" align="stretch">
{options.map((option) => (
<VStack
key={option.value}
as="label"
htmlFor={option.label}
cursor="pointer"
borderWidth="1px"
borderRadius="md"
w="full"
p="6"
spacing={6}
justifyContent="space-between"
>
<VStack spacing={6}>
{option.icon}
<Stack>
<Text fontWeight="bold">{option.label}</Text>
<Text>{option.description}</Text>
</Stack>
</VStack>
<Radio value={option.value} id={option.label} />
</VStack>
))}
</HStack>
</RadioGroup>
</Stack>
)
}

View File

@@ -0,0 +1,45 @@
import { Stack, FormControl, FormLabel, Flex } from '@chakra-ui/react'
import { EditableEmojiOrImageIcon } from 'components/shared/EditableEmojiOrImageIcon'
import { Input } from 'components/shared/Textbox'
import { useWorkspace } from 'contexts/WorkspaceContext'
import React from 'react'
export const WorkspaceSettingsForm = () => {
const { workspace, updateWorkspace } = useWorkspace()
const handleNameChange = (name: string) => {
if (!workspace?.id) return
updateWorkspace(workspace?.id, { name })
}
const handleChangeIcon = (icon: string) => {
if (!workspace?.id) return
updateWorkspace(workspace?.id, { icon })
}
return (
<Stack spacing="6" w="full">
<FormControl>
<FormLabel>Icon</FormLabel>
<Flex>
<EditableEmojiOrImageIcon
icon={workspace?.icon}
onChangeIcon={handleChangeIcon}
boxSize="40px"
/>
</Flex>
</FormControl>
<FormControl>
<FormLabel htmlFor="name">Name</FormLabel>
{workspace && (
<Input
id="name"
withVariableButton={false}
defaultValue={workspace?.name}
onChange={handleNameChange}
/>
)}
</FormControl>
</Stack>
)
}

View File

@@ -0,0 +1,157 @@
import {
Modal,
ModalOverlay,
ModalContent,
Stack,
Text,
Button,
Avatar,
Flex,
} from '@chakra-ui/react'
import {
CreditCardIcon,
HardDriveIcon,
SettingsIcon,
UsersIcon,
} from 'assets/icons'
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
import { useWorkspace } from 'contexts/WorkspaceContext'
import { User, Workspace } from 'db'
import { useState } from 'react'
import { BillingForm } from './BillingForm'
import { MembersList } from './MembersList'
import { MyAccountForm } from './MyAccountForm'
import { EditorSettings } from './UserSettingsForm'
import { WorkspaceSettingsForm } from './WorkspaceSettingsForm'
type Props = {
isOpen: boolean
user: User
workspace: Workspace
onClose: () => void
}
type SettingsTab =
| 'my-account'
| 'user-settings'
| 'workspace-settings'
| 'members'
| 'billing'
export const WorkspaceSettingsModal = ({
isOpen,
user,
workspace,
onClose,
}: Props) => {
const { canEdit } = useWorkspace()
const [selectedTab, setSelectedTab] = useState<SettingsTab>('my-account')
return (
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
<ModalOverlay />
<ModalContent h="600px" flexDir="row">
<Stack spacing={8} w="250px" py="6" borderRightWidth={1} h="full">
<Stack>
<Text pl="4" color="gray.500">
{user.email}
</Text>
<Button
variant={selectedTab === 'my-account' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('my-account')}
leftIcon={
<Avatar
name={user.name ?? undefined}
src={user.image ?? undefined}
boxSize="15px"
/>
}
size="sm"
justifyContent="flex-start"
pl="4"
>
My account
</Button>
<Button
variant={selectedTab === 'user-settings' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('user-settings')}
leftIcon={<SettingsIcon />}
size="sm"
justifyContent="flex-start"
pl="4"
>
Preferences
</Button>
</Stack>
<Stack>
<Text pl="4" color="gray.500">
Workspace
</Text>
{canEdit && (
<Button
variant={
selectedTab === 'workspace-settings' ? 'solid' : 'ghost'
}
onClick={() => setSelectedTab('workspace-settings')}
leftIcon={
<EmojiOrImageIcon
icon={workspace.icon}
boxSize="15px"
defaultIcon={HardDriveIcon}
/>
}
size="sm"
justifyContent="flex-start"
pl="4"
>
Settings
</Button>
)}
<Button
variant={selectedTab === 'members' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('members')}
leftIcon={<UsersIcon />}
size="sm"
justifyContent="flex-start"
pl="4"
>
Members
</Button>
{canEdit && (
<Button
variant={selectedTab === 'billing' ? 'solid' : 'ghost'}
onClick={() => setSelectedTab('billing')}
leftIcon={<CreditCardIcon />}
size="sm"
justifyContent="flex-start"
pl="4"
>
Billing
</Button>
)}
</Stack>
</Stack>
<Flex flex="1" p="10">
<SettingsContent tab={selectedTab} />
</Flex>
</ModalContent>
</Modal>
)
}
const SettingsContent = ({ tab }: { tab: SettingsTab }) => {
switch (tab) {
case 'my-account':
return <MyAccountForm />
case 'user-settings':
return <EditorSettings />
case 'workspace-settings':
return <WorkspaceSettingsForm />
case 'members':
return <MembersList />
case 'billing':
return <BillingForm />
default:
return null
}
}

View File

@@ -0,0 +1 @@
export { WorkspaceSettingsModal } from './WorkspaceSettingsModal'