From 912962536525580e4a0d59fdddf8c7a64bd92cf2 Mon Sep 17 00:00:00 2001 From: one Date: Fri, 8 Aug 2025 00:39:10 +0800 Subject: [PATCH] feat: draggable on filtering (#8929) feat: support dnd on filtering --- .../__tests__/DraggableList.test.tsx | 2 +- .../__tests__/DraggableVirtualList.test.tsx | 2 +- .../__snapshots__/DraggableList.test.tsx.snap | 0 .../DraggableVirtualList.test.tsx.snap | 0 .../__tests__/useDraggableReorder.test.ts | 151 ++++++++++++++++++ .../src/components/DraggableList/index.tsx | 1 + .../DraggableList/useDraggableReorder.ts | 70 ++++++++ .../components/DraggableList/virtual-list.tsx | 6 +- .../pages/settings/ProviderSettings/index.tsx | 35 ++-- 9 files changed, 252 insertions(+), 15 deletions(-) rename src/renderer/src/components/{ => DraggableList}/__tests__/DraggableList.test.tsx (99%) rename src/renderer/src/components/{ => DraggableList}/__tests__/DraggableVirtualList.test.tsx (98%) rename src/renderer/src/components/{ => DraggableList}/__tests__/__snapshots__/DraggableList.test.tsx.snap (100%) rename src/renderer/src/components/{ => DraggableList}/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap (100%) create mode 100644 src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts create mode 100644 src/renderer/src/components/DraggableList/useDraggableReorder.ts diff --git a/src/renderer/src/components/__tests__/DraggableList.test.tsx b/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx similarity index 99% rename from src/renderer/src/components/__tests__/DraggableList.test.tsx rename to src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx index 4878fd4838..a570f58bcf 100644 --- a/src/renderer/src/components/__tests__/DraggableList.test.tsx +++ b/src/renderer/src/components/DraggableList/__tests__/DraggableList.test.tsx @@ -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', () => { diff --git a/src/renderer/src/components/__tests__/DraggableVirtualList.test.tsx b/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx similarity index 98% rename from src/renderer/src/components/__tests__/DraggableVirtualList.test.tsx rename to src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx index b82181ef42..74a7a414ee 100644 --- a/src/renderer/src/components/__tests__/DraggableVirtualList.test.tsx +++ b/src/renderer/src/components/DraggableList/__tests__/DraggableVirtualList.test.tsx @@ -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', () => ({ diff --git a/src/renderer/src/components/__tests__/__snapshots__/DraggableList.test.tsx.snap b/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableList.test.tsx.snap similarity index 100% rename from src/renderer/src/components/__tests__/__snapshots__/DraggableList.test.tsx.snap rename to src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableList.test.tsx.snap diff --git a/src/renderer/src/components/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap b/src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap similarity index 100% rename from src/renderer/src/components/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap rename to src/renderer/src/components/DraggableList/__tests__/__snapshots__/DraggableVirtualList.test.tsx.snap diff --git a/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts b/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts new file mode 100644 index 0000000000..f2d2fe837f --- /dev/null +++ b/src/renderer/src/components/DraggableList/__tests__/useDraggableReorder.test.ts @@ -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) + }) + }) +}) diff --git a/src/renderer/src/components/DraggableList/index.tsx b/src/renderer/src/components/DraggableList/index.tsx index de98dd00d5..642b12bfd7 100644 --- a/src/renderer/src/components/DraggableList/index.tsx +++ b/src/renderer/src/components/DraggableList/index.tsx @@ -1,2 +1,3 @@ export { default as DraggableList } from './list' +export { useDraggableReorder } from './useDraggableReorder' export { default as DraggableVirtualList } from './virtual-list' diff --git a/src/renderer/src/components/DraggableList/useDraggableReorder.ts b/src/renderer/src/components/DraggableList/useDraggableReorder.ts new file mode 100644 index 0000000000..59a04788a6 --- /dev/null +++ b/src/renderer/src/components/DraggableList/useDraggableReorder.ts @@ -0,0 +1,70 @@ +import { DropResult } from '@hello-pangea/dnd' +import { Key, useCallback, useMemo } from 'react' + +interface UseDraggableReorderParams { + /** 原始的、完整的数据列表 */ + 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({ originalList, filteredList, onUpdate, idKey }: UseDraggableReorderParams) { + const getId = useCallback((item: T) => (typeof idKey === 'function' ? idKey(item) : (item[idKey] as Key)), [idKey]) + + // 创建从 item ID 到其在 *原始列表* 中索引的映射 + const itemIndexMap = useMemo(() => { + const map = new Map() + 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 } +} diff --git a/src/renderer/src/components/DraggableList/virtual-list.tsx b/src/renderer/src/components/DraggableList/virtual-list.tsx index 59b3ca2d52..b8020aa051 100644 --- a/src/renderer/src/components/DraggableList/virtual-list.tsx +++ b/src/renderer/src/components/DraggableList/virtual-list.tsx @@ -22,7 +22,7 @@ import { type Key, memo, useCallback, useRef } from 'react' * @property {React.CSSProperties} [itemStyle] 元素内容区域的附加样式 * @property {React.CSSProperties} [itemContainerStyle] 元素拖拽容器的附加样式 * @property {Partial} [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 { itemStyle?: React.CSSProperties itemContainerStyle?: React.CSSProperties droppableProps?: Partial - onUpdate: (list: T[]) => void + onUpdate?: (list: T[]) => void onDragStart?: OnDragStartResponder onDragEnd?: OnDragEndResponder list: T[] @@ -79,7 +79,7 @@ function DraggableVirtualList({ }: DraggableVirtualListProps): 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) diff --git a/src/renderer/src/pages/settings/ProviderSettings/index.tsx b/src/renderer/src/pages/settings/ProviderSettings/index.tsx index 68e5cb0ca0..836a156faa 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/index.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/index.tsx @@ -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 ( @@ -460,11 +475,11 @@ const ProvidersList: FC = () => { 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) => ( setSelectedProvider(provider)}>