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