feat(media): pagination

This commit is contained in:
Florian Sylvain committed 2026-10-13 19:54:59 +02:00
1 parent 04dc151ed7
commit 63c1e7076e
65 files changed
+2960 -365

No files matched your search

+180 -30
View File
@@ -2,9 +2,10 @@
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoute, useRouter, type LocationQuery } from 'vue-router'
import { useEventListener, useMediaQuery, useOnline } from '@vueuse/core'
import { useLocalizedToast } from '@/i18n/presentation'
import AppPaginator from '@/components/AppPaginator.vue'
import BulkDeleteMediaDialog from '@/components/BulkDeleteMediaDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import DeleteMediaDialog from '@/components/DeleteMediaDialog.vue'
@@ -38,16 +39,21 @@ import { toastsAboveBulkBar } from '@/composables/useToastOffset'
import { groupDuplicates, type DuplicateGroup } from '@/lib/mediaDuplicates'
import { joinNames } from '@/lib/mediaFormat'
import {
isFullSet,
parseMediaControls,
toRequestQuery,
sameMediaControls,
toRouteQuery,
type MediaControls,
} from '@/lib/mediaQuery'
import { sameRouteQuery } from '@/lib/articleListQuery'
import { MEDIA_PAGE_SIZE_KEY, storePageSize } from '@/lib/pageSizeMemory'
import { pageCountFor } from '@/lib/pagination'
import { purgedToast } from '@/lib/mediaPurge'
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const MEDIA_PATH = '/media'
const TOAST_DURATION_MS = 5000
const PERSISTENT_TOAST = 0
const BULK_FAILED_TOAST_ID = 'media-bulk-delete-failed'
@@ -72,6 +78,9 @@ const MERGE_CONFLICT_COPY =
const OFFLINE_COPY =
"You're offline. You can look through the images that are loaded, but uploading, renaming and deleting need a connection."
type UrlMode = 'push' | 'replace'
type Notice = { kind: 'range' } | { kind: 'error'; query: MediaControls; mode: UrlMode }
const route = useRoute()
const router = useRouter()
const toast = useLocalizedToast()
@@ -82,11 +91,15 @@ const upload = useMediaUpload({ onAdded: refreshInBackground })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const online = useOnline()
const controls = computed(() => parseMediaControls(route.query))
const requestQuery = computed(() => toRequestQuery(controls.value))
const shown = ref<MediaControls>(parseMediaControls(route.query))
const desired = ref<MediaControls>(shown.value)
const controls = computed(() => shown.value)
const offline = computed(() => !online.value)
const hasLoaded = ref(false)
const loadedOnce = ref(false)
const pendingPage = ref<number | null>(null)
const notice = ref<Notice | null>(null)
const heading = ref<HTMLElement | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const activeId = ref<number | null>(null)
@@ -110,6 +123,8 @@ let bulkDeletedTotal = 0
let bulkFailureRevision = 0
let bulkFailureToastId = BULK_FAILED_TOAST_ID
let failureToastShown = false
let requestToken = 0
let syncedTarget: Record<string, string> | null = null
const activeItem = computed(
() => media.items.value.find((item) => item.id === activeId.value) ?? null,
@@ -119,7 +134,9 @@ const panelItem = computed(() => activeItem.value ?? closingItem.value)
const counts = computed(() => (hasLoaded.value ? media.counts.value : null))
const loadFailed = computed(() => media.status.value === 'error')
const loading = computed(() => media.status.value === 'loading')
const ready = computed(() => media.status.value === 'ready')
const ready = computed(
() => media.status.value === 'ready' || (media.status.value === 'loading' && loadedOnce.value),
)
const isDuplicates = computed(() => controls.value.filter === 'duplicates')
const groups = computed(() => (isDuplicates.value ? groupDuplicates(media.items.value) : []))
const emptyLibrary = computed(
@@ -133,8 +150,24 @@ const nothingToShow = computed(
() =>
ready.value &&
!emptyLibrary.value &&
(isDuplicates.value ? groups.value.length === 0 : media.items.value.length === 0),
(isDuplicates.value
? groups.value.length === 0
: media.items.value.length === 0 && media.total.value === 0),
)
const showPaginator = computed(
() => ready.value && !isDuplicates.value && !emptyLibrary.value && media.total.value > 0,
)
const pageCount = computed(() => pageCountFor(media.total.value, shown.value.size))
const noticeText = computed(() => {
const current = notice.value
if (!current) {
return ''
}
if (current.kind === 'range') {
return t("That page doesn't exist. Showing the last page.")
}
return t("Couldn't load page {0}. Showing page {1}.", [current.query.page, shown.value.page])
})
const emptyVariant = computed(() => {
if (isDuplicates.value && controls.value.q === '') {
return 'no-duplicates'
@@ -191,21 +224,78 @@ watch(showBulkBar, (visible) => (toastsAboveBulkBar.value = visible), {
})
onBeforeUnmount(() => (toastsAboveBulkBar.value = false))
function canonicalizeUrl() {
const canonical = toRouteQuery(controls.value)
const current = Object.fromEntries(
Object.entries(route.query).map(([key, value]) => [
key,
Array.isArray(value) ? (value[0] ?? '') : (value ?? ''),
]),
function sameOrderedQuery(current: LocationQuery, target: Record<string, string>) {
const flatten = (query: LocationQuery) =>
Object.entries(query).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value])
return JSON.stringify(flatten(current)) === JSON.stringify(Object.entries(target))
}
async function syncUrl(query: MediaControls, mode: UrlMode) {
if (route.path !== MEDIA_PATH) {
return
}
const target = toRouteQuery(query)
if (sameOrderedQuery(route.query, target)) {
return
}
syncedTarget = target
await router[mode]({ query: target })
}
function differsOnlyByPage(query: MediaControls, current: MediaControls) {
return sameMediaControls({ ...query, page: current.page }, current)
}
async function load(query: MediaControls, mode: UrlMode) {
const token = ++requestToken
desired.value = query
pendingPage.value = loadedOnce.value && !isFullSet(query) ? query.page : null
const keepOnError = loadedOnce.value && differsOnlyByPage(query, shown.value)
const outcome = await media.load(query, { keepOnError })
if (token !== requestToken || outcome.kind === 'stale') {
return
}
pendingPage.value = null
hasLoaded.value = true
if (outcome.kind === 'failed') {
if (keepOnError && !outcome.handled) {
desired.value = shown.value
notice.value = { kind: 'error', query, mode }
}
return
}
loadedOnce.value = true
const effective = { ...query, page: outcome.page }
selection.clear()
shown.value = effective
desired.value = effective
notice.value = outcome.redirected ? { kind: 'range' } : null
await syncUrl(effective, outcome.redirected ? 'replace' : mode)
selectPending()
}
function updateControls(patch: Partial<MediaControls>, mode: UrlMode = 'replace') {
const changed = Object.entries(patch).some(
([key, value]) => desired.value[key as keyof MediaControls] !== value,
)
if (JSON.stringify(canonical) !== JSON.stringify(current)) {
router.replace({ query: canonical })
if (changed) {
load({ ...desired.value, page: 1, ...patch }, mode)
}
}
function updateControls(patch: Partial<MediaControls>) {
router.replace({ query: toRouteQuery({ ...controls.value, ...patch }) })
function changePage(page: number) {
load({ ...desired.value, page }, 'push')
}
function changeSize(size: number) {
storePageSize(MEDIA_PAGE_SIZE_KEY, size)
load({ ...desired.value, size, page: 1 }, 'push')
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
}
}
function clearFilters() {
@@ -216,8 +306,6 @@ function announce(source: () => string) {
announcementSource.value = source
}
canonicalizeUrl()
watch(activeItem, (item) => {
if (item) {
closingItem.value = item
@@ -225,16 +313,27 @@ watch(activeItem, (item) => {
})
watch(
requestQuery,
async () => {
selection.clear()
await media.load(controls.value)
hasLoaded.value = true
selectPending()
() => route.query,
() => {
if (route.path !== MEDIA_PATH) {
return
}
const ownReplace = syncedTarget !== null && sameRouteQuery(route.query, syncedTarget)
syncedTarget = null
const next = parseMediaControls(route.query, !ownReplace)
if (hasLoaded.value && sameMediaControls(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
}
if (sameMediaControls(next, desired.value) && media.status.value === 'loading') {
return
}
load(next, 'replace')
},
{ immediate: true },
)
load(shown.value, 'replace')
watch(
() => upload.header.value,
(next, previous) => {
@@ -249,8 +348,18 @@ watch(
},
)
function refreshInBackground() {
media.load(controls.value, { silent: true })
async function refreshInBackground() {
const outcome = await media.load(shown.value, { silent: true })
if (outcome.kind !== 'loaded') {
return
}
hasLoaded.value = true
loadedOnce.value = true
if (outcome.page !== shown.value.page) {
shown.value = { ...shown.value, page: outcome.page }
desired.value = shown.value
await syncUrl(shown.value, 'replace')
}
}
function focusCard(id: number) {
@@ -910,7 +1019,7 @@ async function mergeAction() {
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="media.load(controls)"
@click="load(desired, 'replace')"
>
{{ t('Try again') }}
</UButton>
@@ -918,7 +1027,7 @@ async function mergeAction() {
</UAlert>
<MediaToolbar
:controls="controls"
:controls="desired"
:counts="counts"
:show-purge="showPurge"
:purge-disabled="offline"
@@ -998,6 +1107,47 @@ async function mergeAction() {
/>
</template>
</template>
<UAlert
v-if="notice && !isDuplicates"
:role="notice.kind === 'error' ? 'alert' : 'status'"
:color="notice.kind === 'error' ? 'error' : 'info'"
variant="soft"
orientation="vertical"
:close="false"
:icon="notice.kind === 'error' ? 'i-lucide-circle-x' : 'i-lucide-info'"
:title="noticeText"
:description="
notice.kind === 'error'
? t('Check your connection and try again.')
: undefined
"
:ui="{ actions: 'mt-2' }"
>
<template v-if="notice.kind === 'error'" #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="retryNotice"
>
{{ t('Retry') }}
</UButton>
</template>
</UAlert>
<AppPaginator
v-if="showPaginator"
:page="shown.page"
:page-count="pageCount"
:total="media.total.value"
:size="shown.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
/>
</template>
</div>
@@ -187,12 +187,18 @@ afterEach(async () => {
await router.push('/login')
})
const EMPTY_LIBRARY = { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
const EMPTY_LIBRARY = {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, unused: 0, duplicates: 0 },
}
function articleHandlers(extra: Record<string, () => Response | Promise<Response>> = {}) {
return {
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'GET /v1/media/?limit=24': () => mockResponse(200, EMPTY_LIBRARY),
'GET /v1/media/?page=1&size=25': () => mockResponse(200, EMPTY_LIBRARY),
...extra,
}
}
@@ -217,7 +223,8 @@ describe('Dropping a valid file onto the editor opens the uploader on the crop s
it('looks the hash up, skips the list, and uploads to /v1/media/ from the crop step', async () => {
const wrapper = await mountEditor(
articleHandlers({
[`GET /v1/media/?hash=${'a'.repeat(64)}`]: () => mockResponse(200, EMPTY_LIBRARY),
[`GET /v1/media/?hash=${'a'.repeat(64)}&page=1&size=100`]: () =>
mockResponse(200, EMPTY_LIBRARY),
}),
)
@@ -231,7 +238,7 @@ describe('Dropping a valid file onto the editor opens the uploader on the crop s
expect(document.body.textContent).toContain('Crop and resize')
expect(vi.mocked(global.fetch).mock.calls.map(([url]) => url)).not.toContain(
'/v1/media/?limit=24',
'/v1/media/?page=1&size=25',
)
findButton('Upload image')?.click()
@@ -38,7 +38,7 @@ function libraryPage() {
const items = fixture().map((item) =>
item.id === 2 ? { ...item, url: '/static/uploadedImages/2.webp' } : item,
)
return { items, next_cursor: 'c24', counts: { all: 48, unused: 2, duplicates: 2 } }
return { items, total: 48, page: 1, size: 25, counts: { all: 48, unused: 2, duplicates: 2 } }
}
let toastAdd: ReturnType<typeof vi.fn>
@@ -60,7 +60,7 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
if (url === '/v1/article/12?format=html') {
return mockResponse(200, articleFixture(body))
}
if (url === '/v1/media/?limit=24') {
if (url === '/v1/media/?page=1&size=25') {
return mockResponse(200, libraryPage())
}
throw new Error(`Unexpected fetch call: ${url}`)
@@ -79,7 +79,13 @@ function libraryPage() {
4: '/static/uploadedImages/4.svg',
}
const items = fixture().map((item) => ({ ...item, url: urls[item.id] ?? item.url }))
return { items, next_cursor: null, counts: { all: 12, unused: 2, duplicates: 2 } }
return {
items,
total: items.length,
page: 1,
size: 25,
counts: { all: 12, unused: 2, duplicates: 2 },
}
}
let toastAdd: ReturnType<typeof vi.fn>
@@ -113,7 +119,7 @@ async function mountEditor(options: EditorOptions = {}) {
if (url === '/v1/article/12?format=html') {
return reply(200, article(options.body ?? '<p>Intro</p>'))
}
if (url === '/v1/media/?limit=24') {
if (url === '/v1/media/?page=1&size=25') {
return reply(200, libraryPage())
}
if (url === '/v1/image-variants') {
@@ -234,14 +234,15 @@ describe('Group scrolled, one card expanded', () => {
expect(published.text()).toContain('No request body.')
})
it('shows the six media query parameters on the media list', async () => {
it('shows the seven media query parameters on the media list', async () => {
const wrapper = await mountIntegration()
const list = await expand(wrapper, 'GET /v1/media/')
for (const name of ['q', 'hash', 'cursor', 'filter', 'sort', 'limit']) {
for (const name of ['q', 'hash', 'filter', 'sort', 'variants', 'page', 'size']) {
expect(list.text()).toContain(name)
}
expect(list.text()).toContain('1 to 100. Default 24.')
expect(list.text()).toContain('5, 25, 50 or 100. Default 25.')
expect(list.text()).not.toContain('next_cursor')
})
it('states the multipart field on the upload and the owner-only 403 and rate limit on invites', async () => {
@@ -79,7 +79,7 @@ beforeEach(() => {
afterEach(unmountPage)
describe('The Duplicates tab', () => {
it('is enabled, writes the URL, requests filter=duplicates with limit=100 and shows sort and view', async () => {
it('is enabled, writes the URL, requests filter=duplicates at size 100 and shows sort and view', async () => {
await mountPage({ list: duplicatesList() })
expect(document.body.querySelector('[aria-label="Sort"]')).not.toBeNull()
@@ -90,7 +90,7 @@ describe('The Duplicates tab', () => {
await settle()
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
expect(document.body.querySelector('[data-testid="duplicates-count"]')?.textContent).toBe(
@@ -123,7 +123,9 @@ describe('The Duplicates tab', () => {
vi.advanceTimersByTime(120)
await flushPromises()
expect(lastListPath()).toBe('/v1/media/?q=floor&filter=duplicates&sort=newest&limit=100')
expect(lastListPath()).toBe(
'/v1/media/?q=floor&filter=duplicates&sort=newest&page=1&size=100',
)
})
})
@@ -0,0 +1,256 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { listCalls, mountPage, press, settle, tab, textOf, unmountPage } from './mediaTestKit'
import { pagedHandler, parsePaged, library } from './mediaPagingKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
})
const KEY = 'renewcms-page-size-media'
function calls() {
return listCalls().map(([requested]) => parsePaged(requested as string))
}
function lastCall() {
return calls().at(-1)
}
function fullPath() {
return router.currentRoute.value.fullPath
}
function selectText() {
return textOf(document.body.querySelector('[aria-label="Rows per page"]'))
}
async function open(path = '/media') {
await mountPage({ list: pagedHandler() }, path)
}
async function reopen(path = '/media') {
await unmountPage()
await open(path)
}
async function choose(label: string) {
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
;(option as HTMLElement).click()
await settle()
}
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
await unmountPage()
})
describe('Choosing a size stores it', () => {
it.each([
['5 per page', '5', '/media?size=5'],
['25 per page', '25', '/media'],
['50 per page', '50', '/media?size=50'],
['100 per page', '100', '/media?size=100'],
])('%s', async (label, stored, url) => {
await open()
await choose(label)
expect(localStorage.getItem(KEY)).toBe(stored)
expect(fullPath()).toBe(url)
})
})
describe('Restoring the stored size', () => {
it('restores the stored size when /media is opened without a size', async () => {
localStorage.setItem(KEY, '5')
await open()
expect(calls()).toMatchObject([{ page: 1, size: 5 }])
expect(selectText()).toBe('5 per page')
expect(fullPath()).toBe('/media?size=5')
})
it('keeps the size after leaving the page and coming back', async () => {
await open()
await choose('5 per page')
await unmountPage()
await router.push('/')
await open()
expect(lastCall()).toMatchObject({ size: 5 })
expect(selectText()).toBe('5 per page')
})
it('keeps the size across a full page refresh', async () => {
await open()
await choose('5 per page')
await reopen('/media?size=5')
expect(lastCall()).toMatchObject({ size: 5 })
expect(selectText()).toBe('5 per page')
await reopen('/media')
expect(lastCall()).toMatchObject({ size: 5 })
expect(selectText()).toBe('5 per page')
})
it('keeps the stored size across paging, search, tabs, sort and view', async () => {
localStorage.setItem(KEY, '50')
await open('/media?size=50')
document.body.querySelector<HTMLButtonElement>('button[aria-label="Next page"]')?.click()
await settle()
tab('Unused').focus()
press(tab('Unused'), 'Enter')
await settle()
await router.replace({ query: { size: '50', sort: 'largest' } })
await settle()
await router.replace({ query: { size: '50', view: 'list' } })
await settle()
await router.replace({ query: { size: '50', q: 'image' } })
await settle()
expect(calls().length).toBeGreaterThan(4)
expect(calls().every((call) => call.size === 50)).toBe(true)
expect(localStorage.getItem(KEY)).toBe('50')
})
it('uses the stored size when only a page is given', async () => {
localStorage.setItem(KEY, '5')
await open('/media?page=3')
expect(lastCall()).toMatchObject({ page: 3, size: 5 })
expect(fullPath()).toBe('/media?page=3&size=5')
})
})
describe('Replacing the stored size', () => {
it('replaces the stored size when another one is chosen', async () => {
localStorage.setItem(KEY, '5')
await open()
await choose('100 per page')
expect(localStorage.getItem(KEY)).toBe('100')
await reopen()
expect(lastCall()).toMatchObject({ size: 100 })
})
it('remembers a return to 25', async () => {
localStorage.setItem(KEY, '5')
await open()
await choose('25 per page')
expect(localStorage.getItem(KEY)).toBe('25')
await reopen()
expect(lastCall()).toMatchObject({ size: 25 })
expect(fullPath()).toBe('/media')
})
})
describe('URL precedence', () => {
it('lets an explicit size win without storing it', async () => {
localStorage.setItem(KEY, '5')
await open('/media?size=100')
expect(lastCall()).toMatchObject({ page: 1, size: 100 })
expect(selectText()).toBe('100 per page')
expect(localStorage.getItem(KEY)).toBe('5')
await reopen()
expect(lastCall()).toMatchObject({ size: 5 })
})
it('ignores the stored size for an invalid size in the URL', async () => {
localStorage.setItem(KEY, '5')
await open('/media?size=10')
expect(calls()).toMatchObject([{ page: 1, size: 25 }])
expect(fullPath()).toBe('/media')
expect(localStorage.getItem(KEY)).toBe('5')
})
})
describe('An invalid stored value falls back to 25', () => {
it.each(['10', 'abc', '0', '-5', '25.5', ''])('stored %j', async (stored) => {
localStorage.setItem(KEY, stored)
await open()
expect(calls()).toMatchObject([{ page: 1, size: 25 }])
expect(selectText()).toBe('25 per page')
expect(fullPath()).toBe('/media')
})
})
describe('Unavailable storage never breaks the page', () => {
async function expectWorking() {
await open()
expect(calls()).toMatchObject([{ page: 1, size: 25 }])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
await choose('50 per page')
expect(lastCall()).toMatchObject({ page: 1, size: 50 })
expect(fullPath()).toBe('/media?size=50')
}
it('throws on getItem', async () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied')
})
await expectWorking()
})
it('throws on setItem (quota)', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('quota')
})
await expectWorking()
})
it('is undefined', async () => {
vi.stubGlobal('localStorage', undefined)
await expectWorking()
})
})
describe('The three lists keep separate sizes', () => {
it('reads only the media key on /media', async () => {
localStorage.setItem('renewcms-page-size-articles', '50')
localStorage.setItem('renewcms-page-size-activity', '100')
localStorage.setItem(KEY, '5')
await open()
expect(lastCall()).toMatchObject({ size: 5 })
})
})
describe('The Duplicates tab ignores the stored size', () => {
it('loads at size 100, keeps the stored value and sends it again on All', async () => {
localStorage.setItem(KEY, '5')
await mountPage({ list: pagedHandler(library(30, true)) }, '/media?filter=duplicates')
expect(calls().every((call) => call.size === 100)).toBe(true)
expect(localStorage.getItem(KEY)).toBe('5')
tab('All').focus()
press(tab('All'), 'Enter')
await settle()
expect(lastCall()).toMatchObject({ page: 1, size: 5, filter: 'all' })
expect(localStorage.getItem(KEY)).toBe('5')
})
})
@@ -0,0 +1,515 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { ApiError } from '@/composables/useApi'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
bulkBar,
callsTo,
cards,
checkboxFor,
deferred,
findButton,
listCalls,
mountPage,
press,
selectAllCheckbox,
settle,
tab,
textOf,
unmountPage,
} from './mediaTestKit'
import { library, pagedBody, pagedHandler, parsePaged, PAGED_COUNTS } from './mediaPagingKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return { ...actual, useToast: () => ({ add: vi.fn(), remove: vi.fn(), update: vi.fn() }) }
})
const FIRST_PAGE_REQUEST = '/v1/media/?filter=all&sort=newest&page=1&size=25'
function summary() {
return textOf(document.body.querySelector('[data-testid="paginator-summary"]'))
}
function control(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
function paginatorNav() {
return document.body.querySelector('nav[aria-label="Pagination"]')
}
function paginatorButtons() {
return Array.from(paginatorNav()?.querySelectorAll<HTMLButtonElement>('button') ?? [])
}
function pageButtons() {
return paginatorButtons().filter((button) => /^\d+$/.test(textOf(button)))
}
function sizeTrigger() {
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
}
function path() {
return router.currentRoute.value.fullPath
}
function alertText() {
return textOf(document.body.querySelector('[role="alert"], [role="status"]:not(.sr-only)'))
}
function listPaths() {
return listCalls().map(([requested]) => requested as string)
}
async function click(element: HTMLElement | null | undefined) {
element?.click()
await flushPromises()
}
async function choose(label: string) {
sizeTrigger()?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
;(option as HTMLElement).click()
await settle()
}
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
await unmountPage()
})
describe('44 First page', () => {
it('requests page 1 of 25, shows the range and disables First and Previous', async () => {
await mountPage({ list: pagedHandler() })
expect(listPaths()).toEqual([FIRST_PAGE_REQUEST])
expect(summary()).toBe('Showing 1–25 of 312')
expect(control('First page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Next page')?.disabled).toBe(false)
expect(control('Last page')?.disabled).toBe(false)
expect(pageButtons()[0]?.getAttribute('aria-current')).toBe('page')
expect(path()).toBe('/media')
expect(cards()).toHaveLength(25)
})
})
describe('45 Middle page', () => {
it('marks page 7 as current with ellipses and every control enabled', async () => {
await mountPage({ list: pagedHandler() }, '/media?page=7')
expect(summary()).toBe('Showing 151–175 of 312')
const current = pageButtons().filter((button) => button.getAttribute('aria-current'))
expect(current.map(textOf)).toEqual(['7'])
expect(current[0]?.className).toContain('bg-(--rc-sand)')
expect(paginatorNav()?.textContent).toContain('…')
expect(paginatorButtons().every((button) => !button.disabled)).toBe(true)
})
})
describe('46 Last page', () => {
it('shows 12 images, the last range and disables Next and Last', async () => {
await mountPage({ list: pagedHandler() }, '/media?page=13')
expect(cards()).toHaveLength(12)
expect(summary()).toBe('Showing 301–312 of 312')
expect(control('Next page')?.disabled).toBe(true)
expect(control('Last page')?.disabled).toBe(true)
})
})
describe('Single page', () => {
it('keeps the summary and the page-size select and hides the paginator', async () => {
await mountPage({ list: pagedHandler(library(12), { all: 12, unused: 2, duplicates: 2 }) })
expect(summary()).toBe('Showing 1–12 of 12')
expect(sizeTrigger()).not.toBeNull()
expect(paginatorNav()).toBeNull()
})
})
describe('47 List view', () => {
it('puts the paginator under the table on desktop', async () => {
await mountPage({ list: pagedHandler() }, '/media?view=list&page=2')
const table = document.body.querySelector('[data-testid="media-row"]')!
const bar = document.body.querySelector('[data-testid="paginator"]')!
expect(table.compareDocumentPosition(bar) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(pageButtons().length).toBeGreaterThan(2)
})
it('shows Previous, "Page 2 of 13" and Next under the card list on mobile', async () => {
await mountPage({ list: pagedHandler() }, '/media?view=list&page=2', MOBILE_WIDTH)
expect(paginatorNav()?.textContent).toContain('Page 2 of 13')
expect(pageButtons()).toHaveLength(0)
expect(paginatorButtons()).toHaveLength(2)
})
})
describe('48 Loading next page', () => {
it('shows the skeleton, disables the controls and moves the URL only on success', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const all = library()
const next = deferred<unknown>()
await mountPage({
list: (requested) =>
parsePaged(requested).page === 2 ? next.promise : pagedBody(all, requested),
})
await click(control('Next page'))
await vi.advanceTimersByTimeAsync(150)
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(summary()).toBe('Loading page 2…')
expect(paginatorButtons().every((button) => button.disabled)).toBe(true)
expect(path()).toBe('/media')
next.resolve(pagedBody(all, '/v1/media/?filter=all&sort=newest&page=2&size=25'))
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(summary()).toBe('Showing 26–50 of 312')
expect(path()).toBe('/media?page=2')
})
})
describe.each([
['a 500 response', () => Promise.reject(new ApiError(500))],
['a request that never reaches the server', () => Promise.reject(new TypeError('offline'))],
])('49 Error loading a page keeps the previous images (%s)', (_name, failure) => {
it('keeps the images and the URL, shows the notice and Retry loads the page', async () => {
const all = library()
let failing = false
await mountPage(
{
list: (requested) =>
failing && parsePaged(requested).page === 7
? failure()
: pagedBody(all, requested),
},
'/media?page=6',
)
const before = cards().map((card) => card.getAttribute('data-media-id'))
failing = true
await click(control('Next page'))
await settle()
expect(cards().map((card) => card.getAttribute('data-media-id'))).toEqual(before)
expect(path()).toBe('/media?page=6')
expect(alertText()).toContain("Couldn't load page 7. Showing page 6.")
expect(control('Next page')?.disabled).toBe(false)
failing = false
await click(findButton('Retry'))
await settle()
expect(summary()).toBe('Showing 151–175 of 312')
expect(path()).toBe('/media?page=7')
expect(alertText()).not.toContain("Couldn't load page")
})
})
describe('50 Page out of range', () => {
it('replaces the URL with the last page and shows the notice', async () => {
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await mountPage({ list: pagedHandler() }, '/media?page=99')
expect(listPaths().map((requested) => parsePaged(requested).page)).toEqual([99, 13])
expect(replace).toHaveBeenCalledWith({ query: { page: '13' } })
expect(push).toHaveBeenCalledTimes(1)
expect(path()).toBe('/media?page=13')
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
expect(summary()).toBe('Showing 301–312 of 312')
})
it('shows the empty library state with no redirect when the total is 0', async () => {
await mountPage(
{ list: pagedHandler([], { all: 0, unused: 0, duplicates: 0 }) },
'/media?page=5',
)
expect(listPaths()).toHaveLength(1)
expect(document.body.textContent).toContain('No images yet')
expect(path()).toBe('/media')
})
it('shows the no results state after a search with total 0', async () => {
await mountPage({ list: pagedHandler() }, '/media?q=zzzz&page=5')
expect(document.body.textContent).toContain('No images match "zzzz"')
expect(path()).toBe('/media?q=zzzz')
})
})
describe('Controls reset to page 1', () => {
async function expectResetTo(path1: string, act: () => Promise<void> | void) {
await mountPage({ list: pagedHandler() }, '/media?page=7')
const before = listPaths().length
await act()
await settle()
const added = listPaths().slice(before)
expect(added.length).toBeGreaterThan(0)
expect(parsePaged(added.at(-1)!).page).toBe(1)
expect(path()).toBe(path1)
expect(textOf(document.body)).toContain('All 312')
}
it('on a search, once the debounce elapsed', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountPage({ list: pagedHandler() }, '/media?page=7')
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by filename"]',
)!
input.value = 'image'
input.dispatchEvent(new Event('input', { bubbles: true }))
await vi.advanceTimersByTimeAsync(119)
expect(listPaths()).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
expect(parsePaged(listPaths().at(-1)!).page).toBe(1)
expect(path()).toBe('/media?q=image')
expect(textOf(document.body)).toContain('All 312')
})
it('on a tab change', () =>
expectResetTo('/media?filter=unused', () => {
tab('Unused').focus()
press(tab('Unused'), 'Enter')
}))
it('on a sort change', () =>
expectResetTo('/media?sort=oldest', async () => {
await router.replace({ query: { sort: 'oldest' } })
}))
it('on a view change', () =>
expectResetTo('/media?view=list', () => {
document.body.querySelector<HTMLElement>('[aria-label="List view"]')?.click()
}))
it('on a variants filter change', () =>
expectResetTo('/media?variants=with', () => {
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
.find((button) => textOf(button) === 'With variants')
?.click()
}))
})
describe('51 Page size menu', () => {
it.each([
['5 per page', 5, '/media?size=5'],
['25 per page', 25, '/media'],
['50 per page', 50, '/media?size=50'],
['100 per page', 100, '/media?size=100'],
])('choosing %s requests page 1 of %i and ends on %s', async (label, size, url) => {
await mountPage({ list: pagedHandler() }, '/media?page=3')
await choose(label)
expect(parsePaged(listPaths().at(-1)!)).toMatchObject({ page: 1, size })
expect(path()).toBe(url)
})
})
describe('Invalid URL values fall back silently', () => {
it.each(['size=10&page=abc', 'page=0', 'page=-2', 'page=1.5', 'size=0', 'size=200'])(
'/media?%s requests page 1 of 25 and replaces the URL',
async (query) => {
await mountPage({ list: pagedHandler() }, `/media?${query}`)
expect(listPaths()).toEqual([FIRST_PAGE_REQUEST])
expect(path()).toBe('/media')
expect(alertText()).toBe('')
},
)
})
describe('Pagination is kept across the other controls', () => {
it('keeps size, sort and filter on Next', async () => {
await mountPage(
{ list: pagedHandler(library(), PAGED_COUNTS) },
'/media?size=50&sort=largest&filter=unused',
)
await click(control('Next page'))
expect(listPaths().at(-1)).toBe('/v1/media/?filter=unused&sort=largest&page=2&size=50')
expect(path()).toBe('/media?page=2&size=50&filter=unused&sort=largest')
})
})
describe('52 Focus-visible and keyboard', () => {
it('gives every control the primary outline with a 2 px offset', async () => {
await mountPage({ list: pagedHandler() }, '/media?page=7')
for (const element of [...paginatorButtons(), sizeTrigger()]) {
expect(element?.className).toContain('focus-visible:outline-2')
expect(element?.className).toContain('focus-visible:outline-offset-2')
expect(element?.className).toContain('focus-visible:outline-primary')
}
})
it('opens the page-size list on ArrowDown and closes it on Escape', async () => {
await mountPage({ list: pagedHandler() })
const trigger = sizeTrigger()!
trigger.focus()
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
;(document.activeElement ?? document.body).dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
})
})
describe('Select all covers the current page only', () => {
it('selects the 25 images of page 2 and clears on the next page', async () => {
await mountPage({ list: pagedHandler() }, '/media?page=2')
expect(textOf(document.body)).toContain('Select all 25 images')
selectAllCheckbox().click()
await flushPromises()
expect(textOf(bulkBar())).toContain('25 selected')
await click(control('Next page'))
await settle()
expect(bulkBar()).toBeNull()
expect(checkboxFor('image-51.jpg').getAttribute('aria-checked')).toBe('false')
})
})
describe('The Duplicates tab', () => {
it('loads the whole set at size 100, groups it, shows no paginator and drops page and size', async () => {
const all = library(130, true)
await mountPage(
{ list: pagedHandler(all, { all: 312, unused: 2, duplicates: 65 }) },
'/media?filter=duplicates&page=4&size=5',
)
expect(listPaths()).toEqual([
'/v1/media/?filter=duplicates&sort=newest&page=1&size=100',
'/v1/media/?filter=duplicates&sort=newest&page=2&size=100',
])
expect(document.body.querySelectorAll('[data-testid="duplicate-group"]')).toHaveLength(65)
expect(paginatorNav()).toBeNull()
expect(document.body.querySelector('[data-testid="paginator-summary"]')).toBeNull()
expect(sizeTrigger()).toBeNull()
expect(path()).toBe('/media?filter=duplicates')
})
it('returns to the paginated list when All is selected', async () => {
await mountPage({ list: pagedHandler(library(130, true)) }, '/media?filter=duplicates')
tab('All').focus()
press(tab('All'), 'Enter')
await settle()
expect(listPaths().at(-1)).toBe(FIRST_PAGE_REQUEST)
expect(paginatorNav()).not.toBeNull()
})
})
describe('Deleting the last image of the last page', () => {
it('steps back one page, replaces the URL and refreshes the counts', async () => {
const all = library(26)
let removed = false
await mountPage(
{
list: (requested) => {
const live = removed ? all.slice(0, 25) : all
const counts = { all: live.length, unused: 2, duplicates: 2 }
return pagedBody(live, requested, counts)
},
remove: () => {
removed = true
},
},
'/media?page=2',
)
expect(cards()).toHaveLength(1)
cards()[0]!.click()
await settle()
findButton('Delete')!.click()
await settle()
const confirm = Array.from(document.body.querySelectorAll('button')).find(
(button) => textOf(button) === 'Delete image',
)
confirm?.click()
await settle()
expect(summary()).toBe('Showing 1–25 of 25')
expect(path()).toBe('/media')
expect(textOf(document.body)).toContain('All 25')
})
})
describe('A background refresh keeps the current page', () => {
it('requests page 7 again after a delete, with no skeleton and the same URL', async () => {
const all = library()
await mountPage({ list: pagedHandler(all), remove: () => undefined }, '/media?page=7')
const before = listPaths().length
cards()[0]!.click()
await settle()
findButton('Delete')!.click()
await settle()
Array.from(document.body.querySelectorAll('button'))
.find((button) => textOf(button) === 'Delete image')
?.click()
await settle()
const added = listPaths().slice(before)
expect(added).toEqual(['/v1/media/?filter=all&sort=newest&page=7&size=25'])
expect(document.body.querySelector('[data-testid="media-skeleton"]')).toBeNull()
expect(path()).toBe('/media?page=7')
})
})
describe('Hash lookups do not touch the page', () => {
it('sends hash, page=1 and size=100 and leaves the URL and the paginator alone', async () => {
await mountPage(
{
list: pagedHandler(library(60, true)),
hash: () => pagedBody(library(3, true), '/v1/media/?page=1&size=100'),
},
'/media?page=2',
)
cards()[0]!.click()
await settle()
expect(callsTo('GET', /\?hash=hash-\d+&page=1&size=100$/)).toHaveLength(1)
expect(path()).toBe('/media?page=2')
expect(summary()).toBe('Showing 26–50 of 60')
})
})
+20 -3
View File
@@ -261,16 +261,33 @@ describe('The selection follows the query', () => {
expect(bulkBar()).toBeNull()
})
it('clears when the sort changes and stays when the view changes', async () => {
it('clears when the view changes', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp')
await router.replace({ query: { view: 'list' } })
await settle()
expect(textOf(bulkBar())).toContain('2 selected')
await router.replace({ query: { view: 'list', sort: 'oldest' } })
expect(bulkBar()).toBeNull()
})
it('clears when the sort changes', async () => {
await mountPage()
await select('north-wall.jpg', 'kitchen-after.webp')
await router.replace({ query: { sort: 'oldest' } })
await settle()
expect(bulkBar()).toBeNull()
})
it('clears when the page changes', async () => {
await mountPage()
await select('north-wall.jpg')
await router.push({ query: { page: '2' } })
await settle()
expect(bulkBar()).toBeNull()
})
+4 -2
View File
@@ -63,7 +63,9 @@ function hashLookups() {
function hashHandlerFor(name: string, existing = [5, 6, 12]): Handlers['hash'] {
return (path) =>
path === `/v1/media/?hash=${hexOf(name)}` ? listBody(fixture(existing)) : listBody([])
path === `/v1/media/?hash=${hexOf(name)}&page=1&size=100`
? listBody(fixture(existing))
: listBody([])
}
function headerButton(label: string) {
@@ -424,7 +426,7 @@ describe('A row whose content already exists', () => {
await mountWithDuplicate()
expect(hashLookups().map(([path]) => path)).toEqual([
`/v1/media/?hash=${hexOf('floorboards-2.jpg')}`,
`/v1/media/?hash=${hexOf('floorboards-2.jpg')}&page=1&size=100`,
])
expect(FakeXhr.instances).toHaveLength(0)
const row = uploadRows()[0]!
@@ -165,8 +165,8 @@ describe('The variants filter', () => {
})
it.each([
['With variants', '?variants=with', 'filter=all&sort=newest&variants=with&limit=100'],
['Without', '?variants=without', 'filter=all&sort=newest&variants=without&limit=100'],
['With variants', '?variants=with', 'filter=all&sort=newest&variants=with&page=1&size=25'],
['Without', '?variants=without', 'filter=all&sort=newest&variants=without&page=1&size=25'],
])('Choosing %s writes the URL and requests the list', async (label, search, request) => {
await mountPage()
@@ -202,7 +202,7 @@ describe('The variants filter', () => {
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/media')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
})
it.each([
@@ -223,7 +223,7 @@ describe('The variants filter', () => {
])('The query string is the source of truth: %s', async (search, request, selected, url) => {
await mountPage({}, `/media${search}`)
expect(lastListPath()).toBe(`/v1/media/?${request}&limit=100`)
expect(lastListPath()).toBe(`/v1/media/?${request}&page=1&size=25`)
expect(segment(selected).getAttribute('aria-pressed')).toBe('true')
expect(router.currentRoute.value.fullPath).toBe(url)
})
@@ -294,7 +294,7 @@ describe('Sorting by total size', () => {
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ sort: 'total_size' })
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=total_size&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=total_size&page=1&size=25')
expect(textOf(document.body.querySelector('[aria-label="Sort"]'))).toBe(
'Largest total size (original + variants)',
)
@@ -321,7 +321,7 @@ describe('Sorting by total size', () => {
await mountPage({}, '/media?sort=total')
expect(router.currentRoute.value.fullPath).toBe('/media')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
})
})
+18 -15
View File
@@ -226,7 +226,7 @@ function fixture(ids?: number[]): MediaItem[] {
}
function listBody(items: MediaItem[], counts = { all: 12, unused: 2, duplicates: 2 }) {
return { items, next_cursor: null, counts }
return { items, total: items.length, page: 1, size: 25, counts }
}
function listWithout(id: number) {
@@ -440,7 +440,7 @@ describe('Default, many images', () => {
)
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Media')
expect(listCalls().map(([path]) => path)).toEqual([
'/v1/media/?filter=all&sort=newest&limit=100',
'/v1/media/?filter=all&sort=newest&page=1&size=25',
])
expect(tabs().map(textOf)).toEqual(['All 12', 'Unused 2', 'Duplicates 2'])
expect(tab('All').getAttribute('aria-selected')).toBe('true')
@@ -565,7 +565,8 @@ describe('List view', () => {
expect(router.currentRoute.value.query).toEqual({ view: 'list' })
expect(document.body.querySelectorAll('[data-testid="media-row"]')).toHaveLength(12)
expect(listCalls()).toHaveLength(1)
expect(listCalls()).toHaveLength(2)
expect(lastListPath()).toBe('/v1/media/?filter=all&sort=newest&page=1&size=25')
activate(ariaLabel('Grid view'))
await flushPromises()
@@ -603,14 +604,14 @@ describe('List view', () => {
describe('The query string is the source of truth', () => {
it.each([
['?filter=unused', 'filter=unused&sort=newest&limit=100'],
['?sort=largest', 'filter=all&sort=largest&limit=100'],
['?sort=name', 'filter=all&sort=name&limit=100'],
['?sort=oldest', 'filter=all&sort=oldest&limit=100'],
['?q=wall', 'q=wall&filter=all&sort=newest&limit=100'],
['?view=list', 'filter=all&sort=newest&limit=100'],
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&limit=100'],
['?filter=duplicates', 'filter=duplicates&sort=newest&limit=100'],
['?filter=unused', 'filter=unused&sort=newest&page=1&size=25'],
['?sort=largest', 'filter=all&sort=largest&page=1&size=25'],
['?sort=name', 'filter=all&sort=name&page=1&size=25'],
['?sort=oldest', 'filter=all&sort=oldest&page=1&size=25'],
['?q=wall', 'q=wall&filter=all&sort=newest&page=1&size=25'],
['?view=list', 'filter=all&sort=newest&page=1&size=25'],
['?filter=bogus&sort=zzz', 'filter=all&sort=newest&page=1&size=25'],
['?filter=duplicates', 'filter=duplicates&sort=newest&page=1&size=100'],
])('opening /media%s requests %s', async (url, request) => {
await mountPage({}, `/media${url}`)
@@ -657,7 +658,7 @@ describe('The query string is the source of truth', () => {
vi.advanceTimersByTime(1)
await flushPromises()
expect(router.currentRoute.value.query).toEqual({ q: 'wall' })
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=all&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=all&sort=newest&page=1&size=25')
tab('Unused').focus()
press(tab('Unused'), 'Enter')
@@ -712,7 +713,7 @@ describe('The Duplicates tab', () => {
expect(tab('Duplicates').getAttribute('aria-selected')).toBe('true')
expect(router.currentRoute.value.query).toEqual({ filter: 'duplicates' })
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?filter=duplicates&sort=newest&page=1&size=100')
})
})
@@ -813,7 +814,7 @@ describe('Empty library and no results', () => {
vi.advanceTimersByTime(120)
await flushPromises()
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=unused&sort=newest&limit=100')
expect(lastListPath()).toBe('/v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25')
expect(document.body.textContent).toContain('No images match "wall"')
expect(document.body.textContent).toContain(
'Nothing matches in Unused. Check the spelling, or clear the filters.',
@@ -998,7 +999,9 @@ describe('Detail panel', () => {
await clickCard('floorboards.jpg')
expect(requestMock.mock.calls.some(([path]) => path === '/v1/media/?hash=0e')).toBe(true)
expect(
requestMock.mock.calls.some(([path]) => path === '/v1/media/?hash=0e&page=1&size=100'),
).toBe(true)
const alert = panel()!.querySelector('[data-testid="duplicate-alert"]')!
expect(textOf(alert)).toContain('Identical to 2 other images')
expect(textOf(alert)).toContain(
+54
View File
@@ -0,0 +1,54 @@
import type { MediaItem } from '@/composables/useMedia'
import { fixture } from './mediaTestKit'
export const LIBRARY_SIZE = 312
export const PAGED_COUNTS = { all: LIBRARY_SIZE, unused: 2, duplicates: 2 }
export interface PagedParams {
page: number
size: number
filter: string
sort: string
q: string
}
export function library(count = LIBRARY_SIZE, duplicates = false): MediaItem[] {
const template = fixture()[0]!
return Array.from({ length: count }, (_, index) => ({
...template,
id: index + 1,
filename: `image-${index + 1}.jpg`,
url: `/media/image-${index + 1}.jpg`,
sha256: duplicates ? `hash-${Math.floor(index / 2)}` : `unique-${index}`,
duplicate: duplicates,
}))
}
export function parsePaged(path: string): PagedParams {
const query = new URL(path, 'http://localhost').searchParams
return {
page: Number(query.get('page')),
size: Number(query.get('size')),
filter: query.get('filter') ?? '',
sort: query.get('sort') ?? '',
q: query.get('q') ?? '',
}
}
export function pagedBody(all: MediaItem[], path: string, counts = PAGED_COUNTS) {
const { page, size, filter, q } = parsePaged(path)
const matching = all
.filter((item) => filter !== 'duplicates' || item.duplicate)
.filter((item) => !q || item.filename.includes(q))
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
counts,
}
}
export function pagedHandler(all: MediaItem[] = library(), counts = PAGED_COUNTS) {
return (path: string) => pagedBody(all, path, counts)
}
+12 -2
View File
@@ -248,8 +248,18 @@ export function fixture(ids?: number[]): MediaItem[] {
)
}
export function listBody(items: MediaItem[], counts = FULL_COUNTS) {
return { items, next_cursor: null, counts }
export function listBody(
items: MediaItem[],
counts = FULL_COUNTS,
paging: { total?: number; page?: number; size?: number } = {},
) {
return {
items,
total: paging.total ?? items.length,
page: paging.page ?? 1,
size: paging.size ?? 25,
counts,
}
}
export function deferred<T>() {