Compare commits

...

10 Commits

Author SHA1 Message Date
yyh
9ebc0cbe32 Merge branch 'main' into refactor/migrate-react-window-to-tanstack-virtual 2026-02-01 14:42:12 +08:00
yyh
e0554987c9 Merge remote-tracking branch 'origin/main' into refactor/migrate-react-window-to-tanstack-virtual
# Conflicts:
#	web/pnpm-lock.yaml
2026-01-30 18:10:09 +08:00
yyh
817cd53143 Merge remote-tracking branch 'origin/main' into refactor/migrate-react-window-to-tanstack-virtual
# Conflicts:
#	web/pnpm-lock.yaml
2026-01-29 12:34:49 +08:00
yyh
cc3ab30728 Merge branch 'main' into refactor/migrate-react-window-to-tanstack-virtual 2026-01-22 10:07:43 +08:00
yyh
e9462b7504 update 2026-01-21 16:26:20 +08:00
yyh
c5bd31b813 update 2026-01-21 16:22:01 +08:00
yyh
1a23951ae7 Merge remote-tracking branch 'origin/main' into refactor/migrate-react-window-to-tanstack-virtual 2026-01-21 16:17:20 +08:00
yyh
4d60a742dc update 2026-01-21 16:16:52 +08:00
yyh
8cf99a85cb migrate and remove react window 2026-01-21 15:52:18 +08:00
yyh
52a874df98 add tanstack react query and migrate page selector 2026-01-21 15:42:38 +08:00
7 changed files with 1240 additions and 1272 deletions

View File

