2
0

♻️ (builder) Change to features-centric folder structure

This commit is contained in:
Baptiste Arnaud
2022-11-15 09:35:48 +01:00
committed by Baptiste Arnaud
parent 3686465a85
commit 643571fe7d
683 changed files with 3907 additions and 3643 deletions

View File

@ -0,0 +1,198 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from 'react'
import { byId } from 'utils'
import { Plan, Workspace, WorkspaceRole } from 'db'
import { useUser } from '../account/UserProvider'
import { useRouter } from 'next/router'
import { useTypebot } from '../editor/providers/TypebotProvider'
import { useWorkspaces } from './hooks/useWorkspaces'
import { createWorkspaceQuery } from './queries/createWorkspaceQuery'
import { deleteWorkspaceQuery } from './queries/deleteWorkspaceQuery'
import { updateWorkspaceQuery } from './queries/updateWorkspaceQuery'
import { WorkspaceWithMembers } from './types'
const workspaceContext = createContext<{
workspaces?: WorkspaceWithMembers[]
isLoading: boolean
workspace?: WorkspaceWithMembers
canEdit: boolean
currentRole?: WorkspaceRole
switchWorkspace: (workspaceId: string) => void
createWorkspace: (name?: string) => Promise<void>
updateWorkspace: (
workspaceId: string,
updates: Partial<Workspace>
) => Promise<void>
deleteCurrentWorkspace: () => Promise<void>
refreshWorkspace: (expectedUpdates: Partial<Workspace>) => void
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
}>({})
type WorkspaceContextProps = {
children: ReactNode
}
const getNewWorkspaceName = (
userFullName: string | undefined,
existingWorkspaces: Workspace[]
) => {
const workspaceName = userFullName
? `${userFullName}'s workspace`
: 'My workspace'
let newName = workspaceName
let i = 1
while (existingWorkspaces.find((w) => w.name === newName)) {
newName = `${workspaceName} (${i})`
i++
}
return newName
}
export const WorkspaceProvider = ({ children }: WorkspaceContextProps) => {
const { query } = useRouter()
const { user } = useUser()
const userId = user?.id
const { typebot } = useTypebot()
const { workspaces, isLoading, mutate } = useWorkspaces({ userId })
const [currentWorkspace, setCurrentWorkspace] =
useState<WorkspaceWithMembers>()
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string>()
const canEdit =
workspaces
?.find(byId(currentWorkspace?.id))
?.members.find((m) => m.userId === userId)?.role === WorkspaceRole.ADMIN
const currentRole = currentWorkspace?.members.find(
(m) => m.userId === userId
)?.role
useEffect(() => {
if (!workspaces || workspaces.length === 0 || currentWorkspace) return
const lastWorspaceId =
pendingWorkspaceId ??
query.workspaceId?.toString() ??
localStorage.getItem('workspaceId')
const defaultWorkspace = lastWorspaceId
? workspaces.find(byId(lastWorspaceId))
: workspaces.find((w) =>
w.members.some(
(m) => m.userId === userId && m.role === WorkspaceRole.ADMIN
)
)
setCurrentWorkspace(defaultWorkspace ?? workspaces[0])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaces?.length])
useEffect(() => {
if (!currentWorkspace?.id) return
localStorage.setItem('workspaceId', currentWorkspace.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentWorkspace?.id])
useEffect(() => {
if (!currentWorkspace) return setPendingWorkspaceId(typebot?.workspaceId)
if (!typebot?.workspaceId || typebot.workspaceId === currentWorkspace.id)
return
switchWorkspace(typebot.workspaceId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [typebot?.workspaceId])
const switchWorkspace = (workspaceId: string) => {
const newWorkspace = workspaces?.find(byId(workspaceId))
if (!newWorkspace) return
setCurrentWorkspace(newWorkspace)
}
const createWorkspace = async (userFullName?: string) => {
if (!workspaces) return
const newWorkspaceName = getNewWorkspaceName(userFullName, workspaces)
const { data, error } = await createWorkspaceQuery({
name: newWorkspaceName,
plan: Plan.FREE,
})
if (error || !data) return
const { workspace } = data
const newWorkspace = {
...workspace,
members: [
{
role: WorkspaceRole.ADMIN,
userId: userId as string,
workspaceId: workspace.id as string,
},
],
}
mutate({
workspaces: [...workspaces, newWorkspace],
})
setCurrentWorkspace(newWorkspace)
}
const updateWorkspace = async (
workspaceId: string,
updates: Partial<Workspace>
) => {
const { data } = await updateWorkspaceQuery({ id: workspaceId, ...updates })
if (!data || !currentWorkspace) return
setCurrentWorkspace({ ...currentWorkspace, ...updates })
mutate({
workspaces: (workspaces ?? []).map((w) =>
w.id === workspaceId ? { ...data.workspace, members: w.members } : w
),
})
}
const deleteCurrentWorkspace = async () => {
if (!currentWorkspace || !workspaces || workspaces.length < 2) return
const { data } = await deleteWorkspaceQuery(currentWorkspace.id)
if (!data || !currentWorkspace) return
const newWorkspaces = (workspaces ?? []).filter((w) =>
w.id === currentWorkspace.id
? { ...data.workspace, members: w.members }
: w
)
setCurrentWorkspace(newWorkspaces[0])
mutate({
workspaces: newWorkspaces,
})
}
const refreshWorkspace = (expectedUpdates: Partial<Workspace>) => {
if (!currentWorkspace) return
const updatedWorkspace = { ...currentWorkspace, ...expectedUpdates }
mutate({
workspaces: (workspaces ?? []).map((w) =>
w.id === currentWorkspace.id ? updatedWorkspace : w
),
})
setCurrentWorkspace(updatedWorkspace)
}
return (
<workspaceContext.Provider
value={{
workspaces,
workspace: currentWorkspace,
isLoading,
canEdit,
currentRole,
switchWorkspace,
createWorkspace,
updateWorkspace,
deleteCurrentWorkspace,
refreshWorkspace,
}}
>
{children}
</workspaceContext.Provider>
)
}
export const useWorkspace = () => useContext(workspaceContext)

View File

@ -0,0 +1,121 @@
import {
HStack,
Input,
Button,
Menu,
MenuButton,
MenuList,
Stack,
MenuItem,
} from '@chakra-ui/react'
import { ChevronLeftIcon } from '@/components/icons'
import { WorkspaceInvitation, WorkspaceRole } from 'db'
import { FormEvent, useState } from 'react'
import { Member } from '../../types'
import { sendInvitationQuery } from '../../queries/sendInvitationQuery'
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 sendInvitationQuery({
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}>
<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): string => {
switch (role) {
case WorkspaceRole.ADMIN:
return 'Admin'
case WorkspaceRole.MEMBER:
return 'Member'
case WorkspaceRole.GUEST:
return 'Guest'
}
}

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={1}
>
{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,149 @@
import {
Heading,
HStack,
SkeletonCircle,
SkeletonText,
Stack,
} from '@chakra-ui/react'
import { UnlockPlanAlertInfo } from '@/components/UnlockPlanAlertInfo'
import { WorkspaceInvitation, WorkspaceRole } from 'db'
import React from 'react'
import { getSeatsLimit } from 'utils'
import { AddMemberForm } from './AddMemberForm'
import { checkCanInviteMember } from './helpers'
import { MemberItem } from './MemberItem'
import { useUser } from '@/features/account'
import { useWorkspace } from '../../WorkspaceProvider'
import { useMembers } from '../../hooks/useMembers'
import { deleteMemberQuery } from '../../queries/deleteMemberQuery'
import { updateMemberQuery } from '../../queries/updateMemberQuery'
import { deleteInvitationQuery } from '../../queries/deleteInvitationQuery'
import { updateInvitationQuery } from '../../queries/updateInvitationQuery'
import { Member } from '../../types'
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) return
await deleteMemberQuery(workspace.id, memberId)
mutate({
members: members.filter((m) => m.userId !== memberId),
invitations,
})
}
const handleSelectNewRole =
(memberId: string) => async (role: WorkspaceRole) => {
if (!workspace) return
await updateMemberQuery(workspace.id, { userId: memberId, role })
mutate({
members: members.map((m) =>
m.userId === memberId ? { ...m, role } : m
),
invitations,
})
}
const handleDeleteInvitationClick = (id: string) => async () => {
if (!workspace) return
await deleteInvitationQuery({ workspaceId: workspace.id, id })
mutate({
invitations: invitations.filter((i) => i.id !== id),
members,
})
}
const handleSelectNewInvitationRole =
(id: string) => async (type: WorkspaceRole) => {
if (!workspace) return
await updateInvitationQuery({ workspaceId: workspace.id, id, type })
mutate({
invitations: invitations.map((i) => (i.id === id ? { ...i, type } : i)),
members,
})
}
const handleNewInvitation = async (invitation: WorkspaceInvitation) => {
await mutate({
members,
invitations: [...invitations, invitation],
})
}
const handleNewMember = async (member: Member) => {
await mutate({
members: [...members, member],
invitations,
})
}
const currentMembersCount = members.length + invitations.length
const canInviteNewMember = checkCanInviteMember({
plan: workspace?.plan,
customSeatsLimit: workspace?.customSeatsLimit,
currentMembersCount,
})
return (
<Stack w="full" spacing={3}>
{!canInviteNewMember && (
<UnlockPlanAlertInfo
contentLabel={`
Upgrade your plan to work with more team members, and unlock awesome
power features 🚀
`}
/>
)}
{workspace && (
<Heading fontSize="2xl">
Members ({currentMembersCount}/{getSeatsLimit(workspace)})
</Heading>
)}
{workspace?.id && canEdit && (
<AddMemberForm
workspaceId={workspace.id}
onNewInvitation={handleNewInvitation}
onNewMember={handleNewMember}
isLoading={isLoading}
isLocked={!canInviteNewMember}
/>
)}
{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,19 @@
import { Plan } from 'db'
import { getSeatsLimit } from 'utils'
export function checkCanInviteMember({
plan,
customSeatsLimit,
currentMembersCount,
}: {
plan?: Plan
customSeatsLimit?: number | null
currentMembersCount?: number
}) {
if (!plan || !currentMembersCount) return false
return (
getSeatsLimit({ plan, customSeatsLimit: customSeatsLimit ?? null }) >
currentMembersCount
)
}

View File

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

View File

@ -0,0 +1,98 @@
import {
Stack,
FormControl,
FormLabel,
Flex,
Button,
useDisclosure,
Text,
} from '@chakra-ui/react'
import { ConfirmModal } from '@/components/ConfirmModal'
import React from 'react'
import { EditableEmojiOrImageIcon } from '@/components/EditableEmojiOrImageIcon'
import { useWorkspace } from '../WorkspaceProvider'
import { Input } from '@/components/inputs'
export const WorkspaceSettingsForm = ({ onClose }: { onClose: () => void }) => {
const { workspace, workspaces, updateWorkspace, deleteCurrentWorkspace } =
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 })
}
const handleDeleteClick = async () => {
await deleteCurrentWorkspace()
onClose()
}
return (
<Stack spacing="6" w="full">
<FormControl>
<FormLabel>Icon</FormLabel>
<Flex>
{workspace && (
<EditableEmojiOrImageIcon
uploadFilePath={`workspaces/${workspace.id}/icon`}
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>
{workspace && workspaces && workspaces.length > 1 && (
<DeleteWorkspaceButton
onConfirm={handleDeleteClick}
workspaceName={workspace?.name}
/>
)}
</Stack>
)
}
const DeleteWorkspaceButton = ({
workspaceName,
onConfirm,
}: {
workspaceName: string
onConfirm: () => Promise<void>
}) => {
const { isOpen, onOpen, onClose } = useDisclosure()
return (
<>
<Button colorScheme="red" variant="outline" onClick={onOpen}>
Delete workspace
</Button>
<ConfirmModal
isOpen={isOpen}
onConfirm={onConfirm}
onClose={onClose}
message={
<Text>
Are you sure you want to delete {workspaceName} workspace? All its
folders, typebots and results will be deleted forever.'
</Text>
}
confirmButtonLabel="Delete"
/>
</>
)
}

View File

@ -0,0 +1,180 @@
import {
Modal,
ModalOverlay,
ModalContent,
Stack,
Text,
Button,
Avatar,
Flex,
} from '@chakra-ui/react'
import {
CreditCardIcon,
HardDriveIcon,
SettingsIcon,
UsersIcon,
} from '@/components/icons'
import { EmojiOrImageIcon } from '@/components/EmojiOrImageIcon'
import { User, Workspace } from 'db'
import { useState } from 'react'
import { MembersList } from './MembersList'
import { WorkspaceSettingsForm } from './WorkspaceSettingsForm'
import { useWorkspace } from '../WorkspaceProvider'
import { MyAccountForm } from '@/features/account'
import { BillingContent } from '@/features/billing'
import { EditorSettingsForm } from '@/features/editor'
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 minH="600px" flexDir="row">
<Stack
spacing={8}
w="200px"
py="6"
borderRightWidth={1}
justifyContent="space-between"
>
<Stack spacing={8}>
<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 & Usage
</Button>
)}
</Stack>
</Stack>
<Flex justify="center" pt="10">
<Text color="gray.500" fontSize="xs">
Version: 2.5.1
</Text>
</Flex>
</Stack>
{isOpen && (
<Flex flex="1" p="10">
<SettingsContent tab={selectedTab} onClose={onClose} />
</Flex>
)}
</ModalContent>
</Modal>
)
}
const SettingsContent = ({
tab,
onClose,
}: {
tab: SettingsTab
onClose: () => void
}) => {
switch (tab) {
case 'my-account':
return <MyAccountForm />
case 'user-settings':
return <EditorSettingsForm />
case 'workspace-settings':
return <WorkspaceSettingsForm onClose={onClose} />
case 'members':
return <MembersList />
case 'billing':
return <BillingContent />
default:
return null
}
}

