feat: translation flows, revision conflicts, delete scopes, article moves and a target site for new articles
This commit is contained in:
1 parent
339e646eb8
commit
45fbbc33b1
93 files changed
+7630
-294
No files matched your search
@@ -0,0 +1,325 @@
|
||||
import type { Page } from 'playwright'
|
||||
import { siteLanguagesBody } from '../src/test/storyLists'
|
||||
import { listApi } from './articleListStates'
|
||||
import {
|
||||
articleBody,
|
||||
fails,
|
||||
hangs,
|
||||
ok,
|
||||
pending,
|
||||
settle,
|
||||
site,
|
||||
state,
|
||||
type Setup,
|
||||
} from './editorLocalizationStates'
|
||||
import type { ApiMock, DiffException, VisualState } from './states'
|
||||
|
||||
const FRENCH_TITLE_ROOF = "Liste de contrôle pour l'inspection de la toiture"
|
||||
const FRENCH_TITLE_LONG = `${FRENCH_TITLE_ROOF} avec les coûts cachés derrière les carreaux, sous la baignoire et autour des canalisations anciennes`
|
||||
const FRENCH_TITLE_PRICING = 'Comment nous chiffrons une rénovation de salle de bain'
|
||||
const ROOF_TITLE = 'Roof inspection checklist'
|
||||
const AUTHOR_NAMES = [
|
||||
'Marta Alves',
|
||||
'Camille Roche',
|
||||
'Luis Ferrer',
|
||||
'Ana Pereira',
|
||||
'Jonas Weber',
|
||||
'Inès Duarte',
|
||||
'Paul Martin',
|
||||
'Sofia Costa',
|
||||
'Omar Haddad',
|
||||
]
|
||||
const COVER_IMAGE_ID = 1
|
||||
const IMAGE_COUNT = 8
|
||||
|
||||
function boardSite(id: number, key: string, name: string) {
|
||||
return {
|
||||
...site,
|
||||
id,
|
||||
key,
|
||||
name,
|
||||
site_name: name,
|
||||
is_default: id === 1,
|
||||
base_url: `https://${key}.example.com`,
|
||||
}
|
||||
}
|
||||
|
||||
const boardSites = [
|
||||
boardSite(1, 'journal', 'Journal'),
|
||||
boardSite(2, 'atelier', 'Atelier'),
|
||||
boardSite(3, 'notes', 'Notes'),
|
||||
boardSite(4, 'archive', 'Archive'),
|
||||
]
|
||||
|
||||
function languagesOf(tags: string[]) {
|
||||
const base = siteLanguagesBody('journal')
|
||||
return {
|
||||
...base,
|
||||
revision: `${tags.join('-')}-1`,
|
||||
languages: base.languages.filter((language) => tags.includes(language.lang)),
|
||||
}
|
||||
}
|
||||
|
||||
const modalApi = (overrides: Record<string, ApiMock> = {}) =>
|
||||
listApi({
|
||||
'GET /v1/site/': ok(boardSites),
|
||||
'GET /v1/site/atelier/languages': ok(languagesOf(['en', 'fr', 'de'])),
|
||||
'GET /v1/site/notes/languages': ok(languagesOf(['en'])),
|
||||
'GET /v1/site/archive/languages': ok(languagesOf(['en'])),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const roofStory = {
|
||||
id: 105,
|
||||
story_id: 5,
|
||||
title: ROOF_TITLE,
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: true,
|
||||
lang: 'en',
|
||||
site_id: 1,
|
||||
created_at: '2026-03-12T09:14:00Z',
|
||||
updated_at: '2026-10-14T09:00:00Z',
|
||||
story_updated_at: '2026-10-14T09:00:00Z',
|
||||
story_authors: [],
|
||||
capabilities: ['read', 'edit', 'publish', 'delete', 'create_translation'],
|
||||
missing_langs: ['fr', 'ar'],
|
||||
coverage: [
|
||||
{
|
||||
id: 105,
|
||||
lang: 'en',
|
||||
title: ROOF_TITLE,
|
||||
is_online: true,
|
||||
language_enabled: true,
|
||||
capabilities: ['read'],
|
||||
},
|
||||
{
|
||||
id: 106,
|
||||
lang: 'de',
|
||||
title: 'Checkliste für die Dachprüfung',
|
||||
is_online: false,
|
||||
language_enabled: true,
|
||||
capabilities: ['read'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const missingPage: ApiMock = {
|
||||
status: 200,
|
||||
bodyFor: () => ({
|
||||
items: [roofStory],
|
||||
total: 1,
|
||||
page: 1,
|
||||
size: 20,
|
||||
counts: { all: 1, online: 1, draft: 0, missing: 1 },
|
||||
}),
|
||||
}
|
||||
|
||||
function listWithMissing(): Record<string, ApiMock> {
|
||||
const base = modalApi()
|
||||
const list = base['GET /v1/article/']!
|
||||
return {
|
||||
'GET /v1/article/': {
|
||||
status: 200,
|
||||
bodyFor: (url, call) =>
|
||||
url.searchParams.get('tab') === 'missing'
|
||||
? missingPage.bodyFor!(url, call)
|
||||
: list.bodyFor!(url, call),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function openModal(page: Page) {
|
||||
await page.getByRole('button', { name: 'New article', exact: true }).first().click()
|
||||
await page.getByRole('dialog').getByText('Give the article a title').waitFor()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function chooseOption(page: Page, field: string, option: string) {
|
||||
await page.getByRole('combobox', { name: field, exact: true }).click()
|
||||
await page.getByRole('option', { name: option, exact: true }).click()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
async function modalTitle(page: Page, value: string) {
|
||||
const input = page.getByRole('dialog').getByRole('textbox').first()
|
||||
await input.fill(value)
|
||||
}
|
||||
|
||||
const modal = (
|
||||
steps: (page: Page) => Promise<void>,
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
exception?: DiffException,
|
||||
): VisualState => ({
|
||||
exception,
|
||||
path: '/article?site=journal&size=5',
|
||||
timezone: 'UTC',
|
||||
api: modalApi(overrides),
|
||||
steps: async (page) => {
|
||||
await openModal(page)
|
||||
await steps(page)
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
})
|
||||
|
||||
async function startTranslation(page: Page) {
|
||||
await page.getByRole('radio', { name: /Translation of an existing article/ }).click()
|
||||
await settle(page)
|
||||
await chooseOption(page, 'Language', 'French')
|
||||
await page.getByLabel('Article', { exact: true }).click()
|
||||
await page.getByRole('option', { name: ROOF_TITLE }).click()
|
||||
await settle(page)
|
||||
await page.getByRole('dialog').getByRole('textbox').first().fill(FRENCH_TITLE_ROOF)
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const roofEditor: Setup = {
|
||||
current: 'en',
|
||||
siblings: ['en', 'de'],
|
||||
title: ROOF_TITLE,
|
||||
online: { en: true, de: false },
|
||||
search: '?dialog=add-translation&lang=fr',
|
||||
}
|
||||
|
||||
const pricingEditor: Setup = {
|
||||
current: 'en',
|
||||
siblings: ['en', 'de', 'ar'],
|
||||
online: { en: true, de: true, ar: false },
|
||||
search: '?dialog=add-translation&lang=fr',
|
||||
}
|
||||
|
||||
async function dialogTitle(page: Page, value: string) {
|
||||
const input = page.getByRole('dialog').getByRole('textbox').first()
|
||||
await input.waitFor()
|
||||
await input.fill(value)
|
||||
}
|
||||
|
||||
async function submitTranslation(page: Page) {
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Create French draft' }).click()
|
||||
await settle(page)
|
||||
}
|
||||
|
||||
const withoutImages = (setup: Setup): ApiMock => ({
|
||||
status: 200,
|
||||
bodyFor: () => ({ ...articleBody(setup, 'en'), images: [] }),
|
||||
})
|
||||
|
||||
const addTranslation = (
|
||||
setup: Setup,
|
||||
title: string,
|
||||
after?: (page: Page) => Promise<void>,
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
exception?: DiffException,
|
||||
) =>
|
||||
state(
|
||||
setup,
|
||||
async (page) => {
|
||||
await dialogTitle(page, title)
|
||||
await settle(page)
|
||||
await after?.(page)
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
{ 'GET /v1/article/10': withoutImages(setup), ...overrides },
|
||||
exception,
|
||||
)
|
||||
|
||||
const rewindDialog = async (page: Page) => {
|
||||
await page.keyboard.press('Home')
|
||||
await page.getByRole('dialog').evaluate((dialog) => {
|
||||
dialog.querySelectorAll('*').forEach((node) => {
|
||||
node.scrollTop = 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const roofForm = (
|
||||
after?: (page: Page) => Promise<void>,
|
||||
overrides: Record<string, ApiMock> = {},
|
||||
exception?: DiffException,
|
||||
) => addTranslation(roofEditor, FRENCH_TITLE_ROOF, after, overrides, exception)
|
||||
|
||||
const duplicate = (overrides: Record<string, ApiMock>) =>
|
||||
addTranslation(pricingEditor, FRENCH_TITLE_PRICING, submitTranslation, overrides)
|
||||
|
||||
const longSource = () => {
|
||||
const body = articleBody({ ...roofEditor }, 'en')
|
||||
return {
|
||||
status: 200,
|
||||
bodyFor: () => ({
|
||||
...body,
|
||||
cover_image_id: COVER_IMAGE_ID,
|
||||
images: Array.from({ length: IMAGE_COUNT }, (_, index) => ({
|
||||
id: index + 1,
|
||||
url: `/images/${index + 1}.png`,
|
||||
})),
|
||||
authors: AUTHOR_NAMES.map((name, index) => ({
|
||||
id: index + 1,
|
||||
name,
|
||||
slug: name.toLowerCase().replace(' ', '-'),
|
||||
})),
|
||||
}),
|
||||
} satisfies ApiMock
|
||||
}
|
||||
|
||||
const post = (key: string, response: ApiMock) => ({
|
||||
[`POST /v1/article/${key}/translations`]: response,
|
||||
})
|
||||
|
||||
export const newArticleLocalizationStates: Record<string, VisualState> = {
|
||||
'loc.l3.single': modal((page) => chooseOption(page, 'Site', 'Archive')),
|
||||
'loc.l3.multi': modal(async () => undefined),
|
||||
'loc.l3.translation': modal(startTranslation, listWithMissing(), {
|
||||
maxPercent: { desktop: 10, mobile: 23.2 },
|
||||
reason: 'The board draws no New article / Translation choice cards in translation mode, while the "Switching the choice keeps what is shared" scenario requires them (about 105px taller sheet); the board is to be updated.',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
}),
|
||||
'loc.l3.sitechange': modal(async (page) => {
|
||||
await modalTitle(page, 'Spring newsletter')
|
||||
await chooseOption(page, 'Language', 'French')
|
||||
await chooseOption(page, 'Site', 'Notes')
|
||||
}),
|
||||
'loc.l4.form': roofForm(),
|
||||
'loc.l4.empty': roofForm(
|
||||
async (page) => {
|
||||
await page.getByRole('radio', { name: /Start empty/ }).click()
|
||||
await settle(page)
|
||||
},
|
||||
{},
|
||||
{
|
||||
maxPercent: { desktop: 16.8, mobile: 32.2 },
|
||||
reason: 'The board still draws the Source version field and copy note in Start empty, while features/localization_translation_flows.feature says they are no longer shown; the board is to be updated.',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
),
|
||||
'loc.l4.saving': roofForm(submitTranslation, post('10', hangs)),
|
||||
'loc.dup.readable': duplicate(
|
||||
post('10', {
|
||||
status: 409,
|
||||
body: 'language_exists',
|
||||
headers: { 'X-Existing-Article-Id': '12' },
|
||||
}),
|
||||
),
|
||||
'loc.dup.hidden': duplicate(post('10', { status: 409, body: 'language_unavailable' })),
|
||||
'loc.l4.fail': roofForm(submitTranslation, post('10', fails())),
|
||||
'loc.l4.gone': pending('/article/10/edit?dialog=add-translation&lang=fr'),
|
||||
'loc.l4.unreadable': roofForm(
|
||||
submitTranslation,
|
||||
post('10', { status: 404, body: 'source_unavailable' }),
|
||||
{
|
||||
maxPercent: { desktop: 12.1, mobile: 33.6 },
|
||||
reason: 'The board omits the Starting content block and description, while the feature requires the radios so choosing Start empty clears the warning; the board is to be updated.',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
),
|
||||
'loc.l4.langOff': roofForm(submitTranslation, {
|
||||
...post('10', { status: 409, body: 'language_unavailable' }),
|
||||
'GET /v1/site/journal/languages': {
|
||||
status: 200,
|
||||
bodyFor: (_url, call) =>
|
||||
languagesOf(call === 1 ? ['en', 'fr', 'de', 'ar'] : ['en', 'de', 'ar']),
|
||||
},
|
||||
}),
|
||||
'loc.l4.long': addTranslation(roofEditor, FRENCH_TITLE_LONG, rewindDialog, {
|
||||
'GET /v1/article/10': longSource(),
|
||||
}),
|
||||
}
|
||||
Reference in new issue
Block a user