mirror of
https://github.com/langgenius/dify.git
synced 2026-06-24 21:11:16 +08:00
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:
parent
86b73ba205
commit
ea1aa2fecd
@ -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": {
|
||||
|
||||
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 {}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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('<script>alert('xss')</script>')
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
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)
|
||||
|
||||
@ -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"}')
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -315,5 +315,4 @@ export const AvgUserInteractions = createBizChartComponent({
|
||||
yMaxWhenEmpty: 500,
|
||||
isAvg: true,
|
||||
})
|
||||
|
||||
export default Chart
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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}`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -73,4 +73,4 @@ const Badge: React.FC<BadgeProps> = ({
|
||||
Badge.displayName = 'Badge'
|
||||
|
||||
export default Badge
|
||||
export { Badge, BadgeState, BadgeVariants }
|
||||
export { Badge, BadgeState }
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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: '',
|
||||
},
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:\/\//)
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export const PROGRESS_NOT_STARTED = -1
|
||||
export const PROGRESS_ERROR = -2
|
||||
|
||||
@ -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'))
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,7 +0,0 @@
|
||||
export const ValidatedStatus = {
|
||||
Success: 'success',
|
||||
Error: 'error',
|
||||
Exceed: 'exceed',
|
||||
} as const
|
||||
|
||||
export type ValidatedStatus = typeof ValidatedStatus[keyof typeof ValidatedStatus]
|
||||
@ -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()
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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('')
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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: {} })
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user