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,372 @@
|
||||
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 type { Article } from '@/composables/useArticles'
|
||||
import {
|
||||
articleOf,
|
||||
bylines,
|
||||
callsTo,
|
||||
createMetaBackend,
|
||||
failWith,
|
||||
mountMeta,
|
||||
renewSites,
|
||||
textOf,
|
||||
typeInto,
|
||||
unmountMeta,
|
||||
type MetaBackend,
|
||||
} from './metaTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
const SEARCH_WAIT_MS = 320
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(unmountMeta)
|
||||
|
||||
const FIVE = ['Inès Duarte', 'Jonas Pereira', 'Camille Roche', 'Paul Huber', 'Léa Fontaine']
|
||||
|
||||
function backendWith(sites: 'one' | 'several', articles: Article[]): MetaBackend {
|
||||
const backend = createMetaBackend({
|
||||
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
|
||||
})
|
||||
backend.articles = articles
|
||||
return backend
|
||||
}
|
||||
|
||||
function withAuthors(id: number, names: string[], overrides: Partial<Article> = {}) {
|
||||
return articleOf(id, `Article ${id}`, {
|
||||
authors: bylines(...names),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function bodyRows() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
|
||||
}
|
||||
|
||||
function cell(row: HTMLElement, index: number) {
|
||||
return row.children[index] as HTMLElement
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return Array.from(document.body.querySelectorAll('thead th')).map(textOf)
|
||||
}
|
||||
|
||||
function siteSelect() {
|
||||
return document.body.querySelector<HTMLElement>('button[aria-label="Site"]')
|
||||
}
|
||||
|
||||
function listCalls(backend: MetaBackend) {
|
||||
return callsTo(backend, 'GET', '/v1/article/')
|
||||
}
|
||||
|
||||
async function chooseSite(label: string) {
|
||||
siteSelect()!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item) === label)!
|
||||
.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function currentQuery() {
|
||||
return { ...router.currentRoute.value.query }
|
||||
}
|
||||
|
||||
describe('Articles: the Authors column on every frame', () => {
|
||||
it.each([
|
||||
{ authors: 'no authors', names: [], avatars: 0, more: '', text: '—' },
|
||||
{
|
||||
authors: 'one author',
|
||||
names: FIVE.slice(0, 1),
|
||||
avatars: 1,
|
||||
more: '',
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
authors: 'three',
|
||||
names: FIVE.slice(0, 3),
|
||||
avatars: 3,
|
||||
more: '',
|
||||
text: '',
|
||||
},
|
||||
{ authors: 'five', names: FIVE, avatars: 3, more: '+2', text: '' },
|
||||
])('$authors: $avatars avatar(s) and "$more"', async ({ names, avatars, more, text }) => {
|
||||
await mountMeta('articles', '/article', {
|
||||
backend: backendWith('one', [withAuthors(1, names)]),
|
||||
})
|
||||
|
||||
const authorsCell = cell(bodyRows()[0]!, 1)
|
||||
const stack = authorsCell.querySelector('[data-testid="author-stack"]')
|
||||
expect(stack?.querySelectorAll('[aria-hidden="true"]').length ?? 0).toBe(avatars)
|
||||
expect(
|
||||
authorsCell.querySelector('[data-testid="author-stack-more"]')?.textContent ?? '',
|
||||
).toBe(more)
|
||||
if (text) {
|
||||
expect(textOf(authorsCell)).toBe(text)
|
||||
expect(authorsCell.querySelector('[aria-label="No authors"]')).not.toBeNull()
|
||||
}
|
||||
expect(headers()[1]).toBe('Authors')
|
||||
expect(authorsCell.querySelector('[title]')?.getAttribute('title')).toBe(names.join(', '))
|
||||
})
|
||||
|
||||
it('draws avatars with the first name and no site on the mobile cards', async () => {
|
||||
await mountMeta('articles', '/article', {
|
||||
width: MOBILE_WIDTH,
|
||||
backend: backendWith('several', [
|
||||
withAuthors(1, FIVE.slice(0, 3), { site_id: 2 }),
|
||||
withAuthors(2, []),
|
||||
]),
|
||||
})
|
||||
|
||||
const cards = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('li [data-row-title]'),
|
||||
).map((title) => title.closest('li') as HTMLElement)
|
||||
expect(textOf(cards[0])).toContain('Inès Duarte +2')
|
||||
expect(
|
||||
cards[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
|
||||
).toHaveLength(3)
|
||||
expect(textOf(cards[1])).toContain('No authors')
|
||||
expect(textOf(cards[0])).not.toContain('Journal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Articles: the Site column and the Site filter', () => {
|
||||
it('has neither with one site', async () => {
|
||||
await mountMeta('articles', '/article', {
|
||||
backend: backendWith('one', [withAuthors(1, ['Inès Duarte'])]),
|
||||
})
|
||||
|
||||
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
|
||||
expect(siteSelect()).toBeNull()
|
||||
expect(textOf(document.body)).not.toContain('All sites')
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin', 'verified'] as const)(
|
||||
'shows a read-only Site column and a Site select for a %s with several sites',
|
||||
async (role) => {
|
||||
const backend = backendWith('several', [
|
||||
withAuthors(1, ['Inès Duarte'], { site_id: 2 }),
|
||||
withAuthors(2, ['Inès Duarte'], { site_id: 1 }),
|
||||
])
|
||||
backend.me.role = role === 'verified' ? undefined : role
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(headers()).toEqual([
|
||||
'Title',
|
||||
'Authors',
|
||||
'Site',
|
||||
'Status',
|
||||
'Created',
|
||||
'Updated',
|
||||
'',
|
||||
])
|
||||
expect(bodyRows().map((row) => textOf(cell(row, 2)))).toEqual(['Journal', 'Renew'])
|
||||
expect(cell(bodyRows()[0]!, 2).querySelector('button, a, input')).toBeNull()
|
||||
siteSelect()!.click()
|
||||
await flushPromises()
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll('[role="option"]')).map(textOf),
|
||||
).toEqual(['All sites', 'Renew', 'Journal', 'Projects', 'Archive'])
|
||||
},
|
||||
)
|
||||
|
||||
it('drops the Site column and filter when the sites cannot be loaded', async () => {
|
||||
const backend = backendWith('several', [withAuthors(1, ['Inès Duarte'])])
|
||||
backend.responders['GET /v1/site/'] = failWith(500)
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
|
||||
expect(siteSelect()).toBeNull()
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Articles: choosing a site', () => {
|
||||
function journalAndRenew(count: number) {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
withAuthors(index + 1, ['Inès Duarte'], {
|
||||
site_id: index % 2 === 0 ? 2 : 1,
|
||||
is_online: index % 3 !== 0,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it('puts site=journal in the URL without the page, and asks for that site with the same q, status and sort', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(40))
|
||||
await mountMeta('articles', '/article?page=3&size=5&status=online&sort=title:asc', {
|
||||
backend,
|
||||
})
|
||||
const before = textOf(document.body.querySelector('[role="tablist"]'))
|
||||
|
||||
await chooseSite('Journal')
|
||||
|
||||
expect(currentQuery()).toEqual({
|
||||
site: 'journal',
|
||||
size: '5',
|
||||
status: 'online',
|
||||
sort: 'title:asc',
|
||||
})
|
||||
const last = listCalls(backend).at(-1)!
|
||||
expect(last.query.get('site')).toBe('journal')
|
||||
expect(last.query.get('page')).toBe('1')
|
||||
expect(last.query.get('status')).toBe('online')
|
||||
expect(last.query.get('sort')).toBe('title:asc')
|
||||
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(before)
|
||||
expect(siteSelect()!.textContent).toContain('Journal')
|
||||
})
|
||||
|
||||
it('goes back to the full list and page 1 with All sites', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(40))
|
||||
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
|
||||
backend,
|
||||
})
|
||||
|
||||
await chooseSite('All sites')
|
||||
|
||||
expect(currentQuery()).toEqual({ size: '5' })
|
||||
const last = listCalls(backend).at(-1)!
|
||||
expect(last.query.has('site')).toBe(false)
|
||||
expect(last.query.get('page')).toBe('1')
|
||||
})
|
||||
|
||||
it.each(['__all', 'all'])('filters by a real site whose key is %s', async (key) => {
|
||||
const backend = backendWith('several', journalAndRenew(4))
|
||||
backend.sites = backend.sites.map((site) =>
|
||||
site.key === 'journal' ? { ...site, key } : site,
|
||||
)
|
||||
await mountMeta('articles', '/article', { backend })
|
||||
|
||||
await chooseSite('Journal')
|
||||
|
||||
expect(currentQuery()).toEqual({ site: key })
|
||||
expect(listCalls(backend).at(-1)!.query.get('site')).toBe(key)
|
||||
expect(siteSelect()!.textContent).toContain('Journal')
|
||||
})
|
||||
|
||||
it('reads ?site=journal&page=2 on arrival and shows Journal in the filter', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(40))
|
||||
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
|
||||
backend,
|
||||
})
|
||||
|
||||
expect(siteSelect()!.textContent).toContain('Journal')
|
||||
const first = listCalls(backend)[0]!
|
||||
expect(first.query.get('site')).toBe('journal')
|
||||
expect(first.query.get('page')).toBe('2')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ query: '?site=nope', sites: 'several' },
|
||||
{ query: '?site=renew', sites: 'one' },
|
||||
] as const)('drops $query silently with $sites site(s)', async ({ query, sites }) => {
|
||||
const backend = backendWith(sites, journalAndRenew(8))
|
||||
await mountMeta('articles', `/article${query}`, { backend })
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(listCalls(backend).every((call) => !call.query.has('site'))).toBe(true)
|
||||
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
|
||||
expect(bodyRows().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
action: 'searches for "plaster"',
|
||||
act: async () => {
|
||||
await typeInto(
|
||||
document.body.querySelector<HTMLInputElement>('input[type="search"]')!,
|
||||
'plaster',
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'changes the tab',
|
||||
act: async () => {
|
||||
const tab = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="tab"]'),
|
||||
).find((item) => textOf(item).startsWith('Drafts'))!
|
||||
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||
tab.click()
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'changes the sort',
|
||||
act: async () => {
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
|
||||
.find((item) => textOf(item).startsWith('Title'))!
|
||||
.click()
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'goes to page 2',
|
||||
act: async () => {
|
||||
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
{
|
||||
action: 'changes the page size',
|
||||
act: async () => {
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
.find((item) => textOf(item) === '50 per page')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
},
|
||||
},
|
||||
])('keeps ?site=journal when the user $action', async ({ act }) => {
|
||||
const backend = backendWith('several', journalAndRenew(40))
|
||||
await mountMeta('articles', '/article?site=journal&size=5', { backend })
|
||||
|
||||
await act()
|
||||
|
||||
expect(currentQuery().site).toBe('journal')
|
||||
expect(listCalls(backend).at(-1)!.query.get('site')).toBe('journal')
|
||||
})
|
||||
|
||||
it('shows the empty state of a site and returns to the full list with Show all sites', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(6))
|
||||
await mountMeta('articles', '/article?site=archive', { backend })
|
||||
|
||||
const text = textOf(document.body)
|
||||
expect(text).toContain('No articles on "Archive"')
|
||||
expect(text).toContain('Move articles here from the editor, or choose another site.')
|
||||
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
|
||||
.find((item) => textOf(item) === 'Show all sites')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(currentQuery()).toEqual({})
|
||||
expect(listCalls(backend).at(-1)!.query.has('site')).toBe(false)
|
||||
expect(bodyRows().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps the existing No results and No drafts states when a search or a tab is active on a site', async () => {
|
||||
const backend = backendWith('several', journalAndRenew(6))
|
||||
await mountMeta('articles', '/article?site=archive&q=tiles', {
|
||||
backend,
|
||||
})
|
||||
expect(textOf(document.body)).toContain('No articles match "tiles"')
|
||||
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
|
||||
await unmountMeta()
|
||||
|
||||
await mountMeta('articles', '/article?site=archive&status=draft', {
|
||||
backend,
|
||||
})
|
||||
expect(textOf(document.body)).toContain('No drafts')
|
||||
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user