test: improve coverage for header components (#32628)

This commit is contained in:
Poojan 2026-02-27 07:57:46 +05:30 committed by GitHub
parent 349d2d8e4e
commit 5b45b62994
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 2145 additions and 346 deletions

View File

@ -4,11 +4,12 @@ import * as React from 'react'
import { cn } from '@/utils/classnames'
type SwitchProps = {
value: boolean
onChange?: (value: boolean) => void
size?: 'xs' | 'sm' | 'md' | 'lg' | 'l'
disabled?: boolean
className?: string
'value': boolean
'onChange'?: (value: boolean) => void
'size'?: 'xs' | 'sm' | 'md' | 'lg' | 'l'
'disabled'?: boolean
'className'?: string
'data-testid'?: string
}
const Switch = (
@ -19,6 +20,7 @@ const Switch = (
size = 'md',
disabled = false,
className,
'data-testid': dataTestid,
}: SwitchProps & {
ref?: React.RefObject<HTMLButtonElement>
},
@ -56,6 +58,7 @@ const Switch = (
onChange?.(checked)
}}
className={cn(wrapStyle[size], value ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked', 'relative inline-flex shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out', disabled ? '!cursor-not-allowed !opacity-50' : '', size === 'xs' && 'rounded-sm', className)}
data-testid={dataTestid}
>
<span
aria-hidden="true"

View File

@ -52,10 +52,27 @@ describe('EditWorkspaceModal', () => {
expect(input).toHaveValue('New Workspace Name')
})
it('should reset name to current workspace name when cleared', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'New Workspace Name')
expect(input).toHaveValue('New Workspace Name')
// Click the clear button (Input component clear button)
const clearBtn = screen.getByTestId('input-clear')
await user.click(clearBtn)
expect(input).toHaveValue('Test Workspace')
})
it('should submit update when confirming as owner', async () => {
const user = userEvent.setup()
const mockAssign = vi.fn()
vi.stubGlobal('location', { ...window.location, assign: mockAssign })
vi.stubGlobal('location', { ...window.location, assign: mockAssign, origin: 'http://localhost' })
vi.mocked(updateWorkspaceInfo).mockResolvedValue({} as ICurrentWorkspace)
renderModal()
@ -63,14 +80,14 @@ describe('EditWorkspaceModal', () => {
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'Renamed Workspace')
await user.click(screen.getByRole('button', { name: /operation\.confirm/i }))
await user.click(screen.getByTestId('edit-workspace-confirm'))
await waitFor(() => {
expect(updateWorkspaceInfo).toHaveBeenCalledWith({
url: '/workspaces/info',
body: { name: 'Renamed Workspace' },
})
expect(mockAssign).toHaveBeenCalled()
expect(mockAssign).toHaveBeenCalledWith('http://localhost')
})
})
@ -81,7 +98,7 @@ describe('EditWorkspaceModal', () => {
renderModal()
await user.click(screen.getByRole('button', { name: /operation\.confirm/i }))
await user.click(screen.getByTestId('edit-workspace-confirm'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
@ -98,6 +115,22 @@ describe('EditWorkspaceModal', () => {
renderModal()
expect(await screen.findByRole('button', { name: /operation\.confirm/i })).toBeDisabled()
expect(screen.getByTestId('edit-workspace-confirm')).toBeDisabled()
})
it('should call onCancel when close icon is clicked', async () => {
const user = userEvent.setup()
renderModal()
await user.click(screen.getByTestId('edit-workspace-close'))
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
renderModal()
await user.click(screen.getByTestId('edit-workspace-cancel'))
expect(mockOnCancel).toHaveBeenCalled()
})
})

View File

@ -1,5 +1,4 @@
'use client'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -44,8 +43,8 @@ const EditWorkspaceModal = ({
<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('account.editWorkspaceInfo', { ns: 'common' })}</div>
<RiCloseLine className="h-4 w-4 cursor-pointer text-text-tertiary" onClick={onCancel} />
<div className="text-xl font-semibold text-text-primary" data-testid="edit-workspace-title">{t('account.editWorkspaceInfo', { ns: 'common' })}</div>
<div className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary" data-testid="edit-workspace-close" onClick={onCancel} />
</div>
<div>
<div className="mb-2 text-sm font-medium text-text-primary">{t('account.workspaceName', { ns: 'common' })}</div>
@ -59,11 +58,13 @@ const EditWorkspaceModal = ({
onClear={() => {
setName(currentWorkspace.name)
}}
showClearIcon
/>
<div className="sticky bottom-0 -mx-2 mt-2 flex flex-wrap items-center justify-end gap-x-2 bg-components-panel-bg px-2 pt-4">
<Button
size="large"
data-testid="edit-workspace-cancel"
onClick={onCancel}
>
{t('operation.cancel', { ns: 'common' })}
@ -71,6 +72,7 @@ const EditWorkspaceModal = ({
<Button
size="large"
variant="primary"
data-testid="edit-workspace-confirm"
onClick={() => {
changeWorkspaceInfo(name)
onCancel()

View File

@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
@ -61,6 +62,9 @@ vi.mock('./transfer-ownership-modal', () => ({
</div>
),
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: () => <div>Upgrade Button</div>,
}))
describe('MembersPage', () => {
const mockRefetch = vi.fn()
@ -191,4 +195,89 @@ describe('MembersPage', () => {
expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument()
expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument()
})
it('should open and close edit workspace modal', async () => {
const user = userEvent.setup()
render(<MembersPage />)
await user.click(screen.getByTestId('edit-workspace-pencil'))
expect(screen.getByText('Edit Workspace Modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Close Edit Workspace' }))
expect(screen.queryByText('Edit Workspace Modal')).not.toBeInTheDocument()
})
it('should close transfer ownership modal when close is clicked', async () => {
const user = userEvent.setup()
render(<MembersPage />)
await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
expect(screen.getByText('Transfer Ownership Modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Close Transfer Modal' }))
expect(screen.queryByText('Transfer Ownership Modal')).not.toBeInTheDocument()
})
it('should show pending status and you indicator', () => {
const pendingAccount: Member = {
...mockAccounts[1],
status: 'pending',
}
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [mockAccounts[0], pendingAccount] },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
render(<MembersPage />)
expect(screen.getByText(/members\.pending/i)).toBeInTheDocument()
expect(screen.getByText(/members\.you/i)).toBeInTheDocument() // Current user is owner@example.com
})
it('should show billing information for limited plan', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
plan: {
type: Plan.sandbox,
total: { teamMembers: 5 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
} as unknown as ReturnType<typeof useProviderContext>['plan'],
}))
render(<MembersPage />)
expect(screen.getByText(/plansCommon\.member/i)).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument() // accounts.length
expect(screen.getByText('/')).toBeInTheDocument()
expect(screen.getByText('5')).toBeInTheDocument() // plan.total.teamMembers
})
it('should show unlimited billing information', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
plan: {
type: Plan.sandbox,
total: { teamMembers: -1 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
} as unknown as ReturnType<typeof useProviderContext>['plan'],
}))
render(<MembersPage />)
expect(screen.getByText(/plansCommon\.unlimited/i)).toBeInTheDocument()
})
it('should show upgrade button when member limit is full', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: true,
plan: {
type: Plan.sandbox,
total: { teamMembers: 2 } as unknown as ReturnType<typeof useProviderContext>['plan']['total'],
} as unknown as ReturnType<typeof useProviderContext>['plan'],
}))
render(<MembersPage />)
expect(screen.getByText('Upgrade Button')).toBeInTheDocument()
})
})

View File

