feat: sites and authors integration into existing screens (& formated all frontend files kinda accidentally)
This commit is contained in:
1 parent
3d81d2c717
commit
6b8990c55e
239 files changed
+9194
-1354
No files matched your search
@@ -0,0 +1,468 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user