feat(media): pagination
This commit is contained in:
1 parent
04dc151ed7
commit
63c1e7076e
65 files changed
+2960
-365
No files matched your search
@@ -19,6 +19,7 @@ import {
|
||||
sizeRejectionText,
|
||||
validateImageFile,
|
||||
} from '@/lib/imageUpload'
|
||||
import { toHashRequestQuery } from '@/lib/mediaQuery'
|
||||
import { libraryMatchText } from '@/lib/mediaFormat'
|
||||
import { insertedUrl } from '@/lib/variants/variantOptions'
|
||||
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
@@ -349,7 +350,9 @@ async function findExisting(file: File): Promise<MediaItem | null | 'handled'> {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
|
||||
const response = await request<{ items: MediaItem[] }>(
|
||||
`/v1/media/?${toHashRequestQuery(hash)}`,
|
||||
)
|
||||
return oldestOf(response.items) ?? null
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && classifyMediaError(error) === 'handled') {
|
||||
@@ -796,7 +799,7 @@ onUnmounted(() => {
|
||||
:status="library.status.value"
|
||||
:show-skeleton="library.showSkeleton.value"
|
||||
:loading-more="library.loadingMore.value"
|
||||
:has-more="library.nextCursor.value !== null"
|
||||
:has-more="library.hasMore.value"
|
||||
:total="library.total.value"
|
||||
:query="library.query.value"
|
||||
:first-new-id="library.firstNewId.value"
|
||||
|
||||
@@ -115,11 +115,11 @@ describe('The library has images, so Library opens', () => {
|
||||
expect(skeletonTiles()).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('asks for 24 images and nothing else', async () => {
|
||||
it('asks for the first page of 25 images and nothing else', async () => {
|
||||
await openLibrary()
|
||||
|
||||
expect(listCalls()).toHaveLength(1)
|
||||
expect(listCalls()[0]!.toString()).toBe('limit=24')
|
||||
expect(listCalls()[0]!.toString()).toBe('page=1&size=25')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -197,7 +197,7 @@ describe('A search is active, so the count is not shown', () => {
|
||||
})
|
||||
|
||||
describe('Load more is hidden on the last page', () => {
|
||||
it('does not show "Load more" without a cursor', async () => {
|
||||
it('does not show "Load more" once every matching image is loaded', async () => {
|
||||
await openLibrary({ list: () => pageBody(fixture(), null) })
|
||||
|
||||
expect(findButton('Load more')).toBeUndefined()
|
||||
@@ -373,7 +373,7 @@ describe('Insert', () => {
|
||||
})
|
||||
|
||||
describe('Search', () => {
|
||||
it('searches after a 120 ms debounce, with no cursor, and clears the selection', async () => {
|
||||
it('searches after a 120 ms debounce, on page 1, and clears the selection', async () => {
|
||||
await openWithFakeTimers()
|
||||
tile('kitchen-after.webp').click()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -388,8 +388,8 @@ describe('Search', () => {
|
||||
|
||||
expect(listCalls()).toHaveLength(2)
|
||||
expect(listCalls()[1]!.get('q')).toBe('wall')
|
||||
expect(listCalls()[1]!.get('limit')).toBe('24')
|
||||
expect(listCalls()[1]!.has('cursor')).toBe(false)
|
||||
expect(listCalls()[1]!.get('size')).toBe('25')
|
||||
expect(listCalls()[1]!.get('page')).toBe('1')
|
||||
expect(findButton('Insert image')?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
@@ -419,7 +419,7 @@ describe('Search', () => {
|
||||
expect(searchField().value).toBe('')
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.has('q')).toBe(false)
|
||||
expect(last.get('limit')).toBe('24')
|
||||
expect(last.get('size')).toBe('25')
|
||||
})
|
||||
|
||||
it('shows the no results panel and "Clear search" empties the field', async () => {
|
||||
@@ -447,7 +447,7 @@ describe('Load more', () => {
|
||||
it('shows progress, appends the new tiles and moves focus to the first one', async () => {
|
||||
const more = deferred<unknown>()
|
||||
await openWithFakeTimers({
|
||||
list: (query) => (query.get('cursor') ? more.promise : pageBody()),
|
||||
list: (query) => (query.get('page') === '2' ? more.promise : pageBody()),
|
||||
})
|
||||
const button = findButton('Load more')!
|
||||
button.focus()
|
||||
@@ -456,8 +456,8 @@ describe('Load more', () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.get('cursor')).toBe('c24')
|
||||
expect(last.get('limit')).toBe('24')
|
||||
expect(last.get('page')).toBe('2')
|
||||
expect(last.get('size')).toBe('25')
|
||||
const loading = findButton('Loading…')!
|
||||
expect(loading.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(document.activeElement).toBe(loading)
|
||||
@@ -475,7 +475,10 @@ describe('Load more', () => {
|
||||
|
||||
it('draws a row of 3 skeleton tiles on mobile', async () => {
|
||||
await openWithFakeTimers(
|
||||
{ list: (query) => (query.get('cursor') ? new Promise(() => undefined) : pageBody()) },
|
||||
{
|
||||
list: (query) =>
|
||||
query.get('page') === '2' ? new Promise(() => undefined) : pageBody(),
|
||||
},
|
||||
MOBILE_WIDTH,
|
||||
)
|
||||
|
||||
@@ -485,9 +488,9 @@ describe('Load more', () => {
|
||||
expect(skeletonTiles()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('combines the cursor with the search', async () => {
|
||||
it('combines the next page with the search', async () => {
|
||||
await openLibrary({
|
||||
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 'c24'),
|
||||
list: (query) => pageBody(query.get('q') ? fixture([1, 2]) : fixture(), 48),
|
||||
})
|
||||
await searchFor('wall')
|
||||
|
||||
@@ -495,8 +498,8 @@ describe('Load more', () => {
|
||||
|
||||
const last = listCalls().at(-1)!
|
||||
expect(last.get('q')).toBe('wall')
|
||||
expect(last.get('limit')).toBe('24')
|
||||
expect(last.get('cursor')).toBe('c24')
|
||||
expect(last.get('size')).toBe('25')
|
||||
expect(last.get('page')).toBe('2')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,14 +11,11 @@ import { fixture, textOf } from '@/views/__tests__/mediaTestKit'
|
||||
|
||||
export { textOf }
|
||||
|
||||
export const FIRST_PAGE_CURSOR = 'c24'
|
||||
export const FIRST_PAGE_TOTAL = 48
|
||||
export const PICKER_COUNTS = { all: 48, unused: 2, duplicates: 2 }
|
||||
|
||||
export function pageBody(
|
||||
items: MediaItem[] = fixture(),
|
||||
next_cursor: string | null = FIRST_PAGE_CURSOR,
|
||||
) {
|
||||
return { items, next_cursor, counts: PICKER_COUNTS }
|
||||
export function pageBody(items: MediaItem[] = fixture(), total: number | null = FIRST_PAGE_TOTAL) {
|
||||
return { items, total: total ?? items.length, page: 1, size: 25, counts: PICKER_COUNTS }
|
||||
}
|
||||
|
||||
export function secondPage() {
|
||||
|
||||
@@ -26,10 +26,12 @@ function item(id: number, overrides: Partial<MediaItem> = {}): MediaItem {
|
||||
}
|
||||
}
|
||||
|
||||
function listBody(items: MediaItem[]) {
|
||||
function listBody(items: MediaItem[], paging: { total?: number; page?: number } = {}) {
|
||||
return {
|
||||
items,
|
||||
next_cursor: null,
|
||||
total: paging.total ?? items.length,
|
||||
page: paging.page ?? 1,
|
||||
size: 25,
|
||||
counts: { all: items.length, unused: 1, duplicates: 0 },
|
||||
}
|
||||
}
|
||||
@@ -57,20 +59,99 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('useMedia list state', () => {
|
||||
it('requests the list with the controls and limit=100 and stores items and counts', async () => {
|
||||
it('requests the list with the controls, page and size and stores items and counts', async () => {
|
||||
request.mockResolvedValue(listBody([item(1), item(2)]))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, q: 'wall', filter: 'unused' })
|
||||
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'/v1/media/?q=wall&filter=unused&sort=newest&limit=100',
|
||||
'/v1/media/?q=wall&filter=unused&sort=newest&page=1&size=25',
|
||||
)
|
||||
expect(media.items.value.map((entry) => entry.id)).toEqual([1, 2])
|
||||
expect(media.counts.value).toEqual({ all: 2, unused: 1, duplicates: 0 })
|
||||
expect(media.status.value).toBe('ready')
|
||||
})
|
||||
|
||||
it('sends the page and the size it was given and stores the total', async () => {
|
||||
request.mockResolvedValue(listBody([item(1)], { total: 312, page: 7 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 7, size: 50 })
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&page=7&size=50')
|
||||
expect(media.total.value).toBe(312)
|
||||
expect(outcome).toEqual({ kind: 'loaded', page: 7, redirected: false })
|
||||
})
|
||||
|
||||
it('asks for the last page when the page is past the end and keeps the old items until then', async () => {
|
||||
request.mockResolvedValueOnce(listBody([], { total: 312, page: 99 }))
|
||||
request.mockResolvedValueOnce(listBody([item(1)], { total: 312, page: 13 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 99 })
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/v1/media/?filter=all&sort=newest&page=13&size=25',
|
||||
)
|
||||
expect(outcome).toEqual({ kind: 'loaded', page: 13, redirected: true })
|
||||
expect(media.items.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps page 1 and no redirect when the total is zero', async () => {
|
||||
request.mockResolvedValueOnce(listBody([], { total: 0, page: 5 }))
|
||||
const media = useMedia()
|
||||
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 5 })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(outcome).toEqual({ kind: 'loaded', page: 1, redirected: false })
|
||||
})
|
||||
|
||||
it('loads the whole duplicate set at size 100 page after page', async () => {
|
||||
const first = Array.from({ length: 100 }, (_, index) => item(index + 1))
|
||||
const second = Array.from({ length: 30 }, (_, index) => item(index + 101))
|
||||
request.mockResolvedValueOnce(listBody(first, { total: 130 }))
|
||||
request.mockResolvedValueOnce(listBody(second, { total: 130, page: 2 }))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/v1/media/?filter=duplicates&sort=newest&page=1&size=100',
|
||||
)
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/v1/media/?filter=duplicates&sort=newest&page=2&size=100',
|
||||
)
|
||||
expect(media.items.value).toHaveLength(130)
|
||||
})
|
||||
|
||||
it('requests no second duplicate page when the first one holds everything', async () => {
|
||||
request.mockResolvedValueOnce(listBody([item(1), item(2)]))
|
||||
const media = useMedia()
|
||||
|
||||
await media.load({ ...DEFAULT_CONTROLS, filter: 'duplicates' })
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the loaded items and the ready status when a page change fails with keepOnError', async () => {
|
||||
request.mockResolvedValueOnce(listBody([item(1)], { total: 60 }))
|
||||
const media = useMedia()
|
||||
await media.load(DEFAULT_CONTROLS)
|
||||
|
||||
request.mockRejectedValueOnce(new ApiError(500))
|
||||
const outcome = await media.load({ ...DEFAULT_CONTROLS, page: 2 }, { keepOnError: true })
|
||||
|
||||
expect(outcome).toEqual({ kind: 'failed', handled: false })
|
||||
expect(media.status.value).toBe('ready')
|
||||
expect(media.items.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the skeleton only after 150 ms of loading', async () => {
|
||||
const pending = deferred<unknown>()
|
||||
request.mockReturnValue(pending.promise)
|
||||
@@ -184,7 +265,7 @@ describe('useMedia requests', () => {
|
||||
|
||||
const others = await media.findCopies(item(12, { sha256: 'abc123' }))
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?hash=abc123')
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?hash=abc123&page=1&size=100')
|
||||
expect(others.map((entry) => entry.id)).toEqual([5, 6])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { tryOnBeforeUnmount } from '@vueuse/core'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import {
|
||||
@@ -9,21 +9,22 @@ import {
|
||||
} from '@/composables/useMedia'
|
||||
import { useSeenImagesStore } from '@/stores/seenImages'
|
||||
|
||||
export const LIBRARY_PAGE_LIMIT = 24
|
||||
export const LIBRARY_PAGE_SIZE = 25
|
||||
const FIRST_PAGE = 1
|
||||
|
||||
export type LibraryStatus = 'idle' | 'loading' | 'ready' | 'error' | 'handled'
|
||||
|
||||
interface LibraryResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
total: number
|
||||
counts: MediaCounts
|
||||
}
|
||||
|
||||
function requestQuery(query: string, cursor: string | null) {
|
||||
function requestQuery(query: string, page: number) {
|
||||
return [
|
||||
...(query ? [`q=${encodeURIComponent(query)}`] : []),
|
||||
`limit=${LIBRARY_PAGE_LIMIT}`,
|
||||
...(cursor ? [`cursor=${encodeURIComponent(cursor)}`] : []),
|
||||
`page=${page}`,
|
||||
`size=${LIBRARY_PAGE_SIZE}`,
|
||||
].join('&')
|
||||
}
|
||||
|
||||
@@ -33,7 +34,8 @@ export function useLibraryList() {
|
||||
|
||||
const items = ref<MediaItem[]>([])
|
||||
const total = ref(0)
|
||||
const nextCursor = ref<string | null>(null)
|
||||
const matching = ref(0)
|
||||
const loadedPages = ref(0)
|
||||
const status = ref<LibraryStatus>('idle')
|
||||
const query = ref('')
|
||||
const showSkeleton = ref(false)
|
||||
@@ -41,6 +43,7 @@ export function useLibraryList() {
|
||||
const firstNewId = ref<number | null>(null)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let latest = 0
|
||||
const hasMore = computed(() => items.value.length < matching.value)
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
@@ -66,7 +69,8 @@ export function useLibraryList() {
|
||||
latest += 1
|
||||
items.value = []
|
||||
total.value = 0
|
||||
nextCursor.value = null
|
||||
matching.value = 0
|
||||
loadedPages.value = 0
|
||||
status.value = 'idle'
|
||||
query.value = ''
|
||||
loadingMore.value = false
|
||||
@@ -79,21 +83,23 @@ export function useLibraryList() {
|
||||
const ticket = latest
|
||||
query.value = nextQuery
|
||||
items.value = []
|
||||
nextCursor.value = null
|
||||
matching.value = 0
|
||||
loadedPages.value = 0
|
||||
loadingMore.value = false
|
||||
firstNewId.value = null
|
||||
status.value = 'loading'
|
||||
startSkeletonTimer()
|
||||
try {
|
||||
const response = await request<LibraryResponse>(
|
||||
`/v1/media/?${requestQuery(nextQuery, null)}`,
|
||||
`/v1/media/?${requestQuery(nextQuery, FIRST_PAGE)}`,
|
||||
)
|
||||
if (ticket !== latest) {
|
||||
return
|
||||
}
|
||||
items.value = response.items
|
||||
total.value = response.counts.all
|
||||
nextCursor.value = response.next_cursor
|
||||
matching.value = response.total
|
||||
loadedPages.value = FIRST_PAGE
|
||||
seen.remember(response.items)
|
||||
status.value = 'ready'
|
||||
} catch (error) {
|
||||
@@ -109,7 +115,7 @@ export function useLibraryList() {
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (nextCursor.value === null || loadingMore.value || status.value !== 'ready') {
|
||||
if (!hasMore.value || loadingMore.value || status.value !== 'ready') {
|
||||
return
|
||||
}
|
||||
const ticket = latest
|
||||
@@ -117,7 +123,7 @@ export function useLibraryList() {
|
||||
startSkeletonTimer()
|
||||
try {
|
||||
const response = await request<LibraryResponse>(
|
||||
`/v1/media/?${requestQuery(query.value, nextCursor.value)}`,
|
||||
`/v1/media/?${requestQuery(query.value, loadedPages.value + 1)}`,
|
||||
)
|
||||
if (ticket !== latest) {
|
||||
return
|
||||
@@ -125,7 +131,8 @@ export function useLibraryList() {
|
||||
const known = new Set(items.value.map((item) => item.id))
|
||||
const added = response.items.filter((item) => !known.has(item.id))
|
||||
items.value = [...items.value, ...added]
|
||||
nextCursor.value = response.next_cursor
|
||||
matching.value = response.total
|
||||
loadedPages.value += 1
|
||||
firstNewId.value = added[0]?.id ?? null
|
||||
seen.remember(added)
|
||||
} catch {
|
||||
@@ -156,7 +163,7 @@ export function useLibraryList() {
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
status,
|
||||
query,
|
||||
showSkeleton,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ref } from 'vue'
|
||||
import { tryOnBeforeUnmount } from '@vueuse/core'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { toRequestQuery, type MediaControls } from '@/lib/mediaQuery'
|
||||
import {
|
||||
MEDIA_FULL_SET_PAGE_SIZE,
|
||||
isFullSet,
|
||||
toHashRequestQuery,
|
||||
toRequestQuery,
|
||||
type MediaControls,
|
||||
} from '@/lib/mediaQuery'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { bumpMediaRevision } from '@/lib/mediaRevision'
|
||||
|
||||
export interface MediaItem {
|
||||
@@ -44,12 +51,24 @@ export interface BulkDeleteResult {
|
||||
handled: boolean
|
||||
}
|
||||
|
||||
interface MediaListResponse {
|
||||
export interface MediaListResponse {
|
||||
items: MediaItem[]
|
||||
next_cursor: string | null
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
counts: MediaCounts
|
||||
}
|
||||
|
||||
export type MediaLoadOutcome =
|
||||
| { kind: 'loaded'; page: number; redirected: boolean }
|
||||
| { kind: 'failed'; handled: boolean }
|
||||
| { kind: 'stale' }
|
||||
|
||||
interface MediaLoadOptions {
|
||||
silent?: boolean
|
||||
keepOnError?: boolean
|
||||
}
|
||||
|
||||
export type MediaLoadStatus = 'loading' | 'ready' | 'error' | 'handled'
|
||||
export type MediaFailure = 'handled' | 'not-found' | 'failed'
|
||||
|
||||
@@ -68,10 +87,19 @@ export function classifyMediaError(error: unknown): MediaFailure {
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
function isPastLastPage(response: MediaListResponse, page: number, size: number) {
|
||||
return (
|
||||
response.items.length === 0 &&
|
||||
response.total > 0 &&
|
||||
page > pageCountFor(response.total, size)
|
||||
)
|
||||
}
|
||||
|
||||
export function useMedia() {
|
||||
const { request } = useApi()
|
||||
|
||||
const items = ref<MediaItem[]>([])
|
||||
const total = ref(0)
|
||||
const counts = ref<MediaCounts>({ all: 0, unused: 0, duplicates: 0 })
|
||||
const status = ref<MediaLoadStatus>('loading')
|
||||
const showSkeleton = ref(false)
|
||||
@@ -94,7 +122,39 @@ export function useMedia() {
|
||||
}, MEDIA_SKELETON_DELAY_MS)
|
||||
}
|
||||
|
||||
async function load(controls: MediaControls, options: { silent?: boolean } = {}) {
|
||||
function requestPage(controls: MediaControls) {
|
||||
return request<MediaListResponse>(`/v1/media/?${toRequestQuery(controls)}`)
|
||||
}
|
||||
|
||||
async function requestFullSet(controls: MediaControls) {
|
||||
const query = { ...controls, page: 1, size: MEDIA_FULL_SET_PAGE_SIZE }
|
||||
const first = await requestPage(query)
|
||||
const loaded = [...first.items]
|
||||
let latest = first
|
||||
while (loaded.length < first.total && latest.items.length > 0) {
|
||||
query.page += 1
|
||||
latest = await requestPage(query)
|
||||
loaded.push(...latest.items)
|
||||
}
|
||||
return { ...latest, items: loaded, total: loaded.length, page: 1 }
|
||||
}
|
||||
|
||||
async function requestList(controls: MediaControls) {
|
||||
if (isFullSet(controls)) {
|
||||
return { response: await requestFullSet(controls), page: 1, redirected: false }
|
||||
}
|
||||
const response = await requestPage(controls)
|
||||
if (!isPastLastPage(response, controls.page, controls.size)) {
|
||||
return { response, page: response.total === 0 ? 1 : controls.page, redirected: false }
|
||||
}
|
||||
const page = pageCountFor(response.total, controls.size)
|
||||
return { response: await requestPage({ ...controls, page }), page, redirected: true }
|
||||
}
|
||||
|
||||
async function load(
|
||||
controls: MediaControls,
|
||||
options: MediaLoadOptions = {},
|
||||
): Promise<MediaLoadOutcome> {
|
||||
const silent = options.silent === true
|
||||
if (!silent) {
|
||||
latestLoad += 1
|
||||
@@ -102,20 +162,24 @@ export function useMedia() {
|
||||
}
|
||||
const ticket = latestLoad
|
||||
try {
|
||||
const response = await request<MediaListResponse>(
|
||||
`/v1/media/?${toRequestQuery(controls)}`,
|
||||
)
|
||||
const { response, page, redirected } = await requestList(controls)
|
||||
if (ticket !== latestLoad) {
|
||||
return
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
counts.value = response.counts
|
||||
status.value = 'ready'
|
||||
return { kind: 'loaded', page, redirected }
|
||||
} catch (error) {
|
||||
if (silent || ticket !== latestLoad) {
|
||||
return
|
||||
if (ticket !== latestLoad) {
|
||||
return { kind: 'stale' }
|
||||
}
|
||||
status.value = classifyMediaError(error) === 'handled' ? 'handled' : 'error'
|
||||
const handled = classifyMediaError(error) === 'handled'
|
||||
if (!silent) {
|
||||
status.value = handled ? 'handled' : options.keepOnError ? 'ready' : 'error'
|
||||
}
|
||||
return { kind: 'failed', handled }
|
||||
} finally {
|
||||
if (!silent && ticket === latestLoad) {
|
||||
clearSkeletonTimer()
|
||||
@@ -129,7 +193,9 @@ export function useMedia() {
|
||||
}
|
||||
|
||||
async function findCopies(item: MediaItem) {
|
||||
const response = await request<MediaListResponse>(`/v1/media/?hash=${item.sha256}`)
|
||||
const response = await request<MediaListResponse>(
|
||||
`/v1/media/?${toHashRequestQuery(item.sha256)}`,
|
||||
)
|
||||
return response.items.filter((copy) => copy.id !== item.id)
|
||||
}
|
||||
|
||||
@@ -204,6 +270,7 @@ export function useMedia() {
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
counts,
|
||||
status,
|
||||
showSkeleton,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { redirectToLogin, redirectUnverified, useApi } from '@/composables/useApi'
|
||||
import { classifyMediaError, type MediaItem } from '@/composables/useMedia'
|
||||
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||
import { toHashRequestQuery } from '@/lib/mediaQuery'
|
||||
import { hashFile } from '@/lib/fileHash'
|
||||
import { extensionMatchesType, validateImageFile } from '@/lib/imageUpload'
|
||||
import { describeUpload, parseRetryAfter } from '@/lib/mediaUploadRows'
|
||||
@@ -135,7 +136,9 @@ export function useMediaUpload(options: UploadOptions = {}) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const response = await request<{ items: MediaItem[] }>(`/v1/media/?hash=${hash}`)
|
||||
const response = await request<{ items: MediaItem[] }>(
|
||||
`/v1/media/?${toHashRequestQuery(hash)}`,
|
||||
)
|
||||
return oldestOf(response.items) ?? null
|
||||
} catch (error) {
|
||||
return classifyMediaError(error) === 'handled' ? 'halted' : null
|
||||
|
||||
@@ -284,9 +284,9 @@ describe('Response samples use the real domain JSON keys', () => {
|
||||
expect(parsed(find('PATCH /v1/article/{id}/unpublish')).is_online).toBe(false)
|
||||
})
|
||||
|
||||
it('uses items, next_cursor and counts for the media list', () => {
|
||||
it('uses items, total, page, size and counts for the media list, with no next_cursor', () => {
|
||||
const list = parsed(find('GET /v1/media/'))
|
||||
expect(keysOf(list)).toEqual(['counts', 'items', 'next_cursor'])
|
||||
expect(keysOf(list)).toEqual(['counts', 'items', 'page', 'size', 'total'])
|
||||
expect(keysOf(list.counts)).toEqual(['all', 'duplicates', 'unused'])
|
||||
expectOnlyKeys(list.items[0], MEDIA_ITEM_KEYS)
|
||||
})
|
||||
@@ -418,18 +418,20 @@ describe('Request bodies and query parameters', () => {
|
||||
expect(Array.isArray(JSON.parse(list.response))).toBe(false)
|
||||
})
|
||||
|
||||
it('lists q, hash, cursor, filter, sort and limit on the media list only', () => {
|
||||
it('lists q, hash, filter, sort, variants, page and size on the media list only', () => {
|
||||
expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([
|
||||
'q',
|
||||
'hash',
|
||||
'cursor',
|
||||
'filter',
|
||||
'sort',
|
||||
'limit',
|
||||
'variants',
|
||||
'page',
|
||||
'size',
|
||||
])
|
||||
expect(find('GET /v1/media/').query.find((param) => param.name === 'limit')!.type).toBe(
|
||||
expect(find('GET /v1/media/').query.find((param) => param.name === 'size')!.type).toBe(
|
||||
'number',
|
||||
)
|
||||
expect(find('GET /v1/media/').errors.map((error) => error.status)).toContain(400)
|
||||
expect(find('POST /v1/media/').query).toEqual([])
|
||||
expect(find('GET /v1/settings').query).toEqual([])
|
||||
})
|
||||
|
||||
@@ -95,10 +95,24 @@ const FORMAT_QUERY: ApiField = {
|
||||
rule: 'markdown, html or json. Default markdown.',
|
||||
}
|
||||
|
||||
const PAGE_QUERY: ApiField = {
|
||||
name: 'page',
|
||||
type: 'number',
|
||||
required: false,
|
||||
rule: 'A whole number from 1. Default 1.',
|
||||
}
|
||||
|
||||
const SIZE_QUERY: ApiField = {
|
||||
name: 'size',
|
||||
type: 'number',
|
||||
required: false,
|
||||
rule: '5, 25, 50 or 100. Default 25.',
|
||||
}
|
||||
|
||||
const ARTICLE_LIST_QUERY: ApiField[] = [
|
||||
FORMAT_QUERY,
|
||||
{ name: 'page', type: 'number', required: false, rule: 'A whole number from 1. Default 1.' },
|
||||
{ name: 'size', type: 'number', required: false, rule: '5, 25, 50 or 100. Default 25.' },
|
||||
PAGE_QUERY,
|
||||
SIZE_QUERY,
|
||||
{
|
||||
name: 'q',
|
||||
type: 'string',
|
||||
@@ -122,12 +136,6 @@ const MEDIA_QUERY: ApiField[] = [
|
||||
required: false,
|
||||
rule: 'SHA-256 of the content, lowercased.',
|
||||
},
|
||||
{
|
||||
name: 'cursor',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'The next_cursor of the previous page.',
|
||||
},
|
||||
{
|
||||
name: 'filter',
|
||||
type: 'string',
|
||||
@@ -138,9 +146,16 @@ const MEDIA_QUERY: ApiField[] = [
|
||||
name: 'sort',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'newest, oldest, name or largest. Default newest.',
|
||||
rule: 'newest, oldest, name, largest or total_size. Default newest.',
|
||||
},
|
||||
{ name: 'limit', type: 'number', required: false, rule: '1 to 100. Default 24.' },
|
||||
{
|
||||
name: 'variants',
|
||||
type: 'string',
|
||||
required: false,
|
||||
rule: 'with or without. Default both.',
|
||||
},
|
||||
PAGE_QUERY,
|
||||
SIZE_QUERY,
|
||||
]
|
||||
|
||||
const json = (value: unknown) => JSON.stringify(value, null, 2)
|
||||
@@ -423,7 +438,9 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
verified([e(400, 'The media query is invalid.'), e(401, UNAUTHORIZED)]),
|
||||
json({
|
||||
items: [mediaItem()],
|
||||
next_cursor: null,
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 1, unused: 0, duplicates: 0 },
|
||||
}),
|
||||
{ query: MEDIA_QUERY },
|
||||
|
||||
@@ -1603,11 +1603,10 @@ export const messages = {
|
||||
'champ:direction, avec le champ title, status, created_at ou updated_at et la direction asc ou desc. Par défaut updated_at:desc.',
|
||||
'Search text.': 'Texte recherché.',
|
||||
'SHA-256 of the content, lowercased.': 'SHA-256 du contenu, en minuscules.',
|
||||
'The next_cursor of the previous page.': 'Le next_cursor de la page précédente.',
|
||||
'all, unused or duplicates. Default all.': 'all, unused ou duplicates. Par défaut all.',
|
||||
'newest, oldest, name or largest. Default newest.':
|
||||
'newest, oldest, name ou largest. Par défaut newest.',
|
||||
'1 to 100. Default 24.': 'De 1 à 100. Par défaut 24.',
|
||||
'newest, oldest, name, largest or total_size. Default newest.':
|
||||
'newest, oldest, name, largest ou total_size. Par défaut newest.',
|
||||
'with or without. Default both.': 'with ou without. Par défaut les deux.',
|
||||
"Read, write and publish articles. Titles can't be changed after creation.":
|
||||
'Lire, écrire et publier des articles. Les titres ne peuvent pas être modifiés après la création.',
|
||||
"3 to 200 characters. Can't be changed later.":
|
||||
|
||||
@@ -136,7 +136,9 @@ describe('Switch language without losing work', () => {
|
||||
}
|
||||
return {
|
||||
items,
|
||||
next_cursor: null,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: items.length, unused: items.length, duplicates: 0 },
|
||||
}
|
||||
})
|
||||
@@ -518,13 +520,21 @@ describe('Hardener localization outcomes', () => {
|
||||
duplicate: false,
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 1, unused: 1, duplicates: 0 },
|
||||
})
|
||||
const listing = await request()
|
||||
request.mockImplementation(async (path: string) =>
|
||||
path.includes('?hash=')
|
||||
? { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
|
||||
? {
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||
}
|
||||
: listing,
|
||||
)
|
||||
await mountRoute('/media')
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DEFAULT_CONTROLS, parseMediaControls, toRequestQuery, toRouteQuery } from '../mediaQuery'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
DEFAULT_CONTROLS,
|
||||
parseMediaControls,
|
||||
toHashRequestQuery,
|
||||
toRequestQuery,
|
||||
toRouteQuery,
|
||||
} from '../mediaQuery'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('parseMediaControls', () => {
|
||||
it('uses the defaults for an empty query', () => {
|
||||
@@ -21,6 +35,34 @@ describe('parseMediaControls', () => {
|
||||
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ page: '7' }, { page: 7 }],
|
||||
[{ size: '50' }, { size: 50 }],
|
||||
[
|
||||
{ page: '3', size: '5' },
|
||||
{ page: 3, size: 5 },
|
||||
],
|
||||
[{ page: '0' }, {}],
|
||||
[{ page: '-2' }, {}],
|
||||
[{ page: '1.5' }, {}],
|
||||
[{ page: 'abc' }, {}],
|
||||
[{ size: '10' }, {}],
|
||||
[{ size: '0' }, {}],
|
||||
[{ size: '200' }, {}],
|
||||
[{ size: '' }, {}],
|
||||
])('maps the paging of %j', (query, expected) => {
|
||||
expect(parseMediaControls(query)).toEqual({ ...DEFAULT_CONTROLS, ...expected })
|
||||
})
|
||||
|
||||
it('uses the stored size only when the URL has no size', () => {
|
||||
localStorage.setItem('renewcms-page-size-media', '5')
|
||||
|
||||
expect(parseMediaControls({}).size).toBe(5)
|
||||
expect(parseMediaControls({ size: '100' }).size).toBe(100)
|
||||
expect(parseMediaControls({ size: '10' }).size).toBe(25)
|
||||
expect(parseMediaControls({}, false).size).toBe(25)
|
||||
})
|
||||
|
||||
it('takes the first value of a repeated parameter and trims the search', () => {
|
||||
expect(parseMediaControls({ q: [' wall ', 'x'] }).q).toBe('wall')
|
||||
})
|
||||
@@ -33,20 +75,45 @@ describe('toRouteQuery', () => {
|
||||
|
||||
it('keeps only the non-default values', () => {
|
||||
expect(
|
||||
toRouteQuery({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' }),
|
||||
toRouteQuery({
|
||||
...DEFAULT_CONTROLS,
|
||||
q: 'wall',
|
||||
filter: 'unused',
|
||||
sort: 'largest',
|
||||
view: 'list',
|
||||
}),
|
||||
).toEqual({ q: 'wall', filter: 'unused', sort: 'largest', view: 'list' })
|
||||
})
|
||||
|
||||
it('keeps the page and the size when they are not the defaults', () => {
|
||||
expect(toRouteQuery({ ...DEFAULT_CONTROLS, page: 7, size: 50 })).toEqual({
|
||||
page: '7',
|
||||
size: '50',
|
||||
})
|
||||
})
|
||||
|
||||
it('drops the page and the size on the Duplicates tab', () => {
|
||||
expect(
|
||||
toRouteQuery({ ...DEFAULT_CONTROLS, filter: 'duplicates', page: 4, size: 5 }),
|
||||
).toEqual({ filter: 'duplicates' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRequestQuery', () => {
|
||||
it.each([
|
||||
[{}, 'filter=all&sort=newest&limit=100'],
|
||||
[{ filter: 'unused' }, 'filter=unused&sort=newest&limit=100'],
|
||||
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&limit=100'],
|
||||
[{ sort: 'largest' }, 'filter=all&sort=largest&limit=100'],
|
||||
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&limit=100'],
|
||||
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&limit=100'],
|
||||
[{}, 'filter=all&sort=newest&page=1&size=25'],
|
||||
[{ filter: 'unused' }, 'filter=unused&sort=newest&page=1&size=25'],
|
||||
[{ filter: 'duplicates' }, 'filter=duplicates&sort=newest&page=1&size=25'],
|
||||
[{ sort: 'largest' }, 'filter=all&sort=largest&page=1&size=25'],
|
||||
[{ q: 'wall' }, 'q=wall&filter=all&sort=newest&page=1&size=25'],
|
||||
[{ q: 'a b&c', filter: 'unused' }, 'q=a%20b%26c&filter=unused&sort=newest&page=1&size=25'],
|
||||
])('builds the request for %j', (overrides, expected) => {
|
||||
expect(toRequestQuery({ ...DEFAULT_CONTROLS, ...overrides })).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toHashRequestQuery', () => {
|
||||
it('asks for every copy in one page', () => {
|
||||
expect(toHashRequestQuery('abc')).toBe('hash=abc&page=1&size=100')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { MEDIA_PAGE_SIZE_KEY, resolvePageSize } from '@/lib/pageSizeMemory'
|
||||
|
||||
export type MediaFilter = 'all' | 'unused' | 'duplicates'
|
||||
export type MediaSort = 'newest' | 'oldest' | 'name' | 'largest' | 'total_size'
|
||||
@@ -11,9 +12,15 @@ export interface MediaControls {
|
||||
sort: MediaSort
|
||||
variants: MediaVariantsFilter
|
||||
view: MediaView
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export const MEDIA_PAGE_LIMIT = 100
|
||||
export const MEDIA_PAGE_SIZES = [5, 25, 50, 100]
|
||||
export const DEFAULT_MEDIA_PAGE_SIZE = 25
|
||||
export const MEDIA_FULL_SET_PAGE_SIZE = 100
|
||||
const FIRST_PAGE = 1
|
||||
const POSITIVE_INTEGER = /^[1-9]\d*$/
|
||||
|
||||
export const DEFAULT_CONTROLS: MediaControls = {
|
||||
q: '',
|
||||
@@ -21,6 +28,8 @@ export const DEFAULT_CONTROLS: MediaControls = {
|
||||
sort: 'newest',
|
||||
variants: 'all',
|
||||
view: 'grid',
|
||||
page: FIRST_PAGE,
|
||||
size: DEFAULT_MEDIA_PAGE_SIZE,
|
||||
}
|
||||
|
||||
const FILTERS: MediaFilter[] = ['all', 'unused', 'duplicates']
|
||||
@@ -36,18 +45,57 @@ function pick<T extends string>(allowed: T[], value: string, fallback: T) {
|
||||
return (allowed as string[]).includes(value) ? (value as T) : fallback
|
||||
}
|
||||
|
||||
export function parseMediaControls(query: LocationQuery): MediaControls {
|
||||
function parsePage(value: string) {
|
||||
return POSITIVE_INTEGER.test(value) ? Number(value) : FIRST_PAGE
|
||||
}
|
||||
|
||||
function parseSize(value: LocationQuery[string] | undefined, useStoredSize: boolean) {
|
||||
const first = Array.isArray(value) ? value[0] : value
|
||||
const raw = first === null ? '' : first
|
||||
if (raw === undefined && !useStoredSize) {
|
||||
return DEFAULT_MEDIA_PAGE_SIZE
|
||||
}
|
||||
return resolvePageSize(raw, MEDIA_PAGE_SIZE_KEY, MEDIA_PAGE_SIZES, DEFAULT_MEDIA_PAGE_SIZE)
|
||||
}
|
||||
|
||||
export function parseMediaControls(query: LocationQuery, useStoredSize = true): MediaControls {
|
||||
return {
|
||||
q: firstValue(query.q).trim(),
|
||||
filter: pick(FILTERS, firstValue(query.filter), DEFAULT_CONTROLS.filter),
|
||||
sort: pick(SORTS, firstValue(query.sort), DEFAULT_CONTROLS.sort),
|
||||
variants: pick(VARIANTS_FILTERS, firstValue(query.variants), DEFAULT_CONTROLS.variants),
|
||||
view: pick(VIEWS, firstValue(query.view), DEFAULT_CONTROLS.view),
|
||||
page: parsePage(firstValue(query.page)),
|
||||
size: parseSize(query.size, useStoredSize),
|
||||
}
|
||||
}
|
||||
|
||||
export function isFullSet(controls: MediaControls) {
|
||||
return controls.filter === 'duplicates'
|
||||
}
|
||||
|
||||
export function sameMediaControls(left: MediaControls, right: MediaControls) {
|
||||
const samePaging =
|
||||
(isFullSet(left) && isFullSet(right)) ||
|
||||
(left.page === right.page && left.size === right.size)
|
||||
return (
|
||||
left.q === right.q &&
|
||||
left.filter === right.filter &&
|
||||
left.sort === right.sort &&
|
||||
left.variants === right.variants &&
|
||||
left.view === right.view &&
|
||||
samePaging
|
||||
)
|
||||
}
|
||||
|
||||
export function toRouteQuery(controls: MediaControls) {
|
||||
const query: Record<string, string> = {}
|
||||
if (!isFullSet(controls) && controls.page !== DEFAULT_CONTROLS.page) {
|
||||
query.page = String(controls.page)
|
||||
}
|
||||
if (!isFullSet(controls) && controls.size !== DEFAULT_CONTROLS.size) {
|
||||
query.size = String(controls.size)
|
||||
}
|
||||
if (controls.q) {
|
||||
query.q = controls.q
|
||||
}
|
||||
@@ -72,7 +120,12 @@ export function toRequestQuery(controls: MediaControls) {
|
||||
`filter=${controls.filter}`,
|
||||
`sort=${controls.sort}`,
|
||||
...(controls.variants === 'all' ? [] : [`variants=${controls.variants}`]),
|
||||
`limit=${MEDIA_PAGE_LIMIT}`,
|
||||
`page=${controls.page}`,
|
||||
`size=${controls.size}`,
|
||||
]
|
||||
return parts.join('&')
|
||||
}
|
||||
|
||||
export function toHashRequestQuery(hash: string) {
|
||||
return `hash=${hash}&page=${FIRST_PAGE}&size=${MEDIA_FULL_SET_PAGE_SIZE}`
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const ARTICLES_PAGE_SIZE_KEY = 'renewcms-page-size-articles'
|
||||
export const ACTIVITY_PAGE_SIZE_KEY = 'renewcms-page-size-activity'
|
||||
export const MEDIA_PAGE_SIZE_KEY = 'renewcms-page-size-media'
|
||||
|
||||
export function readStoredPageSize(key: string, validSizes: number[]) {
|
||||
try {
|
||||
|
||||
@@ -256,7 +256,9 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
next_cursor: null,
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 25,
|
||||
counts: { all: 0, unused: 0, duplicates: 0 },
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({ getMe, request })
|
||||
@@ -268,7 +270,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
|
||||
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe('Media')
|
||||
expect(app.find('a[href="/media"][aria-current="page"]').exists()).toBe(true)
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&limit=100')
|
||||
expect(request).toHaveBeenCalledWith('/v1/media/?filter=all&sort=newest&page=1&size=25')
|
||||
})
|
||||
|
||||
it('An unverified session on /media is sent to /register/pending', async () => {
|
||||
|
||||
+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>() {
|
||||
|
||||
+233
-2
@@ -1254,11 +1254,15 @@ interface MediaSample {
|
||||
createdAt: string
|
||||
duplicate: boolean
|
||||
tone: number
|
||||
variantCount: number
|
||||
variantSize: number
|
||||
}
|
||||
|
||||
const mediaSamples: MediaSample[] = [
|
||||
{
|
||||
id: 1,
|
||||
variantCount: 4,
|
||||
variantSize: 266240,
|
||||
filename: 'north-wall.jpg',
|
||||
size: 4089446,
|
||||
width: 4032,
|
||||
@@ -1272,6 +1276,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
variantCount: 5,
|
||||
variantSize: 419840,
|
||||
filename: 'kitchen-after.webp',
|
||||
size: 1258291,
|
||||
width: 2400,
|
||||
@@ -1285,6 +1291,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
variantCount: 5,
|
||||
variantSize: 404480,
|
||||
filename: 'kitchen-before.webp',
|
||||
size: 1153434,
|
||||
width: 2400,
|
||||
@@ -1298,6 +1306,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
variantCount: 0,
|
||||
variantSize: 0,
|
||||
filename: 'logo-renew.svg',
|
||||
size: 8192,
|
||||
width: null,
|
||||
@@ -1311,6 +1321,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
variantCount: 3,
|
||||
variantSize: 245760,
|
||||
filename: 'floorboards-copy.jpg',
|
||||
size: 2516582,
|
||||
width: 3600,
|
||||
@@ -1324,6 +1336,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
variantCount: 0,
|
||||
variantSize: 0,
|
||||
filename: 'IMG_2208.jpg',
|
||||
size: 2516582,
|
||||
width: 3600,
|
||||
@@ -1337,6 +1351,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
variantCount: 2,
|
||||
variantSize: 153600,
|
||||
filename: 'IMG_4471.jpg',
|
||||
size: 3250585,
|
||||
width: 3600,
|
||||
@@ -1350,6 +1366,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
variantCount: 0,
|
||||
variantSize: 0,
|
||||
filename: 'window-frame.jpg',
|
||||
size: 1887437,
|
||||
width: 3000,
|
||||
@@ -1363,6 +1381,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
variantCount: 1,
|
||||
variantSize: 61440,
|
||||
filename: 'stone-wall-detail.jpg',
|
||||
size: 3250585,
|
||||
width: 3600,
|
||||
@@ -1376,6 +1396,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
variantCount: 3,
|
||||
variantSize: 194560,
|
||||
filename: 'bathroom-tiles.png',
|
||||
size: 880640,
|
||||
width: 1800,
|
||||
@@ -1389,6 +1411,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
variantCount: 4,
|
||||
variantSize: 286720,
|
||||
filename: 'lime-plaster.jpg',
|
||||
size: 2202009,
|
||||
width: 3200,
|
||||
@@ -1402,6 +1426,8 @@ const mediaSamples: MediaSample[] = [
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
variantCount: 5,
|
||||
variantSize: 440320,
|
||||
filename: 'floorboards.jpg',
|
||||
size: 2516582,
|
||||
width: 3600,
|
||||
@@ -1454,6 +1480,8 @@ function mediaItem(sample: MediaSample, filename = sample.filename) {
|
||||
created_at: sample.createdAt,
|
||||
used_in: sample.usedIn,
|
||||
duplicate: sample.duplicate,
|
||||
variant_count: sample.variantCount,
|
||||
variant_size: sample.variantSize,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1464,7 +1492,46 @@ function mediaItems(ids?: number[], without: number[] = []) {
|
||||
}
|
||||
|
||||
function mediaList(items: unknown[], counts = mediaFullCounts) {
|
||||
return { items, next_cursor: null, counts }
|
||||
return { items, total: items.length, page: 1, size: 25, counts }
|
||||
}
|
||||
|
||||
const mediaPagedTotal = 312
|
||||
const mediaPagedCounts = { all: mediaPagedTotal, unused: 2, duplicates: 2 }
|
||||
const mediaPastLastPage = 99
|
||||
const mediaFailingPage = '7'
|
||||
|
||||
function mediaPagedApi() {
|
||||
return mediaApi({
|
||||
'GET /v1/media/': {
|
||||
status: 200,
|
||||
bodyFor: (url) => {
|
||||
const page = Number(url.searchParams.get('page'))
|
||||
return {
|
||||
items: page === mediaPastLastPage ? [] : mediaItems(),
|
||||
total: mediaPagedTotal,
|
||||
page,
|
||||
size: Number(url.searchParams.get('size')),
|
||||
counts: mediaPagedCounts,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function isMediaPage(url: URL, wanted: string) {
|
||||
return url.pathname === '/v1/media/' && url.searchParams.get('page') === wanted
|
||||
}
|
||||
|
||||
async function scrollEverythingToTop(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
for (const element of document.querySelectorAll('*')) {
|
||||
element.scrollTop = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function pressNextPage(page: Page) {
|
||||
await page.getByRole('button', { name: 'Next page' }).click()
|
||||
}
|
||||
|
||||
function mediaApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
@@ -2015,6 +2082,59 @@ const mediaStates: Record<string, VisualState> = {
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'List failed to load': mediaState(mediaApi({ 'GET /v1/media/': { status: 500 } })),
|
||||
'Pagination: first page': mediaState(mediaPagedApi()),
|
||||
'Pagination: middle page': mediaState(mediaPagedApi(), undefined, '/media?page=7'),
|
||||
'Pagination: last page': mediaState(mediaPagedApi(), undefined, '/media?page=13'),
|
||||
'Pagination: list view': mediaState(mediaPagedApi(), undefined, '/media?view=list&page=2'),
|
||||
'Pagination: loading next page': mediaState(
|
||||
mediaPagedApi(),
|
||||
async (page) => {
|
||||
await page.route(
|
||||
(url) => isMediaPage(url, mediaFailingPage),
|
||||
() => undefined,
|
||||
)
|
||||
await pressNextPage(page)
|
||||
await page.getByTestId('media-skeleton').waitFor()
|
||||
await scrollEverythingToTop(page)
|
||||
},
|
||||
'/media?page=6',
|
||||
),
|
||||
'Pagination: error loading a page': mediaState(
|
||||
mediaPagedApi(),
|
||||
async (page) => {
|
||||
await page.route(
|
||||
(url) => isMediaPage(url, mediaFailingPage),
|
||||
(route) => route.fulfill({ status: 500 }),
|
||||
)
|
||||
await pressNextPage(page)
|
||||
await page.getByText("Couldn't load page 7. Showing page 6.").waitFor()
|
||||
await scrollEverythingToTop(page)
|
||||
},
|
||||
'/media?page=6',
|
||||
),
|
||||
'Pagination: page out of range': mediaState(
|
||||
mediaPagedApi(),
|
||||
async (page) => {
|
||||
await page.getByText("That page doesn't exist. Showing the last page.").waitFor()
|
||||
await scrollEverythingToTop(page)
|
||||
},
|
||||
`/media?page=${mediaPastLastPage}`,
|
||||
),
|
||||
'Pagination: page size menu': mediaState(mediaPagedApi(), async (page) => {
|
||||
await page.locator('[aria-label="Rows per page"]').click()
|
||||
await page.getByRole('listbox').waitFor()
|
||||
await scrollEverythingToTop(page)
|
||||
}),
|
||||
'Pagination: focus-visible': mediaState(
|
||||
mediaPagedApi(),
|
||||
async (page) => {
|
||||
await page.getByRole('button', { name: 'Next page' }).focus()
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
await page.keyboard.press('Tab')
|
||||
await scrollEverythingToTop(page)
|
||||
},
|
||||
'/media?page=7',
|
||||
),
|
||||
Offline: mediaState(mediaApi(), goOffline),
|
||||
}
|
||||
|
||||
@@ -2931,6 +3051,23 @@ const mediaPurgeStates: Record<string, VisualState> = {
|
||||
openMediaPurgeDialog,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Purge duplicates: confirm': mediaState(
|
||||
mediaDuplicatesApi(undefined, {
|
||||
[purgePreviewPath]: { status: 200, body: purgePreviewBody() },
|
||||
}),
|
||||
openMediaPurgeDialog,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Duplicates: no purge button (member)': mediaState(
|
||||
mediaDuplicatesApi(undefined, {
|
||||
'GET /v1/auth/me': {
|
||||
status: 200,
|
||||
body: { ...(mediaSession.body as object), role: 'member' },
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
mediaDuplicatesPath,
|
||||
),
|
||||
'Duplicates tab with the button': mediaState(
|
||||
mediaDuplicatesApi(),
|
||||
undefined,
|
||||
@@ -4255,6 +4392,98 @@ function withVariantExceptions(states: Record<string, VisualState>) {
|
||||
)
|
||||
}
|
||||
|
||||
const variantsRowMobileCeilings: Record<string, number> = {
|
||||
'Grid, many images': 33,
|
||||
'Grid, few images': 26,
|
||||
'List view': 19,
|
||||
Loading: 30,
|
||||
'Hover, focus and keyboard': 28,
|
||||
'Selection and bulk bar': 31,
|
||||
'Bulk delete: confirm': 23,
|
||||
'Bulk delete: in progress': 23,
|
||||
'Bulk delete: partly failed': 24,
|
||||
'Bulk delete: done': 30,
|
||||
'Detail panel: in use': 21,
|
||||
'Detail panel: unused': 20,
|
||||
'Copy URL': 21,
|
||||
'Rename: dialog': 15,
|
||||
'Rename: saving': 19,
|
||||
'Rename: validation': 15,
|
||||
'Rename: failed': 14,
|
||||
'Rename: done': 20,
|
||||
'Delete: image in use': 29,
|
||||
'Delete: in progress': 29,
|
||||
'Delete: done': 32,
|
||||
'Image already gone (404)': 31,
|
||||
'Upload: several files, mixed': 16,
|
||||
'Upload: summary': 15,
|
||||
'Upload: rejected files (413, 400)': 17,
|
||||
'Upload: too many requests (429)': 29,
|
||||
'Duplicates: groups': 29,
|
||||
'Merge: confirm': 13,
|
||||
'Merge: in progress': 13,
|
||||
'Merge: done': 29,
|
||||
Offline: 32,
|
||||
'Pagination: first page': 33,
|
||||
'Pagination: middle page': 33,
|
||||
'Pagination: last page': 33,
|
||||
'Pagination: list view': 19,
|
||||
'Pagination: loading next page': 30,
|
||||
'Pagination: error loading a page': 33,
|
||||
'Pagination: page out of range': 33,
|
||||
'Pagination: page size menu': 33,
|
||||
'Pagination: focus-visible': 33,
|
||||
'Purge duplicates: confirm': 24,
|
||||
'Duplicates: no purge button (member)': 30,
|
||||
'Duplicates tab with the button': 29,
|
||||
'Duplicates tab: offline': 29,
|
||||
}
|
||||
|
||||
function withVariantsRowException(
|
||||
states: Record<string, VisualState>,
|
||||
): Record<string, VisualState> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(states).map(([name, state]) => {
|
||||
const mobile = variantsRowMobileCeilings[name]
|
||||
if (mobile === undefined) {
|
||||
return [name, state]
|
||||
}
|
||||
return [
|
||||
name,
|
||||
{
|
||||
...state,
|
||||
exception: {
|
||||
maxPercent: { desktop: MEDIA_DESKTOP_BUDGET, mobile },
|
||||
reason: 'variants filter row is not drawn on the Media board (docs/design README: not folded into Media)',
|
||||
approvedBy: 'user (Florian Sylvain), 2026-10-13',
|
||||
},
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const MEDIA_DESKTOP_BUDGET = 10
|
||||
|
||||
function withDetailTabsException(states: Record<string, VisualState>): Record<string, VisualState> {
|
||||
const name = 'Detail panel: duplicate'
|
||||
const state = states[name]
|
||||
if (!state) {
|
||||
return states
|
||||
}
|
||||
return {
|
||||
...states,
|
||||
[name]: {
|
||||
...state,
|
||||
exception: {
|
||||
maxPercent: { desktop: 11, mobile: 30 },
|
||||
reason: 'Details | Variants tab row not drawn on the Media board (drawn on the Image detail board)',
|
||||
approvedBy: 'user (Florian Sylvain), 2026-10-13',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
activity: activityStates,
|
||||
profile: {
|
||||
@@ -5644,6 +5873,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
...withVariantExceptions(variantsMediaStates),
|
||||
...withVariantExceptions(variantsEditorStates),
|
||||
},
|
||||
media: { ...mediaStates, ...mediaPurgeStates },
|
||||
media: withDetailTabsException(
|
||||
withVariantsRowException({ ...mediaStates, ...mediaPurgeStates }),
|
||||
),
|
||||
'media-picker': pickerStates,
|
||||
}
|
||||
Reference in new issue
Block a user