diff --git a/src/__tests__/media/multiSelectActions.test.tsx b/src/__tests__/media/multiSelectActions.test.tsx new file mode 100644 index 000000000..1adc5a84c --- /dev/null +++ b/src/__tests__/media/multiSelectActions.test.tsx @@ -0,0 +1,305 @@ +/** + * The Media workspace's multi-selection reaching the actions that read as if + * they already honour it. + * + * Reported from a real install: + * + * 1. Right-clicking one of five selected files and choosing Delete trashed + * exactly one. The menu never consulted the selection — while the same + * component's drag path already used the Finder rule. + * 2. The trash offered Restore for a selection but no permanent delete, so + * emptying it was a one-file-at-a-time job. + * + * Every case renders the real component and drives it the way an operator + * does — the menu, the drag, the confirmation — so the tests fail if the + * component stops using the rule, not just if the rule changes. + */ + +import { afterEach, describe, expect, it } from 'bun:test' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import type { ReactElement } from 'react' +import { BulkEditWindow } from '@admin/pages/media/components/BulkEditWindow/BulkEditWindow' +import { MediaCanvas } from '@admin/pages/media/components/MediaCanvas/MediaCanvas' +import { + FOLDER_ALL, + FOLDER_TRASH, + type UseMediaWorkspaceResult, +} from '@admin/pages/media/hooks/useMediaWorkspace' +import { actionTargets } from '@admin/pages/media/utils/actionTargets' +import { buildFolderTree } from '@admin/pages/media/utils/folderTree' +import { MEDIA_ASSET_DRAG_TYPE } from '@admin/pages/media/utils/mediaDragDrop' +import { AdminSessionProvider } from '@admin/session' +import { ConfirmDeleteProvider } from '@admin/shared/dialogs/ConfirmDeleteDialog' +import type { CoreCapability } from '@core/capabilities' +import type { CmsCurrentUser } from '@core/persistence' +import type { CmsMediaAsset } from '@core/persistence/cmsMedia' + +afterEach(cleanup) + +const TRASHED = '2026-02-01T00:00:00.000Z' + +function asset(id: string, deletedAt: string | null = null): 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, + 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 an operator's clicks made the component do, in order. */ +interface Calls { + trashed: string[] + purged: string[] +} + +function workspace( + assets: CmsMediaAsset[], + selected: string[], + options: { trash?: boolean } = {}, +): { workspace: UseMediaWorkspaceResult; calls: Calls } { + const calls: Calls = { trashed: [], purged: [] } + const selectedIds = new Set(selected) + const ws: UseMediaWorkspaceResult = { + loading: false, + error: null, + clearError: () => {}, + refresh: async () => {}, + folders: [], + folderTree: buildFolderTree([]), + folderById: new Map(), + assets, + visibleAssets: assets, + tagPalette: [], + folderSelection: options.trash ? FOLDER_TRASH : FOLDER_ALL, + setFolderSelection: () => {}, + selectedAssetId: null, + selectedAsset: null, + setSelectedAssetId: () => {}, + selectedAssetIds: selectedIds, + selectedAssets: assets.filter((a) => selectedIds.has(a.id)), + toggleAssetInSelection: () => {}, + addToSelection: () => {}, + selectRange: () => {}, + clearSelection: () => {}, + 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 (id: string) => { + calls.trashed.push(id) + }, + restoreAsset: async () => null, + purgeAsset: async (id: string) => { + calls.purged.push(id) + }, + setAssetFolders: async () => null, + moveAssetsToFolder: async () => {}, + createFolder: async () => null, + renameFolder: async () => null, + moveFolder: async () => null, + deleteFolder: async () => {}, + } + return { workspace: ws, calls } +} + +/** The tree the canvas really runs in: the session and the one confirm host. */ +function renderInAdmin(ui: ReactElement) { + return render( + + {ui} + , + ) +} + +function rightClick(filename: string) { + fireEvent.contextMenu(screen.getByRole('button', { name: `Open ${filename}` })) +} + +function chooseMenuItem(label: RegExp) { + fireEvent.click(screen.getByRole('menuitem', { name: label })) +} + +const FIVE = ['a', 'b', 'c', 'd', 'e'] + +describe('the Finder rule', () => { + it('acts on the whole selection when the clicked item is part of it', () => { + expect(actionTargets('c', new Set(FIVE))).toEqual(FIVE) + }) + + it('acts on the clicked item alone when it sits outside the selection', () => { + expect(actionTargets('z', new Set(['a', 'b']))).toEqual(['z']) + }) + + it('acts on the clicked item when nothing is selected', () => { + expect(actionTargets('a', new Set())).toEqual(['a']) + }) +}) + +describe('the Media context menu acts on the selection', () => { + it('trashes every selected file when one of them is right-clicked', () => { + const { workspace: ws, calls } = workspace(FIVE.map((id) => asset(id)), FIVE) + renderInAdmin() + + rightClick('c.png') + chooseMenuItem(/trash 5 files/i) + + expect(calls.trashed).toEqual(FIVE) + }) + + it('trashes only the right-clicked file when it is outside the selection', () => { + const { workspace: ws, calls } = workspace(['a', 'b', 'z'].map((id) => asset(id)), ['a', 'b']) + renderInAdmin() + + rightClick('z.png') + chooseMenuItem(/move to trash/i) + + expect(calls.trashed).toEqual(['z']) + }) + + it('drags the same set the menu acts on', () => { + const { workspace: ws } = workspace(FIVE.map((id) => asset(id)), FIVE) + renderInAdmin() + + const data = new Map() + const transfer = { + types: [] as string[], + effectAllowed: 'uninitialized', + setData: (type: string, value: string) => { + data.set(type, value) + }, + getData: (type: string) => data.get(type) ?? '', + setDragImage: () => {}, + } + fireEvent.dragStart(screen.getByRole('button', { name: 'Open c.png' }), { dataTransfer: transfer }) + + expect(JSON.parse(data.get(MEDIA_ASSET_DRAG_TYPE) ?? '{}').assetIds).toEqual(FIVE) + }) +}) + +describe('a permanent delete from the trash menu asks first', () => { + function inTrash(selected: string[]): Calls { + const setup = workspace(FIVE.map((id) => asset(id, TRASHED)), selected, { trash: true }) + renderInAdmin() + return setup.calls + } + + it('does not purge the selection on the menu click', () => { + const calls = inTrash(['a', 'b', 'c']) + rightClick('b.png') + chooseMenuItem(/delete 3 files/i) + + expect(calls.purged).toEqual([]) + expect(screen.getByText(/delete 3 files permanently\?/i)).toBeTruthy() + }) + + it('purges every selected file once the operator confirms', () => { + const calls = inTrash(['a', 'b', 'c']) + rightClick('b.png') + chooseMenuItem(/delete 3 files/i) + fireEvent.click(screen.getByRole('button', { name: /^delete 3 files$/i })) + + expect(calls.purged).toEqual(['a', 'b', 'c']) + }) + + it('purges nothing when the operator cancels', () => { + const calls = inTrash(['a', 'b', 'c']) + rightClick('b.png') + chooseMenuItem(/delete 3 files/i) + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + + expect(calls.purged).toEqual([]) + }) + + it('asks for a single file too', () => { + const calls = inTrash([]) + rightClick('d.png') + chooseMenuItem(/delete permanently/i) + + expect(calls.purged).toEqual([]) + expect(screen.getByText(/delete "d\.png" permanently\?/i)).toBeTruthy() + }) +}) + +describe('the bulk window deletes exactly what it counts', () => { + it('counts and purges only the trashed members of a mixed selection', async () => { + const assets = [asset('t1', TRASHED), asset('live'), asset('t2', TRASHED)] + const { workspace: ws, calls } = workspace(assets, ['t1', 'live', 't2']) + renderInAdmin( {}} />) + + fireEvent.click(screen.getByRole('button', { name: /delete permanently/i })) + expect(screen.getByText(/delete 2 files permanently\?/i)).toBeTruthy() + + // The dialog's own confirm button renders after the window's. + fireEvent.click(screen.getAllByRole('button', { name: /delete permanently/i }).at(-1)!) + + // `purgeAsset` 400s on a live asset: the purge must skip it, not just + // the count. + await waitFor(() => expect(calls.purged).toEqual(['t1', 't2'])) + }) +}) diff --git a/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx b/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx index 496894e19..48a121f50 100644 --- a/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx +++ b/src/admin/pages/media/components/BulkEditWindow/BulkEditWindow.tsx @@ -11,6 +11,7 @@ */ import { useState } from 'react' import { Button } from '@ui/components/Button' +import { Dialog } from '@ui/components/Dialog' import { Input, Textarea } from '@ui/components/Input' import { canDeleteMedia, canWriteMedia } from '@admin/access' import { useCurrentAdminUser } from '@admin/sessionContext' @@ -143,10 +144,39 @@ async function runRestoreAll( } } +/** + * Permanent deletion for a whole selection. + * + * Same per-asset loop as its Trash / Restore siblings — `purgeAsset` is a + * single-id endpoint like the other two, so no server work is needed. The + * trash view offered Restore but no counterpart, which left emptying the + * trash a one-file-at-a-time job through the preview window. + */ +async function runPurgeAll( + assets: UseMediaWorkspaceResult['selectedAssets'], + workspace: UseMediaWorkspaceResult, + setBusy: (v: boolean) => void, + setProgress: (v: { done: number; total: number } | null) => void, +): Promise { + const count = assets.length + try { + let done = 0 + for (const asset of assets) { + await workspace.purgeAsset(asset.id) + done += 1 + setProgress({ done, total: count }) + } + } finally { + setBusy(false) + setTimeout(() => setProgress(null), 800) + } +} + export function BulkEditWindow({ workspace, open, onClose }: BulkEditWindowProps) { const currentUser = useCurrentAdminUser() const [plan, setPlan] = useState(EMPTY_PLAN) const [busy, setBusy] = useState(false) + const [purgeConfirmOpen, setPurgeConfirmOpen] = useState(false) const [progress, setProgress] = useState<{ done: number; total: number } | null>(null) const assets = workspace.selectedAssets @@ -181,6 +211,19 @@ export function BulkEditWindow({ workspace, open, onClose }: BulkEditWindowProps } const anyTrashed = assets.some((a) => a.deletedAt !== null) + // Only the trashed members are purgeable — `purgeAsset` 400s on an asset + // that has not been soft-deleted. The confirmation counts these AND the + // purge runs on these, so a mixed selection deletes exactly what the + // dialog promised and never fires a request bound to fail. + const purgeable = assets.filter((a) => a.deletedAt !== null) + const trashedCount = purgeable.length + + async function purgeAll() { + if (!canDelete || busy || trashedCount === 0) return + setBusy(true) + setProgress({ done: 0, total: trashedCount }) + await runPurgeAll(purgeable, workspace, setBusy, setProgress) + } const anyActive = assets.some((a) => a.deletedAt === null) return ( @@ -322,9 +365,48 @@ export function BulkEditWindow({ workspace, open, onClose }: BulkEditWindowProps Restore )} + {canDelete && anyTrashed && ( + + )} )} + setPurgeConfirmOpen(false)} + tone="danger" + eyebrow="Cannot be undone" + title={`Delete ${trashedCount} ${trashedCount === 1 ? 'file' : 'files'} permanently?`} + footer={ + <> + + + + } + > +

