mirror of https://github.com/langgenius/dify.git
Merge branch 'main' into pydantic-remaining
This commit is contained in:
commit
294d9634ae
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ from services.errors.audio import (
|
|||
)
|
||||
|
||||
from ..common.schema import register_schema_models
|
||||
from ..web.wraps import WebApiResource
|
||||
|
||||
|
||||
class TextToAudioPayload(BaseModel):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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'), {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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: [] })
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ export type AnnotationItem = {
|
|||
hit_count: number
|
||||
}
|
||||
|
||||
export type AnnotationCreateResponse = AnnotationItem & {
|
||||
account?: {
|
||||
name?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type HitHistoryItem = {
|
||||
id: string
|
||||
question: string
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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' }))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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[] = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 >
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue