Merge branch 'main' into pydantic-remaining

This commit is contained in:
Asuka Minato 2025-12-19 19:12:42 +09:00
commit 294d9634ae
922 changed files with 30200 additions and 2814 deletions

View File

@ -35,6 +35,14 @@ jobs:
cache: pnpm
cache-dependency-path: ./web/pnpm-lock.yaml
- name: Restore Jest cache
uses: actions/cache@v4
with:
path: web/.cache/jest
key: ${{ runner.os }}-jest-${{ hashFiles('web/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-jest-
- name: Install dependencies
run: pnpm install --frozen-lockfile
@ -45,7 +53,7 @@ jobs:
run: |
pnpm exec jest \
--ci \
--runInBand \
--maxWorkers=100% \
--coverage \
--passWithNoTests
@ -70,6 +78,13 @@ jobs:
node <<'NODE' >> "$GITHUB_STEP_SUMMARY"
const fs = require('fs');
const path = require('path');
let libCoverage = null;
try {
libCoverage = require('istanbul-lib-coverage');
} catch (error) {
libCoverage = null;
}
const summaryPath = path.join('coverage', 'coverage-summary.json');
const finalPath = path.join('coverage', 'coverage-final.json');
@ -91,6 +106,54 @@ jobs:
? JSON.parse(fs.readFileSync(finalPath, 'utf8'))
: null;
const getLineCoverageFromStatements = (statementMap, statementHits) => {
const lineHits = {};
if (!statementMap || !statementHits) {
return lineHits;
}
Object.entries(statementMap).forEach(([key, statement]) => {
const line = statement?.start?.line;
if (!line) {
return;
}
const hits = statementHits[key] ?? 0;
const previous = lineHits[line];
lineHits[line] = previous === undefined ? hits : Math.max(previous, hits);
});
return lineHits;
};
const getFileCoverage = (entry) => (
libCoverage ? libCoverage.createFileCoverage(entry) : null
);
const getLineHits = (entry, fileCoverage) => {
const lineHits = entry.l ?? {};
if (Object.keys(lineHits).length > 0) {
return lineHits;
}
if (fileCoverage) {
return fileCoverage.getLineCoverage();
}
return getLineCoverageFromStatements(entry.statementMap ?? {}, entry.s ?? {});
};
const getUncoveredLines = (entry, fileCoverage, lineHits) => {
if (lineHits && Object.keys(lineHits).length > 0) {
return Object.entries(lineHits)
.filter(([, count]) => count === 0)
.map(([line]) => Number(line))
.sort((a, b) => a - b);
}
if (fileCoverage) {
return fileCoverage.getUncoveredLines();
}
return [];
};
const totals = {
lines: { covered: 0, total: 0 },
statements: { covered: 0, total: 0 },
@ -106,7 +169,7 @@ jobs:
totals[key].covered = totalEntry[key].covered ?? 0;
totals[key].total = totalEntry[key].total ?? 0;
}
});
});
Object.entries(summary)
.filter(([file]) => file !== 'total')
@ -122,7 +185,8 @@ jobs:
});
} else if (coverage) {
Object.entries(coverage).forEach(([file, entry]) => {
const lineHits = entry.l ?? {};
const fileCoverage = getFileCoverage(entry);
const lineHits = getLineHits(entry, fileCoverage);
const statementHits = entry.s ?? {};
const branchHits = entry.b ?? {};
const functionHits = entry.f ?? {};
@ -228,7 +292,8 @@ jobs:
};
const tableRows = Object.entries(coverage)
.map(([file, entry]) => {
const lineHits = entry.l ?? {};
const fileCoverage = getFileCoverage(entry);
const lineHits = getLineHits(entry, fileCoverage);
const statementHits = entry.s ?? {};
const branchHits = entry.b ?? {};
const functionHits = entry.f ?? {};
@ -254,10 +319,7 @@ jobs:
tableTotals.functions.total += functionTotal;
tableTotals.functions.covered += functionCovered;
const uncoveredLines = Object.entries(lineHits)
.filter(([, count]) => count === 0)
.map(([line]) => Number(line))
.sort((a, b) => a - b);
const uncoveredLines = getUncoveredLines(entry, fileCoverage, lineHits);
const filePath = entry.path ?? file;
const relativePath = path.isAbsolute(filePath)
@ -294,46 +356,20 @@ jobs:
};
const rowsForOutput = [allFilesRow, ...tableRows];
const columnWidths = Object.fromEntries(
columns.map(({ key, header }) => [key, header.length]),
);
rowsForOutput.forEach((row) => {
columns.forEach(({ key }) => {
const value = String(row[key] ?? '');
columnWidths[key] = Math.max(columnWidths[key], value.length);
});
});
const formatRow = (row) => columns
.map(({ key, align }) => {
const value = String(row[key] ?? '');
const width = columnWidths[key];
return align === 'right' ? value.padStart(width) : value.padEnd(width);
})
.join(' | ');
const headerRow = columns
.map(({ header, key, align }) => {
const width = columnWidths[key];
return align === 'right' ? header.padStart(width) : header.padEnd(width);
})
.join(' | ');
const dividerRow = columns
.map(({ key }) => '-'.repeat(columnWidths[key]))
.join('|');
const formatRow = (row) => `| ${columns
.map(({ key }) => String(row[key] ?? ''))
.join(' | ')} |`;
const headerRow = `| ${columns.map(({ header }) => header).join(' | ')} |`;
const dividerRow = `| ${columns
.map(({ align }) => (align === 'right' ? '---:' : ':---'))
.join(' | ')} |`;
console.log('');
console.log('<details><summary>Jest coverage table</summary>');
console.log('');
console.log('```');
console.log(dividerRow);
console.log(headerRow);
console.log(dividerRow);
rowsForOutput.forEach((row) => console.log(formatRow(row)));
console.log(dividerRow);
console.log('```');
console.log('</details>');
}
NODE

View File

@ -31,7 +31,6 @@ from services.errors.audio import (
)
from ..common.schema import register_schema_models
from ..web.wraps import WebApiResource
class TextToAudioPayload(BaseModel):

View File

@ -95,7 +95,8 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
splits = re.split(r" +", text)
else:
splits = text.split(separator)
splits = [item + separator if i < len(splits) else item for i, item in enumerate(splits)]
if self._keep_separator:
splits = [s + separator for s in splits[:-1]] + splits[-1:]
else:
splits = list(text)
if separator == "\n":
@ -104,7 +105,7 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
splits = [s for s in splits if (s not in {"", "\n"})]
_good_splits = []
_good_splits_lengths = [] # cache the lengths of the splits
_separator = separator if self._keep_separator else ""
_separator = "" if self._keep_separator else separator
s_lens = self._length_function(splits)
if separator != "":
for s, s_len in zip(splits, s_lens):

View File

@ -120,9 +120,9 @@ def uuid_value(value: Any) -> str:
raise ValueError(error)
def normalize_uuid(value: str | UUID | None) -> str | None:
def normalize_uuid(value: str | UUID) -> str:
if not value:
return None
return ""
try:
return uuid_value(value)

71
web/__mocks__/ky.ts Normal file
View File

@ -0,0 +1,71 @@
/**
* Mock for ky HTTP client
* This mock is used to avoid ESM issues in Jest tests
*/
type KyResponse = {
ok: boolean
status: number
statusText: string
headers: Headers
json: jest.Mock
text: jest.Mock
blob: jest.Mock
arrayBuffer: jest.Mock
clone: jest.Mock
}
type KyInstance = jest.Mock & {
get: jest.Mock
post: jest.Mock
put: jest.Mock
patch: jest.Mock
delete: jest.Mock
head: jest.Mock
create: jest.Mock
extend: jest.Mock
stop: symbol
}
const createResponse = (data: unknown = {}, status = 200): KyResponse => {
const response: KyResponse = {
ok: status >= 200 && status < 300,
status,
statusText: status === 200 ? 'OK' : 'Error',
headers: new Headers(),
json: jest.fn().mockResolvedValue(data),
text: jest.fn().mockResolvedValue(JSON.stringify(data)),
blob: jest.fn().mockResolvedValue(new Blob()),
arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(0)),
clone: jest.fn(),
}
// Ensure clone returns a new response-like object, not the same instance
response.clone.mockImplementation(() => createResponse(data, status))
return response
}
const createKyInstance = (): KyInstance => {
const instance = jest.fn().mockImplementation(() => Promise.resolve(createResponse())) as KyInstance
// HTTP methods
instance.get = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
instance.post = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
instance.put = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
instance.patch = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
instance.delete = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
instance.head = jest.fn().mockImplementation(() => Promise.resolve(createResponse()))
// Create new instance with custom options
instance.create = jest.fn().mockImplementation(() => createKyInstance())
instance.extend = jest.fn().mockImplementation(() => createKyInstance())
// Stop method for AbortController
instance.stop = Symbol('stop')
return instance
}
const ky = createKyInstance()
export default ky
export { ky }

View File

@ -16,7 +16,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import s from './style.module.css'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useStore } from '@/app/components/app/store'
import AppSideBar from '@/app/components/app-sidebar'
import type { NavIcon } from '@/app/components/app-sidebar/navLink'

View File

@ -3,7 +3,7 @@ import { RiCalendarLine } from '@remixicon/react'
import type { Dayjs } from 'dayjs'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { formatToLocalTime } from '@/utils/format'
import { useI18N } from '@/context/i18n'
import Picker from '@/app/components/base/date-and-time-picker/date-picker'

View File

@ -6,7 +6,7 @@ import { SimpleSelect } from '@/app/components/base/select'
import type { Item } from '@/app/components/base/select'
import dayjs from 'dayjs'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
const today = dayjs()

View File

@ -4,7 +4,7 @@ import React, { useCallback, useRef, useState } from 'react'
import type { PopupProps } from './config-popup'
import ConfigPopup from './config-popup'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,

View File

