diff --git a/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx b/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx
new file mode 100644
index 0000000000..28085e52fa
--- /dev/null
+++ b/web/app/components/datasets/settings/form/components/basic-info-section.spec.tsx
@@ -0,0 +1,441 @@
+import type { Member } from '@/models/common'
+import type { DataSet, IconInfo } from '@/models/datasets'
+import type { RetrievalConfig } from '@/types/app'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import { RETRIEVE_METHOD } from '@/types/app'
+import { IndexingType } from '../../../create/step-two'
+import BasicInfoSection from './basic-info-section'
+
+// Mock app-context
+vi.mock('@/context/app-context', () => ({
+ useSelector: () => ({
+ id: 'user-1',
+ name: 'Current User',
+ email: 'current@example.com',
+ avatar_url: '',
+ role: 'owner',
+ }),
+}))
+
+// Mock image uploader hooks for AppIconPicker
+vi.mock('@/app/components/base/image-uploader/hooks', () => ({
+ useLocalFileUploader: () => ({
+ disabled: false,
+ handleLocalFileUpload: vi.fn(),
+ }),
+ useImageFiles: () => ({
+ files: [],
+ onUpload: vi.fn(),
+ onRemove: vi.fn(),
+ onReUpload: vi.fn(),
+ onImageLinkLoadError: vi.fn(),
+ onImageLinkLoadSuccess: vi.fn(),
+ onClear: vi.fn(),
+ }),
+}))
+
+describe('BasicInfoSection', () => {
+ const mockDataset: DataSet = {
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ permission: DatasetPermission.onlyMe,
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFFFFF',
+ icon_url: '',
+ },
+ indexing_technique: IndexingType.QUALIFIED,
+ indexing_status: 'completed',
+ data_source_type: DataSourceType.FILE,
+ doc_form: ChunkingMode.text,
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ app_count: 0,
+ document_count: 5,
+ total_document_count: 5,
+ word_count: 1000,
+ provider: 'vendor',
+ tags: [],
+ partial_member_list: [],
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-1',
+ external_knowledge_api_id: 'api-1',
+ external_knowledge_api_name: 'External API',
+ external_knowledge_api_endpoint: 'https://api.example.com',
+ },
+ external_retrieval_model: {
+ top_k: 3,
+ score_threshold: 0.7,
+ score_threshold_enabled: true,
+ },
+ retrieval_model_dict: {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ } as RetrievalConfig,
+ retrieval_model: {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ } as RetrievalConfig,
+ built_in_field_enabled: false,
+ keyword_number: 10,
+ created_by: 'user-1',
+ updated_by: 'user-1',
+ updated_at: Date.now(),
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ }
+
+ const mockMemberList: Member[] = [
+ { id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
+ { id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
+ ]
+
+ const mockIconInfo: IconInfo = {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFFFFF',
+ icon_url: '',
+ }
+
+ const defaultProps = {
+ currentDataset: mockDataset,
+ isCurrentWorkspaceDatasetOperator: false,
+ name: 'Test Dataset',
+ setName: vi.fn(),
+ description: 'Test description',
+ setDescription: vi.fn(),
+ iconInfo: mockIconInfo,
+ showAppIconPicker: false,
+ handleOpenAppIconPicker: vi.fn(),
+ handleSelectAppIcon: vi.fn(),
+ handleCloseAppIconPicker: vi.fn(),
+ permission: DatasetPermission.onlyMe,
+ setPermission: vi.fn(),
+ selectedMemberIDs: ['user-1'],
+ setSelectedMemberIDs: vi.fn(),
+ memberList: mockMemberList,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument()
+ })
+
+ it('should render name and icon section', () => {
+ render()
+ expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument()
+ })
+
+ it('should render description section', () => {
+ render()
+ expect(screen.getByText(/form\.desc/i)).toBeInTheDocument()
+ })
+
+ it('should render permissions section', () => {
+ render()
+ // Use exact match to avoid matching "permissionsOnlyMe"
+ expect(screen.getByText('datasetSettings.form.permissions')).toBeInTheDocument()
+ })
+
+ it('should render name input with correct value', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ expect(nameInput).toBeInTheDocument()
+ })
+
+ it('should render description textarea with correct value', () => {
+ render()
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ expect(descriptionTextarea).toBeInTheDocument()
+ })
+
+ it('should render app icon with emoji', () => {
+ const { container } = render()
+ // The icon section should be rendered (emoji may be in a span or SVG)
+ const iconSection = container.querySelector('[class*="cursor-pointer"]')
+ expect(iconSection).toBeInTheDocument()
+ })
+ })
+
+ describe('Name Input', () => {
+ it('should call setName when name input changes', () => {
+ const setName = vi.fn()
+ render()
+
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ fireEvent.change(nameInput, { target: { value: 'New Name' } })
+
+ expect(setName).toHaveBeenCalledWith('New Name')
+ })
+
+ it('should disable name input when embedding is not available', () => {
+ const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
+ render()
+
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ expect(nameInput).toBeDisabled()
+ })
+
+ it('should enable name input when embedding is available', () => {
+ render()
+
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ expect(nameInput).not.toBeDisabled()
+ })
+
+ it('should display empty name', () => {
+ const { container } = render()
+
+ // Find the name input by its structure - may be type=text or just input
+ const nameInput = container.querySelector('input')
+ expect(nameInput).toHaveValue('')
+ })
+ })
+
+ describe('Description Textarea', () => {
+ it('should call setDescription when description changes', () => {
+ const setDescription = vi.fn()
+ render()
+
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ fireEvent.change(descriptionTextarea, { target: { value: 'New Description' } })
+
+ expect(setDescription).toHaveBeenCalledWith('New Description')
+ })
+
+ it('should disable description textarea when embedding is not available', () => {
+ const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
+ render()
+
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ expect(descriptionTextarea).toBeDisabled()
+ })
+
+ it('should render placeholder', () => {
+ render()
+
+ const descriptionTextarea = screen.getByPlaceholderText(/form\.descPlaceholder/i)
+ expect(descriptionTextarea).toBeInTheDocument()
+ })
+ })
+
+ describe('App Icon', () => {
+ it('should call handleOpenAppIconPicker when icon is clicked', () => {
+ const handleOpenAppIconPicker = vi.fn()
+ const { container } = render()
+
+ // Find the clickable icon element - it's inside a wrapper that handles the click
+ const iconWrapper = container.querySelector('[class*="cursor-pointer"]')
+ if (iconWrapper) {
+ fireEvent.click(iconWrapper)
+ expect(handleOpenAppIconPicker).toHaveBeenCalled()
+ }
+ })
+
+ it('should render AppIconPicker when showAppIconPicker is true', () => {
+ const { baseElement } = render()
+
+ // AppIconPicker renders a modal with emoji tabs and options via portal
+ // We just verify the component renders without crashing when picker is shown
+ expect(baseElement).toBeInTheDocument()
+ })
+
+ it('should not render AppIconPicker when showAppIconPicker is false', () => {
+ const { container } = render()
+
+ // Check that AppIconPicker is not rendered
+ expect(container.querySelector('[data-testid="app-icon-picker"]')).not.toBeInTheDocument()
+ })
+
+ it('should render image icon when icon_type is image', () => {
+ const imageIconInfo: IconInfo = {
+ icon_type: 'image',
+ icon: 'file-123',
+ icon_background: undefined,
+ icon_url: 'https://example.com/icon.png',
+ }
+ render()
+
+ // For image type, it renders an img element
+ const img = screen.queryByRole('img')
+ if (img) {
+ expect(img).toHaveAttribute('src', expect.stringContaining('icon.png'))
+ }
+ })
+ })
+
+ describe('Permission Selector', () => {
+ it('should render with correct permission value', () => {
+ render()
+
+ expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument()
+ })
+
+ it('should render all team members permission', () => {
+ render()
+
+ expect(screen.getByText(/form\.permissionsAllMember/i)).toBeInTheDocument()
+ })
+
+ it('should be disabled when embedding is not available', () => {
+ const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
+ const { container } = render(
+ ,
+ )
+
+ // Check for disabled state via cursor-not-allowed class
+ const disabledElement = container.querySelector('[class*="cursor-not-allowed"]')
+ expect(disabledElement).toBeInTheDocument()
+ })
+
+ it('should be disabled when user is dataset operator', () => {
+ const { container } = render(
+ ,
+ )
+
+ const disabledElement = container.querySelector('[class*="cursor-not-allowed"]')
+ expect(disabledElement).toBeInTheDocument()
+ })
+
+ it('should call setPermission when permission changes', async () => {
+ const setPermission = vi.fn()
+ render()
+
+ // Open dropdown
+ const trigger = screen.getByText(/form\.permissionsOnlyMe/i)
+ fireEvent.click(trigger)
+
+ await waitFor(() => {
+ // Click All Team Members option
+ const allMemberOptions = screen.getAllByText(/form\.permissionsAllMember/i)
+ fireEvent.click(allMemberOptions[0])
+ })
+
+ expect(setPermission).toHaveBeenCalledWith(DatasetPermission.allTeamMembers)
+ })
+
+ it('should call setSelectedMemberIDs when members are selected', async () => {
+ const setSelectedMemberIDs = vi.fn()
+ const { container } = render(
+ ,
+ )
+
+ // For partial members permission, the member selector should be visible
+ // The exact interaction depends on the MemberSelector component
+ // We verify the component renders without crashing
+ expect(container).toBeInTheDocument()
+ })
+ })
+
+ describe('Undefined Dataset', () => {
+ it('should handle undefined currentDataset gracefully', () => {
+ render()
+
+ // Should still render but inputs might behave differently
+ expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Props Validation', () => {
+ it('should update when name prop changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByDisplayValue('Initial Name')).toBeInTheDocument()
+
+ rerender()
+
+ expect(screen.getByDisplayValue('Updated Name')).toBeInTheDocument()
+ })
+
+ it('should update when description prop changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByDisplayValue('Initial Description')).toBeInTheDocument()
+
+ rerender()
+
+ expect(screen.getByDisplayValue('Updated Description')).toBeInTheDocument()
+ })
+
+ it('should update when permission prop changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument()
+
+ rerender()
+
+ expect(screen.getByText(/form\.permissionsAllMember/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Member List', () => {
+ it('should pass member list to PermissionSelector', () => {
+ const { container } = render(
+ ,
+ )
+
+ // For partial members, a member selector component should be rendered
+ // We verify it renders without crashing
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should handle empty member list', () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have accessible name input', () => {
+ render()
+
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ expect(nameInput.tagName.toLowerCase()).toBe('input')
+ })
+
+ it('should have accessible description textarea', () => {
+ render()
+
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ expect(descriptionTextarea.tagName.toLowerCase()).toBe('textarea')
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/form/components/basic-info-section.tsx b/web/app/components/datasets/settings/form/components/basic-info-section.tsx
new file mode 100644
index 0000000000..3d3cf75851
--- /dev/null
+++ b/web/app/components/datasets/settings/form/components/basic-info-section.tsx
@@ -0,0 +1,124 @@
+'use client'
+import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
+import type { Member } from '@/models/common'
+import type { DataSet, DatasetPermission, IconInfo } from '@/models/datasets'
+import type { AppIconType } from '@/types/app'
+import { useTranslation } from 'react-i18next'
+import AppIcon from '@/app/components/base/app-icon'
+import AppIconPicker from '@/app/components/base/app-icon-picker'
+import Input from '@/app/components/base/input'
+import Textarea from '@/app/components/base/textarea'
+import PermissionSelector from '../../permission-selector'
+
+const rowClass = 'flex gap-x-1'
+const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
+
+type BasicInfoSectionProps = {
+ currentDataset: DataSet | undefined
+ isCurrentWorkspaceDatasetOperator: boolean
+ name: string
+ setName: (value: string) => void
+ description: string
+ setDescription: (value: string) => void
+ iconInfo: IconInfo
+ showAppIconPicker: boolean
+ handleOpenAppIconPicker: () => void
+ handleSelectAppIcon: (icon: AppIconSelection) => void
+ handleCloseAppIconPicker: () => void
+ permission: DatasetPermission | undefined
+ setPermission: (value: DatasetPermission | undefined) => void
+ selectedMemberIDs: string[]
+ setSelectedMemberIDs: (value: string[]) => void
+ memberList: Member[]
+}
+
+const BasicInfoSection = ({
+ currentDataset,
+ isCurrentWorkspaceDatasetOperator,
+ name,
+ setName,
+ description,
+ setDescription,
+ iconInfo,
+ showAppIconPicker,
+ handleOpenAppIconPicker,
+ handleSelectAppIcon,
+ handleCloseAppIconPicker,
+ permission,
+ setPermission,
+ selectedMemberIDs,
+ setSelectedMemberIDs,
+ memberList,
+}: BasicInfoSectionProps) => {
+ const { t } = useTranslation()
+
+ return (
+ <>
+ {/* Dataset name and icon */}
+
+
+
{t('form.nameAndIcon', { ns: 'datasetSettings' })}
+
+
+
+
setName(e.target.value)}
+ />
+
+
+
+ {/* Dataset description */}
+
+
+
{t('form.desc', { ns: 'datasetSettings' })}
+
+
+
+
+
+ {/* Permissions */}
+
+
+
{t('form.permissions', { ns: 'datasetSettings' })}
+
+
+
setPermission(v)}
+ onMemberSelect={setSelectedMemberIDs}
+ memberList={memberList}
+ />
+
+
+
+ {showAppIconPicker && (
+
+ )}
+ >
+ )
+}
+
+export default BasicInfoSection
diff --git a/web/app/components/datasets/settings/form/components/external-knowledge-section.spec.tsx b/web/app/components/datasets/settings/form/components/external-knowledge-section.spec.tsx
new file mode 100644
index 0000000000..96512b5aca
--- /dev/null
+++ b/web/app/components/datasets/settings/form/components/external-knowledge-section.spec.tsx
@@ -0,0 +1,362 @@
+import type { DataSet } from '@/models/datasets'
+import type { RetrievalConfig } from '@/types/app'
+import { render, screen } from '@testing-library/react'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import { RETRIEVE_METHOD } from '@/types/app'
+import { IndexingType } from '../../../create/step-two'
+import ExternalKnowledgeSection from './external-knowledge-section'
+
+describe('ExternalKnowledgeSection', () => {
+ const mockRetrievalConfig: RetrievalConfig = {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ }
+
+ const mockDataset: DataSet = {
+ id: 'dataset-1',
+ name: 'External Dataset',
+ description: 'External dataset description',
+ permission: DatasetPermission.onlyMe,
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFFFFF',
+ icon_url: '',
+ },
+ indexing_technique: IndexingType.QUALIFIED,
+ indexing_status: 'completed',
+ data_source_type: DataSourceType.FILE,
+ doc_form: ChunkingMode.text,
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ app_count: 0,
+ document_count: 5,
+ total_document_count: 5,
+ word_count: 1000,
+ provider: 'external',
+ tags: [],
+ partial_member_list: [],
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-knowledge-123',
+ external_knowledge_api_id: 'api-456',
+ external_knowledge_api_name: 'My External API',
+ external_knowledge_api_endpoint: 'https://api.external.example.com/v1',
+ },
+ external_retrieval_model: {
+ top_k: 5,
+ score_threshold: 0.8,
+ score_threshold_enabled: true,
+ },
+ retrieval_model_dict: mockRetrievalConfig,
+ retrieval_model: mockRetrievalConfig,
+ built_in_field_enabled: false,
+ keyword_number: 10,
+ created_by: 'user-1',
+ updated_by: 'user-1',
+ updated_at: Date.now(),
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ }
+
+ const defaultProps = {
+ currentDataset: mockDataset,
+ topK: 5,
+ scoreThreshold: 0.8,
+ scoreThresholdEnabled: true,
+ handleSettingsChange: vi.fn(),
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
+ })
+
+ it('should render retrieval settings section', () => {
+ render()
+ expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
+ })
+
+ it('should render external knowledge API section', () => {
+ render()
+ expect(screen.getByText(/form\.externalKnowledgeAPI/i)).toBeInTheDocument()
+ })
+
+ it('should render external knowledge ID section', () => {
+ render()
+ expect(screen.getByText(/form\.externalKnowledgeID/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('External Knowledge API Info', () => {
+ it('should display external API name', () => {
+ render()
+ expect(screen.getByText('My External API')).toBeInTheDocument()
+ })
+
+ it('should display external API endpoint', () => {
+ render()
+ expect(screen.getByText('https://api.external.example.com/v1')).toBeInTheDocument()
+ })
+
+ it('should render API connection icon', () => {
+ const { container } = render()
+ // The ApiConnectionMod icon should be rendered
+ const icon = container.querySelector('svg')
+ expect(icon).toBeInTheDocument()
+ })
+
+ it('should display API name and endpoint in the same row', () => {
+ render()
+
+ const apiName = screen.getByText('My External API')
+ const apiEndpoint = screen.getByText('https://api.external.example.com/v1')
+
+ // Both should be in the same container
+ expect(apiName.parentElement?.parentElement).toBe(apiEndpoint.parentElement?.parentElement)
+ })
+ })
+
+ describe('External Knowledge ID', () => {
+ it('should display external knowledge ID value', () => {
+ render()
+ expect(screen.getByText('ext-knowledge-123')).toBeInTheDocument()
+ })
+
+ it('should render ID in a read-only display', () => {
+ render()
+
+ const idElement = screen.getByText('ext-knowledge-123')
+ // The ID should be in a div with input-like styling, not an actual input
+ expect(idElement.tagName.toLowerCase()).toBe('div')
+ })
+ })
+
+ describe('Retrieval Settings', () => {
+ it('should pass topK to RetrievalSettings', () => {
+ render()
+
+ // RetrievalSettings should receive topK prop
+ // The exact rendering depends on RetrievalSettings component
+ })
+
+ it('should pass scoreThreshold to RetrievalSettings', () => {
+ render()
+
+ // RetrievalSettings should receive scoreThreshold prop
+ })
+
+ it('should pass scoreThresholdEnabled to RetrievalSettings', () => {
+ render()
+
+ // RetrievalSettings should receive scoreThresholdEnabled prop
+ })
+
+ it('should call handleSettingsChange when settings change', () => {
+ const handleSettingsChange = vi.fn()
+ render()
+
+ // The handler should be properly passed to RetrievalSettings
+ // Actual interaction depends on RetrievalSettings implementation
+ })
+ })
+
+ describe('Dividers', () => {
+ it('should render dividers between sections', () => {
+ const { container } = render()
+
+ const dividers = container.querySelectorAll('.bg-divider-subtle')
+ expect(dividers.length).toBeGreaterThanOrEqual(2)
+ })
+ })
+
+ describe('Props Updates', () => {
+ it('should update when currentDataset changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByText('My External API')).toBeInTheDocument()
+
+ const updatedDataset = {
+ ...mockDataset,
+ external_knowledge_info: {
+ ...mockDataset.external_knowledge_info,
+ external_knowledge_api_name: 'Updated API Name',
+ },
+ }
+
+ rerender()
+
+ expect(screen.getByText('Updated API Name')).toBeInTheDocument()
+ })
+
+ it('should update when external knowledge ID changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByText('ext-knowledge-123')).toBeInTheDocument()
+
+ const updatedDataset = {
+ ...mockDataset,
+ external_knowledge_info: {
+ ...mockDataset.external_knowledge_info,
+ external_knowledge_id: 'new-ext-id-789',
+ },
+ }
+
+ rerender()
+
+ expect(screen.getByText('new-ext-id-789')).toBeInTheDocument()
+ })
+
+ it('should update when API endpoint changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByText('https://api.external.example.com/v1')).toBeInTheDocument()
+
+ const updatedDataset = {
+ ...mockDataset,
+ external_knowledge_info: {
+ ...mockDataset.external_knowledge_info,
+ external_knowledge_api_endpoint: 'https://new-api.example.com/v2',
+ },
+ }
+
+ rerender()
+
+ expect(screen.getByText('https://new-api.example.com/v2')).toBeInTheDocument()
+ })
+ })
+
+ describe('Layout', () => {
+ it('should have consistent row layout', () => {
+ const { container } = render()
+
+ // Check for flex gap-x-1 class on rows
+ const rows = container.querySelectorAll('.flex.gap-x-1')
+ expect(rows.length).toBeGreaterThan(0)
+ })
+
+ it('should have consistent label width', () => {
+ const { container } = render()
+
+ // Check for w-[180px] label containers
+ const labels = container.querySelectorAll('.w-\\[180px\\]')
+ expect(labels.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Styling', () => {
+ it('should apply correct background to info displays', () => {
+ const { container } = render()
+
+ // Info displays should have bg-components-input-bg-normal
+ const infoDisplays = container.querySelectorAll('.bg-components-input-bg-normal')
+ expect(infoDisplays.length).toBeGreaterThan(0)
+ })
+
+ it('should apply rounded corners to info displays', () => {
+ const { container } = render()
+
+ const roundedElements = container.querySelectorAll('.rounded-lg')
+ expect(roundedElements.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Different External Knowledge Info', () => {
+ it('should handle long API names', () => {
+ const longNameDataset = {
+ ...mockDataset,
+ external_knowledge_info: {
+ ...mockDataset.external_knowledge_info,
+ external_knowledge_api_name: 'This is a very long external knowledge API name that should be truncated',
+ },
+ }
+
+ render()
+
+ expect(screen.getByText(/This is a very long external knowledge API name/)).toBeInTheDocument()
+ })
+
+ it('should handle long API endpoints', () => {
+ const longEndpointDataset = {
+ ...mockDataset,
+ external_knowledge_info: {
+ ...mockDataset.external_knowledge_info,
+ external_knowledge_api_endpoint: 'https://api.very-long-domain-name.example.com/api/v1/external/knowledge',
+ },
+ }
+
+ render()
+
+ expect(screen.getByText(/https:\/\/api.very-long-domain-name.example.com/)).toBeInTheDocument()
+ })
+
+ it('should handle special characters in API name', () => {
+ const specialCharDataset = {
+ ...mockDataset,
+ external_knowledge_info: {
+ ...mockDataset.external_knowledge_info,
+ external_knowledge_api_name: 'API & Service ',
+ },
+ }
+
+ render()
+
+ expect(screen.getByText('API & Service ')).toBeInTheDocument()
+ })
+ })
+
+ describe('RetrievalSettings Integration', () => {
+ it('should pass isInRetrievalSetting=true to RetrievalSettings', () => {
+ render()
+
+ // The RetrievalSettings component should be rendered with isInRetrievalSetting=true
+ // This affects the component's layout/styling
+ })
+
+ it('should handle settings change for top_k', () => {
+ const handleSettingsChange = vi.fn()
+ render()
+
+ // Find and interact with the top_k control in RetrievalSettings
+ // The exact interaction depends on RetrievalSettings implementation
+ })
+
+ it('should handle settings change for score_threshold', () => {
+ const handleSettingsChange = vi.fn()
+ render()
+
+ // Find and interact with the score_threshold control in RetrievalSettings
+ })
+
+ it('should handle settings change for score_threshold_enabled', () => {
+ const handleSettingsChange = vi.fn()
+ render()
+
+ // Find and interact with the score_threshold_enabled toggle in RetrievalSettings
+ })
+ })
+
+ describe('Accessibility', () => {
+ it('should have semantic structure', () => {
+ render()
+
+ // Section labels should be present
+ expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
+ expect(screen.getByText(/form\.externalKnowledgeAPI/i)).toBeInTheDocument()
+ expect(screen.getByText(/form\.externalKnowledgeID/i)).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx b/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx
new file mode 100644
index 0000000000..4b08bb1e7a
--- /dev/null
+++ b/web/app/components/datasets/settings/form/components/external-knowledge-section.tsx
@@ -0,0 +1,84 @@
+'use client'
+import type { DataSet } from '@/models/datasets'
+import { useTranslation } from 'react-i18next'
+import Divider from '@/app/components/base/divider'
+import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
+import RetrievalSettings from '../../../external-knowledge-base/create/RetrievalSettings'
+
+const rowClass = 'flex gap-x-1'
+const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
+
+type ExternalKnowledgeSectionProps = {
+ currentDataset: DataSet
+ topK: number
+ scoreThreshold: number
+ scoreThresholdEnabled: boolean
+ handleSettingsChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void
+}
+
+const ExternalKnowledgeSection = ({
+ currentDataset,
+ topK,
+ scoreThreshold,
+ scoreThresholdEnabled,
+ handleSettingsChange,
+}: ExternalKnowledgeSectionProps) => {
+ const { t } = useTranslation()
+
+ return (
+ <>
+
+
+ {/* Retrieval Settings */}
+
+
+
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
+
+
+
+
+
+
+ {/* External Knowledge API */}
+
+
+
{t('form.externalKnowledgeAPI', { ns: 'datasetSettings' })}
+
+
+
+
+
+ {currentDataset.external_knowledge_info.external_knowledge_api_name}
+
+
ยท
+
+ {currentDataset.external_knowledge_info.external_knowledge_api_endpoint}
+
+
+
+
+
+ {/* External Knowledge ID */}
+
+
+
{t('form.externalKnowledgeID', { ns: 'datasetSettings' })}
+
+
+
+
+ {currentDataset.external_knowledge_info.external_knowledge_id}
+
+
+
+
+ >
+ )
+}
+
+export default ExternalKnowledgeSection
diff --git a/web/app/components/datasets/settings/form/components/indexing-section.spec.tsx b/web/app/components/datasets/settings/form/components/indexing-section.spec.tsx
new file mode 100644
index 0000000000..bf1448b933
--- /dev/null
+++ b/web/app/components/datasets/settings/form/components/indexing-section.spec.tsx
@@ -0,0 +1,501 @@
+import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import type { DataSet, SummaryIndexSetting } from '@/models/datasets'
+import type { RetrievalConfig } from '@/types/app'
+import { fireEvent, render, screen } from '@testing-library/react'
+import { ConfigurationMethodEnum, ModelStatusEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import { RETRIEVE_METHOD } from '@/types/app'
+import { IndexingType } from '../../../create/step-two'
+import IndexingSection from './indexing-section'
+
+// Mock i18n doc link
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
+}))
+
+// Mock app-context for child components
+vi.mock('@/context/app-context', () => ({
+ useSelector: (selector: (state: unknown) => unknown) => {
+ const state = {
+ isCurrentWorkspaceDatasetOperator: false,
+ userProfile: {
+ id: 'user-1',
+ name: 'Current User',
+ email: 'current@example.com',
+ avatar_url: '',
+ role: 'owner',
+ },
+ }
+ return selector(state)
+ },
+}))
+
+// Mock model-provider-page hooks
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelList: () => ({ data: [], mutate: vi.fn(), isLoading: false }),
+ useCurrentProviderAndModel: () => ({ currentProvider: undefined, currentModel: undefined }),
+ useDefaultModel: () => ({ data: undefined, mutate: vi.fn(), isLoading: false }),
+ useModelListAndDefaultModel: () => ({ modelList: [], defaultModel: undefined }),
+ useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
+ modelList: [],
+ defaultModel: undefined,
+ currentProvider: undefined,
+ currentModel: undefined,
+ }),
+ useUpdateModelList: () => vi.fn(),
+ useUpdateModelProviders: () => vi.fn(),
+ useLanguage: () => 'en_US',
+ useSystemDefaultModelAndModelList: () => [undefined, vi.fn()],
+ useProviderCredentialsAndLoadBalancing: () => ({
+ credentials: undefined,
+ loadBalancing: undefined,
+ mutate: vi.fn(),
+ isLoading: false,
+ }),
+ useAnthropicBuyQuota: () => vi.fn(),
+ useMarketplaceAllPlugins: () => ({ plugins: [], isLoading: false }),
+ useRefreshModel: () => ({ handleRefreshModel: vi.fn() }),
+ useModelModalHandler: () => vi.fn(),
+}))
+
+// Mock provider-context
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ textGenerationModelList: [],
+ embeddingsModelList: [],
+ rerankModelList: [],
+ agentThoughtModelList: [],
+ modelProviders: [],
+ textEmbeddingModelList: [],
+ speech2textModelList: [],
+ ttsModelList: [],
+ moderationModelList: [],
+ hasSettedApiKey: true,
+ plan: { type: 'free' },
+ enableBilling: false,
+ onPlanInfoChanged: vi.fn(),
+ isCurrentWorkspaceDatasetOperator: false,
+ supportRetrievalMethods: ['semantic_search', 'full_text_search', 'hybrid_search'],
+ }),
+}))
+
+describe('IndexingSection', () => {
+ const mockRetrievalConfig: RetrievalConfig = {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ }
+
+ const mockDataset: DataSet = {
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ permission: DatasetPermission.onlyMe,
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFFFFF',
+ icon_url: '',
+ },
+ indexing_technique: IndexingType.QUALIFIED,
+ indexing_status: 'completed',
+ data_source_type: DataSourceType.FILE,
+ doc_form: ChunkingMode.text,
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ app_count: 0,
+ document_count: 5,
+ total_document_count: 5,
+ word_count: 1000,
+ provider: 'vendor',
+ tags: [],
+ partial_member_list: [],
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-1',
+ external_knowledge_api_id: 'api-1',
+ external_knowledge_api_name: 'External API',
+ external_knowledge_api_endpoint: 'https://api.example.com',
+ },
+ external_retrieval_model: {
+ top_k: 3,
+ score_threshold: 0.7,
+ score_threshold_enabled: true,
+ },
+ retrieval_model_dict: mockRetrievalConfig,
+ retrieval_model: mockRetrievalConfig,
+ built_in_field_enabled: false,
+ keyword_number: 10,
+ created_by: 'user-1',
+ updated_by: 'user-1',
+ updated_at: Date.now(),
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ }
+
+ const mockEmbeddingModel: DefaultModel = {
+ provider: 'openai',
+ model: 'text-embedding-ada-002',
+ }
+
+ const mockEmbeddingModelList: Model[] = [
+ {
+ provider: 'openai',
+ label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
+ icon_small: { en_US: '', zh_Hans: '' },
+ status: ModelStatusEnum.active,
+ models: [
+ {
+ model: 'text-embedding-ada-002',
+ label: { en_US: 'text-embedding-ada-002', zh_Hans: 'text-embedding-ada-002' },
+ model_type: ModelTypeEnum.textEmbedding,
+ features: [],
+ fetch_from: ConfigurationMethodEnum.predefinedModel,
+ model_properties: {},
+ deprecated: false,
+ status: ModelStatusEnum.active,
+ load_balancing_enabled: false,
+ },
+ ],
+ },
+ ]
+
+ const mockSummaryIndexSetting: SummaryIndexSetting = {
+ enable: false,
+ }
+
+ const defaultProps = {
+ currentDataset: mockDataset,
+ indexMethod: IndexingType.QUALIFIED,
+ setIndexMethod: vi.fn(),
+ keywordNumber: 10,
+ setKeywordNumber: vi.fn(),
+ embeddingModel: mockEmbeddingModel,
+ setEmbeddingModel: vi.fn(),
+ embeddingModelList: mockEmbeddingModelList,
+ retrievalConfig: mockRetrievalConfig,
+ setRetrievalConfig: vi.fn(),
+ summaryIndexSetting: mockSummaryIndexSetting,
+ handleSummaryIndexSettingChange: vi.fn(),
+ showMultiModalTip: false,
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
+ })
+
+ it('should render chunk structure section when doc_form is set', () => {
+ render()
+ expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
+ })
+
+ it('should render index method section when conditions are met', () => {
+ render()
+ // May match multiple elements (label and descriptions)
+ expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
+ })
+
+ it('should render embedding model section when indexMethod is high_quality', () => {
+ render()
+ expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
+ })
+
+ it('should render retrieval settings section', () => {
+ render()
+ expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Chunk Structure Section', () => {
+ it('should not render chunk structure when doc_form is not set', () => {
+ const datasetWithoutDocForm = { ...mockDataset, doc_form: undefined as unknown as ChunkingMode }
+ render()
+
+ expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
+ })
+
+ it('should render learn more link for chunk structure', () => {
+ render()
+
+ const learnMoreLink = screen.getByText(/form\.chunkStructure\.learnMore/i)
+ expect(learnMoreLink).toBeInTheDocument()
+ expect(learnMoreLink).toHaveAttribute('href', expect.stringContaining('chunking-and-cleaning-text'))
+ })
+
+ it('should render chunk structure description', () => {
+ render()
+
+ expect(screen.getByText(/form\.chunkStructure\.description/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Index Method Section', () => {
+ it('should not render index method for parentChild chunking mode', () => {
+ const parentChildDataset = { ...mockDataset, doc_form: ChunkingMode.parentChild }
+ render()
+
+ expect(screen.queryByText(/form\.indexMethod/i)).not.toBeInTheDocument()
+ })
+
+ it('should render high quality option', () => {
+ render()
+
+ expect(screen.getByText(/stepTwo\.qualified/i)).toBeInTheDocument()
+ })
+
+ it('should render economy option', () => {
+ render()
+
+ // May match multiple elements (title and tip)
+ expect(screen.getAllByText(/form\.indexMethodEconomy/i).length).toBeGreaterThan(0)
+ })
+
+ it('should call setIndexMethod when index method changes', () => {
+ const setIndexMethod = vi.fn()
+ const { container } = render()
+
+ // Find the economy option card by looking for clickable elements containing the economy text
+ const economyOptions = screen.getAllByText(/form\.indexMethodEconomy/i)
+ if (economyOptions.length > 0) {
+ const economyCard = economyOptions[0].closest('[class*="cursor-pointer"]')
+ if (economyCard) {
+ fireEvent.click(economyCard)
+ }
+ }
+
+ // The handler should be properly passed - verify component renders without crashing
+ expect(container).toBeInTheDocument()
+ })
+
+ it('should show upgrade warning when switching from economy to high quality', () => {
+ const economyDataset = { ...mockDataset, indexing_technique: IndexingType.ECONOMICAL }
+ render(
+ ,
+ )
+
+ expect(screen.getByText(/form\.upgradeHighQualityTip/i)).toBeInTheDocument()
+ })
+
+ it('should not show upgrade warning when already on high quality', () => {
+ render(
+ ,
+ )
+
+ expect(screen.queryByText(/form\.upgradeHighQualityTip/i)).not.toBeInTheDocument()
+ })
+
+ it('should disable index method when embedding is not available', () => {
+ const datasetWithoutEmbedding = { ...mockDataset, embedding_available: false }
+ render()
+
+ // Index method options should be disabled
+ // The exact implementation depends on the IndexMethod component
+ })
+ })
+
+ describe('Embedding Model Section', () => {
+ it('should render embedding model when indexMethod is high_quality', () => {
+ render()
+
+ expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
+ })
+
+ it('should not render embedding model when indexMethod is economy', () => {
+ render()
+
+ expect(screen.queryByText(/form\.embeddingModel/i)).not.toBeInTheDocument()
+ })
+
+ it('should call setEmbeddingModel when model changes', () => {
+ const setEmbeddingModel = vi.fn()
+ render(
+ ,
+ )
+
+ // The embedding model selector should be rendered
+ expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Summary Index Setting Section', () => {
+ it('should render summary index setting for high quality with text chunking', () => {
+ render(
+ ,
+ )
+
+ // Summary index setting should be rendered based on conditions
+ // The exact rendering depends on the SummaryIndexSetting component
+ })
+
+ it('should not render summary index setting for economy indexing', () => {
+ render(
+ ,
+ )
+
+ // Summary index setting should not be rendered for economy
+ })
+
+ it('should call handleSummaryIndexSettingChange when setting changes', () => {
+ const handleSummaryIndexSettingChange = vi.fn()
+ render(
+ ,
+ )
+
+ // The handler should be properly passed
+ })
+ })
+
+ describe('Retrieval Settings Section', () => {
+ it('should render retrieval settings', () => {
+ render()
+
+ expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
+ })
+
+ it('should render learn more link for retrieval settings', () => {
+ render()
+
+ const learnMoreLinks = screen.getAllByText(/learnMore/i)
+ const retrievalLearnMore = learnMoreLinks.find(link =>
+ link.closest('a')?.href?.includes('setting-indexing-methods'),
+ )
+ expect(retrievalLearnMore).toBeInTheDocument()
+ })
+
+ it('should render RetrievalMethodConfig for high quality indexing', () => {
+ render()
+
+ // RetrievalMethodConfig should be rendered
+ expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
+ })
+
+ it('should render EconomicalRetrievalMethodConfig for economy indexing', () => {
+ render()
+
+ // EconomicalRetrievalMethodConfig should be rendered
+ expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
+ })
+
+ it('should call setRetrievalConfig when config changes', () => {
+ const setRetrievalConfig = vi.fn()
+ render()
+
+ // The handler should be properly passed
+ })
+
+ it('should pass showMultiModalTip to RetrievalMethodConfig', () => {
+ render()
+
+ // The tip should be passed to the config component
+ })
+ })
+
+ describe('External Provider', () => {
+ it('should not render retrieval config for external provider', () => {
+ const externalDataset = { ...mockDataset, provider: 'external' }
+ render()
+
+ // Retrieval config should not be rendered for external provider
+ // This is handled by the parent component, but we verify the condition
+ })
+ })
+
+ describe('Conditional Rendering', () => {
+ it('should show divider between sections', () => {
+ const { container } = render()
+
+ // Dividers should be present
+ const dividers = container.querySelectorAll('.bg-divider-subtle')
+ expect(dividers.length).toBeGreaterThan(0)
+ })
+
+ it('should not render index method when indexing_technique is not set', () => {
+ const datasetWithoutTechnique = { ...mockDataset, indexing_technique: undefined as unknown as IndexingType }
+ render()
+
+ expect(screen.queryByText(/form\.indexMethod/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Keyword Number', () => {
+ it('should pass keywordNumber to IndexMethod', () => {
+ render()
+
+ // The keyword number should be displayed in the economy option description
+ // The exact rendering depends on the IndexMethod component
+ })
+
+ it('should call setKeywordNumber when keyword number changes', () => {
+ const setKeywordNumber = vi.fn()
+ render()
+
+ // The handler should be properly passed
+ })
+ })
+
+ describe('Props Updates', () => {
+ it('should update when indexMethod changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
+
+ rerender()
+
+ expect(screen.queryByText(/form\.embeddingModel/i)).not.toBeInTheDocument()
+ })
+
+ it('should update when currentDataset changes', () => {
+ const { rerender } = render()
+
+ expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
+
+ const datasetWithoutDocForm = { ...mockDataset, doc_form: undefined as unknown as ChunkingMode }
+ rerender()
+
+ expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
+ })
+ })
+
+ describe('Undefined Dataset', () => {
+ it('should handle undefined currentDataset gracefully', () => {
+ render()
+
+ // Should not crash and should handle undefined gracefully
+ // Most sections should not render without a dataset
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/form/components/indexing-section.tsx b/web/app/components/datasets/settings/form/components/indexing-section.tsx
new file mode 100644
index 0000000000..611a7e0f66
--- /dev/null
+++ b/web/app/components/datasets/settings/form/components/indexing-section.tsx
@@ -0,0 +1,206 @@
+'use client'
+import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import type { DataSet, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
+import type { RetrievalConfig } from '@/types/app'
+import { RiAlertFill } from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+import Divider from '@/app/components/base/divider'
+import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
+import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
+import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
+import { useDocLink } from '@/context/i18n'
+import { ChunkingMode } from '@/models/datasets'
+import { IndexingType } from '../../../create/step-two'
+import ChunkStructure from '../../chunk-structure'
+import IndexMethod from '../../index-method'
+import SummaryIndexSetting from '../../summary-index-setting'
+
+const rowClass = 'flex gap-x-1'
+const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
+
+type IndexingSectionProps = {
+ currentDataset: DataSet | undefined
+ indexMethod: IndexingType | undefined
+ setIndexMethod: (value: IndexingType | undefined) => void
+ keywordNumber: number
+ setKeywordNumber: (value: number) => void
+ embeddingModel: DefaultModel
+ setEmbeddingModel: (value: DefaultModel) => void
+ embeddingModelList: Model[]
+ retrievalConfig: RetrievalConfig
+ setRetrievalConfig: (value: RetrievalConfig) => void
+ summaryIndexSetting: SummaryIndexSettingType | undefined
+ handleSummaryIndexSettingChange: (payload: SummaryIndexSettingType) => void
+ showMultiModalTip: boolean
+}
+
+const IndexingSection = ({
+ currentDataset,
+ indexMethod,
+ setIndexMethod,
+ keywordNumber,
+ setKeywordNumber,
+ embeddingModel,
+ setEmbeddingModel,
+ embeddingModelList,
+ retrievalConfig,
+ setRetrievalConfig,
+ summaryIndexSetting,
+ handleSummaryIndexSettingChange,
+ showMultiModalTip,
+}: IndexingSectionProps) => {
+ const { t } = useTranslation()
+ const docLink = useDocLink()
+
+ const isShowIndexMethod = currentDataset
+ && currentDataset.doc_form !== ChunkingMode.parentChild
+ && currentDataset.indexing_technique
+ && indexMethod
+
+ const showUpgradeWarning = currentDataset?.indexing_technique === IndexingType.ECONOMICAL
+ && indexMethod === IndexingType.QUALIFIED
+
+ const showSummaryIndexSetting = indexMethod === IndexingType.QUALIFIED
+ && [ChunkingMode.text, ChunkingMode.parentChild].includes(currentDataset?.doc_form as ChunkingMode)
+
+ return (
+ <>
+ {/* Chunk Structure */}
+ {!!currentDataset?.doc_form && (
+ <>
+
+
+
+
+ {t('form.chunkStructure.title', { ns: 'datasetSettings' })}
+
+
+
+
+
+
+
+ >
+ )}
+
+ {!!(isShowIndexMethod || indexMethod === 'high_quality') && (
+
+ )}
+
+ {/* Index Method */}
+ {!!isShowIndexMethod && (
+
+
+
{t('form.indexMethod', { ns: 'datasetSettings' })}
+
+
+
+ {showUpgradeWarning && (
+
+
+
+
+
+
+ {t('form.upgradeHighQualityTip', { ns: 'datasetSettings' })}
+
+
+ )}
+
+
+ )}
+
+ {/* Embedding Model */}
+ {indexMethod === IndexingType.QUALIFIED && (
+
+
+
+ {t('form.embeddingModel', { ns: 'datasetSettings' })}
+
+
+
+
+
+
+ )}
+
+ {/* Summary Index Setting */}
+ {showSummaryIndexSetting && (
+ <>
+
+
+ >
+ )}
+
+ {/* Retrieval Method Config */}
+ {indexMethod && currentDataset?.provider !== 'external' && (
+ <>
+
+
+
+
+
+ {t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
+
+
+
+
+
+ {indexMethod === IndexingType.QUALIFIED
+ ? (
+
+ )
+ : (
+
+ )}
+
+
+ >
+ )}
+ >
+ )
+}
+
+export default IndexingSection
diff --git a/web/app/components/datasets/settings/form/hooks/use-form-state.spec.ts b/web/app/components/datasets/settings/form/hooks/use-form-state.spec.ts
new file mode 100644
index 0000000000..f79500544b
--- /dev/null
+++ b/web/app/components/datasets/settings/form/hooks/use-form-state.spec.ts
@@ -0,0 +1,763 @@
+import type { DataSet } from '@/models/datasets'
+import type { RetrievalConfig } from '@/types/app'
+import { act, renderHook, waitFor } from '@testing-library/react'
+import { ChunkingMode, DatasetPermission, DataSourceType, WeightedScoreEnum } from '@/models/datasets'
+import { RETRIEVE_METHOD } from '@/types/app'
+import { IndexingType } from '../../../create/step-two'
+import { useFormState } from './use-form-state'
+
+// Mock contexts
+const mockMutateDatasets = vi.fn()
+const mockInvalidDatasetList = vi.fn()
+
+vi.mock('@/context/app-context', () => ({
+ useSelector: () => false, // isCurrentWorkspaceDatasetOperator
+}))
+
+const createDefaultMockDataset = (): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ permission: DatasetPermission.onlyMe,
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFFFFF',
+ icon_url: '',
+ },
+ indexing_technique: IndexingType.QUALIFIED,
+ indexing_status: 'completed',
+ data_source_type: DataSourceType.FILE,
+ doc_form: ChunkingMode.text,
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ app_count: 0,
+ document_count: 5,
+ total_document_count: 5,
+ word_count: 1000,
+ provider: 'vendor',
+ tags: [],
+ partial_member_list: [],
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-1',
+ external_knowledge_api_id: 'api-1',
+ external_knowledge_api_name: 'External API',
+ external_knowledge_api_endpoint: 'https://api.example.com',
+ },
+ external_retrieval_model: {
+ top_k: 3,
+ score_threshold: 0.7,
+ score_threshold_enabled: true,
+ },
+ retrieval_model_dict: {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ } as RetrievalConfig,
+ retrieval_model: {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ } as RetrievalConfig,
+ built_in_field_enabled: false,
+ keyword_number: 10,
+ created_by: 'user-1',
+ updated_by: 'user-1',
+ updated_at: Date.now(),
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+})
+
+let mockDataset: DataSet = createDefaultMockDataset()
+
+vi.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown) => {
+ const state = {
+ dataset: mockDataset,
+ mutateDatasetRes: mockMutateDatasets,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock services
+vi.mock('@/service/datasets', () => ({
+ updateDatasetSetting: vi.fn().mockResolvedValue({}),
+}))
+
+vi.mock('@/service/knowledge/use-dataset', () => ({
+ useInvalidDatasetList: () => mockInvalidDatasetList,
+}))
+
+vi.mock('@/service/use-common', () => ({
+ useMembers: () => ({
+ data: {
+ accounts: [
+ { id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
+ { id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
+ ],
+ },
+ }),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelList: () => ({ data: [] }),
+}))
+
+vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
+ isReRankModelSelected: () => true,
+}))
+
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: vi.fn(),
+ },
+}))
+
+describe('useFormState', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDataset = createDefaultMockDataset()
+ })
+
+ describe('Initial State', () => {
+ it('should initialize with dataset values', () => {
+ const { result } = renderHook(() => useFormState())
+
+ expect(result.current.name).toBe('Test Dataset')
+ expect(result.current.description).toBe('Test description')
+ expect(result.current.permission).toBe(DatasetPermission.onlyMe)
+ expect(result.current.indexMethod).toBe(IndexingType.QUALIFIED)
+ expect(result.current.keywordNumber).toBe(10)
+ })
+
+ it('should initialize icon info from dataset', () => {
+ const { result } = renderHook(() => useFormState())
+
+ expect(result.current.iconInfo).toEqual({
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFFFFF',
+ icon_url: '',
+ })
+ })
+
+ it('should initialize external retrieval settings', () => {
+ const { result } = renderHook(() => useFormState())
+
+ expect(result.current.topK).toBe(3)
+ expect(result.current.scoreThreshold).toBe(0.7)
+ expect(result.current.scoreThresholdEnabled).toBe(true)
+ })
+
+ it('should derive member list from API data', () => {
+ const { result } = renderHook(() => useFormState())
+
+ expect(result.current.memberList).toHaveLength(2)
+ expect(result.current.memberList[0].name).toBe('User 1')
+ })
+
+ it('should return currentDataset from context', () => {
+ const { result } = renderHook(() => useFormState())
+
+ expect(result.current.currentDataset).toBeDefined()
+ expect(result.current.currentDataset?.id).toBe('dataset-1')
+ })
+ })
+
+ describe('State Setters', () => {
+ it('should update name when setName is called', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setName('New Name')
+ })
+
+ expect(result.current.name).toBe('New Name')
+ })
+
+ it('should update description when setDescription is called', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setDescription('New Description')
+ })
+
+ expect(result.current.description).toBe('New Description')
+ })
+
+ it('should update permission when setPermission is called', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setPermission(DatasetPermission.allTeamMembers)
+ })
+
+ expect(result.current.permission).toBe(DatasetPermission.allTeamMembers)
+ })
+
+ it('should update indexMethod when setIndexMethod is called', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setIndexMethod(IndexingType.ECONOMICAL)
+ })
+
+ expect(result.current.indexMethod).toBe(IndexingType.ECONOMICAL)
+ })
+
+ it('should update keywordNumber when setKeywordNumber is called', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setKeywordNumber(20)
+ })
+
+ expect(result.current.keywordNumber).toBe(20)
+ })
+
+ it('should update selectedMemberIDs when setSelectedMemberIDs is called', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setSelectedMemberIDs(['user-1', 'user-2'])
+ })
+
+ expect(result.current.selectedMemberIDs).toEqual(['user-1', 'user-2'])
+ })
+ })
+
+ describe('Icon Handlers', () => {
+ it('should open app icon picker and save previous icon', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleOpenAppIconPicker()
+ })
+
+ expect(result.current.showAppIconPicker).toBe(true)
+ })
+
+ it('should select emoji icon and close picker', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleOpenAppIconPicker()
+ })
+
+ act(() => {
+ result.current.handleSelectAppIcon({
+ type: 'emoji',
+ icon: '๐',
+ background: '#FF0000',
+ })
+ })
+
+ expect(result.current.showAppIconPicker).toBe(false)
+ expect(result.current.iconInfo).toEqual({
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FF0000',
+ icon_url: undefined,
+ })
+ })
+
+ it('should select image icon and close picker', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleOpenAppIconPicker()
+ })
+
+ act(() => {
+ result.current.handleSelectAppIcon({
+ type: 'image',
+ fileId: 'file-123',
+ url: 'https://example.com/icon.png',
+ })
+ })
+
+ expect(result.current.showAppIconPicker).toBe(false)
+ expect(result.current.iconInfo).toEqual({
+ icon_type: 'image',
+ icon: 'file-123',
+ icon_background: undefined,
+ icon_url: 'https://example.com/icon.png',
+ })
+ })
+
+ it('should restore previous icon when picker is closed', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleOpenAppIconPicker()
+ })
+
+ act(() => {
+ result.current.handleSelectAppIcon({
+ type: 'emoji',
+ icon: '๐',
+ background: '#FF0000',
+ })
+ })
+
+ act(() => {
+ result.current.handleOpenAppIconPicker()
+ })
+
+ act(() => {
+ result.current.handleCloseAppIconPicker()
+ })
+
+ expect(result.current.showAppIconPicker).toBe(false)
+ // After close, icon should be restored to the icon before opening
+ expect(result.current.iconInfo).toEqual({
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FF0000',
+ icon_url: undefined,
+ })
+ })
+ })
+
+ describe('External Retrieval Settings Handler', () => {
+ it('should update topK when provided', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleSettingsChange({ top_k: 5 })
+ })
+
+ expect(result.current.topK).toBe(5)
+ })
+
+ it('should update scoreThreshold when provided', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleSettingsChange({ score_threshold: 0.8 })
+ })
+
+ expect(result.current.scoreThreshold).toBe(0.8)
+ })
+
+ it('should update scoreThresholdEnabled when provided', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleSettingsChange({ score_threshold_enabled: false })
+ })
+
+ expect(result.current.scoreThresholdEnabled).toBe(false)
+ })
+
+ it('should update multiple settings at once', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleSettingsChange({
+ top_k: 10,
+ score_threshold: 0.9,
+ score_threshold_enabled: true,
+ })
+ })
+
+ expect(result.current.topK).toBe(10)
+ expect(result.current.scoreThreshold).toBe(0.9)
+ expect(result.current.scoreThresholdEnabled).toBe(true)
+ })
+ })
+
+ describe('Summary Index Setting Handler', () => {
+ it('should update summary index setting', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleSummaryIndexSettingChange({
+ enable: true,
+ })
+ })
+
+ expect(result.current.summaryIndexSetting).toMatchObject({
+ enable: true,
+ })
+ })
+
+ it('should merge with existing settings', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.handleSummaryIndexSettingChange({
+ enable: true,
+ })
+ })
+
+ act(() => {
+ result.current.handleSummaryIndexSettingChange({
+ model_provider_name: 'openai',
+ model_name: 'gpt-4',
+ })
+ })
+
+ expect(result.current.summaryIndexSetting).toMatchObject({
+ enable: true,
+ model_provider_name: 'openai',
+ model_name: 'gpt-4',
+ })
+ })
+ })
+
+ describe('handleSave', () => {
+ it('should show error toast when name is empty', async () => {
+ const Toast = await import('@/app/components/base/toast')
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setName('')
+ })
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ expect(Toast.default.notify).toHaveBeenCalledWith({
+ type: 'error',
+ message: expect.any(String),
+ })
+ })
+
+ it('should show error toast when name is whitespace only', async () => {
+ const Toast = await import('@/app/components/base/toast')
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setName(' ')
+ })
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ expect(Toast.default.notify).toHaveBeenCalledWith({
+ type: 'error',
+ message: expect.any(String),
+ })
+ })
+
+ it('should call updateDatasetSetting with correct params', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ const { result } = renderHook(() => useFormState())
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ expect(updateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ name: 'Test Dataset',
+ description: 'Test description',
+ permission: DatasetPermission.onlyMe,
+ }),
+ })
+ })
+
+ it('should show success toast on successful save', async () => {
+ const Toast = await import('@/app/components/base/toast')
+ const { result } = renderHook(() => useFormState())
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ await waitFor(() => {
+ expect(Toast.default.notify).toHaveBeenCalledWith({
+ type: 'success',
+ message: expect.any(String),
+ })
+ })
+ })
+
+ it('should call mutateDatasets after successful save', async () => {
+ const { result } = renderHook(() => useFormState())
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ await waitFor(() => {
+ expect(mockMutateDatasets).toHaveBeenCalled()
+ })
+ })
+
+ it('should call invalidDatasetList after successful save', async () => {
+ const { result } = renderHook(() => useFormState())
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ await waitFor(() => {
+ expect(mockInvalidDatasetList).toHaveBeenCalled()
+ })
+ })
+
+ it('should set loading to true during save', async () => {
+ const { result } = renderHook(() => useFormState())
+
+ expect(result.current.loading).toBe(false)
+
+ const savePromise = act(async () => {
+ await result.current.handleSave()
+ })
+
+ // Loading should be true during the save operation
+ await savePromise
+
+ expect(result.current.loading).toBe(false) // After completion
+ })
+
+ it('should not save when already loading', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ vi.mocked(updateDatasetSetting).mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
+
+ const { result } = renderHook(() => useFormState())
+
+ // Start first save
+ act(() => {
+ result.current.handleSave()
+ })
+
+ // Try to start second save immediately
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ // Should only have been called once
+ expect(updateDatasetSetting).toHaveBeenCalledTimes(1)
+ })
+
+ it('should show error toast on save failure', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ const Toast = await import('@/app/components/base/toast')
+ vi.mocked(updateDatasetSetting).mockRejectedValueOnce(new Error('Network error'))
+
+ const { result } = renderHook(() => useFormState())
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ expect(Toast.default.notify).toHaveBeenCalledWith({
+ type: 'error',
+ message: expect.any(String),
+ })
+ })
+
+ it('should include partial_member_list when permission is partialMembers', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setPermission(DatasetPermission.partialMembers)
+ result.current.setSelectedMemberIDs(['user-1', 'user-2'])
+ })
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ expect(updateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ partial_member_list: expect.arrayContaining([
+ expect.objectContaining({ user_id: 'user-1' }),
+ expect.objectContaining({ user_id: 'user-2' }),
+ ]),
+ }),
+ })
+ })
+ })
+
+ describe('Embedding Model', () => {
+ it('should initialize embedding model from dataset', () => {
+ const { result } = renderHook(() => useFormState())
+
+ expect(result.current.embeddingModel).toEqual({
+ provider: 'openai',
+ model: 'text-embedding-ada-002',
+ })
+ })
+
+ it('should update embedding model when setEmbeddingModel is called', () => {
+ const { result } = renderHook(() => useFormState())
+
+ act(() => {
+ result.current.setEmbeddingModel({
+ provider: 'cohere',
+ model: 'embed-english-v3.0',
+ })
+ })
+
+ expect(result.current.embeddingModel).toEqual({
+ provider: 'cohere',
+ model: 'embed-english-v3.0',
+ })
+ })
+ })
+
+ describe('Retrieval Config', () => {
+ it('should initialize retrieval config from dataset', () => {
+ const { result } = renderHook(() => useFormState())
+
+ expect(result.current.retrievalConfig).toBeDefined()
+ expect(result.current.retrievalConfig.search_method).toBe(RETRIEVE_METHOD.semantic)
+ })
+
+ it('should update retrieval config when setRetrievalConfig is called', () => {
+ const { result } = renderHook(() => useFormState())
+
+ const newConfig: RetrievalConfig = {
+ ...result.current.retrievalConfig,
+ reranking_enable: true,
+ }
+
+ act(() => {
+ result.current.setRetrievalConfig(newConfig)
+ })
+
+ expect(result.current.retrievalConfig.reranking_enable).toBe(true)
+ })
+
+ it('should include weights in save request when weights are set', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ const { result } = renderHook(() => useFormState())
+
+ // Set retrieval config with weights
+ const configWithWeights: RetrievalConfig = {
+ ...result.current.retrievalConfig,
+ search_method: RETRIEVE_METHOD.hybrid,
+ weights: {
+ weight_type: WeightedScoreEnum.Customized,
+ vector_setting: {
+ vector_weight: 0.7,
+ embedding_provider_name: '',
+ embedding_model_name: '',
+ },
+ keyword_setting: {
+ keyword_weight: 0.3,
+ },
+ },
+ }
+
+ act(() => {
+ result.current.setRetrievalConfig(configWithWeights)
+ })
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ // Verify that weights were included and embedding model info was added
+ expect(updateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ retrieval_model: expect.objectContaining({
+ weights: expect.objectContaining({
+ vector_setting: expect.objectContaining({
+ embedding_provider_name: 'openai',
+ embedding_model_name: 'text-embedding-ada-002',
+ }),
+ }),
+ }),
+ }),
+ })
+ })
+ })
+
+ describe('External Provider', () => {
+ beforeEach(() => {
+ // Update mock dataset to be external provider
+ mockDataset = {
+ ...mockDataset,
+ provider: 'external',
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-123',
+ external_knowledge_api_id: 'api-456',
+ external_knowledge_api_name: 'External API',
+ external_knowledge_api_endpoint: 'https://api.example.com',
+ },
+ external_retrieval_model: {
+ top_k: 5,
+ score_threshold: 0.8,
+ score_threshold_enabled: true,
+ },
+ }
+ })
+
+ it('should include external knowledge info in save request for external provider', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ const { result } = renderHook(() => useFormState())
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ expect(updateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ external_knowledge_id: 'ext-123',
+ external_knowledge_api_id: 'api-456',
+ external_retrieval_model: expect.objectContaining({
+ top_k: expect.any(Number),
+ score_threshold: expect.any(Number),
+ score_threshold_enabled: expect.any(Boolean),
+ }),
+ }),
+ })
+ })
+
+ it('should use correct external retrieval settings', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ const { result } = renderHook(() => useFormState())
+
+ // Update external retrieval settings
+ act(() => {
+ result.current.handleSettingsChange({
+ top_k: 10,
+ score_threshold: 0.9,
+ score_threshold_enabled: false,
+ })
+ })
+
+ await act(async () => {
+ await result.current.handleSave()
+ })
+
+ expect(updateDatasetSetting).toHaveBeenCalledWith({
+ datasetId: 'dataset-1',
+ body: expect.objectContaining({
+ external_retrieval_model: {
+ top_k: 10,
+ score_threshold: 0.9,
+ score_threshold_enabled: false,
+ },
+ }),
+ })
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/form/hooks/use-form-state.ts b/web/app/components/datasets/settings/form/hooks/use-form-state.ts
new file mode 100644
index 0000000000..614995d43a
--- /dev/null
+++ b/web/app/components/datasets/settings/form/hooks/use-form-state.ts
@@ -0,0 +1,264 @@
+'use client'
+import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
+import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import type { Member } from '@/models/common'
+import type { IconInfo, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
+import type { RetrievalConfig } from '@/types/app'
+import { useCallback, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Toast from '@/app/components/base/toast'
+import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
+import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
+import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
+import { useSelector as useAppContextWithSelector } from '@/context/app-context'
+import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
+import { DatasetPermission } from '@/models/datasets'
+import { updateDatasetSetting } from '@/service/datasets'
+import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
+import { useMembers } from '@/service/use-common'
+import { checkShowMultiModalTip } from '../../utils'
+
+const DEFAULT_APP_ICON: IconInfo = {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFF4ED',
+ icon_url: '',
+}
+
+export const useFormState = () => {
+ const { t } = useTranslation()
+ const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
+ const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset)
+ const mutateDatasets = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
+
+ // Basic form state
+ const [loading, setLoading] = useState(false)
+ const [name, setName] = useState(currentDataset?.name ?? '')
+ const [description, setDescription] = useState(currentDataset?.description ?? '')
+
+ // Icon state
+ const [iconInfo, setIconInfo] = useState(currentDataset?.icon_info || DEFAULT_APP_ICON)
+ const [showAppIconPicker, setShowAppIconPicker] = useState(false)
+ const previousAppIcon = useRef(DEFAULT_APP_ICON)
+
+ // Permission state
+ const [permission, setPermission] = useState(currentDataset?.permission)
+ const [selectedMemberIDs, setSelectedMemberIDs] = useState(currentDataset?.partial_member_list || [])
+
+ // External retrieval state
+ const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2)
+ const [scoreThreshold, setScoreThreshold] = useState(currentDataset?.external_retrieval_model.score_threshold ?? 0.5)
+ const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(currentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
+
+ // Indexing and retrieval state
+ const [indexMethod, setIndexMethod] = useState(currentDataset?.indexing_technique)
+ const [keywordNumber, setKeywordNumber] = useState(currentDataset?.keyword_number ?? 10)
+ const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict as RetrievalConfig)
+ const [embeddingModel, setEmbeddingModel] = useState(
+ currentDataset?.embedding_model
+ ? {
+ provider: currentDataset.embedding_model_provider,
+ model: currentDataset.embedding_model,
+ }
+ : {
+ provider: '',
+ model: '',
+ },
+ )
+
+ // Summary index state
+ const [summaryIndexSetting, setSummaryIndexSetting] = useState(currentDataset?.summary_index_setting)
+
+ // Model lists
+ const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
+ const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
+ const { data: membersData } = useMembers()
+ const invalidDatasetList = useInvalidDatasetList()
+
+ // Derive member list from API data
+ const memberList = useMemo(() => {
+ return membersData?.accounts ?? []
+ }, [membersData])
+
+ // Icon handlers
+ const handleOpenAppIconPicker = useCallback(() => {
+ setShowAppIconPicker(true)
+ previousAppIcon.current = iconInfo
+ }, [iconInfo])
+
+ const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
+ const newIconInfo: IconInfo = {
+ icon_type: icon.type,
+ icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
+ icon_background: icon.type === 'emoji' ? icon.background : undefined,
+ icon_url: icon.type === 'emoji' ? undefined : icon.url,
+ }
+ setIconInfo(newIconInfo)
+ setShowAppIconPicker(false)
+ }, [])
+
+ const handleCloseAppIconPicker = useCallback(() => {
+ setIconInfo(previousAppIcon.current)
+ setShowAppIconPicker(false)
+ }, [])
+
+ // External retrieval settings handler
+ const handleSettingsChange = useCallback((data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => {
+ if (data.top_k !== undefined)
+ setTopK(data.top_k)
+ if (data.score_threshold !== undefined)
+ setScoreThreshold(data.score_threshold)
+ if (data.score_threshold_enabled !== undefined)
+ setScoreThresholdEnabled(data.score_threshold_enabled)
+ }, [])
+
+ // Summary index setting handler
+ const handleSummaryIndexSettingChange = useCallback((payload: SummaryIndexSettingType) => {
+ setSummaryIndexSetting(prev => ({ ...prev, ...payload }))
+ }, [])
+
+ // Save handler
+ const handleSave = async () => {
+ if (loading)
+ return
+
+ if (!name?.trim()) {
+ Toast.notify({ type: 'error', message: t('form.nameError', { ns: 'datasetSettings' }) })
+ return
+ }
+
+ if (!isReRankModelSelected({ rerankModelList, retrievalConfig, indexMethod })) {
+ Toast.notify({ type: 'error', message: t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }) })
+ return
+ }
+
+ if (retrievalConfig.weights) {
+ retrievalConfig.weights.vector_setting.embedding_provider_name = embeddingModel.provider || ''
+ retrievalConfig.weights.vector_setting.embedding_model_name = embeddingModel.model || ''
+ }
+
+ try {
+ setLoading(true)
+ const body: Record = {
+ name,
+ icon_info: iconInfo,
+ doc_form: currentDataset?.doc_form,
+ description,
+ permission,
+ indexing_technique: indexMethod,
+ retrieval_model: {
+ ...retrievalConfig,
+ score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0,
+ },
+ embedding_model: embeddingModel.model,
+ embedding_model_provider: embeddingModel.provider,
+ keyword_number: keywordNumber,
+ summary_index_setting: summaryIndexSetting,
+ }
+
+ if (currentDataset!.provider === 'external') {
+ body.external_knowledge_id = currentDataset!.external_knowledge_info.external_knowledge_id
+ body.external_knowledge_api_id = currentDataset!.external_knowledge_info.external_knowledge_api_id
+ body.external_retrieval_model = {
+ top_k: topK,
+ score_threshold: scoreThreshold,
+ score_threshold_enabled: scoreThresholdEnabled,
+ }
+ }
+
+ if (permission === DatasetPermission.partialMembers) {
+ body.partial_member_list = selectedMemberIDs.map((id) => {
+ return {
+ user_id: id,
+ role: memberList.find(member => member.id === id)?.role,
+ }
+ })
+ }
+
+ await updateDatasetSetting({ datasetId: currentDataset!.id, body })
+ Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
+
+ if (mutateDatasets) {
+ await mutateDatasets()
+ invalidDatasetList()
+ }
+ }
+ catch {
+ Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
+ }
+ finally {
+ setLoading(false)
+ }
+ }
+
+ // Computed values
+ const showMultiModalTip = useMemo(() => {
+ return checkShowMultiModalTip({
+ embeddingModel,
+ rerankingEnable: retrievalConfig.reranking_enable,
+ rerankModel: {
+ rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name,
+ rerankingModelName: retrievalConfig.reranking_model.reranking_model_name,
+ },
+ indexMethod,
+ embeddingModelList,
+ rerankModelList,
+ })
+ }, [embeddingModel, rerankModelList, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, embeddingModelList, indexMethod])
+
+ return {
+ // Context values
+ currentDataset,
+ isCurrentWorkspaceDatasetOperator,
+
+ // Loading state
+ loading,
+
+ // Basic form
+ name,
+ setName,
+ description,
+ setDescription,
+
+ // Icon
+ iconInfo,
+ showAppIconPicker,
+ handleOpenAppIconPicker,
+ handleSelectAppIcon,
+ handleCloseAppIconPicker,
+
+ // Permission
+ permission,
+ setPermission,
+ selectedMemberIDs,
+ setSelectedMemberIDs,
+ memberList,
+
+ // External retrieval
+ topK,
+ scoreThreshold,
+ scoreThresholdEnabled,
+ handleSettingsChange,
+
+ // Indexing and retrieval
+ indexMethod,
+ setIndexMethod,
+ keywordNumber,
+ setKeywordNumber,
+ retrievalConfig,
+ setRetrievalConfig,
+ embeddingModel,
+ setEmbeddingModel,
+ embeddingModelList,
+
+ // Summary index
+ summaryIndexSetting,
+ handleSummaryIndexSettingChange,
+
+ // Computed
+ showMultiModalTip,
+
+ // Actions
+ handleSave,
+ }
+}
diff --git a/web/app/components/datasets/settings/form/index.spec.tsx b/web/app/components/datasets/settings/form/index.spec.tsx
new file mode 100644
index 0000000000..03e98861e2
--- /dev/null
+++ b/web/app/components/datasets/settings/form/index.spec.tsx
@@ -0,0 +1,488 @@
+import type { DataSet } from '@/models/datasets'
+import type { RetrievalConfig } from '@/types/app'
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
+import { RETRIEVE_METHOD } from '@/types/app'
+import { IndexingType } from '../../create/step-two'
+import Form from './index'
+
+// Mock contexts
+const mockMutateDatasets = vi.fn()
+const mockInvalidDatasetList = vi.fn()
+
+const mockUserProfile = {
+ id: 'user-1',
+ name: 'Current User',
+ email: 'current@example.com',
+ avatar_url: '',
+ role: 'owner',
+}
+
+vi.mock('@/context/app-context', () => ({
+ useSelector: (selector: (state: unknown) => unknown) => {
+ const state = {
+ isCurrentWorkspaceDatasetOperator: false,
+ userProfile: mockUserProfile,
+ }
+ return selector(state)
+ },
+}))
+
+const createMockDataset = (overrides: Partial = {}): DataSet => ({
+ id: 'dataset-1',
+ name: 'Test Dataset',
+ description: 'Test description',
+ permission: DatasetPermission.onlyMe,
+ icon_info: {
+ icon_type: 'emoji',
+ icon: '๐',
+ icon_background: '#FFFFFF',
+ icon_url: '',
+ },
+ indexing_technique: IndexingType.QUALIFIED,
+ indexing_status: 'completed',
+ data_source_type: DataSourceType.FILE,
+ doc_form: ChunkingMode.text,
+ embedding_model: 'text-embedding-ada-002',
+ embedding_model_provider: 'openai',
+ embedding_available: true,
+ app_count: 0,
+ document_count: 5,
+ total_document_count: 5,
+ word_count: 1000,
+ provider: 'vendor',
+ tags: [],
+ partial_member_list: [],
+ external_knowledge_info: {
+ external_knowledge_id: 'ext-1',
+ external_knowledge_api_id: 'api-1',
+ external_knowledge_api_name: 'External API',
+ external_knowledge_api_endpoint: 'https://api.example.com',
+ },
+ external_retrieval_model: {
+ top_k: 3,
+ score_threshold: 0.7,
+ score_threshold_enabled: true,
+ },
+ retrieval_model_dict: {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ } as RetrievalConfig,
+ retrieval_model: {
+ search_method: RETRIEVE_METHOD.semantic,
+ reranking_enable: false,
+ reranking_model: {
+ reranking_provider_name: '',
+ reranking_model_name: '',
+ },
+ top_k: 3,
+ score_threshold_enabled: false,
+ score_threshold: 0.5,
+ } as RetrievalConfig,
+ built_in_field_enabled: false,
+ keyword_number: 10,
+ created_by: 'user-1',
+ updated_by: 'user-1',
+ updated_at: Date.now(),
+ runtime_mode: 'general',
+ enable_api: true,
+ is_multimodal: false,
+ ...overrides,
+})
+
+let mockDataset: DataSet = createMockDataset()
+
+vi.mock('@/context/dataset-detail', () => ({
+ useDatasetDetailContextWithSelector: (selector: (state: { dataset: DataSet | null, mutateDatasetRes: () => void }) => unknown) => {
+ const state = {
+ dataset: mockDataset,
+ mutateDatasetRes: mockMutateDatasets,
+ }
+ return selector(state)
+ },
+}))
+
+// Mock services
+vi.mock('@/service/datasets', () => ({
+ updateDatasetSetting: vi.fn().mockResolvedValue({}),
+}))
+
+vi.mock('@/service/knowledge/use-dataset', () => ({
+ useInvalidDatasetList: () => mockInvalidDatasetList,
+}))
+
+vi.mock('@/service/use-common', () => ({
+ useMembers: () => ({
+ data: {
+ accounts: [
+ { id: 'user-1', name: 'User 1', email: 'user1@example.com', role: 'owner', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
+ { id: 'user-2', name: 'User 2', email: 'user2@example.com', role: 'admin', avatar: '', avatar_url: '', last_login_at: '', created_at: '', status: 'active' },
+ ],
+ },
+ }),
+}))
+
+vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
+ useModelList: () => ({ data: [], mutate: vi.fn(), isLoading: false }),
+ useCurrentProviderAndModel: () => ({ currentProvider: undefined, currentModel: undefined }),
+ useDefaultModel: () => ({ data: undefined, mutate: vi.fn(), isLoading: false }),
+ useModelListAndDefaultModel: () => ({ modelList: [], defaultModel: undefined }),
+ useModelListAndDefaultModelAndCurrentProviderAndModel: () => ({
+ modelList: [],
+ defaultModel: undefined,
+ currentProvider: undefined,
+ currentModel: undefined,
+ }),
+ useUpdateModelList: () => vi.fn(),
+ useUpdateModelProviders: () => vi.fn(),
+ useLanguage: () => 'en_US',
+ useSystemDefaultModelAndModelList: () => [undefined, vi.fn()],
+ useProviderCredentialsAndLoadBalancing: () => ({
+ credentials: undefined,
+ loadBalancing: undefined,
+ mutate: vi.fn(),
+ isLoading: false,
+ }),
+ useAnthropicBuyQuota: () => vi.fn(),
+ useMarketplaceAllPlugins: () => ({ plugins: [], isLoading: false }),
+ useRefreshModel: () => ({ handleRefreshModel: vi.fn() }),
+ useModelModalHandler: () => vi.fn(),
+}))
+
+// Mock provider-context
+vi.mock('@/context/provider-context', () => ({
+ useProviderContext: () => ({
+ textGenerationModelList: [],
+ embeddingsModelList: [],
+ rerankModelList: [],
+ agentThoughtModelList: [],
+ modelProviders: [],
+ textEmbeddingModelList: [],
+ speech2textModelList: [],
+ ttsModelList: [],
+ moderationModelList: [],
+ hasSettedApiKey: true,
+ plan: { type: 'free' },
+ enableBilling: false,
+ onPlanInfoChanged: vi.fn(),
+ isCurrentWorkspaceDatasetOperator: false,
+ supportRetrievalMethods: ['semantic_search', 'full_text_search', 'hybrid_search'],
+ }),
+}))
+
+vi.mock('@/app/components/datasets/common/check-rerank-model', () => ({
+ isReRankModelSelected: () => true,
+}))
+
+vi.mock('@/app/components/base/toast', () => ({
+ default: {
+ notify: vi.fn(),
+ },
+}))
+
+vi.mock('@/context/i18n', () => ({
+ useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
+}))
+
+describe('Form', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDataset = createMockDataset()
+ })
+
+ describe('Rendering', () => {
+ it('should render without crashing', () => {
+ render()
+ expect(screen.getByRole('button', { name: /form\.save/i })).toBeInTheDocument()
+ })
+
+ it('should render dataset name input with initial value', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ expect(nameInput).toBeInTheDocument()
+ })
+
+ it('should render dataset description textarea', () => {
+ render()
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ expect(descriptionTextarea).toBeInTheDocument()
+ })
+
+ it('should render save button', () => {
+ render()
+ const saveButton = screen.getByRole('button', { name: /form\.save/i })
+ expect(saveButton).toBeInTheDocument()
+ })
+
+ it('should render permission selector', () => {
+ render()
+ // Permission selector renders the current permission text
+ expect(screen.getByText(/form\.permissionsOnlyMe/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('BasicInfoSection', () => {
+ it('should allow editing dataset name', () => {
+ render()
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+
+ fireEvent.change(nameInput, { target: { value: 'Updated Dataset Name' } })
+
+ expect(nameInput).toHaveValue('Updated Dataset Name')
+ })
+
+ it('should allow editing dataset description', () => {
+ render()
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+
+ fireEvent.change(descriptionTextarea, { target: { value: 'Updated description' } })
+
+ expect(descriptionTextarea).toHaveValue('Updated description')
+ })
+
+ it('should render app icon', () => {
+ const { container } = render()
+ // The app icon wrapper should be rendered (icon may be in a span or SVG)
+ // The icon is rendered within a clickable container in the name and icon section
+ const iconSection = container.querySelector('[class*="cursor-pointer"]')
+ expect(iconSection).toBeInTheDocument()
+ })
+ })
+
+ describe('IndexingSection - Internal Provider', () => {
+ it('should render chunk structure section when doc_form is set', () => {
+ render()
+ expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
+ })
+
+ it('should render index method section', () => {
+ render()
+ // May match multiple elements (label and descriptions)
+ expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
+ })
+
+ it('should render embedding model section when indexMethod is high_quality', () => {
+ render()
+ expect(screen.getByText(/form\.embeddingModel/i)).toBeInTheDocument()
+ })
+
+ it('should render retrieval settings section', () => {
+ render()
+ expect(screen.getByText(/form\.retrievalSetting\.title/i)).toBeInTheDocument()
+ })
+
+ it('should render learn more links', () => {
+ render()
+ const learnMoreLinks = screen.getAllByText(/learnMore/i)
+ expect(learnMoreLinks.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('ExternalKnowledgeSection - External Provider', () => {
+ beforeEach(() => {
+ mockDataset = createMockDataset({ provider: 'external' })
+ })
+
+ it('should render external knowledge API info when provider is external', () => {
+ render()
+ expect(screen.getByText(/form\.externalKnowledgeAPI/i)).toBeInTheDocument()
+ })
+
+ it('should render external knowledge ID when provider is external', () => {
+ render()
+ expect(screen.getByText(/form\.externalKnowledgeID/i)).toBeInTheDocument()
+ })
+
+ it('should display external API name', () => {
+ render()
+ expect(screen.getByText('External API')).toBeInTheDocument()
+ })
+
+ it('should display external API endpoint', () => {
+ render()
+ expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
+ })
+
+ it('should display external knowledge ID value', () => {
+ render()
+ expect(screen.getByText('ext-1')).toBeInTheDocument()
+ })
+ })
+
+ describe('Save Functionality', () => {
+ it('should call save when save button is clicked', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ render()
+
+ const saveButton = screen.getByRole('button', { name: /form\.save/i })
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(updateDatasetSetting).toHaveBeenCalled()
+ })
+ })
+
+ it('should show loading state on save button while saving', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ vi.mocked(updateDatasetSetting).mockImplementation(
+ () => new Promise(resolve => setTimeout(resolve, 100)),
+ )
+
+ render()
+
+ const saveButton = screen.getByRole('button', { name: /form\.save/i })
+ fireEvent.click(saveButton)
+
+ // Button should be disabled during loading
+ await waitFor(() => {
+ expect(saveButton).toBeDisabled()
+ })
+ })
+
+ it('should show error when trying to save with empty name', async () => {
+ const Toast = await import('@/app/components/base/toast')
+ render()
+
+ // Clear the name
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ fireEvent.change(nameInput, { target: { value: '' } })
+
+ // Try to save
+ const saveButton = screen.getByRole('button', { name: /form\.save/i })
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(Toast.default.notify).toHaveBeenCalledWith({
+ type: 'error',
+ message: expect.any(String),
+ })
+ })
+ })
+
+ it('should save with updated name', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ render()
+
+ // Update name
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ fireEvent.change(nameInput, { target: { value: 'New Dataset Name' } })
+
+ // Save
+ const saveButton = screen.getByRole('button', { name: /form\.save/i })
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(updateDatasetSetting).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.objectContaining({
+ name: 'New Dataset Name',
+ }),
+ }),
+ )
+ })
+ })
+
+ it('should save with updated description', async () => {
+ const { updateDatasetSetting } = await import('@/service/datasets')
+ render()
+
+ // Update description
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ fireEvent.change(descriptionTextarea, { target: { value: 'New description' } })
+
+ // Save
+ const saveButton = screen.getByRole('button', { name: /form\.save/i })
+ fireEvent.click(saveButton)
+
+ await waitFor(() => {
+ expect(updateDatasetSetting).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.objectContaining({
+ description: 'New description',
+ }),
+ }),
+ )
+ })
+ })
+ })
+
+ describe('Disabled States', () => {
+ it('should disable inputs when embedding is not available', () => {
+ mockDataset = createMockDataset({ embedding_available: false })
+ render()
+
+ const nameInput = screen.getByDisplayValue('Test Dataset')
+ expect(nameInput).toBeDisabled()
+
+ const descriptionTextarea = screen.getByDisplayValue('Test description')
+ expect(descriptionTextarea).toBeDisabled()
+ })
+ })
+
+ describe('Conditional Rendering', () => {
+ it('should not render chunk structure when doc_form is not set', () => {
+ mockDataset = createMockDataset({ doc_form: undefined as unknown as ChunkingMode })
+ render()
+
+ // Chunk structure should not be present
+ expect(screen.queryByText(/form\.chunkStructure\.title/i)).not.toBeInTheDocument()
+ })
+
+ it('should render IndexingSection for internal provider', () => {
+ mockDataset = createMockDataset({ provider: 'vendor' })
+ render()
+
+ // May match multiple elements (label and descriptions)
+ expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
+ expect(screen.queryByText(/form\.externalKnowledgeAPI/i)).not.toBeInTheDocument()
+ })
+
+ it('should render ExternalKnowledgeSection for external provider', () => {
+ mockDataset = createMockDataset({ provider: 'external' })
+ render()
+
+ expect(screen.getByText(/form\.externalKnowledgeAPI/i)).toBeInTheDocument()
+ })
+ })
+
+ describe('Permission Selection', () => {
+ it('should open permission dropdown when clicked', async () => {
+ render()
+
+ const permissionTrigger = screen.getByText(/form\.permissionsOnlyMe/i)
+ fireEvent.click(permissionTrigger)
+
+ await waitFor(() => {
+ // Should show all permission options
+ expect(screen.getAllByText(/form\.permissionsOnlyMe/i).length).toBeGreaterThanOrEqual(1)
+ })
+ })
+ })
+
+ describe('Integration', () => {
+ it('should render all main sections', () => {
+ render()
+
+ // Basic info
+ expect(screen.getByText(/form\.nameAndIcon/i)).toBeInTheDocument()
+ expect(screen.getByText(/form\.desc/i)).toBeInTheDocument()
+ // form.permissions matches multiple elements (label and permission options)
+ expect(screen.getAllByText(/form\.permissions/i).length).toBeGreaterThan(0)
+
+ // Indexing (for internal provider)
+ expect(screen.getByText(/form\.chunkStructure\.title/i)).toBeInTheDocument()
+ // form.indexMethod matches multiple elements
+ expect(screen.getAllByText(/form\.indexMethod/i).length).toBeGreaterThan(0)
+
+ // Save button
+ expect(screen.getByRole('button', { name: /form\.save/i })).toBeInTheDocument()
+ })
+ })
+})
diff --git a/web/app/components/datasets/settings/form/index.tsx b/web/app/components/datasets/settings/form/index.tsx
index 1993c9fd8d..f060701f1c 100644
--- a/web/app/components/datasets/settings/form/index.tsx
+++ b/web/app/components/datasets/settings/form/index.tsx
@@ -1,486 +1,126 @@
'use client'
-import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
-import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
-import type { Member } from '@/models/common'
-import type { IconInfo, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
-import type { AppIconType, RetrievalConfig } from '@/types/app'
-import { RiAlertFill } from '@remixicon/react'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import AppIcon from '@/app/components/base/app-icon'
-import AppIconPicker from '@/app/components/base/app-icon-picker'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
-import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
-import Input from '@/app/components/base/input'
-import Textarea from '@/app/components/base/textarea'
-import Toast from '@/app/components/base/toast'
-import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
-import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
-import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
-import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
-import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
-import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
-import { useSelector as useAppContextWithSelector } from '@/context/app-context'
-import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
-import { useDocLink } from '@/context/i18n'
-import { ChunkingMode, DatasetPermission } from '@/models/datasets'
-import { updateDatasetSetting } from '@/service/datasets'
-import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
-import { useMembers } from '@/service/use-common'
-import { IndexingType } from '../../create/step-two'
-import RetrievalSettings from '../../external-knowledge-base/create/RetrievalSettings'
-import ChunkStructure from '../chunk-structure'
-import IndexMethod from '../index-method'
-import PermissionSelector from '../permission-selector'
-import SummaryIndexSetting from '../summary-index-setting'
-import { checkShowMultiModalTip } from '../utils'
-
-const rowClass = 'flex gap-x-1'
-const labelClass = 'flex items-center shrink-0 w-[180px] h-7 pt-1'
-
-const DEFAULT_APP_ICON: IconInfo = {
- icon_type: 'emoji',
- icon: '๐',
- icon_background: '#FFF4ED',
- icon_url: '',
-}
+import BasicInfoSection from './components/basic-info-section'
+import ExternalKnowledgeSection from './components/external-knowledge-section'
+import IndexingSection from './components/indexing-section'
+import { useFormState } from './hooks/use-form-state'
const Form = () => {
const { t } = useTranslation()
- const docLink = useDocLink()
- const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
- const currentDataset = useDatasetDetailContextWithSelector(state => state.dataset)
- const mutateDatasets = useDatasetDetailContextWithSelector(state => state.mutateDatasetRes)
- const [loading, setLoading] = useState(false)
- const [name, setName] = useState(currentDataset?.name ?? '')
- const [iconInfo, setIconInfo] = useState(currentDataset?.icon_info || DEFAULT_APP_ICON)
- const [showAppIconPicker, setShowAppIconPicker] = useState(false)
- const [description, setDescription] = useState(currentDataset?.description ?? '')
- const [permission, setPermission] = useState(currentDataset?.permission)
- const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2)
- const [scoreThreshold, setScoreThreshold] = useState(currentDataset?.external_retrieval_model.score_threshold ?? 0.5)
- const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(currentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
- const [selectedMemberIDs, setSelectedMemberIDs] = useState(currentDataset?.partial_member_list || [])
- const [memberList, setMemberList] = useState([])
- const [indexMethod, setIndexMethod] = useState(currentDataset?.indexing_technique)
- const [keywordNumber, setKeywordNumber] = useState(currentDataset?.keyword_number ?? 10)
- const [retrievalConfig, setRetrievalConfig] = useState(currentDataset?.retrieval_model_dict as RetrievalConfig)
- const [embeddingModel, setEmbeddingModel] = useState(
- currentDataset?.embedding_model
- ? {
- provider: currentDataset.embedding_model_provider,
- model: currentDataset.embedding_model,
- }
- : {
- provider: '',
- model: '',
- },
- )
- const [summaryIndexSetting, setSummaryIndexSetting] = useState(currentDataset?.summary_index_setting)
- const handleSummaryIndexSettingChange = useCallback((payload: SummaryIndexSettingType) => {
- setSummaryIndexSetting((prev) => {
- return { ...prev, ...payload }
- })
- }, [])
- const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
- const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
- const { data: membersData } = useMembers()
- const previousAppIcon = useRef(DEFAULT_APP_ICON)
+ const {
+ // Context values
+ currentDataset,
+ isCurrentWorkspaceDatasetOperator,
- const handleOpenAppIconPicker = useCallback(() => {
- setShowAppIconPicker(true)
- previousAppIcon.current = iconInfo
- }, [iconInfo])
+ // Loading state
+ loading,
- const handleSelectAppIcon = useCallback((icon: AppIconSelection) => {
- const iconInfo: IconInfo = {
- icon_type: icon.type,
- icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
- icon_background: icon.type === 'emoji' ? icon.background : undefined,
- icon_url: icon.type === 'emoji' ? undefined : icon.url,
- }
- setIconInfo(iconInfo)
- setShowAppIconPicker(false)
- }, [])
+ // Basic form
+ name,
+ setName,
+ description,
+ setDescription,
- const handleCloseAppIconPicker = useCallback(() => {
- setIconInfo(previousAppIcon.current)
- setShowAppIconPicker(false)
- }, [])
+ // Icon
+ iconInfo,
+ showAppIconPicker,
+ handleOpenAppIconPicker,
+ handleSelectAppIcon,
+ handleCloseAppIconPicker,
- const handleSettingsChange = useCallback((data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => {
- if (data.top_k !== undefined)
- setTopK(data.top_k)
- if (data.score_threshold !== undefined)
- setScoreThreshold(data.score_threshold)
- if (data.score_threshold_enabled !== undefined)
- setScoreThresholdEnabled(data.score_threshold_enabled)
- }, [])
+ // Permission
+ permission,
+ setPermission,
+ selectedMemberIDs,
+ setSelectedMemberIDs,
+ memberList,
- useEffect(() => {
- if (!membersData?.accounts)
- setMemberList([])
- else
- setMemberList(membersData.accounts)
- }, [membersData])
+ // External retrieval
+ topK,
+ scoreThreshold,
+ scoreThresholdEnabled,
+ handleSettingsChange,
- const invalidDatasetList = useInvalidDatasetList()
- const handleSave = async () => {
- if (loading)
- return
- if (!name?.trim()) {
- Toast.notify({ type: 'error', message: t('form.nameError', { ns: 'datasetSettings' }) })
- return
- }
- if (
- !isReRankModelSelected({
- rerankModelList,
- retrievalConfig,
- indexMethod,
- })
- ) {
- Toast.notify({ type: 'error', message: t('datasetConfig.rerankModelRequired', { ns: 'appDebug' }) })
- return
- }
- if (retrievalConfig.weights) {
- retrievalConfig.weights.vector_setting.embedding_provider_name = embeddingModel.provider || ''
- retrievalConfig.weights.vector_setting.embedding_model_name = embeddingModel.model || ''
- }
- try {
- setLoading(true)
- const requestParams = {
- datasetId: currentDataset!.id,
- body: {
- name,
- icon_info: iconInfo,
- doc_form: currentDataset?.doc_form,
- description,
- permission,
- indexing_technique: indexMethod,
- retrieval_model: {
- ...retrievalConfig,
- score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0,
- },
- embedding_model: embeddingModel.model,
- embedding_model_provider: embeddingModel.provider,
- ...(currentDataset!.provider === 'external' && {
- external_knowledge_id: currentDataset!.external_knowledge_info.external_knowledge_id,
- external_knowledge_api_id: currentDataset!.external_knowledge_info.external_knowledge_api_id,
- external_retrieval_model: {
- top_k: topK,
- score_threshold: scoreThreshold,
- score_threshold_enabled: scoreThresholdEnabled,
- },
- }),
- keyword_number: keywordNumber,
- summary_index_setting: summaryIndexSetting,
- },
- } as any
- if (permission === DatasetPermission.partialMembers) {
- requestParams.body.partial_member_list = selectedMemberIDs.map((id) => {
- return {
- user_id: id,
- role: memberList.find(member => member.id === id)?.role,
- }
- })
- }
- await updateDatasetSetting(requestParams)
- Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
- if (mutateDatasets) {
- await mutateDatasets()
- invalidDatasetList()
- }
- }
- catch {
- Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
- }
- finally {
- setLoading(false)
- }
- }
+ // Indexing and retrieval
+ indexMethod,
+ setIndexMethod,
+ keywordNumber,
+ setKeywordNumber,
+ retrievalConfig,
+ setRetrievalConfig,
+ embeddingModel,
+ setEmbeddingModel,
+ embeddingModelList,
- const isShowIndexMethod = currentDataset && currentDataset.doc_form !== ChunkingMode.parentChild && currentDataset.indexing_technique && indexMethod
+ // Summary index
+ summaryIndexSetting,
+ handleSummaryIndexSettingChange,
- const showMultiModalTip = useMemo(() => {
- return checkShowMultiModalTip({
- embeddingModel,
- rerankingEnable: retrievalConfig.reranking_enable,
- rerankModel: {
- rerankingProviderName: retrievalConfig.reranking_model.reranking_provider_name,
- rerankingModelName: retrievalConfig.reranking_model.reranking_model_name,
- },
- indexMethod,
- embeddingModelList,
- rerankModelList,
- })
- }, [embeddingModel, rerankModelList, retrievalConfig.reranking_enable, retrievalConfig.reranking_model, embeddingModelList, indexMethod])
+ // Computed
+ showMultiModalTip,
+
+ // Actions
+ handleSave,
+ } = useFormState()
+
+ const isExternalProvider = currentDataset?.provider === 'external'
return (
- {/* Dataset name and icon */}
-
-
-
{t('form.nameAndIcon', { ns: 'datasetSettings' })}
-
-
-
-
setName(e.target.value)}
- />
-
-
- {/* Dataset description */}
-
-
-
{t('form.desc', { ns: 'datasetSettings' })}
-
-
-
-
- {/* Permissions */}
-
-
-
{t('form.permissions', { ns: 'datasetSettings' })}
-
-
-
setPermission(v)}
- onMemberSelect={setSelectedMemberIDs}
- memberList={memberList}
- />
-
-
- {
- !!currentDataset?.doc_form && (
- <>
-
- {/* Chunk Structure */}
-
-
-
- {t('form.chunkStructure.title', { ns: 'datasetSettings' })}
-
-
-
-
-
-
-
- >
- )
- }
- {!!(isShowIndexMethod || indexMethod === 'high_quality') && (
-
- )}
- {!!isShowIndexMethod && (
-
-
-
{t('form.indexMethod', { ns: 'datasetSettings' })}
-
-
-
setIndexMethod(v!)}
- currentValue={currentDataset.indexing_technique}
- keywordNumber={keywordNumber}
- onKeywordNumberChange={setKeywordNumber}
- />
- {currentDataset.indexing_technique === IndexingType.ECONOMICAL && indexMethod === IndexingType.QUALIFIED && (
-
-
-
-
-
-
- {t('form.upgradeHighQualityTip', { ns: 'datasetSettings' })}
-
-
- )}
-
-
- )}
- {indexMethod === IndexingType.QUALIFIED && (
-
-
-
- {t('form.embeddingModel', { ns: 'datasetSettings' })}
-
-
-
-
-
-
- )}
- {
- indexMethod === IndexingType.QUALIFIED
- && [ChunkingMode.text, ChunkingMode.parentChild].includes(currentDataset?.doc_form as ChunkingMode)
- && (
- <>
-
-
- >
- )
- }
- {/* Retrieval Method Config */}
- {currentDataset?.provider === 'external'
- ? (
- <>
-
-
-
-
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
-
-
-
-
-
-
-
{t('form.externalKnowledgeAPI', { ns: 'datasetSettings' })}
-
-
-
-
-
- {currentDataset?.external_knowledge_info.external_knowledge_api_name}
-
-
ยท
-
- {currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}
-
-
-
-
-
-
-
{t('form.externalKnowledgeID', { ns: 'datasetSettings' })}
-
-
-
-
- {currentDataset?.external_knowledge_info.external_knowledge_id}
-
-
-
-
- >
- )
-
- : indexMethod
- ? (
- <>
-
-
-
-
-
- {t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
-
-
-
-
-
- {indexMethod === IndexingType.QUALIFIED
- ? (
-
- )
- : (
-
- )}
-
-
- >
- )
- : null}
-
-
-
+
+ {isExternalProvider
+ ? (
+
+ )
+ : (
+
+ )}
+
+
+
+ {/* Save Button */}
+
- {showAppIconPicker && (
-
- )}
)
}
diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json
index 90632b9ff4..fe03648335 100644
--- a/web/eslint-suppressions.json
+++ b/web/eslint-suppressions.json
@@ -1880,14 +1880,6 @@
"count": 1
}
},
- "app/components/datasets/settings/form/index.tsx": {
- "react-hooks-extra/no-direct-set-state-in-use-effect": {
- "count": 2
- },
- "ts/no-explicit-any": {
- "count": 1
- }
- },
"app/components/datasets/settings/permission-selector/index.tsx": {
"react/no-missing-key": {
"count": 1