feat: pagination in the SPA for the Sites and Authors lists
This commit is contained in:
1 parent
500ad59458
commit
340a0ad461
34 files changed
+2833
-896
No files matched your search
+26
-1
@@ -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
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user