feat: pagination in the SPA for the Sites and Authors lists

This commit is contained in:
Florian Sylvain committed 2026-10-19 04:00:47 +02:00
1 parent 500ad59458
commit 340a0ad461
34 files changed
+2833 -896

No files matched your search

+26 -1
View File
@@ -31,8 +31,27 @@ const VISUAL_REFERENCE =
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
const PAGINATION_STATES = [
'First page',
'Middle page',
'Last page',
'Single page',
'Page size menu',
'Loading a page',
'Error loading a page',
'Empty after search',
'Search resets to page 1',
'Long labels on mobile',
]
const extraSources: Record<string, Source[]> = {
sites: [
{
board: officeBoard,
group: 'Sites: pagination',
states: PAGINATION_STATES,
alias: {},
},
{
board: officeBoard,
group: 'Sites: form',
@@ -67,6 +86,12 @@ const extraSources: Record<string, Source[]> = {
},
],
authors: [
{
board: officeBoard,
group: 'Authors: pagination',
states: PAGINATION_STATES,
alias: {},
},
{
board: officeBoard,
group: 'Authors: form',
@@ -842,7 +867,7 @@ function fulfilment(mock: ApiMock, url: URL, call: number) {
const text = typeof body === 'string'
return {
status: mock.status,
headers: mock.headers,
headers: { ...mock.headers, ...mock.headersFor?.(url, call) },
contentType: text ? 'text/plain; charset=utf-8' : 'application/json',
body: body === undefined ? '' : text ? (body as string) : JSON.stringify(body),
}
+470 -68
View File
@@ -6,6 +6,7 @@ export interface ApiMock {
body?: unknown
bodyFor?: (url: URL, call: number) => unknown
headers?: Record<string, string>
headersFor?: (url: URL, call: number) => Record<string, string>
hang?: boolean
}
@@ -373,6 +374,9 @@ function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiM
'GET /v1/media/': { status: 200, body: emptyLibrary },
'GET /v1/site/': boardSiteList(),
'GET /v1/author/': boardAuthorList(),
'GET /v1/media/12': { status: 404 },
'GET /v1/media/13': { status: 404 },
'GET /v1/media/14': { status: 404 },
'POST /v1/article/12/seo/check': { status: 200, body: seoReady },
...overrides,
}
@@ -4793,7 +4797,9 @@ function sitesApi(
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': directorySession(role),
'GET /v1/site/': { status: 200, body: directorySites },
'GET /v1/site/': siteListMock(directorySites),
'GET /v1/media/11': { status: 200, body: { ...mediaItem(mediaSamples[0]!), id: 11 } },
'GET /v1/media/12': { status: 200, body: { ...mediaItem(mediaSamples[1]!), id: 12 } },
...overrides,
}
}
@@ -4857,15 +4863,403 @@ async function submitDirectoryPanel(page: Page, label: string) {
await page.mouse.move(0, 0)
}
const TOTAL_COUNT_HEADER = 'X-Total-Count'
function pageParams(url: URL) {
return {
q: (url.searchParams.get('q') ?? '').toLowerCase(),
size: Number(url.searchParams.get('size') ?? 25),
page: Number(url.searchParams.get('page') ?? 1),
}
}
function siteListMock(sites: unknown[]): ApiMock {
const matching = (url: URL) => {
const { q } = pageParams(url)
return (sites as { name: string; key: string; base_url: string }[]).filter((site) =>
`${site.name} ${site.key} ${site.base_url}`.toLowerCase().includes(q),
)
}
return {
status: 200,
bodyFor: (url) => {
if (!url.searchParams.has('page')) {
return sites
}
const { size, page } = pageParams(url)
return matching(url).slice((page - 1) * size, page * size)
},
headersFor: (url) => ({ [TOTAL_COUNT_HEADER]: String(matching(url).length) }),
}
}
interface PagerGroup {
page: number
per: number
total: number
q: string
limit: number
}
interface PagerTotals {
total: number
searchTotal: number
emptyTerm: string
}
const pagerSiteNames = [
'Atelier',
'Birchwood',
'Cedar Row',
'Dovetail',
'Eastgate',
'Fieldnotes',
'Granary',
'Harbour',
'Ironwork',
'Joinery Guide',
'Kiln',
'Larch',
'Millstone',
'Nettle',
'Orchard',
'Planing',
'Quarry',
'Ridgeway',
'Slate',
'Tannery',
'Underhill',
'Veneer',
'Workshop',
'Yardline',
'Zinc Works',
]
const pagerSearchSiteNames = [
'Journal',
'Projects',
'Archive',
'Press',
'Careers',
'Docs',
'Events',
'Shop',
]
const pagerFirstNames = [
'Anaïs',
'Bruno',
'Clara',
'Diego',
'Elise',
'Félix',
'Greta',
'Hugo',
'Iris',
'Julien',
'Karim',
'Lucie',
'Mathieu',
'Nora',
'Oscar',
'Pilar',
'Quentin',
'Rosa',
'Samir',
'Tess',
]
const pagerSearchFirstNames = [
'Marc',
'Marie',
'Marta',
'Mario',
'Marina',
'Marius',
'Marlène',
'Marcel',
]
const pagerLastNames = [
'Marchand',
'Nunes',
'Oliveira',
'Perrin',
'Quintela',
'Rocha',
'Santos',
'Teixeira',
'Vidal',
'Weber',
]
const PAGER_VISIBLE_ROWS = 12
const PAGER_ID_OFFSET = 1000
function pagerSlug(text: string) {
return text
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function pagerRows(group: PagerGroup) {
const start = (group.page - 1) * group.per
const count = Math.max(0, Math.min(group.limit, group.per, group.total - start))
return { start, count }
}
function numbered(name: string, round: number) {
return round > 0 ? `${name} ${round + 1}` : name
}
function pagerSites(group: PagerGroup) {
const { start, count } = pagerRows(group)
return Array.from({ length: count }, (_, index) => {
const position = start + index
let name: string
if (group.q) {
const names = pagerSearchSiteNames
name = numbered(
`Renew ${names[position % names.length]}`,
Math.floor(position / names.length),
)
} else if (position < directorySites.length) {
return directorySites[position]!
} else {
const offset = position - directorySites.length
name = numbered(
pagerSiteNames[offset % pagerSiteNames.length]!,
Math.floor(offset / pagerSiteNames.length),
)
}
const key = pagerSlug(name)
return {
...directorySites[1]!,
id: PAGER_ID_OFFSET + position,
name,
site_name: name,
key,
base_url: `https://${group.q ? key.replace(/^renew-/, '') : key}.renew.example`,
default_robots: 'index',
is_default: false,
article_count: (position * 37) % 90,
}
})
}
function pagerAuthors(group: PagerGroup): DirectoryAuthor[] {
const { start, count } = pagerRows(group)
const known = directoryAuthors()
return Array.from({ length: count }, (_, index) => {
const position = start + index
if (!group.q && position < known.length) {
return known[position]!
}
const firstNames = group.q ? pagerSearchFirstNames : pagerFirstNames
const name = `${firstNames[position % firstNames.length]} ${pagerLastNames[(position * 3 + 1) % pagerLastNames.length]}`
const slug = pagerSlug(name)
const linked = position % 3 === 0
return {
id: PAGER_ID_OFFSET + position,
name,
slug,
bio: '',
avatar_id: position % 2 === 0 ? 12 : null,
website: null,
social_links: [],
user_id: linked ? 3 : null,
is_linked: linked,
article_count: (position * 5) % 12,
...(linked ? { user_email: `${slug.split('-')[0]}@renew.example` } : {}),
}
})
}
function pagedDirectoryMock(
kind: 'sites' | 'authors',
totals: PagerTotals,
limit = PAGER_VISIBLE_ROWS,
): ApiMock {
const totalFor = (q: string) =>
q === '' ? totals.total : q === totals.emptyTerm ? 0 : totals.searchTotal
const rowsFor = kind === 'sites' ? pagerSites : pagerAuthors
return {
status: 200,
bodyFor: (url) => {
const { q, size, page } = pageParams(url)
const total = totalFor(q)
const items = total === 0 ? [] : rowsFor({ page, per: size, total, q, limit })
return kind === 'sites' ? items : { items, total, page, size }
},
headersFor: (url) => ({ [TOTAL_COUNT_HEADER]: String(totalFor(pageParams(url).q)) }),
}
}
function holdListPage(path: string, nextPage: number, answer?: number) {
return async (page: Page) => {
await page.route(
(url) => url.pathname === path && url.searchParams.get('page') === String(nextPage),
(route) => (answer ? route.fulfill({ status: answer }) : undefined),
)
}
}
interface DirectoryPagerSetup {
route: string
listPath: string
kind: 'sites' | 'authors'
totals: PagerTotals
api: (list: ApiMock) => Record<string, ApiMock>
middle: number
searchPlaceholder: string
searchTerm: string
singleTotal: number
bigTotal: number
bigPage: number
emptyTitle: string
}
const errorFooterReason =
'The board pins the footer in a clipped list; the app keeps normal flow (board fix pending)'
const errorFooterExceptions: Record<DirectoryPagerSetup['kind'], DiffException> = {
sites: {
maxPercent: { desktop: 10, mobile: 13 },
reason: errorFooterReason,
approvedBy: 'fsylvain',
},
authors: {
maxPercent: { desktop: 10, mobile: 11 },
reason: errorFooterReason,
approvedBy: 'fsylvain',
},
}
const boardLacksSearchReason = 'The board does not draw the new search field (board fix pending)'
interface FittedRows {
desktop: number
mobile: number
}
const FITTED_ROWS = {
menu: { desktop: PAGER_VISIBLE_ROWS, mobile: 5 },
error: { desktop: 9, mobile: 3 },
}
function fitRows(setup: DirectoryPagerSetup, limits: FittedRows, state: VisualState): VisualState {
return {
...state,
steps: async (page) => {
const limited = pagedDirectoryMock(
setup.kind,
setup.totals,
isPhone(page) ? limits.mobile : limits.desktop,
)
await page.route(
(url) => url.pathname === setup.listPath,
(route) => {
const url = new URL(route.request().url())
return route.fulfill({
status: 200,
contentType: 'application/json',
headers: limited.headersFor?.(url, 1),
body: JSON.stringify(limited.bodyFor?.(url, 1)),
})
},
)
await page.reload()
await page
.locator('[data-testid="directory-row"], [data-testid="directory-card"]')
.first()
.waitFor()
await state.steps?.(page)
},
}
}
function directoryPagerStates(setup: DirectoryPagerSetup): Record<string, VisualState> {
const withTotals = (overrides: Partial<PagerTotals> = {}) =>
setup.api(pagedDirectoryMock(setup.kind, { ...setup.totals, ...overrides }))
const lastPage = Math.ceil(setup.totals.total / 25)
const nextPage = setup.middle + 1
return {
'First page': { path: setup.route, api: withTotals() },
'Middle page': { path: `${setup.route}?page=${setup.middle}`, api: withTotals() },
'Last page': { path: `${setup.route}?page=${lastPage}`, api: withTotals() },
'Single page': { path: setup.route, api: withTotals({ total: setup.singleTotal }) },
'Page size menu': fitRows(setup, FITTED_ROWS.menu, {
path: `${setup.route}?page=${setup.middle}`,
api: withTotals(),
steps: async (page) => {
await page.locator('[aria-label="Rows per page"]').click()
await page.getByRole('listbox').waitFor()
await scrollEverythingToTop(page)
},
}),
'Loading a page': {
path: `${setup.route}?page=${setup.middle}`,
api: withTotals(),
steps: async (page) => {
await holdListPage(setup.listPath, nextPage)(page)
await pressNextPage(page)
await page.mouse.move(0, 0)
await page.getByText(`Loading page ${nextPage}…`).waitFor()
await page.locator('[data-testid="directory-skeleton-row"]').first().waitFor()
await scrollEverythingToTop(page)
},
},
'Error loading a page': fitRows(setup, FITTED_ROWS.error, {
exception: errorFooterExceptions[setup.kind],
path: `${setup.route}?page=${setup.middle}`,
api: withTotals(),
steps: async (page) => {
await holdListPage(setup.listPath, nextPage, 500)(page)
await pressNextPage(page)
await page.mouse.move(0, 0)
await page
.getByText(`Couldn't load page ${nextPage}. Showing page ${setup.middle}.`)
.waitFor()
await scrollEverythingToTop(page)
},
}),
'Empty after search': {
path: setup.route,
api: withTotals(),
steps: async (page) => {
await page.getByPlaceholder(setup.searchPlaceholder).fill(setup.totals.emptyTerm)
await page.getByText(setup.emptyTitle).waitFor()
},
},
'Search resets to page 1': {
path: `${setup.route}?q=${setup.searchTerm}`,
api: withTotals(),
},
'Long labels on mobile': {
path: `${setup.route}?page=${setup.bigPage}`,
api: withTotals({ total: setup.bigTotal }),
},
}
}
const sitesStates: Record<string, VisualState> = {
...directoryPagerStates({
route: '/settings/sites',
listPath: '/v1/site/',
kind: 'sites',
totals: { total: 61, searchTotal: 27, emptyTerm: 'blog' },
api: (list) => sitesApi({ 'GET /v1/site/': list }),
middle: 2,
searchPlaceholder: 'Search by name or key',
searchTerm: 'renew',
singleTotal: 4,
bigTotal: 312,
bigPage: 6,
emptyTitle: 'No sites match "blog"',
}),
Default: { path: '/settings/sites', api: sitesApi() },
'Only the default site': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: [{ ...directorySites[0], article_count: 312 }],
},
'GET /v1/site/': siteListMock([{ ...directorySites[0], article_count: 312 }]),
}),
},
Loading: {
@@ -4874,6 +5268,11 @@ const sitesStates: Record<string, VisualState> = {
},
Error: { path: '/settings/sites', api: sitesApi({ 'GET /v1/site/': { status: 500 } }) },
'Row menu': {
exception: {
maxPercent: { desktop: 17, mobile: 10 },
reason: boardLacksSearchReason,
approvedBy: 'fsylvain',
},
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
@@ -4882,6 +5281,11 @@ const sitesStates: Record<string, VisualState> = {
},
},
'Default site: delete unavailable': {
exception: {
maxPercent: { desktop: 11, mobile: 10 },
reason: boardLacksSearchReason,
approvedBy: 'fsylvain',
},
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
@@ -4893,6 +5297,11 @@ const sitesStates: Record<string, VisualState> = {
},
},
'Set as default: confirm': {
exception: {
maxPercent: { desktop: 10, mobile: 11 },
reason: boardLacksSearchReason,
approvedBy: 'fsylvain',
},
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
@@ -4981,12 +5390,11 @@ const sitesStates: Record<string, VisualState> = {
'Edit: SEO defaults': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: directorySites.map((site) =>
'GET /v1/site/': siteListMock(
directorySites.map((site) =>
site.id === 1 ? { ...site, default_description: siteSeoDescription } : site,
),
},
),
}),
steps: async (page) => {
await openSiteEdit(page, 'Renew')
@@ -5009,12 +5417,11 @@ const sitesStates: Record<string, VisualState> = {
'Template: custom': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: directorySites.map((site) =>
'GET /v1/site/': siteListMock(
directorySites.map((site) =>
site.id === 2 ? { ...site, title_template: '%title% – %site%' } : site,
),
},
),
}),
steps: async (page) => {
await openSiteEdit(page, 'Journal')
@@ -5036,12 +5443,11 @@ const sitesStates: Record<string, VisualState> = {
'Save failed': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: directorySites.map((site) =>
'GET /v1/site/': siteListMock(
directorySites.map((site) =>
site.id === 1 ? { ...site, site_name: 'Ren' } : site,
),
},
),
'PATCH /v1/site/1': { status: 500 },
}),
steps: async (page) => {
@@ -5200,6 +5606,13 @@ function authorListMock(authors: DirectoryAuthor[]): ApiMock {
size,
}
},
headersFor: (url) => {
const q = (url.searchParams.get('q') ?? '').toLowerCase()
const total = authors.filter((author) =>
`${author.name} ${author.slug}`.toLowerCase().includes(q),
).length
return { [TOTAL_COUNT_HEADER]: String(total) }
},
}
}
@@ -5255,6 +5668,9 @@ function authorsApi(
'GET /v1/auth/me': directorySession(role),
'GET /v1/author/': authorListMock(options.authors ?? directoryAuthors({}, role !== 'user')),
'GET /v1/user/': { status: 200, body: directoryUsers },
'GET /v1/media/12': { status: 404 },
'GET /v1/media/13': { status: 404 },
'GET /v1/media/14': { status: 404 },
...overrides,
}
}
@@ -5299,6 +5715,20 @@ function avatarPickerList() {
}
const authorsStates: Record<string, VisualState> = {
...directoryPagerStates({
route: '/authors',
listPath: '/v1/author/',
kind: 'authors',
totals: { total: 128, searchTotal: 31, emptyTerm: 'zoe' },
api: (list) => authorsApi({ 'GET /v1/author/': list }),
middle: 3,
searchPlaceholder: 'Search by name or slug',
searchTerm: 'mar',
singleTotal: 6,
bigTotal: 1284,
bigPage: 12,
emptyTitle: 'No authors match "zoe"',
}),
Default: { path: '/authors', api: authorsApi() },
Loading: {
path: '/authors',
@@ -5813,20 +6243,6 @@ async function seoSaveFromPanel(page: Page) {
.click()
}
const boardOmitsMobileDialogReason =
"The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn"
function withMobileException<T extends VisualState>(state: T, mobile: number, reason: string): T {
return {
...state,
exception: {
maxPercent: { desktop: 10, mobile },
reason,
approvedBy: 'fsylvain',
},
}
}
const editorSeoStates: Record<string, VisualState> = {
'Collapsed: ready': seoSidebarState({}, false),
'Collapsed: suggestions': seoSidebarState(
@@ -6384,7 +6800,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
exception: {
maxPercent: { desktop: 16, mobile: 22 },
reason: 'The 403 note under the Errors table has no layout in the design (design gap) and pushes the following rows down; the 409 row is new and the board already differed at HEAD',
reason: 'The board lacks the 409 row, the "Message" heading and the Sites and Authors contents entries',
approvedBy: 'fsylvain',
},
}),
@@ -6867,45 +7283,31 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
{ authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] },
(page) => liftAuthor(page, 'Camille Roche'),
),
'Create author: dialog': withMobileException(
metaState({ withAuthor: true }, (page) => openCreateAuthorDialog(page, 'Zoe Lambert')),
55,
boardOmitsMobileDialogReason,
'Create author: dialog': metaState({ withAuthor: true }, (page) =>
openCreateAuthorDialog(page, 'Zoe Lambert'),
),
'Create author: for me': withMobileException(
metaState({}, async (page) => {
await openCreateAuthorDialog(page, 'Marta Alves')
await page.getByRole('checkbox', { name: /This is me/ }).click()
'Create author: for me': metaState({ authors: [] }, async (page) => {
await openCreateAuthorDialog(page, 'Marta Alves')
await page.getByRole('checkbox', { name: /This is me/ }).click()
await page.mouse.move(0, 0)
}),
'Create author: slug taken': metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
await page.getByRole('button', { name: 'Create author', exact: true }).click()
await page.getByText('is already used by another author').waitFor()
await page.mouse.move(0, 0)
}),
50,
boardOmitsMobileDialogReason,
},
),
'Create author: slug taken': withMobileException(
metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
await page.getByRole('button', { name: 'Create author', exact: true }).click()
await page.getByText('is already used by another author').waitFor()
await page.mouse.move(0, 0)
},
),
40,
boardOmitsMobileDialogReason,
),
'Create author: failed': withMobileException(
metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
await page.getByRole('button', { name: 'Create author', exact: true }).click()
await page.getByText("Couldn't create the author").waitFor()
await page.mouse.move(0, 0)
},
),
52.5,
boardOmitsMobileDialogReason,
'Create author: failed': metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
await page.getByRole('button', { name: 'Create author', exact: true }).click()
await page.getByText("Couldn't create the author").waitFor()
await page.mouse.move(0, 0)
},
),
Loading: {
path: '/article/12/edit',