Files
RenewCMS/web/src/views/__tests__/EditorMetaSection.spec.ts
T

469 lines
16 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import {
allMetaSections,
bylines,
callsTo,
click,
createMetaBackend,
failWith,
metaSection,
mountMeta,
patchCalls,
pending,
renewSites,
saveButtonEl,
textOf,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
afterEach(unmountMeta)
const EDITOR = '/article/12/edit'
function backendWith(sites: 'one' | 'several', authors: string[], siteId = 1): MetaBackend {
const backend = createMetaBackend({
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
})
backend.article.authors = bylines(...authors)
backend.article.site_id = siteId
return backend
}
function summary() {
return textOf(document.body.querySelector('[data-testid="meta-summary"]'))
}
function toggle() {
return document.body.querySelector<HTMLElement>('[data-testid="meta-toggle"]')!
}
function listboxTriggers() {
return Array.from(
document.body.querySelectorAll<HTMLElement>('button[aria-haspopup="listbox"]'),
)
}
function bodyOpen() {
return document.body.querySelector('[data-testid="meta-body"]') !== null
}
function toasts() {
return Array.from(document.body.querySelectorAll('[data-slot="viewport"]'))
.map(textOf)
.join(' ')
}
describe('Editor section: collapsed by default with one site and an author', () => {
it('shows the one-line summary in the details sidebar, above Created, and no Site select', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('one', ['Inès Duarte']),
})
const details = document.body.querySelector('[data-testid="article-details"]')!
const section = metaSection()!
expect(details.contains(section)).toBe(true)
expect(textOf(section)).toContain('Site and authors')
expect(summary()).toBe('Inès Duarte')
expect(toggle().getAttribute('aria-expanded')).toBe('false')
expect(bodyOpen()).toBe(false)
expect(listboxTriggers()).toHaveLength(0)
const text = textOf(details)
expect(text.indexOf('Site and authors')).toBeLessThan(text.indexOf('Created'))
expect(text.indexOf('Details')).toBeLessThan(text.indexOf('Site and authors'))
})
})
describe('Editor section: loading and not found', () => {
it('shows the header with a skeleton summary while the article is pending', async () => {
const backend = backendWith('one', ['Inès Duarte'])
const gate = pending<unknown>()
backend.responders['GET /v1/article/12'] = () => gate.promise
await mountMeta('editor', EDITOR, { backend })
await new Promise((resolve) => setTimeout(resolve, 200))
await flushPromises()
const skeleton = document.body.querySelector('[data-testid="skeleton-details"]')!
expect(textOf(skeleton)).toContain('Site and authors')
expect(skeleton.querySelector('[data-testid="skeleton-meta"]')).not.toBeNull()
expect(metaSection()).toBeNull()
})
it('shows no section in the Not found state', async () => {
const backend = backendWith('one', ['Inès Duarte'])
backend.responders['GET /v1/article/12'] = failWith(404)
await mountMeta('editor', EDITOR, { backend })
expect(textOf(document.body)).toContain("This article doesn't exist")
expect(metaSection()).toBeNull()
expect(document.body.textContent).not.toContain('Site and authors')
})
})
describe('Editor section: it opens by itself when it needs attention', () => {
it.each([
{ sites: 'one', authors: ['Inès Duarte'], state: 'collapsed' },
{ sites: 'several', authors: ['Inès Duarte'], state: 'open' },
{ sites: 'one', authors: [], state: 'open' },
{ sites: 'several', authors: [], state: 'open' },
] as const)(
'$sites site(s) and $authors.length author(s): $state',
async ({ sites, authors, state }) => {
await mountMeta('editor', EDITOR, {
backend: backendWith(sites, [...authors]),
})
expect(bodyOpen()).toBe(state === 'open')
expect(toggle().getAttribute('aria-expanded')).toBe(String(state === 'open'))
},
)
it('stays open while the user adds the first author, instead of collapsing under them', async () => {
await mountMeta('editor', EDITOR, { backend: backendWith('one', []) })
expect(bodyOpen()).toBe(true)
document.body.querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
await flushPromises()
const option = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
).find((item) => textOf(item).includes('Camille Roche'))!
option.click()
await flushPromises()
expect(bodyOpen()).toBe(true)
expect(textOf(metaSection())).toContain('Camille Roche')
})
})
describe('Editor section: the choice is remembered per browser', () => {
it('keeps an opening for the next article, and a collapse over every auto-open, without any request', async () => {
const backend = backendWith('one', ['Inès Duarte'])
await mountMeta('editor', EDITOR, { backend })
toggle().click()
await flushPromises()
expect(bodyOpen()).toBe(true)
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true')
await unmountMeta()
await mountMeta('editor', '/article/13/edit', {
backend: backendWith('one', ['Inès Duarte']),
})
expect(bodyOpen()).toBe(true)
toggle().click()
await flushPromises()
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('false')
await unmountMeta()
const crowded = backendWith('several', [])
await mountMeta('editor', EDITOR, { backend: crowded })
expect(bodyOpen()).toBe(false)
expect(summary()).toContain('No authors')
expect(
crowded.calls.filter(
(call) => call.method !== 'GET' || call.path.startsWith('/v1/author'),
),
).toEqual([])
})
})
describe('Editor section: mobile, inside the Details sheet', () => {
function openSheet() {
return click('Details')
}
function sheet() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')
}
it('hides the desktop sidebar, starts closed in the sheet and shares the choice with it', async () => {
await mountMeta('editor', EDITOR, {
width: MOBILE_WIDTH,
backend: backendWith('several', ['Inès Duarte'], 2),
})
const sidebar = document.body.querySelector('[data-testid="article-details"]')!
expect(sidebar.className).toContain('hidden')
expect(sheet()).toBeNull()
await openSheet()
const inSheet = sheet()!.querySelector<HTMLElement>('[data-testid="meta-section"]')!
expect(inSheet.querySelector('[data-testid="meta-body"]')).toBeNull()
expect(textOf(inSheet)).toContain('Journal · Inès Duarte')
inSheet.querySelector<HTMLElement>('[data-testid="meta-toggle"]')!.click()
await flushPromises()
expect(inSheet.querySelector('[data-testid="meta-body"]')).not.toBeNull()
expect(localStorage.getItem(META_SECTION_OPEN_KEY)).toBe('true')
sheet()!.querySelector<HTMLElement>('button[aria-label="Close"]')!.click()
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 400))
await openSheet()
const again = sheet()!.querySelector<HTMLElement>('[data-testid="meta-section"]')!
expect(again.querySelector('[data-testid="meta-body"]')).not.toBeNull()
expect(allMetaSections().length).toBeGreaterThanOrEqual(2)
})
})
describe('Editor section: collapsed summary variants', () => {
it.each([
{
sites: 'one',
authors: ['Inès Duarte'],
siteId: 1,
line: 'Inès Duarte',
},
{
sites: 'one',
authors: ['Inès Duarte', 'Jonas Pereira', 'Camille Roche'],
siteId: 1,
line: 'Inès Duarte +2',
},
{ sites: 'one', authors: [], siteId: 1, line: 'No authors' },
{
sites: 'several',
authors: ['Inès Duarte'],
siteId: 2,
line: 'Journal · Inès Duarte',
},
{
sites: 'several',
authors: [],
siteId: 2,
line: 'Journal · No authors',
},
] as const)(
'$sites site(s), $authors.length author(s): "$line"',
async ({ sites, authors, siteId, line }) => {
localStorage.setItem(META_SECTION_OPEN_KEY, 'false')
await mountMeta('editor', EDITOR, {
backend: backendWith(sites, [...authors], siteId),
})
expect(summary()).toBe(line)
},
)
})
describe('Editor section: the Site select', () => {
it('is absent with one site and the Authors list is the first control', async () => {
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
await mountMeta('editor', EDITOR, {
backend: backendWith('one', ['Inès Duarte']),
})
const body = document.body.querySelector('[data-testid="meta-body"]')!
expect(textOf(body)).not.toContain('Canonical host')
expect(textOf(body.firstElementChild)).toContain('Authors')
expect(listboxTriggers().map(textOf)).toEqual(['Add an author'])
})
it.each(['owner', 'admin', 'verified'] as const)(
'is above the authors with the canonical host as helper text for a %s',
async (role) => {
const backend = backendWith('several', ['Inès Duarte'], 2)
backend.me.role = role === 'verified' ? undefined : role
await mountMeta('editor', EDITOR, { backend })
const body = textOf(document.body.querySelector('[data-testid="meta-body"]'))
expect(listboxTriggers().map(textOf)[0]).toBe('Journal · journal.renew.example')
expect(body).toContain('Canonical host: journal.renew.example')
expect(body.indexOf('Site')).toBeLessThan(body.indexOf('Authors'))
},
)
it('lists every site with its host, the default first', async () => {
await mountMeta('editor', EDITOR, {
backend: backendWith('several', ['Inès Duarte'], 2),
})
listboxTriggers()[0]!.click()
await flushPromises()
const options = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
).map(textOf)
expect(options).toEqual([
'Renew renew.example',
'Journal journal.renew.example',
'Projects projects.renew.example',
'Archive archive.renew.example',
])
})
it('treats a change as an edit: nothing is sent until Save, then one PATCH with only site_id', async () => {
const backend = backendWith('several', ['Inès Duarte'], 1)
await mountMeta('editor', EDITOR, { backend })
expect(textOf(document.body)).toContain('Saved')
expect(saveButtonEl().disabled).toBe(true)
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.click()
await flushPromises()
expect(textOf(document.body)).toContain('Unsaved changes')
expect(saveButtonEl().disabled).toBe(false)
expect(patchCalls(backend)).toHaveLength(0)
saveButtonEl().click()
await flushPromises()
expect(patchCalls(backend)).toHaveLength(1)
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
expect(textOf(document.body)).toContain('Saved')
expect(textOf(document.body.querySelector('[data-testid="meta-body"]'))).toContain(
'Canonical host: journal.renew.example',
)
})
it('still loads the editor when the sites cannot be loaded, with no select and no notice', async () => {
const backend = backendWith('several', ['Inès Duarte'])
backend.responders['GET /v1/site/'] = failWith(500)
await mountMeta('editor', EDITOR, { backend })
expect(textOf(document.body)).toContain('How we price a bathroom renovation')
expect(summary()).toBe('Inès Duarte')
expect(listboxTriggers()).toHaveLength(0)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(toasts()).toBe('')
})
})
describe('Editor section: a site or author that no longer exists', () => {
it('keeps the changes, shows Save failed, an info toast, and reloads the sites', async () => {
const backend = backendWith('several', ['Inès Duarte'], 1)
backend.responders['PATCH /v1/article/12'] = failWith(404, 'site_not_found')
await mountMeta('editor', EDITOR, { backend })
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.click()
await flushPromises()
saveButtonEl().click()
await flushPromises()
expect(textOf(document.body)).toContain('Save failed')
expect(toasts()).toContain('That site no longer exists')
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(2)
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
})
it('keeps the added author and shows the author toast on author_not_found', async () => {
const backend = backendWith('one', ['Inès Duarte'])
backend.responders['PATCH /v1/article/12'] = failWith(404, 'author_not_found')
localStorage.setItem(META_SECTION_OPEN_KEY, 'true')
await mountMeta('editor', EDITOR, { backend })
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).includes('Camille Roche'))!
.click()
await flushPromises()
saveButtonEl().click()
await flushPromises()
expect(textOf(document.body)).toContain('Save failed')
expect(toasts()).toContain('That author no longer exists')
expect(textOf(metaSection())).toContain('Camille Roche')
})
})
describe('Editor section: the existing save behaviour treats the change like any other edit', () => {
async function changeSite(backend: MetaBackend) {
await mountMeta('editor', EDITOR, { backend })
listboxTriggers()[0]!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Journal'))!
.click()
await flushPromises()
}
it('offers Save and publish and saves with Ctrl+S', async () => {
const backend = backendWith('several', ['Inès Duarte'])
await changeSite(backend)
expect(textOf(document.body)).toContain('Save and publish')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 's', ctrlKey: true }))
await flushPromises()
expect(patchCalls(backend)).toHaveLength(1)
expect(patchCalls(backend)[0]!.body).toEqual({ site_id: 2 })
})
it('asks before leaving with an unsaved site change', async () => {
await changeSite(backendWith('several', ['Inès Duarte']))
const leaving = router.push('/article')
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 50))
await flushPromises()
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain(
'Leave without saving?',
)
await click('Keep editing')
await leaving
expect(router.currentRoute.value.path).toBe(EDITOR)
})
it('still loads the article when sessionStorage throws', async () => {
const spies = ['getItem', 'setItem', 'removeItem'].map((method) =>
vi.spyOn(Storage.prototype, method as 'getItem').mockImplementation(() => {
throw new Error('storage blocked')
}),
)
try {
await mountMeta('editor', EDITOR, {
backend: backendWith('several', ['Inès Duarte']),
})
expect(metaSection()).not.toBeNull()
expect(textOf(document.body)).not.toContain('Unsaved changes')
} finally {
spies.forEach((spy) => spy.mockRestore())
}
})
it('keeps the chosen site in sessionStorage when the session expires and restores it', async () => {
const backend = backendWith('several', ['Inès Duarte'])
backend.responders['PATCH /v1/article/12'] = failWith(401)
await changeSite(backend)
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()
await unmountMeta()
await mountMeta('editor', EDITOR, {
backend: backendWith('several', ['Inès Duarte']),
})
expect(textOf(document.body)).toContain('Unsaved changes')
expect(listboxTriggers()[0]!.textContent).toContain('Journal')
})
})