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:
parent
f569c8ded2
commit
eff25ad8df
4 changed files with 155 additions and 69 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue