Merge branch 'main' into feat/hitl-frontend

This commit is contained in:
twwu
2026-01-20 10:45:27 +08:00
84 changed files with 7771 additions and 424 deletions

View File

@ -1,4 +1,4 @@
import type { FC } from 'react'
import type { FC, MouseEvent } from 'react'
import type { Resources } from './index'
import Link from 'next/link'
import { Fragment, useState } from 'react'
@ -18,6 +18,8 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useDocumentDownload } from '@/service/knowledge/use-document'
import { downloadUrl } from '@/utils/download'
import ProgressTooltip from './progress-tooltip'
import Tooltip from './tooltip'
@ -36,6 +38,30 @@ const Popup: FC<PopupProps> = ({
? (/\.([^.]*)$/.exec(data.documentName)?.[1] || '')
: 'notion'
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
/**
* Download the original uploaded file for citations whose data source is upload-file.
* We request a signed URL from the dataset document download endpoint, then trigger browser download.
*/
const handleDownloadUploadFile = async (e: MouseEvent<HTMLElement>) => {
// Prevent toggling the citation popup when user clicks the download link.
e.preventDefault()
e.stopPropagation()
// Only upload-file citations can be downloaded this way (needs dataset/document ids).
const isUploadFile = data.dataSourceType === 'upload_file' || data.dataSourceType === 'file'
const datasetId = data.sources?.[0]?.dataset_id
const documentId = data.documentId || data.sources?.[0]?.document_id
if (!isUploadFile || !datasetId || !documentId || isDownloading)
return
// Fetch signed URL (usually points to `/files/<id>/file-preview?...&as_attachment=true`).
const res = await downloadDocument({ datasetId, documentId })
if (res?.url)
downloadUrl({ url: res.url, fileName: data.documentName })
}
return (
<PortalToFollowElem
open={open}
@ -49,6 +75,7 @@ const Popup: FC<PopupProps> = ({
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<div className="flex h-7 max-w-[240px] items-center rounded-lg bg-components-button-secondary-bg px-2">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
{/* Keep the trigger purely for opening the popup (no download link here). */}
<div className="truncate text-xs text-text-tertiary">{data.documentName}</div>
</div>
</PortalToFollowElemTrigger>
@ -57,7 +84,21 @@ const Popup: FC<PopupProps> = ({
<div className="px-4 pb-2 pt-3">
<div className="flex h-[18px] items-center">
<FileIcon type={fileType} className="mr-1 h-4 w-4 shrink-0" />
<div className="system-xs-medium truncate text-text-tertiary">{data.documentName}</div>
<div className="system-xs-medium truncate text-text-tertiary">
{/* If it's an upload-file reference, the title becomes a download link. */}
{(data.dataSourceType === 'upload_file' || data.dataSourceType === 'file') && !!data.sources?.[0]?.dataset_id
? (
<button
type="button"
className="cursor-pointer truncate text-text-tertiary hover:underline"
onClick={handleDownloadUploadFile}
disabled={isDownloading}
>
{data.documentName}
</button>
)
: data.documentName}
</div>
</div>
</div>
<div className="max-h-[450px] overflow-y-auto rounded-lg bg-components-panel-bg px-4 py-0.5">

View File

@ -0,0 +1,101 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import EconomicalRetrievalMethodConfig from './index'
// Mock dependencies
vi.mock('../../settings/option-card', () => ({
default: ({ children, title, description, disabled, id }: {
children?: React.ReactNode
title?: string
description?: React.ReactNode
disabled?: boolean
id?: string
}) => (
<div data-testid="option-card" data-title={title} data-id={id} data-disabled={disabled}>
<div>{description}</div>
{children}
</div>
),
}))
vi.mock('../retrieval-param-config', () => ({
default: ({ value, onChange, type }: {
value: Record<string, unknown>
onChange: (value: Record<string, unknown>) => void
type?: string
}) => (
<div data-testid="retrieval-param-config" data-type={type}>
<button onClick={() => onChange({ ...value, newProp: 'changed' })}>
Change Value
</button>
</div>
),
}))
vi.mock('@/app/components/base/icons/src/vender/knowledge', () => ({
VectorSearch: () => <svg data-testid="vector-search-icon" />,
}))
describe('EconomicalRetrievalMethodConfig', () => {
const mockOnChange = vi.fn()
const defaultProps = {
value: {
search_method: RETRIEVE_METHOD.keywordSearch,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 2,
score_threshold_enabled: false,
score_threshold: 0.5,
},
onChange: mockOnChange,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render correctly', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
expect(screen.getByTestId('option-card')).toBeInTheDocument()
expect(screen.getByTestId('retrieval-param-config')).toBeInTheDocument()
// Check if title and description are rendered (mocked i18n returns key)
expect(screen.getByText('dataset.retrieval.keyword_search.description')).toBeInTheDocument()
})
it('should pass correct props to OptionCard', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} disabled={true} />)
const card = screen.getByTestId('option-card')
expect(card).toHaveAttribute('data-disabled', 'true')
expect(card).toHaveAttribute('data-id', RETRIEVE_METHOD.keywordSearch)
})
it('should pass correct props to RetrievalParamConfig', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
const config = screen.getByTestId('retrieval-param-config')
expect(config).toHaveAttribute('data-type', RETRIEVE_METHOD.keywordSearch)
})
it('should handle onChange events', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
fireEvent.click(screen.getByText('Change Value'))
expect(mockOnChange).toHaveBeenCalledTimes(1)
expect(mockOnChange).toHaveBeenCalledWith({
...defaultProps.value,
newProp: 'changed',
})
})
it('should default disabled prop to false', () => {
render(<EconomicalRetrievalMethodConfig {...defaultProps} />)
const card = screen.getByTestId('option-card')
expect(card).toHaveAttribute('data-disabled', 'false')
})
})

View File

@ -0,0 +1,148 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { RETRIEVE_METHOD } from '@/types/app'
import { retrievalIcon } from '../../create/icons'
import RetrievalMethodInfo, { getIcon } from './index'
// Mock next/image
vi.mock('next/image', () => ({
default: ({ src, alt, className }: { src: string, alt: string, className?: string }) => (
<img src={src} alt={alt || ''} className={className} data-testid="method-icon" />
),
}))
// Mock RadioCard
vi.mock('@/app/components/base/radio-card', () => ({
default: ({ title, description, chosenConfig, icon }: { title: string, description: string, chosenConfig: ReactNode, icon: ReactNode }) => (
<div data-testid="radio-card">
<div data-testid="card-title">{title}</div>
<div data-testid="card-description">{description}</div>
<div data-testid="card-icon">{icon}</div>
<div data-testid="chosen-config">{chosenConfig}</div>
</div>
),
}))
// Mock icons
vi.mock('../../create/icons', () => ({
retrievalIcon: {
vector: 'vector-icon.png',
fullText: 'fulltext-icon.png',
hybrid: 'hybrid-icon.png',
},
}))
describe('RetrievalMethodInfo', () => {
const defaultConfig = {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: 'test-provider',
reranking_model_name: 'test-model',
},
top_k: 5,
score_threshold_enabled: true,
score_threshold: 0.8,
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render correctly with full config', () => {
render(<RetrievalMethodInfo value={defaultConfig} />)
expect(screen.getByTestId('radio-card')).toBeInTheDocument()
// Check Title & Description (mocked i18n returns key prefixed with ns)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.semantic_search.title')
expect(screen.getByTestId('card-description')).toHaveTextContent('dataset.retrieval.semantic_search.description')
// Check Icon
const icon = screen.getByTestId('method-icon')
expect(icon).toHaveAttribute('src', 'vector-icon.png')
// Check Config Details
expect(screen.getByText('test-model')).toBeInTheDocument() // Rerank model
expect(screen.getByText('5')).toBeInTheDocument() // Top K
expect(screen.getByText('0.8')).toBeInTheDocument() // Score threshold
})
it('should not render reranking model if missing', () => {
const configWithoutRerank = {
...defaultConfig,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
}
render(<RetrievalMethodInfo value={configWithoutRerank} />)
expect(screen.queryByText('test-model')).not.toBeInTheDocument()
// Other fields should still be there
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should handle different retrieval methods', () => {
// Test Hybrid
const hybridConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.hybrid }
const { unmount } = render(<RetrievalMethodInfo value={hybridConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.hybrid_search.title')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'hybrid-icon.png')
unmount()
// Test FullText
const fullTextConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.fullText }
render(<RetrievalMethodInfo value={fullTextConfig} />)
expect(screen.getByTestId('card-title')).toHaveTextContent('dataset.retrieval.full_text_search.title')
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'fulltext-icon.png')
})
describe('getIcon utility', () => {
it('should return correct icon for each type', () => {
expect(getIcon(RETRIEVE_METHOD.semantic)).toBe(retrievalIcon.vector)
expect(getIcon(RETRIEVE_METHOD.fullText)).toBe(retrievalIcon.fullText)
expect(getIcon(RETRIEVE_METHOD.hybrid)).toBe(retrievalIcon.hybrid)
expect(getIcon(RETRIEVE_METHOD.invertedIndex)).toBe(retrievalIcon.vector)
expect(getIcon(RETRIEVE_METHOD.keywordSearch)).toBe(retrievalIcon.vector)
})
it('should return default vector icon for unknown type', () => {
// Test fallback branch when type is not in the mapping
const unknownType = 'unknown_method' as RETRIEVE_METHOD
expect(getIcon(unknownType)).toBe(retrievalIcon.vector)
})
})
it('should not render score threshold if disabled', () => {
const configWithoutScoreThreshold = {
...defaultConfig,
score_threshold_enabled: false,
score_threshold: 0,
}
render(<RetrievalMethodInfo value={configWithoutScoreThreshold} />)
// score_threshold is still rendered but may be undefined
expect(screen.queryByText('0.8')).not.toBeInTheDocument()
})
it('should render correctly with invertedIndex search method', () => {
const invertedIndexConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.invertedIndex }
render(<RetrievalMethodInfo value={invertedIndexConfig} />)
// invertedIndex uses vector icon
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
})
it('should render correctly with keywordSearch search method', () => {
const keywordSearchConfig = { ...defaultConfig, search_method: RETRIEVE_METHOD.keywordSearch }
render(<RetrievalMethodInfo value={keywordSearchConfig} />)
// keywordSearch uses vector icon
expect(screen.getByTestId('method-icon')).toHaveAttribute('src', 'vector-icon.png')
})
})

View File

