feat: draggable on filtering (#8929)

feat: support dnd on filtering
This commit is contained in:
one 2025-08-08 00:39:10 +08:00 committed by GitHub
parent ff58efcbf3
commit 9129625365
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 252 additions and 15 deletions

View File

@ -3,7 +3,7 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DraggableList } from '../DraggableList'
import { DraggableList } from '../'
// mock @hello-pangea/dnd 组件
vi.mock('@hello-pangea/dnd', () => {

View File

@ -3,7 +3,7 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DraggableVirtualList from '../DraggableList/virtual-list'
import { DraggableVirtualList } from '../'
// Mock 依赖项
vi.mock('@hello-pangea/dnd', () => ({

View File

@ -0,0 +1,151 @@
import { DropResult } from '@hello-pangea/dnd'
import { act, renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useDraggableReorder } from '../useDraggableReorder'
// 辅助函数和模拟数据
const createMockItem = (id: number) => ({ id: `item-${id}`, name: `Item ${id}` })
const mockOriginalList = [createMockItem(1), createMockItem(2), createMockItem(3), createMockItem(4), createMockItem(5)]
/**
* DropResult
* @param sourceIndex -
* @param destIndex -
* @param draggableId - ID itemKey
*/
const createMockDropResult = (sourceIndex: number, destIndex: number | null, draggableId: string): DropResult => ({
reason: 'DROP',
source: { index: sourceIndex, droppableId: 'droppable' },
destination: destIndex !== null ? { index: destIndex, droppableId: 'droppable' } : null,
combine: null,
mode: 'FLUID',
draggableId,
type: 'DEFAULT'
})
describe('useDraggableReorder', () => {
describe('reorder', () => {
it('should correctly reorder the list when it is not filtered', () => {
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useDraggableReorder({
originalList: mockOriginalList,
filteredList: mockOriginalList, // 列表未过滤
onUpdate,
idKey: 'id'
})
)
// 模拟将第一项 (视图索引 0, 原始索引 0) 拖到第三项的位置 (视图索引 2)
// 在未过滤列表中itemKey(0) 返回 0
const dropResult = createMockDropResult(0, 2, '0')
act(() => {
result.current.onDragEnd(dropResult)
})
expect(onUpdate).toHaveBeenCalledTimes(1)
const newList = onUpdate.mock.calls[0][0]
// 原始: [1, 2, 3, 4, 5] -> 拖拽后预期: [2, 3, 1, 4, 5]
expect(newList.map((i) => i.id)).toEqual(['item-2', 'item-3', 'item-1', 'item-4', 'item-5'])
})
it('should correctly reorder the original list when the list is filtered', () => {
const onUpdate = vi.fn()
// 过滤后只剩下奇数项: [item-1, item-3, item-5]
const filteredList = [mockOriginalList[0], mockOriginalList[2], mockOriginalList[4]]
const { result } = renderHook(() =>
useDraggableReorder({
originalList: mockOriginalList,
filteredList,
onUpdate,
idKey: 'id'
})
)
// 在过滤后的列表中,将最后一项 'item-5' (视图索引 2) 拖到第一项 'item-1' (视图索引 0) 的位置
// 'item-5' 的原始索引是 4, 所以 itemKey(2) 返回 4
const dropResult = createMockDropResult(2, 0, '4')
act(() => {
result.current.onDragEnd(dropResult)
})
expect(onUpdate).toHaveBeenCalledTimes(1)
const newList = onUpdate.mock.calls[0][0]
// 原始: [1, 2, 3, 4, 5]
// 拖拽后预期: 'item-5' 移动到 'item-1' 的位置 -> [5, 1, 2, 3, 4]
expect(newList.map((i) => i.id)).toEqual(['item-5', 'item-1', 'item-2', 'item-3', 'item-4'])
})
})
describe('onUpdate', () => {
it('should not call onUpdate if destination is null', () => {
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useDraggableReorder({
originalList: mockOriginalList,
filteredList: mockOriginalList,
onUpdate,
idKey: 'id'
})
)
// 模拟拖拽到列表外
const dropResult = createMockDropResult(0, null, '0')
act(() => {
result.current.onDragEnd(dropResult)
})
expect(onUpdate).not.toHaveBeenCalled()
})
it('should not call onUpdate if source and destination are the same', () => {
const onUpdate = vi.fn()
const { result } = renderHook(() =>
useDraggableReorder({
originalList: mockOriginalList,
filteredList: mockOriginalList,
onUpdate,
idKey: 'id'
})
)
// 模拟拖拽后放回原位
const dropResult = createMockDropResult(1, 1, '1')
act(() => {
result.current.onDragEnd(dropResult)
})
expect(onUpdate).not.toHaveBeenCalled()
})
})
describe('itemKey', () => {
it('should return the correct original index from a filtered list index', () => {
const onUpdate = vi.fn()
// 过滤后只剩下奇数项: [item-1, item-3, item-5]
const filteredList = [mockOriginalList[0], mockOriginalList[2], mockOriginalList[4]]
const { result } = renderHook(() =>
useDraggableReorder({
originalList: mockOriginalList,
filteredList,
onUpdate,
idKey: 'id'
})
)
// 视图索引 0 -> 'item-1' -> 原始索引 0
expect(result.current.itemKey(0)).toBe(0)
// 视图索引 1 -> 'item-3' -> 原始索引 2
expect(result.current.itemKey(1)).toBe(2)
// 视图索引 2 -> 'item-5' -> 原始索引 4
expect(result.current.itemKey(2)).toBe(4)
})
})
})

View File

@ -1,2 +1,3 @@
export { default as DraggableList } from './list'
export { useDraggableReorder } from './useDraggableReorder'
export { default as DraggableVirtualList } from './virtual-list'

View File

@ -0,0 +1,70 @@
import { DropResult } from '@hello-pangea/dnd'
import { Key, useCallback, useMemo } from 'react'
interface UseDraggableReorderParams<T> {
/** 原始的、完整的数据列表 */
originalList: T[]
/** 当前在界面上渲染的、可能被过滤的列表 */
filteredList: T[]
/** 用于更新原始列表状态的函数 */
onUpdate: (newList: T[]) => void
/** 用于从列表项中获取唯一ID的属性名或函数 */
idKey: keyof T | ((item: T) => Key)
}
/**
*
*
* @template T
* @param params - { originalList, filteredList, onUpdate, idKey }
* @returns DraggableVirtualList props: { onDragEnd, itemKey }
*/
export function useDraggableReorder<T>({ originalList, filteredList, onUpdate, idKey }: UseDraggableReorderParams<T>) {
const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey])
// 创建从 item ID 到其在 *原始列表* 中索引的映射
const itemIndexMap = useMemo(() => {
const map = new Map<Key, number>()
originalList.forEach((item, index) => {
map.set(getId(item), index)
})
return map
}, [originalList, getId])
// 创建一个函数,将 *过滤后列表* 的视图索引转换为 *原始列表* 的数据索引
const getItemKey = useCallback(
(index: number): Key => {
const item = filteredList[index]
// 如果找不到item返回视图索引兜底
if (!item) return index
const originalIndex = itemIndexMap.get(getId(item))
return originalIndex ?? index
},
[filteredList, itemIndexMap, getId]
)
// 创建 onDragEnd 回调,封装了所有重排逻辑
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return
// 使用 getItemKey 将视图索引转换为数据索引
const sourceOriginalIndex = getItemKey(result.source.index) as number
const destOriginalIndex = getItemKey(result.destination.index) as number
if (sourceOriginalIndex === destOriginalIndex) return
// 操作原始列表的副本
const newList = [...originalList]
const [movedItem] = newList.splice(sourceOriginalIndex, 1)
newList.splice(destOriginalIndex, 0, movedItem)
// 调用外部更新函数
onUpdate(newList)
},
[originalList, onUpdate, getItemKey]
)
return { onDragEnd, itemKey: getItemKey }
}

