diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx index 60da0e7c9f..e0d33b6a40 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx @@ -11,21 +11,18 @@ import { recursivePushInParentDescendants } from './utils' // Note: react-i18next uses global mock from web/vitest.setup.ts -// Mock react-window FixedSizeList - renders items directly for testing -vi.mock('react-window', () => ({ - FixedSizeList: ({ children: ItemComponent, itemCount, itemData, itemKey }: any) => ( -
- {Array.from({ length: itemCount }).map((_, index) => ( - - ))} -
- ), - areEqual: (prevProps: any, nextProps: any) => prevProps === nextProps, +// Mock @tanstack/react-virtual useVirtualizer hook - renders items directly for testing +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count, getItemKey }: { count: number, getItemKey?: (index: number) => string }) => ({ + getVirtualItems: () => + Array.from({ length: count }).map((_, index) => ({ + index, + key: getItemKey ? getItemKey(index) : index, + start: index * 28, + size: 28, + })), + getTotalSize: () => count * 28, + }), })) // Note: NotionIcon from @/app/components/base/ is NOT mocked - using real component per testing guidelines @@ -119,7 +116,7 @@ describe('PageSelector', () => { render() // Assert - expect(screen.getByTestId('virtual-list')).toBeInTheDocument() + expect(screen.getByText('Test Page')).toBeInTheDocument() }) it('should render empty state when list is empty', () => { @@ -134,7 +131,7 @@ describe('PageSelector', () => { // Assert expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() - expect(screen.queryByTestId('virtual-list')).not.toBeInTheDocument() + expect(screen.queryByText('Test Page')).not.toBeInTheDocument() }) it('should render items using FixedSizeList', () => { @@ -1166,7 +1163,7 @@ describe('PageSelector', () => { render() // Assert - expect(screen.getByTestId('virtual-list')).toBeInTheDocument() + expect(screen.getByText('Test Page')).toBeInTheDocument() }) it('should handle special characters in page name', () => { @@ -1340,7 +1337,7 @@ describe('PageSelector', () => { render() // Assert - expect(screen.getByTestId('virtual-list')).toBeInTheDocument() + expect(screen.getByText('Test Page')).toBeInTheDocument() if (propVariation.canPreview) expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() else diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx index 2ea0bd3112..eb38eb2dc4 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx @@ -1,7 +1,7 @@ import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useVirtualizer } from '@tanstack/react-virtual' +import { useCallback, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { FixedSizeList as List } from 'react-window' import Item from './item' import { recursivePushInParentDescendants } from './utils' @@ -45,29 +45,16 @@ const PageSelector = ({ currentCredentialId, }: PageSelectorProps) => { const { t } = useTranslation() - const [dataList, setDataList] = useState([]) + const parentRef = useRef(null) + const [expandedIds, setExpandedIds] = useState>(() => new Set()) const [currentPreviewPageId, setCurrentPreviewPageId] = useState('') + const prevCredentialIdRef = useRef(currentCredentialId) - useEffect(() => { - setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => { - return { - ...item, - expand: false, - depth: 0, - } - })) - }, [currentCredentialId]) - - const searchDataList = list.filter((item) => { - return item.page_name.includes(searchValue) - }).map((item) => { - return { - ...item, - expand: false, - depth: 0, - } - }) - const currentDataList = searchValue ? searchDataList : dataList + // Reset expanded state when credential changes (render-time detection) + if (prevCredentialIdRef.current !== currentCredentialId) { + prevCredentialIdRef.current = currentCredentialId + setExpandedIds(new Set()) + } const listMapWithChildrenAndDescendants = useMemo(() => { return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => { @@ -80,34 +67,72 @@ const PageSelector = ({ }, {}) }, [list, pagesMap]) + // Compute visible data list based on expanded state + const dataList = useMemo(() => { + const result: NotionPageItem[] = [] + + const buildVisibleList = (parentId: string | null, depth: number) => { + const items = parentId === null + ? list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]) + : list.filter(item => item.parent_id === parentId) + + for (const item of items) { + const isExpanded = expandedIds.has(item.page_id) + result.push({ + ...item, + expand: isExpanded, + depth, + }) + if (isExpanded) { + buildVisibleList(item.page_id, depth + 1) + } + } + } + + buildVisibleList(null, 0) + return result + }, [list, pagesMap, expandedIds]) + + const searchDataList = useMemo(() => list.filter((item) => { + return item.page_name.includes(searchValue) + }).map((item) => { + return { + ...item, + expand: false, + depth: 0, + } + }), [list, searchValue]) + + const currentDataList = searchValue ? searchDataList : dataList + + const virtualizer = useVirtualizer({ + count: currentDataList.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 28, + overscan: 5, + getItemKey: index => currentDataList[index].page_id, + }) + const handleToggle = useCallback((index: number) => { const current = dataList[index] const pageId = current.page_id const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId] - const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants) - const childrenIds = Array.from(currentWithChildrenAndDescendants.children) - let newDataList = [] - if (current.expand) { - current.expand = false - - newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id)) - } - else { - current.expand = true - - newDataList = [ - ...dataList.slice(0, index + 1), - ...childrenIds.map(item => ({ - ...pagesMap[item], - expand: false, - depth: listMapWithChildrenAndDescendants[item].depth, - })), - ...dataList.slice(index + 1), - ] - } - setDataList(newDataList) - }, [dataList, listMapWithChildrenAndDescendants, pagesMap]) + setExpandedIds((prev) => { + const next = new Set(prev) + if (prev.has(pageId)) { + // Collapse: remove current and all descendants + next.delete(pageId) + for (const descendantId of currentWithChildrenAndDescendants.descendants) + next.delete(descendantId) + } + else { + // Expand: add current + next.add(pageId) + } + return next + }) + }, [dataList, listMapWithChildrenAndDescendants]) const handleCheck = useCallback((index: number) => { const copyValue = new Set(checkedIds) @@ -160,30 +185,43 @@ const PageSelector = ({ } return ( - data.dataList[index].page_id} - itemData={{ - dataList: currentDataList, - handleToggle, - checkedIds, - disabledCheckedIds: disabledValue, - handleCheck, - canPreview, - handlePreview, - listMapWithChildrenAndDescendants, - searchValue, - previewPageId: currentPreviewPageId, - pagesMap, - isMultipleChoice, - }} + style={{ height: 296, width: '100%', overflow: 'auto' }} > - {Item} - +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const current = currentDataList[virtualRow.index] + return ( + + ) + })} +
+ ) } diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/item.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/item.tsx index 29e4143e0c..0221062fbd 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/item.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/item.tsx @@ -1,9 +1,7 @@ -import type { ListChildComponentProps } from 'react-window' import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' -import * as React from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { areEqual } from 'react-window' import Checkbox from '@/app/components/base/checkbox' import NotionIcon from '@/app/components/base/notion-icon' import Radio from '@/app/components/base/radio/ui' @@ -23,8 +21,11 @@ type NotionPageItem = { depth: number } & DataSourceNotionPage -const Item = ({ index, style, data }: ListChildComponentProps<{ - dataList: NotionPageItem[] +type ItemProps = { + index: number + virtualStart: number + virtualSize: number + current: NotionPageItem handleToggle: (index: number) => void checkedIds: Set disabledCheckedIds: Set @@ -36,23 +37,26 @@ const Item = ({ index, style, data }: ListChildComponentProps<{ previewPageId: string pagesMap: DataSourceNotionPageMap isMultipleChoice?: boolean -}>) => { +} + +const Item = ({ + index, + virtualStart, + virtualSize, + current, + handleToggle, + checkedIds, + disabledCheckedIds, + handleCheck, + canPreview, + handlePreview, + listMapWithChildrenAndDescendants, + searchValue, + previewPageId, + pagesMap, + isMultipleChoice, +}: ItemProps) => { const { t } = useTranslation() - const { - dataList, - handleToggle, - checkedIds, - disabledCheckedIds, - handleCheck, - canPreview, - handlePreview, - listMapWithChildrenAndDescendants, - searchValue, - previewPageId, - pagesMap, - isMultipleChoice, - } = data - const current = dataList[index] const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id] const hasChild = currentWithChildrenAndDescendants.descendants.size > 0 const ancestors = currentWithChildrenAndDescendants.ancestors @@ -88,7 +92,15 @@ const Item = ({ index, style, data }: ListChildComponentProps<{ return (
{isMultipleChoice ? ( @@ -149,4 +161,4 @@ const Item = ({ index, style, data }: ListChildComponentProps<{ ) } -export default React.memo(Item, areEqual) +export default memo(Item) diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index 12cc3ec896..6d5c94f409 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1707,12 +1707,7 @@ }, "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx": { "ts/no-explicit-any": { - "count": 5 - } - }, - "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 1 + "count": 2 } }, "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx": { diff --git a/web/package.json b/web/package.json index f224a38351..463ea11894 100644 --- a/web/package.json +++ b/web/package.json @@ -136,7 +136,6 @@ "react-sortablejs": "6.1.4", "react-syntax-highlighter": "15.6.6", "react-textarea-autosize": "8.5.9", - "react-window": "1.8.11", "reactflow": "11.11.4", "rehype-katex": "7.0.1", "rehype-raw": "7.0.0", @@ -193,7 +192,6 @@ "@types/react-dom": "19.2.3", "@types/react-slider": "1.3.6", "@types/react-syntax-highlighter": "15.5.13", - "@types/react-window": "1.8.8", "@types/semver": "7.7.1", "@types/sortablejs": "1.15.8", "@types/uuid": "10.0.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 3c4bdd91f5..0f88b6499a 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -303,9 +303,6 @@ importers: react-textarea-autosize: specifier: 8.5.9 version: 8.5.9(@types/react@19.2.7)(react@19.2.3) - react-window: - specifier: 1.8.11 - version: 1.8.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3) reactflow: specifier: 11.11.4 version: 11.11.4(@types/react@19.2.7)(immer@11.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -469,9 +466,6 @@ importers: '@types/react-syntax-highlighter': specifier: 15.5.13 version: 15.5.13 - '@types/react-window': - specifier: 1.8.8 - version: 1.8.8 '@types/semver': specifier: 7.7.1 version: 7.7.1 @@ -3746,9 +3740,6 @@ packages: '@types/react-syntax-highlighter@15.5.13': resolution: {integrity: sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==} - '@types/react-window@1.8.8': - resolution: {integrity: sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==} - '@types/react@19.2.7': resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} @@ -6400,9 +6391,6 @@ packages: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} - memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} - merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -7357,13 +7345,6 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-window@1.8.11: - resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} - engines: {node: '>8.0.0'} - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -12179,10 +12160,6 @@ snapshots: dependencies: '@types/react': 19.2.7 - '@types/react-window@1.8.8': - dependencies: - '@types/react': 19.2.7 - '@types/react@19.2.7': dependencies: csstype: 3.2.3 @@ -15375,8 +15352,6 @@ snapshots: dependencies: fs-monkey: 1.1.0 - memoize-one@5.2.1: {} - merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -16556,13 +16531,6 @@ snapshots: transitivePeerDependencies: - '@types/react' - react-window@1.8.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - '@babel/runtime': 7.28.4 - memoize-one: 5.2.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react@19.2.3: {} reactflow@11.11.4(@types/react@19.2.7)(immer@11.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):