@ -30,9 +30,10 @@ import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '
import useTimestamp from '@/hooks/use-timestamp'
import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets'
import { DatasourceType } from '@/models/pipeline'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document'
import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useDocumentDisable, useDocumentDownloadZip, useDocumentEnable } from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
import { formatNumber } from '@/utils/format'
import BatchAction from '../detail/completed/common/batch-action'
import StatusItem from '../status-item'
@ -222,6 +223,7 @@ const DocumentList: FC<IDocumentListProps> = ({
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: retryIndexDocument } = useDocumentBatchRetryIndex()
const { mutateAsync: requestDocumentsZip, isPending: isDownloadingZip } = useDocumentDownloadZip()
const handleAction = (actionName: DocumentActionType) => {
return async () => {
@ -300,6 +302,39 @@ const DocumentList: FC<IDocumentListProps> = ({
return dataSourceType === DatasourceType.onlineDrive
}, [])
const downloadableSelectedIds = useMemo(() => {
const selectedSet = new Set(selectedIds)
return localDocs
.filter(doc => selectedSet.has(doc.id) && doc.data_source_type === DataSourceType.FILE)
.map(doc => doc.id)
}, [localDocs, selectedIds])
/**
* Generate a random ZIP filename for bulk document downloads.
* We intentionally avoid leaking dataset info in the exported archive name.
*/
const generateDocsZipFileName = useCallback((): string => {
// Prefer UUID for uniqueness; fall back to time+random when unavailable.
const randomPart = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
? crypto.randomUUID()
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
return `${randomPart}-docs.zip`
}, [])
const handleBatchDownload = useCallback(async () => {
if (isDownloadingZip)
return
// Download as a single ZIP to avoid browser caps on multiple automatic downloads.
const [e, blob] = await asyncRunSafe(requestDocumentsZip({ datasetId, documentIds: downloadableSelectedIds }))
if (e || !blob) {
Toast.notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
downloadBlob({ data: blob, fileName: generateDocsZipFileName() })
}, [datasetId, downloadableSelectedIds, generateDocsZipFileName, isDownloadingZip, requestDocumentsZip, t])
return (
<div className="relative mt-3 flex h-full w-full flex-col">
<div className="relative h-0 grow overflow-x-auto">
@ -463,6 +498,7 @@ const DocumentList: FC<IDocumentListProps> = ({
onArchive={handleAction(DocumentActionType.archive)}
onBatchEnable={handleAction(DocumentActionType.enable)}
onBatchDisable={handleAction(DocumentActionType.disable)}
onBatchDownload={downloadableSelectedIds.length > 0 ? handleBatchDownload : undefined}
onBatchDelete={handleAction(DocumentActionType.delete)}
onEditMetadata={showEditModal}
onBatchReIndex={hasErrorDocumentsSelected ? handleBatchReIndex : undefined}

View File

@ -1,8 +1,10 @@
import type { OperationName } from '../types'
import type { CommonResponse } from '@/models/common'
import type { DocumentDownloadResponse } from '@/service/datasets'
import {
RiArchive2Line,
RiDeleteBinLine,
RiDownload2Line,
RiEditLine,
RiEqualizer2Line,
RiLoopLeftLine,
@ -28,6 +30,7 @@ import {
useDocumentArchive,
useDocumentDelete,
useDocumentDisable,
useDocumentDownload,
useDocumentEnable,
useDocumentPause,
useDocumentResume,
@ -37,6 +40,7 @@ import {
} from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
import s from '../style.module.css'
import RenameModal from './rename-modal'
@ -69,7 +73,7 @@ const Operations = ({
scene = 'list',
className = '',
}: OperationsProps) => {
const { id, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const { id, name, enabled = false, archived = false, data_source_type, display_status } = detail || {}
const [showModal, setShowModal] = useState(false)
const [deleting, setDeleting] = useState(false)
const { notify } = useContext(ToastContext)
@ -80,6 +84,7 @@ const Operations = ({
const { mutateAsync: enableDocument } = useDocumentEnable()
const { mutateAsync: disableDocument } = useDocumentDisable()
const { mutateAsync: deleteDocument } = useDocumentDelete()
const { mutateAsync: downloadDocument, isPending: isDownloading } = useDocumentDownload()
const { mutateAsync: syncDocument } = useSyncDocument()
const { mutateAsync: syncWebsite } = useSyncWebsite()
const { mutateAsync: pauseDocument } = useDocumentPause()
@ -158,6 +163,24 @@ const Operations = ({
onUpdate()
}, [onUpdate])
const handleDownload = useCallback(async () => {
// Avoid repeated clicks while the signed URL request is in-flight.
if (isDownloading)
return
// Request a signed URL first (it points to `/files/<id>/file-preview?...&as_attachment=true`).
const [e, res] = await asyncRunSafe<DocumentDownloadResponse>(
downloadDocument({ datasetId, documentId: id }) as Promise<DocumentDownloadResponse>,
)
if (e || !res?.url) {
notify({ type: 'error', message: t('actionMsg.downloadUnsuccessfully', { ns: 'common' }) })
return
}
// Trigger download without navigating away (helps avoid duplicate downloads in some browsers).
downloadUrl({ url: res.url, fileName: name })
}, [datasetId, downloadDocument, id, isDownloading, name, notify, t])
return (
<div className="flex items-center" onClick={e => e.stopPropagation()}>
{isListScene && !embeddingAvailable && (
@ -214,6 +237,20 @@ const Operations = ({
<RiEditLine className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.table.rename', { ns: 'datasetDocuments' })}</span>
</div>
{data_source_type === DataSourceType.FILE && (
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
)}
{['notion_import', DataSourceType.WEB].includes(data_source_type) && (
<div className={s.actionItem} onClick={() => onOperate('sync')}>
<RiLoopLeftLine className="h-4 w-4 text-text-tertiary" />
@ -223,6 +260,23 @@ const Operations = ({
<Divider className="my-1" />
</>
)}
{archived && data_source_type === DataSourceType.FILE && (
<>
<div
className={s.actionItem}
onClick={(evt) => {
evt.preventDefault()
evt.stopPropagation()
evt.nativeEvent.stopImmediatePropagation?.()
handleDownload()
}}
>
<RiDownload2Line className="h-4 w-4 text-text-tertiary" />
<span className={s.actionName}>{t('list.action.download', { ns: 'datasetDocuments' })}</span>
</div>
<Divider className="my-1" />
</>
)}
{!archived && display_status?.toLowerCase() === 'indexing' && (
<div className={s.actionItem} onClick={() => onOperate('pause')}>
<RiPauseCircleLine className="h-4 w-4 text-text-tertiary" />

View File

@ -1,5 +1,5 @@
import type { FC } from 'react'
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDraftLine, RiRefreshLine } from '@remixicon/react'
import { RiArchive2Line, RiCheckboxCircleLine, RiCloseCircleLine, RiDeleteBinLine, RiDownload2Line, RiDraftLine, RiRefreshLine } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@ -14,6 +14,7 @@ type IBatchActionProps = {
selectedIds: string[]
onBatchEnable: () => void
onBatchDisable: () => void
onBatchDownload?: () => void
onBatchDelete: () => Promise<void>
onArchive?: () => void
onEditMetadata?: () => void
@ -26,6 +27,7 @@ const BatchAction: FC<IBatchActionProps> = ({
selectedIds,
onBatchEnable,
onBatchDisable,
onBatchDownload,
onArchive,
onBatchDelete,
onEditMetadata,
@ -103,6 +105,16 @@ const BatchAction: FC<IBatchActionProps> = ({
<span className="px-0.5">{t(`${i18nPrefix}.reIndex`, { ns: 'dataset' })}</span>
</Button>
)}
{onBatchDownload && (
<Button
variant="ghost"
className="gap-x-0.5 px-3"
onClick={onBatchDownload}
>
<RiDownload2Line className="size-4" />
<span className="px-0.5">{t(`${i18nPrefix}.download`, { ns: 'dataset' })}</span>
</Button>
)}
<Button
variant="ghost"
destructive

View File

@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react'
import EmbeddingSkeleton from './index'
// Mock Skeleton components
vi.mock('@/app/components/base/skeleton', () => ({
SkeletonContainer: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-container">{children}</div>,
SkeletonPoint: () => <div data-testid="skeleton-point" />,
SkeletonRectangle: () => <div data-testid="skeleton-rectangle" />,
SkeletonRow: ({ children }: { children?: React.ReactNode }) => <div data-testid="skeleton-row">{children}</div>,
}))
// Mock Divider
vi.mock('@/app/components/base/divider', () => ({
default: () => <div data-testid="divider" />,
}))
describe('EmbeddingSkeleton', () => {
it('should render correct number of skeletons', () => {
render(<EmbeddingSkeleton />)
// It renders 5 CardSkeletons. Each CardSkelton has multiple SkeletonContainers.
// Let's count the number of main wrapper divs (loop is 5)
// Each iteration renders a CardSkeleton and potentially a Divider.
// The component structure is:
// div.relative...
// div.absolute... (mask)
// map(5) -> div.w-full.px-11 -> CardSkelton + Divider (except last?)
// Actually the code says `index !== 9`, but the loop is length 5.
// So `index` goes 0..4. All are !== 9. So 5 dividers should be rendered.
expect(screen.getAllByTestId('divider')).toHaveLength(5)
// Just ensure it renders without crashing and contains skeleton elements
expect(screen.getAllByTestId('skeleton-container').length).toBeGreaterThan(0)
expect(screen.getAllByTestId('skeleton-rectangle').length).toBeGreaterThan(0)
})
it('should render the mask overlay', () => {
const { container } = render(<EmbeddingSkeleton />)
// Check for the absolute positioned mask
const mask = container.querySelector('.bg-dataset-chunk-list-mask-bg')
expect(mask).toBeInTheDocument()
})
})

View File

@ -0,0 +1,92 @@
import { RiArrowRightUpLine, RiBookOpenLine } from '@remixicon/react'
import Link from 'next/link'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Switch from '@/app/components/base/switch'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
type CardProps = {
apiEnabled: boolean
}
const Card = ({
apiEnabled,
}: CardProps) => {
const { t } = useTranslation()
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const apiReferenceUrl = useDatasetApiAccessUrl()
const onToggle = useCallback(async (state: boolean) => {
let result: 'success' | 'fail'
if (state)
result = (await enableDatasetServiceApi(datasetId ?? '')).result
else
result = (await disableDatasetServiceApi(datasetId ?? '')).result
if (result === 'success')
mutateDatasetRes?.()
}, [datasetId, enableDatasetServiceApi, mutateDatasetRes, disableDatasetServiceApi])
return (
<div className="w-[208px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<div className="p-1">
<div className="p-2">
<div className="mb-1.5 flex justify-between">
<div className="flex items-center gap-1">
<Indicator
className="shrink-0"
color={apiEnabled ? 'green' : 'yellow'}
/>
<div
className={cn(
'system-xs-semibold-uppercase',
apiEnabled ? 'text-text-success' : 'text-text-warning',
)}
>
{apiEnabled
? t('serviceApi.enabled', { ns: 'dataset' })
: t('serviceApi.disabled', { ns: 'dataset' })}
</div>
</div>
<Switch
defaultValue={apiEnabled}
onChange={onToggle}
disabled={!isCurrentWorkspaceManager}
/>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('appMenus.apiAccessTip', { ns: 'common' })}
</div>
</div>
</div>
<div className="h-px bg-divider-subtle"></div>
<div className="p-1">
<Link
href={apiReferenceUrl}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 items-center space-x-[7px] rounded-lg px-2 text-text-tertiary hover:bg-state-base-hover"
>
<RiBookOpenLine className="size-3.5 shrink-0" />
<div className="system-sm-regular grow truncate">
{t('overview.apiInfo.doc', { ns: 'appOverview' })}
</div>
<RiArrowRightUpLine className="size-3.5 shrink-0" />
</Link>
</div>
</div>
)
}
export default React.memo(Card)

View File

@ -0,0 +1,65 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import Card from './card'
type ApiAccessProps = {
expand: boolean
apiEnabled: boolean
}
const ApiAccess = ({
expand,
apiEnabled,
}: ApiAccessProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleToggle = () => {
setOpen(!open)
}
return (
<div className="p-3 pt-2">
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger
className="w-full"
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="system-sm-medium grow text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
color={apiEnabled ? 'green' : 'yellow'}
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[10]">
<Card
apiEnabled={apiEnabled}
/>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
export default React.memo(ApiAccess)

View File

@ -1,8 +1,7 @@
import type { RelatedAppResponse } from '@/models/datasets'
import * as React from 'react'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
import ServiceApi from './service-api'
import ApiAccess from './api-access'
import Statistics from './statistics'
type IExtraInfoProps = {
@ -17,7 +16,6 @@ const ExtraInfo = ({
expand,
}: IExtraInfoProps) => {
const apiEnabled = useDatasetDetailContextWithSelector(state => state.dataset?.enable_api)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
return (
<>
@ -28,9 +26,8 @@ const ExtraInfo = ({
relatedApps={relatedApps}
/>
)}
<ServiceApi
<ApiAccess
expand={expand}
apiBaseUrl={apiBaseInfo?.api_base_url ?? ''}
apiEnabled={apiEnabled ?? false}
/>
</>

View File

@ -6,45 +6,22 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import Switch from '@/app/components/base/switch'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import { useDisableDatasetServiceApi, useEnableDatasetServiceApi } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
type CardProps = {
apiEnabled: boolean
apiBaseUrl: string
}
const Card = ({
apiEnabled,
apiBaseUrl,
}: CardProps) => {
const { t } = useTranslation()
const datasetId = useDatasetDetailContextWithSelector(state => state.dataset?.id)
const mutateDatasetRes = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
const { mutateAsync: enableDatasetServiceApi } = useEnableDatasetServiceApi()
const { mutateAsync: disableDatasetServiceApi } = useDisableDatasetServiceApi()
const [isSecretKeyModalVisible, setIsSecretKeyModalVisible] = useState(false)
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const apiReferenceUrl = useDatasetApiAccessUrl()
const onToggle = useCallback(async (state: boolean) => {
let result: 'success' | 'fail'
if (state)
result = (await enableDatasetServiceApi(datasetId ?? '')).result
else
result = (await disableDatasetServiceApi(datasetId ?? '')).result
if (result === 'success')
mutateDatasetRes?.()
}, [datasetId, enableDatasetServiceApi, disableDatasetServiceApi])
const handleOpenSecretKeyModal = useCallback(() => {
setIsSecretKeyModalVisible(true)
}, [])
@ -68,24 +45,16 @@ const Card = ({
<div className="flex items-center gap-x-1">
<Indicator
className="shrink-0"
color={apiEnabled ? 'green' : 'yellow'}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div
className={cn(
'system-xs-semibold-uppercase',
apiEnabled ? 'text-text-success' : 'text-text-warning',
)}
className="system-xs-semibold-uppercase text-text-success"
>
{apiEnabled
? t('serviceApi.enabled', { ns: 'dataset' })
: t('serviceApi.disabled', { ns: 'dataset' })}
{t('serviceApi.enabled', { ns: 'dataset' })}
</div>
</div>
<Switch
defaultValue={apiEnabled}
onChange={onToggle}
disabled={!isCurrentWorkspaceManager}
/>
</div>
<div className="flex flex-col">
<div className="system-xs-regular leading-6 text-text-tertiary">

View File

@ -1,22 +1,17 @@
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
import Card from './card'
type ServiceApiProps = {
expand: boolean
apiBaseUrl: string
apiEnabled: boolean
}
const ServiceApi = ({
expand,
apiBaseUrl,
apiEnabled,
}: ServiceApiProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@ -26,7 +21,7 @@ const ServiceApi = ({
}
return (
<div className="p-3 pt-2">
<div>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
@ -41,22 +36,21 @@ const ServiceApi = ({
onClick={handleToggle}
>
<div className={cn(
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border border-components-panel-border px-3',
!expand && 'w-8 justify-center',
open ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
'relative flex h-8 cursor-pointer items-center gap-2 rounded-lg border-[0.5px] border-components-button-secondary-border-hover bg-components-button-secondary-bg px-3',
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
)}
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>}
<Indicator
className={cn('shrink-0', !expand && 'absolute -right-px -top-px')}
color={apiEnabled ? 'green' : 'yellow'}
className={cn('shrink-0')}
color={
apiBaseUrl ? 'green' : 'yellow'
}
/>
<div className="system-sm-medium grow text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[10]">
<Card
apiEnabled={apiEnabled}
apiBaseUrl={apiBaseUrl}
/>
</PortalToFollowElemContent>

View File

@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react'
import DatasetFooter from './index'
describe('DatasetFooter', () => {
it('should render correctly', () => {
render(<DatasetFooter />)
// Check main title (mocked i18n returns ns:key or key)
// The code uses t('didYouKnow', { ns: 'dataset' })
// With default mock it likely returns 'dataset.didYouKnow'
expect(screen.getByText('dataset.didYouKnow')).toBeInTheDocument()
// Check paragraph content
expect(screen.getByText(/dataset.intro1/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro2/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro3/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro4/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro5/)).toBeInTheDocument()
expect(screen.getByText(/dataset.intro6/)).toBeInTheDocument()
})
it('should have correct styling', () => {
const { container } = render(<DatasetFooter />)
const footer = container.querySelector('footer')
expect(footer).toHaveClass('shrink-0', 'px-12', 'py-6')
const h3 = container.querySelector('h3')
expect(h3).toHaveClass('text-gradient')
})
})

View File

@ -14,13 +14,14 @@ import TagFilter from '@/app/components/base/tag-management/filter'
// Hooks
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
import { useAppContext } from '@/context/app-context'
import { useAppContext, useSelector as useAppContextSelector } from '@/context/app-context'
import { useExternalApiPanel } from '@/context/external-api-panel-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
// Components
import ExternalAPIPanel from '../external-api/external-api-panel'
import ServiceApi from '../extra-info/service-api'
import DatasetFooter from './dataset-footer'
import Datasets from './datasets'
@ -58,6 +59,9 @@ const List = () => {
return router.replace('/apps')
}, [currentWorkspace, router])
const isCurrentWorkspaceManager = useAppContextSelector(state => state.isCurrentWorkspaceManager)
const { data: apiBaseInfo } = useDatasetApiBaseUrl()
return (
<div className="scroll-container relative flex grow flex-col overflow-y-auto bg-background-body">
<div className="sticky top-0 z-10 flex items-center justify-end gap-x-1 bg-background-body px-12 pb-2 pt-4">
@ -81,6 +85,11 @@ const List = () => {
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
{
isCurrentWorkspaceManager && (
<ServiceApi apiBaseUrl={apiBaseInfo?.api_base_url ?? ''} />
)
}
<div className="h-4 w-[1px] bg-divider-regular" />
<Button
className="shadows-shadow-xs gap-0.5"
@ -96,7 +105,6 @@ const List = () => {
{showTagManagementModal && (
<TagManagementModal type="knowledge" show={showTagManagementModal} />
)}
{showExternalApiPanel && <ExternalAPIPanel onClose={() => setShowExternalApiPanel(false)} />}
</div>
)

View File

@ -0,0 +1,49 @@
import { render, screen } from '@testing-library/react'
import NewDatasetCard from './index'
type MockOptionProps = {
text: string
href: string
}
// Mock dependencies
vi.mock('./option', () => ({
default: ({ text, href }: MockOptionProps) => (
<a data-testid="option-link" href={href}>
{text}
</a>
),
}))
vi.mock('@remixicon/react', () => ({
RiAddLine: () => <svg data-testid="icon-add" />,
RiFunctionAddLine: () => <svg data-testid="icon-function" />,
}))
vi.mock('@/app/components/base/icons/src/vender/solid/development', () => ({
ApiConnectionMod: () => <svg data-testid="icon-api" />,
}))
describe('NewDatasetCard', () => {
it('should render all options', () => {
render(<NewDatasetCard />)
const options = screen.getAllByTestId('option-link')
expect(options).toHaveLength(3)
// Check first option (Create Dataset)
const createDataset = options[0]
expect(createDataset).toHaveAttribute('href', '/datasets/create')
expect(createDataset).toHaveTextContent('dataset.createDataset')
// Check second option (Create from Pipeline)
const createFromPipeline = options[1]
expect(createFromPipeline).toHaveAttribute('href', '/datasets/create-from-pipeline')
expect(createFromPipeline).toHaveTextContent('dataset.createFromPipeline')
// Check third option (Connect Dataset)
const connectDataset = options[2]
expect(connectDataset).toHaveAttribute('href', '/datasets/connect')
expect(connectDataset).toHaveTextContent('dataset.connectDataset')
})
})

View File

@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/react'
import { ChunkingMode } from '@/models/datasets'
import ChunkStructure from './index'
type MockOptionCardProps = {
id: string
title: string
isActive?: boolean
disabled?: boolean
}
// Mock dependencies
vi.mock('../option-card', () => ({
default: ({ id, title, isActive, disabled }: MockOptionCardProps) => (
<div
data-testid="option-card"
data-id={id}
data-active={isActive}
data-disabled={disabled}
>
{title}
</div>
),
}))
// Mock hook
vi.mock('./hooks', () => ({
useChunkStructure: () => ({
options: [
{
id: ChunkingMode.text,
title: 'General',
description: 'General description',
icon: <svg />,
effectColor: 'indigo',
iconActiveColor: 'indigo',
},
{
id: ChunkingMode.parentChild,
title: 'Parent-Child',
description: 'PC description',
icon: <svg />,
effectColor: 'blue',
iconActiveColor: 'blue',
},
],
}),
}))
describe('ChunkStructure', () => {
it('should render all options', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const options = screen.getAllByTestId('option-card')
expect(options).toHaveLength(2)
expect(options[0]).toHaveTextContent('General')
expect(options[1]).toHaveTextContent('Parent-Child')
})
it('should set active state correctly', () => {
// Render with 'text' active
const { unmount } = render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const options = screen.getAllByTestId('option-card')
expect(options[0]).toHaveAttribute('data-active', 'true')
expect(options[1]).toHaveAttribute('data-active', 'false')
unmount()
// Render with 'parentChild' active
render(<ChunkStructure chunkStructure={ChunkingMode.parentChild} />)
const newOptions = screen.getAllByTestId('option-card')
expect(newOptions[0]).toHaveAttribute('data-active', 'false')
expect(newOptions[1]).toHaveAttribute('data-active', 'true')
})
it('should be always disabled', () => {
render(<ChunkStructure chunkStructure={ChunkingMode.text} />)
const options = screen.getAllByTestId('option-card')
options.forEach((option) => {
expect(option).toHaveAttribute('data-disabled', 'true')
})
})
})

View File

@ -0,0 +1,130 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ActionList from './action-list'
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.action || 'actions'}`
return key
},
}),
}))
const mockToolData = [
{ name: 'tool-1', label: { en_US: 'Tool 1' } },
{ name: 'tool-2', label: { en_US: 'Tool 2' } },
]
const mockProvider = {
name: 'test-plugin/test-tool',
type: 'builtin',
}
vi.mock('@/service/use-tools', () => ({
useAllToolProviders: () => ({ data: [mockProvider] }),
useBuiltinTools: (key: string) => ({
data: key ? mockToolData : undefined,
}),
}))
vi.mock('@/app/components/tools/provider/tool-item', () => ({
default: ({ tool }: { tool: { name: string } }) => (
<div data-testid="tool-item">{tool.name}</div>
),
}))
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
tool: {
identity: {
author: 'test-author',
name: 'test-tool',
description: { en_US: 'Test' },
icon: 'icon.png',
label: { en_US: 'Test Tool' },
tags: [],
},
credentials_schema: [],
},
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
...overrides,
})
describe('ActionList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render tool items when data is available', () => {
const detail = createPluginDetail()
render(<ActionList detail={detail} />)
expect(screen.getByText('2 actions')).toBeInTheDocument()
expect(screen.getAllByTestId('tool-item')).toHaveLength(2)
})
it('should render tool names', () => {
const detail = createPluginDetail()
render(<ActionList detail={detail} />)
expect(screen.getByText('tool-1')).toBeInTheDocument()
expect(screen.getByText('tool-2')).toBeInTheDocument()
})
it('should return null when no tool declaration', () => {
const detail = createPluginDetail({
declaration: {} as PluginDetail['declaration'],
})
const { container } = render(<ActionList detail={detail} />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when providerKey is empty', () => {
const detail = createPluginDetail({
declaration: {
tool: {
identity: undefined,
},
} as unknown as PluginDetail['declaration'],
})
const { container } = render(<ActionList detail={detail} />)
expect(container).toBeEmptyDOMElement()
})
})
describe('Props', () => {
it('should use plugin_id in provider key construction', () => {
const detail = createPluginDetail()
render(<ActionList detail={detail} />)
// The provider key is constructed from plugin_id and tool identity name
// When they match the mock, it renders
expect(screen.getByText('2 actions')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,131 @@
import type { PluginDetail, StrategyDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AgentStrategyList from './agent-strategy-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.strategy || 'strategies'}`
return key
},
}),
}))
const mockStrategies = [
{
identity: {
author: 'author-1',
name: 'strategy-1',
icon: 'icon.png',
label: { en_US: 'Strategy 1' },
provider: 'provider-1',
},
parameters: [],
description: { en_US: 'Strategy 1 desc' },
output_schema: {},
features: [],
},
] as unknown as StrategyDetail[]
let mockStrategyProviderDetail: { declaration: { identity: unknown, strategies: StrategyDetail[] } } | undefined
vi.mock('@/service/use-strategy', () => ({
useStrategyProviderDetail: () => ({
data: mockStrategyProviderDetail,
}),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/strategy-item', () => ({
default: ({ detail }: { detail: StrategyDetail }) => (
<div data-testid="strategy-item">{detail.identity.name}</div>
),
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
agent_strategy: {
identity: {
author: 'test-author',
name: 'test-strategy',
label: { en_US: 'Test Strategy' },
description: { en_US: 'Test' },
icon: 'icon.png',
tags: [],
},
},
} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('AgentStrategyList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockStrategyProviderDetail = {
declaration: {
identity: { author: 'test', name: 'test' },
strategies: mockStrategies,
},
}
})
describe('Rendering', () => {
it('should render strategy items when data is available', () => {
render(<AgentStrategyList detail={createPluginDetail()} />)
expect(screen.getByText('1 strategy')).toBeInTheDocument()
expect(screen.getByTestId('strategy-item')).toBeInTheDocument()
})
it('should return null when no strategy provider detail', () => {
mockStrategyProviderDetail = undefined
const { container } = render(<AgentStrategyList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should render multiple strategies', () => {
mockStrategyProviderDetail = {
declaration: {
identity: { author: 'test', name: 'test' },
strategies: [
...mockStrategies,
{ ...mockStrategies[0], identity: { ...mockStrategies[0].identity, name: 'strategy-2' } },
],
},
}
render(<AgentStrategyList detail={createPluginDetail()} />)
expect(screen.getByText('2 strategies')).toBeInTheDocument()
expect(screen.getAllByTestId('strategy-item')).toHaveLength(2)
})
})
describe('Props', () => {
it('should pass tenant_id to provider detail', () => {
const detail = createPluginDetail()
detail.tenant_id = 'custom-tenant'
render(<AgentStrategyList detail={detail} />)
expect(screen.getByTestId('strategy-item')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,104 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DatasourceActionList from './datasource-action-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.action || 'actions'}`
return key
},
}),
}))
const mockDataSourceList = [
{ plugin_id: 'test-plugin', name: 'Data Source 1' },
]
let mockDataSourceListData: typeof mockDataSourceList | undefined
vi.mock('@/service/use-pipeline', () => ({
useDataSourceList: () => ({ data: mockDataSourceListData }),
}))
vi.mock('@/app/components/workflow/block-selector/utils', () => ({
transformDataSourceToTool: (ds: unknown) => ds,
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
datasource: {
identity: {
author: 'test-author',
name: 'test-datasource',
description: { en_US: 'Test' },
icon: 'icon.png',
label: { en_US: 'Test Datasource' },
tags: [],
},
credentials_schema: [],
},
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('DatasourceActionList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataSourceListData = mockDataSourceList
})
describe('Rendering', () => {
it('should render action count when data and provider exist', () => {
render(<DatasourceActionList detail={createPluginDetail()} />)
// The component always shows "0 action" because data is hardcoded as empty array
expect(screen.getByText('0 action')).toBeInTheDocument()
})
it('should return null when no provider found', () => {
mockDataSourceListData = []
const { container } = render(<DatasourceActionList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when dataSourceList is undefined', () => {
mockDataSourceListData = undefined
const { container } = render(<DatasourceActionList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
})
describe('Props', () => {
it('should use plugin_id to find matching datasource', () => {
const detail = createPluginDetail()
detail.plugin_id = 'different-plugin'
mockDataSourceListData = [{ plugin_id: 'different-plugin', name: 'Different DS' }]
render(<DatasourceActionList detail={detail} />)
expect(screen.getByText('0 action')).toBeInTheDocument()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,386 @@
import type { EndpointListItem, PluginDetail } from '../types'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import EndpointCard from './endpoint-card'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('copy-to-clipboard', () => ({
default: vi.fn(),
}))
const mockHandleChange = vi.fn()
const mockEnableEndpoint = vi.fn()
const mockDisableEndpoint = vi.fn()
const mockDeleteEndpoint = vi.fn()
const mockUpdateEndpoint = vi.fn()
// Flags to control whether operations should fail
const failureFlags = {
enable: false,
disable: false,
delete: false,
update: false,
}
vi.mock('@/service/use-endpoints', () => ({
useEnableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (id: string) => {
mockEnableEndpoint(id)
if (failureFlags.enable)
onError()
else
onSuccess()
},
}),
useDisableEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (id: string) => {
mockDisableEndpoint(id)
if (failureFlags.disable)
onError()
else
onSuccess()
},
}),
useDeleteEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (id: string) => {
mockDeleteEndpoint(id)
if (failureFlags.delete)
onError()
else
onSuccess()
},
}),
useUpdateEndpoint: ({ onSuccess, onError }: { onSuccess: () => void, onError: () => void }) => ({
mutate: (data: unknown) => {
mockUpdateEndpoint(data)
if (failureFlags.update)
onError()
else
onSuccess()
},
}),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <span data-testid="indicator" data-color={color} />,
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
addDefaultValue: (value: unknown) => value,
}))
vi.mock('./endpoint-modal', () => ({
default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => (
<div data-testid="endpoint-modal">
<button data-testid="modal-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="modal-save" onClick={() => onSaved({ name: 'Updated' })}>Save</button>
</div>
),
}))
const mockEndpointData: EndpointListItem = {
id: 'ep-1',
name: 'Test Endpoint',
url: 'https://api.example.com',
enabled: true,
created_at: '2024-01-01',
updated_at: '2024-01-02',
settings: {},
tenant_id: 'tenant-1',
plugin_id: 'plugin-1',
expired_at: '',
hook_id: 'hook-1',
declaration: {
settings: [],
endpoints: [
{ path: '/api/test', method: 'GET' },
{ path: '/api/hidden', method: 'POST', hidden: true },
],
},
}
const mockPluginDetail: PluginDetail = {
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
}
describe('EndpointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
// Reset failure flags
failureFlags.enable = false
failureFlags.disable = false
failureFlags.delete = false
failureFlags.update = false
// Mock Toast.notify to prevent toast elements from accumulating in DOM
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
afterEach(() => {
vi.useRealTimers()
})
describe('Rendering', () => {
it('should render endpoint name', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
})
it('should render visible endpoints only', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('GET')).toBeInTheDocument()
expect(screen.getByText('https://api.example.com/api/test')).toBeInTheDocument()
expect(screen.queryByText('POST')).not.toBeInTheDocument()
})
it('should show active status when enabled', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('detailPanel.serviceOk')).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
})
it('should show disabled status when not enabled', () => {
const disabledData = { ...mockEndpointData, enabled: false }
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
expect(screen.getByText('detailPanel.disabled')).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'gray')
})
})
describe('User Interactions', () => {
it('should show disable confirm when switching off', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument()
})
it('should call disableEndpoint when confirm disable', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
// Click confirm button in the Confirm dialog
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDisableEndpoint).toHaveBeenCalledWith('ep-1')
})
it('should show delete confirm when delete clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
// Find delete button by its destructive class
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument()
})
it('should call deleteEndpoint when confirm delete', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalledWith('ep-1')
})
it('should show edit modal when edit clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
it('should call updateEndpoint when save in modal', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
})
})
describe('Copy Functionality', () => {
it('should reset copy state after timeout', async () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
// Find copy button by its class
const allButtons = screen.getAllByRole('button')
const copyButton = allButtons.find(btn => btn.classList.contains('ml-2'))
expect(copyButton).toBeDefined()
if (copyButton) {
fireEvent.click(copyButton)
act(() => {
vi.advanceTimersByTime(2000)
})
// After timeout, the component should still be rendered correctly
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
}
})
})
describe('Edge Cases', () => {
it('should handle empty endpoints', () => {
const dataWithNoEndpoints = {
...mockEndpointData,
declaration: { settings: [], endpoints: [] },
}
render(<EndpointCard pluginDetail={mockPluginDetail} data={dataWithNoEndpoints} handleChange={mockHandleChange} />)
expect(screen.getByText('Test Endpoint')).toBeInTheDocument()
})
it('should call handleChange after enable', () => {
const disabledData = { ...mockEndpointData, enabled: false }
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(mockHandleChange).toHaveBeenCalled()
})
it('should hide disable confirm and revert state when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(screen.getByText('detailPanel.endpointDisableTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
// Confirm should be hidden
expect(screen.queryByText('detailPanel.endpointDisableTip')).not.toBeInTheDocument()
})
it('should hide delete confirm when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
expect(deleteButton).toBeDefined()
if (deleteButton)
fireEvent.click(deleteButton)
expect(screen.getByText('detailPanel.endpointDeleteTip')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
expect(screen.queryByText('detailPanel.endpointDeleteTip')).not.toBeInTheDocument()
})
it('should hide edit modal when cancel clicked', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
if (editButton)
fireEvent.click(editButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
})
})
describe('Error Handling', () => {
it('should show error toast when enable fails', () => {
failureFlags.enable = true
const disabledData = { ...mockEndpointData, enabled: false }
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
expect(mockEnableEndpoint).toHaveBeenCalled()
})
it('should show error toast when disable fails', () => {
failureFlags.disable = true
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
fireEvent.click(screen.getByRole('switch'))
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDisableEndpoint).toHaveBeenCalled()
})
it('should show error toast when delete fails', () => {
failureFlags.delete = true
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const allButtons = screen.getAllByRole('button')
const deleteButton = allButtons.find(btn => btn.classList.contains('text-text-tertiary'))
if (deleteButton)
fireEvent.click(deleteButton)
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
expect(mockDeleteEndpoint).toHaveBeenCalled()
})
it('should show error toast when update fails', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
const actionButtons = screen.getAllByRole('button', { name: '' })
const editButton = actionButtons[0]
expect(editButton).toBeDefined()
if (editButton)
fireEvent.click(editButton)
// Verify modal is open
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
// Set failure flag before save is clicked
failureFlags.update = true
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockUpdateEndpoint).toHaveBeenCalled()
// On error, handleChange is not called
expect(mockHandleChange).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,222 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import EndpointList from './endpoint-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
const mockEndpoints = [
{ id: 'ep-1', name: 'Endpoint 1', url: 'https://api.example.com', declaration: { settings: [], endpoints: [] } },
]
let mockEndpointListData: { endpoints: typeof mockEndpoints } | undefined
const mockInvalidateEndpointList = vi.fn()
const mockCreateEndpoint = vi.fn()
vi.mock('@/service/use-endpoints', () => ({
useEndpointList: () => ({ data: mockEndpointListData }),
useInvalidateEndpointList: () => mockInvalidateEndpointList,
useCreateEndpoint: ({ onSuccess }: { onSuccess: () => void }) => ({
mutate: (data: unknown) => {
mockCreateEndpoint(data)
onSuccess()
},
}),
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
toolCredentialToFormSchemas: (schemas: unknown[]) => schemas,
}))
vi.mock('./endpoint-card', () => ({
default: ({ data }: { data: { name: string } }) => (
<div data-testid="endpoint-card">{data.name}</div>
),
}))
vi.mock('./endpoint-modal', () => ({
default: ({ onCancel, onSaved }: { onCancel: () => void, onSaved: (state: unknown) => void }) => (
<div data-testid="endpoint-modal">
<button data-testid="modal-cancel" onClick={onCancel}>Cancel</button>
<button data-testid="modal-save" onClick={() => onSaved({ name: 'New Endpoint' })}>Save</button>
</div>
),
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
endpoint: { settings: [], endpoints: [] },
tool: undefined,
} as unknown as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('EndpointList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEndpointListData = { endpoints: mockEndpoints }
})
describe('Rendering', () => {
it('should render endpoint list', () => {
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument()
})
it('should render endpoint cards', () => {
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByTestId('endpoint-card')).toBeInTheDocument()
expect(screen.getByText('Endpoint 1')).toBeInTheDocument()
})
it('should return null when no data', () => {
mockEndpointListData = undefined
const { container } = render(<EndpointList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should show empty message when no endpoints', () => {
mockEndpointListData = { endpoints: [] }
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getByText('detailPanel.endpointsEmpty')).toBeInTheDocument()
})
it('should render add button', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
expect(addButton).toBeDefined()
})
})
describe('User Interactions', () => {
it('should show modal when add button clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
})
it('should hide modal when cancel clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
expect(screen.getByTestId('endpoint-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('modal-cancel'))
expect(screen.queryByTestId('endpoint-modal')).not.toBeInTheDocument()
})
it('should call createEndpoint when save clicked', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalled()
})
})
describe('Border Style', () => {
it('should render with border style based on tool existence', () => {
const detail = createPluginDetail()
detail.declaration.tool = {} as PluginDetail['declaration']['tool']
render(<EndpointList detail={detail} />)
// Verify the component renders correctly
expect(screen.getByText('detailPanel.endpoints')).toBeInTheDocument()
})
})
describe('Multiple Endpoints', () => {
it('should render multiple endpoint cards', () => {
mockEndpointListData = {
endpoints: [
{ id: 'ep-1', name: 'Endpoint 1', url: 'https://api1.example.com', declaration: { settings: [], endpoints: [] } },
{ id: 'ep-2', name: 'Endpoint 2', url: 'https://api2.example.com', declaration: { settings: [], endpoints: [] } },
],
}
render(<EndpointList detail={createPluginDetail()} />)
expect(screen.getAllByTestId('endpoint-card')).toHaveLength(2)
})
})
describe('Tooltip', () => {
it('should render with tooltip content', () => {
render(<EndpointList detail={createPluginDetail()} />)
// Tooltip is rendered - the add button should be visible
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
expect(addButton).toBeDefined()
})
})
describe('Create Endpoint Flow', () => {
it('should invalidate endpoint list after successful create', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockInvalidateEndpointList).toHaveBeenCalledWith('test-plugin')
})
it('should pass correct params to createEndpoint', () => {
render(<EndpointList detail={createPluginDetail()} />)
const addButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (addButton)
fireEvent.click(addButton)
fireEvent.click(screen.getByTestId('modal-save'))
expect(mockCreateEndpoint).toHaveBeenCalledWith({
pluginUniqueID: 'test-uid',
state: { name: 'New Endpoint' },
})
})
})
})

View File

@ -0,0 +1,519 @@
import type { FormSchema } from '../../base/form/types'
import type { PluginDetail } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Toast from '@/app/components/base/toast'
import EndpointModal from './endpoint-modal'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
if (opts?.field)
return `${key}: ${opts.field}`
return key
},
}),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string> | string) =>
typeof obj === 'string' ? obj : obj?.en_US || '',
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-modal/Form', () => ({
default: ({ value, onChange, fieldMoreInfo }: {
value: Record<string, unknown>
onChange: (v: Record<string, unknown>) => void
fieldMoreInfo?: (item: { url?: string }) => React.ReactNode
}) => {
return (
<div data-testid="form">
<input
data-testid="form-input"
value={value.name as string || ''}
onChange={e => onChange({ ...value, name: e.target.value })}
/>
{/* Render fieldMoreInfo to test url link */}
{fieldMoreInfo && (
<div data-testid="field-more-info">
{fieldMoreInfo({ url: 'https://example.com' })}
{fieldMoreInfo({})}
</div>
)}
</div>
)
},
}))
vi.mock('../readme-panel/entrance', () => ({
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
}))
const mockFormSchemas = [
{ name: 'name', label: { en_US: 'Name' }, type: 'text-input', required: true, default: '' },
{ name: 'apiKey', label: { en_US: 'API Key' }, type: 'secret-input', required: false, default: '' },
] as unknown as FormSchema[]
const mockPluginDetail: PluginDetail = {
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
}
describe('EndpointModal', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn()
let mockToastNotify: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
mockToastNotify = vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
describe('Rendering', () => {
it('should render drawer', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render title and description', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByText('detailPanel.endpointModalTitle')).toBeInTheDocument()
expect(screen.getByText('detailPanel.endpointModalDesc')).toBeInTheDocument()
})
it('should render form with fieldMoreInfo url link', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('field-more-info')).toBeInTheDocument()
// Should render the "howToGet" link when url exists
expect(screen.getByText('howToGet')).toBeInTheDocument()
})
it('should render readme entrance', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('readme-entrance')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onCancel when cancel clicked', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.cancel' }))
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button clicked', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
// Find the close button (ActionButton with RiCloseLine icon)
const allButtons = screen.getAllByRole('button')
const closeButton = allButtons.find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
expect(mockOnCancel).toHaveBeenCalledTimes(1)
})
it('should update form value when input changes', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
const input = screen.getByTestId('form-input')
fireEvent.change(input, { target: { value: 'Test Name' } })
expect(input).toHaveValue('Test Name')
})
})
describe('Default Values', () => {
it('should use defaultValues when provided', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
defaultValues={{ name: 'Default Name' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('form-input')).toHaveValue('Default Name')
})
it('should extract default values from schemas when no defaultValues', () => {
const schemasWithDefaults = [
{ name: 'name', label: 'Name', type: 'text-input', required: true, default: 'Schema Default' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithDefaults}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('form-input')).toHaveValue('Schema Default')
})
it('should handle schemas without default values', () => {
const schemasNoDefault = [
{ name: 'name', label: 'Name', type: 'text-input', required: false },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasNoDefault}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
expect(screen.getByTestId('form')).toBeInTheDocument()
})
})
describe('Validation - handleSave', () => {
it('should show toast error when required field is empty', () => {
const schemasWithRequired = [
{ name: 'name', label: { en_US: 'Name Field' }, type: 'text-input', required: true, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithRequired}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('errorMsg.fieldRequired'),
})
expect(mockOnSaved).not.toHaveBeenCalled()
})
it('should show toast error with string label when required field is empty', () => {
const schemasWithStringLabel = [
{ name: 'name', label: 'String Label', type: 'text-input', required: true, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithStringLabel}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: expect.stringContaining('String Label'),
})
})
it('should call onSaved when all required fields are filled', () => {
render(
<EndpointModal
formSchemas={mockFormSchemas}
defaultValues={{ name: 'Valid Name' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ name: 'Valid Name' })
})
it('should not validate non-required empty fields', () => {
const schemasOptional = [
{ name: 'optional', label: 'Optional', type: 'text-input', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasOptional}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockToastNotify).not.toHaveBeenCalled()
expect(mockOnSaved).toHaveBeenCalled()
})
})
describe('Boolean Field Processing', () => {
it('should convert string "true" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'true' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "1" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: '1' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "True" to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'True' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert string "false" to boolean false', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 'false' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should convert number 1 to boolean true', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 1 }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should convert number 0 to boolean false', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: 0 }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should preserve boolean true value', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: true }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: true })
})
it('should preserve boolean false value', () => {
const schemasWithBoolean = [
{ name: 'enabled', label: 'Enabled', type: 'boolean', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithBoolean}
defaultValues={{ enabled: false }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ enabled: false })
})
it('should not process non-boolean fields', () => {
const schemasWithText = [
{ name: 'text', label: 'Text', type: 'text-input', required: false, default: '' },
] as unknown as FormSchema[]
render(
<EndpointModal
formSchemas={schemasWithText}
defaultValues={{ text: 'hello' }}
onCancel={mockOnCancel}
onSaved={mockOnSaved}
pluginDetail={mockPluginDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.save' }))
expect(mockOnSaved).toHaveBeenCalledWith({ text: 'hello' })
})
})
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
expect(EndpointModal).toBeDefined()
expect((EndpointModal as { $$typeof?: symbol }).$$typeof).toBeDefined()
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,103 @@
import type { PluginDetail } from '@/app/components/plugins/types'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ModelList from './model-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} models`
return key
},
}),
}))
const mockModels = [
{ model: 'gpt-4', provider: 'openai' },
{ model: 'gpt-3.5', provider: 'openai' },
]
let mockModelListResponse: { data: typeof mockModels } | undefined
vi.mock('@/service/use-models', () => ({
useModelProviderModelList: () => ({
data: mockModelListResponse,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { modelName: string }) => (
<span data-testid="model-icon">{modelName}</span>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => (
<span data-testid="model-name">{modelItem.model}</span>
),
}))
const createPluginDetail = (): PluginDetail => ({
id: 'test-id',
created_at: '2024-01-01',
updated_at: '2024-01-02',
name: 'Test Plugin',
plugin_id: 'test-plugin',
plugin_unique_identifier: 'test-uid',
declaration: {
model: { provider: 'openai' },
} as PluginDetail['declaration'],
installation_id: 'install-1',
tenant_id: 'tenant-1',
endpoints_setups: 0,
endpoints_active: 0,
version: '1.0.0',
latest_version: '1.0.0',
latest_unique_identifier: 'test-uid',
source: 'marketplace' as PluginDetail['source'],
meta: undefined,
status: 'active',
deprecated_reason: '',
alternative_plugin_id: '',
})
describe('ModelList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockModelListResponse = { data: mockModels }
})
describe('Rendering', () => {
it('should render model list when data is available', () => {
render(<ModelList detail={createPluginDetail()} />)
expect(screen.getByText('2 models')).toBeInTheDocument()
})
it('should render model icons and names', () => {
render(<ModelList detail={createPluginDetail()} />)
expect(screen.getAllByTestId('model-icon')).toHaveLength(2)
expect(screen.getAllByTestId('model-name')).toHaveLength(2)
// Both icon and name show the model name, so use getAllByText
expect(screen.getAllByText('gpt-4')).toHaveLength(2)
expect(screen.getAllByText('gpt-3.5')).toHaveLength(2)
})
it('should return null when no data', () => {
mockModelListResponse = undefined
const { container } = render(<ModelList detail={createPluginDetail()} />)
expect(container).toBeEmptyDOMElement()
})
it('should handle empty model list', () => {
mockModelListResponse = { data: [] }
render(<ModelList detail={createPluginDetail()} />)
expect(screen.getByText('0 models')).toBeInTheDocument()
expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,215 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginSource } from '../types'
import OperationDropdown from './operation-dropdown'
// Mock dependencies
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: <T,>(selector: (state: { systemFeatures: { enable_marketplace: boolean } }) => T): T =>
selector({ systemFeatures: { enable_marketplace: true } }),
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/base/action-button', () => ({
default: ({ children, className, onClick }: { children: React.ReactNode, className?: string, onClick?: () => void }) => (
<button data-testid="action-button" className={className} onClick={onClick}>
{children}
</button>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal-elem" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => (
<div data-testid="portal-content" className={className}>{children}</div>
),
}))
describe('OperationDropdown', () => {
const mockOnInfo = vi.fn()
const mockOnCheckVersion = vi.fn()
const mockOnRemove = vi.fn()
const defaultProps = {
source: PluginSource.github,
detailUrl: 'https://github.com/test/repo',
onInfo: mockOnInfo,
onCheckVersion: mockOnCheckVersion,
onRemove: mockOnRemove,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render trigger button', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByTestId('portal-trigger')).toBeInTheDocument()
expect(screen.getByTestId('action-button')).toBeInTheDocument()
})
it('should render dropdown content', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should render info option for github source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
expect(screen.getByText('detailPanel.operation.info')).toBeInTheDocument()
})
it('should render check update option for github source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
expect(screen.getByText('detailPanel.operation.checkUpdate')).toBeInTheDocument()
})
it('should render view detail option for github source with marketplace enabled', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument()
})
it('should render view detail option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)
expect(screen.getByText('detailPanel.operation.viewDetail')).toBeInTheDocument()
})
it('should always render remove option', () => {
render(<OperationDropdown {...defaultProps} />)
expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument()
})
it('should not render info option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)
expect(screen.queryByText('detailPanel.operation.info')).not.toBeInTheDocument()
})
it('should not render check update option for marketplace source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.marketplace} />)
expect(screen.queryByText('detailPanel.operation.checkUpdate')).not.toBeInTheDocument()
})
it('should not render view detail for local source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.local} />)
expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument()
})
it('should not render view detail for debugging source', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.debugging} />)
expect(screen.queryByText('detailPanel.operation.viewDetail')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should toggle dropdown when trigger is clicked', () => {
render(<OperationDropdown {...defaultProps} />)
const trigger = screen.getByTestId('portal-trigger')
fireEvent.click(trigger)
// The portal-elem should reflect the open state
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
})
it('should call onInfo when info option is clicked', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
fireEvent.click(screen.getByText('detailPanel.operation.info'))
expect(mockOnInfo).toHaveBeenCalledTimes(1)
})
it('should call onCheckVersion when check update option is clicked', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
fireEvent.click(screen.getByText('detailPanel.operation.checkUpdate'))
expect(mockOnCheckVersion).toHaveBeenCalledTimes(1)
})
it('should call onRemove when remove option is clicked', () => {
render(<OperationDropdown {...defaultProps} />)
fireEvent.click(screen.getByText('detailPanel.operation.remove'))
expect(mockOnRemove).toHaveBeenCalledTimes(1)
})
it('should have correct href for view detail link', () => {
render(<OperationDropdown {...defaultProps} source={PluginSource.github} />)
const link = screen.getByText('detailPanel.operation.viewDetail').closest('a')
expect(link).toHaveAttribute('href', 'https://github.com/test/repo')
expect(link).toHaveAttribute('target', '_blank')
})
})
describe('Props Variations', () => {
it('should handle all plugin sources', () => {
const sources = [
PluginSource.github,
PluginSource.marketplace,
PluginSource.local,
PluginSource.debugging,
]
sources.forEach((source) => {
const { unmount } = render(
<OperationDropdown {...defaultProps} source={source} />,
)
expect(screen.getByTestId('portal-elem')).toBeInTheDocument()
expect(screen.getByText('detailPanel.operation.remove')).toBeInTheDocument()
unmount()
})
})
it('should handle different detail URLs', () => {
const urls = [
'https://github.com/owner/repo',
'https://marketplace.example.com/plugin/123',
]
urls.forEach((url) => {
const { unmount } = render(
<OperationDropdown {...defaultProps} detailUrl={url} source={PluginSource.github} />,
)
const link = screen.getByText('detailPanel.operation.viewDetail').closest('a')
expect(link).toHaveAttribute('href', url)
unmount()
})
})
})
describe('Memoization', () => {
it('should be wrapped with React.memo', () => {
// Verify the component is exported as a memo component
expect(OperationDropdown).toBeDefined()
// React.memo wraps the component, so it should have $$typeof
expect((OperationDropdown as { $$typeof?: symbol }).$$typeof).toBeDefined()
})
})
})

