refactor(web): migrate members invite overlays to base ui

This commit is contained in:
yyh
2026-03-23 15:57:35 +08:00
parent 9336935295
commit 8320de2c2f
6 changed files with 186 additions and 200 deletions

View File

@ -53,6 +53,9 @@ describe('InviteModal', () => {
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
</ToastContext.Provider>,
)
const fillEmails = (value: string) => {
fireEvent.change(screen.getByTestId('mock-email-input'), { target: { value } })
}
it('should render invite modal content', async () => {
renderModal()
@ -68,12 +71,8 @@ describe('InviteModal', () => {
})
it('should enable send button after entering an email', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
})
@ -84,7 +83,7 @@ describe('InviteModal', () => {
renderModal()
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
fillEmails('user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@ -103,8 +102,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@ -116,8 +114,6 @@ describe('InviteModal', () => {
})
it('should keep send button disabled when license limit is exceeded', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
@ -125,8 +121,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
@ -144,9 +139,8 @@ describe('InviteModal', () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
// Use an email that passes basic validation but fails our strict regex (needs 2+ char TLD)
await user.type(input, 'invalid@email.c')
fillEmails('invalid@email.c')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
expect(mockNotify).toHaveBeenCalledWith({
@ -160,8 +154,7 @@ describe('InviteModal', () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
expect(screen.getByText('user@example.com')).toBeInTheDocument()
@ -203,7 +196,7 @@ describe('InviteModal', () => {
renderModal()
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
fillEmails('user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@ -214,8 +207,6 @@ describe('InviteModal', () => {
})
it('should show destructive text color when used size exceeds limit', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
@ -223,8 +214,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
// usedSize = 10 + 1 = 11 > limit 10 → destructive color
const counter = screen.getByText('11')
@ -241,8 +231,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
@ -264,8 +253,6 @@ describe('InviteModal', () => {
})
it('should show destructive color and disable send button when limit is exactly met with one email', async () => {
const user = userEvent.setup()
// size=10, limit=10 - adding 1 email makes usedSize=11 > limit=10
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
@ -274,8 +261,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
// isLimitExceeded=true → button is disabled, cannot submit
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
@ -293,8 +279,7 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
@ -320,11 +305,9 @@ describe('InviteModal', () => {
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
fillEmails('user@example.com')
// isLimited=false → no destructive color
const counter = screen.getByText('1')

View File

@ -1,12 +0,0 @@
.modal {
padding: 24px 32px !important;
width: 400px !important;
}
.emailsInput {
background-color: rgb(243 244 246 / var(--tw-bg-opacity)) !important;
}
.emailBackground {
background-color: white !important;
}

View File

@ -2,20 +2,17 @@
import type { RoleKey } from './role-selector'
import type { InvitationResult } from '@/models/common'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactMultiEmail } from 'react-multi-email'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { toast } from '@/app/components/base/ui/toast'
import { emailRegex } from '@/config'
import { useLocale } from '@/context/i18n'
import { useProviderContextSelector } from '@/context/provider-context'
import { inviteMember } from '@/service/common'
import { cn } from '@/utils/classnames'
import s from './index.module.css'
import RoleSelector from './role-selector'
import 'react-multi-email/dist/style.css'
@ -34,7 +31,6 @@ const InviteModal = ({
const licenseLimit = useProviderContextSelector(s => s.licenseLimit)
const refreshLicenseLimit = useProviderContextSelector(s => s.refreshLicenseLimit)
const [emails, setEmails] = useState<string[]>([])
const { notify } = useContext(ToastContext)
const [isLimited, setIsLimited] = useState(false)
const [isLimitExceeded, setIsLimitExceeded] = useState(false)
const [usedSize, setUsedSize] = useState(licenseLimit.workspace_members.size ?? 0)
@ -74,21 +70,28 @@ const InviteModal = ({
catch { }
}
else {
notify({ type: 'error', message: t('members.emailInvalid', { ns: 'common' }) })
toast.error(t('members.emailInvalid', { ns: 'common' }))
}
setIsSubmitted()
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, t, isSubmitting, refreshLicenseLimit, setIsSubmitted, setIsSubmitting])
return (
<div className={cn(s.wrap)}>
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
<div className="mb-2 flex justify-between">
<div className="text-xl font-semibold text-text-primary">{t('members.inviteTeamMember', { ns: 'common' })}</div>
<div
data-testid="invite-modal-close"
className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary"
onClick={onCancel}
/>
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[400px] overflow-visible px-8 py-6"
>
<DialogCloseButton data-testid="invite-modal-close" className="right-8 top-6" />
<div className="mb-2 pr-8">
<DialogTitle className="text-xl font-semibold text-text-primary">
{t('members.inviteTeamMember', { ns: 'common' })}
</DialogTitle>
</div>
<div className="mb-3 text-[13px] text-text-tertiary">{t('members.inviteTeamMemberTip', { ns: 'common' })}</div>
{!isEmailSetup && (
@ -152,8 +155,8 @@ const InviteModal = ({
{t('members.sendInvite', { ns: 'common' })}
</Button>
</div>
</Modal>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,11 +1,10 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@ -25,115 +24,111 @@ export type RoleSelectorProps = {
const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { datasetOperatorEnabled } = useProviderContext()
const [open, setOpen] = React.useState(false)
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => setOpen(v => !v)}
className="block"
>
<PopoverTrigger
data-testid="role-selector-trigger"
className={cn(
'flex w-full cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover',
open && 'bg-state-base-hover',
)}
>
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
</PopoverTrigger>
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg"
>
<div className="p-1">
<div
data-testid="role-selector-trigger"
className={cn('flex cursor-pointer items-center rounded-lg bg-components-input-bg-normal px-3 py-2 hover:bg-state-base-hover', open && 'bg-state-base-hover')}
data-testid="role-option-normal"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('normal')
setOpen(false)
}}
>
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
<div className="i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-secondary" />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1002]">
<div className="relative w-[336px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div
data-testid="role-option-normal"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('normal')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
{value === 'normal' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
<div
data-testid="role-option-editor"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('editor')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
{value === 'editor' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
<div
data-testid="role-option-admin"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('admin')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
{value === 'admin' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
{datasetOperatorEnabled && (
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.normal', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.normalTip', { ns: 'common' })}</div>
{value === 'normal' && (
<div
data-testid="role-option-dataset_operator"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('dataset_operator')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
{value === 'dataset_operator' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
<div
data-testid="role-option-editor"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('editor')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.editor', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.editorTip', { ns: 'common' })}</div>
{value === 'editor' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
<div
data-testid="role-option-admin"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('admin')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.admin', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.adminTip', { ns: 'common' })}</div>
{value === 'admin' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
{datasetOperatorEnabled && (
<div
data-testid="role-option-dataset_operator"
className="cursor-pointer rounded-lg p-2 hover:bg-state-base-hover"
onClick={() => {
onChange('dataset_operator')
setOpen(false)
}}
>
<div className="relative pl-5">
<div className="text-sm leading-5 text-text-secondary">{t('members.datasetOperator', { ns: 'common' })}</div>
<div className="text-xs leading-[18px] text-text-tertiary">{t('members.datasetOperatorTip', { ns: 'common' })}</div>
{value === 'dataset_operator' && (
<div
data-testid="role-option-check"
className="i-custom-vender-line-general-check absolute left-0 top-0.5 h-4 w-4 text-text-accent"
/>
)}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}

View File

@ -1,15 +1,10 @@
import type { InvitationResult } from '@/models/common'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { CheckCircleIcon } from '@heroicons/react/24/solid'
import { RiQuestionLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Modal from '@/app/components/base/modal'
import Tooltip from '@/app/components/base/tooltip'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { IS_CE_EDITION } from '@/config'
import s from './index.module.css'
import InvitationLink from './invitation-link'
export type SuccessInvitationResult = Extract<InvitationResult, { status: 'success' }>
@ -29,8 +24,18 @@ const InvitedModal = ({
const failedInvitationResults = useMemo<FailedInvitationResult[]>(() => invitationResults?.filter(item => item.status !== 'success') as FailedInvitationResult[], [invitationResults])
return (
<div className={s.wrap}>
<Modal isShow onClose={noop} className={s.modal}>
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[480px] p-8"
>
<DialogCloseButton className="right-8 top-8" />
<div className="mb-3 flex justify-between">
<div className="
flex h-12 w-12 items-center justify-center rounded-xl
@ -38,11 +43,10 @@ const InvitedModal = ({
shadow-xl
"
>
<CheckCircleIcon className="h-[22px] w-[22px] text-[#039855]" />
<div className="i-heroicons-check-circle-solid h-[22px] w-[22px] text-[#039855]" />
</div>
<XMarkIcon className="h-4 w-4 cursor-pointer" onClick={onCancel} />
</div>
<div className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</div>
<DialogTitle className="mb-1 text-xl font-semibold text-text-primary">{t('members.invitationSent', { ns: 'common' })}</DialogTitle>
{!IS_CE_EDITION && (
<div className="mb-10 text-sm text-text-tertiary">{t('members.invitationSentTip', { ns: 'common' })}</div>
)}
@ -54,7 +58,7 @@ const InvitedModal = ({
!!successInvitationResults.length
&& (
<>
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
<div className="py-2 text-sm font-medium text-text-primary">{t('members.invitationLink', { ns: 'common' })}</div>
{successInvitationResults.map(item =>
<InvitationLink key={item.email} value={item} />)}
</>
@ -64,18 +68,23 @@ const InvitedModal = ({
!!failedInvitationResults.length
&& (
<>
<div className="font-Medium py-2 text-sm text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
<div className="py-2 text-sm font-medium text-text-primary">{t('members.failedInvitationEmails', { ns: 'common' })}</div>
<div className="flex flex-wrap justify-between gap-y-1">
{
failedInvitationResults.map(item => (
<div key={item.email} className="flex justify-center rounded-md border border-red-300 bg-orange-50 px-1">
<Tooltip
popupContent={item.message}
>
<div className="flex items-center justify-center gap-1 text-sm">
{item.email}
<RiQuestionLine className="h-4 w-4 text-red-300" />
</div>
<Tooltip>
<TooltipTrigger
render={(
<div className="flex items-center justify-center gap-1 text-sm">
{item.email}
<div className="i-ri-question-line h-4 w-4 text-red-300" />
</div>
)}
/>
<TooltipContent>
{item.message}
</TooltipContent>
</Tooltip>
</div>
),
@ -97,8 +106,8 @@ const InvitedModal = ({
{t('members.ok', { ns: 'common' })}
</Button>
</div>
</Modal>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -4,7 +4,7 @@ import copy from 'copy-to-clipboard'
import { t } from 'i18next'
import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import s from './index.module.css'
type IInvitationLinkProps = {
@ -38,20 +38,28 @@ const InvitationLink = ({
<div className="flex items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal py-2 hover:bg-state-base-hover" data-testid="invitation-link-container">
<div className="flex h-5 grow items-center">
<div className="relative h-full grow text-[13px]">
<Tooltip
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
>
<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>
<Tooltip>
<TooltipTrigger
render={<div className="absolute left-0 right-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle} data-testid="invitation-link-url">{value.url}</div>}
/>
<TooltipContent>
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
</TooltipContent>
</Tooltip>
</div>
<div className="h-4 shrink-0 border bg-divider-regular" />
<Tooltip
popupContent={isCopied ? `${t('copied', { ns: 'appApi' })}` : `${t('copy', { ns: 'appApi' })}`}
>
<div className="shrink-0 px-0.5">
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
</div>
</div>
<Tooltip>
<TooltipTrigger
render={(
<div className="shrink-0 px-0.5">
<div className={`box-border flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover ${s.copyIcon} ${isCopied ? s.copied : ''}`} onClick={copyHandle} data-testid="invitation-link-copy">
</div>
</div>
)}
/>
<TooltipContent>
{isCopied ? t('copied', { ns: 'appApi' }) : t('copy', { ns: 'appApi' })}
</TooltipContent>
</Tooltip>
</div>
</div>