@@ -1,9 +1,8 @@
import type { ListChildComponentProps } from 'react-window'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import { memo, useEffect, useMemo, useState } from 'react' import { useVirtualizer } from '@tanstack/react-virtual'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { areEqual, FixedSizeList as List } from 'react-window'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import Checkbox from '../../checkbox' import Checkbox from '../../checkbox'
import NotionIcon from '../../notion-icon' import NotionIcon from '../../notion-icon'
@@ -32,6 +31,22 @@ type NotionPageItem = {
depth: number depth: number
} & DataSourceNotionPage } & DataSourceNotionPage
type ItemProps = {
virtualStart: number
virtualSize: number
current: NotionPageItem
onToggle: (pageId: string) => void
checkedIds: Set<string>
disabledCheckedIds: Set<string>
onCheck: (pageId: string) => void
canPreview?: boolean
onPreview: (pageId: string) => void
listMapWithChildrenAndDescendants: NotionPageTreeMap
searchValue: string
previewPageId: string
pagesMap: DataSourceNotionPageMap
}
const recursivePushInParentDescendants = ( const recursivePushInParentDescendants = (
pagesMap: DataSourceNotionPageMap, pagesMap: DataSourceNotionPageMap,
listTreeMap: NotionPageTreeMap, listTreeMap: NotionPageTreeMap,
@@ -69,34 +84,22 @@ const recursivePushInParentDescendants = (
} }
} }
const ItemComponent = ({ index, style, data }: ListChildComponentProps<{ const ItemComponent = ({
dataList: NotionPageItem[] virtualStart,
handleToggle: (index: number) => void virtualSize,
checkedIds: Set<string> current,
disabledCheckedIds: Set<string> onToggle,
handleCheck: (index: number) => void checkedIds,
canPreview?: boolean disabledCheckedIds,
handlePreview: (index: number) => void onCheck,
listMapWithChildrenAndDescendants: NotionPageTreeMap canPreview,
searchValue: string onPreview,
previewPageId: string listMapWithChildrenAndDescendants,
pagesMap: DataSourceNotionPageMap searchValue,
}>) => { previewPageId,
pagesMap,
}: ItemProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const {
dataList,
handleToggle,
checkedIds,
disabledCheckedIds,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId,
pagesMap,
} = data
const current = dataList[index]
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id] const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
const hasChild = currentWithChildrenAndDescendants.descendants.size > 0 const hasChild = currentWithChildrenAndDescendants.descendants.size > 0
const ancestors = currentWithChildrenAndDescendants.ancestors const ancestors = currentWithChildrenAndDescendants.ancestors
@@ -109,7 +112,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
<div <div
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover" className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
style={{ marginLeft: current.depth * 8 }} style={{ marginLeft: current.depth * 8 }}
onClick={() => handleToggle(index)} onClick={() => onToggle(current.page_id)}
> >
{ {
current.expand current.expand
@@ -132,15 +135,21 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
return ( return (
<div <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')} 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)`,
}}
> >
<Checkbox <Checkbox
className="mr-2 shrink-0" className="mr-2 shrink-0"
checked={checkedIds.has(current.page_id)} checked={checkedIds.has(current.page_id)}
disabled={disabled} disabled={disabled}
onCheck={() => { onCheck={() => onCheck(current.page_id)}
handleCheck(index)
}}
/> />
{!searchValue && renderArrow()} {!searchValue && renderArrow()}
<NotionIcon <NotionIcon
@@ -160,7 +169,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px] font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex" hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex"
onClick={() => handlePreview(index)} onClick={() => onPreview(current.page_id)}
> >
{t('dataSource.notion.selector.preview', { ns: 'common' })} {t('dataSource.notion.selector.preview', { ns: 'common' })}
</div> </div>
@@ -179,7 +188,7 @@ const ItemComponent = ({ index, style, data }: ListChildComponentProps<{
</div> </div>
) )
} }
const Item = memo(ItemComponent, areEqual) const Item = memo(ItemComponent)
const PageSelector = ({ const PageSelector = ({
value, value,
@@ -193,31 +202,10 @@ const PageSelector = ({
onPreview, onPreview,
}: PageSelectorProps) => { }: PageSelectorProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const [dataList, setDataList] = useState<NotionPageItem[]>([]) const parentRef = useRef<HTMLDivElement>(null)
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set())
const [localPreviewPageId, setLocalPreviewPageId] = useState('') const [localPreviewPageId, setLocalPreviewPageId] = useState('')
useEffect(() => {
setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
}))
}, [list])
const searchDataList = list.filter((item) => {
return item.page_name.includes(searchValue)
}).map((item) => {
return {
...item,
expand: false,
depth: 0,
}
})
const currentDataList = searchValue ? searchDataList : dataList
const currentPreviewPageId = previewPageId === undefined ? localPreviewPageId : previewPageId
const listMapWithChildrenAndDescendants = useMemo(() => { const listMapWithChildrenAndDescendants = useMemo(() => {
return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => { return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
const pageId = next.page_id const pageId = next.page_id
@@ -229,47 +217,89 @@ const PageSelector = ({
}, {}) }, {})
}, [list, pagesMap]) }, [list, pagesMap])
const handleToggle = (index: number) => { const childrenByParent = useMemo(() => {
const current = dataList[index] const map = new Map<string | null, DataSourceNotionPage[]>()
const pageId = current.page_id for (const item of list) {
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId] const isRoot = item.parent_id === 'root' || !pagesMap[item.parent_id]
const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants) const parentKey = isRoot ? null : item.parent_id
const childrenIds = Array.from(currentWithChildrenAndDescendants.children) const children = map.get(parentKey) || []
let newDataList = [] children.push(item)
map.set(parentKey, children)
if (current.expand) {
current.expand = false
newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id))
} }
else { return map
current.expand = true }, [list, pagesMap])
newDataList = [ const dataList = useMemo(() => {
...dataList.slice(0, index + 1), const result: NotionPageItem[] = []
...childrenIds.map(item => ({
...pagesMap[item], const buildVisibleList = (parentId: string | null, depth: number) => {
expand: false, const items = childrenByParent.get(parentId) || []
depth: listMapWithChildrenAndDescendants[item].depth,
})), for (const item of items) {
...dataList.slice(index + 1), const isExpanded = expandedIds.has(item.page_id)
] result.push({
...item,
expand: isExpanded,
depth,
})
if (isExpanded) {
buildVisibleList(item.page_id, depth + 1)
}
}
} }
setDataList(newDataList)
}
const copyValue = new Set(value) buildVisibleList(null, 0)
const handleCheck = (index: number) => { return result
const current = currentDataList[index] }, [childrenByParent, expandedIds])
const pageId = current.page_id
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 currentPreviewPageId = previewPageId === undefined ? localPreviewPageId : previewPageId
const virtualizer = useVirtualizer({
count: currentDataList.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 28,
overscan: 5,
getItemKey: index => currentDataList[index].page_id,
})
const handleToggle = useCallback((pageId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (prev.has(pageId)) {
next.delete(pageId)
const descendants = listMapWithChildrenAndDescendants[pageId]?.descendants
if (descendants) {
for (const descendantId of descendants)
next.delete(descendantId)
}
}
else {
next.add(pageId)
}
return next
})
}, [listMapWithChildrenAndDescendants])
const handleCheck = useCallback((pageId: string) => {
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId] const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
const copyValue = new Set(value)
if (copyValue.has(pageId)) { if (copyValue.has(pageId)) {
if (!searchValue) { if (!searchValue) {
for (const item of currentWithChildrenAndDescendants.descendants) for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.delete(item) copyValue.delete(item)
} }
copyValue.delete(pageId) copyValue.delete(pageId)
} }
else { else {
@@ -277,22 +307,17 @@ const PageSelector = ({
for (const item of currentWithChildrenAndDescendants.descendants) for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.add(item) copyValue.add(item)
} }
copyValue.add(pageId) copyValue.add(pageId)
} }
onSelect(new Set(copyValue)) onSelect(copyValue)
} }, [listMapWithChildrenAndDescendants, onSelect, searchValue, value])
const handlePreview = (index: number) => {
const current = currentDataList[index]
const pageId = current.page_id
const handlePreview = useCallback((pageId: string) => {
setLocalPreviewPageId(pageId) setLocalPreviewPageId(pageId)
if (onPreview) if (onPreview)
onPreview(pageId) onPreview(pageId)
} }, [onPreview])
if (!currentDataList.length) { if (!currentDataList.length) {
return ( return (
@@ -303,29 +328,41 @@ const PageSelector = ({
} }
return ( return (
<List <div
ref={parentRef}
className="py-2" className="py-2"
height={296} style={{ height: 296, width: '100%', overflow: 'auto' }}
itemCount={currentDataList.length}
itemSize={28}
width="100%"
itemKey={(index, data) => data.dataList[index].page_id}
itemData={{
dataList: currentDataList,
handleToggle,
checkedIds: value,
disabledCheckedIds: disabledValue,
handleCheck,
canPreview,
handlePreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId: currentPreviewPageId,
pagesMap,
}}
> >
{Item} <div
</List> style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const current = currentDataList[virtualRow.index]
return (
<Item
key={virtualRow.key}
virtualStart={virtualRow.start}
virtualSize={virtualRow.size}
current={current}
onToggle={handleToggle}
checkedIds={value}
disabledCheckedIds={disabledValue}
onCheck={handleCheck}
canPreview={canPreview}
onPreview={handlePreview}
listMapWithChildrenAndDescendants={listMapWithChildrenAndDescendants}
searchValue={searchValue}
previewPageId={currentPreviewPageId}
pagesMap={pagesMap}
/>
)
})}
</div>
</div>
) )
} }

