Skip to content
85 changes: 85 additions & 0 deletions src/__tests__/media/multiSelectActions.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* The Media workspace's multi-selection reaching the actions that read as if
* they already honour it.
*
* Three behaviours, all 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.
* 3. Escape did not close the floating windows, though every other overlay
* in the admin takes it.
*
* These cover the selection rule itself. It is pure and the interesting
* cases are the boundaries: an item inside the selection acts on all of it,
* an item outside acts on itself alone, and an empty selection never widens
* a single right-click into nothing.
*/

import { describe, expect, it } from 'bun:test'

/**
* The rule as `contextMenuTargets` implements it, and as
* `handleAssetDragStart` has implemented it all along.
*/
function targetsFor(clickedId: string, selected: string[]): string[] {
const selectedIds = new Set(selected)
return selectedIds.has(clickedId) && selected.length > 0 ? [...selected] : [clickedId]
}

describe('which assets a Media right-click acts on', () => {
it('acts on the whole selection when the clicked file is part of it', () => {
const five = ['a', 'b', 'c', 'd', 'e']
expect(targetsFor('c', five)).toEqual(five)
})

it('acts on the clicked file alone when it sits outside the selection', () => {
// Right-clicking away from a selection is how every file manager starts a
// new, unrelated action — it must not sweep the old selection in.
expect(targetsFor('z', ['a', 'b'])).toEqual(['z'])
})

it('acts on the clicked file when nothing is selected', () => {
expect(targetsFor('a', [])).toEqual(['a'])
})

it('is the same rule the drag path uses', () => {
// `handleAssetDragStart` resolves its ids identically. If these ever
// diverge, dragging and right-clicking the same file would act on
// different sets, which is the state this change removed.
const dragIds = (clicked: string, selected: string[]) => {
const ids = [...selected]
return new Set(selected).has(clicked) && ids.length > 0 ? ids : [clicked]
}
for (const [clicked, selected] of [
['c', ['a', 'b', 'c']],
['z', ['a', 'b']],
['a', []],
] as const) {
expect(targetsFor(clicked, [...selected])).toEqual(dragIds(clicked, [...selected]))
}
})
})

describe('which assets a bulk purge counts', () => {
/** `trashedCount` — only soft-deleted rows are purgeable. */
const purgeable = (assets: { deletedAt: string | null }[]) =>
assets.filter((a) => a.deletedAt !== null).length

it('counts only the trashed members of a mixed selection', () => {
// `purgeAsset` 400s on an asset that was never soft-deleted, so a
// confirmation promising to delete the whole selection would overstate
// what is about to happen.
expect(purgeable([
{ deletedAt: '2026-01-01T00:00:00.000Z' },
{ deletedAt: null },
{ deletedAt: '2026-01-02T00:00:00.000Z' },
])).toBe(2)
})

it('counts nothing when the selection is entirely live', () => {
expect(purgeable([{ deletedAt: null }, { deletedAt: null }])).toBe(0)
})
})
140 changes: 140 additions & 0 deletions src/__tests__/media/selectAll.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Select All in the Media workspace.
*
* Two questions decide whether this is safe, and both are about scope rather
* than mechanics:
*
* 1. What does "all" mean? `visibleAssets` — whatever the folder, filter,
* search and trash toggle have already narrowed to. Anything wider would
* let "select all, then delete" in the trash reach live assets.
* 2. When must the shortcut stay out of the way? While the caret is in a
* text field, where Ctrl/Cmd+A means select-the-text, and while a dialog
* is open, where it belongs to whatever that dialog contains.
*
* The guard predicate is pure, so it is tested directly rather than through a
* mounted grid — the interesting cases are inputs, textareas, contenteditable
* and an open dialog, and a render harness would obscure rather than clarify
* them.
*/

import { afterEach, describe, expect, it } from 'bun:test'

/**
* `true` when the Ctrl/Cmd+A handler should claim the event, mirroring the
* guards in `MediaCanvas`.
*/
function claimsSelectAll(target: EventTarget | null): boolean {
if (
target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| (target instanceof HTMLElement && target.isContentEditable)
) return false
// Only a real modal — `aria-modal="true"` — takes the shortcut. The
// floating windows carry `role="dialog"` but leave the grid usable behind
// them, so matching the role would disable the shortcut almost everywhere.
if (document.querySelector('[aria-modal="true"]')) return false
return true
}

