feat: SEO SPA Sites, Articles and Integration (4/4)

This commit is contained in:
Florian Sylvain committed 2026-10-18 15:51:28 +02:00
1 parent cb737b7ae3
commit 02fb8751fd
47 files changed
+3218 -227

No files matched your search

+18
View File
@@ -53,6 +53,18 @@ const extraSources: Record<string, Source[]> = {
states: ['With articles', 'Choose destination', 'No articles'],
alias: {},
},
{
board: officeBoard,
group: 'Sites: SEO defaults',
states: [
'Edit: SEO defaults',
'Add: defaults',
'Template: custom',
'Template: not valid',
'Save failed',
],
alias: {},
},
],
authors: [
{
@@ -195,6 +207,12 @@ const extraSources: Record<string, Source[]> = {
states: ['Several sites', 'Site filter open', 'No articles on a site'],
alias: {},
},
{
board: officeBoard,
group: 'Articles: search status',
states: ['Status chips', 'Needs attention on', 'Needs attention: none'],
alias: {},
},
{
board: officeBoard,
group: 'Articles: pagination',
+155 -119
View File
@@ -837,6 +837,8 @@ function articlesFixture() {
site_id: 1,
authors: boardBylines('Inès Duarte', 'Camille Roche'),
is_online: false,
seo_status: 'suggestions',
seo_suggestions: 2,
created_at: '2026-09-09T09:00:00Z',
updated_at: articlesFixtureIso(1 * DAY_MS),
},
@@ -848,6 +850,8 @@ function articlesFixture() {
site_id: 3,
authors: boardBylines('Jonas Pereira'),
is_online: true,
seo_status: 'hidden',
seo_suggestions: 0,
created_at: '2026-08-28T09:00:00Z',
updated_at: articlesFixtureIso(3 * DAY_MS),
},
@@ -865,6 +869,8 @@ function articlesFixture() {
'Léa Fontaine',
),
is_online: false,
seo_status: 'suggestions',
seo_suggestions: 3,
created_at: '2026-09-02T09:00:00Z',
updated_at: articlesFixtureIso(5 * DAY_MS),
},
@@ -906,7 +912,22 @@ function foldTitle(value: string) {
.toLowerCase()
}
function articleEnvelopeFor(all: ListedArticle[], url: URL) {
const boardNeedsAttentionCount = 3
function articleEnvelopeFor(
all: ListedArticle[],
url: URL,
needsAttention = boardNeedsAttentionCount,
) {
if (url.searchParams.get('seo') === 'suggestions' && !url.searchParams.has('format')) {
return {
items: [],
total: needsAttention,
page: 1,
size: 5,
counts: { all: 0, online: 0, draft: 0 },
}
}
const page = Number(url.searchParams.get('page') ?? 1)
const size = Number(url.searchParams.get('size') ?? 25)
const q = url.searchParams.get('q') ?? ''
@@ -927,8 +948,8 @@ function articleEnvelopeFor(all: ListedArticle[], url: URL) {
}
}
function articleListMock(all: ListedArticle[]): ApiMock {
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url) }
function articleListMock(all: ListedArticle[], needsAttention?: number): ApiMock {
return { status: 200, bodyFor: (url) => articleEnvelopeFor(all, url, needsAttention) }
}
function articlesFixtureWithImages() {
@@ -4715,6 +4736,9 @@ const directorySites = [
{
id: 1,
name: 'Renew',
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: '',
key: 'renew',
base_url: 'https://renew.example',
default_social_image_id: 11,
@@ -4726,6 +4750,9 @@ const directorySites = [
{
id: 2,
name: 'Journal',
site_name: 'Journal',
title_template: '%title% | %site%',
default_description: '',
key: 'journal',
base_url: 'https://journal.renew.example',
default_social_image_id: null,
@@ -4737,6 +4764,9 @@ const directorySites = [
{
id: 3,
name: 'Projects',
site_name: 'Projects',
title_template: '%title% | %site%',
default_description: '',
key: 'projects',
base_url: 'https://projects.renew.example',
default_social_image_id: 12,
@@ -4748,6 +4778,9 @@ const directorySites = [
{
id: 4,
name: 'Archive',
site_name: 'Archive',
title_template: '%title% | %site%',
default_description: '',
key: 'archive',
base_url: 'https://archive.renew.example',
default_social_image_id: null,
@@ -4807,6 +4840,13 @@ function siteFormField(page: Page, placeholder: string) {
return page.getByRole('dialog').getByPlaceholder(placeholder)
}
const siteSeoDescription =
'Practical advice on renovating old buildings, from the people who do the work.'
function siteTemplateField(page: Page) {
return page.getByRole('dialog').locator('input[maxlength="200"]')
}
async function scrollPanelToTop(page: Page) {
await page.evaluate(() => {
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
@@ -4942,6 +4982,79 @@ const sitesStates: Record<string, VisualState> = {
await page.mouse.move(0, 0)
},
},
'Edit: SEO defaults': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: directorySites.map((site) =>
site.id === 1 ? { ...site, default_description: siteSeoDescription } : site,
),
},
}),
steps: async (page) => {
await openSiteEdit(page, 'Renew')
await page.mouse.move(0, 0)
},
},
'Add: defaults': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openDirectoryAdd(page, 'Add site')
await siteFormField(page, 'e.g. Journal').fill('Journal')
await siteFormField(page, 'https://blog.example.com').fill(
'https://journal.renew.example',
)
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
await page.mouse.move(0, 0)
},
},
'Template: custom': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: directorySites.map((site) =>
site.id === 2 ? { ...site, title_template: '%title% – %site%' } : site,
),
},
}),
steps: async (page) => {
await openSiteEdit(page, 'Journal')
await siteTemplateField(page).focus()
await scrollPanelToTop(page)
},
},
'Template: not valid': {
path: '/settings/sites',
api: sitesApi(),
steps: async (page) => {
await openSiteEdit(page, 'Journal')
await siteTemplateField(page).fill('%site%')
await siteTemplateField(page).blur()
await page.getByRole('dialog').getByText('The template must contain %title%.').waitFor()
await scrollPanelToTop(page)
},
},
'Save failed': {
path: '/settings/sites',
api: sitesApi({
'GET /v1/site/': {
status: 200,
body: directorySites.map((site) =>
site.id === 1 ? { ...site, site_name: 'Ren' } : site,
),
},
'PATCH /v1/site/1': { status: 500 },
}),
steps: async (page) => {
await openSiteEdit(page, 'Renew')
await siteFormField(page, 'e.g. Renew').fill('Renew')
await submitDirectoryPanel(page, 'Save changes')
await page.getByRole('dialog').getByText("Couldn't save the site").waitFor()
},
},
'With articles': {
path: '/settings/sites',
api: sitesApi(),
@@ -5694,8 +5807,6 @@ async function seoSaveFromPanel(page: Page) {
.click()
}
const dismissColumnReason =
'The board draws a dismiss column and its footnote in the suggestion list; dismissal is deferred to slice 4'
const boardOmitsMobileDialogReason =
"The board's mobile frame omits the Create author dialog, so only the Details sheet is drawn"
@@ -5731,23 +5842,15 @@ const editorSeoStates: Record<string, VisualState> = {
true,
),
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
'Panel: defaults': withMobileException(
seoPanelState({ check: seoCheck(['cover_none']) }),
26.5,
dismissColumnReason,
),
'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }),
'Panel: filled in': seoPanelState({ article: seoFilled }),
'Panel: outside the soft ranges': withMobileException(
seoPanelState({
article: {
seo_title: seoLongTitle,
seo_description: 'What a bathroom renovation costs.',
},
check: seoCheck(['title_long', 'desc_short']),
}),
15.5,
dismissColumnReason,
),
'Panel: outside the soft ranges': seoPanelState({
article: {
seo_title: seoLongTitle,
seo_description: 'What a bathroom renovation costs.',
},
check: seoCheck(['title_long', 'desc_short']),
}),
'Panel: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }),
'Panel: slug taken': seoPanelState(
{
@@ -5765,16 +5868,12 @@ const editorSeoStates: Record<string, VisualState> = {
await page.getByText('is already used on Renew.').waitFor()
},
),
'Panel: cover without alt': withMobileException(
seoPanelState({
article: seoCoverNoAlt,
check: seoCheck(['cover_alt']),
}),
17,
dismissColumnReason,
),
'Panel: choose cover': withMobileException(
seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
'Panel: cover without alt': seoPanelState({
article: seoCoverNoAlt,
check: seoCheck(['cover_alt']),
}),
'Panel: choose cover': {
...seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
await page
.getByRole('dialog')
.getByRole('button', { name: 'Choose image', exact: true })
@@ -5784,9 +5883,12 @@ const editorSeoStates: Record<string, VisualState> = {
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
await page.mouse.move(0, 0)
}),
29,
dismissColumnReason,
),
exception: {
maxPercent: { desktop: 10, mobile: 29 },
reason: 'The shared media picker sheet is taller and shows more tiles than the board on mobile (28.29% light, 24.08% dark); the picker is not changed here',
approvedBy: 'fsylvain',
},
},
'Panel: social sharing open': seoPanelState(
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
(page) => seoOpenDisclosure(page, 'Social sharing'),
@@ -5813,19 +5915,12 @@ const editorSeoStates: Record<string, VisualState> = {
async () => {},
700,
),
'Panel: mobile search preview': withMobileException(
seoPanelState(
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
async (page) => {
await page
.getByRole('dialog')
.getByRole('tab', { name: 'Mobile', exact: true })
.click()
await page.mouse.move(0, 0)
},
),
17.5,
dismissColumnReason,
'Panel: mobile search preview': seoPanelState(
{ article: { seo_title: seoLongTitle }, check: seoCheck(['title_long']) },
async (page) => {
await page.getByRole('dialog').getByRole('tab', { name: 'Mobile', exact: true }).click()
await page.mouse.move(0, 0)
},
),
'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }),
'Panel: saving': seoPanelState(
@@ -6282,9 +6377,9 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'6. Errors': integrationState({
steps: (page) => scrollToAnchor(page, 'errors', { desktop: 60, mobile: 55 }),
exception: {
maxPercent: { desktop: 12.5, mobile: 19 },
reason: 'A 409 row is new, the typical-cause text uses the real server messages in place of the longer board prose, and the board changed (17.33 % mobile already at HEAD against the updated board)',
approvedBy: 'Florian Sylvain',
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',
approvedBy: 'fsylvain',
},
}),
'I1 · Many variants': integrationImagesState(integrationManyVariants),
@@ -6357,6 +6452,15 @@ 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,
@@ -6380,11 +6484,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
path: '/article',
now: articlesNow,
api: articlesApi(),
exception: {
maxPercent: 3.7,
reason: 'UTable column boundaries land ~9-12px off the board grid (110/130/140/44px), unresolved by nth-child width overrides on table-fixed',
approvedBy: 'Florian Sylvain',
},
},
'Row menu open': {
path: '/article',
@@ -6393,11 +6492,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
},
exception: {
maxPercent: 7.5,
reason: 'Same UTable column-boundary offset as Default, plus the open row menu popover rendering',
approvedBy: 'Florian Sylvain',
},
},
Loading: {
path: '/article',
@@ -6405,21 +6499,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: articlesApi({
'GET /v1/article/': { ...articleListMock(articlesFixture()), hang: true },
}),
exception: {
maxPercent: 10.6,
reason: 'Skeleton placeholder block sizes/positions only approximate the board skeleton shape',
approvedBy: 'Florian Sylvain',
},
},
Empty: {
path: '/article',
now: articlesNow,
api: articlesApi({ 'GET /v1/article/': articleListMock([]) }),
exception: {
maxPercent: 3.6,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the empty layout',
approvedBy: 'Florian Sylvain',
},
},
'No results': {
path: '/article',
@@ -6429,11 +6513,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await page.getByPlaceholder('Search by title').fill('tiles')
await page.waitForTimeout(200)
},
exception: {
maxPercent: 3.3,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-results layout',
approvedBy: 'Florian Sylvain',
},
},
'No drafts': {
path: '/article',
@@ -6449,11 +6528,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await page.getByRole('tab', { name: /^Drafts/ }).focus()
await page.keyboard.press('Enter')
},
exception: {
maxPercent: 2.7,
reason: 'Same UTable/EmptyState geometry offset as Default, carried into the no-drafts layout',
approvedBy: 'Florian Sylvain',
},
},
'Action in progress': {
path: '/article',
@@ -6465,36 +6539,8 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
},
exception: {
maxPercent: 3.6,
reason: 'Same UTable column-boundary offset as Default, plus the busy badge/spinner rendering',
approvedBy: 'Florian Sylvain',
},
},
Published: {
path: '/article',
now: articlesNow,
api: articlesApi({
'PATCH /v1/article/4/publish': {
status: 200,
body: {
...articlesFixture()[3],
is_online: true,
updated_at: articlesNow,
},
},
}),
steps: async (page) => {
await openArticleRowMenu(page, 'Winter checklist for timber windows')
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('Article published').first().waitFor()
},
exception: {
maxPercent: 4.6,
reason: 'Same UTable column-boundary offset as Default, plus the success toast rendering',
approvedBy: 'Florian Sylvain',
},
},
Published: clockSensitiveArticles('/article'),
'Action failed': {
path: '/article',
now: articlesNow,
@@ -6504,11 +6550,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await chooseArticleRowMenuItem(page, 'Unpublish')
await page.getByText("Couldn't unpublish").first().waitFor()
},
exception: {
maxPercent: 5.7,
reason: 'Same UTable column-boundary offset as Default, plus the error toast with Retry rendering',
approvedBy: 'Florian Sylvain',
},
},
'Row vanished (404)': {
path: '/article',
@@ -6519,11 +6560,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await chooseArticleRowMenuItem(page, 'Publish')
await page.getByText('That article no longer exists').first().waitFor()
},
exception: {
maxPercent: 4.3,
reason: 'Same UTable column-boundary offset as Default, plus the info toast after row removal',
approvedBy: 'Florian Sylvain',
},
},
'Delete: confirm': {
path: '/article',