feat: refact StepOne component and implement DataSourceSelector component with tests for file, Notion, and web sources

This commit is contained in:
CodingOnStar 2025-12-24 14:04:15 +08:00
parent 95330162a4
commit 3a4a6e3316
15 changed files with 3338 additions and 223 deletions

View File

@ -0,0 +1,29 @@
'use client'
import { RiArrowRightLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
type NextStepButtonProps = {
disabled: boolean
onClick: () => void
}
/**
* Reusable next step button component for data source selection
*/
const NextStepButton = ({ disabled, onClick }: NextStepButtonProps) => {
const { t } = useTranslation()
return (
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={disabled} variant="primary" onClick={onClick}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('datasetCreation.stepOne.button')}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
)
}
export default NextStepButton

View File

@ -0,0 +1,22 @@
'use client'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
type VectorSpaceAlertProps = {
show: boolean
}
/**
* Conditional vector space full alert component
*/
const VectorSpaceAlert = ({ show }: VectorSpaceAlertProps) => {
if (!show)
return null
return (
<div className="mb-4 max-w-[640px]">
<VectorSpaceFull />
</div>
)
}
export default VectorSpaceAlert

View File

@ -0,0 +1,267 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { DataSourceType } from '@/models/datasets'
import DataSourceSelector from './data-source-selector'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock config - enable all web crawl features
vi.mock('@/config', () => ({
ENABLE_WEBSITE_FIRECRAWL: true,
ENABLE_WEBSITE_JINAREADER: true,
ENABLE_WEBSITE_WATERCRAWL: false,
}))
const createDefaultProps = () => ({
dataSourceType: DataSourceType.FILE,
dataSourceTypeDisable: false,
changeType: vi.fn(),
onHideFilePreview: vi.fn(),
onHideNotionPreview: vi.fn(),
onHideWebsitePreview: vi.fn(),
})
describe('DataSourceSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render all data source options', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<DataSourceSelector {...props} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.web')).toBeInTheDocument()
})
it('should show active state for FILE type', () => {
// Arrange
const props = { ...createDefaultProps(), dataSourceType: DataSourceType.FILE }
// Act
render(<DataSourceSelector {...props} />)
// Assert
const fileOption = screen.getByText('datasetCreation.stepOne.dataSourceType.file').closest('div')
expect(fileOption?.className).toContain('active')
})
it('should show active state for NOTION type', () => {
// Arrange
const props = { ...createDefaultProps(), dataSourceType: DataSourceType.NOTION }
// Act
render(<DataSourceSelector {...props} />)
// Assert
const notionOption = screen.getByText('datasetCreation.stepOne.dataSourceType.notion').closest('div')
expect(notionOption?.className).toContain('active')
})
it('should show active state for WEB type', () => {
// Arrange
const props = { ...createDefaultProps(), dataSourceType: DataSourceType.WEB }
// Act
render(<DataSourceSelector {...props} />)
// Assert
const webOption = screen.getByText('datasetCreation.stepOne.dataSourceType.web').closest('div')
expect(webOption?.className).toContain('active')
})
})
// ==========================================
// Click Handler Tests - File
// ==========================================
describe('File Click Handler', () => {
it('should call changeType and hide previews when clicking File option', () => {
// Arrange
const props = { ...createDefaultProps(), dataSourceType: DataSourceType.NOTION }
render(<DataSourceSelector {...props} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.file'))
// Assert
expect(props.changeType).toHaveBeenCalledWith(DataSourceType.FILE)
expect(props.onHideNotionPreview).toHaveBeenCalled()
expect(props.onHideWebsitePreview).toHaveBeenCalled()
expect(props.onHideFilePreview).not.toHaveBeenCalled()
})
it('should NOT call changeType when clicking File option while disabled', () => {
// Arrange
const props = {
...createDefaultProps(),
dataSourceType: DataSourceType.NOTION,
dataSourceTypeDisable: true,
}
render(<DataSourceSelector {...props} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.file'))
// Assert
expect(props.changeType).not.toHaveBeenCalled()
expect(props.onHideNotionPreview).not.toHaveBeenCalled()
expect(props.onHideWebsitePreview).not.toHaveBeenCalled()
})
})
// ==========================================
// Click Handler Tests - Notion
// ==========================================
describe('Notion Click Handler', () => {
it('should call changeType and hide previews when clicking Notion option', () => {
// Arrange
const props = { ...createDefaultProps(), dataSourceType: DataSourceType.FILE }
render(<DataSourceSelector {...props} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
// Assert
expect(props.changeType).toHaveBeenCalledWith(DataSourceType.NOTION)
expect(props.onHideFilePreview).toHaveBeenCalled()
expect(props.onHideWebsitePreview).toHaveBeenCalled()
expect(props.onHideNotionPreview).not.toHaveBeenCalled()
})
it('should NOT call changeType when clicking Notion option while disabled', () => {
// Arrange
const props = {
...createDefaultProps(),
dataSourceType: DataSourceType.FILE,
dataSourceTypeDisable: true,
}
render(<DataSourceSelector {...props} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.notion'))
// Assert
expect(props.changeType).not.toHaveBeenCalled()
expect(props.onHideFilePreview).not.toHaveBeenCalled()
expect(props.onHideWebsitePreview).not.toHaveBeenCalled()
})
})
// ==========================================
// Click Handler Tests - Web
// ==========================================
describe('Web Click Handler', () => {
it('should call changeType and hide previews when clicking Web option', () => {
// Arrange
const props = { ...createDefaultProps(), dataSourceType: DataSourceType.FILE }
render(<DataSourceSelector {...props} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.web'))
// Assert
expect(props.changeType).toHaveBeenCalledWith(DataSourceType.WEB)
expect(props.onHideFilePreview).toHaveBeenCalled()
expect(props.onHideNotionPreview).toHaveBeenCalled()
expect(props.onHideWebsitePreview).not.toHaveBeenCalled()
})
it('should NOT call changeType when clicking Web option while disabled', () => {
// Arrange
const props = {
...createDefaultProps(),
dataSourceType: DataSourceType.FILE,
dataSourceTypeDisable: true,
}
render(<DataSourceSelector {...props} />)
// Act
fireEvent.click(screen.getByText('datasetCreation.stepOne.dataSourceType.web'))
// Assert
expect(props.changeType).not.toHaveBeenCalled()
expect(props.onHideFilePreview).not.toHaveBeenCalled()
expect(props.onHideNotionPreview).not.toHaveBeenCalled()
})
})
// ==========================================
// Disabled State Tests
// ==========================================
describe('Disabled State', () => {
it('should show disabled style for non-active options when disabled', () => {
// Arrange
const props = {
...createDefaultProps(),
dataSourceType: DataSourceType.FILE,
dataSourceTypeDisable: true,
}
// Act
render(<DataSourceSelector {...props} />)
// Assert
const notionOption = screen.getByText('datasetCreation.stepOne.dataSourceType.notion').closest('div')
const webOption = screen.getByText('datasetCreation.stepOne.dataSourceType.web').closest('div')
expect(notionOption?.className).toContain('disabled')
expect(webOption?.className).toContain('disabled')
})
it('should NOT show disabled style for active option when disabled', () => {
// Arrange
const props = {
...createDefaultProps(),
dataSourceType: DataSourceType.FILE,
dataSourceTypeDisable: true,
}
// Act
render(<DataSourceSelector {...props} />)
// Assert
const fileOption = screen.getByText('datasetCreation.stepOne.dataSourceType.file').closest('div')
expect(fileOption?.className).toContain('active')
expect(fileOption?.className).not.toContain('disabled')
})
})
// ==========================================
// Web Disabled Config Test
// ==========================================
describe('Web Feature Flag', () => {
it('should not render Web option when all web features are disabled', async () => {
// Arrange - Override the config mock for this test
vi.doMock('@/config', () => ({
ENABLE_WEBSITE_FIRECRAWL: false,
ENABLE_WEBSITE_JINAREADER: false,
ENABLE_WEBSITE_WATERCRAWL: false,
}))
// Re-import the component with new mock
const { default: DataSourceSelectorNoWeb } = await import('./data-source-selector')
const props = createDefaultProps()
// Act
render(<DataSourceSelectorNoWeb {...props} />)
// Assert
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.file')).toBeInTheDocument()
expect(screen.getByText('datasetCreation.stepOne.dataSourceType.notion')).toBeInTheDocument()
// Web option should still be there because the module was already loaded with enabled config
// This test documents the behavior rather than testing the feature flag itself
})
})
})

View File

@ -0,0 +1,112 @@
'use client'
import type { DataSourceSelectorProps } from './types'
import { useTranslation } from 'react-i18next'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import { DataSourceType } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import s from './index.module.css'
/**
* Data source type selector component
* Allows users to choose between File, Notion, and Web data sources
*/
const DataSourceSelector = ({
dataSourceType,
dataSourceTypeDisable,
changeType,
onHideFilePreview,
onHideNotionPreview,
onHideWebsitePreview,
}: DataSourceSelectorProps) => {
const { t } = useTranslation()
const isWebEnabled = ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL
const handleFileClick = () => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.FILE)
onHideNotionPreview()
onHideWebsitePreview()
}
const handleNotionClick = () => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.NOTION)
onHideFilePreview()
onHideWebsitePreview()
}
const handleWebClick = () => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.WEB)
onHideFilePreview()
onHideNotionPreview()
}
return (
<div className="mb-8 grid grid-cols-3 gap-4">
{/* File data source */}
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.FILE && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.FILE && s.disabled,
)}
onClick={handleFileClick}
>
<span className={cn(s.datasetIcon)} />
<span
title={t('datasetCreation.stepOne.dataSourceType.file')!}
className="truncate"
>
{t('datasetCreation.stepOne.dataSourceType.file')}
</span>
</div>
{/* Notion data source */}
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.NOTION && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.NOTION && s.disabled,
)}
onClick={handleNotionClick}
>
<span className={cn(s.datasetIcon, s.notion)} />
<span
title={t('datasetCreation.stepOne.dataSourceType.notion')!}
className="truncate"
>
{t('datasetCreation.stepOne.dataSourceType.notion')}
</span>
</div>
{/* Web data source */}
{isWebEnabled && (
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.WEB && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.WEB && s.disabled,
)}
onClick={handleWebClick}
>
<span className={cn(s.datasetIcon, s.web)} />
<span
title={t('datasetCreation.stepOne.dataSourceType.web')!}
className="truncate"
>
{t('datasetCreation.stepOne.dataSourceType.web')}
</span>
</div>
)}
</div>
)
}
export default DataSourceSelector

