feat: SEO SPA editor integration
This commit is contained in:
1 parent
eb9876fde3
commit
470584400f
62 files changed
+7471
-58
No files matched your search
@@ -129,6 +129,50 @@ const extraSources: Record<string, Source[]> = {
|
||||
],
|
||||
alias: {},
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Editor: search and sharing',
|
||||
states: [
|
||||
'Collapsed: ready',
|
||||
'Collapsed: suggestions',
|
||||
'Collapsed: hidden',
|
||||
'Open: ready',
|
||||
'Open: suggestions',
|
||||
'Open: checking',
|
||||
'Open: hidden',
|
||||
'Open: other site',
|
||||
'Panel: defaults',
|
||||
'Panel: filled in',
|
||||
'Panel: outside the soft ranges',
|
||||
'Panel: slug edited',
|
||||
'Panel: slug taken',
|
||||
'Panel: cover without alt',
|
||||
'Panel: choose cover',
|
||||
'Panel: social sharing open',
|
||||
'Panel: social customised',
|
||||
'Panel: advanced, invalid canonical',
|
||||
'Panel: hidden from search',
|
||||
'Panel: mobile search preview',
|
||||
'Panel: checking',
|
||||
'Panel: saving',
|
||||
'Panel: save failed',
|
||||
'Saved (toast)',
|
||||
],
|
||||
alias: { 'Saved (toast)': 'Saved' },
|
||||
},
|
||||
{
|
||||
board: officeBoard,
|
||||
group: 'Editor: what the frontend gets',
|
||||
states: [
|
||||
'Formatted',
|
||||
'Raw JSON',
|
||||
'Copied',
|
||||
'Draft article',
|
||||
'Dialog: loading',
|
||||
'Dialog: error',
|
||||
],
|
||||
alias: { 'Dialog: loading': 'Loading', 'Dialog: error': 'Error' },
|
||||
},
|
||||
],
|
||||
profile: [
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user