feat: editor language menu, title field, header actions, publish dialogs, read-only rendering and draft recovery

This commit is contained in:
Florian Sylvain committed 2026-11-02 17:13:14 +01:00
1 parent a671b81f80
commit 339e646eb8
85 files changed
+6369 -624

No files matched your search

@@ -0,0 +1,313 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
WRITE_FAILS,
failWith,
kit,
press,
settle,
text,
typeInto,
unmountRoute,
} from './localizationKit'
import {
EDIT_ONLY,
FULL,
PUBLISH_ONLY,
READ,
articleOf,
buttonNamed,
editBody,
mountEditor,
openMoreActions,
titleInput,
type EditorFixture,
} from './editorKit'
import type { ArticleCapability } from '@/composables/useArticles'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const TABLET_WIDTH = 1024
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function primary() {
return document.body
.querySelector<HTMLElement>('[data-testid="primary-action"]')
?.textContent?.trim()
}
function saveButton() {
return document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')
}
function menuItems() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
}
function menuItem(label: string) {
return menuItems().find((item) => item.textContent?.includes(label))
}
async function dirty() {
await typeInto(titleInput(), 'Couts reels')
}
interface Row {
name: string
fixture: EditorFixture
edit?: boolean
primary: string | undefined
save: 'disabled' | 'enabled' | 'absent'
unpublish?: boolean
text?: string[]
}
const ONLINE_ALERT = 'This version is online. Saving updates the live page right away.'
const MATRIX: Row[] = [
{
name: 'clean draft with edit and publish',
fixture: {},
primary: 'Publish French',
save: 'disabled',
},
{
name: 'clean draft with edit only',
fixture: { caps: EDIT_ONLY },
primary: undefined,
save: 'disabled',
text: ['Publishing needs the publish permission'],
},
{
name: 'dirty draft with edit only',
fixture: { caps: EDIT_ONLY },
edit: true,
primary: 'Save',
save: 'absent',
text: ['Saving keeps it as a draft.'],
},
{
name: 'dirty draft with edit and publish',
fixture: {},
edit: true,
primary: 'Save and publish French',
save: 'enabled',
},
{
name: 'clean online with edit and publish',
fixture: { online: { fr: true } },
primary: undefined,
save: 'disabled',
unpublish: true,
text: [ONLINE_ALERT],
},
{
name: 'dirty online with edit and publish',
fixture: { online: { fr: true } },
edit: true,
primary: 'Save',
save: 'absent',
unpublish: true,
text: [ONLINE_ALERT],
},
{
name: 'publish without edit',
fixture: { caps: PUBLISH_ONLY },
primary: 'Publish French',
save: 'absent',
},
{
name: 'read only',
fixture: { caps: READ },
primary: undefined,
save: 'absent',
},
{
name: 'saving an online version without publish',
fixture: { caps: EDIT_ONLY, online: { fr: true } },
edit: true,
primary: 'Save',
save: 'absent',
unpublish: false,
text: [ONLINE_ALERT],
},
]
describe('loc.l5.*: Header priority and action matrix', () => {
it.each(MATRIX)('$name', async (row) => {
await mountEditor(row.fixture)
if (row.edit) {
await dirty()
}
expect(primary()).toBe(row.primary)
expect(
saveButton() === null ? 'absent' : saveButton()!.disabled ? 'disabled' : 'enabled',
).toBe(row.save)
expect(buttonNamed('Unpublish French') !== undefined).toBe(row.unpublish ?? false)
row.text?.forEach((fragment) => expect(text()).toContain(fragment))
})
it('renders at most one filled button for every combination', async () => {
const capabilitySets: ArticleCapability[][] = [READ, EDIT_ONLY, PUBLISH_ONLY, FULL]
for (const caps of capabilitySets) {
await mountEditor({ caps })
expect(
document.body.querySelectorAll('[data-testid="primary-action"]').length,
).toBeLessThanOrEqual(1)
await unmountRoute()
}
})
})
describe('loc.l5.saving and saveFail: Saving and save failed', () => {
it('marks the primary busy while saving', async () => {
await mountEditor({ extra: { 'PATCH /v1/article/12': () => new Promise(() => undefined) } })
await dirty()
await press('Save')
const busy = document.body.querySelector('[data-testid="primary-action"]')
expect(busy?.textContent).toContain('Saving…')
expect(busy?.getAttribute('aria-busy')).toBe('true')
expect(saveButton()).toBeNull()
})
it('shows the banner, "Try saving again" and publishes nothing when a save fails', async () => {
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
await editBody()
await press('Save')
expect(text()).toContain("Couldn't save French")
expect(text()).toContain(
'Your changes are kept here. Nothing was published. Try saving again.',
)
expect(primary()).toBe('Try saving again')
})
it('reads Saved and offers Publish French after a good save', async () => {
await mountEditor({
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
})
await dirty()
await press('Save')
expect(text()).toContain('Saved')
expect(primary()).toBe('Publish French')
expect(saveButton()!.disabled).toBe(true)
})
})
describe('loc.l5.ovf: Collapse order by width', () => {
const online = { online: { fr: true } }
it('keeps Unpublish French and More actions in the header at 1440', async () => {
await mountEditor(online)
expect(buttonNamed('Unpublish French')).toBeDefined()
expect(buttonNamed('More actions')).toBeDefined()
})
it('moves Unpublish into More actions at 1024', async () => {
await mountEditor({ ...online, width: TABLET_WIDTH })
expect(buttonNamed('Unpublish French')).toBeUndefined()
await openMoreActions()
expect(menuItem('Unpublish French')).toBeDefined()
})
it('puts Save, the primary and an overflow button in the bottom bar at 390', async () => {
await mountEditor({ ...online, width: MOBILE_WIDTH })
await dirty()
const bar = document.body.querySelector<HTMLElement>('.border-t.lg\\:hidden')!
expect(bar.textContent).toContain('Save')
expect(bar.querySelector('[aria-label="More actions"]')).not.toBeNull()
const targets = Array.from(bar.querySelectorAll('button'))
expect(targets.every((target) => /max-md:h-11|size-11/.test(target.className))).toBe(true)
})
it('opens the overflow as a sheet on a phone with Unpublish, Edit slug, Move and Delete', async () => {
await mountEditor({ ...online, width: MOBILE_WIDTH })
await openMoreActions()
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
'Unpublish French',
`Edit slug${NOT_AVAILABLE}`,
`Move article${NOT_AVAILABLE}`,
'Delete French version',
`Delete article${NOT_AVAILABLE}`,
])
})
})
const NOT_AVAILABLE = 'Not available yet.'
describe('loc.perm.createNotAll: The overflow menu follows capabilities', () => {
it('lists Edit slug, Move article and both delete items for the owner', async () => {
await mountEditor()
await openMoreActions()
expect(menuItems().map((item) => item.textContent?.trim())).toEqual([
`Edit slug${NOT_AVAILABLE}`,
`Move article${NOT_AVAILABLE}`,
'Delete French version',
`Delete article${NOT_AVAILABLE}`,
])
})
it('shows Edit slug, Move article and Delete article disabled with a reason until they 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)
}
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
})
it('disables Delete article and Move with the reason when a sibling lacks delete', async () => {
await mountEditor({ siblingCaps: { en: EDIT_ONLY } })
await openMoreActions()
for (const label of ['Move article', 'Delete article']) {
const item = menuItem(label)!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(item.textContent).toContain("You can't delete this article.")
}
expect(menuItem('Delete French version')!.getAttribute('aria-disabled')).toBeNull()
})
it('shows no overflow button on a read-only version', async () => {
await mountEditor({ caps: READ })
expect(buttonNamed('More actions')).toBeUndefined()
})
})
describe('loc.perm.*: Server checks still win', () => {
it('keeps the edit and reads Save failed when PATCH answers 403', async () => {
await mountEditor({
caps: EDIT_ONLY,
extra: { 'PATCH /v1/article/12': failWith(403, 'missing_permission') },
})
await dirty()
await press('Save')
expect(text()).toContain('Save failed')
expect(titleInput().value).toBe('Couts reels')
expect(kit.calls.some((call) => call.method === 'PATCH')).toBe(true)
await settle()
})
})
@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { settle, text, unmountRoute } from './localizationKit'
import {
EDIT_ONLY,
FULL,
READ,
buttonNamed,
languageTrigger,
menuRows,
mountEditor,
openLanguageMenu,
rowFor,
titleInput,
} from './editorKit'
import type { LanguageFixture } from './localizationData'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => initializeLocale())
afterEach(unmountRoute)
const LANGUAGE_NAMES = ['English', 'Arabic', 'French', 'German']
function rowNames() {
return menuRows().map((row) => row.textContent?.trim() ?? '')
}
function languageOf(row: string) {
return LANGUAGE_NAMES.find((name) => row.startsWith(name)) ?? row
}
function languageRows(tags: string[]): LanguageFixture[] {
return tags.map((lang) => ({ lang, versions: 1, published: 0 }))
}
describe('loc.l5.menu: The menu lists readable versions in API order', () => {
it('opens a menu, not a listbox, with English, Arabic and French and no German', async () => {
await mountEditor({ caps: EDIT_ONLY })
await openLanguageMenu()
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
expect(rowNames().map(languageOf)).toEqual(['English', 'Arabic', 'French'])
})
it('names the control "Language: French. Switch version"', async () => {
await mountEditor()
expect(languageTrigger()?.getAttribute('aria-label')).toBe(
'Language: French. Switch version',
)
})
it('shows each language name, code and publication badge, with the French row current', async () => {
await mountEditor({ caps: EDIT_ONLY })
await openLanguageMenu()
expect(rowNames()).toEqual([
expect.stringMatching(/^EnglishEN.*Published/),
expect.stringMatching(/^ArabicAR.*Published/),
expect.stringMatching(/^FrenchFR.*Draft/),
])
expect(rowFor('French')?.querySelector('[aria-current="true"]')).not.toBeNull()
expect(rowFor('English')?.querySelector('[aria-current="true"]')).toBeNull()
})
})
describe('loc.l5.two and loc.l5.many: Menu size', () => {
it('shows two rows without a filter on a two-language site', async () => {
await mountEditor({
caps: EDIT_ONLY,
translations: ['en', 'fr'],
languages: languageRows(['en', 'fr']),
})
await openLanguageMenu()
expect(menuRows()).toHaveLength(2)
expect(document.body.querySelector('input[placeholder="Find a language"]')).toBeNull()
})
it('shows all four rows without a filter', async () => {
await mountEditor({ caps: EDIT_ONLY, translations: ['en', 'fr', 'de', 'ar'] })
await openLanguageMenu()
expect(menuRows()).toHaveLength(4)
expect(document.body.querySelector('input[placeholder="Find a language"]')).toBeNull()
})
it('adds a filter field that narrows the rows by language name on a many-language site', async () => {
const tags = ['en', 'fr', 'de', 'ar', 'es', 'it', 'ja', 'nl', 'pl', 'pt-BR']
await mountEditor({
caps: FULL,
translations: ['en', 'fr', 'de', 'ar'],
languages: languageRows(tags),
})
await openLanguageMenu()
const filter = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Find a language"]',
)!
expect(menuRows().length).toBeGreaterThan(8)
filter.value = 'port'
filter.dispatchEvent(new Event('input', { bubbles: true }))
await settle()
expect(rowNames().map((name) => name.slice(0, 'Portuguese'.length))).toEqual(['Portuguese'])
})
})
describe('loc.perm.*: Row tags and absent rows follow each sibling capabilities', () => {
it('tags a read-only sibling as Read only and still opens it', async () => {
await mountEditor({ siblingCaps: { ar: READ } })
await openLanguageMenu()
expect(rowFor('Arabic')?.textContent).toContain('Read only')
expect(rowFor('English')?.textContent).not.toContain('Read only')
})
it('shows no German row, count or placeholder when German is not in translations', async () => {
await mountEditor({ caps: EDIT_ONLY })
await openLanguageMenu()
expect(rowFor('German')).toBeUndefined()
expect(text()).not.toContain('German')
})
it('keeps a disabled language listed and marks it as disabled', async () => {
await mountEditor({ languageEnabled: { ar: false } })
await openLanguageMenu()
expect(rowFor('Arabic')?.textContent).toContain('Disabled')
})
})
describe('loc.l5b.withAdd, noAdd and roMenu: Add translation rows', () => {
it('lists missing languages as Add translation rows when the caller can create', async () => {
await mountEditor({ caps: FULL })
await openLanguageMenu()
expect(rowFor('German')?.textContent).toContain('Add translation')
})
it('offers nothing for missing languages without create_translation', async () => {
await mountEditor({ caps: EDIT_ONLY })
await openLanguageMenu()
expect(text()).not.toContain('Add translation')
})
it('offers no Add translation row on a read-only version', async () => {
await mountEditor({ caps: READ })
await openLanguageMenu()
expect(text()).not.toContain('Add translation')
})
it('renders the Add translation row without doing anything when chosen', async () => {
await mountEditor({ caps: FULL })
await openLanguageMenu()
rowFor('German')!.click()
await settle()
expect(titleInput().value).toBe('Couts de renovation')
})
})
describe('The menu is keyboard operable', () => {
it('opens on ArrowDown with focus on the current row', async () => {
await mountEditor()
languageTrigger()!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
)
await settle()
expect(document.body.querySelector('[role="menu"]')).not.toBeNull()
expect(document.activeElement?.textContent).toContain('French')
})
it('focuses the current row when the sheet opens on a phone', async () => {
await mountEditor({ width: MOBILE_WIDTH })
await openLanguageMenu()
expect(document.activeElement?.textContent).toContain('French')
})
it('closes on Escape', async () => {
await mountEditor()
await openLanguageMenu()
document.activeElement?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
)
await settle()
expect(document.body.querySelector('[role="menu"]')).toBeNull()
})
})
describe('A single-language site shows no language control', () => {
it('hides the control, names no language and reads Publish', async () => {
await mountEditor({
lang: 'en',
translations: ['en'],
languages: languageRows(['en']),
online: { en: false },
})
expect(languageTrigger()).toBeNull()
expect(buttonNamed('Publish')).toBeDefined()
expect(text()).not.toContain('English')
expect(titleInput().getAttribute('id')).not.toBeNull()
})
})
describe('On a phone the menu opens as a sheet', () => {
it('lists the same rows in a bottom sheet', async () => {
await mountEditor({ width: MOBILE_WIDTH, caps: EDIT_ONLY })
languageTrigger()!.click()
await settle()
expect(rowNames().map(languageOf)).toEqual(['English', 'Arabic', 'French'])
})
})
@@ -109,7 +109,7 @@ describe('Editor section: loading and not found', () => {
backend.responders['GET /v1/article/12'] = failWith(404)
await mountMeta('editor', EDITOR, { backend })
expect(textOf(document.body)).toContain("This article doesn't exist")
expect(textOf(document.body)).toContain("This version isn't available")
expect(metaSection()).toBeNull()
expect(document.body.textContent).not.toContain('Site and authors')
})
@@ -454,12 +454,9 @@ describe('Editor section: the existing save behaviour treats the change like any
saveButtonEl().click()
await flushPromises()
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft-meta:12')!)).toMatchObject(
{
site_id: 2,
},
)
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBeNull()
const stored = JSON.parse(sessionStorage.getItem('renewcms:article-draft:1:12')!)
expect(stored.meta).toMatchObject({ site_id: 2 })
expect(stored.body).toBeUndefined()
await unmountMeta()
await mountMeta('editor', EDITOR, {
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import { callsTo, kit, settle, text, unmountRoute } from './localizationKit'
import {
EDIT_ONLY,
PUBLISH_ONLY,
READ,
buttonNamed,
mountEditor,
titleInput,
toolbar,
} from './editorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function pressControlS() {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true, bubbles: true }))
}
function bodyEditor() {
return document.body.querySelector('[role="textbox"][aria-multiline="true"]')
}
describe('loc.perm.readonly: A version without edit renders read-only', () => {
it('reads the banner, locks the title and body, hides the toolbar and every action', async () => {
await mountEditor({ caps: READ })
expect(text()).toContain("You can read this version. You can't edit it.")
expect(text()).toContain(
'The source reference and the versions you can read stay available.',
)
expect(titleInput().getAttribute('aria-readonly')).toBe('true')
expect(titleInput().readOnly).toBe(true)
expect(bodyEditor()?.getAttribute('aria-readonly')).toBe('true')
expect(toolbar()).toBeNull()
for (const label of [
'Save',
'Publish French',
'Unpublish French',
'Delete French version',
]) {
expect(buttonNamed(label)).toBeUndefined()
}
})
it('does nothing on Ctrl+S', async () => {
await mountEditor({ caps: READ })
pressControlS()
await settle()
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
expect(kit.calls.some((call) => call.method === 'PATCH')).toBe(false)
})
})
describe('loc.perm.publishNoEdit: Publish without edit', () => {
it('reads the notice, keeps the fields read-only and offers Publish French', async () => {
await mountEditor({ caps: PUBLISH_ONLY })
expect(text()).toContain('You can publish this version but not edit it')
expect(text()).toContain('Fields are read-only. Publish stays available.')
expect(titleInput().readOnly).toBe(true)
expect(toolbar()).toBeNull()
expect(buttonNamed('Publish French')).toBeDefined()
expect(buttonNamed('Save')).toBeUndefined()
})
})
describe('loc.perm.editNoPublish: Edit without publish', () => {
it('reads the hint, offers Save and no Publish control', async () => {
await mountEditor({ caps: EDIT_ONLY })
expect(text()).toContain('Publishing needs the publish permission')
expect(text()).toContain('Saving keeps it as a draft.')
expect(buttonNamed('Save')).toBeDefined()
expect(buttonNamed('Publish French')).toBeUndefined()
expect(toolbar()).not.toBeNull()
expect(titleInput().readOnly).toBe(false)
})
})
@@ -0,0 +1,237 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import {
WRITE_FAILS,
callsTo,
dialogBox,
dialogText,
failWith,
kit,
press,
settle,
text,
toastText,
typeInto,
unmountRoute,
} from './localizationKit'
import { articleOf, buttonNamed, editBody, mountEditor, titleInput } from './editorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const THREE = ['en', 'fr', 'de'] as const
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
const saved = () => articleOf({ translations: [...THREE] })
const published = () => articleOf({ translations: [...THREE], online: { fr: true } })
function requests() {
return kit.calls
.filter((call) => call.method === 'PATCH' || call.method === 'DELETE')
.map((call) => `${call.method} ${call.path}`)
}
async function openDialogFor(label: string) {
await press(label)
expect(dialogBox()).not.toBeNull()
}
describe('loc.l8.saveAndPublish: Save and publish saves first, then publishes only that version', () => {
function setup(extra: Record<string, unknown> = {}) {
return mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': saved(),
'PATCH /v1/article/12/publish': published(),
...extra,
} as never,
})
}
it('explains the order in the confirmation dialog', async () => {
await setup()
await editBody()
await openDialogFor('Save and publish French')
expect(dialogText()).toContain('Save and publish French?')
expect(dialogText()).toContain(
"Your unsaved changes to the French version are saved first, then only the French version goes online. English and German aren't affected.",
)
})
it('sends the save, then the publish, touches no other article and toasts', async () => {
await setup()
await editBody()
await openDialogFor('Save and publish French')
await press('Save and publish French', dialogBox()!)
expect(requests()).toEqual(['PATCH /v1/article/12', 'PATCH /v1/article/12/publish'])
await vi.waitFor(() => expect(toastText()).toContain('French published'))
expect(toastText()).toContain("Other versions weren't changed.")
})
})
describe('loc.l8.saveFailPublish: A failed save publishes nothing', () => {
it('toasts, shows the banner and the retry primary, and never calls publish', async () => {
await mountEditor({
translations: [...THREE],
extra: { 'PATCH /v1/article/12': WRITE_FAILS },
})
await editBody()
await openDialogFor('Save and publish French')
await press('Save and publish French', dialogBox()!)
expect(requests()).toEqual(['PATCH /v1/article/12'])
await vi.waitFor(() => expect(toastText()).toContain("Couldn't save French"))
expect(toastText()).toContain('Nothing was published. Your changes are kept. Try again.')
expect(text()).toContain(
'Your changes are kept here. Nothing was published. Try saving again.',
)
expect(buttonNamed('Try saving again')).toBeDefined()
expect(text()).toContain('Save failed')
})
})
describe('loc.l5.pubFail: A failed publish after a good save is not a failed save', () => {
async function failAfterSave() {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': saved(),
'PATCH /v1/article/12/publish': failWith(500, 'Internal server error'),
},
})
await editBody()
await openDialogFor('Save and publish French')
await press('Save and publish French', dialogBox()!)
}
it('says the draft is saved and offers Retry publish', async () => {
await failAfterSave()
expect(text()).toContain('Saved as a draft. Publishing failed. Try publishing again.')
expect(text()).toContain('Your edits are saved. Only the publishing step failed.')
expect(text()).toContain('Saved')
expect(text()).not.toContain('Save failed')
expect(buttonNamed('Retry publish')).toBeDefined()
})
it('sends only the publish request on Retry publish', async () => {
await failAfterSave()
await press('Retry publish')
expect(requests()).toEqual([
'PATCH /v1/article/12',
'PATCH /v1/article/12/publish',
'PATCH /v1/article/12/publish',
])
})
})
describe('loc.lang.publishDisabled: Publishing is blocked in a disabled language', () => {
it('warns after a 422 language_disabled and keeps Save working', async () => {
await mountEditor({
translations: [...THREE],
extra: {
'PATCH /v1/article/12': saved(),
'PATCH /v1/article/12/publish': failWith(422, 'language_disabled'),
},
})
await openDialogFor('Publish French')
await press('Publish French', dialogBox()!)
expect(text()).toContain('French is disabled on this site. Re-enable it to publish.')
expect(text()).toContain(
'You can still edit and save this version. Ask an owner to re-enable French.',
)
await typeInto(titleInput(), 'Couts reels')
await press('Save')
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(1)
})
it('shows the warning up front when the version language is disabled', async () => {
await mountEditor({ translations: [...THREE], languageEnabled: { fr: false } })
expect(text()).toContain('French is disabled on this site. Re-enable it to publish.')
expect((buttonNamed('Publish French') as HTMLButtonElement).disabled).toBe(true)
})
})
describe('loc.l8.publish, unpublish and fail: Publishing a clean version', () => {
it('publishes French only after confirming the dialog', async () => {
await mountEditor({
translations: [...THREE],
extra: { 'PATCH /v1/article/12/publish': published() },
})
await openDialogFor('Publish French')
expect(dialogText()).toContain('Publish French?')
expect(dialogText()).toContain(
"Only the French version goes online. English and German aren't affected.",
)
await press('Publish French', dialogBox()!)
expect(requests()).toEqual(['PATCH /v1/article/12/publish'])
})
it('unpublishes French only after confirming the dialog', async () => {
await mountEditor({
translations: [...THREE],
online: { fr: true },
extra: { 'PATCH /v1/article/12/unpublish': saved() },
})
await openDialogFor('Unpublish French')
expect(dialogText()).toContain('Unpublish French?')
expect(dialogText()).toContain(
'The French version goes back to draft and stops being public. English stays online.',
)
await press('Unpublish French', dialogBox()!)
expect(requests()).toEqual(['PATCH /v1/article/12/unpublish'])
})
it('toasts "Couldn\'t publish French" when a clean publish fails', async () => {
await mountEditor({
translations: [...THREE],
extra: { 'PATCH /v1/article/12/publish': WRITE_FAILS },
})
await openDialogFor('Publish French')
await press('Publish French', dialogBox()!)
await vi.waitFor(() => expect(toastText()).toContain("Couldn't publish French"))
expect(toastText()).toContain('Nothing changed. Try again.')
})
})
describe('loc.l8.unpublishDirty: Unpublish never saves local edits', () => {
it('explains that edits stay, sends only /unpublish and keeps the state unsaved', async () => {
await mountEditor({
translations: [...THREE],
online: { fr: true },
extra: { 'PATCH /v1/article/12/unpublish': saved() },
})
await typeInto(titleInput(), 'Couts reels')
await openDialogFor('Unpublish French')
expect(dialogText()).toContain(
'The saved French version goes offline and becomes a draft. English stays online.',
)
expect(dialogText()).toContain('Your edits stay in the editor')
expect(dialogText()).toContain(
'Your unsaved changes are kept here. Nothing is saved when you unpublish. Save them yourself when you are ready.',
)
await press('Unpublish French', dialogBox()!)
await settle()
expect(requests()).toEqual(['PATCH /v1/article/12/unpublish'])
expect(titleInput().value).toBe('Couts reels')
expect(text()).toContain('Unsaved changes')
})
})
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import {
WRITE_FAILS,
failWith,
kit,
press,
settle,
text,
typeInto,
unmountRoute,
} from './localizationKit'
import { EDIT_ONLY, READ, articleOf, editBody, mountEditor, titleInput } from './editorKit'
import { removeStoredDraftsOf } from '@/lib/storedDraft'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const KEY_FOR_7 = 'renewcms:article-draft:7:12'
const STORED = {
title: 'Stored title',
body: '<p>Stored body</p>',
mirrored_at: '2026-10-14T14:18:00Z',
}
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function store(key = KEY_FOR_7) {
sessionStorage.setItem(key, JSON.stringify(STORED))
}
function editorBody() {
return kit.wrapper!.findComponent({ name: 'ArticleEditor' }).props('modelValue')
}
describe('A 401 on save mirrors the draft for this user and article', () => {
it('stores the title and body under user 7 and article 12 with the mirror time', async () => {
await mountEditor({ extra: { 'PATCH /v1/article/12': failWith(401) } })
await typeInto(titleInput(), 'Couts reels')
await editBody('<p>Edited body</p>')
await press('Save')
const stored = JSON.parse(sessionStorage.getItem(KEY_FOR_7)!)
expect(stored).toMatchObject({ title: 'Couts reels', body: '<p>Edited body</p>' })
expect(new Date(stored.mirrored_at).getTime()).not.toBeNaN()
})
})
describe('loc.l11.restored: Restoring after login', () => {
it('shows the stored title and body, not the loaded ones, with the info alert and unsaved state', async () => {
store()
await mountEditor()
expect(titleInput().value).toBe('Stored title')
expect(editorBody()).toBe('<p>Stored body</p>')
expect(text()).toContain('Restored unsaved changes from 14:18')
expect(text()).toContain(
'You logged in again. Your French title and body were kept in this browser.',
)
expect(text()).toContain('Unsaved changes')
})
})
describe('A draft never leaks across accounts or articles', () => {
it('shows the loaded article unchanged for another account', async () => {
store()
await mountEditor({ userId: 8 })
expect(titleInput().value).toBe('Couts de renovation')
expect(text()).not.toContain('Restored unsaved changes')
expect(sessionStorage.getItem(KEY_FOR_7)).not.toBeNull()
})
it('restores nothing on another article of the same account', async () => {
store()
await mountEditor({ lang: 'en' })
expect(titleInput().value).toBe('Renovation costs')
expect(text()).not.toContain('Restored unsaved changes')
})
})
describe('When the local draft is deleted', () => {
it('removes it after a save answers 200', async () => {
store()
await mountEditor({ extra: { 'PATCH /v1/article/12': articleOf({}) } })
await press('Save')
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
})
it('removes it when GET answers 404', async () => {
store()
await mountEditor({ extra: { 'GET /v1/article/12': failWith(404, 'Article not found') } })
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
expect(text()).toContain("This version isn't available")
})
it('removes it and never renders it when the response has no edit capability', async () => {
store()
await mountEditor({ caps: READ })
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
expect(titleInput().value).toBe('Couts de renovation')
expect(text()).not.toContain('Restored unsaved changes')
})
it('removes every draft of the user on an explicit logout', () => {
sessionStorage.setItem(KEY_FOR_7, '{}')
sessionStorage.setItem('renewcms:article-draft:7:30', '{}')
sessionStorage.setItem('renewcms:article-draft:8:12', '{}')
removeStoredDraftsOf(7)
expect(sessionStorage.getItem(KEY_FOR_7)).toBeNull()
expect(sessionStorage.getItem('renewcms:article-draft:7:30')).toBeNull()
expect(sessionStorage.getItem('renewcms:article-draft:8:12')).not.toBeNull()
})
it('keeps it when a save fails with an ordinary error', async () => {
store()
await mountEditor({ caps: EDIT_ONLY, extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
await press('Save')
await settle()
expect(sessionStorage.getItem(KEY_FOR_7)).not.toBeNull()
})
})
@@ -152,8 +152,10 @@ describe('Saving state', () => {
expect(fieldByLabel(panel, 'Meta title')!.className).toContain('disabled:opacity-100')
expect(fieldByLabel(panel, 'Meta title')!.className).not.toContain('opacity-40')
expect(
textOf(document.body.querySelector('[data-testid="save-button"]')!.parentElement),
).toContain('Save')
document.body
.querySelector('[data-testid="primary-action"]')!
.getAttribute('aria-busy'),
).toBe('true')
expect(document.body.textContent).toContain('Saving…')
gate.resolve({})
await flushPromises()
@@ -212,7 +214,7 @@ describe('Save failed', () => {
expect(fieldByLabel(panel, 'Meta title')!.disabled).toBe(false)
expect(saveChangesButton(panel).disabled).toBe(false)
expect(document.body.textContent).toContain('Save failed')
expect(document.body.textContent).toContain('Retry')
expect(document.body.textContent).toContain('Try saving again')
})
})
@@ -10,6 +10,7 @@ import { typeInto } from './directoryTestKit'
import { oneSite } from './editorMetaKit'
import { fieldByLabel, openPanel, panelRoot, saveChangesButton } from './seoTestKit'
import { ownerAccess } from '@/test/access'
import type { ArticleCapability } from '@/lib/articleTypes'
function reply(status: number, body: unknown = null, text = '') {
return {
@@ -26,6 +27,7 @@ const article = {
title: 'How we price a bathroom renovation',
body: '<p>Intro</p>',
is_online: false,
capabilities: ['read', 'edit', 'publish', 'delete'] as ArticleCapability[],
site_id: 1,
authors: [{ id: 2, name: 'Inès Duarte', slug: 'ines-duarte' }],
slug: 'how-we-price-a-bathroom-renovation',
@@ -71,7 +73,7 @@ async function mountEditor() {
setViewportWidth(DESKTOP_WIDTH)
Element.prototype.scrollIntoView = vi.fn()
const session = useSessionStore()
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.session = { id: 1, verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
await router.push('/article/12/edit')
wrapper = mount(RootComponent, {
@@ -112,7 +114,7 @@ describe('A session problem during the check follows the existing rules', () =>
await mountEditor()
expect(document.body.textContent).toContain("This article doesn't exist.")
expect(document.body.textContent).toContain("This version isn't available")
expect(document.body.querySelector('[data-testid="seo-section"]')).toBeNull()
})
})
@@ -129,7 +131,7 @@ describe('Session expiry keeps the SEO draft', () => {
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
const mirrored = JSON.parse(sessionStorage.getItem('renewcms:article-draft-seo:12')!)
const mirrored = JSON.parse(sessionStorage.getItem('renewcms:article-draft:1:12')!).seo
expect(mirrored.seo_title).toBe('A title kept across the login')
wrapper?.unmount()
@@ -332,6 +332,6 @@ describe('The check could not run', () => {
backend.responders['POST /v1/article/12/seo/check'] = failWith(404)
await mountSeo(backend)
expect(document.body.textContent).toContain("This article doesn't exist.")
expect(document.body.textContent).toContain("This version isn't available")
})
})
+243
View File
@@ -0,0 +1,243 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { initializeLocale } from '@/i18n'
import {
WRITE_FAILS,
callsTo,
deferred,
failWith,
press,
settle,
text,
typeInto,
unmountRoute,
} from './localizationKit'
import {
TITLES,
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 HELPER = "Changing the title doesn't change the address."
const TITLE_REJECTED = 'Article title must be between 3 and 200 characters.'
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(unmountRoute)
function helper() {
return document.getElementById(titleInput().getAttribute('aria-describedby')!)
}
function saveEnabled() {
return !(buttonNamed('Save') as HTMLButtonElement | undefined)?.disabled
}
function primaryDisabled() {
return (document.body.querySelector('[data-testid="primary-action"]') as HTMLButtonElement)
.disabled
}
async function focusField() {
titleInput().focus()
titleInput().dispatchEvent(new FocusEvent('focus'))
await settle()
}
describe('loc.l5.title: The title replaces the heading and the old tooltip', () => {
it('is a text field labelled "Title (French)" holding the title', async () => {
await mountEditor()
expect(titleInput().tagName).toBe('TEXTAREA')
expect(titleInput().value).toBe(TITLES.fr)
expect(document.body.querySelector('label')?.textContent).toBe('Title (French)')
})
it('has no tooltip about the title being fixed anywhere', async () => {
await mountEditor()
expect(document.body.innerHTML).not.toContain("The title can't be changed after creation.")
expect(document.body.querySelector('[title]')?.getAttribute('title') ?? '').not.toContain(
'title',
)
})
it('reads "Title" on a single-language site', async () => {
await mountEditor({
lang: 'en',
translations: ['en'],
languages: [{ lang: 'en', versions: 1, published: 1 }],
})
expect(document.body.querySelector('label')?.textContent).toBe('Title')
})
})
describe('Title field states', () => {
it('loc.l5.default: is neutral with no helper and Save disabled', async () => {
await mountEditor()
expect(helper()?.textContent?.trim()).toBe('')
expect(titleInput().getAttribute('aria-invalid')).toBeNull()
expect(saveEnabled()).toBe(false)
})
it('loc.l5.focus: shows the address note with Save still disabled', async () => {
await mountEditor()
await focusField()
expect(helper()?.textContent).toContain(HELPER)
expect(saveEnabled()).toBe(false)
})
it('loc.l5.titleEdit: shows the counter "11 / 200" and enables Save while typing', async () => {
await mountEditor()
await focusField()
await typeInto(titleInput(), 'Couts reels')
expect(text()).toContain('11 / 200')
expect(helper()?.textContent).toContain(HELPER)
expect(saveEnabled()).toBe(true)
})
it('loc.l5.titleUnsaved: reads Unsaved changes once focus leaves, Save stays enabled', async () => {
await mountEditor()
await focusField()
await typeInto(titleInput(), 'Couts reels')
titleInput().dispatchEvent(new FocusEvent('blur'))
await settle()
expect(text()).toContain('Unsaved changes')
expect(helper()?.textContent).toContain(HELPER)
expect(saveEnabled()).toBe(true)
})
it('loc.l5.invalid: flags an empty title as an alert and blocks Save', async () => {
await mountEditor()
await typeInto(titleInput(), '')
expect(titleInput().getAttribute('aria-invalid')).toBe('true')
expect(helper()?.getAttribute('role')).toBe('alert')
expect(helper()?.textContent).toContain('Enter a title')
expect(primaryDisabled()).toBe(true)
})
it('loc.l5.titleShort: flags two characters with the counter "2 / 200"', async () => {
await mountEditor()
await typeInto(titleInput(), 'ab')
expect(helper()?.textContent).toContain('Use at least 3 characters')
expect(text()).toContain('2 / 200')
expect(primaryDisabled()).toBe(true)
})
it('loc.l5.titleLong: flags 203 characters with the counter "203 / 200"', async () => {
await mountEditor()
await typeInto(titleInput(), 'a'.repeat(203))
expect(helper()?.textContent).toContain('Use 200 characters or fewer')
expect(text()).toContain('203 / 200')
expect(primaryDisabled()).toBe(true)
})
it('counts code points: 200 four-byte emoji are valid', async () => {
await mountEditor()
await focusField()
await typeInto(titleInput(), '😀'.repeat(200))
expect(text()).toContain('200 / 200')
expect(titleInput().getAttribute('aria-invalid')).toBeNull()
expect(primaryDisabled()).toBe(false)
})
})
describe('loc.l5.titleSaving: Saving the title sends it with the other edits', () => {
it('sends the title and the body in one PATCH, with no slug', async () => {
await mountEditor({
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
})
await typeInto(titleInput(), 'Couts reels')
await editBody('<p>New body</p>')
await press('Save')
const calls = callsTo('PATCH', '/v1/article/12')
expect(calls).toHaveLength(1)
expect(calls[0]!.body).toEqual({ title: 'Couts reels', body: '<p>New body</p>' })
})
it('shows a spinner and "Saving title…" while in flight', async () => {
const gate = deferred<unknown>()
await mountEditor({ extra: { 'PATCH /v1/article/12': () => gate.promise } })
await typeInto(titleInput(), 'Couts reels')
await press('Save')
expect(helper()?.textContent).toContain('Saving title…')
gate.resolve({ ...articleOf({}), title: 'Couts reels' })
await settle()
})
it('updates the breadcrumb and the menu row on 200', async () => {
await mountEditor({
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
})
await typeInto(titleInput(), 'Couts reels')
await press('Save')
expect(
document.body.querySelector('nav[aria-label="breadcrumb" i]')?.textContent,
).toContain('Couts reels')
await openLanguageMenu()
expect(rowFor('French')?.textContent).toContain('Couts reels')
expect(titleInput().value).toBe('Couts reels')
})
})
describe('loc.l5.failed: A failed title save keeps the text', () => {
it('keeps the text, shows the helper and a Try again action, and still reads unsaved', async () => {
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
await typeInto(titleInput(), 'Couts reels')
await press('Save')
expect(titleInput().value).toBe('Couts reels')
expect(titleInput().getAttribute('aria-invalid')).toBe('true')
expect(helper()?.textContent).toContain("Couldn't save the title. Your text is kept.")
expect(buttonNamed('Try again')).toBeDefined()
expect(text()).toContain('Save failed')
})
it('resends the same PATCH on Try again and returns focus to the field', async () => {
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
await typeInto(titleInput(), 'Couts reels')
await press('Save')
await press('Try again')
const calls = callsTo('PATCH', '/v1/article/12')
expect(calls).toHaveLength(2)
expect(calls[1]!.body).toEqual(calls[0]!.body)
expect(document.activeElement).toBe(titleInput())
})
})
describe('loc.l5.invalid: The server rejects a title the client allowed', () => {
it('shows the plain-text message in the invalid state and keeps the edit', async () => {
await mountEditor({ extra: { 'PATCH /v1/article/12': failWith(400, TITLE_REJECTED) } })
await typeInto(titleInput(), 'Couts reels')
await press('Save')
expect(helper()?.textContent).toContain(TITLE_REJECTED)
expect(titleInput().getAttribute('aria-invalid')).toBe('true')
expect(titleInput().value).toBe('Couts reels')
})
})
@@ -0,0 +1,214 @@
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,
deferred,
dialogBox,
dialogText,
failWith,
press,
settle,
text,
toastText,
typeInto,
unmountRoute,
} from './localizationKit'
import {
TITLES,
articleOf,
buttonNamed,
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 DRAFT_KEY = 'renewcms:article-draft:7:12'
const SKELETON_DELAY_MS = 150
beforeEach(() => {
initializeLocale()
sessionStorage.clear()
})
afterEach(async () => {
vi.useRealTimers()
await unmountRoute()
})
async function chooseEnglish() {
await openLanguageMenu()
rowFor('English')!.click()
await settle()
}
async function editFrenchTitle() {
await typeInto(titleInput(), 'Couts reels')
}
describe('loc.l5.menu: Switching from a clean editor', () => {
it('requests the other version before the route changes, then shows its title', async () => {
let routeWhenRequested = ''
await mountEditor({
extra: {
'GET /v1/article/10': () => {
routeWhenRequested = router.currentRoute.value.path
return articleOf({ lang: 'fr' }, 'en')
},
},
})
await chooseEnglish()
expect(routeWhenRequested).toBe('/article/12/edit')
expect(router.currentRoute.value.path).toBe('/article/10/edit')
expect(titleInput().value).toBe(TITLES.en)
expect(callsTo('GET', '/v1/article/10')).toHaveLength(1)
})
it('announces "Editing English version" politely', async () => {
await mountEditor()
await chooseEnglish()
const live = document.body.querySelector('[data-testid="live-announcement"]')
expect(live?.textContent).toBe('Editing English version')
})
})
describe('loc.l5.loading: A slow switch shows a loading state', () => {
it('shows the loading version state after 150 ms and keeps the current version on screen', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const gate = deferred<unknown>()
await mountEditor({ extra: { 'GET /v1/article/10': () => gate.promise } })
await chooseEnglish()
expect(document.body.querySelector('[data-testid="skeleton-content"]')).toBeNull()
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await settle()
expect(document.body.querySelector('[data-testid="skeleton-content"]')).not.toBeNull()
expect(titleInput().value).toBe(TITLES.fr)
expect(router.currentRoute.value.path).toBe('/article/12/edit')
})
it('never shows a loading state when the response is faster than 150 ms', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountEditor()
await chooseEnglish()
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS * 2)
expect(document.body.querySelector('[data-testid="skeleton-content"]')).toBeNull()
})
})
describe('loc.l5.navFail: A failed switch keeps the current version', () => {
it.each([
['a 500', failWith(500, 'Internal server error')],
[
'a network failure',
() => {
throw new NetworkError()
},
],
])('toasts and stays on French after %s', async (_name, failure) => {
await mountEditor({ extra: { 'GET /v1/article/10': failure } })
await chooseEnglish()
await vi.waitFor(() => expect(toastText()).toContain("Couldn't open the English version"))
expect(toastText()).toContain("You're still editing French. Try again.")
expect(router.currentRoute.value.path).toBe('/article/12/edit')
expect(titleInput().value).toBe(TITLES.fr)
})
})
describe('loc.l5.gone: The editor opened directly on an unavailable version', () => {
it('shows only the Version unavailable state with one action to Articles', async () => {
await mountEditor({ extra: { 'GET /v1/article/12': failWith(404, 'Article not found') } })
expect(text()).toContain("This version isn't available")
expect(text()).toContain('It may have been deleted, or you may not have access.')
expect(document.body.querySelector('label')).toBeNull()
expect(buttonNamed('Save')).toBeUndefined()
buttonNamed('Back to Articles')!.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
})
})
describe('loc.l5.unsaved: Choosing another version with unsaved changes asks first', () => {
async function openDialog() {
await mountEditor()
await editFrenchTitle()
await chooseEnglish()
}
it('asks with the exact copy and the three buttons in order', async () => {
await openDialog()
expect(dialogText()).toContain('Leave without saving?')
expect(dialogText()).toContain("Your changes to the French version aren't saved.")
const labels = Array.from(dialogBox()!.querySelectorAll('button'))
.map((button) => button.textContent?.trim())
.filter((label) => label)
expect(labels).toEqual([
'Keep editing',
'Discard and open English',
'Save and open English',
])
})
it('Keep editing closes the dialog, keeps the title and sends nothing', async () => {
await openDialog()
await press('Keep editing', dialogBox()!)
expect(dialogBox()).toBeNull()
expect(router.currentRoute.value.path).toBe('/article/12/edit')
expect(titleInput().value).toBe('Couts reels')
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
})
it('Discard and open English removes the local draft and saves nothing', async () => {
sessionStorage.setItem(
DRAFT_KEY,
JSON.stringify({ title: 'Kept', mirrored_at: '2026-10-14T14:18:00Z' }),
)
await mountEditor()
await chooseEnglish()
await press('Discard and open English', dialogBox()!)
expect(sessionStorage.getItem(DRAFT_KEY)).toBeNull()
expect(router.currentRoute.value.path).toBe('/article/10/edit')
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(0)
})
it('Save and open English saves first, then opens the version', async () => {
await mountEditor({
extra: { 'PATCH /v1/article/12': { ...articleOf({}), title: 'Couts reels' } },
})
await editFrenchTitle()
await chooseEnglish()
await press('Save and open English', dialogBox()!)
expect(callsTo('PATCH', '/v1/article/12')).toHaveLength(1)
expect(router.currentRoute.value.path).toBe('/article/10/edit')
})
it('stops when the save fails: no request for article 10 and Save failed shows', async () => {
await mountEditor({ extra: { 'PATCH /v1/article/12': WRITE_FAILS } })
await editFrenchTitle()
await chooseEnglish()
await press('Save and open English', dialogBox()!)
expect(router.currentRoute.value.path).toBe('/article/12/edit')
expect(callsTo('GET', '/v1/article/10')).toHaveLength(0)
expect(text()).toContain('Save failed')
expect(titleInput().value).toBe('Couts reels')
})
})
+45 -13
View File
@@ -10,6 +10,7 @@ import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
import { ownerAccess } from '@/test/access'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -98,7 +99,7 @@ async function mountEditor(
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.session = { id: 7, verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push(path)
@@ -135,6 +136,7 @@ function paragraphOfLength(totalLength: number) {
}
beforeEach(() => {
setViewportWidth(DESKTOP_WIDTH)
vi.useFakeTimers()
vi.setSystemTime(new Date(FIXED_NOW))
})
@@ -194,7 +196,7 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
describe('Saved — a freshly loaded article, draft or online', () => {
it.each([
{ is_online: false, badge: 'Draft', publishLabel: 'Publish' },
{ is_online: true, badge: 'Online', publishLabel: 'Unpublish' },
{ is_online: true, badge: 'Published', publishLabel: 'Unpublish' },
])(
'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => {
@@ -251,7 +253,8 @@ describe('Saving — Save sends the PATCH and disables itself while in flight',
body: paragraphOfLength(4320),
})
expect(wrapper.text()).toContain('Saving…')
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
expect(saveButton(wrapper).exists()).toBe(false)
expect(wrapper.find('[data-testid="primary-action"]').attributes('aria-busy')).toBe('true')
})
})
@@ -328,14 +331,14 @@ describe('SaveStatus switches to a relative time after an hour', () => {
})
})
describe('Save failed — a 500 or a network failure keeps the changes and offers Retry', () => {
describe('Save failed — a 500 or a network failure keeps the changes and offers Try saving again', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows Save failed with Retry for $name', async ({ handler }) => {
])('shows Save failed with Try saving again for $name', async ({ handler }) => {
const wrapper = await mountEditor({
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12?format=html': handler,
@@ -345,12 +348,14 @@ describe('Save failed — a 500 or a network failure keeps the changes and offer
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
expect(wrapper.findAll('button').some((button) => button.text() === 'Retry')).toBe(true)
expect(
wrapper.findAll('button').some((button) => button.text() === 'Try saving again'),
).toBe(true)
expect(articleEditorBody(wrapper)).toBe(paragraph('Revised opening paragraph.'))
})
})
describe('Retry resends the same body and can recover', () => {
describe('Try saving again resends the same body and can recover', () => {
it('resends the same PATCH and shows Saved at the current time', async () => {
const patchCall = vi
.fn()
@@ -371,7 +376,9 @@ describe('Retry resends the same body and can recover', () => {
await saveButton(wrapper).trigger('click')
await flushPromises()
const retryButton = wrapper.findAll('button').find((button) => button.text() === 'Retry')
const retryButton = wrapper
.findAll('button')
.find((button) => button.text() === 'Try saving again')
await retryButton?.trigger('click')
await flushPromises()
@@ -448,7 +455,7 @@ describe('Very long article — no length limit', () => {
await flushPromises()
expect(wrapper.text()).toContain('Saving 120,000 characters. This can take a few seconds.')
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
expect(saveButton(wrapper).exists()).toBe(false)
})
it('reads Saved once the PATCH resolves', async () => {
@@ -601,6 +608,29 @@ describe('Esc or the overlay on the leave guard behaves like "Keep editing"', ()
})
})
describe('Leaving after a failed save still prompts', () => {
it('opens the leave dialog while Save failed is showing', async () => {
const wrapper = await mountEditor({
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'PATCH /v1/article/12?format=html': () => mockResponse(500),
})
await typeBody(wrapper, paragraph('Revised opening paragraph.'))
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Save failed')
const navigation = router.push('/article')
await flushPromises()
expect((overlayMock.getProps() as Record<string, unknown>).title).toBe(
'Leave without saving?',
)
overlayMock.resolveOpen(true)
await navigation
expect(router.currentRoute.value.path).toBe('/article/12/edit')
})
})
describe('Leaving a clean article never prompts', () => {
it('navigates directly with no ConfirmDialog', async () => {
const wrapper = await mountEditor({
@@ -627,13 +657,15 @@ describe('Session expired mid-save keeps the draft and restores it after logging
await saveButton(wrapper).trigger('click')
await flushPromises()
expect(sessionStorage.getItem('renewcms:article-draft:12')).toBe(body)
expect(JSON.parse(sessionStorage.getItem('renewcms:article-draft:7:12') ?? '{}').body).toBe(
body,
)
expect(router.currentRoute.value.path).toBe('/login')
expect(router.currentRoute.value.query.redirect).toBe('/article/12/edit')
expect(router.currentRoute.value.query.reason).toBe('expired')
const session = useSessionStore()
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.session = { id: 7, verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/12?format=html': () =>
@@ -797,13 +829,13 @@ describe('An unknown or deleted article id replaces the editor with an error sta
'/article/9999/edit',
)
expect(wrapper.text()).toContain("This article doesn't exist.")
expect(wrapper.text()).toContain("This version isn't available")
expect(wrapper.find('[data-testid="save-button"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('Publish')
const allArticlesButton = wrapper
.findAll('button')
.find((button) => button.text() === 'All articles')
.find((button) => button.text() === 'Back to Articles')
await allArticlesButton?.trigger('click')
await flushPromises()
+1 -1
View File
@@ -211,7 +211,7 @@ describe('Dismiss x: behaviour', () => {
await openPanel()
await dismiss(0)
expect(textOf(document.body)).toContain("This article doesn't exist.")
expect(textOf(document.body)).toContain("This version isn't available")
})
it('shows nothing and sends no check on a 401', async () => {
+195
View File
@@ -0,0 +1,195 @@
import RootComponent from '@/App.vue'
import { DESKTOP_WIDTH } from '@/components/__tests__/viewport'
import type { ArticleCapability } from '@/composables/useArticles'
import { storyEnvelope } from './articleEnvelope'
import { languagesBody, type LanguageFixture } from './localizationData'
import { kit, mountRoute, settle, text, type Route } from './localizationKit'
export const FULL: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
export const READ: ArticleCapability[] = ['read']
export const EDIT_ONLY: ArticleCapability[] = ['read', 'edit']
export const PUBLISH_ONLY: ArticleCapability[] = ['read', 'publish']
export type Lang = 'en' | 'fr' | 'ar' | 'de'
export const IDS: Record<Lang, number> = { en: 10, fr: 12, ar: 13, de: 11 }
export const TITLES: Record<Lang, string> = {
en: 'Renovation costs',
fr: 'Couts de renovation',
ar: 'Takalif',
de: 'Renovierungskosten',
}
const ONLINE: Record<Lang, boolean> = { en: true, fr: false, ar: true, de: false }
const UPDATED_AT = '2026-09-26T11:02:00Z'
const TRANSLATION_ORDER: Lang[] = ['en', 'ar', 'fr']
export interface EditorFixture {
lang?: Lang
caps?: ArticleCapability[]
siblingCaps?: Partial<Record<Lang, ArticleCapability[]>>
online?: Partial<Record<Lang, boolean>>
languageEnabled?: Partial<Record<Lang, boolean>>
translations?: Lang[]
body?: string
languages?: LanguageFixture[]
extra?: Record<string, Route>
userId?: number
width?: number
}
const JOURNAL_LANGUAGES: LanguageFixture[] = [
{ lang: 'en', versions: 7, published: 6 },
{ lang: 'fr', versions: 4, published: 2 },
{ lang: 'de', versions: 4, published: 3 },
{ lang: 'ar', versions: 1, published: 0 },
{ lang: 'es', enabled: false, versions: 1, published: 1 },
]
const SITE = {
id: 1,
name: 'Journal',
key: 'journal',
site_name: 'Journal',
title_template: '%title% | %site%',
default_description: '',
base_url: 'https://journal.example.com',
default_social_image_id: null,
default_robots: 'index',
locale: 'en',
is_default: true,
article_count: 4,
public_url_pattern: null,
}
function capsOf(fixture: EditorFixture, lang: Lang) {
const current = fixture.lang ?? 'fr'
return lang === current ? (fixture.caps ?? FULL) : (fixture.siblingCaps?.[lang] ?? FULL)
}
function entryOf(fixture: EditorFixture, lang: Lang) {
return {
id: IDS[lang],
lang,
title: TITLES[lang],
is_online: fixture.online?.[lang] ?? ONLINE[lang],
language_enabled: fixture.languageEnabled?.[lang] ?? true,
capabilities: capsOf(fixture, lang),
}
}
export function articleOf(fixture: EditorFixture, lang: Lang = fixture.lang ?? 'fr') {
const order = fixture.translations ?? TRANSLATION_ORDER
return {
id: IDS[lang],
title: TITLES[lang],
body: fixture.body ?? `<p>Body of ${TITLES[lang]}</p>`,
images: [],
is_online: fixture.online?.[lang] ?? ONLINE[lang],
lang,
story_id: 5,
capabilities: capsOf(fixture, lang),
translations: order.map((tag) => entryOf(fixture, tag)),
site_id: 1,
authors: [],
created_at: '2026-03-12T09:14:00Z',
updated_at: UPDATED_AT,
}
}
export function editorRoutes(fixture: EditorFixture = {}): Record<string, Route> {
const current = fixture.lang ?? 'fr'
const ready = { status: 'ready', suggestions: [], slug: { available: true, suggestion: null } }
const routes: Record<string, Route> = {
'GET /v1/site/': [SITE],
'GET /v1/site/journal/languages': languagesBody(
'journal-1',
'en',
fixture.languages ?? JOURNAL_LANGUAGES,
),
'GET /v1/author/': { items: [], total: 0, page: 1, size: 100 },
'GET /v1/article/': (call) =>
storyEnvelope([], { size: Number(call.query.get('size')) || 25 }),
'GET /v1/media/': { items: [], next_cursor: null, counts: { all: 0 } },
}
for (const lang of Object.keys(IDS) as Lang[]) {
routes[`GET /v1/article/${IDS[lang]}`] = articleOf(fixture, lang)
routes[`POST /v1/article/${IDS[lang]}/seo/check`] = ready
}
routes[`GET /v1/article/${IDS[current]}`] = articleOf(fixture, current)
return { ...routes, ...fixture.extra }
}
export async function mountEditor(fixture: EditorFixture = {}, path?: string) {
const lang = fixture.lang ?? 'fr'
await mountRoute(path ?? `/article/${IDS[lang]}/edit`, {
routes: editorRoutes(fixture),
width: fixture.width ?? DESKTOP_WIDTH,
userId: fixture.userId ?? 7,
root: RootComponent,
})
await settle()
}
export function titleInput() {
const label = Array.from(document.body.querySelectorAll('label')).find((element) =>
/^Title/.test(element.textContent ?? ''),
)
return document.getElementById(label?.getAttribute('for') ?? '') as HTMLTextAreaElement
}
export function languageTrigger() {
return document.body.querySelector<HTMLElement>('[aria-label$="Switch version"]')
}
export function pointerDown(target: Element | null) {
target?.dispatchEvent(
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
pointerType: 'mouse',
}),
)
}
export async function openLanguageMenu() {
pointerDown(languageTrigger())
languageTrigger()!.click()
await settle()
}
export async function openMoreActions() {
const trigger = document.body.querySelector<HTMLElement>('[aria-label="More actions"]')
pointerDown(trigger)
trigger!.click()
await settle()
}
export function menuRows() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
)
}
export function rowFor(name: string) {
return menuRows().find((row) => row.textContent?.trim().startsWith(name))
}
export function pageText() {
return text()
}
export function buttonNamed(label: string | RegExp) {
return Array.from(document.body.querySelectorAll<HTMLElement>('button')).find((element) => {
const name = (element.getAttribute('aria-label') || element.textContent || '')
.replace(/\s+/g, ' ')
.trim()
return typeof label === 'string' ? name === label : label.test(name)
})
}
export async function editBody(html = '<p>New body</p>') {
kit.wrapper!.findComponent({ name: 'ArticleEditor' }).vm.$emit('update:modelValue', html)
await settle()
}
export function toolbar() {
return document.body.querySelector('.article-toolbar')
}
+14 -2
View File
@@ -1,4 +1,4 @@
import type { Byline } from '@/composables/useArticles'
import type { ArticleCapability, Byline } from '@/composables/useArticles'
import type { Site } from '@/composables/useSites'
export const defaultSite: Site = {
@@ -59,7 +59,19 @@ export const camille: Byline = {
}
export const paul: Byline = { id: 8, name: 'Paul Huber', slug: 'paul-huber' }
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
const FULL_CAPABILITIES: ArticleCapability[] = [
'read',
'edit',
'publish',
'delete',
'create_translation',
]
export const articleMeta = {
site_id: defaultSite.id,
authors: [ines],
capabilities: FULL_CAPABILITIES,
}
export const readyCheck = {
status: 'ready',
+8 -6
View File
@@ -1,5 +1,5 @@
import { vi } from 'vitest'
import { defineComponent, h } from 'vue'
import { defineComponent, h, type Component } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { RouterView } from 'vue-router'
@@ -46,6 +46,8 @@ interface MountOptions {
role?: string
routes: Record<string, Route>
width?: number
userId?: number
root?: Component
}
export async function mountRoute(url: string, options: MountOptions) {
@@ -57,7 +59,7 @@ export async function mountRoute(url: string, options: MountOptions) {
session.session = {
verified: true,
username: 'marta',
id: 1,
id: options.userId ?? 1,
email: 'marta@renew.example',
displayName: 'Marta Alves',
access: accessFor(options.role ?? 'owner'),
@@ -85,10 +87,10 @@ export async function mountRoute(url: string, options: MountOptions) {
requestPaged: vi.fn(),
} as unknown as ReturnType<typeof useApi>)
await router.push(url)
kit.wrapper = mount(
defineComponent({ render: () => h(UApp, null, { default: () => h(RouterView) }) }),
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
)
const root =
options.root ??
defineComponent({ render: () => h(UApp, null, { default: () => h(RouterView) }) })
kit.wrapper = mount(root, { attachTo: document.body, global: { plugins: [router, uiPlugin] } })
await settle()
}
+1
View File
@@ -97,6 +97,7 @@ export function articleOf(id: number, title: string, overrides: Partial<Article>
title,
body: '<p>Intro</p>',
is_online: false,
capabilities: ['read', 'edit', 'publish', 'delete', 'create_translation'],
site_id: 1,
authors: bylines('Inès Duarte'),
created_at: '2026-03-12T09:14:00Z',