feat: SEO SPA Sites, Articles and Integration (4/4)
This commit is contained in:
1 parent
cb737b7ae3
commit
02fb8751fd
47 files changed
+3218
-227
No files matched your search
+155
-119
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user