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:
Novice
2026-03-23 14:20:06 +08:00
1671 changed files with 124822 additions and 22302 deletions

View File

@ -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(),
}))

View File

@ -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>,
}))

View File

@ -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} />,

View File

@ -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(),

View File

@ -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

View File

@ -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')>()

View File

@ -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(),

View File

@ -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' },

View File

@ -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'

View File

@ -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(),

View File

@ -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(),

View File

@ -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'

View File

@ -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', () => {

View File

@ -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

View File

@ -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)
})
})

View File

@ -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')
})
})

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import Empty from './empty'
import Empty from '../empty'
describe('Empty State', () => {
describe('Rendering', () => {

View File

@ -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(),

View File

@ -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', () => ({

View File

@ -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(),

View File

@ -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(),

View File

@ -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[] = [

View File

@ -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(),
}))

View File

@ -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

View File

@ -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(),
}))

View File

@ -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(),
}))

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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" />,
}))

View File

@ -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>

View File

@ -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>

View File

@ -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', () => {

View File

@ -4,7 +4,7 @@ import {
ValidatedErrorMessage,
ValidatedSuccessIcon,
ValidatingTip,
} from './ValidateStatus'
} from '../ValidateStatus'
describe('ValidateStatus', () => {
beforeEach(() => {

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { ValidatedStatus } from './declarations'
import { ValidatedStatus } from '../declarations'
describe('declarations', () => {
describe('ValidatedStatus', () => {

View File

@ -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(() => {

View File

@ -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),
}))

View File

@ -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 }),
}))

View File

@ -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'

View File

@ -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>

View File

@ -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')

View File

@ -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)
})
})

View File

@ -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()
})
})
})

View File

@ -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

View File

@ -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>
)}

View File

@ -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(),

View File

@ -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')

View File

@ -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 }))

View File

@ -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')

View File

@ -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()

View File

@ -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')

View File

@ -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>
),

View File

@ -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')

View File

@ -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 () => {

View File

@ -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>
)
}

View File

@ -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)
})
})
})

View File

@ -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')
})
})

View File

@ -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(),

View File

@ -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()
})
})

View File

@ -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()
})
})

View File

@ -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', () => {

View File

@ -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)
})
})

View File

@ -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')

View File

@ -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])
}

View File

@ -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'
}
}

View File

@ -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,

View File

@ -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()
})
})

View File

@ -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}

View File

@ -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>

View File

@ -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

View File

@ -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" />,
}))

View File

@ -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', () => ({

View File

@ -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()}

View File

@ -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}

View File

@ -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>
),
}))

View File

@ -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])}>

View File

@ -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

View File

@ -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', () => {

View File

@ -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

View File

@ -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>
)
}

View File

@ -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(),

View File

@ -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),

View File

@ -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

View File

@ -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()
})
})

View File

@ -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', () => {

View File

@ -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',

View File

@ -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

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import ModelBadge from './index'
import ModelBadge from '../index'
describe('ModelBadge', () => {
beforeEach(() => {

View File

@ -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>
)

View File

@ -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