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.
This commit is contained in:
Richard R 2026-05-28 05:18:53 -06:00
parent f569c8ded2
commit eff25ad8df
4 changed files with 155 additions and 69 deletions

View file

@ -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
/>
<ConfirmDialog
isOpen={clearFoldersPrompt}
onClose={() => setClearFoldersPrompt(false)}
onConfirm={handleClearFolders}
title="Remove All Folders"
message="Remove all folders? This will not delete documents."
confirmText="Remove Folders"
isDangerous
/>
</FinderWindow>
);
}

View file

@ -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 (
<p
<div
className={
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-muted font-semibold ' +
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-muted font-semibold leading-none flex items-center justify-between ' +
(isFirst ? 'pt-1.5' : 'pt-3')
}
>
{children}
</p>
<span>{children}</span>
{rightSlot && <span className="inline-flex items-center leading-none shrink-0">{rightSlot}</span>}
</div>
);
}
@ -164,6 +175,7 @@ export function FinderSidebar({
counts,
onDeleteFolder,
onNewFolder,
onClearFolders,
onDropOnFolder,
width,
onWidthChange,
@ -197,7 +209,7 @@ export function FinderSidebar({
{topSlot}
</div>
)}
<SectionLabel isFirst={!!topSlot}>Library</SectionLabel>
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
<SidebarRow
active={filter === 'all'}
onClick={() => onFilterChange('all')}
@ -212,7 +224,7 @@ export function FinderSidebar({
label="Recently Opened"
/>
<SectionLabel>Kinds</SectionLabel>
<SectionHeader>Kinds</SectionHeader>
<SidebarRow
active={filter === 'pdf'}
onClick={() => onFilterChange('pdf')}
@ -235,20 +247,63 @@ export function FinderSidebar({
count={counts.html}
/>
<div className="px-2 pt-3 pb-1 flex items-center justify-between">
<p className="text-[10px] uppercase tracking-[0.08em] text-muted font-semibold">
Folders
</p>
<button
type="button"
onClick={onNewFolder}
className="inline-flex items-center justify-center h-6 w-6 rounded-md border border-offbase bg-base text-foreground hover:text-accent hover:border-accent hover:bg-offbase transition-all duration-200 ease-out hover:scale-[1.01]"
title="New folder"
aria-label="New folder"
>
<FolderPlusIcon className="w-4 h-4" />
</button>
</div>
<SectionHeader
rightSlot={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton
className="inline-flex items-center justify-center h-3.5 w-5 rounded-sm text-muted hover:text-accent transition-colors duration-200 ease-out focus:outline-none"
title="Folder actions"
aria-label="Folder actions"
>
<DotsHorizontalIcon className="w-4 h-2.5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
anchor="bottom start"
className="z-50 mt-2 min-w-[180px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1 normal-case tracking-normal font-normal"
>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={onNewFolder}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<FolderPlusIcon className="h-4 w-4" />
New Folder
</button>
)}
</MenuItem>
<MenuItem disabled={folders.length === 0}>
{({ active, disabled }) => (
<button
type="button"
onClick={onClearFolders}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Remove All Folders
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
)}
>
Folders
</SectionHeader>
{folders.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-muted">No folders yet</p>
) : (

View file

@ -393,6 +393,24 @@ export function DotsVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
export function DotsHorizontalIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 12"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "0.75em"}
{...props}
>
<circle cx="4" cy="6" r="1.5" />
<circle cx="12" cy="6" r="1.5" />
<circle cx="20" cy="6" r="1.5" />
</svg>
);
}
export function ChevronLeftIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg

View file

@ -1,5 +1,13 @@
import { test, expect, type Page } from '@playwright/test';
import { setupTest, uploadFiles, ensureDocumentsListed, waitForDocumentListHintPersist, dispatchHtml5DragAndDrop } from './helpers';
import {
setupTest,
uploadFiles,
ensureDocumentsListed,
waitForDocumentListHintPersist,
dispatchHtml5DragAndDrop,
expectDocumentListed,
expectNoDocumentLink,
} from './helpers';
test.describe('Document folders and hint persistence', () => {
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);
});
});