feat: translation flows, revision conflicts, delete scopes, article moves and a target site for new articles

This commit is contained in:
Florian Sylvain committed 2026-11-03 00:12:29 +01:00
1 parent 339e646eb8
commit 45fbbc33b1
93 files changed
+7630 -294

No files matched your search

+290 -32
View File
@@ -28,7 +28,7 @@ const MINUTE_MS = 60 * 1000
const SAVED_MINUTES_AGO = 3
const LOAD_SETTLE_MS = 400
const paragraphs: Record<Lang, string[]> = {
export const paragraphs: Record<Lang, string[]> = {
en: [
'Most quotes fail because they hide the same three costs. Here is how we itemise them before the first tile is ordered.',
'Start with the room, not the catalogue. Measure the walls, note where the pipes run and photograph the existing fixtures.',
@@ -84,11 +84,11 @@ const seoOneSuggestion = {
suggestions: [{ code: 'cover_none', field: 'cover_image_id', params: {} }],
slug: { available: true, suggestion: null },
}
const ok = (body: unknown): ApiMock => ({ status: 200, body })
const fails = (status = 500): ApiMock => ({ status, body: 'Internal server error' })
const hangs: ApiMock = { status: 200, hang: true }
export const ok = (body: unknown): ApiMock => ({ status: 200, body })
export const fails = (status = 500): ApiMock => ({ status, body: 'Internal server error' })
export const hangs: ApiMock = { status: 200, hang: true }
const site = {
export const site = {
id: 1,
name: 'Journal',
key: 'journal',
@@ -104,7 +104,8 @@ const site = {
public_url_pattern: null,
}
interface Setup {
export interface Setup {
search?: string
current?: Lang
capabilities?: string[]
siblings?: Lang[]
@@ -133,7 +134,7 @@ function entry(setup: Setup, lang: Lang) {
}
}
function articleBody(setup: Setup, lang: Lang) {
export function articleBody(setup: Setup, lang: Lang) {
const version = versions[lang]
const current = lang === (setup.current ?? 'fr')
const siblings = setup.siblings ?? ['en', 'fr', 'de']
@@ -146,6 +147,7 @@ function articleBody(setup: Setup, lang: Lang) {
is_online: setup.online?.[lang] ?? version.online,
lang: version.lang,
story_id: 5,
revision: 3,
capabilities: current
? (setup.capabilities ?? FULL)
: (setup.siblingCapabilities?.[lang] ?? FULL),
@@ -196,10 +198,10 @@ function editorApi(setup: Setup = {}, overrides: Record<string, ApiMock> = {}) {
}
function pathOf(setup: Setup) {
return `/article/${versions[setup.current ?? 'fr'].id}/edit`
return `/article/${versions[setup.current ?? 'fr'].id}/edit${setup.search ?? ''}`
}
function state(
export function state(
setup: Setup,
steps?: (page: Page) => Promise<void>,
overrides: Record<string, ApiMock> = {},
@@ -214,13 +216,13 @@ function state(
}
}
const pending = (path: string): VisualState => ({ path, skip: 'pending' })
export const pending = (path: string): VisualState => ({ path, skip: 'pending' })
function narrow(page: Page) {
export function narrow(page: Page) {
return (page.viewportSize()?.width ?? 0) < 1024
}
async function settle(page: Page) {
export async function settle(page: Page) {
await page.waitForTimeout(LOAD_SETTLE_MS)
await page.mouse.move(0, 0)
}
@@ -233,7 +235,7 @@ async function scrollEverythingToTop(page: Page) {
})
}
async function editBody(page: Page) {
export async function editBody(page: Page) {
await page.locator('[role="textbox"][contenteditable="true"]').first().click()
await page.keyboard.press('Control+End')
await page.keyboard.type(' x')
@@ -270,13 +272,13 @@ async function chooseLanguage(page: Page, name: string) {
await page.getByRole('menuitem', { name: new RegExp(`^${name}`) }).click()
}
async function press(page: Page, name: string | RegExp) {
export async function press(page: Page, name: string | RegExp) {
const exact = typeof name === 'string'
await page.getByRole('button', { name, exact }).first().click()
await settle(page)
}
async function openOverflow(page: Page) {
export async function openOverflow(page: Page) {
await page.getByRole('button', { name: 'More actions' }).first().click()
await page.getByRole('menuitem').first().waitFor()
await settle(page)
@@ -296,7 +298,7 @@ async function pressUnpublish(page: Page) {
await settle(page)
}
async function confirmDialog(page: Page, label: string) {
export async function confirmDialog(page: Page, label: string) {
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click()
await settle(page)
}
@@ -306,6 +308,13 @@ async function dirtyThenPress(page: Page, label: string | RegExp) {
await press(page, label)
}
async function chooseOverflowItem(page: Page, label: string) {
await openOverflow(page)
await page.getByRole('menuitem', { name: new RegExp(`^${label}`) }).click()
await page.getByRole('dialog').waitFor()
await settle(page)
}
const saveFails: Record<string, ApiMock> = { 'PATCH /v1/article/12': fails() }
const savedFrench = (online = false) =>
ok({ ...articleBody({ current: 'fr', online: { fr: online } }, 'fr') })
@@ -535,11 +544,32 @@ const l8: Record<string, VisualState> = {
await editBody(page)
await pressUnpublish(page)
}),
'loc.l8.delete': pending('/article/12/edit'),
'loc.l8.delete': state({}, (page) => chooseOverflowItem(page, 'Delete French version')),
'loc.l8.deleteSource': pending('/article/10/edit'),
'loc.l8.deleteLast': pending('/article/10/edit'),
'loc.l8.deleteStoryOk': pending('/article/10/edit'),
'loc.l8.deleteStory': pending('/article/12/edit'),
'loc.l8.deleteLast': state(
{
current: 'en',
siblings: ['en'],
online: { en: true },
title: FRENCH_TITLE,
paragraphs: paragraphs.fr,
},
(page) => chooseOverflowItem(page, 'Delete article'),
),
'loc.l8.deleteStoryOk': state(
{ siblings: ['en', 'fr', 'de', 'ar'], online: { fr: true } },
(page) => chooseOverflowItem(page, 'Delete article'),
),
'loc.l8.deleteStory': state(
{ siblings: ['en', 'fr', 'de', 'ar'] },
async (page) => {
await chooseOverflowItem(page, 'Delete article')
await confirmDialog(page, 'Delete article')
await page.getByRole('dialog').getByText("You can't delete this article").waitFor()
await settle(page)
},
{ 'DELETE /v1/article/10': { status: 403, body: 'missing_permission' } },
),
'loc.l8.done': state(
{},
async (page) => {
@@ -573,6 +603,135 @@ const RESTORED_AT = '2026-10-14T14:18:00Z'
const STORED_DRAFT_KEY = 'renewcms:article-draft:7:12'
const olderFrench = paragraphs.fr.slice(0, 2)
const THEIRS_AT = '2026-10-14T14:32:00Z'
const AGAIN_AT = '2026-10-14T14:41:00Z'
const FRENCH_SLUG = 'comment-nous-chiffrons-une-renovation'
const conflictHeaders = { 'X-Current-Revision': '4' }
const staleSave: ApiMock = { status: 409, body: 'revision_conflict', headers: conflictHeaders }
function frenchAtCalls(updatedAtByCall: Record<number, string>, extra: object = {}): ApiMock {
const loaded = articleBody({ suggestion: true }, 'fr')
return {
status: 200,
bodyFor: (_url, call) =>
call === 1
? loaded
: {
...loaded,
...extra,
revision: 4,
updated_at: updatedAtByCall[call] ?? loaded.updated_at,
},
}
}
const conflictApi = (times: Record<number, string> = { 2: THEIRS_AT }) => ({
'PATCH /v1/article/12': staleSave,
'GET /v1/article/12': frenchAtCalls(times),
})
async function openConflict(page: Page) {
await editBody(page)
await press(page, 'Save')
await page.getByRole('dialog').getByText('Someone else saved this version').first().waitFor()
await settle(page)
}
async function storeRestoredDraft(page: Page) {
await page.evaluate(
({ key, body, mirroredAt }) =>
sessionStorage.setItem(
key,
JSON.stringify({ body, revision: 3, mirrored_at: mirroredAt }),
),
{
key: STORED_DRAFT_KEY,
body: paragraphs.fr.map((text) => `<p>${text}</p>`).join(''),
mirroredAt: RESTORED_AT,
},
)
await page.reload()
await page.getByText('Restored unsaved changes from 14:18', { exact: true }).first().waitFor()
await settle(page)
}
async function pressInDialog(page: Page, name: string) {
await page.getByRole('dialog').getByRole('button', { name, exact: true }).click()
await settle(page)
}
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 moveSites = [
boardSite(1, 'journal', 'Journal'),
boardSite(2, 'atelier', 'Atelier'),
boardSite(4, 'archive', 'Archive'),
boardSite(3, 'notes', 'Notes'),
]
const moveSetup: Setup = {
siblings: ['en', 'fr', 'de', 'ar'],
siblingCapabilities: { ar: READ_ONLY },
online: { en: true, fr: false, de: true, ar: false },
}
const movedAll = {
story_id: 5,
site: 'atelier',
site_id: 2,
article_ids: [10, 12, 11, 13],
}
const moveApi = (overrides: Record<string, ApiMock> = {}) => ({
'GET /v1/site/': ok(moveSites),
'GET /v1/site/notes/languages': ok(languagesOf(['en'])),
'GET /v1/site/atelier/languages': ok(languagesOf(['en', 'fr', 'de'])),
'POST /v1/story/5/move': ok(movedAll),
...overrides,
})
async function openMovePanel(page: Page) {
if (narrow(page)) {
await page.getByRole('button', { name: 'Details', exact: true }).click()
await page.getByRole('button', { name: /^Site and authors/ }).click()
await settle(page)
}
await page.getByRole('button', { name: 'Site', exact: true }).first().click()
await page.getByRole('listbox', { name: 'Site' }).waitFor()
await settle(page)
}
async function chooseMoveTarget(page: Page, name: string) {
await openMovePanel(page)
await page.getByRole('option', { name }).click()
await page.getByRole('button', { name: 'Move article…', exact: true }).click()
await page.getByRole('dialog').getByText(`Move to ${name}?`).waitFor()
await settle(page)
}
async function confirmMove(page: Page, name: string) {
await chooseMoveTarget(page, name)
await pressInDialog(page, 'Move article')
}
const mv = (
steps: (page: Page) => Promise<void>,
overrides: Record<string, ApiMock> = {},
setup: Setup = moveSetup,
exception?: DiffException,
) => state({ suggestion: true, ...setup }, steps, moveApi(overrides), exception)
export const conflictsMovesStates: Record<string, VisualState> = {
'loc.l11.session': pending('/article/12/edit'),
'loc.l11.restored': state({ paragraphs: olderFrench }, async (page) => {
@@ -589,17 +748,116 @@ export const conflictsMovesStates: Record<string, VisualState> = {
await page.getByText('Restored unsaved changes from 14:18', { exact: true }).waitFor()
await settle(page)
}),
'loc.conflict': pending('/article/12/edit'),
'loc.l11.conflictRestored': pending('/article/12/edit'),
'loc.l11.reloadConfirm': pending('/article/12/edit'),
'loc.l11.conflict2': pending('/article/12/edit'),
'loc.l11.download': pending('/article/12/edit'),
'loc.l11b.moveSelect': pending('/article/12/edit'),
'loc.conflict': state({ suggestion: true }, openConflict, conflictApi()),
'loc.l11.conflictRestored': state(
{ suggestion: true, paragraphs: olderFrench },
async (page) => {
await storeRestoredDraft(page)
await press(page, 'Save')
await page
.getByRole('dialog')
.getByText('Someone else saved this version')
.first()
.waitFor()
await settle(page)
},
conflictApi(),
),
'loc.l11.reloadConfirm': state(
{ suggestion: true },
async (page) => {
await openConflict(page)
await pressInDialog(page, 'Reload theirs')
},
conflictApi(),
),
'loc.l11.conflict2': state(
{ suggestion: true },
async (page) => {
await openConflict(page)
await pressInDialog(page, 'Save over theirs')
await page.getByText("Couldn't replace theirs").first().waitFor()
await settle(page)
},
conflictApi({ 2: THEIRS_AT, 3: AGAIN_AT }),
),
'loc.l11.download': state(
{ suggestion: true },
async (page) => {
await openConflict(page)
await pressInDialog(page, 'Download my version')
await page.getByText('Download started', { exact: true }).first().waitFor()
await settle(page)
},
conflictApi(),
),
'loc.l11b.moveSelect': mv(openMovePanel),
'loc.l11b.moveSelectHidden': pending('/article/12/edit'),
'loc.l11b.moveConfirm': pending('/article/12/edit'),
'loc.l11b.moveBusy': pending('/article/12/edit'),
'loc.l11b.moveDone': pending('/article/12/edit'),
'loc.l11b.move': pending('/article/12/edit'),
'loc.l11b.moveSlug': pending('/article/12/edit'),
'loc.l11b.movePerm': pending('/article/12/edit'),
'loc.l11b.moveConfirm': mv((page) => chooseMoveTarget(page, 'Atelier')),
'loc.l11b.moveBusy': mv((page) => confirmMove(page, 'Atelier'), {
'POST /v1/story/5/move': hangs,
}),
'loc.l11b.moveDone': mv(
async (page) => {
await confirmMove(page, 'Atelier')
await page.getByText('Moved to Atelier', { exact: true }).first().waitFor()
await settle(page)
},
{ 'GET /v1/article/12': frenchAtCalls({}, { site_id: 2, revision: 4 }) },
),
'loc.l11b.move': mv(
async (page) => {
await confirmMove(page, 'Notes')
await page
.getByRole('dialog')
.getByText("Notes doesn't enable every language")
.waitFor()
await settle(page)
},
{
'POST /v1/story/5/move': {
status: 422,
body: 'language_not_enabled',
headers: { 'X-Missing-Languages': 'fr,de,ar' },
},
},
),
'loc.l11b.moveSlug': mv(
async (page) => {
await confirmMove(page, 'Atelier')
await page
.getByRole('dialog')
.getByText('An address is already used on Atelier')
.waitFor()
await settle(page)
},
{
'POST /v1/story/5/move': {
status: 409,
body: 'slug_conflict',
headers: { 'X-Conflicting-Articles': '12' },
},
'GET /v1/article/12': {
status: 200,
bodyFor: (_url, call) => ({
...articleBody({ suggestion: true }, 'fr'),
...(call > 1 ? { slug: FRENCH_SLUG } : {}),
}),
},
},
moveSetup,
{
maxPercent: { desktop: 10, mobile: 17.4 },
reason: 'The board alert has an Edit slug button; Edit slug stays in slice 6, so the board and slice 6 are to be reconciled.',
approvedBy: 'Florian Sylvain',
},
),
'loc.l11b.movePerm': mv(
async (page) => {
await confirmMove(page, 'Atelier')
await page.getByRole('dialog').getByText("You can't move this article").waitFor()
await settle(page)
},
{ 'POST /v1/story/5/move': { status: 403, body: 'missing_permission' } },
),
}