diff --git a/web/app/components/datasets/documents/components/documents-header.tsx b/web/app/components/datasets/documents/components/documents-header.tsx new file mode 100644 index 0000000000..b54bfe8859 --- /dev/null +++ b/web/app/components/datasets/documents/components/documents-header.tsx @@ -0,0 +1,200 @@ +'use client' +import type { FC } from 'react' +import type { Item } from '@/app/components/base/select' +import type { BuiltInMetadataItem, MetadataItemWithValueLength } from '@/app/components/datasets/metadata/types' +import type { SortType } from '@/service/datasets' +import { PlusIcon } from '@heroicons/react/24/solid' +import { RiDraftLine, RiExternalLinkLine } from '@remixicon/react' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Chip from '@/app/components/base/chip' +import Input from '@/app/components/base/input' +import Sort from '@/app/components/base/sort' +import AutoDisabledDocument from '@/app/components/datasets/common/document-status-with-action/auto-disabled-document' +import IndexFailed from '@/app/components/datasets/common/document-status-with-action/index-failed' +import StatusWithAction from '@/app/components/datasets/common/document-status-with-action/status-with-action' +import DatasetMetadataDrawer from '@/app/components/datasets/metadata/metadata-dataset/dataset-metadata-drawer' +import { useDocLink } from '@/context/i18n' +import { DataSourceType } from '@/models/datasets' +import { useIndexStatus } from '../status-item/hooks' + +type DocumentsHeaderProps = { + // Dataset info + datasetId: string + dataSourceType?: DataSourceType + embeddingAvailable: boolean + isFreePlan: boolean + + // Filter & sort + statusFilterValue: string + sortValue: SortType + inputValue: string + onStatusFilterChange: (value: string) => void + onStatusFilterClear: () => void + onSortChange: (value: string) => void + onInputChange: (value: string) => void + + // Metadata modal + isShowEditMetadataModal: boolean + showEditMetadataModal: () => void + hideEditMetadataModal: () => void + datasetMetaData?: MetadataItemWithValueLength[] + builtInMetaData?: BuiltInMetadataItem[] + builtInEnabled: boolean + onAddMetaData: (payload: BuiltInMetadataItem) => Promise + onRenameMetaData: (payload: MetadataItemWithValueLength) => Promise + onDeleteMetaData: (metaDataId: string) => Promise + onBuiltInEnabledChange: (enabled: boolean) => void + + // Actions + onAddDocument: () => void +} + +const DocumentsHeader: FC = ({ + datasetId, + dataSourceType, + embeddingAvailable, + isFreePlan, + statusFilterValue, + sortValue, + inputValue, + onStatusFilterChange, + onStatusFilterClear, + onSortChange, + onInputChange, + isShowEditMetadataModal, + showEditMetadataModal, + hideEditMetadataModal, + datasetMetaData, + builtInMetaData, + builtInEnabled, + onAddMetaData, + onRenameMetaData, + onDeleteMetaData, + onBuiltInEnabledChange, + onAddDocument, +}) => { + const { t } = useTranslation() + const docLink = useDocLink() + const DOC_INDEX_STATUS_MAP = useIndexStatus() + + const isDataSourceNotion = dataSourceType === DataSourceType.NOTION + const isDataSourceWeb = dataSourceType === DataSourceType.WEB + + const statusFilterItems: Item[] = useMemo(() => [ + { value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) as string }, + { value: 'queuing', name: DOC_INDEX_STATUS_MAP.queuing.text }, + { value: 'indexing', name: DOC_INDEX_STATUS_MAP.indexing.text }, + { value: 'paused', name: DOC_INDEX_STATUS_MAP.paused.text }, + { value: 'error', name: DOC_INDEX_STATUS_MAP.error.text }, + { value: 'available', name: DOC_INDEX_STATUS_MAP.available.text }, + { value: 'enabled', name: DOC_INDEX_STATUS_MAP.enabled.text }, + { value: 'disabled', name: DOC_INDEX_STATUS_MAP.disabled.text }, + { value: 'archived', name: DOC_INDEX_STATUS_MAP.archived.text }, + ], [DOC_INDEX_STATUS_MAP, t]) + + const sortItems: Item[] = useMemo(() => [ + { value: 'created_at', name: t('list.sort.uploadTime', { ns: 'datasetDocuments' }) as string }, + { value: 'hit_count', name: t('list.sort.hitCount', { ns: 'datasetDocuments' }) as string }, + ], [t]) + + // Determine add button text based on data source type + const addButtonText = useMemo(() => { + if (isDataSourceNotion) + return t('list.addPages', { ns: 'datasetDocuments' }) + if (isDataSourceWeb) + return t('list.addUrl', { ns: 'datasetDocuments' }) + return t('list.addFile', { ns: 'datasetDocuments' }) + }, [isDataSourceNotion, isDataSourceWeb, t]) + + return ( + <> + {/* Title section */} +
+

+ {t('list.title', { ns: 'datasetDocuments' })} +

+
+ {t('list.desc', { ns: 'datasetDocuments' })} + + {t('list.learnMore', { ns: 'datasetDocuments' })} + + +
+
+ + {/* Toolbar section */} +
+ {/* Left: Filters */} +
+ onStatusFilterChange(item?.value ? String(item.value) : '')} + onClear={onStatusFilterClear} + /> + onInputChange(e.target.value)} + onClear={() => onInputChange('')} + /> +
+ onSortChange(String(value))} + /> +
+ + {/* Right: Actions */} +
+ {!isFreePlan && } + + {!embeddingAvailable && ( + + )} + {embeddingAvailable && ( + + )} + {isShowEditMetadataModal && ( + + )} + {embeddingAvailable && ( + + )} +
+
+ + ) +} + +export default DocumentsHeader diff --git a/web/app/components/datasets/documents/components/empty-element.tsx b/web/app/components/datasets/documents/components/empty-element.tsx new file mode 100644 index 0000000000..40c4bbdb9e --- /dev/null +++ b/web/app/components/datasets/documents/components/empty-element.tsx @@ -0,0 +1,41 @@ +'use client' +import type { FC } from 'react' +import { PlusIcon } from '@heroicons/react/24/solid' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import s from '../style.module.css' +import { FolderPlusIcon, NotionIcon, ThreeDotsIcon } from './icons' + +type EmptyElementProps = { + canAdd: boolean + onClick: () => void + type?: 'upload' | 'sync' +} + +const EmptyElement: FC = ({ canAdd = true, onClick, type = 'upload' }) => { + const { t } = useTranslation() + return ( +
+
+
+ {type === 'upload' ? : } +
+ + {t('list.empty.title', { ns: 'datasetDocuments' })} + + +
+ {t(`list.empty.${type}.tip`, { ns: 'datasetDocuments' })} +
+ {type === 'upload' && canAdd && ( + + )} +
+
+ ) +} + +export default EmptyElement diff --git a/web/app/components/datasets/documents/components/icons.tsx b/web/app/components/datasets/documents/components/icons.tsx new file mode 100644 index 0000000000..6a862f12f0 --- /dev/null +++ b/web/app/components/datasets/documents/components/icons.tsx @@ -0,0 +1,34 @@ +import type * as React from 'react' + +export const FolderPlusIcon = ({ className }: React.SVGProps) => { + return ( + + + + ) +} + +export const ThreeDotsIcon = ({ className }: React.SVGProps) => { + return ( + + + + ) +} + +export const NotionIcon = ({ className }: React.SVGProps) => { + return ( + + + + + + + + + + + + + ) +} diff --git a/web/app/components/datasets/documents/list.tsx b/web/app/components/datasets/documents/components/list.tsx similarity index 97% rename from web/app/components/datasets/documents/list.tsx rename to web/app/components/datasets/documents/components/list.tsx index 5fd6cd3a70..2bf9c278c4 100644 --- a/web/app/components/datasets/documents/list.tsx +++ b/web/app/components/datasets/documents/components/list.tsx @@ -16,13 +16,16 @@ import * as React from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import Checkbox from '@/app/components/base/checkbox' +import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon' import NotionIcon from '@/app/components/base/notion-icon' import Pagination from '@/app/components/base/pagination' import Toast from '@/app/components/base/toast' import Tooltip from '@/app/components/base/tooltip' +import ChunkingModeLabel from '@/app/components/datasets/common/chunking-mode-label' import { normalizeStatusForQuery } from '@/app/components/datasets/documents/status-filter' import { extensionToFileType } from '@/app/components/datasets/hit-testing/utils/extension-to-file-type' import EditMetadataBatchModal from '@/app/components/datasets/metadata/edit-metadata-batch/modal' +import useBatchEditDocumentMetadata from '@/app/components/datasets/metadata/hooks/use-batch-edit-document-metadata' import { useDatasetDetailContextWithSelector as useDatasetDetailContext } from '@/context/dataset-detail' import useTimestamp from '@/hooks/use-timestamp' import { ChunkingMode, DataSourceType, DocumentActionType } from '@/models/datasets' @@ -31,14 +34,11 @@ import { useDocumentArchive, useDocumentBatchRetryIndex, useDocumentDelete, useD import { asyncRunSafe } from '@/utils' import { cn } from '@/utils/classnames' import { formatNumber } from '@/utils/format' -import FileTypeIcon from '../../base/file-uploader/file-type-icon' -import ChunkingModeLabel from '../common/chunking-mode-label' -import useBatchEditDocumentMetadata from '../metadata/hooks/use-batch-edit-document-metadata' -import BatchAction from './detail/completed/common/batch-action' +import BatchAction from '../detail/completed/common/batch-action' +import StatusItem from '../status-item' +import s from '../style.module.css' import Operations from './operations' import RenameModal from './rename-modal' -import StatusItem from './status-item' -import s from './style.module.css' export const renderTdValue = (value: string | number | null, isEmptyStyle = false) => { return ( diff --git a/web/app/components/datasets/documents/operations.tsx b/web/app/components/datasets/documents/components/operations.tsx similarity index 96% rename from web/app/components/datasets/documents/operations.tsx rename to web/app/components/datasets/documents/components/operations.tsx index 93afec6f8e..0d3c40c053 100644 --- a/web/app/components/datasets/documents/operations.tsx +++ b/web/app/components/datasets/documents/components/operations.tsx @@ -1,4 +1,4 @@ -import type { OperationName } from './types' +import type { OperationName } from '../types' import type { CommonResponse } from '@/models/common' import { RiArchive2Line, @@ -17,6 +17,12 @@ import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useContext } from 'use-context-selector' +import Confirm from '@/app/components/base/confirm' +import Divider from '@/app/components/base/divider' +import CustomPopover from '@/app/components/base/popover' +import Switch from '@/app/components/base/switch' +import { ToastContext } from '@/app/components/base/toast' +import Tooltip from '@/app/components/base/tooltip' import { DataSourceType, DocumentActionType } from '@/models/datasets' import { useDocumentArchive, @@ -31,14 +37,8 @@ import { } from '@/service/knowledge/use-document' import { asyncRunSafe } from '@/utils' import { cn } from '@/utils/classnames' -import Confirm from '../../base/confirm' -import Divider from '../../base/divider' -import CustomPopover from '../../base/popover' -import Switch from '../../base/switch' -import { ToastContext } from '../../base/toast' -import Tooltip from '../../base/tooltip' +import s from '../style.module.css' import RenameModal from './rename-modal' -import s from './style.module.css' type OperationsProps = { embeddingAvailable: boolean diff --git a/web/app/components/datasets/documents/rename-modal.tsx b/web/app/components/datasets/documents/components/rename-modal.tsx similarity index 97% rename from web/app/components/datasets/documents/rename-modal.tsx rename to web/app/components/datasets/documents/components/rename-modal.tsx index cf3b5a05a1..a119a2da9e 100644 --- a/web/app/components/datasets/documents/rename-modal.tsx +++ b/web/app/components/datasets/documents/components/rename-modal.tsx @@ -7,8 +7,8 @@ import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Modal from '@/app/components/base/modal' +import Toast from '@/app/components/base/toast' import { renameDocumentName } from '@/service/datasets' -import Toast from '../../base/toast' type Props = { datasetId: string diff --git a/web/app/components/datasets/documents/detail/index.tsx b/web/app/components/datasets/documents/detail/index.tsx index 3ded3f9fd4..ea2c453355 100644 --- a/web/app/components/datasets/documents/detail/index.tsx +++ b/web/app/components/datasets/documents/detail/index.tsx @@ -18,7 +18,7 @@ import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from ' import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' import { cn } from '@/utils/classnames' -import Operations from '../operations' +import Operations from '../components/operations' import StatusItem from '../status-item' import BatchModal from './batch-modal' import Completed from './completed' diff --git a/web/app/components/datasets/documents/hooks/use-documents-page-state.ts b/web/app/components/datasets/documents/hooks/use-documents-page-state.ts new file mode 100644 index 0000000000..d6528cd58d --- /dev/null +++ b/web/app/components/datasets/documents/hooks/use-documents-page-state.ts @@ -0,0 +1,197 @@ +import type { DocumentListResponse } from '@/models/datasets' +import type { SortType } from '@/service/datasets' +import { useDebounce, useDebounceFn } from 'ahooks' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { normalizeStatusForQuery, sanitizeStatusValue } from '../status-filter' +import useDocumentListQueryState from './use-document-list-query-state' + +/** + * Custom hook to manage documents page state including: + * - Search state (input value, debounced search value) + * - Filter state (status filter, sort value) + * - Pagination state (current page, limit) + * - Selection state (selected document ids) + * - Polling state (timer control for auto-refresh) + */ +export function useDocumentsPageState() { + const { query, updateQuery } = useDocumentListQueryState() + + // Search state + const [inputValue, setInputValue] = useState('') + const [searchValue, setSearchValue] = useState('') + const debouncedSearchValue = useDebounce(searchValue, { wait: 500 }) + + // Filter & sort state + const [statusFilterValue, setStatusFilterValue] = useState(() => sanitizeStatusValue(query.status)) + const [sortValue, setSortValue] = useState(query.sort) + const normalizedStatusFilterValue = useMemo( + () => normalizeStatusForQuery(statusFilterValue), + [statusFilterValue], + ) + + // Pagination state + const [currPage, setCurrPage] = useState(query.page - 1) + const [limit, setLimit] = useState(query.limit) + + // Selection state + const [selectedIds, setSelectedIds] = useState([]) + + // Polling state + const [timerCanRun, setTimerCanRun] = useState(true) + + // Initialize search value from URL on mount + useEffect(() => { + if (query.keyword) { + setInputValue(query.keyword) + setSearchValue(query.keyword) + } + }, []) // Only run on mount + + // Sync local state with URL query changes + useEffect(() => { + setCurrPage(query.page - 1) + setLimit(query.limit) + if (query.keyword !== searchValue) { + setInputValue(query.keyword) + setSearchValue(query.keyword) + } + setStatusFilterValue((prev) => { + const nextValue = sanitizeStatusValue(query.status) + return prev === nextValue ? prev : nextValue + }) + setSortValue(query.sort) + }, [query]) + + // Update URL when search changes + useEffect(() => { + if (debouncedSearchValue !== query.keyword) { + setCurrPage(0) + updateQuery({ keyword: debouncedSearchValue, page: 1 }) + } + }, [debouncedSearchValue, query.keyword, updateQuery]) + + // Clear selection when search changes + useEffect(() => { + if (searchValue !== query.keyword) + setSelectedIds([]) + }, [searchValue, query.keyword]) + + // Clear selection when status filter changes + useEffect(() => { + setSelectedIds([]) + }, [normalizedStatusFilterValue]) + + // Page change handler + const handlePageChange = useCallback((newPage: number) => { + setCurrPage(newPage) + updateQuery({ page: newPage + 1 }) + }, [updateQuery]) + + // Limit change handler + const handleLimitChange = useCallback((newLimit: number) => { + setLimit(newLimit) + setCurrPage(0) + updateQuery({ limit: newLimit, page: 1 }) + }, [updateQuery]) + + // Debounced search handler + const { run: handleSearch } = useDebounceFn(() => { + setSearchValue(inputValue) + }, { wait: 500 }) + + // Input change handler + const handleInputChange = useCallback((value: string) => { + setInputValue(value) + handleSearch() + }, [handleSearch]) + + // Status filter change handler + const handleStatusFilterChange = useCallback((value: string) => { + const selectedValue = sanitizeStatusValue(value) + setStatusFilterValue(selectedValue) + setCurrPage(0) + updateQuery({ status: selectedValue, page: 1 }) + }, [updateQuery]) + + // Status filter clear handler + const handleStatusFilterClear = useCallback(() => { + if (statusFilterValue === 'all') + return + setStatusFilterValue('all') + setCurrPage(0) + updateQuery({ status: 'all', page: 1 }) + }, [statusFilterValue, updateQuery]) + + // Sort change handler + const handleSortChange = useCallback((value: string) => { + const next = value as SortType + if (next === sortValue) + return + setSortValue(next) + setCurrPage(0) + updateQuery({ sort: next, page: 1 }) + }, [sortValue, updateQuery]) + + // Update polling state based on documents response + const updatePollingState = useCallback((documentsRes: DocumentListResponse | undefined) => { + if (!documentsRes?.data) + return + + let completedNum = 0 + documentsRes.data.forEach((documentItem) => { + const { indexing_status } = documentItem + const isEmbedded = indexing_status === 'completed' || indexing_status === 'paused' || indexing_status === 'error' + if (isEmbedded) + completedNum++ + }) + + const hasIncompleteDocuments = completedNum !== documentsRes.data.length + const transientStatuses = ['queuing', 'indexing', 'paused'] + const shouldForcePolling = normalizedStatusFilterValue === 'all' + ? false + : transientStatuses.includes(normalizedStatusFilterValue) + setTimerCanRun(shouldForcePolling || hasIncompleteDocuments) + }, [normalizedStatusFilterValue]) + + // Adjust page when total pages change + const adjustPageForTotal = useCallback((documentsRes: DocumentListResponse | undefined) => { + if (!documentsRes) + return + const totalPages = Math.ceil(documentsRes.total / limit) + if (totalPages < currPage + 1) + setCurrPage(totalPages === 0 ? 0 : totalPages - 1) + }, [limit, currPage]) + + return { + // Search state + inputValue, + searchValue, + debouncedSearchValue, + handleInputChange, + + // Filter & sort state + statusFilterValue, + sortValue, + normalizedStatusFilterValue, + handleStatusFilterChange, + handleStatusFilterClear, + handleSortChange, + + // Pagination state + currPage, + limit, + handlePageChange, + handleLimitChange, + + // Selection state + selectedIds, + setSelectedIds, + + // Polling state + timerCanRun, + updatePollingState, + adjustPageForTotal, + } +} + +export default useDocumentsPageState diff --git a/web/app/components/datasets/documents/index.tsx b/web/app/components/datasets/documents/index.tsx index dcfb0c4ab6..676e715f56 100644 --- a/web/app/components/datasets/documents/index.tsx +++ b/web/app/components/datasets/documents/index.tsx @@ -1,185 +1,55 @@ 'use client' import type { FC } from 'react' -import type { Item } from '@/app/components/base/select' -import type { SortType } from '@/service/datasets' -import { PlusIcon } from '@heroicons/react/24/solid' -import { RiDraftLine, RiExternalLinkLine } from '@remixicon/react' -import { useDebounce, useDebounceFn } from 'ahooks' import { useRouter } from 'next/navigation' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import Button from '@/app/components/base/button' -import Input from '@/app/components/base/input' +import { useCallback, useEffect } from 'react' import Loading from '@/app/components/base/loading' -import IndexFailed from '@/app/components/datasets/common/document-status-with-action/index-failed' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' -import { useDocLink } from '@/context/i18n' import { useProviderContext } from '@/context/provider-context' import { DataSourceType } from '@/models/datasets' import { useDocumentList, useInvalidDocumentDetail, useInvalidDocumentList } from '@/service/knowledge/use-document' import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment' import { useInvalid } from '@/service/use-base' -import { cn } from '@/utils/classnames' -import Chip from '../../base/chip' -import Sort from '../../base/sort' -import AutoDisabledDocument from '../common/document-status-with-action/auto-disabled-document' -import StatusWithAction from '../common/document-status-with-action/status-with-action' import useEditDocumentMetadata from '../metadata/hooks/use-edit-dataset-metadata' -import DatasetMetadataDrawer from '../metadata/metadata-dataset/dataset-metadata-drawer' -import useDocumentListQueryState from './hooks/use-document-list-query-state' -import List from './list' -import { normalizeStatusForQuery, sanitizeStatusValue } from './status-filter' -import { useIndexStatus } from './status-item/hooks' -import s from './style.module.css' - -const FolderPlusIcon = ({ className }: React.SVGProps) => { - return ( - - - - ) -} - -const ThreeDotsIcon = ({ className }: React.SVGProps) => { - return ( - - - - ) -} - -const NotionIcon = ({ className }: React.SVGProps) => { - return ( - - - - - - - - - - - - - ) -} - -const EmptyElement: FC<{ canAdd: boolean, onClick: () => void, type?: 'upload' | 'sync' }> = ({ canAdd = true, onClick, type = 'upload' }) => { - const { t } = useTranslation() - return ( -
-
-
- {type === 'upload' ? : } -
- - {t('list.empty.title', { ns: 'datasetDocuments' })} - - -
- {t(`list.empty.${type}.tip`, { ns: 'datasetDocuments' })} -
- {type === 'upload' && canAdd && ( - - )} -
-
- ) -} +import DocumentsHeader from './components/documents-header' +import EmptyElement from './components/empty-element' +import List from './components/list' +import useDocumentsPageState from './hooks/use-documents-page-state' type IDocumentsProps = { datasetId: string } const Documents: FC = ({ datasetId }) => { - const { t } = useTranslation() - const docLink = useDocLink() + const router = useRouter() const { plan } = useProviderContext() const isFreePlan = plan.type === 'sandbox' - const { query, updateQuery } = useDocumentListQueryState() - const [inputValue, setInputValue] = useState('') // the input value - const [searchValue, setSearchValue] = useState('') - const [statusFilterValue, setStatusFilterValue] = useState(() => sanitizeStatusValue(query.status)) - const [sortValue, setSortValue] = useState(query.sort) - const DOC_INDEX_STATUS_MAP = useIndexStatus() - const [currPage, setCurrPage] = React.useState(query.page - 1) // Convert to 0-based index - const [limit, setLimit] = useState(query.limit) - const router = useRouter() const dataset = useDatasetDetailContextWithSelector(s => s.dataset) - const [timerCanRun, setTimerCanRun] = useState(true) - const isDataSourceNotion = dataset?.data_source_type === DataSourceType.NOTION - const isDataSourceWeb = dataset?.data_source_type === DataSourceType.WEB - const isDataSourceFile = dataset?.data_source_type === DataSourceType.FILE const embeddingAvailable = !!dataset?.embedding_available - const debouncedSearchValue = useDebounce(searchValue, { wait: 500 }) - const statusFilterItems: Item[] = useMemo(() => [ - { value: 'all', name: t('list.index.all', { ns: 'datasetDocuments' }) as string }, - { value: 'queuing', name: DOC_INDEX_STATUS_MAP.queuing.text }, - { value: 'indexing', name: DOC_INDEX_STATUS_MAP.indexing.text }, - { value: 'paused', name: DOC_INDEX_STATUS_MAP.paused.text }, - { value: 'error', name: DOC_INDEX_STATUS_MAP.error.text }, - { value: 'available', name: DOC_INDEX_STATUS_MAP.available.text }, - { value: 'enabled', name: DOC_INDEX_STATUS_MAP.enabled.text }, - { value: 'disabled', name: DOC_INDEX_STATUS_MAP.disabled.text }, - { value: 'archived', name: DOC_INDEX_STATUS_MAP.archived.text }, - ], [DOC_INDEX_STATUS_MAP, t]) - const normalizedStatusFilterValue = useMemo(() => normalizeStatusForQuery(statusFilterValue), [statusFilterValue]) - const sortItems: Item[] = useMemo(() => [ - { value: 'created_at', name: t('list.sort.uploadTime', { ns: 'datasetDocuments' }) as string }, - { value: 'hit_count', name: t('list.sort.hitCount', { ns: 'datasetDocuments' }) as string }, - ], [t]) - - // Initialize search value from URL on mount - useEffect(() => { - if (query.keyword) { - setInputValue(query.keyword) - setSearchValue(query.keyword) - } - }, []) // Only run on mount - - // Sync local state with URL query changes - useEffect(() => { - setCurrPage(query.page - 1) - setLimit(query.limit) - if (query.keyword !== searchValue) { - setInputValue(query.keyword) - setSearchValue(query.keyword) - } - setStatusFilterValue((prev) => { - const nextValue = sanitizeStatusValue(query.status) - return prev === nextValue ? prev : nextValue - }) - setSortValue(query.sort) - }, [query]) - - // Update URL when pagination changes - const handlePageChange = (newPage: number) => { - setCurrPage(newPage) - updateQuery({ page: newPage + 1 }) // Pagination emits 0-based page, convert to 1-based for URL - } - - // Update URL when limit changes - const handleLimitChange = (newLimit: number) => { - setLimit(newLimit) - setCurrPage(0) // Reset to first page when limit changes - updateQuery({ limit: newLimit, page: 1 }) - } - - // Update URL when search changes - useEffect(() => { - if (debouncedSearchValue !== query.keyword) { - setCurrPage(0) // Reset to first page when search changes - updateQuery({ keyword: debouncedSearchValue, page: 1 }) - } - }, [debouncedSearchValue, query.keyword, updateQuery]) + // Use custom hook for page state management + const { + inputValue, + debouncedSearchValue, + handleInputChange, + statusFilterValue, + sortValue, + normalizedStatusFilterValue, + handleStatusFilterChange, + handleStatusFilterClear, + handleSortChange, + currPage, + limit, + handlePageChange, + handleLimitChange, + selectedIds, + setSelectedIds, + timerCanRun, + updatePollingState, + adjustPageForTotal, + } = useDocumentsPageState() + // Fetch document list const { data: documentsRes, isLoading: isListLoading } = useDocumentList({ datasetId, query: { @@ -192,16 +62,18 @@ const Documents: FC = ({ datasetId }) => { refetchInterval: timerCanRun ? 2500 : 0, }) - const invalidDocumentList = useInvalidDocumentList(datasetId) - + // Update polling state when documents change useEffect(() => { - if (documentsRes) { - const totalPages = Math.ceil(documentsRes.total / limit) - if (totalPages < currPage + 1) - setCurrPage(totalPages === 0 ? 0 : totalPages - 1) - } - }, [documentsRes]) + updatePollingState(documentsRes) + }, [documentsRes, updatePollingState]) + // Adjust page when total changes + useEffect(() => { + adjustPageForTotal(documentsRes) + }, [documentsRes, adjustPageForTotal]) + + // Invalidation hooks + const invalidDocumentList = useInvalidDocumentList(datasetId) const invalidDocumentDetail = useInvalidDocumentDetail() const invalidChunkList = useInvalid(useSegmentListKey) const invalidChildChunkList = useInvalid(useChildSegmentListKey) @@ -213,73 +85,9 @@ const Documents: FC = ({ datasetId }) => { invalidChunkList() invalidChildChunkList() }, 5000) - }, []) - - useEffect(() => { - let completedNum = 0 - let percent = 0 - documentsRes?.data?.forEach((documentItem) => { - const { indexing_status, completed_segments, total_segments } = documentItem - const isEmbedded = indexing_status === 'completed' || indexing_status === 'paused' || indexing_status === 'error' - - if (isEmbedded) - completedNum++ - - const completedCount = completed_segments || 0 - const totalCount = total_segments || 0 - if (totalCount === 0 && completedCount === 0) { - percent = isEmbedded ? 100 : 0 - } - else { - const per = Math.round(completedCount * 100 / totalCount) - percent = per > 100 ? 100 : per - } - return { - ...documentItem, - percent, - } - }) - - const hasIncompleteDocuments = completedNum !== documentsRes?.data?.length - const transientStatuses = ['queuing', 'indexing', 'paused'] - const shouldForcePolling = normalizedStatusFilterValue === 'all' - ? false - : transientStatuses.includes(normalizedStatusFilterValue) - setTimerCanRun(shouldForcePolling || hasIncompleteDocuments) - }, [documentsRes, normalizedStatusFilterValue]) - const total = documentsRes?.total || 0 - - const routeToDocCreate = () => { - // if dataset is created from pipeline, go to create from pipeline page - if (dataset?.runtime_mode === 'rag_pipeline') { - router.push(`/datasets/${datasetId}/documents/create-from-pipeline`) - return - } - router.push(`/datasets/${datasetId}/documents/create`) - } - - const documentsList = documentsRes?.data - const [selectedIds, setSelectedIds] = useState([]) - - // Clear selection when search changes to avoid confusion - useEffect(() => { - if (searchValue !== query.keyword) - setSelectedIds([]) - }, [searchValue, query.keyword]) - - useEffect(() => { - setSelectedIds([]) - }, [normalizedStatusFilterValue]) - - const { run: handleSearch } = useDebounceFn(() => { - setSearchValue(inputValue) - }, { wait: 500 }) - - const handleInputChange = (value: string) => { - setInputValue(value) - handleSearch() - } + }, [invalidDocumentList, invalidDocumentDetail, invalidChunkList, invalidChildChunkList]) + // Metadata editing hook const { isShowEditModal: isShowEditMetadataModal, showEditModal: showEditMetadataModal, @@ -297,130 +105,84 @@ const Documents: FC = ({ datasetId }) => { onUpdateDocList: invalidDocumentList, }) + // Route to document creation page + const routeToDocCreate = useCallback(() => { + if (dataset?.runtime_mode === 'rag_pipeline') { + router.push(`/datasets/${datasetId}/documents/create-from-pipeline`) + return + } + router.push(`/datasets/${datasetId}/documents/create`) + }, [dataset?.runtime_mode, datasetId, router]) + + const total = documentsRes?.total || 0 + const documentsList = documentsRes?.data + + // Render content based on loading and data state + const renderContent = () => { + if (isListLoading) + return + + if (total > 0) { + return ( + + ) + } + + const isDataSourceNotion = dataset?.data_source_type === DataSourceType.NOTION + return ( + + ) + } + return (
-
-

{t('list.title', { ns: 'datasetDocuments' })}

-
- {t('list.desc', { ns: 'datasetDocuments' })} - - {t('list.learnMore', { ns: 'datasetDocuments' })} - - -
-
+
-
-
- { - const selectedValue = sanitizeStatusValue(item?.value ? String(item.value) : '') - setStatusFilterValue(selectedValue) - setCurrPage(0) - updateQuery({ status: selectedValue, page: 1 }) - }} - onClear={() => { - if (statusFilterValue === 'all') - return - setStatusFilterValue('all') - setCurrPage(0) - updateQuery({ status: 'all', page: 1 }) - }} - /> - handleInputChange(e.target.value)} - onClear={() => handleInputChange('')} - /> -
- { - const next = String(value) as SortType - if (next === sortValue) - return - setSortValue(next) - setCurrPage(0) - updateQuery({ sort: next, page: 1 }) - }} - /> -
-
- {!isFreePlan && } - - {!embeddingAvailable && } - {embeddingAvailable && ( - - )} - {isShowEditMetadataModal && ( - - )} - {embeddingAvailable && ( - - )} -
-
- {isListLoading - ? - // eslint-disable-next-line sonarjs/no-nested-conditional - : total > 0 - ? ( - - ) - : ( - - )} + {renderContent()}
)