fix(files): allow collapsing active folder tree branches

This commit is contained in:
saltbo
2026-07-23 10:11:26 -04:00
parent 31005e1446
commit ea8fdf236e
2 changed files with 121 additions and 12 deletions
@@ -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()
})
})
+25 -12
View File
@@ -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 && (