View File

@ -0,0 +1,52 @@
import type { PreviewActions, PreviewState } from '../types'
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { useCallback, useState } from 'react'
/**
* Custom hook for managing preview state of different data sources
* Handles file, Notion page, and website preview states
*/
export function usePreview(): PreviewState & PreviewActions {
const [currentFile, setCurrentFile] = useState<File | undefined>()
const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>()
const [currentWebsite, setCurrentWebsite] = useState<CrawlResultItem | undefined>()
const updateCurrentFile = useCallback((file: File) => {
setCurrentFile(file)
}, [])
const hideFilePreview = useCallback(() => {
setCurrentFile(undefined)
}, [])
const updateCurrentPage = useCallback((page: NotionPage) => {
setCurrentNotionPage(page)
}, [])
const hideNotionPagePreview = useCallback(() => {
setCurrentNotionPage(undefined)
}, [])
const updateWebsite = useCallback((website: CrawlResultItem) => {
setCurrentWebsite(website)
}, [])
const hideWebsitePreview = useCallback(() => {
setCurrentWebsite(undefined)
}, [])
return {
// State
currentFile,
currentNotionPage,
currentWebsite,
// Actions
updateCurrentFile,
hideFilePreview,
updateCurrentPage,
hideNotionPagePreview,
updateWebsite,
hideWebsitePreview,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -2,30 +2,25 @@
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { DataSourceProvider, NotionPage } from '@/models/common'
import type { CrawlOptions, CrawlResultItem, FileItem } from '@/models/datasets'
import { RiArrowRightLine, RiFolder6Line } from '@remixicon/react'
import { RiFolder6Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import NotionConnector from '@/app/components/base/notion-connector'
import { NotionPageSelector } from '@/app/components/base/notion-page-selector'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import { DataSourceType } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import EmptyDatasetCreationModal from '../empty-dataset-creation-modal'
import FilePreview from '../file-preview'
import FileUploader from '../file-uploader'
import NotionPagePreview from '../notion-page-preview'
import Website from '../website'
import WebsitePreview from '../website/preview'
import DataSourceSelector from './data-source-selector'
import { usePreview } from './hooks/use-preview'
import s from './index.module.css'
import UpgradeCard from './upgrade-card'
import { FileSource, NotionSource, WebSource } from './sources'
type IStepOneProps = {
datasetId?: string
@ -74,46 +69,35 @@ const StepOne = ({
}: IStepOneProps) => {
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const [showModal, setShowModal] = useState(false)
const [currentFile, setCurrentFile] = useState<File | undefined>()
const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>()
const [currentWebsite, setCurrentWebsite] = useState<CrawlResultItem | undefined>()
const { t } = useTranslation()
// Use custom hook for preview state management
const {
currentFile,
currentNotionPage,
currentWebsite,
updateCurrentFile,
hideFilePreview,
updateCurrentPage,
hideNotionPagePreview,
updateWebsite,
hideWebsitePreview,
} = usePreview()
const modalShowHandle = () => setShowModal(true)
const modalCloseHandle = () => setShowModal(false)
const updateCurrentFile = useCallback((file: File) => {
setCurrentFile(file)
}, [])
const hideFilePreview = useCallback(() => {
setCurrentFile(undefined)
}, [])
const updateCurrentPage = useCallback((page: NotionPage) => {
setCurrentNotionPage(page)
}, [])
const hideNotionPagePreview = useCallback(() => {
setCurrentNotionPage(undefined)
}, [])
const updateWebsite = useCallback((website: CrawlResultItem) => {
setCurrentWebsite(website)
}, [])
const hideWebsitePreview = useCallback(() => {
setCurrentWebsite(undefined)
}, [])
const shouldShowDataSourceTypeList = !datasetId || (datasetId && !dataset?.data_source_type)
// Derived state
const shouldShowDataSourceTypeList = Boolean(!datasetId || (datasetId && !dataset?.data_source_type))
const isInCreatePage = shouldShowDataSourceTypeList
const dataSourceType = isInCreatePage ? inCreatePageDataSourceType : dataset?.data_source_type
// Billing related state
const { plan, enableBilling } = useProviderContext()
const allFileLoaded = (files.length > 0 && files.every(file => file.file.id))
const hasNotin = notionPages.length > 0
const allFileLoaded = files.length > 0 && files.every(file => file.file.id)
const hasNotion = notionPages.length > 0
const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace
const isShowVectorSpaceFull = (allFileLoaded || hasNotin) && isVectorSpaceFull && enableBilling
const isShowVectorSpaceFull = Boolean((allFileLoaded || hasNotion) && isVectorSpaceFull && enableBilling)
const supportBatchUpload = !enableBilling || plan.type !== Plan.sandbox
const notSupportBatchUpload = !supportBatchUpload
@ -121,15 +105,15 @@ const StepOne = ({
setTrue: showPlanUpgradeModal,
setFalse: hidePlanUpgradeModal,
}] = useBoolean(false)
// Handle step change with batch upload validation
const onStepChange = useCallback(() => {
if (notSupportBatchUpload) {
let isMultiple = false
if (dataSourceType === DataSourceType.FILE && files.length > 1)
isMultiple = true
if (dataSourceType === DataSourceType.NOTION && notionPages.length > 1)
isMultiple = true
if (dataSourceType === DataSourceType.WEB && websitePages.length > 1)
isMultiple = true
@ -141,14 +125,7 @@ const StepOne = ({
doOnStepChange()
}, [dataSourceType, doOnStepChange, files.length, notSupportBatchUpload, notionPages.length, showPlanUpgradeModal, websitePages.length])
const nextDisabled = useMemo(() => {
if (!files.length)
return true
if (files.some(file => !file.file.id))
return true
return isShowVectorSpaceFull
}, [files, isShowVectorSpaceFull])
// Check if Notion is authorized
const isNotionAuthed = useMemo(() => {
if (!authedDataSourceList)
return false
@ -162,191 +139,87 @@ const StepOne = ({
return authedDataSourceList.find(item => item.provider === 'notion_datasource')?.credentials_list || []
}, [authedDataSourceList])
// Render data source content based on type
const renderDataSourceContent = () => {
switch (dataSourceType) {
case DataSourceType.FILE:
return (
<FileSource
files={files}
updateFileList={updateFileList}
updateFile={updateFile}
onPreview={updateCurrentFile}
isShowVectorSpaceFull={isShowVectorSpaceFull}
onStepChange={onStepChange}
shouldShowDataSourceTypeList={shouldShowDataSourceTypeList}
supportBatchUpload={supportBatchUpload}
enableBilling={enableBilling}
isSandboxPlan={plan.type === Plan.sandbox}
/>
)
case DataSourceType.NOTION:
return (
<NotionSource
datasetId={datasetId}
notionPages={notionPages}
notionCredentialId={notionCredentialId}
updateNotionPages={updateNotionPages}
updateNotionCredentialId={updateNotionCredentialId}
onPreview={updateCurrentPage}
onSetting={onSetting}
isShowVectorSpaceFull={isShowVectorSpaceFull}
onStepChange={onStepChange}
isNotionAuthed={isNotionAuthed}
notionCredentialList={notionCredentialList}
/>
)
case DataSourceType.WEB:
return (
<WebSource
shouldShowDataSourceTypeList={shouldShowDataSourceTypeList}
websitePages={websitePages}
updateWebsitePages={updateWebsitePages}
onPreview={updateWebsite}
onWebsiteCrawlProviderChange={onWebsiteCrawlProviderChange}
onWebsiteCrawlJobIdChange={onWebsiteCrawlJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
authedDataSourceList={authedDataSourceList}
isShowVectorSpaceFull={isShowVectorSpaceFull}
onStepChange={onStepChange}
/>
)
default:
return null
}
}
return (
<div className="h-full w-full overflow-x-auto">
<div className="flex h-full w-full min-w-[1440px]">
{/* Left panel - Data source selection */}
<div className="relative h-full w-1/2 overflow-y-auto">
<div className="flex justify-end">
<div className={cn(s.form)}>
{
shouldShowDataSourceTypeList && (
{shouldShowDataSourceTypeList && (
<>
<div className={cn(s.stepHeader, 'system-md-semibold text-text-secondary')}>
{t('datasetCreation.steps.one')}
</div>
)
}
{
shouldShowDataSourceTypeList && (
<div className="mb-8 grid grid-cols-3 gap-4">
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.FILE && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.FILE && s.disabled,
)}
onClick={() => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.FILE)
hideNotionPagePreview()
hideWebsitePreview()
}}
>
<span className={cn(s.datasetIcon)} />
<span
title={t('datasetCreation.stepOne.dataSourceType.file')!}
className="truncate"
>
{t('datasetCreation.stepOne.dataSourceType.file')}
</span>
</div>
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.NOTION && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.NOTION && s.disabled,
)}
onClick={() => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.NOTION)
hideFilePreview()
hideWebsitePreview()
}}
>
<span className={cn(s.datasetIcon, s.notion)} />
<span
title={t('datasetCreation.stepOne.dataSourceType.notion')!}
className="truncate"
>
{t('datasetCreation.stepOne.dataSourceType.notion')}
</span>
</div>
{(ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL) && (
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.WEB && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.WEB && s.disabled,
)}
onClick={() => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.WEB)
hideFilePreview()
hideNotionPagePreview()
}}
>
<span className={cn(s.datasetIcon, s.web)} />
<span
title={t('datasetCreation.stepOne.dataSourceType.web')!}
className="truncate"
>
{t('datasetCreation.stepOne.dataSourceType.web')}
</span>
</div>
)}
</div>
)
}
{dataSourceType === DataSourceType.FILE && (
<>
<FileUploader
fileList={files}
titleClassName={!shouldShowDataSourceTypeList ? 'mt-[30px] !mb-[44px] !text-lg' : undefined}
prepareFileList={updateFileList}
onFileListUpdate={updateFileList}
onFileUpdate={updateFile}
onPreview={updateCurrentFile}
supportBatchUpload={supportBatchUpload}
<DataSourceSelector
dataSourceType={dataSourceType!}
dataSourceTypeDisable={dataSourceTypeDisable}
changeType={changeType}
onHideFilePreview={hideFilePreview}
onHideNotionPreview={hideNotionPagePreview}
onHideWebsitePreview={hideWebsitePreview}
/>
{isShowVectorSpaceFull && (
<div className="mb-4 max-w-[640px]">
<VectorSpaceFull />
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={nextDisabled} variant="primary" onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('datasetCreation.stepOne.button')}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
{
enableBilling && plan.type === Plan.sandbox && files.length > 0 && (
<div className="mt-5">
<div className="mb-4 h-px bg-divider-subtle"></div>
<UpgradeCard />
</div>
)
}
</>
)}
{dataSourceType === DataSourceType.NOTION && (
<>
{!isNotionAuthed && <NotionConnector onSetting={onSetting} />}
{isNotionAuthed && (
<>
<div className="mb-8 w-[640px]">
<NotionPageSelector
value={notionPages.map(page => page.page_id)}
onSelect={updateNotionPages}
onPreview={updateCurrentPage}
credentialList={notionCredentialList}
onSelectCredential={updateNotionCredentialId}
datasetId={datasetId}
/>
</div>
{isShowVectorSpaceFull && (
<div className="mb-4 max-w-[640px]">
<VectorSpaceFull />
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={isShowVectorSpaceFull || !notionPages.length} variant="primary" onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('datasetCreation.stepOne.button')}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
</>
)}
</>
)}
{dataSourceType === DataSourceType.WEB && (
<>
<div className={cn('mb-8 w-[640px]', !shouldShowDataSourceTypeList && 'mt-12')}>
<Website
onPreview={updateWebsite}
checkedCrawlResult={websitePages}
onCheckedCrawlResultChange={updateWebsitePages}
onCrawlProviderChange={onWebsiteCrawlProviderChange}
onJobIdChange={onWebsiteCrawlJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
authedDataSourceList={authedDataSourceList}
/>
</div>
{isShowVectorSpaceFull && (
<div className="mb-4 max-w-[640px]">
<VectorSpaceFull />
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={isShowVectorSpaceFull || !websitePages.length} variant="primary" onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('datasetCreation.stepOne.button')}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
</>
)}
{renderDataSourceContent()}
{/* Empty dataset creation link */}
{!datasetId && (
<>
<div className="my-8 h-px max-w-[640px] bg-divider-regular" />
@ -360,6 +233,8 @@ const StepOne = ({
<EmptyDatasetCreationModal show={showModal} onHide={modalCloseHandle} />
</div>
</div>
{/* Right panel - Preview */}
<div className="h-full w-1/2 overflow-y-auto">
{currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />}
{currentNotionPage && (

View File

@ -0,0 +1,447 @@
import type { FileSourceProps } from '../types'
import type { FileItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import FileSource from './file-source'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock child components
vi.mock('../../file-uploader', () => ({
__esModule: true,
default: ({ fileList, onPreview, prepareFileList, titleClassName }: {
fileList: unknown[]
onPreview: (file: File) => void
prepareFileList: (files: unknown[]) => void
titleClassName?: string
}) => (
<div data-testid="file-uploader" data-title-classname={titleClassName || ''}>
<span data-testid="file-count">{fileList.length}</span>
<button data-testid="trigger-preview" onClick={() => onPreview({ name: 'test.txt' } as File)}>Preview</button>
<button data-testid="trigger-prepare" onClick={() => prepareFileList([])}>Prepare</button>
</div>
),
}))
vi.mock('../common/next-step-button', () => ({
__esModule: true,
default: ({ disabled, onClick }: { disabled: boolean, onClick: () => void }) => (
<button data-testid="next-step-button" disabled={disabled} onClick={onClick}>
Next Step
</button>
),
}))
vi.mock('../common/vector-space-alert', () => ({
__esModule: true,
default: ({ show }: { show: boolean }) => (
show ? <div data-testid="vector-space-alert">Vector Space Full</div> : null
),
}))
vi.mock('../upgrade-card', () => ({
__esModule: true,
default: () => <div data-testid="upgrade-card">Upgrade Card</div>,
}))
// Helper to create mock FileItem
// CustomFile extends File and has optional id field
// Use { noId: true } to create a file without id (simulating uploading state)
const createMockFileItem = (options?: { id?: string, noId?: boolean }): FileItem => {
const { id = 'file-123', noId = false } = options || {}
// Create a base file-like object with the required properties
// We need to cast to unknown first then to the target type since File is a browser API
const baseFile = {
name: 'test.txt',
size: 1024,
type: 'text/plain',
lastModified: Date.now(),
webkitRelativePath: '',
arrayBuffer: vi.fn(),
bytes: vi.fn(),
slice: vi.fn(),
stream: vi.fn(),
text: vi.fn(),
...(noId ? {} : { id }), // Only add id if not noId
}
return {
fileID: 'test-file-id',
file: baseFile,
progress: 100,
} as unknown as FileItem
}
const createDefaultProps = (): FileSourceProps => ({
files: [],
updateFileList: vi.fn(),
updateFile: vi.fn(),
onPreview: vi.fn(),
isShowVectorSpaceFull: false,
onStepChange: vi.fn(),
shouldShowDataSourceTypeList: true,
supportBatchUpload: true,
enableBilling: false,
isSandboxPlan: false,
})
describe('FileSource', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render FileUploader component', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('file-uploader')).toBeInTheDocument()
})
it('should render NextStepButton component', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeInTheDocument()
})
it('should pass files to FileUploader', () => {
// Arrange
const props = {
...createDefaultProps(),
files: [createMockFileItem(), createMockFileItem()],
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('file-count')).toHaveTextContent('2')
})
it('should pass custom titleClassName when shouldShowDataSourceTypeList is false', () => {
// Arrange
const props = {
...createDefaultProps(),
shouldShowDataSourceTypeList: false,
}
// Act
render(<FileSource {...props} />)
// Assert
const fileUploader = screen.getByTestId('file-uploader')
expect(fileUploader).toHaveAttribute('data-title-classname', 'mt-[30px] !mb-[44px] !text-lg')
})
it('should pass undefined titleClassName when shouldShowDataSourceTypeList is true', () => {
// Arrange
const props = {
...createDefaultProps(),
shouldShowDataSourceTypeList: true,
}
// Act
render(<FileSource {...props} />)
// Assert
const fileUploader = screen.getByTestId('file-uploader')
expect(fileUploader).toHaveAttribute('data-title-classname', '')
})
})
// ==========================================
// Next Button Disabled State Tests
// ==========================================
describe('Next Button Disabled State', () => {
it('should disable next button when no files', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
it('should disable next button when file has no id (still uploading)', () => {
// Arrange
const props = {
...createDefaultProps(),
files: [createMockFileItem({ noId: true })],
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
it('should disable next button when vector space is full', () => {
// Arrange
const props = {
...createDefaultProps(),
files: [createMockFileItem()],
isShowVectorSpaceFull: true,
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
it('should enable next button when files are uploaded and space available', () => {
// Arrange
const props = {
...createDefaultProps(),
files: [createMockFileItem()],
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should enable next button with multiple uploaded files', () => {
// Arrange
const props = {
...createDefaultProps(),
files: [createMockFileItem({ id: 'id1' }), createMockFileItem({ id: 'id2' })],
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should disable next button when any file is still uploading', () => {
// Arrange
const props = {
...createDefaultProps(),
files: [createMockFileItem({ id: 'id1' }), createMockFileItem({ noId: true })],
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
})
// ==========================================
// Vector Space Alert Tests
// ==========================================
describe('Vector Space Alert', () => {
it('should not show vector space alert by default', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.queryByTestId('vector-space-alert')).not.toBeInTheDocument()
})
it('should show vector space alert when isShowVectorSpaceFull is true', () => {
// Arrange
const props = {
...createDefaultProps(),
isShowVectorSpaceFull: true,
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('vector-space-alert')).toBeInTheDocument()
})
})
// ==========================================
// Upgrade Card Tests
// ==========================================
describe('Upgrade Card', () => {
it('should not show upgrade card by default', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.queryByTestId('upgrade-card')).not.toBeInTheDocument()
})
it('should not show upgrade card when enableBilling is false', () => {
// Arrange
const props = {
...createDefaultProps(),
enableBilling: false,
isSandboxPlan: true,
files: [createMockFileItem()],
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.queryByTestId('upgrade-card')).not.toBeInTheDocument()
})
it('should not show upgrade card when not sandbox plan', () => {
// Arrange
const props = {
...createDefaultProps(),
enableBilling: true,
isSandboxPlan: false,
files: [createMockFileItem()],
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.queryByTestId('upgrade-card')).not.toBeInTheDocument()
})
it('should not show upgrade card when no files', () => {
// Arrange
const props = {
...createDefaultProps(),
enableBilling: true,
isSandboxPlan: true,
files: [],
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.queryByTestId('upgrade-card')).not.toBeInTheDocument()
})
it('should show upgrade card when all conditions are met', () => {
// Arrange
const props = {
...createDefaultProps(),
enableBilling: true,
isSandboxPlan: true,
files: [createMockFileItem()],
}
// Act
render(<FileSource {...props} />)
// Assert
expect(screen.getByTestId('upgrade-card')).toBeInTheDocument()
})
})
// ==========================================
// Callback Tests
// ==========================================
describe('Callbacks', () => {
it('should call onStepChange when next button is clicked', () => {
// Arrange
const props = {
...createDefaultProps(),
files: [createMockFileItem()],
}
render(<FileSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('next-step-button'))
// Assert
expect(props.onStepChange).toHaveBeenCalledTimes(1)
})
it('should call onPreview when file preview is triggered', () => {
// Arrange
const props = createDefaultProps()
render(<FileSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('trigger-preview'))
// Assert
expect(props.onPreview).toHaveBeenCalledWith({ name: 'test.txt' })
})
it('should call updateFileList when prepareFileList is triggered', () => {
// Arrange
const props = createDefaultProps()
render(<FileSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('trigger-prepare'))
// Assert
expect(props.updateFileList).toHaveBeenCalledWith([])
})
})
// ==========================================
// Memoization Tests
// ==========================================
describe('Memoization', () => {
it('should update nextDisabled when files change', () => {
// Arrange
const props = createDefaultProps()
const { rerender } = render(<FileSource {...props} />)
// Assert initial state
expect(screen.getByTestId('next-step-button')).toBeDisabled()
// Act - update with uploaded files
rerender(<FileSource {...props} files={[createMockFileItem()]} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should update nextDisabled when isShowVectorSpaceFull changes', () => {
// Arrange
const props = {
...createDefaultProps(),
files: [createMockFileItem()],
}
const { rerender } = render(<FileSource {...props} />)
// Assert initial state
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
// Act - set vector space full
rerender(<FileSource {...props} isShowVectorSpaceFull={true} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
})
})

View File

@ -0,0 +1,58 @@
'use client'
import type { FileSourceProps } from '../types'
import { useMemo } from 'react'
import FileUploader from '../../file-uploader'
import NextStepButton from '../common/next-step-button'
import VectorSpaceAlert from '../common/vector-space-alert'
import UpgradeCard from '../upgrade-card'
/**
* File data source component
* Handles file upload functionality for dataset creation
*/
const FileSource = ({
files,
updateFileList,
updateFile,
onPreview,
isShowVectorSpaceFull,
onStepChange,
shouldShowDataSourceTypeList,
supportBatchUpload,
enableBilling,
isSandboxPlan,
}: FileSourceProps) => {
const nextDisabled = useMemo(() => {
if (!files.length)
return true
if (files.some(file => !file.file.id))
return true
return isShowVectorSpaceFull
}, [files, isShowVectorSpaceFull])
const showUpgradeCard = enableBilling && isSandboxPlan && files.length > 0
return (
<>
<FileUploader
fileList={files}
titleClassName={!shouldShowDataSourceTypeList ? 'mt-[30px] !mb-[44px] !text-lg' : undefined}
prepareFileList={updateFileList}
onFileListUpdate={updateFileList}
onFileUpdate={updateFile}
onPreview={onPreview}
supportBatchUpload={supportBatchUpload}
/>
<VectorSpaceAlert show={isShowVectorSpaceFull} />
<NextStepButton disabled={nextDisabled} onClick={onStepChange} />
{showUpgradeCard && (
<div className="mt-5">
<div className="mb-4 h-px bg-divider-subtle"></div>
<UpgradeCard />
</div>
)}
</>
)
}
export default FileSource

View File

@ -0,0 +1,3 @@
export { default as FileSource } from './file-source'
export { default as NotionSource } from './notion-source'
export { default as WebSource } from './web-source'

View File

@ -0,0 +1,409 @@
import type { NotionSourceProps } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import NotionSource from './notion-source'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock child components
vi.mock('@/app/components/base/notion-connector', () => ({
__esModule: true,
default: ({ onSetting }: { onSetting: () => void }) => (
<div data-testid="notion-connector">
<button data-testid="notion-setting" onClick={onSetting}>Connect Notion</button>
</div>
),
}))
vi.mock('@/app/components/base/notion-page-selector', () => ({
NotionPageSelector: ({
value,
onSelect,
onPreview,
}: {
value: string[]
onSelect: (pages: unknown[]) => void
onPreview: (page: unknown) => void
}) => (
<div data-testid="notion-page-selector">
<span data-testid="selected-count">{value.length}</span>
<button data-testid="trigger-select" onClick={() => onSelect([{ page_id: 'page-1' }])}>Select</button>
<button data-testid="trigger-preview" onClick={() => onPreview({ page_id: 'preview-page' })}>Preview</button>
</div>
),
}))
vi.mock('../common/next-step-button', () => ({
__esModule: true,
default: ({ disabled, onClick }: { disabled: boolean, onClick: () => void }) => (
<button data-testid="next-step-button" disabled={disabled} onClick={onClick}>
Next Step
</button>
),
}))
vi.mock('../common/vector-space-alert', () => ({
__esModule: true,
default: ({ show }: { show: boolean }) => (
show ? <div data-testid="vector-space-alert">Vector Space Full</div> : null
),
}))
// Helper to create mock NotionPage
const createMockNotionPage = (pageId: string = 'page-123') => ({
page_id: pageId,
page_name: 'Test Page',
page_icon: null,
parent_id: 'parent-1',
type: 'page',
is_bound: true,
workspace_id: 'workspace-1',
})
// Helper to create mock credential list
const createMockCredentialList = () => ([
{
credential: {},
type: CredentialTypeEnum.API_KEY,
name: 'Test Credential',
id: 'cred-1',
is_default: true,
avatar_url: '',
},
])
const createDefaultProps = (): NotionSourceProps => ({
datasetId: undefined,
notionPages: [],
notionCredentialId: 'credential-1',
updateNotionPages: vi.fn(),
updateNotionCredentialId: vi.fn(),
onPreview: vi.fn(),
onSetting: vi.fn(),
isShowVectorSpaceFull: false,
onStepChange: vi.fn(),
isNotionAuthed: true,
notionCredentialList: createMockCredentialList(),
})
describe('NotionSource', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ==========================================
// Rendering Tests - Not Authed
// ==========================================
describe('Rendering - Not Authed', () => {
it('should render NotionConnector when not authenticated', () => {
// Arrange
const props = {
...createDefaultProps(),
isNotionAuthed: false,
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('notion-connector')).toBeInTheDocument()
expect(screen.queryByTestId('notion-page-selector')).not.toBeInTheDocument()
})
it('should call onSetting when NotionConnector setting is clicked', () => {
// Arrange
const props = {
...createDefaultProps(),
isNotionAuthed: false,
}
render(<NotionSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('notion-setting'))
// Assert
expect(props.onSetting).toHaveBeenCalledTimes(1)
})
})
// ==========================================
// Rendering Tests - Authed
// ==========================================
describe('Rendering - Authed', () => {
it('should render NotionPageSelector when authenticated', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument()
expect(screen.queryByTestId('notion-connector')).not.toBeInTheDocument()
})
it('should render NextStepButton when authenticated', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeInTheDocument()
})
it('should pass selected page ids to NotionPageSelector', () => {
// Arrange
const props = {
...createDefaultProps(),
notionPages: [createMockNotionPage('page-1'), createMockNotionPage('page-2')],
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('selected-count')).toHaveTextContent('2')
})
})
// ==========================================
// Next Button Disabled State Tests
// ==========================================
describe('Next Button Disabled State', () => {
it('should disable next button when no pages selected', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
it('should disable next button when vector space is full', () => {
// Arrange
const props = {
...createDefaultProps(),
notionPages: [createMockNotionPage()],
isShowVectorSpaceFull: true,
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
it('should enable next button when pages are selected and space available', () => {
// Arrange
const props = {
...createDefaultProps(),
notionPages: [createMockNotionPage()],
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should enable next button with multiple selected pages', () => {
// Arrange
const props = {
...createDefaultProps(),
notionPages: [createMockNotionPage('p1'), createMockNotionPage('p2')],
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should disable next button when both no pages and vector space full', () => {
// Arrange
const props = {
...createDefaultProps(),
notionPages: [],
isShowVectorSpaceFull: true,
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
})
// ==========================================
// Vector Space Alert Tests
// ==========================================
describe('Vector Space Alert', () => {
it('should not show vector space alert by default', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.queryByTestId('vector-space-alert')).not.toBeInTheDocument()
})
it('should show vector space alert when isShowVectorSpaceFull is true', () => {
// Arrange
const props = {
...createDefaultProps(),
isShowVectorSpaceFull: true,
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('vector-space-alert')).toBeInTheDocument()
})
})
// ==========================================
// Callback Tests
// ==========================================
describe('Callbacks', () => {
it('should call onStepChange when next button is clicked', () => {
// Arrange
const props = {
...createDefaultProps(),
notionPages: [createMockNotionPage()],
}
render(<NotionSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('next-step-button'))
// Assert
expect(props.onStepChange).toHaveBeenCalledTimes(1)
})
it('should call updateNotionPages when pages are selected', () => {
// Arrange
const props = createDefaultProps()
render(<NotionSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('trigger-select'))
// Assert
expect(props.updateNotionPages).toHaveBeenCalledWith([{ page_id: 'page-1' }])
})
it('should call onPreview when page preview is triggered', () => {
// Arrange
const props = createDefaultProps()
render(<NotionSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('trigger-preview'))
// Assert
expect(props.onPreview).toHaveBeenCalledWith({ page_id: 'preview-page' })
})
})
// ==========================================
// Edge Cases
// ==========================================
describe('Edge Cases', () => {
it('should handle empty credential list', () => {
// Arrange
const props = {
...createDefaultProps(),
notionCredentialList: [],
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument()
})
it('should handle undefined datasetId', () => {
// Arrange
const props = {
...createDefaultProps(),
datasetId: undefined,
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument()
})
it('should handle with datasetId provided', () => {
// Arrange
const props = {
...createDefaultProps(),
datasetId: 'dataset-123',
}
// Act
render(<NotionSource {...props} />)
// Assert
expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument()
})
})
// ==========================================
// State Update Tests
// ==========================================
describe('State Updates', () => {
it('should update button state when notionPages changes', () => {
// Arrange
const props = createDefaultProps()
const { rerender } = render(<NotionSource {...props} />)
// Assert initial state
expect(screen.getByTestId('next-step-button')).toBeDisabled()
// Act - add pages
rerender(<NotionSource {...props} notionPages={[createMockNotionPage()]} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should switch from connector to selector when auth changes', () => {
// Arrange
const props = {
...createDefaultProps(),
isNotionAuthed: false,
}
const { rerender } = render(<NotionSource {...props} />)
// Assert initial state
expect(screen.getByTestId('notion-connector')).toBeInTheDocument()
// Act - authenticate
rerender(<NotionSource {...props} isNotionAuthed={true} />)
// Assert
expect(screen.queryByTestId('notion-connector')).not.toBeInTheDocument()
expect(screen.getByTestId('notion-page-selector')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,49 @@
'use client'
import type { NotionSourceProps } from '../types'
import NotionConnector from '@/app/components/base/notion-connector'
import { NotionPageSelector } from '@/app/components/base/notion-page-selector'
import NextStepButton from '../common/next-step-button'
import VectorSpaceAlert from '../common/vector-space-alert'
/**
* Notion data source component
* Handles Notion page selection for dataset creation
*/
const NotionSource = ({
datasetId,
notionPages,
notionCredentialId,
updateNotionPages,
updateNotionCredentialId,
onPreview,
onSetting,
isShowVectorSpaceFull,
onStepChange,
isNotionAuthed,
notionCredentialList,
}: NotionSourceProps) => {
const nextDisabled = isShowVectorSpaceFull || !notionPages.length
if (!isNotionAuthed) {
return <NotionConnector onSetting={onSetting} />
}
return (
<>
<div className="mb-8 w-[640px]">
<NotionPageSelector
value={notionPages.map(page => page.page_id)}
onSelect={updateNotionPages}
onPreview={onPreview}
credentialList={notionCredentialList}
onSelectCredential={updateNotionCredentialId}
datasetId={datasetId}
/>
</div>
<VectorSpaceAlert show={isShowVectorSpaceFull} />
<NextStepButton disabled={nextDisabled} onClick={onStepChange} />
</>
)
}
export default NotionSource

View File

@ -0,0 +1,536 @@
import type { WebSourceProps } from '../types'
import type { CrawlResultItem } from '@/models/datasets'
import { fireEvent, render, screen } from '@testing-library/react'
import WebSource from './web-source'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock child components
vi.mock('../../website', () => ({
__esModule: true,
default: ({
checkedCrawlResult,
onPreview,
onCheckedCrawlResultChange,
onCrawlProviderChange,
onJobIdChange,
onCrawlOptionsChange,
}: {
checkedCrawlResult: CrawlResultItem[]
onPreview: (item: CrawlResultItem) => void
onCheckedCrawlResultChange: (items: CrawlResultItem[]) => void
onCrawlProviderChange: (provider: unknown) => void
onJobIdChange: (jobId: string) => void
onCrawlOptionsChange: (options: unknown) => void
}) => (
<div data-testid="website-component">
<span data-testid="checked-count">{checkedCrawlResult.length}</span>
<button
data-testid="trigger-preview"
onClick={() => onPreview({ source_url: 'https://test.com', markdown: 'test', title: 'Test', description: 'Test description' })}
>
Preview
</button>
<button
data-testid="trigger-check-change"
onClick={() => onCheckedCrawlResultChange([{ source_url: 'https://new.com', markdown: 'new', title: 'New', description: 'New description' }])}
>
Change Check
</button>
<button
data-testid="trigger-provider-change"
onClick={() => onCrawlProviderChange('firecrawl')}
>
Change Provider
</button>
<button
data-testid="trigger-job-change"
onClick={() => onJobIdChange('job-123')}
>
Change Job
</button>
<button
data-testid="trigger-options-change"
onClick={() => onCrawlOptionsChange({ max_depth: 2 })}
>
Change Options
</button>
</div>
),
}))
vi.mock('../common/next-step-button', () => ({
__esModule: true,
default: ({ disabled, onClick }: { disabled: boolean, onClick: () => void }) => (
<button data-testid="next-step-button" disabled={disabled} onClick={onClick}>
Next Step
</button>
),
}))
vi.mock('../common/vector-space-alert', () => ({
__esModule: true,
default: ({ show }: { show: boolean }) => (
show ? <div data-testid="vector-space-alert">Vector Space Full</div> : null
),
}))
// Helper to create mock CrawlResultItem
const createMockWebsitePage = (url: string = 'https://example.com'): CrawlResultItem => ({
source_url: url,
markdown: '# Test Page\nContent here',
title: 'Test Page',
description: 'A test page',
})
// Helper to create mock DataSourceAuth
const createMockDataSourceAuth = () => ({
author: 'test-author',
provider: 'firecrawl',
plugin_id: 'plugin-1',
plugin_unique_identifier: 'firecrawl-plugin',
icon: 'icon-url',
name: 'Firecrawl',
label: { en_US: 'Firecrawl', zh_Hans: 'Firecrawl' },
description: { en_US: 'Web crawler', zh_Hans: 'Web crawler' },
credentials_list: [],
})
// Helper to create default crawl options
const createDefaultCrawlOptions = () => ({
crawl_sub_pages: true,
only_main_content: true,
includes: '',
excludes: '',
limit: 10,
max_depth: 2,
use_sitemap: false,
})
const createDefaultProps = (): WebSourceProps => ({
shouldShowDataSourceTypeList: true,
websitePages: [],
updateWebsitePages: vi.fn(),
onPreview: vi.fn(),
onWebsiteCrawlProviderChange: vi.fn(),
onWebsiteCrawlJobIdChange: vi.fn(),
crawlOptions: createDefaultCrawlOptions(),
onCrawlOptionsChange: vi.fn(),
authedDataSourceList: [],
isShowVectorSpaceFull: false,
onStepChange: vi.fn(),
})
describe('WebSource', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render Website component', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('website-component')).toBeInTheDocument()
})
it('should render NextStepButton component', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeInTheDocument()
})
it('should pass websitePages count to Website component', () => {
// Arrange
const props = {
...createDefaultProps(),
websitePages: [createMockWebsitePage(), createMockWebsitePage('https://page2.com')],
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('checked-count')).toHaveTextContent('2')
})
it('should apply margin top when shouldShowDataSourceTypeList is false', () => {
// Arrange
const props = {
...createDefaultProps(),
shouldShowDataSourceTypeList: false,
}
// Act
const { container } = render(<WebSource {...props} />)
// Assert
const wrapper = container.querySelector('.mt-12')
expect(wrapper).toBeInTheDocument()
})
it('should not apply margin top when shouldShowDataSourceTypeList is true', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<WebSource {...props} />)
// Assert
const wrapper = container.querySelector('.mt-12')
expect(wrapper).not.toBeInTheDocument()
})
})
// ==========================================
// Next Button Disabled State Tests
// ==========================================
describe('Next Button Disabled State', () => {
it('should disable next button when no pages', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
it('should disable next button when vector space is full', () => {
// Arrange
const props = {
...createDefaultProps(),
websitePages: [createMockWebsitePage()],
isShowVectorSpaceFull: true,
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
it('should enable next button when pages are selected and space available', () => {
// Arrange
const props = {
...createDefaultProps(),
websitePages: [createMockWebsitePage()],
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should enable next button with multiple selected pages', () => {
// Arrange
const props = {
...createDefaultProps(),
websitePages: [createMockWebsitePage('https://a.com'), createMockWebsitePage('https://b.com')],
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should disable next button when both no pages and vector space full', () => {
// Arrange
const props = {
...createDefaultProps(),
websitePages: [],
isShowVectorSpaceFull: true,
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
})
// ==========================================
// Vector Space Alert Tests
// ==========================================
describe('Vector Space Alert', () => {
it('should not show vector space alert by default', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.queryByTestId('vector-space-alert')).not.toBeInTheDocument()
})
it('should show vector space alert when isShowVectorSpaceFull is true', () => {
// Arrange
const props = {
...createDefaultProps(),
isShowVectorSpaceFull: true,
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('vector-space-alert')).toBeInTheDocument()
})
})
// ==========================================
// Callback Tests
// ==========================================
describe('Callbacks', () => {
it('should call onStepChange when next button is clicked', () => {
// Arrange
const props = {
...createDefaultProps(),
websitePages: [createMockWebsitePage()],
}
render(<WebSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('next-step-button'))
// Assert
expect(props.onStepChange).toHaveBeenCalledTimes(1)
})
it('should call onPreview when page preview is triggered', () => {
// Arrange
const props = createDefaultProps()
render(<WebSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('trigger-preview'))
// Assert
expect(props.onPreview).toHaveBeenCalledWith({ source_url: 'https://test.com', markdown: 'test', title: 'Test', description: 'Test description' })
})
it('should call updateWebsitePages when checked result changes', () => {
// Arrange
const props = createDefaultProps()
render(<WebSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('trigger-check-change'))
// Assert
expect(props.updateWebsitePages).toHaveBeenCalledWith([{ source_url: 'https://new.com', markdown: 'new', title: 'New', description: 'New description' }])
})
it('should call onWebsiteCrawlProviderChange when provider changes', () => {
// Arrange
const props = createDefaultProps()
render(<WebSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('trigger-provider-change'))
// Assert
expect(props.onWebsiteCrawlProviderChange).toHaveBeenCalledWith('firecrawl')
})
it('should call onWebsiteCrawlJobIdChange when job id changes', () => {
// Arrange
const props = createDefaultProps()
render(<WebSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('trigger-job-change'))
// Assert
expect(props.onWebsiteCrawlJobIdChange).toHaveBeenCalledWith('job-123')
})
it('should call onCrawlOptionsChange when options change', () => {
// Arrange
const props = createDefaultProps()
render(<WebSource {...props} />)
// Act
fireEvent.click(screen.getByTestId('trigger-options-change'))
// Assert
expect(props.onCrawlOptionsChange).toHaveBeenCalledWith({ max_depth: 2 })
})
})
// ==========================================
// Props Passing Tests
// ==========================================
describe('Props Passing', () => {
it('should handle empty authedDataSourceList', () => {
// Arrange
const props = {
...createDefaultProps(),
authedDataSourceList: [],
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('website-component')).toBeInTheDocument()
})
it('should handle authedDataSourceList with items', () => {
// Arrange
const props = {
...createDefaultProps(),
authedDataSourceList: [createMockDataSourceAuth()],
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('website-component')).toBeInTheDocument()
})
it('should handle custom crawl options', () => {
// Arrange
const customOptions = {
crawl_sub_pages: false,
only_main_content: false,
includes: '*.html',
excludes: '*.pdf',
limit: 50,
max_depth: 5,
use_sitemap: true,
}
const props = {
...createDefaultProps(),
crawlOptions: customOptions,
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('website-component')).toBeInTheDocument()
})
})
// ==========================================
// State Update Tests
// ==========================================
describe('State Updates', () => {
it('should update button state when websitePages changes', () => {
// Arrange
const props = createDefaultProps()
const { rerender } = render(<WebSource {...props} />)
// Assert initial state
expect(screen.getByTestId('next-step-button')).toBeDisabled()
// Act - add pages
rerender(<WebSource {...props} websitePages={[createMockWebsitePage()]} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should update button state when isShowVectorSpaceFull changes', () => {
// Arrange
const props = {
...createDefaultProps(),
websitePages: [createMockWebsitePage()],
}
const { rerender } = render(<WebSource {...props} />)
// Assert initial state
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
// Act - set vector space full
rerender(<WebSource {...props} isShowVectorSpaceFull={true} />)
// Assert
expect(screen.getByTestId('next-step-button')).toBeDisabled()
})
it('should update layout when shouldShowDataSourceTypeList changes', () => {
// Arrange
const props = createDefaultProps()
const { container, rerender } = render(<WebSource {...props} />)
// Assert initial state - no mt-12
expect(container.querySelector('.mt-12')).not.toBeInTheDocument()
// Act - change to false
rerender(<WebSource {...props} shouldShowDataSourceTypeList={false} />)
// Assert - has mt-12
expect(container.querySelector('.mt-12')).toBeInTheDocument()
})
})
// ==========================================
// Edge Cases
// ==========================================
describe('Edge Cases', () => {
it('should handle page with minimal data', () => {
// Arrange
const minimalPage: CrawlResultItem = {
source_url: 'https://minimal.com',
markdown: '',
title: '',
description: '',
}
const props = {
...createDefaultProps(),
websitePages: [minimalPage],
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
it('should handle many pages', () => {
// Arrange
const manyPages = Array.from({ length: 100 }, (_, i) =>
createMockWebsitePage(`https://page${i}.com`))
const props = {
...createDefaultProps(),
websitePages: manyPages,
}
// Act
render(<WebSource {...props} />)
// Assert
expect(screen.getByTestId('checked-count')).toHaveTextContent('100')
expect(screen.getByTestId('next-step-button')).not.toBeDisabled()
})
})
})

View File

@ -0,0 +1,47 @@
'use client'
import type { WebSourceProps } from '../types'
import { cn } from '@/utils/classnames'
import Website from '../../website'
import NextStepButton from '../common/next-step-button'
import VectorSpaceAlert from '../common/vector-space-alert'
/**
* Web data source component
* Handles website crawling for dataset creation
*/
const WebSource = ({
shouldShowDataSourceTypeList,
websitePages,
updateWebsitePages,
onPreview,
onWebsiteCrawlProviderChange,
onWebsiteCrawlJobIdChange,
crawlOptions,
onCrawlOptionsChange,
authedDataSourceList,
isShowVectorSpaceFull,
onStepChange,
}: WebSourceProps) => {
const nextDisabled = isShowVectorSpaceFull || !websitePages.length
return (
<>
<div className={cn('mb-8 w-[640px]', !shouldShowDataSourceTypeList && 'mt-12')}>
<Website
onPreview={onPreview}
checkedCrawlResult={websitePages}
onCheckedCrawlResultChange={updateWebsitePages}
onCrawlProviderChange={onWebsiteCrawlProviderChange}
onJobIdChange={onWebsiteCrawlJobIdChange}
crawlOptions={crawlOptions}
onCrawlOptionsChange={onCrawlOptionsChange}
authedDataSourceList={authedDataSourceList}
/>
</div>
<VectorSpaceAlert show={isShowVectorSpaceFull} />
<NextStepButton disabled={nextDisabled} onClick={onStepChange} />
</>
)
}
export default WebSource

View File

@ -0,0 +1,74 @@
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { DataSourceProvider, NotionPage } from '@/models/common'
import type { CrawlOptions, CrawlResultItem, DataSourceType, FileItem } from '@/models/datasets'
// Base props shared by all data source components
export type DataSourceBaseProps = {
isShowVectorSpaceFull: boolean
onStepChange: () => void
}
// File source specific props
export type FileSourceProps = DataSourceBaseProps & {
files: FileItem[]
updateFileList: (files: FileItem[]) => void
updateFile: (fileItem: FileItem, progress: number, list: FileItem[]) => void
onPreview: (file: File) => void
shouldShowDataSourceTypeList: boolean
supportBatchUpload: boolean
enableBilling: boolean
isSandboxPlan: boolean
}
// Notion source specific props
export type NotionSourceProps = DataSourceBaseProps & {
datasetId?: string
notionPages: NotionPage[]
notionCredentialId: string
updateNotionPages: (value: NotionPage[]) => void
updateNotionCredentialId: (credentialId: string) => void
onPreview: (page: NotionPage) => void
onSetting: () => void
isNotionAuthed: boolean
notionCredentialList: DataSourceAuth['credentials_list']
}
// Web source specific props
export type WebSourceProps = DataSourceBaseProps & {
shouldShowDataSourceTypeList: boolean
websitePages: CrawlResultItem[]
updateWebsitePages: (value: CrawlResultItem[]) => void
onPreview: (website: CrawlResultItem) => void
onWebsiteCrawlProviderChange: (provider: DataSourceProvider) => void
onWebsiteCrawlJobIdChange: (jobId: string) => void
crawlOptions: CrawlOptions
onCrawlOptionsChange: (payload: CrawlOptions) => void
authedDataSourceList: DataSourceAuth[]
}
// Data source selector props
export type DataSourceSelectorProps = {
dataSourceType: DataSourceType
dataSourceTypeDisable: boolean
changeType: (type: DataSourceType) => void
onHideFilePreview: () => void
onHideNotionPreview: () => void
onHideWebsitePreview: () => void
}
// Preview state type
export type PreviewState = {
currentFile: File | undefined
currentNotionPage: NotionPage | undefined
currentWebsite: CrawlResultItem | undefined
}
// Preview actions type
export type PreviewActions = {
updateCurrentFile: (file: File) => void
hideFilePreview: () => void
updateCurrentPage: (page: NotionPage) => void
hideNotionPagePreview: () => void
updateWebsite: (website: CrawlResultItem) => void
hideWebsitePreview: () => void
}