feat(media): pagination
This commit is contained in:
1 parent
04dc151ed7
commit
63c1e7076e
65 files changed
+2960
-365
No files matched your search
+180
-30
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>() {
|
||||
|
||||
Reference in new issue
Block a user