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
+316 -147

No files matched your search

+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()
+103 -47
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.' },
check: seoCheck(['title_long', 'desc_short']),
}),
'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,20 +5765,28 @@ const editorSeoStates: Record<string, VisualState> = {
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: cover without alt': withMobileException(
seoPanelState({
article: seoCoverNoAlt,
check: seoCheck(['cover_alt']),
}),
17,
dismissColumnReason,
),
'Panel: choose cover': withMobileException(
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)
}),
29,
dismissColumnReason,
),
'Panel: social sharing open': seoPanelState(
{ article: { cover_image_id: 1, cover_image_alt: seoCoverAlt } },
(page) => seoOpenDisclosure(page, 'Social sharing'),
@@ -5778,12 +5813,19 @@ const editorSeoStates: Record<string, VisualState> = {
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: 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: checking': seoPanelState({ check: { status: 200, hang: true } }),
'Panel: saving': seoPanelState(
@@ -6783,31 +6825,45 @@ 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) => {
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()
'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)
},
}),
50,
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)
},
'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,
),
Loading: {
path: '/article/12/edit',