View File

@@ -11,21 +11,18 @@ import { recursivePushInParentDescendants } from './utils'
// Note: react-i18next uses global mock from web/vitest.setup.ts // Note: react-i18next uses global mock from web/vitest.setup.ts
// Mock react-window FixedSizeList - renders items directly for testing // Mock @tanstack/react-virtual useVirtualizer hook - renders items directly for testing
vi.mock('react-window', () => ({ vi.mock('@tanstack/react-virtual', () => ({
FixedSizeList: ({ children: ItemComponent, itemCount, itemData, itemKey }: any) => ( useVirtualizer: ({ count, getItemKey }: { count: number, getItemKey?: (index: number) => string }) => ({
<div data-testid="virtual-list"> getVirtualItems: () =>
{Array.from({ length: itemCount }).map((_, index) => ( Array.from({ length: count }).map((_, index) => ({
<ItemComponent index,
key={itemKey?.(index, itemData) || index} key: getItemKey ? getItemKey(index) : index,
index={index} start: index * 28,
style={{ top: index * 28, left: 0, right: 0, width: '100%', position: 'absolute' }} size: 28,
data={itemData} })),
/> getTotalSize: () => count * 28,
))} }),
</div>
),
areEqual: (prevProps: any, nextProps: any) => prevProps === nextProps,
})) }))
// Note: NotionIcon from @/app/components/base/ is NOT mocked - using real component per testing guidelines // Note: NotionIcon from @/app/components/base/ is NOT mocked - using real component per testing guidelines
@@ -119,7 +116,7 @@ describe('PageSelector', () => {
render(<PageSelector {...props} />) render(<PageSelector {...props} />)
// Assert // Assert
expect(screen.getByTestId('virtual-list')).toBeInTheDocument() expect(screen.getByText('Test Page')).toBeInTheDocument()
}) })
it('should render empty state when list is empty', () => { it('should render empty state when list is empty', () => {
@@ -134,7 +131,7 @@ describe('PageSelector', () => {
// Assert // Assert
expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() 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', () => { it('should render items using FixedSizeList', () => {
@@ -1166,7 +1163,7 @@ describe('PageSelector', () => {
render(<PageSelector {...props} />) render(<PageSelector {...props} />)
// Assert // Assert
expect(screen.getByTestId('virtual-list')).toBeInTheDocument() expect(screen.getByText('Test Page')).toBeInTheDocument()
}) })
it('should handle special characters in page name', () => { it('should handle special characters in page name', () => {
@@ -1340,7 +1337,7 @@ describe('PageSelector', () => {
render(<PageSelector {...props} />) render(<PageSelector {...props} />)
// Assert // Assert
expect(screen.getByTestId('virtual-list')).toBeInTheDocument() expect(screen.getByText('Test Page')).toBeInTheDocument()
if (propVariation.canPreview) if (propVariation.canPreview)
expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument()
else else

View File

@@ -1,7 +1,7 @@
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' 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 { useTranslation } from 'react-i18next'
import { FixedSizeList as List } from 'react-window'
import Item from './item' import Item from './item'
import { recursivePushInParentDescendants } from './utils' import { recursivePushInParentDescendants } from './utils'
@@ -45,29 +45,16 @@ const PageSelector = ({
currentCredentialId, currentCredentialId,
}: PageSelectorProps) => { }: PageSelectorProps) => {
const { t } = useTranslation() 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 [currentPreviewPageId, setCurrentPreviewPageId] = useState('')
const prevCredentialIdRef = useRef(currentCredentialId)
useEffect(() => { // Reset expanded state when credential changes (render-time detection)
setDataList(list.filter(item => item.parent_id === 'root' || !pagesMap[item.parent_id]).map((item) => { if (prevCredentialIdRef.current !== currentCredentialId) {
return { prevCredentialIdRef.current = currentCredentialId
...item, setExpandedIds(new Set())
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
const listMapWithChildrenAndDescendants = useMemo(() => { const listMapWithChildrenAndDescendants = useMemo(() => {
return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => { return list.reduce((prev: NotionPageTreeMap, next: DataSourceNotionPage) => {
@@ -80,39 +67,86 @@ const PageSelector = ({
}, {}) }, {})
}, [list, pagesMap]) }, [list, pagesMap])
const handleToggle = useCallback((index: number) => { // Pre-build children index for O(1) lookup instead of O(n) filter
const current = dataList[index] const childrenByParent = useMemo(() => {
const pageId = current.page_id const map = new Map<string | null, DataSourceNotionPage[]>()
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId] for (const item of list) {
const descendantsIds = Array.from(currentWithChildrenAndDescendants.descendants) const isRoot = item.parent_id === 'root' || !pagesMap[item.parent_id]
const childrenIds = Array.from(currentWithChildrenAndDescendants.children) const parentKey = isRoot ? null : item.parent_id
let newDataList = [] const children = map.get(parentKey) || []
children.push(item)
if (current.expand) { map.set(parentKey, children)
current.expand = false
newDataList = dataList.filter(item => !descendantsIds.includes(item.page_id))
} }
else { return map
current.expand = true }, [list, pagesMap])
newDataList = [ // Compute visible data list based on expanded state
...dataList.slice(0, index + 1), const dataList = useMemo(() => {
...childrenIds.map(item => ({ const result: NotionPageItem[] = []
...pagesMap[item],
expand: false, const buildVisibleList = (parentId: string | null, depth: number) => {
depth: listMapWithChildrenAndDescendants[item].depth, const items = childrenByParent.get(parentId) || []
})),
...dataList.slice(index + 1), 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)
}
}
} }
setDataList(newDataList)
}, [dataList, listMapWithChildrenAndDescendants, pagesMap])
const handleCheck = useCallback((index: number) => { buildVisibleList(null, 0)
return result
}, [childrenByParent, 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,
})
// Stable callback - no dependencies on dataList
const handleToggle = useCallback((pageId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (prev.has(pageId)) {
// Collapse: remove current and all descendants
next.delete(pageId)
const descendants = listMapWithChildrenAndDescendants[pageId]?.descendants
if (descendants) {
for (const descendantId of descendants)
next.delete(descendantId)
}
}
else {
next.add(pageId)
}
return next
})
}, [listMapWithChildrenAndDescendants])
// Stable callback - uses pageId parameter instead of index
const handleCheck = useCallback((pageId: string) => {
const copyValue = new Set(checkedIds) const copyValue = new Set(checkedIds)
const current = currentDataList[index]
const pageId = current.page_id
const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId] const currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[pageId]
if (copyValue.has(pageId)) { if (copyValue.has(pageId)) {
@@ -120,7 +154,6 @@ const PageSelector = ({
for (const item of currentWithChildrenAndDescendants.descendants) for (const item of currentWithChildrenAndDescendants.descendants)
copyValue.delete(item) copyValue.delete(item)
} }
copyValue.delete(pageId) copyValue.delete(pageId)
} }
else { else {
@@ -138,18 +171,15 @@ const PageSelector = ({
} }
} }
onSelect(new Set(copyValue)) onSelect(copyValue)
}, [currentDataList, isMultipleChoice, listMapWithChildrenAndDescendants, onSelect, searchValue, checkedIds]) }, [checkedIds, isMultipleChoice, listMapWithChildrenAndDescendants, onSelect, searchValue])
const handlePreview = useCallback((index: number) => {
const current = currentDataList[index]
const pageId = current.page_id
// Stable callback
const handlePreview = useCallback((pageId: string) => {
setCurrentPreviewPageId(pageId) setCurrentPreviewPageId(pageId)
if (onPreview) if (onPreview)
onPreview(pageId) onPreview(pageId)
}, [currentDataList, onPreview]) }, [onPreview])
if (!currentDataList.length) { if (!currentDataList.length) {
return ( return (
@@ -160,30 +190,42 @@ const PageSelector = ({
} }
return ( return (
<List <div
ref={parentRef}
className="py-2" className="py-2"
height={296} style={{ height: 296, width: '100%', overflow: 'auto' }}
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,
}}
> >
{Item} <div
</List> style={{
height: virtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const current = currentDataList[virtualRow.index]
return (
<Item
key={virtualRow.key}
virtualStart={virtualRow.start}
virtualSize={virtualRow.size}
current={current}
onToggle={handleToggle}
checkedIds={checkedIds}
disabledCheckedIds={disabledValue}
onCheck={handleCheck}
canPreview={canPreview}
onPreview={handlePreview}
listMapWithChildrenAndDescendants={listMapWithChildrenAndDescendants}
searchValue={searchValue}
previewPageId={currentPreviewPageId}
pagesMap={pagesMap}
isMultipleChoice={isMultipleChoice}
/>
)
})}
</div>
</div>
) )
} }

View File

@@ -1,9 +1,7 @@
import type { ListChildComponentProps } from 'react-window'
import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common'
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react' import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
import * as React from 'react' import { memo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { areEqual } from 'react-window'
import Checkbox from '@/app/components/base/checkbox' import Checkbox from '@/app/components/base/checkbox'
import NotionIcon from '@/app/components/base/notion-icon' import NotionIcon from '@/app/components/base/notion-icon'
import Radio from '@/app/components/base/radio/ui' import Radio from '@/app/components/base/radio/ui'
@@ -23,36 +21,40 @@ type NotionPageItem = {
depth: number depth: number
} & DataSourceNotionPage } & DataSourceNotionPage
const Item = ({ index, style, data }: ListChildComponentProps<{ type ItemProps = {
dataList: NotionPageItem[] virtualStart: number
handleToggle: (index: number) => void virtualSize: number
current: NotionPageItem
onToggle: (pageId: string) => void
checkedIds: Set<string> checkedIds: Set<string>
disabledCheckedIds: Set<string> disabledCheckedIds: Set<string>
handleCheck: (index: number) => void onCheck: (pageId: string) => void
canPreview?: boolean canPreview?: boolean
handlePreview: (index: number) => void onPreview: (pageId: string) => void
listMapWithChildrenAndDescendants: NotionPageTreeMap listMapWithChildrenAndDescendants: NotionPageTreeMap
searchValue: string searchValue: string
previewPageId: string previewPageId: string
pagesMap: DataSourceNotionPageMap pagesMap: DataSourceNotionPageMap
isMultipleChoice?: boolean isMultipleChoice?: boolean
}>) => { }
const Item = ({
virtualStart,
virtualSize,
current,
onToggle,
checkedIds,
disabledCheckedIds,
onCheck,
canPreview,
onPreview,
listMapWithChildrenAndDescendants,
searchValue,
previewPageId,
pagesMap,
isMultipleChoice,
}: ItemProps) => {
const { t } = useTranslation() 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 currentWithChildrenAndDescendants = listMapWithChildrenAndDescendants[current.page_id]
const hasChild = currentWithChildrenAndDescendants.descendants.size > 0 const hasChild = currentWithChildrenAndDescendants.descendants.size > 0
const ancestors = currentWithChildrenAndDescendants.ancestors const ancestors = currentWithChildrenAndDescendants.ancestors
@@ -65,7 +67,7 @@ const Item = ({ index, style, data }: ListChildComponentProps<{
<div <div
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover" className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-md hover:bg-components-button-ghost-bg-hover"
style={{ marginLeft: current.depth * 8 }} style={{ marginLeft: current.depth * 8 }}
onClick={() => handleToggle(index)} onClick={() => onToggle(current.page_id)}
> >
{ {
current.expand current.expand
@@ -88,7 +90,15 @@ const Item = ({ index, style, data }: ListChildComponentProps<{
return ( return (
<div <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')} 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 {isMultipleChoice
? ( ? (
@@ -96,9 +106,7 @@ const Item = ({ index, style, data }: ListChildComponentProps<{
className="mr-2 shrink-0" className="mr-2 shrink-0"
checked={checkedIds.has(current.page_id)} checked={checkedIds.has(current.page_id)}
disabled={disabled} disabled={disabled}
onCheck={() => { onCheck={() => onCheck(current.page_id)}
handleCheck(index)
}}
/> />
) )
: ( : (
@@ -106,9 +114,7 @@ const Item = ({ index, style, data }: ListChildComponentProps<{
className="mr-2 shrink-0" className="mr-2 shrink-0"
isChecked={checkedIds.has(current.page_id)} isChecked={checkedIds.has(current.page_id)}
disabled={disabled} disabled={disabled}
onCheck={() => { onCheck={() => onCheck(current.page_id)}
handleCheck(index)
}}
/> />
)} )}
{!searchValue && renderArrow()} {!searchValue && renderArrow()}
@@ -129,7 +135,7 @@ const Item = ({ index, style, data }: ListChildComponentProps<{
className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs className="ml-1 hidden h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-xs
font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px] font-medium leading-4 text-components-button-secondary-text shadow-xs shadow-shadow-shadow-3 backdrop-blur-[10px]
hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex" hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover group-hover:flex"
onClick={() => handlePreview(index)} onClick={() => onPreview(current.page_id)}
> >
{t('dataSource.notion.selector.preview', { ns: 'common' })} {t('dataSource.notion.selector.preview', { ns: 'common' })}
</div> </div>
@@ -149,4 +155,4 @@ const Item = ({ index, style, data }: ListChildComponentProps<{
) )
} }
export default React.memo(Item, areEqual) export default memo(Item)

View File

@@ -1399,11 +1399,6 @@
"count": 2 "count": 2
} }
}, },
"app/components/base/notion-page-selector/page-selector/index.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 1
}
},
"app/components/base/pagination/index.tsx": { "app/components/base/pagination/index.tsx": {
"unicorn/prefer-number-properties": { "unicorn/prefer-number-properties": {
"count": 1 "count": 1
@@ -1848,12 +1843,7 @@
}, },
"app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx": { "app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 5 "count": 2
}
},
"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
} }
}, },
"app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx": { "app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx": {

View File

@@ -84,6 +84,7 @@
"@tailwindcss/typography": "0.5.19", "@tailwindcss/typography": "0.5.19",
"@tanstack/react-form": "1.23.7", "@tanstack/react-form": "1.23.7",
"@tanstack/react-query": "5.90.5", "@tanstack/react-query": "5.90.5",
"@tanstack/react-virtual": "3.13.18",
"abcjs": "6.5.2", "abcjs": "6.5.2",
"ahooks": "3.9.5", "ahooks": "3.9.5",
"class-variance-authority": "0.7.1", "class-variance-authority": "0.7.1",
@@ -142,7 +143,6 @@
"react-sortablejs": "6.1.4", "react-sortablejs": "6.1.4",
"react-syntax-highlighter": "15.6.6", "react-syntax-highlighter": "15.6.6",
"react-textarea-autosize": "8.5.9", "react-textarea-autosize": "8.5.9",
"react-window": "1.8.11",
"reactflow": "11.11.4", "reactflow": "11.11.4",
"rehype-katex": "7.0.1", "rehype-katex": "7.0.1",
"rehype-raw": "7.0.0", "rehype-raw": "7.0.0",
@@ -199,7 +199,6 @@
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/react-slider": "1.3.6", "@types/react-slider": "1.3.6",
"@types/react-syntax-highlighter": "15.5.13", "@types/react-syntax-highlighter": "15.5.13",
"@types/react-window": "1.8.8",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@types/sortablejs": "1.15.8", "@types/sortablejs": "1.15.8",
"@types/uuid": "10.0.0", "@types/uuid": "10.0.0",

1905
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff