feat(SPA): media library indications for custom medial field type usage

This commit is contained in:
Florian Sylvain committed 2026-10-21 16:17:09 +02:00
1 parent 3e7fc9a4a5
commit 0fc3511e2b
28 files changed
+2060 -131

No files matched your search

+25 -15
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, provide, ref, watch } from 'vue'
import { useRoute, type LocationQuery } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation'
@@ -30,7 +30,6 @@ import {
useMedia,
type BulkDeleteResult,
type MediaItem,
type MediaUsageItem,
} from '@/composables/useMedia'
import { useMediaSelection } from '@/composables/useMediaSelection'
import { useMediaUpload } from '@/composables/useMediaUpload'
@@ -39,6 +38,7 @@ import type { PurgeResult } from '@/composables/useMediaPurge'
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
import { joinNames } from '@/lib/mediaFormat'
import { MEDIA_USAGE_KEY } from '@/lib/mediaUsageContext'
import {
isFullSet,
parseMediaControls,
@@ -149,7 +149,6 @@ const purgeOpen = ref(false)
const dragDepth = ref(0)
const announcementSource = ref<() => string>(() => '')
const announcement = computed(() => announcementSource.value())
const usage = ref<MediaUsageItem[]>([])
const copies = ref<MediaItem[]>([])
let opener: HTMLElement | null = null
let purgeChangedList = false
@@ -160,9 +159,25 @@ let bulkFailureRevision = 0
let bulkFailureToastId = BULK_FAILED_TOAST_ID
let failureToastShown = false
provide(MEDIA_USAGE_KEY, {
stateOf: media.usageOf,
ensure: (id) => {
void media.ensureUsage(id)
},
})
const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
)
watch(
() =>
panelOpen.value && activeItem.value !== null && media.usageOf(activeItem.value.id) === null,
(missing) => {
if (missing && activeItem.value && activeItem.value.used_in > 0) {
loadUsage(activeItem.value)
}
},
)
const closingItem = ref<MediaItem | null>(null)
const panelItem = computed(() => activeItem.value ?? closingItem.value)
const counts = computed(() => (hasLoaded.value ? media.counts.value : null))
@@ -365,15 +380,10 @@ function restoreFocusToOpener(id: number | null) {
}
async function loadUsage(item: MediaItem) {
try {
const rows = await media.fetchUsage(item.id)
if (activeId.value === item.id) {
usage.value = rows
}
} catch (error) {
if (activeId.value === item.id && classifyMediaError(error) === 'not-found') {
handleGone(item)
}
await media.ensureUsage(item.id, true)
const state = media.usageOf(item.id)
if (activeId.value === item.id && state?.status === 'failed' && state.failure === 'not-found') {
handleGone(item)
}
}
@@ -395,7 +405,6 @@ function openPanel(id: number, element: HTMLElement) {
}
opener = element
activeId.value = id
usage.value = []
copies.value = []
skipFocusRestore = false
panelOpen.value = true
@@ -1150,7 +1159,7 @@ async function mergeAction() {
<MediaDetailPanel
:open="panelOpen"
:item="panelItem"
:usage="usage"
:usage="panelItem ? media.usageOf(panelItem.id) : null"
:copies="copies"
:offline="offline"
@update:open="onPanelOpenChange"
@@ -1158,6 +1167,7 @@ async function mergeAction() {
@rename="renameOpen = true"
@delete="deleteOpen = true"
@review-duplicates="reviewDuplicates"
@retry-usage="activeItem && loadUsage(activeItem)"
@gone="activeItem && handleGone(activeItem)"
/>
@@ -1171,7 +1181,7 @@ async function mergeAction() {
<DeleteMediaDialog
v-if="deleteOpen && activeItem"
:item="activeItem"
:usage="usage"
:usage="media.usageOf(activeItem.id)"
:action="deleteAction"
@close="deleteOpen = false"
/>
@@ -264,9 +264,9 @@ describe('Merge', () => {
const box = dialog()!
expect(textOf(box)).toContain('Merge into floorboards.jpg?')
expect(textOf(box)).toContain(
"Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 articles will be updated. This can't be undone.",
"Articles that use the other 2 copies will be switched to floorboards.jpg, and the other 2 copies will be deleted. 5 places will be updated. This can't be undone.",
)
expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 articles now')
expect(textOf(box)).toContain('floorboards.jpg Kept · used in 4 places now')
expect(Array.from(box.querySelectorAll('button')).map(textOf)).toEqual([
'Cancel',
'Merge 2 copies',
@@ -278,7 +278,7 @@ describe('Merge', () => {
await openMerge('IMG_2208.jpg')
expect(textOf(dialog())).toContain('Merge into IMG_2208.jpg?')
expect(textOf(dialog())).toContain('7 articles will be updated.')
expect(textOf(dialog())).toContain('7 places will be updated.')
})
it('uses the singular for a group of two copies', async () => {
@@ -357,7 +357,7 @@ describe('Bulk delete', () => {
const box = dialog()!
expect(textOf(box)).toContain('Delete 3 images?')
expect(textOf(box)).toContain(
"2 of them are used in 5 articles, which will show a broken image. The other one isn't used anywhere. This can't be undone.",
"2 of them are used in 5 places, which will show a broken image or a missing-image notice. The other one isn't used anywhere. This can't be undone.",
)
const rows = Array.from(box.querySelectorAll('[data-testid="bulk-delete-row"]')).map(textOf)
expect(rows).toEqual([
+662
View File
@@ -0,0 +1,662 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { ApiError, NetworkError } from '@/composables/useApi'
import type { MediaUsageItem } from '@/composables/useMedia'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
callsTo,
cardFor,
cards,
deferred,
dialog,
fixture,
findButton,
listBody,
mountPage,
panel,
settle,
textOf,
unmountPage,
type Handlers,
} from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const USED_IN: Record<number, number> = { 1: 2, 8: 0, 10: 3, 11: 7, 4: 2 }
const NOTICE = 'This image is no longer in the library'
function article(id: number, title: string, published: boolean): MediaUsageItem {
return { type: 'article', id, title, published, fields: [] }
}
function eventEntry(id: number, title: unknown, type = 'text'): MediaUsageItem {
return {
type: 'entry',
id,
fields: ['thumbnail'],
content_type_key: 'events',
content_type_name: 'Events',
first_field: { key: 'title', type, value: title },
}
}
const LIME_TITLES = ['Lime plaster workshop', 'Heritage stone walk', 'Winter open day']
function entriesOf(count: number): MediaUsageItem[] {
return Array.from({ length: count }, (_, index) =>
eventEntry(index + 1, LIME_TITLES[index] ?? `Event ${index + 1}`),
)
}
const ARTICLES: MediaUsageItem[] = [
article(20, 'Open day at the workshop', true),
article(21, 'Restoring lime plaster', false),
]
const USAGE: Record<number, MediaUsageItem[]> = {
1: ARTICLES,
4: [
{ type: 'author', id: 1, fields: ['avatar'] },
{ type: 'site', id: 2, fields: ['logo'] },
],
10: entriesOf(3),
11: [...ARTICLES, ...entriesOf(5)],
}
function library(): Handlers {
return {
list: () =>
listBody(
fixture().map((item) =>
item.id in USED_IN ? { ...item, used_in: USED_IN[item.id]! } : item,
),
),
usage: (id) => USAGE[id] ?? [],
}
}
async function open(filename: string) {
cardFor(filename).click()
await settle()
}
function usageCalls() {
return callsTo('GET', /\/usage$/)
}
function badgeTexts(root: Element) {
return Array.from(root.querySelectorAll('[data-kind]')).map(textOf)
}
function tooltipLines() {
return Array.from(document.body.querySelectorAll('[data-testid="media-usage-line"]')).map(
textOf,
)
}
function triggerOf(filename: string) {
return cardFor(filename).querySelector<HTMLElement>('[data-testid="media-usage-trigger"]')!
}
async function hover(filename: string) {
triggerOf(filename).dispatchEvent(new MouseEvent('pointerenter'))
await settle()
}
afterEach(unmountPage)
describe('The label of the usage badge', () => {
it.each([
['window-frame.jpg', 'Unused'],
['kitchen-before.webp', 'Used in 1'],
['bathroom-tiles.png', 'Used in 3'],
['lime-plaster.jpg', 'Used in 7'],
])('%s reads %s in the grid', async (filename, label) => {
await mountPage(library(), '/media', undefined, { app: true })
const badges = badgeTexts(cardFor(filename))
expect(badges).toContain(label)
expect(
cardFor(filename).querySelector('[data-kind="used"] svg, [data-kind="used"] i'),
).toBeNull()
})
it('reads the same in the list row and as a button', async () => {
await mountPage(library(), '/media?view=list', undefined, { app: true })
const rows = Array.from(document.body.querySelectorAll('[data-testid="media-row"]'))
const lime = rows.find((row) => textOf(row).includes('lime-plaster.jpg'))!
const unused = rows.find((row) => textOf(row).includes('window-frame.jpg'))!
const button = lime.querySelector<HTMLElement>('[data-testid="media-used-in"]')!
expect(textOf(button)).toBe('Used in 7')
expect(button.className).toContain('underline')
expect(textOf(unused.querySelector('[data-kind="unused"]'))).toBe('Unused')
expect(textOf(lime)).not.toMatch(/\d+ articles?/)
expect(textOf(unused)).not.toMatch(/\d+ articles?/)
})
it('reads the same in the mobile card', async () => {
await mountPage(library(), '/media?view=list', MOBILE_WIDTH, { app: true })
const row = Array.from(
document.body.querySelectorAll('[data-testid="media-card-row"]'),
).find((entry) => textOf(entry).includes('bathroom-tiles.png'))!
expect(badgeTexts(row)).toContain('Used in 3')
})
it.each([
['window-frame.jpg', 'unused'],
['kitchen-before.webp', 'used in 1 place'],
['lime-plaster.jpg', 'used in 7 places'],
])('names the card of %s with "%s"', async (filename, usage) => {
await mountPage(library(), '/media', undefined, { app: true })
expect(cardFor(filename).getAttribute('aria-label')).toMatch(new RegExp(`, ${usage}$`))
})
})
describe('The usage tooltip', () => {
it('opens on hover with the entries line only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('bathroom-tiles.png')
expect(callsTo('GET', /\/v1\/media\/10\/usage$/)).toHaveLength(1)
expect(tooltipLines()).toEqual(['Used in entries: 3'])
})
it('lists articles first then entries', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('lime-plaster.jpg')
expect(tooltipLines()).toEqual(['Used in articles: 2', 'Used in entries: 5'])
})
it('shows articles only without an entries line', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('north-wall.jpg')
expect(tooltipLines()).toEqual(['Used in articles: 2'])
})
it('closes when the pointer leaves', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('bathroom-tiles.png')
triggerOf('bathroom-tiles.png').dispatchEvent(new MouseEvent('pointerleave'))
await settle()
expect(tooltipLines()).toEqual([])
})
it('closes on Escape without closing anything else', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('bathroom-tiles.png')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
await settle()
expect(tooltipLines()).toEqual([])
expect(cards().length).toBeGreaterThan(0)
})
it('opens when the card takes the keyboard focus', async () => {
await mountPage(library(), '/media', undefined, { app: true })
cardFor('bathroom-tiles.png').focus()
await settle()
expect(tooltipLines()).toEqual(['Used in entries: 3'])
cardFor('bathroom-tiles.png').blur()
await settle()
expect(tooltipLines()).toEqual([])
})
it('fetches once per image until the list is reloaded', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await hover('bathroom-tiles.png')
await hover('bathroom-tiles.png')
await open('bathroom-tiles.png')
expect(callsTo('GET', /\/v1\/media\/10\/usage$/)).toHaveLength(1)
})
it('waits for the usage then opens if the pointer is still there', async () => {
const pending = deferred<MediaUsageItem[]>()
await mountPage({ ...library(), usage: () => pending.promise }, '/media', undefined, {
app: true,
})
await hover('bathroom-tiles.png')
expect(tooltipLines()).toEqual([])
expect(textOf(triggerOf('bathroom-tiles.png'))).toBe('Used in 3')
pending.resolve(USAGE[10]!)
await settle()
expect(tooltipLines()).toEqual(['Used in entries: 3'])
})
it.each([
['answers 500', () => Promise.reject(new ApiError(500))],
['fails on the network', () => Promise.reject(new NetworkError())],
['answers 404', () => Promise.reject(new ApiError(404))],
['answers only authors and sites', () => USAGE[4]],
])('shows nothing when the usage %s', async (_name, usage) => {
await mountPage({ ...library(), usage }, '/media', undefined, { app: true })
await hover('kitchen-after.webp')
await hover('bathroom-tiles.png')
expect(tooltipLines()).toEqual([])
expect(cards()).toHaveLength(12)
})
it('does not open on mobile and sends no request', async () => {
await mountPage(library(), '/media', MOBILE_WIDTH, { app: true })
await hover('bathroom-tiles.png')
expect(tooltipLines()).toEqual([])
expect(usageCalls()).toHaveLength(0)
expect(textOf(cardFor('bathroom-tiles.png'))).toContain('Used in 3')
})
it('does not send a request for an unused image', async () => {
await mountPage(library(), '/media', undefined, { app: true })
cardFor('window-frame.jpg').focus()
await settle()
expect(tooltipLines()).toEqual([])
expect(usageCalls()).toHaveLength(0)
expect(
cardFor('window-frame.jpg').querySelector('[data-testid="media-usage-trigger"]'),
).toBeNull()
})
})
describe('The detail panel', () => {
function rows(selector: string) {
return Array.from(panel()!.querySelectorAll(selector))
}
it('lists the articles and the first three entries then "and 2 more"', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('lime-plaster.jpg')
const content = panel()!
const headings = Array.from(content.querySelectorAll('h3')).map(textOf)
expect(headings).toEqual(['Used in 2 articles', 'Used in entries'])
expect(rows('[data-testid="usage-row"]').map(textOf)).toEqual([
'Open day at the workshop Online',
'Restoring lime plaster Draft',
])
const entries = rows('[data-testid="usage-entry-row"]')
expect(entries.map(textOf)).toEqual([
'Events Lime plaster workshop',
'Events Heritage stone walk',
'Events Winter open day',
])
const link = entries[0]!.querySelector('a')!
expect(link.getAttribute('href')).toBe('/content/events/1')
expect(link.getAttribute('title')).toBe('Lime plaster workshop')
expect(link.getAttribute('aria-label')).toBe('Lime plaster workshop, Events')
expect(link.className).toContain('underline')
expect(link.className).toContain('truncate')
expect(entries[0]!.className).toContain('h-12')
expect(entries[0]!.className).toContain('md:h-9')
expect(entries[0]!.querySelector('[aria-hidden="true"]')).not.toBeNull()
const more = content.querySelector('[data-testid="usage-more"]')!
expect(textOf(more)).toBe('and 2 more')
expect(more.querySelector('a, button')).toBeNull()
expect(more.hasAttribute('tabindex')).toBe(false)
expect(link.className).toContain('focus-visible:outline-2')
expect(link.className).toContain('focus-visible:outline-primary')
const focusables = Array.from(
content.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex]'),
)
const entryLinks = Array.from(content.querySelectorAll('[data-testid="usage-entry-row"] a'))
const order = focusables.filter((element) => entryLinks.includes(element))
expect(order).toEqual(entryLinks)
expect(focusables.some((element) => more.contains(element))).toBe(false)
expect(textOf(content)).toContain(
'Counted from the text of your articles each time you open this page.',
)
})
it.each([
[1, 1, null],
[3, 3, null],
[4, 3, 'and 1 more'],
[5, 3, 'and 2 more'],
[120, 3, 'and 117 more'],
])('with %i entries shows %i rows', async (count, shown, more) => {
await mountPage({ ...library(), usage: () => entriesOf(count) }, '/media', undefined, {
app: true,
})
await open('bathroom-tiles.png')
expect(rows('[data-testid="usage-entry-row"]')).toHaveLength(shown)
const moreRow = panel()!.querySelector('[data-testid="usage-more"]')
expect(moreRow ? textOf(moreRow) : null).toBe(more)
})
it('says no article shows an image used by entries only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('bathroom-tiles.png')
const content = panel()!
expect(textOf(content.querySelector('h3'))).toBe('Not used in any article')
expect(textOf(content)).toContain('No article shows it.')
expect(textOf(content)).not.toContain('safe to delete')
expect(textOf(content)).toContain('Used in entries')
})
it('is unchanged for an image used by articles only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('north-wall.jpg')
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
expect(panel()!.querySelector('[data-testid="usage-entries"]')).toBeNull()
})
it('says nothing is safe to claim for authors and sites only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('logo-renew.svg')
const content = panel()!
expect(textOf(content.querySelector('h3'))).toBe('Not used in any article')
expect(textOf(content)).toContain('No article shows it.')
expect(textOf(content)).not.toContain('safe to delete')
expect(content.querySelector('[data-testid="usage-row"]')).toBeNull()
expect(content.querySelector('[data-testid="usage-entry-row"]')).toBeNull()
})
it('sends no request and claims it is safe for an unused image', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('window-frame.jpg')
expect(usageCalls()).toHaveLength(0)
expect(textOf(panel())).toContain('This image is safe to delete. No article shows it.')
})
it('draws a taller banner preview only when entries use the image', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('lime-plaster.jpg')
const preview = panel()!.querySelector('[data-testid="media-thumb"]')!
expect(preview.className).toContain('aspect-panel-banner')
expect(preview.className).toContain('md:aspect-16/6')
})
it('keeps the default preview without entries', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('north-wall.jpg')
const preview = panel()!.querySelector('[data-testid="media-thumb"]')!
expect(preview.className).toContain('aspect-4/3')
expect(preview.className).toContain('md:aspect-16/10')
})
it('shows the skeleton after 150 ms, never before, and the sections after the answer', async () => {
const pending = deferred<MediaUsageItem[]>()
await mountPage({ ...library(), usage: () => pending.promise }, '/media', undefined, {
app: true,
})
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
cardFor('lime-plaster.jpg').click()
await flushPromises()
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in')
expect(panel()!.querySelector('[data-testid="usage-skeleton"]')).toBeNull()
expect(findButton('Delete', panel()!)!.disabled).toBe(false)
vi.advanceTimersByTime(150)
await flushPromises()
expect(panel()!.querySelectorAll('[data-testid="usage-skeleton"] li')).toHaveLength(3)
vi.useRealTimers()
pending.resolve(USAGE[11]!)
await settle()
expect(panel()!.querySelector('[data-testid="usage-skeleton"]')).toBeNull()
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
})
it.each([
['answers 500', () => Promise.reject(new ApiError(500))],
['fails on the network', () => Promise.reject(new NetworkError())],
['answers 429', () => Promise.reject(new ApiError(429))],
])('shows the failure and Try again when the usage %s', async (_name, failure) => {
let calls = 0
await mountPage(
{
...library(),
usage: (id) => (++calls === 1 ? failure() : USAGE[id]),
},
'/media',
undefined,
{ app: true },
)
await open('lime-plaster.jpg')
expect(textOf(panel()!.querySelector('[data-testid="usage-error"]'))).toContain(
"Couldn't load where this image is used.",
)
expect(findButton('Delete', panel()!)!.disabled).toBe(false)
const listCalls = callsTo('GET', /^\/v1\/media\/\?/).length
findButton('Try again', panel()!)!.click()
await settle()
expect(usageCalls()).toHaveLength(2)
expect(callsTo('GET', /^\/v1\/media\/\?/)).toHaveLength(listCalls)
expect(panel()!.querySelector('[data-testid="usage-error"]')).toBeNull()
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
})
it('closes the panel and says the image is gone on a 404', async () => {
await mountPage(
{
...library(),
usage: () => Promise.reject(new ApiError(404)),
},
'/media',
undefined,
{ app: true },
)
await open('lime-plaster.jpg')
expect(panel()).toBeNull()
expect(textOf(document.body)).toContain('That image no longer exists')
})
it('ignores a late answer of the previous image', async () => {
const slow = deferred<MediaUsageItem[]>()
await mountPage(
{ ...library(), usage: (id) => (id === 10 ? slow.promise : USAGE[id]) },
'/media',
undefined,
{ app: true },
)
await open('bathroom-tiles.png')
panel()!.querySelector<HTMLElement>('[data-slot="close"]')?.click()
await settle()
await open('north-wall.jpg')
slow.resolve(USAGE[10]!)
await settle()
const content = panel()!
expect(textOf(content.querySelector('h3'))).toBe('Used in 2 articles')
expect(content.querySelector('[data-testid="usage-entry-row"]')).toBeNull()
})
})
describe('The delete dialog', () => {
async function openDelete(filename: string) {
await open(filename)
findButton('Delete', panel()!)!.click()
await settle()
}
it('lists entries then articles for an image used by both', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('lime-plaster.jpg')
const box = dialog()!
expect(textOf(box.querySelector('[data-slot="title"]'))).toBe(
"Delete an image that's in use?",
)
expect(textOf(box.querySelector('[data-slot="description"]'))).toBe(
`"lime-plaster.jpg" is used in 5 entries and 2 articles. Those entry fields will show "${NOTICE}", and the articles will show a broken image. This can't be undone.`,
)
const lists = Array.from(box.querySelectorAll('ul'))
expect(lists.map((list) => list.getAttribute('data-testid'))).toEqual([
'affected-entries',
'affected-articles',
])
expect(textOf(box.querySelector('h3'))).toBe('Used in entries')
expect(
Array.from(box.querySelectorAll('[data-testid="affected-entry"]')).map(textOf),
).toEqual([
'Events Lime plaster workshop',
'Events Heritage stone walk',
'Events Winter open day',
])
expect(textOf(box.querySelector('[data-testid="affected-more"]'))).toBe('and 2 more')
expect(box.querySelector('[data-testid="affected-entry"] a')).toBeNull()
expect(
Array.from(box.querySelectorAll('[data-testid="affected-article"]')).map(textOf),
).toEqual(['Open day at the workshop Online', 'Restoring lime plaster Draft'])
expect(findButton('Cancel', box)).toBeTruthy()
expect(findButton('Delete image', box)).toBeTruthy()
})
it('words an image used by entries only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('bathroom-tiles.png')
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toBe(
`"bathroom-tiles.png" is used in 3 entries. Those entry fields will show "${NOTICE}". This can't be undone.`,
)
expect(dialog()!.querySelector('[data-testid="affected-articles"]')).toBeNull()
})
it('keeps the unused copy and sends no request', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('window-frame.jpg')
expect(textOf(dialog()!.querySelector('[data-slot="title"]'))).toBe('Delete this image?')
expect(dialog()!.querySelector('ul')).toBeNull()
expect(usageCalls()).toHaveLength(0)
})
it('uses the generic copy for authors and sites only', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('logo-renew.svg')
expect(textOf(dialog()!.querySelector('[data-slot="title"]'))).toBe(
"Delete an image that's in use?",
)
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toBe(
`"logo-renew.svg" is in use. Articles will show a broken image and entry fields will show "${NOTICE}". This can't be undone.`,
)
expect(dialog()!.querySelector('ul')).toBeNull()
})
it('opens at once with the generic copy then switches when the usage answers', async () => {
const pending = deferred<MediaUsageItem[]>()
await mountPage({ ...library(), usage: () => pending.promise }, '/media', undefined, {
app: true,
})
await openDelete('lime-plaster.jpg')
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toContain(
'"lime-plaster.jpg" is in use.',
)
expect(dialog()!.querySelector('ul')).toBeNull()
expect(findButton('Delete image', dialog()!)!.disabled).toBe(false)
const cancel = findButton('Cancel', dialog()!)!
const focused = document.activeElement
pending.resolve(USAGE[11]!)
await settle()
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toContain(
'is used in 5 entries and 2 articles.',
)
expect(dialog()!.querySelectorAll('ul')).toHaveLength(2)
expect(document.activeElement).toBe(focused)
expect(cancel.isConnected).toBe(true)
})
it('uses the generic copy after a failure', async () => {
await mountPage(
{ ...library(), usage: () => Promise.reject(new ApiError(500)) },
'/media',
undefined,
{ app: true },
)
await openDelete('lime-plaster.jpg')
expect(textOf(dialog()!.querySelector('[data-slot="description"]'))).toContain(
'"lime-plaster.jpg" is in use.',
)
expect(findButton('Delete image', dialog()!)!.disabled).toBe(false)
})
it('returns the focus to the Delete button of the panel on Cancel', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await open('lime-plaster.jpg')
const trigger = findButton('Delete', panel()!)!
trigger.focus()
trigger.click()
await settle()
findButton('Cancel', dialog()!)!.click()
await settle()
expect(dialog()).toBeNull()
expect(document.activeElement).toBe(trigger)
})
it('deletes with the usual request', async () => {
await mountPage(library(), '/media', undefined, { app: true })
await openDelete('lime-plaster.jpg')
findButton('Delete image', dialog()!)!.click()
await settle()
expect(callsTo('DELETE', /\/v1\/media\/11$/)).toHaveLength(1)
})
})
@@ -114,7 +114,7 @@ describe('The library badge', () => {
await mountPage()
expect(cardFor('north-wall.jpg').getAttribute('aria-label')).toBe(
'north-wall.jpg, 3.9 MB, used in 2 articles',
'north-wall.jpg, 3.9 MB, used in 2 places',
)
})
+13 -9
View File
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
@@ -319,7 +321,10 @@ async function mountPage(handlers: Handlers = {}, url = '/media', width = DESKTO
request: requestMock,
})
await router.push(url)
wrapper = mount(MediaView, {
const root = defineComponent({
render: () => h(UApp, null, { default: () => h(MediaView) }),
})
wrapper = mount(root, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
@@ -495,15 +500,15 @@ describe('Default, many images', () => {
describe('Badges and accessible names', () => {
it.each([
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 articles', ['Used in 2']],
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 article', ['Used in 1']],
['north-wall.jpg', 'north-wall.jpg, 3.9 MB, used in 2 places', ['Used in 2']],
['kitchen-before.webp', 'kitchen-before.webp, 1.1 MB, used in 1 place', ['Used in 1']],
['window-frame.jpg', 'window-frame.jpg, 1.8 MB, unused', ['Unused']],
[
'floorboards.jpg',
'floorboards.jpg, 2.4 MB, used in 4 articles',
'floorboards.jpg, 2.4 MB, used in 4 places',
['Duplicate', 'Used in 4'],
],
['logo-renew.svg', 'logo-renew.svg, 8 KB, used in 5 articles', ['Used in 5']],
['logo-renew.svg', 'logo-renew.svg, 8 KB, used in 5 places', ['Used in 5']],
])('card %s reads %s with badges %j', async (filename, name, badges) => {
await mountPage()
@@ -557,7 +562,7 @@ describe('List view', () => {
fixture().map((item) => String(item.id)),
)
expect(textOf(rows[0])).toBe(
'north-wall.jpg 4032 × 3024 3.9 MB JPEG 2 articles Marta Alves 12 Sep 2026',
'north-wall.jpg 4032 × 3024 3.9 MB JPEG Used in 2 Marta Alves 12 Sep 2026',
)
expect(textOf(rows[4])).toContain('Duplicate')
expect(rows[0]!.querySelector('[data-testid="media-used-in"]')!.className).toContain(
@@ -918,7 +923,6 @@ describe('Hover, focus and keyboard', () => {
)
await flushPromises()
expect(panel()).toBeNull()
expect(requestMock.mock.calls.filter(([path]) => /usage/.test(path))).toHaveLength(0)
press(cards()[1]!, 'Enter')
await flushPromises()
@@ -1001,7 +1005,7 @@ describe('Detail panel', () => {
await clickCard('north-wall.jpg')
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in')
expect(panel()!.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(0)
pending.resolve(USAGE)
await flushPromises()
@@ -1014,7 +1018,7 @@ describe('Detail panel', () => {
await clickCard('north-wall.jpg')
expect(panel()!.querySelectorAll('[data-testid="usage-row"]')).toHaveLength(0)
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in 2 articles')
expect(textOf(panel()!.querySelector('h3'))).toBe('Used in')
})
it('unused: sends no usage request and says it is safe to delete', async () => {