mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-19 01:51:11 +08:00
fix(files): allow collapsing active folder tree branches
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { StorageObject } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listObjectsByPath } from '@/lib/api'
|
||||
import { FolderTree } from './folder-tree'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
currentPath: 'parent/child',
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-router', () => ({
|
||||
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
useSearch: () => ({ path: mocks.currentPath }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
listObjectsByPath: vi.fn(),
|
||||
}))
|
||||
|
||||
function folder(id: string, name: string, parent = ''): StorageObject {
|
||||
return {
|
||||
id,
|
||||
orgId: 'org-1',
|
||||
alias: '',
|
||||
name,
|
||||
type: 'folder',
|
||||
size: 0,
|
||||
dirtype: DirType.USER_FOLDER,
|
||||
parent,
|
||||
object: '',
|
||||
storageId: 'storage-1',
|
||||
status: 'active',
|
||||
trashedAt: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function page(items: StorageObject[]) {
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
}
|
||||
}
|
||||
|
||||
function renderFolderTree() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FolderTree />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.currentPath = 'parent/child'
|
||||
vi.mocked(listObjectsByPath).mockImplementation(async (path) => {
|
||||
if (path === '') return page([folder('parent', 'parent')])
|
||||
if (path === 'parent') return page([folder('child', 'child', 'parent')])
|
||||
return page([])
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('FolderTree', () => {
|
||||
it('allows the current path ancestor to be collapsed manually', async () => {
|
||||
const view = renderFolderTree()
|
||||
await view.findByText('child')
|
||||
const trigger = await view.findByRole('button', { name: 'parent' })
|
||||
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
|
||||
fireEvent.click(trigger)
|
||||
|
||||
await waitFor(() => expect(trigger.getAttribute('aria-expanded')).toBe('false'))
|
||||
expect(view.queryByText('child')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { StorageObject } from '@shared/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link, useSearch } from '@tanstack/react-router'
|
||||
import { ChevronRight, Folder } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem } from '@/components/ui/sidebar'
|
||||
import { listObjectsByPath } from '@/lib/api'
|
||||
@@ -35,7 +35,10 @@ function FolderNode({
|
||||
const isActive = currentPath === folderPath
|
||||
|
||||
const [open, setOpen] = useState(shouldAutoExpand)
|
||||
const expanded = open || shouldAutoExpand
|
||||
useEffect(() => {
|
||||
if (isAncestorOf(folderPath, currentPath)) setOpen(true)
|
||||
}, [folderPath, currentPath])
|
||||
|
||||
// Always prefetch to know if this folder has children (for arrow visibility)
|
||||
const query = useFolders(folderPath, true)
|
||||
const subFolders = query.data ?? []
|
||||
@@ -43,17 +46,27 @@ function FolderNode({
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem>
|
||||
<Collapsible open={expanded} onOpenChange={setOpen}>
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<SidebarMenuSubButton asChild isActive={isActive}>
|
||||
<Link to="/files" search={{ path: folderPath }}>
|
||||
<CollapsibleTrigger asChild onClick={(e) => e.preventDefault()} disabled={!hasChildren}>
|
||||
<ChevronRight
|
||||
className={`h-3 w-3 shrink-0 transition-transform data-[state=open]:rotate-90 ${hasChildren ? '' : 'invisible'}`}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
</Link>
|
||||
<div>
|
||||
{hasChildren ? (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={folder.name}
|
||||
className="group/trigger flex size-4 shrink-0 items-center justify-center"
|
||||
>
|
||||
<ChevronRight className="size-3 transition-transform group-data-[state=open]/trigger:rotate-90" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
<span className="size-4 shrink-0" />
|
||||
)}
|
||||
<Link to="/files" search={{ path: folderPath }} className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Folder className="size-4 shrink-0" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</SidebarMenuSubButton>
|
||||
<CollapsibleContent>
|
||||
{subFolders.length > 0 && (
|
||||
|
||||
Reference in New Issue
Block a user