feat(SPA): media library indications for custom medial field type usage
This commit is contained in:
1 parent
3e7fc9a4a5
commit
0fc3511e2b
28 files changed
+2060
-131
No files matched your search
+25
-15
@@ -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([
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user