@ -12,7 +12,7 @@ import Indicator from '@/app/components/header/indicator'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import Divider from '@/app/components/base/divider'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
const I18N_PREFIX = 'app.tracing'

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Input from '@/app/components/base/input'
type Props = {

View File

@ -12,7 +12,7 @@ import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangS
import { TracingProvider } from './type'
import TracingIcon from './tracing-icon'
import ConfigButton from './config-button'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
import Indicator from '@/app/components/header/indicator'
import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'

View File

@ -6,7 +6,7 @@ import {
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { TracingProvider } from './type'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { AliyunIconBig, ArizeIconBig, DatabricksIconBig, LangfuseIconBig, LangsmithIconBig, MlflowIconBig, OpikIconBig, PhoenixIconBig, TencentIconBig, WeaveIconBig } from '@/app/components/base/icons/src/public/tracing'
import { Eye as View } from '@/app/components/base/icons/src/vender/solid/general'

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { TracingIcon as Icon } from '@/app/components/base/icons/src/public/tracing'
type Props = {

View File

@ -23,7 +23,7 @@ import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use
import useDocumentTitle from '@/hooks/use-document-title'
import ExtraInfo from '@/app/components/datasets/extra-info'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
export type IAppDetailLayoutProps = {
children: React.ReactNode

View File

@ -1,7 +1,7 @@
'use client'
import Header from '@/app/signin/_header'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
export default function SignInLayout({ children }: any) {

View File

@ -2,7 +2,7 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import cn from 'classnames'
import { cn } from '@/utils/classnames'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import Button from '@/app/components/base/button'

View File

@ -1,6 +1,6 @@
'use client'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import type { PropsWithChildren } from 'react'

View File

@ -7,7 +7,7 @@ import Loading from '@/app/components/base/loading'
import MailAndCodeAuth from './components/mail-and-code-auth'
import MailAndPasswordAuth from './components/mail-and-password-auth'
import SSOAuth from './components/sso-auth'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { LicenseStatus } from '@/types/feature'
import { IS_CE_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'

View File

@ -1,7 +1,7 @@
'use client'
import Header from '@/app/signin/_header'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { AppContextProvider } from '@/context/app-context'

View File

@ -1,13 +1,13 @@
'use client'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useRouter, useSearchParams } from 'next/navigation'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Button from '@/app/components/base/button'
import { invitationCheck } from '@/service/common'
import Loading from '@/app/components/base/loading'
import useDocumentTitle from '@/hooks/use-document-title'
import { useInvitationCheck } from '@/service/use-common'
const ActivateForm = () => {
useDocumentTitle('')
@ -26,19 +26,21 @@ const ActivateForm = () => {
token,
},
}
const { data: checkRes } = useSWR(checkParams, invitationCheck, {
revalidateOnFocus: false,
onSuccess(data) {
if (data.is_valid) {
const params = new URLSearchParams(searchParams)
const { email, workspace_id } = data.data
params.set('email', encodeURIComponent(email))
params.set('workspace_id', encodeURIComponent(workspace_id))
params.set('invite_token', encodeURIComponent(token as string))
router.replace(`/signin?${params.toString()}`)
}
},
})
const { data: checkRes } = useInvitationCheck({
...checkParams.params,
token: token || undefined,
}, true)
useEffect(() => {
if (checkRes?.is_valid) {
const params = new URLSearchParams(searchParams)
const { email, workspace_id } = checkRes.data
params.set('email', encodeURIComponent(email))
params.set('workspace_id', encodeURIComponent(workspace_id))
params.set('invite_token', encodeURIComponent(token as string))
router.replace(`/signin?${params.toString()}`)
}
}, [checkRes, router, searchParams, token])
return (
<div className={

View File

@ -2,7 +2,7 @@
import React from 'react'
import Header from '../signin/_header'
import ActivateForm from './activateForm'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
const Activate = () => {

View File

@ -29,7 +29,7 @@ import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overvie
import type { Operation } from './app-operations'
import AppOperations from './app-operations'
import dynamic from 'next/dynamic'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { AppModeEnum } from '@/types/app'
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {

View File

@ -16,7 +16,7 @@ import AppInfo from './app-info'
import NavLink from './navLink'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { NavIcon } from './navLink'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { AppModeEnum } from '@/types/app'
type Props = {

View File

@ -2,7 +2,7 @@ import React, { useCallback, useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import ActionButton from '../../base/action-button'
import { RiMoreFill } from '@remixicon/react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Menu from './menu'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'

View File

@ -0,0 +1,379 @@
import React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DatasetInfo from './index'
import Dropdown from './dropdown'
import Menu from './menu'
import MenuItem from './menu-item'
import type { DataSet } from '@/models/datasets'
import {
ChunkingMode,
DataSourceType,
DatasetPermission,
} from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { RiEditLine } from '@remixicon/react'
let mockDataset: DataSet
let mockIsDatasetOperator = false
const mockReplace = jest.fn()
const mockInvalidDatasetList = jest.fn()
const mockInvalidDatasetDetail = jest.fn()
const mockExportPipeline = jest.fn()
const mockCheckIsUsedInApp = jest.fn()
const mockDeleteDataset = jest.fn()
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: {
icon: '📙',
icon_background: '#FFF4ED',
icon_type: 'emoji',
icon_url: '',
},
description: 'Dataset description',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1690000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 1,
total_document_count: 1,
word_count: 1000,
provider: 'internal',
embedding_model: 'text-embedding-3',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
...overrides,
})
jest.mock('next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }),
}))
jest.mock('@/context/app-context', () => ({
useSelector: (selector: (state: { isCurrentWorkspaceDatasetOperator: boolean }) => unknown) =>
selector({ isCurrentWorkspaceDatasetOperator: mockIsDatasetOperator }),
}))
jest.mock('@/service/knowledge/use-dataset', () => ({
datasetDetailQueryKeyPrefix: ['dataset', 'detail'],
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
jest.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidDatasetDetail,
}))
jest.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipeline,
}),
}))
jest.mock('@/service/datasets', () => ({
checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args),
deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args),
}))
jest.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'indexing-technique',
}),
}))
jest.mock('@/app/components/datasets/rename-modal', () => ({
__esModule: true,
default: ({
show,
onClose,
onSuccess,
}: {
show: boolean
onClose: () => void
onSuccess?: () => void
}) => {
if (!show)
return null
return (
<div data-testid="rename-modal">
<button type="button" onClick={onSuccess}>Success</button>
<button type="button" onClick={onClose}>Close</button>
</div>
)
},
}))
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
const trigger = screen.getByRole('button')
await user.click(trigger)
}
describe('DatasetInfo', () => {
beforeEach(() => {
jest.clearAllMocks()
mockDataset = createDataset()
mockIsDatasetOperator = false
})
// Rendering of dataset summary details based on expand and dataset state.
describe('Rendering', () => {
it('should show dataset details when expanded', () => {
// Arrange
mockDataset = createDataset({ is_published: true })
render(<DatasetInfo expand />)
// Assert
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
expect(screen.getByText('Dataset description')).toBeInTheDocument()
expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument()
expect(screen.getByText('indexing-technique')).toBeInTheDocument()
})
it('should show external tag when provider is external', () => {
// Arrange
mockDataset = createDataset({ provider: 'external', is_published: false })
render(<DatasetInfo expand />)
// Assert
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
expect(screen.queryByText('dataset.chunkingMode.general')).not.toBeInTheDocument()
})
it('should hide detailed fields when collapsed', () => {
// Arrange
render(<DatasetInfo expand={false} />)
// Assert
expect(screen.queryByText('Dataset Name')).not.toBeInTheDocument()
expect(screen.queryByText('Dataset description')).not.toBeInTheDocument()
})
})
})
describe('MenuItem', () => {
beforeEach(() => {
jest.clearAllMocks()
})
// Event handling for menu item interactions.
describe('Interactions', () => {
it('should call handler when clicked', async () => {
const user = userEvent.setup()
const handleClick = jest.fn()
// Arrange
render(<MenuItem name="Edit" Icon={RiEditLine} handleClick={handleClick} />)
// Act
await user.click(screen.getByText('Edit'))
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
})
})
describe('Menu', () => {
beforeEach(() => {
jest.clearAllMocks()
mockDataset = createDataset()
})
// Rendering of menu options based on runtime mode and delete visibility.
describe('Rendering', () => {
it('should show edit, export, and delete options when rag pipeline and deletable', () => {
// Arrange
mockDataset = createDataset({ runtime_mode: 'rag_pipeline' })
render(
<Menu
showDelete
openRenameModal={jest.fn()}
handleExportPipeline={jest.fn()}
detectIsUsedByApp={jest.fn()}
/>,
)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument()
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
})
it('should hide export and delete options when not rag pipeline and not deletable', () => {
// Arrange
mockDataset = createDataset({ runtime_mode: 'general' })
render(
<Menu
showDelete={false}
openRenameModal={jest.fn()}
handleExportPipeline={jest.fn()}
detectIsUsedByApp={jest.fn()}
/>,
)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.operations.exportPipeline')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
})
describe('Dropdown', () => {
beforeEach(() => {
jest.clearAllMocks()
mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' })
mockIsDatasetOperator = false
mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' })
mockCheckIsUsedInApp.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
if (!('createObjectURL' in URL)) {
Object.defineProperty(URL, 'createObjectURL', {
value: jest.fn(),
writable: true,
})
}
if (!('revokeObjectURL' in URL)) {
Object.defineProperty(URL, 'revokeObjectURL', {
value: jest.fn(),
writable: true,
})
}
})
// Rendering behavior based on workspace role.
describe('Rendering', () => {
it('should hide delete option when user is dataset operator', async () => {
const user = userEvent.setup()
// Arrange
mockIsDatasetOperator = true
render(<Dropdown expand />)
// Act
await openMenu(user)
// Assert
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
// User interactions that trigger modals and exports.
describe('Interactions', () => {
it('should open rename modal when edit is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.operation.edit'))
// Assert
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should export pipeline when export is clicked', async () => {
const user = userEvent.setup()
const anchorClickSpy = jest.spyOn(HTMLAnchorElement.prototype, 'click')
const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL')
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
// Assert
await waitFor(() => {
expect(mockExportPipeline).toHaveBeenCalledWith({
pipelineId: 'pipeline-1',
include: false,
})
})
expect(createObjectURLSpy).toHaveBeenCalledTimes(1)
expect(anchorClickSpy).toHaveBeenCalledTimes(1)
})
it('should show delete confirmation when delete is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.operation.delete'))
// Assert
await waitFor(() => {
expect(screen.getByText('dataset.deleteDatasetConfirmContent')).toBeInTheDocument()
})
})
it('should delete dataset and redirect when confirm is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.operation.delete'))
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
// Assert
await waitFor(() => {
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
})
expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1)
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
})
})

View File

@ -8,7 +8,7 @@ import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { DataSet } from '@/models/datasets'
import { DOC_FORM_TEXT } from '@/models/datasets'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Dropdown from './dropdown'
type DatasetInfoProps = {

View File

@ -11,7 +11,7 @@ import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
import NavLink from './navLink'
import type { NavIcon } from './navLink'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import Effect from '../base/effect'
import Dropdown from './dataset-info/dropdown'

View File

@ -9,7 +9,7 @@ import AppSidebarDropdown from './app-sidebar-dropdown'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Divider from '../base/divider'
import { useHover, useKeyPress } from 'ahooks'
import ToggleButton from './toggle-button'

View File

@ -2,7 +2,7 @@
import React from 'react'
import { useSelectedLayoutSegment } from 'next/navigation'
import Link from 'next/link'
import classNames from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import type { RemixiconComponentType } from '@remixicon/react'
export type NavIcon = React.ComponentType<
@ -42,7 +42,7 @@ const NavLink = ({
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const renderIcon = () => (
<div className={classNames(mode !== 'expand' && '-ml-1')}>
<div className={cn(mode !== 'expand' && '-ml-1')}>
<NavIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
</div>
)
@ -53,21 +53,17 @@ const NavLink = ({
key={name}
type='button'
disabled
className={classNames(
'system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
'pl-3 pr-1',
)}
className={cn('system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
'pl-3 pr-1')}
title={mode === 'collapse' ? name : ''}
aria-disabled
>
{renderIcon()}
<span
className={classNames(
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0',
)}
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
@ -79,22 +75,18 @@ const NavLink = ({
<Link
key={name}
href={href}
className={classNames(
isActive
? 'system-sm-semibold border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
'flex h-8 items-center rounded-lg pl-3 pr-1',
)}
className={cn(isActive
? 'system-sm-semibold border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
'flex h-8 items-center rounded-lg pl-3 pr-1')}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}
<span
className={classNames(
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0',
)}
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>

View File

@ -1,7 +1,7 @@
import React from 'react'
import Button from '../base/button'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Tooltip from '../base/tooltip'
import { useTranslation } from 'react-i18next'
import { getKeyboardKeyNameBySystem } from '../workflow/utils'

View File

@ -3,7 +3,7 @@ import { RiDeleteBinLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import Divider from '@/app/components/base/divider'
import classNames from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Confirm from '@/app/components/base/confirm'
const i18nPrefix = 'appAnnotation.batchAction'
@ -38,7 +38,7 @@ const BatchAction: FC<IBatchActionProps> = ({
setIsNotDeleting()
}
return (
<div className={classNames('pointer-events-none flex w-full justify-center', className)}>
<div className={cn('pointer-events-none flex w-full justify-center', className)}>
<div className='pointer-events-auto flex items-center gap-x-1 rounded-[10px] border border-components-actionbar-border-accent bg-components-actionbar-bg-accent p-1 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]'>
<div className='inline-flex items-center gap-x-2 py-1 pl-2 pr-3'>
<span className='flex h-5 w-5 items-center justify-center rounded-md bg-text-accent px-1 py-0.5 text-xs font-medium text-text-primary-on-surface'>

View File

@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiDeleteBinLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast'
import Button from '@/app/components/base/button'

View File

@ -245,7 +245,7 @@ describe('EditItem', () => {
expect(mockSave).toHaveBeenCalledWith('Test save content')
})
it('should show delete option when content changes', async () => {
it('should show delete option and restore original content when delete is clicked', async () => {
// Arrange
const mockSave = jest.fn().mockResolvedValue(undefined)
const props = {
@ -267,7 +267,13 @@ describe('EditItem', () => {
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockSave).toHaveBeenCalledWith('Modified content')
expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content')
expect(await screen.findByText('common.operation.delete')).toBeInTheDocument()
await user.click(screen.getByText('common.operation.delete'))
expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content')
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should handle keyboard interactions in edit mode', async () => {
@ -393,5 +399,68 @@ describe('EditItem', () => {
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.getByText('Test content')).toBeInTheDocument()
})
it('should handle save failure gracefully in edit mode', async () => {
// Arrange
const mockSave = jest.fn().mockRejectedValueOnce(new Error('Save failed'))
const props = {
...defaultProps,
onSave: mockSave,
}
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
// Enter edit mode and save (should fail)
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.type(textarea, 'New content')
// Save should fail but not throw
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert - Should remain in edit mode when save fails
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
expect(mockSave).toHaveBeenCalledWith('New content')
})
it('should handle delete action failure gracefully', async () => {
// Arrange
const mockSave = jest.fn()
.mockResolvedValueOnce(undefined) // First save succeeds
.mockRejectedValueOnce(new Error('Delete failed')) // Delete fails
const props = {
...defaultProps,
onSave: mockSave,
}
const user = userEvent.setup()
// Act
render(<EditItem {...props} />)
// Edit content to show delete button
await user.click(screen.getByText('common.operation.edit'))
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Modified content')
// Save to create new content
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await screen.findByText('common.operation.delete')
// Click delete (should fail but not throw)
await user.click(screen.getByText('common.operation.delete'))
// Assert - Delete action should handle error gracefully
expect(mockSave).toHaveBeenCalledTimes(2)
expect(mockSave).toHaveBeenNthCalledWith(1, 'Modified content')
expect(mockSave).toHaveBeenNthCalledWith(2, 'Test content')
// When delete fails, the delete button should still be visible (state not changed)
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
expect(screen.getByText('Modified content')).toBeInTheDocument()
})
})
})

View File

@ -6,7 +6,7 @@ import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
import Textarea from '@/app/components/base/textarea'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
export enum EditItemType {
Query = 'query',
@ -52,8 +52,14 @@ const EditItem: FC<Props> = ({
}, [content])
const handleSave = async () => {
await onSave(newContent)
setIsEdit(false)
try {
await onSave(newContent)
setIsEdit(false)
}
catch {
// Keep edit mode open when save fails
// Error notification is handled by the parent component
}
}
const handleCancel = () => {
@ -96,9 +102,16 @@ const EditItem: FC<Props> = ({
<div className='mr-2'>·</div>
<div
className='flex cursor-pointer items-center space-x-1'
onClick={() => {
setNewContent(content)
onSave(content)
onClick={async () => {
try {
await onSave(content)
// Only update UI state after successful delete
setNewContent(content)
}
catch {
// Delete action failed - error is already handled by parent
// UI state remains unchanged, user can retry
}
}}
>
<div className='h-3.5 w-3.5'>

View File

@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Toast, { type IToastProps, type ToastHandle } from '@/app/components/base/toast'
import EditAnnotationModal from './index'
@ -408,7 +408,7 @@ describe('EditAnnotationModal', () => {
// Error Handling (CRITICAL for coverage)
describe('Error Handling', () => {
it('should handle addAnnotation API failure gracefully', async () => {
it('should show error toast and skip callbacks when addAnnotation fails', async () => {
// Arrange
const mockOnAdded = jest.fn()
const props = {
@ -420,29 +420,75 @@ describe('EditAnnotationModal', () => {
// Mock API failure
mockAddAnnotation.mockRejectedValueOnce(new Error('API Error'))
// Act & Assert - Should handle API error without crashing
expect(async () => {
render(<EditAnnotationModal {...props} />)
// Act
render(<EditAnnotationModal {...props} />)
// Find and click edit link for query
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
// Find and click edit link for query
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
// Find textarea and enter new content
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'New query content')
// Find textarea and enter new content
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'New query content')
// Click save button
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Click save button
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Should not call onAdded on error
expect(mockOnAdded).not.toHaveBeenCalled()
}).not.toThrow()
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'API Error',
type: 'error',
})
})
expect(mockOnAdded).not.toHaveBeenCalled()
// Verify edit mode remains open (textarea should still be visible)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
})
it('should handle editAnnotation API failure gracefully', async () => {
it('should show fallback error message when addAnnotation error has no message', async () => {
// Arrange
const mockOnAdded = jest.fn()
const props = {
...defaultProps,
onAdded: mockOnAdded,
}
const user = userEvent.setup()
mockAddAnnotation.mockRejectedValueOnce({})
// Act
render(<EditAnnotationModal {...props} />)
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'New query content')
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'common.api.actionFailed',
type: 'error',
})
})
expect(mockOnAdded).not.toHaveBeenCalled()
// Verify edit mode remains open (textarea should still be visible)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
})
it('should show error toast and skip callbacks when editAnnotation fails', async () => {
// Arrange
const mockOnEdited = jest.fn()
const props = {
@ -456,24 +502,72 @@ describe('EditAnnotationModal', () => {
// Mock API failure
mockEditAnnotation.mockRejectedValueOnce(new Error('API Error'))
// Act & Assert - Should handle API error without crashing
expect(async () => {
render(<EditAnnotationModal {...props} />)
// Act
render(<EditAnnotationModal {...props} />)
// Edit query content
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
// Edit query content
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Modified query')
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Modified query')
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Should not call onEdited on error
expect(mockOnEdited).not.toHaveBeenCalled()
}).not.toThrow()
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'API Error',
type: 'error',
})
})
expect(mockOnEdited).not.toHaveBeenCalled()
// Verify edit mode remains open (textarea should still be visible)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
})
it('should show fallback error message when editAnnotation error is not an Error instance', async () => {
// Arrange
const mockOnEdited = jest.fn()
const props = {
...defaultProps,
annotationId: 'test-annotation-id',
messageId: 'test-message-id',
onEdited: mockOnEdited,
}
const user = userEvent.setup()
mockEditAnnotation.mockRejectedValueOnce('oops')
// Act
render(<EditAnnotationModal {...props} />)
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Modified query')
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'common.api.actionFailed',
type: 'error',
})
})
expect(mockOnEdited).not.toHaveBeenCalled()
// Verify edit mode remains open (textarea should still be visible)
expect(screen.getByRole('textbox')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeInTheDocument()
})
})
@ -526,25 +620,33 @@ describe('EditAnnotationModal', () => {
})
})
// Toast Notifications (Simplified)
// Toast Notifications (Success)
describe('Toast Notifications', () => {
it('should trigger success notification when save operation completes', async () => {
it('should show success notification when save operation completes', async () => {
// Arrange
const mockOnAdded = jest.fn()
const props = {
...defaultProps,
onAdded: mockOnAdded,
}
const props = { ...defaultProps }
const user = userEvent.setup()
// Act
render(<EditAnnotationModal {...props} />)
// Simulate successful save by calling handleSave indirectly
const mockSave = jest.fn()
expect(mockSave).not.toHaveBeenCalled()
const editLinks = screen.getAllByText(/common\.operation\.edit/i)
await user.click(editLinks[0])
// Assert - Toast spy is available and will be called during real save operations
expect(toastNotifySpy).toBeDefined()
const textarea = screen.getByRole('textbox')
await user.clear(textarea)
await user.type(textarea, 'Updated query')
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
// Assert
await waitFor(() => {
expect(toastNotifySpy).toHaveBeenCalledWith({
message: 'common.api.actionSuccess',
type: 'success',
})
})
})
})

View File

@ -53,27 +53,39 @@ const EditAnnotationModal: FC<Props> = ({
postQuery = editedContent
else
postAnswer = editedContent
if (!isAdd) {
await editAnnotation(appId, annotationId, {
message_id: messageId,
question: postQuery,
answer: postAnswer,
})
onEdited(postQuery, postAnswer)
}
else {
const res: any = await addAnnotation(appId, {
question: postQuery,
answer: postAnswer,
message_id: messageId,
})
onAdded(res.id, res.account?.name, postQuery, postAnswer)
}
try {
if (!isAdd) {
await editAnnotation(appId, annotationId, {
message_id: messageId,
question: postQuery,
answer: postAnswer,
})
onEdited(postQuery, postAnswer)
}
else {
const res = await addAnnotation(appId, {
question: postQuery,
answer: postAnswer,
message_id: messageId,
})
onAdded(res.id, res.account?.name ?? '', postQuery, postAnswer)
}
Toast.notify({
message: t('common.api.actionSuccess') as string,
type: 'success',
})
Toast.notify({
message: t('common.api.actionSuccess') as string,
type: 'success',
})
}
catch (error) {
const fallbackMessage = t('common.api.actionFailed') as string
const message = error instanceof Error && error.message ? error.message : fallbackMessage
Toast.notify({
message,
type: 'error',
})
// Re-throw to preserve edit mode behavior for UI components
throw error
}
}
const [showModal, setShowModal] = useState(false)

View File

@ -1,3 +1,4 @@
import * as React from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ComponentProps } from 'react'
@ -7,6 +8,120 @@ import { LanguagesSupported } from '@/i18n-config/language'
import type { AnnotationItemBasic } from '../type'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
jest.mock('@headlessui/react', () => {
type PopoverContextValue = { open: boolean; setOpen: (open: boolean) => void }
type MenuContextValue = { open: boolean; setOpen: (open: boolean) => void }
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
const MenuContext = React.createContext<MenuContextValue | null>(null)
const Popover = ({ children }: { children: React.ReactNode | ((props: { open: boolean }) => React.ReactNode) }) => {
const [open, setOpen] = React.useState(false)
const value = React.useMemo(() => ({ open, setOpen }), [open])
return (
<PopoverContext.Provider value={value}>
{typeof children === 'function' ? children({ open }) : children}
</PopoverContext.Provider>
)
}
const PopoverButton = React.forwardRef(({ onClick, children, ...props }: { onClick?: () => void; children?: React.ReactNode }, ref: React.Ref<HTMLButtonElement>) => {
const context = React.useContext(PopoverContext)
const handleClick = () => {
context?.setOpen(!context.open)
onClick?.()
}
return (
<button
ref={ref}
type="button"
aria-expanded={context?.open ?? false}
onClick={handleClick}
{...props}
>
{children}
</button>
)
})
const PopoverPanel = React.forwardRef(({ children, ...props }: { children: React.ReactNode | ((props: { close: () => void }) => React.ReactNode) }, ref: React.Ref<HTMLDivElement>) => {
const context = React.useContext(PopoverContext)
if (!context?.open) return null
const content = typeof children === 'function' ? children({ close: () => context.setOpen(false) }) : children
return (
<div ref={ref} {...props}>
{content}
</div>
)
})
const Menu = ({ children }: { children: React.ReactNode }) => {
const [open, setOpen] = React.useState(false)
const value = React.useMemo(() => ({ open, setOpen }), [open])
return (
<MenuContext.Provider value={value}>
{children}
</MenuContext.Provider>
)
}
const MenuButton = ({ onClick, children, ...props }: { onClick?: () => void; children?: React.ReactNode }) => {
const context = React.useContext(MenuContext)
const handleClick = () => {
context?.setOpen(!context.open)
onClick?.()
}
return (
<button type="button" aria-expanded={context?.open ?? false} onClick={handleClick} {...props}>
{children}
</button>
)
}
const MenuItems = ({ children, ...props }: { children: React.ReactNode }) => {
const context = React.useContext(MenuContext)
if (!context?.open) return null
return (
<div {...props}>
{children}
</div>
)
}
return {
Dialog: ({ open, children, className }: { open?: boolean; children: React.ReactNode; className?: string }) => {
if (open === false) return null
return (
<div role="dialog" className={className}>
{children}
</div>
)
},
DialogBackdrop: ({ children, className, onClick }: { children?: React.ReactNode; className?: string; onClick?: () => void }) => (
<div className={className} onClick={onClick}>
{children}
</div>
),
DialogPanel: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
DialogTitle: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
Popover,
PopoverButton,
PopoverPanel,
Menu,
MenuButton,
MenuItems,
Transition: ({ show = true, children }: { show?: boolean; children: React.ReactNode }) => (show ? <>{children}</> : null),
TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}
})
let lastCSVDownloaderProps: Record<string, unknown> | undefined
const mockCSVDownloader = jest.fn(({ children, ...props }) => {
lastCSVDownloaderProps = props
@ -121,6 +236,7 @@ const mockedClearAllAnnotations = jest.mocked(clearAllAnnotations)
describe('HeaderOptions', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.useRealTimers()
mockCSVDownloader.mockClear()
lastCSVDownloaderProps = undefined
mockedFetchAnnotations.mockResolvedValue({ data: [] })

View File

@ -17,7 +17,7 @@ import Button from '../../../base/button'
import AddAnnotationModal from '../add-annotation-modal'
import type { AnnotationItemBasic } from '../type'
import BatchAddModal from '../batch-add-annotation-modal'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import CustomPopover from '@/app/components/base/popover'
import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'

View File

@ -25,7 +25,7 @@ import { sleep } from '@/utils'
import { useProviderContext } from '@/context/provider-context'
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
import { type App, AppModeEnum } from '@/types/app'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { delAnnotations } from '@/service/annotation'
type Props = {

View File

@ -7,7 +7,7 @@ import type { AnnotationItem } from './type'
import RemoveAnnotationConfirmModal from './remove-annotation-confirm-modal'
import ActionButton from '@/app/components/base/action-button'
import useTimestamp from '@/hooks/use-timestamp'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Checkbox from '@/app/components/base/checkbox'
import BatchAction from './batch-action'

View File

@ -12,6 +12,12 @@ export type AnnotationItem = {
hit_count: number
}
export type AnnotationCreateResponse = AnnotationItem & {
account?: {
name?: string
}
}
export type HitHistoryItem = {
id: string
question: string

View File

@ -14,7 +14,7 @@ import TabSlider from '@/app/components/base/tab-slider-plain'
import { fetchHitHistoryList } from '@/service/annotation'
import { APP_PAGE_LIMIT } from '@/config'
import useTimestamp from '@/hooks/use-timestamp'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
type Props = {
appId: string

View File

@ -2,7 +2,7 @@ import { Fragment, useCallback } from 'react'
import type { ReactNode } from 'react'
import { Dialog, Transition } from '@headlessui/react'
import { RiCloseLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
type DialogProps = {
className?: string

View File

@ -11,7 +11,7 @@ import Input from '../../base/input'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import Loading from '../../base/loading'
import useAccessControlStore from '../../../../context/access-control-store'
import classNames from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
import { SubjectType } from '@/models/access-control'
@ -106,7 +106,7 @@ function SelectedGroupsBreadCrumb() {
setSelectedGroupsForBreadcrumb([])
}, [setSelectedGroupsForBreadcrumb])
return <div className='flex h-7 items-center gap-x-0.5 px-2 py-0.5'>
<span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
<span className={cn('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
{selectedGroupsForBreadcrumb.map((group, index) => {
return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'>
<span>/</span>
@ -198,7 +198,7 @@ type BaseItemProps = {
children: React.ReactNode
}
function BaseItem({ children, className }: BaseItemProps) {
return <div className={classNames('flex cursor-pointer items-center space-x-2 p-1 pl-2 hover:rounded-lg hover:bg-state-base-hover', className)}>
return <div className={cn('flex cursor-pointer items-center space-x-2 p-1 pl-2 hover:rounded-lg hover:bg-state-base-hover', className)}>
{children}
</div>
}

View File

@ -1,6 +1,6 @@
import type { HTMLProps, PropsWithChildren } from 'react'
import { RiArrowRightUpLine } from '@remixicon/react'
import classNames from '@/utils/classnames'
import { cn } from '@/utils/classnames'
export type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & {
icon?: React.ReactNode
@ -19,11 +19,9 @@ const SuggestedAction = ({ icon, link, disabled, children, className, onClick, .
href={disabled ? undefined : link}
target='_blank'
rel='noreferrer'
className={classNames(
'flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
className={cn('flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer text-text-secondary hover:bg-state-accent-hover hover:text-text-accent',
className,
)}
className)}
onClick={handleClick}
{...props}
>

View File

@ -1,7 +1,7 @@
'use client'
import type { FC, ReactNode } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
export type IFeaturePanelProps = {
className?: string

View File

@ -6,7 +6,7 @@ import {
RiAddLine,
RiEditLine,
} from '@remixicon/react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
export type IOperationBtnProps = {

View File

@ -14,7 +14,7 @@ import s from './style.module.css'
import MessageTypeSelector from './message-type-selector'
import ConfirmAddVar from './confirm-add-var'
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import type { PromptRole, PromptVariable } from '@/models/debug'
import {
Copy,

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import { useBoolean, useClickAway } from 'ahooks'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { PromptRole } from '@/models/debug'
import { ChevronSelectorVertical } from '@/app/components/base/icons/src/vender/line/arrows'
type Props = {

View File

@ -2,7 +2,7 @@
import React, { useCallback, useEffect, useState } from 'react'
import type { FC } from 'react'
import { useDebounceFn } from 'ahooks'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
type Props = {
className?: string

View File

@ -7,7 +7,7 @@ import { produce } from 'immer'
import { useContext } from 'use-context-selector'
import ConfirmAddVar from './confirm-add-var'
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import type { PromptVariable } from '@/models/debug'
import Tooltip from '@/app/components/base/tooltip'
import { AppModeEnum } from '@/types/app'

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
type Props = {

View File

@ -2,7 +2,6 @@
import type { FC } from 'react'
import React, { useState } from 'react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@ -10,7 +9,7 @@ import {
} from '@/app/components/base/portal-to-follow-elem'
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
import type { InputVarType } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Badge from '@/app/components/base/badge'
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
@ -47,7 +46,7 @@ const TypeSelector: FC<Props> = ({
>
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)} className='w-full'>
<div
className={classNames(`group flex h-9 items-center justify-between rounded-lg border-0 bg-components-input-bg-normal px-2 text-sm hover:bg-state-base-hover-alt ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}`)}
className={cn(`group flex h-9 items-center justify-between rounded-lg border-0 bg-components-input-bg-normal px-2 text-sm hover:bg-state-base-hover-alt ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}`)}
title={selectedItem?.name}
>
<div className='flex items-center'>
@ -69,7 +68,7 @@ const TypeSelector: FC<Props> = ({
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[61]'>
<div
className={classNames('w-[432px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-none sm:text-sm', popupInnerClassName)}
className={cn('w-[432px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-none sm:text-sm', popupInnerClassName)}
>
{items.map((item: Item) => (
<div

View File

@ -4,7 +4,7 @@ import React, { useState } from 'react'
import { RiAddLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { ReactSortable } from 'react-sortablejs'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
export type Options = string[]
export type IConfigSelectProps = {

View File

@ -23,7 +23,7 @@ import { useModalContext } from '@/context/modal-context'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import type { InputVar } from '@/app/components/workflow/types'
import { InputVarType } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
export const ADD_EXTERNAL_DATA_TOOL = 'ADD_EXTERNAL_DATA_TOOL'

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import type { InputVarType } from '@/app/components/workflow/types'
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
export type ISelectTypeItemProps = {

View File

@ -10,7 +10,7 @@ import type { IInputTypeIconProps } from './input-type-icon'
import IconTypeIcon from './input-type-icon'
import { BracketsX as VarIcon } from '@/app/components/base/icons/src/vender/line/development'
import Badge from '@/app/components/base/badge'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
type ItemProps = {
className?: string

View File

@ -0,0 +1,227 @@
import React from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfigVision from './index'
import ParamConfig from './param-config'
import ParamConfigContent from './param-config-content'
import type { FeatureStoreState } from '@/app/components/base/features/store'
import type { FileUpload } from '@/app/components/base/features/types'
import { Resolution, TransferMethod } from '@/types/app'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
const mockUseContext = jest.fn()
jest.mock('use-context-selector', () => {
const actual = jest.requireActual('use-context-selector')
return {
...actual,
useContext: (context: unknown) => mockUseContext(context),
}
})
const mockUseFeatures = jest.fn()
const mockUseFeaturesStore = jest.fn()
jest.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
const defaultFile: FileUpload = {
enabled: false,
allowed_file_types: [],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
number_limits: 3,
image: {
enabled: false,
detail: Resolution.low,
number_limits: 3,
transfer_methods: [TransferMethod.local_file, TransferMethod.remote_url],
},
}
let featureStoreState: FeatureStoreState
let setFeaturesMock: jest.Mock
const setupFeatureStore = (fileOverrides: Partial<FileUpload> = {}) => {
const mergedFile: FileUpload = {
...defaultFile,
...fileOverrides,
image: {
...defaultFile.image,
...fileOverrides.image,
},
}
featureStoreState = {
features: {
file: mergedFile,
},
setFeatures: jest.fn(),
showFeaturesModal: false,
setShowFeaturesModal: jest.fn(),
}
setFeaturesMock = featureStoreState.setFeatures as jest.Mock
mockUseFeaturesStore.mockReturnValue({
getState: () => featureStoreState,
})
mockUseFeatures.mockImplementation(selector => selector(featureStoreState))
}
const getLatestFileConfig = () => {
expect(setFeaturesMock).toHaveBeenCalled()
const latestFeatures = setFeaturesMock.mock.calls[setFeaturesMock.mock.calls.length - 1][0] as { file: FileUpload }
return latestFeatures.file
}
beforeEach(() => {
jest.clearAllMocks()
mockUseContext.mockReturnValue({
isShowVisionConfig: true,
isAllowVideoUpload: false,
})
setupFeatureStore()
})
// ConfigVision handles toggling file upload types + visibility rules.
describe('ConfigVision', () => {
it('should not render when vision configuration is hidden', () => {
mockUseContext.mockReturnValue({
isShowVisionConfig: false,
isAllowVideoUpload: false,
})
render(<ConfigVision />)
expect(screen.queryByText('appDebug.vision.name')).not.toBeInTheDocument()
})
it('should show the toggle and parameter controls when visible', () => {
render(<ConfigVision />)
expect(screen.getByText('appDebug.vision.name')).toBeInTheDocument()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
})
it('should enable both image and video uploads when toggled on with video support', async () => {
const user = userEvent.setup()
mockUseContext.mockReturnValue({
isShowVisionConfig: true,
isAllowVideoUpload: true,
})
setupFeatureStore({
allowed_file_types: [],
})
render(<ConfigVision />)
await user.click(screen.getByRole('switch'))
const updatedFile = getLatestFileConfig()
expect(updatedFile.allowed_file_types).toEqual([SupportUploadFileTypes.image, SupportUploadFileTypes.video])
expect(updatedFile.image?.enabled).toBe(true)
expect(updatedFile.enabled).toBe(true)
})
it('should disable image and video uploads when toggled off and no other types remain', async () => {
const user = userEvent.setup()
mockUseContext.mockReturnValue({
isShowVisionConfig: true,
isAllowVideoUpload: true,
})
setupFeatureStore({
allowed_file_types: [SupportUploadFileTypes.image, SupportUploadFileTypes.video],
enabled: true,
image: {
enabled: true,
},
})
render(<ConfigVision />)
await user.click(screen.getByRole('switch'))
const updatedFile = getLatestFileConfig()
expect(updatedFile.allowed_file_types).toEqual([])
expect(updatedFile.enabled).toBe(false)
expect(updatedFile.image?.enabled).toBe(false)
})
it('should keep file uploads enabled when other file types remain after disabling vision', async () => {
const user = userEvent.setup()
mockUseContext.mockReturnValue({
isShowVisionConfig: true,
isAllowVideoUpload: false,
})
setupFeatureStore({
allowed_file_types: [SupportUploadFileTypes.image, SupportUploadFileTypes.document],
enabled: true,
image: { enabled: true },
})
render(<ConfigVision />)
await user.click(screen.getByRole('switch'))
const updatedFile = getLatestFileConfig()
expect(updatedFile.allowed_file_types).toEqual([SupportUploadFileTypes.document])
expect(updatedFile.enabled).toBe(true)
expect(updatedFile.image?.enabled).toBe(false)
})
})
// ParamConfig exposes ParamConfigContent via an inline trigger.
describe('ParamConfig', () => {
it('should toggle parameter panel when clicking the settings button', async () => {
setupFeatureStore()
const user = userEvent.setup()
render(<ParamConfig />)
expect(screen.queryByText('appDebug.vision.visionSettings.title')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'appDebug.voice.settings' }))
expect(await screen.findByText('appDebug.vision.visionSettings.title')).toBeInTheDocument()
})
})
// ParamConfigContent manages resolution, upload source, and count limits.
describe('ParamConfigContent', () => {
it('should set resolution to high when the corresponding option is selected', async () => {
const user = userEvent.setup()
setupFeatureStore({
image: { detail: Resolution.low },
})
render(<ParamConfigContent />)
await user.click(screen.getByText('appDebug.vision.visionSettings.high'))
const updatedFile = getLatestFileConfig()
expect(updatedFile.image?.detail).toBe(Resolution.high)
})
it('should switch upload method to local only', async () => {
const user = userEvent.setup()
setupFeatureStore({
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
})
render(<ParamConfigContent />)
await user.click(screen.getByText('appDebug.vision.visionSettings.localUpload'))
const updatedFile = getLatestFileConfig()
expect(updatedFile.allowed_file_upload_methods).toEqual([TransferMethod.local_file])
expect(updatedFile.image?.transfer_methods).toEqual([TransferMethod.local_file])
})
it('should update upload limit value when input changes', async () => {
setupFeatureStore({
number_limits: 2,
})
render(<ParamConfigContent />)
const input = screen.getByRole('spinbutton') as HTMLInputElement
fireEvent.change(input, { target: { value: '4' } })
const updatedFile = getLatestFileConfig()
expect(updatedFile.number_limits).toBe(4)
expect(updatedFile.image?.number_limits).toBe(4)
})
})

View File

@ -10,7 +10,7 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
const ParamsConfig: FC = () => {
const { t } = useTranslation()

View File

@ -0,0 +1,100 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AgentSettingButton from './agent-setting-button'
import type { AgentConfig } from '@/models/debug'
import { AgentStrategy } from '@/types/app'
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
let latestAgentSettingProps: any
jest.mock('./agent/agent-setting', () => ({
__esModule: true,
default: (props: any) => {
latestAgentSettingProps = props
return (
<div data-testid="agent-setting">
<button onClick={() => props.onSave({ ...props.payload, max_iteration: 9 })}>
save-agent
</button>
<button onClick={props.onCancel}>
cancel-agent
</button>
</div>
)
},
}))
const createAgentConfig = (overrides: Partial<AgentConfig> = {}): AgentConfig => ({
enabled: true,
strategy: AgentStrategy.react,
max_iteration: 3,
tools: [],
...overrides,
})
const setup = (overrides: Partial<React.ComponentProps<typeof AgentSettingButton>> = {}) => {
const props: React.ComponentProps<typeof AgentSettingButton> = {
isFunctionCall: false,
isChatModel: true,
onAgentSettingChange: jest.fn(),
agentConfig: createAgentConfig(),
...overrides,
}
const user = userEvent.setup()
render(<AgentSettingButton {...props} />)
return { props, user }
}
beforeEach(() => {
jest.clearAllMocks()
latestAgentSettingProps = undefined
})
describe('AgentSettingButton', () => {
it('should render button label from translation key', () => {
setup()
expect(screen.getByRole('button', { name: 'appDebug.agent.setting.name' })).toBeInTheDocument()
})
it('should open AgentSetting with the provided configuration when clicked', async () => {
const { user, props } = setup({ isFunctionCall: true, isChatModel: false })
await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
expect(screen.getByTestId('agent-setting')).toBeInTheDocument()
expect(latestAgentSettingProps.isFunctionCall).toBe(true)
expect(latestAgentSettingProps.isChatModel).toBe(false)
expect(latestAgentSettingProps.payload).toEqual(props.agentConfig)
})
it('should call onAgentSettingChange and close when AgentSetting saves', async () => {
const { user, props } = setup()
await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
await user.click(screen.getByText('save-agent'))
expect(props.onAgentSettingChange).toHaveBeenCalledTimes(1)
expect(props.onAgentSettingChange).toHaveBeenCalledWith({
...props.agentConfig,
max_iteration: 9,
})
expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument()
})
it('should close AgentSetting without saving when cancel is triggered', async () => {
const { user, props } = setup()
await user.click(screen.getByRole('button', { name: 'appDebug.agent.setting.name' }))
await user.click(screen.getByText('cancel-agent'))
expect(props.onAgentSettingChange).not.toHaveBeenCalled()
expect(screen.queryByTestId('agent-setting')).not.toBeInTheDocument()
})
})

View File

@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
type Props = {
className?: string

View File

@ -25,7 +25,7 @@ import { MAX_TOOLS_NUM } from '@/config'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
import { canFindTool } from '@/utils'

View File

@ -22,7 +22,7 @@ import { CollectionType } from '@/app/components/tools/types'
import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import {
AuthCategory,

View File

@ -4,7 +4,7 @@ import React from 'react'
import copy from 'copy-to-clipboard'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import {
Copy,
CopyCheck,

View File

@ -5,31 +5,6 @@ import AssistantTypePicker from './index'
import type { AgentConfig } from '@/models/debug'
import { AgentStrategy } from '@/types/app'
// Type definition for AgentSetting props
type AgentSettingProps = {
isChatModel: boolean
payload: AgentConfig
isFunctionCall: boolean
onCancel: () => void
onSave: (payload: AgentConfig) => void
}
// Track mock calls for props validation
let mockAgentSettingProps: AgentSettingProps | null = null
// Mock AgentSetting component (complex modal with external hooks)
jest.mock('../agent/agent-setting', () => {
return function MockAgentSetting(props: AgentSettingProps) {
mockAgentSettingProps = props
return (
<div data-testid="agent-setting-modal">
<button onClick={() => props.onSave({ max_iteration: 5 } as AgentConfig)}>Save</button>
<button onClick={props.onCancel}>Cancel</button>
</div>
)
}
})
// Test utilities
const defaultAgentConfig: AgentConfig = {
enabled: true,
@ -62,7 +37,6 @@ const getOptionByDescription = (descriptionRegex: RegExp) => {
describe('AssistantTypePicker', () => {
beforeEach(() => {
jest.clearAllMocks()
mockAgentSettingProps = null
})
// Rendering tests (REQUIRED)
@ -139,8 +113,8 @@ describe('AssistantTypePicker', () => {
renderComponent()
// Act
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Both options should be visible
await waitFor(() => {
@ -225,8 +199,8 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'chat' })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown and select agent
await waitFor(() => {
@ -235,7 +209,7 @@ describe('AssistantTypePicker', () => {
})
const agentOptions = screen.getAllByText(/agentAssistant.name/i)
await user.click(agentOptions[0].closest('div')!)
await user.click(agentOptions[0])
// Assert - Dropdown should remain open (agent settings should be visible)
await waitFor(() => {
@ -250,8 +224,8 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'chat', onChange })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown and click same option
await waitFor(() => {
@ -260,7 +234,7 @@ describe('AssistantTypePicker', () => {
})
const chatOptions = screen.getAllByText(/chatAssistant.name/i)
await user.click(chatOptions[1].closest('div')!)
await user.click(chatOptions[1])
// Assert
expect(onChange).not.toHaveBeenCalled()
@ -276,8 +250,8 @@ describe('AssistantTypePicker', () => {
renderComponent({ disabled: true, onChange })
// Act - Open dropdown (dropdown can still open when disabled)
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown to open
await waitFor(() => {
@ -298,8 +272,8 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'agent', disabled: true })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Assert - Agent settings option should not be visible
await waitFor(() => {
@ -313,8 +287,8 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Assert - Agent settings option should be visible
await waitFor(() => {
@ -331,20 +305,20 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
// Click agent settings
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
})
})
@ -354,8 +328,8 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'chat', disabled: false })
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Wait for dropdown to open
await waitFor(() => {
@ -363,7 +337,7 @@ describe('AssistantTypePicker', () => {
})
// Assert - Agent settings modal should not appear (value is 'chat')
expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument()
expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument()
})
it('should call onAgentSettingChange when saving agent settings', async () => {
@ -373,26 +347,26 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
// Act - Open dropdown and agent settings
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Wait for modal and click save
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
})
const saveButton = screen.getByText('Save')
const saveButton = screen.getByText(/common.operation.save/i)
await user.click(saveButton)
// Assert
expect(onAgentSettingChange).toHaveBeenCalledWith({ max_iteration: 5 })
expect(onAgentSettingChange).toHaveBeenCalledWith(defaultAgentConfig)
})
it('should close modal when saving agent settings', async () => {
@ -401,26 +375,26 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown, agent settings, and save
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
expect(screen.getByText(/appDebug.agent.setting.name/i)).toBeInTheDocument()
})
const saveButton = screen.getByText('Save')
const saveButton = screen.getByText(/common.operation.save/i)
await user.click(saveButton)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument()
expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument()
})
})
@ -431,26 +405,26 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'agent', disabled: false, onAgentSettingChange })
// Act - Open dropdown, agent settings, and cancel
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
})
const cancelButton = screen.getByText('Cancel')
const cancelButton = screen.getByText(/common.operation.cancel/i)
await user.click(cancelButton)
// Assert
await waitFor(() => {
expect(screen.queryByTestId('agent-setting-modal')).not.toBeInTheDocument()
expect(screen.queryByText(/common.operation.save/i)).not.toBeInTheDocument()
})
expect(onAgentSettingChange).not.toHaveBeenCalled()
})
@ -461,19 +435,19 @@ describe('AssistantTypePicker', () => {
renderComponent({ value: 'agent', disabled: false })
// Act - Open dropdown and agent settings
const trigger = screen.getByText(/agentAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i).closest('div')
await user.click(agentSettingsTrigger!)
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert - Modal should be open and dropdown should close
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
})
// The dropdown should be closed (agent settings description should not be visible)
@ -492,10 +466,10 @@ describe('AssistantTypePicker', () => {
renderComponent()
// Act
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
await user.click(trigger!)
await user.click(trigger!)
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
await user.click(trigger)
await user.click(trigger)
// Assert - Should not crash
expect(trigger).toBeInTheDocument()
@ -538,8 +512,8 @@ describe('AssistantTypePicker', () => {
})
}).not.toThrow()
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
})
it('should handle empty agentConfig', async () => {
@ -630,8 +604,8 @@ describe('AssistantTypePicker', () => {
renderComponent()
// Act - Open dropdown
const trigger = screen.getByText(/chatAssistant.name/i).closest('div')
await user.click(trigger!)
const trigger = screen.getByText(/chatAssistant.name/i)
await user.click(trigger)
// Assert - Descriptions should be visible
await waitFor(() => {
@ -657,18 +631,14 @@ describe('AssistantTypePicker', () => {
})
})
// Props Validation for AgentSetting
describe('AgentSetting Props', () => {
it('should pass isFunctionCall and isChatModel props to AgentSetting', async () => {
// Agent Setting Integration
describe('AgentSetting Integration', () => {
it('should show function call mode when isFunctionCall is true', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({
value: 'agent',
isFunctionCall: true,
isChatModel: false,
})
renderComponent({ value: 'agent', isFunctionCall: true, isChatModel: false })
// Act - Open dropdown and trigger AgentSetting
// Act - Open dropdown and settings modal
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
@ -679,17 +649,37 @@ describe('AssistantTypePicker', () => {
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert - Verify AgentSetting receives correct props
// Assert
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
})
expect(mockAgentSettingProps).not.toBeNull()
expect(mockAgentSettingProps!.isFunctionCall).toBe(true)
expect(mockAgentSettingProps!.isChatModel).toBe(false)
expect(screen.getByText(/appDebug.agent.agentModeType.functionCall/i)).toBeInTheDocument()
})
it('should pass agentConfig payload to AgentSetting', async () => {
it('should show built-in prompt when isFunctionCall is false', async () => {
// Arrange
const user = userEvent.setup()
renderComponent({ value: 'agent', isFunctionCall: false, isChatModel: true })
// Act - Open dropdown and settings modal
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/agent.setting.name/i)).toBeInTheDocument()
})
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert
await waitFor(() => {
expect(screen.getByText(/common.operation.save/i)).toBeInTheDocument()
})
expect(screen.getByText(/tools.builtInPromptTitle/i)).toBeInTheDocument()
})
it('should initialize max iteration from agentConfig payload', async () => {
// Arrange
const user = userEvent.setup()
const customConfig: AgentConfig = {
@ -699,12 +689,9 @@ describe('AssistantTypePicker', () => {
tools: [],
}
renderComponent({
value: 'agent',
agentConfig: customConfig,
})
renderComponent({ value: 'agent', agentConfig: customConfig })
// Act - Open AgentSetting
// Act - Open dropdown and settings modal
const trigger = screen.getByText(/agentAssistant.name/i)
await user.click(trigger)
@ -715,13 +702,10 @@ describe('AssistantTypePicker', () => {
const agentSettingsTrigger = screen.getByText(/agent.setting.name/i)
await user.click(agentSettingsTrigger)
// Assert - Verify payload was passed
await waitFor(() => {
expect(screen.getByTestId('agent-setting-modal')).toBeInTheDocument()
})
expect(mockAgentSettingProps).not.toBeNull()
expect(mockAgentSettingProps!.payload).toEqual(customConfig)
// Assert
await screen.findByText(/common.operation.save/i)
const maxIterationInput = await screen.findByRole('spinbutton')
expect(maxIterationInput).toHaveValue(10)
})
})

View File

@ -4,7 +4,7 @@ import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiArrowDownSLine } from '@remixicon/react'
import AgentSetting from '../agent/agent-setting'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,

View File

@ -3,7 +3,7 @@ import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid
import { useBoolean } from 'ahooks'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Textarea from '@/app/components/base/textarea'
import { useTranslation } from 'react-i18next'

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import React from 'react'
import PromptEditor from '@/app/components/base/prompt-editor'
import type { GeneratorType } from './types'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { useTranslation } from 'react-i18next'

View File

@ -1,7 +1,7 @@
import { RiArrowDownSLine, RiSparklingFill } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import React from 'react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { Markdown } from '@/app/components/base/markdown'
import { useTranslation } from 'react-i18next'
import s from './style.module.css'

View File

@ -1,7 +1,7 @@
import React, { useCallback } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import { useBoolean } from 'ahooks'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'

View File

@ -0,0 +1,123 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfigAudio from './config-audio'
import type { FeatureStoreState } from '@/app/components/base/features/store'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
const mockUseContext = jest.fn()
jest.mock('use-context-selector', () => {
const actual = jest.requireActual('use-context-selector')
return {
...actual,
useContext: (context: unknown) => mockUseContext(context),
}
})
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
const mockUseFeatures = jest.fn()
const mockUseFeaturesStore = jest.fn()
jest.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
type SetupOptions = {
isVisible?: boolean
allowedTypes?: SupportUploadFileTypes[]
}
let mockFeatureStoreState: FeatureStoreState
let mockSetFeatures: jest.Mock
const mockStore = {
getState: jest.fn<FeatureStoreState, []>(() => mockFeatureStoreState),
}
const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => {
mockSetFeatures = jest.fn()
mockFeatureStoreState = {
features: {
file: {
allowed_file_types: allowedTypes,
enabled: allowedTypes.length > 0,
},
},
setFeatures: mockSetFeatures,
showFeaturesModal: false,
setShowFeaturesModal: jest.fn(),
}
mockStore.getState.mockImplementation(() => mockFeatureStoreState)
mockUseFeaturesStore.mockReturnValue(mockStore)
mockUseFeatures.mockImplementation(selector => selector(mockFeatureStoreState))
}
const renderConfigAudio = (options: SetupOptions = {}) => {
const {
isVisible = true,
allowedTypes = [],
} = options
setupFeatureStore(allowedTypes)
mockUseContext.mockReturnValue({
isShowAudioConfig: isVisible,
})
const user = userEvent.setup()
render(<ConfigAudio />)
return {
user,
setFeatures: mockSetFeatures,
}
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('ConfigAudio', () => {
it('should not render when the audio configuration is hidden', () => {
renderConfigAudio({ isVisible: false })
expect(screen.queryByText('appDebug.feature.audioUpload.title')).not.toBeInTheDocument()
})
it('should display the audio toggle state based on feature store data', () => {
renderConfigAudio({ allowedTypes: [SupportUploadFileTypes.audio] })
expect(screen.getByText('appDebug.feature.audioUpload.title')).toBeInTheDocument()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('should enable audio uploads when toggled on', async () => {
const { user, setFeatures } = renderConfigAudio()
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({
file: expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.audio],
enabled: true,
}),
}))
})
it('should disable audio uploads and turn off file feature when last type is removed', async () => {
const { user, setFeatures } = renderConfigAudio({ allowedTypes: [SupportUploadFileTypes.audio] })
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-checked', 'true')
await user.click(toggle)
expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({
file: expect.objectContaining({
allowed_file_types: [],
enabled: false,
}),
}))
})
})

View File

@ -0,0 +1,119 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ConfigDocument from './config-document'
import type { FeatureStoreState } from '@/app/components/base/features/store'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
const mockUseContext = jest.fn()
jest.mock('use-context-selector', () => {
const actual = jest.requireActual('use-context-selector')
return {
...actual,
useContext: (context: unknown) => mockUseContext(context),
}
})
const mockUseFeatures = jest.fn()
const mockUseFeaturesStore = jest.fn()
jest.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: FeatureStoreState) => any) => mockUseFeatures(selector),
useFeaturesStore: () => mockUseFeaturesStore(),
}))
type SetupOptions = {
isVisible?: boolean
allowedTypes?: SupportUploadFileTypes[]
}
let mockFeatureStoreState: FeatureStoreState
let mockSetFeatures: jest.Mock
const mockStore = {
getState: jest.fn<FeatureStoreState, []>(() => mockFeatureStoreState),
}
const setupFeatureStore = (allowedTypes: SupportUploadFileTypes[] = []) => {
mockSetFeatures = jest.fn()
mockFeatureStoreState = {
features: {
file: {
allowed_file_types: allowedTypes,
enabled: allowedTypes.length > 0,
},
},
setFeatures: mockSetFeatures,
showFeaturesModal: false,
setShowFeaturesModal: jest.fn(),
}
mockStore.getState.mockImplementation(() => mockFeatureStoreState)
mockUseFeaturesStore.mockReturnValue(mockStore)
mockUseFeatures.mockImplementation(selector => selector(mockFeatureStoreState))
}
const renderConfigDocument = (options: SetupOptions = {}) => {
const {
isVisible = true,
allowedTypes = [],
} = options
setupFeatureStore(allowedTypes)
mockUseContext.mockReturnValue({
isShowDocumentConfig: isVisible,
})
const user = userEvent.setup()
render(<ConfigDocument />)
return {
user,
setFeatures: mockSetFeatures,
}
}
beforeEach(() => {
jest.clearAllMocks()
})
describe('ConfigDocument', () => {
it('should not render when the document configuration is hidden', () => {
renderConfigDocument({ isVisible: false })
expect(screen.queryByText('appDebug.feature.documentUpload.title')).not.toBeInTheDocument()
})
it('should show document toggle badge when configuration is visible', () => {
renderConfigDocument({ allowedTypes: [SupportUploadFileTypes.document] })
expect(screen.getByText('appDebug.feature.documentUpload.title')).toBeInTheDocument()
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
})
it('should add document type to allowed list when toggled on', async () => {
const { user, setFeatures } = renderConfigDocument({ allowedTypes: [SupportUploadFileTypes.audio] })
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-checked', 'false')
await user.click(toggle)
expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({
file: expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.audio, SupportUploadFileTypes.document],
enabled: true,
}),
}))
})
it('should remove document type but keep file feature enabled when other types remain', async () => {
const { user, setFeatures } = renderConfigDocument({
allowedTypes: [SupportUploadFileTypes.document, SupportUploadFileTypes.audio],
})
const toggle = screen.getByRole('switch')
expect(toggle).toHaveAttribute('aria-checked', 'true')
await user.click(toggle)
expect(setFeatures).toHaveBeenCalledWith(expect.objectContaining({
file: expect.objectContaining({
allowed_file_types: [SupportUploadFileTypes.audio],
enabled: true,
}),
}))
})
})

View File

@ -0,0 +1,254 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import Config from './index'
import type { ModelConfig, PromptVariable } from '@/models/debug'
import * as useContextSelector from 'use-context-selector'
import type { ToolItem } from '@/types/app'
import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app'
jest.mock('use-context-selector', () => {
const actual = jest.requireActual('use-context-selector')
return {
...actual,
useContext: jest.fn(),
}
})
const mockFormattingDispatcher = jest.fn()
jest.mock('../debug/hooks', () => ({
__esModule: true,
useFormattingChangedDispatcher: () => mockFormattingDispatcher,
}))
let latestConfigPromptProps: any
jest.mock('@/app/components/app/configuration/config-prompt', () => ({
__esModule: true,
default: (props: any) => {
latestConfigPromptProps = props
return <div data-testid="config-prompt" />
},
}))
let latestConfigVarProps: any
jest.mock('@/app/components/app/configuration/config-var', () => ({
__esModule: true,
default: (props: any) => {
latestConfigVarProps = props
return <div data-testid="config-var" />
},
}))
jest.mock('../dataset-config', () => ({
__esModule: true,
default: () => <div data-testid="dataset-config" />,
}))
jest.mock('./agent/agent-tools', () => ({
__esModule: true,
default: () => <div data-testid="agent-tools" />,
}))
jest.mock('../config-vision', () => ({
__esModule: true,
default: () => <div data-testid="config-vision" />,
}))
jest.mock('./config-document', () => ({
__esModule: true,
default: () => <div data-testid="config-document" />,
}))
jest.mock('./config-audio', () => ({
__esModule: true,
default: () => <div data-testid="config-audio" />,
}))
let latestHistoryPanelProps: any
jest.mock('../config-prompt/conversation-history/history-panel', () => ({
__esModule: true,
default: (props: any) => {
latestHistoryPanelProps = props
return <div data-testid="history-panel" />
},
}))
type MockContext = {
mode: AppModeEnum
isAdvancedMode: boolean
modelModeType: ModelModeType
isAgent: boolean
hasSetBlockStatus: {
context: boolean
history: boolean
query: boolean
}
showHistoryModal: jest.Mock
modelConfig: ModelConfig
setModelConfig: jest.Mock
setPrevPromptConfig: jest.Mock
}
const createPromptVariable = (overrides: Partial<PromptVariable> = {}): PromptVariable => ({
key: 'variable',
name: 'Variable',
type: 'string',
...overrides,
})
const createModelConfig = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
provider: 'openai',
model_id: 'gpt-4',
mode: ModelModeType.chat,
configs: {
prompt_template: 'Hello {{variable}}',
prompt_variables: [createPromptVariable({ key: 'existing' })],
},
chat_prompt_config: null,
completion_prompt_config: null,
opening_statement: null,
more_like_this: null,
suggested_questions: null,
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: null,
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
dataSets: [],
agentConfig: {
enabled: false,
strategy: AgentStrategy.react,
max_iteration: 1,
tools: [] as ToolItem[],
},
...overrides,
})
const createContextValue = (overrides: Partial<MockContext> = {}): MockContext => ({
mode: AppModeEnum.CHAT,
isAdvancedMode: false,
modelModeType: ModelModeType.chat,
isAgent: false,
hasSetBlockStatus: {
context: false,
history: true,
query: false,
},
showHistoryModal: jest.fn(),
modelConfig: createModelConfig(),
setModelConfig: jest.fn(),
setPrevPromptConfig: jest.fn(),
...overrides,
})
const mockUseContext = useContextSelector.useContext as jest.Mock
const renderConfig = (contextOverrides: Partial<MockContext> = {}) => {
const contextValue = createContextValue(contextOverrides)
mockUseContext.mockReturnValue(contextValue)
return {
contextValue,
...render(<Config />),
}
}
beforeEach(() => {
jest.clearAllMocks()
latestConfigPromptProps = undefined
latestConfigVarProps = undefined
latestHistoryPanelProps = undefined
})
// Rendering scenarios ensure the layout toggles agent/history specific sections correctly.
describe('Config - Rendering', () => {
it('should render baseline sections without agent specific panels', () => {
renderConfig()
expect(screen.getByTestId('config-prompt')).toBeInTheDocument()
expect(screen.getByTestId('config-var')).toBeInTheDocument()
expect(screen.getByTestId('dataset-config')).toBeInTheDocument()
expect(screen.getByTestId('config-vision')).toBeInTheDocument()
expect(screen.getByTestId('config-document')).toBeInTheDocument()
expect(screen.getByTestId('config-audio')).toBeInTheDocument()
expect(screen.queryByTestId('agent-tools')).not.toBeInTheDocument()
expect(screen.queryByTestId('history-panel')).not.toBeInTheDocument()
})
it('should show AgentTools when app runs in agent mode', () => {
renderConfig({ isAgent: true })
expect(screen.getByTestId('agent-tools')).toBeInTheDocument()
})
it('should display HistoryPanel only when advanced chat completion values apply', () => {
const showHistoryModal = jest.fn()
renderConfig({
isAdvancedMode: true,
mode: AppModeEnum.ADVANCED_CHAT,
modelModeType: ModelModeType.completion,
hasSetBlockStatus: {
context: false,
history: false,
query: false,
},
showHistoryModal,
})
expect(screen.getByTestId('history-panel')).toBeInTheDocument()
expect(latestHistoryPanelProps.showWarning).toBe(true)
expect(latestHistoryPanelProps.onShowEditModal).toBe(showHistoryModal)
})
})
// Prompt handling scenarios validate integration between Config and prompt children.
describe('Config - Prompt Handling', () => {
it('should update prompt template and dispatch formatting event when text changes', () => {
const { contextValue } = renderConfig()
const previousVariables = contextValue.modelConfig.configs.prompt_variables
const additions = [createPromptVariable({ key: 'new', name: 'New' })]
latestConfigPromptProps.onChange('Updated template', additions)
expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs)
expect(contextValue.setModelConfig).toHaveBeenCalledWith(expect.objectContaining({
configs: expect.objectContaining({
prompt_template: 'Updated template',
prompt_variables: [...previousVariables, ...additions],
}),
}))
expect(mockFormattingDispatcher).toHaveBeenCalledTimes(1)
})
it('should skip formatting dispatcher when template remains identical', () => {
const { contextValue } = renderConfig()
const unchangedTemplate = contextValue.modelConfig.configs.prompt_template
latestConfigPromptProps.onChange(unchangedTemplate, [createPromptVariable({ key: 'added' })])
expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs)
expect(mockFormattingDispatcher).not.toHaveBeenCalled()
})
it('should replace prompt variables when ConfigVar reports updates', () => {
const { contextValue } = renderConfig()
const replacementVariables = [createPromptVariable({ key: 'replacement' })]
latestConfigVarProps.onPromptVariablesChange(replacementVariables)
expect(contextValue.setPrevPromptConfig).toHaveBeenCalledWith(contextValue.modelConfig.configs)
expect(contextValue.setModelConfig).toHaveBeenCalledWith(expect.objectContaining({
configs: expect.objectContaining({
prompt_variables: replacementVariables,
}),
}))
})
})

View File

@ -13,7 +13,7 @@ import Drawer from '@/app/components/base/drawer'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import AppIcon from '@/app/components/base/app-icon'
type ItemProps = {

View File

@ -4,7 +4,7 @@ import React from 'react'
import { useTranslation } from 'react-i18next'
import type { Props } from './var-picker'
import VarPicker from './var-picker'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { BracketsX } from '@/app/components/base/icons/src/vender/line/development'
import Tooltip from '@/app/components/base/tooltip'

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,

View File

@ -20,7 +20,7 @@ import type {
DataSet,
} from '@/models/datasets'
import { RerankingModeEnum } from '@/models/datasets'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { useSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/hooks'
import Switch from '@/app/components/base/switch'
import Toast from '@/app/components/base/toast'

View File

@ -1,5 +1,6 @@
import * as React from 'react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ParamsConfig from './index'
import ConfigContext from '@/context/debug-configuration'
import type { DatasetConfigs } from '@/models/debug'
@ -11,6 +12,37 @@ import {
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
jest.mock('@headlessui/react', () => ({
Dialog: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div role="dialog" className={className}>
{children}
</div>
),
DialogPanel: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
DialogTitle: ({ children, className, ...props }: { children: React.ReactNode; className?: string }) => (
<div className={className} {...props}>
{children}
</div>
),
Transition: ({ show, children }: { show: boolean; children: React.ReactNode }) => (show ? <>{children}</> : null),
TransitionChild: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Switch: ({ checked, onChange, children, ...props }: { checked: boolean; onChange?: (value: boolean) => void; children?: React.ReactNode }) => (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange?.(!checked)}
{...props}
>
{children}
</button>
),
}))
jest.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModelAndCurrentProviderAndModel: jest.fn(),
useCurrentProviderAndModel: jest.fn(),
@ -74,9 +106,6 @@ const renderParamsConfig = ({
initialModalOpen?: boolean
disabled?: boolean
} = {}) => {
const setDatasetConfigsSpy = jest.fn<void, [DatasetConfigs]>()
const setModalOpenSpy = jest.fn<void, [boolean]>()
const Wrapper = ({ children }: { children: React.ReactNode }) => {
const [datasetConfigsState, setDatasetConfigsState] = React.useState(datasetConfigs)
const [modalOpen, setModalOpen] = React.useState(initialModalOpen)
@ -84,12 +113,10 @@ const renderParamsConfig = ({
const contextValue = {
datasetConfigs: datasetConfigsState,
setDatasetConfigs: (next: DatasetConfigs) => {
setDatasetConfigsSpy(next)
setDatasetConfigsState(next)
},
rerankSettingModalOpen: modalOpen,
setRerankSettingModalOpen: (open: boolean) => {
setModalOpenSpy(open)
setModalOpen(open)
},
} as unknown as React.ComponentProps<typeof ConfigContext.Provider>['value']
@ -101,18 +128,13 @@ const renderParamsConfig = ({
)
}
render(
return render(
<ParamsConfig
disabled={disabled}
selectedDatasets={[]}
/>,
{ wrapper: Wrapper },
)
return {
setDatasetConfigsSpy,
setModalOpenSpy,
}
}
describe('dataset-config/params-config', () => {
@ -151,77 +173,92 @@ describe('dataset-config/params-config', () => {
describe('User Interactions', () => {
it('should open modal and persist changes when save is clicked', async () => {
// Arrange
const { setDatasetConfigsSpy } = renderParamsConfig()
renderParamsConfig()
const user = userEvent.setup()
// Act
fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
// Change top_k via the first number input increment control.
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
fireEvent.click(incrementButtons[0])
await user.click(incrementButtons[0])
const saveButton = await dialogScope.findByRole('button', { name: 'common.operation.save' })
fireEvent.click(saveButton)
await waitFor(() => {
const [topKInput] = dialogScope.getAllByRole('spinbutton')
expect(topKInput).toHaveValue(5)
})
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 5 }))
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog)
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
// Assert
expect(reopenedTopKInput).toHaveValue(5)
})
it('should discard changes when cancel is clicked', async () => {
// Arrange
const { setDatasetConfigsSpy } = renderParamsConfig()
renderParamsConfig()
const user = userEvent.setup()
// Act
fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
const incrementButtons = dialogScope.getAllByRole('button', { name: 'increment' })
fireEvent.click(incrementButtons[0])
await user.click(incrementButtons[0])
await waitFor(() => {
const [topKInput] = dialogScope.getAllByRole('spinbutton')
expect(topKInput).toHaveValue(5)
})
const cancelButton = await dialogScope.findByRole('button', { name: 'common.operation.cancel' })
fireEvent.click(cancelButton)
await user.click(cancelButton)
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
// Re-open and save without changes.
fireEvent.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
// Re-open and verify the original value remains.
await user.click(screen.getByRole('button', { name: 'dataset.retrievalSettings' }))
const reopenedDialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const reopenedScope = within(reopenedDialog)
const reopenedSave = await reopenedScope.findByRole('button', { name: 'common.operation.save' })
fireEvent.click(reopenedSave)
const [reopenedTopKInput] = reopenedScope.getAllByRole('spinbutton')
// Assert - should save original top_k rather than the canceled change.
expect(setDatasetConfigsSpy).toHaveBeenCalledWith(expect.objectContaining({ top_k: 4 }))
// Assert
expect(reopenedTopKInput).toHaveValue(4)
})
it('should prevent saving when rerank model is required but invalid', async () => {
// Arrange
const { setDatasetConfigsSpy } = renderParamsConfig({
renderParamsConfig({
datasetConfigs: createDatasetConfigs({
reranking_enable: true,
reranking_mode: RerankingModeEnum.RerankingModel,
}),
initialModalOpen: true,
})
const user = userEvent.setup()
// Act
const dialog = await screen.findByRole('dialog', {}, { timeout: 3000 })
const dialogScope = within(dialog)
fireEvent.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
await user.click(dialogScope.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(toastNotifySpy).toHaveBeenCalledWith({
type: 'error',
message: 'appDebug.datasetConfig.rerankModelRequired',
})
expect(setDatasetConfigsSpy).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
})

View File

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiEqualizer2Line } from '@remixicon/react'
import ConfigContent from './config-content'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import ConfigContext from '@/context/debug-configuration'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'

View File

@ -2,7 +2,7 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import './weighted-score.css'
import Slider from '@/app/components/base/slider'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
const formatNumber = (value: number) => {

View File

@ -10,7 +10,7 @@ import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import AppIcon from '@/app/components/base/app-icon'
import { useInfiniteDatasets } from '@/service/knowledge/use-dataset'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'

View File

@ -7,8 +7,9 @@ import { ChunkingMode, DataSourceType, DatasetPermission, RerankingModeEnum } fr
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { updateDatasetSetting } from '@/service/datasets'
import { fetchMembers } from '@/service/common'
import { useMembers } from '@/service/use-common'
import { RETRIEVE_METHOD, type RetrievalConfig } from '@/types/app'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
const mockNotify = jest.fn()
const mockOnCancel = jest.fn()
@ -41,8 +42,10 @@ jest.mock('@/service/datasets', () => ({
updateDatasetSetting: jest.fn(),
}))
jest.mock('@/service/common', () => ({
fetchMembers: jest.fn(),
jest.mock('@/service/use-common', () => ({
__esModule: true,
...jest.requireActual('@/service/use-common'),
useMembers: jest.fn(),
}))
jest.mock('@/context/app-context', () => ({
@ -103,7 +106,7 @@ jest.mock('@/app/components/datasets/settings/utils', () => ({
}))
const mockUpdateDatasetSetting = updateDatasetSetting as jest.MockedFunction<typeof updateDatasetSetting>
const mockFetchMembers = fetchMembers as jest.MockedFunction<typeof fetchMembers>
const mockUseMembers = useMembers as jest.MockedFunction<typeof useMembers>
const createRetrievalConfig = (overrides: Partial<RetrievalConfig> = {}): RetrievalConfig => ({
search_method: RETRIEVE_METHOD.semantic,
@ -192,10 +195,43 @@ const renderWithProviders = (dataset: DataSet) => {
)
}
const createMemberList = (): DataSet['partial_member_list'] => ([
'member-2',
])
const renderSettingsModal = async (dataset: DataSet) => {
renderWithProviders(dataset)
await waitFor(() => expect(mockUseMembers).toHaveBeenCalled())
}
describe('SettingsModal', () => {
beforeEach(() => {
jest.clearAllMocks()
mockIsWorkspaceDatasetOperator = false
mockUseMembers.mockReturnValue({
data: {
accounts: [
{
id: 'user-1',
name: 'User One',
email: 'user@example.com',
avatar: 'avatar.png',
avatar_url: 'avatar.png',
status: 'active',
role: 'owner',
},
{
id: 'member-2',
name: 'Member Two',
email: 'member@example.com',
avatar: 'avatar.png',
avatar_url: 'avatar.png',
status: 'active',
role: 'editor',
},
],
},
} as ReturnType<typeof useMembers>)
mockUseModelList.mockImplementation((type: ModelTypeEnum) => {
if (type === ModelTypeEnum.rerank) {
return {
@ -213,261 +249,289 @@ describe('SettingsModal', () => {
mockUseModelListAndDefaultModelAndCurrentProviderAndModel.mockReturnValue({ defaultModel: null, currentModel: null })
mockUseCurrentProviderAndModel.mockReturnValue({ currentProvider: null, currentModel: null })
mockCheckShowMultiModalTip.mockReturnValue(false)
mockFetchMembers.mockResolvedValue({
accounts: [
{
id: 'user-1',
name: 'User One',
email: 'user@example.com',
avatar: 'avatar.png',
avatar_url: 'avatar.png',
status: 'active',
role: 'owner',
},
{
id: 'member-2',
name: 'Member Two',
email: 'member@example.com',
avatar: 'avatar.png',
avatar_url: 'avatar.png',
status: 'active',
role: 'editor',
},
],
})
mockUpdateDatasetSetting.mockResolvedValue(createDataset())
})
it('renders dataset details', async () => {
renderWithProviders(createDataset())
// Rendering and basic field bindings.
describe('Rendering', () => {
it('should render dataset details when dataset is provided', async () => {
// Arrange
const dataset = createDataset()
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
// Act
await renderSettingsModal(dataset)
expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset')
expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description')
})
it('calls onCancel when cancel is clicked', async () => {
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
await userEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('shows external knowledge info for external datasets', async () => {
const dataset = createDataset({
provider: 'external',
external_knowledge_info: {
external_knowledge_id: 'ext-id-123',
external_knowledge_api_id: 'ext-api-id-123',
external_knowledge_api_name: 'External Knowledge API',
external_knowledge_api_endpoint: 'https://api.external.com',
},
// Assert
expect(screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')).toHaveValue('Test Dataset')
expect(screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')).toHaveValue('Description')
})
renderWithProviders(dataset)
it('should show external knowledge info when dataset is external', async () => {
// Arrange
const dataset = createDataset({
provider: 'external',
external_knowledge_info: {
external_knowledge_id: 'ext-id-123',
external_knowledge_api_id: 'ext-api-id-123',
external_knowledge_api_name: 'External Knowledge API',
external_knowledge_api_endpoint: 'https://api.external.com',
},
})
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
// Act
await renderSettingsModal(dataset)
expect(screen.getByText('External Knowledge API')).toBeInTheDocument()
expect(screen.getByText('https://api.external.com')).toBeInTheDocument()
expect(screen.getByText('ext-id-123')).toBeInTheDocument()
})
it('updates name when user types', async () => {
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'New Dataset Name')
expect(nameInput).toHaveValue('New Dataset Name')
})
it('updates description when user types', async () => {
renderWithProviders(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
const descriptionInput = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')
await userEvent.clear(descriptionInput)
await userEvent.type(descriptionInput, 'New description')
expect(descriptionInput).toHaveValue('New description')
})
it('shows and dismisses retrieval change tip when index method changes', async () => {
const dataset = createDataset({ indexing_technique: IndexingType.ECONOMICAL })
renderWithProviders(dataset)
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
await userEvent.click(screen.getByText('datasetCreation.stepTwo.qualified'))
expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument()
await userEvent.click(screen.getByLabelText('close-retrieval-change-tip'))
await waitFor(() => {
expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument()
// Assert
expect(screen.getByText('External Knowledge API')).toBeInTheDocument()
expect(screen.getByText('https://api.external.com')).toBeInTheDocument()
expect(screen.getByText('ext-id-123')).toBeInTheDocument()
})
})
it('requires dataset name before saving', async () => {
renderWithProviders(createDataset())
// User interactions that update visible state.
describe('Interactions', () => {
it('should call onCancel when cancel button is clicked', async () => {
// Arrange
const user = userEvent.setup()
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
// Act
await renderSettingsModal(createDataset())
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
await userEvent.clear(nameInput)
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'datasetSettings.form.nameError',
}))
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
it('requires rerank model when reranking is enabled', async () => {
mockUseModelList.mockReturnValue({ data: [] })
const dataset = createDataset({}, createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}))
renderWithProviders(dataset)
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'appDebug.datasetConfig.rerankModelRequired',
}))
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
it('saves internal dataset changes', async () => {
const rerankRetrieval = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'rerank-provider',
reranking_model_name: 'rerank-model',
},
})
const dataset = createDataset({
retrieval_model: rerankRetrieval,
retrieval_model_dict: rerankRetrieval,
// Assert
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
renderWithProviders(dataset)
it('should update name input when user types', async () => {
// Arrange
const user = userEvent.setup()
await renderSettingsModal(createDataset())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
await userEvent.clear(nameInput)
await userEvent.type(nameInput, 'Updated Internal Dataset')
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Act
await user.clear(nameInput)
await user.type(nameInput, 'New Dataset Name')
await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
// Assert
expect(nameInput).toHaveValue('New Dataset Name')
})
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
body: expect.objectContaining({
name: 'Updated Internal Dataset',
permission: DatasetPermission.allTeamMembers,
}),
}))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
}))
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
name: 'Updated Internal Dataset',
retrieval_model_dict: expect.objectContaining({
it('should update description input when user types', async () => {
// Arrange
const user = userEvent.setup()
await renderSettingsModal(createDataset())
const descriptionInput = screen.getByPlaceholderText('datasetSettings.form.descPlaceholder')
// Act
await user.clear(descriptionInput)
await user.type(descriptionInput, 'New description')
// Assert
expect(descriptionInput).toHaveValue('New description')
})
it('should show and dismiss retrieval change tip when indexing method changes', async () => {
// Arrange
const user = userEvent.setup()
const dataset = createDataset({ indexing_technique: IndexingType.ECONOMICAL })
// Act
await renderSettingsModal(dataset)
await user.click(screen.getByText('datasetCreation.stepTwo.qualified'))
// Assert
expect(await screen.findByText('appDebug.datasetConfig.retrieveChangeTip')).toBeInTheDocument()
// Act
await user.click(screen.getByLabelText('close-retrieval-change-tip'))
// Assert
await waitFor(() => {
expect(screen.queryByText('appDebug.datasetConfig.retrieveChangeTip')).not.toBeInTheDocument()
})
})
it('should open account setting modal when embedding model tip is clicked', async () => {
// Arrange
const user = userEvent.setup()
// Act
await renderSettingsModal(createDataset())
await user.click(screen.getByText('datasetSettings.form.embeddingModelTipLink'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
})
})
// Validation guardrails before saving.
describe('Validation', () => {
it('should block save when dataset name is empty', async () => {
// Arrange
const user = userEvent.setup()
await renderSettingsModal(createDataset())
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
// Act
await user.clear(nameInput)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'datasetSettings.form.nameError',
}))
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
it('should block save when reranking is enabled without model', async () => {
// Arrange
const user = userEvent.setup()
mockUseModelList.mockReturnValue({ data: [] })
const dataset = createDataset({}, createRetrievalConfig({
reranking_enable: true,
}),
}))
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}))
// Act
await renderSettingsModal(dataset)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
message: 'appDebug.datasetConfig.rerankModelRequired',
}))
expect(mockUpdateDatasetSetting).not.toHaveBeenCalled()
})
})
it('saves external dataset with partial members and updated retrieval params', async () => {
const dataset = createDataset({
provider: 'external',
permission: DatasetPermission.partialMembers,
partial_member_list: ['member-2'],
external_retrieval_model: {
top_k: 5,
score_threshold: 0.3,
score_threshold_enabled: true,
},
}, {
score_threshold_enabled: true,
score_threshold: 0.8,
// Save flows and side effects.
describe('Save', () => {
it('should save internal dataset changes when form is valid', async () => {
// Arrange
const user = userEvent.setup()
const rerankRetrieval = createRetrievalConfig({
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'rerank-provider',
reranking_model_name: 'rerank-model',
},
})
const dataset = createDataset({
retrieval_model: rerankRetrieval,
retrieval_model_dict: rerankRetrieval,
})
// Act
await renderSettingsModal(dataset)
const nameInput = screen.getByPlaceholderText('datasetSettings.form.namePlaceholder')
await user.clear(nameInput)
await user.type(nameInput, 'Updated Internal Dataset')
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Assert
await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
body: expect.objectContaining({
name: 'Updated Internal Dataset',
permission: DatasetPermission.allTeamMembers,
}),
}))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
}))
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
name: 'Updated Internal Dataset',
retrieval_model_dict: expect.objectContaining({
reranking_enable: true,
}),
}))
})
renderWithProviders(dataset)
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
body: expect.objectContaining({
it('should save external dataset changes when partial members configured', async () => {
// Arrange
const user = userEvent.setup()
const dataset = createDataset({
provider: 'external',
permission: DatasetPermission.partialMembers,
external_retrieval_model: expect.objectContaining({
partial_member_list: createMemberList(),
external_retrieval_model: {
top_k: 5,
}),
partial_member_list: [
{
user_id: 'member-2',
role: 'editor',
},
],
}),
}))
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
retrieval_model_dict: expect.objectContaining({
score_threshold: 0.3,
score_threshold_enabled: true,
},
}, {
score_threshold_enabled: true,
score_threshold: 0.8,
}),
}))
})
})
it('disables save button while saving', async () => {
mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
// Act
await renderSettingsModal(dataset)
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
renderWithProviders(createDataset())
// Assert
await waitFor(() => expect(mockUpdateDatasetSetting).toHaveBeenCalled())
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
expect(mockUpdateDatasetSetting).toHaveBeenCalledWith(expect.objectContaining({
body: expect.objectContaining({
permission: DatasetPermission.partialMembers,
external_retrieval_model: expect.objectContaining({
top_k: 5,
}),
partial_member_list: [
{
user_id: 'member-2',
role: 'editor',
},
],
}),
}))
expect(mockOnSave).toHaveBeenCalledWith(expect.objectContaining({
retrieval_model_dict: expect.objectContaining({
score_threshold_enabled: true,
score_threshold: 0.8,
}),
}))
})
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await userEvent.click(saveButton)
it('should disable save button while saving', async () => {
// Arrange
const user = userEvent.setup()
mockUpdateDatasetSetting.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
expect(saveButton).toBeDisabled()
})
// Act
await renderSettingsModal(createDataset())
it('shows error toast when save fails', async () => {
mockUpdateDatasetSetting.mockRejectedValue(new Error('API Error'))
const saveButton = screen.getByRole('button', { name: 'common.operation.save' })
await user.click(saveButton)
renderWithProviders(createDataset())
// Assert
expect(saveButton).toBeDisabled()
})
await waitFor(() => expect(mockFetchMembers).toHaveBeenCalled())
it('should show error toast when save fails', async () => {
// Arrange
const user = userEvent.setup()
mockUpdateDatasetSetting.mockRejectedValue(new Error('API Error'))
await userEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
// Act
await renderSettingsModal(createDataset())
await user.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
// Assert
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
})
})
})

View File

@ -1,10 +1,9 @@
import type { FC } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useMount } from 'ahooks'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isEqual } from 'lodash-es'
import { RiCloseLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import IndexMethod from '@/app/components/datasets/settings/index-method'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
@ -21,10 +20,10 @@ import PermissionSelector from '@/app/components/datasets/settings/permission-se
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fetchMembers } from '@/service/common'
import type { Member } from '@/models/common'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { useDocLink } from '@/context/i18n'
import { useMembers } from '@/service/use-common'
import { checkShowMultiModalTip } from '@/app/components/datasets/settings/utils'
import { RetrievalChangeTip, RetrievalSection } from './retrieval-section'
@ -63,6 +62,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(localeCurrentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset.partial_member_list || [])
const [memberList, setMemberList] = useState<Member[]>([])
const { data: membersData } = useMembers()
const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique)
const [retrievalConfig, setRetrievalConfig] = useState(localeCurrentDataset?.retrieval_model_dict as RetrievalConfig)
@ -160,17 +160,12 @@ const SettingsModal: FC<SettingsModalProps> = ({
}
}
const getMembers = async () => {
const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} })
if (!accounts)
useEffect(() => {
if (!membersData?.accounts)
setMemberList([])
else
setMemberList(accounts)
}
useMount(() => {
getMembers()
})
setMemberList(membersData.accounts)
}, [membersData])
const showMultiModalTip = useMemo(() => {
return checkShowMultiModalTip({

View File

@ -1,6 +1,6 @@
import { RiCloseLine } from '@remixicon/react'
import type { FC } from 'react'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'

View File

@ -7,7 +7,7 @@ import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
import type { Inputs } from '@/models/debug'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
type Props = {

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { createRef } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { type ReactNode, type RefObject, createRef } from 'react'
import DebugWithSingleModel from './index'
import type { DebugWithSingleModelRefType } from './index'
import type { ChatItem } from '@/app/components/base/chat/types'
@ -8,7 +8,8 @@ import type { ProviderContextState } from '@/context/provider-context'
import type { DatasetConfigs, ModelConfig } from '@/models/debug'
import { PromptMode } from '@/models/debug'
import { type Collection, CollectionType } from '@/app/components/tools/types'
import { AgentStrategy, AppModeEnum, ModelModeType } from '@/types/app'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { AgentStrategy, AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app'
// ============================================================================
// Test Data Factories (Following testing.md guidelines)
@ -67,21 +68,6 @@ function createMockModelConfig(overrides: Partial<ModelConfig> = {}): ModelConfi
}
}
/**
* Factory function for creating mock ChatItem list
* Note: Currently unused but kept for potential future test cases
*/
// eslint-disable-next-line unused-imports/no-unused-vars
function createMockChatList(items: Partial<ChatItem>[] = []): ChatItem[] {
return items.map((item, index) => ({
id: `msg-${index}`,
content: 'Test message',
isAnswer: false,
message_files: [],
...item,
}))
}
/**
* Factory function for creating mock Collection list
*/
@ -156,9 +142,9 @@ const mockFetchSuggestedQuestions = jest.fn()
const mockStopChatMessageResponding = jest.fn()
jest.mock('@/service/debug', () => ({
fetchConversationMessages: (...args: any[]) => mockFetchConversationMessages(...args),
fetchSuggestedQuestions: (...args: any[]) => mockFetchSuggestedQuestions(...args),
stopChatMessageResponding: (...args: any[]) => mockStopChatMessageResponding(...args),
fetchConversationMessages: (...args: unknown[]) => mockFetchConversationMessages(...args),
fetchSuggestedQuestions: (...args: unknown[]) => mockFetchSuggestedQuestions(...args),
stopChatMessageResponding: (...args: unknown[]) => mockStopChatMessageResponding(...args),
}))
jest.mock('next/navigation', () => ({
@ -255,11 +241,11 @@ const mockDebugConfigContext = {
score_threshold: 0.7,
datasets: { datasets: [] },
} as DatasetConfigs,
datasetConfigsRef: { current: null } as any,
datasetConfigsRef: createRef<DatasetConfigs>(),
setDatasetConfigs: jest.fn(),
hasSetContextVar: false,
isShowVisionConfig: false,
visionConfig: { enabled: false, number_limits: 2, detail: 'low' as any, transfer_methods: [] },
visionConfig: { enabled: false, number_limits: 2, detail: Resolution.low, transfer_methods: [] },
setVisionConfig: jest.fn(),
isAllowVideoUpload: false,
isShowDocumentConfig: false,
@ -295,7 +281,19 @@ jest.mock('@/context/app-context', () => ({
useAppContext: jest.fn(() => mockAppContext),
}))
const mockFeatures = {
type FeatureState = {
moreLikeThis: { enabled: boolean }
opening: { enabled: boolean; opening_statement: string; suggested_questions: string[] }
moderation: { enabled: boolean }
speech2text: { enabled: boolean }
text2speech: { enabled: boolean }
file: { enabled: boolean }
suggested: { enabled: boolean }
citation: { enabled: boolean }
annotationReply: { enabled: boolean }
}
const defaultFeatures: FeatureState = {
moreLikeThis: { enabled: false },
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
moderation: { enabled: false },
@ -306,13 +304,11 @@ const mockFeatures = {
citation: { enabled: false },
annotationReply: { enabled: false },
}
type FeatureSelector = (state: { features: FeatureState }) => unknown
let mockFeaturesState: FeatureState = { ...defaultFeatures }
jest.mock('@/app/components/base/features/hooks', () => ({
useFeatures: jest.fn((selector) => {
if (typeof selector === 'function')
return selector({ features: mockFeatures })
return mockFeatures
}),
useFeatures: jest.fn(),
}))
const mockConfigFromDebugContext = {
@ -345,7 +341,7 @@ jest.mock('../hooks', () => ({
const mockSetShowAppConfigureFeaturesModal = jest.fn()
jest.mock('@/app/components/app/store', () => ({
useStore: jest.fn((selector) => {
useStore: jest.fn((selector?: (state: { setShowAppConfigureFeaturesModal: typeof mockSetShowAppConfigureFeaturesModal }) => unknown) => {
if (typeof selector === 'function')
return selector({ setShowAppConfigureFeaturesModal: mockSetShowAppConfigureFeaturesModal })
return mockSetShowAppConfigureFeaturesModal
@ -384,12 +380,31 @@ jest.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
},
}))
// Mock external APIs that might be used
globalThis.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}))
type MockChatProps = {
chatList?: ChatItem[]
isResponding?: boolean
onSend?: (message: string, files?: FileEntity[]) => void
onRegenerate?: (chatItem: ChatItem, editedQuestion?: { message: string; files?: FileEntity[] }) => void
onStopResponding?: () => void
suggestedQuestions?: string[]
questionIcon?: ReactNode
answerIcon?: ReactNode
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
onAnnotationEdited?: (question: string, answer: string, index: number) => void
onAnnotationRemoved?: (index: number) => void
switchSibling?: (siblingMessageId: string) => void
onFeatureBarClick?: (state: boolean) => void
}
const mockFile: FileEntity = {
id: 'file-1',
name: 'test.png',
size: 123,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
}
// Mock Chat component (complex with many dependencies)
// This is a pragmatic mock that tests the integration at DebugWithSingleModel level
@ -408,11 +423,13 @@ jest.mock('@/app/components/base/chat/chat', () => {
onAnnotationRemoved,
switchSibling,
onFeatureBarClick,
}: any) {
}: MockChatProps) {
const items = chatList || []
const suggested = suggestedQuestions ?? []
return (
<div data-testid="chat-component">
<div data-testid="chat-list">
{chatList?.map((item: any) => (
{items.map((item: ChatItem) => (
<div key={item.id} data-testid={`chat-item-${item.id}`}>
{item.content}
</div>
@ -434,14 +451,21 @@ jest.mock('@/app/components/base/chat/chat', () => {
>
Send
</button>
<button
data-testid="send-with-files"
onClick={() => onSend?.('test message', [mockFile])}
disabled={isResponding}
>
Send With Files
</button>
{isResponding && (
<button data-testid="stop-button" onClick={onStopResponding}>
Stop
</button>
)}
{suggestedQuestions?.length > 0 && (
{suggested.length > 0 && (
<div data-testid="suggested-questions">
{suggestedQuestions.map((q: string, i: number) => (
{suggested.map((q: string, i: number) => (
<button key={i} onClick={() => onSend?.(q, [])}>
{q}
</button>
@ -451,7 +475,13 @@ jest.mock('@/app/components/base/chat/chat', () => {
{onRegenerate && (
<button
data-testid="regenerate-button"
onClick={() => onRegenerate({ id: 'msg-1', parentMessageId: 'msg-0' })}
onClick={() => onRegenerate({
id: 'msg-1',
content: 'Question',
isAnswer: false,
message_files: [],
parentMessageId: 'msg-0',
})}
>
Regenerate
</button>
@ -506,12 +536,30 @@ jest.mock('@/app/components/base/chat/chat', () => {
// ============================================================================
describe('DebugWithSingleModel', () => {
let ref: React.RefObject<DebugWithSingleModelRefType | null>
let ref: RefObject<DebugWithSingleModelRefType | null>
beforeEach(() => {
jest.clearAllMocks()
ref = createRef<DebugWithSingleModelRefType | null>()
const { useDebugConfigurationContext } = require('@/context/debug-configuration')
const { useProviderContext } = require('@/context/provider-context')
const { useAppContext } = require('@/context/app-context')
const { useConfigFromDebugContext, useFormattingChangedSubscription } = require('../hooks')
const { useFeatures } = require('@/app/components/base/features/hooks') as { useFeatures: jest.Mock }
useDebugConfigurationContext.mockReturnValue(mockDebugConfigContext)
useProviderContext.mockReturnValue(mockProviderContext)
useAppContext.mockReturnValue(mockAppContext)
useConfigFromDebugContext.mockReturnValue(mockConfigFromDebugContext)
useFormattingChangedSubscription.mockReturnValue(undefined)
mockFeaturesState = { ...defaultFeatures }
useFeatures.mockImplementation((selector?: FeatureSelector) => {
if (typeof selector === 'function')
return selector({ features: mockFeaturesState })
return mockFeaturesState
})
// Reset mock implementations
mockFetchConversationMessages.mockResolvedValue({ data: [] })
mockFetchSuggestedQuestions.mockResolvedValue({ data: [] })
@ -521,7 +569,7 @@ describe('DebugWithSingleModel', () => {
// Rendering Tests
describe('Rendering', () => {
it('should render without crashing', () => {
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
// Verify Chat component is rendered
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
@ -532,7 +580,7 @@ describe('DebugWithSingleModel', () => {
it('should render with custom checkCanSend prop', () => {
const checkCanSend = jest.fn(() => true)
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -543,122 +591,88 @@ describe('DebugWithSingleModel', () => {
it('should respect checkCanSend returning true', async () => {
const checkCanSend = jest.fn(() => true)
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />)
const sendButton = screen.getByTestId('send-button')
fireEvent.click(sendButton)
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(checkCanSend).toHaveBeenCalled()
expect(ssePost).toHaveBeenCalled()
})
expect(ssePost.mock.calls[0][0]).toBe('apps/test-app-id/chat-messages')
})
it('should prevent send when checkCanSend returns false', async () => {
const checkCanSend = jest.fn(() => false)
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} checkCanSend={checkCanSend} />)
const sendButton = screen.getByTestId('send-button')
fireEvent.click(sendButton)
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(checkCanSend).toHaveBeenCalled()
expect(checkCanSend).toHaveReturnedWith(false)
})
expect(ssePost).not.toHaveBeenCalled()
})
})
// Context Integration Tests
describe('Context Integration', () => {
it('should use debug configuration context', () => {
const { useDebugConfigurationContext } = require('@/context/debug-configuration')
// User Interactions
describe('User Interactions', () => {
it('should open feature configuration when feature bar is clicked', () => {
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('feature-bar-button'))
expect(useDebugConfigurationContext).toHaveBeenCalled()
})
it('should use provider context for model list', () => {
const { useProviderContext } = require('@/context/provider-context')
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(useProviderContext).toHaveBeenCalled()
})
it('should use app context for user profile', () => {
const { useAppContext } = require('@/context/app-context')
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(useAppContext).toHaveBeenCalled()
})
it('should use features from features hook', () => {
const { useFeatures } = require('@/app/components/base/features/hooks')
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(useFeatures).toHaveBeenCalled()
})
it('should use config from debug context hook', () => {
const { useConfigFromDebugContext } = require('../hooks')
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(useConfigFromDebugContext).toHaveBeenCalled()
})
it('should subscribe to formatting changes', () => {
const { useFormattingChangedSubscription } = require('../hooks')
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(useFormattingChangedSubscription).toHaveBeenCalled()
expect(mockSetShowAppConfigureFeaturesModal).toHaveBeenCalledWith(true)
})
})
// Model Configuration Tests
describe('Model Configuration', () => {
it('should merge features into config correctly when all features enabled', () => {
const { useFeatures } = require('@/app/components/base/features/hooks')
it('should include opening features in request when enabled', async () => {
mockFeaturesState = {
...defaultFeatures,
opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] },
}
useFeatures.mockReturnValue((selector: any) => {
const features = {
moreLikeThis: { enabled: true },
opening: { enabled: true, opening_statement: 'Hello!', suggested_questions: ['Q1'] },
moderation: { enabled: true },
speech2text: { enabled: true },
text2speech: { enabled: true },
file: { enabled: true },
suggested: { enabled: true },
citation: { enabled: true },
annotationReply: { enabled: true },
}
return typeof selector === 'function' ? selector({ features }) : features
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('send-button'))
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(ssePost).toHaveBeenCalled()
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
const body = ssePost.mock.calls[0][1].body
expect(body.model_config.opening_statement).toBe('Hello!')
expect(body.model_config.suggested_questions).toEqual(['Q1'])
})
it('should handle opening feature disabled correctly', () => {
const { useFeatures } = require('@/app/components/base/features/hooks')
it('should omit opening statement when feature is disabled', async () => {
mockFeaturesState = {
...defaultFeatures,
opening: { enabled: false, opening_statement: 'Should not appear', suggested_questions: ['Q1'] },
}
useFeatures.mockReturnValue((selector: any) => {
const features = {
...mockFeatures,
opening: { enabled: false, opening_statement: 'Should not appear', suggested_questions: ['Q1'] },
}
return typeof selector === 'function' ? selector({ features }) : features
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('send-button'))
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(ssePost).toHaveBeenCalled()
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
// When opening is disabled, opening_statement should be empty
expect(screen.queryByText('Should not appear')).not.toBeInTheDocument()
const body = ssePost.mock.calls[0][1].body
expect(body.model_config.opening_statement).toBe('')
expect(body.model_config.suggested_questions).toEqual([])
})
it('should handle model without vision support', () => {
@ -689,7 +703,7 @@ describe('DebugWithSingleModel', () => {
],
}))
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -710,7 +724,7 @@ describe('DebugWithSingleModel', () => {
],
}))
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -735,7 +749,7 @@ describe('DebugWithSingleModel', () => {
}),
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
// Component should render successfully with filtered variables
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
@ -754,7 +768,7 @@ describe('DebugWithSingleModel', () => {
}),
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -763,7 +777,7 @@ describe('DebugWithSingleModel', () => {
// Tool Icons Tests
describe('Tool Icons', () => {
it('should map tool icons from collection list', () => {
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -783,7 +797,7 @@ describe('DebugWithSingleModel', () => {
}),
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -812,7 +826,7 @@ describe('DebugWithSingleModel', () => {
collectionList: [],
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -828,7 +842,7 @@ describe('DebugWithSingleModel', () => {
inputs: {},
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -846,7 +860,7 @@ describe('DebugWithSingleModel', () => {
},
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -859,7 +873,7 @@ describe('DebugWithSingleModel', () => {
completionParams: {},
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
@ -868,7 +882,7 @@ describe('DebugWithSingleModel', () => {
// Imperative Handle Tests
describe('Imperative Handle', () => {
it('should expose handleRestart method via ref', () => {
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(ref.current).not.toBeNull()
expect(ref.current?.handleRestart).toBeDefined()
@ -876,65 +890,26 @@ describe('DebugWithSingleModel', () => {
})
it('should call handleRestart when invoked via ref', () => {
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
expect(() => {
act(() => {
ref.current?.handleRestart()
}).not.toThrow()
})
})
// Memory and Performance Tests
describe('Memory and Performance', () => {
it('should properly memoize component', () => {
const { rerender } = render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
// Re-render with same props
rerender(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
it('should have displayName set for debugging', () => {
expect(DebugWithSingleModel).toBeDefined()
// memo wraps the component
expect(typeof DebugWithSingleModel).toBe('object')
})
})
// Async Operations Tests
describe('Async Operations', () => {
it('should handle API calls during message send', async () => {
mockFetchConversationMessages.mockResolvedValue({ data: [] })
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
const textarea = screen.getByRole('textbox', { hidden: true })
fireEvent.change(textarea, { target: { value: 'Test message' } })
// Component should render without errors during async operations
await waitFor(() => {
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
})
it('should handle API errors gracefully', async () => {
mockFetchConversationMessages.mockRejectedValue(new Error('API Error'))
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
// Component should still render even if API calls fail
await waitFor(() => {
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
})
})
})
// File Upload Tests
describe('File Upload', () => {
it('should not include files when vision is not supported', () => {
it('should not include files when vision is not supported', async () => {
const { useDebugConfigurationContext } = require('@/context/debug-configuration')
const { useProviderContext } = require('@/context/provider-context')
const { useFeatures } = require('@/app/components/base/features/hooks')
useDebugConfigurationContext.mockReturnValue({
...mockDebugConfigContext,
modelConfig: createMockModelConfig({
model_id: 'gpt-3.5-turbo',
}),
})
useProviderContext.mockReturnValue(createMockProviderContext({
textGenerationModelList: [
@ -961,23 +936,34 @@ describe('DebugWithSingleModel', () => {
],
}))
useFeatures.mockReturnValue((selector: any) => {
const features = {
...mockFeatures,
file: { enabled: true }, // File upload enabled
}
return typeof selector === 'function' ? selector({ features }) : features
mockFeaturesState = {
...defaultFeatures,
file: { enabled: true },
}
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('send-with-files'))
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(ssePost).toHaveBeenCalled()
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
// Should render but not allow file uploads
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
const body = ssePost.mock.calls[0][1].body
expect(body.files).toEqual([])
})
it('should support files when vision is enabled', () => {
it('should support files when vision is enabled', async () => {
const { useDebugConfigurationContext } = require('@/context/debug-configuration')
const { useProviderContext } = require('@/context/provider-context')
const { useFeatures } = require('@/app/components/base/features/hooks')
useDebugConfigurationContext.mockReturnValue({
...mockDebugConfigContext,
modelConfig: createMockModelConfig({
model_id: 'gpt-4-vision',
}),
})
useProviderContext.mockReturnValue(createMockProviderContext({
textGenerationModelList: [
@ -1004,17 +990,22 @@ describe('DebugWithSingleModel', () => {
],
}))
useFeatures.mockReturnValue((selector: any) => {
const features = {
...mockFeatures,
file: { enabled: true },
}
return typeof selector === 'function' ? selector({ features }) : features
mockFeaturesState = {
...defaultFeatures,
file: { enabled: true },
}
render(<DebugWithSingleModel ref={ref as RefObject<DebugWithSingleModelRefType>} />)
fireEvent.click(screen.getByTestId('send-with-files'))
const { ssePost } = require('@/service/base') as { ssePost: jest.Mock }
await waitFor(() => {
expect(ssePost).toHaveBeenCalled()
})
render(<DebugWithSingleModel ref={ref as React.RefObject<DebugWithSingleModelRefType>} />)
expect(screen.getByTestId('chat-component')).toBeInTheDocument()
const body = ssePost.mock.calls[0][1].body
expect(body.files).toHaveLength(1)
})
})
})

View File

@ -1,7 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import useSWR from 'swr'
import { basePath } from '@/utils/var'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@ -72,7 +71,7 @@ import type { Features as FeaturesData, FileUpload } from '@/app/components/base
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
import { fetchFileUploadConfig } from '@/service/common'
import { useFileUploadConfig } from '@/service/use-common'
import {
correctModelProvider,
correctToolProvider,
@ -101,7 +100,7 @@ const Configuration: FC = () => {
showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal,
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
})))
const { data: fileUploadConfigResponse } = useSWR({ url: '/files/upload' }, fetchFileUploadConfig)
const { data: fileUploadConfigResponse } = useFileUploadConfig()
const latestPublishedAt = useMemo(() => appDetail?.model_config?.updated_at, [appDetail])
const [formattingChanged, setFormattingChanged] = useState(false)

View File

@ -21,7 +21,7 @@ import FeatureBar from '@/app/components/base/features/new-feature-panel/feature
import type { VisionFile, VisionSettings } from '@/types/app'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
import { useStore as useAppStore } from '@/app/components/app/store'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
export type IPromptValuePanelProps = {

View File

@ -1,6 +1,5 @@
import type { FC } from 'react'
import { useState } from 'react'
import useSWR from 'swr'
import { useContext } from 'use-context-selector'
import { useTranslation } from 'react-i18next'
import FormGeneration from '@/app/components/base/features/new-feature-panel/moderation/form-generation'
@ -9,7 +8,6 @@ import Button from '@/app/components/base/button'
import EmojiPicker from '@/app/components/base/emoji-picker'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import { fetchCodeBasedExtensionList } from '@/service/common'
import { SimpleSelect } from '@/app/components/base/select'
import I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
@ -21,6 +19,7 @@ import { useToastContext } from '@/app/components/base/toast'
import AppIcon from '@/app/components/base/app-icon'
import { noop } from 'lodash-es'
import { useDocLink } from '@/context/i18n'
import { useCodeBasedExtensions } from '@/service/use-common'
const systemTypes = ['api']
type ExternalDataToolModalProps = {
@ -46,10 +45,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
const { locale } = useContext(I18n)
const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' })
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const { data: codeBasedExtensionList } = useSWR(
'/code-based-extension?module=external_data_tool',
fetchCodeBasedExtensionList,
)
const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool')
const providers: Provider[] = [
{

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
import { PlusIcon } from '@heroicons/react/20/solid'
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import type { App } from '@/models/explore'
import AppIcon from '@/app/components/base/app-icon'

View File

@ -11,7 +11,7 @@ import AppCard from '../app-card'
import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar'
import Toast from '@/app/components/base/toast'
import Divider from '@/app/components/base/divider'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import ExploreContext from '@/context/explore-context'
import type { App } from '@/models/explore'
import { fetchAppDetail, fetchAppList } from '@/service/explore'

View File

@ -1,7 +1,7 @@
'use client'
import { RiStickyNoteAddLine, RiThumbUpLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import classNames from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Divider from '@/app/components/base/divider'
export enum AppCategories {
@ -40,13 +40,13 @@ type CategoryItemProps = {
}
function CategoryItem({ category, active, onClick }: CategoryItemProps) {
return <li
className={classNames('group flex h-8 cursor-pointer items-center gap-2 rounded-lg p-1 pl-3 hover:bg-state-base-hover [&.active]:bg-state-base-active', active && 'active')}
className={cn('group flex h-8 cursor-pointer items-center gap-2 rounded-lg p-1 pl-3 hover:bg-state-base-hover [&.active]:bg-state-base-active', active && 'active')}
onClick={() => { onClick?.(category) }}>
{category === AppCategories.RECOMMENDED && <div className='inline-flex h-5 w-5 items-center justify-center rounded-md'>
<RiThumbUpLine className='h-4 w-4 text-components-menu-item-text group-[.active]:text-components-menu-item-text-active' />
</div>}
<AppCategoryLabel category={category}
className={classNames('system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', active && 'system-sm-semibold')} />
className={cn('system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', active && 'system-sm-semibold')} />
</li >
}

View File

@ -13,7 +13,7 @@ import AppIconPicker from '../../base/app-icon-picker'
import type { AppIconSelection } from '../../base/app-icon-picker'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { basePath } from '@/utils/var'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'

View File

@ -25,7 +25,7 @@ import { useProviderContext } from '@/context/provider-context'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { getRedirection } from '@/utils/app-redirection'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { noop } from 'lodash-es'
import { trackEvent } from '@/app/components/base/amplitude'

View File

@ -8,7 +8,7 @@ import {
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { formatFileSize } from '@/utils/format'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files'
import { ToastContext } from '@/app/components/base/toast'
import ActionButton from '@/app/components/base/action-button'

View File

@ -0,0 +1,167 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import DuplicateAppModal from './index'
import Toast from '@/app/components/base/toast'
import type { ProviderContextState } from '@/context/provider-context'
import { baseProviderContextValue } from '@/context/provider-context'
import { Plan } from '@/app/components/billing/type'
const appsFullRenderSpy = jest.fn()
jest.mock('@/app/components/billing/apps-full-in-dialog', () => ({
__esModule: true,
default: ({ loc }: { loc: string }) => {
appsFullRenderSpy(loc)
return <div data-testid="apps-full">AppsFull</div>
},
}))
const useProviderContextMock = jest.fn<ProviderContextState, []>()
jest.mock('@/context/provider-context', () => {
const actual = jest.requireActual('@/context/provider-context')
return {
...actual,
useProviderContext: () => useProviderContextMock(),
}
})
const renderComponent = (overrides: Partial<React.ComponentProps<typeof DuplicateAppModal>> = {}) => {
const onConfirm = jest.fn().mockResolvedValue(undefined)
const onHide = jest.fn()
const props: React.ComponentProps<typeof DuplicateAppModal> = {
appName: 'My App',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFEAD5',
icon_url: null,
show: true,
onConfirm,
onHide,
...overrides,
}
const utils = render(<DuplicateAppModal {...props} />)
return {
...utils,
onConfirm,
onHide,
}
}
const setupProviderContext = (overrides: Partial<ProviderContextState> = {}) => {
useProviderContextMock.mockReturnValue({
...baseProviderContextValue,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: {
...baseProviderContextValue.plan.usage,
buildApps: 0,
},
total: {
...baseProviderContextValue.plan.total,
buildApps: 10,
},
},
enableBilling: false,
...overrides,
} as ProviderContextState)
}
describe('DuplicateAppModal', () => {
beforeEach(() => {
jest.clearAllMocks()
setupProviderContext()
})
// Rendering output based on modal visibility.
describe('Rendering', () => {
it('should render modal content when show is true', () => {
// Arrange
renderComponent()
// Assert
expect(screen.getByText('app.duplicateTitle')).toBeInTheDocument()
expect(screen.getByDisplayValue('My App')).toBeInTheDocument()
})
it('should not render modal content when show is false', () => {
// Arrange
renderComponent({ show: false })
// Assert
expect(screen.queryByText('app.duplicateTitle')).not.toBeInTheDocument()
})
})
// Prop-driven states such as full plan handling.
describe('Props', () => {
it('should disable duplicate button and show apps full content when plan is full', () => {
// Arrange
setupProviderContext({
enableBilling: true,
plan: {
...baseProviderContextValue.plan,
type: Plan.sandbox,
usage: { ...baseProviderContextValue.plan.usage, buildApps: 10 },
total: { ...baseProviderContextValue.plan.total, buildApps: 10 },
},
})
renderComponent()
// Assert
expect(screen.getByTestId('apps-full')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'app.duplicate' })).toBeDisabled()
})
})
// User interactions for cancel and confirm flows.
describe('Interactions', () => {
it('should call onHide when cancel is clicked', async () => {
const user = userEvent.setup()
// Arrange
const { onHide } = renderComponent()
// Act
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
// Assert
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should show error toast when name is empty', async () => {
const user = userEvent.setup()
const toastSpy = jest.spyOn(Toast, 'notify')
// Arrange
const { onConfirm, onHide } = renderComponent()
// Act
await user.clear(screen.getByDisplayValue('My App'))
await user.click(screen.getByRole('button', { name: 'app.duplicate' }))
// Assert
expect(toastSpy).toHaveBeenCalledWith({ type: 'error', message: 'explore.appCustomize.nameRequired' })
expect(onConfirm).not.toHaveBeenCalled()
expect(onHide).not.toHaveBeenCalled()
})
it('should submit app info and hide modal when duplicate is clicked', async () => {
const user = userEvent.setup()
// Arrange
const { onConfirm, onHide } = renderComponent()
// Act
await user.clear(screen.getByDisplayValue('My App'))
await user.type(screen.getByRole('textbox'), 'New App')
await user.click(screen.getByRole('button', { name: 'app.duplicate' }))
// Assert
expect(onConfirm).toHaveBeenCalledWith({
name: 'New App',
icon_type: 'emoji',
icon: '🚀',
icon_background: '#FFEAD5',
})
expect(onHide).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -3,7 +3,7 @@ import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiCloseLine } from '@remixicon/react'
import AppIconPicker from '../../base/app-icon-picker'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'

View File

@ -3,7 +3,7 @@ import type { FC } from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import Log from '@/app/components/app/log'
import WorkflowLog from '@/app/components/app/workflow-log'
import Annotation from '@/app/components/app/annotation'

View File

@ -39,7 +39,7 @@ import Tooltip from '@/app/components/base/tooltip'
import CopyIcon from '@/app/components/base/copy-icon'
import { buildChatItemTree, getThreadMessages } from '@/app/components/base/chat/utils'
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
import cn from '@/utils/classnames'
import { cn } from '@/utils/classnames'
import { noop } from 'lodash-es'
import PromptLogModal from '../../base/prompt-log-modal'
import { WorkflowContextProvider } from '@/app/components/workflow/context'

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