mirror of
https://github.com/langgenius/dify.git
synced 2026-03-10 11:10:19 +08:00
test: improve coverage for header components (#32628)
This commit is contained in:
parent
349d2d8e4e
commit
5b45b62994
@ -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"
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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],
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
{
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user