afterEach(() => {
document.body.innerHTML = ''
})

describe('when Ctrl/Cmd+A selects every visible asset', () => {
it('claims the shortcut over the grid', () => {
const grid = document.createElement('div')
document.body.append(grid)
expect(claimsSelectAll(grid)).toBe(true)
})

it('leaves it to the browser inside the search box', () => {
// Someone typing a filter means "select what I typed", not "select every
// file in this folder".
const input = document.createElement('input')
document.body.append(input)
expect(claimsSelectAll(input)).toBe(false)
})

it('leaves it alone in a textarea', () => {
const textarea = document.createElement('textarea')
document.body.append(textarea)
expect(claimsSelectAll(textarea)).toBe(false)
})

it('leaves it alone in a contenteditable field', () => {
// The alt-text and caption fields in the viewer are rich inputs, and they
// are not <input> elements.
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
document.body.append(editable)
// jsdom does not derive isContentEditable from the attribute.
Object.defineProperty(editable, 'isContentEditable', { value: true })
expect(claimsSelectAll(editable)).toBe(false)
})

it('stands down while a modal dialog is open', () => {
// A rename dialog or a delete confirmation owns the shortcut — selecting
// the grid behind it would change what a confirmed action applies to.
const grid = document.createElement('div')
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('aria-modal', 'true')
document.body.append(grid, dialog)
expect(claimsSelectAll(grid)).toBe(false)
})

it('keeps working while a floating window is open', () => {
// The regression this guard caused: selecting one asset opens the viewer
// window, which carries `role="dialog"` but no `aria-modal` because the
// grid stays usable behind it. Matching the role disabled Ctrl/Cmd+A for
// almost the whole time anyone spends in Media.
const grid = document.createElement('div')
const viewer = document.createElement('aside')
viewer.setAttribute('role', 'dialog')
document.body.append(grid, viewer)
expect(claimsSelectAll(grid)).toBe(true)
})
})

describe('the toggle', () => {
/** `allVisibleSelected` — the condition the button and shortcut both read. */
const allSelected = (visible: string[], selected: string[]) =>
visible.length > 0 && visible.every((id) => new Set(selected).has(id))

it('is not "all selected" when the grid is empty', () => {
// Otherwise `every` on an empty array reports true and the button would
// offer to clear a selection that does not exist.
expect(allSelected([], [])).toBe(false)
})

it('is not "all selected" when only some are', () => {
expect(allSelected(['a', 'b', 'c'], ['a', 'b'])).toBe(false)
})

it('is "all selected" once every visible asset is in the selection', () => {
expect(allSelected(['a', 'b'], ['a', 'b'])).toBe(true)
})

it('stays "all selected" when the selection reaches past the filter', () => {
// A selection made before narrowing the filter can hold ids that are no
// longer visible. The button asks about what is on screen, so those do
// not stop it offering to clear.
expect(allSelected(['a', 'b'], ['a', 'b', 'offscreen'])).toBe(true)
})
})

describe('what "all" means', () => {
it('is the visible set, not the whole library', () => {
// `visibleAssets` is already narrowed by folder, filter, search and the
// trash toggle. Selecting past it would let "select all, then delete
// permanently" in the trash reach live assets.
const library = [
{ id: 'a', deletedAt: '2026-01-01T00:00:00.000Z' },
{ id: 'b', deletedAt: null },
{ id: 'c', deletedAt: '2026-01-02T00:00:00.000Z' },
]
const visibleInTrash = library.filter((a) => a.deletedAt !== null)
expect(visibleInTrash.map((a) => a.id)).toEqual(['a', 'c'])
expect(visibleInTrash).not.toContain(library[1])
})
})
72 changes: 72 additions & 0 deletions src/__tests__/media/uploadQueueWindow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Dismissing the upload queue window while files are still uploading.
*
* The close button worked; an effect immediately undid it. It depended on
* both `uploadQueue.active` AND `uploadQueueOpen`, so every close re-ran it
* with `open` now false and the guard `active && !open` re-opened the window
* on the next commit. While a transfer was in flight the window could not be
* made to stay shut.
*
* The rule these pin is the one the fix restores: an upload STARTING opens
* the window, and nothing reopens it after that. Closing hides the transfer
* rather than cancelling it, so the toolbar button carries the count instead.
*/

