feat(editor): ✨ Team workspaces
This commit is contained in:
@ -7,23 +7,40 @@ import {
|
||||
Text,
|
||||
HStack,
|
||||
Flex,
|
||||
Avatar,
|
||||
SkeletonCircle,
|
||||
Skeleton,
|
||||
Button,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react'
|
||||
import { TypebotLogo } from 'assets/logos'
|
||||
import { NextChakraLink } from 'components/nextChakra/NextChakraLink'
|
||||
import { LogOutIcon, SettingsIcon } from 'assets/icons'
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
HardDriveIcon,
|
||||
LogOutIcon,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from 'assets/icons'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
|
||||
import { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
|
||||
|
||||
export const DashboardHeader = () => {
|
||||
const { user } = useUser()
|
||||
|
||||
const { workspace, workspaces, switchWorkspace, createWorkspace } =
|
||||
useWorkspace()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const handleLogOut = () => {
|
||||
localStorage.removeItem('workspaceId')
|
||||
signOut()
|
||||
}
|
||||
|
||||
const handleCreateNewWorkspace = () =>
|
||||
createWorkspace(user?.name ?? undefined)
|
||||
|
||||
return (
|
||||
<Flex w="full" borderBottomWidth="1px" justify="center">
|
||||
<Flex
|
||||
@ -40,34 +57,72 @@ export const DashboardHeader = () => {
|
||||
>
|
||||
<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>
|
||||
<HStack>
|
||||
{user && workspace && (
|
||||
<WorkspaceSettingsModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
user={user}
|
||||
workspace={workspace}
|
||||
/>
|
||||
)}
|
||||
<Button leftIcon={<SettingsIcon />} onClick={onOpen}>
|
||||
Settings & Members
|
||||
</Button>
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton as={Button} variant="outline" px="2">
|
||||
<HStack>
|
||||
<SkeletonCircle
|
||||
isLoaded={workspace !== undefined}
|
||||
alignItems="center"
|
||||
display="flex"
|
||||
boxSize="20px"
|
||||
>
|
||||
<EmojiOrImageIcon
|
||||
boxSize="20px"
|
||||
icon={workspace?.icon}
|
||||
defaultIcon={HardDriveIcon}
|
||||
/>
|
||||
</SkeletonCircle>
|
||||
{workspace && (
|
||||
<Text noOfLines={0} maxW="200px">
|
||||
{workspace.name}
|
||||
</Text>
|
||||
)}
|
||||
<ChevronLeftIcon transform="rotate(-90deg)" />
|
||||
</HStack>
|
||||
</MenuButton>
|
||||
<MenuList>
|
||||
{workspaces
|
||||
?.filter((w) => w.id !== workspace?.id)
|
||||
.map((workspace) => (
|
||||
<MenuItem
|
||||
key={workspace.id}
|
||||
onClick={() => switchWorkspace(workspace.id)}
|
||||
>
|
||||
<HStack>
|
||||
<EmojiOrImageIcon
|
||||
icon={workspace.icon}
|
||||
boxSize="16px"
|
||||
defaultIcon={HardDriveIcon}
|
||||
/>
|
||||
<Text>{workspace.name}</Text>
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem onClick={handleCreateNewWorkspace} icon={<PlusIcon />}>
|
||||
New workspace
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={handleLogOut}
|
||||
icon={<LogOutIcon />}
|
||||
color="orange.500"
|
||||
>
|
||||
Log out
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
|
@ -19,15 +19,14 @@ import {
|
||||
TypebotInDashboard,
|
||||
useTypebots,
|
||||
} from 'services/typebots'
|
||||
import { useSharedTypebotsCount } from 'services/user/sharedTypebots'
|
||||
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'
|
||||
import { OnboardingModal } from './OnboardingModal'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
|
||||
type Props = { folder: DashboardFolder | null }
|
||||
|
||||
@ -35,6 +34,7 @@ const dragDistanceTolerance = 20
|
||||
|
||||
export const FolderContent = ({ folder }: Props) => {
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false)
|
||||
const {
|
||||
setDraggedTypebot,
|
||||
@ -60,6 +60,7 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
isLoading: isFolderLoading,
|
||||
mutate: mutateFolders,
|
||||
} = useFolders({
|
||||
workspaceId: workspace?.id,
|
||||
parentId: folder?.id,
|
||||
onError: (error) => {
|
||||
toast({ title: "Couldn't fetch folders", description: error.message })
|
||||
@ -71,22 +72,13 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
isLoading: isTypebotLoading,
|
||||
mutate: mutateTypebots,
|
||||
} = useTypebots({
|
||||
workspaceId: workspace?.id,
|
||||
folderId: folder?.id,
|
||||
onError: (error) => {
|
||||
toast({ title: "Couldn't fetch typebots", description: error.message })
|
||||
},
|
||||
})
|
||||
|
||||
const { totalSharedTypebots } = useSharedTypebotsCount({
|
||||
userId: folder === null ? user?.id : undefined,
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Couldn't fetch shared typebots",
|
||||
description: error.message,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const moveTypebotToFolder = async (typebotId: string, folderId: string) => {
|
||||
if (!typebots) return
|
||||
const { error } = await patchTypebot(typebotId, {
|
||||
@ -97,9 +89,9 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
}
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!folders) return
|
||||
if (!folders || !workspace) return
|
||||
setIsCreatingFolder(true)
|
||||
const { error, data: newFolder } = await createFolder({
|
||||
const { error, data: newFolder } = await createFolder(workspace.id, {
|
||||
parentFolderId: folder?.id ?? null,
|
||||
})
|
||||
setIsCreatingFolder(false)
|
||||
@ -164,7 +156,7 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
|
||||
return (
|
||||
<Flex w="full" flex="1" justify="center">
|
||||
{typebots && user && folder === null && (
|
||||
{typebots && !isTypebotLoading && user && folder === null && (
|
||||
<OnboardingModal totalTypebots={typebots.length} />
|
||||
)}
|
||||
<Stack w="1000px" spacing={6}>
|
||||
@ -185,7 +177,6 @@ export const FolderContent = ({ folder }: Props) => {
|
||||
isLoading={isTypebotLoading}
|
||||
isFirstBot={typebots?.length === 0 && folder === null}
|
||||
/>
|
||||
{totalSharedTypebots > 0 && <SharedTypebotsButton />}
|
||||
{isFolderLoading && <ButtonSkeleton />}
|
||||
{folders &&
|
||||
folders.map((folder) => (
|
||||
|
@ -2,18 +2,18 @@ import { Button, HStack, Tag, useDisclosure, Text } from '@chakra-ui/react'
|
||||
import { FolderPlusIcon } from 'assets/icons'
|
||||
import { UpgradeModal } from 'components/shared/modals/UpgradeModal'
|
||||
import { LimitReached } from 'components/shared/modals/UpgradeModal/UpgradeModal'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { useWorkspace } from 'contexts/WorkspaceContext'
|
||||
import React from 'react'
|
||||
import { isFreePlan } from 'services/user'
|
||||
import { isFreePlan } from 'services/workspace'
|
||||
|
||||
type Props = { isLoading: boolean; onClick: () => void }
|
||||
|
||||
export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
||||
const { user } = useUser()
|
||||
const { workspace } = useWorkspace()
|
||||
const { isOpen, onOpen, onClose } = useDisclosure()
|
||||
|
||||
const handleClick = () => {
|
||||
if (isFreePlan(user)) return onOpen()
|
||||
if (isFreePlan(workspace)) return onOpen()
|
||||
onClick()
|
||||
}
|
||||
return (
|
||||
@ -24,7 +24,7 @@ export const CreateFolderButton = ({ isLoading, onClick }: Props) => {
|
||||
>
|
||||
<HStack>
|
||||
<Text>Create a folder</Text>
|
||||
{isFreePlan(user) && <Tag colorScheme="orange">Pro</Tag>}
|
||||
{isFreePlan(workspace) && <Tag colorScheme="orange">Pro</Tag>}
|
||||
</HStack>
|
||||
<UpgradeModal
|
||||
isOpen={isOpen}
|
||||
|
@ -1,40 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
@ -20,7 +20,7 @@ import { deleteTypebot, importTypebot, getTypebot } from 'services/typebots'
|
||||
import { Typebot } from 'models'
|
||||
import { useTypebotDnd } from 'contexts/TypebotDndContext'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { TypebotIcon } from 'components/shared/TypebotHeader/TypebotIcon'
|
||||
import { EmojiOrImageIcon } from 'components/shared/EmojiOrImageIcon'
|
||||
import { useUser } from 'contexts/UserContext'
|
||||
import { Plan } from 'db'
|
||||
|
||||
@ -157,7 +157,7 @@ export const TypebotButton = ({
|
||||
alignItems="center"
|
||||
fontSize={'4xl'}
|
||||
>
|
||||
{<TypebotIcon icon={typebot.icon} boxSize={'35px'} />}
|
||||
{<EmojiOrImageIcon icon={typebot.icon} boxSize={'35px'} />}
|
||||
</Flex>
|
||||
<Text textAlign="center">{typebot.name}</Text>
|
||||
</VStack>
|
||||
|
@ -24,6 +24,7 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
|
||||
const confettiCanvaContainer = useRef<HTMLCanvasElement | null>(null)
|
||||
const confettiCanon = useRef<confetti.CreateTypes>()
|
||||
const [chosenCategories, setChosenCategories] = useState<string[]>([])
|
||||
const [openedOnce, setOpenedOnce] = useState(false)
|
||||
|
||||
const toast = useToast({
|
||||
position: 'top-right',
|
||||
@ -37,12 +38,16 @@ export const OnboardingModal = ({ totalTypebots }: Props) => {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (openedOnce) return
|
||||
const isNewUser =
|
||||
user &&
|
||||
new Date(user?.createdAt as unknown as string).toDateString() ===
|
||||
new Date().toDateString() &&
|
||||
totalTypebots === 0
|
||||
if (isNewUser) onOpen()
|
||||
if (isNewUser) {
|
||||
onOpen()
|
||||
setOpenedOnce(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user])
|
||||
|
||||
|
@ -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>
|
||||
}
|
||||
}
|
||||
}
|
@ -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'
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
|
Reference in New Issue
Block a user