feat: editorial article list

This commit is contained in:
Florian Sylvain committed 2026-11-02 07:28:44 +01:00
1 parent a0b3c58c52
commit a671b81f80
103 files changed
+6493 -1805

No files matched your search

+39 -233
View File
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
import type { Page } from 'playwright'
import { accessFor, customAccess, roleRefFor } from '../src/test/access'
import { approvedExceptions } from './approvedExceptions'
import { articleListStates } from './articleListStates'
import { localizationStates } from './localizationStates'
import { rolesStates } from './rolesStates'
@@ -22,7 +23,7 @@ export interface DiffException {
export interface VisualState {
exception?: DiffException
skip?: 'clock-sensitive' | 'pending'
skip?: 'clock-sensitive' | 'pending' | 'superseded'
path: string
origin?: string
now?: string
@@ -957,21 +958,6 @@ function articleListMock(all: ListedArticle[], needsAttention?: number): ApiMock
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url, needsAttention) }
}
function articlesFixtureWithImages() {
return articlesFixture().map((article) =>
article.id === 4
? {
...article,
images: [
{ id: 10, url: '/images/10.png' },
{ id: 11, url: '/images/11.png' },
{ id: 12, url: '/images/12.png' },
],
}
: article,
)
}
function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return {
'GET /v1/auth/me': verifiedSession,
@@ -981,49 +967,7 @@ function articlesApi(overrides: Record<string, ApiMock> = {}): Record<string, Ap
}
}
function multiSiteArticlesApi(overrides: Record<string, ApiMock> = {}) {
return articlesApi({ 'GET /v1/site/': boardSiteList(4), ...overrides })
}
async function openSiteFilter(page: Page) {
await page.locator('button[aria-label="Site"]:visible').click()
await page.getByRole('option').first().waitFor()
await page.mouse.move(0, 0)
}
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()
}
async function chooseArticleRowMenuItem(page: Page, label: string) {
await page.getByRole('menuitem', { name: label, exact: true }).click()
}
const supersededArticleState: VisualState = { path: '/article', skip: 'superseded' }
async function fillResetRequestEmail(page: Page, email: string) {
await page.getByPlaceholder('you@example.com').fill(email)
@@ -9505,180 +9449,41 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
}),
},
articles: {
...pagination,
'Status chips': clockSensitiveArticles('/article'),
'Needs attention on': clockSensitiveArticles('/article?seo=suggestions'),
'Needs attention: none': {
path: '/article?seo=suggestions',
api: articlesApi({ 'GET /v1/article/': articleListMock([], 0) }),
steps: async (page) => {
await page.getByText('Nothing needs attention').waitFor()
},
},
'Several sites': {
path: '/article',
now: articlesNow,
api: multiSiteArticlesApi(),
},
'Site filter open': {
path: '/article',
now: articlesNow,
api: multiSiteArticlesApi(),
steps: openSiteFilter,
},
'No articles on a site': {
path: '/article?site=archive',
now: articlesNow,
api: multiSiteArticlesApi(),
steps: async (page) => {
await page.getByText('No articles on "Archive"').waitFor()
},
},
Default: {
path: '/article',
now: articlesNow,
api: articlesApi(),
},
'Row menu open': {
path: '/article',
now: articlesNow,
api: articlesApi(),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
},
},
Loading: {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
}),
},
Empty: {
path: '/article',
now: articlesNow,
api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
},
'No results': {
path: '/article',
now: articlesNow,
api: articlesApi(),
steps: async (page) => {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.waitForTimeout(200)
},
},
'No drafts': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(
articlesFixture()
.slice(0, 4)
.map((article) => ({ ...article, is_online: true })),
),
}),
steps: async (page) => {
await page.getByRole('tab', { name: /^Drafts/ }).focus()
await page.keyboard.press('Enter')
},
},
'Action in progress': {
path: '/article',
now: articlesNow,
api: articlesApi({
'PATCH /v1/article/4/publish': { status: 200, hang: true },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
},
},
Published: clockSensitiveArticles('/article'),
'Action failed': {
path: '/article',
now: articlesNow,
api: articlesApi({ 'PATCH /v1/article/3/unpublish': { status: 500 } }),
steps: async (page) => {
await openArticleRowMenu(page, 'Before and after: the Rue Verte kitchen')
await chooseArticleRowMenuItem(page, 'Unpublish')
await page.getByText("Couldn't unpublish").first().waitFor()
},
},
'Row vanished (404)': {
path: '/article',
now: articlesNow,
api: articlesApi({ 'PATCH /v1/article/2/publish': { status: 404 } }),
steps: async (page) => {
await openArticleRowMenu(page, 'Choosing lime plaster for old stone walls')
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('That article no longer exists').first().waitFor()
},
},
'Delete: confirm': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page.getByRole('alertdialog').waitFor()
},
},
'Delete: in progress': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204, hang: true },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
},
},
'Delete: failed': {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 500 },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
await page.getByText("Couldn't delete the article").waitFor()
},
},
Deleted: {
path: '/article',
now: articlesNow,
api: articlesApi({
'GET /v1/article/': articleListMock(articlesFixtureWithImages()),
'DELETE /v1/article/4': { status: 204 },
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Delete…')
await page
.getByRole('alertdialog')
.getByRole('button', { name: 'Delete article' })
.click()
await page.getByText('Article deleted').first().waitFor()
},
},
},
articles: Object.fromEntries(
[
'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',
'Status chips',
'Needs attention on',
'Needs attention: none',
'Several sites',
'Site filter open',
'No articles on a site',
'Default',
'Row menu open',
'Loading',
'Empty',
'No results',
'No drafts',
'Action in progress',
'Published',
'Action failed',
'Row vanished (404)',
'Delete: confirm',
'Delete: in progress',
'Delete: failed',
'Deleted',
].map((state) => [state, supersededArticleState]),
),
home: {
Default: {
path: '/home',
@@ -10541,4 +10346,5 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
),
'media-picker': pickerStates,
'localization-site-languages': localizationStates,
'localization-list': articleListStates,
}