♻️ (builder) Change to features-centric folder structure
This commit is contained in:
committed by
Baptiste Arnaud
parent
3686465a85
commit
643571fe7d
198
apps/builder/src/features/workspace/WorkspaceProvider.tsx
Normal file
198
apps/builder/src/features/workspace/WorkspaceProvider.tsx
Normal 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)
|
@ -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'
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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
|
||||
)
|
||||
}
|
@ -0,0 +1 @@
|
||||
export { MembersList } from './MembersList'
|
@ -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"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
1
apps/builder/src/features/workspace/components/index.tsx
Normal file
1
apps/builder/src/features/workspace/components/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { WorkspaceSettingsModal } from './WorkspaceSettingsModal'
|
20
apps/builder/src/features/workspace/hooks/useMembers.ts
Normal file
20
apps/builder/src/features/workspace/hooks/useMembers.ts
Normal 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,
|
||||
}
|
||||
}
|
17
apps/builder/src/features/workspace/hooks/useWorkspaces.ts
Normal file
17
apps/builder/src/features/workspace/hooks/useWorkspaces.ts
Normal 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,
|
||||
}
|
||||
}
|
2
apps/builder/src/features/workspace/index.ts
Normal file
2
apps/builder/src/features/workspace/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceProvider'
|
||||
export { WorkspaceSettingsModal } from './components/WorkspaceSettingsModal'
|
@ -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,
|
||||
})
|
@ -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',
|
||||
})
|
@ -0,0 +1,7 @@
|
||||
import { sendRequest } from 'utils'
|
||||
|
||||
export const deleteMemberQuery = (workspaceId: string, userId: string) =>
|
||||
sendRequest({
|
||||
method: 'DELETE',
|
||||
url: `/api/workspaces/${workspaceId}/members/${userId}`,
|
||||
})
|
@ -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',
|
||||
})
|
@ -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,
|
||||
})
|
@ -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,
|
||||
})
|
@ -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,
|
||||
})
|
@ -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,
|
||||
})
|
9
apps/builder/src/features/workspace/types.ts
Normal file
9
apps/builder/src/features/workspace/types.ts
Normal 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[] }
|
180
apps/builder/src/features/workspace/workspaces.spec.ts
Normal file
180
apps/builder/src/features/workspace/workspaces.spec.ts
Normal 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()
|
||||
})
|
Reference in New Issue
Block a user