chore(web): remove unused frontend code (#37866)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-06-24 17:25:52 +08:00 committed by GitHub
parent 86b73ba205
commit ea1aa2fecd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
141 changed files with 193 additions and 6760 deletions

View File

@ -474,11 +474,6 @@
"count": 1
}
},
"web/app/components/app/configuration/base/var-highlight/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"web/app/components/app/configuration/config-prompt/__tests__/index.spec.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -1068,34 +1063,6 @@
"count": 1
}
},
"web/app/components/base/block-input/index.stories.tsx": {
"no-console": {
"count": 2
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/base/block-input/index.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
"react/no-nested-component-definitions": {
"count": 1
},
"react/set-state-in-effect": {
"count": 1
},
"react/static-components": {
"count": 2
}
},
"web/app/components/base/carousel/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
@ -1387,7 +1354,7 @@
},
"web/app/components/base/error-boundary/index.tsx": {
"react-refresh/only-export-components": {
"count": 3
"count": 1
},
"react/jsx-no-key-after-spread": {
"count": 1
@ -2610,11 +2577,6 @@
"count": 3
}
},
"web/app/components/datasets/common/retrieval-method-info/index.tsx": {
"react-refresh/only-export-components": {
"count": 1
}
},
"web/app/components/datasets/create-from-pipeline/create-options/create-from-dsl-modal/header.tsx": {
"jsx-a11y/click-events-have-key-events": {
"count": 1
@ -3089,19 +3051,6 @@
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx": {
"ts/no-non-null-asserted-optional-chain": {
"count": 1
}
},
"web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 6
},
"react/set-state-in-effect": {
"count": 6
}
},
"web/app/components/datasets/documents/detail/metadata/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 1
@ -5121,10 +5070,10 @@
},
"web/app/components/workflow/nodes/_base/components/layout/index.tsx": {
"no-barrel-files/no-barrel-files": {
"count": 7
"count": 6
},
"react-refresh/only-export-components": {
"count": 7
"count": 6
}
},
"web/app/components/workflow/nodes/_base/components/mcp-tool-availability.tsx": {
@ -5486,9 +5435,6 @@
"erasable-syntax-only/enums": {
"count": 1
},
"no-barrel-files/no-barrel-files": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
@ -7177,11 +7123,6 @@
"count": 1
}
},
"web/service/__tests__/use-snippet-workflows.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/__tests__/use-tools.spec.tsx": {
"no-restricted-imports": {
"count": 1
@ -7286,7 +7227,7 @@
"count": 1
},
"ts/no-explicit-any": {
"count": 26
"count": 13
}
},
"web/service/datasets.ts": {
@ -7294,7 +7235,7 @@
"count": 1
},
"ts/no-explicit-any": {
"count": 6
"count": 5
}
},
"web/service/debug.ts": {

View File

@ -1,188 +0,0 @@
/**
* Integration test: DSL export/import flow
*
* Validates DSL export logic (sync draft check secrets download)
* and DSL import modal state management.
*/
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
const mockDoSyncWorkflowDraft = vi.fn().mockResolvedValue(undefined)
const mockExportPipelineConfig = vi.fn().mockResolvedValue({ data: 'yaml-content' })
const mockNotify = vi.fn()
const mockToast = {
success: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'success', message, ...options }),
error: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'error', message, ...options }),
warning: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'warning', message, ...options }),
info: (message: string, options?: Record<string, unknown>) => mockNotify({ type: 'info', message, ...options }),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast,
}))
const mockEventEmitter = { emit: vi.fn() }
const mockDownloadBlob = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
pipelineId: 'pipeline-abc',
knowledgeName: 'My Pipeline',
}),
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: mockEventEmitter,
}),
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipelineConfig,
}),
}))
vi.mock('@/service/workflow', () => ({
fetchWorkflowDraft: vi.fn(),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
}))
vi.mock('@/app/components/rag-pipeline/hooks/use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
describe('DSL Export/Import Flow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Export Flow', () => {
it('should sync draft then export then download', async () => {
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'pipeline-abc',
include: false,
})
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
fileName: 'My Pipeline.pipeline',
}))
})
it('should export with include flag when specified', async () => {
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL(true)
})
expect(mockExportPipelineConfig).toHaveBeenCalledWith({
pipelineId: 'pipeline-abc',
include: true,
})
})
it('should notify on export error', async () => {
mockDoSyncWorkflowDraft.mockRejectedValueOnce(new Error('sync failed'))
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.handleExportDSL()
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
describe('Export Check Flow', () => {
it('should export directly when no secret environment variables', async () => {
const { fetchWorkflowDraft } = await import('@/service/workflow')
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
environment_variables: [
{ value_type: 'string', key: 'API_URL', value: 'https://api.example.com' },
],
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
// Should proceed to export directly (no secret vars)
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
})
it('should emit DSL_EXPORT_CHECK event when secret variables exist', async () => {
const { fetchWorkflowDraft } = await import('@/service/workflow')
vi.mocked(fetchWorkflowDraft).mockResolvedValueOnce({
environment_variables: [
{ value_type: 'secret', key: 'API_KEY', value: '***' },
],
} as unknown as Awaited<ReturnType<typeof fetchWorkflowDraft>>)
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockEventEmitter.emit).toHaveBeenCalledWith(expect.objectContaining({
type: 'DSL_EXPORT_CHECK',
payload: expect.objectContaining({
data: expect.arrayContaining([
expect.objectContaining({ value_type: 'secret' }),
]),
}),
}))
})
it('should notify on export check error', async () => {
const { fetchWorkflowDraft } = await import('@/service/workflow')
vi.mocked(fetchWorkflowDraft).mockRejectedValueOnce(new Error('fetch failed'))
const { useDSL } = await import('@/app/components/rag-pipeline/hooks/use-DSL')
const { result } = renderHook(() => useDSL())
await act(async () => {
await result.current.exportCheck()
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
})

View File

@ -1,60 +0,0 @@
/**
* XSS Prevention Test Suite
*
* This test verifies that the XSS vulnerability in block-input has been properly
* fixed by replacing dangerouslySetInnerHTML with safe React rendering.
*/
import { cleanup, render } from '@testing-library/react'
import * as React from 'react'
import BlockInput from '../app/components/base/block-input'
// Mock styles
vi.mock('../app/components/app/configuration/base/var-highlight/style.module.css', () => ({
default: {
item: 'mock-item-class',
},
}))
describe('XSS Prevention - Block Input Security', () => {
afterEach(() => {
cleanup()
})
describe('BlockInput Component Security', () => {
it('should safely render malicious variable names without executing scripts', () => {
const testInput = 'user@test.com{{<script>alert("XSS")</script>}}'
const { container } = render(<BlockInput value={testInput} readonly={true} />)
const scriptElements = container.querySelectorAll('script')
expect(scriptElements).toHaveLength(0)
const textContent = container.textContent
expect(textContent).toContain('<script>')
})
it('should preserve legitimate variable highlighting', () => {
const legitimateInput = 'Hello {{userName}} welcome to {{appName}}'
const { container } = render(<BlockInput value={legitimateInput} readonly={true} />)
const textContent = container.textContent
expect(textContent).toContain('userName')
expect(textContent).toContain('appName')
})
})
describe('React Automatic Escaping Verification', () => {
it('should confirm React automatic escaping works correctly', () => {
const TestComponent = () => <span>{'<script>alert("xss")</script>'}</span>
const { container } = render(<TestComponent />)
const spanElement = container.querySelector('span')
const scriptElements = container.querySelectorAll('script')
expect(spanElement?.textContent).toBe('<script>alert("xss")</script>')
expect(scriptElements).toHaveLength(0)
})
})
})
export {}

View File

@ -1,168 +0,0 @@
import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import AppInfo from '..'
const mockDetailPanel = vi.hoisted(() => vi.fn())
const mockModals = vi.hoisted(() => vi.fn())
let mockAppPermissionKeys = ['app.acl.view_layout']
const mockSetPanelOpen = vi.fn()
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
workspacePermissionKeys: ['app.create_and_management'],
}),
useSelector: (selector: (state: { userProfile: { id: string }, workspacePermissionKeys: string[] }) => unknown) => selector({
userProfile: { id: 'user-1' },
workspacePermissionKeys: ['app.create_and_management'],
}),
}))
vi.mock('../app-info-trigger', () => ({
default: React.memo(({ appDetail, expand, onClick }: {
appDetail: App & Partial<AppSSO>
expand: boolean
onClick: () => void
}) => (
<button type="button" data-testid="trigger" data-expand={expand} onClick={onClick}>
{appDetail.name}
</button>
)),
}))
vi.mock('../app-info-detail-panel', () => ({
default: React.memo((props: { show: boolean, onClose: () => void }) => {
mockDetailPanel(props)
return props.show ? <div data-testid="detail-panel"><button type="button" onClick={props.onClose}>Close Panel</button></div> : null
}),
}))
vi.mock('../app-info-modals', () => ({
default: React.memo((props: { activeModal: string | null }) => {
mockModals(props)
return props.activeModal ? <div data-testid="modals" data-modal={props.activeModal} /> : null
}),
}))
const mockAppDetail: App & Partial<AppSSO> = {
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon: '🤖',
icon_type: 'emoji',
icon_background: '#FFEAD5',
icon_url: '',
description: '',
use_icon_as_answer_icon: false,
permission_keys: mockAppPermissionKeys,
} as App & Partial<AppSSO>
const mockUseAppInfoActions = {
appDetail: mockAppDetail,
panelOpen: false,
setPanelOpen: mockSetPanelOpen,
closePanel: vi.fn(),
activeModal: null as string | null,
openModal: vi.fn(),
closeModal: vi.fn(),
secretEnvList: [],
setSecretEnvList: vi.fn(),
onEdit: vi.fn(),
onCopy: vi.fn(),
onExport: vi.fn(),
exportCheck: vi.fn(),
handleConfirmExport: vi.fn(),
onConfirmDelete: vi.fn(),
}
vi.mock('../use-app-info-actions', () => ({
useAppInfoActions: () => mockUseAppInfoActions,
}))
describe('AppInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAppPermissionKeys = ['app.acl.view_layout']
mockUseAppInfoActions.appDetail = mockAppDetail
mockUseAppInfoActions.appDetail.permission_keys = mockAppPermissionKeys
mockUseAppInfoActions.panelOpen = false
mockUseAppInfoActions.activeModal = null
})
it('should return null when appDetail is not available', () => {
mockUseAppInfoActions.appDetail = undefined as unknown as App & Partial<AppSSO>
const { container } = render(<AppInfo expand />)
expect(container.innerHTML).toBe('')
})
it('should render trigger when not onlyShowDetail', () => {
render(<AppInfo expand />)
expect(screen.getByTestId('trigger'))!.toBeInTheDocument()
})
it('should not mount detail layer while the app info panel is closed', () => {
render(<AppInfo expand />)
expect(mockDetailPanel).not.toHaveBeenCalled()
expect(mockModals).not.toHaveBeenCalled()
})
it('should not render trigger when onlyShowDetail is true', () => {
render(<AppInfo expand onlyShowDetail />)
expect(screen.queryByTestId('trigger')).not.toBeInTheDocument()
})
it('should pass expand prop to trigger', () => {
render(<AppInfo expand />)
expect(screen.getByTestId('trigger'))!.toHaveAttribute('data-expand', 'true')
const { unmount } = render(<AppInfo expand={false} />)
const triggers = screen.getAllByTestId('trigger')
expect(triggers[triggers.length - 1])!.toHaveAttribute('data-expand', 'false')
unmount()
})
it('should toggle panel when trigger is clicked and user is editor', async () => {
const user = userEvent.setup()
render(<AppInfo expand />)
await user.click(screen.getByTestId('trigger'))
expect(mockSetPanelOpen).toHaveBeenCalled()
const updater = mockSetPanelOpen.mock.calls[0]![0] as (v: boolean) => boolean
expect(updater(false)).toBe(true)
expect(updater(true)).toBe(false)
})
it('should not toggle panel when app ACL does not allow layout access', async () => {
const user = userEvent.setup()
mockAppPermissionKeys = []
mockUseAppInfoActions.appDetail.permission_keys = mockAppPermissionKeys
render(<AppInfo expand />)
await user.click(screen.getByTestId('trigger'))
expect(mockSetPanelOpen).not.toHaveBeenCalled()
})
it('should show detail panel based on panelOpen when not onlyShowDetail', () => {
mockUseAppInfoActions.panelOpen = true
render(<AppInfo expand />)
expect(screen.getByTestId('detail-panel'))!.toBeInTheDocument()
expect(mockDetailPanel).toHaveBeenCalled()
})
it('should show detail panel based on openState when onlyShowDetail', () => {
render(<AppInfo expand onlyShowDetail openState />)
expect(screen.getByTestId('detail-panel'))!.toBeInTheDocument()
})
it('should hide detail panel when openState is false and onlyShowDetail', () => {
render(<AppInfo expand onlyShowDetail openState={false} />)
expect(screen.queryByTestId('detail-panel')).not.toBeInTheDocument()
expect(mockDetailPanel).not.toHaveBeenCalled()
expect(mockModals).not.toHaveBeenCalled()
})
})

View File

