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
@@ -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' } },
|
||||
),
|
||||
}
|
||||
Reference in new issue
Block a user