+ This removes each file and every generated size from disk. Any page + still referencing one will render a broken image. +

+
) } diff --git a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx index 3dba08963..72da99532 100644 --- a/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx +++ b/src/admin/pages/media/components/MediaCanvas/MediaCanvas.tsx @@ -19,6 +19,7 @@ import { FilterBar, type FilterBarItem } from '@ui/components/FilterBar' import { Select } from '@ui/components/Select' import { Skeleton } from '@ui/components/Skeleton' import { canDeleteMedia, canWriteMedia } from '@admin/access' +import { useConfirmDelete } from '@admin/shared/dialogs/ConfirmDeleteDialog' import { useCurrentAdminUser } from '@admin/sessionContext' import { ExplorerItemContextMenu, @@ -48,6 +49,7 @@ import { type UseMediaWorkspaceResult, } from '../../hooks/useMediaWorkspace' import { childFoldersForParent } from '../../utils/folderTree' +import { actionTargets } from '../../utils/actionTargets' import { writeMediaAssetDragData, writeMediaFolderDragData, @@ -122,6 +124,7 @@ function folderMatchesQuery(folder: CmsMediaFolder, query: string): boolean { export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanvasProps) { const currentUser = useCurrentAdminUser() + const confirmDelete = useConfirmDelete() const [viewMode, setViewModeState] = useState(readStoredMediaViewMode) const [contextMenu, setContextMenu] = useState(null) const [renameTarget, setRenameTarget] = useState(null) @@ -189,11 +192,7 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv event.preventDefault() return } - const selectedIds = Array.from(workspace.selectedAssetIds) - const dragIds = workspace.selectedAssetIds.has(asset.id) && selectedIds.length > 0 - ? selectedIds - : [asset.id] - writeMediaAssetDragData(event.dataTransfer, dragIds) + writeMediaAssetDragData(event.dataTransfer, actionTargets(asset.id, workspace.selectedAssetIds)) } function handleFolderDragStart(folder: CmsMediaFolder, event: DragEvent) { @@ -517,27 +516,67 @@ export function MediaCanvas({ workspace, selectionMode = 'standard' }: MediaCanv )} - {contextMenu && ( - setContextMenu(null)} - onRename={() => { - setRenameTarget(contextMenu.asset) - setContextMenu(null) - }} - onDelete={() => { - const target = contextMenu.asset - setContextMenu(null) - if (trashView) void workspace.purgeAsset(target.id) - else void workspace.trashAsset(target.id) - }} - showRename={canWrite} - showDelete={canDelete} - extraItems={buildExtraMenuItems(contextMenu.asset)} - /> - )} + {contextMenu && (() => { + // `actionTargets`, not the clicked asset: the menu used to ignore the + // selection, so right-clicking one of five selected files and choosing + // Delete trashed exactly one. It deliberately does NOT adopt the clicked + // asset into the selection the way the site explorer does — Media's + // floating windows derive from the selection during render (viewer at + // <= 1, bulk edit at >= 2, MediaPage.tsx), so writing it here would pop + // a window open underneath the menu. + const targets = actionTargets(contextMenu.asset.id, workspace.selectedAssetIds) + const many = targets.length > 1 + return ( + setContextMenu(null)} + {...(many ? { headerLabel: `${targets.length} files` } : {})} + onRename={() => { + setRenameTarget(contextMenu.asset) + setContextMenu(null) + }} + onDelete={() => { + const target = contextMenu.asset + setContextMenu(null) + if (!trashView) { + for (const id of targets) void workspace.trashAsset(id) + return + } + // Purging is the one media action nothing can undo: it removes + // the original and every generated size from the storage + // adapter, not just the row — and from here it can reach a + // whole selection at once. `alwaysConfirm` because the + // `confirmBeforeDelete` preference defaults off, and an operator + // who turned it off was opting out of confirming a TRASH, which + // is reversible. + confirmDelete({ + title: many + ? `Delete ${targets.length} files permanently?` + : `Delete "${target.filename}" permanently?`, + description: many + ? 'This removes every one of these files and all their generated sizes from disk. It cannot be undone.' + : 'This removes the file and every generated size from disk. It cannot be undone.', + confirmLabel: many ? `Delete ${targets.length} files` : 'Delete permanently', + alwaysConfirm: true, + commit: () => { + for (const id of targets) void workspace.purgeAsset(id) + }, + }) + }} + deleteLabel={ + many + ? `${trashView ? 'Delete' : 'Trash'} ${targets.length} files` + : trashView ? 'Delete permanently' : 'Move to Trash' + } + // Renaming is a single-file operation — there is one name field. + showRename={canWrite && !many} + showDelete={canDelete} + extraItems={buildExtraMenuItems(contextMenu.asset)} + /> + ) + })()} {renameTarget && ( ({ x: window.innerWidth - 880, y: 80 }), ) + // This window renders its own shell rather than `FloatingWindow`, so it + // needs the same Escape rule wired up directly. The stacking check is what + // keeps the purge confirmation below owning Escape until it closes. + useTopmostEscape(true, panelRef, onClose) + // ── Save callbacks ──────────────────────────────────────────────────────── const saveTitle = async (next: string) => { if (!canWrite) return diff --git a/src/admin/pages/media/utils/actionTargets.ts b/src/admin/pages/media/utils/actionTargets.ts new file mode 100644 index 000000000..c7f40b6a0 --- /dev/null +++ b/src/admin/pages/media/utils/actionTargets.ts @@ -0,0 +1,12 @@ +/** + * Which assets an action on one tile applies to — the Finder rule. + * + * Acting on an item inside the selection acts on the whole selection; acting + * on one outside it acts on that item alone, so right-clicking away from a + * selection starts a new, unrelated action instead of sweeping the old + * selection in. Dragging and the context menu both resolve their ids here, + * so the same tile can never drag one set of files and delete another. + */ +export function actionTargets(clickedId: string, selectedIds: ReadonlySet): string[] { + return selectedIds.has(clickedId) ? [...selectedIds] : [clickedId] +} diff --git a/src/admin/shared/FloatingWindow/FloatingWindow.tsx b/src/admin/shared/FloatingWindow/FloatingWindow.tsx index 1c7b368fa..dd02aaaea 100644 --- a/src/admin/shared/FloatingWindow/FloatingWindow.tsx +++ b/src/admin/shared/FloatingWindow/FloatingWindow.tsx @@ -4,6 +4,7 @@ import { PanelHeader } from '@admin/shared/PanelHeader' import type { FloatingPanelId, PanelPosition } from '@admin/state/workspaceLayoutStorage' import { cn } from '@ui/cn' import { useDraggablePanel } from './useDraggablePanel' +import { useTopmostEscape } from './useTopmostEscape' import styles from './FloatingWindow.module.css' interface FloatingWindowProps { @@ -52,6 +53,8 @@ export function FloatingWindow({ ) useImperativeHandle(forwardedRef, () => panelRef.current as HTMLDivElement) + useTopmostEscape(open, panelRef, onClose) + if (!open) return null const style = { diff --git a/src/admin/shared/FloatingWindow/index.ts b/src/admin/shared/FloatingWindow/index.ts index c69ae3b01..34dd0571d 100644 --- a/src/admin/shared/FloatingWindow/index.ts +++ b/src/admin/shared/FloatingWindow/index.ts @@ -8,3 +8,4 @@ export { clampFloatingPanelSize, useResizablePanel, } from './useResizablePanel' +export { useTopmostEscape } from './useTopmostEscape' diff --git a/src/admin/shared/FloatingWindow/useTopmostEscape.ts b/src/admin/shared/FloatingWindow/useTopmostEscape.ts new file mode 100644 index 000000000..1c58e95c5 --- /dev/null +++ b/src/admin/shared/FloatingWindow/useTopmostEscape.ts @@ -0,0 +1,66 @@ +/** + * Escape-to-close for a floating panel, honouring whatever is stacked above it. + * + * Every overlay in this admin takes Escape — the settings modal, the shared + * `Dialog`, Spotlight. The draggable windows did not: they overlay the grid + * they were opened from, and the only way out was the header's close button. + * + * The stacking check is the same one `SettingsModal` uses, and it is the + * reason this is a hook rather than three copies. A window can open a + * `Dialog` of its own — a delete confirmation, the replace-file picker — and + * that dialog must own Escape until it closes. Without the check, one press + * would collapse the confirmation and the window underneath it together. + * + * `alertdialog` counts as a layer: `Dialog` renders that role instead of + * `dialog` when `tone === 'danger'`, which is exactly what a destructive + * confirmation opened from one of these windows is. + * + * So does `menu`. A context menu opened inside a window owns Escape while it + * is up — closing the menu and the window together on one press loses the + * user's place. Menus portal to `document.body`, so they are not descendants + * of the panel and the document-order test alone would miss them. + */ + +import { useEffect, useEffectEvent, type RefObject } from 'react' + +export function useTopmostEscape( + open: boolean, + panelRef: RefObject, + onClose: () => void, +): void { + // `useEffectEvent` keeps `onClose` out of the dependency array — callers + // pass an inline arrow, and re-subscribing the listener on every render + // would drop keystrokes between removal and re-add. + const closeEvent = useEffectEvent(() => onClose()) + + useEffect(() => { + if (!open) return undefined + + function onKeyDown(event: globalThis.KeyboardEvent) { + if (event.key !== 'Escape') return + const self = panelRef.current + if (!self) return + + // An open menu owns Escape wherever it sits — it is a transient layer + // above everything, and unlike a dialog it may render before the panel + // in document order. + if (document.querySelector('[role="menu"]')) return + + const stackedAbove = Array.from( + document.querySelectorAll('[role="dialog"], [role="alertdialog"]'), + ).some( + (el) => + el !== self + && Boolean(self.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING), + ) + if (stackedAbove) return + + event.preventDefault() + event.stopPropagation() + closeEvent() + } + + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [open, panelRef]) +}