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);
|
>(null);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
const [manualFolderPrompt, setManualFolderPrompt] = useState(false);
|
const [manualFolderPrompt, setManualFolderPrompt] = useState(false);
|
||||||
|
const [clearFoldersPrompt, setClearFoldersPrompt] = useState(false);
|
||||||
|
|
||||||
const isNarrow = useIsNarrow();
|
const isNarrow = useIsNarrow();
|
||||||
const selection = useDocumentSelection();
|
const selection = useDocumentSelection();
|
||||||
|
|
@ -432,6 +433,13 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all');
|
if (sidebarFilter === `folder:${folderId}`) setSidebarFilter('all');
|
||||||
}, [sidebarFilter]);
|
}, [sidebarFilter]);
|
||||||
|
|
||||||
|
const handleClearFolders = useCallback(() => {
|
||||||
|
setFolders([]);
|
||||||
|
if (sidebarFilter.startsWith('folder:')) setSidebarFilter('all');
|
||||||
|
setClearFoldersPrompt(false);
|
||||||
|
selection.clear();
|
||||||
|
}, [selection, sidebarFilter]);
|
||||||
|
|
||||||
// Status bar summary.
|
// Status bar summary.
|
||||||
const summary = useMemo(() => {
|
const summary = useMemo(() => {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
@ -490,6 +498,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
setNewFolderName('');
|
setNewFolderName('');
|
||||||
setManualFolderPrompt(true);
|
setManualFolderPrompt(true);
|
||||||
}}
|
}}
|
||||||
|
onClearFolders={() => setClearFoldersPrompt(true)}
|
||||||
onDropOnFolder={handleDropOnFolder}
|
onDropOnFolder={handleDropOnFolder}
|
||||||
width={sidebarWidth}
|
width={sidebarWidth}
|
||||||
onWidthChange={setSidebarWidth}
|
onWidthChange={setSidebarWidth}
|
||||||
|
|
@ -619,6 +628,16 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||||
confirmText="Delete"
|
confirmText="Delete"
|
||||||
isDangerous
|
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>
|
</FinderWindow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
'use client';
|
'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 { useDrop } from 'react-dnd';
|
||||||
import type { Folder, SidebarFilter } from '@/types/documents';
|
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 { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
||||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@ interface FinderSidebarProps {
|
||||||
counts: { all: number; pdf: number; epub: number; html: number };
|
counts: { all: number; pdf: number; epub: number; html: number };
|
||||||
onDeleteFolder: (folderId: string) => void;
|
onDeleteFolder: (folderId: string) => void;
|
||||||
onNewFolder: () => void;
|
onNewFolder: () => void;
|
||||||
|
onClearFolders: () => void;
|
||||||
/** When dragging onto a folder row, move dropped docs into that folder. */
|
/** When dragging onto a folder row, move dropped docs into that folder. */
|
||||||
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
|
||||||
/** Width controls (desktop only). */
|
/** 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 (
|
return (
|
||||||
<p
|
<div
|
||||||
className={
|
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')
|
(isFirst ? 'pt-1.5' : 'pt-3')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
<span>{children}</span>
|
||||||
</p>
|
{rightSlot && <span className="inline-flex items-center leading-none shrink-0">{rightSlot}</span>}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +175,7 @@ export function FinderSidebar({
|
||||||
counts,
|
counts,
|
||||||
onDeleteFolder,
|
onDeleteFolder,
|
||||||
onNewFolder,
|
onNewFolder,
|
||||||
|
onClearFolders,
|
||||||
onDropOnFolder,
|
onDropOnFolder,
|
||||||
width,
|
width,
|
||||||
onWidthChange,
|
onWidthChange,
|
||||||
|
|
@ -197,7 +209,7 @@ export function FinderSidebar({
|
||||||
{topSlot}
|
{topSlot}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SectionLabel isFirst={!!topSlot}>Library</SectionLabel>
|
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
|
||||||
<SidebarRow
|
<SidebarRow
|
||||||
active={filter === 'all'}
|
active={filter === 'all'}
|
||||||
onClick={() => onFilterChange('all')}
|
onClick={() => onFilterChange('all')}
|
||||||
|
|
@ -212,7 +224,7 @@ export function FinderSidebar({
|
||||||
label="Recently Opened"
|
label="Recently Opened"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionLabel>Kinds</SectionLabel>
|
<SectionHeader>Kinds</SectionHeader>
|
||||||
<SidebarRow
|
<SidebarRow
|
||||||
active={filter === 'pdf'}
|
active={filter === 'pdf'}
|
||||||
onClick={() => onFilterChange('pdf')}
|
onClick={() => onFilterChange('pdf')}
|
||||||
|
|
@ -235,20 +247,63 @@ export function FinderSidebar({
|
||||||
count={counts.html}
|
count={counts.html}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="px-2 pt-3 pb-1 flex items-center justify-between">
|
<SectionHeader
|
||||||
<p className="text-[10px] uppercase tracking-[0.08em] text-muted font-semibold">
|
rightSlot={(
|
||||||
Folders
|
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
||||||
</p>
|
<MenuButton
|
||||||
<button
|
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"
|
||||||
type="button"
|
title="Folder actions"
|
||||||
onClick={onNewFolder}
|
aria-label="Folder actions"
|
||||||
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"
|
<DotsHorizontalIcon className="w-4 h-2.5" />
|
||||||
aria-label="New folder"
|
</MenuButton>
|
||||||
>
|
<Transition
|
||||||
<FolderPlusIcon className="w-4 h-4" />
|
as={Fragment}
|
||||||
</button>
|
enter="transition ease-out duration-100"
|
||||||
</div>
|
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 ? (
|
{folders.length === 0 ? (
|
||||||
<p className="px-2 py-1 text-[11px] text-muted">No folders yet</p>
|
<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>) {
|
export function ChevronLeftIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { test, expect, type Page } from '@playwright/test';
|
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.describe('Document folders and hint persistence', () => {
|
||||||
test.beforeEach(async ({ page }, testInfo) => {
|
test.beforeEach(async ({ page }, testInfo) => {
|
||||||
|
|
@ -13,10 +21,16 @@ test.describe('Document folders and hint persistence', () => {
|
||||||
return link.locator('xpath=ancestor::*[@draggable="true"][1]');
|
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 }) => {
|
test('Folder creation via drag-and-drop with persistence', async ({ page }) => {
|
||||||
// Upload three docs
|
// Upload four docs (one stays outside folder to verify filtering)
|
||||||
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt');
|
await uploadFiles(page, 'sample.pdf', 'sample.epub', 'sample.txt', 'sample.md');
|
||||||
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt']);
|
await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub', 'sample.txt', 'sample.md']);
|
||||||
|
|
||||||
// Drag PDF onto EPUB to create a folder
|
// Drag PDF onto EPUB to create a folder
|
||||||
const pdfRow = rowFor(page, 'sample.pdf');
|
const pdfRow = rowFor(page, 'sample.pdf');
|
||||||
|
|
@ -30,51 +44,31 @@ test.describe('Document folders and hint persistence', () => {
|
||||||
await nameInput.press('Enter');
|
await nameInput.press('Enter');
|
||||||
await expect(page.getByRole('dialog', { name: 'Create New Folder' })).toHaveCount(0);
|
await expect(page.getByRole('dialog', { name: 'Create New Folder' })).toHaveCount(0);
|
||||||
|
|
||||||
// Folder shows with both docs
|
// Sidebar folder row exists and folder becomes selected (content filtered to folder docs)
|
||||||
const folderHeading = page.getByRole('heading', { name: 'My Folder' });
|
const myFolderRow = folderRow(page, 'My Folder');
|
||||||
await expect(folderHeading).toBeVisible();
|
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
|
// Switch to all documents and drag TXT into sidebar folder row
|
||||||
const folderContainer = folderHeading.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]');
|
await allDocumentsRow(page).click();
|
||||||
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
|
|
||||||
const txtRow = rowFor(page, 'sample.txt');
|
const txtRow = rowFor(page, 'sample.txt');
|
||||||
await dispatchHtml5DragAndDrop(page, txtRow, folderContainer);
|
await dispatchHtml5DragAndDrop(page, txtRow, myFolderRow);
|
||||||
await expect(folderContainer.getByRole('link', { name: /sample\.txt/i })).toBeVisible();
|
await expectDocumentListed(page, 'sample.txt');
|
||||||
|
await expectNoDocumentLink(page, 'sample.md');
|
||||||
|
|
||||||
// Collapse folder and verify items are hidden
|
// Reload and verify persisted folder + membership
|
||||||
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
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
const folderHeadingAfter = page.getByRole('heading', { name: 'My Folder' });
|
const myFolderRowAfter = folderRow(page, 'My Folder');
|
||||||
await expect(folderHeadingAfter).toBeVisible();
|
await expect(myFolderRowAfter).toBeVisible();
|
||||||
const folderContainerAfter = folderHeadingAfter.locator('xpath=ancestor::*[contains(concat(" ", normalize-space(@class), " "), " rounded-md ") and contains(concat(" ", normalize-space(@class), " "), " border ")][1]');
|
await myFolderRowAfter.click();
|
||||||
|
await expectDocumentListed(page, 'sample.pdf');
|
||||||
// Still collapsed after reload
|
await expectDocumentListed(page, 'sample.epub');
|
||||||
await expect(folderContainerAfter.getByRole('button', { name: 'Expand folder' })).toBeVisible();
|
await expectDocumentListed(page, 'sample.txt');
|
||||||
await expect(folderContainerAfter.getByRole('link', { name: /sample\.pdf/i })).toHaveCount(0);
|
await expectNoDocumentLink(page, 'sample.md');
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Dismiss “Drag files to make folders” hint persists after reload', async ({ page }) => {
|
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 uploadFiles(page, 'sample.pdf', 'sample.epub');
|
||||||
await ensureDocumentsListed(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 expect(hint).toBeVisible();
|
||||||
await page.getByRole('button', { name: 'Dismiss hint' }).click();
|
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
|
// Reload and ensure it remains dismissed
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.waitForLoadState('networkidle');
|
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