View File

@ -22,7 +22,7 @@ import { type Key, memo, useCallback, useRef } from 'react'
* @property {React.CSSProperties} [itemStyle]
* @property {React.CSSProperties} [itemContainerStyle]
* @property {Partial<DroppableProps>} [droppableProps] Droppable
* @property {(list: T[]) => void} onUpdate
* @property {(list: T[]) => void} [onUpdate] useDraggableReorder
* @property {OnDragStartResponder} [onDragStart]
* @property {OnDragEndResponder} [onDragEnd]
* @property {T[]} list
@ -39,7 +39,7 @@ interface DraggableVirtualListProps<T> {
itemStyle?: React.CSSProperties
itemContainerStyle?: React.CSSProperties
droppableProps?: Partial<DroppableProps>
onUpdate: (list: T[]) => void
onUpdate?: (list: T[]) => void
onDragStart?: OnDragStartResponder
onDragEnd?: OnDragEndResponder
list: T[]
@ -79,7 +79,7 @@ function DraggableVirtualList<T>({
}: DraggableVirtualListProps<T>): React.ReactElement {
const _onDragEnd = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided)
if (result.destination) {
if (onUpdate && result.destination) {
const sourceIndex = result.source.index
const destIndex = result.destination.index
const reorderAgents = droppableReorder(list, sourceIndex, destIndex)

View File

@ -1,5 +1,6 @@
import { DropResult } from '@hello-pangea/dnd'
import { loggerService } from '@logger'
import { DraggableVirtualList } from '@renderer/components/DraggableList'
import { DraggableVirtualList, useDraggableReorder } from '@renderer/components/DraggableList'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { getProviderLogo } from '@renderer/config/providers'
import { useAllProviders, useProviders } from '@renderer/hooks/useProvider'
@ -271,11 +272,6 @@ const ProvidersList: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
const handleUpdateProviders = (reorderProviders: Provider[]) => {
setDragging(false)
updateProviders(reorderProviders)
}
const onAddProvider = async () => {
const { name: providerName, type, logo } = await AddProviderPopup.show()
@ -438,6 +434,25 @@ const ProvidersList: FC = () => {
return isProviderMatch || isModelMatch
})
const { onDragEnd: handleReorder, itemKey } = useDraggableReorder({
originalList: providers,
filteredList: filteredProviders,
onUpdate: updateProviders,
idKey: 'id'
})
const handleDragStart = useCallback(() => {
setDragging(true)
}, [])
const handleDragEnd = useCallback(
(result: DropResult) => {
setDragging(false)
handleReorder(result)
},
[handleReorder]
)
return (
<Container className="selectable">
<ProviderListContainer>
@ -460,11 +475,11 @@ const ProvidersList: FC = () => {
</AddButtonWrapper>
<DraggableVirtualList
list={filteredProviders}
onUpdate={handleUpdateProviders}
onDragStart={() => setDragging(true)}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
estimateSize={useCallback(() => 40, [])}
itemKey={itemKey}
overscan={3}
disabled={searchText !== ''}
style={{
height: `calc(100% - 2 * ${BUTTON_WRAPPER_HEIGHT}px)`
}}
@ -476,7 +491,7 @@ const ProvidersList: FC = () => {
{(provider) => (
<Dropdown menu={{ items: getDropdownMenus(provider) }} trigger={['contextMenu']}>
<ProviderListItem
key={JSON.stringify(provider)}
key={provider.id}
className={provider.id === selectedProvider?.id ? 'active' : ''}
onClick={() => setSelectedProvider(provider)}>
<DragHandle>