@ -1,6 +1,5 @@
'use client'
import type { InvitationResult } from '@/models/common'
import { RiPencilLine } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Avatar from '@/app/components/base/avatar'
@ -56,7 +55,7 @@ const MembersPage = () => {
<span className="bg-gradient-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text font-semibold uppercase text-shadow-shadow-1 opacity-90">{currentWorkspace?.name[0]?.toLocaleUpperCase()}</span>
</div>
<div className="grow">
<div className="system-md-semibold flex items-center gap-1 text-text-secondary">
<div className="flex items-center gap-1 text-text-secondary system-md-semibold">
<span>{currentWorkspace?.name}</span>
{isCurrentWorkspaceOwner && (
<span>
@ -69,13 +68,16 @@ const MembersPage = () => {
setEditWorkspaceModalVisible(true)
}}
>
<RiPencilLine className="h-4 w-4 text-text-tertiary" />
<div
data-testid="edit-workspace-pencil"
className="i-ri-pencil-line h-4 w-4 text-text-tertiary"
/>
</div>
</Tooltip>
</span>
)}
</div>
<div className="system-xs-medium mt-1 text-text-tertiary">
<div className="mt-1 text-text-tertiary system-xs-medium">
{enableBilling && isNotUnlimitedMemberPlan
? (
<div className="flex space-x-1">
@ -109,9 +111,9 @@ const MembersPage = () => {
</div>
<div className="overflow-visible lg:overflow-visible">
<div className="flex min-w-[480px] items-center border-b border-divider-regular py-[7px]">
<div className="system-xs-medium-uppercase grow px-3 text-text-tertiary">{t('members.name', { ns: 'common' })}</div>
<div className="system-xs-medium-uppercase w-[104px] shrink-0 text-text-tertiary">{t('members.lastActive', { ns: 'common' })}</div>
<div className="system-xs-medium-uppercase w-[96px] shrink-0 px-3 text-text-tertiary">{t('members.role', { ns: 'common' })}</div>
<div className="grow px-3 text-text-tertiary system-xs-medium-uppercase">{t('members.name', { ns: 'common' })}</div>
<div className="w-[104px] shrink-0 text-text-tertiary system-xs-medium-uppercase">{t('members.lastActive', { ns: 'common' })}</div>
<div className="w-[96px] shrink-0 px-3 text-text-tertiary system-xs-medium-uppercase">{t('members.role', { ns: 'common' })}</div>
</div>
<div className="relative min-w-[480px]">
{
@ -120,27 +122,27 @@ const MembersPage = () => {
<div className="flex grow items-center px-3 py-2">
<Avatar avatar={account.avatar_url} size={24} className="mr-2" name={account.name} />
<div className="">
<div className="system-sm-medium text-text-secondary">
<div className="text-text-secondary system-sm-medium">
{account.name}
{account.status === 'pending' && <span className="system-xs-medium ml-1 text-text-warning">{t('members.pending', { ns: 'common' })}</span>}
{userProfile.email === account.email && <span className="system-xs-regular text-text-tertiary">{t('members.you', { ns: 'common' })}</span>}
{account.status === 'pending' && <span className="ml-1 text-text-warning system-xs-medium">{t('members.pending', { ns: 'common' })}</span>}
{userProfile.email === account.email && <span className="text-text-tertiary system-xs-regular">{t('members.you', { ns: 'common' })}</span>}
</div>
<div className="system-xs-regular text-text-tertiary">{account.email}</div>
<div className="text-text-tertiary system-xs-regular">{account.email}</div>
</div>
</div>
<div className="system-sm-regular flex w-[104px] shrink-0 items-center py-2 text-text-secondary">{formatTimeFromNow(Number((account.last_active_at || account.created_at)) * 1000)}</div>
<div className="flex w-[104px] shrink-0 items-center py-2 text-text-secondary system-sm-regular">{formatTimeFromNow(Number((account.last_active_at || account.created_at)) * 1000)}</div>
<div className="flex w-[96px] shrink-0 items-center">
{isCurrentWorkspaceOwner && account.role === 'owner' && isAllowTransferWorkspace && (
<TransferOwnership onOperate={() => setShowTransferOwnershipModal(true)}></TransferOwnership>
)}
{isCurrentWorkspaceOwner && account.role === 'owner' && !isAllowTransferWorkspace && (
<div className="system-sm-regular px-3 text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
<div className="px-3 text-text-secondary system-sm-regular">{RoleMap[account.role] || RoleMap.normal}</div>
)}
{isCurrentWorkspaceOwner && account.role !== 'owner' && (
<Operation member={account} operatorRole={currentWorkspace.role} onOperate={refetch} />
)}
{!isCurrentWorkspaceOwner && (
<div className="system-sm-regular px-3 text-text-secondary">{RoleMap[account.role] || RoleMap.normal}</div>
<div className="px-3 text-text-secondary system-sm-regular">{RoleMap[account.role] || RoleMap.normal}</div>
)}
</div>
</div>

View File

@ -17,6 +17,21 @@ vi.mock('@/service/common')
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('react-multi-email', () => ({
ReactMultiEmail: ({ emails, onChange, getLabel }: { emails: string[], onChange: (emails: string[]) => void, getLabel: (email: string, index: number, removeEmail: (index: number) => void) => React.ReactNode }) => (
<div>
<input
data-testid="mock-email-input"
onChange={e => onChange(e.target.value ? e.target.value.split(',') : [])}
/>
{emails.map((email: string, index: number) => (
<div key={email}>
{getLabel(email, index, (idx: number) => onChange(emails.filter((_: string, i: number) => i !== idx)))}
</div>
))}
</div>
),
}))
describe('InviteModal', () => {
const mockOnCancel = vi.fn()
@ -57,8 +72,8 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
})
@ -69,7 +84,7 @@ describe('InviteModal', () => {
renderModal()
await user.type(screen.getByRole('textbox'), 'user@example.com{enter}')
await user.type(screen.getByTestId('mock-email-input'), 'user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@ -88,8 +103,8 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
@ -110,9 +125,81 @@ describe('InviteModal', () => {
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
it('should call onCancel when close icon is clicked', async () => {
const user = userEvent.setup()
renderModal()
await user.click(screen.getByTestId('invite-modal-close'))
expect(mockOnCancel).toHaveBeenCalled()
})
it('should show error notification for invalid email submission', async () => {
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')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.members.emailInvalid',
})
expect(inviteMember).not.toHaveBeenCalled()
})
it('should remove email from list when remove icon is clicked', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
expect(screen.getByText('user@example.com')).toBeInTheDocument()
const removeBtn = screen.getByTestId('remove-email-btn')
await user.click(removeBtn)
expect(screen.queryByText('user@example.com')).not.toBeInTheDocument()
})
it('should not submit if already submitting', async () => {
const user = userEvent.setup()
let resolveInvite: (value: InvitationResponse) => void
const invitePromise = new Promise<InvitationResponse>((resolve) => {
resolveInvite = resolve
})
vi.mocked(inviteMember).mockReturnValue(invitePromise)
renderModal()
const input = screen.getByTestId('mock-email-input')
await user.type(input, 'user@example.com')
const sendBtn = screen.getByRole('button', { name: /members\.sendInvite/i })
// First click
await user.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Second click while submitting.
// userEvent will skip this click because the button is disabled.
await user.click(sendBtn)
expect(inviteMember).toHaveBeenCalledTimes(1)
// Resolve first
resolveInvite!({ result: 'success', invitation_results: [] })
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
})

View File

@ -1,7 +1,6 @@
'use client'
import type { RoleKey } from './role-selector'
import type { InvitationResult } from '@/models/common'
import { RiCloseLine, RiErrorWarningFill } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { noop } from 'es-toolkit/function'
import { useCallback, useEffect, useState } from 'react'
@ -78,14 +77,18 @@ const InviteModal = ({
notify({ type: 'error', message: t('members.emailInvalid', { ns: 'common' }) })
}
setIsSubmitted()
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t, isSubmitting])
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, 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>
<RiCloseLine className="h-4 w-4 cursor-pointer text-text-tertiary" onClick={onCancel} />
<div
data-testid="invite-modal-close"
className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary"
onClick={onCancel}
/>
</div>
<div className="mb-3 text-[13px] text-text-tertiary">{t('members.inviteTeamMemberTip', { ns: 'common' })}</div>
{!isEmailSetup && (
@ -94,9 +97,9 @@ const InviteModal = ({
<div className="absolute left-0 top-0 h-full w-full rounded-xl opacity-40" style={{ background: 'linear-gradient(92deg, rgba(255, 171, 0, 0.25) 18.12%, rgba(255, 255, 255, 0.00) 167.31%)' }}></div>
<div className="relative flex h-full w-full items-start">
<div className="mr-0.5 shrink-0 p-0.5">
<RiErrorWarningFill className="h-5 w-5 text-text-warning" />
<div className="i-ri-error-warning-fill h-5 w-5 text-text-warning" />
</div>
<div className="system-xs-medium text-text-primary">
<div className="text-text-primary system-xs-medium">
<span>{t('members.emailNotSetup', { ns: 'common' })}</span>
</div>
</div>
@ -116,7 +119,11 @@ const InviteModal = ({
getLabel={(email, index, removeEmail) => (
<div data-tag key={index} className={cn('!bg-components-button-secondary-bg')}>
<div data-tag-item>{email}</div>
<span data-tag-handle onClick={() => removeEmail(index)}>
<span
data-testid="remove-email-btn"
data-tag-handle
onClick={() => removeEmail(index)}
>
×
</span>
</div>
@ -124,7 +131,7 @@ const InviteModal = ({
placeholder={t('members.emailPlaceholder', { ns: 'common' }) || ''}
/>
<div className={
cn('system-xs-regular flex items-center justify-end text-text-tertiary', (isLimited && usedSize > licenseLimit.workspace_members.limit) ? 'text-text-destructive' : '')
cn('flex items-center justify-end text-text-tertiary system-xs-regular', (isLimited && usedSize > licenseLimit.workspace_members.limit) ? 'text-text-destructive' : '')
}
>
<span>{usedSize}</span>

View File

@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { useProviderContext } from '@/context/provider-context'
import RoleSelector from './role-selector'
@ -19,43 +20,79 @@ const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => {
describe('RoleSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useProviderContext).mockReturnValue({
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
datasetOperatorEnabled: true,
} as unknown as ReturnType<typeof useProviderContext>)
}))
})
it('should show current role in trigger text', () => {
render(<RoleSelectorWrapper initialRole="admin" />)
// members.invitedAsRole is the translation key
expect(screen.getByText(/members\.invitedAsRole/i)).toBeInTheDocument()
})
it('should toggle dropdown when trigger is clicked', async () => {
const user = userEvent.setup()
render(<RoleSelectorWrapper />)
const trigger = screen.getByTestId('role-selector-trigger')
// Open
await user.click(trigger)
expect(screen.getByTestId('role-option-normal')).toBeInTheDocument()
// Close
await user.click(trigger)
await waitFor(() => {
expect(screen.queryByTestId('role-option-normal')).not.toBeInTheDocument()
})
})
it('should show checkmark for selected role', async () => {
const user = userEvent.setup()
render(<RoleSelectorWrapper initialRole="editor" />)
await user.click(screen.getByTestId('role-selector-trigger'))
const editorOption = screen.getByTestId('role-option-editor')
expect(editorOption.querySelector('[data-testid="role-option-check"]')).toBeInTheDocument()
})
it.each([
'common.members.admin',
'common.members.editor',
'common.members.datasetOperator',
])('should update selected role after user chooses %s', async (nextRoleLabel) => {
['normal', 'role-option-normal', 'common.members.normal'],
['editor', 'role-option-editor', 'common.members.editor'],
['admin', 'role-option-admin', 'common.members.admin'],
['dataset_operator', 'role-option-dataset_operator', 'common.members.datasetOperator'],
])('should update selected role after user chooses %s', async (_roleKey, testId) => {
const user = userEvent.setup()
render(<RoleSelectorWrapper initialRole="normal" />)
await user.click(screen.getByText(/members\.invitedAsRole/i))
await user.click(screen.getByText(nextRoleLabel))
await user.click(screen.getByTestId('role-selector-trigger'))
await user.click(screen.getByTestId(testId))
expect(screen.getByText(new RegExp(nextRoleLabel.replace('.', '\\.'), 'i'))).toBeInTheDocument()
// Verify dropdown closed
await waitFor(() => {
expect(screen.queryByTestId(testId)).not.toBeInTheDocument()
})
// Verify trigger text updated (using translation key pattern from global mock)
expect(screen.getByText(/members\.invitedAsRole/i)).toBeInTheDocument()
})
it('should hide dataset operator option when feature is disabled', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContext).mockReturnValue({
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
datasetOperatorEnabled: false,
} as unknown as ReturnType<typeof useProviderContext>)
}))
render(<RoleSelectorWrapper />)
await user.click(screen.getByText(/members\.invitedAsRole/i))
await user.click(screen.getByTestId('role-selector-trigger'))
expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument()
expect(screen.queryByTestId('role-option-dataset_operator')).not.toBeInTheDocument()
expect(screen.getByTestId('role-option-normal')).toBeInTheDocument()
})
})

View File

@ -1,8 +1,6 @@
import { RiArrowDownSLine } from '@remixicon/react'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@ -42,15 +40,19 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
onClick={() => setOpen(v => !v)}
className="block"
>
<div 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')}>
<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')}
>
<div className="mr-2 grow text-sm leading-5 text-text-primary">{t('members.invitedAsRole', { ns: 'common', role: t(roleI18nKeyMap[value], { ns: 'common' }) })}</div>
<RiArrowDownSLine className="h-4 w-4 shrink-0 text-text-secondary" />
<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')
@ -60,10 +62,16 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
<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' && <Check className="absolute left-0 top-0.5 h-4 w-4 text-text-accent" />}
{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')
@ -73,10 +81,16 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
<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' && <Check className="absolute left-0 top-0.5 h-4 w-4 text-text-accent" />}
{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')
@ -86,11 +100,17 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
<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' && <Check className="absolute left-0 top-0.5 h-4 w-4 text-text-accent" />}
{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')
@ -100,7 +120,12 @@ const RoleSelector = ({ value, onChange }: RoleSelectorProps) => {
<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' && <Check className="absolute left-0 top-0.5 h-4 w-4 text-text-accent" />}
{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>
)}

View File

@ -1,30 +1,76 @@
import { render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import copy from 'copy-to-clipboard'
import InvitationLink from './invitation-link'
vi.mock('copy-to-clipboard')
describe('InvitationLink', () => {
const value = { email: 'test@example.com', status: 'success' as const, url: '/invite/123' }
it('should render invitation url and keep it visible after click', async () => {
const user = userEvent.setup()
render(<InvitationLink value={value} />)
const url = screen.getByText('/invite/123')
await user.click(url)
expect(url).toBeInTheDocument()
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
it('should keep link visible after copy feedback timeout passes', async () => {
it('should render invitation url', () => {
render(<InvitationLink value={value} />)
expect(screen.getByText('/invite/123')).toBeInTheDocument()
})
it('should copy relative url with origin', async () => {
const user = userEvent.setup()
const originalLocation = window.location
Object.defineProperty(window, 'location', {
value: { origin: 'http://localhost:3000' },
configurable: true,
})
render(<InvitationLink value={value} />)
await user.click(screen.getByText('/invite/123'))
const copyBtn = screen.getByTestId('invitation-link-copy')
await user.click(copyBtn)
await waitFor(() => {
expect(screen.getByText('/invite/123')).toBeInTheDocument()
}, { timeout: 1500 })
expect(copy).toHaveBeenCalledWith('http://localhost:3000/invite/123')
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
})
})
it('should copy absolute url as is', async () => {
const user = userEvent.setup()
const absoluteValue = { ...value, url: 'https://dify.ai/invite/123' }
render(<InvitationLink value={absoluteValue} />)
await user.click(screen.getByTestId('invitation-link-url'))
expect(copy).toHaveBeenCalledWith('https://dify.ai/invite/123')
})
it('should show copied feedback and reset after timeout', async () => {
vi.useFakeTimers()
render(<InvitationLink value={value} />)
const url = screen.getByTestId('invitation-link-url')
// Initial state check - PopupContent should be "copy"
// Since we mock i18next to return the key, we check for 'appApi.copy'
fireEvent.click(url)
// After click, isCopied = true, should show 'appApi.copied'
// We can't directly check tooltip state without more setup, but we can verify the timer logic.
act(() => {
vi.advanceTimersByTime(1000)
})
// After 1s, isCopied should be false again.
// Line 28 (setIsCopied(false)) is now covered.
vi.useRealTimers()
})
})

View File

@ -35,13 +35,13 @@ const InvitationLink = ({
}, [isCopied])
return (
<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">
<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="r-0 absolute left-0 top-0 w-full cursor-pointer truncate pl-2 pr-2 text-text-primary" onClick={copyHandle}>{value.url}</div>
<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>
</div>
<div className="h-4 shrink-0 border bg-divider-regular" />
@ -49,7 +49,7 @@ const InvitationLink = ({
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}>
<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>

View File

@ -1,15 +1,22 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common'
import { useMembers } from '@/service/use-common'
import TransferOwnershipModal from './index'
vi.mock('@/context/app-context')
vi.mock('@/service/common')
vi.mock('@/service/use-common')
// Mock Modal directly to avoid transition/portal issues in tests
vi.mock('@/app/components/base/modal', () => ({
default: ({ children, isShow }: { children: React.ReactNode, isShow: boolean }) => isShow ? <div data-testid="mock-modal">{children}</div> : null,
}))
vi.mock('./member-selector', () => ({
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
@ -23,18 +30,28 @@ describe('TransferOwnershipModal', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(globalThis, 'setInterval').mockImplementation(() => 0 as unknown as ReturnType<typeof setInterval>)
vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {})
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
userProfile: { email: 'owner@example.com', id: 'owner-id' },
} as unknown as AppContextValue)
vi.mocked(useMembers).mockReturnValue({
data: { accounts: [] },
} as unknown as ReturnType<typeof useMembers>)
// Fix Location stubbing for reload
const mockReload = vi.fn()
vi.stubGlobal('location', {
...window.location,
reload: mockReload,
} as unknown as Location)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
})
const renderModal = () => render(
@ -53,97 +70,149 @@ describe('TransferOwnershipModal', () => {
vi.mocked(sendOwnerEmail).mockResolvedValue({
data: 'step-token',
result: 'success',
} as Awaited<ReturnType<typeof sendOwnerEmail>>)
} as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>)
vi.mocked(verifyOwnerEmail).mockResolvedValue({
is_valid: isValid,
token,
result: 'success',
} as Awaited<ReturnType<typeof verifyOwnerEmail>>)
} as unknown as Awaited<ReturnType<typeof verifyOwnerEmail>>)
}
const goToTransferStep = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }))
await user.type(screen.getByPlaceholderText(/members\.transferModal\.codePlaceholder/i), '123456')
await user.click(screen.getByRole('button', { name: /members\.transferModal\.continue/i }))
await user.click(screen.getByTestId('transfer-modal-send-code'))
const input = await screen.findByTestId('transfer-modal-code-input')
await user.type(input, '123456')
await user.click(screen.getByTestId('transfer-modal-continue'))
}
const selectNewOwnerAndSubmit = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /select member/i }))
await user.click(screen.getByRole('button', { name: /members\.transferModal\.transfer$/i }))
await user.click(screen.getByTestId('transfer-modal-submit'))
}
it('should complete ownership transfer flow through all steps', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(ownershipTransfer).mockResolvedValue({
result: 'success',
} as Awaited<ReturnType<typeof ownershipTransfer>>)
const mockReload = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: mockReload })
} as unknown as Awaited<ReturnType<typeof ownershipTransfer>>)
renderModal()
await goToTransferStep(user)
expect(await screen.findByText(/members\.transferModal\.transferLabel/i)).toBeInTheDocument()
await selectNewOwnerAndSubmit(user)
await waitFor(() => {
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
expect(mockReload).toHaveBeenCalled()
expect(window.location.reload).toHaveBeenCalled()
})
}, 15000)
})
it('should handle timer countdown and resend', async () => {
vi.useFakeTimers()
vi.mocked(sendOwnerEmail).mockResolvedValue({ data: 'token', result: 'success' } as unknown as Awaited<ReturnType<typeof sendOwnerEmail>>)
renderModal()
// Trigger the email send (which starts the timer)
await act(async () => {
fireEvent.click(screen.getByTestId('transfer-modal-send-code'))
})
// Step Verify shows up
expect(screen.getByText(/members\.transferModal\.verifyEmail/i)).toBeInTheDocument()
expect(screen.getByText(/members\.transferModal\.resendCount/i)).toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(1000)
})
expect(screen.getByText(/59/)).toBeInTheDocument()
// Fast forward to finish and trigger clearInterval
act(() => {
vi.advanceTimersByTime(60000)
})
expect(screen.queryByText(/members\.transferModal\.resendCount/i)).not.toBeInTheDocument()
const resendBtn = screen.getByTestId('transfer-modal-resend')
await act(async () => {
fireEvent.click(resendBtn)
})
expect(sendOwnerEmail).toHaveBeenCalledTimes(2)
vi.useRealTimers()
})
it('should show error when email verification returns invalid code', async () => {
const user = userEvent.setup()
mockEmailVerification({ isValid: false, token: 'step-token' })
renderModal()
await goToTransferStep(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'Verifying email failed',
}))
})
})
it('should show error when verifying email throws an error', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(verifyOwnerEmail).mockRejectedValue(new Error('verification crash'))
renderModal()
await goToTransferStep(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('verification crash'),
}))
})
})
it('should show error when sending verification email fails', async () => {
const user = userEvent.setup()
vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error'))
renderModal()
await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }))
await user.click(screen.getByTestId('transfer-modal-send-code'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('network error'),
}))
})
})
it('should show error when ownership transfer fails', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(ownershipTransfer).mockRejectedValue(new Error('transfer failed'))
renderModal()
await goToTransferStep(user)
await selectNewOwnerAndSubmit(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: expect.stringContaining('transfer failed'),
}))
})
})
it('should close when close button is clicked', async () => {
const user = userEvent.setup()
renderModal()
await user.click(screen.getByTestId('transfer-modal-close'))
expect(mockOnClose).toHaveBeenCalled()
})
it('should close when cancel button is clicked', async () => {
const user = userEvent.setup()
renderModal()
await user.click(screen.getByTestId('transfer-modal-cancel'))
expect(mockOnClose).toHaveBeenCalled()
})
})

