feat: editor language menu, title field, header actions, publish dialogs, read-only rendering and draft recovery
This commit is contained in:
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")
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user