mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 08:58:09 +08:00
Merge main HEAD (segment 5) into sandboxed-agent-rebase
Resolve 83 conflicts: 10 backend, 62 frontend, 11 config/lock files. Preserve sandbox/agent/collaboration features while adopting main's UI refactorings (Dialog/AlertDialog/Popover), model provider updates, and enterprise features. Made-with: Cursor
This commit is contained in:
@ -1,10 +1,10 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { vi } from 'vitest'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import HeaderWrapper from './header-wrapper'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import HeaderWrapper from '../header-wrapper'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: vi.fn(),
|
||||
}))
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import Header from './index'
|
||||
import Header from '../index'
|
||||
|
||||
function createMockComponent(testId: string) {
|
||||
return () => <div data-testid={testId} />
|
||||
@ -52,7 +52,7 @@ vi.mock('@/context/workspace-context-provider', () => ({
|
||||
WorkspaceProvider: ({ children }: { children?: React.ReactNode }) => children,
|
||||
}))
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({ children, href }: { children?: React.ReactNode, href?: string }) => <a href={href}>{children}</a>,
|
||||
}))
|
||||
|
||||
@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { NOTICE_I18N } from '@/i18n-config/language'
|
||||
import MaintenanceNotice from './maintenance-notice'
|
||||
import MaintenanceNotice from '../maintenance-notice'
|
||||
|
||||
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
|
||||
X: ({ onClick }: { onClick?: () => void }) => <button type="button" aria-label="close notice" onClick={onClick} />,
|
||||
@ -2,7 +2,7 @@ import type { LangGeniusVersionResponse } from '@/models/common'
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import AccountAbout from './index'
|
||||
import AccountAbout from '../index'
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: vi.fn(),
|
||||
@ -2,15 +2,15 @@
|
||||
import type { LangGeniusVersionResponse } from '@/models/common'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
import Link from '@/next/link'
|
||||
|
||||
type IAccountSettingProps = {
|
||||
langGeniusVersionInfo: LangGeniusVersionResponse
|
||||
onCancel: () => void
|
||||
|
||||
@ -8,8 +8,8 @@ import { useModalContext } from '@/context/modal-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import { getDocDownloadUrl } from '@/service/common'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import Toast from '../../base/toast'
|
||||
import Compliance from './compliance'
|
||||
import Toast from '../../../base/toast'
|
||||
import Compliance from '../compliance'
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
@ -3,20 +3,20 @@ import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import AppSelector from './index'
|
||||
import AppSelector from '../index'
|
||||
|
||||
vi.mock('../account-setting', () => ({
|
||||
vi.mock('../../account-setting', () => ({
|
||||
default: () => <div data-testid="account-setting">AccountSetting</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../account-about', () => ({
|
||||
vi.mock('../../account-about', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div data-testid="account-about">
|
||||
Version
|
||||
@ -53,8 +53,8 @@ vi.mock('@/service/use-common', () => ({
|
||||
useLogout: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/navigation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('next/navigation')>()
|
||||
vi.mock('@/next/navigation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/next/navigation')>()
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(),
|
||||
@ -5,7 +5,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/app/co
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import Support from './support'
|
||||
import Support from '../support'
|
||||
|
||||
const { mockZendeskKey } = vi.hoisted(() => ({
|
||||
mockZendeskKey: { value: 'test-key' },
|
||||
@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
@ -18,6 +16,8 @@ import { useDocLink } from '@/context/i18n'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { env } from '@/env'
|
||||
import Link from '@/next/link'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import AccountAbout from '../account-about'
|
||||
|
||||
@ -5,7 +5,7 @@ import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import { useWorkspacesContext } from '@/context/workspace-context'
|
||||
import { switchWorkspace } from '@/service/common'
|
||||
import WorkplaceSelector from './index'
|
||||
import WorkplaceSelector from '../index'
|
||||
|
||||
vi.mock('@/context/workspace-context', () => ({
|
||||
useWorkspacesContext: vi.fn(),
|
||||
@ -1,7 +1,7 @@
|
||||
import type { AccountIntegrate } from '@/models/common'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useAccountIntegrates } from '@/service/use-common'
|
||||
import IntegrationsPage from './index'
|
||||
import IntegrationsPage from '../index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useAccountIntegrates: vi.fn(),
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from '@/next/link'
|
||||
import { useAccountIntegrates } from '@/service/use-common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from './index.module.css'
|
||||
|
||||
@ -3,7 +3,7 @@ import {
|
||||
ACCOUNT_SETTING_TAB,
|
||||
DEFAULT_ACCOUNT_SETTING_TAB,
|
||||
isValidAccountSettingTab,
|
||||
} from './constants'
|
||||
} from '../constants'
|
||||
|
||||
describe('AccountSetting Constants', () => {
|
||||
it('should have correct ACCOUNT_SETTING_MODAL_ACTION', () => {
|
||||
@ -1,11 +1,15 @@
|
||||
import type { AccountSettingTab } from '../constants'
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { ACCOUNT_SETTING_TAB } from './constants'
|
||||
import AccountSetting from './index'
|
||||
import { ACCOUNT_SETTING_TAB } from '../constants'
|
||||
import AccountSetting from '../index'
|
||||
|
||||
const mockResetModelProviderListExpanded = vi.fn()
|
||||
|
||||
vi.mock('@/context/provider-context', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/context/provider-context')>()
|
||||
@ -23,7 +27,7 @@ vi.mock('@/context/app-context', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
@ -47,10 +51,15 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
|
||||
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
|
||||
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
|
||||
useUpdateModelList: vi.fn(() => vi.fn()),
|
||||
useInvalidateDefaultModel: vi.fn(() => vi.fn()),
|
||||
useModelList: vi.fn(() => ({ data: [], isLoading: false })),
|
||||
useSystemDefaultModelAndModelList: vi.fn(() => [null, vi.fn()]),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/atoms', () => ({
|
||||
useResetModelProviderListExpanded: () => mockResetModelProviderListExpanded,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-datasource', () => ({
|
||||
useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
|
||||
}))
|
||||
@ -105,6 +114,38 @@ const baseAppContextValue: AppContextValue = {
|
||||
describe('AccountSetting', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnTabChange = vi.fn()
|
||||
const renderAccountSetting = (props?: {
|
||||
initialTab?: AccountSettingTab
|
||||
onCancel?: () => void
|
||||
onTabChange?: (tab: AccountSettingTab) => void
|
||||
}) => {
|
||||
const {
|
||||
initialTab = ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
onCancel = mockOnCancel,
|
||||
onTabChange = mockOnTabChange,
|
||||
} = props ?? {}
|
||||
|
||||
const StatefulAccountSetting = () => {
|
||||
const [activeTab, setActiveTab] = useState<AccountSettingTab>(initialTab)
|
||||
|
||||
return (
|
||||
<AccountSetting
|
||||
onCancelAction={onCancel}
|
||||
activeTab={activeTab}
|
||||
onTabChangeAction={(tab) => {
|
||||
setActiveTab(tab)
|
||||
onTabChange(tab)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<StatefulAccountSetting />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -120,11 +161,7 @@ describe('AccountSetting', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the sidebar with correct menu items', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
|
||||
@ -137,13 +174,9 @@ describe('AccountSetting', () => {
|
||||
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should respect the activeTab prop', () => {
|
||||
it('should respect the initial tab', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} activeTab={ACCOUNT_SETTING_TAB.DATA_SOURCE} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
|
||||
|
||||
// Assert
|
||||
// Check that the active item title is Data Source
|
||||
@ -157,11 +190,7 @@ describe('AccountSetting', () => {
|
||||
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
|
||||
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
// On mobile, the labels should not be rendered as per the implementation
|
||||
@ -176,11 +205,7 @@ describe('AccountSetting', () => {
|
||||
})
|
||||
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
|
||||
@ -197,11 +222,7 @@ describe('AccountSetting', () => {
|
||||
})
|
||||
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByText('common.settings.billing')).not.toBeInTheDocument()
|
||||
@ -212,11 +233,7 @@ describe('AccountSetting', () => {
|
||||
describe('Tab Navigation', () => {
|
||||
it('should change active tab when clicking on menu item', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} onTabChange={mockOnTabChange} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting({ onTabChange: mockOnTabChange })
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.settings.provider'))
|
||||
@ -229,11 +246,7 @@ describe('AccountSetting', () => {
|
||||
|
||||
it('should navigate through various tabs and show correct details', () => {
|
||||
// Act & Assert
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
|
||||
// Billing
|
||||
fireEvent.click(screen.getByText('common.settings.billing'))
|
||||
@ -267,13 +280,11 @@ describe('AccountSetting', () => {
|
||||
describe('Interactions', () => {
|
||||
it('should call onCancel when clicking close button', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0])
|
||||
renderAccountSetting()
|
||||
const closeIcon = document.querySelector('.i-ri-close-line')
|
||||
const closeButton = closeIcon?.closest('button')
|
||||
expect(closeButton).not.toBeNull()
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
@ -281,11 +292,7 @@ describe('AccountSetting', () => {
|
||||
|
||||
it('should call onCancel when pressing Escape key', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
// Assert
|
||||
@ -294,12 +301,7 @@ describe('AccountSetting', () => {
|
||||
|
||||
it('should update search value in provider tab', () => {
|
||||
// Arrange
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
fireEvent.click(screen.getByText('common.settings.provider'))
|
||||
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
|
||||
|
||||
// Act
|
||||
const input = screen.getByRole('textbox')
|
||||
@ -312,11 +314,7 @@ describe('AccountSetting', () => {
|
||||
|
||||
it('should handle scroll event in panel', () => {
|
||||
// Act
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AccountSetting onCancel={mockOnCancel} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
renderAccountSetting()
|
||||
const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
|
||||
|
||||
// Assert
|
||||
@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import MenuDialog from '../menu-dialog'
|
||||
|
||||
type DialogProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: DialogProps['onOpenChange']
|
||||
|
||||
vi.mock('@/app/components/base/ui/dialog', () => ({
|
||||
Dialog: ({ children, onOpenChange }: DialogProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div data-testid="dialog">{children}</div>
|
||||
},
|
||||
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('MenuDialog dialog lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
})
|
||||
|
||||
it('should only call onClose when the dialog requests closing', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<MenuDialog show={true} onClose={onClose}>
|
||||
<div>Content</div>
|
||||
</MenuDialog>,
|
||||
)
|
||||
|
||||
latestOnOpenChange?.(true)
|
||||
latestOnOpenChange?.(false)
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import MenuDialog from './menu-dialog'
|
||||
import MenuDialog from '../menu-dialog'
|
||||
|
||||
describe('MenuDialog', () => {
|
||||
beforeEach(() => {
|
||||
@ -40,8 +40,7 @@ describe('MenuDialog', () => {
|
||||
)
|
||||
|
||||
// Assert
|
||||
const panel = screen.getByRole('dialog').querySelector('.custom-class')
|
||||
expect(panel).toBeInTheDocument()
|
||||
expect(screen.getByRole('dialog')).toHaveClass('custom-class')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import Empty from './empty'
|
||||
import Empty from '../empty'
|
||||
|
||||
describe('Empty State', () => {
|
||||
describe('Rendering', () => {
|
||||
@ -4,7 +4,7 @@ import type { ApiBasedExtension } from '@/models/common'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionPage from './index'
|
||||
import ApiBasedExtensionPage from '../index'
|
||||
|
||||
vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(),
|
||||
@ -5,7 +5,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import * as reactI18next from 'react-i18next'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { deleteApiBasedExtension } from '@/service/common'
|
||||
import Item from './item'
|
||||
import Item from '../item'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
@ -5,7 +5,7 @@ import * as reactI18next from 'react-i18next'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
|
||||
import ApiBasedExtensionModal from './modal'
|
||||
import ApiBasedExtensionModal from '../modal'
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: vi.fn(),
|
||||
@ -5,7 +5,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionSelector from './selector'
|
||||
import ApiBasedExtensionSelector from '../selector'
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
@ -1,6 +1,6 @@
|
||||
import type { IItem } from './index'
|
||||
import type { IItem } from '../index'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Collapse from './index'
|
||||
import Collapse from '../index'
|
||||
|
||||
describe('Collapse', () => {
|
||||
const mockItems: IItem[] = [
|
||||
@ -1,4 +1,4 @@
|
||||
import type { DataSourceAuth } from './types'
|
||||
import type { DataSourceAuth } from '../types'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
|
||||
@ -8,8 +8,8 @@ import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { openOAuthPopup } from '@/hooks/use-oauth'
|
||||
import { useGetDataSourceOAuthUrl, useInvalidDataSourceAuth, useInvalidDataSourceListAuth, useInvalidDefaultDataSourceListAuth } from '@/service/use-datasource'
|
||||
import { useInvalidDataSourceList } from '@/service/use-pipeline'
|
||||
import Card from './card'
|
||||
import { useDataSourceAuthUpdate } from './hooks'
|
||||
import Card from '../card'
|
||||
import { useDataSourceAuthUpdate } from '../hooks'
|
||||
|
||||
vi.mock('@/app/components/plugins/plugin-auth', () => ({
|
||||
ApiKeyModal: vi.fn(({ onClose, onUpdate, onRemove, disabled, editValues }: { onClose: () => void, onUpdate: () => void, onRemove: () => void, disabled: boolean, editValues: Record<string, unknown> }) => (
|
||||
@ -43,7 +43,7 @@ vi.mock('@/service/use-datasource', () => ({
|
||||
useInvalidDefaultDataSourceListAuth: vi.fn(() => vi.fn()),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
useDataSourceAuthUpdate: vi.fn(),
|
||||
}))
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import type { DataSourceAuth } from './types'
|
||||
import type { DataSourceAuth } from '../types'
|
||||
import type { FormSchema } from '@/app/components/base/form/types'
|
||||
import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
|
||||
import Configure from './configure'
|
||||
import Configure from '../configure'
|
||||
|
||||
/**
|
||||
* Configure Component Tests
|
||||
@ -1,5 +1,5 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { DataSourceAuth } from './types'
|
||||
import type { DataSourceAuth } from '../types'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
|
||||
@ -7,8 +7,8 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import { useGetDataSourceListAuth, useGetDataSourceOAuthUrl } from '@/service/use-datasource'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from './hooks'
|
||||
import DataSourcePage from './index'
|
||||
import { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from '../hooks'
|
||||
import DataSourcePage from '../index'
|
||||
|
||||
/**
|
||||
* DataSourcePage Component Tests
|
||||
@ -33,7 +33,7 @@ vi.mock('@/service/use-datasource', () => ({
|
||||
useGetDataSourceOAuthUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
useDataSourceAuthUpdate: vi.fn(),
|
||||
useMarketplaceAllPlugins: vi.fn(),
|
||||
}))
|
||||
@ -1,10 +1,10 @@
|
||||
import type { DataSourceAuth } from './types'
|
||||
import type { DataSourceAuth } from '../types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useMarketplaceAllPlugins } from './hooks'
|
||||
import InstallFromMarketplace from './install-from-marketplace'
|
||||
import { useMarketplaceAllPlugins } from '../hooks'
|
||||
import InstallFromMarketplace from '../install-from-marketplace'
|
||||
|
||||
/**
|
||||
* InstallFromMarketplace Component Tests
|
||||
@ -16,7 +16,7 @@ vi.mock('next-themes', () => ({
|
||||
useTheme: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
|
||||
<a href={href} data-testid="mock-link">{children}</a>
|
||||
),
|
||||
@ -54,7 +54,7 @@ vi.mock('@/app/components/plugins/provider-card', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
useMarketplaceAllPlugins: vi.fn(),
|
||||
}))
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { DataSourceCredential } from './types'
|
||||
import type { DataSourceCredential } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
import Item from './item'
|
||||
import Item from '../item'
|
||||
|
||||
/**
|
||||
* Item Component Tests
|
||||
@ -1,7 +1,7 @@
|
||||
import type { DataSourceCredential } from './types'
|
||||
import type { DataSourceCredential } from '../types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
|
||||
import Operator from './operator'
|
||||
import Operator from '../operator'
|
||||
|
||||
/**
|
||||
* Operator Component Tests
|
||||
@ -5,7 +5,7 @@ import {
|
||||
useInvalidDefaultDataSourceListAuth,
|
||||
} from '@/service/use-datasource'
|
||||
import { useInvalidDataSourceList } from '@/service/use-pipeline'
|
||||
import { useDataSourceAuthUpdate } from './use-data-source-auth-update'
|
||||
import { useDataSourceAuthUpdate } from '../use-data-source-auth-update'
|
||||
|
||||
/**
|
||||
* useDataSourceAuthUpdate Hook Tests
|
||||
@ -5,7 +5,7 @@ import {
|
||||
useMarketplacePluginsByCollectionId,
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useMarketplaceAllPlugins } from './use-marketplace-all-plugins'
|
||||
import { useMarketplaceAllPlugins } from '../use-marketplace-all-plugins'
|
||||
|
||||
/**
|
||||
* useMarketplaceAllPlugins Hook Tests
|
||||
@ -4,7 +4,6 @@ import {
|
||||
RiArrowRightUpLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -15,6 +14,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import List from '@/app/components/plugins/marketplace/list'
|
||||
import ProviderCard from '@/app/components/plugins/provider-card'
|
||||
import Link from '@/next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import {
|
||||
|
||||
@ -4,7 +4,7 @@ import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useDataSourceIntegrates, useInvalidDataSourceIntegrates, useNotionConnection } from '@/service/use-common'
|
||||
import DataSourceNotion from './index'
|
||||
import DataSourceNotion from '../index'
|
||||
|
||||
/**
|
||||
* DataSourceNotion Component Tests
|
||||
@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { syncDataSourceNotion, updateDataSourceNotionAction } from '@/service/common'
|
||||
import { useInvalidDataSourceIntegrates } from '@/service/use-common'
|
||||
import Operate from './index'
|
||||
import Operate from '../index'
|
||||
|
||||
/**
|
||||
* Operate Component (Notion) Tests
|
||||
@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import ConfigFirecrawlModal from './config-firecrawl-modal'
|
||||
import ConfigFirecrawlModal from '../config-firecrawl-modal'
|
||||
|
||||
/**
|
||||
* ConfigFirecrawlModal Component Tests
|
||||
@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import ConfigJinaReaderModal from './config-jina-reader-modal'
|
||||
import ConfigJinaReaderModal from '../config-jina-reader-modal'
|
||||
|
||||
/**
|
||||
* ConfigJinaReaderModal Component Tests
|
||||
@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { createDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import ConfigWatercrawlModal from './config-watercrawl-modal'
|
||||
import ConfigWatercrawlModal from '../config-watercrawl-modal'
|
||||
|
||||
/**
|
||||
* ConfigWatercrawlModal Component Tests
|
||||
@ -5,7 +5,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import { fetchDataSources, removeDataSourceApiKeyBinding } from '@/service/datasets'
|
||||
import DataSourceWebsite from './index'
|
||||
import DataSourceWebsite from '../index'
|
||||
|
||||
/**
|
||||
* DataSourceWebsite Component Tests
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ConfigItemType } from './config-item'
|
||||
import type { ConfigItemType } from '../config-item'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ConfigItem from './config-item'
|
||||
import { DataSourceType } from './types'
|
||||
import ConfigItem from '../config-item'
|
||||
import { DataSourceType } from '../types'
|
||||
|
||||
/**
|
||||
* ConfigItem Component Tests
|
||||
@ -9,7 +9,7 @@ import { DataSourceType } from './types'
|
||||
*/
|
||||
|
||||
// Mock Operate component to isolate ConfigItem unit tests.
|
||||
vi.mock('../data-source-notion/operate', () => ({
|
||||
vi.mock('../../data-source-notion/operate', () => ({
|
||||
default: ({ onAuthAgain, payload }: { onAuthAgain: () => void, payload: { id: string, total: number } }) => (
|
||||
<div data-testid="mock-operate">
|
||||
<button onClick={onAuthAgain} data-testid="operate-auth-btn">Auth Again</button>
|
||||
@ -1,15 +1,15 @@
|
||||
import type { ConfigItemType } from './config-item'
|
||||
import type { ConfigItemType } from '../config-item'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DataSourceProvider } from '@/models/common'
|
||||
import Panel from './index'
|
||||
import { DataSourceType } from './types'
|
||||
import Panel from '../index'
|
||||
import { DataSourceType } from '../types'
|
||||
|
||||
/**
|
||||
* Panel Component Tests
|
||||
* Tests layout, conditional rendering, and interactions for data source panels (Notion and Website).
|
||||
*/
|
||||
|
||||
vi.mock('../data-source-notion/operate', () => ({
|
||||
vi.mock('../../data-source-notion/operate', () => ({
|
||||
default: () => <div data-testid="mock-operate" />,
|
||||
}))
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SearchInput from '@/app/components/base/search-input'
|
||||
import BillingPage from '@/app/components/billing/billing-page'
|
||||
@ -21,15 +21,16 @@ import LanguagePage from './language-page'
|
||||
import MembersPage from './members-page'
|
||||
import ModelProviderPage from './model-provider-page'
|
||||
import SandboxProviderPage from './sandbox-provider-page'
|
||||
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
|
||||
|
||||
const iconClassName = `
|
||||
w-5 h-5 mr-2
|
||||
`
|
||||
|
||||
type IAccountSettingProps = {
|
||||
onCancel: () => void
|
||||
activeTab?: AccountSettingTab
|
||||
onTabChange?: (tab: AccountSettingTab) => void
|
||||
onCancelAction: () => void
|
||||
activeTab: AccountSettingTab
|
||||
onTabChangeAction: (tab: AccountSettingTab) => void
|
||||
}
|
||||
|
||||
type GroupItem = {
|
||||
@ -41,14 +42,12 @@ type GroupItem = {
|
||||
}
|
||||
|
||||
export default function AccountSetting({
|
||||
onCancel,
|
||||
activeTab = ACCOUNT_SETTING_TAB.MEMBERS,
|
||||
onTabChange,
|
||||
onCancelAction,
|
||||
activeTab,
|
||||
onTabChangeAction,
|
||||
}: IAccountSettingProps) {
|
||||
const [activeMenu, setActiveMenu] = useState<AccountSettingTab>(activeTab)
|
||||
useEffect(() => {
|
||||
setActiveMenu(activeTab)
|
||||
}, [activeTab])
|
||||
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
|
||||
const activeMenu = activeTab
|
||||
const { t } = useTranslation()
|
||||
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
|
||||
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
@ -155,10 +154,22 @@ export default function AccountSetting({
|
||||
|
||||
const [searchValue, setSearchValue] = useState<string>('')
|
||||
|
||||
const handleTabChange = useCallback((tab: AccountSettingTab) => {
|
||||
if (tab === ACCOUNT_SETTING_TAB.PROVIDER)
|
||||
resetModelProviderListExpanded()
|
||||
|
||||
onTabChangeAction(tab)
|
||||
}, [onTabChangeAction, resetModelProviderListExpanded])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetModelProviderListExpanded()
|
||||
onCancelAction()
|
||||
}, [onCancelAction, resetModelProviderListExpanded])
|
||||
|
||||
return (
|
||||
<MenuDialog
|
||||
show
|
||||
onClose={onCancel}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div className="mx-auto flex h-[100vh] max-w-[1048px]">
|
||||
<div className="flex w-[44px] flex-col border-r border-divider-burn pl-4 pr-6 sm:w-[224px]">
|
||||
@ -173,21 +184,22 @@ export default function AccountSetting({
|
||||
<div>
|
||||
{
|
||||
menuItem.items.map(item => (
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
key={item.key}
|
||||
className={cn(
|
||||
'mb-0.5 flex h-[37px] cursor-pointer items-center rounded-lg p-1 pl-3 text-sm',
|
||||
'mb-0.5 flex h-[37px] w-full items-center rounded-lg p-1 pl-3 text-left text-sm',
|
||||
activeMenu === item.key ? 'bg-state-base-active text-components-menu-item-text-active system-sm-semibold' : 'text-components-menu-item-text system-sm-medium',
|
||||
)}
|
||||
aria-label={item.name}
|
||||
title={item.name}
|
||||
onClick={() => {
|
||||
setActiveMenu(item.key)
|
||||
onTabChange?.(item.key)
|
||||
handleTabChange(item.key)
|
||||
}}
|
||||
>
|
||||
{activeMenu === item.key ? item.activeIcon : item.icon}
|
||||
{!isMobile && <div className="truncate">{item.name}</div>}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
@ -202,7 +214,8 @@ export default function AccountSetting({
|
||||
variant="tertiary"
|
||||
size="large"
|
||||
className="px-2"
|
||||
onClick={onCancel}
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<span className="i-ri-close-line h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { ValidatedStatus } from './declarations'
|
||||
import KeyInput from './KeyInput'
|
||||
import { ValidatedStatus } from '../declarations'
|
||||
import KeyInput from '../KeyInput'
|
||||
|
||||
type Props = ComponentProps<typeof KeyInput>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Operate from './Operate'
|
||||
import Operate from '../Operate'
|
||||
|
||||
describe('Operate', () => {
|
||||
it('should render cancel and save when editing is open', () => {
|
||||
@ -4,7 +4,7 @@ import {
|
||||
ValidatedErrorMessage,
|
||||
ValidatedSuccessIcon,
|
||||
ValidatingTip,
|
||||
} from './ValidateStatus'
|
||||
} from '../ValidateStatus'
|
||||
|
||||
describe('ValidateStatus', () => {
|
||||
beforeEach(() => {
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ValidatedStatus } from './declarations'
|
||||
import { ValidatedStatus } from '../declarations'
|
||||
|
||||
describe('declarations', () => {
|
||||
describe('ValidatedStatus', () => {
|
||||
@ -1,6 +1,6 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { ValidatedStatus } from './declarations'
|
||||
import { useValidate } from './hooks'
|
||||
import { ValidatedStatus } from '../declarations'
|
||||
import { useValidate } from '../hooks'
|
||||
|
||||
describe('useValidate', () => {
|
||||
beforeEach(() => {
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Form } from './declarations'
|
||||
import type { Form } from '../declarations'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import KeyValidator from './index'
|
||||
import KeyValidator from '../index'
|
||||
|
||||
let subscriptionCallback: ((value: string) => void) | null = null
|
||||
const mockEmit = vi.fn((value: string) => {
|
||||
@ -22,7 +22,7 @@ vi.mock('@/context/event-emitter', () => ({
|
||||
const mockValidate = vi.fn()
|
||||
const mockUseValidate = vi.fn()
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
useValidate: (...args: unknown[]) => mockUseValidate(...args),
|
||||
}))
|
||||
|
||||
@ -4,7 +4,7 @@ import { ToastProvider } from '@/app/components/base/toast'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { timezones } from '@/utils/timezone'
|
||||
import LanguagePage from './index'
|
||||
import LanguagePage from '../index'
|
||||
|
||||
const mockRefresh = vi.fn()
|
||||
const mockMutateUserProfile = vi.fn()
|
||||
@ -61,7 +61,7 @@ vi.mock('@/app/components/base/select', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({ refresh: mockRefresh }),
|
||||
}))
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
import type { Item } from '@/app/components/base/select'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
@ -12,6 +11,7 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { setLocaleOnClient } from '@/i18n-config'
|
||||
import { languages } from '@/i18n-config/language'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { updateUserProfile } from '@/service/common'
|
||||
import { timezones } from '@/utils/timezone'
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import MembersPage from './index'
|
||||
import MembersPage from '../index'
|
||||
|
||||
vi.mock('@/context/app-context')
|
||||
vi.mock('@/context/global-public-context')
|
||||
@ -18,7 +18,7 @@ vi.mock('@/context/provider-context')
|
||||
vi.mock('@/hooks/use-format-time-from-now')
|
||||
vi.mock('@/service/use-common')
|
||||
|
||||
vi.mock('./edit-workspace-modal', () => ({
|
||||
vi.mock('../edit-workspace-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div>
|
||||
<div>Edit Workspace Modal</div>
|
||||
@ -26,12 +26,12 @@ vi.mock('./edit-workspace-modal', () => ({
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('./invite-button', () => ({
|
||||
vi.mock('../invite-button', () => ({
|
||||
default: ({ onClick, disabled }: { onClick: () => void, disabled: boolean }) => (
|
||||
<button onClick={onClick} disabled={disabled}>Invite</button>
|
||||
),
|
||||
}))
|
||||
vi.mock('./invite-modal', () => ({
|
||||
vi.mock('../invite-modal', () => ({
|
||||
default: ({ onCancel, onSend }: { onCancel: () => void, onSend: (results: Array<{ email: string, status: 'success', url: string }>) => void }) => (
|
||||
<div>
|
||||
<div>Invite Modal</div>
|
||||
@ -40,7 +40,7 @@ vi.mock('./invite-modal', () => ({
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('./invited-modal', () => ({
|
||||
vi.mock('../invited-modal', () => ({
|
||||
default: ({ onCancel }: { onCancel: () => void }) => (
|
||||
<div>
|
||||
<div>Invited Modal</div>
|
||||
@ -48,13 +48,13 @@ vi.mock('./invited-modal', () => ({
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('./operation', () => ({
|
||||
vi.mock('../operation', () => ({
|
||||
default: () => <div>Member Operation</div>,
|
||||
}))
|
||||
vi.mock('./operation/transfer-ownership', () => ({
|
||||
vi.mock('../operation/transfer-ownership', () => ({
|
||||
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
|
||||
}))
|
||||
vi.mock('./transfer-ownership-modal', () => ({
|
||||
vi.mock('../transfer-ownership-modal', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div>
|
||||
<div>Transfer Ownership Modal</div>
|
||||
@ -5,7 +5,7 @@ import { vi } from 'vitest'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useWorkspacePermissions } from '@/service/use-workspace'
|
||||
import InviteButton from './invite-button'
|
||||
import InviteButton from '../invite-button'
|
||||
|
||||
vi.mock('@/context/app-context')
|
||||
vi.mock('@/context/global-public-context')
|
||||
@ -0,0 +1,57 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import EditWorkspaceModal from '../index'
|
||||
|
||||
type DialogProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
let latestOnOpenChange: DialogProps['onOpenChange']
|
||||
|
||||
vi.mock('@/app/components/base/ui/dialog', () => ({
|
||||
Dialog: ({ children, onOpenChange }: DialogProps) => {
|
||||
latestOnOpenChange = onOpenChange
|
||||
return <div data-testid="dialog">{children}</div>
|
||||
},
|
||||
DialogCloseButton: ({ ...props }: Record<string, unknown>) => <button {...props} />,
|
||||
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children, className }: { children: ReactNode, className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('EditWorkspaceModal dialog lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
latestOnOpenChange = undefined
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
currentWorkspace: { name: 'Test Workspace' },
|
||||
isCurrentWorkspaceOwner: true,
|
||||
} as never)
|
||||
})
|
||||
|
||||
it('should only call onCancel when the dialog requests closing', () => {
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
|
||||
<EditWorkspaceModal onCancel={onCancel} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
|
||||
latestOnOpenChange?.(true)
|
||||
latestOnOpenChange?.(false)
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -1,12 +1,12 @@
|
||||
import type { AppContextValue } from '@/context/app-context'
|
||||
import type { ICurrentWorkspace } from '@/models/common'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { 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/context'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { updateWorkspaceInfo } from '@/service/common'
|
||||
import EditWorkspaceModal from './index'
|
||||
import EditWorkspaceModal from '../index'
|
||||
|
||||
vi.mock('@/context/app-context')
|
||||
vi.mock('@/service/common')
|
||||
@ -40,35 +40,24 @@ describe('EditWorkspaceModal', () => {
|
||||
expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render on the base/ui overlay layer', async () => {
|
||||
renderModal()
|
||||
|
||||
expect(await screen.findByRole('dialog')).toHaveClass('z-[1002]')
|
||||
})
|
||||
|
||||
it('should let user edit workspace name', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
|
||||
const input = screen.getByLabelText(/account\.workspaceName/i)
|
||||
await user.clear(input)
|
||||
await user.type(input, 'New Workspace Name')
|
||||
|
||||
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()
|
||||
@ -77,10 +66,10 @@ describe('EditWorkspaceModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
|
||||
const input = screen.getByLabelText(/account\.workspaceName/i)
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Renamed Workspace')
|
||||
await user.click(screen.getByTestId('edit-workspace-confirm'))
|
||||
await user.click(screen.getByTestId('edit-workspace-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateWorkspaceInfo).toHaveBeenCalledWith({
|
||||
@ -89,6 +78,8 @@ describe('EditWorkspaceModal', () => {
|
||||
})
|
||||
expect(mockAssign).toHaveBeenCalledWith('http://localhost')
|
||||
})
|
||||
|
||||
expect(mockOnCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show error toast when update fails', async () => {
|
||||
@ -98,7 +89,10 @@ describe('EditWorkspaceModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
await user.click(screen.getByTestId('edit-workspace-confirm'))
|
||||
const input = screen.getByLabelText(/account\.workspaceName/i)
|
||||
await user.clear(input)
|
||||
await user.type(input, 'Broken Workspace')
|
||||
await user.click(screen.getByTestId('edit-workspace-save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@ -107,6 +101,40 @@ describe('EditWorkspaceModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should disable save button when there are no changes', async () => {
|
||||
renderModal()
|
||||
|
||||
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable save button and show error when the name is empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
renderModal()
|
||||
|
||||
const input = screen.getByLabelText(/account\.workspaceName/i)
|
||||
await user.clear(input)
|
||||
|
||||
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
|
||||
expect(input).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(screen.getByTestId('edit-workspace-error')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not submit when the form is submitted while save is disabled', async () => {
|
||||
renderModal()
|
||||
|
||||
const saveButton = screen.getByTestId('edit-workspace-save')
|
||||
const form = saveButton.closest('form')
|
||||
|
||||
expect(saveButton).toBeDisabled()
|
||||
expect(form).not.toBeNull()
|
||||
|
||||
fireEvent.submit(form!)
|
||||
|
||||
expect(updateWorkspaceInfo).not.toHaveBeenCalled()
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disable confirm button for non-owners', async () => {
|
||||
vi.mocked(useAppContext).mockReturnValue({
|
||||
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
|
||||
@ -115,7 +143,7 @@ describe('EditWorkspaceModal', () => {
|
||||
|
||||
renderModal()
|
||||
|
||||
expect(screen.getByTestId('edit-workspace-confirm')).toBeDisabled()
|
||||
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onCancel when close icon is clicked', async () => {
|
||||
@ -133,4 +161,14 @@ describe('EditWorkspaceModal', () => {
|
||||
await user.click(screen.getByTestId('edit-workspace-cancel'))
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onCancel when Escape key is pressed', async () => {
|
||||
renderModal()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,20 +1,19 @@
|
||||
'use client'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { useState } from 'react'
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { updateWorkspaceInfo } from '@/service/common'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import s from './index.module.css'
|
||||
|
||||
type IEditWorkspaceModalProps = {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const EditWorkspaceModal = ({
|
||||
onCancel,
|
||||
}: IEditWorkspaceModalProps) => {
|
||||
@ -22,13 +21,33 @@ const EditWorkspaceModal = ({
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
|
||||
const [name, setName] = useState<string>(currentWorkspace.name)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const inputId = useId()
|
||||
const errorId = useId()
|
||||
const normalizedName = name.trim()
|
||||
const hasChanges = normalizedName !== currentWorkspace.name
|
||||
const hasError = normalizedName.length === 0
|
||||
const isSaveDisabled = !isCurrentWorkspaceOwner || !hasChanges || hasError || isSubmitting
|
||||
const nameErrorMessage = useMemo(() => {
|
||||
if (!hasError)
|
||||
return ''
|
||||
|
||||
const changeWorkspaceInfo = async (name: string) => {
|
||||
return t('errorMsg.fieldRequired', {
|
||||
ns: 'common',
|
||||
field: t('account.workspaceName', { ns: 'common' }),
|
||||
})
|
||||
}, [hasError, t])
|
||||
|
||||
const changeWorkspaceInfo = async () => {
|
||||
if (isSaveDisabled)
|
||||
return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await updateWorkspaceInfo({
|
||||
url: '/workspaces/info',
|
||||
body: {
|
||||
name,
|
||||
name: normalizedName,
|
||||
},
|
||||
})
|
||||
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
|
||||
@ -37,33 +56,74 @@ const EditWorkspaceModal = ({
|
||||
catch {
|
||||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
}
|
||||
finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
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" 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>
|
||||
<Input
|
||||
className="mb-2"
|
||||
value={name}
|
||||
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
}}
|
||||
onClear={() => {
|
||||
setName(currentWorkspace.name)
|
||||
}}
|
||||
showClearIcon
|
||||
/>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="overflow-visible"
|
||||
>
|
||||
<DialogCloseButton data-testid="edit-workspace-close" />
|
||||
|
||||
<form
|
||||
className="flex flex-col"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void changeWorkspaceInfo()
|
||||
}}
|
||||
>
|
||||
<div className="mb-4 pr-8">
|
||||
<DialogTitle className="text-xl font-semibold text-text-primary" data-testid="edit-workspace-title">
|
||||
{t('account.editWorkspaceInfo', { ns: 'common' })}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-text-primary">
|
||||
{t('account.workspaceName', { ns: 'common' })}
|
||||
</label>
|
||||
<Input
|
||||
id={inputId}
|
||||
autoFocus
|
||||
value={name}
|
||||
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value)
|
||||
}}
|
||||
aria-invalid={hasError}
|
||||
aria-describedby={hasError ? errorId : undefined}
|
||||
className={cn(
|
||||
hasError && 'border-components-input-border-destructive bg-components-input-bg-destructive hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
|
||||
)}
|
||||
/>
|
||||
<div className="min-h-6">
|
||||
{hasError && (
|
||||
<p
|
||||
id={errorId}
|
||||
data-testid="edit-workspace-error"
|
||||
className="text-text-destructive system-xs-regular"
|
||||
role="alert"
|
||||
>
|
||||
{nameErrorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
type="button"
|
||||
data-testid="edit-workspace-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
@ -71,21 +131,22 @@ const EditWorkspaceModal = ({
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
data-testid="edit-workspace-confirm"
|
||||
onClick={() => {
|
||||
changeWorkspaceInfo(name)
|
||||
onCancel()
|
||||
}}
|
||||
disabled={!isCurrentWorkspaceOwner}
|
||||
data-testid="edit-workspace-save"
|
||||
disabled={isSaveDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
{t(
|
||||
isSubmitting ? 'operation.saving' : 'operation.save',
|
||||
{ ns: 'common' },
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditWorkspaceModal
|
||||
|
||||
@ -3,7 +3,7 @@ import type { InvitationResult } from '@/models/common'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Avatar } from '@/app/components/base/avatar'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import { NUM_INFINITE } from '@/app/components/billing/config'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
@ -59,20 +59,25 @@ const MembersPage = () => {
|
||||
<span>{currentWorkspace?.name}</span>
|
||||
{isCurrentWorkspaceOwner && (
|
||||
<span>
|
||||
<Tooltip
|
||||
popupContent={t('account.editWorkspaceInfo', { ns: 'common' })}
|
||||
>
|
||||
<div
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-black/5"
|
||||
onClick={() => {
|
||||
setEditWorkspaceModalVisible(true)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="edit-workspace-pencil"
|
||||
className="i-ri-pencil-line h-4 w-4 text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div
|
||||
className="cursor-pointer rounded-md p-1 hover:bg-black/5"
|
||||
onClick={() => {
|
||||
setEditWorkspaceModalVisible(true)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="edit-workspace-pencil"
|
||||
className="i-ri-pencil-line h-4 w-4 text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('account.editWorkspaceInfo', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -5,7 +5,7 @@ import { vi } from 'vitest'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { inviteMember } from '@/service/common'
|
||||
import InviteModal from './index'
|
||||
import InviteModal from '../index'
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContextSelector: vi.fn(),
|
||||
@ -4,7 +4,7 @@ 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'
|
||||
import RoleSelector from '../role-selector'
|
||||
|
||||
vi.mock('@/context/provider-context')
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { InvitationResult } from '@/models/common'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import InvitedModal from './index'
|
||||
import InvitedModal from '../index'
|
||||
|
||||
const mockConfigState = vi.hoisted(() => ({ isCeEdition: true }))
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
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'
|
||||
import InvitationLink from '../invitation-link'
|
||||
|
||||
vi.mock('copy-to-clipboard')
|
||||
|
||||
@ -3,7 +3,7 @@ import { 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/context'
|
||||
import Operation from './index'
|
||||
import Operation from '../index'
|
||||
|
||||
const mockUpdateMemberRole = vi.fn()
|
||||
const mockDeleteMemberOrCancelInvitation = vi.fn()
|
||||
@ -6,7 +6,7 @@ import { vi } from 'vitest'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useWorkspacePermissions } from '@/service/use-workspace'
|
||||
import TransferOwnership from './transfer-ownership'
|
||||
import TransferOwnership from '../transfer-ownership'
|
||||
|
||||
vi.mock('@/context/app-context')
|
||||
vi.mock('@/context/global-public-context')
|
||||
@ -7,13 +7,13 @@ import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import TransferOwnershipModal from './index'
|
||||
import TransferOwnershipModal from '../index'
|
||||
|
||||
vi.mock('@/context/app-context')
|
||||
vi.mock('@/service/common')
|
||||
vi.mock('@/service/use-common')
|
||||
|
||||
vi.mock('./member-selector', () => ({
|
||||
vi.mock('../member-selector', () => ({
|
||||
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
|
||||
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
|
||||
),
|
||||
@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { vi } from 'vitest'
|
||||
import { useMembers } from '@/service/use-common'
|
||||
import MemberSelector from './member-selector'
|
||||
import MemberSelector from '../member-selector'
|
||||
|
||||
vi.mock('@/service/use-common')
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Button from '@/app/components/base/button'
|
||||
@ -36,18 +36,33 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
|
||||
const [stepToken, setStepToken] = useState<string>('')
|
||||
const [newOwner, setNewOwner] = useState<string>('')
|
||||
const [isTransfer, setIsTransfer] = useState<boolean>(false)
|
||||
const timerIdRef = React.useRef<number | undefined>(undefined)
|
||||
|
||||
const retimeCountdown = useCallback((timerId?: number) => {
|
||||
if (timerIdRef.current !== undefined)
|
||||
window.clearInterval(timerIdRef.current)
|
||||
|
||||
timerIdRef.current = timerId
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!show)
|
||||
retimeCountdown()
|
||||
|
||||
return retimeCountdown
|
||||
}, [retimeCountdown, show])
|
||||
|
||||
const startCount = () => {
|
||||
setTime(60)
|
||||
const timer = setInterval(() => {
|
||||
retimeCountdown(window.setInterval(() => {
|
||||
setTime((prev) => {
|
||||
if (prev <= 0) {
|
||||
clearInterval(timer)
|
||||
if (prev <= 1) {
|
||||
retimeCountdown()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
}, 1000))
|
||||
}
|
||||
|
||||
const sendEmail = async () => {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { Fragment, useCallback, useEffect } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type DialogProps = {
|
||||
@ -19,42 +18,25 @@ const MenuDialog = ({
|
||||
}: DialogProps) => {
|
||||
const close = useCallback(() => onClose?.(), [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [close])
|
||||
|
||||
return (
|
||||
<Transition appear show={show} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-[60]" onClose={noop}>
|
||||
<div className="fixed inset-0">
|
||||
<div className="flex min-h-full flex-col items-center justify-center">
|
||||
<TransitionChild>
|
||||
<DialogPanel className={cn(
|
||||
'relative h-full w-full grow overflow-hidden bg-background-sidenav-bg p-0 text-left align-middle backdrop-blur-md transition-all',
|
||||
'duration-300 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
|
||||
'data-[enter]:scale-100 data-[enter]:opacity-100',
|
||||
'data-[enter]:scale-95 data-[leave]:opacity-0',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-0 top-0 h-full w-1/2 bg-components-panel-bg" />
|
||||
{children}
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
<Dialog
|
||||
open={show}
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
overlayClassName="bg-transparent"
|
||||
className={cn(
|
||||
'left-0 top-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-hidden rounded-none border-none bg-background-sidenav-bg p-0 shadow-none backdrop-blur-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-0 top-0 h-full w-1/2 bg-components-panel-bg" />
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,399 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { Provider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
useExpandModelProviderList,
|
||||
useModelProviderListExpanded,
|
||||
useResetModelProviderListExpanded,
|
||||
useSetModelProviderListExpanded,
|
||||
} from '../atoms'
|
||||
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<Provider>{children}</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('atoms', () => {
|
||||
let wrapper: ReturnType<typeof createWrapper>
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createWrapper()
|
||||
})
|
||||
|
||||
// Read hook: returns whether a specific provider is expanded
|
||||
describe('useModelProviderListExpanded', () => {
|
||||
it('should return false when provider has not been expanded', () => {
|
||||
const { result } = renderHook(
|
||||
() => useModelProviderListExpanded('openai'),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false for any unknown provider name', () => {
|
||||
const { result } = renderHook(
|
||||
() => useModelProviderListExpanded('nonexistent-provider'),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
expect(result.current).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when provider has been expanded via setter', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('openai'),
|
||||
setExpanded: useSetModelProviderListExpanded('openai'),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setExpanded(true)
|
||||
})
|
||||
|
||||
expect(result.current.expanded).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Setter hook: toggles expanded state for a specific provider
|
||||
describe('useSetModelProviderListExpanded', () => {
|
||||
it('should expand a provider when called with true', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('anthropic'),
|
||||
setExpanded: useSetModelProviderListExpanded('anthropic'),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setExpanded(true)
|
||||
})
|
||||
|
||||
expect(result.current.expanded).toBe(true)
|
||||
})
|
||||
|
||||
it('should collapse a provider when called with false', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('anthropic'),
|
||||
setExpanded: useSetModelProviderListExpanded('anthropic'),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setExpanded(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setExpanded(false)
|
||||
})
|
||||
|
||||
expect(result.current.expanded).toBe(false)
|
||||
})
|
||||
|
||||
it('should not affect other providers when setting one', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
openaiExpanded: useModelProviderListExpanded('openai'),
|
||||
anthropicExpanded: useModelProviderListExpanded('anthropic'),
|
||||
setOpenai: useSetModelProviderListExpanded('openai'),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setOpenai(true)
|
||||
})
|
||||
|
||||
expect(result.current.openaiExpanded).toBe(true)
|
||||
expect(result.current.anthropicExpanded).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// Expand hook: expands any provider by name
|
||||
describe('useExpandModelProviderList', () => {
|
||||
it('should expand the specified provider', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('google'),
|
||||
expand: useExpandModelProviderList(),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.expand('google')
|
||||
})
|
||||
|
||||
expect(result.current.expanded).toBe(true)
|
||||
})
|
||||
|
||||
it('should expand multiple providers independently', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
openaiExpanded: useModelProviderListExpanded('openai'),
|
||||
anthropicExpanded: useModelProviderListExpanded('anthropic'),
|
||||
expand: useExpandModelProviderList(),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.expand('openai')
|
||||
})
|
||||
act(() => {
|
||||
result.current.expand('anthropic')
|
||||
})
|
||||
|
||||
expect(result.current.openaiExpanded).toBe(true)
|
||||
expect(result.current.anthropicExpanded).toBe(true)
|
||||
})
|
||||
|
||||
it('should not collapse already expanded providers when expanding another', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
openaiExpanded: useModelProviderListExpanded('openai'),
|
||||
anthropicExpanded: useModelProviderListExpanded('anthropic'),
|
||||
expand: useExpandModelProviderList(),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.expand('openai')
|
||||
})
|
||||
act(() => {
|
||||
result.current.expand('anthropic')
|
||||
})
|
||||
|
||||
expect(result.current.openaiExpanded).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Reset hook: clears all expanded state back to empty
|
||||
describe('useResetModelProviderListExpanded', () => {
|
||||
it('should reset all expanded providers to false', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
openaiExpanded: useModelProviderListExpanded('openai'),
|
||||
anthropicExpanded: useModelProviderListExpanded('anthropic'),
|
||||
expand: useExpandModelProviderList(),
|
||||
reset: useResetModelProviderListExpanded(),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.expand('openai')
|
||||
})
|
||||
act(() => {
|
||||
result.current.expand('anthropic')
|
||||
})
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.openaiExpanded).toBe(false)
|
||||
expect(result.current.anthropicExpanded).toBe(false)
|
||||
})
|
||||
|
||||
it('should be safe to call when no providers are expanded', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('openai'),
|
||||
reset: useResetModelProviderListExpanded(),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.expanded).toBe(false)
|
||||
})
|
||||
|
||||
it('should allow re-expanding providers after reset', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('openai'),
|
||||
expand: useExpandModelProviderList(),
|
||||
reset: useResetModelProviderListExpanded(),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.expand('openai')
|
||||
})
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
act(() => {
|
||||
result.current.expand('openai')
|
||||
})
|
||||
|
||||
expect(result.current.expanded).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Cross-hook interaction: verify hooks cooperate through the shared atom
|
||||
describe('Cross-hook interaction', () => {
|
||||
it('should reflect state set by useSetModelProviderListExpanded in useModelProviderListExpanded', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('openai'),
|
||||
setExpanded: useSetModelProviderListExpanded('openai'),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setExpanded(true)
|
||||
})
|
||||
|
||||
expect(result.current.expanded).toBe(true)
|
||||
})
|
||||
|
||||
it('should reflect state set by useExpandModelProviderList in useModelProviderListExpanded', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('anthropic'),
|
||||
expand: useExpandModelProviderList(),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.expand('anthropic')
|
||||
})
|
||||
|
||||
expect(result.current.expanded).toBe(true)
|
||||
})
|
||||
|
||||
it('should allow useSetModelProviderListExpanded to collapse a provider expanded by useExpandModelProviderList', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('openai'),
|
||||
expand: useExpandModelProviderList(),
|
||||
setExpanded: useSetModelProviderListExpanded('openai'),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.expand('openai')
|
||||
})
|
||||
expect(result.current.expanded).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.setExpanded(false)
|
||||
})
|
||||
expect(result.current.expanded).toBe(false)
|
||||
})
|
||||
|
||||
it('should reset state set by useSetModelProviderListExpanded via useResetModelProviderListExpanded', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('openai'),
|
||||
setExpanded: useSetModelProviderListExpanded('openai'),
|
||||
reset: useResetModelProviderListExpanded(),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setExpanded(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.reset()
|
||||
})
|
||||
|
||||
expect(result.current.expanded).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// selectAtom granularity: changing one provider should not affect unrelated reads
|
||||
describe('selectAtom granularity', () => {
|
||||
it('should not cause unrelated provider reads to change when one provider is toggled', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
openai: useModelProviderListExpanded('openai'),
|
||||
anthropic: useModelProviderListExpanded('anthropic'),
|
||||
google: useModelProviderListExpanded('google'),
|
||||
setOpenai: useSetModelProviderListExpanded('openai'),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
const anthropicBefore = result.current.anthropic
|
||||
const googleBefore = result.current.google
|
||||
|
||||
act(() => {
|
||||
result.current.setOpenai(true)
|
||||
})
|
||||
|
||||
expect(result.current.openai).toBe(true)
|
||||
expect(result.current.anthropic).toBe(anthropicBefore)
|
||||
expect(result.current.google).toBe(googleBefore)
|
||||
})
|
||||
|
||||
it('should keep individual provider states independent across multiple expansions and collapses', () => {
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
openai: useModelProviderListExpanded('openai'),
|
||||
anthropic: useModelProviderListExpanded('anthropic'),
|
||||
setOpenai: useSetModelProviderListExpanded('openai'),
|
||||
setAnthropic: useSetModelProviderListExpanded('anthropic'),
|
||||
}),
|
||||
{ wrapper },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.setOpenai(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setAnthropic(true)
|
||||
})
|
||||
act(() => {
|
||||
result.current.setOpenai(false)
|
||||
})
|
||||
|
||||
expect(result.current.openai).toBe(false)
|
||||
expect(result.current.anthropic).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// Isolation: separate Provider instances have independent state
|
||||
describe('Provider isolation', () => {
|
||||
it('should have independent state across different Provider instances', () => {
|
||||
const wrapper1 = createWrapper()
|
||||
const wrapper2 = createWrapper()
|
||||
|
||||
const { result: result1 } = renderHook(
|
||||
() => ({
|
||||
expanded: useModelProviderListExpanded('openai'),
|
||||
setExpanded: useSetModelProviderListExpanded('openai'),
|
||||
}),
|
||||
{ wrapper: wrapper1 },
|
||||
)
|
||||
|
||||
const { result: result2 } = renderHook(
|
||||
() => useModelProviderListExpanded('openai'),
|
||||
{ wrapper: wrapper2 },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result1.current.setExpanded(true)
|
||||
})
|
||||
|
||||
expect(result1.current.expanded).toBe(true)
|
||||
expect(result2.current).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,160 @@
|
||||
import type { Model, ModelItem, ModelProvider } from '../declarations'
|
||||
import type { CredentialPanelState } from '../provider-added-card/use-credential-panel-state'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '../declarations'
|
||||
import { deriveModelStatus } from '../derive-model-status'
|
||||
|
||||
const createCredentialState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
|
||||
variant: 'credits-active',
|
||||
priority: 'credits',
|
||||
supportsCredits: true,
|
||||
showPrioritySwitcher: true,
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: false,
|
||||
credentialName: undefined,
|
||||
credits: 100,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
|
||||
model: 'text-embedding-3-large',
|
||||
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
|
||||
model_type: ModelTypeEnum.textEmbedding,
|
||||
fetch_from: ConfigurationMethodEnum.predefinedModel,
|
||||
status: ModelStatusEnum.active,
|
||||
model_properties: {},
|
||||
load_balancing_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createModelProvider = (): ModelProvider =>
|
||||
({ provider: 'openai' } as ModelProvider)
|
||||
|
||||
const createModel = (overrides: Partial<Model> = {}): Model => ({
|
||||
provider: 'openai',
|
||||
icon_small: { en_US: '', zh_Hans: '' },
|
||||
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
|
||||
models: [createModelItem()],
|
||||
status: ModelStatusEnum.active,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('deriveModelStatus', () => {
|
||||
it('should return empty when model id or provider name is missing', () => {
|
||||
expect(
|
||||
deriveModelStatus('', 'openai', createModelProvider(), createModelItem(), createCredentialState()),
|
||||
).toBe('empty')
|
||||
expect(
|
||||
deriveModelStatus('text-embedding-3-large', '', createModelProvider(), createModelItem(), createCredentialState()),
|
||||
).toBe('empty')
|
||||
})
|
||||
|
||||
it('should return incompatible when provider plugin is missing', () => {
|
||||
expect(
|
||||
deriveModelStatus('text-embedding-3-large', 'openai', undefined, createModelItem(), createCredentialState()),
|
||||
).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('should return incompatible when model is missing from the provider list', () => {
|
||||
expect(
|
||||
deriveModelStatus('text-embedding-3-large', 'openai', createModel(), undefined, createCredentialState()),
|
||||
).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('should return credits-exhausted when model is missing and AI credits are exhausted without api key', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
undefined,
|
||||
createCredentialState({
|
||||
priority: 'apiKey',
|
||||
hasCredentials: false,
|
||||
isCreditsExhausted: true,
|
||||
}),
|
||||
),
|
||||
).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('should return configure-required when the model status is no-configure', () => {
|
||||
expect(
|
||||
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem({ status: ModelStatusEnum.noConfigure }), createCredentialState()),
|
||||
).toBe('configure-required')
|
||||
})
|
||||
|
||||
it('should return disabled when the model status is disabled', () => {
|
||||
expect(
|
||||
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem({ status: ModelStatusEnum.disabled }), createCredentialState()),
|
||||
).toBe('disabled')
|
||||
})
|
||||
|
||||
it('should return credits-exhausted when credential state takes priority', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
createModelItem(),
|
||||
createCredentialState({ isCreditsExhausted: true }),
|
||||
),
|
||||
).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('should return api-key-unavailable when credential state is api-unavailable', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
createModelItem(),
|
||||
createCredentialState({ variant: 'api-unavailable', priority: 'apiKey' }),
|
||||
),
|
||||
).toBe('api-key-unavailable')
|
||||
})
|
||||
|
||||
it('should return credits-exhausted when model status is quota exceeded', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
createModelItem({ status: ModelStatusEnum.quotaExceeded }),
|
||||
createCredentialState({ priority: 'apiKey' }),
|
||||
),
|
||||
).toBe('credits-exhausted')
|
||||
})
|
||||
|
||||
it('should return api-key-unavailable when model status is credential removed', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
createModelItem({ status: ModelStatusEnum.credentialRemoved }),
|
||||
createCredentialState({ priority: 'apiKey' }),
|
||||
),
|
||||
).toBe('api-key-unavailable')
|
||||
})
|
||||
|
||||
it('should return incompatible when model status is no-permission', () => {
|
||||
expect(
|
||||
deriveModelStatus(
|
||||
'text-embedding-3-large',
|
||||
'openai',
|
||||
createModelProvider(),
|
||||
createModelItem({ status: ModelStatusEnum.noPermission }),
|
||||
createCredentialState({ priority: 'apiKey' }),
|
||||
),
|
||||
).toBe('incompatible')
|
||||
})
|
||||
|
||||
it('should return active when model and credential state are available', () => {
|
||||
expect(
|
||||
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem(), createCredentialState()),
|
||||
).toBe('active')
|
||||
})
|
||||
})
|
||||
@ -6,9 +6,10 @@ import type {
|
||||
DefaultModelResponse,
|
||||
Model,
|
||||
ModelProvider,
|
||||
} from './declarations'
|
||||
} from '../declarations'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { fetchDefaultModal, fetchModelList, fetchModelProviderCredentials } from '@/service/common'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
@ -18,11 +19,12 @@ import {
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
PreferredProviderTypeEnum,
|
||||
} from './declarations'
|
||||
} from '../declarations'
|
||||
import {
|
||||
useAnthropicBuyQuota,
|
||||
useCurrentProviderAndModel,
|
||||
useDefaultModel,
|
||||
useInvalidateDefaultModel,
|
||||
useLanguage,
|
||||
useMarketplaceAllPlugins,
|
||||
useModelList,
|
||||
@ -35,8 +37,7 @@ import {
|
||||
useTextGenerationCurrentProviderAndModelAndModelList,
|
||||
useUpdateModelList,
|
||||
useUpdateModelProviders,
|
||||
} from './hooks'
|
||||
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
|
||||
} from '../hooks'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
@ -78,14 +79,6 @@ vi.mock('@/context/modal-context', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: vi.fn(() => ({
|
||||
eventEmitter: {
|
||||
emit: vi.fn(),
|
||||
},
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
useMarketplacePlugins: vi.fn(() => ({
|
||||
plugins: [],
|
||||
@ -99,12 +92,16 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('../atoms', () => ({
|
||||
useExpandModelProviderList: vi.fn(() => vi.fn()),
|
||||
}))
|
||||
|
||||
const { useQuery, useQueryClient } = await import('@tanstack/react-query')
|
||||
const { getPayUrl } = await import('@/service/common')
|
||||
const { useProviderContext } = await import('@/context/provider-context')
|
||||
const { useModalContextSelector } = await import('@/context/modal-context')
|
||||
const { useEventEmitterContextContext } = await import('@/context/event-emitter')
|
||||
const { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = await import('@/app/components/plugins/marketplace/hooks')
|
||||
const { useExpandModelProviderList } = await import('../atoms')
|
||||
|
||||
describe('hooks', () => {
|
||||
beforeEach(() => {
|
||||
@ -913,6 +910,38 @@ describe('hooks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useInvalidateDefaultModel', () => {
|
||||
it('should invalidate default model queries', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
|
||||
const { result } = renderHook(() => useInvalidateDefaultModel())
|
||||
|
||||
act(() => {
|
||||
result.current(ModelTypeEnum.textGeneration)
|
||||
})
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['default-model', ModelTypeEnum.textGeneration],
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle multiple model types', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
|
||||
const { result } = renderHook(() => useInvalidateDefaultModel())
|
||||
|
||||
act(() => {
|
||||
result.current(ModelTypeEnum.textGeneration)
|
||||
result.current(ModelTypeEnum.textEmbedding)
|
||||
result.current(ModelTypeEnum.rerank)
|
||||
})
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAnthropicBuyQuota', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
@ -1275,39 +1304,52 @@ describe('hooks', () => {
|
||||
|
||||
it('should refresh providers and model lists', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
const emit = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit },
|
||||
})
|
||||
|
||||
const provider = createMockProvider()
|
||||
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
provider: provider.provider,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { result } = renderHook(() => useRefreshModel())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshModel(provider)
|
||||
})
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'none',
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-providers'] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] })
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textEmbedding] })
|
||||
})
|
||||
|
||||
it('should emit event when refreshModelList is true and custom config is active', () => {
|
||||
it('should expand target provider list when refreshModelList is true and custom config is active', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
const emit = vi.fn()
|
||||
const expandModelProviderList = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit },
|
||||
})
|
||||
; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList)
|
||||
|
||||
const provider = createMockProvider()
|
||||
const customFields: CustomConfigurationModelFixedFields = {
|
||||
__model_name: 'gpt-4',
|
||||
__model_type: ModelTypeEnum.textGeneration,
|
||||
}
|
||||
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
provider: provider.provider,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRefreshModel())
|
||||
|
||||
@ -1315,23 +1357,30 @@ describe('hooks', () => {
|
||||
result.current.handleRefreshModel(provider, customFields, true)
|
||||
})
|
||||
|
||||
expect(emit).toHaveBeenCalledWith({
|
||||
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
|
||||
payload: 'openai',
|
||||
expect(expandModelProviderList).toHaveBeenCalledWith('openai')
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'active',
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] })
|
||||
})
|
||||
|
||||
it('should not emit event when custom config is not active', () => {
|
||||
it('should not expand provider list when custom config is not active', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
const emit = vi.fn()
|
||||
const expandModelProviderList = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit },
|
||||
})
|
||||
; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList)
|
||||
|
||||
const provider = { ...createMockProvider(), custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure } }
|
||||
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
provider: provider.provider,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useRefreshModel())
|
||||
|
||||
@ -1339,17 +1388,43 @@ describe('hooks', () => {
|
||||
result.current.handleRefreshModel(provider, undefined, true)
|
||||
})
|
||||
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
expect(expandModelProviderList).not.toHaveBeenCalled()
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit event and invalidate all supported model types when __model_type is undefined', () => {
|
||||
it('should refetch active model provider list when custom refresh callback is absent', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
|
||||
const provider = createMockProvider()
|
||||
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
provider: provider.provider,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { result } = renderHook(() => useRefreshModel())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshModel(provider, undefined, true)
|
||||
})
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'active',
|
||||
})
|
||||
})
|
||||
|
||||
it('should invalidate all supported model types when __model_type is undefined', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
const emit = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit },
|
||||
})
|
||||
|
||||
const provider = createMockProvider()
|
||||
const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields
|
||||
@ -1360,11 +1435,7 @@ describe('hooks', () => {
|
||||
result.current.handleRefreshModel(provider, customFields, true)
|
||||
})
|
||||
|
||||
expect(emit).toHaveBeenCalledWith({
|
||||
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
|
||||
payload: 'openai',
|
||||
})
|
||||
// When __model_type is undefined, all supported model types are invalidated
|
||||
// When __model_type is undefined, all supported model types are invalidated.
|
||||
const modelListCalls = invalidateQueries.mock.calls.filter(
|
||||
call => call[0]?.queryKey?.[0] === 'model-list',
|
||||
)
|
||||
@ -1375,9 +1446,6 @@ describe('hooks', () => {
|
||||
const invalidateQueries = vi.fn()
|
||||
|
||||
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
|
||||
; (useEventEmitterContextContext as Mock).mockReturnValue({
|
||||
eventEmitter: { emit: vi.fn() },
|
||||
})
|
||||
|
||||
const provider = {
|
||||
...createMockProvider(),
|
||||
@ -0,0 +1,89 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import {
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
QuotaUnitEnum,
|
||||
} from '../declarations'
|
||||
import ModelProviderPage from '../index'
|
||||
|
||||
const mockQuotaConfig = {
|
||||
quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_unit: QuotaUnitEnum.times,
|
||||
quota_limit: 100,
|
||||
quota_used: 1,
|
||||
last_used: 0,
|
||||
is_valid: true,
|
||||
}
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
IS_CLOUD_EDITION: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useSystemFeaturesQuery: () => ({
|
||||
data: {
|
||||
enable_marketplace: false,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: [{
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDefaultModel: () => ({ data: null, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card', () => ({
|
||||
default: () => <div data-testid="provider-card" />,
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card/quota-panel', () => ({
|
||||
default: () => <div data-testid="quota-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('../system-model-selector', () => ({
|
||||
default: () => <div data-testid="system-model-selector" />,
|
||||
}))
|
||||
|
||||
vi.mock('../install-from-marketplace', () => ({
|
||||
default: () => <div data-testid="install-from-marketplace" />,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: () => ({ data: undefined }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
plugins: {
|
||||
checkInstalled: { queryOptions: () => ({}) },
|
||||
latestVersions: { queryOptions: () => ({}) },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ModelProviderPage non-cloud branch', () => {
|
||||
it('should skip the quota panel when cloud edition is disabled', () => {
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('quota-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,278 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
QuotaUnitEnum,
|
||||
} from '../declarations'
|
||||
import ModelProviderPage from '../index'
|
||||
|
||||
let mockEnableMarketplace = true
|
||||
|
||||
const mockQuotaConfig = {
|
||||
quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_unit: QuotaUnitEnum.times,
|
||||
quota_limit: 100,
|
||||
quota_used: 1,
|
||||
last_used: 0,
|
||||
is_valid: true,
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useSystemFeaturesQuery: () => ({
|
||||
data: {
|
||||
enable_marketplace: mockEnableMarketplace,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockProviders = [
|
||||
{
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
label: { en_US: 'Anthropic' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: mockProviders,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockDefaultModels: Record<string, { data: unknown, isLoading: boolean }> = {
|
||||
'llm': { data: null, isLoading: false },
|
||||
'text-embedding': { data: null, isLoading: false },
|
||||
'rerank': { data: null, isLoading: false },
|
||||
'speech2text': { data: null, isLoading: false },
|
||||
'tts': { data: null, isLoading: false },
|
||||
}
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
|
||||
}))
|
||||
|
||||
vi.mock('../install-from-marketplace', () => ({
|
||||
default: () => <div data-testid="install-from-marketplace" />,
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card', () => ({
|
||||
default: ({ provider }: { provider: { provider: string } }) => <div data-testid="provider-card">{provider.provider}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('../provider-added-card/quota-panel', () => ({
|
||||
default: () => <div data-testid="quota-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('../system-model-selector', () => ({
|
||||
default: () => <div data-testid="system-model-selector" />,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: () => ({ data: undefined }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
plugins: {
|
||||
checkInstalled: { queryOptions: () => ({}) },
|
||||
latestVersions: { queryOptions: () => ({}) },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ModelProviderPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mockEnableMarketplace = true
|
||||
Object.keys(mockDefaultModels).forEach((key) => {
|
||||
mockDefaultModels[key] = { data: null, isLoading: false }
|
||||
})
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}, {
|
||||
provider: 'anthropic',
|
||||
label: { en_US: 'Anthropic' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should render main elements', () => {
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configured and not configured providers sections', () => {
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
|
||||
expect(screen.getByText('anthropic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter providers based on search text', () => {
|
||||
render(<ModelProviderPage searchText="open" />)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty state if no configured providers match', () => {
|
||||
render(<ModelProviderPage searchText="non-existent" />)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide marketplace section when marketplace feature is disabled', () => {
|
||||
mockEnableMarketplace = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('system model config status', () => {
|
||||
it('should not show top warning when no configured providers exist (empty state card handles it)', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'anthropic',
|
||||
label: { en_US: 'Anthropic' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show none-configured warning when providers exist but no default models set', () => {
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
expect(screen.getByText('common.modelProvider.noneConfigured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show partially-configured warning when some default models are set', () => {
|
||||
mockDefaultModels.llm = {
|
||||
data: { model: 'gpt-4', model_type: 'llm', provider: { provider: 'openai', icon_small: { en_US: '' } } },
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show warning when all default models are configured', () => {
|
||||
const makeModel = (model: string, type: string) => ({
|
||||
data: { model, model_type: type, provider: { provider: 'openai', icon_small: { en_US: '' } } },
|
||||
isLoading: false,
|
||||
})
|
||||
mockDefaultModels.llm = makeModel('gpt-4', 'llm')
|
||||
mockDefaultModels['text-embedding'] = makeModel('text-embedding-3', 'text-embedding')
|
||||
mockDefaultModels.rerank = makeModel('rerank-v3', 'rerank')
|
||||
mockDefaultModels.speech2text = makeModel('whisper-1', 'speech2text')
|
||||
mockDefaultModels.tts = makeModel('tts-1', 'tts')
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show warning while loading', () => {
|
||||
Object.keys(mockDefaultModels).forEach((key) => {
|
||||
mockDefaultModels[key] = { data: null, isLoading: true }
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should prioritize fixed providers in visible order', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'zeta-provider',
|
||||
label: { en_US: 'Zeta Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}, {
|
||||
provider: 'langgenius/anthropic/anthropic',
|
||||
label: { en_US: 'Anthropic Fixed' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}, {
|
||||
provider: 'langgenius/openai/openai',
|
||||
label: { en_US: 'OpenAI Fixed' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
|
||||
expect(renderedProviders).toEqual([
|
||||
'langgenius/openai/openai',
|
||||
'langgenius/anthropic/anthropic',
|
||||
'zeta-provider',
|
||||
])
|
||||
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,13 +1,13 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import type { ModelProvider } from './declarations'
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useMarketplaceAllPlugins } from './hooks'
|
||||
import InstallFromMarketplace from './install-from-marketplace'
|
||||
import { useMarketplaceAllPlugins } from '../hooks'
|
||||
import InstallFromMarketplace from '../install-from-marketplace'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('next/link', () => ({
|
||||
vi.mock('@/next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode, href: string }) => <a href={href}>{children}</a>,
|
||||
}))
|
||||
|
||||
@ -39,7 +39,7 @@ vi.mock('@/app/components/plugins/provider-card', () => ({
|
||||
default: ({ payload }: { payload: { name: string } }) => <div>{payload.name}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
useMarketplaceAllPlugins: vi.fn(() => ({
|
||||
plugins: [],
|
||||
isLoading: false,
|
||||
@ -61,8 +61,15 @@ describe('InstallFromMarketplace', () => {
|
||||
|
||||
it('should collapse when clicked', () => {
|
||||
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
|
||||
fireEvent.click(screen.getByText('common.modelProvider.installProvider'))
|
||||
const toggle = screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ })
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument()
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByTestId('plugin-list')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show loading state', () => {
|
||||
@ -0,0 +1,42 @@
|
||||
import type { ModelProvider } from '../declarations'
|
||||
import { CurrentSystemQuotaTypeEnum } from '../declarations'
|
||||
import { providerSupportsCredits } from '../supports-credits'
|
||||
|
||||
vi.mock('@/config', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/config')>()
|
||||
return { ...actual, IS_CLOUD_EDITION: true }
|
||||
})
|
||||
|
||||
const makeProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
|
||||
provider: 'langgenius/openai/openai',
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
|
||||
quota_configurations: [],
|
||||
},
|
||||
...overrides,
|
||||
} as ModelProvider)
|
||||
|
||||
describe('providerSupportsCredits', () => {
|
||||
it('returns true when the provider is system-enabled and listed in trial_models', () => {
|
||||
expect(providerSupportsCredits(makeProvider(), ['langgenius/openai/openai'])).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when the provider is not listed in trial_models', () => {
|
||||
expect(providerSupportsCredits(makeProvider(), ['langgenius/anthropic/anthropic'])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when system hosting is disabled', () => {
|
||||
expect(providerSupportsCredits(makeProvider({
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
|
||||
quota_configurations: [],
|
||||
},
|
||||
}), ['langgenius/openai/openai'])).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for an undefined provider', () => {
|
||||
expect(providerSupportsCredits(undefined, ['langgenius/openai/openai'])).toBe(false)
|
||||
})
|
||||
})
|
||||
@ -6,23 +6,24 @@ import {
|
||||
validateModelLoadBalancingCredentials,
|
||||
validateModelProvider,
|
||||
} from '@/service/common'
|
||||
import { ValidatedStatus } from '../key-validator/declarations'
|
||||
import { ValidatedStatus } from '../../key-validator/declarations'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
FormTypeEnum,
|
||||
ModelTypeEnum,
|
||||
} from './declarations'
|
||||
} from '../declarations'
|
||||
import {
|
||||
genModelNameFormSchema,
|
||||
genModelTypeFormSchema,
|
||||
modelTypeFormat,
|
||||
providerToPluginId,
|
||||
removeCredentials,
|
||||
saveCredentials,
|
||||
savePredefinedLoadBalancingConfig,
|
||||
sizeFormat,
|
||||
validateCredentials,
|
||||
validateLoadBalancingCredentials,
|
||||
} from './utils'
|
||||
} from '../utils'
|
||||
|
||||
// Mock service/common functions
|
||||
vi.mock('@/service/common', () => ({
|
||||
@ -47,6 +48,16 @@ describe('utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerToPluginId', () => {
|
||||
it('should return the plugin id prefix when the provider key contains a provider segment', () => {
|
||||
expect(providerToPluginId('langgenius/openai/openai')).toBe('langgenius/openai')
|
||||
})
|
||||
|
||||
it('should return an empty string when the provider key has no plugin prefix', () => {
|
||||
expect(providerToPluginId('openai')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('modelTypeFormat', () => {
|
||||
it('should format text embedding type', () => {
|
||||
expect(modelTypeFormat(ModelTypeEnum.textEmbedding)).toBe('TEXT EMBEDDING')
|
||||
@ -0,0 +1,35 @@
|
||||
import { atom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { selectAtom } from 'jotai/utils'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
const expandedAtom = atom<Record<string, boolean>>({})
|
||||
|
||||
export function useModelProviderListExpanded(providerName: string) {
|
||||
return useAtomValue(
|
||||
useMemo(
|
||||
() => selectAtom(expandedAtom, s => !!s[providerName]),
|
||||
[providerName],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function useSetModelProviderListExpanded(providerName: string) {
|
||||
const set = useSetAtom(expandedAtom)
|
||||
return useCallback(
|
||||
(expanded: boolean) => set(prev => ({ ...prev, [providerName]: expanded })),
|
||||
[providerName, set],
|
||||
)
|
||||
}
|
||||
|
||||
export function useExpandModelProviderList() {
|
||||
const set = useSetAtom(expandedAtom)
|
||||
return useCallback(
|
||||
(providerName: string) => set(prev => ({ ...prev, [providerName]: true })),
|
||||
[set],
|
||||
)
|
||||
}
|
||||
|
||||
export function useResetModelProviderListExpanded() {
|
||||
const set = useSetAtom(expandedAtom)
|
||||
return useCallback(() => set({}), [set])
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
import type { Model, ModelItem, ModelProvider } from './declarations'
|
||||
import type { CredentialPanelState } from './provider-added-card/use-credential-panel-state'
|
||||
import { ModelStatusEnum } from './declarations'
|
||||
|
||||
export type DerivedModelStatus
|
||||
= | 'empty'
|
||||
| 'active'
|
||||
| 'configure-required'
|
||||
| 'credits-exhausted'
|
||||
| 'api-key-unavailable'
|
||||
| 'disabled'
|
||||
| 'incompatible'
|
||||
|
||||
export const DERIVED_MODEL_STATUS_BADGE_I18N = {
|
||||
'configure-required': 'modelProvider.selector.configureRequired',
|
||||
'credits-exhausted': 'modelProvider.selector.creditsExhausted',
|
||||
'api-key-unavailable': 'modelProvider.selector.apiKeyUnavailable',
|
||||
'disabled': 'modelProvider.selector.disabled',
|
||||
'incompatible': 'modelProvider.selector.incompatible',
|
||||
} as const satisfies Partial<Record<DerivedModelStatus, string>>
|
||||
|
||||
export const DERIVED_MODEL_STATUS_TOOLTIP_I18N = {
|
||||
'credits-exhausted': 'modelProvider.selector.creditsExhaustedTip',
|
||||
'api-key-unavailable': 'modelProvider.selector.apiKeyUnavailableTip',
|
||||
'incompatible': 'modelProvider.selector.incompatibleTip',
|
||||
} as const satisfies Partial<Record<DerivedModelStatus, string>>
|
||||
|
||||
export const deriveModelStatus = (
|
||||
modelId: string | undefined,
|
||||
providerName: string | undefined,
|
||||
currentModelProvider: ModelProvider | Model | undefined,
|
||||
currentModel: ModelItem | undefined,
|
||||
credentialState: CredentialPanelState,
|
||||
): DerivedModelStatus => {
|
||||
if (!modelId || !providerName)
|
||||
return 'empty'
|
||||
|
||||
if (!currentModelProvider)
|
||||
return 'incompatible'
|
||||
|
||||
const isCreditsExhaustedWithoutApiKey = credentialState.supportsCredits
|
||||
&& credentialState.isCreditsExhausted
|
||||
&& !credentialState.hasCredentials
|
||||
const isCreditsPriorityExhausted = credentialState.priority === 'credits'
|
||||
&& credentialState.supportsCredits
|
||||
&& credentialState.isCreditsExhausted
|
||||
|
||||
if (isCreditsPriorityExhausted || isCreditsExhaustedWithoutApiKey)
|
||||
return 'credits-exhausted'
|
||||
|
||||
if (!currentModel)
|
||||
return 'incompatible'
|
||||
|
||||
if (credentialState.variant === 'api-unavailable')
|
||||
return 'api-key-unavailable'
|
||||
|
||||
switch (currentModel.status) {
|
||||
case ModelStatusEnum.active:
|
||||
return 'active'
|
||||
case ModelStatusEnum.noConfigure:
|
||||
return 'configure-required'
|
||||
case ModelStatusEnum.quotaExceeded:
|
||||
return 'credits-exhausted'
|
||||
case ModelStatusEnum.credentialRemoved:
|
||||
return 'api-key-unavailable'
|
||||
case ModelStatusEnum.disabled:
|
||||
return 'disabled'
|
||||
case ModelStatusEnum.noPermission:
|
||||
default:
|
||||
return 'incompatible'
|
||||
}
|
||||
}
|
||||
@ -21,10 +21,10 @@ import {
|
||||
useMarketplacePluginsByCollectionId,
|
||||
} from '@/app/components/plugins/marketplace/hooks'
|
||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
fetchDefaultModal,
|
||||
fetchModelList,
|
||||
@ -32,12 +32,12 @@ import {
|
||||
getPayUrl,
|
||||
} from '@/service/common'
|
||||
import { commonQueryKeys } from '@/service/use-common'
|
||||
import { useExpandModelProviderList } from './atoms'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
ModelStatusEnum,
|
||||
} from './declarations'
|
||||
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
|
||||
|
||||
type UseDefaultModelAndModelList = (
|
||||
defaultModel: DefaultModelResponse | undefined,
|
||||
@ -57,15 +57,21 @@ export const useSystemDefaultModelAndModelList: UseDefaultModelAndModelList = (
|
||||
|
||||
return currentDefaultModel
|
||||
}, [defaultModel, modelList])
|
||||
const currentDefaultModelKey = currentDefaultModel
|
||||
? `${currentDefaultModel.provider}:${currentDefaultModel.model}`
|
||||
: ''
|
||||
const [defaultModelState, setDefaultModelState] = useState<DefaultModel | undefined>(currentDefaultModel)
|
||||
const handleDefaultModelChange = useCallback((model: DefaultModel) => {
|
||||
setDefaultModelState(model)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
setDefaultModelState(currentDefaultModel)
|
||||
}, [currentDefaultModel])
|
||||
const [defaultModelSourceKey, setDefaultModelSourceKey] = useState(currentDefaultModelKey)
|
||||
const selectedDefaultModel = defaultModelSourceKey === currentDefaultModelKey
|
||||
? defaultModelState
|
||||
: currentDefaultModel
|
||||
|
||||
return [defaultModelState, handleDefaultModelChange]
|
||||
const handleDefaultModelChange = useCallback((model: DefaultModel) => {
|
||||
setDefaultModelSourceKey(currentDefaultModelKey)
|
||||
setDefaultModelState(model)
|
||||
}, [currentDefaultModelKey])
|
||||
|
||||
return [selectedDefaultModel, handleDefaultModelChange]
|
||||
}
|
||||
|
||||
export const useLanguage = () => {
|
||||
@ -116,7 +122,7 @@ export const useProviderCredentialsAndLoadBalancing = (
|
||||
predefinedFormSchemasValue?.credentials,
|
||||
])
|
||||
|
||||
const mutate = useMemo(() => () => {
|
||||
const mutate = useCallback(() => {
|
||||
if (predefinedEnabled)
|
||||
queryClient.invalidateQueries({ queryKey: ['model-providers', 'credentials', provider, credentialId] })
|
||||
if (customEnabled)
|
||||
@ -222,6 +228,14 @@ export const useUpdateModelList = () => {
|
||||
return updateModelList
|
||||
}
|
||||
|
||||
export const useInvalidateDefaultModel = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useCallback((type: ModelTypeEnum) => {
|
||||
queryClient.invalidateQueries({ queryKey: commonQueryKeys.defaultModel(type) })
|
||||
}, [queryClient])
|
||||
}
|
||||
|
||||
export const useAnthropicBuyQuota = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@ -314,7 +328,8 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
|
||||
}
|
||||
|
||||
export const useRefreshModel = () => {
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const expandModelProviderList = useExpandModelProviderList()
|
||||
const queryClient = useQueryClient()
|
||||
const updateModelProviders = useUpdateModelProviders()
|
||||
const updateModelList = useUpdateModelList()
|
||||
const handleRefreshModel = useCallback((
|
||||
@ -322,6 +337,19 @@ export const useRefreshModel = () => {
|
||||
CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
|
||||
refreshModelList?: boolean,
|
||||
) => {
|
||||
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
provider: provider.provider,
|
||||
},
|
||||
},
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'none',
|
||||
})
|
||||
|
||||
updateModelProviders()
|
||||
|
||||
provider.supported_model_types.forEach((type) => {
|
||||
@ -329,15 +357,17 @@ export const useRefreshModel = () => {
|
||||
})
|
||||
|
||||
if (refreshModelList && provider.custom_configuration.status === CustomConfigurationStatusEnum.active) {
|
||||
eventEmitter?.emit({
|
||||
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
|
||||
payload: provider.provider,
|
||||
} as any)
|
||||
expandModelProviderList(provider.provider)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: modelProviderModelListQueryKey,
|
||||
exact: true,
|
||||
refetchType: 'active',
|
||||
})
|
||||
|
||||
if (CustomConfigurationModelFixedFields?.__model_type)
|
||||
updateModelList(CustomConfigurationModelFixedFields.__model_type)
|
||||
}
|
||||
}, [eventEmitter, updateModelList, updateModelProviders])
|
||||
}, [expandModelProviderList, queryClient, updateModelList, updateModelProviders])
|
||||
|
||||
return {
|
||||
handleRefreshModel,
|
||||
|
||||
@ -1,332 +0,0 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CurrentSystemQuotaTypeEnum,
|
||||
CustomConfigurationStatusEnum,
|
||||
QuotaUnitEnum,
|
||||
} from './declarations'
|
||||
import ModelProviderPage from './index'
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
mutateCurrentWorkspace: vi.fn(),
|
||||
isValidatingCurrentWorkspace: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockGlobalState = {
|
||||
systemFeatures: { enable_marketplace: true },
|
||||
}
|
||||
|
||||
const mockQuotaConfig = {
|
||||
quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_unit: QuotaUnitEnum.times,
|
||||
quota_limit: 100,
|
||||
quota_used: 1,
|
||||
last_used: 0,
|
||||
is_valid: true,
|
||||
}
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (s: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector(mockGlobalState),
|
||||
}))
|
||||
|
||||
const mockProviders = [
|
||||
{
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: 'anthropic',
|
||||
label: { en_US: 'Anthropic' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
modelProviders: mockProviders,
|
||||
}),
|
||||
}))
|
||||
|
||||
type MockDefaultModelData = {
|
||||
model: string
|
||||
provider?: { provider: string }
|
||||
} | null
|
||||
|
||||
const mockDefaultModelState: {
|
||||
data: MockDefaultModelData
|
||||
isLoading: boolean
|
||||
} = {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
}
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
useDefaultModel: () => mockDefaultModelState,
|
||||
}))
|
||||
|
||||
vi.mock('./install-from-marketplace', () => ({
|
||||
default: () => <div data-testid="install-from-marketplace" />,
|
||||
}))
|
||||
|
||||
vi.mock('./provider-added-card', () => ({
|
||||
default: ({ provider }: { provider: { provider: string } }) => <div data-testid="provider-card">{provider.provider}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./provider-added-card/quota-panel', () => ({
|
||||
default: () => <div data-testid="quota-panel" />,
|
||||
}))
|
||||
|
||||
vi.mock('./system-model-selector', () => ({
|
||||
default: () => <div data-testid="system-model-selector" />,
|
||||
}))
|
||||
|
||||
describe('ModelProviderPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mockGlobalState.systemFeatures.enable_marketplace = true
|
||||
mockDefaultModelState.data = null
|
||||
mockDefaultModelState.isLoading = false
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'openai',
|
||||
label: { en_US: 'OpenAI' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}, {
|
||||
provider: 'anthropic',
|
||||
label: { en_US: 'Anthropic' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should render main elements', () => {
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('install-from-marketplace')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render configured and not configured providers sections', () => {
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
|
||||
expect(screen.getByText('anthropic')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter providers based on search text', () => {
|
||||
render(<ModelProviderPage searchText="open" />)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show empty state if no configured providers match', () => {
|
||||
render(<ModelProviderPage searchText="non-existent" />)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide marketplace section when marketplace feature is disabled', () => {
|
||||
mockGlobalState.systemFeatures.enable_marketplace = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should prioritize fixed providers in visible order', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'zeta-provider',
|
||||
label: { en_US: 'Zeta Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}, {
|
||||
provider: 'langgenius/anthropic/anthropic',
|
||||
label: { en_US: 'Anthropic Fixed' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}, {
|
||||
provider: 'langgenius/openai/openai',
|
||||
label: { en_US: 'OpenAI Fixed' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
|
||||
expect(renderedProviders).toEqual([
|
||||
'langgenius/openai/openai',
|
||||
'langgenius/anthropic/anthropic',
|
||||
'zeta-provider',
|
||||
])
|
||||
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show not configured alert when all default models are absent', () => {
|
||||
mockDefaultModelState.data = null
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when default model is loading', () => {
|
||||
mockDefaultModelState.data = null
|
||||
mockDefaultModelState.isLoading = true
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should filter providers by label text', () => {
|
||||
render(<ModelProviderPage searchText="OpenAI" />)
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(600)
|
||||
})
|
||||
expect(screen.getByText('openai')).toBeInTheDocument()
|
||||
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should classify system-enabled providers with matching quota as configured', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'sys-provider',
|
||||
label: { en_US: 'System Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByText('sys-provider')).toBeInTheDocument()
|
||||
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should classify system-enabled provider with no matching quota as not configured', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'sys-no-quota',
|
||||
label: { en_US: 'System No Quota' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||
system_configuration: {
|
||||
enabled: true,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.getByText('sys-no-quota')).toBeInTheDocument()
|
||||
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should preserve order of two non-fixed providers (sort returns 0)', () => {
|
||||
mockProviders.splice(0, mockProviders.length, {
|
||||
provider: 'alpha-provider',
|
||||
label: { en_US: 'Alpha Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
}, {
|
||||
provider: 'beta-provider',
|
||||
label: { en_US: 'Beta Provider' },
|
||||
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||
system_configuration: {
|
||||
enabled: false,
|
||||
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||
quota_configurations: [mockQuotaConfig],
|
||||
},
|
||||
})
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
|
||||
expect(renderedProviders).toEqual(['alpha-provider', 'beta-provider'])
|
||||
})
|
||||
|
||||
it('should not show not configured alert when shared default model mock has data', () => {
|
||||
mockDefaultModelState.data = { model: 'embed-model' }
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when rerankDefaultModel has data', () => {
|
||||
mockDefaultModelState.data = { model: 'rerank-model', provider: { provider: 'cohere' } }
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when ttsDefaultModel has data', () => {
|
||||
mockDefaultModelState.data = { model: 'tts-model', provider: { provider: 'openai' } }
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show not configured alert when speech2textDefaultModel has data', () => {
|
||||
mockDefaultModelState.data = { model: 'whisper', provider: { provider: 'openai' } }
|
||||
mockDefaultModelState.isLoading = false
|
||||
|
||||
render(<ModelProviderPage searchText="" />)
|
||||
|
||||
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,17 +1,16 @@
|
||||
import type {
|
||||
ModelProvider,
|
||||
} from './declarations'
|
||||
import {
|
||||
RiAlertFill,
|
||||
RiBrainLine,
|
||||
} from '@remixicon/react'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
|
||||
import { IS_CLOUD_EDITION } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useSystemFeaturesQuery } from '@/context/global-public-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
CustomConfigurationStatusEnum,
|
||||
@ -24,6 +23,9 @@ import InstallFromMarketplace from './install-from-marketplace'
|
||||
import ProviderAddedCard from './provider-added-card'
|
||||
import QuotaPanel from './provider-added-card/quota-panel'
|
||||
import SystemModelSelector from './system-model-selector'
|
||||
import { providerToPluginId } from './utils'
|
||||
|
||||
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
|
||||
|
||||
type Props = {
|
||||
searchText: string
|
||||
@ -34,20 +36,35 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
|
||||
const ModelProviderPage = ({ searchText }: Props) => {
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const { t } = useTranslation()
|
||||
const { mutateCurrentWorkspace, isValidatingCurrentWorkspace } = useAppContext()
|
||||
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
|
||||
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
|
||||
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
|
||||
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
|
||||
const { modelProviders: providers } = useProviderContext()
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: systemFeatures } = useSystemFeaturesQuery()
|
||||
|
||||
const allPluginIds = useMemo(() => {
|
||||
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
|
||||
}, [providers])
|
||||
const { data: installedPlugins } = useQuery(consoleQuery.plugins.checkInstalled.queryOptions({
|
||||
input: { body: { plugin_ids: allPluginIds } },
|
||||
enabled: allPluginIds.length > 0,
|
||||
staleTime: 0,
|
||||
}))
|
||||
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
|
||||
const pluginDetailMap = useMemo(() => {
|
||||
const map = new Map<string, PluginDetail>()
|
||||
for (const plugin of enrichedPlugins)
|
||||
map.set(plugin.plugin_id, plugin)
|
||||
return map
|
||||
}, [enrichedPlugins])
|
||||
const enableMarketplace = systemFeatures?.enable_marketplace ?? false
|
||||
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|
||||
|| isEmbeddingsDefaultModelLoading
|
||||
|| isRerankDefaultModelLoading
|
||||
|| isSpeech2textDefaultModelLoading
|
||||
|| isTTSDefaultModelLoading
|
||||
const defaultModelNotConfigured = !isDefaultModelLoading && !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
|
||||
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
|
||||
const configuredProviders: ModelProvider[] = []
|
||||
const notConfiguredProviders: ModelProvider[] = []
|
||||
@ -57,7 +74,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
provider.custom_configuration.status === CustomConfigurationStatusEnum.active
|
||||
|| (
|
||||
provider.system_configuration.enabled === true
|
||||
&& provider.system_configuration.quota_configurations.find(item => item.quota_type === provider.system_configuration.current_quota_type)
|
||||
&& provider.system_configuration.quota_configurations.some(item => item.quota_type === provider.system_configuration.current_quota_type)
|
||||
)
|
||||
) {
|
||||
configuredProviders.push(provider)
|
||||
@ -79,6 +96,26 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
|
||||
return [configuredProviders, notConfiguredProviders]
|
||||
}, [providers])
|
||||
|
||||
const systemModelConfigStatus: SystemModelConfigStatus = useMemo(() => {
|
||||
const defaultModels = [textGenerationDefaultModel, embeddingsDefaultModel, rerankDefaultModel, speech2textDefaultModel, ttsDefaultModel]
|
||||
const configuredCount = defaultModels.filter(Boolean).length
|
||||
if (configuredCount === 0 && configuredProviders.length === 0)
|
||||
return 'no-provider'
|
||||
if (configuredCount === 0)
|
||||
return 'none-configured'
|
||||
if (configuredCount < defaultModels.length)
|
||||
return 'partially-configured'
|
||||
return 'fully-configured'
|
||||
}, [configuredProviders, textGenerationDefaultModel, embeddingsDefaultModel, rerankDefaultModel, speech2textDefaultModel, ttsDefaultModel])
|
||||
const warningTextKey
|
||||
= systemModelConfigStatus === 'none-configured'
|
||||
? 'modelProvider.noneConfigured'
|
||||
: systemModelConfigStatus === 'partially-configured'
|
||||
? 'modelProvider.notConfigured'
|
||||
: null
|
||||
const showWarning = !isDefaultModelLoading && !!warningTextKey
|
||||
|
||||
const [filteredConfiguredProviders, filteredNotConfiguredProviders] = useMemo(() => {
|
||||
const filteredConfiguredProviders = configuredProviders.filter(
|
||||
provider => provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase())
|
||||
@ -92,28 +129,24 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
|
||||
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
|
||||
|
||||
useEffect(() => {
|
||||
mutateCurrentWorkspace()
|
||||
}, [mutateCurrentWorkspace])
|
||||
|
||||
return (
|
||||
<div className="relative -mt-2 pt-1">
|
||||
<div className={cn('mb-2 flex items-center')}>
|
||||
<div className="grow text-text-primary system-md-semibold">{t('modelProvider.models', { ns: 'common' })}</div>
|
||||
<div className={cn(
|
||||
'relative flex shrink-0 items-center justify-end gap-2 rounded-lg border border-transparent p-px',
|
||||
defaultModelNotConfigured && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
|
||||
showWarning && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
|
||||
)}
|
||||
>
|
||||
{defaultModelNotConfigured && <div className="absolute bottom-0 left-0 right-0 top-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
|
||||
{defaultModelNotConfigured && (
|
||||
{showWarning && <div className="absolute bottom-0 left-0 right-0 top-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
|
||||
{showWarning && (
|
||||
<div className="flex items-center gap-1 text-text-primary system-xs-medium">
|
||||
<RiAlertFill className="h-4 w-4 text-text-warning-secondary" />
|
||||
<span className="max-w-[460px] truncate" title={t('modelProvider.notConfigured', { ns: 'common' })}>{t('modelProvider.notConfigured', { ns: 'common' })}</span>
|
||||
<span className="i-ri-alert-fill h-4 w-4 text-text-warning-secondary" />
|
||||
<span className="max-w-[460px] truncate" title={t(warningTextKey, { ns: 'common' })}>{t(warningTextKey, { ns: 'common' })}</span>
|
||||
</div>
|
||||
)}
|
||||
<SystemModelSelector
|
||||
notConfigured={defaultModelNotConfigured}
|
||||
notConfigured={showWarning}
|
||||
textGenerationDefaultModel={textGenerationDefaultModel}
|
||||
embeddingsDefaultModel={embeddingsDefaultModel}
|
||||
rerankDefaultModel={rerankDefaultModel}
|
||||
@ -123,11 +156,11 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} isLoading={isValidatingCurrentWorkspace} />}
|
||||
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
|
||||
{!filteredConfiguredProviders?.length && (
|
||||
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur">
|
||||
<RiBrainLine className="h-5 w-5 text-text-primary" />
|
||||
<span className="i-ri-brain-line h-5 w-5 text-text-primary" />
|
||||
</div>
|
||||
<div className="mt-2 text-text-secondary system-sm-medium">{t('modelProvider.emptyProviderTitle', { ns: 'common' })}</div>
|
||||
<div className="mt-1 text-text-tertiary system-xs-regular">{t('modelProvider.emptyProviderTip', { ns: 'common' })}</div>
|
||||
@ -139,6 +172,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
<ProviderAddedCard
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -152,13 +186,14 @@ const ModelProviderPage = ({ searchText }: Props) => {
|
||||
notConfigured
|
||||
key={provider.provider}
|
||||
provider={provider}
|
||||
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{
|
||||
enable_marketplace && (
|
||||
enableMarketplace && (
|
||||
<InstallFromMarketplace
|
||||
providers={providers}
|
||||
searchText={searchText}
|
||||
|
||||
@ -2,18 +2,14 @@ import type {
|
||||
ModelProvider,
|
||||
} from './declarations'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightUpLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import Link from 'next/link'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import List from '@/app/components/plugins/marketplace/list'
|
||||
import ProviderCard from '@/app/components/plugins/provider-card'
|
||||
import Link from '@/next/link'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import {
|
||||
@ -47,15 +43,25 @@ const InstallFromMarketplace = ({
|
||||
<div className="mb-2">
|
||||
<Divider className="!mt-4 h-px" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex cursor-pointer items-center gap-1 text-text-primary system-md-semibold" onClick={() => setCollapse(!collapse)}>
|
||||
<RiArrowDownSLine className={cn('h-4 w-4', collapse && '-rotate-90')} />
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left text-text-primary system-md-semibold"
|
||||
onClick={() => setCollapse(prev => !prev)}
|
||||
aria-expanded={!collapse}
|
||||
>
|
||||
<span className={cn('i-ri-arrow-down-s-line h-4 w-4', collapse && '-rotate-90')} />
|
||||
{t('modelProvider.installProvider', { ns: 'common' })}
|
||||
</div>
|
||||
</button>
|
||||
<div className="mb-2 flex items-center pt-2">
|
||||
<span className="pr-1 text-text-tertiary system-sm-regular">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
|
||||
<Link target="_blank" href={getMarketplaceUrl('', { theme })} className="inline-flex items-center text-text-accent system-sm-medium">
|
||||
<Link
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={getMarketplaceUrl('', { theme })}
|
||||
className="inline-flex items-center text-text-accent system-sm-medium"
|
||||
>
|
||||
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
|
||||
<RiArrowRightUpLine className="h-4 w-4" />
|
||||
<span className="i-ri-arrow-right-up-line h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { CustomModel, ModelCredential, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ConfigurationMethodEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import AddCredentialInLoadBalancing from './add-credential-in-load-balancing'
|
||||
import AddCredentialInLoadBalancing from '../add-credential-in-load-balancing'
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
|
||||
Authorized: ({
|
||||
@ -112,7 +112,7 @@ describe('AddCredentialInLoadBalancing', () => {
|
||||
// Must invalidate module cache so the component picks up the new mock
|
||||
vi.resetModules()
|
||||
try {
|
||||
const { default: AddCredentialLB } = await import('./add-credential-in-load-balancing')
|
||||
const { default: AddCredentialLB } = await import('../add-credential-in-load-balancing')
|
||||
|
||||
const { container } = render(
|
||||
<AddCredentialLB
|
||||
@ -1,13 +1,13 @@
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import AddCustomModel from './add-custom-model'
|
||||
import AddCustomModel from '../add-custom-model'
|
||||
|
||||
// Mock hooks
|
||||
const mockHandleOpenModalForAddNewCustomModel = vi.fn()
|
||||
const mockHandleOpenModalForAddCustomModelToModelList = vi.fn()
|
||||
|
||||
vi.mock('./hooks/use-auth', () => ({
|
||||
vi.mock('../hooks/use-auth', () => ({
|
||||
useAuth: (_provider: unknown, _configMethod: unknown, _fixedFields: unknown, options: { mode: string }) => {
|
||||
if (options.mode === 'config-custom-model') {
|
||||
return { handleOpenModal: mockHandleOpenModalForAddNewCustomModel }
|
||||
@ -20,12 +20,12 @@ vi.mock('./hooks/use-auth', () => ({
|
||||
}))
|
||||
|
||||
let mockCanAddedModels: { model: string, model_type: string }[] = []
|
||||
vi.mock('./hooks/use-custom-models', () => ({
|
||||
vi.mock('../hooks/use-custom-models', () => ({
|
||||
useCanAddedModels: () => mockCanAddedModels,
|
||||
}))
|
||||
|
||||
// Mock components
|
||||
vi.mock('../model-icon', () => ({
|
||||
vi.mock('../../model-icon', () => ({
|
||||
default: () => <div data-testid="model-icon" />,
|
||||
}))
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ConfigModel from './config-model'
|
||||
import ConfigModel from '../config-model'
|
||||
|
||||
// Mock icons
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
@ -1,15 +1,15 @@
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ConfigProvider from './config-provider'
|
||||
import ConfigProvider from '../config-provider'
|
||||
|
||||
const mockUseCredentialStatus = vi.fn()
|
||||
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
useCredentialStatus: () => mockUseCredentialStatus(),
|
||||
}))
|
||||
|
||||
vi.mock('./authorized', () => ({
|
||||
vi.mock('../authorized', () => ({
|
||||
default: ({ renderTrigger }: { renderTrigger: () => React.ReactNode }) => (
|
||||
<div>
|
||||
{renderTrigger()}
|
||||
@ -1,8 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import CredentialSelector from './credential-selector'
|
||||
import CredentialSelector from '../credential-selector'
|
||||
|
||||
vi.mock('./authorized/credential-item', () => ({
|
||||
vi.mock('../authorized/credential-item', () => ({
|
||||
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick?: (c: unknown) => void }) => (
|
||||
<button type="button" onClick={() => onItemClick?.(credential)}>
|
||||
{credential.credential_name}
|
||||
@ -1,10 +1,10 @@
|
||||
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ManageCustomModelCredentials from './manage-custom-model-credentials'
|
||||
import ManageCustomModelCredentials from '../manage-custom-model-credentials'
|
||||
|
||||
// Mock hooks
|
||||
const mockUseCustomModels = vi.fn()
|
||||
vi.mock('./hooks', () => ({
|
||||
vi.mock('../hooks', () => ({
|
||||
useCustomModels: () => mockUseCustomModels(),
|
||||
useAuth: () => ({
|
||||
handleOpenModal: vi.fn(),
|
||||
@ -12,14 +12,34 @@ vi.mock('./hooks', () => ({
|
||||
}))
|
||||
|
||||
// Mock Authorized
|
||||
vi.mock('./authorized', () => ({
|
||||
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: Array<{ selectedCredential?: unknown }>, popupTitle: string }) => (
|
||||
vi.mock('../authorized', () => ({
|
||||
default: ({
|
||||
renderTrigger,
|
||||
items,
|
||||
popupTitle,
|
||||
}: {
|
||||
renderTrigger: (o?: boolean) => React.ReactNode
|
||||
items: Array<{
|
||||
model?: { model?: string }
|
||||
selectedCredential?: { credential_id?: string }
|
||||
}>
|
||||
popupTitle: string
|
||||
}) => (
|
||||
<div data-testid="authorized-mock">
|
||||
<div data-testid="trigger-closed">{renderTrigger()}</div>
|
||||
<div data-testid="trigger-open">{renderTrigger(true)}</div>
|
||||
<div data-testid="popup-title">{popupTitle}</div>
|
||||
<div data-testid="items-count">{items.length}</div>
|
||||
<div data-testid="items-selected">{items.map((it, i) => <span key={i} data-testid={`selected-${i}`}>{it.selectedCredential ? 'has-cred' : 'no-cred'}</span>)}</div>
|
||||
<div data-testid="items-selected">
|
||||
{items.map((item, index) => (
|
||||
<span
|
||||
key={item.model?.model ?? item.selectedCredential?.credential_id ?? `missing-${popupTitle}`}
|
||||
data-testid={`selected-${index}`}
|
||||
>
|
||||
{item.selectedCredential ? 'has-cred' : 'no-cred'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -1,10 +1,10 @@
|
||||
import type { CustomModel, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import SwitchCredentialInLoadBalancing from './switch-credential-in-load-balancing'
|
||||
import SwitchCredentialInLoadBalancing from '../switch-credential-in-load-balancing'
|
||||
|
||||
// Mock components
|
||||
vi.mock('./authorized', () => ({
|
||||
vi.mock('../authorized', () => ({
|
||||
default: ({ renderTrigger, onItemClick, items }: { renderTrigger: () => React.ReactNode, onItemClick: (c: unknown) => void, items: { credentials: unknown[] }[] }) => (
|
||||
<div data-testid="authorized-mock">
|
||||
<div data-testid="trigger-container" onClick={() => onItemClick(items[0].credentials[0])}>
|
||||
@ -1,13 +1,13 @@
|
||||
import type { Credential, CustomModelCredential, ModelProvider } from '../../declarations'
|
||||
import type { Credential, CustomModelCredential, ModelProvider } from '../../../declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '../../declarations'
|
||||
import { AuthorizedItem } from './authorized-item'
|
||||
import { ModelTypeEnum } from '../../../declarations'
|
||||
import { AuthorizedItem } from '../authorized-item'
|
||||
|
||||
vi.mock('../../model-icon', () => ({
|
||||
vi.mock('../../../model-icon', () => ({
|
||||
default: ({ modelName }: { modelName: string }) => <div data-testid="model-icon">{modelName}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('./credential-item', () => ({
|
||||
vi.mock('../credential-item', () => ({
|
||||
default: ({ credential, onEdit, onDelete, onItemClick }: {
|
||||
credential: Credential
|
||||
onEdit?: (credential: Credential) => void
|
||||
@ -1,12 +1,6 @@
|
||||
import type { Credential } from '../../declarations'
|
||||
import type { Credential } from '../../../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import CredentialItem from './credential-item'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiCheckLine: () => <div data-testid="check-icon" />,
|
||||
RiDeleteBinLine: () => <div data-testid="delete-icon" />,
|
||||
RiEqualizer2Line: () => <div data-testid="edit-icon" />,
|
||||
}))
|
||||
import CredentialItem from '../credential-item'
|
||||
|
||||
vi.mock('@/app/components/header/indicator', () => ({
|
||||
default: () => <div data-testid="indicator" />,
|
||||
@ -61,8 +55,12 @@ describe('CredentialItem', () => {
|
||||
|
||||
render(<CredentialItem credential={credential} onEdit={onEdit} onDelete={onDelete} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-icon').closest('button') as HTMLButtonElement)
|
||||
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const editButton = buttons.find(b => b.querySelector('.i-ri-equalizer-2-line'))!
|
||||
const deleteButton = buttons.find(b => b.querySelector('.i-ri-delete-bin-line'))!
|
||||
|
||||
fireEvent.click(editButton)
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onEdit).toHaveBeenCalledWith(credential)
|
||||
expect(onDelete).toHaveBeenCalledWith(credential)
|
||||
@ -81,7 +79,10 @@ describe('CredentialItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
|
||||
const deleteButton = screen.getAllByRole('button')
|
||||
.find(b => b.querySelector('.i-ri-delete-bin-line'))!
|
||||
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
@ -121,14 +122,16 @@ describe('CredentialItem', () => {
|
||||
|
||||
render(<CredentialItem credential={credential} disabled onDelete={onDelete} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
|
||||
const deleteButton = screen.getAllByRole('button')
|
||||
.find(b => b.querySelector('.i-ri-delete-bin-line'))!
|
||||
fireEvent.click(deleteButton)
|
||||
|
||||
expect(onDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match
|
||||
it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<CredentialItem
|
||||
credential={credential}
|
||||
showSelectedIcon
|
||||
@ -136,7 +139,7 @@ describe('CredentialItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
|
||||
expect(container.querySelector('.i-ri-check-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => {
|
||||
@ -1,7 +1,7 @@
|
||||
import type { Credential, CustomModel, ModelProvider } from '../../declarations'
|
||||
import type { Credential, CustomModel, ModelProvider } from '../../../declarations'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ConfigurationMethodEnum, ModelTypeEnum } from '../../declarations'
|
||||
import Authorized from './index'
|
||||
import { ConfigurationMethodEnum, ModelTypeEnum } from '../../../declarations'
|
||||
import Authorized from '../index'
|
||||
|
||||
const mockHandleOpenModal = vi.fn()
|
||||
const mockHandleActiveCredential = vi.fn()
|
||||
@ -12,7 +12,7 @@ const mockHandleConfirmDelete = vi.fn()
|
||||
let mockDeleteCredentialId: string | null = null
|
||||
let mockDoingAction = false
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
vi.mock('../../hooks', () => ({
|
||||
useAuth: () => ({
|
||||
openConfirmDelete: mockOpenConfirmDelete,
|
||||
closeConfirmDelete: mockCloseConfirmDelete,
|
||||
@ -24,7 +24,7 @@ vi.mock('../hooks', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('./authorized-item', () => ({
|
||||
vi.mock('../authorized-item', () => ({
|
||||
default: ({ credentials, model, onEdit, onDelete, onItemClick }: {
|
||||
credentials: Credential[]
|
||||
model?: CustomModel
|
||||
@ -1,9 +1,4 @@
|
||||
import type { Credential } from '../../declarations'
|
||||
import {
|
||||
RiCheckLine,
|
||||
RiDeleteBinLine,
|
||||
RiEqualizer2Line,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
@ -11,7 +6,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@ -56,7 +51,7 @@ const CredentialItem = ({
|
||||
key={credential.credential_id}
|
||||
className={cn(
|
||||
'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover',
|
||||
(disabled || credential.not_allowed_to_use) && 'cursor-not-allowed opacity-50',
|
||||
(disabled || credential.not_allowed_to_use) ? 'cursor-not-allowed opacity-50' : onItemClick && 'cursor-pointer',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled || credential.not_allowed_to_use)
|
||||
@ -70,7 +65,7 @@ const CredentialItem = ({
|
||||
<div className="h-4 w-4">
|
||||
{
|
||||
selectedCredentialId === credential.credential_id && (
|
||||
<RiCheckLine className="h-4 w-4 text-text-accent" />
|
||||
<span className="i-ri-check-line h-4 w-4 text-text-accent" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@ -96,38 +91,50 @@ const CredentialItem = ({
|
||||
<div className="ml-2 hidden shrink-0 items-center group-hover:flex">
|
||||
{
|
||||
!disableEdit && !credential.not_allowed_to_use && (
|
||||
<Tooltip popupContent={t('operation.edit', { ns: 'common' })}>
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit?.(credential)
|
||||
}}
|
||||
>
|
||||
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit?.(credential)
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>{t('operation.edit', { ns: 'common' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
!disableDelete && (
|
||||
<Tooltip popupContent={disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })}>
|
||||
<ActionButton
|
||||
className="hover:bg-transparent"
|
||||
onClick={(e) => {
|
||||
if (disabled || disableDeleteWhenSelected)
|
||||
return
|
||||
e.stopPropagation()
|
||||
onDelete?.(credential)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className={cn(
|
||||
'h-4 w-4 text-text-tertiary',
|
||||
!disableDeleteWhenSelected && 'hover:text-text-destructive',
|
||||
disableDeleteWhenSelected && 'opacity-50',
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<ActionButton
|
||||
className="hover:bg-transparent"
|
||||
onClick={(e) => {
|
||||
if (disabled || disableDeleteWhenSelected)
|
||||
return
|
||||
e.stopPropagation()
|
||||
onDelete?.(credential)
|
||||
}}
|
||||
>
|
||||
<span className={cn(
|
||||
'i-ri-delete-bin-line h-4 w-4 text-text-tertiary',
|
||||
!disableDeleteWhenSelected && 'hover:text-text-destructive',
|
||||
disableDeleteWhenSelected && 'opacity-50',
|
||||
)}
|
||||
/>
|
||||
</ActionButton>
|
||||
)}
|
||||
/>
|
||||
</ActionButton>
|
||||
/>
|
||||
<TooltipContent>
|
||||
{disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -139,8 +146,9 @@ const CredentialItem = ({
|
||||
|
||||
if (credential.not_allowed_to_use) {
|
||||
return (
|
||||
<Tooltip popupContent={t('auth.customCredentialUnavailable', { ns: 'plugin' })}>
|
||||
{Item}
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={Item} />
|
||||
<TooltipContent>{t('auth.customCredentialUnavailable', { ns: 'plugin' })}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { CustomModel } from '../../declarations'
|
||||
import type { CustomModel } from '../../../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { ModelTypeEnum } from '../../declarations'
|
||||
import { useAuthService, useGetCredential } from './use-auth-service'
|
||||
import { ModelTypeEnum } from '../../../declarations'
|
||||
import { useAuthService, useGetCredential } from '../use-auth-service'
|
||||
|
||||
vi.mock('@/service/use-models', () => ({
|
||||
useGetProviderCredential: vi.fn(),
|
||||
@ -3,11 +3,11 @@ import type {
|
||||
Credential,
|
||||
CustomModel,
|
||||
ModelProvider,
|
||||
} from '../../declarations'
|
||||
} from '../../../declarations'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { ToastContext } from '@/app/components/base/toast/context'
|
||||
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations'
|
||||
import { useAuth } from './use-auth'
|
||||
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../../declarations'
|
||||
import { useAuth } from '../use-auth'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockHandleRefreshModel = vi.fn()
|
||||
@ -39,7 +39,7 @@ vi.mock('@/service/use-models', () => ({
|
||||
useDeleteModel: () => ({ mutateAsync: mockDeleteModelService }),
|
||||
}))
|
||||
|
||||
vi.mock('./use-auth-service', () => ({
|
||||
vi.mock('../use-auth-service', () => ({
|
||||
useAuthService: () => ({
|
||||
getDeleteCredentialService: (isModel: boolean) => (isModel ? mockDeleteModelCredential : mockDeleteProviderCredential),
|
||||
getActiveCredentialService: (isModel: boolean) => (isModel ? mockActiveModelCredential : mockActiveProviderCredential),
|
||||
@ -1,13 +1,13 @@
|
||||
import type { Credential, CustomModelCredential, ModelProvider } from '../../declarations'
|
||||
import type { Credential, CustomModelCredential, ModelProvider } from '../../../declarations'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useCredentialData } from './use-credential-data'
|
||||
import { useCredentialData } from '../use-credential-data'
|
||||
|
||||
vi.mock('./use-auth-service', () => ({
|
||||
vi.mock('../use-auth-service', () => ({
|
||||
useGetCredential: vi.fn(),
|
||||
}))
|
||||
|
||||
const { useGetCredential } = await import('./use-auth-service')
|
||||
const { useGetCredential } = await import('../use-auth-service')
|
||||
|
||||
describe('useCredentialData', () => {
|
||||
let queryClient: QueryClient
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ModelProvider } from '../../declarations'
|
||||
import type { ModelProvider } from '../../../declarations'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useCredentialStatus } from './use-credential-status'
|
||||
import { useCredentialStatus } from '../use-credential-status'
|
||||
|
||||
describe('useCredentialStatus', () => {
|
||||
it('computes authorized and authRemoved status correctly', () => {
|
||||
@ -53,4 +53,14 @@ describe('useCredentialStatus', () => {
|
||||
expect(result.current.hasCredential).toBe(false)
|
||||
expect(result.current.available_credentials).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles undefined provider gracefully', () => {
|
||||
const { result } = renderHook(() => useCredentialStatus(undefined))
|
||||
expect(result.current.hasCredential).toBe(false)
|
||||
expect(result.current.authorized).toBeFalsy()
|
||||
expect(result.current.authRemoved).toBe(false)
|
||||
expect(result.current.available_credentials).toBeUndefined()
|
||||
expect(result.current.current_credential_id).toBeUndefined()
|
||||
expect(result.current.current_credential_name).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ModelProvider } from '../../declarations'
|
||||
import type { ModelProvider } from '../../../declarations'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useCanAddedModels, useCustomModels } from './use-custom-models'
|
||||
import { useCanAddedModels, useCustomModels } from '../use-custom-models'
|
||||
|
||||
describe('useCustomModels and useCanAddedModels', () => {
|
||||
it('extracts custom models from provider correctly', () => {
|
||||
@ -2,13 +2,13 @@ import type {
|
||||
Credential,
|
||||
CustomModelCredential,
|
||||
ModelProvider,
|
||||
} from '../../declarations'
|
||||
} from '../../../declarations'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FormTypeEnum } from '@/app/components/base/form/types'
|
||||
import { useModelFormSchemas } from './use-model-form-schemas'
|
||||
import { useModelFormSchemas } from '../use-model-form-schemas'
|
||||
|
||||
vi.mock('../../utils', () => ({
|
||||
vi.mock('../../../utils', () => ({
|
||||
genModelNameFormSchema: vi.fn(() => ({
|
||||
type: FormTypeEnum.textInput,
|
||||
variable: '__model_name',
|
||||
@ -3,12 +3,12 @@ import type {
|
||||
} from '../../declarations'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export const useCredentialStatus = (provider: ModelProvider) => {
|
||||
export const useCredentialStatus = (provider: ModelProvider | undefined) => {
|
||||
const {
|
||||
current_credential_id,
|
||||
current_credential_name,
|
||||
available_credentials,
|
||||
} = provider.custom_configuration
|
||||
} = provider?.custom_configuration ?? {}
|
||||
const hasCredential = !!available_credentials?.length
|
||||
const authorized = current_credential_id && current_credential_name
|
||||
const authRemoved = hasCredential && !current_credential_id && !current_credential_name
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ModelBadge from './index'
|
||||
import ModelBadge from '../index'
|
||||
|
||||
describe('ModelBadge', () => {
|
||||
beforeEach(() => {
|
||||
@ -10,7 +10,7 @@ const ModelBadge: FC<ModelBadgeProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('flex h-[18px] cursor-default items-center rounded-[5px] border border-divider-deep px-1 text-text-tertiary system-2xs-medium-uppercase', className)}>
|
||||
<div className={cn('inline-flex h-[18px] shrink-0 items-center justify-center whitespace-nowrap rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] text-text-tertiary system-2xs-medium-uppercase', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { Model } from '../declarations'
|
||||
import type { Model } from '../../declarations'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Theme } from '@/types/app'
|
||||
import {
|
||||
ConfigurationMethodEnum,
|
||||
ModelStatusEnum,
|
||||
ModelTypeEnum,
|
||||
} from '../declarations'
|
||||
import ModelIcon from './index'
|
||||
} from '../../declarations'
|
||||
import ModelIcon from '../index'
|
||||
|
||||
type I18nText = {
|
||||
en_US: string
|
||||
@ -20,7 +20,7 @@ vi.mock('@/hooks/use-theme', () => ({
|
||||
default: () => ({ theme: mockTheme }),
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
vi.mock('../../hooks', () => ({
|
||||
useLanguage: () => mockLanguage,
|
||||
}))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user