From eff25ad8df63c066b25fcef4f3f331eb300294be Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 28 May 2026 05:18:53 -0600 Subject: [PATCH] feat(doclist): add bulk folder removal with sidebar menu and confirmation Introduce a "Remove All Folders" action accessible via a new sidebar menu, allowing users to clear all folders at once. Add a confirmation dialog to prevent accidental removal. Update the FinderSidebar to include a menu button with the new action, and implement supporting state and handlers in the document list. Add DotsHorizontalIcon for menu UI. Expand folder tests to cover the new bulk removal flow. --- src/components/doclist/DocumentList.tsx | 19 ++++ .../doclist/window/FinderSidebar.tsx | 101 ++++++++++++++---- src/components/icons/Icons.tsx | 18 ++++ tests/folders.spec.ts | 86 +++++++-------- 4 files changed, 155 insertions(+), 69 deletions(-) diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 4928084..5d9118a 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -140,6 +140,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { >(null); const [newFolderName, setNewFolderName] = useState(''); const [manualFolderPrompt, setManualFolderPrompt] = useState(false); + const [clearFoldersPrompt, setClearFoldersPrompt] = useState(false); const isNarrow = useIsNarrow(); const selection = useDocumentSelection(); @@ -432,6 +433,13 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all'); }, [sidebarFilter]); + const handleClearFolders = useCallback(() => { + setFolders([]); + if (sidebarFilter.startsWith('folder:')) setSidebarFilter('all'); + setClearFoldersPrompt(false); + selection.clear(); + }, [selection, sidebarFilter]); + // Status bar summary. const summary = useMemo(() => { const parts: string[] = []; @@ -490,6 +498,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { setNewFolderName(''); setManualFolderPrompt(true); }} + onClearFolders={() => setClearFoldersPrompt(true)} onDropOnFolder={handleDropOnFolder} width={sidebarWidth} onWidthChange={setSidebarWidth} @@ -619,6 +628,16 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { confirmText="Delete" isDangerous /> + + setClearFoldersPrompt(false)} + onConfirm={handleClearFolders} + title="Remove All Folders" + message="Remove all folders? This will not delete documents." + confirmText="Remove Folders" + isDangerous + /> ); } diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx index e973151..358eded 100644 --- a/src/components/doclist/window/FinderSidebar.tsx +++ b/src/components/doclist/window/FinderSidebar.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useRef, type CSSProperties, type ReactNode } from 'react'; +import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'; +import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react'; import { useDrop } from 'react-dnd'; import type { Folder, SidebarFilter } from '@/types/documents'; -import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; +import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons'; import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons'; import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; @@ -14,6 +15,7 @@ interface FinderSidebarProps { counts: { all: number; pdf: number; epub: number; html: number }; onDeleteFolder: (folderId: string) => void; onNewFolder: () => void; + onClearFolders: () => void; /** When dragging onto a folder row, move dropped docs into that folder. */ onDropOnFolder: (folderId: string, item: DocumentDragItem) => void; /** Width controls (desktop only). */ @@ -144,16 +146,25 @@ function FolderRow({ ); } -function SectionLabel({ children, isFirst }: { children: ReactNode; isFirst?: boolean }) { +function SectionHeader({ + children, + isFirst, + rightSlot, +}: { + children: ReactNode; + isFirst?: boolean; + rightSlot?: ReactNode; +}) { return ( -

- {children} -

+ {children} + {rightSlot && {rightSlot}} + ); } @@ -164,6 +175,7 @@ export function FinderSidebar({ counts, onDeleteFolder, onNewFolder, + onClearFolders, onDropOnFolder, width, onWidthChange, @@ -197,7 +209,7 @@ export function FinderSidebar({ {topSlot} )} - Library + Library onFilterChange('all')} @@ -212,7 +224,7 @@ export function FinderSidebar({ label="Recently Opened" /> - Kinds + Kinds onFilterChange('pdf')} @@ -235,20 +247,63 @@ export function FinderSidebar({ count={counts.html} /> -
-

- Folders -

- -
+ + + + + + + + {({ active }) => ( + + )} + + + {({ active, disabled }) => ( + + )} + + + + + )} + > + Folders + {folders.length === 0 ? (

No folders yet

) : ( diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index 9e0d10b..74c6e8f 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -393,6 +393,24 @@ export function DotsVerticalIcon(props: React.SVGProps) { ); } +export function DotsHorizontalIcon(props: React.SVGProps) { + return ( + + + + + + ); +} + export function ChevronLeftIcon(props: React.SVGProps) { return ( { test.beforeEach(async ({ page }, testInfo) => { @@ -13,10 +21,16 @@ test.describe('Document folders and hint persistence', () => { return link.locator('xpath=ancestor::*[@draggable="true"][1]'); }; + const folderRow = (page: Page, folderName: string) => + page.getByRole('button', { name: new RegExp(`^${folderName}\\b`, 'i') }).first(); + + const allDocumentsRow = (page: Page) => + page.getByRole('button', { name: /^All Documents\b/i }).first(); + test('Folder creation via drag-and-drop with persistence', async ({ page }) => { - // Upload three docs - await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt'); - await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']); + // Upload four docs (one stays outside folder to verify filtering) + await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt', 'sample.md'); + await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt', 'sample.md']); // Drag PDF onto EPUB to create a folder const pdfRow = rowFor(page, 'sample.pdf'); @@ -30,51 +44,31 @@ test.describe('Document folders and hint persistence', () => { await nameInput.press('Enter'); await expect(page.getByRole('dialog', { name: 'Create New Folder' })).toHaveCount(0); - // Folder shows with both docs - const folderHeading = page.getByRole('heading', { name: 'My Folder' }); - await expect(folderHeading).toBeVisible(); + // Sidebar folder row exists and folder becomes selected (content filtered to folder docs) + const myFolderRow = folderRow(page, 'My Folder'); + await expect(myFolderRow).toBeVisible(); + await expectDocumentListed(page, 'sample.pdf'); + await expectDocumentListed(page, 'sample.epub'); + await expectNoDocumentLink(page, 'sample.txt'); + await expectNoDocumentLink(page, 'sample.md'); - // Scope checks inside the folder container - const folderContainer = folderHeading.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]'); - await expect(folderContainer.getByRole('link', { name: /sample\.pdf/i })).toBeVisible(); - await expect(folderContainer.getByRole('link', { name: /sample\.epub/i })).toBeVisible(); - - // Drag third doc (TXT) into folder + // Switch to all documents and drag TXT into sidebar folder row + await allDocumentsRow(page).click(); const txtRow = rowFor(page, 'sample.txt'); - await dispatchHtml5DragAndDrop(page, txtRow, folderContainer); - await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toBeVisible(); + await dispatchHtml5DragAndDrop(page, txtRow, myFolderRow); + await expectDocumentListed(page, 'sample.txt'); + await expectNoDocumentLink(page, 'sample.md'); - // Collapse folder and verify items are hidden - const collapseBtn = folderContainer.getByRole('button', { name: 'Collapse folder' }); - await collapseBtn.scrollIntoViewIfNeeded(); - await expect(collapseBtn).toBeVisible(); - await collapseBtn.click(); - await expect(folderContainer.getByRole('button', { name: 'Expand folder' })).toBeVisible(); - await expect(folderContainer.getByRole('link', { name: /sample\.pdf/i })).toHaveCount(0); - await expect(folderContainer.getByRole('link', { name: /sample\.epub/i })).toHaveCount(0); - await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toHaveCount(0); - - // Reload and verify persisted folder with collapsed state and documents + // Reload and verify persisted folder + membership await page.reload(); await page.waitForLoadState('networkidle'); - const folderHeadingAfter = page.getByRole('heading', { name: 'My Folder' }); - await expect(folderHeadingAfter).toBeVisible(); - const folderContainerAfter = folderHeadingAfter.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]'); - - // Still collapsed after reload - await expect(folderContainerAfter.getByRole('button', { name: 'Expand folder' })).toBeVisible(); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.pdf/i })).toHaveCount(0); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.epub/i })).toHaveCount(0); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.txt/i })).toHaveCount(0); - - // Expand and verify all three documents visible - const expandBtn = folderContainerAfter.getByRole('button', { name: 'Expand folder' }); - await expandBtn.scrollIntoViewIfNeeded(); - await expect(expandBtn).toBeVisible(); - await expandBtn.click(); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.pdf/i })).toBeVisible(); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.epub/i })).toBeVisible(); - await expect(folderContainerAfter.getByRole('link', { name: /sample\.txt/i })).toBeVisible(); + const myFolderRowAfter = folderRow(page, 'My Folder'); + await expect(myFolderRowAfter).toBeVisible(); + await myFolderRowAfter.click(); + await expectDocumentListed(page, 'sample.pdf'); + await expectDocumentListed(page, 'sample.epub'); + await expectDocumentListed(page, 'sample.txt'); + await expectNoDocumentLink(page, 'sample.md'); }); test('Dismiss “Drag files to make folders” hint persists after reload', async ({ page }) => { @@ -82,7 +76,7 @@ test.describe('Document folders and hint persistence', () => { await uploadFiles(page, 'sample.pdf', 'sample.epub'); await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']); - const hint = page.getByText('Drag files on top of each other to make folders'); + const hint = page.getByText('Drag files onto each other to make folders. Drop into the sidebar to move.'); await expect(hint).toBeVisible(); await page.getByRole('button', { name: 'Dismiss hint' }).click(); @@ -95,6 +89,6 @@ test.describe('Document folders and hint persistence', () => { // Reload and ensure it remains dismissed await page.reload(); await page.waitForLoadState('networkidle'); - await expect(page.getByText('Drag files on top of each other to make folders')).toHaveCount(0); + await expect(page.getByText('Drag files onto each other to make folders. Drop into the sidebar to move.')).toHaveCount(0); }); });