View File

@ -0,0 +1,461 @@
import type { SimpleDetail } from './store'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { usePluginStore } from './store'
// Factory function to create mock SimpleDetail
const createSimpleDetail = (overrides: Partial<SimpleDetail> = {}): SimpleDetail => ({
plugin_id: 'test-plugin-id',
name: 'Test Plugin',
plugin_unique_identifier: 'test-plugin-uid',
id: 'test-id',
provider: 'test-provider',
declaration: {
category: 'tool' as SimpleDetail['declaration']['category'],
name: 'test-declaration',
},
...overrides,
})
describe('usePluginStore', () => {
beforeEach(() => {
// Reset store state before each test
const { result } = renderHook(() => usePluginStore())
act(() => {
result.current.setDetail(undefined)
})
})
describe('Initial State', () => {
it('should have undefined detail initially', () => {
const { result } = renderHook(() => usePluginStore())
expect(result.current.detail).toBeUndefined()
})
it('should provide setDetail function', () => {
const { result } = renderHook(() => usePluginStore())
expect(typeof result.current.setDetail).toBe('function')
})
})
describe('setDetail', () => {
it('should set detail with valid SimpleDetail', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toEqual(detail)
})
it('should set detail to undefined', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
// First set a value
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toEqual(detail)
// Then clear it
act(() => {
result.current.setDetail(undefined)
})
expect(result.current.detail).toBeUndefined()
})
it('should update detail when called multiple times', () => {
const { result } = renderHook(() => usePluginStore())
const detail1 = createSimpleDetail({ plugin_id: 'plugin-1' })
const detail2 = createSimpleDetail({ plugin_id: 'plugin-2' })
act(() => {
result.current.setDetail(detail1)
})
expect(result.current.detail?.plugin_id).toBe('plugin-1')
act(() => {
result.current.setDetail(detail2)
})
expect(result.current.detail?.plugin_id).toBe('plugin-2')
})
it('should handle detail with trigger declaration', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
trigger: {
subscription_schema: [],
subscription_constructor: null,
},
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.trigger).toEqual({
subscription_schema: [],
subscription_constructor: null,
})
})
it('should handle detail with partial declaration', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
name: 'partial-plugin',
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.name).toBe('partial-plugin')
})
})
describe('Store Sharing', () => {
it('should share state across multiple hook instances', () => {
const { result: result1 } = renderHook(() => usePluginStore())
const { result: result2 } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result1.current.setDetail(detail)
})
// Both hooks should see the same state
expect(result1.current.detail).toEqual(detail)
expect(result2.current.detail).toEqual(detail)
})
it('should update all hook instances when state changes', () => {
const { result: result1 } = renderHook(() => usePluginStore())
const { result: result2 } = renderHook(() => usePluginStore())
const detail1 = createSimpleDetail({ name: 'Plugin One' })
const detail2 = createSimpleDetail({ name: 'Plugin Two' })
act(() => {
result1.current.setDetail(detail1)
})
expect(result1.current.detail?.name).toBe('Plugin One')
expect(result2.current.detail?.name).toBe('Plugin One')
act(() => {
result2.current.setDetail(detail2)
})
expect(result1.current.detail?.name).toBe('Plugin Two')
expect(result2.current.detail?.name).toBe('Plugin Two')
})
})
describe('Selector Pattern', () => {
// Extract selectors to reduce nesting depth
const selectDetail = (state: ReturnType<typeof usePluginStore.getState>) => state.detail
const selectSetDetail = (state: ReturnType<typeof usePluginStore.getState>) => state.setDetail
it('should support selector to get specific field', () => {
const { result: setterResult } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({ plugin_id: 'selected-plugin' })
act(() => {
setterResult.current.setDetail(detail)
})
// Use selector to get only detail
const { result: selectorResult } = renderHook(() => usePluginStore(selectDetail))
expect(selectorResult.current?.plugin_id).toBe('selected-plugin')
})
it('should support selector to get setDetail function', () => {
const { result } = renderHook(() => usePluginStore(selectSetDetail))
expect(typeof result.current).toBe('function')
})
})
describe('Edge Cases', () => {
it('should handle empty string values in detail', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
plugin_id: '',
name: '',
plugin_unique_identifier: '',
provider: '',
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.plugin_id).toBe('')
expect(result.current.detail?.name).toBe('')
})
it('should handle detail with empty declaration', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration).toEqual({})
})
it('should handle rapid state updates', () => {
const { result } = renderHook(() => usePluginStore())
act(() => {
for (let i = 0; i < 10; i++)
result.current.setDetail(createSimpleDetail({ plugin_id: `plugin-${i}` }))
})
expect(result.current.detail?.plugin_id).toBe('plugin-9')
})
it('should handle setDetail called without arguments', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toBeDefined()
act(() => {
result.current.setDetail()
})
expect(result.current.detail).toBeUndefined()
})
})
describe('Type Safety', () => {
it('should preserve all SimpleDetail fields correctly', () => {
const { result } = renderHook(() => usePluginStore())
const detail: SimpleDetail = {
plugin_id: 'type-test-id',
name: 'Type Test Plugin',
plugin_unique_identifier: 'type-test-uid',
id: 'type-id',
provider: 'type-provider',
declaration: {
category: 'model' as SimpleDetail['declaration']['category'],
name: 'type-declaration',
version: '2.0.0',
author: 'test-author',
},
}
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail).toStrictEqual(detail)
expect(result.current.detail?.plugin_id).toBe('type-test-id')
expect(result.current.detail?.name).toBe('Type Test Plugin')
expect(result.current.detail?.plugin_unique_identifier).toBe('type-test-uid')
expect(result.current.detail?.id).toBe('type-id')
expect(result.current.detail?.provider).toBe('type-provider')
})
it('should handle declaration with subscription_constructor', () => {
const { result } = renderHook(() => usePluginStore())
const mockConstructor = {
credentials_schema: [],
oauth_schema: {
client_schema: [],
credentials_schema: [],
},
parameters: [],
}
const detail = createSimpleDetail({
declaration: {
trigger: {
subscription_schema: [],
subscription_constructor: mockConstructor as unknown as NonNullable<SimpleDetail['declaration']['trigger']>['subscription_constructor'],
},
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.trigger?.subscription_constructor).toBeDefined()
})
it('should handle declaration with subscription_schema', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
trigger: {
subscription_schema: [],
subscription_constructor: null,
},
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.trigger?.subscription_schema).toEqual([])
})
})
describe('State Persistence', () => {
it('should maintain state after multiple renders', () => {
const detail = createSimpleDetail({ name: 'Persistent Plugin' })
const { result, rerender } = renderHook(() => usePluginStore())
act(() => {
result.current.setDetail(detail)
})
// Rerender multiple times
rerender()
rerender()
rerender()
expect(result.current.detail?.name).toBe('Persistent Plugin')
})
it('should maintain reference equality for unchanged state', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail()
act(() => {
result.current.setDetail(detail)
})
const firstDetailRef = result.current.detail
// Get state again without changing
const { result: result2 } = renderHook(() => usePluginStore())
expect(result2.current.detail).toBe(firstDetailRef)
})
})
describe('Concurrent Updates', () => {
it('should handle updates from multiple sources correctly', () => {
const { result: hook1 } = renderHook(() => usePluginStore())
const { result: hook2 } = renderHook(() => usePluginStore())
const { result: hook3 } = renderHook(() => usePluginStore())
act(() => {
hook1.current.setDetail(createSimpleDetail({ name: 'From Hook 1' }))
})
act(() => {
hook2.current.setDetail(createSimpleDetail({ name: 'From Hook 2' }))
})
act(() => {
hook3.current.setDetail(createSimpleDetail({ name: 'From Hook 3' }))
})
// All hooks should reflect the last update
expect(hook1.current.detail?.name).toBe('From Hook 3')
expect(hook2.current.detail?.name).toBe('From Hook 3')
expect(hook3.current.detail?.name).toBe('From Hook 3')
})
it('should handle interleaved read and write operations', () => {
const { result } = renderHook(() => usePluginStore())
act(() => {
result.current.setDetail(createSimpleDetail({ plugin_id: 'step-1' }))
})
expect(result.current.detail?.plugin_id).toBe('step-1')
act(() => {
result.current.setDetail(createSimpleDetail({ plugin_id: 'step-2' }))
})
expect(result.current.detail?.plugin_id).toBe('step-2')
act(() => {
result.current.setDetail(undefined)
})
expect(result.current.detail).toBeUndefined()
act(() => {
result.current.setDetail(createSimpleDetail({ plugin_id: 'step-3' }))
})
expect(result.current.detail?.plugin_id).toBe('step-3')
})
})
describe('Declaration Variations', () => {
it('should handle declaration with all optional fields', () => {
const { result } = renderHook(() => usePluginStore())
const detail = createSimpleDetail({
declaration: {
category: 'extension' as SimpleDetail['declaration']['category'],
name: 'full-declaration',
version: '1.0.0',
author: 'full-author',
icon: 'icon.png',
verified: true,
tags: ['tag1', 'tag2'],
},
})
act(() => {
result.current.setDetail(detail)
})
const decl = result.current.detail?.declaration
expect(decl?.category).toBe('extension')
expect(decl?.name).toBe('full-declaration')
expect(decl?.version).toBe('1.0.0')
expect(decl?.author).toBe('full-author')
expect(decl?.icon).toBe('icon.png')
expect(decl?.verified).toBe(true)
expect(decl?.tags).toEqual(['tag1', 'tag2'])
})
it('should handle declaration with nested tool object', () => {
const { result } = renderHook(() => usePluginStore())
const mockTool = {
identity: {
author: 'tool-author',
name: 'tool-name',
icon: 'tool-icon.png',
tags: ['api', 'utility'],
},
credentials_schema: [],
}
const detail = createSimpleDetail({
declaration: {
tool: mockTool as unknown as SimpleDetail['declaration']['tool'],
},
})
act(() => {
result.current.setDetail(detail)
})
expect(result.current.detail?.declaration.tool?.identity.name).toBe('tool-name')
expect(result.current.detail?.declaration.tool?.identity.tags).toEqual(['api', 'utility'])
})
})
})