@ -5,7 +5,6 @@ import { getAppACLCapabilities } from '@/utils/permission'
import AppInfoDetailPanel from './app-info-detail-panel'
import AppInfoModals from './app-info-modals'
import AppInfoTrigger from './app-info-trigger'
import { useAppInfoActions } from './use-app-info-actions'
type IAppInfoProps = {
expand: boolean
@ -122,16 +121,3 @@ export const AppInfoView = ({
</div>
)
}
const AppInfo = ({ onDetailExpand, ...props }: IAppInfoProps) => {
const actions = useAppInfoActions({ onDetailExpand })
return (
<AppInfoView
{...props}
actions={actions}
/>
)
}
export default React.memo(AppInfo)

View File

@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import VarHighlight, { varHighlightHTML } from '../index'
import VarHighlight from '../index'
describe('VarHighlight', () => {
beforeEach(() => {
@ -35,32 +35,4 @@ describe('VarHighlight', () => {
expect(container.firstChild).toHaveClass('mt-2')
})
})
// Escaping HTML via helper
describe('varHighlightHTML', () => {
it('should escape dangerous characters before returning HTML string', () => {
// Arrange
const props = { name: '<script>alert(\'xss\')</script>' }
// Act
const html = varHighlightHTML(props)
// Assert
expect(html).toContain('&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;')
expect(html).not.toContain('<script>')
})
it('should include custom class names in the wrapper element', () => {
// Arrange
const props = { name: 'data', className: 'text-primary' }
// Act
const html = varHighlightHTML(props)
// Assert
// CSS modules add a hash to class names, so the class attribute may contain _item_xxx
expect(html).toContain('text-primary')
expect(html).toContain('item')
})
})
})

View File

@ -24,23 +24,4 @@ const VarHighlight: FC<IVarHighlightProps> = ({
</div>
)
}
// DEPRECATED: This function is vulnerable to XSS attacks and should not be used
// Use the VarHighlight React component instead
export const varHighlightHTML = ({ name, className = '' }: IVarHighlightProps) => {
const escapedName = name
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
const html = `<div class="${s.item} ${className} inline-flex mb-2 items-center justify-center px-1 rounded-md h-5 text-xs font-medium text-primary-600">
<span class='opacity-60'>{{</span>
<span>${escapedName}</span>
<span class='opacity-60'>}}</span>
</div>`
return html
}
export default React.memo(VarHighlight)

View File

@ -9,7 +9,6 @@ import {
isJsonSchemaEmpty,
isStringInputType,
normalizeSelectDefaultValue,
parseCheckboxSelectValue,
updatePayloadField,
validateConfigModalPayload,
} from '../utils'
@ -82,9 +81,7 @@ describe('config-modal utils', () => {
expect(nextPayload.default).toBeUndefined()
})
it('should parse checkbox default values and normalize json schema editor content', () => {
expect(parseCheckboxSelectValue('true')).toBe(true)
expect(parseCheckboxSelectValue('false')).toBe(false)
it('should normalize json schema editor content', () => {
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, { type: 'object' } as never)).toBe(JSON.stringify({ type: 'object' }, null, 2))
expect(getJsonSchemaEditorValue(InputVarType.textInput, '{"type":"object"}')).toBe('')
expect(getJsonSchemaEditorValue(InputVarType.jsonObject, '{"type":"object"}')).toBe('{"type":"object"}')

View File

@ -33,10 +33,6 @@ export const getCheckboxDefaultSelectValue = (value: InputVar['default'] | boole
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
return CHECKBOX_DEFAULT_FALSE_VALUE
}
export const parseCheckboxSelectValue = (value: string) =>
value === CHECKBOX_DEFAULT_TRUE_VALUE
export const normalizeSelectDefaultValue = (inputVar: InputVar) => {
if (inputVar.type === InputVarType.select && inputVar.default === '')
return { ...inputVar, default: undefined }

View File

@ -8,7 +8,7 @@ import { ComparisonOperator, LogicalOperator } from '@/app/components/workflow/n
import { getSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/utils'
import { DatasetPermission, DataSourceType } from '@/models/datasets'
import { AppModeEnum, ModelModeType, RETRIEVE_TYPE } from '@/types/app'
import { DatasetACLPermission, getDatasetACLCapabilities, hasEditPermissionForDataset } from '@/utils/permission'
import { DatasetACLPermission, getDatasetACLCapabilities } from '@/utils/permission'
import DatasetConfig from '../index'
// Mock external dependencies
@ -65,7 +65,6 @@ vi.mock('@/utils/permission', () => ({
canDelete: false,
canAccessConfig: false,
})),
hasEditPermissionForDataset: vi.fn(() => true),
}))
vi.mock('../../debug/hooks', () => ({
@ -477,7 +476,6 @@ describe('DatasetConfig', () => {
permission: DatasetPermission.allTeamMembers,
permission_keys: [DatasetACLPermission.Use],
})
vi.mocked(hasEditPermissionForDataset).mockReturnValue(true)
vi.mocked(getDatasetACLCapabilities).mockReturnValue({
canReadonly: false,
canEdit: false,
@ -994,56 +992,6 @@ describe('DatasetConfig', () => {
})
})
describe('Permission Handling', () => {
it('should hide edit options when user lacks permission', () => {
vi.mocked(hasEditPermissionForDataset).mockReturnValue(false)
const dataset = createMockDataset({
created_by: 'other-user',
permission: DatasetPermission.onlyMe,
})
renderDatasetConfig({
dataSets: [dataset],
})
// The editable property should be false when no permission
// The editable property should be false when no permission
expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument()
})
it('should show readonly state for non-editable datasets', () => {
vi.mocked(hasEditPermissionForDataset).mockReturnValue(false)
const dataset = createMockDataset({
created_by: 'admin',
permission: DatasetPermission.allTeamMembers,
})
renderDatasetConfig({
dataSets: [dataset],
})
expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument()
})
it('should allow editing when user has partial member permission', () => {
vi.mocked(hasEditPermissionForDataset).mockReturnValue(true)
const dataset = createMockDataset({
created_by: 'admin',
permission: DatasetPermission.partialMembers,
partial_member_list: ['user-123'],
})
renderDatasetConfig({
dataSets: [dataset],
})
expect(screen.getByTestId(`card-item-${dataset.id}`))!.toBeInTheDocument()
})
})
describe('Dataset Reordering and Management', () => {
it('should maintain dataset order after updates', () => {
const datasets = [

View File

@ -5,13 +5,11 @@ import {
applyAnnotationEdited,
applyAnnotationRemoved,
buildChatThreadState,
buildConversationUrl,
getCompletionMessageFiles,
getConversationRowValues,
getDetailVarList,
getFormattedChatList,
getThreadChatItems,
hasConversationFeedback,
isNearTopLoadMore,
mergePaginatedChatItems,
mergeUniqueChatItems,
@ -159,7 +157,6 @@ describe('log list utils', () => {
})
it('should derive urls, scroll thresholds, row values, and detail metadata', () => {
expect(buildConversationUrl('/apps/app-1/logs', 'page=2', 'conversation-1')).toBe('/apps/app-1/logs?page=2&conversation_id=conversation-1')
expect(isNearTopLoadMore({
clientHeight: 200,
scrollHeight: 600,
@ -212,9 +209,7 @@ describe('log list utils', () => {
}, false)).toEqual(['https://example.com/file-1'])
})
it('should remove conversation ids from urls, handle default inputs, and detect conversation feedback', () => {
expect(buildConversationUrl('/apps/app-1/logs', 'page=2&conversation_id=conversation-1')).toBe('/apps/app-1/logs?page=2')
it('should handle default inputs', () => {
expect(getConversationRowValues({
isChatMode: false,
log: {
@ -233,8 +228,5 @@ describe('log list utils', () => {
leftValue: 'fallback input',
rightValue: 0,
})
expect(hasConversationFeedback({ like: 0, dislike: 0 })).toBe(false)
expect(hasConversationFeedback({ like: 1, dislike: 0 })).toBe(true)
})
})

View File

@ -33,11 +33,6 @@ type ConversationLogDetail = {
name?: string
}
type ConversationFeedbackStats = {
dislike?: number
like?: number
}
const getUserInputVariable = (item: UserInputFormItem) => {
const variable = Object.values(item)[0]?.variable
@ -264,18 +259,6 @@ export const applyAnnotationRemoved = (items: IChatItem[], index: number) => ite
return item
})
export const buildConversationUrl = (pathname: string, searchParams: string, conversationId?: string) => {
const params = new URLSearchParams(searchParams)
if (conversationId)
params.set('conversation_id', conversationId)
else
params.delete('conversation_id')
const queryString = params.toString()
return queryString ? `${pathname}?${queryString}` : pathname
}
export const isNearTopLoadMore = ({
clientHeight,
scrollHeight,
@ -338,6 +321,3 @@ export const getCompletionMessageFiles = (detail: ConversationLogDetail, isChatM
return messageFiles.flatMap(item => item.url ? [item.url] : [])
}
export const hasConversationFeedback = (stats?: ConversationFeedbackStats | null) =>
Boolean(stats?.like || stats?.dislike)

View File

@ -315,5 +315,4 @@ export const AvgUserInteractions = createBizChartComponent({
yMaxWhenEmpty: 500,
isAvg: true,
})
export default Chart

View File

@ -2,14 +2,14 @@ import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { render } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AmplitudeProvider from '../AmplitudeProvider'
import { resetAmplitudeInitializationForTests } from '../init'
const mockConfig = vi.hoisted(() => ({
AMPLITUDE_API_KEY: 'test-api-key',
IS_CLOUD_EDITION: true,
}))
let AmplitudeProvider: typeof import('../AmplitudeProvider').default
vi.mock('@/config', () => ({
get AMPLITUDE_API_KEY() {
return mockConfig.AMPLITUDE_API_KEY
@ -32,11 +32,12 @@ vi.mock('@amplitude/plugin-session-replay-browser', () => ({
}))
describe('AmplitudeProvider', () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
mockConfig.IS_CLOUD_EDITION = true
resetAmplitudeInitializationForTests()
;({ default: AmplitudeProvider } = await import('../AmplitudeProvider'))
})
describe('Component', () => {

View File

@ -1,13 +1,14 @@
import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ensureAmplitudeInitialized, resetAmplitudeInitializationForTests } from '../init'
const mockConfig = vi.hoisted(() => ({
AMPLITUDE_API_KEY: 'test-api-key',
IS_CLOUD_EDITION: true,
}))
let ensureAmplitudeInitialized: typeof import('../init').ensureAmplitudeInitialized
vi.mock('@/config', () => ({
get AMPLITUDE_API_KEY() {
return mockConfig.AMPLITUDE_API_KEY
@ -30,11 +31,12 @@ vi.mock('@amplitude/plugin-session-replay-browser', () => ({
}))
describe('amplitude init helper', () => {
beforeEach(() => {
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
mockConfig.IS_CLOUD_EDITION = true
resetAmplitudeInitializationForTests()
;({ ensureAmplitudeInitialized } = await import('../init'))
})
describe('ensureAmplitudeInitialized', () => {

View File

@ -75,8 +75,3 @@ export const ensureAmplitudeInitialized = ({
throw error
}
}
// Only used by unit tests to reset module-scoped initialization state.
export const resetAmplitudeInitializationForTests = () => {
isAmplitudeInitialized = false
}

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Badge, { BadgeState, BadgeVariants } from '../index'
import Badge, { BadgeState } from '../index'
describe('Badge', () => {
describe('Rendering', () => {
@ -332,29 +332,5 @@ describe('Badge', () => {
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}`)
})
})
})
})

View File

@ -73,4 +73,4 @@ const Badge: React.FC<BadgeProps> = ({
Badge.displayName = 'Badge'
export default Badge
export { Badge, BadgeState, BadgeVariants }
export { Badge, BadgeState }

View File

@ -1,235 +1,4 @@
import { toast } from '@langgenius/dify-ui/toast'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import BlockInput, { getInputKeys } from '../index'
vi.mock('@/utils/var', () => ({
checkKeys: vi.fn((_keys: string[]) => ({
isValid: true,
errorMessageKey: '',
errorKey: '',
})),
}))
describe('BlockInput', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(toast, 'error').mockReturnValue('toast-error')
cleanup()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<BlockInput value="" />)
const wrapper = screen.getByTestId('block-input')
expect(wrapper).toBeInTheDocument()
})
it('should render with initial value', () => {
const { container } = render(<BlockInput value="Hello World" />)
expect(container.textContent).toContain('Hello World')
})
it('should render variable highlights', () => {
render(<BlockInput value="Hello {{name}}" />)
const nameElement = screen.getByText('name')
expect(nameElement).toBeInTheDocument()
expect(nameElement.parentElement).toHaveClass('text-primary-600')
})
it('should render multiple variable highlights', () => {
render(<BlockInput value="{{foo}} and {{bar}}" />)
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
})
it('should display character count in footer when not readonly', () => {
render(<BlockInput value="Hello" />)
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should hide footer in readonly mode', () => {
render(<BlockInput value="Hello" readonly />)
expect(screen.queryByText('5')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className', () => {
render(<BlockInput value="test" className="custom-class" />)
const innerContent = screen.getByTestId('block-input-content')
expect(innerContent).toHaveClass('custom-class')
})
it('should apply readonly prop with max height', () => {
render(<BlockInput value="test" readonly />)
const contentDiv = screen.getByTestId('block-input').firstChild as Element
expect(contentDiv).toHaveClass('max-h-[180px]')
})
it('should have default empty value', () => {
render(<BlockInput value="" />)
const contentDiv = screen.getByTestId('block-input')
expect(contentDiv).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should enter edit mode when clicked', async () => {
render(<BlockInput value="Hello" />)
const contentArea = screen.getByText('Hello')
fireEvent.click(contentArea)
await waitFor(() => {
expect(screen.getByRole('textbox')).toBeInTheDocument()
})
})
it('should update value when typing in edit mode', async () => {
const onConfirm = vi.fn()
const { checkKeys } = await import('@/utils/var')
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
render(<BlockInput value="Hello" onConfirm={onConfirm} />)
const contentArea = screen.getByText('Hello')
fireEvent.click(contentArea)
const textarea = await screen.findByRole('textbox')
fireEvent.change(textarea, { target: { value: 'Hello World' } })
expect(textarea).toHaveValue('Hello World')
})
it('should call onConfirm on value change with valid keys', async () => {
const onConfirm = vi.fn()
const { checkKeys } = await import('@/utils/var')
; (checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({ isValid: true, errorMessageKey: '', errorKey: '' })
render(<BlockInput value="initial" onConfirm={onConfirm} />)
const contentArea = screen.getByText('initial')
fireEvent.click(contentArea)
const textarea = await screen.findByRole('textbox')
fireEvent.change(textarea, { target: { value: '{{name}}' } })
await waitFor(() => {
expect(onConfirm).toHaveBeenCalledWith('{{name}}', ['name'])
})
})
it('should show error toast on value change with invalid keys', async () => {
const onConfirm = vi.fn()
const { checkKeys } = await import('@/utils/var');
(checkKeys as ReturnType<typeof vi.fn>).mockReturnValue({
isValid: false,
errorMessageKey: 'invalidKey',
errorKey: 'test_key',
})
render(<BlockInput value="initial" onConfirm={onConfirm} />)
const contentArea = screen.getByText('initial')
fireEvent.click(contentArea)
const textarea = await screen.findByRole('textbox')
fireEvent.change(textarea, { target: { value: '{{invalid}}' } })
await waitFor(() => {
expect(toast.error).toHaveBeenCalled()
})
expect(onConfirm).not.toHaveBeenCalled()
})
it('should not enter edit mode when readonly is true', () => {
render(<BlockInput value="Hello" readonly />)
const contentArea = screen.getByText('Hello')
fireEvent.click(contentArea)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle change when onConfirm is not provided', async () => {
render(<BlockInput value="Hello" />)
const contentArea = screen.getByText('Hello')
fireEvent.click(contentArea)
const textarea = await screen.findByRole('textbox')
fireEvent.change(textarea, { target: { value: 'Hello World' } })
expect(textarea).toHaveValue('Hello World')
})
it('should enter edit mode when clicked with empty value', async () => {
render(<BlockInput value="" />)
const contentArea = screen.getByTestId('block-input').firstChild as Element
fireEvent.click(contentArea)
const textarea = await screen.findByRole('textbox')
expect(textarea).toBeInTheDocument()
})
it('should exit edit mode on blur-sm', async () => {
render(<BlockInput value="Hello" />)
const contentArea = screen.getByText('Hello')
fireEvent.click(contentArea)
const textarea = await screen.findByRole('textbox')
expect(textarea).toBeInTheDocument()
fireEvent.blur(textarea)
await waitFor(() => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
})
})
describe('Edge Cases', () => {
it('should handle empty string value', () => {
const { container } = render(<BlockInput value="" />)
expect(container.textContent).toBe('0')
const span = screen.getByTestId('block-input').querySelector('span')
expect(span).toBeInTheDocument()
expect(span).toBeEmptyDOMElement()
})
it('should handle value without variables', () => {
render(<BlockInput value="plain text" />)
expect(screen.getByText('plain text')).toBeInTheDocument()
})
it('should handle newlines in value', () => {
const { container } = render(<BlockInput value={`line1\nline2`} />)
expect(screen.getByText(/line1/)).toBeInTheDocument()
expect(container.querySelector('br')).toBeInTheDocument()
})
it('should handle multiple same variables', () => {
render(<BlockInput value="{{name}} and {{name}}" />)
const highlights = screen.getAllByText('name')
expect(highlights).toHaveLength(2)
})
it('should handle value with only variables', () => {
render(<BlockInput value="{{foo}}{{bar}}{{baz}}" />)
expect(screen.getByText('foo')).toBeInTheDocument()
expect(screen.getByText('bar')).toBeInTheDocument()
expect(screen.getByText('baz')).toBeInTheDocument()
})
it('should handle text adjacent to variables', () => {
render(<BlockInput value="prefix {{var}} suffix" />)
expect(screen.getByText(/prefix/)).toBeInTheDocument()
expect(screen.getByText(/suffix/)).toBeInTheDocument()
})
})
})
import { getInputKeys } from '../index'
describe('getInputKeys', () => {
it('should extract keys from {{}} syntax', () => {

View File

@ -1,191 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import BlockInput from '.'
const meta = {
title: 'Base/Data Entry/BlockInput',
component: BlockInput,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Block input component with variable highlighting. Supports {{variable}} syntax with validation and visual highlighting of variable names.',
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: 'text',
description: 'Input value (supports {{variable}} syntax)',
},
className: {
control: 'text',
description: 'Wrapper CSS classes',
},
highLightClassName: {
control: 'text',
description: 'CSS class for highlighted variables (default: text-blue-500)',
},
readonly: {
control: 'boolean',
description: 'Read-only mode',
},
},
} satisfies Meta<typeof BlockInput>
export default meta
type Story = StoryObj<typeof meta>
// Interactive demo wrapper
const BlockInputDemo = (args: any) => {
const [value, setValue] = useState(args.value || '')
const [keys, setKeys] = useState<string[]>([])
return (
<div style={{ width: '600px' }}>
<BlockInput
{...args}
value={value}
onConfirm={(newValue, extractedKeys) => {
setValue(newValue)
setKeys(extractedKeys)
console.log('Value confirmed:', newValue)
console.log('Extracted keys:', extractedKeys)
}}
/>
{keys.length > 0 && (
<div className="mt-4 rounded-lg bg-blue-50 p-3">
<div className="mb-2 text-sm font-medium text-gray-700">Detected Variables:</div>
<div className="flex flex-wrap gap-2">
{keys.map(key => (
<span key={key} className="rounded-sm bg-blue-500 px-2 py-1 text-xs text-white">
{key}
</span>
))}
</div>
</div>
)}
</div>
)
}
// Default state
export const Default: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: '',
readonly: false,
},
}
// With single variable
export const SingleVariable: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Hello {{name}}, welcome to the application!',
readonly: false,
},
}
// With multiple variables
export const MultipleVariables: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Dear {{user_name}},\n\nYour order {{order_id}} has been shipped to {{address}}.\n\nThank you for shopping with us!',
readonly: false,
},
}
// Complex template
export const ComplexTemplate: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Hi {{customer_name}},\n\nYour {{product_type}} subscription will renew on {{renewal_date}} for {{amount}}.\n\nYour payment method ending in {{card_last_4}} will be charged.\n\nQuestions? Contact us at {{support_email}}.',
readonly: false,
},
}
// Read-only mode
export const ReadOnlyMode: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'This is a read-only template with {{variable1}} and {{variable2}}.\n\nYou cannot edit this content.',
readonly: true,
},
}
// Empty state
export const EmptyState: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: '',
readonly: false,
},
}
// Long content
export const LongContent: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Dear {{recipient_name}},\n\nWe are writing to inform you about the upcoming changes to your {{service_name}} account.\n\nEffective {{effective_date}}, your plan will include:\n\n1. Access to {{feature_1}}\n2. {{feature_2}} with unlimited usage\n3. Priority support via {{support_channel}}\n4. Monthly reports sent to {{email_address}}\n\nYour new monthly rate will be {{new_price}}, compared to your current rate of {{old_price}}.\n\nIf you have any questions, please contact our team at {{contact_info}}.\n\nBest regards,\n{{company_name}} Team',
readonly: false,
},
}
// Variables with underscores
export const VariablesWithUnderscores: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'User {{user_id}} from {{user_country}} has {{total_orders}} orders with status {{order_status}}.',
readonly: false,
},
}
// Adjacent variables
export const AdjacentVariables: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'File: {{file_name}}.{{file_extension}} ({{file_size}}{{size_unit}})',
readonly: false,
},
}
// Real-world example - Email template
export const EmailTemplate: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Subject: Your {{service_name}} account has been created\n\nHi {{first_name}},\n\nWelcome to {{company_name}}! Your account is now active.\n\nUsername: {{username}}\nEmail: {{email}}\n\nGet started at {{app_url}}\n\nThanks,\nThe {{company_name}} Team',
readonly: false,
},
}
// Real-world example - Notification template
export const NotificationTemplate: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: '🔔 {{user_name}} mentioned you in {{channel_name}}\n\n"{{message_preview}}"\n\nReply now: {{message_url}}',
readonly: false,
},
}
// Custom styling
export const CustomStyling: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'This template uses {{custom_variable}} with custom styling.',
readonly: false,
className: 'bg-gray-50 border-2 border-blue-200',
},
}
// Interactive playground
export const Playground: Story = {
render: args => <BlockInputDemo {...args} />,
args: {
value: 'Try editing this text and adding variables like {{example}}',
readonly: false,
className: '',
highLightClassName: '',
},
}

View File

@ -1,14 +1,5 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { checkKeys } from '@/utils/var'
import VarHighlight from '../../app/configuration/base/var-highlight'
// regex to match the {{}} and replace it with a span
const regex = /\{\{([^}]+)\}\}/g
@ -28,133 +19,3 @@ export const getInputKeys = (value: string) => {
})
return res
}
type IBlockInputProps = {
value: string
className?: string // wrapper class
highLightClassName?: string // class for the highlighted text default is text-blue-500
readonly?: boolean
onConfirm?: (value: string, keys: string[]) => void
}
const BlockInput: FC<IBlockInputProps> = ({
value = '',
className,
readonly = false,
onConfirm,
}) => {
const { t } = useTranslation()
// current is used to store the current value of the contentEditable element
const [currentValue, setCurrentValue] = useState<string>(value)
useEffect(() => {
setCurrentValue(value)
}, [value])
const contentEditableRef = useRef<HTMLTextAreaElement>(null)
const [isEditing, setIsEditing] = useState<boolean>(false)
useEffect(() => {
if (isEditing && contentEditableRef.current) {
// TODO: Focus at the click position
if (currentValue)
contentEditableRef.current.setSelectionRange(currentValue.length, currentValue.length)
contentEditableRef.current.focus()
}
}, [isEditing])
const style = cn({
'block size-full border-0 px-4 py-2 text-sm break-all text-gray-900 outline-0': true,
'block-input--editing': isEditing,
})
const renderSafeContent = (value: string) => {
const parts = value.split(/(\{\{[^}]+\}\}|\n)/g)
return parts.map((part, index) => {
const variableMatch = /^\{\{([^}]+)\}\}$/.exec(part)
if (variableMatch) {
return (
<VarHighlight
key={`var-${index}`}
name={variableMatch[1]!}
/>
)
}
if (part === '\n')
return <br key={`br-${index}`} />
return <span key={`text-${index}`}>{part}</span>
})
}
// Not use useCallback. That will cause out callback get old data.
const handleSubmit = (value: string) => {
if (onConfirm) {
const keys = getInputKeys(value)
const result = checkKeys(keys)
if (!result.isValid) {
toast.error(t(`varKeyError.${result.errorMessageKey}`, { ns: 'appDebug', key: result.errorKey }))
return
}
onConfirm(value, keys)
}
}
const onValueChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value
setCurrentValue(value)
handleSubmit(value)
}, [])
// Prevent rerendering caused cursor to jump to the start of the contentEditable element
const TextAreaContentView = () => {
return (
<div className={cn(style, className)} data-testid="block-input-content">
{renderSafeContent(currentValue || '')}
</div>
)
}
const placeholder = ''
const editAreaClassName = 'focus:outline-hidden bg-transparent text-sm'
const textAreaContent = (
<div className={cn(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', 'overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
{isEditing
? (
<div className="h-full px-4 py-2">
<textarea
ref={contentEditableRef}
className={cn(editAreaClassName, 'block size-full resize-none')}
placeholder={placeholder}
onChange={onValueChange}
value={currentValue}
onBlur={() => {
blur()
setIsEditing(false)
// click confirm also make blur. Then outer value is change. So below code has problem.
// setTimeout(() => {
// handleCancel()
// }, 1000)
}}
/>
</div>
)
: <TextAreaContentView />}
</div>
)
return (
<div className={cn('block-input w-full overflow-y-auto rounded-xl border-none bg-white')} data-testid="block-input">
{textAreaContent}
{/* footer */}
{!readonly && (
<div className="flex pb-2 pl-4">
<div className="h-[18px] rounded-md bg-gray-100 px-1 text-xs leading-[18px] text-gray-500">{currentValue?.length}</div>
</div>
)}
</div>
)
}
export default React.memo(BlockInput)

View File

@ -5,7 +5,6 @@ import dayjs, {
getDateWithTimezone,
getDaysInMonth,
getHourIn12Hour,
parseDateWithFormat,
toDayjs,
} from '../dayjs'
@ -293,46 +292,6 @@ describe('dayjs extended utilities', () => {
})
})
// Tests for parseDateWithFormat
describe('parseDateWithFormat', () => {
it('should return null for empty string', () => {
expect(parseDateWithFormat('')).toBeNull()
})
it('should parse with provided format from common formats', () => {
// Uses YYYY-MM-DD which is in COMMON_PARSE_FORMATS
const result = parseDateWithFormat('2024-06-15', 'YYYY-MM-DD')
expect(result).not.toBeNull()
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
})
it('should return null for invalid date with format', () => {
const result = parseDateWithFormat('not-a-date', 'YYYY-MM-DD')
expect(result).toBeNull()
})
it('should try common formats when no format is specified', () => {
const result = parseDateWithFormat('2024-06-15')
expect(result).not.toBeNull()
expect(result?.format('YYYY-MM-DD')).toBe('2024-06-15')
})
it('should parse ISO datetime format', () => {
const result = parseDateWithFormat('2024-06-15T12:00:00')
expect(result).not.toBeNull()
})
it('should return null for unparseable string without format', () => {
const result = parseDateWithFormat('gibberish')
expect(result).toBeNull()
})
})
// Tests for formatDateForOutput
describe('formatDateForOutput', () => {
it('should return empty string for invalid date', () => {

View File

@ -10,7 +10,6 @@ import {
getDaysInMonth,
getHourIn12Hour,
isDayjsObject,
parseDateWithFormat,
toDayjs,
} from '../dayjs'
@ -256,42 +255,6 @@ describe('toDayjs', () => {
})
})
// ── parseDateWithFormat ────────────────────────────────────────────────────
describe('parseDateWithFormat', () => {
it('returns null for empty string', () => {
expect(parseDateWithFormat('')).toBeNull()
})
it('parses with explicit format', () => {
// Use YYYY/MM/DD which is unambiguous
const result = parseDateWithFormat('2024/05/01', 'YYYY/MM/DD')
expect(result?.format('YYYY-MM-DD')).toBe('2024-05-01')
})
it('returns null for invalid string with explicit format', () => {
expect(parseDateWithFormat('not-a-date', 'YYYY-MM-DD')).toBeNull()
})
it('parses using common formats (YYYY-MM-DD)', () => {
const result = parseDateWithFormat('2024-05-01')
expect(result?.format('YYYY-MM-DD')).toBe('2024-05-01')
})
it('parses using common formats (YYYY/MM/DD)', () => {
const result = parseDateWithFormat('2024/05/01')
expect(result?.format('YYYY-MM-DD')).toBe('2024-05-01')
})
it('parses ISO datetime strings via common formats', () => {
const result = parseDateWithFormat('2024-05-01T14:30:00')
expect(result?.hour()).toBe(14)
})
it('returns null for completely unparseable string', () => {
expect(parseDateWithFormat('ZZZZ-ZZ-ZZ')).toBeNull()
})
})
// ── formatDateForOutput ────────────────────────────────────────────────────
describe('formatDateForOutput', () => {
it('returns empty string for invalid date', () => {

View File

@ -222,32 +222,6 @@ export const toDayjs = (value: string | Dayjs | undefined, options: ToDayjsOptio
warnParseFailure(value)
return undefined
}
// Parse date with multiple format support
export const parseDateWithFormat = (dateString: string, format?: string): Dayjs | null => {
if (!dateString)
return null
// If format is specified, use it directly
if (format) {
const parsed = dayjs(dateString, format, true)
return parsed.isValid() ? parsed : null
}
// Try common date formats
const formats = [
...COMMON_PARSE_FORMATS,
]
for (const fmt of formats) {
const parsed = dayjs(dateString, fmt, true)
if (parsed.isValid())
return parsed
}
return null
}
// Format date output with localization support
export const formatDateForOutput = (date: Dayjs, includeTime: boolean = false, _locale: string = 'en-US'): string => {
if (!date || !date.isValid())

View File

@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createReactI18nextMock } from '@/test/i18n-mock'
import ErrorBoundary, { ErrorFallback, useAsyncError, useErrorHandler, withErrorBoundary } from '../index'
import ErrorBoundary, { withErrorBoundary } from '../index'
const mockConfig = vi.hoisted(() => ({
isDev: false,
@ -340,54 +340,6 @@ describe('ErrorBoundary utility exports', () => {
consoleErrorSpy.mockRestore()
})
// Validate imperative error hook behavior.
describe('useErrorHandler', () => {
it('should trigger error boundary fallback when setError is called', async () => {
const HookConsumer = () => {
const setError = useErrorHandler()
return (
<button onClick={() => setError(new Error('handler boom'))}>
Trigger hook error
</button>
)
}
render(
<ErrorBoundary fallback={<div>Hook fallback shown</div>}>
<HookConsumer />
</ErrorBoundary>,
)
fireEvent.click(screen.getByRole('button', { name: 'Trigger hook error' }))
expect(await screen.findByText('Hook fallback shown')).toBeInTheDocument()
})
})
// Validate async error bridge hook behavior.
describe('useAsyncError', () => {
it('should trigger error boundary fallback when async error callback is called', async () => {
const AsyncHookConsumer = () => {
const throwAsyncError = useAsyncError()
return (
<button onClick={() => throwAsyncError(new Error('async hook boom'))}>
Trigger async hook error
</button>
)
}
render(
<ErrorBoundary fallback={<div>Async fallback shown</div>}>
<AsyncHookConsumer />
</ErrorBoundary>,
)
fireEvent.click(screen.getByRole('button', { name: 'Trigger async hook error' }))
expect(await screen.findByText('Async fallback shown')).toBeInTheDocument()
})
})
// Validate HOC wrapper behavior and metadata.
describe('withErrorBoundary', () => {
it('should wrap component and render custom title when wrapped component throws', async () => {
@ -427,25 +379,4 @@ describe('ErrorBoundary utility exports', () => {
expect(Wrapped.displayName).toBe('withErrorBoundary(Component)')
})
})
// Validate simple fallback helper component.
describe('ErrorFallback', () => {
it('should render message and call reset action when button is clicked', () => {
const resetErrorBoundaryAction = vi.fn()
render(
<ErrorFallback
error={new Error('fallback helper message')}
resetErrorBoundaryAction={resetErrorBoundaryAction}
/>,
)
expect(screen.getByText('Oops! Something went wrong')).toBeInTheDocument()
expect(screen.getByText('fallback helper message')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(resetErrorBoundaryAction).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -238,35 +238,7 @@ const ErrorBoundary: React.FC<ErrorBoundaryProps> = (props) => {
onResetKeysChange={onResetKeysChange}
/>
)
}
// Hook for imperative error handling
export function useErrorHandler() {
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
if (error)
throw error
}, [error])
return setError
}
// Hook for catching async errors
export function useAsyncError() {
const [, setError] = useState()
return useCallback(
(error: Error) => {
setError(() => {
throw error
})
},
[setError],
)
}
// HOC for wrapping components with error boundary
}// HOC for wrapping components with error boundary
export function withErrorBoundary<P extends object>(
Component: React.ComponentType<P>,
errorBoundaryProps?: Omit<ErrorBoundaryProps, 'children'>,
@ -281,23 +253,4 @@ export function withErrorBoundary<P extends object>(
return WrappedComponent
}
// Simple error fallback component
export const ErrorFallback: React.FC<{
error: Error
resetErrorBoundaryAction: () => void
}> = ({ error, resetErrorBoundaryAction }) => {
const { t } = useTranslation()
return (
<div className="flex min-h-[200px] flex-col items-center justify-center rounded-lg border border-red-200 bg-red-50 p-8">
<h2 className="mb-2 text-lg font-semibold text-red-800">{t('errorBoundary.fallbackTitle', { ns: 'common' })}</h2>
<p className="mb-4 text-center text-red-600">{error.message}</p>
<Button onClick={resetErrorBoundaryAction} size="small">
{t('errorBoundary.tryAgainCompact', { ns: 'common' })}
</Button>
</div>
)
}
export default ErrorBoundary

View File

@ -9,7 +9,6 @@ import {
fileUpload,
getFileAppearanceType,
getFileExtension,
getFileNameFromUrl,
getFilesInLogs,
getFileUploadErrorMessage,
getProcessedFiles,
@ -627,18 +626,6 @@ describe('file-uploader utils', () => {
})
})
describe('getFileNameFromUrl', () => {
it('should extract filename from URL', () => {
expect(getFileNameFromUrl('http://example.com/path/file.txt'))
.toBe('file.txt')
})
it('should return empty string for URL ending with slash', () => {
expect(getFileNameFromUrl('http://example.com/path/'))
.toBe('')
})
})
describe('getSupportFileExtensionList', () => {
it('should handle custom file types', () => {
const result = getSupportFileExtensionList(

View File

@ -206,12 +206,6 @@ export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
}
})
}
export const getFileNameFromUrl = (url: string) => {
const urlParts = url.split('/')
return urlParts[urlParts.length - 1] || ''
}
export const getSupportFileExtensionList = (allowFileTypes: string[], allowFileExtensions: string[]) => {
if (allowFileTypes.includes(SupportUploadFileTypes.custom))
return allowFileExtensions.map(item => item.slice(1).toUpperCase())

View File

@ -1,6 +1,6 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ImageGallery, { ImageGalleryTest } from '..'
import ImageGallery from '..'
const getImages = (container: HTMLElement) => container.querySelectorAll('img')
@ -132,13 +132,3 @@ describe('ImageGallery', () => {
})
})
})
describe('ImageGalleryTest', () => {
it('should render multiple ImageGallery instances', () => {
const { container } = render(<ImageGalleryTest />)
const imgs = getImages(container)
// 6 images renders galleries with 1+2+3+4+5+6 = 21 images total
expect(imgs.length).toBe(21)
})
})

View File

@ -67,24 +67,3 @@ const ImageGallery: FC<Props> = ({
}
export default React.memo(ImageGallery)
export const ImageGalleryTest = () => {
const imgGallerySrcs = (() => {
const srcs = []
for (let i = 0; i < 6; i++)
// srcs.push('https://placekitten.com/640/360')
// srcs.push('https://placekitten.com/360/640')
srcs.push('https://placekitten.com/360/360')
return srcs
})()
return (
<div className="space-y-2">
{imgGallerySrcs.map((_, index) => (
<div key={index} className="rounded-lg bg-[#D1E9FF80] p-4 pb-2">
<ImageGallery srcs={imgGallerySrcs.slice(0, index + 1)} />
</div>
))}
</div>
)
}

View File

@ -1,8 +1,7 @@
import type { ClipboardEvent, DragEvent } from 'react'
import type { ImageFile, VisionSettings } from '@/types/app'
import type { ImageFile } from '@/types/app'
import { act, renderHook } from '@testing-library/react'
import { Resolution, TransferMethod } from '@/types/app'
import { useClipboardUploader, useDraggableUploader, useImageFiles, useLocalFileUploader } from '../hooks'
import { TransferMethod } from '@/types/app'
import { useImageFiles, useLocalFileUploader } from '../hooks'
const mockNotify = vi.fn()
vi.mock('@langgenius/dify-ui/toast', () => ({
@ -35,15 +34,6 @@ const createImageFile = (overrides: Partial<ImageFile> = {}): ImageFile => ({
...overrides,
})
const createVisionSettings = (overrides: Partial<VisionSettings> = {}): VisionSettings => ({
enabled: true,
number_limits: 5,
detail: Resolution.high,
transfer_methods: [TransferMethod.local_file],
image_file_size_limit: 10,
...overrides,
})
describe('useImageFiles', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -493,284 +483,3 @@ describe('useLocalFileUploader', () => {
)
})
})
describe('useClipboardUploader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should be disabled when visionConfig is undefined', () => {
const onUpload = vi.fn()
const { result } = renderHook(() =>
useClipboardUploader({ files: [], onUpload }),
)
// The hook returns onPaste, and since disabled is true, pasting should not upload
expect(result.current.onPaste).toBeInstanceOf(Function)
})
it('should be disabled when visionConfig.enabled is false', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({ enabled: false })
const { result } = renderHook(() =>
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
const mockEvent = {
clipboardData: { files: [file] },
preventDefault: vi.fn(),
} as unknown as ClipboardEvent<HTMLTextAreaElement>
act(() => {
result.current.onPaste(mockEvent)
})
// Paste occurs but the file should NOT be uploaded because disabled
expect(onUpload).not.toHaveBeenCalled()
})
it('should be disabled when local upload is not allowed', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({
transfer_methods: [TransferMethod.remote_url],
})
renderHook(() =>
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
)
expect(onUpload).not.toHaveBeenCalled()
})
it('should be disabled when files count reaches number_limits', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({ number_limits: 1 })
const files = [createImageFile({ _id: 'file-1' })]
renderHook(() =>
useClipboardUploader({ files, visionConfig: settings, onUpload }),
)
expect(onUpload).not.toHaveBeenCalled()
})
it('should call handleLocalFileUpload when pasting a file', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
const mockEvent = {
clipboardData: {
files: [file],
},
preventDefault: vi.fn(),
} as unknown as ClipboardEvent<HTMLTextAreaElement>
act(() => {
result.current.onPaste(mockEvent)
})
expect(mockEvent.preventDefault).toHaveBeenCalled()
})
it('should not prevent default when pasting text (no file)', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useClipboardUploader({ files: [], visionConfig: settings, onUpload }),
)
const mockEvent = {
clipboardData: {
files: [] as File[],
},
preventDefault: vi.fn(),
} as unknown as ClipboardEvent<HTMLTextAreaElement>
act(() => {
result.current.onPaste(mockEvent)
})
expect(mockEvent.preventDefault).not.toHaveBeenCalled()
})
})
describe('useDraggableUploader', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const createDragEvent = (files: File[] = []) => ({
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
files,
},
} as unknown as DragEvent<HTMLDivElement>)
it('should return drag event handlers and isDragActive state', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
expect(result.current.onDragEnter).toBeInstanceOf(Function)
expect(result.current.onDragOver).toBeInstanceOf(Function)
expect(result.current.onDragLeave).toBeInstanceOf(Function)
expect(result.current.onDrop).toBeInstanceOf(Function)
expect(result.current.isDragActive).toBe(false)
})
it('should set isDragActive to true on dragEnter when not disabled', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const event = createDragEvent()
act(() => {
result.current.onDragEnter(event)
})
expect(result.current.isDragActive).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
})
it('should not set isDragActive on dragEnter when disabled', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({ enabled: false })
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const event = createDragEvent()
act(() => {
result.current.onDragEnter(event)
})
expect(result.current.isDragActive).toBe(false)
})
it('should call preventDefault and stopPropagation on dragOver', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const event = createDragEvent()
act(() => {
result.current.onDragOver(event)
})
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
})
it('should set isDragActive to false on dragLeave', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
// First activate drag
act(() => {
result.current.onDragEnter(createDragEvent())
})
expect(result.current.isDragActive).toBe(true)
// Then leave
const leaveEvent = createDragEvent()
act(() => {
result.current.onDragLeave(leaveEvent)
})
expect(result.current.isDragActive).toBe(false)
expect(leaveEvent.preventDefault).toHaveBeenCalled()
expect(leaveEvent.stopPropagation).toHaveBeenCalled()
})
it('should set isDragActive to false on drop and upload file', async () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const file = new File(['test'], 'test.png', { type: 'image/png' })
const event = createDragEvent([file])
// Activate drag first
act(() => {
result.current.onDragEnter(createDragEvent())
})
expect(result.current.isDragActive).toBe(true)
act(() => {
result.current.onDrop(event)
})
expect(result.current.isDragActive).toBe(false)
expect(event.preventDefault).toHaveBeenCalled()
expect(event.stopPropagation).toHaveBeenCalled()
// Verify the file was actually handed to the upload pipeline
await vi.waitFor(() => {
expect(mockImageUpload).toHaveBeenCalled()
})
})
it('should not upload when dropping with no files', () => {
const onUpload = vi.fn()
const settings = createVisionSettings()
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files: [], visionConfig: settings, onUpload }),
)
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: {
files: [] as unknown as FileList,
},
} as unknown as React.DragEvent<HTMLDivElement>
act(() => {
result.current.onDrop(event)
})
// onUpload should not be called directly since no file was dropped
expect(onUpload).not.toHaveBeenCalled()
})
it('should be disabled when files count exceeds number_limits', () => {
const onUpload = vi.fn()
const settings = createVisionSettings({ number_limits: 1 })
const files = [createImageFile({ _id: 'file-1' })]
const { result } = renderHook(() =>
useDraggableUploader<HTMLDivElement>({ files, visionConfig: settings, onUpload }),
)
const event = createDragEvent()
act(() => {
result.current.onDragEnter(event)
})
// Should not activate drag when disabled
expect(result.current.isDragActive).toBe(false)
})
})

View File

@ -1,5 +1,4 @@
import type { ClipboardEvent } from 'react'
import type { ImageFile, VisionSettings } from '@/types/app'
import type { ImageFile } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -154,75 +153,3 @@ export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useL
}, [disabled, limit, t, onUpload, params?.token])
return { disabled, handleLocalFileUpload }
}
type useClipboardUploaderProps = {
files: ImageFile[]
visionConfig?: VisionSettings
onUpload: (imageFile: ImageFile) => void
}
export const useClipboardUploader = ({ visionConfig, onUpload, files }: useClipboardUploaderProps) => {
const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file)
const disabled = useMemo(() => !visionConfig
|| !visionConfig?.enabled
|| !allowLocalUpload
|| files.length >= visionConfig.number_limits!, [allowLocalUpload, files.length, visionConfig])
const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig])
const { handleLocalFileUpload } = useLocalFileUploader({ limit, onUpload, disabled })
const handleClipboardPaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => {
// reserve native text copy behavior
const file = e.clipboardData?.files[0]
// when copied file, prevent default action
if (file) {
e.preventDefault()
handleLocalFileUpload(file)
}
}, [handleLocalFileUpload])
return {
onPaste: handleClipboardPaste,
}
}
type useDraggableUploaderProps = {
files: ImageFile[]
visionConfig?: VisionSettings
onUpload: (imageFile: ImageFile) => void
}
export const useDraggableUploader = <T extends HTMLElement>({ visionConfig, onUpload, files }: useDraggableUploaderProps) => {
const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file)
const disabled = useMemo(() => !visionConfig
|| !visionConfig?.enabled
|| !allowLocalUpload
|| files.length >= visionConfig.number_limits!, [allowLocalUpload, files.length, visionConfig])
const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig])
const { handleLocalFileUpload } = useLocalFileUploader({ disabled, onUpload, limit })
const [isDragActive, setIsDragActive] = useState(false)
const handleDragEnter = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
if (!disabled)
setIsDragActive(true)
}, [disabled])
const handleDragOver = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
}, [])
const handleDragLeave = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent<T>) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
const file = e.dataTransfer.files[0]
if (!file)
return
handleLocalFileUpload(file)
}, [handleLocalFileUpload])
return {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
isDragActive,
}
}

View File

@ -3,7 +3,6 @@ import {
checkHasContextBlock,
checkHasHistoryBlock,
checkHasQueryBlock,
checkHasRequestURLBlock,
CONTEXT_PLACEHOLDER_TEXT,
CURRENT_PLACEHOLDER_TEXT,
ERROR_MESSAGE_PLACEHOLDER_TEXT,
@ -55,12 +54,6 @@ describe('prompt-editor constants', () => {
expect(checkHasQueryBlock('plain text')).toBe(false)
expect(checkHasQueryBlock(`before ${QUERY_PLACEHOLDER_TEXT} after`)).toBe(true)
})
it('should detect request url placeholder only when present', () => {
expect(checkHasRequestURLBlock('')).toBe(false)
expect(checkHasRequestURLBlock('plain text')).toBe(false)
expect(checkHasRequestURLBlock(`before ${REQUEST_URL_PLACEHOLDER_TEXT} after`)).toBe(true)
})
})
describe('getInputVars', () => {

View File

@ -2,7 +2,6 @@ import type {
Klass,
LexicalEditor,
LexicalNode,
RangeSelection,
TextNode,
} from 'lexical'
import type { CustomTextNode } from '../plugins/custom-text/node'
@ -10,21 +9,15 @@ import type { MenuTextMatch } from '../types'
import {
$splitNodeContainingQuery,
decoratorTransform,
getSelectedNode,
registerLexicalTextEntity,
textToEditorState,
} from '../utils'
const mockState = vi.hoisted(() => ({
isAtNodeEnd: false,
selection: null as unknown,
createTextNode: vi.fn(),
}))
vi.mock('@lexical/selection', () => ({
$isAtNodeEnd: () => mockState.isAtNodeEnd,
}))
vi.mock('lexical', async (importOriginal) => {
const actual = await importOriginal<typeof import('lexical')>()
return {
@ -43,7 +36,6 @@ vi.mock('./plugins/custom-text/node', () => ({
describe('prompt-editor/utils', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.isAtNodeEnd = false
mockState.selection = null
})
function makeEditor() {
@ -57,74 +49,6 @@ describe('prompt-editor/utils', () => {
return { editor, registerNodeTransform }
}
// ---------------------------------------------------------------------------
// getSelectedNode
// ---------------------------------------------------------------------------
describe('getSelectedNode', () => {
it('should return anchor node when anchor and focus are the same node', () => {
const sharedNode = { id: 'same' }
const selection = {
anchor: { getNode: () => sharedNode },
focus: { getNode: () => sharedNode },
isBackward: () => false,
} as unknown as RangeSelection
expect(getSelectedNode(selection)).toBe(sharedNode)
})
it('should return anchor node for backward selection when focus IS at node end', () => {
const anchorNode = { id: 'anchor' }
const focusNode = { id: 'focus' }
const selection = {
anchor: { getNode: () => anchorNode },
focus: { getNode: () => focusNode },
isBackward: () => true,
} as unknown as RangeSelection
mockState.isAtNodeEnd = true
expect(getSelectedNode(selection)).toBe(anchorNode)
})
it('should return focus node for backward selection when focus is NOT at node end', () => {
const anchorNode = { id: 'anchor' }
const focusNode = { id: 'focus' }
const selection = {
anchor: { getNode: () => anchorNode },
focus: { getNode: () => focusNode },
isBackward: () => true,
} as unknown as RangeSelection
mockState.isAtNodeEnd = false
expect(getSelectedNode(selection)).toBe(focusNode)
})
it('should return anchor node for forward selection when anchor IS at node end', () => {
const anchorNode = { id: 'anchor' }
const focusNode = { id: 'focus' }
const selection = {
anchor: { getNode: () => anchorNode },
focus: { getNode: () => focusNode },
isBackward: () => false,
} as unknown as RangeSelection
mockState.isAtNodeEnd = true
expect(getSelectedNode(selection)).toBe(anchorNode)
})
it('should return focus node for forward selection when anchor is NOT at node end', () => {
const anchorNode = { id: 'anchor' }
const focusNode = { id: 'focus' }
const selection = {
anchor: { getNode: () => anchorNode },
focus: { getNode: () => focusNode },
isBackward: () => false,
} as unknown as RangeSelection
mockState.isAtNodeEnd = false
expect(getSelectedNode(selection)).toBe(focusNode)
})
})
// ---------------------------------------------------------------------------
// registerLexicalTextEntity
// ---------------------------------------------------------------------------

View File

@ -30,13 +30,6 @@ export const checkHasQueryBlock = (text: string) => {
return false
return text.includes(QUERY_PLACEHOLDER_TEXT)
}
export const checkHasRequestURLBlock = (text: string) => {
if (!text)
return false
return text.includes(REQUEST_URL_PLACEHOLDER_TEXT)
}
/*
* {{#1711617514996.name#}} => [1711617514996, name]
* {{#1711617514996.sys.query#}} => [sys, query]

View File

@ -11,7 +11,6 @@ import {
import CurrentBlockComponent from '../component'
import {
$createCurrentBlockNode,
$isCurrentBlockNode,
CurrentBlockNode,
} from '../node'
@ -175,21 +174,5 @@ describe('CurrentBlockNode', () => {
expect(node).toBeInstanceOf(CurrentBlockNode)
})
it('should identify current block nodes using type guard helper', () => {
const editor = createTestEditor()
let node!: CurrentBlockNode
act(() => {
editor.update(() => {
node = $createCurrentBlockNode(GeneratorType.prompt)
appendNodeToRoot(node)
})
})
expect($isCurrentBlockNode(node)).toBe(true)
expect($isCurrentBlockNode(null)).toBe(false)
expect($isCurrentBlockNode(undefined)).toBe(false)
})
})
})

View File

@ -1,4 +1,4 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { NodeKey, SerializedLexicalNode } from 'lexical'
import type { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { DecoratorNode } from 'lexical'
import CurrentBlockComponent from './component'
@ -70,9 +70,3 @@ export class CurrentBlockNode extends DecoratorNode<React.JSX.Element> {
export function $createCurrentBlockNode(type: GeneratorType): CurrentBlockNode {
return new CurrentBlockNode(type)
}
export function $isCurrentBlockNode(
node: CurrentBlockNode | LexicalNode | null | undefined,
): boolean {
return node instanceof CurrentBlockNode
}

View File

@ -1,6 +1,6 @@
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
import { createEditor } from 'lexical'
import { $createErrorMessageBlockNode, $isErrorMessageBlockNode, ErrorMessageBlockNode } from '../node'
import { $createErrorMessageBlockNode, ErrorMessageBlockNode } from '../node'
describe('ErrorMessageBlockNode', () => {
let editor: LexicalEditor
@ -72,15 +72,4 @@ describe('ErrorMessageBlockNode', () => {
expect(imported).toBeInstanceOf(ErrorMessageBlockNode)
})
})
it('should return correct type guard values for lexical and non lexical inputs', () => {
runInEditor(() => {
const node = new ErrorMessageBlockNode()
expect($isErrorMessageBlockNode(node)).toBe(true)
expect($isErrorMessageBlockNode(null)).toBe(false)
expect($isErrorMessageBlockNode(undefined)).toBe(false)
expect($isErrorMessageBlockNode({} as ErrorMessageBlockNode)).toBe(false)
})
})
})

View File

@ -1,4 +1,4 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { NodeKey, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import ErrorMessageBlockComponent from './component'
@ -59,9 +59,3 @@ export class ErrorMessageBlockNode extends DecoratorNode<React.JSX.Element> {
export function $createErrorMessageBlockNode(): ErrorMessageBlockNode {
return new ErrorMessageBlockNode()
}
export function $isErrorMessageBlockNode(
node: ErrorMessageBlockNode | LexicalNode | null | undefined,
): boolean {
return node instanceof ErrorMessageBlockNode
}

View File

@ -12,7 +12,6 @@ import {
import HITLInputBlockComponent from '../component'
import {
$createHITLInputNode,
$isHITLInputNode,
HITLInputNode,
} from '../node'
@ -202,7 +201,7 @@ describe('HITLInputNode', () => {
})
})
it('should create and update DOM and support helper type guard', () => {
it('should create and update DOM', () => {
const editor = createTestEditor()
const props = createNodeProps()
@ -227,11 +226,7 @@ describe('HITLInputNode', () => {
expectInlineWrapperDom(dom, ['w-[calc(100%-1px)]', 'support-drag'])
expect(node.updateDOM()).toBe(false)
expect($isHITLInputNode(node)).toBe(true)
})
})
expect($isHITLInputNode(null)).toBe(false)
expect($isHITLInputNode(undefined)).toBe(false)
})
})

View File

@ -1,4 +1,4 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { NodeKey, SerializedLexicalNode } from 'lexical'
import type { GetVarType } from '../../types'
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
@ -269,9 +269,3 @@ export function $createHITLInputNode(
readonly,
)
}
export function $isHITLInputNode(
node: HITLInputNode | LexicalNode | null | undefined,
): node is HITLInputNode {
return node instanceof HITLInputNode
}

View File

@ -6,7 +6,6 @@ import {
import LastRunBlockComponent from '../component'
import {
$createLastRunBlockNode,
$isLastRunBlockNode,
LastRunBlockNode,
} from '../node'
@ -102,13 +101,5 @@ describe('LastRunBlockNode', () => {
expect(node).toBeInstanceOf(LastRunBlockNode)
})
it('should identify last run block nodes using type guard helper', () => {
const { node } = createNodeInEditor()
expect($isLastRunBlockNode(node)).toBe(true)
expect($isLastRunBlockNode(null)).toBe(false)
expect($isLastRunBlockNode(undefined)).toBe(false)
})
})
})

View File

@ -1,4 +1,4 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { NodeKey, SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import LastRunBlockComponent from './component'
@ -59,9 +59,3 @@ export class LastRunBlockNode extends DecoratorNode<React.JSX.Element> {
export function $createLastRunBlockNode(): LastRunBlockNode {
return new LastRunBlockNode()
}
export function $isLastRunBlockNode(
node: LastRunBlockNode | LexicalNode | null | undefined,
): boolean {
return node instanceof LastRunBlockNode
}

View File

@ -6,7 +6,6 @@ import {
import RequestURLBlockComponent from '../component'
import {
$createRequestURLBlockNode,
$isRequestURLBlockNode,
RequestURLBlockNode,
} from '../node'
@ -102,13 +101,5 @@ describe('RequestURLBlockNode', () => {
expect(node).toBeInstanceOf(RequestURLBlockNode)
})
it('should identify request URL block nodes using type guard', () => {
const { node } = createNodeInEditor()
expect($isRequestURLBlockNode(node)).toBe(true)
expect($isRequestURLBlockNode(null)).toBe(false)
expect($isRequestURLBlockNode(undefined)).toBe(false)
})
})
})

View File

@ -1,4 +1,4 @@
import type { LexicalNode, SerializedLexicalNode } from 'lexical'
import type { SerializedLexicalNode } from 'lexical'
import { DecoratorNode } from 'lexical'
import RequestURLBlockComponent from './component'
@ -51,9 +51,3 @@ export class RequestURLBlockNode extends DecoratorNode<React.JSX.Element> {
export function $createRequestURLBlockNode(): RequestURLBlockNode {
return new RequestURLBlockNode()
}
export function $isRequestURLBlockNode(
node: RequestURLBlockNode | LexicalNode | null | undefined,
): node is RequestURLBlockNode {
return node instanceof RequestURLBlockNode
}

View File

@ -8,7 +8,6 @@ import { createEditor } from 'lexical'
import RosterReferenceBlockComponent from '../component'
import {
$createRosterReferenceBlockNode,
$isRosterReferenceBlockNode,
RosterReferenceBlockNode,
} from '../node'
import {
@ -104,16 +103,12 @@ describe('RosterReferenceBlockNode', () => {
})
})
it('should create node with helper and support type guard checks', () => {
it('should create node with helper', () => {
runInEditor(() => {
const node = $createRosterReferenceBlockNode('[§skill:playwright:Playwright§]')
expect(node).toBeInstanceOf(RosterReferenceBlockNode)
expect(node.getTextContent()).toBe('[§skill:playwright:Playwright§]')
expect($isRosterReferenceBlockNode(node)).toBe(true)
expect($isRosterReferenceBlockNode(null)).toBe(false)
expect($isRosterReferenceBlockNode(undefined)).toBe(false)
expect($isRosterReferenceBlockNode({} as LexicalNode)).toBe(false)
})
})
})

View File

@ -1,5 +1,4 @@
import type {
LexicalNode,
NodeKey,
SerializedLexicalNode,
} from 'lexical'
@ -68,9 +67,3 @@ export class RosterReferenceBlockNode extends DecoratorNode<JSX.Element> {
export function $createRosterReferenceBlockNode(text = ''): RosterReferenceBlockNode {
return $applyNodeReplacement(new RosterReferenceBlockNode(text))
}
export function $isRosterReferenceBlockNode(
node: LexicalNode | null | undefined,
): node is RosterReferenceBlockNode {
return node instanceof RosterReferenceBlockNode
}

View File

@ -2,7 +2,6 @@ import type { EditorConfig, Klass, LexicalEditor, LexicalNode, SerializedTextNod
import { createEditor } from 'lexical'
import {
$createVariableValueBlockNode,
$isVariableValueNodeBlock,
VariableValueBlockNode,
} from '../node'
@ -77,16 +76,12 @@ describe('VariableValueBlockNode', () => {
})
})
it('should create node with helper and support type guard checks', () => {
it('should create node with helper', () => {
runInEditor(() => {
const node = $createVariableValueBlockNode('{{org_id}}')
expect(node).toBeInstanceOf(VariableValueBlockNode)
expect(node.getTextContent()).toBe('{{org_id}}')
expect($isVariableValueNodeBlock(node)).toBe(true)
expect($isVariableValueNodeBlock(null)).toBe(false)
expect($isVariableValueNodeBlock(undefined)).toBe(false)
expect($isVariableValueNodeBlock({} as LexicalNode)).toBe(false)
})
})
})

View File

@ -1,6 +1,5 @@
import type {
EditorConfig,
LexicalNode,
SerializedTextNode,
} from 'lexical'
import {
@ -56,9 +55,3 @@ export class VariableValueBlockNode extends TextNode {
export function $createVariableValueBlockNode(text = ''): VariableValueBlockNode {
return $applyNodeReplacement(new VariableValueBlockNode(text))
}
export function $isVariableValueNodeBlock(
node: LexicalNode | null | undefined,
): node is VariableValueBlockNode {
return node instanceof VariableValueBlockNode
}

View File

@ -5,7 +5,6 @@ import { Type } from '@/app/components/workflow/nodes/llm/types'
import { BlockEnum, VarType } from '@/app/components/workflow/types'
import {
$createWorkflowVariableBlockNode,
$isWorkflowVariableBlockNode,
WorkflowVariableBlockNode,
} from '../node'
@ -145,7 +144,7 @@ describe('WorkflowVariableBlockNode', () => {
})
})
it('should create node helper and type guard checks', () => {
it('should create node helper', () => {
runInEditor(() => {
const availableVariables: NodeOutPutVar[] = [{
nodeId: 'node-1',
@ -156,10 +155,6 @@ describe('WorkflowVariableBlockNode', () => {
expect(node).toBeInstanceOf(WorkflowVariableBlockNode)
expect(node.getAvailableVariables()).toEqual(availableVariables)
expect($isWorkflowVariableBlockNode(node)).toBe(true)
expect($isWorkflowVariableBlockNode(null)).toBe(false)
expect($isWorkflowVariableBlockNode(undefined)).toBe(false)
expect($isWorkflowVariableBlockNode({} as LexicalNode)).toBe(false)
})
})
})

View File

@ -1,4 +1,4 @@
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
import type { NodeKey, SerializedLexicalNode } from 'lexical'
import type { GetVarType, WorkflowVariableBlockType } from '../../types'
import type { NodeOutPutVar } from '@/app/components/workflow/types'
import { DecoratorNode } from 'lexical'
@ -137,9 +137,3 @@ export function $createWorkflowVariableBlockNode(
availableVariables,
)
}
export function $isWorkflowVariableBlockNode(
node: WorkflowVariableBlockNode | LexicalNode | null | undefined,
): node is WorkflowVariableBlockNode {
return node instanceof WorkflowVariableBlockNode
}

View File

@ -1,14 +1,11 @@
import type { EntityMatch } from '@lexical/text'
import type {
ElementNode,
Klass,
LexicalEditor,
LexicalNode,
RangeSelection,
TextNode,
} from 'lexical'
import type { MenuTextMatch } from './types'
import { $isAtNodeEnd } from '@lexical/selection'
import {
$createTextNode,
$getSelection,
@ -17,23 +14,6 @@ import {
} from 'lexical'
import { CustomTextNode } from './plugins/custom-text/node'
export function getSelectedNode(
selection: RangeSelection,
): TextNode | ElementNode {
const anchor = selection.anchor
const focus = selection.focus
const anchorNode = selection.anchor.getNode()
const focusNode = selection.focus.getNode()
if (anchorNode === focusNode)
return anchorNode
const isBackward = selection.isBackward()
if (isBackward)
return $isAtNodeEnd(focus) ? anchorNode : focusNode
else
return $isAtNodeEnd(anchor) ? anchorNode : focusNode
}
export function registerLexicalTextEntity<T extends TextNode>(
editor: LexicalEditor,
getMatch: (text: string) => null | EntityMatch,

View File

@ -64,64 +64,6 @@ describe('zendesk/utils', () => {
})
})
describe('setZendeskWidgetVisibility', () => {
it('should call window.zE to show widget when visible is true', async () => {
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
const { setZendeskWidgetVisibility } = await import('../utils')
setZendeskWidgetVisibility(true)
expect(window.zE).toHaveBeenCalledWith('messenger', 'show')
})
it('should call window.zE to hide widget when visible is false', async () => {
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
const { setZendeskWidgetVisibility } = await import('../utils')
setZendeskWidgetVisibility(false)
expect(window.zE).toHaveBeenCalledWith('messenger', 'hide')
})
it('should not call window.zE when IS_CE_EDITION is true', async () => {
vi.doMock('@/config', () => ({ IS_CE_EDITION: true }))
const { setZendeskWidgetVisibility } = await import('../utils')
setZendeskWidgetVisibility(true)
expect(window.zE).not.toHaveBeenCalled()
})
})
describe('toggleZendeskWindow', () => {
it('should call window.zE to open messenger when open is true', async () => {
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
const { toggleZendeskWindow } = await import('../utils')
toggleZendeskWindow(true)
expect(window.zE).toHaveBeenCalledWith('messenger', 'open')
})
it('should call window.zE to close messenger when open is false', async () => {
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))
const { toggleZendeskWindow } = await import('../utils')
toggleZendeskWindow(false)
expect(window.zE).toHaveBeenCalledWith('messenger', 'close')
})
it('should not call window.zE when IS_CE_EDITION is true', async () => {
vi.doMock('@/config', () => ({ IS_CE_EDITION: true }))
const { toggleZendeskWindow } = await import('../utils')
toggleZendeskWindow(true)
expect(window.zE).not.toHaveBeenCalled()
})
})
describe('openZendeskWindow', () => {
it('should show and open messenger when zE exists', async () => {
vi.doMock('@/config', () => ({ IS_CE_EDITION: false }))

View File

@ -22,16 +22,6 @@ export const setZendeskConversationFields = (fields: ConversationField[], callba
window.zE('messenger:set', 'conversationFields', fields, callback)
}
export const setZendeskWidgetVisibility = (visible: boolean) => {
if (!IS_CE_EDITION && window.zE)
window.zE('messenger', visible ? 'show' : 'hide')
}
export const toggleZendeskWindow = (open: boolean) => {
if (!IS_CE_EDITION && window.zE)
window.zE('messenger', open ? 'open' : 'close')
}
type OpenZendeskWindowOptions = {
interval?: number
retries?: number

View File

@ -1,4 +1,4 @@
import { ALL_PLANS, contactSalesUrl, contractSales, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE, unAvailable } from '../config'
import { ALL_PLANS, contactSalesUrl, defaultPlan, getStartedWithCommunityUrl, getWithPremiumUrl, NUM_INFINITE } from '../config'
import { Priority } from '../type'
describe('Billing Config', () => {
@ -7,14 +7,6 @@ describe('Billing Config', () => {
expect(NUM_INFINITE).toBe(-1)
})
it('should define contractSales string', () => {
expect(contractSales).toBe('contractSales')
})
it('should define unAvailable string', () => {
expect(unAvailable).toBe('unAvailable')
})
it('should define valid URL constants', () => {
expect(contactSalesUrl).toMatch(/^https:\/\//)
expect(getStartedWithCommunityUrl).toMatch(/^https:\/\//)

View File

@ -4,9 +4,6 @@ import { Plan, Priority } from '@/app/components/billing/type'
const supportModelProviders = 'OpenAI/Anthropic/Llama2/Azure OpenAI/Hugging Face/Replicate'
export const NUM_INFINITE = -1
export const contractSales = 'contractSales'
export const unAvailable = 'unAvailable'
export const contactSalesUrl = 'https://vikgc6bnu1s.typeform.com/dify-business'
export const getStartedWithCommunityUrl = 'https://github.com/langgenius/dify'
export const getWithPremiumUrl = 'https://aws.amazon.com/marketplace/pp/prodview-t22mebxzwjhu6'

View File

@ -1,10 +1,10 @@
import type { DefaultModelResponse, Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
import { describe, expect, it } from 'vitest'
import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { RerankingModeEnum } from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { ensureRerankModelSelected, isReRankModelSelected } from '../check-rerank-model'
import { isReRankModelSelected } from '../check-rerank-model'
// Test data factory
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
@ -53,15 +53,6 @@ const createRerankModelList = (): Model[] => [
},
]
const createDefaultRerankModel = (): DefaultModelResponse => ({
model: 'rerank-english-v2.0',
model_type: ModelTypeEnum.rerank,
provider: {
provider: 'cohere',
icon_small: { en_US: '', zh_Hans: '' },
},
})
describe('check-rerank-model', () => {
describe('isReRankModelSelected', () => {
describe('Core Functionality', () => {
@ -261,166 +252,4 @@ describe('check-rerank-model', () => {
})
})
})
describe('ensureRerankModelSelected', () => {
describe('Core Functionality', () => {
it('should return original config when reranking model already selected', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result).toEqual(config)
})
it('should apply default model when reranking enabled but no model selected', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result.reranking_model).toEqual({
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
})
})
it('should apply default model for hybrid search method', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.hybrid,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result.reranking_model).toEqual({
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-english-v2.0',
})
})
})
describe('Edge Cases', () => {
it('should return original config when indexMethod is not high_quality', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'economy',
})
expect(result).toEqual(config)
})
it('should return original config when rerankDefaultModel is null', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: null as unknown as DefaultModelResponse,
indexMethod: 'high_quality',
})
expect(result).toEqual(config)
})
it('should return original config when reranking disabled and not hybrid search', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result).toEqual(config)
})
it('should return original config when indexMethod is undefined', () => {
const config = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: undefined,
})
expect(result).toEqual(config)
})
it('should preserve other config properties when applying default model', () => {
const config = createRetrievalConfig({
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: true,
top_k: 10,
score_threshold_enabled: true,
score_threshold: 0.8,
})
const result = ensureRerankModelSelected({
retrievalConfig: config,
rerankDefaultModel: createDefaultRerankModel(),
indexMethod: 'high_quality',
})
expect(result.top_k).toBe(10)
expect(result.score_threshold_enabled).toBe(true)
expect(result.score_threshold).toBe(0.8)
expect(result.search_method).toBe(RETRIEVE_METHOD.semantic)
})
})
})
})

View File

@ -1,5 +1,4 @@
import type {
DefaultModelResponse,
Model,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RetrievalConfig } from '@/types/app'
@ -44,30 +43,3 @@ export const isReRankModelSelected = ({
return true
}
export const ensureRerankModelSelected = ({
rerankDefaultModel,
indexMethod,
retrievalConfig,
}: {
rerankDefaultModel: DefaultModelResponse
retrievalConfig: RetrievalConfig
indexMethod?: string
}) => {
const rerankModel = retrievalConfig.reranking_model?.reranking_model_name ? retrievalConfig.reranking_model : undefined
if (
indexMethod === 'high_quality'
&& (retrievalConfig.reranking_enable || retrievalConfig.search_method === RETRIEVE_METHOD.hybrid)
&& !rerankModel
&& rerankDefaultModel
) {
return {
...retrievalConfig,
reranking_model: {
reranking_provider_name: rerankDefaultModel.provider.provider,
reranking_model_name: rerankDefaultModel.model,
},
}
}
return retrievalConfig
}

View File

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../../create/icons'
import RetrievalMethodInfo, { getIcon } from '../index'
import { getIcon } from '../index'
// Mock icons
vi.mock('../../../create/icons', () => ({
@ -13,72 +12,10 @@ vi.mock('../../../create/icons', () => ({
}))
describe('RetrievalMethodInfo', () => {
const defaultConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: 'test-provider',
reranking_model_name: 'test-model',
},
top_k: 5,
score_threshold_enabled: true,
score_threshold: 0.8,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render correctly with full config', () => {
const { container } = render(<RetrievalMethodInfo value={defaultConfig} />)
// Check Title & Description (mocked i18n returns key prefixed with ns)
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
expect(screen.getByText('dataset.retrieval.semantic_search.description')).toBeInTheDocument()
// Check Icon
const icon = container.querySelector('img')
expect(icon).toHaveAttribute('src', 'vector-icon.png')
// Check Config Details
expect(screen.getByText('test-model')).toBeInTheDocument() // Rerank model
expect(screen.getByText('5')).toBeInTheDocument() // Top K
expect(screen.getByText('0.8')).toBeInTheDocument() // Score threshold
})
it('should not render reranking model if missing', () => {
const configWithoutRerank = {
...defaultConfig,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}
render(<RetrievalMethodInfo value={configWithoutRerank} />)
expect(screen.queryByText('test-model')).not.toBeInTheDocument()
// Other fields should still be there
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should handle different retrieval methods', () => {
// Test Hybrid
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
const { container, unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
expect(screen.getByText('dataset.retrieval.hybrid_search.title')).toBeInTheDocument()
expect(container.querySelector('img')).toHaveAttribute('src', 'hybrid-icon.png')
unmount()
// Test FullText
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
const { container: fullTextContainer } = render(<RetrievalMethodInfo value={fullTextConfig} />)
expect(screen.getByText('dataset.retrieval.full_text_search.title')).toBeInTheDocument()
expect(fullTextContainer.querySelector('img')).toHaveAttribute('src', 'fulltext-icon.png')
})
describe('getIcon utility', () => {
it('should return correct icon for each type', () => {
expect(getIcon(RETRIEVE_METHOD.semantic)).toBe(retrievalIcon.vector)
@ -94,33 +31,4 @@ describe('RetrievalMethodInfo', () => {
expect(getIcon(unknownType)).toBe(retrievalIcon.vector)
})
})
it('should not render score threshold if disabled', () => {
const configWithoutScoreThreshold = {
...defaultConfig,
score_threshold_enabled: false,
score_threshold: 0,
}
render(<RetrievalMethodInfo value={configWithoutScoreThreshold} />)
// score_threshold is still rendered but may be undefined
expect(screen.queryByText('0.8')).not.toBeInTheDocument()
})
it('should render correctly with invertedIndex search method', () => {
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
const { container } = render(<RetrievalMethodInfo value={invertedIndexConfig} />)
// invertedIndex uses vector icon
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
})
it('should render correctly with keywordSearch search method', () => {
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
const { container } = render(<RetrievalMethodInfo value={keywordSearchConfig} />)
// keywordSearch uses vector icon
expect(container.querySelector('img')).toHaveAttribute('src', 'vector-icon.png')
})
})

View File

@ -1,16 +1,7 @@
'use client'
import type { FC } from 'react'
import type { RetrievalConfig } from '@/types/app'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import RadioCard from '@/app/components/base/radio-card'
import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../create/icons'
type Props = Readonly<{
value: RetrievalConfig
}>
export const getIcon = (type: RETRIEVE_METHOD) => {
return ({
[RETRIEVE_METHOD.semantic]: retrievalIcon.vector,
@ -20,45 +11,3 @@ export const getIcon = (type: RETRIEVE_METHOD) => {
[RETRIEVE_METHOD.keywordSearch]: retrievalIcon.vector,
})[type] || retrievalIcon.vector
}
const EconomicalRetrievalMethodConfig: FC<Props> = ({
// type,
value,
}) => {
const { t } = useTranslation()
const type = value.search_method
const icon = <img className="size-3.5 text-util-colors-purple-purple-600" src={getIcon(type)} alt="" />
return (
<div className="space-y-2">
<RadioCard
icon={icon}
title={t(`retrieval.${type}.title`, { ns: 'dataset' })}
description={t(`retrieval.${type}.description`, { ns: 'dataset' })}
noRadio
chosenConfigWrapClassName="pb-3!"
chosenConfig={(
<div className="flex flex-wrap text-xs leading-[18px] font-normal">
{value.reranking_model.reranking_model_name && (
<div className="mr-8 flex space-x-1">
<div className="text-gray-500">{t('modelProvider.rerankModel.key', { ns: 'common' })}</div>
<div className="font-medium text-gray-800">{value.reranking_model.reranking_model_name}</div>
</div>
)}
<div className="mr-8 flex space-x-1">
<div className="text-gray-500">{t('datasetConfig.top_k', { ns: 'appDebug' })}</div>
<div className="font-medium text-gray-800">{value.top_k}</div>
</div>
<div className="mr-8 flex space-x-1">
<div className="text-gray-500">{t('datasetConfig.score_threshold', { ns: 'appDebug' })}</div>
<div className="font-medium text-gray-800">{value.score_threshold}</div>
</div>
</div>
)}
/>
</div>
)
}
export default React.memo(EconomicalRetrievalMethodConfig)

View File

@ -2,7 +2,7 @@ import type { FileListItemProps } from '../file-list-item'
import type { CustomFile as File, FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants'
import { PROGRESS_ERROR } from '../../constants'
import FileListItem from '../file-list-item'
// Mock theme hook - can be changed per test
@ -53,7 +53,7 @@ describe('FileListItem', () => {
const createMockFileItem = (overrides: Partial<FileItem> = {}): FileItem => ({
fileID: 'file-123',
file: createMockFile(overrides.file as Partial<File>),
progress: PROGRESS_NOT_STARTED,
progress: 100,
...overrides,
})
@ -139,13 +139,6 @@ describe('FileListItem', () => {
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
it('should not show progress chart when not started (-1)', () => {
const fileItem = createMockFileItem({ progress: PROGRESS_NOT_STARTED })
render(<FileListItem {...defaultProps} fileItem={fileItem} />)
expect(screen.queryByTestId('pie-chart')).not.toBeInTheDocument()
})
})
describe('error state', () => {

View File

@ -1,2 +1 @@
export const PROGRESS_NOT_STARTED = -1
export const PROGRESS_ERROR = -2

View File

@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import type { CustomFile, FileItem } from '@/models/datasets'
import { act, render, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROGRESS_ERROR, PROGRESS_NOT_STARTED } from '../../constants'
import { PROGRESS_ERROR } from '../../constants'
const { mockNotify, mockToast } = vi.hoisted(() => {
const mockNotify = vi.fn()
@ -854,31 +854,6 @@ describe('useLocalFileUpload', () => {
})
describe('file progress constants', () => {
it('should use PROGRESS_NOT_STARTED for new files', async () => {
mockUpload.mockResolvedValue({ id: 'file-id' })
const { result } = renderHook(
() => useLocalFileUpload({ allowedExtensions: ['pdf'] }),
{ wrapper: createWrapper() },
)
const mockFile = new File(['content'], 'test.pdf', { type: 'application/pdf' })
const event = {
target: {
files: [mockFile],
},
} as unknown as React.ChangeEvent<HTMLInputElement>
act(() => {
result.current.fileChangeHandle(event)
})
await waitFor(() => {
const callArgs = mockSetLocalFileList.mock.calls[0]![0]
expect(callArgs[0].progress).toBe(PROGRESS_NOT_STARTED)
})
})
it('should set PROGRESS_ERROR on upload failure', async () => {
mockUpload.mockRejectedValue(new Error('Upload failed'))

View File

@ -9,7 +9,6 @@ import {
isEmbeddingStatus,
isTerminalStatus,
useEmbeddingStatus,
useInvalidateEmbeddingStatus,
usePauseIndexing,
useResumeIndexing,
} from '../use-embedding-status'
@ -386,77 +385,4 @@ describe('use-embedding-status', () => {
})
})
})
describe('useInvalidateEmbeddingStatus', () => {
it('should return a function', () => {
const { result } = renderHook(
() => useInvalidateEmbeddingStatus(),
{ wrapper: createWrapper() },
)
expect(typeof result.current).toBe('function')
})
it('should invalidate specific query when datasetId and documentId are provided', async () => {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
// Set some initial data in the cache
queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], {
id: 'doc1',
indexing_status: 'indexing',
})
const { result } = renderHook(
() => useInvalidateEmbeddingStatus(),
{ wrapper },
)
await act(async () => {
result.current('ds1', 'doc1')
})
// The query should be invalidated (marked as stale)
const queryState = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1'])
expect(queryState?.isInvalidated).toBe(true)
})
it('should invalidate all embedding status queries when ids are not provided', async () => {
const queryClient = createTestQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
// Set some initial data in the cache for multiple documents
queryClient.setQueryData(['embedding', 'indexing-status', 'ds1', 'doc1'], {
id: 'doc1',
indexing_status: 'indexing',
})
queryClient.setQueryData(['embedding', 'indexing-status', 'ds2', 'doc2'], {
id: 'doc2',
indexing_status: 'completed',
})
const { result } = renderHook(
() => useInvalidateEmbeddingStatus(),
{ wrapper },
)
await act(async () => {
result.current()
})
// Both queries should be invalidated
const queryState1 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds1', 'doc1'])
const queryState2 = queryClient.getQueryState(['embedding', 'indexing-status', 'ds2', 'doc2'])
expect(queryState1?.isInvalidated).toBe(true)
expect(queryState2?.isInvalidated).toBe(true)
})
})
})

View File

@ -129,19 +129,3 @@ export const useResumeIndexing = ({ datasetId, documentId, onSuccess, onError }:
onError,
})
}
export const useInvalidateEmbeddingStatus = () => {
const queryClient = useQueryClient()
return useCallback((datasetId?: string, documentId?: string) => {
if (datasetId && documentId) {
queryClient.invalidateQueries({
queryKey: [NAME_SPACE, 'indexing-status', datasetId, documentId],
})
}
else {
queryClient.invalidateQueries({
queryKey: [NAME_SPACE, 'indexing-status'],
})
}
}, [queryClient])
}

View File

@ -1,708 +0,0 @@
import type { FullDocumentDetail } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Metadata, { FieldInfo } from '../index'
// Mock document context
vi.mock('../../context', () => ({
useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) => {
return selector({ datasetId: 'test-dataset-id', documentId: 'test-document-id' })
},
}))
const toastMocks = vi.hoisted(() => {
const record = vi.fn()
const api = vi.fn((message: unknown, options?: Record<string, unknown>) => record({ message, ...options }))
return {
record,
api: Object.assign(api, {
success: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'success', message, ...options })),
error: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'error', message, ...options })),
warning: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'warning', message, ...options })),
info: vi.fn((message: unknown, options?: Record<string, unknown>) => record({ type: 'info', message, ...options })),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
}),
}
})
vi.mock('use-context-selector', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
useContext: () => ({ notify: toastMocks.api }),
}
})
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: toastMocks.api,
}))
// Mock modifyDocMetadata
const mockModifyDocMetadata = vi.fn()
vi.mock('@/service/datasets', () => ({
modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args),
}))
// Mock useMetadataMap and related hooks
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: {
text: 'Book',
iconName: 'book',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
author: { label: 'Author', inputType: 'input' },
publisher: { label: 'Publisher', inputType: 'input' },
publication_date: { label: 'Publication Date', inputType: 'input' },
isbn: { label: 'ISBN', inputType: 'input' },
category: { label: 'Category', inputType: 'select' },
},
},
web_page: {
text: 'Web Page',
iconName: 'web',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
url: { label: 'URL', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
},
},
paper: {
text: 'Paper',
iconName: 'paper',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
},
},
social_media_post: {
text: 'Social Media Post',
iconName: 'social',
subFieldsMap: {
platform: { label: 'Platform', inputType: 'input' },
},
},
personal_document: {
text: 'Personal Document',
iconName: 'personal',
subFieldsMap: {
document_type: { label: 'Document Type', inputType: 'select' },
},
},
business_document: {
text: 'Business Document',
iconName: 'business',
subFieldsMap: {
document_type: { label: 'Document Type', inputType: 'select' },
},
},
im_chat_log: {
text: 'IM Chat Log',
iconName: 'chat',
subFieldsMap: {
platform: { label: 'Platform', inputType: 'input' },
},
},
originInfo: {
text: 'Origin Info',
subFieldsMap: {
data_source_type: { label: 'Data Source Type', inputType: 'input' },
name: { label: 'Name', inputType: 'input' },
},
},
technicalParameters: {
text: 'Technical Parameters',
subFieldsMap: {
segment_count: { label: 'Segment Count', inputType: 'input' },
hit_count: { label: 'Hit Count', inputType: 'input', render: (v: number, segCount?: number) => `${v}/${segCount}` },
},
},
}),
useLanguages: () => ({
en: 'English',
zh: 'Chinese',
}),
useBookCategories: () => ({
'fiction': 'Fiction',
'non-fiction': 'Non-Fiction',
}),
usePersonalDocCategories: () => ({
resume: 'Resume',
letter: 'Letter',
}),
useBusinessDocCategories: () => ({
report: 'Report',
proposal: 'Proposal',
}),
}))
vi.mock('@/utils', () => ({
asyncRunSafe: async (promise: Promise<unknown>) => {
try {
const result = await promise
return [null, result]
}
catch (e) {
return [e, null]
}
},
getTextWidthWithCanvas: () => 100,
}))
const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({
id: 'doc-1',
name: 'Test Document',
doc_type: 'book',
doc_metadata: {
title: 'Test Book',
author: 'Test Author',
language: 'en',
},
data_source_type: 'upload_file',
segment_count: 10,
hit_count: 5,
...overrides,
} as FullDocumentDetail)
describe('Metadata', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
docDetail: createMockDocDetail(),
loading: false,
onUpdate: vi.fn(),
canEdit: true,
}
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<Metadata {...defaultProps} />)
expect(container.firstChild)!.toBeInTheDocument()
})
it('should render metadata title', () => {
render(<Metadata {...defaultProps} />)
expect(screen.getByText(/metadata\.title/i))!.toBeInTheDocument()
})
it('should render edit button', () => {
render(<Metadata {...defaultProps} />)
expect(screen.getByText(/operation\.edit/i))!.toBeInTheDocument()
})
it('should hide edit button when canEdit is false', () => {
render(<Metadata {...defaultProps} canEdit={false} />)
expect(screen.queryByText(/operation\.edit/i)).not.toBeInTheDocument()
})
it('should hide edit button by default when canEdit is omitted', () => {
const { canEdit: _canEdit, ...propsWithoutCanEdit } = defaultProps
render(<Metadata {...propsWithoutCanEdit} />)
expect(screen.queryByText(/operation\.edit/i)).not.toBeInTheDocument()
})
it('should show loading state', () => {
render(<Metadata {...defaultProps} loading={true} />)
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
// Assert - Loading component should be rendered, title should not
expect(screen.queryByText(/metadata\.title/i)).not.toBeInTheDocument()
})
it('should display document type icon and text', () => {
render(<Metadata {...defaultProps} />)
expect(screen.getByText('Book'))!.toBeInTheDocument()
})
})
// Edit mode (tests useMetadataState hook integration)
describe('Edit Mode', () => {
it('should enter edit mode when edit button is clicked', () => {
render(<Metadata {...defaultProps} />)
fireEvent.click(screen.getByText(/operation\.edit/i))
expect(screen.getByText(/operation\.cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/operation\.save/i))!.toBeInTheDocument()
})
it('should show change link in edit mode', () => {
render(<Metadata {...defaultProps} />)
fireEvent.click(screen.getByText(/operation\.edit/i))
expect(screen.getByText(/operation\.change/i))!.toBeInTheDocument()
})
it('should cancel edit and restore values when cancel is clicked', () => {
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
fireEvent.click(screen.getByText(/operation\.cancel/i))
// Assert - should be back to view mode
// Assert - should be back to view mode
expect(screen.getByText(/operation\.edit/i))!.toBeInTheDocument()
})
it('should save metadata when save button is clicked', async () => {
mockModifyDocMetadata.mockResolvedValueOnce({})
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
fireEvent.click(screen.getByText(/operation\.save/i))
await waitFor(() => {
expect(mockModifyDocMetadata).toHaveBeenCalled()
})
})
it('should show success notification after successful save', async () => {
mockModifyDocMetadata.mockResolvedValueOnce({})
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
fireEvent.click(screen.getByText(/operation\.save/i))
await waitFor(() => {
expect(toastMocks.record).toHaveBeenCalledWith(
expect.objectContaining({
type: 'success',
}),
)
})
})
it('should show error notification after failed save', async () => {
mockModifyDocMetadata.mockRejectedValueOnce(new Error('Save failed'))
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
fireEvent.click(screen.getByText(/operation\.save/i))
await waitFor(() => {
expect(toastMocks.record).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
}),
)
})
})
})
// Document type selection (tests DocTypeSelector sub-component integration)
describe('Document Type Selection', () => {
it('should show doc type selection when no doc_type exists', () => {
const docDetail = createMockDocDetail({ doc_type: '' })
render(<Metadata {...defaultProps} docDetail={docDetail} />)
expect(screen.getByText(/metadata\.docTypeSelectTitle/i))!.toBeInTheDocument()
})
it('should keep doc type selection read-only when canEdit is false', () => {
const docDetail = createMockDocDetail({ doc_type: '' })
render(<Metadata {...defaultProps} docDetail={docDetail} canEdit={false} />)
expect(screen.queryByText(/metadata\.docTypeSelectTitle/i)).not.toBeInTheDocument()
expect(screen.queryByText(/metadata\.firstMetaAction/i)).not.toBeInTheDocument()
expect(screen.queryByText(/operation\.save/i)).not.toBeInTheDocument()
expect(mockModifyDocMetadata).not.toHaveBeenCalled()
})
it('should show description when no doc_type exists', () => {
const docDetail = createMockDocDetail({ doc_type: '' })
render(<Metadata {...defaultProps} docDetail={docDetail} />)
expect(screen.getByText(/metadata\.desc/i))!.toBeInTheDocument()
})
it('should show change link in edit mode when doc_type exists', () => {
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
expect(screen.getByText(/operation\.change/i))!.toBeInTheDocument()
})
it('should show doc type change title after clicking change', () => {
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
fireEvent.click(screen.getByText(/operation\.change/i))
expect(screen.getByText(/metadata\.docTypeChangeTitle/i))!.toBeInTheDocument()
})
})
// Fixed fields (tests MetadataFieldList sub-component integration)
describe('Fixed Fields', () => {
it('should render origin info fields', () => {
render(<Metadata {...defaultProps} />)
// Assert
// Assert
expect(screen.getByText('Data Source Type'))!.toBeInTheDocument()
})
it('should render technical parameters fields', () => {
render(<Metadata {...defaultProps} />)
expect(screen.getByText('Segment Count'))!.toBeInTheDocument()
expect(screen.getByText('Hit Count'))!.toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle doc_type as others', () => {
const docDetail = createMockDocDetail({ doc_type: 'others' })
const { container } = render(<Metadata {...defaultProps} docDetail={docDetail} />)
// Assert
// Assert
expect(container.firstChild)!.toBeInTheDocument()
})
it('should handle undefined docDetail gracefully', () => {
const { container } = render(<Metadata {...defaultProps} docDetail={undefined} loading={false} />)
// Assert
// Assert
expect(container.firstChild)!.toBeInTheDocument()
})
it('should update document type display when docDetail changes', () => {
const { rerender } = render(<Metadata {...defaultProps} />)
// Act - verify initial state shows Book
// Act - verify initial state shows Book
expect(screen.getByText('Book'))!.toBeInTheDocument()
// Update with new doc type
const updatedDocDetail = createMockDocDetail({ doc_type: 'paper' })
rerender(<Metadata {...defaultProps} docDetail={updatedDocDetail} />)
expect(screen.getByText('Paper'))!.toBeInTheDocument()
})
})
// First meta action button
describe('First Meta Action Button', () => {
it('should show first meta action button when no doc type exists', () => {
const docDetail = createMockDocDetail({ doc_type: '' })
render(<Metadata {...defaultProps} docDetail={docDetail} />)
expect(screen.getByText(/metadata\.firstMetaAction/i))!.toBeInTheDocument()
})
})
})
describe('FieldInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultFieldInfoProps = {
label: 'Test Label',
value: 'Test Value',
displayedValue: 'Test Display Value',
}
// Rendering
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<FieldInfo {...defaultFieldInfoProps} />)
expect(container.firstChild)!.toBeInTheDocument()
})
it('should render label', () => {
render(<FieldInfo {...defaultFieldInfoProps} />)
expect(screen.getByText('Test Label'))!.toBeInTheDocument()
})
it('should render displayed value in view mode', () => {
render(<FieldInfo {...defaultFieldInfoProps} showEdit={false} />)
expect(screen.getByText('Test Display Value'))!.toBeInTheDocument()
})
})
// Edit mode
describe('Edit Mode', () => {
it('should render input when showEdit is true and inputType is input', () => {
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={vi.fn()} />)
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should render select when showEdit is true and inputType is select', () => {
render(
<FieldInfo
{...defaultFieldInfoProps}
showEdit={true}
inputType="select"
selectOptions={[{ value: 'opt1', name: 'Option 1' }]}
onUpdate={vi.fn()}
/>,
)
// Assert - SimpleSelect should be rendered
// Assert - SimpleSelect should be rendered
expect(screen.getByRole('combobox'))!.toBeInTheDocument()
})
it('should render textarea when showEdit is true and inputType is textarea', () => {
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={vi.fn()} />)
expect(screen.getByRole('textbox'))!.toBeInTheDocument()
})
it('should call onUpdate when input value changes', () => {
const mockOnUpdate = vi.fn()
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" onUpdate={mockOnUpdate} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Value' } })
expect(mockOnUpdate).toHaveBeenCalledWith('New Value')
})
it('should call onUpdate when textarea value changes', () => {
const mockOnUpdate = vi.fn()
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="textarea" onUpdate={mockOnUpdate} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Textarea Value' } })
expect(mockOnUpdate).toHaveBeenCalledWith('New Textarea Value')
})
})
// Props
describe('Props', () => {
it('should render value icon when provided', () => {
render(<FieldInfo {...defaultFieldInfoProps} valueIcon={<span data-testid="value-icon">Icon</span>} />)
expect(screen.getByTestId('value-icon'))!.toBeInTheDocument()
})
it('should use defaultValue when provided', () => {
render(<FieldInfo {...defaultFieldInfoProps} showEdit={true} inputType="input" defaultValue="Default" onUpdate={vi.fn()} />)
const input = screen.getByRole('textbox')
expect(input)!.toHaveAttribute('placeholder')
})
})
})
// --- useMetadataState hook coverage tests (via component interactions) ---
describe('useMetadataState coverage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const defaultProps = {
docDetail: createMockDocDetail(),
loading: false,
onUpdate: vi.fn(),
canEdit: true,
}
describe('cancelDocType', () => {
it('should cancel doc type change and return to edit mode', () => {
// Arrange
render(<Metadata {...defaultProps} />)
// Enter edit mode → click change to open doc type selector
fireEvent.click(screen.getByText(/operation\.edit/i))
fireEvent.click(screen.getByText(/operation\.change/i))
// Now in doc type selector mode — should show cancel button
// Now in doc type selector mode — should show cancel button
expect(screen.getByText(/operation\.cancel/i))!.toBeInTheDocument()
// Act — cancel the doc type change
fireEvent.click(screen.getByText(/operation\.cancel/i))
// Assert — should be back to edit mode (cancel + save buttons visible)
// Assert — should be back to edit mode (cancel + save buttons visible)
expect(screen.getByText(/operation\.save/i))!.toBeInTheDocument()
})
})
describe('confirmDocType', () => {
it('should confirm same doc type and return to edit mode keeping metadata', () => {
// Arrange — useEffect syncs tempDocType='book' from docDetail
render(<Metadata {...defaultProps} />)
// Enter edit mode → click change to open doc type selector
fireEvent.click(screen.getByText(/operation\.edit/i))
fireEvent.click(screen.getByText(/operation\.change/i))
// DocTypeSelector shows save/cancel buttons
// DocTypeSelector shows save/cancel buttons
expect(screen.getByText(/metadata\.docTypeChangeTitle/i))!.toBeInTheDocument()
// Act — click save to confirm same doc type (tempDocType='book')
fireEvent.click(screen.getByText(/operation\.save/i))
// Assert — should return to edit mode with metadata fields visible
// Assert — should return to edit mode with metadata fields visible
expect(screen.getByText(/operation\.cancel/i))!.toBeInTheDocument()
expect(screen.getByText(/operation\.save/i))!.toBeInTheDocument()
})
})
describe('cancelEdit when no docType', () => {
it('should show doc type selection when cancel is clicked with doc_type others', () => {
// Arrange — doc with 'others' type normalizes to '' internally.
// The useEffect sees doc_type='others' (truthy) and syncs state,
// so the component initially shows view mode. Enter edit → cancel to trigger cancelEdit.
const docDetail = createMockDocDetail({ doc_type: 'others' })
render(<Metadata {...defaultProps} docDetail={docDetail} />)
// 'others' is normalized to '' → useEffect fires (doc_type truthy) → view mode
// The rendered type uses default 'book' fallback for display
// 'others' is normalized to '' → useEffect fires (doc_type truthy) → view mode
// The rendered type uses default 'book' fallback for display
expect(screen.getByText(/operation\.edit/i))!.toBeInTheDocument()
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
expect(screen.getByText(/operation\.cancel/i))!.toBeInTheDocument()
// Act — cancel edit; internally docType is '' so cancelEdit goes to showDocTypes
fireEvent.click(screen.getByText(/operation\.cancel/i))
// Assert — should show doc type selection since normalized docType was ''
// Assert — should show doc type selection since normalized docType was ''
expect(screen.getByText(/metadata\.docTypeSelectTitle/i))!.toBeInTheDocument()
})
})
describe('updateMetadataField', () => {
it('should update metadata field value via input', () => {
// Arrange
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
// Act — find an input and change its value (Title field)
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBeGreaterThan(0)
fireEvent.change(inputs[0]!, { target: { value: 'Updated Title' } })
// Assert — the input should have the new value
// Assert — the input should have the new value
expect(inputs[0])!.toHaveValue('Updated Title')
})
})
describe('saveMetadata calls modifyDocMetadata with correct body', () => {
it('should pass doc_type and doc_metadata in save request', async () => {
// Arrange
mockModifyDocMetadata.mockResolvedValueOnce({})
render(<Metadata {...defaultProps} />)
// Enter edit mode
fireEvent.click(screen.getByText(/operation\.edit/i))
// Act — save
fireEvent.click(screen.getByText(/operation\.save/i))
// Assert
await waitFor(() => {
expect(mockModifyDocMetadata).toHaveBeenCalledWith(
expect.objectContaining({
datasetId: 'test-dataset-id',
documentId: 'test-document-id',
body: expect.objectContaining({
doc_type: 'book',
}),
}),
)
})
})
})
describe('useEffect sync', () => {
it('should handle doc_metadata being null in effect sync', () => {
// Arrange — first render with null metadata
const { rerender } = render(
<Metadata
{...defaultProps}
docDetail={createMockDocDetail({ doc_metadata: null })}
/>,
)
// Act — rerender with a different doc_type to trigger useEffect sync
rerender(
<Metadata
{...defaultProps}
docDetail={createMockDocDetail({ doc_type: 'paper', doc_metadata: null })}
/>,
)
// Assert — should render without crashing, showing Paper type
// Assert — should render without crashing, showing Paper type
expect(screen.getByText('Paper'))!.toBeInTheDocument()
})
})
})

View File

@ -1,144 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DocTypeSelector, { DocumentTypeDisplay } from '../doc-type-selector'
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: { text: 'Book', iconName: 'book' },
web_page: { text: 'Web Page', iconName: 'web' },
paper: { text: 'Paper', iconName: 'paper' },
social_media_post: { text: 'Social Media Post', iconName: 'social' },
personal_document: { text: 'Personal Document', iconName: 'personal' },
business_document: { text: 'Business Document', iconName: 'business' },
wikipedia_entry: { text: 'Wikipedia', iconName: 'wiki' },
}),
}))
vi.mock('@/models/datasets', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>
return {
...actual,
CUSTOMIZABLE_DOC_TYPES: ['book', 'web_page', 'paper'],
}
})
describe('DocTypeSelector', () => {
const defaultProps = {
docType: '' as '' | 'book',
documentType: undefined as '' | 'book' | undefined,
tempDocType: '' as '' | 'book' | 'web_page',
onTempDocTypeChange: vi.fn(),
onConfirm: vi.fn(),
onCancel: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Verify first-time setup UI (no existing doc type)
describe('First Time Selection', () => {
it('should render description and selection title when no doc type exists', () => {
render(<DocTypeSelector {...defaultProps} docType="" documentType={undefined} />)
expect(screen.getByText(/metadata\.desc/)).toBeInTheDocument()
expect(screen.getByText(/metadata\.docTypeSelectTitle/)).toBeInTheDocument()
})
it('should render icon buttons for each doc type', () => {
render(<DocTypeSelector {...defaultProps} />)
expect(screen.getAllByRole('radio')).toHaveLength(3)
})
it('should render confirm button disabled when tempDocType is empty', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="" />)
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
expect(confirmBtn.closest('button')).toBeDisabled()
})
it('should render confirm button enabled when tempDocType is set', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
const confirmBtn = screen.getByText(/metadata\.firstMetaAction/)
expect(confirmBtn.closest('button')).not.toBeDisabled()
})
it('should call onConfirm when confirm button is clicked', () => {
render(<DocTypeSelector {...defaultProps} tempDocType="book" />)
fireEvent.click(screen.getByText(/metadata\.firstMetaAction/))
expect(defaultProps.onConfirm).toHaveBeenCalled()
})
})
// Verify change-type UI (has existing doc type)
describe('Change Doc Type', () => {
it('should render change title and warning when documentType exists', () => {
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
expect(screen.getByText(/metadata\.docTypeChangeTitle/)).toBeInTheDocument()
expect(screen.getByText(/metadata\.docTypeSelectWarning/)).toBeInTheDocument()
})
it('should render save and cancel buttons when documentType exists', () => {
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
expect(screen.getByText(/operation\.save/)).toBeInTheDocument()
expect(screen.getByText(/operation\.cancel/)).toBeInTheDocument()
})
it('should call onCancel when cancel button is clicked', () => {
render(<DocTypeSelector {...defaultProps} docType="book" documentType="book" />)
fireEvent.click(screen.getByText(/operation\.cancel/))
expect(defaultProps.onCancel).toHaveBeenCalled()
})
})
})
describe('DocumentTypeDisplay', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify read-only display of current doc type
describe('Rendering', () => {
it('should render the doc type text', () => {
render(<DocumentTypeDisplay displayType="book" />)
expect(screen.getByText('Book')).toBeInTheDocument()
})
it('should show change link when showChangeLink is true', () => {
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} />)
expect(screen.getByText(/operation\.change/)).toBeInTheDocument()
})
it('should not show change link when showChangeLink is false', () => {
render(<DocumentTypeDisplay displayType="book" showChangeLink={false} />)
expect(screen.queryByText(/operation\.change/)).not.toBeInTheDocument()
})
it('should call onChangeClick when change link is clicked', () => {
const onClick = vi.fn()
render(<DocumentTypeDisplay displayType="book" showChangeLink={true} onChangeClick={onClick} />)
fireEvent.click(screen.getByRole('button', { name: /operation\.change/ }))
expect(onClick).toHaveBeenCalled()
})
it('should fallback to "book" display when displayType is empty and no change link', () => {
render(<DocumentTypeDisplay displayType="" showChangeLink={false} />)
expect(screen.getByText('Book')).toBeInTheDocument()
})
})
})

View File

@ -1,149 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import MetadataFieldList from '../metadata-field-list'
vi.mock('@/hooks/use-metadata', () => ({
useMetadataMap: () => ({
book: {
text: 'Book',
subFieldsMap: {
title: { label: 'Title', inputType: 'input' },
language: { label: 'Language', inputType: 'select' },
author: { label: 'Author', inputType: 'input' },
},
},
originInfo: {
text: 'Origin Info',
subFieldsMap: {
source: { label: 'Source', inputType: 'input' },
hit_count: { label: 'Hit Count', inputType: 'input', render: (val: number, segCount?: number) => `${val} / ${segCount}` },
},
},
}),
useLanguages: () => ({ en: 'English', zh: 'Chinese' }),
useBookCategories: () => ({ fiction: 'Fiction', nonfiction: 'Non-fiction' }),
usePersonalDocCategories: () => ({}),
useBusinessDocCategories: () => ({}),
}))
describe('MetadataFieldList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify rendering of metadata fields based on mainField
describe('Rendering', () => {
it('should render all fields for the given mainField', () => {
render(
<MetadataFieldList
mainField="book"
metadata={{ title: 'Test Book', language: 'en', author: 'John' }}
/>,
)
expect(screen.getByText('Title'))!.toBeInTheDocument()
expect(screen.getByText('Language'))!.toBeInTheDocument()
expect(screen.getByText('Author'))!.toBeInTheDocument()
})
it('should return null when mainField is empty', () => {
const { container } = render(
<MetadataFieldList mainField="" metadata={{}} />,
)
expect(container.firstChild).toBeNull()
})
it('should display "-" for missing field values', () => {
render(
<MetadataFieldList
mainField="book"
metadata={{}}
/>,
)
// All three fields should show "-"
const dashes = screen.getAllByText('-')
expect(dashes.length).toBeGreaterThanOrEqual(3)
})
it('should resolve select values to their display name', () => {
render(
<MetadataFieldList
mainField="book"
metadata={{ language: 'en' }}
/>,
)
expect(screen.getByText('English'))!.toBeInTheDocument()
})
})
// Verify edit mode passes correct props
describe('Edit Mode', () => {
it('should render fields in edit mode when canEdit is true', () => {
render(
<MetadataFieldList
mainField="book"
canEdit={true}
metadata={{ title: 'Book Title' }}
/>,
)
// In edit mode, FieldInfo renders input elements
const inputs = screen.getAllByRole('textbox')
expect(inputs.length).toBeGreaterThan(0)
})
it('should call onFieldUpdate when a field value changes', () => {
const onUpdate = vi.fn()
render(
<MetadataFieldList
mainField="book"
canEdit={true}
metadata={{ title: '' }}
onFieldUpdate={onUpdate}
/>,
)
// Find the first textbox and type in it
const inputs = screen.getAllByRole('textbox')
fireEvent.change(inputs[0]!, { target: { value: 'New Title' } })
expect(onUpdate).toHaveBeenCalled()
})
})
// Verify fixed field types use docDetail as source
describe('Fixed Field Types', () => {
it('should use docDetail as source data for originInfo type', () => {
const docDetail = { source: 'Web', hit_count: 42, segment_count: 10 }
render(
<MetadataFieldList
mainField="originInfo"
docDetail={docDetail as never}
metadata={{}}
/>,
)
expect(screen.getByText('Source'))!.toBeInTheDocument()
expect(screen.getByText('Web'))!.toBeInTheDocument()
})
it('should render custom render function output for fields with render', () => {
const docDetail = { source: 'API', hit_count: 15, segment_count: 5 }
render(
<MetadataFieldList
mainField="originInfo"
docDetail={docDetail as never}
metadata={{}}
/>,
)
expect(screen.getByText('15 / 5'))!.toBeInTheDocument()
})
})
})

View File

@ -1,166 +0,0 @@
'use client'
import type { FC } from 'react'
import type { DocType } from '@/models/datasets'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { FieldItem, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
import { Radio } from '@langgenius/dify-ui/radio'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { useMetadataMap } from '@/hooks/use-metadata'
import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
import s from '../style.module.css'
const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => {
return <div className={cn(s.commonIcon, s[`${iconName}Icon`], className)} />
}
const IconButton: FC<{ type: DocType, isChecked: boolean }> = ({ type, isChecked = false }) => {
const metadataMap = useMetadataMap()
return (
<Tooltip>
<TooltipTrigger
render={(
<span className={cn(s.iconWrapper, 'group', isChecked ? s.iconCheck : '')}>
<TypeIcon
iconName={metadataMap[type].iconName || ''}
className={`group-hover:bg-primary-600 ${isChecked ? 'bg-primary-600!' : ''}`}
/>
</span>
)}
/>
<TooltipContent>
{metadataMap[type].text}
</TooltipContent>
</Tooltip>
)
}
type DocTypeSelectorProps = {
docType: DocType | ''
documentType?: DocType | ''
tempDocType: DocType | ''
onTempDocTypeChange: (type: DocType | '') => void
onConfirm: () => void
onCancel: () => void
}
const DocTypeSelector: FC<DocTypeSelectorProps> = ({
docType,
documentType,
tempDocType,
onTempDocTypeChange,
onConfirm,
onCancel,
}) => {
const { t } = useTranslation()
const metadataMap = useMetadataMap()
const isFirstTime = !docType && !documentType
const currValue = tempDocType ?? documentType
return (
<>
{isFirstTime && (
<div className={s.desc}>{t('metadata.desc', { ns: 'datasetDocuments' })}</div>
)}
<div className={s.operationWrapper}>
<FieldRoot name="document_type" className="contents">
<FieldsetRoot
render={(
<RadioGroup
value={currValue ?? ''}
onValueChange={onTempDocTypeChange}
className={s.radioGroup}
/>
)}
>
<FieldsetLegend className={s.title}>
{isFirstTime
? t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })
: t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })}
</FieldsetLegend>
{documentType && (
<span className={s.changeTip}>{t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })}</span>
)}
{CUSTOMIZABLE_DOC_TYPES.map(type => (
<FieldItem key={type}>
<FieldLabel
className={cn(
s.radio,
'focus-within:ring-2 focus-within:ring-components-input-border-hover focus-within:ring-offset-1 focus-within:outline-hidden',
currValue === type && 'shadow-none',
)}
>
<Radio
value={type}
aria-label={metadataMap[type].text}
className="sr-only"
/>
<IconButton type={type} isChecked={currValue === type} />
</FieldLabel>
</FieldItem>
))}
</FieldsetRoot>
</FieldRoot>
{isFirstTime && (
<Button variant="primary" onClick={onConfirm} disabled={!tempDocType}>
{t('metadata.firstMetaAction', { ns: 'datasetDocuments' })}
</Button>
)}
{documentType && (
<div className={s.opBtnWrapper}>
<Button onClick={onConfirm} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary">
{t('operation.save', { ns: 'common' })}
</Button>
<Button onClick={onCancel} className={`${s.opBtn} ${s.opCancelBtn}`}>
{t('operation.cancel', { ns: 'common' })}
</Button>
</div>
)}
</div>
</>
)
}
type DocumentTypeDisplayProps = {
displayType: DocType | ''
showChangeLink?: boolean
onChangeClick?: () => void
}
export const DocumentTypeDisplay: FC<DocumentTypeDisplayProps> = ({
displayType,
showChangeLink = false,
onChangeClick,
}) => {
const { t } = useTranslation()
const metadataMap = useMetadataMap()
const effectiveType = displayType || 'book'
return (
<div className={s.documentTypeShow}>
{(displayType || !showChangeLink) && (
<>
<TypeIcon iconName={metadataMap[effectiveType]?.iconName || ''} className={s.iconShow} />
{metadataMap[effectiveType].text}
{showChangeLink && (
<div className="ml-1 inline-flex items-center gap-1">
·
<button
type="button"
className="inline cursor-pointer border-none bg-transparent p-0 text-left hover:text-text-accent"
onClick={onChangeClick}
>
{t('operation.change', { ns: 'common' })}
</button>
</div>
)}
</>
)}
</div>
)
}
export default DocTypeSelector

View File

@ -1,88 +0,0 @@
'use client'
import type { FC } from 'react'
import type { metadataType } from '@/hooks/use-metadata'
import type { FullDocumentDetail } from '@/models/datasets'
import { get } from 'es-toolkit/compat'
import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata'
import FieldInfo from './field-info'
const map2Options = (map: Record<string, string>) => {
return Object.keys(map).map(key => ({ value: key, name: map[key]! }))
}
function useCategoryMapResolver(mainField: metadataType | '') {
const languageMap = useLanguages()
const bookCategoryMap = useBookCategories()
const personalDocCategoryMap = usePersonalDocCategories()
const businessDocCategoryMap = useBusinessDocCategories()
return (field: string): Record<string, string> => {
if (field === 'language')
return languageMap
if (field === 'category' && mainField === 'book')
return bookCategoryMap
if (field === 'document_type') {
if (mainField === 'personal_document')
return personalDocCategoryMap
if (mainField === 'business_document')
return businessDocCategoryMap
}
return {}
}
}
type MetadataFieldListProps = {
mainField: metadataType | ''
canEdit?: boolean
metadata?: Record<string, string>
docDetail?: FullDocumentDetail
onFieldUpdate?: (field: string, value: string) => void
}
const MetadataFieldList: FC<MetadataFieldListProps> = ({
mainField,
canEdit = false,
metadata,
docDetail,
onFieldUpdate,
}) => {
const metadataMap = useMetadataMap()
const getCategoryMap = useCategoryMapResolver(mainField)
if (!mainField)
return null
const fieldMap = metadataMap[mainField]?.subFieldsMap
const isFixedField = ['originInfo', 'technicalParameters'].includes(mainField)
const sourceData = isFixedField ? docDetail : metadata
const getDisplayValue = (field: string) => {
const val = get(sourceData, field, '')
if (!val && val !== 0)
return '-'
if (fieldMap[field]?.inputType === 'select')
return getCategoryMap(field)[val]
if (fieldMap[field]?.render)
return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined)
return val
}
return (
<div className="flex flex-col gap-1">
{Object.keys(fieldMap).map(field => (
<FieldInfo
key={fieldMap[field]?.label}
label={fieldMap[field]?.label!}
displayedValue={getDisplayValue(field)}
value={get(sourceData, field, '')}
inputType={fieldMap[field]?.inputType || 'input'}
showEdit={canEdit}
onUpdate={val => onFieldUpdate?.(field, val)}
selectOptions={map2Options(getCategoryMap(field))}
/>
))}
</div>
)
}
export default MetadataFieldList

View File

@ -1,175 +0,0 @@
import type { ReactNode } from 'react'
import type { FullDocumentDetail } from '@/models/datasets'
import { act, renderHook } from '@testing-library/react'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { useMetadataState } from '../use-metadata-state'
const { mockNotify, mockModifyDocMetadata, mockToast } = vi.hoisted(() => {
const mockNotify = vi.fn()
const mockToast = Object.assign(mockNotify, {
success: vi.fn((message, options) => mockNotify({ type: 'success', message, ...options })),
error: vi.fn((message, options) => mockNotify({ type: 'error', message, ...options })),
warning: vi.fn((message, options) => mockNotify({ type: 'warning', message, ...options })),
info: vi.fn((message, options) => mockNotify({ type: 'info', message, ...options })),
dismiss: vi.fn(),
update: vi.fn(),
promise: vi.fn(),
})
return { mockNotify, mockModifyDocMetadata: vi.fn(), mockToast }
})
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: mockToast,
}))
vi.mock('../../../context', () => ({
useDocumentContext: (selector: (state: { datasetId: string, documentId: string }) => unknown) =>
selector({ datasetId: 'ds-1', documentId: 'doc-1' }),
}))
vi.mock('@/service/datasets', () => ({
modifyDocMetadata: (...args: unknown[]) => mockModifyDocMetadata(...args),
}))
vi.mock('@/hooks/use-metadata', () => ({ useMetadataMap: () => ({}) }))
vi.mock('@/utils', () => ({
asyncRunSafe: async (promise: Promise<unknown>) => {
try {
return [null, await promise]
}
catch (e) { return [e] }
},
}))
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(React.Fragment, { children })
type DocDetail = Parameters<typeof useMetadataState>[0]['docDetail']
const makeDoc = (overrides: Partial<FullDocumentDetail> = {}): DocDetail =>
({ doc_type: 'book', doc_metadata: { title: 'Test Book', author: 'Author' }, ...overrides } as DocDetail)
describe('useMetadataState', () => {
// Verify all metadata editing workflows using a stable docDetail reference
it('should manage the full metadata editing lifecycle', async () => {
mockModifyDocMetadata.mockResolvedValue({ result: 'ok' })
const onUpdate = vi.fn()
// IMPORTANT: Create a stable reference outside the render callback
// to prevent useEffect infinite loops on docDetail?.doc_metadata
const stableDocDetail = makeDoc()
const { result } = renderHook(() =>
useMetadataState({ docDetail: stableDocDetail, onUpdate, canEdit: true }), { wrapper })
// --- Initialization ---
expect(result.current.docType).toBe('book')
expect(result.current.editStatus).toBe(false)
expect(result.current.showDocTypes).toBe(false)
expect(result.current.metadataParams.documentType).toBe('book')
expect(result.current.metadataParams.metadata).toEqual({ title: 'Test Book', author: 'Author' })
// --- Enable editing ---
act(() => {
result.current.enableEdit()
})
expect(result.current.editStatus).toBe(true)
// --- Update individual field ---
act(() => {
result.current.updateMetadataField('title', 'Modified Title')
})
expect(result.current.metadataParams.metadata.title).toBe('Modified Title')
expect(result.current.metadataParams.metadata.author).toBe('Author')
// --- Cancel edit restores original data ---
act(() => {
result.current.cancelEdit()
})
expect(result.current.metadataParams.metadata.title).toBe('Test Book')
expect(result.current.editStatus).toBe(false)
// --- Doc type selection: cancel restores previous ---
act(() => {
result.current.enableEdit()
})
act(() => {
result.current.setShowDocTypes(true)
})
act(() => {
result.current.setTempDocType('web_page')
})
act(() => {
result.current.cancelDocType()
})
expect(result.current.tempDocType).toBe('book')
expect(result.current.showDocTypes).toBe(false)
// --- Confirm different doc type clears metadata ---
act(() => {
result.current.setShowDocTypes(true)
})
act(() => {
result.current.setTempDocType('web_page')
})
act(() => {
result.current.confirmDocType()
})
expect(result.current.metadataParams.documentType).toBe('web_page')
expect(result.current.metadataParams.metadata).toEqual({})
// --- Save succeeds ---
await act(async () => {
await result.current.saveMetadata()
})
expect(mockModifyDocMetadata).toHaveBeenCalledWith({
datasetId: 'ds-1',
documentId: 'doc-1',
body: { doc_type: 'web_page', doc_metadata: {} },
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }))
expect(onUpdate).toHaveBeenCalled()
expect(result.current.editStatus).toBe(false)
expect(result.current.saveLoading).toBe(false)
// --- Save failure notifies error ---
mockNotify.mockClear()
mockModifyDocMetadata.mockRejectedValue(new Error('fail'))
act(() => {
result.current.enableEdit()
})
await act(async () => {
await result.current.saveMetadata()
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
// Verify empty doc type starts in editing mode
it('should initialize in editing mode when no doc type exists', () => {
const stableDocDetail = makeDoc({ doc_type: '' as FullDocumentDetail['doc_type'], doc_metadata: {} as FullDocumentDetail['doc_metadata'] })
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail, canEdit: true }), { wrapper })
expect(result.current.docType).toBe('')
expect(result.current.editStatus).toBe(true)
expect(result.current.showDocTypes).toBe(true)
})
// Verify "others" normalization
it('should normalize "others" doc_type to empty string', () => {
const stableDocDetail = makeDoc({ doc_type: 'others' as FullDocumentDetail['doc_type'] })
const { result } = renderHook(() => useMetadataState({ docDetail: stableDocDetail }), { wrapper })
expect(result.current.docType).toBe('')
})
// Verify undefined docDetail handling
it('should handle undefined docDetail gracefully', () => {
const { result } = renderHook(() => useMetadataState({ docDetail: undefined, canEdit: true }), { wrapper })
expect(result.current.docType).toBe('')
expect(result.current.editStatus).toBe(true)
})
})

View File

@ -1,156 +0,0 @@
'use client'
import type { CommonResponse } from '@/models/common'
import type { DocType, FullDocumentDetail } from '@/models/datasets'
import { toast } from '@langgenius/dify-ui/toast'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { modifyDocMetadata } from '@/service/datasets'
import { asyncRunSafe } from '@/utils'
import { useDocumentContext } from '../../context'
type MetadataState = {
documentType?: DocType | ''
metadata: Record<string, string>
}
/**
* Normalize raw doc_type: treat 'others' as empty string.
*/
const normalizeDocType = (rawDocType: string): DocType | '' => {
return rawDocType === 'others' ? '' : rawDocType as DocType | ''
}
type UseMetadataStateOptions = {
docDetail?: FullDocumentDetail
onUpdate?: () => void
canEdit?: boolean
}
export function useMetadataState({ docDetail, onUpdate, canEdit = false }: UseMetadataStateOptions) {
const { doc_metadata = {} } = docDetail || {}
const rawDocType = docDetail?.doc_type ?? ''
const docType = normalizeDocType(rawDocType)
const shouldSelectDocType = canEdit && !rawDocType
const { t } = useTranslation()
const datasetId = useDocumentContext(s => s.datasetId)
const documentId = useDocumentContext(s => s.documentId)
// If no documentType yet, start in editing + showDocTypes mode
const [editStatus, setEditStatus] = useState(shouldSelectDocType)
const [metadataParams, setMetadataParams] = useState<MetadataState>(rawDocType
? { documentType: docType, metadata: (doc_metadata || {}) as Record<string, string> }
: { metadata: {} })
const [showDocTypes, setShowDocTypes] = useState(shouldSelectDocType)
const [tempDocType, setTempDocType] = useState<DocType | ''>('')
const [saveLoading, setSaveLoading] = useState(false)
// Sync local state when the upstream docDetail changes (e.g. after save or navigation).
// These setters are intentionally called together to batch-reset multiple pieces
// of derived editing state that cannot be expressed as pure derived values.
useEffect(() => {
if (!rawDocType)
return
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setEditStatus(false)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setShowDocTypes(false)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setTempDocType(docType)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setMetadataParams({
documentType: docType,
metadata: (docDetail?.doc_metadata || {}) as Record<string, string>,
})
}, [docDetail?.doc_metadata, docType, rawDocType])
useEffect(() => {
if (rawDocType && canEdit)
return
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setEditStatus(canEdit && !rawDocType)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setShowDocTypes(canEdit && !rawDocType)
}, [canEdit, rawDocType])
const updateShowDocTypes = (show: boolean) => {
if (!canEdit)
return
setShowDocTypes(show)
}
const confirmDocType = () => {
if (!canEdit)
return
if (!tempDocType)
return
setMetadataParams({
documentType: tempDocType,
// Clear metadata when switching to a different doc type
metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {},
})
setEditStatus(true)
setShowDocTypes(false)
}
const cancelDocType = () => {
if (!canEdit)
return
setTempDocType(metadataParams.documentType ?? '')
setEditStatus(true)
setShowDocTypes(false)
}
const enableEdit = () => {
if (!canEdit)
return
setEditStatus(true)
}
const cancelEdit = () => {
if (!canEdit)
return
setMetadataParams({ documentType: docType || '', metadata: { ...docDetail?.doc_metadata } })
setEditStatus(!docType)
if (!docType)
setShowDocTypes(true)
}
const saveMetadata = async () => {
if (!canEdit)
return
setSaveLoading(true)
const [e] = await asyncRunSafe<CommonResponse>(modifyDocMetadata({
datasetId,
documentId,
body: {
doc_type: metadataParams.documentType || docType || '',
doc_metadata: metadataParams.metadata,
},
}) as Promise<CommonResponse>)
if (!e)
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
else
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
onUpdate?.()
setEditStatus(false)
setSaveLoading(false)
}
const updateMetadataField = (field: string, value: string) => {
if (!canEdit)
return
setMetadataParams(prev => ({ ...prev, metadata: { ...prev.metadata, [field]: value } }))
}
return {
docType,
editStatus,
showDocTypes,
tempDocType,
saveLoading,
metadataParams,
setTempDocType,
setShowDocTypes: updateShowDocTypes,
confirmDocType,
cancelDocType,
enableEdit,
cancelEdit,
saveMetadata,
updateMetadataField,
}
}

View File

@ -1,129 +1,3 @@
'use client'
import type { FC } from 'react'
import type { FullDocumentDetail } from '@/models/datasets'
import { PencilIcon } from '@heroicons/react/24/outline'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Loading from '@/app/components/base/loading'
import { useMetadataMap } from '@/hooks/use-metadata'
import DocTypeSelector, { DocumentTypeDisplay } from './components/doc-type-selector'
import MetadataFieldList from './components/metadata-field-list'
import { useMetadataState } from './hooks/use-metadata-state'
import s from './style.module.css'
export { default as FieldInfo } from './components/field-info'
type MetadataProps = {
docDetail?: FullDocumentDetail
loading: boolean
onUpdate: () => void
canEdit?: boolean
}
const Metadata: FC<MetadataProps> = ({ docDetail, loading, onUpdate, canEdit = false }) => {
const { t } = useTranslation()
const metadataMap = useMetadataMap()
const {
docType,
editStatus,
showDocTypes,
tempDocType,
saveLoading,
metadataParams,
setTempDocType,
setShowDocTypes,
confirmDocType,
cancelDocType,
enableEdit,
cancelEdit,
saveMetadata,
updateMetadataField,
} = useMetadataState({ docDetail, onUpdate, canEdit })
if (loading) {
return (
<div className={`${s.main} bg-gray-25`}>
<Loading type="app" />
</div>
)
}
return (
<div className={`${s.main} ${editStatus ? 'bg-white' : 'bg-gray-25'}`}>
{/* Header: title + action buttons */}
<div className={s.titleWrapper}>
<span className={s.title}>{t('metadata.title', { ns: 'datasetDocuments' })}</span>
{!editStatus
? (
canEdit && (
<Button onClick={enableEdit} className={`${s.opBtn} ${s.opEditBtn}`}>
<PencilIcon className={s.opIcon} />
{t('operation.edit', { ns: 'common' })}
</Button>
)
)
: canEdit && !showDocTypes && (
<div className={s.opBtnWrapper}>
<Button onClick={cancelEdit} className={`${s.opBtn} ${s.opCancelBtn}`}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button onClick={saveMetadata} className={`${s.opBtn} ${s.opSaveBtn}`} variant="primary" loading={saveLoading}>
{t('operation.save', { ns: 'common' })}
</Button>
</div>
)}
</div>
{/* Document type display / selector */}
{!editStatus
? <DocumentTypeDisplay displayType={docType} />
: showDocTypes
? null
: (
<DocumentTypeDisplay
displayType={metadataParams.documentType || ''}
showChangeLink={canEdit && editStatus}
onChangeClick={() => setShowDocTypes(true)}
/>
)}
{/* Divider between type display and fields (skip when in first-time selection) */}
{(!docType && showDocTypes) ? null : <Divider />}
{/* Doc type selector or editable metadata fields */}
{canEdit && showDocTypes
? (
<DocTypeSelector
docType={docType}
documentType={metadataParams.documentType}
tempDocType={tempDocType}
onTempDocTypeChange={setTempDocType}
onConfirm={confirmDocType}
onCancel={cancelDocType}
/>
)
: (
<MetadataFieldList
mainField={metadataParams.documentType || ''}
canEdit={canEdit && editStatus}
metadata={metadataParams.metadata}
docDetail={docDetail}
onFieldUpdate={updateMetadataField}
/>
)}
{/* Fixed fields: origin info */}
<Divider />
<MetadataFieldList mainField="originInfo" docDetail={docDetail} />
{/* Fixed fields: technical parameters */}
<div className={`${s.title} mt-8`}>{metadataMap.technicalParameters.text}</div>
<Divider />
<MetadataFieldList mainField="technicalParameters" docDetail={docDetail} />
</div>
)
}
export default Metadata

View File

@ -1,224 +0,0 @@
import type { ReactNode } from 'react'
import type { IWorkspace } from '@/models/common'
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createTestQueryClient } from '@/__tests__/utils/mock-system-features'
import { consoleQuery } from '@/service/client'
import WorkplaceSelector from '../index'
const toastMocks = vi.hoisted(() => ({
mockSwitchWorkspace: vi.fn(),
mockNotify: vi.fn(),
}))
type MockSelectState = {
value: string
onValueChange: (value: string | null) => void
}
const selectMocks = vi.hoisted(() => ({
state: {
value: '',
onValueChange: () => {},
} as MockSelectState,
reset: (): MockSelectState => ({
value: '',
onValueChange: () => {},
}),
}))
vi.mock('@/service/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/service/client')>()
const workspacesQueryKey = ['console', 'workspaces', 'get'] as const
const consoleQuery = new Proxy(actual.consoleQuery, {
get(target, prop, receiver) {
if (prop === 'workspaces') {
return {
get: {
queryKey: () => workspacesQueryKey,
queryOptions: () => ({
queryKey: workspacesQueryKey,
queryFn: () => new Promise(() => {}),
}),
},
switch: {
post: {
mutationOptions: () => ({
mutationFn: (variables: unknown) => toastMocks.mockSwitchWorkspace(variables),
}),
},
},
}
}
return Reflect.get(target, prop, receiver)
},
})
return {
...actual,
consoleQuery,
}
})
vi.mock('@langgenius/dify-ui/toast', () => ({
default: {
notify: (args: unknown) => toastMocks.mockNotify(args),
},
toast: {
success: (message: string) => toastMocks.mockNotify({ type: 'success', message }),
error: (message: string) => toastMocks.mockNotify({ type: 'error', message }),
warning: (message: string) => toastMocks.mockNotify({ type: 'warning', message }),
info: (message: string) => toastMocks.mockNotify({ type: 'info', message }),
},
}))
vi.mock('@langgenius/dify-ui/select', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/select')>()
return {
...actual,
Select: ({
value,
onValueChange,
children,
}: {
value: string
onValueChange: (value: string | null) => void
children: ReactNode
}) => {
selectMocks.state = { value, onValueChange }
return <div data-testid="workplace-selector-root">{children}</div>
},
SelectTrigger: ({ children }: { children: ReactNode }) => (
<button data-testid="workplace-selector-trigger" type="button">
{children}
</button>
),
SelectContent: ({ children }: { children: ReactNode }) => (
<div data-testid="workplace-selector-content">{children}</div>
),
SelectGroup: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectGroupLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({
children,
value,
}: {
children: ReactNode
value: string
}) => (
<button
data-testid={`workspace-option-${value}`}
type="button"
onClick={() => selectMocks.state.onValueChange(value)}
>
{children}
</button>
),
SelectItemText: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}
})
describe('WorkplaceSelector', () => {
const defaultWorkspaces: IWorkspace[] = [
{ id: '1', name: 'Workspace 1', current: true, plan: 'professional', status: 'normal', created_at: Date.now() },
{ id: '2', name: 'Workspace 2', current: false, plan: 'sandbox', status: 'normal', created_at: Date.now() },
]
const { mockNotify, mockSwitchWorkspace } = toastMocks
const mockAssign = vi.fn()
let mockWorkspaces: IWorkspace[] = []
beforeEach(() => {
vi.clearAllMocks()
selectMocks.state = selectMocks.reset()
mockWorkspaces = defaultWorkspaces
vi.stubGlobal('location', { ...window.location, assign: mockAssign })
})
const renderComponent = () => {
const queryClient = createTestQueryClient()
queryClient.setQueryData(consoleQuery.workspaces.get.queryKey(), { workspaces: mockWorkspaces })
return render(
<QueryClientProvider client={queryClient}>
<WorkplaceSelector />
</QueryClientProvider>,
)
}
describe('Rendering', () => {
it('should render current workspace and available workspace options', () => {
renderComponent()
expect(screen.getByTestId('workplace-selector-trigger'))!.toHaveTextContent('Workspace 1')
expect(screen.getByTestId('workspace-option-1'))!.toBeInTheDocument()
expect(screen.getByTestId('workspace-option-2'))!.toBeInTheDocument()
expect(screen.getByTestId('workspace-option-1'))!.toHaveTextContent('Workspace 1')
expect(screen.getByTestId('workspace-option-2'))!.toHaveTextContent('Workspace 2')
})
})
describe('Workspace Switching', () => {
it('should switch workspace successfully', async () => {
mockSwitchWorkspace.mockResolvedValue({
result: 'success',
new_tenant: mockWorkspaces[1]!,
})
renderComponent()
fireEvent.click(screen.getByTestId('workspace-option-2'))
await waitFor(() => expect(mockSwitchWorkspace).toHaveBeenCalledWith({
body: { tenant_id: '2' },
}))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
})
expect(mockAssign).toHaveBeenCalled()
})
})
it('should not switch to the already current workspace', () => {
renderComponent()
fireEvent.click(screen.getByTestId('workspace-option-1'))
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
})
it('should handle switching error correctly', async () => {
mockSwitchWorkspace.mockRejectedValue(new Error('Failed'))
renderComponent()
fireEvent.click(screen.getByTestId('workspace-option-2'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
})
})
})
describe('Edge Cases', () => {
it('should not crash when no workspace has current value', () => {
mockWorkspaces = [
{ id: '1', name: 'Workspace 1', current: false, plan: 'professional', status: 'normal', created_at: Date.now() },
]
expect(() => renderComponent()).not.toThrow()
})
it('should not crash when workspace name is empty string', () => {
mockWorkspaces = [
{ id: '1', name: '', current: true, plan: 'sandbox', status: 'normal', created_at: Date.now() },
]
expect(() => renderComponent()).not.toThrow()
})
})
})

View File

@ -1,21 +1,15 @@
import type { Plan } from '@/app/components/billing/type'
import type { IWorkspace } from '@/models/common'
import {
Select,
SelectContent,
SelectGroup,
SelectGroupLabel,
SelectItem,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { PlanBadge } from '@/app/components/header/plan-badge'
import { consoleQuery } from '@/service/client'
import { basePath } from '@/utils/var'
type WorkplaceSelectorContentProps = {
workspaces: IWorkspace[]
@ -61,52 +55,3 @@ export const WorkplaceSelectorContent = memo(({
)
})
WorkplaceSelectorContent.displayName = 'WorkplaceSelectorContent'
const WorkplaceSelector = () => {
const { t } = useTranslation()
const { data: workspacesData } = useQuery(consoleQuery.workspaces.get.queryOptions())
const switchWorkspaceMutation = useMutation(consoleQuery.workspaces.switch.post.mutationOptions())
const workspaces = workspacesData?.workspaces ?? []
const currentWorkspace = workspaces.find(v => v.current)
const handleSwitchWorkspace = async (tenant_id: string) => {
try {
if (currentWorkspace?.id === tenant_id)
return
await switchWorkspaceMutation.mutateAsync({ body: { tenant_id } })
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
location.assign(`${location.origin}${basePath}`)
}
catch {
toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }))
}
}
return (
<Select
value={currentWorkspace?.id ?? ''}
onValueChange={(value) => {
if (value)
void handleSwitchWorkspace(value)
}}
>
<SelectTrigger
className="w-auto cursor-pointer rounded-[10px] border-0 bg-transparent p-0.5 hover:bg-state-base-hover data-popup-open:bg-state-base-hover"
>
<div className="flex items-center">
<div className="mr-1.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-[13px] max-[800px]:mr-0">
<span className="h-6 bg-linear-to-r from-components-avatar-shape-fill-stop-0 to-components-avatar-shape-fill-stop-100 bg-clip-text align-middle leading-6 font-semibold text-shadow-shadow-1 uppercase opacity-90">
{currentWorkspace?.name[0]?.toLocaleUpperCase()}
</span>
</div>
<div className="max-w-[149px] min-w-0 truncate system-sm-medium text-text-secondary max-[800px]:hidden" title={currentWorkspace?.name}>
{currentWorkspace?.name}
</div>
</div>
</SelectTrigger>
<WorkplaceSelectorContent workspaces={workspaces} />
</Select>
)
}
export default WorkplaceSelector

View File

@ -2,15 +2,8 @@ import {
ACCOUNT_SETTING_MODAL_ACTION,
ACCOUNT_SETTING_TAB,
DEFAULT_ACCOUNT_SETTING_TAB,
isValidAccountSettingTab,
isValidSettingsTab,
} from '../constants'
import {
enableMovedAccountSettingDestinations,
getMovedAccountSettingDestination,
isMovedAccountSettingTab,
movedAccountSettingDestinations,
} from '../destinations'
describe('AccountSetting Constants', () => {
it('should have correct ACCOUNT_SETTING_MODAL_ACTION', () => {
@ -34,25 +27,6 @@ describe('AccountSetting Constants', () => {
expect(DEFAULT_ACCOUNT_SETTING_TAB).toBe(ACCOUNT_SETTING_TAB.MEMBERS)
})
it('isValidAccountSettingTab should return true for valid tabs', () => {
expect(isValidAccountSettingTab('provider')).toBe(true)
expect(isValidAccountSettingTab('members')).toBe(true)
expect(isValidAccountSettingTab('permissions')).toBe(true)
expect(isValidAccountSettingTab('access-rules')).toBe(true)
expect(isValidAccountSettingTab('billing')).toBe(true)
expect(isValidAccountSettingTab('data-source')).toBe(true)
expect(isValidAccountSettingTab('custom-endpoint')).toBe(true)
expect(isValidAccountSettingTab('custom')).toBe(true)
expect(isValidAccountSettingTab('preferences')).toBe(true)
expect(isValidAccountSettingTab('language')).toBe(true)
})
it('isValidAccountSettingTab should return false for invalid tabs', () => {
expect(isValidAccountSettingTab(null)).toBe(false)
expect(isValidAccountSettingTab('')).toBe(false)
expect(isValidAccountSettingTab('invalid')).toBe(false)
})
it('isValidSettingsTab should include integrations tabs', () => {
expect(isValidSettingsTab('permissions')).toBe(true)
expect(isValidSettingsTab('access-rules')).toBe(true)
@ -64,15 +38,4 @@ describe('AccountSetting Constants', () => {
expect(isValidSettingsTab('agent-strategy')).toBe(true)
expect(isValidSettingsTab('invalid')).toBe(false)
})
it('should map migrated setting tabs to integrations sections', () => {
expect(enableMovedAccountSettingDestinations).toBe(true)
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.PROVIDER]).toBe('/integrations/model-provider')
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.DATA_SOURCE]).toBe('/integrations/data-source')
expect(movedAccountSettingDestinations[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]).toBe('/integrations/custom-endpoint')
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.PROVIDER)).toBe('/integrations/model-provider')
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.DATA_SOURCE)).toBe('/integrations/data-source')
expect(getMovedAccountSettingDestination(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION)).toBe('/integrations/custom-endpoint')
expect(isMovedAccountSettingTab(ACCOUNT_SETTING_TAB.BILLING)).toBe(false)
})
})

View File

@ -46,13 +46,6 @@ export const SETTINGS_TAB_VALUES = [
] as const
export type SettingsTab = typeof SETTINGS_TAB_VALUES[number]
export const isValidAccountSettingTab = (tab: string | null): tab is AccountSettingTab => {
if (!tab)
return false
return Object.values(ACCOUNT_SETTING_TAB).includes(tab as AccountSettingTab)
}
export const isValidSettingsTab = (tab: string | null): tab is SettingsTab => {
if (!tab)
return false

View File

@ -1,6 +1,5 @@
import type { AccountSettingTab } from './constants'
import type { IntegrationSection } from '@/app/components/integrations/routes'
import { buildIntegrationPath } from '@/app/components/integrations/routes'
import { ACCOUNT_SETTING_TAB } from './constants'
export const integrationSectionByMovedAccountSettingTab = {
@ -9,23 +8,4 @@ export const integrationSectionByMovedAccountSettingTab = {
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: 'custom-endpoint',
} as const satisfies Partial<Record<AccountSettingTab, IntegrationSection>>
export const movedAccountSettingDestinations = {
[ACCOUNT_SETTING_TAB.PROVIDER]: buildIntegrationPath('provider'),
[ACCOUNT_SETTING_TAB.DATA_SOURCE]: buildIntegrationPath('data-source'),
[ACCOUNT_SETTING_TAB.API_BASED_EXTENSION]: buildIntegrationPath('custom-endpoint'),
} as const satisfies Partial<Record<AccountSettingTab, string>>
export type MovedAccountSettingTab = keyof typeof movedAccountSettingDestinations
export const enableMovedAccountSettingDestinations = true
export const isMovedAccountSettingTab = (tab: AccountSettingTab): tab is MovedAccountSettingTab => {
return tab in movedAccountSettingDestinations
}
export const getMovedAccountSettingDestination = (tab: MovedAccountSettingTab) => {
if (!enableMovedAccountSettingDestinations)
return undefined
return movedAccountSettingDestinations[tab]
}
export type MovedAccountSettingTab = keyof typeof integrationSectionByMovedAccountSettingTab

View File

@ -1,16 +1,4 @@
import {
RiErrorWarningFill,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { CheckCircle } from '@/app/components/base/icons/src/vender/solid/general'
export const ValidatedErrorIcon = () => {
return <RiErrorWarningFill className="h-4 w-4 text-[#D92D20]" />
}
export const ValidatedSuccessIcon = () => {
return <CheckCircle className="h-4 w-4 text-[#039855]" />
}
export const ValidatingTip = () => {
const { t } = useTranslation()
@ -20,14 +8,3 @@ export const ValidatingTip = () => {
</div>
)
}
export const ValidatedErrorMessage = ({ errorMessage }: { errorMessage: string }) => {
const { t } = useTranslation()
return (
<div className="mt-2 text-xs font-normal text-[#D92D20]">
{t('provider.validatedError', { ns: 'common' })}
{errorMessage}
</div>
)
}

View File

@ -1,8 +1,5 @@
import { render, screen } from '@testing-library/react'
import {
ValidatedErrorIcon,
ValidatedErrorMessage,
ValidatedSuccessIcon,
ValidatingTip,
} from '../ValidateStatus'
@ -16,20 +13,4 @@ describe('ValidateStatus', () => {
expect(screen.getByText('common.provider.validating')).toBeInTheDocument()
})
it('should show translated error text with the backend message', () => {
render(<ValidatedErrorMessage errorMessage="invalid-token" />)
expect(screen.getByText('common.provider.validatedErrorinvalid-token')).toBeInTheDocument()
})
it('should render decorative icon for success and error states', () => {
const { container, rerender } = render(<ValidatedSuccessIcon />)
expect(container.firstElementChild).toBeTruthy()
rerender(<ValidatedErrorIcon />)
expect(container.firstElementChild).toBeTruthy()
})
})

View File

@ -1,12 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ValidatedStatus } from '../declarations'
describe('declarations', () => {
describe('ValidatedStatus', () => {
it('should expose expected status values', () => {
expect(ValidatedStatus.Success).toBe('success')
expect(ValidatedStatus.Error).toBe('error')
expect(ValidatedStatus.Exceed).toBe('exceed')
})
})
})

View File

@ -1,7 +0,0 @@
export const ValidatedStatus = {
Success: 'success',
Error: 'error',
Exceed: 'exceed',
} as const
export type ValidatedStatus = typeof ValidatedStatus[keyof typeof ValidatedStatus]

View File

@ -7,10 +7,10 @@ import type {
Model,
ModelProvider,
} from '../declarations'
import { act, renderHook, waitFor } from '@testing-library/react'
import { act, renderHook } from '@testing-library/react'
import { useLocale } from '@/context/i18n'
import { consoleQuery } from '@/service/client'
import { fetchDefaultModal, fetchModelList, fetchModelProviderCredentials } from '@/service/common'
import { fetchDefaultModal, fetchModelList } from '@/service/common'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@ -21,7 +21,6 @@ import {
PreferredProviderTypeEnum,
} from '../declarations'
import {
useAnthropicBuyQuota,
useCurrentProviderAndModel,
useDefaultModel,
useInvalidateDefaultModel,
@ -31,7 +30,6 @@ import {
useModelListAndDefaultModel,
useModelListAndDefaultModelAndCurrentProviderAndModel,
useModelModalHandler,
useProviderCredentialsAndLoadBalancing,
useRefreshModel,
useSystemDefaultModelAndModelList,
useTextGenerationCurrentProviderAndModelAndModelList,
@ -50,8 +48,6 @@ vi.mock('@tanstack/react-query', () => ({
vi.mock('@/service/common', () => ({
fetchDefaultModal: vi.fn(),
fetchModelList: vi.fn(),
fetchModelProviderCredentials: vi.fn(),
getPayUrl: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
@ -97,7 +93,6 @@ vi.mock('../atoms', () => ({
}))
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 { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = await import('@/app/components/plugins/marketplace/hooks')
@ -246,241 +241,6 @@ describe('hooks', () => {
})
})
describe('useProviderCredentialsAndLoadBalancing', () => {
const mockCredentials = { api_key: 'test-key', enabled: true }
const mockLoadBalancing = { enabled: true, configs: [] }
beforeEach(() => {
; (useQueryClient as Mock).mockReturnValue({
invalidateQueries: vi.fn(),
})
})
it('should fetch predefined credentials when configured', async () => {
(useQuery as Mock).mockReturnValue({
data: { credentials: mockCredentials, load_balancing: mockLoadBalancing },
isPending: false,
})
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
true,
undefined,
'cred-id',
))
expect(result.current.credentials).toEqual(mockCredentials)
expect(result.current.loadBalancing).toEqual(mockLoadBalancing)
expect(result.current.isLoading).toBe(false)
// Coverage for queryFn
const queryCall = (useQuery as Mock).mock.calls.find(call => call[0].queryKey[1] === 'credentials')
if (queryCall) {
await queryCall[0].queryFn()
expect(fetchModelProviderCredentials).toHaveBeenCalled()
}
})
it('should not fetch predefined credentials when not configured', () => {
(useQuery as Mock).mockReturnValue({
data: undefined,
isPending: false,
})
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
false,
undefined,
'cred-id',
))
expect(result.current.credentials).toBeUndefined()
})
it('should fetch custom credentials with model fields', async () => {
(useQuery as Mock).mockReturnValue({
data: { credentials: mockCredentials, load_balancing: mockLoadBalancing },
isPending: false,
})
const customFields = { __model_name: 'gpt-4', __model_type: ModelTypeEnum.textGeneration }
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.customizableModel,
true,
customFields,
'cred-id',
))
expect(result.current.credentials).toEqual({
...mockCredentials,
...customFields,
})
// Coverage for queryFn
const queryCall = (useQuery as Mock).mock.calls.find(call => call[0].queryKey[1] === 'models')
if (queryCall) {
await queryCall[0].queryFn()
expect(fetchModelProviderCredentials).toHaveBeenCalled()
}
})
it('should return undefined credentials when custom data is not available', () => {
(useQuery as Mock).mockReturnValue({
data: { load_balancing: mockLoadBalancing },
isPending: false,
})
const customFields = { __model_name: 'gpt-4', __model_type: ModelTypeEnum.textGeneration }
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.customizableModel,
true,
customFields,
'cred-id',
))
expect(result.current.credentials).toBeUndefined()
})
it('should handle loading state', () => {
(useQuery as Mock).mockReturnValue({
data: undefined,
isPending: true,
})
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
true,
undefined,
'cred-id',
))
expect(result.current.isLoading).toBe(true)
})
it('should call mutate and invalidate queries for predefined model', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useQuery as Mock).mockReturnValue({
data: { credentials: mockCredentials },
isPending: false,
})
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
true,
undefined,
'cred-id',
))
act(() => {
result.current.mutate()
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-providers', 'credentials', 'openai', 'cred-id'],
})
})
it('should call mutate and invalidate queries for custom model', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useQuery as Mock).mockReturnValue({
data: { credentials: mockCredentials },
isPending: false,
})
const customFields = { __model_name: 'gpt-4', __model_type: ModelTypeEnum.textGeneration }
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.customizableModel,
true,
customFields,
'cred-id',
))
act(() => {
result.current.mutate()
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-providers', 'models', 'credentials', 'openai', ModelTypeEnum.textGeneration, 'gpt-4', 'cred-id'],
})
})
it('should return undefined credentials when credentialId is not provided', () => {
// When credentialId is absent, predefinedEnabled=false so query is disabled and returns no data
; (useQuery as Mock).mockReturnValue({
data: undefined,
isPending: false,
})
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
true,
undefined,
undefined,
))
expect(result.current.credentials).toBeUndefined()
})
it('should not call invalidateQueries when neither predefined nor custom is enabled', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useQuery as Mock).mockReturnValue({
data: undefined,
isPending: false,
})
// Both predefinedEnabled and customEnabled are false (no credentialId)
const { result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
false,
undefined,
undefined,
))
act(() => {
result.current.mutate()
})
expect(invalidateQueries).not.toHaveBeenCalled()
})
it('should build URL without credentialId when not provided in predefined queryFn', async () => {
// Trigger the queryFn when credentialId is undefined but predefinedEnabled is true
; (useQuery as Mock).mockReturnValue({
data: { credentials: { api_key: 'k' } },
isPending: false,
})
const { result: _result } = renderHook(() => useProviderCredentialsAndLoadBalancing(
'openai',
ConfigurationMethodEnum.predefinedModel,
true,
undefined,
undefined,
))
// Find and invoke the predefined queryFn
const queryCall = (useQuery as Mock).mock.calls.find(
call => call[0].queryKey?.[1] === 'credentials',
)
if (queryCall) {
await queryCall[0].queryFn()
expect(fetchModelProviderCredentials).toHaveBeenCalled()
}
})
})
describe('useModelList', () => {
const mockModelData = [
{ provider: 'openai', models: [{ model: 'gpt-4' }] },
@ -942,93 +702,6 @@ describe('hooks', () => {
})
})
describe('useAnthropicBuyQuota', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
value: { href: '' },
writable: true,
configurable: true,
})
})
it('should fetch payment URL and redirect', async () => {
const mockUrl = 'https://payment.anthropic.com/checkout'
; (getPayUrl as Mock).mockResolvedValue({ url: mockUrl })
const { result } = renderHook(() => useAnthropicBuyQuota())
await act(async () => {
await result.current()
})
expect(getPayUrl).toHaveBeenCalledWith('/workspaces/current/model-providers/anthropic/checkout-url')
await waitFor(() => {
expect(window.location.href).toBe(mockUrl)
})
})
it('should prevent concurrent calls while loading', async () => {
// The loading guard in useAnthropicBuyQuota relies on React re-render to expose `loading=true`.
// A slow first call keeps loading=true after the first render; a second call from the
// re-rendered hook captures loading=true and returns early.
let resolveFirst: (value: { url: string }) => void
const firstCallPromise = new Promise<{ url: string }>((resolve) => {
resolveFirst = resolve
})
; (getPayUrl as Mock)
.mockReturnValueOnce(firstCallPromise)
.mockResolvedValue({ url: 'https://example.com' })
const { result } = renderHook(() => useAnthropicBuyQuota())
// Start the first call this sets loading=true
let firstCall: Promise<void>
act(() => {
firstCall = result.current()
})
// Wait for re-render where loading=true
// Then call again while loading is true to hit the guard (line 230)
act(() => {
result.current()
})
// Resolve the first promise
await act(async () => {
resolveFirst!({ url: 'https://example.com' })
await firstCall!
})
// Should only be called once due to loading guard
expect(getPayUrl).toHaveBeenCalledTimes(1)
})
it('should handle errors gracefully and reset loading state', async () => {
; (getPayUrl as Mock).mockRejectedValue(new Error('Network error'))
const { result } = renderHook(() => useAnthropicBuyQuota())
// The hook does not catch the error, so it re-throws; wrap it to avoid unhandled rejection
await act(async () => {
try {
await result.current()
}
catch {
// expected rejection
}
})
expect(getPayUrl).toHaveBeenCalledWith('/workspaces/current/model-providers/anthropic/checkout-url')
// After error, loading state is reset via finally block — a second call should proceed
; (getPayUrl as Mock).mockResolvedValue({ url: 'https://example.com' })
await act(async () => {
await result.current()
})
expect(getPayUrl).toHaveBeenCalledTimes(2)
})
})
describe('useUpdateModelProviders', () => {
it('should invalidate model providers queries', () => {
const invalidateQueries = vi.fn()

View File

@ -1,14 +1,5 @@
import type { Mock } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
deleteModelProvider,
setModelProvider,
validateModelLoadBalancingCredentials,
validateModelProvider,
} from '@/service/common'
import { ValidatedStatus } from '../../key-validator/declarations'
import {
ConfigurationMethodEnum,
FormTypeEnum,
ModelTypeEnum,
} from '../declarations'
@ -17,22 +8,9 @@ import {
genModelTypeFormSchema,
modelTypeFormat,
providerToPluginId,
removeCredentials,
saveCredentials,
savePredefinedLoadBalancingConfig,
sizeFormat,
validateCredentials,
validateLoadBalancingCredentials,
} from '../utils'
// Mock service/common functions
vi.mock('@/service/common', () => ({
deleteModelProvider: vi.fn(),
setModelProvider: vi.fn(),
validateModelLoadBalancingCredentials: vi.fn(),
validateModelProvider: vi.fn(),
}))
describe('utils', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -68,210 +46,6 @@ describe('utils', () => {
})
})
describe('validateCredentials', () => {
it('should validate predefined credentials successfully', async () => {
(validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'success' })
const result = await validateCredentials(true, 'provider', { key: 'value' })
expect(result).toEqual({ status: ValidatedStatus.Success })
expect(validateModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/credentials/validate',
body: { credentials: { key: 'value' } },
})
})
it('should validate custom credentials successfully', async () => {
(validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'success' })
const result = await validateCredentials(false, 'provider', {
__model_name: 'model',
__model_type: 'type',
key: 'value',
})
expect(result).toEqual({ status: ValidatedStatus.Success })
expect(validateModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/models/credentials/validate',
body: {
model: 'model',
model_type: 'type',
credentials: { key: 'value' },
},
})
})
it('should handle validation failure', async () => {
(validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'error', error: 'failed' })
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' })
})
it('should handle exception', async () => {
(validateModelProvider as unknown as Mock).mockRejectedValue(new Error('network error'))
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'network error' })
})
it('should return Unknown error when non-Error is thrown', async () => {
(validateModelProvider as unknown as Mock).mockRejectedValue('string error')
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Unknown error' })
})
it('should return default error message when error field is empty', async () => {
(validateModelProvider as unknown as Mock).mockResolvedValue({ result: 'error', error: '' })
const result = await validateCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'error' })
})
})
describe('validateLoadBalancingCredentials', () => {
it('should validate load balancing credentials successfully', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'success' })
const result = await validateLoadBalancingCredentials(true, 'provider', {
__model_name: 'model',
__model_type: 'type',
key: 'value',
})
expect(result).toEqual({ status: ValidatedStatus.Success })
expect(validateModelLoadBalancingCredentials).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/models/load-balancing-configs/credentials-validate',
body: {
model: 'model',
model_type: 'type',
credentials: { key: 'value' },
},
})
})
it('should validate load balancing credentials successfully with id', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'success' })
const result = await validateLoadBalancingCredentials(true, 'provider', {
__model_name: 'model',
__model_type: 'type',
key: 'value',
}, 'id')
expect(result).toEqual({ status: ValidatedStatus.Success })
expect(validateModelLoadBalancingCredentials).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/models/load-balancing-configs/id/credentials-validate',
body: {
model: 'model',
model_type: 'type',
credentials: { key: 'value' },
},
})
})
it('should handle validation failure', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'error', error: 'failed' })
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'failed' })
})
it('should return Unknown error when non-Error is thrown', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockRejectedValue(42)
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Unknown error' })
})
it('should handle exception with Error', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockRejectedValue(new Error('Timeout'))
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'Timeout' })
})
it('should return default error message when error field is empty', async () => {
(validateModelLoadBalancingCredentials as unknown as Mock).mockResolvedValue({ result: 'error', error: '' })
const result = await validateLoadBalancingCredentials(true, 'provider', {})
expect(result).toEqual({ status: ValidatedStatus.Error, message: 'error' })
})
})
describe('saveCredentials', () => {
it('should save predefined credentials', async () => {
await saveCredentials(true, 'provider', { __authorization_name__: 'name', key: 'value' })
expect(setModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/credentials',
body: {
config_from: ConfigurationMethodEnum.predefinedModel,
credentials: { key: 'value' },
load_balancing: undefined,
name: 'name',
},
})
})
it('should save custom credentials', async () => {
await saveCredentials(false, 'provider', {
__model_name: 'model',
__model_type: 'type',
key: 'value',
})
expect(setModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/models',
body: {
model: 'model',
model_type: 'type',
credentials: { key: 'value' },
load_balancing: undefined,
},
})
})
})
describe('savePredefinedLoadBalancingConfig', () => {
it('should save predefined load balancing config', async () => {
await savePredefinedLoadBalancingConfig('provider', {
__model_name: 'model',
__model_type: 'type',
key: 'value',
})
expect(setModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/models',
body: {
config_from: ConfigurationMethodEnum.predefinedModel,
model: 'model',
model_type: 'type',
credentials: { key: 'value' },
load_balancing: undefined,
},
})
})
})
describe('removeCredentials', () => {
it('should remove predefined credentials', async () => {
await removeCredentials(true, 'provider', {}, 'id')
expect(deleteModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/credentials',
body: { credential_id: 'id' },
})
})
it('should remove custom credentials', async () => {
await removeCredentials(false, 'provider', {
__model_name: 'model',
__model_type: 'type',
})
expect(deleteModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/models',
body: {
model: 'model',
model_type: 'type',
},
})
})
it('should remove predefined credentials without credentialId', async () => {
await removeCredentials(true, 'provider', {})
expect(deleteModelProvider).toHaveBeenCalledWith({
url: '/workspaces/current/model-providers/provider/credentials',
body: undefined,
})
})
it('should not call delete endpoint when non-predefined payload is falsy', async () => {
await removeCredentials(false, 'provider', null as unknown as Record<string, unknown>)
expect(deleteModelProvider).not.toHaveBeenCalled()
})
})
describe('genModelTypeFormSchema', () => {
it('should generate form schema', () => {
const schema = genModelTypeFormSchema([ModelTypeEnum.textGeneration])

View File

@ -1,4 +1,5 @@
import type {
ConfigurationMethodEnum,
Credential,
CustomConfigurationModelFixedFields,
CustomModel,
@ -7,6 +8,7 @@ import type {
Model,
ModelModalModeEnum,
ModelProvider,
ModelTypeEnum,
} from './declarations'
import { useQuery, useQueryClient } from '@tanstack/react-query'
@ -28,13 +30,10 @@ import { consoleQuery } from '@/service/client'
import {
fetchDefaultModal,
fetchModelList,
fetchModelProviderCredentials,
getPayUrl,
} from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
import { useExpandModelProviderList } from './atoms'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
} from './declarations'
@ -78,69 +77,6 @@ export const useLanguage = () => {
const locale = useLocale()
return locale.replace('-', '_')
}
export const useProviderCredentialsAndLoadBalancing = (
provider: string,
configurationMethod: ConfigurationMethodEnum,
configured?: boolean,
currentCustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
credentialId?: string,
) => {
const queryClient = useQueryClient()
const predefinedEnabled = configurationMethod === ConfigurationMethodEnum.predefinedModel && configured && !!credentialId
const customEnabled = configurationMethod === ConfigurationMethodEnum.customizableModel && !!currentCustomConfigurationModelFixedFields && !!credentialId
const { data: predefinedFormSchemasValue, isPending: isPredefinedLoading } = useQuery(
{
queryKey: ['model-providers', 'credentials', provider, credentialId],
queryFn: () => fetchModelProviderCredentials(`/workspaces/current/model-providers/${provider}/credentials${credentialId ? `?credential_id=${credentialId}` : ''}`),
enabled: predefinedEnabled,
},
)
const { data: customFormSchemasValue, isPending: isCustomizedLoading } = useQuery(
{
queryKey: ['model-providers', 'models', 'credentials', provider, currentCustomConfigurationModelFixedFields?.__model_type, currentCustomConfigurationModelFixedFields?.__model_name, credentialId],
queryFn: () => fetchModelProviderCredentials(`/workspaces/current/model-providers/${provider}/models/credentials?model=${currentCustomConfigurationModelFixedFields?.__model_name}&model_type=${currentCustomConfigurationModelFixedFields?.__model_type}${credentialId ? `&credential_id=${credentialId}` : ''}`),
enabled: customEnabled,
},
)
const credentials = useMemo(() => {
return configurationMethod === ConfigurationMethodEnum.predefinedModel
? predefinedFormSchemasValue?.credentials
: customFormSchemasValue?.credentials
? {
...customFormSchemasValue?.credentials,
...currentCustomConfigurationModelFixedFields,
}
: undefined
}, [
configurationMethod,
credentialId,
currentCustomConfigurationModelFixedFields,
customFormSchemasValue?.credentials,
predefinedFormSchemasValue?.credentials,
])
const mutate = useCallback(() => {
if (predefinedEnabled)
queryClient.invalidateQueries({ queryKey: ['model-providers', 'credentials', provider, credentialId] })
if (customEnabled)
queryClient.invalidateQueries({ queryKey: ['model-providers', 'models', 'credentials', provider, currentCustomConfigurationModelFixedFields?.__model_type, currentCustomConfigurationModelFixedFields?.__model_name, credentialId] })
}, [customEnabled, credentialId, currentCustomConfigurationModelFixedFields?.__model_name, currentCustomConfigurationModelFixedFields?.__model_type, predefinedEnabled, provider, queryClient])
return {
credentials,
loadBalancing: (configurationMethod === ConfigurationMethodEnum.predefinedModel
? predefinedFormSchemasValue
: customFormSchemasValue
)?.load_balancing,
mutate,
isLoading: isPredefinedLoading || isCustomizedLoading,
}
// as ([Record<string, string | boolean | undefined> | undefined, ModelLoadBalancingConfig | undefined])
}
export const useModelList = (type: ModelTypeEnum) => {
const { data, refetch, isPending } = useQuery({
queryKey: commonQueryKeys.modelList(type),
@ -235,28 +171,6 @@ export const useInvalidateDefaultModel = () => {
queryClient.invalidateQueries({ queryKey: commonQueryKeys.defaultModel(type) })
}, [queryClient])
}
export const useAnthropicBuyQuota = () => {
const [loading, setLoading] = useState(false)
const handleGetPayUrl = async () => {
if (loading)
return
setLoading(true)
try {
const res = await getPayUrl('/workspaces/current/model-providers/anthropic/checkout-url')
window.location.href = res.url
}
finally {
setLoading(false)
}
}
return handleGetPayUrl
}
export const useUpdateModelProviders = () => {
const queryClient = useQueryClient()

View File

@ -2,20 +2,11 @@ import type { ComponentType } from 'react'
import type {
CredentialFormSchemaSelect,
CredentialFormSchemaTextInput,
FormValue,
ModelLoadBalancingConfig,
} from './declarations'
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
import {
deleteModelProvider,
setModelProvider,
validateModelLoadBalancingCredentials,
validateModelProvider,
} from '@/service/common'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ValidatedStatus } from '../key-validator/declarations'
import {
ConfigurationMethodEnum,
FormTypeEnum,
MODEL_TYPE_TEXT,
ModelTypeEnum,
@ -61,130 +52,6 @@ export const isNullOrUndefined = (value: unknown): value is null | undefined =>
return value === undefined || value === null
}
export const validateCredentials = async (predefined: boolean, provider: string, v: FormValue) => {
let body, url
if (predefined) {
body = {
credentials: v,
}
url = `/workspaces/current/model-providers/${provider}/credentials/validate`
}
else {
const { __model_name, __model_type, ...credentials } = v
body = {
model: __model_name,
model_type: __model_type,
credentials,
}
url = `/workspaces/current/model-providers/${provider}/models/credentials/validate`
}
try {
const res = await validateModelProvider({ url, body })
if (res.result === 'success')
return Promise.resolve({ status: ValidatedStatus.Success })
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unknown error'
return Promise.resolve({ status: ValidatedStatus.Error, message })
}
}
export const validateLoadBalancingCredentials = async (predefined: boolean, provider: string, v: FormValue, id?: string): Promise<{
status: ValidatedStatus
message?: string
}> => {
const { __model_name, __model_type, ...credentials } = v
try {
const res = await validateModelLoadBalancingCredentials({
url: `/workspaces/current/model-providers/${provider}/models/load-balancing-configs/${id ? `${id}/` : ''}credentials-validate`,
body: {
model: __model_name,
model_type: __model_type,
credentials,
},
})
if (res.result === 'success')
return Promise.resolve({ status: ValidatedStatus.Success })
else
return Promise.resolve({ status: ValidatedStatus.Error, message: res.error || 'error' })
}
catch (e: unknown) {
const message = e instanceof Error ? e.message : 'Unknown error'
return Promise.resolve({ status: ValidatedStatus.Error, message })
}
}
export const saveCredentials = async (predefined: boolean, provider: string, v: FormValue, loadBalancing?: ModelLoadBalancingConfig) => {
let body, url
if (predefined) {
const { __authorization_name__, ...rest } = v
body = {
config_from: ConfigurationMethodEnum.predefinedModel,
credentials: rest,
load_balancing: loadBalancing,
name: __authorization_name__,
}
url = `/workspaces/current/model-providers/${provider}/credentials`
}
else {
const { __model_name, __model_type, ...credentials } = v
body = {
model: __model_name,
model_type: __model_type,
credentials,
load_balancing: loadBalancing,
}
url = `/workspaces/current/model-providers/${provider}/models`
}
return setModelProvider({ url, body })
}
export const savePredefinedLoadBalancingConfig = async (provider: string, v: FormValue, loadBalancing?: ModelLoadBalancingConfig) => {
const { __model_name, __model_type, ...credentials } = v
const body = {
config_from: ConfigurationMethodEnum.predefinedModel,
model: __model_name,
model_type: __model_type,
credentials,
load_balancing: loadBalancing,
}
const url = `/workspaces/current/model-providers/${provider}/models`
return setModelProvider({ url, body })
}
export const removeCredentials = async (predefined: boolean, provider: string, v: FormValue, credentialId?: string) => {
let url = ''
let body
if (predefined) {
url = `/workspaces/current/model-providers/${provider}/credentials`
if (credentialId) {
body = {
credential_id: credentialId,
}
}
}
else {
if (!v)
return
const { __model_name, __model_type } = v
body = {
model: __model_name,
model_type: __model_type,
}
url = `/workspaces/current/model-providers/${provider}/models`
}
return deleteModelProvider({ url, body })
}
export const sizeFormat = (size: number) => {
const remainder = Math.floor(size / 1000)
if (remainder < 1)

View File

@ -1,11 +1,9 @@
import {
buildIntegrationPath,
buildMarketplacePathByIntegrationSection,
buildMarketplaceUrlPathByIntegrationSection,
getIntegrationRedirectPathByLegacyToolsSearchParams,
getIntegrationRouteTargetBySlug,
integrationPathBySection,
marketplaceCategoryByIntegrationSection,
marketplaceUrlPathByIntegrationSection,
} from '../routes'
@ -26,24 +24,6 @@ describe('integration routes', () => {
expect(buildIntegrationPath('custom-tool')).toBe('/integrations/tools/api')
})
it('maps integration sections to marketplace category paths', () => {
expect(marketplaceCategoryByIntegrationSection).toEqual({
'provider': 'model',
'builtin': 'tool',
'mcp': 'tool',
'custom-tool': 'tool',
'workflow-tool': 'tool',
'data-source': 'datasource',
'custom-endpoint': 'extension',
'trigger': 'trigger',
'agent-strategy': 'agent-strategy',
'extension': 'extension',
})
expect(buildMarketplacePathByIntegrationSection('provider')).toBe('/marketplace?category=model')
expect(buildMarketplacePathByIntegrationSection('custom-tool')).toBe('/marketplace?category=tool')
expect(buildMarketplacePathByIntegrationSection('extension')).toBe('/marketplace?category=extension')
})
it('maps integration sections to marketplace platform paths', () => {
expect(marketplaceUrlPathByIntegrationSection).toEqual({
'provider': '/plugins/model',

View File

@ -70,19 +70,6 @@ export const sectionByToolCategory: Record<ToolCategory, IntegrationSection> = {
mcp: 'mcp',
}
export const marketplaceCategoryByIntegrationSection: Partial<Record<IntegrationSection, string>> = {
'provider': 'model',
'builtin': 'tool',
'mcp': 'tool',
'custom-tool': 'tool',
'workflow-tool': 'tool',
'data-source': 'datasource',
'custom-endpoint': 'extension',
'trigger': 'trigger',
'agent-strategy': 'agent-strategy',
'extension': 'extension',
}
export const marketplaceUrlPathByIntegrationSection: Partial<Record<IntegrationSection, string>> = {
'provider': '/plugins/model',
'builtin': '/plugins/tool',
@ -112,17 +99,6 @@ export const integrationPathBySection: Record<IntegrationSection, string> = {
export const buildIntegrationPath = (section: IntegrationSection) => {
return integrationPathBySection[section]
}
export const buildMarketplacePathByIntegrationSection = (section: IntegrationSection) => {
const category = marketplaceCategoryByIntegrationSection[section]
if (!category)
return '/marketplace'
const params = new URLSearchParams({ category })
return `/marketplace?${params.toString()}`
}
export const buildMarketplaceUrlPathByIntegrationSection = (section: IntegrationSection) => {
return marketplaceUrlPathByIntegrationSection[section] ?? '/plugins'
}

View File

@ -1,9 +1,7 @@
import type { TagKey } from '../constants'
import type { Plugin } from '../types'
import { describe, expect, it } from 'vitest'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import { PluginCategoryEnum } from '../types'
import { getPluginCardIconUrl, getValidCategoryKeys, getValidTagKeys } from '../utils'
import { getPluginCardIconUrl } from '../utils'
const createPlugin = (overrides: Partial<Pick<Plugin, 'from' | 'name' | 'org' | 'type'>> = {}): Pick<Plugin, 'from' | 'name' | 'org' | 'type'> => ({
from: 'github',
@ -14,50 +12,6 @@ const createPlugin = (overrides: Partial<Pick<Plugin, 'from' | 'name' | 'org' |
})
describe('plugins/utils', () => {
describe('getValidTagKeys', () => {
it('returns only valid tag keys from the predefined set', () => {
const input = ['agent', 'rag', 'invalid-tag', 'search'] as TagKey[]
const result = getValidTagKeys(input)
expect(result).toEqual(['agent', 'rag', 'search'])
})
it('returns empty array when no valid tags', () => {
const result = getValidTagKeys(['foo', 'bar'] as unknown as TagKey[])
expect(result).toEqual([])
})
it('returns empty array for empty input', () => {
expect(getValidTagKeys([])).toEqual([])
})
it('preserves all valid tags when all are valid', () => {
const input: TagKey[] = ['agent', 'rag', 'search', 'image']
const result = getValidTagKeys(input)
expect(result).toEqual(input)
})
})
describe('getValidCategoryKeys', () => {
it('returns matching category for valid key', () => {
expect(getValidCategoryKeys(PluginCategoryEnum.model)).toBe(PluginCategoryEnum.model)
expect(getValidCategoryKeys(PluginCategoryEnum.tool)).toBe(PluginCategoryEnum.tool)
expect(getValidCategoryKeys(PluginCategoryEnum.agent)).toBe(PluginCategoryEnum.agent)
expect(getValidCategoryKeys('bundle')).toBe('bundle')
})
it('returns undefined for invalid category', () => {
expect(getValidCategoryKeys('nonexistent')).toBeUndefined()
})
it('returns undefined for undefined input', () => {
expect(getValidCategoryKeys(undefined)).toBeUndefined()
})
it('returns undefined for empty string', () => {
expect(getValidCategoryKeys('')).toBeUndefined()
})
})
describe('getPluginCardIconUrl', () => {
it('returns an empty string when icon is missing', () => {
expect(getPluginCardIconUrl(createPlugin(), undefined, 'tenant-1')).toBe('')

View File

@ -11,7 +11,6 @@ import {
useMarketplaceSort,
useMarketplaceSortValue,
useSearchPluginText,
useSetMarketplaceSort,
} from '../atoms'
import { DEFAULT_SORT } from '../constants'
@ -47,20 +46,6 @@ describe('Marketplace sort atoms', () => {
expect(result.current).toEqual(DEFAULT_SORT)
})
it('should return setter from useSetMarketplaceSort', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => ({
setSort: useSetMarketplaceSort(),
sortValue: useMarketplaceSortValue(),
}), { wrapper })
act(() => {
result.current.setSort({ sortBy: 'created_at', sortOrder: 'ASC' })
})
expect(result.current.sortValue).toEqual({ sortBy: 'created_at', sortOrder: 'ASC' })
})
it('should update sort value via useMarketplaceSort setter', () => {
const { wrapper } = createWrapper()
const { result } = renderHook(() => useMarketplaceSort(), { wrapper })

View File

@ -118,22 +118,6 @@ describe('getPluginLinkInMarketplace', () => {
})
})
describe('getPluginDetailLinkInMarketplace', () => {
it('should return correct detail link for regular plugin', async () => {
const { getPluginDetailLinkInMarketplace } = await import('../utils')
const plugin = createMockPlugin({ org: 'test-org', name: 'test-plugin', type: 'plugin' })
const link = getPluginDetailLinkInMarketplace(plugin)
expect(link).toBe('/plugins/test-org/test-plugin')
})
it('should return correct detail link for bundle', async () => {
const { getPluginDetailLinkInMarketplace } = await import('../utils')
const bundle = createMockPlugin({ org: 'test-org', name: 'test-bundle', type: 'bundle' })
const link = getPluginDetailLinkInMarketplace(bundle)
expect(link).toBe('/bundles/test-org/test-bundle')
})
})
describe('getMarketplaceListCondition', () => {
it('should return category condition for tool', async () => {
const { getMarketplaceListCondition } = await import('../utils')

View File

@ -12,10 +12,6 @@ export function useMarketplaceSort() {
export function useMarketplaceSortValue() {
return useAtomValue(marketplaceSortAtom)
}
export function useSetMarketplaceSort() {
return useSetAtom(marketplaceSortAtom)
}
export function useSearchPluginText() {
return useQueryState('q', marketplaceSearchParamsParsers.q)
}

View File

@ -57,13 +57,6 @@ export const getPluginLinkInMarketplace = (plugin: Plugin, params?: Record<strin
export const getMarketplaceCategoryUrl = (category?: string, params?: Record<string, string | undefined>) => {
return getMarketplaceUrl(category ? `/plugins/${category}` : '/plugins', params)
}
export const getPluginDetailLinkInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle')
return `/bundles/${plugin.org}/${plugin.name}`
return `/plugins/${plugin.org}/${plugin.name}`
}
export const getMarketplacePluginsByCollectionId = async (
collectionId: string,
query?: CollectionsAndPluginsSearchParams,

View File

@ -3,10 +3,8 @@ import { FormTypeEnum } from '@/app/components/header/account-setting/model-prov
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
import { VarType } from '@/app/components/workflow/types'
import {
createEmptyAppValue,
createFilterVar,
createPickerProps,
createReasoningFormContext,
getFieldFlags,
getFieldTitle,
getVarKindType,
@ -161,21 +159,7 @@ describe('reasoning-config-form helpers', () => {
}))
})
it('provides label helpers and empty defaults', () => {
it('provides label helpers', () => {
expect(getFieldTitle({ en_US: 'Prompt', zh_Hans: 'Prompt' }, 'en_US')).toBe('Prompt')
expect(createEmptyAppValue()).toEqual({
app_id: '',
inputs: {},
files: [],
})
expect(createReasoningFormContext({
availableNodes: [{ id: 'node-1' }] as never,
nodeId: 'node-current',
nodeOutputVars: [{ nodeId: 'node-1' }] as never,
})).toEqual({
availableNodes: [{ id: 'node-1' }],
nodeId: 'node-current',
nodeOutputVars: [{ nodeId: 'node-1' }],
})
})
})

View File

@ -1,7 +1,6 @@
import type { Node } from 'reactflow'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ToolFormSchema } from '@/app/components/tools/utils/to-form-schema'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import type { ValueSelector, Var } from '@/app/components/workflow/types'
import { produce } from 'immer'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types'
@ -211,23 +210,3 @@ export const createPickerProps = ({
export const getFieldTitle = (labels: { [key: string]: string }, language: string) => {
return labels[language] || labels.en_US
}
export const createEmptyAppValue = () => ({
app_id: '',
inputs: {},
files: [],
})
export const createReasoningFormContext = ({
availableNodes,
nodeId,
nodeOutputVars,
}: {
availableNodes: Node[]
nodeId: string
nodeOutputVars: NodeOutPutVar[]
}) => ({
availableNodes,
nodeId,
nodeOutputVars,
})

View File

@ -1,21 +1,6 @@
import type {
TagKey,
} from './constants'
import type { Plugin } from './types'
import { API_PREFIX, MARKETPLACE_API_PREFIX } from '@/config'
import {
categoryKeys,
tagKeys,
} from './constants'
export const getValidTagKeys = (tags: TagKey[]) => {
return tags.filter(tag => tagKeys.includes(tag))
}
export const getValidCategoryKeys = (category?: string) => {
return categoryKeys.find(key => key === category)
}
const hasUrlProtocol = (value: string) => /^[a-z][a-z\d+.-]*:/i.test(value)

View File

@ -8,14 +8,11 @@ import { FlowType } from '@/types/common'
import {
useAvailableNodesMetaData,
useDSL,
useGetRunAndTraceUrl,
useInputFieldPanel,
useNodesSyncDraft,
usePipelineInit,
usePipelineRefreshDraft,
usePipelineRun,
usePipelineStartRun,
} from '../index'
import { useConfigsMap } from '../use-configs-map'
import { useConfigurations, useInitialData } from '../use-input-fields'
@ -458,22 +455,11 @@ describe('usePipelineTemplate', () => {
})
})
describe('useDSL', () => {
it('should be defined and exported', () => {
expect(useDSL).toBeDefined()
expect(typeof useDSL).toBe('function')
})
})
describe('exports', () => {
it('should export useAvailableNodesMetaData', () => {
expect(useAvailableNodesMetaData).toBeDefined()
})
it('should export useDSL', () => {
expect(useDSL).toBeDefined()
})
it('should export useGetRunAndTraceUrl', () => {
expect(useGetRunAndTraceUrl).toBeDefined()
})
@ -493,14 +479,6 @@ describe('exports', () => {
it('should export usePipelineRefreshDraft', () => {
expect(usePipelineRefreshDraft).toBeDefined()
})
it('should export usePipelineRun', () => {
expect(usePipelineRun).toBeDefined()
})
it('should export usePipelineStartRun', () => {
expect(usePipelineStartRun).toBeDefined()
})
})
afterEach(() => {

View File

@ -1,6 +1,6 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useDSL } from '../use-DSL'
import { useDSLByCanEdit } from '../use-DSL'
const toastMocks = vi.hoisted(() => ({
call: vi.fn(),
@ -27,7 +27,7 @@ vi.mock('@/context/event-emitter', () => ({
const mockDoSyncWorkflowDraft = vi.fn()
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft }),
useNodesSyncDraftByCanEdit: () => ({ doSyncWorkflowDraft: mockDoSyncWorkflowDraft }),
}))
const mockGetState = vi.fn()
@ -53,7 +53,7 @@ vi.mock('@/app/components/workflow/constants', () => ({
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
}))
describe('useDSL', () => {
describe('useDSLByCanEdit', () => {
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn>, style: { display: string }, remove: ReturnType<typeof vi.fn> }
let originalCreateElement: typeof document.createElement
let originalAppendChild: typeof document.body.appendChild
@ -107,7 +107,7 @@ describe('useDSL', () => {
it('should return early when pipelineId is not set', async () => {
mockGetState.mockReturnValue({ pipelineId: null, knowledgeName: 'test' })
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.handleExportDSL()
@ -117,7 +117,7 @@ describe('useDSL', () => {
})
it('should create and download file', async () => {
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.handleExportDSL()
@ -127,7 +127,7 @@ describe('useDSL', () => {
})
it('should set correct download filename', async () => {
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.handleExportDSL()
@ -141,7 +141,7 @@ describe('useDSL', () => {
})
it('should pass blob data to downloadBlob', async () => {
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.handleExportDSL()
@ -157,7 +157,7 @@ describe('useDSL', () => {
it('should handle export error', async () => {
mockExportPipelineConfig.mockRejectedValue(new Error('Export failed'))
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.handleExportDSL()
@ -172,7 +172,7 @@ describe('useDSL', () => {
})
it('should pass include parameter', async () => {
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.handleExportDSL(true)
@ -191,7 +191,7 @@ describe('useDSL', () => {
it('should return early when pipelineId is not set', async () => {
mockGetState.mockReturnValue({ pipelineId: null })
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.exportCheck()
@ -203,7 +203,7 @@ describe('useDSL', () => {
it('should call handleExportDSL directly when no secret variables', async () => {
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.exportCheck()
@ -219,7 +219,7 @@ describe('useDSL', () => {
const secretVars = [{ value_type: 'secret', name: 'API_KEY' }]
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars })
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.exportCheck()
@ -238,7 +238,7 @@ describe('useDSL', () => {
it('should handle export check error', async () => {
mockFetchWorkflowDraft.mockRejectedValue(new Error('Fetch failed'))
const { result } = renderHook(() => useDSL())
const { result } = renderHook(() => useDSLByCanEdit(true))
await act(async () => {
await result.current.exportCheck()

View File

@ -4,7 +4,7 @@ import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { usePipelineRun } from '../use-pipeline-run'
import { usePipelineRunByCanEdit } from '../use-pipeline-run'
const mockStoreGetState = vi.fn()
const mockGetViewport = vi.fn()
@ -30,7 +30,7 @@ vi.mock('@/app/components/workflow/store', () => ({
const mockDoSyncWorkflowDraft = vi.fn()
vi.mock('../use-nodes-sync-draft', () => ({
useNodesSyncDraft: () => ({
useNodesSyncDraftByCanEdit: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
}))
@ -91,7 +91,7 @@ vi.mock('@/types/common', () => ({
},
}))
describe('usePipelineRun', () => {
describe('usePipelineRunByCanEdit', () => {
const mockSetNodes = vi.fn()
const mockGetNodes = vi.fn()
const mockSetBackupDraft = vi.fn()
@ -147,35 +147,35 @@ describe('usePipelineRun', () => {
describe('hook initialization', () => {
it('should return handleBackupDraft function', () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
expect(result.current.handleBackupDraft).toBeDefined()
expect(typeof result.current.handleBackupDraft).toBe('function')
})
it('should return handleLoadBackupDraft function', () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
expect(result.current.handleLoadBackupDraft).toBeDefined()
expect(typeof result.current.handleLoadBackupDraft).toBe('function')
})
it('should return handleRun function', () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
expect(result.current.handleRun).toBeDefined()
expect(typeof result.current.handleRun).toBe('function')
})
it('should return handleStopRun function', () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
expect(result.current.handleStopRun).toBeDefined()
expect(typeof result.current.handleStopRun).toBe('function')
})
it('should return handleRestoreFromPublishedWorkflow function', () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
expect(result.current.handleRestoreFromPublishedWorkflow).toBeDefined()
expect(typeof result.current.handleRestoreFromPublishedWorkflow).toBe('function')
@ -184,7 +184,7 @@ describe('usePipelineRun', () => {
describe('handleBackupDraft', () => {
it('should backup draft when no backup exists', () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
act(() => {
result.current.handleBackupDraft()
@ -205,7 +205,7 @@ describe('usePipelineRun', () => {
setWorkflowRunningData: mockSetWorkflowRunningData,
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
act(() => {
result.current.handleBackupDraft()
@ -234,7 +234,7 @@ describe('usePipelineRun', () => {
setWorkflowRunningData: mockSetWorkflowRunningData,
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
act(() => {
result.current.handleLoadBackupDraft()
@ -260,7 +260,7 @@ describe('usePipelineRun', () => {
setWorkflowRunningData: mockSetWorkflowRunningData,
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
act(() => {
result.current.handleLoadBackupDraft()
@ -272,7 +272,7 @@ describe('usePipelineRun', () => {
describe('handleStopRun', () => {
it('should call stop workflow run service', () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
act(() => {
result.current.handleStopRun('task-123')
@ -296,7 +296,7 @@ describe('usePipelineRun', () => {
rag_pipeline_variables: [{ variable: 'input', type: 'text-input' }],
}
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
act(() => {
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory)
@ -320,7 +320,7 @@ describe('usePipelineRun', () => {
rag_pipeline_variables: [],
}
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
act(() => {
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory)
@ -340,7 +340,7 @@ describe('usePipelineRun', () => {
rag_pipeline_variables: [{ variable: 'query', type: 'text-input' }],
}
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
act(() => {
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory)
@ -360,7 +360,7 @@ describe('usePipelineRun', () => {
rag_pipeline_variables: undefined,
}
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
act(() => {
result.current.handleRestoreFromPublishedWorkflow(publishedWorkflow as unknown as VersionHistory)
@ -373,7 +373,7 @@ describe('usePipelineRun', () => {
describe('handleRun', () => {
it('should sync workflow draft before running', async () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} })
@ -383,7 +383,7 @@ describe('usePipelineRun', () => {
})
it('should reset node selection and running status', async () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} })
@ -393,7 +393,7 @@ describe('usePipelineRun', () => {
})
it('should clear history workflow data', async () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} })
@ -403,7 +403,7 @@ describe('usePipelineRun', () => {
})
it('should set initial running data', async () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} })
@ -422,7 +422,7 @@ describe('usePipelineRun', () => {
})
it('should call ssePost with correct URL', async () => {
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: { query: 'test' } })
@ -443,7 +443,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onWorkflowStarted })
@ -465,7 +465,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onWorkflowFinished })
@ -487,7 +487,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onError })
@ -509,7 +509,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onNodeStarted })
@ -530,7 +530,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onNodeFinished })
@ -551,7 +551,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onIterationStart })
@ -572,7 +572,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onIterationNext })
@ -593,7 +593,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onIterationFinish })
@ -614,7 +614,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onLoopStart })
@ -635,7 +635,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onLoopNext })
@ -656,7 +656,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onLoopFinish })
@ -677,7 +677,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onNodeRetry })
@ -698,7 +698,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onAgentLog })
@ -718,7 +718,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} })
@ -738,7 +738,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} })
@ -759,7 +759,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} }, { onData: customCallback } as unknown as Parameters<typeof result.current.handleRun>[1])
@ -775,7 +775,7 @@ describe('usePipelineRun', () => {
capturedCallbacks = callbacks
})
const { result } = renderHook(() => usePipelineRun())
const { result } = renderHook(() => usePipelineRunByCanEdit(true))
await act(async () => {
await result.current.handleRun({ inputs: {} })

View File

@ -3,7 +3,7 @@ import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { usePipelineStartRun } from '../use-pipeline-start-run'
import { usePipelineStartRunByCanEdit } from '../use-pipeline-start-run'
const mockWorkflowStoreGetState = vi.fn()
const mockWorkflowStoreSetState = vi.fn()
@ -23,7 +23,7 @@ vi.mock('@/app/components/workflow/hooks', () => ({
const mockDoSyncWorkflowDraft = vi.fn()
vi.mock('@/app/components/rag-pipeline/hooks', () => ({
useNodesSyncDraft: () => ({
useNodesSyncDraftByCanEdit: () => ({
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
}),
useInputFieldPanel: () => ({
@ -31,7 +31,7 @@ vi.mock('@/app/components/rag-pipeline/hooks', () => ({
}),
}))
describe('usePipelineStartRun', () => {
describe('usePipelineStartRunByCanEdit', () => {
const mockSetIsPreparingDataSource = vi.fn()
const mockSetShowEnvPanel = vi.fn()
const mockSetShowDebugAndPreviewPanel = vi.fn()
@ -57,14 +57,14 @@ describe('usePipelineStartRun', () => {
describe('hook initialization', () => {
it('should return handleStartWorkflowRun function', () => {
const { result } = renderHook(() => usePipelineStartRun())
const { result } = renderHook(() => usePipelineStartRunByCanEdit(true))
expect(result.current.handleStartWorkflowRun).toBeDefined()
expect(typeof result.current.handleStartWorkflowRun).toBe('function')
})
it('should return handleWorkflowStartRunInWorkflow function', () => {
const { result } = renderHook(() => usePipelineStartRun())
const { result } = renderHook(() => usePipelineStartRunByCanEdit(true))
expect(result.current.handleWorkflowStartRunInWorkflow).toBeDefined()
expect(typeof result.current.handleWorkflowStartRunInWorkflow).toBe('function')
@ -84,7 +84,7 @@ describe('usePipelineStartRun', () => {
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
const { result } = renderHook(() => usePipelineStartRunByCanEdit(true))
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
@ -105,7 +105,7 @@ describe('usePipelineStartRun', () => {
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
const { result } = renderHook(() => usePipelineStartRunByCanEdit(true))
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
@ -127,7 +127,7 @@ describe('usePipelineStartRun', () => {
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
const { result } = renderHook(() => usePipelineStartRunByCanEdit(true))
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
@ -147,7 +147,7 @@ describe('usePipelineStartRun', () => {
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
const { result } = renderHook(() => usePipelineStartRunByCanEdit(true))
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
@ -168,7 +168,7 @@ describe('usePipelineStartRun', () => {
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
const { result } = renderHook(() => usePipelineStartRunByCanEdit(true))
await act(async () => {
await result.current.handleWorkflowStartRunInWorkflow()
@ -189,7 +189,7 @@ describe('usePipelineStartRun', () => {
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
})
const { result } = renderHook(() => usePipelineStartRun())
const { result } = renderHook(() => usePipelineStartRunByCanEdit(true))
await act(async () => {
result.current.handleStartWorkflowRun()

Some files were not shown because too many files have changed in this diff Show More