diff --git a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx
index d5be00354a..54031a87d4 100644
--- a/web/app/components/datasets/create/website/base/checkbox-with-label.tsx
+++ b/web/app/components/datasets/create/website/base/checkbox-with-label.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Checkbox from '@/app/components/base/checkbox'
import Tooltip from '@/app/components/base/tooltip'
diff --git a/web/app/components/datasets/create/website/base/crawled-result-item.tsx b/web/app/components/datasets/create/website/base/crawled-result-item.tsx
index 6253d56380..ae546f7757 100644
--- a/web/app/components/datasets/create/website/base/crawled-result-item.tsx
+++ b/web/app/components/datasets/create/website/base/crawled-result-item.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets'
import Checkbox from '@/app/components/base/checkbox'
import Button from '@/app/components/base/button'
diff --git a/web/app/components/datasets/create/website/base/crawled-result.tsx b/web/app/components/datasets/create/website/base/crawled-result.tsx
index 655723175f..34a702445c 100644
--- a/web/app/components/datasets/create/website/base/crawled-result.tsx
+++ b/web/app/components/datasets/create/website/base/crawled-result.tsx
@@ -4,7 +4,7 @@ import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import CheckboxWithLabel from './checkbox-with-label'
import CrawledResultItem from './crawled-result-item'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CrawlResultItem } from '@/models/datasets'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
diff --git a/web/app/components/datasets/create/website/base/error-message.tsx b/web/app/components/datasets/create/website/base/error-message.tsx
index 2788eb9013..4a88dfe79e 100644
--- a/web/app/components/datasets/create/website/base/error-message.tsx
+++ b/web/app/components/datasets/create/website/base/error-message.tsx
@@ -1,7 +1,7 @@
'use client'
import type { FC } from 'react'
import React from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
type Props = {
diff --git a/web/app/components/datasets/create/website/base/field.tsx b/web/app/components/datasets/create/website/base/field.tsx
index 43d76464e6..5db048c6b9 100644
--- a/web/app/components/datasets/create/website/base/field.tsx
+++ b/web/app/components/datasets/create/website/base/field.tsx
@@ -2,7 +2,7 @@
import type { FC } from 'react'
import React from 'react'
import Input from './input'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import Tooltip from '@/app/components/base/tooltip'
type Props = {
diff --git a/web/app/components/datasets/create/website/base/header.tsx b/web/app/components/datasets/create/website/base/header.tsx
index 5a99835522..c5aa95189f 100644
--- a/web/app/components/datasets/create/website/base/header.tsx
+++ b/web/app/components/datasets/create/website/base/header.tsx
@@ -1,7 +1,7 @@
import React from 'react'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react'
type HeaderProps = {
diff --git a/web/app/components/datasets/create/website/base/options-wrap.tsx b/web/app/components/datasets/create/website/base/options-wrap.tsx
index f17a546203..9428782fdb 100644
--- a/web/app/components/datasets/create/website/base/options-wrap.tsx
+++ b/web/app/components/datasets/create/website/base/options-wrap.tsx
@@ -4,7 +4,7 @@ import type { FC } from 'react'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { RiEqualizer2Line } from '@remixicon/react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
diff --git a/web/app/components/datasets/create/website/firecrawl/options.tsx b/web/app/components/datasets/create/website/firecrawl/options.tsx
index dea6c0e5b9..f31ae93e9d 100644
--- a/web/app/components/datasets/create/website/firecrawl/options.tsx
+++ b/web/app/components/datasets/create/website/firecrawl/options.tsx
@@ -4,7 +4,7 @@ import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import CheckboxWithLabel from '../base/checkbox-with-label'
import Field from '../base/field'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CrawlOptions } from '@/models/datasets'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
diff --git a/web/app/components/datasets/create/website/index.tsx b/web/app/components/datasets/create/website/index.tsx
index ee7ace6815..180f6c6bcd 100644
--- a/web/app/components/datasets/create/website/index.tsx
+++ b/web/app/components/datasets/create/website/index.tsx
@@ -7,7 +7,7 @@ import NoData from './no-data'
import Firecrawl from './firecrawl'
import Watercrawl from './watercrawl'
import JinaReader from './jina-reader'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import { useModalContext } from '@/context/modal-context'
import type { CrawlOptions, CrawlResultItem } from '@/models/datasets'
import { DataSourceProvider } from '@/models/common'
diff --git a/web/app/components/datasets/create/website/jina-reader/options.tsx b/web/app/components/datasets/create/website/jina-reader/options.tsx
index 33af3138e8..3b8eab469e 100644
--- a/web/app/components/datasets/create/website/jina-reader/options.tsx
+++ b/web/app/components/datasets/create/website/jina-reader/options.tsx
@@ -4,7 +4,7 @@ import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import CheckboxWithLabel from '../base/checkbox-with-label'
import Field from '../base/field'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CrawlOptions } from '@/models/datasets'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
diff --git a/web/app/components/datasets/create/website/preview.tsx b/web/app/components/datasets/create/website/preview.tsx
index f43dc83589..ff2ca4ec43 100644
--- a/web/app/components/datasets/create/website/preview.tsx
+++ b/web/app/components/datasets/create/website/preview.tsx
@@ -3,7 +3,7 @@ import React from 'react'
import { useTranslation } from 'react-i18next'
import { XMarkIcon } from '@heroicons/react/20/solid'
import s from '../file-preview/index.module.css'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CrawlResultItem } from '@/models/datasets'
type IProps = {
diff --git a/web/app/components/datasets/create/website/watercrawl/options.tsx b/web/app/components/datasets/create/website/watercrawl/options.tsx
index 030505030e..e2e59bfa7d 100644
--- a/web/app/components/datasets/create/website/watercrawl/options.tsx
+++ b/web/app/components/datasets/create/website/watercrawl/options.tsx
@@ -4,7 +4,7 @@ import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import CheckboxWithLabel from '../base/checkbox-with-label'
import Field from '../base/field'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
import type { CrawlOptions } from '@/models/datasets'
const I18N_PREFIX = 'datasetCreation.stepOne.website'
diff --git a/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx
new file mode 100644
index 0000000000..e3076bd172
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/actions/index.spec.tsx
@@ -0,0 +1,825 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import React from 'react'
+import Actions from './index'
+
+// ==========================================
+// Mock External Dependencies
+// ==========================================
+
+// Mock next/navigation - useParams returns datasetId
+const mockDatasetId = 'test-dataset-id'
+jest.mock('next/navigation', () => ({
+ useParams: () => ({ datasetId: mockDatasetId }),
+}))
+
+// Mock next/link to capture href
+jest.mock('next/link', () => {
+ return ({ children, href, replace }: { children: React.ReactNode; href: string; replace?: boolean }) => (
+
+ {children}
+
+ )
+})
+
+// ==========================================
+// Test Suite
+// ==========================================
+
+describe('Actions', () => {
+ // Default mock for required props
+ const defaultProps = {
+ handleNextStep: jest.fn(),
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ // ==========================================
+ // Rendering Tests
+ // ==========================================
+ describe('Rendering', () => {
+ // Tests basic rendering functionality
+ it('should render without crashing', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).toBeInTheDocument()
+ })
+
+ it('should render cancel button with correct link', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ const cancelLink = screen.getByRole('link')
+ expect(cancelLink).toHaveAttribute('href', `/datasets/${mockDatasetId}/documents`)
+ expect(cancelLink).toHaveAttribute('data-replace', 'true')
+ })
+
+ it('should render next step button with arrow icon', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })
+ expect(nextButton).toBeInTheDocument()
+ expect(nextButton.querySelector('svg')).toBeInTheDocument()
+ })
+
+ it('should render cancel button with correct translation key', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
+ })
+
+ it('should not render select all section by default', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==========================================
+ // Props Testing
+ // ==========================================
+ describe('Props', () => {
+ // Tests for prop variations and defaults
+ describe('disabled prop', () => {
+ it('should not disable next step button when disabled is false', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })
+ expect(nextButton).not.toBeDisabled()
+ })
+
+ it('should disable next step button when disabled is true', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })
+ expect(nextButton).toBeDisabled()
+ })
+
+ it('should not disable next step button when disabled is undefined', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })
+ expect(nextButton).not.toBeDisabled()
+ })
+ })
+
+ describe('showSelect prop', () => {
+ it('should show select all section when showSelect is true', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument()
+ })
+
+ it('should hide select all section when showSelect is false', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument()
+ })
+
+ it('should hide select all section when showSelect defaults to false', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('tip prop', () => {
+ it('should show tip when showSelect is true and tip is provided', () => {
+ // Arrange
+ const tip = 'This is a helpful tip'
+
+ // Act
+ render(
)
+
+ // Assert
+ expect(screen.getByText(tip)).toBeInTheDocument()
+ expect(screen.getByTitle(tip)).toBeInTheDocument()
+ })
+
+ it('should not show tip when showSelect is false even if tip is provided', () => {
+ // Arrange
+ const tip = 'This is a helpful tip'
+
+ // Act
+ render(
)
+
+ // Assert
+ expect(screen.queryByText(tip)).not.toBeInTheDocument()
+ })
+
+ it('should not show tip when tip is empty string', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ const tipElements = screen.queryAllByTitle('')
+ // Empty tip should not render a tip element
+ expect(tipElements.length).toBe(0)
+ })
+
+ it('should use empty string as default tip value', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert - tip container should not exist when tip defaults to empty string
+ const tipContainer = document.querySelector('.text-text-tertiary.truncate')
+ expect(tipContainer).not.toBeInTheDocument()
+ })
+ })
+ })
+
+ // ==========================================
+ // Event Handlers Testing
+ // ==========================================
+ describe('User Interactions', () => {
+ // Tests for event handlers
+ it('should call handleNextStep when next button is clicked', () => {
+ // Arrange
+ const handleNextStep = jest.fn()
+ render(
)
+
+ // Act
+ fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))
+
+ // Assert
+ expect(handleNextStep).toHaveBeenCalledTimes(1)
+ })
+
+ it('should not call handleNextStep when next button is disabled and clicked', () => {
+ // Arrange
+ const handleNextStep = jest.fn()
+ render(
)
+
+ // Act
+ fireEvent.click(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i }))
+
+ // Assert
+ expect(handleNextStep).not.toHaveBeenCalled()
+ })
+
+ it('should call onSelectAll when checkbox is clicked', () => {
+ // Arrange
+ const onSelectAll = jest.fn()
+ render(
+
,
+ )
+
+ // Act - find the checkbox container and click it
+ const selectAllLabel = screen.getByText('common.operation.selectAll')
+ const checkboxContainer = selectAllLabel.closest('.flex.shrink-0.items-center')
+ const checkbox = checkboxContainer?.querySelector('[class*="cursor-pointer"]')
+ if (checkbox)
+ fireEvent.click(checkbox)
+
+ // Assert
+ expect(onSelectAll).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ // ==========================================
+ // Memoization Logic Testing
+ // ==========================================
+ describe('Memoization Logic', () => {
+ // Tests for useMemo hooks (indeterminate and checked)
+ describe('indeterminate calculation', () => {
+ it('should return false when showSelect is false', () => {
+ // Arrange & Act
+ render(
+
,
+ )
+
+ // Assert - checkbox not rendered, so can't check indeterminate directly
+ expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument()
+ })
+
+ it('should return false when selectedOptions is undefined', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - checkbox should not be indeterminate
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should return false when totalOptions is undefined', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - checkbox should exist
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should return true when some options are selected (0 < selectedOptions < totalOptions)', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - checkbox should render in indeterminate state
+ // The checkbox component renders IndeterminateIcon when indeterminate and not checked
+ const selectAllContainer = container.querySelector('.flex.shrink-0.items-center')
+ expect(selectAllContainer).toBeInTheDocument()
+ })
+
+ it('should return false when no options are selected (selectedOptions === 0)', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - checkbox should be unchecked and not indeterminate
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should return false when all options are selected (selectedOptions === totalOptions)', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - checkbox should be checked, not indeterminate
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+ })
+
+ describe('checked calculation', () => {
+ it('should return false when showSelect is false', () => {
+ // Arrange & Act
+ render(
+
,
+ )
+
+ // Assert - checkbox not rendered
+ expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument()
+ })
+
+ it('should return false when selectedOptions is undefined', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should return false when totalOptions is undefined', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should return true when all options are selected (selectedOptions === totalOptions)', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - checkbox should show checked state (RiCheckLine icon)
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should return false when selectedOptions is 0', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - checkbox should be unchecked
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should return false when not all options are selected', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - checkbox should be indeterminate, not checked
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+ })
+ })
+
+ // ==========================================
+ // Component Memoization Testing
+ // ==========================================
+ describe('Component Memoization', () => {
+ // Tests for React.memo behavior
+ it('should be wrapped with React.memo', () => {
+ // Assert - verify component has memo wrapper
+ expect(Actions.$$typeof).toBe(Symbol.for('react.memo'))
+ })
+
+ it('should not re-render when props are the same', () => {
+ // Arrange
+ const handleNextStep = jest.fn()
+ const props = {
+ handleNextStep,
+ disabled: false,
+ showSelect: true,
+ totalOptions: 5,
+ selectedOptions: 3,
+ onSelectAll: jest.fn(),
+ tip: 'Test tip',
+ }
+
+ // Act
+ const { rerender } = render(
)
+
+ // Re-render with same props
+ rerender(
)
+
+ // Assert - component renders correctly after rerender
+ expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument()
+ expect(screen.getByText('Test tip')).toBeInTheDocument()
+ })
+
+ it('should re-render when props change', () => {
+ // Arrange
+ const handleNextStep = jest.fn()
+ const initialProps = {
+ handleNextStep,
+ disabled: false,
+ showSelect: true,
+ totalOptions: 5,
+ selectedOptions: 0,
+ onSelectAll: jest.fn(),
+ tip: 'Initial tip',
+ }
+
+ // Act
+ const { rerender } = render(
)
+ expect(screen.getByText('Initial tip')).toBeInTheDocument()
+
+ // Rerender with different props
+ rerender(
)
+
+ // Assert
+ expect(screen.getByText('Updated tip')).toBeInTheDocument()
+ expect(screen.queryByText('Initial tip')).not.toBeInTheDocument()
+ })
+ })
+
+ // ==========================================
+ // Edge Cases Testing
+ // ==========================================
+ describe('Edge Cases', () => {
+ // Tests for boundary conditions and unusual inputs
+ it('should handle totalOptions of 0', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - should render checkbox
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should handle very large totalOptions', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should handle very long tip text', () => {
+ // Arrange
+ const longTip = 'A'.repeat(500)
+
+ // Act
+ render(
+
,
+ )
+
+ // Assert - tip should render with truncate class
+ const tipElement = screen.getByTitle(longTip)
+ expect(tipElement).toHaveClass('truncate')
+ })
+
+ it('should handle tip with special characters', () => {
+ // Arrange
+ const specialTip = ' & "quotes" \'apostrophes\''
+
+ // Act
+ render(
+
,
+ )
+
+ // Assert - special characters should be rendered safely
+ expect(screen.getByText(specialTip)).toBeInTheDocument()
+ })
+
+ it('should handle tip with unicode characters', () => {
+ // Arrange
+ const unicodeTip = '选中 5 个文件,共 10MB 🚀'
+
+ // Act
+ render(
+
,
+ )
+
+ // Assert
+ expect(screen.getByText(unicodeTip)).toBeInTheDocument()
+ })
+
+ it('should handle selectedOptions greater than totalOptions', () => {
+ // This is an edge case that shouldn't happen but should be handled gracefully
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - should still render
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should handle negative selectedOptions', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - should still render (though this is an invalid state)
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ })
+
+ it('should handle onSelectAll being undefined when showSelect is true', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - should render checkbox
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+
+ // Click should not throw
+ if (checkbox)
+ expect(() => fireEvent.click(checkbox)).not.toThrow()
+ })
+
+ it('should handle empty datasetId from params', () => {
+ // This test verifies the link is constructed even with empty datasetId
+ // Arrange & Act
+ render(
)
+
+ // Assert - link should still be present with the mocked datasetId
+ const cancelLink = screen.getByRole('link')
+ expect(cancelLink).toHaveAttribute('href', '/datasets/test-dataset-id/documents')
+ })
+ })
+
+ // ==========================================
+ // All Prop Combinations Testing
+ // ==========================================
+ describe('Prop Combinations', () => {
+ // Tests for various combinations of props
+ it('should handle disabled=true with showSelect=false', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })
+ expect(nextButton).toBeDisabled()
+ expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument()
+ })
+
+ it('should handle disabled=true with showSelect=true', () => {
+ // Arrange & Act
+ render(
+
,
+ )
+
+ // Assert
+ const nextButton = screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })
+ expect(nextButton).toBeDisabled()
+ expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument()
+ })
+
+ it('should render complete component with all props provided', () => {
+ // Arrange
+ const allProps = {
+ disabled: false,
+ handleNextStep: jest.fn(),
+ showSelect: true,
+ totalOptions: 10,
+ selectedOptions: 5,
+ onSelectAll: jest.fn(),
+ tip: 'All props provided',
+ }
+
+ // Act
+ render(
)
+
+ // Assert
+ expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument()
+ expect(screen.getByText('All props provided')).toBeInTheDocument()
+ expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled()
+ })
+
+ it('should render minimal component with only required props', () => {
+ // Arrange & Act
+ render(
)
+
+ // Assert
+ expect(screen.queryByText('common.operation.selectAll')).not.toBeInTheDocument()
+ expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /datasetCreation.stepOne.button/i })).not.toBeDisabled()
+ })
+ })
+
+ // ==========================================
+ // Selection State Variations Testing
+ // ==========================================
+ describe('Selection State Variations', () => {
+ // Tests for different selection states
+ const selectionStates = [
+ { totalOptions: 10, selectedOptions: 0, expectedState: 'unchecked' },
+ { totalOptions: 10, selectedOptions: 5, expectedState: 'indeterminate' },
+ { totalOptions: 10, selectedOptions: 10, expectedState: 'checked' },
+ { totalOptions: 1, selectedOptions: 0, expectedState: 'unchecked' },
+ { totalOptions: 1, selectedOptions: 1, expectedState: 'checked' },
+ { totalOptions: 100, selectedOptions: 1, expectedState: 'indeterminate' },
+ { totalOptions: 100, selectedOptions: 99, expectedState: 'indeterminate' },
+ ]
+
+ it.each(selectionStates)(
+ 'should render with $expectedState state when totalOptions=$totalOptions and selectedOptions=$selectedOptions',
+ ({ totalOptions, selectedOptions }) => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - component should render without errors
+ const checkbox = container.querySelector('[class*="cursor-pointer"]')
+ expect(checkbox).toBeInTheDocument()
+ expect(screen.getByText('common.operation.selectAll')).toBeInTheDocument()
+ },
+ )
+ })
+
+ // ==========================================
+ // Layout Structure Testing
+ // ==========================================
+ describe('Layout', () => {
+ // Tests for correct layout structure
+ it('should have correct container structure', () => {
+ // Arrange & Act
+ const { container } = render(
)
+
+ // Assert
+ const mainContainer = container.querySelector('.flex.items-center.gap-x-2.overflow-hidden')
+ expect(mainContainer).toBeInTheDocument()
+ })
+
+ it('should have correct button container structure', () => {
+ // Arrange & Act
+ const { container } = render(
)
+
+ // Assert - buttons should be in a flex container
+ const buttonContainer = container.querySelector('.flex.grow.items-center.justify-end.gap-x-2')
+ expect(buttonContainer).toBeInTheDocument()
+ })
+
+ it('should position select all section before buttons when showSelect is true', () => {
+ // Arrange & Act
+ const { container } = render(
+
,
+ )
+
+ // Assert - select all section should exist
+ const selectAllSection = container.querySelector('.flex.shrink-0.items-center')
+ expect(selectAllSection).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/datasource-icon.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/datasource-icon.tsx
index a2fc0caca7..7aef143cc0 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/datasource-icon.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/datasource-icon.tsx
@@ -1,6 +1,6 @@
import type { FC } from 'react'
import { memo } from 'react'
-import cn from '@/utils/classnames'
+import { cn } from '@/utils/classnames'
type DatasourceIconProps = {
size?: string
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx
new file mode 100644
index 0000000000..4ae74be9d1
--- /dev/null
+++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx
@@ -0,0 +1,1662 @@
+import { act, fireEvent, render, screen } from '@testing-library/react'
+import { renderHook } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import React from 'react'
+import DataSourceOptions from './index'
+import OptionCard from './option-card'
+import DatasourceIcon from './datasource-icon'
+import { useDatasourceIcon } from './hooks'
+import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
+import { BlockEnum, type Node } from '@/app/components/workflow/types'
+import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types'
+
+// ==========================================
+// Mock External Dependencies
+// ==========================================
+
+// Mock useDatasourceOptions hook from parent hooks
+const mockUseDatasourceOptions = jest.fn()
+jest.mock('../hooks', () => ({
+ useDatasourceOptions: (nodes: Node
[]) => mockUseDatasourceOptions(nodes),
+}))
+
+// Mock useDataSourceList API hook
+const mockUseDataSourceList = jest.fn()
+jest.mock('@/service/use-pipeline', () => ({
+ useDataSourceList: (enabled: boolean) => mockUseDataSourceList(enabled),
+}))
+
+// Mock transformDataSourceToTool utility
+const mockTransformDataSourceToTool = jest.fn()
+jest.mock('@/app/components/workflow/block-selector/utils', () => ({
+ transformDataSourceToTool: (item: unknown) => mockTransformDataSourceToTool(item),
+}))
+
+// Mock basePath
+jest.mock('@/utils/var', () => ({
+ basePath: '/mock-base-path',
+}))
+
+// ==========================================
+// Test Data Builders
+// ==========================================
+
+const createMockDataSourceNodeData = (overrides?: Partial): DataSourceNodeType => ({
+ title: 'Test Data Source',
+ desc: 'Test description',
+ type: BlockEnum.DataSource,
+ plugin_id: 'test-plugin-id',
+ provider_type: 'local_file',
+ provider_name: 'Test Provider',
+ datasource_name: 'test-datasource',
+ datasource_label: 'Test Datasource Label',
+ datasource_parameters: {},
+ datasource_configurations: {},
+ ...overrides,
+})
+
+const createMockPipelineNode = (overrides?: Partial>): Node => {
+ const nodeData = createMockDataSourceNodeData(overrides?.data)
+ return {
+ id: `node-${Math.random().toString(36).slice(2, 9)}`,
+ type: 'custom',
+ position: { x: 0, y: 0 },
+ data: nodeData,
+ ...overrides,
+ }
+}
+
+const createMockPipelineNodes = (count = 3): Node[] => {
+ return Array.from({ length: count }, (_, i) =>
+ createMockPipelineNode({
+ id: `node-${i + 1}`,
+ data: createMockDataSourceNodeData({
+ title: `Data Source ${i + 1}`,
+ plugin_id: `plugin-${i + 1}`,
+ datasource_name: `datasource-${i + 1}`,
+ }),
+ }),
+ )
+}
+
+const createMockDatasourceOption = (
+ node: Node,
+) => ({
+ label: node.data.title,
+ value: node.id,
+ data: node.data,
+})
+
+const createMockDataSourceListItem = (overrides?: Record) => ({
+ declaration: {
+ identity: {
+ icon: '/icons/test-icon.png',
+ name: 'test-datasource',
+ label: { en_US: 'Test Datasource' },
+ },
+ provider: 'test-provider',
+ },
+ plugin_id: 'test-plugin-id',
+ ...overrides,
+})
+
+// ==========================================
+// Test Utilities
+// ==========================================
+
+const createQueryClient = () => new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+})
+
+const renderWithProviders = (
+ ui: React.ReactElement,
+ queryClient?: QueryClient,
+) => {
+ const client = queryClient || createQueryClient()
+ return render(
+