feat: public API, SEO and existing surfaces
This commit is contained in:
1 parent
45fbbc33b1
commit
c390fcfe8d
103 files changed
+4476
-231
No files matched your search
@@ -112,7 +112,9 @@ describe('Existing screens are unaffected', () => {
|
||||
'localization-list',
|
||||
'localization-editor',
|
||||
'localization-conflicts-moves',
|
||||
'localization-new-article',
|
||||
'localization-new-article',
|
||||
'localization-metadata-api',
|
||||
'localization-surfaces',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { Page } from 'playwright'
|
||||
import { accessFor } from '../src/test/access'
|
||||
import { siteLanguagesBody } from '../src/test/storyLists'
|
||||
import {
|
||||
articleBody,
|
||||
fails,
|
||||
narrow,
|
||||
ok,
|
||||
press,
|
||||
settle,
|
||||
site,
|
||||
state,
|
||||
type Setup,
|
||||
} from './editorLocalizationStates'
|
||||
import type { ApiMock, VisualState } from './states'
|
||||
|
||||
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
|
||||
const FRENCH_SLUG = 'comment-nous-chiffrons-une-renovation'
|
||||
const FRENCH_TITLE = 'Comment nous chiffrons une rénovation de salle de bain'
|
||||
|
||||
const alternates = [
|
||||
{
|
||||
id: 10,
|
||||
lang: 'en',
|
||||
story_id: 5,
|
||||
slug: 'how-we-price-a-bathroom-renovation',
|
||||
url: 'https://journal.example.com/en/how-we-price-a-bathroom-renovation',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
lang: 'de',
|
||||
story_id: 5,
|
||||
slug: 'so-kalkulieren-wir-eine-badsanierung',
|
||||
url: 'https://journal.example.com/de/so-kalkulieren-wir-eine-badsanierung',
|
||||
},
|
||||
]
|
||||
|
||||
function resolved(locale: string, canonical = `https://journal.example.com/fr/${FRENCH_SLUG}`) {
|
||||
return {
|
||||
title: `${FRENCH_TITLE} | Journal`,
|
||||
description: '',
|
||||
canonical_url: canonical,
|
||||
robots: 'index, follow',
|
||||
og: {
|
||||
type: 'article',
|
||||
title: '',
|
||||
description: '',
|
||||
image: '',
|
||||
image_alt: '',
|
||||
url: canonical,
|
||||
site_name: 'Journal',
|
||||
locale,
|
||||
locale_alternates: [],
|
||||
},
|
||||
twitter: { card: 'summary' },
|
||||
image: { url: '', alt: '', source: 'none' },
|
||||
authors: [],
|
||||
published_at: null,
|
||||
modified_at: '2026-10-14T09:12:00Z',
|
||||
fallbacks: {
|
||||
title: 'title',
|
||||
description: 'none',
|
||||
image: 'none',
|
||||
canonical: 'pattern',
|
||||
site_text_skipped: true,
|
||||
},
|
||||
json_ld: {},
|
||||
lang: 'fr',
|
||||
alternates: canonical.includes('/fr/') ? alternates : [],
|
||||
}
|
||||
}
|
||||
|
||||
function languages(social: boolean) {
|
||||
const body = siteLanguagesBody('journal')
|
||||
return {
|
||||
...body,
|
||||
languages: body.languages.map((row) =>
|
||||
row.lang === 'fr'
|
||||
? {
|
||||
...row,
|
||||
social_locale: social ? 'fr_FR' : null,
|
||||
social_locale_state: social ? 'set' : 'not_set',
|
||||
}
|
||||
: row,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function frenchArticle(setup: Setup) {
|
||||
return { ...articleBody(setup, 'fr'), slug: FRENCH_SLUG, title: FRENCH_TITLE }
|
||||
}
|
||||
|
||||
export async function openSeoPanel(page: Page) {
|
||||
if (narrow(page)) {
|
||||
await page
|
||||
.getByRole('button', { name: /^Details/ })
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
}
|
||||
const body = page.locator('[data-testid="seo-body"]:visible')
|
||||
if (!(await body.count())) {
|
||||
await page.locator('[data-testid="seo-toggle"]:visible').first().click()
|
||||
}
|
||||
await page.locator('[data-testid="seo-edit"]:visible').first().click()
|
||||
await page.locator('[data-testid="seo-language-cards"]').waitFor()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
export function seoState(
|
||||
setup: Setup,
|
||||
pattern: string | null,
|
||||
locale: string,
|
||||
social: boolean,
|
||||
steps: (page: Page) => Promise<void> = openSeoPanel,
|
||||
): VisualState {
|
||||
const canonical = pattern
|
||||
? `https://journal.example.com/fr/${FRENCH_SLUG}`
|
||||
: `https://journal.example.com/${FRENCH_SLUG}`
|
||||
const overrides: Record<string, ApiMock> = {
|
||||
'GET /v1/site/': ok([{ ...site, public_url_pattern: pattern }]),
|
||||
'GET /v1/auth/me': {
|
||||
status: 200,
|
||||
body: {
|
||||
id: 7,
|
||||
is_verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
display_name: 'Marta Alves',
|
||||
access: accessFor('owner'),
|
||||
},
|
||||
},
|
||||
'GET /v1/article/12': ok(frenchArticle(setup)),
|
||||
'GET /v1/article/12/seo': ok(resolved(locale, canonical)),
|
||||
'POST /v1/article/12/seo/check': ok({
|
||||
status: 'ready',
|
||||
suggestions: [],
|
||||
slug: { available: true, suggestion: null },
|
||||
}),
|
||||
}
|
||||
return state({ languages: languages(social), ...setup }, steps, overrides)
|
||||
}
|
||||
|
||||
async function openEditSlug(page: Page) {
|
||||
await openSeoPanel(page)
|
||||
await press(page, 'Edit slug')
|
||||
await page.getByRole('dialog').last().waitFor()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
export const metadataApiStates: Record<string, VisualState> = {
|
||||
'loc.seo.noFallback': seoState({ suggestion: false }, PATTERN, 'fr_FR', true),
|
||||
'loc.slug.published': seoState(
|
||||
{ suggestion: false, online: { fr: true } },
|
||||
PATTERN,
|
||||
'fr_FR',
|
||||
true,
|
||||
openEditSlug,
|
||||
),
|
||||
'loc.url.unset': seoState({ suggestion: false }, null, 'fr_FR', true),
|
||||
'loc.l10.noSocial': seoState({ suggestion: false }, PATTERN, '', false),
|
||||
'loc.l10.api': {
|
||||
path: '/integration',
|
||||
api: {
|
||||
'GET /v1/auth/me': {
|
||||
status: 200,
|
||||
body: {
|
||||
id: 7,
|
||||
is_verified: true,
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
display_name: 'Marta Alves',
|
||||
access: accessFor('owner'),
|
||||
},
|
||||
},
|
||||
'GET /v1/image-variants': fails(500),
|
||||
},
|
||||
steps: async (page: Page) => {
|
||||
await page.locator('[data-testid="integration-languages"]').scrollIntoViewIfNeeded()
|
||||
await settle(page)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface LocalizationScreen {
|
||||
const BOARD_LINE = /^Board:\s*([^.]+?)\./m
|
||||
const GROUP_HEADING = /^## (L\d+[a-z]*) (.+)$/
|
||||
const STATE_ROW = /^\| (loc\.[A-Za-z0-9.]+) \| /
|
||||
const SELECTOR = /LocaleFrame\{s:\{v:"([^"]+)",x:"([^"]+)"\}\}/
|
||||
const SELECTOR = /LocaleFrame\{s:\{v:"([^"]+)",x:"([^"]*)"\}\}/
|
||||
|
||||
export function parseSizes(cell: string) {
|
||||
return cell
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Page } from 'playwright'
|
||||
import { openOverflow, settle, state, type Setup } from './editorLocalizationStates'
|
||||
import { seoState } from './localizationMetadataStates'
|
||||
import type { VisualState } from './states'
|
||||
|
||||
async function openDetails(page: Page) {
|
||||
const width = page.viewportSize()?.width ?? 0
|
||||
if (width < 1024) {
|
||||
await page
|
||||
.getByRole('button', { name: /^Details/ })
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
}
|
||||
await page
|
||||
.getByText('Public API URL', { exact: true })
|
||||
.locator('visible=true')
|
||||
.first()
|
||||
.waitFor()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function openMenu(page: Page) {
|
||||
await openOverflow(page)
|
||||
}
|
||||
|
||||
async function copyApiUrl(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: async () => undefined },
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
await openDetails(page)
|
||||
await page.getByRole('button', { name: 'Copy the public API URL' }).first().click()
|
||||
await page.getByText('API URL copied', { exact: true }).waitFor()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const PATTERN = 'https://journal.example.com/{lang}/{slug}'
|
||||
const french: Setup = { suggestion: false }
|
||||
|
||||
export const surfacesStates: Record<string, VisualState> = {
|
||||
'loc.l12.home': { path: '/home', skip: 'clock-sensitive' },
|
||||
'loc.l12.homeFr': { path: '/home', skip: 'clock-sensitive' },
|
||||
'loc.l12.activity': { path: '/activity', skip: 'clock-sensitive' },
|
||||
'loc.l12.menu': state(french, openMenu),
|
||||
'loc.l12.apiCopy': state(french, copyApiUrl),
|
||||
'loc.l12.seoPreview': seoState(french, PATTERN, 'fr_FR', true),
|
||||
'loc.l12.siteFallback': seoState(french, PATTERN, 'fr_FR', true),
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { approvedExceptions } from './approvedExceptions'
|
||||
import { articleListStates } from './articleListStates'
|
||||
import { conflictsMovesStates, editorLocalizationStates } from './editorLocalizationStates'
|
||||
import { localizationStates } from './localizationStates'
|
||||
import { metadataApiStates } from './localizationMetadataStates'
|
||||
import { surfacesStates } from './localizationSurfacesStates'
|
||||
import { newArticleLocalizationStates } from './newArticleLocalizationStates'
|
||||
import { rolesStates } from './rolesStates'
|
||||
|
||||
@@ -1842,6 +1844,52 @@ function mediaUsageApi(overrides: Record<string, ApiMock> = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
const mediaLocalizedUsage = [
|
||||
{
|
||||
type: 'article',
|
||||
id: 10,
|
||||
title: 'How we price a bathroom renovation',
|
||||
published: true,
|
||||
fields: ['cover'],
|
||||
story_id: 5,
|
||||
lang: 'en',
|
||||
},
|
||||
{
|
||||
type: 'article',
|
||||
id: 11,
|
||||
title: 'So kalkulieren wir eine Badsanierung',
|
||||
published: true,
|
||||
fields: ['cover'],
|
||||
story_id: 5,
|
||||
lang: 'de',
|
||||
},
|
||||
{
|
||||
type: 'article',
|
||||
id: 12,
|
||||
title: 'Comment nous chiffrons une rénovation de salle de bain',
|
||||
published: false,
|
||||
fields: ['body', 'cover'],
|
||||
story_id: 5,
|
||||
lang: 'fr',
|
||||
},
|
||||
{
|
||||
type: 'article',
|
||||
id: 20,
|
||||
title: 'A guide to lime plaster',
|
||||
published: true,
|
||||
fields: ['body'],
|
||||
story_id: 6,
|
||||
lang: 'en',
|
||||
},
|
||||
]
|
||||
|
||||
const mediaSurfaceStates: Record<string, VisualState> = {
|
||||
'loc.l12.media': mediaState(
|
||||
mediaApi({ 'GET /v1/media/1/usage': { status: 200, body: mediaLocalizedUsage } }),
|
||||
(page) => openMediaPanel(page, 1),
|
||||
),
|
||||
}
|
||||
|
||||
const mediaDuplicateIds = [12, 5, 6]
|
||||
const mediaDuplicateGroups = [mediaDuplicateIds, [7, 9]]
|
||||
|
||||
@@ -10352,4 +10400,6 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
'localization-editor': editorLocalizationStates,
|
||||
'localization-conflicts-moves': conflictsMovesStates,
|
||||
'localization-new-article': newArticleLocalizationStates,
|
||||
'localization-metadata-api': metadataApiStates,
|
||||
'localization-surfaces': { ...surfacesStates, ...mediaSurfaceStates },
|
||||
}
|
||||
Reference in new issue
Block a user