feat(SPA): screens for Sites and Authors
This commit is contained in:
1 parent
b34a173d83
commit
3d81d2c717
82 files changed
+10676
-37
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user