mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 10:28:10 +08:00
Merge branch 'main' into feat/hitl-frontend
This commit is contained in:
@ -176,7 +176,7 @@ const DatasetConfig: FC = () => {
|
||||
}))
|
||||
}, [setDatasetConfigs, datasetConfigsRef])
|
||||
|
||||
const handleAddCondition = useCallback<HandleAddCondition>(({ name, type }) => {
|
||||
const handleAddCondition = useCallback<HandleAddCondition>(({ id, name, type }) => {
|
||||
let operator: ComparisonOperator = ComparisonOperator.is
|
||||
|
||||
if (type === MetadataFilteringVariableType.number)
|
||||
@ -184,6 +184,7 @@ const DatasetConfig: FC = () => {
|
||||
|
||||
const newCondition = {
|
||||
id: uuid4(),
|
||||
metadata_id: id, // Save metadata.id for reliable reference
|
||||
name,
|
||||
comparison_operator: operator,
|
||||
}
|
||||
|
||||
@ -679,7 +679,7 @@ const Configuration: FC = () => {
|
||||
const toolInCollectionList = collectionList.find(c => tool.provider_id === c.id)
|
||||
return {
|
||||
...tool,
|
||||
isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.id === tool.id && deletedTool.tool_name === tool.tool_name) ?? false,
|
||||
isDeleted: res.deleted_tools?.some((deletedTool: any) => deletedTool.provider_id === tool.provider_id && deletedTool.tool_name === tool.tool_name) ?? false,
|
||||
notAuthor: toolInCollectionList?.is_team_authorization === false,
|
||||
...(tool.provider_type === 'builtin'
|
||||
? {
|
||||
|
||||
136
web/app/components/app/create-app-dialog/app-list/index.spec.tsx
Normal file
136
web/app/components/app/create-app-dialog/app-list/index.spec.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import Apps from './index'
|
||||
|
||||
const mockUseExploreAppList = vi.fn()
|
||||
|
||||
vi.mock('ahooks', () => ({
|
||||
useDebounceFn: (fn: () => void) => ({
|
||||
run: () => setTimeout(fn, 0),
|
||||
cancel: vi.fn(),
|
||||
flush: () => fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({ isCurrentWorkspaceEditor: true }),
|
||||
}))
|
||||
vi.mock('use-context-selector', async () => {
|
||||
const actual = await vi.importActual<typeof import('use-context-selector')>('use-context-selector')
|
||||
return {
|
||||
...actual,
|
||||
useContext: () => ({ hasEditPermission: true }),
|
||||
}
|
||||
})
|
||||
vi.mock('@/hooks/use-tab-searchparams', () => ({
|
||||
useTabSearchParams: () => ['Recommended', vi.fn()],
|
||||
}))
|
||||
vi.mock('@/service/use-explore', () => ({
|
||||
useExploreAppList: () => mockUseExploreAppList(),
|
||||
}))
|
||||
vi.mock('@/app/components/app/type-selector', () => ({
|
||||
__esModule: true,
|
||||
default: ({ value, onChange }: { value: AppModeEnum[], onChange: (value: AppModeEnum[]) => void }) => (
|
||||
<button data-testid="type-selector" onClick={() => onChange([...value, 'chat' as AppModeEnum])}>{value.join(',')}</button>
|
||||
),
|
||||
}))
|
||||
vi.mock('../app-card', () => ({
|
||||
__esModule: true,
|
||||
default: ({ app, onCreate }: { app: any, onCreate: () => void }) => (
|
||||
<div
|
||||
data-testid="app-card"
|
||||
data-name={app.app.name}
|
||||
onClick={onCreate}
|
||||
>
|
||||
{app.app.name}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
vi.mock('@/app/components/explore/create-app-modal', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="create-from-template-modal" />,
|
||||
}))
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
default: { notify: vi.fn() },
|
||||
}))
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/service/apps', () => ({
|
||||
importDSL: vi.fn().mockResolvedValue({ app_id: '1' }),
|
||||
}))
|
||||
vi.mock('@/service/explore', () => ({
|
||||
fetchAppDetail: vi.fn().mockResolvedValue({
|
||||
export_data: 'dsl',
|
||||
mode: 'chat',
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({
|
||||
usePluginDependencies: () => ({
|
||||
handleCheckPluginDependencies: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/utils/app-redirection', () => ({
|
||||
getRedirection: vi.fn(),
|
||||
}))
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}))
|
||||
|
||||
const createAppEntry = (name: string, category: string) => ({
|
||||
app_id: name,
|
||||
category,
|
||||
app: {
|
||||
id: name,
|
||||
name,
|
||||
icon_type: 'emoji',
|
||||
icon: '🙂',
|
||||
icon_background: '#000',
|
||||
icon_url: null,
|
||||
description: 'desc',
|
||||
mode: AppModeEnum.CHAT,
|
||||
},
|
||||
})
|
||||
|
||||
describe('Apps', () => {
|
||||
const defaultData = {
|
||||
allList: [
|
||||
createAppEntry('Alpha', 'Cat A'),
|
||||
createAppEntry('Bravo', 'Cat B'),
|
||||
],
|
||||
categories: ['Cat A', 'Cat B'],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseExploreAppList.mockReturnValue({
|
||||
data: defaultData,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders template cards when data is available', () => {
|
||||
render(<Apps />)
|
||||
|
||||
expect(screen.getAllByTestId('app-card')).toHaveLength(2)
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
||||
expect(screen.getByText('Bravo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens create modal when a template card is clicked', () => {
|
||||
render(<Apps />)
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('app-card')[0])
|
||||
expect(screen.getByTestId('create-from-template-modal')).toBeInTheDocument()
|
||||
})
|
||||
it('shows no template message when list is empty', () => {
|
||||
mockUseExploreAppList.mockReturnValueOnce({
|
||||
data: { allList: [], categories: [] },
|
||||
isLoading: false,
|
||||
})
|
||||
|
||||
render(<Apps />)
|
||||
|
||||
expect(screen.getByText('app.newApp.noTemplateFound')).toBeInTheDocument()
|
||||
expect(screen.getByText('app.newApp.noTemplateFoundTip')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,38 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Sidebar, { AppCategories } from './sidebar'
|
||||
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiStickyNoteAddLine: () => <span>sticky</span>,
|
||||
RiThumbUpLine: () => <span>thumb</span>,
|
||||
}))
|
||||
describe('Sidebar', () => {
|
||||
it('renders recommended and custom categories', () => {
|
||||
render(<Sidebar current={AppCategories.RECOMMENDED} categories={['Cat A', 'Cat B']} />)
|
||||
|
||||
expect(screen.getByText('app.newAppFromTemplate.sidebar.Recommended')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cat A')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cat B')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('notifies callbacks when items are clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const onCreate = vi.fn()
|
||||
render(
|
||||
<Sidebar
|
||||
current="Cat A"
|
||||
categories={['Cat A']}
|
||||
onClick={onClick}
|
||||
onCreateFromBlank={onCreate}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('app.newAppFromTemplate.sidebar.Recommended'))
|
||||
expect(onClick).toHaveBeenCalledWith(AppCategories.RECOMMENDED)
|
||||
|
||||
fireEvent.click(screen.getByText('Cat A'))
|
||||
expect(onClick).toHaveBeenCalledWith('Cat A')
|
||||
|
||||
fireEvent.click(screen.getByText('app.newApp.startFromBlank'))
|
||||
expect(onCreate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
217
web/app/components/app/overview/settings/index.spec.tsx
Normal file
217
web/app/components/app/overview/settings/index.spec.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { baseProviderContextValue } from '@/context/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import SettingsModal from './index'
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next')
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
if (options?.returnObjects)
|
||||
return [`${key}-feature-1`, `${key}-feature-2`]
|
||||
if (options)
|
||||
return `${key}:${JSON.stringify(options)}`
|
||||
return key
|
||||
},
|
||||
i18n: {
|
||||
language: 'en',
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
Trans: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}
|
||||
})
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnSave = vi.fn()
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockUseProviderContext = vi.fn<() => ProviderContextState>()
|
||||
|
||||
const buildModalContext = (): ModalContextState => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
setShowApiBasedExtensionModal: vi.fn(),
|
||||
setShowModerationSettingModal: vi.fn(),
|
||||
setShowExternalDataToolModal: vi.fn(),
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
setShowAnnotationFullModal: vi.fn(),
|
||||
setShowModelModal: vi.fn(),
|
||||
setShowExternalKnowledgeAPIModal: vi.fn(),
|
||||
setShowModelLoadBalancingModal: vi.fn(),
|
||||
setShowOpeningModal: vi.fn(),
|
||||
setShowUpdatePluginModal: vi.fn(),
|
||||
setShowEducationExpireNoticeModal: vi.fn(),
|
||||
setShowTriggerEventsLimitModal: vi.fn(),
|
||||
})
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => buildModalContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/toast', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/app/components/base/toast')>('@/app/components/base/toast')
|
||||
return {
|
||||
...actual,
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/context/i18n')>('@/context/i18n')
|
||||
return {
|
||||
...actual,
|
||||
useDocLink: () => (path?: string) => `https://docs.example.com${path ?? ''}`,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/context/provider-context', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/context/provider-context')>('@/context/provider-context')
|
||||
return {
|
||||
...actual,
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}
|
||||
})
|
||||
|
||||
const mockAppInfo = {
|
||||
site: {
|
||||
title: 'Test App',
|
||||
icon_type: 'emoji',
|
||||
icon: '😀',
|
||||
icon_background: '#ABCDEF',
|
||||
icon_url: 'https://example.com/icon.png',
|
||||
description: 'A description',
|
||||
chat_color_theme: '#123456',
|
||||
chat_color_theme_inverted: true,
|
||||
copyright: '© Dify',
|
||||
privacy_policy: '',
|
||||
custom_disclaimer: 'Disclaimer',
|
||||
default_language: 'en-US',
|
||||
show_workflow_steps: true,
|
||||
use_icon_as_answer_icon: true,
|
||||
},
|
||||
mode: AppModeEnum.ADVANCED_CHAT,
|
||||
enable_sso: false,
|
||||
} as unknown as AppDetailResponse & Partial<AppSSO>
|
||||
|
||||
const renderSettingsModal = () => render(
|
||||
<SettingsModal
|
||||
isChat
|
||||
isShow
|
||||
appInfo={mockAppInfo}
|
||||
onClose={mockOnClose}
|
||||
onSave={mockOnSave}
|
||||
/>,
|
||||
)
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
beforeEach(() => {
|
||||
mockNotify.mockClear()
|
||||
mockOnClose.mockClear()
|
||||
mockOnSave.mockClear()
|
||||
mockSetShowPricingModal.mockClear()
|
||||
mockSetShowAccountSettingModal.mockClear()
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
...baseProviderContextValue,
|
||||
enableBilling: true,
|
||||
plan: {
|
||||
...baseProviderContextValue.plan,
|
||||
type: Plan.sandbox,
|
||||
},
|
||||
webappCopyrightEnabled: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the modal and expose the expanded settings section', async () => {
|
||||
renderSettingsModal()
|
||||
expect(screen.getByText('appOverview.overview.appInfo.settings.title')).toBeInTheDocument()
|
||||
|
||||
const showMoreEntry = screen.getByText('appOverview.overview.appInfo.settings.more.entry')
|
||||
fireEvent.click(showMoreEntry)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.copyRightPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify the user when the name is empty', async () => {
|
||||
renderSettingsModal()
|
||||
const nameInput = screen.getByPlaceholderText('app.appNamePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'app.newApp.nameNotEmpty' }))
|
||||
})
|
||||
expect(mockOnSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate the theme color and show an error when the hex is invalid', async () => {
|
||||
renderSettingsModal()
|
||||
const colorInput = screen.getByPlaceholderText('E.g #A020F0')
|
||||
fireEvent.change(colorInput, { target: { value: 'not-a-hex' } })
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'appOverview.overview.appInfo.settings.invalidHexMessage',
|
||||
}))
|
||||
})
|
||||
expect(mockOnSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should validate the privacy policy URL when advanced settings are open', async () => {
|
||||
renderSettingsModal()
|
||||
fireEvent.click(screen.getByText('appOverview.overview.appInfo.settings.more.entry'))
|
||||
const privacyInput = screen.getByPlaceholderText('appOverview.overview.appInfo.settings.more.privacyPolicyPlaceholder')
|
||||
// eslint-disable-next-line sonarjs/no-clear-text-protocols
|
||||
fireEvent.change(privacyInput, { target: { value: 'ftp://invalid-url' } })
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'appOverview.overview.appInfo.settings.invalidPrivacyPolicy',
|
||||
}))
|
||||
})
|
||||
expect(mockOnSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save valid settings and close the modal', async () => {
|
||||
mockOnSave.mockResolvedValueOnce(undefined)
|
||||
renderSettingsModal()
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => expect(mockOnSave).toHaveBeenCalled())
|
||||
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: mockAppInfo.site.title,
|
||||
description: mockAppInfo.site.description,
|
||||
default_language: mockAppInfo.site.default_language,
|
||||
chat_color_theme: mockAppInfo.site.chat_color_theme,
|
||||
chat_color_theme_inverted: mockAppInfo.site.chat_color_theme_inverted,
|
||||
prompt_public: false,
|
||||
copyright: mockAppInfo.site.copyright,
|
||||
privacy_policy: mockAppInfo.site.privacy_policy,
|
||||
custom_disclaimer: mockAppInfo.site.custom_disclaimer,
|
||||
icon_type: 'emoji',
|
||||
icon: mockAppInfo.site.icon,
|
||||
icon_background: mockAppInfo.site.icon_background,
|
||||
show_workflow_steps: mockAppInfo.site.show_workflow_steps,
|
||||
use_icon_as_answer_icon: mockAppInfo.site.use_icon_as_answer_icon,
|
||||
enable_sso: mockAppInfo.enable_sso,
|
||||
}))
|
||||
expect(mockOnClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
360
web/app/components/base/badge/index.spec.tsx
Normal file
360
web/app/components/base/badge/index.spec.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Badge, { BadgeState, BadgeVariants } from './index'
|
||||
|
||||
describe('Badge', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render as a div element with badge class', () => {
|
||||
render(<Badge>Test Badge</Badge>)
|
||||
|
||||
const badge = screen.getByText('Test Badge')
|
||||
expect(badge).toHaveClass('badge')
|
||||
expect(badge.tagName).toBe('DIV')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ children: undefined, label: 'no children' },
|
||||
{ children: '', label: 'empty string' },
|
||||
])('should render correctly when provided $label', ({ children }) => {
|
||||
const { container } = render(<Badge>{children}</Badge>)
|
||||
|
||||
expect(container.firstChild).toHaveClass('badge')
|
||||
})
|
||||
|
||||
it('should render React Node children correctly', () => {
|
||||
render(
|
||||
<Badge data-testid="badge-with-icon">
|
||||
<span data-testid="custom-icon">🔔</span>
|
||||
</Badge>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('badge-with-icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('custom-icon')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('size prop', () => {
|
||||
it.each([
|
||||
{ size: undefined, label: 'medium (default)' },
|
||||
{ size: 's', label: 'small' },
|
||||
{ size: 'm', label: 'medium' },
|
||||
{ size: 'l', label: 'large' },
|
||||
] as const)('should render with $label size', ({ size }) => {
|
||||
render(<Badge size={size}>Test</Badge>)
|
||||
|
||||
const expectedSize = size || 'm'
|
||||
expect(screen.getByText('Test')).toHaveClass('badge', `badge-${expectedSize}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('state prop', () => {
|
||||
it.each([
|
||||
{ state: BadgeState.Warning, label: 'warning', expectedClass: 'badge-warning' },
|
||||
{ state: BadgeState.Accent, label: 'accent', expectedClass: 'badge-accent' },
|
||||
])('should render with $label state', ({ state, expectedClass }) => {
|
||||
render(<Badge state={state}>State Test</Badge>)
|
||||
|
||||
expect(screen.getByText('State Test')).toHaveClass(expectedClass)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ state: undefined, label: 'default (undefined)' },
|
||||
{ state: BadgeState.Default, label: 'default (explicit)' },
|
||||
])('should use default styles when state is $label', ({ state }) => {
|
||||
render(<Badge state={state}>State Test</Badge>)
|
||||
|
||||
const badge = screen.getByText('State Test')
|
||||
expect(badge).not.toHaveClass('badge-warning', 'badge-accent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('iconOnly prop', () => {
|
||||
it.each([
|
||||
{ size: 's', iconOnly: false, label: 'small with text' },
|
||||
{ size: 's', iconOnly: true, label: 'small icon-only' },
|
||||
{ size: 'm', iconOnly: false, label: 'medium with text' },
|
||||
{ size: 'm', iconOnly: true, label: 'medium icon-only' },
|
||||
{ size: 'l', iconOnly: false, label: 'large with text' },
|
||||
{ size: 'l', iconOnly: true, label: 'large icon-only' },
|
||||
] as const)('should render correctly for $label', ({ size, iconOnly }) => {
|
||||
const { container } = render(<Badge size={size} iconOnly={iconOnly}>🔔</Badge>)
|
||||
const badge = screen.getByText('🔔')
|
||||
|
||||
// Verify badge renders with correct size
|
||||
expect(badge).toHaveClass('badge', `badge-${size}`)
|
||||
|
||||
// Verify the badge is in the DOM and contains the content
|
||||
expect(badge).toBeInTheDocument()
|
||||
expect(container.firstChild).toBe(badge)
|
||||
})
|
||||
|
||||
it('should apply icon-only padding when iconOnly is true', () => {
|
||||
render(<Badge iconOnly>🔔</Badge>)
|
||||
|
||||
// When iconOnly is true, the badge should have uniform padding (all sides equal)
|
||||
const badge = screen.getByText('🔔')
|
||||
expect(badge).toHaveClass('p-1')
|
||||
})
|
||||
|
||||
it('should apply asymmetric padding when iconOnly is false', () => {
|
||||
render(<Badge iconOnly={false}>Badge</Badge>)
|
||||
|
||||
// When iconOnly is false, the badge should have different horizontal and vertical padding
|
||||
const badge = screen.getByText('Badge')
|
||||
expect(badge).toHaveClass('px-[5px]', 'py-[2px]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uppercase prop', () => {
|
||||
it.each([
|
||||
{ uppercase: undefined, label: 'default (undefined)', expected: 'system-2xs-medium' },
|
||||
{ uppercase: false, label: 'explicitly false', expected: 'system-2xs-medium' },
|
||||
{ uppercase: true, label: 'true', expected: 'system-2xs-medium-uppercase' },
|
||||
])('should apply $expected class when uppercase is $label', ({ uppercase, expected }) => {
|
||||
render(<Badge uppercase={uppercase}>Text</Badge>)
|
||||
|
||||
expect(screen.getByText('Text')).toHaveClass(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('styleCss prop', () => {
|
||||
it('should apply custom inline styles correctly', () => {
|
||||
const customStyles = {
|
||||
backgroundColor: 'rgb(0, 0, 255)',
|
||||
color: 'rgb(255, 255, 255)',
|
||||
padding: '10px',
|
||||
}
|
||||
render(<Badge styleCss={customStyles}>Styled Badge</Badge>)
|
||||
|
||||
expect(screen.getByText('Styled Badge')).toHaveStyle(customStyles)
|
||||
})
|
||||
|
||||
it('should apply inline styles without overriding core classes', () => {
|
||||
render(<Badge styleCss={{ backgroundColor: 'rgb(255, 0, 0)', margin: '5px' }}>Custom</Badge>)
|
||||
|
||||
const badge = screen.getByText('Custom')
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgb(255, 0, 0)', margin: '5px' })
|
||||
expect(badge).toHaveClass('badge')
|
||||
})
|
||||
})
|
||||
|
||||
describe('className prop', () => {
|
||||
it.each([
|
||||
{
|
||||
props: { className: 'custom-badge' },
|
||||
expected: ['badge', 'custom-badge'],
|
||||
label: 'single custom class',
|
||||
},
|
||||
{
|
||||
props: { className: 'custom-class another-class', size: 'l' as const },
|
||||
expected: ['badge', 'badge-l', 'custom-class', 'another-class'],
|
||||
label: 'multiple classes with size variant',
|
||||
},
|
||||
])('should merge $label with default classes', ({ props, expected }) => {
|
||||
render(<Badge {...props}>Test</Badge>)
|
||||
|
||||
expect(screen.getByText('Test')).toHaveClass(...expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTML attributes passthrough', () => {
|
||||
it.each([
|
||||
{ attr: 'data-testid', value: 'custom-badge-id', label: 'data attribute' },
|
||||
{ attr: 'id', value: 'unique-badge', label: 'id attribute' },
|
||||
{ attr: 'aria-label', value: 'Notification badge', label: 'aria-label' },
|
||||
{ attr: 'title', value: 'Hover tooltip', label: 'title attribute' },
|
||||
{ attr: 'role', value: 'status', label: 'ARIA role' },
|
||||
])('should pass through $label correctly', ({ attr, value }) => {
|
||||
render(<Badge {...{ [attr]: value }}>Test</Badge>)
|
||||
|
||||
expect(screen.getByText('Test')).toHaveAttribute(attr, value)
|
||||
})
|
||||
|
||||
it('should support multiple HTML attributes simultaneously', () => {
|
||||
render(
|
||||
<Badge
|
||||
data-testid="multi-attr-badge"
|
||||
id="badge-123"
|
||||
aria-label="Status indicator"
|
||||
title="Current status"
|
||||
>
|
||||
Test
|
||||
</Badge>,
|
||||
)
|
||||
|
||||
const badge = screen.getByTestId('multi-attr-badge')
|
||||
expect(badge).toHaveAttribute('id', 'badge-123')
|
||||
expect(badge).toHaveAttribute('aria-label', 'Status indicator')
|
||||
expect(badge).toHaveAttribute('title', 'Current status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event handlers', () => {
|
||||
it.each([
|
||||
{ handler: 'onClick', trigger: fireEvent.click, label: 'click' },
|
||||
{ handler: 'onMouseEnter', trigger: fireEvent.mouseEnter, label: 'mouse enter' },
|
||||
{ handler: 'onMouseLeave', trigger: fireEvent.mouseLeave, label: 'mouse leave' },
|
||||
])('should trigger $handler when $label occurs', ({ handler, trigger }) => {
|
||||
const mockHandler = vi.fn()
|
||||
render(<Badge {...{ [handler]: mockHandler }}>Badge</Badge>)
|
||||
|
||||
trigger(screen.getByText('Badge'))
|
||||
|
||||
expect(mockHandler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should handle user interaction flow with multiple events', () => {
|
||||
const handlers = {
|
||||
onClick: vi.fn(),
|
||||
onMouseEnter: vi.fn(),
|
||||
onMouseLeave: vi.fn(),
|
||||
}
|
||||
render(<Badge {...handlers}>Interactive</Badge>)
|
||||
|
||||
const badge = screen.getByText('Interactive')
|
||||
fireEvent.mouseEnter(badge)
|
||||
fireEvent.click(badge)
|
||||
fireEvent.mouseLeave(badge)
|
||||
|
||||
expect(handlers.onMouseEnter).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.onClick).toHaveBeenCalledTimes(1)
|
||||
expect(handlers.onMouseLeave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should pass event object to handler with correct properties', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<Badge onClick={handleClick}>Event Badge</Badge>)
|
||||
|
||||
fireEvent.click(screen.getByText('Event Badge'))
|
||||
|
||||
expect(handleClick).toHaveBeenCalledWith(expect.objectContaining({
|
||||
type: 'click',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Combined props', () => {
|
||||
it('should correctly apply all props when used together', () => {
|
||||
render(
|
||||
<Badge
|
||||
size="l"
|
||||
state={BadgeState.Warning}
|
||||
uppercase
|
||||
className="custom-badge"
|
||||
styleCss={{ backgroundColor: 'rgb(0, 0, 255)' }}
|
||||
data-testid="combined-badge"
|
||||
>
|
||||
Full Featured
|
||||
</Badge>,
|
||||
)
|
||||
|
||||
const badge = screen.getByTestId('combined-badge')
|
||||
expect(badge).toHaveClass('badge', 'badge-l', 'badge-warning', 'system-2xs-medium-uppercase', 'custom-badge')
|
||||
expect(badge).toHaveStyle({ backgroundColor: 'rgb(0, 0, 255)' })
|
||||
expect(badge).toHaveTextContent('Full Featured')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
props: { size: 'l' as const, state: BadgeState.Accent },
|
||||
expected: ['badge', 'badge-l', 'badge-accent'],
|
||||
label: 'size and state variants',
|
||||
},
|
||||
{
|
||||
props: { iconOnly: true, uppercase: true },
|
||||
expected: ['badge', 'system-2xs-medium-uppercase'],
|
||||
label: 'iconOnly and uppercase',
|
||||
},
|
||||
])('should combine $label correctly', ({ props, expected }) => {
|
||||
render(<Badge {...props}>Test</Badge>)
|
||||
|
||||
expect(screen.getByText('Test')).toHaveClass(...expected)
|
||||
})
|
||||
|
||||
it('should handle event handlers with combined props', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<Badge size="s" state={BadgeState.Warning} onClick={handleClick} className="interactive">
|
||||
Test
|
||||
</Badge>,
|
||||
)
|
||||
|
||||
const badge = screen.getByText('Test')
|
||||
expect(badge).toHaveClass('badge', 'badge-s', 'badge-warning', 'interactive')
|
||||
|
||||
fireEvent.click(badge)
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it.each([
|
||||
{ children: 42, text: '42', label: 'numeric value' },
|
||||
{ children: 0, text: '0', label: 'zero' },
|
||||
])('should render $label correctly', ({ children, text }) => {
|
||||
render(<Badge>{children}</Badge>)
|
||||
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ children: null, label: 'null' },
|
||||
{ children: false, label: 'boolean false' },
|
||||
])('should handle $label children without errors', ({ children }) => {
|
||||
const { container } = render(<Badge>{children}</Badge>)
|
||||
|
||||
expect(container.firstChild).toHaveClass('badge')
|
||||
})
|
||||
|
||||
it('should render complex nested content correctly', () => {
|
||||
render(
|
||||
<Badge>
|
||||
<span data-testid="icon">🔔</span>
|
||||
<span data-testid="count">5</span>
|
||||
</Badge>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('count')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Component metadata and exports', () => {
|
||||
it('should have correct displayName for debugging', () => {
|
||||
expect(Badge.displayName).toBe('Badge')
|
||||
})
|
||||
|
||||
describe('BadgeState enum', () => {
|
||||
it.each([
|
||||
{ key: 'Warning', value: 'warning' },
|
||||
{ key: 'Accent', value: 'accent' },
|
||||
{ key: 'Default', value: '' },
|
||||
])('should export $key state with value "$value"', ({ key, value }) => {
|
||||
expect(BadgeState[key as keyof typeof BadgeState]).toBe(value)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BadgeVariants utility', () => {
|
||||
it('should be a function', () => {
|
||||
expect(typeof BadgeVariants).toBe('function')
|
||||
})
|
||||
|
||||
it('should generate base badge class with default medium size', () => {
|
||||
const result = BadgeVariants({})
|
||||
|
||||
expect(result).toContain('badge')
|
||||
expect(result).toContain('badge-m')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ size: 's' },
|
||||
{ size: 'm' },
|
||||
{ size: 'l' },
|
||||
] as const)('should generate correct classes for size=$size', ({ size }) => {
|
||||
const result = BadgeVariants({ size })
|
||||
|
||||
expect(result).toContain('badge')
|
||||
expect(result).toContain(`badge-${size}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
84
web/app/components/billing/billing-page/index.spec.tsx
Normal file
84
web/app/components/billing/billing-page/index.spec.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Billing from './index'
|
||||
|
||||
let currentBillingUrl: string | null = 'https://billing'
|
||||
let fetching = false
|
||||
let isManager = true
|
||||
let enableBilling = true
|
||||
|
||||
const refetchMock = vi.fn()
|
||||
const openAsyncWindowMock = vi.fn()
|
||||
|
||||
vi.mock('@/service/use-billing', () => ({
|
||||
useBillingUrl: () => ({
|
||||
data: currentBillingUrl,
|
||||
isFetching: fetching,
|
||||
refetch: refetchMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-async-window-open', () => ({
|
||||
useAsyncWindowOpen: () => openAsyncWindowMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
isCurrentWorkspaceManager: isManager,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
enableBilling,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../plan', () => ({
|
||||
__esModule: true,
|
||||
default: ({ loc }: { loc: string }) => <div data-testid="plan-component" data-loc={loc} />,
|
||||
}))
|
||||
|
||||
describe('Billing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentBillingUrl = 'https://billing'
|
||||
fetching = false
|
||||
isManager = true
|
||||
enableBilling = true
|
||||
refetchMock.mockResolvedValue({ data: 'https://billing' })
|
||||
})
|
||||
|
||||
it('hides the billing action when user is not manager or billing is disabled', () => {
|
||||
isManager = false
|
||||
render(<Billing />)
|
||||
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
|
||||
|
||||
vi.clearAllMocks()
|
||||
isManager = true
|
||||
enableBilling = false
|
||||
render(<Billing />)
|
||||
expect(screen.queryByRole('button', { name: /billing\.viewBillingTitle/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the billing window with the immediate url when the button is clicked', async () => {
|
||||
render(<Billing />)
|
||||
|
||||
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
|
||||
fireEvent.click(actionButton)
|
||||
|
||||
await waitFor(() => expect(openAsyncWindowMock).toHaveBeenCalled())
|
||||
const [, options] = openAsyncWindowMock.mock.calls[0]
|
||||
expect(options).toMatchObject({
|
||||
immediateUrl: currentBillingUrl,
|
||||
features: 'noopener,noreferrer',
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the button while billing url is fetching', () => {
|
||||
fetching = true
|
||||
render(<Billing />)
|
||||
|
||||
const actionButton = screen.getByRole('button', { name: /billing\.viewBillingTitle/ })
|
||||
expect(actionButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
92
web/app/components/billing/header-billing-btn/index.spec.tsx
Normal file
92
web/app/components/billing/header-billing-btn/index.spec.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { Plan } from '../type'
|
||||
import HeaderBillingBtn from './index'
|
||||
|
||||
type HeaderGlobal = typeof globalThis & {
|
||||
__mockProviderContext?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function getHeaderGlobal(): HeaderGlobal {
|
||||
return globalThis as HeaderGlobal
|
||||
}
|
||||
|
||||
const ensureProviderContextMock = () => {
|
||||
const globals = getHeaderGlobal()
|
||||
if (!globals.__mockProviderContext)
|
||||
throw new Error('Provider context mock not set')
|
||||
return globals.__mockProviderContext
|
||||
}
|
||||
|
||||
vi.mock('@/context/provider-context', () => {
|
||||
const mock = vi.fn()
|
||||
const globals = getHeaderGlobal()
|
||||
globals.__mockProviderContext = mock
|
||||
return {
|
||||
useProviderContext: () => mock(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
__esModule: true,
|
||||
default: () => <button data-testid="upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
describe('HeaderBillingBtn', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
ensureProviderContextMock().mockReturnValue({
|
||||
plan: {
|
||||
type: Plan.professional,
|
||||
},
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders nothing when billing is disabled or plan is not fetched', () => {
|
||||
ensureProviderContextMock().mockReturnValueOnce({
|
||||
plan: {
|
||||
type: Plan.professional,
|
||||
},
|
||||
enableBilling: false,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
|
||||
const { container } = render(<HeaderBillingBtn />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('renders upgrade button for sandbox plan', () => {
|
||||
ensureProviderContextMock().mockReturnValueOnce({
|
||||
plan: {
|
||||
type: Plan.sandbox,
|
||||
},
|
||||
enableBilling: true,
|
||||
isFetchedPlan: true,
|
||||
})
|
||||
|
||||
render(<HeaderBillingBtn />)
|
||||
|
||||
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders plan badge and forwards clicks when not display-only', () => {
|
||||
const onClick = vi.fn()
|
||||
|
||||
const { rerender } = render(<HeaderBillingBtn onClick={onClick} />)
|
||||
|
||||
const badge = screen.getByText('pro').closest('div')
|
||||
|
||||
expect(badge).toHaveClass('cursor-pointer')
|
||||
|
||||
fireEvent.click(badge!)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<HeaderBillingBtn onClick={onClick} isDisplayOnly />)
|
||||
expect(screen.getByText('pro').closest('div')).toHaveClass('cursor-default')
|
||||
|
||||
fireEvent.click(screen.getByText('pro').closest('div')!)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
44
web/app/components/billing/partner-stack/index.spec.tsx
Normal file
44
web/app/components/billing/partner-stack/index.spec.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import PartnerStack from './index'
|
||||
|
||||
let isCloudEdition = true
|
||||
|
||||
const saveOrUpdate = vi.fn()
|
||||
const bind = vi.fn()
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
get IS_CLOUD_EDITION() {
|
||||
return isCloudEdition
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./use-ps-info', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
saveOrUpdate,
|
||||
bind,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('PartnerStack', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
isCloudEdition = true
|
||||
})
|
||||
|
||||
it('does not call partner stack helpers when not in cloud edition', () => {
|
||||
isCloudEdition = false
|
||||
|
||||
render(<PartnerStack />)
|
||||
|
||||
expect(saveOrUpdate).not.toHaveBeenCalled()
|
||||
expect(bind).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls saveOrUpdate and bind once when running in cloud edition', () => {
|
||||
render(<PartnerStack />)
|
||||
|
||||
expect(saveOrUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(bind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
197
web/app/components/billing/partner-stack/use-ps-info.spec.tsx
Normal file
197
web/app/components/billing/partner-stack/use-ps-info.spec.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { PARTNER_STACK_CONFIG } from '@/config'
|
||||
import usePSInfo from './use-ps-info'
|
||||
|
||||
let searchParamsValues: Record<string, string | null> = {}
|
||||
const setSearchParams = (values: Record<string, string | null>) => {
|
||||
searchParamsValues = values
|
||||
}
|
||||
|
||||
type PartnerStackGlobal = typeof globalThis & {
|
||||
__partnerStackCookieMocks?: {
|
||||
get: ReturnType<typeof vi.fn>
|
||||
set: ReturnType<typeof vi.fn>
|
||||
remove: ReturnType<typeof vi.fn>
|
||||
}
|
||||
__partnerStackMutateAsync?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function getPartnerStackGlobal(): PartnerStackGlobal {
|
||||
return globalThis as PartnerStackGlobal
|
||||
}
|
||||
|
||||
const ensureCookieMocks = () => {
|
||||
const globals = getPartnerStackGlobal()
|
||||
if (!globals.__partnerStackCookieMocks)
|
||||
throw new Error('Cookie mocks not initialized')
|
||||
return globals.__partnerStackCookieMocks
|
||||
}
|
||||
|
||||
const ensureMutateAsync = () => {
|
||||
const globals = getPartnerStackGlobal()
|
||||
if (!globals.__partnerStackMutateAsync)
|
||||
throw new Error('Mutate mock not initialized')
|
||||
return globals.__partnerStackMutateAsync
|
||||
}
|
||||
|
||||
vi.mock('js-cookie', () => {
|
||||
const get = vi.fn()
|
||||
const set = vi.fn()
|
||||
const remove = vi.fn()
|
||||
const globals = getPartnerStackGlobal()
|
||||
globals.__partnerStackCookieMocks = { get, set, remove }
|
||||
const cookieApi = { get, set, remove }
|
||||
return {
|
||||
__esModule: true,
|
||||
default: cookieApi,
|
||||
get,
|
||||
set,
|
||||
remove,
|
||||
}
|
||||
})
|
||||
vi.mock('next/navigation', () => ({
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => searchParamsValues[key] ?? null,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/service/use-billing', () => {
|
||||
const mutateAsync = vi.fn()
|
||||
const globals = getPartnerStackGlobal()
|
||||
globals.__partnerStackMutateAsync = mutateAsync
|
||||
return {
|
||||
useBindPartnerStackInfo: () => ({
|
||||
mutateAsync,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('usePSInfo', () => {
|
||||
const originalLocationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location')
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'location', {
|
||||
value: { hostname: 'cloud.dify.ai' },
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setSearchParams({})
|
||||
const { get, set, remove } = ensureCookieMocks()
|
||||
get.mockReset()
|
||||
set.mockReset()
|
||||
remove.mockReset()
|
||||
const mutate = ensureMutateAsync()
|
||||
mutate.mockReset()
|
||||
mutate.mockResolvedValue(undefined)
|
||||
get.mockReturnValue('{}')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (originalLocationDescriptor)
|
||||
Object.defineProperty(globalThis, 'location', originalLocationDescriptor)
|
||||
})
|
||||
|
||||
it('saves partner info when query params change', () => {
|
||||
const { get, set } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({ partnerKey: 'old', clickId: 'old-click' }))
|
||||
setSearchParams({
|
||||
ps_partner_key: 'new-partner',
|
||||
ps_xid: 'new-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
expect(result.current.psPartnerKey).toBe('new-partner')
|
||||
expect(result.current.psClickId).toBe('new-click')
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
PARTNER_STACK_CONFIG.cookieName,
|
||||
JSON.stringify({
|
||||
partnerKey: 'new-partner',
|
||||
clickId: 'new-click',
|
||||
}),
|
||||
{
|
||||
expires: PARTNER_STACK_CONFIG.saveCookieDays,
|
||||
path: '/',
|
||||
domain: '.dify.ai',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('does not overwrite cookie when params do not change', () => {
|
||||
setSearchParams({
|
||||
ps_partner_key: 'existing',
|
||||
ps_xid: 'existing-click',
|
||||
})
|
||||
const { get } = ensureCookieMocks()
|
||||
get.mockReturnValue(JSON.stringify({
|
||||
partnerKey: 'existing',
|
||||
clickId: 'existing-click',
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
act(() => {
|
||||
result.current.saveOrUpdate()
|
||||
})
|
||||
|
||||
const { set } = ensureCookieMocks()
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('binds partner info and clears cookie once', async () => {
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
const mutate = ensureMutateAsync()
|
||||
const { remove } = ensureCookieMocks()
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mutate).toHaveBeenCalledWith({
|
||||
partnerKey: 'bind-partner',
|
||||
clickId: 'bind-click',
|
||||
})
|
||||
expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, {
|
||||
path: '/',
|
||||
domain: '.dify.ai',
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
expect(mutate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('still removes cookie when bind fails with status 400', async () => {
|
||||
const mutate = ensureMutateAsync()
|
||||
mutate.mockRejectedValueOnce({ status: 400 })
|
||||
setSearchParams({
|
||||
ps_partner_key: 'bind-partner',
|
||||
ps_xid: 'bind-click',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => usePSInfo())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bind()
|
||||
})
|
||||
|
||||
const { remove } = ensureCookieMocks()
|
||||
expect(remove).toHaveBeenCalledWith(PARTNER_STACK_CONFIG.cookieName, {
|
||||
path: '/',
|
||||
domain: '.dify.ai',
|
||||
})
|
||||
})
|
||||
})
|
||||
130
web/app/components/billing/plan/index.spec.tsx
Normal file
130
web/app/components/billing/plan/index.spec.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
|
||||
import { Plan } from '../type'
|
||||
import PlanComp from './index'
|
||||
|
||||
let currentPath = '/billing'
|
||||
|
||||
const push = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push }),
|
||||
usePathname: () => currentPath,
|
||||
}))
|
||||
|
||||
const setShowAccountSettingModalMock = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
useModalContextSelector: (selector: any) => selector({
|
||||
setShowAccountSettingModal: setShowAccountSettingModalMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
const providerContextMock = vi.fn()
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => providerContextMock(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: { email: 'user@example.com' },
|
||||
isCurrentWorkspaceManager: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mutateAsyncMock = vi.fn()
|
||||
let isPending = false
|
||||
vi.mock('@/service/use-education', () => ({
|
||||
useEducationVerify: () => ({
|
||||
mutateAsync: mutateAsyncMock,
|
||||
isPending,
|
||||
}),
|
||||
}))
|
||||
|
||||
const verifyStateModalMock = vi.fn(props => (
|
||||
<div data-testid="verify-modal" data-is-show={props.isShow ? 'true' : 'false'}>
|
||||
{props.isShow ? 'visible' : 'hidden'}
|
||||
</div>
|
||||
))
|
||||
vi.mock('@/app/education-apply/verify-state-modal', () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
default: (props: any) => verifyStateModalMock(props),
|
||||
}))
|
||||
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
__esModule: true,
|
||||
default: () => <button data-testid="plan-upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
describe('PlanComp', () => {
|
||||
const planMock = {
|
||||
type: Plan.professional,
|
||||
usage: {
|
||||
teamMembers: 4,
|
||||
documentsUploadQuota: 3,
|
||||
vectorSpace: 8,
|
||||
annotatedResponse: 5,
|
||||
triggerEvents: 60,
|
||||
apiRateLimit: 100,
|
||||
},
|
||||
total: {
|
||||
teamMembers: 10,
|
||||
documentsUploadQuota: 20,
|
||||
vectorSpace: 10,
|
||||
annotatedResponse: 500,
|
||||
triggerEvents: 100,
|
||||
apiRateLimit: 200,
|
||||
},
|
||||
reset: {
|
||||
triggerEvents: 2,
|
||||
apiRateLimit: 1,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
currentPath = '/billing'
|
||||
isPending = false
|
||||
providerContextMock.mockReturnValue({
|
||||
plan: planMock,
|
||||
enableEducationPlan: true,
|
||||
allowRefreshEducationVerify: false,
|
||||
isEducationAccount: false,
|
||||
})
|
||||
mutateAsyncMock.mockReset()
|
||||
mutateAsyncMock.mockResolvedValue({ token: 'token' })
|
||||
})
|
||||
|
||||
it('renders plan info and handles education verify success', async () => {
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
expect(screen.getByText('billing.plans.professional.name')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('plan-upgrade-btn')).toBeInTheDocument()
|
||||
|
||||
const verifyBtn = screen.getByText('education.toVerified')
|
||||
fireEvent.click(verifyBtn)
|
||||
|
||||
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
|
||||
await waitFor(() => expect(push).toHaveBeenCalledWith('/education-apply?token=token'))
|
||||
expect(localStorage.removeItem).toHaveBeenCalledWith(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
|
||||
})
|
||||
|
||||
it('shows modal when education verify fails', async () => {
|
||||
mutateAsyncMock.mockRejectedValueOnce(new Error('boom'))
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
const verifyBtn = screen.getByText('education.toVerified')
|
||||
fireEvent.click(verifyBtn)
|
||||
|
||||
await waitFor(() => expect(mutateAsyncMock).toHaveBeenCalled())
|
||||
await waitFor(() => expect(screen.getByTestId('verify-modal').getAttribute('data-is-show')).toBe('true'))
|
||||
})
|
||||
|
||||
it('resets modal context when on education apply path', () => {
|
||||
currentPath = '/education-apply/setup'
|
||||
render(<PlanComp loc="billing-page" />)
|
||||
|
||||
expect(setShowAccountSettingModalMock).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
25
web/app/components/billing/progress-bar/index.spec.tsx
Normal file
25
web/app/components/billing/progress-bar/index.spec.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ProgressBar from './index'
|
||||
|
||||
describe('ProgressBar', () => {
|
||||
it('renders with provided percent and color', () => {
|
||||
render(<ProgressBar percent={42} color="bg-test-color" />)
|
||||
|
||||
const bar = screen.getByTestId('billing-progress-bar')
|
||||
expect(bar).toHaveClass('bg-test-color')
|
||||
expect(bar.getAttribute('style')).toContain('width: 42%')
|
||||
})
|
||||
|
||||
it('caps width at 100% when percent exceeds max', () => {
|
||||
render(<ProgressBar percent={150} color="bg-test-color" />)
|
||||
|
||||
const bar = screen.getByTestId('billing-progress-bar')
|
||||
expect(bar.getAttribute('style')).toContain('width: 100%')
|
||||
})
|
||||
|
||||
it('uses the default color when no color prop is provided', () => {
|
||||
render(<ProgressBar percent={20} color={undefined as unknown as string} />)
|
||||
|
||||
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('#2970FF')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,70 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import TriggerEventsLimitModal from './index'
|
||||
|
||||
const mockOnClose = vi.fn()
|
||||
const mockOnUpgrade = vi.fn()
|
||||
|
||||
const planUpgradeModalMock = vi.fn((props: { show: boolean, title: string, description: string, extraInfo?: React.ReactNode, onClose: () => void, onUpgrade: () => void }) => (
|
||||
<div
|
||||
data-testid="plan-upgrade-modal"
|
||||
data-show={props.show}
|
||||
data-title={props.title}
|
||||
data-description={props.description}
|
||||
>
|
||||
{props.extraInfo}
|
||||
</div>
|
||||
))
|
||||
|
||||
vi.mock('@/app/components/billing/plan-upgrade-modal', () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
default: (props: any) => planUpgradeModalMock(props),
|
||||
}))
|
||||
|
||||
describe('TriggerEventsLimitModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('passes the trigger usage props to the upgrade modal', () => {
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show
|
||||
onClose={mockOnClose}
|
||||
onUpgrade={mockOnUpgrade}
|
||||
usage={12}
|
||||
total={20}
|
||||
resetInDays={5}
|
||||
/>,
|
||||
)
|
||||
|
||||
const modal = screen.getByTestId('plan-upgrade-modal')
|
||||
expect(modal.getAttribute('data-show')).toBe('true')
|
||||
expect(modal.getAttribute('data-title')).toContain('billing.triggerLimitModal.title')
|
||||
expect(modal.getAttribute('data-description')).toContain('billing.triggerLimitModal.description')
|
||||
expect(planUpgradeModalMock).toHaveBeenCalled()
|
||||
|
||||
const passedProps = planUpgradeModalMock.mock.calls[0][0]
|
||||
expect(passedProps.onClose).toBe(mockOnClose)
|
||||
expect(passedProps.onUpgrade).toBe(mockOnUpgrade)
|
||||
|
||||
expect(screen.getByText('billing.triggerLimitModal.usageTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('12')).toBeInTheDocument()
|
||||
expect(screen.getByText('20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders even when trigger modal is hidden', () => {
|
||||
render(
|
||||
<TriggerEventsLimitModal
|
||||
show={false}
|
||||
onClose={mockOnClose}
|
||||
onUpgrade={mockOnUpgrade}
|
||||
usage={0}
|
||||
total={0}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(planUpgradeModalMock).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('plan-upgrade-modal').getAttribute('data-show')).toBe('false')
|
||||
})
|
||||
})
|
||||
35
web/app/components/billing/usage-info/apps-info.spec.tsx
Normal file
35
web/app/components/billing/usage-info/apps-info.spec.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { defaultPlan } from '../config'
|
||||
import AppsInfo from './apps-info'
|
||||
|
||||
const appsUsage = 7
|
||||
const appsTotal = 15
|
||||
|
||||
const mockPlan = {
|
||||
...defaultPlan,
|
||||
usage: {
|
||||
...defaultPlan.usage,
|
||||
buildApps: appsUsage,
|
||||
},
|
||||
total: {
|
||||
...defaultPlan.total,
|
||||
buildApps: appsTotal,
|
||||
},
|
||||
}
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
plan: mockPlan,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('AppsInfo', () => {
|
||||
it('renders build apps usage information with context data', () => {
|
||||
render(<AppsInfo className="apps-info-class" />)
|
||||
|
||||
expect(screen.getByText('billing.usagePage.buildApps')).toBeInTheDocument()
|
||||
expect(screen.getByText(`${appsUsage}`)).toBeInTheDocument()
|
||||
expect(screen.getByText(`${appsTotal}`)).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.usagePage.buildApps').closest('.apps-info-class')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
114
web/app/components/billing/usage-info/index.spec.tsx
Normal file
114
web/app/components/billing/usage-info/index.spec.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { NUM_INFINITE } from '../config'
|
||||
import UsageInfo from './index'
|
||||
|
||||
const TestIcon = () => <span data-testid="usage-icon" />
|
||||
|
||||
describe('UsageInfo', () => {
|
||||
it('renders the metric with a suffix unit and tooltip text', () => {
|
||||
render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Apps"
|
||||
usage={30}
|
||||
total={100}
|
||||
unit="GB"
|
||||
tooltip="tooltip text"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('usage-icon')).toBeInTheDocument()
|
||||
expect(screen.getByText('Apps')).toBeInTheDocument()
|
||||
expect(screen.getByText('30')).toBeInTheDocument()
|
||||
expect(screen.getByText('100')).toBeInTheDocument()
|
||||
expect(screen.getByText('GB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders inline unit when unitPosition is inline', () => {
|
||||
render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
usage={20}
|
||||
total={100}
|
||||
unit="GB"
|
||||
unitPosition="inline"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('100GB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows reset hint text instead of the unit when resetHint is provided', () => {
|
||||
const resetHint = 'Resets in 3 days'
|
||||
render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
usage={20}
|
||||
total={100}
|
||||
unit="GB"
|
||||
resetHint={resetHint}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(resetHint)).toBeInTheDocument()
|
||||
expect(screen.queryByText('GB')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays unlimited text when total is infinite', () => {
|
||||
render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
usage={10}
|
||||
total={NUM_INFINITE}
|
||||
unit="GB"
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies warning color when usage is close to the limit', () => {
|
||||
render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
usage={85}
|
||||
total={100}
|
||||
/>,
|
||||
)
|
||||
|
||||
const progressBar = screen.getByTestId('billing-progress-bar')
|
||||
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
|
||||
})
|
||||
|
||||
it('applies error color when usage exceeds the limit', () => {
|
||||
render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
usage={120}
|
||||
total={100}
|
||||
/>,
|
||||
)
|
||||
|
||||
const progressBar = screen.getByTestId('billing-progress-bar')
|
||||
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
|
||||
})
|
||||
|
||||
it('does not render the icon when hideIcon is true', () => {
|
||||
render(
|
||||
<UsageInfo
|
||||
Icon={TestIcon}
|
||||
name="Storage"
|
||||
usage={5}
|
||||
total={100}
|
||||
hideIcon
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
58
web/app/components/billing/vector-space-full/index.spec.tsx
Normal file
58
web/app/components/billing/vector-space-full/index.spec.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import VectorSpaceFull from './index'
|
||||
|
||||
type VectorProviderGlobal = typeof globalThis & {
|
||||
__vectorProviderContext?: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function getVectorGlobal(): VectorProviderGlobal {
|
||||
return globalThis as VectorProviderGlobal
|
||||
}
|
||||
|
||||
vi.mock('@/context/provider-context', () => {
|
||||
const mock = vi.fn()
|
||||
getVectorGlobal().__vectorProviderContext = mock
|
||||
return {
|
||||
useProviderContext: () => mock(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../upgrade-btn', () => ({
|
||||
__esModule: true,
|
||||
default: () => <button data-testid="vector-upgrade-btn" type="button">Upgrade</button>,
|
||||
}))
|
||||
|
||||
describe('VectorSpaceFull', () => {
|
||||
const planMock = {
|
||||
type: 'team',
|
||||
usage: {
|
||||
vectorSpace: 8,
|
||||
},
|
||||
total: {
|
||||
vectorSpace: 10,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const globals = getVectorGlobal()
|
||||
globals.__vectorProviderContext?.mockReturnValue({
|
||||
plan: planMock,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders tip text and upgrade button', () => {
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
expect(screen.getByText('billing.vectorSpace.fullTip')).toBeInTheDocument()
|
||||
expect(screen.getByText('billing.vectorSpace.fullSolution')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('vector-upgrade-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows vector usage and total', () => {
|
||||
render(<VectorSpaceFull />)
|
||||
|
||||
expect(screen.getByText('8')).toBeInTheDocument()
|
||||
expect(screen.getByText('10MB')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,91 @@
|
||||
import type { Member } from '@/models/common'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Operation from './index'
|
||||
|
||||
const mockUpdateMemberRole = vi.fn()
|
||||
const mockDeleteMemberOrCancelInvitation = vi.fn()
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
deleteMemberOrCancelInvitation: () => mockDeleteMemberOrCancelInvitation(),
|
||||
updateMemberRole: () => mockUpdateMemberRole(),
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.fn(() => ({
|
||||
datasetOperatorEnabled: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
const defaultMember: Member = {
|
||||
id: 'member-id',
|
||||
name: 'Test Member',
|
||||
email: 'test@example.com',
|
||||
avatar: '',
|
||||
avatar_url: null,
|
||||
status: 'active',
|
||||
role: 'editor',
|
||||
last_login_at: '',
|
||||
last_active_at: '',
|
||||
created_at: '',
|
||||
}
|
||||
|
||||
const renderOperation = (propsOverride: Partial<Member> = {}, operatorRole = 'owner', onOperate?: () => void) => {
|
||||
const mergedMember = { ...defaultMember, ...propsOverride }
|
||||
return render(
|
||||
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
|
||||
<Operation member={mergedMember} operatorRole={operatorRole} onOperate={onOperate ?? vi.fn()} />
|
||||
</ToastContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Operation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: false })
|
||||
})
|
||||
|
||||
it('renders the current role label', () => {
|
||||
renderOperation()
|
||||
|
||||
expect(screen.getByText('common.members.editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows dataset operator option when the feature flag is enabled', async () => {
|
||||
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
|
||||
renderOperation()
|
||||
|
||||
fireEvent.click(screen.getByText('common.members.editor'))
|
||||
|
||||
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls updateMemberRole and onOperate when selecting another role', async () => {
|
||||
const onOperate = vi.fn()
|
||||
renderOperation({}, 'owner', onOperate)
|
||||
|
||||
fireEvent.click(screen.getByText('common.members.editor'))
|
||||
fireEvent.click(await screen.findByText('common.members.normal'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateMemberRole).toHaveBeenCalled()
|
||||
expect(onOperate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
|
||||
const onOperate = vi.fn()
|
||||
renderOperation({}, 'owner', onOperate)
|
||||
|
||||
fireEvent.click(screen.getByText('common.members.editor'))
|
||||
fireEvent.click(await screen.findByText('common.members.removeFromTeam'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()
|
||||
expect(onOperate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,10 +1,14 @@
|
||||
'use client'
|
||||
import type { Member } from '@/models/common'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
|
||||
import { Fragment, useMemo } from 'react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { deleteMemberOrCancelInvitation, updateMemberRole } from '@/service/common'
|
||||
@ -21,6 +25,7 @@ const Operation = ({
|
||||
operatorRole,
|
||||
onOperate,
|
||||
}: IOperationProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { datasetOperatorEnabled } = useProviderContext()
|
||||
const RoleMap = {
|
||||
@ -51,6 +56,7 @@ const Operation = ({
|
||||
const { notify } = useContext(ToastContext)
|
||||
const toHump = (name: string) => name.replace(/_(\w)/g, (all, letter) => letter.toUpperCase())
|
||||
const handleDeleteMemberOrCancelInvitation = async () => {
|
||||
setOpen(false)
|
||||
try {
|
||||
await deleteMemberOrCancelInvitation({ url: `/workspaces/current/members/${member.id}` })
|
||||
onOperate()
|
||||
@ -61,6 +67,7 @@ const Operation = ({
|
||||
}
|
||||
}
|
||||
const handleUpdateMemberRole = async (role: string) => {
|
||||
setOpen(false)
|
||||
try {
|
||||
await updateMemberRole({
|
||||
url: `/workspaces/current/members/${member.id}/update-role`,
|
||||
@ -75,63 +82,50 @@ const Operation = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative h-full w-full">
|
||||
{
|
||||
({ open }) => (
|
||||
<>
|
||||
<MenuButton className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
{RoleMap[member.role] || RoleMap.normal}
|
||||
<ChevronDownIcon className={cn('h-4 w-4 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className={cn('absolute right-0 top-[52px] z-10 origin-top-right rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}
|
||||
>
|
||||
<div className="p-1">
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottom-end"
|
||||
offset={{ mainAxis: 4 }}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => setOpen(prev => !prev)}>
|
||||
<div className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
|
||||
{RoleMap[member.role] || RoleMap.normal}
|
||||
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[999]">
|
||||
<div className={cn('inline-flex flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm')}>
|
||||
<div className="p-1">
|
||||
{
|
||||
roleList.map(role => (
|
||||
<div key={role} className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={() => handleUpdateMemberRole(role)}>
|
||||
{
|
||||
roleList.map(role => (
|
||||
<MenuItem key={role}>
|
||||
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={() => handleUpdateMemberRole(role)}>
|
||||
{
|
||||
role === member.role
|
||||
? <CheckIcon className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
|
||||
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
|
||||
}
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(`common.members.${toHump(role)}` as any)}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip` as any)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
))
|
||||
role === member.role
|
||||
? <CheckIcon className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
|
||||
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
|
||||
}
|
||||
</div>
|
||||
<MenuItem>
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
|
||||
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('common.members.removeFromTeam')}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('common.members.removeFromTeamTip')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(`common.members.${toHump(role)}` as any)}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(`common.members.${toHump(role)}Tip` as any)}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Menu>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div className="border-t border-divider-subtle p-1">
|
||||
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
|
||||
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
|
||||
<div>
|
||||
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('common.members.removeFromTeam')}</div>
|
||||
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('common.members.removeFromTeamTip')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default Operation
|
||||
export default memo(Operation)
|
||||
|
||||
@ -26,7 +26,7 @@ import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Res from '@/app/components/share/text-generation/result'
|
||||
import RunOnce from '@/app/components/share/text-generation/run-once'
|
||||
import { appDefaultIconBackground, DEFAULT_VALUE_MAX_LEN } from '@/config'
|
||||
import { appDefaultIconBackground, BATCH_CONCURRENCY, DEFAULT_VALUE_MAX_LEN } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useWebAppStore } from '@/context/web-app-context'
|
||||
import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||
@ -43,7 +43,7 @@ import MenuDropdown from './menu-dropdown'
|
||||
import RunBatch from './run-batch'
|
||||
import ResDownload from './run-batch/res-download'
|
||||
|
||||
const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group.
|
||||
const GROUP_SIZE = BATCH_CONCURRENCY // to avoid RPM(Request per minute) limit. The group task finished then the next group.
|
||||
enum TaskStatus {
|
||||
pending = 'pending',
|
||||
running = 'running',
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
import type { Credential } from '@/app/components/tools/types'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { AuthHeaderPrefix, AuthType } from '@/app/components/tools/types'
|
||||
import ConfigCredential from './config-credentials'
|
||||
|
||||
describe('ConfigCredential', () => {
|
||||
const baseCredential: Credential = {
|
||||
auth_type: AuthType.none,
|
||||
}
|
||||
const mockOnChange = vi.fn()
|
||||
const mockOnHide = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders and calls onHide when cancel is pressed', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<ConfigCredential
|
||||
credential={baseCredential}
|
||||
onChange={mockOnChange}
|
||||
onHide={mockOnHide}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
expect(mockOnHide).toHaveBeenCalledTimes(1)
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows selecting apiKeyHeader and submits the new credential', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<ConfigCredential
|
||||
credential={baseCredential}
|
||||
onChange={mockOnChange}
|
||||
onHide={mockOnHide}
|
||||
/>,
|
||||
)
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('tools.createTool.authMethod.types.api_key_header'))
|
||||
const headerInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiKeyPlaceholder')
|
||||
const valueInput = screen.getByPlaceholderText('tools.createTool.authMethod.types.apiValuePlaceholder')
|
||||
fireEvent.change(headerInput, { target: { value: 'X-Auth' } })
|
||||
fireEvent.change(valueInput, { target: { value: 'sEcReT' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith({
|
||||
auth_type: AuthType.apiKeyHeader,
|
||||
api_key_header: 'X-Auth',
|
||||
api_key_header_prefix: AuthHeaderPrefix.custom,
|
||||
api_key_value: 'sEcReT',
|
||||
})
|
||||
expect(mockOnHide).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,55 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { importSchemaFromURL } from '@/service/tools'
|
||||
import Toast from '../../base/toast'
|
||||
import examples from './examples'
|
||||
import GetSchema from './get-schema'
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
importSchemaFromURL: vi.fn(),
|
||||
}))
|
||||
const importSchemaFromURLMock = vi.mocked(importSchemaFromURL)
|
||||
|
||||
describe('GetSchema', () => {
|
||||
const notifySpy = vi.spyOn(Toast, 'notify')
|
||||
const mockOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
notifySpy.mockClear()
|
||||
importSchemaFromURLMock.mockReset()
|
||||
render(<GetSchema onChange={mockOnChange} />)
|
||||
})
|
||||
|
||||
it('shows an error when the URL is not http', () => {
|
||||
fireEvent.click(screen.getByText('tools.createTool.importFromUrl'))
|
||||
const input = screen.getByPlaceholderText('tools.createTool.importFromUrlPlaceHolder')
|
||||
// eslint-disable-next-line sonarjs/no-clear-text-protocols
|
||||
fireEvent.change(input, { target: { value: 'ftp://invalid' } })
|
||||
fireEvent.click(screen.getByText('common.operation.ok'))
|
||||
|
||||
expect(notifySpy).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'tools.createTool.urlError',
|
||||
})
|
||||
})
|
||||
|
||||
it('imports schema from url when valid', async () => {
|
||||
fireEvent.click(screen.getByText('tools.createTool.importFromUrl'))
|
||||
const input = screen.getByPlaceholderText('tools.createTool.importFromUrlPlaceHolder')
|
||||
fireEvent.change(input, { target: { value: 'https://example.com' } })
|
||||
importSchemaFromURLMock.mockResolvedValueOnce({ schema: 'result-schema' })
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.ok'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnChange).toHaveBeenCalledWith('result-schema')
|
||||
})
|
||||
})
|
||||
|
||||
it('selects example schema when example option clicked', () => {
|
||||
fireEvent.click(screen.getByText('tools.createTool.examples'))
|
||||
fireEvent.click(screen.getByText(`tools.createTool.exampleOptions.${examples[0].key}`))
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(examples[0].content)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,154 @@
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { parseParamsSchema } from '@/service/tools'
|
||||
import EditCustomCollectionModal from './index'
|
||||
|
||||
vi.mock('ahooks', async () => {
|
||||
const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
|
||||
return {
|
||||
...actual,
|
||||
useDebounce: (value: unknown) => value,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
parseParamsSchema: vi.fn(),
|
||||
}))
|
||||
const parseParamsSchemaMock = vi.mocked(parseParamsSchema)
|
||||
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: (): ModalContextState => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
setShowApiBasedExtensionModal: vi.fn(),
|
||||
setShowModerationSettingModal: vi.fn(),
|
||||
setShowExternalDataToolModal: vi.fn(),
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
setShowAnnotationFullModal: vi.fn(),
|
||||
setShowModelModal: vi.fn(),
|
||||
setShowExternalKnowledgeAPIModal: vi.fn(),
|
||||
setShowModelLoadBalancingModal: vi.fn(),
|
||||
setShowOpeningModal: vi.fn(),
|
||||
setShowUpdatePluginModal: vi.fn(),
|
||||
setShowEducationExpireNoticeModal: vi.fn(),
|
||||
setShowTriggerEventsLimitModal: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockUseProviderContext = vi.fn()
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockUseProviderContext(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/context/i18n')>('@/context/i18n')
|
||||
return {
|
||||
...actual,
|
||||
useDocLink: () => (path?: string) => `https://docs.example.com${path ?? ''}`,
|
||||
}
|
||||
})
|
||||
|
||||
describe('EditCustomCollectionModal', () => {
|
||||
const mockOnHide = vi.fn()
|
||||
const mockOnAdd = vi.fn()
|
||||
const mockOnEdit = vi.fn()
|
||||
const mockOnRemove = vi.fn()
|
||||
const toastNotifySpy = vi.spyOn(Toast, 'notify')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
toastNotifySpy.mockClear()
|
||||
parseParamsSchemaMock.mockResolvedValue({
|
||||
parameters_schema: [],
|
||||
schema_type: 'openapi',
|
||||
})
|
||||
mockUseProviderContext.mockReturnValue({
|
||||
plan: {
|
||||
type: Plan.sandbox,
|
||||
},
|
||||
enableBilling: false,
|
||||
webappCopyrightEnabled: true,
|
||||
} as ProviderContextState)
|
||||
})
|
||||
|
||||
const renderModal = () => render(
|
||||
<EditCustomCollectionModal
|
||||
payload={undefined}
|
||||
onHide={mockOnHide}
|
||||
onAdd={mockOnAdd}
|
||||
onEdit={mockOnEdit}
|
||||
onRemove={mockOnRemove}
|
||||
/>,
|
||||
)
|
||||
|
||||
it('shows an error when the provider name is missing', async () => {
|
||||
renderModal()
|
||||
|
||||
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
|
||||
fireEvent.change(schemaInput, { target: { value: '{}' } })
|
||||
await waitFor(() => {
|
||||
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.name"}',
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
expect(mockOnAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error when the schema is missing', async () => {
|
||||
renderModal()
|
||||
|
||||
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
|
||||
fireEvent.change(providerInput, { target: { value: 'provider' } })
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastNotifySpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'common.errorMsg.fieldRequired:{"field":"tools.createTool.schema"}',
|
||||
type: 'error',
|
||||
}))
|
||||
})
|
||||
expect(mockOnAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves a valid custom collection', async () => {
|
||||
renderModal()
|
||||
const providerInput = screen.getByPlaceholderText('tools.createTool.toolNamePlaceHolder')
|
||||
fireEvent.change(providerInput, { target: { value: 'provider' } })
|
||||
|
||||
const schemaInput = screen.getByPlaceholderText('tools.createTool.schemaPlaceHolder')
|
||||
fireEvent.change(schemaInput, { target: { value: '{}' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(parseParamsSchemaMock).toHaveBeenCalledWith('{}')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnAdd).toHaveBeenCalledWith(expect.objectContaining({
|
||||
provider: 'provider',
|
||||
schema: '{}',
|
||||
schema_type: 'openapi',
|
||||
credentials: {
|
||||
auth_type: 'none',
|
||||
},
|
||||
labels: [],
|
||||
}))
|
||||
expect(toastNotifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,87 @@
|
||||
import type { CustomCollectionBackend, CustomParamSchema } from '@/app/components/tools/types'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { AuthType } from '@/app/components/tools/types'
|
||||
import I18n from '@/context/i18n'
|
||||
import { testAPIAvailable } from '@/service/tools'
|
||||
import TestApi from './test-api'
|
||||
|
||||
vi.mock('@/service/tools', () => ({
|
||||
testAPIAvailable: vi.fn(),
|
||||
}))
|
||||
const testAPIAvailableMock = vi.mocked(testAPIAvailable)
|
||||
|
||||
describe('TestApi', () => {
|
||||
const customCollection: CustomCollectionBackend = {
|
||||
provider: 'custom',
|
||||
credentials: {
|
||||
auth_type: AuthType.none,
|
||||
},
|
||||
schema_type: 'openapi',
|
||||
schema: '{ }',
|
||||
icon: { background: '', content: '' },
|
||||
privacy_policy: '',
|
||||
custom_disclaimer: '',
|
||||
id: 'test-id',
|
||||
labels: [],
|
||||
}
|
||||
const tool: CustomParamSchema = {
|
||||
operation_id: 'testOp',
|
||||
summary: 'summary',
|
||||
method: 'GET',
|
||||
server_url: 'https://api.example.com',
|
||||
parameters: [{
|
||||
name: 'limit',
|
||||
label: {
|
||||
en_US: 'Limit',
|
||||
zh_Hans: '限制',
|
||||
},
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
} as any],
|
||||
}
|
||||
|
||||
const renderTestApi = () => {
|
||||
const providerValue = {
|
||||
locale: 'en-US',
|
||||
i18n: {},
|
||||
setLocaleOnClient: vi.fn(),
|
||||
}
|
||||
return render(
|
||||
<I18n.Provider value={providerValue as any}>
|
||||
<TestApi
|
||||
customCollection={customCollection}
|
||||
tool={tool}
|
||||
onHide={vi.fn()}
|
||||
/>
|
||||
</I18n.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders parameters and runs the API test', async () => {
|
||||
testAPIAvailableMock.mockResolvedValueOnce({ result: 'ok' })
|
||||
renderTestApi()
|
||||
|
||||
const parameterInput = screen.getAllByRole('textbox')[0]
|
||||
fireEvent.change(parameterInput, { target: { value: '5' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tools.test.title' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(testAPIAvailableMock).toHaveBeenCalledWith({
|
||||
provider_name: customCollection.provider,
|
||||
tool_name: tool.operation_id,
|
||||
credentials: {
|
||||
auth_type: AuthType.none,
|
||||
},
|
||||
schema_type: customCollection.schema_type,
|
||||
schema: customCollection.schema,
|
||||
parameters: {
|
||||
limit: '5',
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('ok')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -188,8 +188,8 @@ const FeaturesTrigger = () => {
|
||||
{isChatMode && (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-components-button-secondary-text',
|
||||
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
'rounded-lg border border-transparent text-components-button-secondary-text',
|
||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
onClick={handleShowFeatures}
|
||||
>
|
||||
|
||||
@ -23,8 +23,8 @@ const ChatVariableButton = ({ disabled }: { disabled: boolean }) => {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'p-2',
|
||||
theme === 'dark' && showChatVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
'rounded-lg border border-transparent p-2',
|
||||
theme === 'dark' && showChatVariablePanel && 'border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
|
||||
@ -26,8 +26,8 @@ const EnvButton = ({ disabled }: { disabled: boolean }) => {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'p-2',
|
||||
theme === 'dark' && showEnvPanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
'rounded-lg border border-transparent p-2',
|
||||
theme === 'dark' && showEnvPanel && 'border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
variant="ghost"
|
||||
disabled={disabled}
|
||||
|
||||
@ -26,8 +26,8 @@ const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'p-2',
|
||||
theme === 'dark' && showGlobalVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
'rounded-lg border border-transparent p-2',
|
||||
theme === 'dark' && showGlobalVariablePanel && 'border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
|
||||
@ -86,7 +86,8 @@ const HeaderInRestoring = ({
|
||||
disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft}
|
||||
variant="primary"
|
||||
className={cn(
|
||||
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
'rounded-lg border border-transparent',
|
||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
>
|
||||
{t('workflow.common.restore')}
|
||||
@ -94,8 +95,8 @@ const HeaderInRestoring = ({
|
||||
<Button
|
||||
onClick={handleCancelRestore}
|
||||
className={cn(
|
||||
'text-components-button-secondary-accent-text',
|
||||
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
'rounded-lg border border-transparent text-components-button-secondary-accent-text',
|
||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-x-0.5">
|
||||
|
||||
@ -61,8 +61,8 @@ const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'p-2',
|
||||
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
'p-2 rounded-lg border border-transparent',
|
||||
theme === 'dark' && 'border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
onClick={handleViewVersionHistory}
|
||||
>
|
||||
|
||||
@ -63,6 +63,11 @@ export const useShortcuts = (): void => {
|
||||
return !isEventTargetInputArea(e.target as HTMLElement)
|
||||
}, [])
|
||||
|
||||
const shouldHandleCopy = useCallback(() => {
|
||||
const selection = document.getSelection()
|
||||
return !selection || selection.isCollapsed
|
||||
}, [])
|
||||
|
||||
useKeyPress(['delete', 'backspace'], (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
@ -73,7 +78,7 @@ export const useShortcuts = (): void => {
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
|
||||
if (shouldHandleShortcut(e) && shouldHandleCopy() && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
handleNodesCopy()
|
||||
}
|
||||
|
||||
@ -195,9 +195,11 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const store = useStoreApi()
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === WORKFLOW_DATA_UPDATE) {
|
||||
setNodes(v.payload.nodes)
|
||||
store.getState().setNodes(v.payload.nodes)
|
||||
setEdges(v.payload.edges)
|
||||
|
||||
if (v.payload.viewport)
|
||||
@ -359,7 +361,6 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
}
|
||||
}, [schemaTypeDefinitions, fetchInspectVars, isLoadedVars, vars, customTools, buildInTools, workflowTools, mcpTools, dataSourceList])
|
||||
|
||||
const store = useStoreApi()
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
store.getState().onError = (code, message) => {
|
||||
if (code === '002')
|
||||
|
||||
@ -62,8 +62,15 @@ const ConditionItem = ({
|
||||
}, [onRemoveCondition, condition.id])
|
||||
|
||||
const currentMetadata = useMemo(() => {
|
||||
// Try to match by metadata_id first (reliable reference)
|
||||
if (condition.metadata_id) {
|
||||
const found = metadataList.find(metadata => metadata.id === condition.metadata_id)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
// Fallback to name matching for backward compatibility with old conditions
|
||||
return metadataList.find(metadata => metadata.name === condition.name)
|
||||
}, [metadataList, condition.name])
|
||||
}, [metadataList, condition.metadata_id, condition.name])
|
||||
|
||||
const handleConditionOperatorChange = useCallback((operator: ComparisonOperator) => {
|
||||
onUpdateCondition?.(
|
||||
|
||||
@ -27,11 +27,17 @@ const MetadataTrigger = ({
|
||||
useEffect(() => {
|
||||
if (selectedDatasetsLoaded) {
|
||||
conditions.forEach((condition) => {
|
||||
if (!metadataList.find(metadata => metadata.name === condition.name))
|
||||
// First try to match by metadata_id for reliable reference
|
||||
const foundById = condition.metadata_id && metadataList.find(metadata => metadata.id === condition.metadata_id)
|
||||
// Fallback to name matching only for backward compatibility with old conditions
|
||||
const foundByName = !condition.metadata_id && metadataList.find(metadata => metadata.name === condition.name)
|
||||
|
||||
// Only remove condition if both metadata_id and name matching fail
|
||||
if (!foundById && !foundByName)
|
||||
handleRemoveCondition(condition.id)
|
||||
})
|
||||
}
|
||||
}, [metadataList, handleRemoveCondition, selectedDatasetsLoaded])
|
||||
}, [metadataFilteringConditions, metadataList, handleRemoveCondition, selectedDatasetsLoaded])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
|
||||
@ -86,6 +86,7 @@ export enum MetadataFilteringVariableType {
|
||||
export type MetadataFilteringCondition = {
|
||||
id: string
|
||||
name: string
|
||||
metadata_id?: string
|
||||
comparison_operator: ComparisonOperator
|
||||
value?: string | number
|
||||
}
|
||||
|
||||
@ -305,7 +305,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
|
||||
}))
|
||||
}, [setInputs])
|
||||
|
||||
const handleAddCondition = useCallback<HandleAddCondition>(({ name, type }) => {
|
||||
const handleAddCondition = useCallback<HandleAddCondition>(({ id, name, type }) => {
|
||||
let operator: ComparisonOperator = ComparisonOperator.is
|
||||
|
||||
if (type === MetadataFilteringVariableType.number)
|
||||
@ -313,6 +313,7 @@ const useConfig = (id: string, payload: KnowledgeRetrievalNodeType) => {
|
||||
|
||||
const newCondition = {
|
||||
id: uuid4(),
|
||||
metadata_id: id, // Save metadata.id for reliable reference
|
||||
name,
|
||||
comparison_operator: operator,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user