feat(SPA): screens for Sites and Authors

This commit is contained in:
Florian Sylvain committed 2026-10-14 23:00:50 +02:00
1 parent b34a173d83
commit 3d81d2c717
82 files changed
+10676 -37

No files matched your search

+324
View File
@@ -0,0 +1,324 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import type { Author } from '@/composables/useAuthors'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
authorsFixture,
button,
callsTo,
click,
createBackend,
dialog,
failWith,
mountDirectory,
networkFailure,
openRowMenu,
pending,
rowTexts,
rows,
textOf,
typeInto,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const OWNER_DESCRIPTION =
"The names readers see on your articles. An author doesn't need an account."
const READ_ONLY_DESCRIPTION =
'The names readers see on your articles. Only an owner or admin can change authors.'
afterEach(unmountDirectory)
function listCalls(backend: ReturnType<typeof createBackend>) {
return backend.calls.filter((call) => call.method === 'GET' && call.path === '/v1/author/')
}
function manyAuthors(count: number): Author[] {
return Array.from({ length: count }, (_, index) => ({
...authorsFixture()[1]!,
id: 500 + index,
name: `Writer ${String(index).padStart(2, '0')}`,
slug: `writer-${index}`,
}))
}
describe.each(['owner', 'admin'])('Authors: default for %s', (role) => {
it('shows the header, the search field, the columns and the rows', async () => {
const backend = await mountDirectory('authors', '/authors', { role })
expect(document.body.querySelector('h1')?.textContent).toBe('Authors')
expect(document.body.textContent).toContain(OWNER_DESCRIPTION)
expect(button('Add author')).toBeTruthy()
expect(
document.body.querySelector('input[placeholder="Search by name or slug"]'),
).toBeTruthy()
const header = document.body.querySelector(
'[data-testid="directory-row"]',
)!.previousElementSibling!
expect(textOf(header)).toBe('Author Slug Account Articles')
expect(rowTexts()).toEqual([
'ID Inès Duarte ines-duarte Linked 9 articles',
'CR Camille Roche camille-roche Guest 3 articles',
'AK Dr. Alexandra Konstantinopoulou-Whitfield dr-alexandra-konstantinopoulou-whitfield Guest 1 article',
'LF Léa Fontaine lea-fontaine Guest No articles',
])
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?page=1&size=25')
})
it('puts Authors between Media and Users in the sidebar, current on /authors', async () => {
await mountDirectory('authors', '/authors', { role })
const links = Array.from(
document.body.querySelectorAll<HTMLAnchorElement>('nav[aria-label="Main"] a'),
)
const hrefs = links.map((link) => link.getAttribute('href'))
expect(hrefs.slice(2, 5)).toEqual(['/media', '/authors', '/users'])
expect(links[3]!.getAttribute('aria-current')).toBe('page')
})
it('shows a long name in full on hover', async () => {
await mountDirectory('authors', '/authors', { role })
const name = Array.from(document.body.querySelectorAll('span.truncate')).find((span) =>
textOf(span).startsWith('Dr. Alexandra'),
)!
expect(name.getAttribute('title')).toBe('Dr. Alexandra Konstantinopoulou-Whitfield')
})
})
describe('Authors: loading', () => {
it('draws skeleton rows after the delay and marks the list busy', async () => {
vi.useFakeTimers()
const backend = createBackend()
const hold = pending<unknown>()
backend.responders['GET /v1/author/'] = () => hold.promise
await mountDirectory('authors', '/authors', { backend })
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
await vi.advanceTimersByTimeAsync(SKELETON_DELAY_MS)
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(
document.body.querySelectorAll('[data-testid="directory-skeleton-row"]'),
).toHaveLength(6)
})
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Authors: error on load with %s', (_name, responder) => {
it('shows the alert and requests the list again on Try again', async () => {
const backend = createBackend()
backend.responders['GET /v1/author/'] = responder
await mountDirectory('authors', '/authors', { backend })
const alert = textOf(document.body.querySelector('[role="alert"]'))
expect(alert).toContain("Couldn't load authors")
expect(alert).toContain('Check your connection and try again.')
delete backend.responders['GET /v1/author/']
await click('Try again')
expect(listCalls(backend)).toHaveLength(2)
expect(rows()).toHaveLength(4)
})
})
describe.each([
['owner', true],
['admin', true],
['member', false],
])('Authors: empty, first run for %s', (role, canAdd) => {
it('hides the toolbar and shows the empty state', async () => {
const backend = createBackend()
backend.authors = []
await mountDirectory('authors', '/authors', { backend, role })
expect(
document.body.querySelector('input[placeholder="Search by name or slug"]'),
).toBeNull()
expect(document.body.textContent).toContain('No authors yet')
expect(document.body.textContent).toContain(
"Authors are the names readers see on articles. They don't need an account.",
)
const emptyButton = Array.from(document.body.querySelectorAll('button')).filter(
(element) => textOf(element) === 'Add author',
)
expect(emptyButton.length > 0).toBe(canAdd)
})
})
describe('Authors: no results', () => {
it('names the search and clears it from the URL', async () => {
const backend = await mountDirectory('authors', '/authors?q=zoe')
expect(document.body.textContent).toContain('No authors match "zoe"')
expect(document.body.textContent).toContain(
'Check the spelling, or search for a shorter part of the name.',
)
await click('Clear search')
await flushPromises()
expect(router.currentRoute.value.query.q).toBeUndefined()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?page=1&size=25')
})
})
describe('Authors: search and pagination in the URL', () => {
it('debounces the search, requests it and keeps it in the URL', async () => {
vi.useFakeTimers()
const backend = await mountDirectory('authors', '/authors')
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by name or slug"]',
)!
await typeInto(input, 'cam')
expect(listCalls(backend)).toHaveLength(1)
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
await flushPromises()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=cam&page=1&size=25')
expect(router.currentRoute.value.query.q).toBe('cam')
})
it('requests the page and size found in the URL', async () => {
const backend = createBackend()
backend.authors = manyAuthors(130)
await mountDirectory('authors', '/authors?q=writer&page=2&size=50', { backend })
expect(listCalls(backend)[0]!.raw).toBe('/v1/author/?q=writer&page=2&size=50')
expect(document.body.textContent).toContain('Showing 51–100 of 130')
})
it('shows the paginator only when the total exceeds 25', async () => {
await mountDirectory('authors', '/authors')
expect(document.body.textContent).not.toContain('per page')
await unmountDirectory()
const backend = createBackend()
backend.authors = manyAuthors(40)
await mountDirectory('authors', '/authors', { backend })
expect(document.body.textContent).toContain('Showing 1–25 of 40')
})
})
describe.each([['size=30'], ['page=abc'], ['page=0']])(
'Authors: invalid value in the URL (%s)',
(query) => {
it('replaces the value by its default with no notice and never reaches the API', async () => {
const backend = await mountDirectory('authors', `/authors?${query}`)
await flushPromises()
expect(router.currentRoute.value.query.size).toBeUndefined()
expect(router.currentRoute.value.query.page).toBeUndefined()
expect(listCalls(backend).map((call) => call.raw)).toEqual([
'/v1/author/?page=1&size=25',
])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
},
)
describe('Authors: row menu for an owner or admin', () => {
it('offers Edit, Link user and Delete… for a guest and Edit and Delete… for a linked author', async () => {
await mountDirectory('authors', '/authors')
await openRowMenu('Camille Roche')
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
['Edit', 'Link user', 'Delete…'],
)
})
it('lists Edit and Delete… only for a linked author', async () => {
await mountDirectory('authors', '/authors')
await openRowMenu('Inès Duarte')
expect(Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)).toEqual(
['Edit', 'Delete…'],
)
})
it('opens a bottom sheet below md', async () => {
await mountDirectory('authors', '/authors', { width: MOBILE_WIDTH })
await openRowMenu('Camille Roche')
expect(textOf(dialog()!.querySelector('h2'))).toBe('Camille Roche')
expect(dialog()!.querySelectorAll('[role="menuitem"]')).toHaveLength(3)
})
})
describe('Authors: read-only list for other roles', () => {
it('has no Add author button, no pinned button, no row menu and a chevron on each row', async () => {
await mountDirectory('authors', '/authors', { role: 'member', width: MOBILE_WIDTH })
expect(document.body.textContent).toContain(READ_ONLY_DESCRIPTION)
expect(button('Add author')).toBeUndefined()
expect(
Array.from(document.body.querySelectorAll('button[aria-label^="Actions for"]')),
).toHaveLength(0)
expect(document.body.querySelectorAll('button[aria-label^="View "]')).toHaveLength(4)
})
it('keeps search working and shows the Authors sidebar entry on desktop', async () => {
vi.useFakeTimers()
const backend = await mountDirectory('authors', '/authors', { role: 'member' })
expect(
document.body.querySelector('nav[aria-label="Main"] a[href="/authors"]'),
).not.toBeNull()
const input = document.body.querySelector<HTMLInputElement>(
'input[placeholder="Search by name or slug"]',
)!
await typeInto(input, 'lea')
await vi.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS)
await flushPromises()
expect(listCalls(backend).at(-1)!.raw).toBe('/v1/author/?q=lea&page=1&size=25')
})
})
describe('Authors: read-only detail', () => {
async function openDetail() {
await mountDirectory('authors', '/authors', { role: 'member' })
await click('View Inès Duarte')
}
it('shows the author with Close as the only action and no email', async () => {
await openDetail()
const text = textOf(dialog())
for (const copy of [
'Inès Duarte',
'Linked',
'9 articles',
'Bio',
'Website',
'Only an owner or admin can change this author.',
]) {
expect(text).toContain(copy)
}
expect(text).not.toContain('ines.duarte@renew.example')
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
const actions = Array.from(dialog()!.querySelectorAll('button')).map(textOf)
expect(actions.filter((label) => label !== '')).toEqual(['Close'])
})
it('opens the same panel from /authors?edit=ines-duarte', async () => {
await mountDirectory('authors', '/authors?edit=ines-duarte', { role: 'member' })
expect(textOf(dialog())).toContain('Only an owner or admin can change this author.')
expect(dialog()!.querySelectorAll('input, textarea')).toHaveLength(0)
})
})
void callsTo