feat(media): pagination
This commit is contained in:
1 parent
04dc151ed7
commit
63c1e7076e
65 files changed
+2960
-365
No files matched your search
+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