View File

@ -0,0 +1,203 @@
import type { StrategyDetail as StrategyDetailType } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StrategyDetail from './strategy-detail'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: () => <span data-testid="card-icon" />,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
type ProviderType = Parameters<typeof StrategyDetail>[0]['provider']
const mockProvider = {
author: 'test-author',
name: 'test-provider',
description: { en_US: 'Provider desc' },
tenant_id: 'tenant-1',
icon: 'icon.png',
label: { en_US: 'Test Provider' },
tags: [],
} as unknown as ProviderType
const mockDetail = {
identity: {
author: 'author-1',
name: 'strategy-1',
icon: 'icon.png',
label: { en_US: 'Strategy Label' },
provider: 'provider-1',
},
parameters: [
{
name: 'param1',
label: { en_US: 'Parameter 1' },
type: 'text-input',
required: true,
human_description: { en_US: 'A text parameter' },
},
],
description: { en_US: 'Strategy description' },
output_schema: {
properties: {
result: { type: 'string', description: 'Result output' },
items: { type: 'array', items: { type: 'string' }, description: 'Array items' },
},
},
features: [],
} as unknown as StrategyDetailType
describe('StrategyDetail', () => {
const mockOnHide = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render drawer', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render provider label', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('Test Provider')).toBeInTheDocument()
})
it('should render strategy label', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('Strategy Label')).toBeInTheDocument()
})
it('should render parameters section', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
})
it('should render output schema section', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('OUTPUT')).toBeInTheDocument()
expect(screen.getByText('result')).toBeInTheDocument()
expect(screen.getByText('String')).toBeInTheDocument()
})
it('should render BACK button', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
expect(screen.getByText('BACK')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onHide when close button clicked', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
// Find the close button (ActionButton with action-btn class)
const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
it('should call onHide when BACK clicked', () => {
render(<StrategyDetail provider={mockProvider} detail={mockDetail} onHide={mockOnHide} />)
fireEvent.click(screen.getByText('BACK'))
expect(mockOnHide).toHaveBeenCalledTimes(1)
})
})
describe('Parameter Types', () => {
it('should display correct type for number-input', () => {
const detailWithNumber = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'number-input' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithNumber} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument()
})
it('should display correct type for checkbox', () => {
const detailWithCheckbox = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'checkbox' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithCheckbox} onHide={mockOnHide} />)
expect(screen.getByText('boolean')).toBeInTheDocument()
})
it('should display correct type for file', () => {
const detailWithFile = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'file' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithFile} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument()
})
it('should display correct type for array[tools]', () => {
const detailWithArrayTools = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'array[tools]' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithArrayTools} onHide={mockOnHide} />)
expect(screen.getByText('multiple-tool-select')).toBeInTheDocument()
})
it('should display original type for unknown types', () => {
const detailWithUnknown = {
...mockDetail,
parameters: [{ ...mockDetail.parameters[0], type: 'custom-type' }],
}
render(<StrategyDetail provider={mockProvider} detail={detailWithUnknown} onHide={mockOnHide} />)
expect(screen.getByText('custom-type')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty parameters', () => {
const detailEmpty = { ...mockDetail, parameters: [] }
render(<StrategyDetail provider={mockProvider} detail={detailEmpty} onHide={mockOnHide} />)
expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
})
it('should handle no output schema', () => {
const detailNoOutput = { ...mockDetail, output_schema: undefined as unknown as Record<string, unknown> }
render(<StrategyDetail provider={mockProvider} detail={detailNoOutput} onHide={mockOnHide} />)
expect(screen.queryByText('OUTPUT')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,102 @@
import type { StrategyDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import StrategyItem from './strategy-item'
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (obj: Record<string, string>) => obj?.en_US || '',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('./strategy-detail', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="strategy-detail-panel">
<button data-testid="hide-btn" onClick={onHide}>Hide</button>
</div>
),
}))
const mockProvider = {
author: 'test-author',
name: 'test-provider',
description: { en_US: 'Provider desc' } as Record<string, string>,
tenant_id: 'tenant-1',
icon: 'icon.png',
label: { en_US: 'Test Provider' } as Record<string, string>,
tags: [] as string[],
}
const mockDetail = {
identity: {
author: 'author-1',
name: 'strategy-1',
icon: 'icon.png',
label: { en_US: 'Strategy Label' } as Record<string, string>,
provider: 'provider-1',
},
parameters: [],
description: { en_US: 'Strategy description' } as Record<string, string>,
output_schema: {},
features: [],
} as StrategyDetail
describe('StrategyItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render strategy label', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
expect(screen.getByText('Strategy Label')).toBeInTheDocument()
})
it('should render strategy description', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
expect(screen.getByText('Strategy description')).toBeInTheDocument()
})
it('should not show detail panel initially', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should show detail panel when clicked', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
fireEvent.click(screen.getByText('Strategy Label'))
expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument()
})
it('should hide detail panel when hide is called', () => {
render(<StrategyItem provider={mockProvider} detail={mockDetail} />)
fireEvent.click(screen.getByText('Strategy Label'))
expect(screen.getByTestId('strategy-detail-panel')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('hide-btn'))
expect(screen.queryByTestId('strategy-detail-panel')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should handle empty description', () => {
const detailWithEmptyDesc = {
...mockDetail,
description: { en_US: '' } as Record<string, string>,
} as StrategyDetail
render(<StrategyItem provider={mockProvider} detail={detailWithEmptyDesc} />)
expect(screen.getByText('Strategy Label')).toBeInTheDocument()
})
})
})

