feat: editorial article list
This commit is contained in:
1 parent
a0b3c58c52
commit
a671b81f80
103 files changed
+6493
-1805
No files matched your search
@@ -107,6 +107,6 @@ describe('Every state has a setup', () => {
|
||||
describe('Existing screens are unaffected', () => {
|
||||
it('reports the parser as the only discovery for localization screens', () => {
|
||||
const names = Object.keys(visualStates).filter((name) => name.startsWith('localization-'))
|
||||
expect(names).toEqual(['localization-site-languages'])
|
||||
expect(names).toEqual(['localization-site-languages', 'localization-list'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Page } from 'playwright'
|
||||
import { accessFor } from '../src/test/access'
|
||||
import {
|
||||
siteLanguagesBody,
|
||||
storyAuthorsBody,
|
||||
storyPageFor,
|
||||
type StoryViewer,
|
||||
} from '../src/test/storyLists'
|
||||
import type { ApiMock, VisualState } from './states'
|
||||
|
||||
const ok = (body: unknown): ApiMock => ({ status: 200, body })
|
||||
|
||||
function site(id: number, key: string, name: string, isDefault = false) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
key,
|
||||
site_name: name,
|
||||
title_template: '%title% | %site%',
|
||||
default_description: '',
|
||||
base_url: `https://${key}.example.com`,
|
||||
default_social_image_id: null,
|
||||
default_robots: 'index',
|
||||
locale: 'en',
|
||||
is_default: isDefault,
|
||||
article_count: 0,
|
||||
public_url_pattern: null,
|
||||
}
|
||||
}
|
||||
|
||||
const sites = [
|
||||
site(1, 'journal', 'Journal', true),
|
||||
site(2, 'atelier', 'Atelier'),
|
||||
site(3, 'press', 'Press'),
|
||||
]
|
||||
|
||||
function session(access: unknown): ApiMock {
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
id: 1,
|
||||
is_verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
display_name: 'Marta Alves',
|
||||
access,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function listApi(
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
viewer: StoryViewer = {},
|
||||
access: unknown = accessFor('owner'),
|
||||
): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': session(access),
|
||||
'GET /v1/site/': ok(sites),
|
||||
'GET /v1/site/journal/languages': {
|
||||
status: 200,
|
||||
bodyFor: () => siteLanguagesBody('journal'),
|
||||
},
|
||||
'GET /v1/site/atelier/languages': {
|
||||
status: 200,
|
||||
bodyFor: () => siteLanguagesBody('atelier'),
|
||||
},
|
||||
'GET /v1/site/press/languages': { status: 200, bodyFor: () => siteLanguagesBody('press') },
|
||||
'GET /v1/author/': { status: 200, bodyFor: () => storyAuthorsBody() },
|
||||
'GET /v1/article/': { status: 200, bodyFor: (url) => storyPageFor(url, viewer) },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const clockSensitive = (path: string): VisualState => ({ path, skip: 'clock-sensitive' })
|
||||
|
||||
async function scrollTabsRight(page: Page) {
|
||||
const button = page.getByRole('button', { name: 'Scroll tabs right', exact: true })
|
||||
if (await button.isVisible()) {
|
||||
await button.click()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
export const articleListStates: Record<string, VisualState> = {
|
||||
'loc.l7.all': clockSensitive('/article?site=journal&size=5'),
|
||||
'loc.l7.page2': { path: '/article?site=journal&page=2&size=5', api: listApi() },
|
||||
'loc.l7.fr': clockSensitive('/article?site=journal&lang=fr&size=5'),
|
||||
'loc.l7.frMissing': {
|
||||
path: '/article?site=journal&lang=fr&tab=missing&size=5',
|
||||
api: listApi(),
|
||||
},
|
||||
'loc.l7.emptyMissing': {
|
||||
path: '/article?site=press&lang=fr&tab=missing&size=5',
|
||||
api: listApi(),
|
||||
},
|
||||
'loc.l7.noResults': { path: '/article?site=journal&q=zinc&size=5', api: listApi() },
|
||||
'loc.l7.frLoading': {
|
||||
path: '/article?site=journal&lang=fr&size=5',
|
||||
api: listApi({ 'GET /v1/article/': { status: 200, hang: true } }),
|
||||
steps: async (page) => {
|
||||
await page.waitForTimeout(400)
|
||||
},
|
||||
},
|
||||
'loc.l7.listFail': {
|
||||
path: '/article?site=journal&size=5',
|
||||
api: listApi({ 'GET /v1/article/': { status: 500, body: 'Failed to retrieve articles' } }),
|
||||
},
|
||||
'loc.l7.limited': clockSensitive('/article?site=journal&size=5'),
|
||||
'loc.l7.review': { path: '/article?site=journal&size=5', skip: 'pending' },
|
||||
'loc.l7.author': clockSensitive('/article?site=journal&author=1&size=5'),
|
||||
'loc.l7.langFilter': clockSensitive('/article?site=journal&size=5'),
|
||||
'loc.l7.rowMenu': clockSensitive('/article?site=journal&size=5'),
|
||||
'loc.l7.filters': clockSensitive('/article?site=journal&lang=fr&status=draft&size=5'),
|
||||
'loc.l7.tabsScroll': {
|
||||
path: '/article?site=journal&lang=fr&tab=missing&size=5',
|
||||
api: listApi(),
|
||||
steps: scrollTabsRight,
|
||||
},
|
||||
'loc.l7.long': clockSensitive('/article?site=atelier&status=draft&seo=suggestions&size=5'),
|
||||
}
|
||||
@@ -1517,6 +1517,13 @@ async function main() {
|
||||
lines.push(`PENDING ${state} · ${viewport} · ${theme}: slice B2`)
|
||||
continue
|
||||
}
|
||||
if (skip === 'superseded') {
|
||||
skipped++
|
||||
lines.push(
|
||||
`SKIP ${state} · ${viewport} · ${theme}: board describes the list replaced by localization-list`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (skip) {
|
||||
skipped++
|
||||
lines.push(`SKIP ${state} · ${viewport} · ${theme}: ${skip}`)
|
||||
|
||||
+39
-233
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user