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

@@ -455,6 +455,21 @@ describe('loc.l7.rowMenu, Row menu', () => {
expect(menuLabels()).toEqual(expected)
})
it('opens the editor with the Add translation dialog and no language when several are missing', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
await openMenu('Winter checklist for timber windows')
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => textOf(item).startsWith('Add translation'))!
.click()
await flushPromises()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toMatch(/^\/article\/\d+\/edit$/),
)
expect(router.currentRoute.value.query.dialog).toBe('add-translation')
expect(router.currentRoute.value.query.lang).toBeUndefined()
})
it('hides Add translation on an article that has every language', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
await openMenu('How we price a bathroom renovation')
@@ -0,0 +1,544 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { initializeLocale } from '@/i18n'
import LocaleSelect from '@/components/localization/LocaleSelect.vue'
import { NetworkError } from '@/composables/useApi'
import {
callsTo,
deferred,
dialogBox,
dialogText,
failWith,
kit,
press,
settle,
typeInto,
unmountRoute,
} from './localizationKit'
import { EDIT_ONLY, IDS, articleOf, mountEditor, type EditorFixture } from './editorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const DIALOG = '/article/12/edit?dialog=add-translation'
const CREATED_ID = 14
const FOUR = ['en', 'fr', 'ar'] as const
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function created() {
return {
...articleOf({ lang: 'fr' }, 'de'),
id: CREATED_ID,
title: 'Kosten',
is_online: false,
revision: 1,
}
}
function open(fixture: EditorFixture = {}, url = `${DIALOG}&lang=de`) {
return mountEditor(
{
translations: [...FOUR],
...fixture,
extra: {
'POST /v1/article/12/translations': created(),
'GET /v1/article/14': created(),
...fixture.extra,
} as never,
},
url,
)
}
function field(label: string) {
return kit
.wrapper!.findAllComponents(LocaleSelect)
.find((component) => component.props('ariaLabel') === label)
}
function dialogTitleInput() {
return dialogBox()!.querySelector<HTMLInputElement>('input[type="text"], input:not([type])')!
}
async function submit(label = 'Create German draft') {
await press(label, dialogBox()!)
}
async function fillTitle(value = 'Kosten') {
await typeInto(dialogTitleInput(), value)
}
describe('loc.l4.form: The dialog opens from the URL and from the language menu', () => {
it('opens for German with the title field focused', async () => {
await open()
expect(dialogText()).toContain('Add a translation')
expect(field('Language')!.props('modelValue')).toBe('de')
expect(document.activeElement).toBe(dialogTitleInput())
})
it('opens with Language empty and the primary disabled when no language is given', async () => {
await open({}, DIALOG)
expect(field('Language')!.props('modelValue')).toBeUndefined()
const primary = Array.from(dialogBox()!.querySelectorAll('button')).find((button) =>
/Create/.test(button.textContent ?? ''),
)!
expect(primary.hasAttribute('disabled')).toBe(true)
})
it.each([
['French, because a version exists', 'fr'],
['Spanish, because it is disabled', 'es'],
])('leaves Language empty for %s', async (_name, lang) => {
await open({}, `${DIALOG}&lang=${lang}`)
expect(field('Language')!.props('modelValue')).toBeUndefined()
})
it('opens no dialog and strips the parameters without create_translation', async () => {
await open({ caps: EDIT_ONLY })
expect(dialogBox()).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/article/12/edit')
})
it('closes with the Back button', async () => {
await open({}, '/article/12/edit')
await router.push(`${DIALOG}&lang=de`)
await settle()
expect(dialogBox()).not.toBeNull()
router.back()
await new Promise((resolve) => setTimeout(resolve, 50))
await settle()
expect(dialogBox()).toBeNull()
})
})
describe('Offered languages are the enabled languages with no readable version', () => {
it('lists German only, in the site order', async () => {
await open()
expect(field('Language')!.props('items')).toEqual([{ label: 'German', value: 'de' }])
})
it('lists a language whose version is unreadable to the caller too', async () => {
await open({ translations: ['en', 'fr'] }, `${DIALOG}&lang=de`)
expect(field('Language')!.props('items')).toEqual([
{ label: 'German', value: 'de' },
{ label: 'Arabic', value: 'ar' },
])
})
})
describe('The form copy, source and Start empty', () => {
it('describes the copy and offers the board fields and footer', async () => {
await open()
const text = dialogText()
expect(text).toContain(
'Creates a German draft of "Couts de renovation". It isn\'t published.',
)
expect(text).toContain('Title in German')
expect(text).toContain(
'Copying text is a starting point: it stays in English until you translate it.',
)
expect(text).toContain('Start empty')
expect(text).toContain('Copy from English')
expect(text).toContain(
"Copies body, excerpt, search and social text, image alternative text and authors. Images are reused, files aren't duplicated. The version starts as Needs review.",
)
const footer = Array.from(dialogBox()!.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(footer.slice(-2)).toEqual(['Cancel', 'Create German draft'])
})
it('picks the default-language version as the source and labels the options', async () => {
await open()
expect(field('Source version')!.props('modelValue')).toBe(String(IDS.en))
expect(field('Source version')!.props('items')).toEqual([
{ label: 'English · published', value: '10' },
{ label: 'French · draft', value: '12' },
{ label: 'Arabic · published', value: '13' },
])
})
it('hides the source and the copy note on Start empty', async () => {
await open()
kit.wrapper!.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit(
'update:modelValue',
'empty',
)
await settle()
expect(field('Source version')).toBeUndefined()
expect(dialogText()).not.toContain('Copying text is a starting point')
expect(dialogText()).toContain(
'Only the title. Freshness shows No source until you pick a tracked source.',
)
})
})
describe('Title validation runs before any request', () => {
it.each([
['nothing', '', 'Enter a title'],
['"ab"', 'ab', 'Use at least 3 characters'],
['201 characters', 'x'.repeat(201), 'Use 200 characters or fewer'],
])('shows the message for %s and sends nothing', async (_name, title, message) => {
await open()
await fillTitle(title)
await submit()
expect(dialogText()).toContain(message)
expect(callsTo('POST', '/v1/article/12/translations')).toHaveLength(0)
})
it('sends the request for 200 emoji', async () => {
await open()
await fillTitle('😀'.repeat(200))
await submit()
expect(callsTo('POST', '/v1/article/12/translations')).toHaveLength(1)
})
})
describe('Creating the translation', () => {
it('copies from the chosen source with the open version in the path', async () => {
await open()
field('Source version')!.vm.$emit('update:modelValue', String(IDS.fr))
await settle()
await fillTitle()
await submit()
expect(callsTo('POST', '/v1/article/12/translations')[0]!.body).toEqual({
lang: 'de',
title: 'Kosten',
copy: true,
source_article_id: 12,
})
})
it('sends no source when starting empty', async () => {
await open()
kit.wrapper!.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit(
'update:modelValue',
'empty',
)
await settle()
await fillTitle()
await submit()
expect(callsTo('POST', '/v1/article/12/translations')[0]!.body).toEqual({
lang: 'de',
title: 'Kosten',
copy: false,
})
})
it('locks the dialog while creating: Creating…, disabled Cancel, Escape does nothing', async () => {
const gate = deferred<unknown>()
await open({ extra: { 'POST /v1/article/12/translations': () => gate.promise } as never })
await fillTitle()
await submit()
const labels = Array.from(dialogBox()!.querySelectorAll('button')).map((button) => ({
label: button.textContent?.trim(),
disabled: button.hasAttribute('disabled'),
}))
expect(labels).toContainEqual({ label: 'Cancel', disabled: true })
expect(labels.some((entry) => entry.label === 'Creating…')).toBe(true)
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(dialogBox()).not.toBeNull()
gate.resolve(created())
await settle()
})
it('opens the new version, announces it and lists it in the menu', async () => {
await open()
await fillTitle()
await submit()
expect(dialogBox()).toBeNull()
expect(router.currentRoute.value.path).toBe('/article/14/edit')
expect(document.body.querySelector('[data-testid="live-announcement"]')?.textContent).toBe(
'German draft created',
)
})
it('asks before leaving unsaved edits and keeps the open version on Keep editing', async () => {
await open()
await fillTitle()
kit.wrapper!.findComponent({ name: 'ArticleEditor' }).vm.$emit(
'update:modelValue',
'<p>Edit</p>',
)
await settle()
await submit()
expect(dialogText()).toContain('Leave without saving?')
expect(dialogText()).toContain('Discard and open German')
expect(dialogText()).toContain('Save and open German')
await press('Keep editing')
expect(router.currentRoute.value.path).toBe('/article/12/edit')
})
})
describe('Failures keep the entries', () => {
const exists = failWith(409, 'language_exists', { existingArticleId: 11 })
const unavailable = failWith(409, 'language_unavailable') as () => never
async function fail(response: unknown, languages?: EditorFixture['languages']) {
await open({
languages,
extra: { 'POST /v1/article/12/translations': response } as never,
})
await fillTitle()
await submit()
}
function primaryDisabled() {
return Array.from(dialogBox()!.querySelectorAll('button'))
.find((button) => /^Create German draft$/.test(button.textContent?.trim() ?? ''))!
.hasAttribute('disabled')
}
it('loc.dup.readable: offers Open German version and disables the primary', async () => {
await fail(exists)
expect(dialogText()).toContain('German already exists for this article')
expect(dialogText()).toContain('Open it to continue. Your entries are kept here.')
expect(dialogText()).toContain('Open German version')
expect(primaryDisabled()).toBe(true)
expect(dialogTitleInput().value).toBe('Kosten')
})
it('opens the existing version through the version switch', async () => {
await fail(exists)
kit.calls.length = 0
await press('Open German version')
expect(dialogBox()).toBeNull()
expect(router.currentRoute.value.path).toBe('/article/11/edit')
})
it('loc.dup.hidden: a neutral error with no Open action while German is still enabled', async () => {
await fail(unavailable)
expect(dialogText()).toContain("German can't be created for this article")
expect(dialogText()).toContain(
'Try a different language, or ask an owner. Your entries are kept.',
)
expect(dialogText()).not.toContain('Open German version')
expect(primaryDisabled()).toBe(true)
})
it('loc.l4.langOff: German no longer enabled makes Language read-only with the warning', async () => {
let reads = 0
let refused = false
await open({
extra: {
'POST /v1/article/12/translations': () => {
refused = true
return unavailable()
},
'GET /v1/site/journal/languages': () => {
reads += 1
return {
revision: 'r',
default: 'en',
needs_setup: false,
available: [],
languages: (refused ? ['en', 'fr', 'ar'] : ['en', 'fr', 'de', 'ar']).map(
(lang) => ({
lang,
enabled: true,
is_default: lang === 'en',
version_count: 1,
published_count: 1,
social_locale: null,
social_locale_state: 'not_set',
}),
),
}
},
} as never,
})
const before = reads
await fillTitle()
await submit()
expect(reads).toBe(before + 1)
expect(dialogText()).toContain('German was disabled')
expect(dialogText()).toContain('German was disabled on this site.')
expect(primaryDisabled()).toBe(true)
})
it('loc.l4.langOff on a 422 language_not_enabled', async () => {
await fail(failWith(422, 'language_not_enabled'))
expect(dialogText()).toContain(
'German was disabled on this site while you were filling this in',
)
})
it('loc.l4.unreadable: warns and asks for another source on a 404 source_unavailable', async () => {
await fail(failWith(404, 'source_unavailable'))
expect(dialogText()).toContain("The source isn't available to you")
expect(dialogText()).toContain('Choose a version')
expect(primaryDisabled()).toBe(true)
expect(dialogTitleInput().value).toBe('Kosten')
})
it.each([
['a 500', failWith(500, 'Internal server error')],
[
'a network failure',
() => {
throw new NetworkError()
},
],
['a 403', failWith(403, 'missing_permission')],
['a 400', failWith(400, 'The request cannot be processed')],
])('loc.l4.fail: keeps the entries and offers Try again after %s', async (_name, response) => {
await fail(response)
expect(dialogText()).toContain("Couldn't create the translation")
expect(dialogText()).toContain('Your entries are kept. Try again.')
expect(dialogText()).toContain('Try again')
expect(dialogTitleInput().value).toBe('Kosten')
})
it('shows the same DOM for the three neutral 409 answers', async () => {
await fail(unavailable)
const first = dialogBox()!.innerHTML.replace(/id="[^"]*"/g, '')
await unmountRoute()
await fail(failWith(409, 'language_unavailable', { existingArticleId: undefined }))
const second = dialogBox()!.innerHTML.replace(/id="[^"]*"/g, '')
expect(second).toBe(first)
})
it('closes the dialog and shows Version unavailable when the open version disappears', async () => {
await fail(failWith(404, 'Article not found'))
expect(dialogBox()).toBeNull()
expect(document.body.textContent).toContain("This version isn't available")
})
})
describe('Recovering from a blocking state', () => {
it('re-enables the primary after choosing another readable source', async () => {
await open({
extra: {
'POST /v1/article/12/translations': failWith(404, 'source_unavailable'),
} as never,
})
await fillTitle()
await submit()
field('Source version')!.vm.$emit('update:modelValue', String(IDS.fr))
await settle()
expect(dialogText()).not.toContain("The source isn't available to you")
})
it('re-enables the primary after choosing Start empty', async () => {
await open({
extra: {
'POST /v1/article/12/translations': failWith(404, 'source_unavailable'),
} as never,
})
await fillTitle()
await submit()
kit.wrapper!.findComponent({ name: 'LocaleChoiceCards' }).vm.$emit(
'update:modelValue',
'empty',
)
await settle()
expect(dialogText()).not.toContain("The source isn't available to you")
})
})
describe('The dialog is keyboard operable', () => {
it('closes on Escape, removes the parameters from the URL', async () => {
await open()
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(dialogBox()).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/article/12/edit')
})
})
describe('loc.l4.long: Long content shows what is copied', () => {
function source(authors: number, images: number) {
return {
...articleOf({}, 'en'),
authors: Array.from({ length: authors }, (_, index) => ({
id: index + 1,
name: `Author ${index + 1}`,
slug: `author-${index + 1}`,
})),
cover_image_id: images > 0 ? 1 : null,
images: Array.from({ length: images }, (_, index) => ({ id: index + 1 })),
}
}
it('shows no Authors or Images note for one author and no image', async () => {
await open({ extra: { 'GET /v1/article/10': source(1, 0) } as never })
expect(dialogText()).not.toContain('Authors')
expect(dialogText()).not.toContain('Cover image')
expect(dialogText()).not.toContain('inline image')
})
it('names the first five authors and counts the rest, and counts the images', async () => {
await open({ extra: { 'GET /v1/article/10': source(10, 8) } as never })
expect(dialogText()).toContain(
'Author 1, Author 2, Author 3, Author 4, Author 5 and 5 more will be copied. You can change them in the editor.',
)
expect(dialogText()).toContain(
'Cover image and 7 inline images are reused. Alternative text is copied and stays in English until you translate it.',
)
})
})
describe('A 401 is handled by the global handler', () => {
it('goes to login and keeps nothing about the dialog', async () => {
await open({ extra: { 'POST /v1/article/12/translations': failWith(401) } as never })
await fillTitle()
await submit()
expect(sessionStorage.length).toBe(0)
})
})
@@ -0,0 +1,313 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { initializeLocale } from '@/i18n'
import { ApiError, NetworkError } from '@/composables/useApi'
import {
callsTo,
dialogBox,
dialogText,
failWith,
kit,
press,
settle,
text,
toastText,
unmountRoute,
} from './localizationKit'
import {
READ,
articleOf,
editBody,
mountEditor,
openMoreActions,
type EditorFixture,
} from './editorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const ALL = ['en', 'fr', 'de', 'ar'] as const
const gone = null
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function menuItem(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
(item) => item.textContent?.trim().startsWith(label),
)
}
function deletes() {
return kit.calls.filter((call) => call.method === 'DELETE').map((call) => call.path)
}
function everyDelete(handler: unknown = gone): Record<string, unknown> {
return {
'DELETE /v1/article/10': handler,
'DELETE /v1/article/11': handler,
'DELETE /v1/article/12': handler,
'DELETE /v1/article/13': handler,
}
}
function open(fixture: EditorFixture = {}, extra: Record<string, unknown> = {}) {
return mountEditor({
translations: [...ALL],
...fixture,
extra: { ...everyDelete(), ...extra } as never,
})
}
async function choose(label: string) {
await openMoreActions()
menuItem(label)!.click()
await settle()
}
describe('Delete scopes: the menu follows what the owner reads', () => {
it('enables both items when four versions are readable', async () => {
await open()
await openMoreActions()
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
expect(menuItem('Delete article')!.getAttribute('aria-disabled')).toBeNull()
})
it('shows Delete article alone when only English is readable', async () => {
await open({ lang: 'en', translations: ['en'] })
await openMoreActions()
expect(menuItem('Delete article')!.getAttribute('aria-disabled')).toBeNull()
expect(menuItem('Delete English version')).toBeUndefined()
})
it('disables Delete article with the reason when 13 cannot be deleted', async () => {
await open({ siblingCaps: { ar: READ } })
await openMoreActions()
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
expect(menuItem('Delete article')!.getAttribute('aria-disabled')).toBe('true')
expect(menuItem('Delete article')!.textContent).toContain("You can't delete this article.")
})
})
describe('loc.l8.delete: Delete French version', () => {
it('asks with a danger button and deletes only this version', async () => {
await open()
await choose('Delete French version')
expect(dialogText()).toContain('Delete the French version?')
expect(dialogText()).toContain(
'"Couts de renovation" will be deleted. Other versions stay.',
)
const buttons = Array.from(dialogBox()!.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(buttons.slice(-2)).toEqual(['Cancel', 'Delete French version'])
await press('Delete French version', dialogBox()!)
expect(deletes()).toEqual(['/v1/article/12'])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(toastText()).toContain('French version deleted')
expect(toastText()).toContain('"Couts de renovation" is gone. Other versions stay.')
})
it('leaves a dirty draft without asking and discards the stored copy', async () => {
await open()
await editBody('<p>Edits</p>')
await choose('Delete French version')
await press('Delete French version', dialogBox()!)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
expect(Object.keys(sessionStorage)).toEqual([])
})
it('closes quietly and goes to the list when the version is already gone', async () => {
await open({}, { 'DELETE /v1/article/12': failWith(404, 'Article not found') })
await choose('Delete French version')
await press('Delete French version', dialogBox()!)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(toastText()).not.toContain("Couldn't")
})
})
describe('loc.l8.deleteLast: Delete the only version', () => {
it('names the language and deletes the article', async () => {
await open({ lang: 'en', translations: ['en'] })
await choose('Delete article')
expect(dialogText()).toContain('Delete the only version?')
expect(dialogText()).toContain(
'English is the only version of this article. Deleting it deletes the article.',
)
await press('Delete article', dialogBox()!)
expect(deletes()).toEqual(['/v1/article/10'])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(toastText()).toContain('Article deleted')
})
})
describe('loc.l8.deleteStoryOk: Delete the whole article', () => {
it('names the readable versions in the site order', async () => {
await open()
await choose('Delete article')
expect(dialogText()).toContain('Delete English, French, German and Arabic?')
expect(dialogText()).toContain(
"This deletes the article and every version you can read. It can't be undone.",
)
})
it('deletes the other versions one after another and the open one last', async () => {
const events: string[] = []
const slow = (id: number) => async () => {
events.push(`start ${id}`)
await new Promise((resolve) => setTimeout(resolve, 5))
events.push(`end ${id}`)
return null
}
await open(
{},
{
'DELETE /v1/article/10': slow(10),
'DELETE /v1/article/11': slow(11),
'DELETE /v1/article/12': slow(12),
'DELETE /v1/article/13': slow(13),
},
)
await choose('Delete article')
await press('Delete article', dialogBox()!)
await new Promise((resolve) => setTimeout(resolve, 60))
await settle()
expect(events).toEqual([
'start 10',
'end 10',
'start 11',
'end 11',
'start 13',
'end 13',
'start 12',
'end 12',
])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
expect(toastText()).toContain('Article deleted')
})
it('never names or requests a version the owner cannot read', async () => {
await open({ translations: ['en', 'fr'] })
await choose('Delete article')
expect(dialogText()).toContain('Delete English and French?')
await press('Delete article', dialogBox()!)
expect(deletes()).toEqual(['/v1/article/10', '/v1/article/12'])
})
})
describe('loc.l8.deleteStory: The whole-article delete is refused', () => {
it('shows the blocked dialog without a toast and switches to the version confirmation', async () => {
await open({}, { 'DELETE /v1/article/10': failWith(403, 'missing_permission') })
await choose('Delete article')
await press('Delete article', dialogBox()!)
expect(dialogText()).toContain("You can't delete this article")
expect(dialogText()).toContain(
'Deleting an article needs delete permission on every version. You can still delete the version you are editing.',
)
expect(toastText()).not.toContain('permission')
expect(deletes()).toEqual(['/v1/article/10'])
const buttons = Array.from(dialogBox()!.querySelectorAll('button')).map((button) =>
button.textContent?.trim(),
)
expect(buttons.slice(-2)).toEqual(['Close', 'Delete French version'])
await press('Delete French version', dialogBox()!)
expect(dialogText()).toContain('Delete the French version?')
})
})
describe('A whole-article delete stops half way', () => {
it.each([
['a 500', failWith(500, 'Internal server error')],
['a 403', failWith(403, 'missing_permission')],
[
'a network failure',
() => {
throw new NetworkError()
},
],
])('requests nothing more after %s and refetches the open version', async (_name, failure) => {
await open({}, { 'DELETE /v1/article/11': failure })
const reads = callsTo('GET', '/v1/article/12').length
await choose('Delete article')
await press('Delete article', dialogBox()!)
expect(deletes()).toEqual(['/v1/article/10', '/v1/article/11'])
expect(toastText()).toContain("Couldn't delete the whole article")
expect(toastText()).toContain(
'Some versions were deleted. Open the language menu to see what is left.',
)
expect(callsTo('GET', '/v1/article/12').length).toBe(reads + 1)
expect(router.currentRoute.value.path).toBe('/article/12/edit')
})
it('skips a version that is already gone and carries on to the open one', async () => {
await open({}, { 'DELETE /v1/article/11': failWith(404, 'Article not found') })
await choose('Delete article')
await press('Delete article', dialogBox()!)
expect(deletes()).toEqual([
'/v1/article/10',
'/v1/article/11',
'/v1/article/13',
'/v1/article/12',
])
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
})
it('shows Version unavailable when the open version is gone after the failure', async () => {
let reads = 0
await open(
{},
{
'DELETE /v1/article/11': failWith(500, 'Internal server error'),
'GET /v1/article/12': () => {
reads += 1
if (reads > 1) {
throw new ApiError(404, undefined, 'Article not found')
}
return articleOf({ translations: [...ALL] })
},
},
)
await choose('Delete article')
await press('Delete article', dialogBox()!)
expect(text()).toContain("This version isn't available")
})
})
describe('A single-language site keeps the plain wording', () => {
it('reads Delete article and Delete this article? without naming a language', async () => {
await open({
lang: 'en',
translations: ['en'],
languages: [{ lang: 'en', versions: 1, published: 1 }],
})
await choose('Delete article')
expect(dialogText()).toContain('Delete this article?')
expect(dialogText()).not.toContain('English')
expect(text()).not.toContain('Delete English version')
})
})
@@ -243,9 +243,9 @@ describe('loc.l5.ovf: Collapse order by width', () => {
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
'Unpublish French',
`Edit slug${NOT_AVAILABLE}`,
`Move article${NOT_AVAILABLE}`,
'Move article',
'Delete French version',
`Delete article${NOT_AVAILABLE}`,
'Delete article',
])
})
})
@@ -259,22 +259,22 @@ describe('loc.perm.createNotAll: The overflow menu follows capabilities', () =>
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
`Edit slug${NOT_AVAILABLE}`,
`Move article${NOT_AVAILABLE}`,
'Move article',
'Delete French version',
`Delete article${NOT_AVAILABLE}`,
'Delete article',
])
})
it('shows Edit slug, Move article and Delete article disabled with a reason until they are wired', async () => {
it('keeps only Edit slug disabled with a reason; Move article and both deletes are wired', async () => {
await mountEditor()
await openMoreActions()
for (const label of ['Edit slug', 'Move article', 'Delete article']) {
const item = menuItem(label)!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(item.textContent).toContain(NOT_AVAILABLE)
const slug = menuItem('Edit slug')!
expect(slug.getAttribute('aria-disabled')).toBe('true')
expect(slug.textContent).toContain(NOT_AVAILABLE)
for (const label of ['Move article', 'Delete French version', 'Delete article']) {
expect(menuItem(label)!.getAttribute('aria-disabled')).toBeNull()
}
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
})
it('disables Delete article and Move with the reason when a sibling lacks delete', async () => {
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { initializeLocale } from '@/i18n'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { settle, text, unmountRoute } from './localizationKit'
@@ -159,12 +160,19 @@ describe('loc.l5b.withAdd, noAdd and roMenu: Add translation rows', () => {
expect(text()).not.toContain('Add translation')
})
it('renders the Add translation row without doing anything when chosen', async () => {
it('opens the Add a translation dialog for the row and closes the menu', async () => {
await mountEditor({ caps: FULL })
await openLanguageMenu()
rowFor('German')!.click()
await settle()
expect(router.currentRoute.value.fullPath).toBe(
'/article/12/edit?dialog=add-translation&lang=de',
)
expect(document.body.querySelector('[role="menu"]')).toBeNull()
expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain(
'Add a translation',
)
expect(titleInput().value).toBe('Couts de renovation')
})
})
@@ -7,7 +7,6 @@ import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import {
allMetaSections,
bylines,
callsTo,
click,
createMetaBackend,
failWith,
@@ -59,6 +58,20 @@ function listboxTriggers() {
)
}
function siteField() {
return document.body.querySelector<HTMLElement>(
'button[aria-haspopup="dialog"][aria-label="Site"]',
)!
}
function siteOptions() {
return Array.from(
document.body.querySelectorAll<HTMLElement>(
'[role="listbox"][aria-label="Site"] [role="option"]',
),
)
}
function bodyOpen() {
return document.body.querySelector('[data-testid="meta-body"]') !== null
}
@@ -283,56 +296,47 @@ describe('Editor section: the Site select', () => {
await mountMeta('editor', EDITOR, { backend })
const body = textOf(document.body.querySelector('[data-testid="meta-body"]'))
expect(listboxTriggers().map(textOf)[0]).toBe('Journal · journal.renew.example')
expect(textOf(siteField())).toBe('Journal · journal.renew.example')
expect(body).toContain('Canonical host: journal.renew.example')
expect(body.indexOf('Site')).toBeLessThan(body.indexOf('Authors'))
},
)
it('lists every site with its host, the default first', async () => {
it('opens the move panel from the Site field with the sites, the current one marked', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('several', ['Inès Duarte'], 2),
})
listboxTriggers()[0]!.click()
siteField().click()
await flushPromises()
const options = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
).map(textOf)
expect(options).toEqual([
'Renew renew.example',
'Journal journal.renew.example',
'Projects projects.renew.example',
'Archive archive.renew.example',
expect(siteOptions().map((option) => textOf(option))).toEqual([
'Renew',
'Journal',
'Projects',
'Archive',
])
expect(
siteOptions()
.find((option) => textOf(option).startsWith('Journal'))!
.getAttribute('aria-selected'),
).toBe('true')
})
it('treats a change as an edit: nothing is sent until Save, then one PATCH with only site_id', async () => {
it('stages nothing when a site is chosen: Save stays disabled and no site_id is sent', async () => {
const backend = backendWith('several', ['Inès Duarte'], 1)
await mountMeta('editor', EDITOR, { backend })
expect(textOf(document.body)).toContain('Saved')
expect(saveButtonEl().disabled).toBe(true)
listboxTriggers()[0]!.click()
siteField().click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
siteOptions()
.find((option) => textOf(option).startsWith('Journal'))!
.click()
await flushPromises()
expect(textOf(document.body)).toContain('Unsaved changes')
expect(saveButtonEl().disabled).toBe(false)
expect(saveButtonEl().disabled).toBe(true)
expect(textOf(document.body)).not.toContain('Unsaved changes')
expect(patchCalls(backend)).toHaveLength(0)
saveButtonEl().click()
await flushPromises()
expect(patchCalls(backend)).toHaveLength(1)
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
expect(textOf(document.body)).toContain('Saved')
expect(textOf(document.body.querySelector('[data-testid="meta-body"]'))).toContain(
'Canonical host: journal.renew.example',
)
})
it('still loads the editor when the sites cannot be loaded, with no select and no notice', async () => {
@@ -349,26 +353,6 @@ describe('Editor section: the Site select', () => {
})
describe('Editor section: a site or author that no longer exists', () => {
it('keeps the changes, shows Save failed, an info toast, and reloads the sites', async () => {
const backend = backendWith('several', ['Inès Duarte'], 1)
backend.responders['PATCH /v1/article/12'] = failWith(404, 'site_not_found')
await mountMeta('editor', EDITOR, { backend })
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.click()
await flushPromises()
saveButtonEl().click()
await flushPromises()
expect(textOf(document.body)).toContain('Save failed')
expect(toasts()).toContain('That site no longer exists')
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
})
it('keeps the added author and shows the author toast on author_not_found', async () => {
const backend = backendWith('one', ['Inès Duarte'])
backend.responders['PATCH /v1/article/12'] = failWith(404, 'author_not_found')
@@ -396,7 +380,7 @@ describe('Editor section: the existing save behaviour treats the change like any
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.find((item) => textOf(item).includes('Camille Roche'))!
.click()
await flushPromises()
}
@@ -410,10 +394,10 @@ describe('Editor section: the existing save behaviour treats the change like any
await flushPromises()
expect(patchCalls(backend)).toHaveLength(1)
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
expect(patchCalls(backend)[0]!.body).toMatchObject({ author_ids: expect.any(Array) })
})
it('asks before leaving with an unsaved site change', async () => {
it('asks before leaving with an unsaved author change', async () => {
await changeSite(backendWith('several', ['Inès Duarte']))
const leaving = router.push('/article')
@@ -447,7 +431,7 @@ describe('Editor section: the existing save behaviour treats the change like any
}
})
it('keeps the chosen site in sessionStorage when the session expires and restores it', async () => {
it('keeps the added author in sessionStorage when the session expires and restores it', async () => {
const backend = backendWith('several', ['Inès Duarte'])
backend.responders['PATCH /v1/article/12'] = failWith(401)
await changeSite(backend)
@@ -455,7 +439,9 @@ describe('Editor section: the existing save behaviour treats the change like any
await flushPromises()
const stored = JSON.parse(sessionStorage.getItem('renewcms:article-draft:1:12')!)
expect(stored.meta).toMatchObject({ site_id: 2 })
expect(stored.meta.authors.map((author: { name: string }) => author.name)).toContain(
'Camille Roche',
)
expect(stored.body).toBeUndefined()
await unmountMeta()
@@ -463,6 +449,6 @@ describe('Editor section: the existing save behaviour treats the change like any
backend: backendWith('several', ['Inès Duarte']),
})
expect(textOf(document.body)).toContain('Unsaved changes')
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
expect(textOf(metaSection())).toContain('Camille Roche')
})
})
+522
View File
@@ -0,0 +1,522 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import { ApiError } from '@/lib/apiError'
import {
callsTo,
deferred,
dialogBox,
dialogText,
press,
settle,
text,
toastText,
unmountRoute,
} from './localizationKit'
import {
FULL,
READ,
REVISIONS,
articleOf,
buttonNamed,
editBody,
mountEditor,
openMoreActions,
type EditorFixture,
} from './editorKit'
import { languagesBody } from './localizationData'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const ALL = ['en', 'fr', 'de', 'ar'] as const
function site(id: number, key: string, name: string, isDefault = false) {
return {
id,
key,
name,
base_url: `https://${key}.example.com`,
default_social_image_id: null,
default_robots: 'index',
locale: 'en',
is_default: isDefault,
article_count: 4,
}
}
const SITES = [
site(1, 'journal', 'Journal', true),
site(2, 'atelier', 'Atelier'),
site(4, 'archive', 'Archive'),
site(3, 'notes', 'Notes'),
]
const MOVED = { story_id: 5, site: 'atelier', site_id: 2, article_ids: [10, 12, 11, 13] }
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function open(fixture: EditorFixture = {}, extra: Record<string, unknown> = {}) {
return mountEditor({
translations: [...ALL],
siblingCaps: { ar: READ },
...fixture,
extra: {
'GET /v1/site/': SITES,
'GET /v1/site/notes/languages': languagesBody('n', 'en', [
{ lang: 'en', versions: 1, published: 1 },
]),
'POST /v1/story/5/move': MOVED,
...extra,
} as never,
})
}
function listbox() {
return document.body.querySelector<HTMLElement>('[role="listbox"][aria-label="Site"]')
}
function options() {
return Array.from(listbox()?.querySelectorAll<HTMLElement>('[role="option"]') ?? [])
}
function siteField() {
return document.body.querySelector<HTMLElement>(
'button[aria-haspopup="dialog"][aria-label="Site"]',
)!
}
async function openPanel() {
siteField().click()
await settle()
}
async function choosePanelSite(name: string) {
options()
.find((option) => option.textContent?.includes(name))!
.click()
await settle()
}
async function confirmMoveTo(name: string) {
await openPanel()
await choosePanelSite(name)
await press('Move article…')
}
function moveCalls() {
return callsTo('POST', '/v1/story/5/move')
}
describe('loc.l11b.moveSelect: The panel opens from the Site field or More actions', () => {
it('lists the sites in a listbox named Site with the note of readable versions', async () => {
await open()
await openPanel()
expect(options().map((option) => option.textContent?.trim())).toEqual([
'Journal',
'Atelier',
'Archive',
'Notes',
])
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
const note = dialogText()
expect(note).toContain('Moves all language versions of this article.')
for (const line of [
'English · Published',
'French · Draft',
'German · Draft',
'Arabic · Published · Read only',
]) {
expect(note).toContain(line)
}
})
it('opens from the More actions item too', async () => {
await open({ siblingCaps: { ar: FULL } })
await openMoreActions()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => item.textContent?.trim().startsWith('Move article'))!
.click()
await settle()
expect(listbox()).not.toBeNull()
})
it('keeps Move article… disabled until another site is chosen and stages nothing', async () => {
await open()
await openPanel()
const primary = () =>
Array.from(dialogBox()!.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Move article…',
)!
expect(primary().hasAttribute('disabled')).toBe(true)
await choosePanelSite('Atelier')
expect(primary().hasAttribute('disabled')).toBe(false)
await choosePanelSite('Journal')
expect(primary().hasAttribute('disabled')).toBe(true)
expect(text()).not.toContain('Unsaved changes')
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
})
it('lists no sentence or footnote about versions the caller cannot read', async () => {
await open({ translations: ['en', 'fr', 'de'] })
await openPanel()
expect(dialogText()).toContain('English · Published')
expect(dialogText()).not.toContain('Arabic')
expect(dialogText()).not.toMatch(/can't read|hidden|unreadable/i)
})
it('moves focus into the listbox, arrows move and the panel closes on Escape', async () => {
await open()
await openPanel()
expect(document.activeElement).toBe(options()[0])
document.activeElement!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await settle()
expect(document.activeElement).toBe(options()[1])
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(listbox()).toBeNull()
})
})
describe('loc.l11b.moveConfirm, moveBusy and moveDone', () => {
it('confirms with the versions and the addresses alert', async () => {
await open()
await openPanel()
await choosePanelSite('Atelier')
await press('Move article…')
expect(dialogText()).toContain('Move to Atelier?')
expect(dialogText()).toContain('This moves every version together. Nothing is split.')
expect(dialogText()).toContain('English · Published')
expect(dialogText()).toContain('Addresses and links')
expect(dialogText()).toContain(
'Each version keeps its address. Links from the old site to it will stop working. RenewCMS does not create redirects.',
)
})
it('sends the site key and locks the dialog while moving', async () => {
const gate = deferred<unknown>()
await open({}, { 'POST /v1/story/5/move': () => gate.promise })
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(moveCalls()[0]!.body).toEqual({ site: 'atelier' })
expect(dialogText()).toContain('Moving to Atelier…')
expect(dialogText()).toContain('All 4 versions move together. Keep this page open.')
expect(dialogText()).toContain('This takes a few seconds.')
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(dialogBox()).not.toBeNull()
gate.resolve(MOVED)
await settle()
})
it('toasts, announces, refetches silently, keeps unsaved edits and saves with the new revision', async () => {
let reads = 0
await open(
{},
{
'GET /v1/article/12': () => {
reads += 1
return reads === 1
? articleOf({ translations: [...ALL] })
: { ...articleOf({ translations: [...ALL] }), site_id: 2, revision: 8 }
},
'PATCH /v1/article/12': { ...articleOf({}), revision: 9 },
},
)
await editBody('<p>Edits</p>')
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogBox()).toBeNull()
expect(toastText()).toContain('Moved to Atelier')
expect(toastText()).toContain('All 4 versions moved together.')
expect(document.body.querySelector('[data-testid="live-announcement"]')?.textContent).toBe(
'Moved to Atelier',
)
expect(reads).toBe(2)
expect(text()).toContain('Unsaved changes')
await press('Save')
expect(callsTo('PATCH', '/v1/article/12')[0]!.body).toEqual({
body: '<p>Edits</p>',
if_revision: 8,
})
expect(REVISIONS.fr).toBe(3)
})
})
describe('The silent refetch after a move', () => {
const OUT_OF_DATE = 'This page may be out of date. Reload it to see the latest.'
function announcement() {
return document.body.querySelector('[data-testid="live-announcement"]')?.textContent
}
it('retries once and keeps quiet when the second read succeeds', async () => {
let reads = 0
await open(
{},
{
'GET /v1/article/12': () => {
reads += 1
if (reads === 2) {
throw new ApiError(500, undefined, 'Internal server error')
}
return articleOf({ translations: [...ALL] })
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
await settle()
expect(reads).toBe(3)
expect(announcement()).toBe('Moved to Atelier')
})
it('announces politely that the page may be out of date after two failures and keeps edits', async () => {
let reads = 0
await open(
{},
{
'GET /v1/article/12': () => {
reads += 1
if (reads > 1) {
throw new ApiError(500, undefined, 'Internal server error')
}
return articleOf({ translations: [...ALL] })
},
},
)
await editBody('<p>Edits</p>')
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
await settle()
expect(reads).toBe(3)
expect(announcement()).toBe(OUT_OF_DATE)
expect(text()).toContain('Unsaved changes')
})
it('keeps what changed while the refetch was in flight', async () => {
const pending = deferred<ReturnType<typeof articleOf>>()
let reads = 0
await open(
{ translations: [...ALL] },
{
'GET /v1/article/12': () => {
reads += 1
return reads === 1 ? articleOf({ translations: [...ALL] }) : pending.promise
},
'PATCH /v1/article/12/publish': articleOf({
translations: [...ALL],
online: { fr: true },
}),
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
await press('Publish French')
await press('Publish French', dialogBox()!)
pending.resolve(articleOf({ translations: [...ALL] }))
await settle()
expect(buttonNamed('Unpublish French')).toBeTruthy()
})
})
describe('Refused moves keep the dialog open and move nothing', () => {
function primaryDisabled() {
return Array.from(dialogBox()!.querySelectorAll('button'))
.find((button) => button.textContent?.trim() === 'Move article')!
.hasAttribute('disabled')
}
it('loc.l11b.move: names the missing readable languages from the destination', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(422, undefined, 'language_not_enabled', {
missingLanguages: ['fr', 'de', 'ar'],
})
},
},
)
await confirmMoveTo('Notes')
await press('Move article', dialogBox()!)
expect(callsTo('GET', '/v1/site/notes/languages')).toHaveLength(1)
expect(dialogText()).toContain("Notes doesn't enable every language in this article")
expect(dialogText()).toContain(
'Notes enables English only. Enable French, German and Arabic on Notes first. Languages disabled on Journal count too. Nothing was moved.',
)
expect(primaryDisabled()).toBe(true)
})
it('never names a language the owner cannot read', async () => {
await open(
{ translations: ['en', 'fr', 'de'] },
{
'POST /v1/story/5/move': () => {
throw new ApiError(422, undefined, 'language_not_enabled', {
missingLanguages: ['fr', 'de', 'ar'],
})
},
},
)
await confirmMoveTo('Notes')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain('Enable French and German on Notes first.')
expect(dialogText()).not.toContain('Arabic')
})
it('loc.l11b.moveSlug: names the readable conflicting version with its address and no Edit slug', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(409, undefined, 'slug_conflict', {
conflictingArticles: [12],
})
},
'GET /v1/article/12': { ...articleOf({ translations: [...ALL] }), slug: 'couts' },
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain('An address is already used on Atelier')
expect(dialogText()).toContain(
'The French address "couts" is already used by another French article on Atelier. Change it before moving. Nothing was moved.',
)
expect(dialogText()).not.toContain('Edit slug')
expect(primaryDisabled()).toBe(true)
})
it('writes one sentence per conflicting readable version and skips hidden ones', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(409, undefined, 'slug_conflict', {
conflictingArticles: [12, 11, 99],
})
},
'GET /v1/article/12': { ...articleOf({ translations: [...ALL] }), slug: 'couts' },
'GET /v1/article/11': {
...articleOf({ translations: [...ALL] }, 'de'),
slug: 'kosten',
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain('The French address "couts"')
expect(dialogText()).toContain('The German address "kosten"')
expect(callsTo('GET', '/v1/article/99')).toHaveLength(0)
})
it('loc.l11b.movePerm: a neutral dialog with Close and no global toast on a 403', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(403, undefined, 'missing_permission', {
code: 'missing_permission',
})
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain("You can't move this article")
expect(dialogText()).toContain('Moving needs edit permission on every version')
expect(dialogText()).toContain('Ask an owner to move it. Nothing was changed.')
expect(toastText()).not.toContain('permission')
await press('Close', dialogBox()!)
expect(dialogBox()).toBeNull()
})
it.each([
['a 500', () => new ApiError(500)],
['a network failure', () => new TypeError('Failed to fetch')],
])("shows Couldn't move the article after %s", async (_name, make) => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw make()
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain("Couldn't move the article")
expect(dialogText()).toContain('Nothing was moved. Try again.')
})
it('refetches the sites after a 404 site_not_found', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(404, undefined, 'site_not_found')
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogText()).toContain("Couldn't move the article")
expect(callsTo('GET', '/v1/site/')).toHaveLength(2)
})
it('closes the dialog and shows Version unavailable after a 404 Article not found', async () => {
await open(
{},
{
'POST /v1/story/5/move': () => {
throw new ApiError(404, undefined, 'Article not found')
},
},
)
await confirmMoveTo('Atelier')
await press('Move article', dialogBox()!)
expect(dialogBox()).toBeNull()
expect(text()).toContain("This version isn't available")
})
})
@@ -0,0 +1,543 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { initializeLocale } from '@/i18n'
import { NetworkError } from '@/composables/useApi'
import {
WRITE_FAILS,
callsTo,
dialogBox,
dialogText,
failWith,
kit,
press,
settle,
text,
toastText,
typeInto,
unmountRoute,
} from './localizationKit'
import {
REVISIONS,
articleOf,
buttonNamed,
editBody,
mountEditor,
openLanguageMenu,
rowFor,
titleInput,
} from './editorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const KEY = 'renewcms:article-draft:7:12'
const THEIRS_AT = '2026-10-14T14:32:00Z'
const AGAIN_AT = '2026-10-14T14:41:00Z'
const THREE = ['en', 'fr', 'de'] as const
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(async () => {
vi.unstubAllGlobals()
await unmountRoute()
})
function patchBodies() {
return callsTo('PATCH', '/v1/article/12').map((call) => call.body)
}
function readBlob(blob: Blob) {
return new Promise<string>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result))
reader.readAsText(blob)
})
}
const conflict = (revision = 4) => failWith(409, 'revision_conflict', { currentRevision: revision })
function theirs(updatedAt = THEIRS_AT, revision = 4) {
return { ...articleOf({}), title: 'Couts par un autre', revision, updated_at: updatedAt }
}
function conflictRoutes(overrides: Record<string, unknown> = {}) {
let reads = 0
return {
'PATCH /v1/article/12': conflict(),
'GET /v1/article/12': () => {
reads += 1
return reads === 1 ? articleOf({}) : theirs()
},
...overrides,
} as never
}
async function openConflict(overrides: Record<string, unknown> = {}) {
await mountEditor({ extra: conflictRoutes(overrides) })
await editBody('<p>Mes changements</p>')
await press('Save')
}
describe('Every save of the open version carries if_revision', () => {
it('sends the loaded revision with the changed fields and takes the revision of the answer', async () => {
await mountEditor({
extra: { 'PATCH /v1/article/12': { ...articleOf({}), revision: 4 } },
})
await editBody('<p>One</p>')
await press('Save')
await editBody('<p>Two</p>')
await press('Save')
expect(patchBodies()).toEqual([
{ body: '<p>One</p>', if_revision: REVISIONS.fr },
{ body: '<p>Two</p>', if_revision: 4 },
])
})
it('sends it when saving before publishing', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': articleOf({ translations: [...THREE] }),
'PATCH /v1/article/12/publish': articleOf({
translations: [...THREE],
online: { fr: true },
}),
} as never,
})
await editBody('<p>One</p>')
await press('Save and publish French')
await press('Save and publish French', dialogBox()!)
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
})
it('sends it when saving before opening English', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': articleOf({ translations: [...THREE] }),
'GET /v1/article/10': articleOf({ lang: 'fr', translations: [...THREE] }, 'en'),
} as never,
})
await editBody('<p>One</p>')
await openLanguageMenu()
rowFor('English')!.click()
await settle()
await press('Save and open English')
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
})
it('sends the same revision again on Try saving again', async () => {
let failures = 1
await mountEditor({
extra: {
'PATCH /v1/article/12': () => {
if (failures-- > 0) {
throw new NetworkError()
}
return articleOf({})
},
} as never,
})
await editBody('<p>One</p>')
await press('Save')
await press('Try saving again')
expect(patchBodies()).toEqual([
{ body: '<p>One</p>', if_revision: 3 },
{ body: '<p>One</p>', if_revision: 3 },
])
})
it('sends it on the title helper Try again', async () => {
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
await typeInto(titleInput(), 'Couts reels')
await press('Save')
await press('Try again')
expect(
patchBodies().every((body) => (body as { if_revision: number }).if_revision === 3),
).toBe(true)
})
it('publishes and unpublishes with no body and leaves the base revision', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12/publish': articleOf({
translations: [...THREE],
online: { fr: true },
}),
'PATCH /v1/article/12': { ...articleOf({}), revision: 9 },
} as never,
})
await press('Publish French')
await press('Publish French', dialogBox()!)
await editBody('<p>One</p>')
await press('Save')
expect(callsTo('PATCH', '/v1/article/12/publish')[0]!.body).toBeUndefined()
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
})
})
describe('loc.l11.conflictRestored: A restored draft keeps the revision it was written against', () => {
it('sends revision 3 while the server is at 5 and shows the Restored notice in the dialog', async () => {
sessionStorage.setItem(
KEY,
JSON.stringify({
body: '<p>Stored body</p>',
revision: 3,
mirrored_at: '2026-10-14T14:18:00Z',
}),
)
await mountEditor({
extra: conflictRoutes({
'GET /v1/article/12': { ...articleOf({}), revision: 5 },
}),
})
await press('Save')
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
expect(dialogText()).toContain('Restored unsaved changes from 14:18')
expect(dialogText()).toContain(
'Your session ended. These changes were kept in this browser.',
)
})
it('uses the revision loaded now for a stored draft that has none', async () => {
sessionStorage.setItem(
KEY,
JSON.stringify({ body: '<p>Stored body</p>', mirrored_at: '2026-10-14T14:18:00Z' }),
)
await mountEditor({ extra: { 'PATCH /v1/article/12': articleOf({}) } })
await press('Save')
expect(patchBodies()[0]).toMatchObject({ if_revision: 3 })
})
})
describe('loc.conflict: A stale save opens the dialog', () => {
it('reads their version for the time and offers the four choices in order', async () => {
await openConflict()
expect(callsTo('GET', '/v1/article/12')).toHaveLength(2)
expect(dialogText()).toContain('Someone else saved this version')
expect(dialogText()).toContain(
"Someone else saved this version at 14:32. Your changes aren't saved. They stay here until you choose.",
)
const labels = Array.from(dialogBox()!.querySelectorAll('button'))
.map((button) => button.textContent?.trim())
.filter((label) => label && label !== 'Close')
expect(labels).toEqual([
'Keep my changes',
'Download my version',
'Reload theirs',
'Save over theirs',
])
expect(dialogText()).not.toMatch(/revision|\b4\b/i)
expect(text()).toContain('Unsaved changes')
})
it('starts focus on Keep my changes', async () => {
await openConflict()
expect(document.activeElement?.textContent?.trim()).toBe('Keep my changes')
})
it('fills only Keep my changes', async () => {
await openConflict()
const keep = Array.from(dialogBox()!.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Keep my changes',
)!
const others = ['Download my version', 'Reload theirs', 'Save over theirs'].map(
(label) =>
Array.from(dialogBox()!.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === label,
)!,
)
expect(others.every((button) => button.className === others[0]!.className)).toBe(true)
expect(keep.className).not.toBe(others[0]!.className)
})
it('shows all four options on a phone', async () => {
await mountEditor({ width: 390, extra: conflictRoutes() })
await editBody('<p>Mes changements</p>')
await press('Save')
expect(dialogText()).toContain('Save over theirs')
expect(dialogBox()!.className).toMatch(/max-sm:/)
})
})
describe('Conflicts during a chained action stop the chain', () => {
it('never sends publish after a conflict on Save and publish', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': conflict(),
'GET /v1/article/12': () => articleOf({ translations: [...THREE] }),
} as never,
})
await editBody('<p>One</p>')
await press('Save and publish French')
await press('Save and publish French', dialogBox()!)
expect(dialogText()).toContain('Someone else saved this version')
expect(patchBodies()).toHaveLength(1)
expect(callsTo('PATCH', '/v1/article/12/publish')).toHaveLength(0)
})
it('never requests English after a conflict on Save and open English', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': conflict(),
'GET /v1/article/12': () => articleOf({ translations: [...THREE] }),
'GET /v1/article/10': articleOf({ lang: 'fr' }, 'en'),
} as never,
})
await editBody('<p>One</p>')
await openLanguageMenu()
rowFor('English')!.click()
await settle()
await press('Save and open English')
expect(dialogText()).toContain('Someone else saved this version')
expect(patchBodies()).toHaveLength(1)
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
})
})
describe('The conflict reload fails', () => {
it.each([
['a 500', failWith(500, 'Internal server error')],
[
'a network failure',
() => {
throw new NetworkError()
},
],
])('shows Save failed with the edits kept after %s', async (_name, failure) => {
let reads = 0
await mountEditor({
extra: {
'PATCH /v1/article/12': conflict(),
'GET /v1/article/12': (call: unknown) => {
reads += 1
return reads === 1 ? articleOf({}) : (failure as (c: unknown) => unknown)(call)
},
} as never,
})
await editBody('<p>Mes changements</p>')
await press('Save')
expect(dialogBox()).toBeNull()
expect(text()).toContain('Save failed')
await press('Try saving again')
expect(patchBodies()).toHaveLength(2)
expect(patchBodies()[1]).toEqual(patchBodies()[0])
})
})
describe('Keep my changes', () => {
it('closes the dialog, sends nothing and keeps the edits; the next Save conflicts again', async () => {
await openConflict()
await press('Keep my changes')
expect(dialogBox()).toBeNull()
expect(patchBodies()).toHaveLength(1)
expect(text()).toContain('Unsaved changes')
await press('Save')
expect(patchBodies()).toHaveLength(2)
expect(dialogText()).toContain('Someone else saved this version')
})
it('treats Escape like Keep my changes and returns focus to the title', async () => {
await openConflict()
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
bubbles: true,
cancelable: true,
}),
)
await settle()
expect(dialogBox()).toBeNull()
expect(document.activeElement).toBe(titleInput())
})
})
describe('loc.l11.download: Download my version', () => {
it('downloads the title, a blank line and the body, with a toast and no request', async () => {
const blobs: Blob[] = []
URL.createObjectURL = vi.fn((blob: Blob | MediaSource) => {
blobs.push(blob as Blob)
return 'blob:conflict'
})
URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined)
await openConflict()
const requests = kit.calls.length
await press('Download my version')
expect(await readBlob(blobs[0]!)).toBe('Couts de renovation\n\nMes changements\n')
expect(kit.calls).toHaveLength(requests)
expect(URL.revokeObjectURL).not.toHaveBeenCalled()
expect(toastText()).toContain('Download started')
expect(toastText()).toContain(
'Your French title and body are saved as a text file. Your changes are still in the editor.',
)
expect(document.body.querySelector('[data-testid="live-announcement"]')?.textContent).toBe(
'Download started',
)
expect(dialogBox()).not.toBeNull()
})
})
describe('loc.l11.reloadConfirm: Reload theirs asks first', () => {
it('switches to the confirmation and discards the edits when confirmed', async () => {
await openConflict()
sessionStorage.setItem(KEY, JSON.stringify({ body: '<p>x</p>', mirrored_at: THEIRS_AT }))
await press('Reload theirs')
expect(dialogText()).toContain('Reload theirs?')
expect(dialogText()).toContain('This discards your unsaved changes.')
await press('Reload and discard my changes')
expect(dialogBox()).toBeNull()
expect(titleInput().value).toBe('Couts par un autre')
expect(text()).not.toContain('Unsaved changes')
expect(sessionStorage.getItem(KEY)).toBeNull()
await editBody('<p>Next</p>')
await press('Save')
expect(patchBodies().at(-1)).toMatchObject({ if_revision: 4 })
})
it('goes back to editing with Keep my changes from the confirmation', async () => {
await openConflict()
await press('Reload theirs')
await press('Keep my changes')
expect(dialogBox()).toBeNull()
expect(text()).toContain('Unsaved changes')
})
})
describe('loc.l11.conflict2 and Save over theirs outcomes', () => {
function routesWith(second: unknown) {
let patches = 0
let reads = 0
return {
'PATCH /v1/article/12': (call: unknown) => {
patches += 1
if (patches === 1) {
return conflict()(call as never)
}
return typeof second === 'function'
? (second as (c: unknown) => unknown)(call)
: second
},
'GET /v1/article/12': () => {
reads += 1
if (reads === 1) {
return articleOf({})
}
return reads === 2 ? theirs() : theirs(AGAIN_AT, 6)
},
} as never
}
it('sends if_revision 4, closes the dialog and takes the new revision', async () => {
await mountEditor({ extra: routesWith({ ...articleOf({}), revision: 5 }) })
await editBody('<p>Mes changements</p>')
await press('Save')
const reads = callsTo('GET', '/v1/article/12').length
await press('Save over theirs')
expect(patchBodies()[1]).toMatchObject({ body: '<p>Mes changements</p>', if_revision: 4 })
expect(callsTo('GET', '/v1/article/12')).toHaveLength(reads)
expect(dialogBox()).toBeNull()
expect(text()).toContain('Saved')
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
})
it('reads again on a second conflict, keeps the dialog and shows the newer time', async () => {
await mountEditor({
extra: routesWith(failWith(409, 'revision_conflict', { currentRevision: 6 })),
})
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(dialogText()).toContain("Couldn't replace theirs")
expect(dialogText()).toContain(
'Someone else saved this version again at 14:41, before your save went through. Nothing was overwritten. Your changes are still here.',
)
await press('Save over theirs')
expect(patchBodies()[2]).toMatchObject({ if_revision: 6 })
})
it.each([
['a 500', failWith(500, 'Internal server error')],
[
'a network failure',
() => {
throw new NetworkError()
},
],
])('keeps the dialog with a retryable alert after %s', async (_name, failure) => {
await mountEditor({ extra: routesWith(failure) })
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(dialogText()).toContain("Couldn't replace theirs")
expect(dialogText()).toContain(
'Nothing was overwritten. Your changes are still here. Try again.',
)
expect(buttonNamed('Save over theirs')!.hasAttribute('disabled')).toBe(false)
})
it('mirrors the draft and goes to login on a 401', async () => {
await mountEditor({ extra: routesWith(failWith(401)) })
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(JSON.parse(sessionStorage.getItem(KEY)!)).toMatchObject({
body: '<p>Mes changements</p>',
})
})
it('closes the dialog on a 403 and shows Save failed', async () => {
await mountEditor({ extra: routesWith(failWith(403, 'missing_permission')) })
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(dialogBox()).toBeNull()
expect(text()).toContain('Save failed')
})
it('closes the dialog and shows Version unavailable on a 404', async () => {
await mountEditor({ extra: routesWith(failWith(404, 'Article not found')) })
await editBody('<p>Mes changements</p>')
await press('Save')
await press('Save over theirs')
expect(dialogBox()).toBeNull()
expect(text()).toContain("This version isn't available")
expect(router.currentRoute.value.path).toBe('/article/12/edit')
})
})
+5 -1
View File
@@ -174,7 +174,11 @@ describe('loc.l5.titleSaving: Saving the title sends it with the other edits', (
const calls = callsTo('PATCH', '/v1/article/12')
expect(calls).toHaveLength(1)
expect(calls[0]!.body).toEqual({ title: 'Couts reels', body: '<p>New body</p>' })
expect(calls[0]!.body).toEqual({
title: 'Couts reels',
body: '<p>New body</p>',
if_revision: 3,
})
})
it('shows a spinner and "Saving title…" while in flight', async () => {
+1 -1
View File
@@ -247,7 +247,7 @@ describe('A successful create hands off to the editor with a toast', () => {
await flushPromises()
const confirmButton = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Create draft',
(button) => button.textContent?.trim() === 'Create article',
)
confirmButton?.click()
await flushPromises()
+2
View File
@@ -20,6 +20,7 @@ export const TITLES: Record<Lang, string> = {
de: 'Renovierungskosten',
}
const ONLINE: Record<Lang, boolean> = { en: true, fr: false, ar: true, de: false }
export const REVISIONS: Record<Lang, number> = { en: 7, fr: 3, ar: 5, de: 2 }
const UPDATED_AT = '2026-09-26T11:02:00Z'
const TRANSLATION_ORDER: Lang[] = ['en', 'ar', 'fr']
@@ -87,6 +88,7 @@ export function articleOf(fixture: EditorFixture, lang: Lang = fixture.lang ?? '
is_online: fixture.online?.[lang] ?? ONLINE[lang],
lang,
story_id: 5,
revision: REVISIONS[lang],
capabilities: capsOf(fixture, lang),
translations: order.map((tag) => entryOf(fixture, tag)),
site_id: 1,
+3 -2
View File
@@ -7,6 +7,7 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { ApiError, useApi } from '@/composables/useApi'
import type { ApiErrorDetails } from '@/lib/apiError'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
@@ -22,9 +23,9 @@ export type Route = unknown | ((call: Call) => unknown | Promise<unknown>)
export const kit: { wrapper: VueWrapper | null; calls: Call[] } = { wrapper: null, calls: [] }
export function failWith(status: number, body?: string): Route {
export function failWith(status: number, body?: string, details?: ApiErrorDetails): Route {
return () => {
throw new ApiError(status, undefined, body)
throw new ApiError(status, undefined, body, details)
}
}