View File

@ -1874,4 +1874,187 @@ describe('CommonCreateModal', () => {
expect(screen.getByTestId('modal')).toHaveAttribute('data-disabled', 'true')
})
})
describe('normalizeFormType Additional Branches', () => {
it('should handle "text" type by returning textInput', () => {
const detailWithText = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [],
parameters: [
{ name: 'text_type_field', type: 'text' },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithText)
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />)
expect(screen.getByTestId('form-field-text_type_field')).toBeInTheDocument()
})
it('should handle "secret" type by returning secretInput', () => {
const detailWithSecret = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [],
parameters: [
{ name: 'secret_type_field', type: 'secret' },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithSecret)
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.OAUTH} builder={builder} />)
expect(screen.getByTestId('form-field-secret_type_field')).toBeInTheDocument()
})
})
describe('HandleManualPropertiesChange Provider Fallback', () => {
it('should not call updateBuilder when provider is empty', async () => {
const detailWithEmptyProvider = createMockPluginDetail({
provider: '',
declaration: {
trigger: {
subscription_schema: [
{ name: 'webhook_url', type: 'text', required: true },
],
subscription_constructor: {
credentials_schema: [],
parameters: [],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithEmptyProvider)
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
// updateBuilder should not be called when provider is empty
expect(mockUpdateBuilder).not.toHaveBeenCalled()
})
})
describe('Configuration Step Without Endpoint', () => {
it('should handle builder without endpoint', async () => {
const builderWithoutEndpoint = createMockSubscriptionBuilder({
endpoint: '',
})
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} builder={builderWithoutEndpoint} />)
// Component should render without errors
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})
describe('ApiKeyStep Flow Additional Coverage', () => {
it('should handle verify when no builder created yet', async () => {
const detailWithCredentials = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [
{ name: 'api_key', type: 'secret', required: true },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithCredentials)
// Make createBuilder slow
mockCreateBuilder.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)))
render(<CommonCreateModal {...defaultProps} />)
// Click verify before builder is created
fireEvent.click(screen.getByTestId('modal-confirm'))
// Should still attempt to verify
expect(screen.getByTestId('modal')).toBeInTheDocument()
})
})
describe('Auto Parameters Not For APIKEY in Configuration', () => {
it('should include parameters for APIKEY in configuration step', async () => {
const detailWithParams = createMockPluginDetail({
declaration: {
trigger: {
subscription_constructor: {
credentials_schema: [
{ name: 'api_key', type: 'secret', required: true },
],
parameters: [
{ name: 'extra_param', type: 'string', required: true },
],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithParams)
// First verify credentials
mockVerifyCredentials.mockImplementation((params, { onSuccess }) => {
onSuccess()
})
const builder = createMockSubscriptionBuilder()
render(<CommonCreateModal {...defaultProps} builder={builder} />)
// Click verify
fireEvent.click(screen.getByTestId('modal-confirm'))
await waitFor(() => {
expect(mockVerifyCredentials).toHaveBeenCalled()
})
// Now in configuration step, should see extra_param
expect(screen.getByTestId('form-field-extra_param')).toBeInTheDocument()
})
})
describe('needCheckValidatedValues Option', () => {
it('should pass needCheckValidatedValues: false for manual properties', async () => {
const detailWithManualSchema = createMockPluginDetail({
declaration: {
trigger: {
subscription_schema: [
{ name: 'webhook_url', type: 'text', required: true },
],
subscription_constructor: {
credentials_schema: [],
parameters: [],
},
},
},
})
mockUsePluginStore.mockReturnValue(detailWithManualSchema)
render(<CommonCreateModal {...defaultProps} createType={SupportedCreationMethods.MANUAL} />)
await waitFor(() => {
expect(mockCreateBuilder).toHaveBeenCalled()
})
const input = screen.getByTestId('form-field-webhook_url')
fireEvent.change(input, { target: { value: 'test' } })
await waitFor(() => {
expect(mockUpdateBuilder).toHaveBeenCalled()
})
})
})
})

View File

@ -1475,4 +1475,213 @@ describe('CreateSubscriptionButton', () => {
})
})
})
// ==================== OAuth Callback Edge Cases ====================
describe('OAuth Callback - Falsy Data', () => {
it('should not open modal when OAuth callback returns falsy data', async () => {
// Arrange
const { openOAuthPopup } = await import('@/hooks/use-oauth')
vi.mocked(openOAuthPopup).mockImplementation((url: string, callback: (data?: unknown) => void) => {
callback(undefined) // falsy callback data
return null
})
const mockBuilder: TriggerSubscriptionBuilder = {
id: 'oauth-builder',
name: 'OAuth Builder',
provider: 'test-provider',
credential_type: TriggerCredentialTypeEnum.Oauth2,
credentials: {},
endpoint: 'https://test.com',
parameters: {},
properties: {},
workflows_in_use: 0,
}
mockInitiateOAuth.mockImplementation((_provider: string, callbacks: { onSuccess: (response: { authorization_url: string, subscription_builder: TriggerSubscriptionBuilder }) => void }) => {
callbacks.onSuccess({
authorization_url: 'https://oauth.test.com/authorize',
subscription_builder: mockBuilder,
})
})
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.OAUTH, SupportedCreationMethods.MANUAL],
}),
oauthConfig: createOAuthConfig({ configured: true }),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
// Click on OAuth option
const oauthOption = screen.getByTestId(`option-${SupportedCreationMethods.OAUTH}`)
fireEvent.click(oauthOption)
// Assert - modal should NOT open because callback data was falsy
await waitFor(() => {
expect(screen.queryByTestId('common-create-modal')).not.toBeInTheDocument()
})
})
})
// ==================== TriggerProps ClassName Branches ====================
describe('TriggerProps ClassName Branches', () => {
it('should apply pointer-events-none when non-default method with multiple supported methods', () => {
// Arrange - Single APIKEY method (methodType = APIKEY, not DEFAULT_METHOD)
// But we need multiple methods to test this branch
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.APIKEY, SupportedCreationMethods.MANUAL],
}),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
// The methodType will be DEFAULT_METHOD since multiple methods
// This verifies the render doesn't crash with multiple methods
expect(screen.getByTestId('custom-select')).toHaveAttribute('data-value', 'default')
})
})
// ==================== Tooltip Disabled Branches ====================
describe('Tooltip Disabled Branches', () => {
it('should enable tooltip when single method and not at max count', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL],
}),
subscriptions: [createSubscription()], // Not at max
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - tooltip should be enabled (disabled prop = false for single method)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
it('should disable tooltip when multiple methods and not at max count', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL, SupportedCreationMethods.APIKEY],
}),
subscriptions: [createSubscription()], // Not at max
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - tooltip should be disabled (neither single method nor at max)
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
// ==================== Tooltip PopupContent Branches ====================
describe('Tooltip PopupContent Branches', () => {
it('should show max count message when at max subscriptions', () => {
// Arrange
const maxSubscriptions = createMaxSubscriptions()
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL],
}),
subscriptions: maxSubscriptions,
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - component renders with max subscriptions
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
it('should show method description when not at max', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.MANUAL],
}),
subscriptions: [], // Not at max
})
const props = createDefaultProps({ buttonType: CreateButtonType.ICON_BUTTON })
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert - component renders without max subscriptions
expect(screen.getByTestId('custom-trigger')).toBeInTheDocument()
})
})
// ==================== Provider Info Fallbacks ====================
describe('Provider Info Fallbacks', () => {
it('should handle undefined supported_creation_methods', () => {
// Arrange - providerInfo with undefined supported_creation_methods
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: {
...createProviderInfo(),
supported_creation_methods: undefined as unknown as SupportedCreationMethods[],
},
})
const props = createDefaultProps()
// Act
const { container } = render(<CreateSubscriptionButton {...props} />)
// Assert - should render null when supported methods fallback to empty
expect(container).toBeEmptyDOMElement()
})
it('should handle providerInfo with null supported_creation_methods', () => {
// Arrange
mockProviderInfo = { data: { ...createProviderInfo(), supported_creation_methods: null as unknown as SupportedCreationMethods[] } }
mockOAuthConfig = { data: undefined, refetch: vi.fn() }
mockStoreDetail = createStoreDetail()
const props = createDefaultProps()
// Act
const { container } = render(<CreateSubscriptionButton {...props} />)
// Assert - should render null
expect(container).toBeEmptyDOMElement()
})
})
// ==================== Method Type Logic ====================
describe('Method Type Logic', () => {
it('should use single method as methodType when only one supported', () => {
// Arrange
setupMocks({
storeDetail: createStoreDetail(),
providerInfo: createProviderInfo({
supported_creation_methods: [SupportedCreationMethods.APIKEY],
}),
})
const props = createDefaultProps()
// Act
render(<CreateSubscriptionButton {...props} />)
// Assert
const customSelect = screen.getByTestId('custom-select')
expect(customSelect).toHaveAttribute('data-value', SupportedCreationMethods.APIKEY)
})
})
})

