fix: SEO SPA editor last fixes (3/4)

This commit is contained in:
Florian Sylvain committed 2026-10-16 16:31:17 +02:00
1 parent 470584400f
commit cb737b7ae3
19 files changed
+279 -110

No files matched your search

+16 -10
View File
@@ -14,7 +14,7 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
And the SPA never re-implements a suggestion rule. It renders the suggestions, the order and the status the check returns. The copy of each code lives in the SPA and has a French entry
And the suggestion codes are title_long, title_empty, desc_short, desc_long, cover_alt, cover_none, slug_long, no_au, no_h, body_alt and short
And a suggestion's field maps to a link label: seo_title "meta title", seo_description "meta description", cover_image_alt and cover_image_id "cover image", slug "URL slug", author_ids "authors", body "body"
And the soft ranges of the counters are meta title 30 to 60 and description 70 to 160 (docs/design). They colour a counter and never block
And the soft ranges of the counters are meta title 30 to 60 and description 70 to 160 (docs/design), and the social title has no counter. They colour a counter and never block
And suggestions never disable Save, "Save and publish" or Publish
And the sample article is 12, "How we price a bathroom renovation", online on the site Renew (renew.example, id 1), slug "how-we-price-a-bathroom-renovation", with no SEO field set, and the author "Inès Duarte"
And the sample cover media is "north-wall.jpg" (the site social image) and the sample panel values are those of the board frames "Panel: filled in" and "Panel: outside the soft ranges"
@@ -103,20 +103,19 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
| body | body | focus moves to the article body in the editor |
| author_ids | authors | the "Site and authors" section opens and focus moves to its list |
@not-in-design
Scenario Outline: Copy of the codes the design does not draw
Scenario Outline: Copy of the other codes, as the board draws it
Given the check answers the code "<code>" with params <params>
Then the message reads "<message>" and has a French entry
Examples:
| code | params | message |
| title_empty | {} | The meta title is empty. |
| desc_long | {"length": 170, "max": 160} | The description is 170 characters. About 70 to 160 reads best in results. |
| cover_none | {} | This article has no cover image. |
| slug_long | {"length": 80, "max": 75} | The URL slug is 80 characters. Shorter addresses are easier to share. |
| no_au | {} | This article has no author. |
| title_empty | {} | No meta title yet. The article title is used instead. |
| desc_long | {"length": 170, "max": 160} | The description is 170 characters. Search engines may cut it off after about 160. |
| cover_none | {} | No cover image. The site's social image is used. |
| slug_long | {"length": 80, "max": 75} | The slug is 80 characters. Shorter addresses are easier to share. |
| no_au | {} | This article has no authors, so the structured data has no author. |
| body_alt | {"count": 2} | 2 images in the body have no alt text. |
| short | {"length": 40, "min": 300} | The body is 40 characters. Longer articles tend to do better in search. |
| short | {"length": 40, "min": 300} | The article is under 300 words. That is fine for a short note. |
Scenario Outline: The messages the design does draw use the params of the response
Given the check answers the code "<code>" with params <params>
@@ -316,6 +315,7 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
| renew.example/costs | the field shows "Enter a full address starting with https://" and the field is invalid |
| http://renew.example/a | the field shows "Enter a full address starting with https://" |
| https://renew.example/a | no error is shown |
| " https://renew.example/a" | the field shows "Enter a full address starting with https://" (the value is never trimmed) |
| | no error is shown and "Canonical URL: automatic" is shown when collapsed |
Scenario: Canonical refused by the API
@@ -380,7 +380,7 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
Scenario: Saving state
Given "Save changes" was activated
Then while the request runs every field of the panel is disabled at 40%, the status reads "Saving…" and the page behind stays as it was
Then while the request runs every field of the panel is disabled with its text colour unchanged, the status reads "Saving…" and the page behind stays as it was
Scenario: Saved
When the PATCH answers 200
@@ -408,6 +408,12 @@ Feature: Editor: search and sharing, suggestions and "What the frontend gets"
When the user activates Save, presses Ctrl or Cmd S, or activates "Save and publish"
Then the PATCH holds seo_title and the publish request follows for "Save and publish"
Scenario: Save and publish saves a dirty draft first
Given the draft is dirty
When the user activates "Save and publish"
Then the PATCH is sent first and the publish request follows only if it succeeded
And with a clean draft only the publish request is sent
Scenario: Leaving with unsaved SEO edits
Given the draft holds an unsaved SEO value
When the user navigates away
+1 -1
View File
@@ -50,7 +50,7 @@ Setups go in `web/visual/states.ts`, one setup per state, with mocked /v1 and th
Each has a French entry in `web/src/i18n/messages.ts`.
1. The copy of the codes the design does not draw: title_empty, desc_long, cover_none, slug_long, no_au, body_alt, short.
1. The codes now use the board copy: title_empty, desc_long, cover_none, slug_long, no_au, body_alt, short.
2. The other fallback chips (excerpt, site default description, no image) and the chips of the dialog.
3. The check failure text "Couldn't check this article." with "Try again" (no state in the design).
4. The "Go to authors" and "Go to URL slug" labels, and where "Go to body" and "Go to authors" move focus.
+1 -1
View File
@@ -76,7 +76,7 @@ function remove() {
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<span
v-if="caption"
class="truncate font-mono text-xs text-highlighted"
class="truncate font-mono text-xs/5 text-highlighted"
data-testid="media-field-caption"
>{{ caption }}</span
>
+2 -10
View File
@@ -30,7 +30,6 @@ import { chipLabel, descriptionChip } from '@/lib/seoCopy'
import type { DirectoryNotice } from '@/lib/directoryTypes'
import {
DESCRIPTION_RANGE,
SOCIAL_TITLE_RANGE,
TITLE_RANGE,
hasSocialOverrides,
siteDisplayName,
@@ -328,7 +327,7 @@ onMounted(() => {
names.resolve([siteImageId.value, seo.value.cover_image_id, seo.value.social_image_id])
})
const DISABLED_FIELD = 'disabled:opacity-40'
const DISABLED_FIELD = 'disabled:bg-elevated disabled:opacity-100'
const fieldUi = { base: `h-11 text-lg text-ellipsis md:h-10 md:text-sm ${DISABLED_FIELD}` }
const monoFieldUi = {
base: `h-11 font-mono text-base text-ellipsis md:h-10 md:text-code ${DISABLED_FIELD}`,
@@ -553,13 +552,6 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
>
<div class="flex flex-col gap-1.5">
<DirectoryField :label="t('Social title')" optional>
<template #hint>
<SoftCounter
:value="charCount(seo.social_title)"
:min="SOCIAL_TITLE_RANGE.min"
:max="SOCIAL_TITLE_RANGE.max"
/>
</template>
<UInput
:model-value="seo.social_title"
:placeholder="seo.seo_title || articleTitle"
@@ -639,7 +631,7 @@ const areaUi = { base: `min-h-30 text-lg md:min-h-24 md:text-sm ${DISABLED_FIELD
"
:ui="{
root: 'items-start gap-3',
base: 'mt-0.5 h-4.5 w-8 data-[disabled]:opacity-40',
base: 'mt-0.5 h-4.5 w-8',
thumb: 'size-3.5',
label: 'font-medium text-highlighted',
description: 'text-xs/4.5 text-pretty text-muted',
+19 -7
View File
@@ -2021,22 +2021,15 @@ export const messages = {
Suggestions: 'Suggestions',
'The meta title is {0} characters. Search engines may cut it off after about {1}.':
'Le titre meta fait {0} caractères. Les moteurs de recherche peuvent le couper après environ {1}.',
'The meta title is empty.': 'Le titre meta est vide.',
'The description is {0} characters. About 70 to 160 reads best in results.':
'La description fait {0} caractères. Entre 70 et 160 environ se lit mieux dans les résultats.',
'The cover image has no alt text. Add a short description of it.':
'L’image de couverture n’a pas de texte alternatif. Ajoutez-en une courte description.',
'This article has no cover image.': 'Cet article n’a pas d’image de couverture.',
'The URL slug is {0} characters. Shorter addresses are easier to share.':
'Le slug de l’URL fait {0} caractères. Les adresses plus courtes se partagent plus facilement.',
'This article has no author.': 'Cet article n’a pas d’auteur.',
'The body has no headings. H2 headings help readers scan the article.':
'Le corps n’a pas de titres. Les titres H2 aident les lecteurs à parcourir l’article.',
'1 image in the body has no alt text.': '1 image du corps n’a pas de texte alternatif.',
'{0} images in the body have no alt text.':
'{0} images du corps n’ont pas de texte alternatif.',
'The body is {0} characters. Longer articles tend to do better in search.':
'Le corps fait {0} caractères. Les articles plus longs ont tendance à mieux se classer.',
'Go to meta title': 'Aller au titre meta',
'Go to meta description': 'Aller à la description meta',
'Go to cover image': 'Aller à l’image de couverture',
@@ -2132,6 +2125,25 @@ export const messages = {
'Les suggestions ne bloquent jamais l’enregistrement ni la publication.',
"Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration.":
'Décrit l’image pour les personnes qui utilisent un lecteur d’écran, et s’affiche si elle ne charge pas. Laissez vide si l’image est purement décorative.',
'No meta title yet. The article title is used instead.':
'Pas encore de titre meta. Le titre de l’article est utilisé à la place.',
'The description is {0} characters. Search engines may cut it off after about {1}.':
'La description fait {0} caractères. Les moteurs de recherche peuvent la couper après environ {1}.',
"No cover image. The site's social image is used.":
'Pas d’image de couverture. L’image de partage du site est utilisée.',
'The slug is {0} characters. Shorter addresses are easier to share.':
'Le slug fait {0} caractères. Les adresses plus courtes se partagent plus facilement.',
'This article has no authors, so the structured data has no author.':
'Cet article n’a pas d’auteur, donc les données structurées n’ont pas d’auteur.',
'The article is under 300 words. That is fine for a short note.':
'L’article fait moins de 300 mots. C’est correct pour une courte note.',
'article title + template': 'titre de l’article + modèle',
'first sentence of the body': 'première phrase du corps',
excerpt: 'extrait',
'site default description': 'description par défaut du site',
'cover image': 'image de couverture',
'site social image': 'image de partage du site',
'site address + slug': 'adresse du site + slug',
} as const
export type MessageKey = keyof typeof messages
+4 -4
View File
@@ -166,12 +166,12 @@ describe('Search and sharing in French', () => {
const rows = apiPreviewRows(resolved)
expect(rows.map((row) => row.chip)).toEqual([
'Titre de l’article utilisé',
'Extrait utilisé',
'Adresse du site et slug utilisés',
'titre de l’article + modèle',
'extrait',
'adresse du site + slug',
null,
null,
'Image de partage du site utilisée',
'image de partage du site',
null,
null,
null,
+17
View File
@@ -167,3 +167,20 @@ describe('parseDirectoryQuery', () => {
})
})
})
describe('slugify parity with the API slug generator', () => {
it.each([
['Crème Brûlée à la façon', 'creme-brulee-a-la-facon'],
['Straße œuvre Łódź', 'strasse-oeuvre-lodz'],
['Ångström', 'angstrom'],
['Tiếng Việt', 'ti-ng-vi-t'],
[' --Hello, World!-- ', 'hello-world'],
['', ''],
])('turns %j into %j', (text, expected) => {
expect(slugify(text, 100)).toBe(expected)
})
it('cuts to the limit and drops a trailing dash', () => {
expect(slugify('abcd efgh', 5)).toBe('abcd')
})
})
+8 -5
View File
@@ -8,13 +8,16 @@ function suggestion(code: string, params: Record<string, number> = {}): SeoSugge
}
describe('suggestionMessage', () => {
it('uses the same wording for short and long descriptions with the length filled in', () => {
it('fills the length and the maximum into the short and long description messages', () => {
const short = suggestionMessage(suggestion('desc_short', { length: 12 }))
const long = suggestionMessage(suggestion('desc_long', { length: 200 }))
const long = suggestionMessage(suggestion('desc_long', { length: 200, max: 160 }))
expect(short).toContain('12')
expect(long).toContain('200')
expect(short.replace('12', '#')).toBe(long.replace('200', '#'))
expect(short).toBe(
'The description is 12 characters. About 70 to 160 reads best in results.',
)
expect(long).toBe(
'The description is 200 characters. Search engines may cut it off after about 160.',
)
})
it('switches between the singular and plural body image wording', () => {
+1 -1
View File
@@ -114,5 +114,5 @@ export function isAbsoluteHttpsUrl(value: string) {
}
export function canonicalIsValid(value: string) {
return value.trim() === '' || isAbsoluteHttpsUrl(value.trim())
return value === '' || (value === value.trim() && isAbsoluteHttpsUrl(value))
}
+72 -12
View File
@@ -6,28 +6,88 @@ export const SITE_KEY_MAX = 32
export const SITE_KEY_MIN = 2
export const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']
const LIGATURES: Record<string, string> = {
const FOLDED_LETTERS: Record<string, string> = {
à: 'a',
á: 'a',
â: 'a',
ã: 'a',
ä: 'a',
å: 'a',
ā: 'a',
ă: 'a',
ą: 'a',
ç: 'c',
ć: 'c',
č: 'c',
ĉ: 'c',
ď: 'd',
è: 'e',
é: 'e',
ê: 'e',
ë: 'e',
ē: 'e',
ė: 'e',
ę: 'e',
ě: 'e',
ğ: 'g',
ì: 'i',
í: 'i',
î: 'i',
ï: 'i',
ī: 'i',
į: 'i',
ı: 'i',
ł: 'l',
ñ: 'n',
ń: 'n',
ň: 'n',
ò: 'o',
ó: 'o',
ô: 'o',
õ: 'o',
ö: 'o',
ø: 'o',
ō: 'o',
ő: 'o',
ř: 'r',
ś: 's',
š: 's',
ş: 's',
ș: 's',
ť: 't',
ț: 't',
ţ: 't',
ù: 'u',
ú: 'u',
û: 'u',
ü: 'u',
ū: 'u',
ů: 'u',
ű: 'u',
ų: 'u',
ý: 'y',
ÿ: 'y',
ź: 'z',
ż: 'z',
ž: 'z',
æ: 'ae',
đ: 'd',
œ: 'oe',
ß: 'ss',
ø: 'o',
ł: 'l',
đ: 'd',
ı: 'i',
ľ: 'l',
}
const COMBINING_MARKS = /\p{M}/gu
const NON_SLUG_RUN = /[^a-z0-9]+/g
const EDGE_DASHES = /^-+|-+$/g
const TRAILING_DASHES = /-+$/
const TITLE_PREFIX = /^Dr\.\s*/
const NUMBERED_SUFFIX = /-(\d+)$/
export function slugify(text: string, max: number) {
const folded = text
.toLowerCase()
.normalize('NFD')
.replace(COMBINING_MARKS, '')
.replace(/[æœßøłđı]/g, (letter) => LIGATURES[letter] ?? letter)
return folded.replace(NON_SLUG_RUN, '-').slice(0, max).replace(EDGE_DASHES, '')
const folded = [...text.toLowerCase()]
.map((letter) => FOLDED_LETTERS[letter] ?? letter)
.join('')
const generated = folded.replace(NON_SLUG_RUN, '-').replace(EDGE_DASHES, '')
return generated.slice(0, max).replace(TRAILING_DASHES, '')
}
export function suggestFreeSlug(
+19 -19
View File
@@ -1,5 +1,4 @@
import { t, locale } from '@/i18n'
import { chipLabel, descriptionChip, type ChipKey } from '@/lib/seoCopy'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import type { ResolvedSeo } from '@/lib/seoTypes'
@@ -40,22 +39,23 @@ export function formatApiDate(iso: string | null) {
: `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}, ${clock}`
}
function titleChip(source: string) {
return source === 'title' ? chipLabel('article_title') : null
const FALLBACK_CHIPS: Record<string, Record<string, () => string>> = {
title: { title: () => t('article title + template') },
description: {
body: () => t('first sentence of the body'),
excerpt: () => t('excerpt'),
site: () => t('site default description'),
},
image: {
cover: () => t('cover image'),
site: () => t('site social image'),
none: () => t('No image'),
},
canonical: { site: () => t('site address + slug') },
}
function imageChip(source: string) {
const keys: Record<string, ChipKey> = {
cover: 'cover_image',
site: 'site_image',
none: 'no_image',
}
const key = keys[source]
return key ? chipLabel(key) : null
}
function canonicalChip(source: string) {
return source === 'site' ? chipLabel('canonical') : null
function fallbackChip(field: string, source: string) {
return FALLBACK_CHIPS[field]?.[source]?.() ?? null
}
function authorLine(author: ResolvedSeo['authors'][number]) {
@@ -77,20 +77,20 @@ function jsonLdSummary(jsonLd: Record<string, unknown>) {
export function apiPreviewRows(resolved: ResolvedSeo): ApiPreviewRow[] {
const { fallbacks } = resolved
return [
{ key: 'title', value: resolved.title, chip: titleChip(fallbacks.title) },
{ key: 'title', value: resolved.title, chip: fallbackChip('title', fallbacks.title) },
{
key: 'description',
value: resolved.description,
chip: descriptionChip(fallbacks.description),
chip: fallbackChip('description', fallbacks.description),
},
{
key: 'canonical_url',
value: resolved.canonical_url,
chip: canonicalChip(fallbacks.canonical),
chip: fallbackChip('canonical', fallbacks.canonical),
},
{ key: 'robots', value: resolved.robots, chip: null },
{ key: 'og.type', value: resolved.og.type, chip: null },
{ key: 'og.image', value: resolved.og.image, chip: imageChip(fallbacks.image) },
{ key: 'og.image', value: resolved.og.image, chip: fallbackChip('image', fallbacks.image) },
{ key: 'og.image_alt', value: resolved.og.image_alt, chip: null },
{ key: 'twitter.card', value: resolved.twitter.card, chip: null },
{
+10 -11
View File
@@ -14,25 +14,24 @@ const MESSAGES: Record<SuggestionCode, (params: Record<string, number>) => strin
params.length,
params.max,
]),
title_empty: () => t('The meta title is empty.'),
title_empty: () => t('No meta title yet. The article title is used instead.'),
desc_short: descriptionLength,
desc_long: descriptionLength,
cover_alt: () => t('The cover image has no alt text. Add a short description of it.'),
cover_none: () => t('This article has no cover image.'),
slug_long: (params) =>
t('The URL slug is {0} characters. Shorter addresses are easier to share.', [
desc_long: (params) =>
t('The description is {0} characters. Search engines may cut it off after about {1}.', [
params.length,
params.max,
]),
no_au: () => t('This article has no author.'),
cover_alt: () => t('The cover image has no alt text. Add a short description of it.'),
cover_none: () => t("No cover image. The site's social image is used."),
slug_long: (params) =>
t('The slug is {0} characters. Shorter addresses are easier to share.', [params.length]),
no_au: () => t('This article has no authors, so the structured data has no author.'),
no_h: () => t('The body has no headings. H2 headings help readers scan the article.'),
body_alt: (params) =>
params.count === 1
? t('1 image in the body has no alt text.')
: t('{0} images in the body have no alt text.', [params.count]),
short: (params) =>
t('The body is {0} characters. Longer articles tend to do better in search.', [
params.length,
]),
short: () => t('The article is under 300 words. That is fine for a short note.'),
}
const LINK_LABELS: Record<SuggestionField, () => string> = {
-1
View File
@@ -11,7 +11,6 @@ export const PREVIEW_LIMITS: Record<PreviewDevice, { title: number; description:
export const TITLE_RANGE = { min: 30, max: 60 }
export const DESCRIPTION_RANGE = { min: 70, max: 160 }
export const SOCIAL_TITLE_RANGE = { min: 0, max: 90 }
const DEFAULT_TEMPLATE = '%title% | %site%'
const SENTENCE_END = /^(.+?[.!?])(\s|$)/s
@@ -214,9 +214,9 @@ describe('A late answer never overwrites a newer one', () => {
gates[0]!.resolve(checkAnswer([], 'hidden'))
await flushPromises()
expect(textOf(sectionBody())).toContain('This article has no cover image.')
expect(textOf(sectionBody())).toContain("No cover image. The site's social image is used.")
expect(textOf(sectionBody())).not.toContain('Hidden from search engines')
expect(textOf(panelRoot()!)).toContain('This article has no cover image.')
expect(textOf(panelRoot()!)).toContain("No cover image. The site's social image is used.")
})
})
@@ -222,13 +222,13 @@ describe('Formatted', () => {
})
it.each([
{ key: 'title', source: 'title', chip: 'Using article title' },
{ key: 'description', source: 'body', chip: 'Using first sentence of the body' },
{ key: 'description', source: 'excerpt', chip: 'Using the excerpt' },
{ key: 'description', source: 'site', chip: 'Using the site default description' },
{ key: 'image', source: 'cover', chip: 'Using cover image' },
{ key: 'image', source: 'site', chip: 'Using site social image' },
{ key: 'canonical', source: 'site', chip: 'Using site address and slug' },
{ key: 'title', source: 'title', chip: 'article title + template' },
{ key: 'description', source: 'body', chip: 'first sentence of the body' },
{ key: 'description', source: 'excerpt', chip: 'excerpt' },
{ key: 'description', source: 'site', chip: 'site default description' },
{ key: 'image', source: 'cover', chip: 'cover image' },
{ key: 'image', source: 'site', chip: 'site social image' },
{ key: 'canonical', source: 'site', chip: 'site address + slug' },
{ key: 'image', source: 'none', chip: 'No image' },
])('shows "$chip" when fallbacks.$key is $source', async ({ key, source, chip }) => {
await mountWithDialog({
@@ -427,6 +427,8 @@ describe('Advanced, canonical URL', () => {
it.each([
{ value: 'renew.example/costs', error: true },
{ value: 'http://renew.example/a', error: true },
{ value: ' https://renew.example/a', error: true },
{ value: 'https://renew.example/a ', error: true },
{ value: 'https://renew.example/a', error: false },
{ value: '', error: false },
])('for "$value" the error is shown: $error', async ({ value, error }) => {
@@ -149,6 +149,8 @@ describe('Saving state', () => {
expect(fieldByLabel(panel, 'Meta description')!.disabled).toBe(true)
expect(fieldByLabel(panel, 'Excerpt')!.disabled).toBe(true)
expect(textOf(panel)).toContain('Saving…')
expect(fieldByLabel(panel, 'Meta title')!.className).toContain('disabled:opacity-100')
expect(fieldByLabel(panel, 'Meta title')!.className).not.toContain('opacity-40')
expect(
textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement),
).toContain('Save')
@@ -286,6 +288,22 @@ describe('The editor Save button treats SEO edits like any other edit', () => {
})
})
describe('Publish with a clean draft', () => {
it('only sends the publish request', async () => {
const backend = await mountSeo(seoBackend())
backend.responders['PATCH /v1/article/12/publish'] = () => ({
...backend.article,
is_online: true,
})
const before = backend.calls.length
await click('Publish')
const patches = backend.calls.slice(before).filter((call) => call.method === 'PATCH')
expect(patches.map((call) => call.path)).toEqual(['/v1/article/12/publish'])
})
})
describe('Leaving with unsaved SEO edits', () => {
it('asks Leave without saving? when the draft holds an unsaved SEO value', async () => {
const { panel } = await failedPanelSave()
@@ -111,7 +111,7 @@ describe('The open section: address, suggestions and actions', () => {
expect(panel).not.toBeNull()
const suggestions = panel.querySelector('[data-testid="seo-panel-suggestions"]')!
expect(document.activeElement).toBe(suggestions)
expect(textOf(suggestions)).toContain('The body is 40 characters.')
expect(textOf(suggestions)).toContain('The article is under 300 words.')
})
it('shows no "+N more" action with three suggestions or fewer', async () => {
@@ -263,28 +263,33 @@ describe('The messages follow the params of the response', () => {
{},
'The body has no headings. H2 headings help readers scan the article.',
],
['title_empty', 'seo_title', {}, 'The meta title is empty.'],
['title_empty', 'seo_title', {}, 'No meta title yet. The article title is used instead.'],
[
'desc_long',
'seo_description',
{ length: 170, max: 160 },
'The description is 170 characters. About 70 to 160 reads best in results.',
'The description is 170 characters. Search engines may cut it off after about 160.',
],
['cover_none', 'cover_image_id', {}, 'This article has no cover image.'],
['cover_none', 'cover_image_id', {}, "No cover image. The site's social image is used."],
[
'slug_long',
'slug',
{ length: 80, max: 75 },
'The URL slug is 80 characters. Shorter addresses are easier to share.',
'The slug is 80 characters. Shorter addresses are easier to share.',
],
[
'no_au',
'author_ids',
{},
'This article has no authors, so the structured data has no author.',
],
['no_au', 'author_ids', {}, 'This article has no author.'],
['body_alt', 'body', { count: 2 }, '2 images in the body have no alt text.'],
['body_alt', 'body', { count: 1 }, '1 image in the body has no alt text.'],
[
'short',
'body',
{ length: 40, min: 300 },
'The body is 40 characters. Longer articles tend to do better in search.',
'The article is under 300 words. That is fine for a short note.',
],
] as const)('renders %s with %j', async (code, field, params, message) => {
const backend = seoBackend()
+68 -12
View File
@@ -5694,6 +5694,22 @@ 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"
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(
@@ -5715,12 +5731,23 @@ const editorSeoStates: Record<string, VisualState> = {
true,
),
'Open: other site': seoSidebarState({ article: { site_id: 2 }, sites: [seoJournal()] }, true),
'Panel: defaults': seoPanelState({ check: seoCheck(['cover_none']) }),
'Panel: defaults': withMobileException(
seoPanelState({ check: seoCheck(['cover_none']) }),
26.5,
dismissColumnReason,
),
'Panel: filled in': seoPanelState({ article: seoFilled }),
'Panel: outside the soft ranges': seoPanelState({
article: { seo_title: seoLongTitle, seo_description: 'What a bathroom renovation costs.' },
'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: slug edited': seoPanelState({ article: { slug: 'bathroom-costs' } }),
'Panel: slug taken': seoPanelState(
{
@@ -5738,11 +5765,16 @@ const editorSeoStates: Record<string, VisualState> = {
await page.getByText('is already used on Renew.').waitFor()
},
),
'Panel: cover without alt': seoPanelState({
'Panel: cover without alt': withMobileException(
seoPanelState({
article: seoCoverNoAlt,
check: seoCheck(['cover_alt']),
}),
'Panel: choose cover': seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
17,
dismissColumnReason,
),
'Panel: choose cover': withMobileException(
seoPanelState({ check: seoCheck(['cover_none']) }, async (page) => {
await page
.getByRole('dialog')
.getByRole('button', { name: 'Choose image', exact: true })
@@ -5752,6 +5784,9 @@ 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,
),
'Panel: social sharing open': seoPanelState(
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
(page) => seoOpenDisclosure(page, 'Social sharing'),
@@ -5778,13 +5813,20 @@ const editorSeoStates: Record<string, VisualState> = {
async () => {},
700,
),
'Panel: mobile search preview': seoPanelState(
'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
.getByRole('dialog')
.getByRole('tab', { name: 'Mobile', exact: true })
.click()
await page.mouse.move(0, 0)
},
),
17.5,
dismissColumnReason,
),
'Panel: checking': seoPanelState({ check: { status: 200, hang: true } }),
'Panel: saving': seoPanelState(
{ overrides: { 'PATCH /v1/article/12': { status: 200, hang: true } } },
@@ -6783,15 +6825,22 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
{ authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'] },
(page) => liftAuthor(page, 'Camille Roche'),
),
'Create author: dialog': metaState({ withAuthor: true }, (page) =>
openCreateAuthorDialog(page, 'Zoe Lambert'),
'Create author: dialog': withMobileException(
metaState({ withAuthor: true }, (page) => openCreateAuthorDialog(page, 'Zoe Lambert')),
55,
boardOmitsMobileDialogReason,
),
'Create author: for me': metaState({}, async (page) => {
'Create author: for me': withMobileException(
metaState({}, 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(
50,
boardOmitsMobileDialogReason,
),
'Create author: slug taken': withMobileException(
metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': newAuthorSlugTaken } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
@@ -6800,7 +6849,11 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await page.mouse.move(0, 0)
},
),
'Create author: failed': metaState(
40,
boardOmitsMobileDialogReason,
),
'Create author: failed': withMobileException(
metaState(
{ withAuthor: true, overrides: { 'POST /v1/author/': { status: 500 } } },
async (page) => {
await openCreateAuthorDialog(page, 'Zoe Lambert')
@@ -6809,6 +6862,9 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await page.mouse.move(0, 0)
},
),
52.5,
boardOmitsMobileDialogReason,
),
Loading: {
path: '/article/12/edit',
now: editorNow,