feat: SEO SPA editor integration

This commit is contained in:
Florian Sylvain committed 2026-10-16 03:06:57 +02:00
1 parent eb9876fde3
commit 470584400f
62 files changed
+7471 -58

No files matched your search

+468
View File
@@ -309,6 +309,8 @@ function updateApi(
const editorNow = '2026-09-26T11:02:00Z'
const seoReady = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
const bathroomBody =
"<p>Every bathroom is different, but the way we price them isn't. We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>" +
'<h2>What goes into the price</h2>' +
@@ -371,6 +373,7 @@ function editorApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiM
'GET /v1/media/': { status: 200, body: emptyLibrary },
'GET /v1/site/': boardSiteList(),
'GET /v1/author/': boardAuthorList(),
'POST /v1/article/12/seo/check': { status: 200, body: seoReady },
...overrides,
}
}
@@ -5419,6 +5422,470 @@ const authorsStates: Record<string, VisualState> = {
},
}
const seoDescription =
'A bathroom renovation is priced by room size, plumbing changes and finish level.'
const seoLongTitle = 'How we price a bathroom renovation: surveys, plumbing, tiling and finishes'
const seoCoverAlt = 'A tiled bathroom wall during renovation'
const seoFilledTitle = 'What a bathroom renovation costs'
const seoFilledDescription =
'A bathroom renovation is priced by room size, plumbing changes and finish level. Here is how we build a quote.'
const seoFilledExcerpt = 'How we price a bathroom renovation, from the survey to the final invoice.'
const seoBody =
`<p>${seoDescription} We start with a visit, measure everything, and <strong>write down what stays</strong>.</p>` +
'<h2>What goes into the price</h2>' +
'<ul><li>Stripping out and making good</li><li>Plumbing moves, if any</li><li>Tiles, fittings and a day for silicone</li></ul>' +
'<p>We don’t add a margin on materials you choose yourself.</p>'
function seoSuggestion(code: string, field: string, params: Record<string, number> = {}) {
return { code, field, params }
}
const seoSuggestions: Record<string, ReturnType<typeof seoSuggestion>> = {
title_long: seoSuggestion('title_long', 'seo_title', { length: 74, max: 60 }),
desc_short: seoSuggestion('desc_short', 'seo_description', { length: 41, min: 70 }),
cover_alt: seoSuggestion('cover_alt', 'cover_image_alt'),
cover_none: seoSuggestion('cover_none', 'cover_image_id'),
no_h: seoSuggestion('no_h', 'body'),
short: seoSuggestion('short', 'body', { length: 120, min: 300 }),
}
function seoCheck(
codes: string[],
status = codes.length ? 'suggestions' : 'ready',
slug: { available: boolean; suggestion: string | null } = { available: true, suggestion: null },
): ApiMock {
return {
status: 200,
body: { status, suggestions: codes.map((code) => seoSuggestions[code]), slug },
}
}
const seoTakenSlug = { available: false, suggestion: 'bathroom-costs-2' }
function seoSite(overrides: Record<string, unknown> = {}) {
return {
...directorySites[0]!,
default_social_image_id: 1,
site_name: 'Renew',
title_template: '%title% | %site%',
default_description: '',
...overrides,
}
}
function seoJournal() {
return {
...directorySites[1]!,
site_name: 'Journal',
title_template: '%title% | %site%',
default_description: '',
}
}
function seoArticle(overrides: Record<string, unknown> = {}, body = seoBody) {
return {
...editorArticle,
body,
slug: 'how-we-price-a-bathroom-renovation',
excerpt: '',
seo_title: '',
seo_description: '',
cover_image_id: null,
cover_image_alt: '',
social_title: '',
social_description: '',
social_image_id: null,
canonical_url: '',
noindex: false,
seo_dismissed: [],
...overrides,
}
}
const seoApiResponse = {
title: 'How we price a bathroom renovation | Renew',
description: seoDescription,
canonical_url: 'https://renew.example/how-we-price-a-bathroom-renovation',
robots: 'index, follow',
og: {
type: 'article',
title: 'How we price a bathroom renovation | Renew',
description: seoDescription,
url: 'https://renew.example/how-we-price-a-bathroom-renovation',
site_name: 'Renew',
image: 'https://renew.example/media/north-wall.jpg',
image_alt: seoCoverAlt,
locale: 'en',
},
twitter: {
card: 'summary_large_image',
title: 'How we price a bathroom renovation | Renew',
description: seoDescription,
image: 'https://renew.example/media/north-wall.jpg',
image_alt: seoCoverAlt,
},
image: {
url: 'https://renew.example/media/north-wall.jpg',
alt: seoCoverAlt,
source: 'cover_image',
},
authors: [
{
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
same_as: ['https://www.linkedin.com/in/ines-duarte'],
},
],
published_at: '2026-03-12T09:14:00Z',
modified_at: '2026-10-15T11:02:00Z',
fallbacks: { title: 'title', description: 'body', image: 'cover', canonical: 'site' },
json_ld: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'How we price a bathroom renovation',
description: seoDescription,
image: ['https://renew.example/media/north-wall.jpg'],
author: [
{
'@type': 'Person',
name: 'Inès Duarte',
url: 'https://ines-duarte.example',
sameAs: ['https://www.linkedin.com/in/ines-duarte'],
},
],
datePublished: '2026-03-12T09:14:00Z',
dateModified: '2026-10-15T11:02:00Z',
mainEntityOfPage: 'https://renew.example/how-we-price-a-bathroom-renovation',
},
}
interface SeoOptions {
article?: Record<string, unknown>
body?: string
check?: ApiMock
sites?: unknown[]
overrides?: Record<string, ApiMock>
library?: boolean
}
function seoApi(options: SeoOptions = {}): Record<string, ApiMock> {
return editorApi({
'GET /v1/article/12': {
status: 200,
body: seoArticle(options.article, options.body),
},
'GET /v1/site/': { status: 200, body: options.sites ?? [seoSite()] },
'POST /v1/article/12/seo/check': options.check ?? { status: 200, body: seoReady },
'GET /v1/media/': { status: 200, bodyFor: avatarPickerList },
'GET /v1/image-variants': { status: 403 },
'GET /v1/article/12/seo': { status: 200, body: seoApiResponse },
...(options.overrides ?? {}),
})
}
async function seoOpenSection(page: Page) {
if (isNarrow(page)) {
await page.getByRole('button', { name: 'Details', exact: true }).click()
await page.getByRole('dialog').waitFor()
}
const toggle = page.locator('[data-testid="seo-toggle"]:visible')
await toggle.waitFor()
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click()
}
await page.locator('[data-testid="seo-body"]:visible').waitFor()
await page.mouse.move(0, 0)
}
async function seoOpenPanel(page: Page) {
await seoOpenSection(page)
await page.locator('[data-testid="seo-edit"]:visible').click()
await page.locator('[data-testid="seo-panel-suggestions"]').waitFor()
await page.waitForTimeout(250)
await page.mouse.move(0, 0)
}
async function seoScrollPanel(page: Page, top: number) {
await page.evaluate((offset) => {
document.querySelectorAll('[role="dialog"] [data-slot="body"]').forEach((body) => {
body.scrollTop = offset
})
}, top)
await page.mouse.move(0, 0)
}
async function seoOpenDisclosure(page: Page, title: string) {
await page.locator('[data-testid="seo-disclosure-toggle"]', { hasText: title }).first().click()
await page.mouse.move(0, 0)
}
function seoSidebarState(options: SeoOptions, open: boolean): VisualState {
return {
path: '/article/12/edit',
now: editorNow,
api: seoApi(options),
steps: async (page) => {
if (open) {
await seoOpenSection(page)
return
}
if (isNarrow(page)) {
await page.getByRole('button', { name: 'Details', exact: true }).click()
await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
}
},
}
}
function seoPanelState(
options: SeoOptions,
steps: (page: Page) => Promise<void> = async () => {},
scrollY = 0,
): VisualState {
return {
path: '/article/12/edit',
now: editorNow,
api: seoApi(options),
steps: async (page) => {
await seoOpenPanel(page)
await steps(page)
await seoScrollPanel(page, scrollY)
},
}
}
async function seoTypeInPanel(page: Page, label: string, value: string) {
await page.getByRole('dialog').getByLabel(label).first().fill(value)
}
const seoFilled = {
seo_title: seoFilledTitle,
seo_description: seoFilledDescription,
excerpt: seoFilledExcerpt,
cover_image_id: 1,
cover_image_alt: seoCoverAlt,
}
const seoCoverNoAlt = { cover_image_id: 1, cover_image_alt: '' }
const seoSocialCustom = {
cover_image_id: 1,
cover_image_alt: seoCoverAlt,
social_title: 'Bathroom costs, explained',
social_description: 'A plain guide to what drives the price.',
social_image_id: 2,
}
async function seoOpenApiDialog(page: Page) {
await seoOpenSection(page)
await page.locator('[data-testid="seo-preview"]:visible').click()
await page.locator('[data-testid="api-preview"]').waitFor()
await page.waitForTimeout(250)
await page.mouse.move(0, 0)
}
async function seoSaveFromPanel(page: Page) {
await seoTypeInPanel(page, 'Meta title', seoFilledTitle)
await page
.getByRole('dialog')
.getByRole('button', { name: 'Save changes', exact: true })
.click()
}
const editorSeoStates: Record<string, VisualState> = {
'Collapsed: ready': seoSidebarState({}, false),
'Collapsed: suggestions': seoSidebarState(
{ check: seoCheck(['title_long', 'cover_alt']) },
false,
),
'Collapsed: hidden': seoSidebarState(
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
false,
),
'Open: ready': seoSidebarState({}, true),
'Open: suggestions': seoSidebarState(
{ check: seoCheck(['title_long', 'cover_alt', 'no_h', 'short']) },
true,
),
'Open: checking': seoSidebarState({ check: { status: 200, hang: true } }, true),
'Open: hidden': seoSidebarState(
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
true,
),
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }),
'Panel: filled in': seoPanelState({ article: seoFilled }),
'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(
{
check: {
status: 200,
bodyFor: (_url, call) =>
seoCheck([], 'ready', call >= 3 ? seoTakenSlug : undefined).body,
},
},
async (page) => {
await page.getByRole('dialog').getByRole('button', { name: 'Edit slug' }).click()
const slug = page.getByRole('dialog').getByLabel('URL slug')
await slug.fill('bathroom-costs')
await slug.blur()
await page.getByText('is already used on Renew.').waitFor()
},
),
'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 })
.first()
.click()
await waitForPickerTiles(page)
await page.locator('[data-picker-tile][aria-label^="kitchen-after.webp,"]').click()
await page.mouse.move(0, 0)
}),
'Panel: social sharing open': seoPanelState(
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
(page) => seoOpenDisclosure(page, 'Social sharing'),
560,
),
'Panel: social customised': seoPanelState(
{ article: seoSocialCustom },
(page) => seoOpenDisclosure(page, 'Social sharing'),
560,
),
'Panel: advanced, invalid canonical': seoPanelState(
{},
async (page) => {
await seoOpenDisclosure(page, 'Advanced')
const canonical = page.getByRole('dialog').getByLabel('Canonical URL')
await canonical.fill('renew.example/costs')
await canonical.blur()
await page.getByText('Enter a full address starting with https://').waitFor()
},
640,
),
'Panel: hidden from search': seoPanelState(
{ article: { noindex: true }, check: seoCheck([], 'hidden') },
async () => {},
700,
),
'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(
{ overrides: { 'PATCH /v1/article/12': { status: 200, hang: true } } },
async (page) => {
await seoSaveFromPanel(page)
await page.getByRole('dialog').getByText('Saving…').first().waitFor()
await page.mouse.move(0, 0)
},
),
'Panel: save failed': seoPanelState(
{ overrides: { 'PATCH /v1/article/12': { status: 500 } } },
async (page) => {
await seoSaveFromPanel(page)
await page.getByText("Couldn't save search and sharing.").waitFor()
await page.mouse.move(0, 0)
},
),
'Saved (toast)': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({ overrides: { 'PATCH /v1/article/12': { status: 200, body: seoArticle() } } }),
steps: async (page) => {
await seoOpenPanel(page)
await seoSaveFromPanel(page)
await page.getByText('Search and sharing saved').waitFor()
await page.mouse.move(0, 0)
},
},
Formatted: {
path: '/article/12/edit',
now: editorNow,
api: seoApi({ article: { is_online: true } }),
steps: seoOpenApiDialog,
},
'Raw JSON': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({ article: { is_online: true } }),
steps: async (page) => {
await seoOpenApiDialog(page)
await page.getByRole('tab', { name: 'Raw JSON', exact: true }).click({ force: true })
await page.mouse.move(0, 0)
},
},
Copied: {
path: '/article/12/edit',
origin: integrationOrigin,
now: editorNow,
api: seoApi({ article: { is_online: true } }),
steps: async (page) => {
await page.evaluate(() => {
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: async () => undefined },
configurable: true,
})
})
await seoOpenApiDialog(page)
await page.getByRole('tab', { name: 'Raw JSON', exact: true }).click({ force: true })
await page
.getByRole('button', { name: 'Copy JSON', exact: true })
.click({ force: true })
await page.getByRole('button', { name: 'Copied', exact: true }).waitFor()
await page.mouse.move(0, 0)
},
},
'Draft article': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({ article: { is_online: false } }),
steps: seoOpenApiDialog,
},
'Dialog: loading': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({
article: { is_online: true },
overrides: { 'GET /v1/article/12/seo': { status: 200, hang: true } },
}),
steps: async (page) => {
await seoOpenSection(page)
await page.locator('[data-testid="seo-preview"]:visible').click()
await page.locator('[data-testid="api-preview-loading"]').waitFor()
await page.mouse.move(0, 0)
},
},
'Dialog: error': {
path: '/article/12/edit',
now: editorNow,
api: seoApi({
article: { is_online: true },
overrides: { 'GET /v1/article/12/seo': { status: 500 } },
}),
steps: async (page) => {
await seoOpenSection(page)
await page.locator('[data-testid="seo-preview"]:visible').click()
await page.locator('[data-testid="api-preview-error"]').waitFor()
await page.mouse.move(0, 0)
},
},
}
export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates,
authors: authorsStates,
@@ -6273,6 +6740,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
},
editor: {
...editorSeoStates,
'Open: one site, one author': metaState({}, async () => {}),
'Open: several sites': metaState({ sites: 4, siteId: 2 }, async () => {}),
'Site select open': metaState({ sites: 4, siteId: 2 }, openSiteSelect),