View File

@ -1240,4 +1240,60 @@ describe('OAuthClientSettingsModal', () => {
vi.useRealTimers()
})
})
describe('OAuth Client Schema Params Fallback', () => {
it('should handle schema when params is truthy but schema name not in params', () => {
const configWithSchemaNotInParams = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
params: {
client_id: 'test-id',
client_secret: 'test-secret',
},
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
{ name: 'client_secret', type: 'secret-input' as unknown, required: true, label: { 'en-US': 'Client Secret' } as unknown },
{ name: 'extra_field', type: 'text-input' as unknown, required: false, label: { 'en-US': 'Extra' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
})
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithSchemaNotInParams} />)
// extra_field should be rendered but without default value
const extraInput = screen.getByTestId('form-field-extra_field') as HTMLInputElement
expect(extraInput.defaultValue).toBe('')
})
it('should handle oauth_client_schema with undefined params', () => {
const configWithUndefinedParams = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
params: undefined as unknown as TriggerOAuthConfig['params'],
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
})
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithUndefinedParams} />)
// Form should not render because params is undefined (schema condition fails)
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
})
it('should handle oauth_client_schema with null params', () => {
const configWithNullParams = createMockOAuthConfig({
system_configured: false,
custom_enabled: true,
params: null as unknown as TriggerOAuthConfig['params'],
oauth_client_schema: [
{ name: 'client_id', type: 'text-input' as unknown, required: true, label: { 'en-US': 'Client ID' } as unknown },
] as TriggerOAuthConfig['oauth_client_schema'],
})
render(<OAuthClientSettingsModal {...defaultProps} oauthConfig={configWithNullParams} />)
// Form should not render because params is null
expect(screen.queryByTestId('base-form')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,287 @@
import type { TriggerEvent } from '@/app/components/plugins/types'
import type { TriggerProviderApiEntity } from '@/app/components/workflow/block-selector/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EventDetailDrawer } from './event-detail-drawer'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: () => <span data-testid="card-icon" />,
}))
vi.mock('@/app/components/plugins/card/base/description', () => ({
default: ({ text }: { text: string }) => <div data-testid="description">{text}</div>,
}))
vi.mock('@/app/components/plugins/card/base/org-info', () => ({
default: ({ orgName }: { orgName: string }) => <div data-testid="org-info">{orgName}</div>,
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
triggerEventParametersToFormSchemas: (params: Array<Record<string, unknown>>) =>
params.map(p => ({
label: (p.label as Record<string, string>) || { en_US: p.name as string },
type: (p.type as string) || 'text-input',
required: (p.required as boolean) || false,
description: p.description as Record<string, string> | undefined,
})),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show/field', () => ({
default: ({ name }: { name: string }) => <div data-testid="output-field">{name}</div>,
}))
const mockEventInfo = {
name: 'test-event',
identity: {
author: 'test-author',
name: 'test-event',
label: { en_US: 'Test Event' },
},
description: { en_US: 'Test event description' },
parameters: [
{
name: 'param1',
label: { en_US: 'Parameter 1' },
type: 'text-input',
auto_generate: null,
template: null,
scope: null,
required: true,
multiple: false,
default: null,
min: null,
max: null,
precision: null,
description: { en_US: 'A test parameter' },
},
],
output_schema: {
properties: {
result: { type: 'string', description: 'Result' },
},
required: ['result'],
},
} as unknown as TriggerEvent
const mockProviderInfo = {
provider: 'test-provider',
author: 'test-author',
name: 'test-provider/test-name',
icon: 'icon.png',
description: { en_US: 'Provider desc' },
supported_creation_methods: [],
} as unknown as TriggerProviderApiEntity
describe('EventDetailDrawer', () => {
const mockOnClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render drawer', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('should render event title', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('Test Event')).toBeInTheDocument()
})
it('should render event description', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByTestId('description')).toHaveTextContent('Test event description')
})
it('should render org info', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByTestId('org-info')).toBeInTheDocument()
})
it('should render parameters section', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('setBuiltInTools.parameters')).toBeInTheDocument()
expect(screen.getByText('Parameter 1')).toBeInTheDocument()
})
it('should render output section', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
expect(screen.getByTestId('output-field')).toHaveTextContent('result')
})
it('should render back button', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('detailPanel.operation.back')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onClose when close button clicked', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
// Find the close button (ActionButton with action-btn class)
const closeButton = screen.getAllByRole('button').find(btn => btn.classList.contains('action-btn'))
if (closeButton)
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
it('should call onClose when back clicked', () => {
render(<EventDetailDrawer eventInfo={mockEventInfo} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
fireEvent.click(screen.getByText('detailPanel.operation.back'))
expect(mockOnClose).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle no parameters', () => {
const eventWithNoParams = { ...mockEventInfo, parameters: [] }
render(<EventDetailDrawer eventInfo={eventWithNoParams} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.item.noParameters')).toBeInTheDocument()
})
it('should handle no output schema', () => {
const eventWithNoOutput = { ...mockEventInfo, output_schema: {} }
render(<EventDetailDrawer eventInfo={eventWithNoOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
expect(screen.queryByTestId('output-field')).not.toBeInTheDocument()
})
})
describe('Parameter Types', () => {
it('should display correct type for number-input', () => {
const eventWithNumber = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'number-input' }],
}
render(<EventDetailDrawer eventInfo={eventWithNumber} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('setBuiltInTools.number')).toBeInTheDocument()
})
it('should display correct type for checkbox', () => {
const eventWithCheckbox = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'checkbox' }],
}
render(<EventDetailDrawer eventInfo={eventWithCheckbox} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('boolean')).toBeInTheDocument()
})
it('should display correct type for file', () => {
const eventWithFile = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'file' }],
}
render(<EventDetailDrawer eventInfo={eventWithFile} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('setBuiltInTools.file')).toBeInTheDocument()
})
it('should display original type for unknown types', () => {
const eventWithUnknown = {
...mockEventInfo,
parameters: [{ ...mockEventInfo.parameters[0], type: 'custom-type' }],
}
render(<EventDetailDrawer eventInfo={eventWithUnknown} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('custom-type')).toBeInTheDocument()
})
})
describe('Output Schema Conversion', () => {
it('should handle array type in output schema', () => {
const eventWithArrayOutput = {
...mockEventInfo,
output_schema: {
properties: {
items: { type: 'array', items: { type: 'string' }, description: 'Array items' },
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithArrayOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
it('should handle nested properties in output schema', () => {
const eventWithNestedOutput = {
...mockEventInfo,
output_schema: {
properties: {
nested: {
type: 'object',
properties: { inner: { type: 'string' } },
required: ['inner'],
},
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithNestedOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
it('should handle enum in output schema', () => {
const eventWithEnumOutput = {
...mockEventInfo,
output_schema: {
properties: {
status: { type: 'string', enum: ['active', 'inactive'], description: 'Status' },
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithEnumOutput} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
it('should handle array type schema', () => {
const eventWithArrayType = {
...mockEventInfo,
output_schema: {
properties: {
multi: { type: ['string', 'null'], description: 'Multi type' },
},
required: [],
},
}
render(<EventDetailDrawer eventInfo={eventWithArrayType} providerInfo={mockProviderInfo} onClose={mockOnClose} />)
expect(screen.getByText('events.output')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,146 @@
import type { TriggerEvent } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TriggerEventsList } from './event-list'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.num !== undefined)
return `${options.num} ${options.event || 'events'}`
return key
},
}),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | false | null)[]) => args.filter(Boolean).join(' '),
}))
const mockTriggerEvents = [
{
name: 'event-1',
identity: {
author: 'author-1',
name: 'event-1',
label: { en_US: 'Event One' },
},
description: { en_US: 'Event one description' },
parameters: [],
output_schema: {},
},
] as unknown as TriggerEvent[]
let mockDetail: { plugin_id: string, provider: string } | undefined
let mockProviderInfo: { events: TriggerEvent[] } | undefined
vi.mock('../store', () => ({
usePluginStore: (selector: (state: { detail: typeof mockDetail }) => typeof mockDetail) =>
selector({ detail: mockDetail }),
}))
vi.mock('@/service/use-triggers', () => ({
useTriggerProviderInfo: () => ({ data: mockProviderInfo }),
}))
vi.mock('./event-detail-drawer', () => ({
EventDetailDrawer: ({ onClose }: { onClose: () => void }) => (
<div data-testid="event-detail-drawer">
<button data-testid="close-drawer" onClick={onClose}>Close</button>
</div>
),
}))
describe('TriggerEventsList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDetail = { plugin_id: 'test-plugin', provider: 'test-provider' }
mockProviderInfo = { events: mockTriggerEvents }
})
describe('Rendering', () => {
it('should render event count', () => {
render(<TriggerEventsList />)
expect(screen.getByText('1 events.event')).toBeInTheDocument()
})
it('should render event cards', () => {
render(<TriggerEventsList />)
expect(screen.getByText('Event One')).toBeInTheDocument()
expect(screen.getByText('Event one description')).toBeInTheDocument()
})
it('should return null when no provider info', () => {
mockProviderInfo = undefined
const { container } = render(<TriggerEventsList />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when no events', () => {
mockProviderInfo = { events: [] }
const { container } = render(<TriggerEventsList />)
expect(container).toBeEmptyDOMElement()
})
it('should return null when no detail', () => {
mockDetail = undefined
mockProviderInfo = undefined
const { container } = render(<TriggerEventsList />)
expect(container).toBeEmptyDOMElement()
})
})
describe('User Interactions', () => {
it('should show detail drawer when event card clicked', () => {
render(<TriggerEventsList />)
fireEvent.click(screen.getByText('Event One'))
expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
})
it('should hide detail drawer when close clicked', () => {
render(<TriggerEventsList />)
fireEvent.click(screen.getByText('Event One'))
expect(screen.getByTestId('event-detail-drawer')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('close-drawer'))
expect(screen.queryByTestId('event-detail-drawer')).not.toBeInTheDocument()
})
})
describe('Multiple Events', () => {
it('should render multiple event cards', () => {
const secondEvent = {
name: 'event-2',
identity: {
author: 'author-2',
name: 'event-2',
label: { en_US: 'Event Two' },
},
description: { en_US: 'Event two description' },
parameters: [],
output_schema: {},
} as unknown as TriggerEvent
mockProviderInfo = {
events: [...mockTriggerEvents, secondEvent],
}
render(<TriggerEventsList />)
expect(screen.getByText('Event One')).toBeInTheDocument()
expect(screen.getByText('Event Two')).toBeInTheDocument()
expect(screen.getByText('2 events.events')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { NAME_FIELD } from './utils'
describe('utils', () => {
describe('NAME_FIELD', () => {
it('should have correct type', () => {
expect(NAME_FIELD.type).toBe(FormTypeEnum.textInput)
})
it('should have correct name', () => {
expect(NAME_FIELD.name).toBe('name')
})
it('should have label translations', () => {
expect(NAME_FIELD.label).toBeDefined()
expect(NAME_FIELD.label.en_US).toBe('Endpoint Name')
expect(NAME_FIELD.label.zh_Hans).toBe('端点名称')
expect(NAME_FIELD.label.ja_JP).toBe('エンドポイント名')
expect(NAME_FIELD.label.pt_BR).toBe('Nome do ponto final')
})
it('should have placeholder translations', () => {
expect(NAME_FIELD.placeholder).toBeDefined()
expect(NAME_FIELD.placeholder.en_US).toBe('Endpoint Name')
expect(NAME_FIELD.placeholder.zh_Hans).toBe('端点名称')
expect(NAME_FIELD.placeholder.ja_JP).toBe('エンドポイント名')
expect(NAME_FIELD.placeholder.pt_BR).toBe('Nome do ponto final')
})
it('should be required', () => {
expect(NAME_FIELD.required).toBe(true)
})
it('should have empty default value', () => {
expect(NAME_FIELD.default).toBe('')
})
it('should have null help', () => {
expect(NAME_FIELD.help).toBeNull()
})
it('should have all required field properties', () => {
const requiredKeys = ['type', 'name', 'label', 'placeholder', 'required', 'default', 'help']
requiredKeys.forEach((key) => {
expect(NAME_FIELD).toHaveProperty(key)
})
})
it('should match expected structure', () => {
expect(NAME_FIELD).toEqual({
type: FormTypeEnum.textInput,
name: 'name',
label: {
en_US: 'Endpoint Name',
zh_Hans: '端点名称',
ja_JP: 'エンドポイント名',
pt_BR: 'Nome do ponto final',
},
placeholder: {
en_US: 'Endpoint Name',
zh_Hans: '端点名称',
ja_JP: 'エンドポイント名',
pt_BR: 'Nome do ponto final',
},
required: true,
default: '',
help: null,
})
})
})
})

View File

@ -4220,11 +4220,6 @@
"count": 1
}
},
"i18n/en-US/common.json": {
"no-irregular-whitespace": {
"count": 3
}
},
"i18n/fr-FR/app-debug.json": {
"no-irregular-whitespace": {
"count": 1

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Abbrechen",
"stepOne.uploader.change": "Ändern",
"stepOne.uploader.failed": "Hochladen fehlgeschlagen",
"stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{size}}MB pro Datei.",
"stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{batchCount}} Dateien pro Batch und {{size}} MB pro Datei. Insgesamt maximal {{totalCount}} Dateien.",
"stepOne.uploader.title": "Textdatei hochladen",
"stepOne.uploader.validation.count": "Mehrere Dateien nicht unterstützt",
"stepOne.uploader.validation.filesNumber": "Sie haben das Limit für die Stapelverarbeitung von {{filesNumber}} erreicht.",

View File

@ -61,6 +61,7 @@
"account.workspaceName": "Workspace Name",
"account.workspaceNamePlaceholder": "Enter workspace name",
"actionMsg.copySuccessfully": "Copied successfully",
"actionMsg.downloadUnsuccessfully": "Download failed. Please try again later.",
"actionMsg.generatedSuccessfully": "Generated successfully",
"actionMsg.generatedUnsuccessfully": "Generated unsuccessfully",
"actionMsg.modifiedSuccessfully": "Modified successfully",
@ -91,6 +92,7 @@
"apiBasedExtension.title": "API extensions provide centralized API management, simplifying configuration for easy use across Dify's applications.",
"apiBasedExtension.type": "Type",
"appMenus.apiAccess": "API Access",
"appMenus.apiAccessTip": "This knowledge base is accessible via the Service API",
"appMenus.logAndAnn": "Logs & Annotations",
"appMenus.logs": "Logs",
"appMenus.overview": "Monitoring",
@ -281,7 +283,7 @@
"model.params.setToCurrentModelMaxTokenTip": "Max token is updated to the 80% maximum token of the current model {{maxToken}}.",
"model.params.stop_sequences": "Stop sequences",
"model.params.stop_sequencesPlaceholder": "Enter sequence and press Tab",
"model.params.stop_sequencesTip": "Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.",
"model.params.stop_sequencesTip": "Up to four sequences where the API will stop generating further tokens. The returned text will not contain the stop sequence.",
"model.params.temperature": "Temperature",
"model.params.temperatureTip": "Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.",
"model.params.top_p": "Top P",

View File

@ -26,6 +26,7 @@
"list.action.archive": "Archive",
"list.action.batchAdd": "Batch add",
"list.action.delete": "Delete",
"list.action.download": "Download",
"list.action.enableWarning": "Archived file cannot be enabled",
"list.action.pause": "Pause",
"list.action.resume": "Resume",

View File

@ -7,6 +7,7 @@
"batchAction.cancel": "Cancel",
"batchAction.delete": "Delete",
"batchAction.disable": "Disable",
"batchAction.download": "Download",
"batchAction.enable": "Enable",
"batchAction.reIndex": "Re-index",
"batchAction.selected": "Selected",
@ -170,7 +171,7 @@
"serviceApi.card.endpoint": "Service API Endpoint",
"serviceApi.card.title": "Backend service api",
"serviceApi.disabled": "Disabled",
"serviceApi.enabled": "In Service",
"serviceApi.enabled": "Enabled",
"serviceApi.title": "Service API",
"unavailable": "Unavailable",
"updated": "Updated",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Cancelar",
"stepOne.uploader.change": "Cambiar",
"stepOne.uploader.failed": "Error al cargar",
"stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{size}}MB cada uno.",
"stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{batchCount}} archivos por lote y {{size}} MB cada uno. Total máximo de {{totalCount}} archivos.",
"stepOne.uploader.title": "Cargar archivo",
"stepOne.uploader.validation.count": "No se admiten varios archivos",
"stepOne.uploader.validation.filesNumber": "Has alcanzado el límite de carga por lotes de {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "لغو",
"stepOne.uploader.change": "تغییر",
"stepOne.uploader.failed": "بارگذاری ناموفق بود",
"stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{size}}MB هر کدام.",
"stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{batchCount}} فایل در هر دسته و {{size}} مگابایت برای هر فایل. حداکثر کل {{totalCount}} فایل.",
"stepOne.uploader.title": "بارگذاری فایل",
"stepOne.uploader.validation.count": "چندین فایل پشتیبانی نمیشود",
"stepOne.uploader.validation.filesNumber": "شما به حد مجاز بارگذاری دستهای {{filesNumber}} رسیدهاید.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Annuler",
"stepOne.uploader.change": "Changer",
"stepOne.uploader.failed": "Le téléchargement a échoué",
"stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Max {{size}}MB chacun.",
"stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Maximum {{batchCount}} fichiers par lot et {{size}} MB chacun. Maximum total de {{totalCount}} fichiers.",
"stepOne.uploader.title": "Télécharger le fichier texte",
"stepOne.uploader.validation.count": "Plusieurs fichiers non pris en charge",
"stepOne.uploader.validation.filesNumber": "Vous avez atteint la limite de téléchargement par lot de {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "रद्द करें",
"stepOne.uploader.change": "बदलें",
"stepOne.uploader.failed": "अपलोड विफल रहा",
"stepOne.uploader.tip": "समर्थित {{supportTypes}}। प्रत्येक अधिकतम {{size}}MB।",
"stepOne.uploader.tip": "{{supportTypes}} समर्थित है। एक बैच में अधिकतम {{batchCount}} फ़ाइलें और प्रत्येक {{size}} MB। कुल अधिकतम {{totalCount}} फ़ाइलें।",
"stepOne.uploader.title": "फ़ाइल अपलोड करें",
"stepOne.uploader.validation.count": "एकाधिक फ़ाइलें समर्थित नहीं हैं",
"stepOne.uploader.validation.filesNumber": "आपने {{filesNumber}} की बैच अपलोड सीमा तक पहुँच गए हैं।",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Annulla",
"stepOne.uploader.change": "Cambia",
"stepOne.uploader.failed": "Caricamento fallito",
"stepOne.uploader.tip": "Supporta {{supportTypes}}. Max {{size}}MB ciascuno.",
"stepOne.uploader.tip": "Supporta {{supportTypes}}. Massimo {{batchCount}} file per batch e {{size}} MB ciascuno. Totale massimo {{totalCount}} file.",
"stepOne.uploader.title": "Carica file",
"stepOne.uploader.validation.count": "Più file non supportati",
"stepOne.uploader.validation.filesNumber": "Hai raggiunto il limite di caricamento batch di {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "キャンセル",
"stepOne.uploader.change": "変更",
"stepOne.uploader.failed": "アップロードに失敗しました",
"stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1あたり最大サイズは{{size}}MB です。",
"stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1バッチあたり最大{{batchCount}}ファイル、各ファイル{{size}}MB まで。合計最大{{totalCount}}ファイル。",
"stepOne.uploader.title": "テキストファイルをアップロード",
"stepOne.uploader.validation.count": "複数のファイルはサポートされていません",
"stepOne.uploader.validation.filesNumber": "バッチアップロードの制限({{filesNumber}}個)に達しました。",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "취소",
"stepOne.uploader.change": "변경",
"stepOne.uploader.failed": "업로드에 실패했습니다",
"stepOne.uploader.tip": "{{supportTypes}}을 (를) 지원합니다. 파일당 최대 크기는 {{size}}MB 입니다.",
"stepOne.uploader.tip": "{{supportTypes}}을(를) 지원합니다. 배치당 최대 {{batchCount}}개 파일, 각 파일당 {{size}}MB까지. 총 최대 {{totalCount}}개 파일.",
"stepOne.uploader.title": "텍스트 파일 업로드",
"stepOne.uploader.validation.count": "여러 파일은 지원되지 않습니다",
"stepOne.uploader.validation.filesNumber": "일괄 업로드 제한 ({{filesNumber}}개) 에 도달했습니다.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Anuluj",
"stepOne.uploader.change": "Zmień",
"stepOne.uploader.failed": "Przesyłanie nie powiodło się",
"stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{size}}MB każdy.",
"stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{batchCount}} plików w partii, każdy do {{size}} MB. Łącznie maksymalnie {{totalCount}} plików.",
"stepOne.uploader.title": "Prześlij plik tekstowy",
"stepOne.uploader.validation.count": "Nieobsługiwane przesyłanie wielu plików",
"stepOne.uploader.validation.filesNumber": "Osiągnąłeś limit przesłania partii {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Cancelar",
"stepOne.uploader.change": "Alterar",
"stepOne.uploader.failed": "Falha no envio",
"stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{size}}MB cada.",
"stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{batchCount}} arquivos por lote e {{size}} MB cada. Total máximo de {{totalCount}} arquivos.",
"stepOne.uploader.title": "Enviar arquivo de texto",
"stepOne.uploader.validation.count": "Vários arquivos não suportados",
"stepOne.uploader.validation.filesNumber": "Limite de upload em massa {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Anulează",
"stepOne.uploader.change": "Schimbă",
"stepOne.uploader.failed": "Încărcarea a eșuat",
"stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{size}}MB fiecare.",
"stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{batchCount}} fișiere pe lot și {{size}} MB fiecare. Total maxim {{totalCount}} fișiere.",
"stepOne.uploader.title": "Încărcați fișier text",
"stepOne.uploader.validation.count": "Nu se acceptă mai multe fișiere",
"stepOne.uploader.validation.filesNumber": "Ați atins limita de încărcare în lot de {{filesNumber}} fișiere.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Отмена",
"stepOne.uploader.change": "Изменить",
"stepOne.uploader.failed": "Ошибка загрузки",
"stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{size}} МБ каждый.",
"stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{batchCount}} файлов за раз, каждый до {{size}} МБ. Всего максимум {{totalCount}} файлов.",
"stepOne.uploader.title": "Загрузить файл",
"stepOne.uploader.validation.count": "Несколько файлов не поддерживаются",
"stepOne.uploader.validation.filesNumber": "Вы достигли лимита пакетной загрузки {{filesNumber}} файлов.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Prekliči",
"stepOne.uploader.change": "Zamenjaj",
"stepOne.uploader.failed": "Nalaganje ni uspelo",
"stepOne.uploader.tip": "Podprti tipi datotek: {{supportTypes}}. Največ {{size}}MB na datoteko.",
"stepOne.uploader.tip": "Podpira {{supportTypes}}. Največje število datotek v seriji: {{batchCount}}, vsaka do {{size}} MB. Skupaj največ {{totalCount}} datotek.",
"stepOne.uploader.title": "Naloži datoteko",
"stepOne.uploader.validation.count": "Podprta je le ena datoteka",
"stepOne.uploader.validation.filesNumber": "Dosegli ste omejitev za pošiljanje {{filesNumber}} datotek.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "ยกเลิก",
"stepOne.uploader.change": "เปลี่ยน",
"stepOne.uploader.failed": "อัปโหลดล้มเหลว",
"stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{size}}MB แต่ละตัว",
"stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{batchCount}} ไฟล์ต่อชุดและ {{size}} MB แต่ละไฟล์ รวมสูงสุด {{totalCount}} ไฟล์",
"stepOne.uploader.title": "อัปโหลดไฟล์",
"stepOne.uploader.validation.count": "ไม่รองรับหลายไฟล์",
"stepOne.uploader.validation.filesNumber": "คุณถึงขีดจํากัดการอัปโหลดเป็นชุดของ {{filesNumber}} แล้ว",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "İptal",
"stepOne.uploader.change": "Değiştir",
"stepOne.uploader.failed": "Yükleme başarısız",
"stepOne.uploader.tip": "Destekler {{supportTypes}}. Her biri en fazla {{size}}MB.",
"stepOne.uploader.tip": "{{supportTypes}} destekler. Parti başına en fazla {{batchCount}} dosya ve her biri {{size}} MB. Toplam en fazla {{totalCount}} dosya.",
"stepOne.uploader.title": "Dosya yükle",
"stepOne.uploader.validation.count": "Birden fazla dosya desteklenmiyor",
"stepOne.uploader.validation.filesNumber": "Toplu yükleme sınırına ulaştınız, {{filesNumber}} dosya.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Скасувати",
"stepOne.uploader.change": "Змінити",
"stepOne.uploader.failed": "Завантаження не вдалося",
"stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{size}} МБ кожен.",
"stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{batchCount}} файлів за раз, кожен до {{size}} МБ. Загалом максимум {{totalCount}} файлів.",
"stepOne.uploader.title": "Завантажити текстовий файл",
"stepOne.uploader.validation.count": "Не підтримується завантаження кількох файлів",
"stepOne.uploader.validation.filesNumber": "Ліміт масового завантаження {{filesNumber}}.",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "Hủy",
"stepOne.uploader.change": "Thay đổi",
"stepOne.uploader.failed": "Tải lên thất bại",
"stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{size}}MB mỗi tệp.",
"stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{batchCount}} tệp trong một lô và {{size}} MB mỗi tệp. Tổng tối đa {{totalCount}} tệp.",
"stepOne.uploader.title": "Tải lên tệp văn bản",
"stepOne.uploader.validation.count": "Không hỗ trợ tải lên nhiều tệp",
"stepOne.uploader.validation.filesNumber": "Bạn đã đạt đến giới hạn tải lên lô của {{filesNumber}} tệp.",

View File

@ -91,6 +91,7 @@
"apiBasedExtension.title": "API 扩展提供了一个集中式的 API 管理,在此统一添加 API 配置后,方便在 Dify 上的各类应用中直接使用。",
"apiBasedExtension.type": "类型",
"appMenus.apiAccess": "访问 API",
"appMenus.apiAccessTip": "此知识库可通过服务 API 访问",
"appMenus.logAndAnn": "日志与标注",
"appMenus.logs": "日志",
"appMenus.overview": "监测",

View File

@ -170,7 +170,7 @@
"serviceApi.card.endpoint": "API 端点",
"serviceApi.card.title": "后端服务 API",
"serviceApi.disabled": "已停用",
"serviceApi.enabled": "运行中",
"serviceApi.enabled": "已启用",
"serviceApi.title": "服务 API",
"unavailable": "不可用",
"updated": "更新于",

View File

@ -35,7 +35,7 @@
"stepOne.uploader.cancel": "取消",
"stepOne.uploader.change": "更改檔案",
"stepOne.uploader.failed": "上傳失敗",
"stepOne.uploader.tip": "支援 {{supportTypes}},每個檔案不超過 {{size}}MB。",
"stepOne.uploader.tip": "支援 {{supportTypes}}。每批最多 {{batchCount}} 個檔案,每個檔案不超過 {{size}} MB,總數不超過 {{totalCount}} 個檔案。",
"stepOne.uploader.title": "上傳文字檔案",
"stepOne.uploader.validation.count": "暫不支援多個檔案",
"stepOne.uploader.validation.filesNumber": "批次上傳限制 {{filesNumber}}。",

View File

@ -40,6 +40,15 @@ type CommonDocReq = {
documentId: string
}
export type DocumentDownloadResponse = {
url: string
}
export type DocumentDownloadZipRequest = {
datasetId: string
documentIds: string[]
}
type BatchReq = {
datasetId: string
batchId: string
@ -158,6 +167,18 @@ export const resumeDocIndexing = ({ datasetId, documentId }: CommonDocReq): Prom
return patch<CommonResponse>(`/datasets/${datasetId}/documents/${documentId}/processing/resume`)
}
export const fetchDocumentDownloadUrl = ({ datasetId, documentId }: CommonDocReq): Promise<DocumentDownloadResponse> => {
return get<DocumentDownloadResponse>(`/datasets/${datasetId}/documents/${documentId}/download`, {})
}
export const downloadDocumentsZip = ({ datasetId, documentIds }: DocumentDownloadZipRequest): Promise<Blob> => {
return post<Blob>(`/datasets/${datasetId}/documents/download-zip`, {
body: {
document_ids: documentIds,
},
})
}
export const preImportNotionPages = ({ url, datasetId }: { url: string, datasetId?: string }): Promise<{ notion_info: DataSourceNotionWorkspace[] }> => {
return get<{ notion_info: DataSourceNotionWorkspace[] }>(url, { params: { dataset_id: datasetId } })
}

View File

@ -1,4 +1,4 @@
import type { MetadataType, SortType } from '../datasets'
import type { DocumentDownloadResponse, DocumentDownloadZipRequest, MetadataType, SortType } from '../datasets'
import type { CommonResponse } from '@/models/common'
import type { DocumentDetailResponse, DocumentListResponse, UpdateDocumentBatchParams } from '@/models/datasets'
import {
@ -8,7 +8,7 @@ import {
import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter'
import { DocumentActionType } from '@/models/datasets'
import { del, get, patch, post } from '../base'
import { pauseDocIndexing, resumeDocIndexing } from '../datasets'
import { downloadDocumentsZip, fetchDocumentDownloadUrl, pauseDocIndexing, resumeDocIndexing } from '../datasets'
import { useInvalid } from '../use-base'
const NAME_SPACE = 'knowledge/document'
@ -164,6 +164,26 @@ export const useDocumentResume = () => {
})
}
export const useDocumentDownload = () => {
return useMutation({
mutationFn: ({ datasetId, documentId }: UpdateDocumentBatchParams) => {
if (!datasetId || !documentId)
throw new Error('datasetId and documentId are required')
return fetchDocumentDownloadUrl({ datasetId, documentId }) as Promise<DocumentDownloadResponse>
},
})
}
export const useDocumentDownloadZip = () => {
return useMutation({
mutationFn: ({ datasetId, documentIds }: DocumentDownloadZipRequest) => {
if (!datasetId || !documentIds?.length)
throw new Error('datasetId and documentIds are required')
return downloadDocumentsZip({ datasetId, documentIds })
},
})
}
export const useDocumentBatchRetryIndex = () => {
return useMutation({
mutationFn: ({ datasetId, documentIds }: { datasetId: string, documentIds: string[] }) => {

34
web/utils/download.ts Normal file
View File

@ -0,0 +1,34 @@
export type DownloadUrlOptions = {
url: string
fileName?: string
rel?: string
target?: string
}
const triggerDownload = ({ url, fileName, rel, target }: DownloadUrlOptions) => {
if (!url)
return
const anchor = document.createElement('a')
anchor.href = url
if (fileName)
anchor.download = fileName
if (rel)
anchor.rel = rel
if (target)
anchor.target = target
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
export const downloadUrl = ({ url, fileName, rel = 'noopener noreferrer', target }: DownloadUrlOptions) => {
triggerDownload({ url, fileName, rel, target })
}
export const downloadBlob = ({ data, fileName }: { data: Blob, fileName: string }) => {
const url = window.URL.createObjectURL(data)
triggerDownload({ url, fileName, rel: 'noopener noreferrer' })
window.URL.revokeObjectURL(url)
}