feat: articles pagination

This commit is contained in:
Florian Sylvain committed 2026-10-11 19:14:37 +02:00
1 parent a2d224240c
commit 73f4b539fe
45 files changed
+3528 -317

No files matched your search

+20
View File
@@ -32,6 +32,26 @@ const VISUAL_REFERENCE =
const officeBoard = 'RenewCMS Screens Back-office.dc.html'
const extraSources: Record<string, Source[]> = {
articles: [
{
board: officeBoard,
group: 'Articles: pagination',
states: [
'First page',
'Middle page',
'Last page',
'Single page',
'Loading next page',
'Error loading a page',
'Page out of range',
'Search resets to page 1',
'Empty after search',
'Page size menu',
'Focus-visible',
],
alias: {},
},
],
activity: [
{
board: officeBoard,
+77 -17
View File
@@ -673,6 +673,40 @@ function articlesFixture() {
]
}
interface ListedArticle {
title: string
is_online: boolean
}
function foldTitle(value: string) {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
}
function articleEnvelopeFor(all: ListedArticle[], url: URL) {
const page = Number(url.searchParams.get('page') ?? 1)
const size = Number(url.searchParams.get('size') ?? 25)
const q = url.searchParams.get('q') ?? ''
const status = url.searchParams.get('status') ?? ''
const matching = all
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || foldTitle(article.title).includes(foldTitle(q)))
const online = all.filter((article) => article.is_online).length
return {
items: matching.slice((page - 1) * size, page * size),
total: matching.length,
page,
size,
counts: { all: all.length, online, draft: all.length - online },
}
}
function articleListMock(all: ListedArticle[]): ApiMock {
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url) }
}
function articlesFixtureWithImages() {
return articlesFixture().map((article) =>
article.id === 4
@@ -691,11 +725,37 @@ function articlesFixtureWithImages() {
function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: articlesFixture() },
'GET /v1/article/': articleListMock(articlesFixture()),
...overrides,
}
}
const clockSensitiveArticles = (path: string): VisualState => ({
path,
skip: 'clock-sensitive',
})
const pagination: Record<string, VisualState> = {
'First page': clockSensitiveArticles('/article'),
'Middle page': clockSensitiveArticles('/article?page=7'),
'Last page': clockSensitiveArticles('/article?page=13'),
'Single page': clockSensitiveArticles('/article'),
'Loading next page': clockSensitiveArticles('/article?page=6'),
'Error loading a page': clockSensitiveArticles('/article?page=6'),
'Page out of range': clockSensitiveArticles('/article?page=99'),
'Search resets to page 1': clockSensitiveArticles('/article?page=7'),
'Empty after search': {
path: '/article',
api: articlesApi(),
steps: async (page) => {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.getByText('No articles match "tiles"').waitFor()
},
},
'Page size menu': clockSensitiveArticles('/article?page=2'),
'Focus-visible': clockSensitiveArticles('/article?page=7'),
}
async function openArticleRowMenu(page: Page, title: string) {
await page.getByRole('button', { name: `Actions for ${title}` }).click()
}
@@ -4000,7 +4060,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/article/': articleListMock(sixArticlesFixture),
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
},
steps: openAccountMenu,
@@ -4324,6 +4384,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
}),
},
articles: {
...pagination,
Default: {
path: '/article',
now: articlesNow,
@@ -4351,7 +4412,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixture(), hang: true },
'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
}),
exception: {
maxPercent: 10.6,
@@ -4362,7 +4423,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
Empty: {
path: '/article',
now: articlesNow,
api: articlesApi({ 'GET /v1/article/': { status: 200, body: [] } }),
api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
exception: {
maxPercent: 3.6,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout',
@@ -4387,12 +4448,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': {
status: 200,
body: articlesFixture()
'GET /v1/article/': articleListMock(
articlesFixture()
.slice(0, 4)
.map((article) => ({ ...article, is_online: true })),
},
),
}),
steps: async (page) => {
await page.getByRole('tab', { name: /^Drafts/ }).focus()
@@ -4478,7 +4538,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
@@ -4490,7 +4550,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204, hang: true },
}),
steps: async (page) => {
@@ -4506,7 +4566,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 500 },
}),
steps: async (page) => {
@@ -4523,7 +4583,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { status: 200, body: articlesFixtureWithImages() },
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204 },
}),
steps: async (page) => {
@@ -4545,7 +4605,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/article/': articleListMock(sixArticlesFixture),
},
},
Loading: {
@@ -4555,7 +4615,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': { ...homeActivityMock, hang: true },
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
'GET /v1/article/': { ...articleListMock(sixArticlesFixture), hang: true },
},
},
'Empty, first run': {
@@ -4565,7 +4625,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: [] },
'GET /v1/article/': articleListMock([]),
},
},
Error: {
@@ -4584,7 +4644,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/article/': articleListMock(sixArticlesFixture),
'GET /v1/system/update': { status: 200, body: updateAvailable() },
},
},
@@ -4594,7 +4654,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/article/': articleListMock(sixArticlesFixture),
'GET /v1/system/update': { status: 200, body: updateJob('ready') },
},
},