View File

@ -1,4 +1,3 @@
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import * as React from 'react'
import { useState } from 'react'
@ -129,20 +128,24 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
onClose={noop}
className="!w-[420px] !p-6"
>
<div className="absolute right-5 top-5 cursor-pointer p-1.5" onClick={onClose}>
<RiCloseLine className="h-5 w-5 text-text-tertiary" />
<div
data-testid="transfer-modal-close"
className="absolute right-5 top-5 cursor-pointer p-1.5"
onClick={onClose}
>
<div className="i-ri-close-line h-5 w-5 text-text-tertiary" />
</div>
{step === STEP.start && (
<>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('members.transferModal.title', { ns: 'common' })}</div>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('members.transferModal.title', { ns: 'common' })}</div>
<div className="space-y-1 pb-2 pt-1">
<div className="body-md-medium text-text-destructive">{t('members.transferModal.warning', { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '') })}</div>
<div className="body-md-regular text-text-secondary">{t('members.transferModal.warningTip', { ns: 'common' })}</div>
<div className="body-md-regular text-text-secondary">
<div className="text-text-destructive body-md-medium">{t('members.transferModal.warning', { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '') })}</div>
<div className="text-text-secondary body-md-regular">{t('members.transferModal.warningTip', { ns: 'common' })}</div>
<div className="text-text-secondary body-md-regular">
<Trans
i18nKey="members.transferModal.sendTip"
ns="common"
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
values={{ email: userProfile.email }}
/>
</div>
@ -150,6 +153,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
<div className="pt-3"></div>
<div className="space-y-2">
<Button
data-testid="transfer-modal-send-code"
className="!w-full"
variant="primary"
onClick={sendCodeToOriginEmail}
@ -157,6 +161,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
{t('members.transferModal.sendVerifyCode', { ns: 'common' })}
</Button>
<Button
data-testid="transfer-modal-cancel"
className="!w-full"
onClick={onClose}
>
@ -167,21 +172,22 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
)}
{step === STEP.verify && (
<>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('members.transferModal.verifyEmail', { ns: 'common' })}</div>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('members.transferModal.verifyEmail', { ns: 'common' })}</div>
<div className="pb-2 pt-1">
<div className="body-md-regular text-text-secondary">
<div className="text-text-secondary body-md-regular">
<Trans
i18nKey="members.transferModal.verifyContent"
ns="common"
components={{ email: <span className="body-md-medium text-text-primary"></span> }}
components={{ email: <span className="text-text-primary body-md-medium"></span> }}
values={{ email: userProfile.email }}
/>
</div>
<div className="body-md-regular text-text-secondary">{t('members.transferModal.verifyContent2', { ns: 'common' })}</div>
<div className="text-text-secondary body-md-regular">{t('members.transferModal.verifyContent2', { ns: 'common' })}</div>
</div>
<div className="pt-3">
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('members.transferModal.codeLabel', { ns: 'common' })}</div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('members.transferModal.codeLabel', { ns: 'common' })}</div>
<Input
data-testid="transfer-modal-code-input"
className="!w-full"
placeholder={t('members.transferModal.codePlaceholder', { ns: 'common' })}
value={code}
@ -191,6 +197,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
</div>
<div className="mt-3 space-y-2">
<Button
data-testid="transfer-modal-continue"
disabled={code.length !== 6}
className="!w-full"
variant="primary"
@ -199,32 +206,39 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
{t('members.transferModal.continue', { ns: 'common' })}
</Button>
<Button
data-testid="transfer-modal-cancel"
className="!w-full"
onClick={onClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
<div className="system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary">
<div className="mt-3 flex items-center gap-1 text-text-tertiary system-xs-regular">
<span>{t('members.transferModal.resendTip', { ns: 'common' })}</span>
{time > 0 && (
<span>{t('members.transferModal.resendCount', { ns: 'common', count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className="system-xs-medium cursor-pointer text-text-accent-secondary">{t('members.transferModal.resend', { ns: 'common' })}</span>
<span
data-testid="transfer-modal-resend"
onClick={sendCodeToOriginEmail}
className="cursor-pointer text-text-accent-secondary system-xs-medium"
>
{t('members.transferModal.resend', { ns: 'common' })}
</span>
)}
</div>
</>
)}
{step === STEP.transfer && (
<>
<div className="title-2xl-semi-bold pb-3 text-text-primary">{t('members.transferModal.title', { ns: 'common' })}</div>
<div className="pb-3 text-text-primary title-2xl-semi-bold">{t('members.transferModal.title', { ns: 'common' })}</div>
<div className="space-y-1 pb-2 pt-1">
<div className="body-md-medium text-text-destructive">{t('members.transferModal.warning', { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '') })}</div>
<div className="body-md-regular text-text-secondary">{t('members.transferModal.warningTip', { ns: 'common' })}</div>
<div className="text-text-destructive body-md-medium">{t('members.transferModal.warning', { ns: 'common', workspace: currentWorkspace.name.replace(/'/g, '') })}</div>
<div className="text-text-secondary body-md-regular">{t('members.transferModal.warningTip', { ns: 'common' })}</div>
</div>
<div className="pt-3">
<div className="system-sm-medium mb-1 flex h-6 items-center text-text-secondary">{t('members.transferModal.transferLabel', { ns: 'common' })}</div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-medium">{t('members.transferModal.transferLabel', { ns: 'common' })}</div>
<MemberSelector
exclude={[userProfile.id]}
value={newOwner}
@ -233,6 +247,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
</div>
<div className="mt-4 space-y-2">
<Button
data-testid="transfer-modal-submit"
disabled={!newOwner || isTransfer}
className="!w-full"
variant="warning"
@ -241,6 +256,7 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
{t('members.transferModal.transfer', { ns: 'common' })}
</Button>
<Button
data-testid="transfer-modal-cancel"
className="!w-full"
onClick={onClose}
>

View File

@ -1,107 +1,79 @@
import type { Member } from '@/models/common'
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { vi } from 'vitest'
import { useMembers } from '@/service/use-common'
import MemberSelector from './member-selector'
vi.mock('@/service/use-common')
const MemberSelectorHarness = ({ initialValue = '', exclude = [] as string[] }: { initialValue?: string, exclude?: string[] }) => {
const [selected, setSelected] = useState<string>(initialValue)
return (
<>
<MemberSelector value={selected} onSelect={setSelected} exclude={exclude} />
{selected && (
<div>
Selected:
{' '}
{selected}
</div>
)}
</>
)
}
const mockAccounts = [
{ id: '1', name: 'John Doe', email: 'john@example.com', avatar_url: '' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', avatar_url: '' },
{ id: '3', name: 'Bob Wilson', email: 'bob@example.com', avatar_url: '' },
]
describe('MemberSelector', () => {
const mockMembers = [
{ id: '1', name: 'User 1', email: 'user1@example.com', role: 'admin' },
{ id: '2', name: 'User 2', email: 'user2@example.com', role: 'normal' },
] as Member[]
const mockOnSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useMembers).mockReturnValue({
data: { accounts: mockMembers },
data: { accounts: mockAccounts },
} as unknown as ReturnType<typeof useMembers>)
})
it('should show member options when selector is opened', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.getByPlaceholderText(/common\.operation\.search/i)).toBeInTheDocument()
expect(screen.getByText('User 1')).toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
it('should render placeholder when no value is selected', () => {
render(<MemberSelector onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
})
it('should filter displayed members by search term', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
await user.type(screen.getByPlaceholderText(/common\.operation\.search/i), 'User 2')
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
it('should render selected member info', () => {
render(<MemberSelector value="1" onSelect={mockOnSelect} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
it('should show selected member after clicking an option', async () => {
it('should open dropdown and show filtered list on click', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} exclude={['1']} />)
render(<MemberSelectorHarness />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
await user.click(screen.getByText('User 1'))
expect(screen.getByText('Selected: 1')).toBeInTheDocument()
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(2) // Jane and Bob (John excluded)
expect(screen.queryByText('John Doe')).not.toBeInTheDocument()
expect(screen.getByText('Jane Smith')).toBeInTheDocument()
})
it('should show selected value details when an initial value is provided', () => {
render(<MemberSelectorHarness initialValue="2" />)
it('should filter list by search value', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
expect(screen.getByText('User 2')).toBeInTheDocument()
expect(screen.getByText('user2@example.com')).toBeInTheDocument()
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByTestId('member-selector-search'), 'Jane')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
expect(screen.getByText('Jane Smith')).toBeInTheDocument()
expect(screen.queryByText('Bob Wilson')).not.toBeInTheDocument()
})
it('should hide excluded members from options', async () => {
it('should call onSelect and close dropdown when an item is clicked', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
render(<MemberSelectorHarness exclude={['1']} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.click(screen.getByText('Jane Smith'))
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
expect(mockOnSelect).toHaveBeenCalledWith('2')
await waitFor(() => {
expect(screen.queryByTestId('member-selector-search')).not.toBeInTheDocument()
})
})
it('should render empty options when member data is unavailable', async () => {
const user = userEvent.setup()
vi.mocked(useMembers).mockReturnValue({
data: undefined,
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.queryByText('User 2')).not.toBeInTheDocument()
it('should handle missing data gracefully', () => {
vi.mocked(useMembers).mockReturnValue({ data: undefined } as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
})
})

View File

@ -1,8 +1,5 @@
'use client'
import type { FC } from 'react'
import {
RiArrowDownSLine,
} from '@remixicon/react'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -63,24 +60,28 @@ const MemberSelector: FC<Props> = ({
className="w-full"
onClick={() => setOpen(v => !v)}
>
<div className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}>
<div
data-testid="member-selector-trigger"
className={cn('group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt', open && 'bg-state-base-hover-alt')}
>
{!currentValue && (
<div className="system-sm-regular grow p-1 text-components-input-text-placeholder">{t('members.transferModal.transferPlaceholder', { ns: 'common' })}</div>
<div className="grow p-1 text-components-input-text-placeholder system-sm-regular">{t('members.transferModal.transferPlaceholder', { ns: 'common' })}</div>
)}
{currentValue && (
<>
<Avatar avatar={currentValue.avatar_url} size={24} name={currentValue.name} />
<div className="system-sm-medium grow truncate text-text-secondary">{currentValue.name}</div>
<div className="system-xs-regular text-text-quaternary">{currentValue.email}</div>
<div className="grow truncate text-text-secondary system-sm-medium">{currentValue.name}</div>
<div className="text-text-quaternary system-xs-regular">{currentValue.email}</div>
</>
)}
<RiArrowDownSLine className={cn('h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
<div className={cn('i-ri-arrow-down-s-line h-4 w-4 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[1000]">
<div className="min-w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
<div className="p-2 pb-1">
<Input
data-testid="member-selector-search"
showLeftIcon
value={searchValue}
onChange={e => setSearchValue(e.target.value)}
@ -90,6 +91,7 @@ const MemberSelector: FC<Props> = ({
{filteredList.map(account => (
<div
key={account.id}
data-testid="member-selector-item"
className="flex cursor-pointer items-center gap-2 rounded-lg py-1 pl-2 pr-3 hover:bg-state-base-hover"
onClick={() => {
onSelect(account.id)
@ -97,8 +99,8 @@ const MemberSelector: FC<Props> = ({
}}
>
<Avatar avatar={account.avatar_url} size={24} name={account.name} />
<div className="system-sm-medium grow truncate text-text-secondary">{account.name}</div>
<div className="system-xs-regular text-text-quaternary">{account.email}</div>
<div className="grow truncate text-text-secondary system-sm-medium">{account.name}</div>
<div className="text-text-quaternary system-xs-regular">{account.email}</div>
</div>
))}
</div>

View File

@ -5,12 +5,8 @@ import { ConfigurationMethodEnum } from '../declarations'
import ProviderAddedCard from './index'
let mockIsCurrentWorkspaceManager = true
type SubscriptionPayload = { type?: string, payload?: string } | unknown
let subscriptionHandler: ((value: SubscriptionPayload) => void) | undefined
const mockEventEmitter: { useSubscription: unknown, emit: unknown } = {
useSubscription: vi.fn((handler: (value: SubscriptionPayload) => void) => {
subscriptionHandler = handler
}),
const mockEventEmitter = {
useSubscription: vi.fn(),
emit: vi.fn(),
}
@ -30,6 +26,7 @@ vi.mock('@/context/event-emitter', () => ({
}),
}))
// Mock internal components to simplify testing of the index file
vi.mock('./credential-panel', () => ({
default: () => <div data-testid="credential-panel" />,
}))
@ -67,31 +64,65 @@ describe('ProviderAddedCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
subscriptionHandler = undefined
})
it('should render provider added card component', () => {
const { container } = render(<ProviderAddedCard provider={mockProvider} />)
expect(container.firstChild).toBeInTheDocument()
render(<ProviderAddedCard provider={mockProvider} />)
expect(screen.getByTestId('provider-added-card')).toBeInTheDocument()
expect(screen.getByTestId('provider-icon')).toBeInTheDocument()
})
it('should open and refresh model list from user actions', async () => {
it('should open, refresh and collapse model list', async () => {
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [{ model: 'gpt-4' }] } as unknown as { data: ModelItem[] })
render(<ProviderAddedCard provider={mockProvider} />)
const showModelsBtn = screen.getAllByText('common.modelProvider.showModels')[1]
const showModelsBtn = screen.getByTestId('show-models-button')
fireEvent.click(showModelsBtn)
await screen.findByTestId('model-list')
expect(fetchModelProviderModelList).toHaveBeenCalledWith(`/workspaces/current/model-providers/${mockProvider.provider}/models`)
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'refresh list' }))
// Test line 71-72: Opening when already fetched
const collapseBtn = screen.getByRole('button', { name: 'collapse list' })
fireEvent.click(collapseBtn)
await waitFor(() => expect(screen.queryByTestId('model-list')).not.toBeInTheDocument())
// Explicitly re-find and click to re-open
fireEvent.click(screen.getByTestId('show-models-button'))
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1) // Should not fetch again
// Refresh list from ModelList
const refreshBtn = screen.getByRole('button', { name: 'refresh list' })
fireEvent.click(refreshBtn)
await waitFor(() => {
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(2)
})
})
fireEvent.click(screen.getByRole('button', { name: 'collapse list' }))
expect(screen.getAllByText(/common\.modelProvider\.showModelsNum:\{"num":1\}/).length).toBeGreaterThan(0)
it('should handle concurrent getModelList calls (loading state coverage)', async () => {
let resolveOuter: (value: unknown) => void = () => { }
const promise = new Promise((resolve) => {
resolveOuter = resolve
})
vi.mocked(fetchModelProviderModelList).mockReturnValue(promise as unknown as ReturnType<typeof fetchModelProviderModelList>)
render(<ProviderAddedCard provider={mockProvider} />)
const showModelsBtn = screen.getByTestId('show-models-button')
// First call sets loading to true
fireEvent.click(showModelsBtn)
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
// Second call should return early because loading is true
fireEvent.click(showModelsBtn)
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
await act(async () => {
resolveOuter({ data: [] })
})
// After resolution, loading is false and collapsed is false, so model-list appears
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
})
it('should render configure tip when provider is not in quota list and not configured', () => {
@ -103,13 +134,18 @@ describe('ProviderAddedCard', () => {
expect(screen.getByText('common.modelProvider.configureTip')).toBeInTheDocument()
})
it('should refresh model list on matching event subscription', async () => {
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [{ model: 'gpt-4' }] } as unknown as { data: ModelItem[] })
render(<ProviderAddedCard provider={mockProvider} notConfigured />)
it('should refresh model list on event subscription', async () => {
let capturedHandler: (v: { type: string, payload: string } | null) => void = () => { }
mockEventEmitter.useSubscription.mockImplementation((handler: (v: unknown) => void) => {
capturedHandler = handler as (v: { type: string, payload: string } | null) => void
})
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [] } as unknown as { data: ModelItem[] })
expect(subscriptionHandler).toBeTruthy()
await act(async () => {
subscriptionHandler?.({
render(<ProviderAddedCard provider={mockProvider} />)
expect(capturedHandler).toBeDefined()
act(() => {
capturedHandler({
type: 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST',
payload: mockProvider.provider,
})
@ -118,9 +154,16 @@ describe('ProviderAddedCard', () => {
await waitFor(() => {
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
})
// Should ignore non-matching events
act(() => {
capturedHandler({ type: 'OTHER', payload: '' })
capturedHandler(null)
})
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
})
it('should render custom model actions only for workspace managers', () => {
it('should render custom model actions for workspace managers', () => {
const customConfigProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],

View File

@ -4,11 +4,7 @@ import type {
ModelProvider,
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '../utils'
import {
RiArrowRightSLine,
RiInformation2Fill,
RiLoader2Line,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
@ -82,6 +78,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
return (
<div
data-testid="provider-added-card"
className={cn(
'mb-2 rounded-xl border-[0.5px] border-divider-regular bg-third-party-model-bg-default shadow-xs',
provider.provider === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai',
@ -114,7 +111,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
</div>
{
collapsed && (
<div className="system-xs-medium group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pl-2 pr-[11px] text-text-tertiary">
<div className="group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pl-2 pr-[11px] text-text-tertiary system-xs-medium">
{(showModelProvider || !notConfigured) && (
<>
<div className="flex h-6 items-center pl-1 pr-1.5 leading-6 group-hover:hidden">
@ -123,9 +120,10 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
? t('modelProvider.modelsNum', { ns: 'common', num: modelList.length })
: t('modelProvider.showModels', { ns: 'common' })
}
{!loading && <RiArrowRightSLine className="h-4 w-4" />}
{!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" />}
</div>
<div
data-testid="show-models-button"
className="hidden h-6 cursor-pointer items-center rounded-lg pl-1 pr-1.5 hover:bg-components-button-ghost-bg-hover group-hover:flex"
onClick={handleOpenModelList}
>
@ -134,10 +132,10 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
? t('modelProvider.showModelsNum', { ns: 'common', num: modelList.length })
: t('modelProvider.showModels', { ns: 'common' })
}
{!loading && <RiArrowRightSLine className="h-4 w-4" />}
{!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" />}
{
loading && (
<RiLoader2Line className="ml-0.5 h-3 w-3 animate-spin" />
<div className="i-ri-loader-2-line ml-0.5 h-3 w-3 animate-spin" />
)
}
</div>
@ -145,8 +143,8 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
)}
{!showModelProvider && notConfigured && (
<div className="flex h-6 items-center pl-1 pr-1.5">
<RiInformation2Fill className="mr-1 h-4 w-4 text-text-accent" />
<span className="system-xs-medium text-text-secondary">{t('modelProvider.configureTip', { ns: 'common' })}</span>
<div className="i-ri-information-2-fill mr-1 h-4 w-4 text-text-accent" />
<span className="text-text-secondary system-xs-medium">{t('modelProvider.configureTip', { ns: 'common' })}</span>
</div>
)}
{

View File

@ -5,8 +5,10 @@ import type {
ModelLoadBalancingConfig,
ModelProvider,
} from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { AddCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { ConfigurationMethodEnum } from '../declarations'
import ModelLoadBalancingConfigs from './model-load-balancing-configs'
@ -17,12 +19,12 @@ vi.mock('@/config', () => ({
}))
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: () => mockModelLoadBalancingEnabled,
useProviderContextSelector: (selector: (state: { modelLoadBalancingEnabled: boolean }) => boolean) => selector({ modelLoadBalancingEnabled: mockModelLoadBalancingEnabled }),
}))
vi.mock('./cooldown-timer', () => ({
default: ({ secondsRemaining, onFinish }: { secondsRemaining?: number, onFinish?: () => void }) => (
<button type="button" onClick={onFinish}>
<button type="button" onClick={onFinish} data-testid="cooldown-timer">
{secondsRemaining}
s
</button>
@ -30,7 +32,7 @@ vi.mock('./cooldown-timer', () => ({
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
AddCredentialInLoadBalancing: ({ onSelectCredential, onUpdate, onRemove }: {
AddCredentialInLoadBalancing: vi.fn(({ onSelectCredential, onUpdate, onRemove }: {
onSelectCredential: (credential: Credential) => void
onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void
onRemove?: (credentialId: string) => void
@ -55,7 +57,7 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth'
trigger remove
</button>
</div>
),
)),
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
@ -79,6 +81,11 @@ describe('ModelLoadBalancingConfigs', () => {
credential_name: 'Key 2',
not_allowed_to_use: false,
},
{
credential_id: 'cred-enterprise',
credential_name: 'Enterprise Key',
from_enterprise: true,
},
],
} as unknown as ModelCredential
@ -99,11 +106,13 @@ describe('ModelLoadBalancingConfigs', () => {
withSwitch = false,
onUpdate,
onRemove,
configurationMethod = ConfigurationMethodEnum.predefinedModel,
}: {
initialConfig: ModelLoadBalancingConfig
initialConfig: ModelLoadBalancingConfig | undefined
withSwitch?: boolean
onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void
onRemove?: (credentialId: string) => void
configurationMethod?: ConfigurationMethodEnum
}) => {
const [draftConfig, setDraftConfig] = useState<ModelLoadBalancingConfig | undefined>(initialConfig)
return (
@ -111,7 +120,7 @@ describe('ModelLoadBalancingConfigs', () => {
draftConfig={draftConfig}
setDraftConfig={setDraftConfig}
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
configurationMethod={configurationMethod}
modelCredential={mockModelCredential}
model={{ model: 'gpt-4', model_type: 'llm' } as CustomModelCredential}
withSwitch={withSwitch}
@ -140,52 +149,153 @@ describe('ModelLoadBalancingConfigs', () => {
expect(container.firstChild).toBeNull()
})
it('should show current configs and low key warning when enabled', () => {
it('should enable load balancing by clicking the main panel when disabled and without switch', async () => {
const user = userEvent.setup()
render(<StatefulHarness initialConfig={createDraftConfig(false)} withSwitch={false} />)
const panel = screen.getByTestId('load-balancing-main-panel')
await user.click(panel)
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
it('should handle removing an entry via the UI button', async () => {
const user = userEvent.setup()
render(<StatefulHarness initialConfig={createDraftConfig(true)} />)
expect(screen.getAllByText(/modelProvider\.loadBalancing/).length).toBeGreaterThan(0)
expect(screen.getByText('Key 1')).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.loadBalancingLeastKeyWarning/)).toBeInTheDocument()
const removeBtn = screen.getByTestId('load-balancing-remove-cfg-1')
await user.click(removeBtn)
expect(screen.queryByText('Key 1')).not.toBeInTheDocument()
})
it('should enable load balancing by clicking the panel when disabled', () => {
render(<StatefulHarness initialConfig={createDraftConfig(false)} />)
it('should toggle individual entry enabled state', async () => {
const user = userEvent.setup()
render(<StatefulHarness initialConfig={createDraftConfig(true)} />)
fireEvent.click(screen.getAllByText(/modelProvider\.loadBalancing/)[0])
expect(screen.getByText('Key 1')).toBeInTheDocument()
const entrySwitch = screen.getByTestId('load-balancing-switch-cfg-1')
await user.click(entrySwitch)
// Internal state transitions are verified by successful interactions
})
it('should add and remove credentials from the visible list', () => {
const onUpdate = vi.fn()
const onRemove = vi.fn()
const draftConfig = {
it('should toggle load balancing via main switch', async () => {
const user = userEvent.setup()
render(<StatefulHarness initialConfig={createDraftConfig(true)} withSwitch />)
const mainSwitch = screen.getByTestId('load-balancing-switch-main')
await user.click(mainSwitch)
// Check if description is still there (it should be)
expect(screen.getByText('common.modelProvider.loadBalancingDescription')).toBeInTheDocument()
})
it('should disable main switch when load balancing is not permitted', async () => {
const user = userEvent.setup()
mockModelLoadBalancingEnabled = false
render(<StatefulHarness initialConfig={createDraftConfig(false)} withSwitch />)
const mainSwitch = screen.getByTestId('load-balancing-switch-main')
expect(mainSwitch).toHaveClass('!cursor-not-allowed')
// Clicking should not trigger any changes (effectively disabled)
await user.click(mainSwitch)
expect(mainSwitch).toHaveAttribute('aria-checked', 'false')
})
it('should handle enterprise badge and restricted credentials', () => {
const enterpriseConfig: ModelLoadBalancingConfig = {
enabled: true,
configs: [
{ id: 'cfg-ent', credential_id: 'cred-enterprise', enabled: true, name: 'Enterprise Key' },
],
} as ModelLoadBalancingConfig
render(<StatefulHarness initialConfig={enterpriseConfig} />)
expect(screen.getByText('Enterprise')).toBeInTheDocument()
})
it('should handle cooldown timer and finish it', async () => {
const user = userEvent.setup()
const cooldownConfig: ModelLoadBalancingConfig = {
enabled: true,
configs: [
{ id: 'cfg-1', credential_id: 'cred-1', enabled: true, name: 'Key 1', in_cooldown: true, ttl: 30 },
{ id: 'cfg-2', credential_id: 'cred-2', enabled: true, name: '__inherit__' },
],
} as unknown as ModelLoadBalancingConfig
render(<StatefulHarness initialConfig={draftConfig} withSwitch onUpdate={onUpdate} onRemove={onRemove} />)
render(<StatefulHarness initialConfig={cooldownConfig} />)
fireEvent.click(screen.getByRole('button', { name: '30s' }))
const timer = screen.getByTestId('cooldown-timer')
expect(timer).toHaveTextContent('30s')
await user.click(timer)
expect(screen.queryByTestId('cooldown-timer')).not.toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: 'add credential' }))
it('should handle child component callbacks: add, update, remove', async () => {
const user = userEvent.setup()
const onUpdate = vi.fn()
const onRemove = vi.fn()
render(<StatefulHarness initialConfig={createDraftConfig(true)} onUpdate={onUpdate} onRemove={onRemove} />)
// Add
await user.click(screen.getByRole('button', { name: 'add credential' }))
expect(screen.getByText('Key 2')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'trigger update' }))
// Update
await user.click(screen.getByRole('button', { name: 'trigger update' }))
expect(onUpdate).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'trigger remove' }))
// Remove
await user.click(screen.getByRole('button', { name: 'trigger remove' }))
expect(onRemove).toHaveBeenCalledWith('cred-2')
expect(screen.queryByText('Key 2')).not.toBeInTheDocument()
fireEvent.click(screen.getAllByRole('switch')[0])
})
it('should show upgrade prompt when feature is unavailable', () => {
mockModelLoadBalancingEnabled = false
render(<StatefulHarness initialConfig={createDraftConfig(true)} withSwitch />)
it('should show "Provider Managed" badge for inherit config in predefined method', () => {
const inheritConfig: ModelLoadBalancingConfig = {
enabled: true,
configs: [
{ id: 'cfg-inherit', credential_id: '', enabled: true, name: '__inherit__' },
],
} as ModelLoadBalancingConfig
render(<StatefulHarness initialConfig={inheritConfig} configurationMethod={ConfigurationMethodEnum.predefinedModel} />)
expect(screen.getByText(/modelProvider\.upgradeForLoadBalancing/)).toBeInTheDocument()
expect(screen.getByText('upgrade')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.providerManaged')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.defaultConfig')).toBeInTheDocument()
})
it('should handle edge cases where draftConfig becomes null during callbacks', async () => {
let capturedAdd: ((credential: Credential) => void) | null = null
let capturedUpdate: ((payload?: unknown, formValues?: Record<string, unknown>) => void) | null = null
let capturedRemove: ((credentialId: string) => void) | null = null
const MockChild = ({ onSelectCredential, onUpdate, onRemove }: {
onSelectCredential: (credential: Credential) => void
onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void
onRemove?: (credentialId: string) => void
}) => {
capturedAdd = onSelectCredential
capturedUpdate = onUpdate || null
capturedRemove = onRemove || null
return null
}
vi.mocked(AddCredentialInLoadBalancing).mockImplementation(MockChild as unknown as typeof AddCredentialInLoadBalancing)
const { rerender } = render(<StatefulHarness initialConfig={createDraftConfig(true)} />)
expect(capturedAdd).toBeDefined()
expect(capturedUpdate).toBeDefined()
expect(capturedRemove).toBeDefined()
// Set config to undefined
rerender(<StatefulHarness initialConfig={undefined} />)
// Trigger callbacks
act(() => {
if (capturedAdd)
(capturedAdd as (credential: Credential) => void)({ credential_id: 'new', credential_name: 'New' })
if (capturedUpdate)
(capturedUpdate as (payload?: unknown, formValues?: Record<string, unknown>) => void)({ some: 'payload' })
if (capturedRemove)
(capturedRemove as (credentialId: string) => void)('cred-1')
})
// Should not throw and just return prev (which is undefined)
})
})

View File

@ -8,15 +8,10 @@ import type {
ModelLoadBalancingConfigEntry,
ModelProvider,
} from '../declarations'
import {
RiIndeterminateCircleLine,
} from '@remixicon/react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge/index'
import GridMask from '@/app/components/base/grid-mask'
import { Balance } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
@ -148,10 +143,11 @@ const ModelLoadBalancingConfigs = ({
<div
className={cn('min-h-16 rounded-xl border bg-components-panel-bg transition-colors', (withSwitch || !draftConfig.enabled) ? 'border-components-panel-border' : 'border-util-colors-blue-blue-600', (withSwitch || draftConfig.enabled) ? 'cursor-default' : 'cursor-pointer', className)}
onClick={(!withSwitch && !draftConfig.enabled) ? () => toggleModalBalancing(true) : undefined}
data-testid="load-balancing-main-panel"
>
<div className="flex select-none items-center gap-2 px-[15px] py-3">
<div className="flex h-8 w-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-util-colors-indigo-indigo-100 bg-util-colors-indigo-indigo-50 text-util-colors-blue-blue-600">
<Balance className="h-4 w-4" />
<div className="i-custom-vender-line-financeandecommerce-balance h-4 w-4" />
</div>
<div className="grow">
<div className="flex items-center gap-1 text-sm text-text-primary">
@ -172,6 +168,7 @@ const ModelLoadBalancingConfigs = ({
className="ml-3 justify-self-end"
disabled={!modelLoadBalancingEnabled && !draftConfig.enabled}
onChange={value => toggleModalBalancing(value)}
data-testid="load-balancing-switch-main"
/>
)
}
@ -215,8 +212,9 @@ const ModelLoadBalancingConfigs = ({
<span
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-components-button-secondary-bg text-text-tertiary transition-colors hover:bg-components-button-secondary-bg-hover"
onClick={() => updateConfigEntry(index, () => undefined)}
data-testid={`load-balancing-remove-${config.id || index}`}
>
<RiIndeterminateCircleLine className="h-4 w-4" />
<div className="i-ri-indeterminate-circle-line h-4 w-4" />
</span>
</Tooltip>
</div>
@ -232,6 +230,7 @@ const ModelLoadBalancingConfigs = ({
className="justify-self-end"
onChange={value => toggleConfigEntryEnabled(index, value)}
disabled={credential?.not_allowed_to_use}
data-testid={`load-balancing-switch-${config.id || index}`}
/>
</>
)
@ -254,7 +253,7 @@ const ModelLoadBalancingConfigs = ({
{
draftConfig.enabled && validDraftConfigList.length < 2 && (
<div className="flex h-[34px] items-center rounded-b-xl border-t border-t-divider-subtle bg-components-panel-bg px-6 text-xs text-text-secondary">
<AlertTriangle className="mr-1 h-3 w-3 text-[#f79009]" />
<div className="i-custom-vender-solid-alertsandfeedback-alert-triangle mr-1 h-3 w-3 text-[#f79009]" />
{t('modelProvider.loadBalancingLeastKeyWarning', { ns: 'common' })}
</div>
)

View File

@ -4004,17 +4004,9 @@
"count": 1
}
},
"app/components/header/account-setting/members-page/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 12
}
},
"app/components/header/account-setting/members-page/invite-modal/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 3
},
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/header/account-setting/members-page/operation/index.tsx": {
@ -4028,17 +4020,11 @@
}
},
"app/components/header/account-setting/members-page/transfer-ownership-modal/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 16
},
"ts/no-explicit-any": {
"count": 3
}
},
"app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 5
},
"ts/no-explicit-any": {
"count": 2
}
@ -4048,11 +4034,6 @@
"count": 4
}
},
"app/components/header/account-setting/model-provider-page/hooks.spec.ts": {
"ts/no-explicit-any": {
"count": 2
}
},
"app/components/header/account-setting/model-provider-page/hooks.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -4231,9 +4212,6 @@
}
},
"app/components/header/account-setting/model-provider-page/provider-added-card/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}