mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 02:09:03 +08:00
parent
ff58efcbf3
commit
9129625365
@ -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', () => {
|
||||
@ -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', () => ({
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,2 +1,3 @@
|
||||
export { default as DraggableList } from './list'
|
||||
export { useDraggableReorder } from './useDraggableReorder'
|
||||
export { default as DraggableVirtualList } from './virtual-list'
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user