mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 22:28:55 +08:00
migrate and remove react window
This commit is contained in:
parent
52a874df98
commit
8cf99a85cb
@ -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) => (
|
||||
<div data-testid="virtual-list">
|
||||
{Array.from({ length: itemCount }).map((_, index) => (
|
||||
<ItemComponent
|
||||
key={itemKey?.(index, itemData) || index}
|
||||
index={index}
|
||||
style={{ top: index * 28, left: 0, right: 0, width: '100%', position: 'absolute' }}
|
||||
data={itemData}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
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(<PageSelector {...props} />)
|
||||
|
||||
// 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(<PageSelector {...props} />)
|
||||
|
||||
// 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(<PageSelector {...props} />)
|
||||
|
||||
// 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
|
||||
|
||||
@ -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<NotionPageItem[]>([])
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => 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 (
|
||||
<List
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="py-2"
|
||||
height={296}
|
||||
itemCount={currentDataList.length}
|
||||
itemSize={28}
|
||||
width="100%"
|
||||
itemKey={(index, data) => 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}
|
||||
</List>
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const current = currentDataList[virtualRow.index]
|
||||
return (
|
||||
<Item
|
||||
key={virtualRow.key}
|
||||
index={virtualRow.index}
|
||||
virtualStart={virtualRow.start}
|
||||
virtualSize={virtualRow.size}
|
||||
current={current}
|
||||
handleToggle={handleToggle}
|
||||
checkedIds={checkedIds}
|
||||
disabledCheckedIds={disabledValue}
|
||||
handleCheck={handleCheck}
|
||||
canPreview={canPreview}
|
||||
handlePreview={handlePreview}
|
||||
listMapWithChildrenAndDescendants={listMapWithChildrenAndDescendants}
|
||||
searchValue={searchValue}
|
||||
previewPageId={currentPreviewPageId}
|
||||
pagesMap={pagesMap}
|
||||
isMultipleChoice={isMultipleChoice}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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<string>
|
||||
disabledCheckedIds: Set<string>
|
||||
@ -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 (
|
||||
<div
|
||||
className={cn('group flex cursor-pointer items-center rounded-md pl-2 pr-[2px] hover:bg-state-base-hover', previewPageId === current.page_id && 'bg-state-base-hover')}
|
||||
style={{ ...style, top: style.top as number + 8, left: 8, right: 8, width: 'calc(100% - 16px)' }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 8,
|
||||
right: 8,
|
||||
width: 'calc(100% - 16px)',
|
||||
height: virtualSize,
|
||||
transform: `translateY(${virtualStart + 8}px)`,
|
||||
}}
|
||||
>
|
||||
{isMultipleChoice
|
||||
? (
|
||||
@ -149,4 +161,4 @@ const Item = ({ index, style, data }: ListChildComponentProps<{
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Item, areEqual)
|
||||
export default memo(Item)
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
32
web/pnpm-lock.yaml
generated
32
web/pnpm-lock.yaml
generated
@ -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):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user