import { describe, expect, it } from 'bun:test'

/**
* The effect's guard in both shapes: the old one that read the open state,
* and the fixed one keyed only on the transition into `active`.
*/
const oldGuard = (active: boolean, open: boolean) => active && !open
const newGuard = (active: boolean) => active

describe('what reopens the upload queue window', () => {
it('the old guard fires again the moment the user closes it', () => {
// Upload running, user clicks close → open flips false → the effect's
// dependencies changed → it runs → active && !open → reopened.
expect(oldGuard(true, false)).toBe(true)
})

it('the fixed guard does not re-run on close', () => {
// `active` has not changed, so the effect does not re-run at all. This
// test documents the dependency, not the boolean: the value is the same
// either way, and the bug was the extra dependency.
expect(newGuard(true)).toBe(true)
})

it('an upload starting still opens the window', () => {
expect(newGuard(false)).toBe(false)
expect(newGuard(true)).toBe(true)
})
})

describe('the progress the toolbar button reports', () => {
type Item = { status: 'queued' | 'uploading' | 'succeeded' | 'failed' | 'cancelled' }

const inFlight = (items: Item[]) =>
items.filter((i) => i.status === 'queued' || i.status === 'uploading').length

it('counts queued and uploading as still running', () => {
expect(inFlight([
{ status: 'queued' }, { status: 'uploading' }, { status: 'succeeded' },
])).toBe(2)
})

it('counts a failed upload as finished, not in flight', () => {
// A failure is done — it needs a retry, not a progress bar. Counting it
// as running would leave the button stuck mid-count forever.
expect(inFlight([{ status: 'failed' }, { status: 'cancelled' }])).toBe(0)
})

it('reports nothing to show when the queue is empty', () => {
expect(inFlight([])).toBe(0)
})

it('derives the done count from the total', () => {
const items: Item[] = [
{ status: 'succeeded' }, { status: 'succeeded' }, { status: 'uploading' },
]
expect(items.length - inFlight(items)).toBe(2)
})
})
31 changes: 25 additions & 6 deletions src/admin/pages/media/MediaPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,43 @@ export function MediaPage() {
// completes (the user dismisses it) and the toolbar button toggles it — so
// it can't be derived. Auto-opening on the async upload transition is the
// legitimate "sync UI to an external async system" use of an effect.
//
// Keyed on the TRANSITION into `active`, not on the flag plus the current
// open state. Depending on `uploadQueueOpen` re-ran this on every close and
// immediately re-opened the window, so while an upload was in flight the
// close button could not be made to stick.
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (workspace.uploadQueue.active && !uploadQueueOpen) {
setUploadQueueOpen(true)
}
}, [workspace.uploadQueue.active, uploadQueueOpen])
if (workspace.uploadQueue.active) setUploadQueueOpen(true)
}, [workspace.uploadQueue.active])
/* eslint-enable react-hooks/set-state-in-effect */

// Closing the queue window hides the transfer, it does not cancel it — so
// the button that reopens it carries the progress. Without this, dismissing
// the window during an upload left no sign anything was still running.
const uploadsInFlight = workspace.uploadQueue.items.filter(
(item) => item.status === 'queued' || item.status === 'uploading',
).length
const uploadsTotal = workspace.uploadQueue.items.length

const toolbarRightSlot = (
<Button
variant="ghost"
size="sm"
onClick={() => setUploadQueueOpen((open) => !open)}
aria-label="Toggle upload queue"
aria-label={
uploadsInFlight > 0
? `Toggle upload queue — ${uploadsTotal - uploadsInFlight} of ${uploadsTotal} done`
: 'Toggle upload queue'
}
pressed={uploadQueueOpen}
>
<UploadIcon size={13} />
<span>Uploads</span>
<span>
{uploadsInFlight > 0
? `Uploads ${uploadsTotal - uploadsInFlight}/${uploadsTotal}`
: 'Uploads'}
</span>
{workspace.uploadQueue.active && (
<span aria-hidden="true" style={{ marginLeft: 4 }}>·</span>
)}
Expand Down
Loading
Loading