From 169eb57e2bb274ccb067937a6d6c14aa1cd31b4e Mon Sep 17 00:00:00 2001 From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:20:07 +0200 Subject: [PATCH] feat(media): select every visible asset with Ctrl/Cmd+A or a toolbar button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emptying the trash, or tagging a folder's worth of uploads, meant clicking every file. Ctrl/Cmd+A now selects every asset the grid shows, and a toolbar button makes the shortcut discoverable. Both toggle: when the selection already covers everything visible, they clear it. "Everything" is `visibleAssets` — what the folder, filter and search have narrowed to — not the whole library, so "select all, then delete" in the trash can never reach a live file off screen. The shortcut is document-level (the grid has no tabindex) and stands down in a text field, where the browser's own select-all is meant, and while a real modal is open. It keys on `aria-modal`, not `role="dialog"`: selecting one asset opens the viewer window, which carries the role but leaves the grid usable, and keying on the role turned the shortcut off almost everywhere in Media. The tests render MediaCanvas and press the real shortcut and button; each guard was checked against a component with it removed. Co-Authored-By: Claude Opus 5.5 --- docs/features/media.md | 1 + src/__tests__/media/selectAll.test.tsx | 262 ++++++++++++++++++ .../components/MediaCanvas/MediaCanvas.tsx | 81 ++++++ 3 files changed, 344 insertions(+) create mode 100644 src/__tests__/media/selectAll.test.tsx diff --git a/docs/features/media.md b/docs/features/media.md index 3c1483ee3..e33d065b5 100644 --- a/docs/features/media.md +++ b/docs/features/media.md @@ -15,6 +15,7 @@ The workspace is canvas-style: it uses `AdminWorkspaceCanvasLayout`, the lighter - **Drag/drop:** assets can be dragged into folders from the canvas or folder tree. A drop replaces the asset's folder memberships with the target folder, matching desktop file-manager move semantics. - **Floating windows:** Asset viewer, upload queue, bulk edit. Each uses the shared `@admin/shared/FloatingWindow` shell or its `useDraggablePanel` hook with a unique id (`mediaDetachedInspector`, `mediaUploadQueue`, `mediaBulkEdit`). Position survives reload via `workspaceLayoutStorage`. - **Auto-open behavior:** upload queue opens when uploads start; bulk-edit opens at 2+ selected; viewer opens on primary selection. +- **Select all:** Ctrl/Cmd+A, or the toolbar's All button, selects every asset in `visibleAssets` — what the folder, filter and search currently show, never the whole library — and clears the selection when it already covers them. The shortcut yields to text fields and to an open modal (`aria-modal="true"`), but not to the floating windows, which leave the grid usable behind them. - **Server side:** `media_assets`, `media_folders`, `media_asset_folders` tables. Handlers under `/admin/api/cms/media`, `/admin/api/cms/media/folders`, `/admin/api/cms/media/storage`. Repositories at `server/repositories/media*.ts`. - **Storage adapters:** built-in local-disk plus plugin-registered adapters. Non-public-url adapters route through `/_instatic/media//` for signed redirects. - **Plugin media ingestion:** `api.cms.media.upsert(...)` lets plugins import allowlisted HTTPS images or contained package assets through the same validation, storage, and responsive-variant pipeline as admin uploads. diff --git a/src/__tests__/media/selectAll.test.tsx b/src/__tests__/media/selectAll.test.tsx new file mode 100644 index 000000000..29297d982 --- /dev/null +++ b/src/__tests__/media/selectAll.test.tsx @@ -0,0 +1,262 @@ +/** + * Selecting every visible media asset — Ctrl/Cmd+A, and a toolbar button + * that makes the shortcut discoverable. + * + * Emptying the trash, or tagging a folder's worth of uploads, used to mean + * clicking every file. Every case renders the real MediaCanvas and presses + * the real shortcut or button, so the tests fail if the component stops + * honouring the rule — not just if a copy of the rule changes. + */ + +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MediaCanvas } from '@admin/pages/media/components/MediaCanvas/MediaCanvas' +import { FOLDER_ALL, type UseMediaWorkspaceResult } from '@admin/pages/media/hooks/useMediaWorkspace' +import { buildFolderTree } from '@admin/pages/media/utils/folderTree' +import { AdminSessionProvider } from '@admin/session' +import type { CoreCapability } from '@core/capabilities' +import type { CmsCurrentUser } from '@core/persistence' +import type { CmsMediaAsset } from '@core/persistence/cmsMedia' + +afterEach(() => { + cleanup() + document.body.innerHTML = '' +}) + +function asset(id: string): CmsMediaAsset { + return { + id, + filename: `${id}.png`, + mimeType: 'image/png', + sizeBytes: 1200, + publicPath: `/uploads/${id}.png`, + uploadedByUserId: null, + createdAt: '2026-01-01T00:00:00.000Z', + altText: '', + caption: '', + title: '', + tags: [], + width: null, + height: null, + durationMs: null, + dominantColor: null, + deletedAt: null, + replacedAt: null, + folderIds: [], + blurHash: null, + variants: [], + posterPath: null, + } +} + +const CAPABILITIES: CoreCapability[] = ['media.read', 'media.write', 'media.replace', 'media.delete'] + +function currentUser(): CmsCurrentUser { + return { + id: 'user_media_manager', + email: 'media-manager@example.com', + displayName: 'Media Manager', + status: 'active', + role: { + id: 'role_media_manager', + slug: 'media-manager', + name: 'Media Manager', + description: 'Media test role', + isSystem: false, + capabilities: CAPABILITIES, + }, + capabilities: CAPABILITIES, + lastLoginAt: null, + failedLoginCount: 0, + lockedUntil: null, + passwordUpdatedAt: null, + mfaEnabled: false, + mfaEnabledAt: null, + mfaRecoveryCodesRemaining: 0, + stepUpAuthMode: 'password', + stepUpWindowMinutes: 15, + avatarMediaId: null, + avatarUrl: null, + gravatarHash: '', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + } +} + +/** What the canvas asked the workspace to do. */ +interface Calls { + added: string[][] + cleared: number +} + +function renderCanvas(options: { + assets?: string[] + visible?: string[] + selected?: string[] +} = {}): Calls { + const assets = (options.assets ?? ['a', 'b', 'c']).map(asset) + const visibleIds = new Set(options.visible ?? assets.map((a) => a.id)) + const visibleAssets = assets.filter((a) => visibleIds.has(a.id)) + const selectedIds = new Set(options.selected ?? []) + const calls: Calls = { added: [], cleared: 0 } + const workspace: UseMediaWorkspaceResult = { + loading: false, + error: null, + clearError: () => {}, + refresh: async () => {}, + folders: [], + folderTree: buildFolderTree([]), + folderById: new Map(), + assets, + visibleAssets, + tagPalette: [], + folderSelection: FOLDER_ALL, + setFolderSelection: () => {}, + selectedAssetId: null, + selectedAsset: null, + setSelectedAssetId: () => {}, + selectedAssetIds: selectedIds, + selectedAssets: assets.filter((a) => selectedIds.has(a.id)), + toggleAssetInSelection: () => {}, + addToSelection: (ids: string[]) => { + calls.added.push(ids) + }, + selectRange: () => {}, + clearSelection: () => { + calls.cleared += 1 + }, + uploadQueue: { + items: [], + active: false, + enqueue: () => {}, + retry: () => {}, + remove: () => {}, + clearFinished: () => {}, + cancelAll: () => {}, + }, + filters: { type: 'all', q: '', tag: '', sort: 'newest' }, + setFilterType: () => {}, + setQuery: () => {}, + setTag: () => {}, + setSort: () => {}, + uploadFiles: async () => {}, + renameAsset: async () => null, + updateAsset: async () => null, + replaceAssetFile: async () => null, + trashAsset: async () => {}, + restoreAsset: async () => null, + purgeAsset: async () => {}, + setAssetFolders: async () => null, + moveAssetsToFolder: async () => {}, + createFolder: async () => null, + renameFolder: async () => null, + moveFolder: async () => null, + deleteFolder: async () => {}, + } + render( + + + , + ) + return calls +} + +/** Ctrl+A on Windows/Linux, Cmd+A on a Mac — both modifiers, so either rule fires. */ +function pressSelectAll(target: Element = document.body) { + fireEvent.keyDown(target, { key: 'a', ctrlKey: true, metaKey: true }) +} + +function mount(element: HTMLElement): HTMLElement { + document.body.appendChild(element) + return element +} + +describe('Ctrl/Cmd+A over the media grid', () => { + it('selects every visible asset', () => { + const calls = renderCanvas() + pressSelectAll() + expect(calls.added).toEqual([['a', 'b', 'c']]) + }) + + it('selects what the filter shows, not the whole library', () => { + // The only reading that makes "select all, then delete" safe in the + // trash: it can never reach past the filter into assets not on screen. + const calls = renderCanvas({ assets: ['a', 'b', 'c'], visible: ['a', 'c'] }) + pressSelectAll() + expect(calls.added).toEqual([['a', 'c']]) + }) + + it('clears the selection when it already covers everything visible', () => { + const calls = renderCanvas({ selected: ['a', 'b', 'c'] }) + pressSelectAll() + expect(calls.cleared).toBe(1) + expect(calls.added).toEqual([]) + }) + + it('leaves the shortcut to the browser inside the search box', () => { + const calls = renderCanvas() + pressSelectAll(screen.getByRole('searchbox', { name: 'Search media' })) + expect(calls.added).toEqual([]) + }) + + it('leaves it alone in a textarea or an editable field', () => { + const calls = renderCanvas() + pressSelectAll(mount(document.createElement('textarea'))) + const editable = mount(document.createElement('div')) + editable.contentEditable = 'true' + pressSelectAll(editable) + expect(calls.added).toEqual([]) + }) + + it('stands down while a modal dialog is open', () => { + // A rename or a delete confirmation keeps its own select-all. + const calls = renderCanvas() + const modal = mount(document.createElement('div')) + modal.setAttribute('role', 'dialog') + modal.setAttribute('aria-modal', 'true') + pressSelectAll() + expect(calls.added).toEqual([]) + }) + + it('keeps working beside a floating window, which is not modal', () => { + // Selecting one asset opens the viewer window, which carries + // role="dialog" but leaves the grid usable behind it. Keying on the role + // instead of aria-modal switched the shortcut off almost everywhere. + const calls = renderCanvas() + mount(document.createElement('div')).setAttribute('role', 'dialog') + pressSelectAll() + expect(calls.added).toEqual([['a', 'b', 'c']]) + }) + + it('does nothing on an empty grid', () => { + const calls = renderCanvas({ assets: [] }) + pressSelectAll() + expect(calls.added).toEqual([]) + expect(calls.cleared).toBe(0) + }) +}) + +describe('the toolbar button', () => { + it('names how many it will select, and selects them', () => { + const calls = renderCanvas() + fireEvent.click(screen.getByRole('button', { name: 'Select all 3 items' })) + expect(calls.added).toEqual([['a', 'b', 'c']]) + }) + + it('turns into Clear selection once everything visible is selected', () => { + const calls = renderCanvas({ selected: ['a', 'b', 'c'] }) + fireEvent.click(screen.getByRole('button', { name: 'Clear selection' })) + expect(calls.cleared).toBe(1) + }) + + it('is disabled on an empty grid', () => { + const calls = renderCanvas({ assets: [] }) + const button = screen.getByRole('button', { name: 'Select all 0 items' }) + // `Button` marks a disabled button that carries a tooltip with + // aria-disabled rather than the native attribute, so the tooltip still + // shows; a click must still do nothing. + expect(button.getAttribute('aria-disabled')).toBe('true') + fireEvent.click(button) + expect(calls.added).toEqual([]) + }) +}) diff --git a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx index 3dba08963..ca1bf2f58 100644 --- a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx +++ b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx @@ -6,6 +6,8 @@ * navigation land in M3/M4 — this component is the first interactive surface. */ import { + useEffect, + useEffectEvent, useState, type ChangeEvent, type DragEvent, @@ -196,6 +198,61 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv writeMediaAssetDragData(event.dataTransfer, dragIds) } + /** + * Select everything currently on screen. + * + * "Everything" is `visibleAssets` — what the active folder, filter, search + * and trash toggle have already narrowed to — not the whole library. That is + * what every file manager means by Select All, and it is the only reading + * that makes the trash view's "select all, then delete" safe: it cannot + * reach past the filter into live assets. + */ + const allVisibleSelected = + workspace.visibleAssets.length > 0 + && workspace.visibleAssets.every((asset) => workspace.selectedAssetIds.has(asset.id)) + + /** Select every visible asset, or clear the selection when it already covers them. */ + function toggleSelectAllVisible() { + if (workspace.visibleAssets.length === 0) return + if (allVisibleSelected) { + workspace.clearSelection() + return + } + workspace.addToSelection(workspace.visibleAssets.map((asset) => asset.id)) + } + + // Ctrl/Cmd+A, the shortcut people try first. Document-level because the grid + // is a plain div with no tabindex, so a React `onKeyDown` would only fire + // while focus happened to sit on a tile. + // + // Ignored while focus is in a text field — the browser's own select-all is + // what someone typing in the search box means — and while any dialog is + // open, so a rename or a delete confirmation keeps its own select-all. + const selectAllEvent = useEffectEvent(() => toggleSelectAllVisible()) + useEffect(() => { + function onKeyDown(event: globalThis.KeyboardEvent) { + if (event.key !== 'a' && event.key !== 'A') return + if (!(isMacLike() ? event.metaKey : event.ctrlKey)) return + const target = event.target + if ( + target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || (target instanceof HTMLElement && target.isContentEditable) + ) return + // Only a real MODAL takes the shortcut away. `aria-modal` is what marks + // one: `Dialog` sets it, the floating windows do not. Matching + // `role="dialog"` instead disabled the shortcut almost everywhere in + // Media, because selecting a single asset opens the viewer window — and + // that carries `role="dialog"` while deliberately leaving the grid + // usable behind it. + if (document.querySelector('[aria-modal="true"]')) return + event.preventDefault() + selectAllEvent() + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, []) + function handleFolderDragStart(folder: CmsMediaFolder, event: DragEvent) { if (!canWrite) { event.preventDefault() @@ -346,6 +403,30 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv groupLabel="Filter media type" trailing={(
+ {/* Discoverability for Ctrl/Cmd+A. The shortcut is the one people + reach for, but only if they already know it exists — and the + trash is where it matters most, because emptying it was + otherwise a file-at-a-time job. */} +