View File

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

View File

@ -0,0 +1,20 @@
import { WorkspaceInvitation } from 'db'
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { env } from 'utils'
import { Member } from '../types'
export const useMembers = ({ workspaceId }: { workspaceId?: string }) => {
const { data, error, mutate } = useSWR<
{ members: Member[]; invitations: WorkspaceInvitation[] },
Error
>(workspaceId ? `/api/workspaces/${workspaceId}/members` : null, fetcher, {
dedupingInterval: env('E2E_TEST') === 'true' ? 0 : undefined,
})
return {
members: data?.members ?? [],
invitations: data?.invitations ?? [],
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1,17 @@
import { fetcher } from '@/utils/helpers'
import useSWR from 'swr'
import { WorkspaceWithMembers } from '../types'
export const useWorkspaces = ({ userId }: { userId?: string }) => {
const { data, error, mutate } = useSWR<
{
workspaces: WorkspaceWithMembers[]
},
Error
>(userId ? `/api/workspaces` : null, fetcher)
return {
workspaces: data?.workspaces,
isLoading: !error && !data,
mutate,
}
}

View File

@ -0,0 +1,2 @@
export { WorkspaceProvider, useWorkspace } from './WorkspaceProvider'
export { WorkspaceSettingsModal } from './components/WorkspaceSettingsModal'

View File

@ -0,0 +1,28 @@
import { Workspace } from 'db'
import { sendRequest } from 'utils'
export const createWorkspaceQuery = async (
body: Omit<
Workspace,
| 'id'
| 'icon'
| 'createdAt'
| 'stripeId'
| 'additionalChatsIndex'
| 'additionalStorageIndex'
| 'chatsLimitFirstEmailSentAt'
| 'chatsLimitSecondEmailSentAt'
| 'storageLimitFirstEmailSentAt'
| 'storageLimitSecondEmailSentAt'
| 'customChatsLimit'
| 'customStorageLimit'
| 'customSeatsLimit'
>
) =>
sendRequest<{
workspace: Workspace
}>({
url: `/api/workspaces`,
method: 'POST',
body,
})

View File

@ -0,0 +1,10 @@
import { sendRequest } from 'utils'
export const deleteInvitationQuery = (invitation: {
workspaceId: string
id: string
}) =>
sendRequest({
url: `/api/workspaces/${invitation.workspaceId}/invitations/${invitation.id}`,
method: 'DELETE',
})

View File

@ -0,0 +1,7 @@
import { sendRequest } from 'utils'
export const deleteMemberQuery = (workspaceId: string, userId: string) =>
sendRequest({
method: 'DELETE',
url: `/api/workspaces/${workspaceId}/members/${userId}`,
})

View File

@ -0,0 +1,10 @@
import { Workspace } from 'db'
import { sendRequest } from 'utils'
export const deleteWorkspaceQuery = (workspaceId: string) =>
sendRequest<{
workspace: Workspace
}>({
url: `/api/workspaces/${workspaceId}`,
method: 'DELETE',
})

View File

@ -0,0 +1,12 @@
import { WorkspaceInvitation } from 'db'
import { sendRequest } from 'utils'
import { Member } from '../types'
export const sendInvitationQuery = (
invitation: Omit<WorkspaceInvitation, 'id' | 'createdAt'>
) =>
sendRequest<{ invitation?: WorkspaceInvitation; member?: Member }>({
url: `/api/workspaces/${invitation.workspaceId}/invitations`,
method: 'POST',
body: invitation,
})

View File

@ -0,0 +1,11 @@
import { WorkspaceInvitation } from 'db'
import { sendRequest } from 'utils'
export const updateInvitationQuery = (
invitation: Partial<WorkspaceInvitation>
) =>
sendRequest({
url: `/api/workspaces/${invitation.workspaceId}/invitations/${invitation.id}`,
method: 'PATCH',
body: invitation,
})

View File

@ -0,0 +1,12 @@
import { MemberInWorkspace } from 'db'
import { sendRequest } from 'utils'
export const updateMemberQuery = (
workspaceId: string,
member: Partial<MemberInWorkspace>
) =>
sendRequest({
method: 'PATCH',
url: `/api/workspaces/${workspaceId}/members/${member.userId}`,
body: member,
})

View File

@ -0,0 +1,11 @@
import { Workspace } from 'db'
import { sendRequest } from 'utils'
export const updateWorkspaceQuery = async (updates: Partial<Workspace>) =>
sendRequest<{
workspace: Workspace
}>({
url: `/api/workspaces/${updates.id}`,
method: 'PATCH',
body: updates,
})

View File

@ -0,0 +1,9 @@
import { MemberInWorkspace, Workspace } from 'db'
export type Member = MemberInWorkspace & {
name: string | null
image: string | null
email: string | null
}
export type WorkspaceWithMembers = Workspace & { members: MemberInWorkspace[] }

View File

@ -0,0 +1,180 @@
import test, { expect } from '@playwright/test'
import cuid from 'cuid'
import { defaultTextInputOptions, InputBlockType } from 'models'
import { createTypebots } from 'utils/playwright/databaseActions'
import {
proWorkspaceId,
starterWorkspaceId,
} from 'utils/playwright/databaseSetup'
import { parseDefaultGroupWithBlock } from 'utils/playwright/databaseHelpers'
import { mockSessionResponsesToOtherUser } from 'utils/playwright/testHelpers'
const proTypebotId = cuid()
const starterTypebotId = cuid()
test.beforeAll(async () => {
await createTypebots([
{
id: proTypebotId,
name: 'Pro typebot',
workspaceId: proWorkspaceId,
},
])
await createTypebots([
{
id: starterTypebotId,
name: 'Starter typebot',
workspaceId: starterWorkspaceId,
...parseDefaultGroupWithBlock({
type: InputBlockType.TEXT,
options: {
...defaultTextInputOptions,
labels: {
...defaultTextInputOptions.labels,
placeholder: 'Hey there',
},
},
}),
},
])
})
test('can switch between workspaces and access typebot', async ({ page }) => {
await page.goto('/typebots')
await expect(page.locator('text="Pro typebot"')).toBeVisible()
await page.click('text=Pro workspace')
await page.click('text="Starter workspace"')
await expect(page.locator('text="Pro typebot"')).toBeHidden()
await page.click('text="Starter typebot"')
await expect(page.locator('text="Hey there"')).toBeVisible()
})
test('can create and delete a new workspace', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Pro workspace')
await expect(page.locator('text="Pro workspace" >> nth=1')).toBeHidden()
await page.click('text=New workspace')
await expect(page.locator('text="Pro typebot"')).toBeHidden()
await page.click("text=John Doe's workspace")
await expect(page.locator('text="Pro workspace"')).toBeVisible()
await page.click('text=Settings & Members')
await page.click('text="Settings"')
await page.click('text="Delete workspace"')
await expect(
page.locator(
"text=Are you sure you want to delete John Doe's workspace workspace?"
)
).toBeVisible()
await page.click('text="Delete"')
await expect(page.locator('text="John Doe\'s workspace"')).toBeHidden()
})
test('can update workspace info', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text="Settings"')
await page.click('[data-testid="editable-icon"]')
await page.fill('input[placeholder="Search..."]', 'building')
await page.click('text="🏦"')
await page.waitForTimeout(500)
await page.fill('input[value="Pro workspace"]', 'My awesome workspace')
await page.getByTestId('typebot-logo').click({ force: true })
await expect(
page.getByRole('button', { name: '🏦 My awesome workspace Pro' })
).toBeVisible()
})
test('can manage members', async ({ page }) => {
await page.goto('/typebots')
await page.click('text=Settings & Members')
await page.click('text="Members"')
await expect(page.locator('text="user@email.com"').nth(1)).toBeVisible()
await expect(page.locator('button >> text="Invite"')).toBeEnabled()
await page.fill(
'input[placeholder="colleague@company.com"]',
'guest@email.com'
)
await page.click('button >> text="Invite"')
await expect(page.locator('button >> text="Invite"')).toBeEnabled()
await expect(
page.locator('input[placeholder="colleague@company.com"]')
).toHaveAttribute('value', '')
await expect(page.locator('text="guest@email.com"')).toBeVisible()
await expect(page.locator('text="Pending"')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Members (2/5)' })
).toBeVisible()
await page.fill(
'input[placeholder="colleague@company.com"]',
'other-user@email.com'
)
await page.click('text="Member" >> nth=0')
await page.click('text="Admin"')
await page.click('button >> text="Invite"')
await expect(
page.locator('input[placeholder="colleague@company.com"]')
).toHaveAttribute('value', '')
await expect(page.locator('text="other-user@email.com"')).toBeVisible()
await expect(page.locator('text="James Doe"')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Members (3/5)' })
).toBeVisible()
await page.click('text="other-user@email.com"')
await page.click('button >> text="Member"')
await expect(page.locator('[data-testid="tag"] >> text="Admin"')).toHaveCount(
1
)
await page.click('text="other-user@email.com"')
await page.click('text="guest@email.com"')
await page.click('text="Admin" >> nth=-1')
await expect(page.locator('[data-testid="tag"] >> text="Admin"')).toHaveCount(
2
)
await page.click('text="guest@email.com"')
await page.click('button >> text="Remove"')
await expect(page.locator('text="guest@email.com"')).toBeHidden()
await mockSessionResponsesToOtherUser(page)
await page.goto('/typebots')
await page.click('text=Settings & Members')
await expect(page.locator('text="Settings"')).toBeHidden()
await page.click('text="Members"')
await expect(page.locator('text="other-user@email.com"')).toBeVisible()
await expect(
page.locator('input[placeholder="colleague@company.com"]')
).toBeHidden()
await page.click('text="other-user@email.com"')
await expect(page.locator('button >> text="Remove"')).toBeHidden()
})
test("can't add new members when limit is reached", async ({ page }) => {
await page.goto('/typebots')
await page.click('text="My awesome workspace"')
await page.click('text="Free workspace"')
await page.click('text=Settings & Members')
await page.click('text="Members"')
await expect(page.locator('button >> text="Invite"')).toBeDisabled()
await expect(
page.locator(
'text="Upgrade your plan to work with more team members, and unlock awesome power features 🚀"'
)
).toBeVisible()
await page.click('text="Free workspace"', { force: true })
await page.click('text="Free workspace"')
await page.click('text="Starter workspace"')
await page.click('text=Settings & Members')
await page.click('text="Members"')
await page.fill(
'input[placeholder="colleague@company.com"]',
'guest@email.com'
)
await page.click('button >> text="Invite"')
await expect(
page.locator(
'text="Upgrade your plan to work with more team members, and unlock awesome power features 🚀"'
)
).toBeVisible()
await expect(page.locator('button >> text="Invite"')).toBeDisabled()
})