feat: editorial article list
This commit is contained in:
1 parent
a0b3c58c52
commit
a671b81f80
103 files changed
+6493
-1805
No files matched your search
@@ -5,11 +5,11 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import type { Article, ArticlePage } from '@/composables/useArticles'
|
||||
import type { Article, StoryPage } from '@/composables/useArticles'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import ArticleView from '../ArticleView.vue'
|
||||
import { oneSite } from './editorMetaKit'
|
||||
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import { storyEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
const TOTAL = 312
|
||||
@@ -77,7 +77,7 @@ let deleteHandler: (id: number) => Response | Promise<Response>
|
||||
let wrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
|
||||
function respondFromFixture(params: EnvelopeParams) {
|
||||
return mockResponse(200, articleEnvelope(articles, params))
|
||||
return mockResponse(200, storyEnvelope(articles, params))
|
||||
}
|
||||
|
||||
function installFetch() {
|
||||
@@ -256,7 +256,7 @@ describe('3 Last page', () => {
|
||||
})
|
||||
|
||||
describe('4 Single page', () => {
|
||||
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
|
||||
it('keeps the summary and the page-size select, hides the paginator and reads the tab count', async () => {
|
||||
articles = buildArticles(6).map((article, index) => ({
|
||||
...article,
|
||||
is_online: index < 4,
|
||||
@@ -266,9 +266,7 @@ describe('4 Single page', () => {
|
||||
expect(summary()).toBe('Showing 1–6 of 6')
|
||||
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(pageSizeTrigger()).not.toBeNull()
|
||||
expect(wrapper.text()).toContain('All 6')
|
||||
expect(wrapper.text()).toContain('Online 4')
|
||||
expect(wrapper.text()).toContain('Drafts 2')
|
||||
expect(wrapper.text()).toContain('All articles 6')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -351,7 +349,7 @@ describe('7a A shrinking server cannot loop the last-page redirect', () => {
|
||||
listHandler = (params) => {
|
||||
calls += 1
|
||||
return mockResponse(200, {
|
||||
...articleEnvelope(articles, params),
|
||||
...storyEnvelope(articles, params),
|
||||
items: [],
|
||||
total: 25 * (30 - calls),
|
||||
})
|
||||
@@ -375,7 +373,7 @@ describe('7b Out of range with total 0 shows the empty state, no redirect', () =
|
||||
})
|
||||
|
||||
describe('8 Search resets to page 1', () => {
|
||||
it('sends q with page 1, updates the URL on success and leaves the tab counts alone', async () => {
|
||||
it('sends q with page 1, updates the URL on success and follows the search in the tab count', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const wrapper = await mountAt('/article?page=7')
|
||||
listCalls = []
|
||||
@@ -389,14 +387,12 @@ describe('8 Search resets to page 1', () => {
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: 'plaster', status: '', sort: '' }])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?q=plaster')
|
||||
expect(summary()).toBe('Showing 1–15 of 15')
|
||||
expect(wrapper.text()).toContain('All 312')
|
||||
expect(wrapper.text()).toContain('Online 208')
|
||||
expect(wrapper.text()).toContain('Drafts 104')
|
||||
expect(wrapper.text()).toContain('All articles 15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('9 Empty after search', () => {
|
||||
it('hides the paginator and the summary and keeps the tab counts', async () => {
|
||||
it('hides the paginator and the summary and shows every count at 0', async () => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
const wrapper = await mountAt('/article')
|
||||
|
||||
@@ -405,15 +401,11 @@ describe('9 Empty after search', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No articles match "tiles"')
|
||||
expect(wrapper.text()).toContain(
|
||||
'Check the spelling, or search for a shorter part of the title.',
|
||||
)
|
||||
expect(wrapper.text()).toContain('Search covers titles in every language you can read.')
|
||||
expect(wrapper.text()).toContain('Clear search')
|
||||
expect(summary()).toBeUndefined()
|
||||
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(wrapper.text()).toContain('All 312')
|
||||
expect(wrapper.text()).toContain('Online 208')
|
||||
expect(wrapper.text()).toContain('Drafts 104')
|
||||
expect(wrapper.text()).toContain('All articles 0')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -494,12 +486,12 @@ describe('Keyboard, the page-size list', () => {
|
||||
})
|
||||
|
||||
describe('Tabs filter on the server', () => {
|
||||
it('sends status=draft with page 1, updates the URL on success and keeps the counts', async () => {
|
||||
it('sends tab=missing with page 1 and updates the URL on success', async () => {
|
||||
const wrapper = await mountAt('/article?page=3')
|
||||
listCalls = []
|
||||
|
||||
const tab = Array.from(document.body.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.trim().startsWith('Drafts'),
|
||||
button.textContent?.trim().startsWith('Needs translation'),
|
||||
)
|
||||
tab?.focus()
|
||||
tab?.dispatchEvent(
|
||||
@@ -511,11 +503,11 @@ describe('Tabs filter on the server', () => {
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: 'draft', sort: '' }])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?status=draft')
|
||||
expect(wrapper.text()).toContain('All 312')
|
||||
expect(wrapper.text()).toContain('Online 208')
|
||||
expect(wrapper.text()).toContain('Drafts 104')
|
||||
expect(listCalls).toEqual([
|
||||
{ page: 1, size: 25, q: '', status: '', sort: '', tab: 'missing' },
|
||||
])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?tab=missing')
|
||||
expect(wrapper.text()).toContain('All articles 312')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -559,13 +551,13 @@ describe('Deleting the last row of the last page', () => {
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article')
|
||||
expect(summary()).toBe('Showing 1–25 of 25')
|
||||
expect(document.body.textContent).toContain('All 25')
|
||||
expect(document.body.textContent).toContain('All articles 25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The envelope is the only shape read', () => {
|
||||
it('renders rows from items', async () => {
|
||||
const page: ArticlePage = articleEnvelope(buildArticles(3))
|
||||
const page: StoryPage = storyEnvelope(buildArticles(3))
|
||||
listHandler = () => mockResponse(200, page)
|
||||
const wrapper = await mountAt('/article')
|
||||
|
||||
@@ -591,9 +583,7 @@ function headerArrow(label: string) {
|
||||
|
||||
describe('8a Sort headers, server-side, reset to page 1', () => {
|
||||
it.each([
|
||||
['Title', 'title:asc'],
|
||||
['Status', 'status:asc'],
|
||||
['Created', 'created_at:asc'],
|
||||
['Article', 'title:asc'],
|
||||
['Updated', 'updated_at:asc'],
|
||||
])('the first click on %s sends sort=%s and page 1', async (label, sort) => {
|
||||
const wrapper = await mountAt('/article?page=7')
|
||||
@@ -604,14 +594,14 @@ describe('8a Sort headers, server-side, reset to page 1', () => {
|
||||
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort }])
|
||||
expect(router.currentRoute.value.fullPath).toBe(`/article?sort=${sort}`)
|
||||
expect(headerArrow(label)).toBe('up')
|
||||
expect(wrapper.text()).toContain('All 312')
|
||||
expect(wrapper.text()).toContain('All articles 312')
|
||||
})
|
||||
|
||||
it('keeps the URL unchanged until the response arrives', async () => {
|
||||
await mountAt('/article?page=7')
|
||||
listHandler = () => new Promise(() => undefined)
|
||||
|
||||
await click(headerButton('Title'))
|
||||
await click(headerButton('Article'))
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
|
||||
})
|
||||
@@ -622,10 +612,10 @@ describe('8b Toggling the sorted header', () => {
|
||||
await mountAt('/article?sort=title:asc')
|
||||
listCalls = []
|
||||
|
||||
await click(headerButton('Title'))
|
||||
await click(headerButton('Article'))
|
||||
expect(listCalls.at(-1)).toMatchObject({ sort: 'title:desc', page: 1 })
|
||||
expect(router.currentRoute.value.fullPath).toBe('/article?sort=title:desc')
|
||||
expect(headerArrow('Title')).toBe('down')
|
||||
expect(headerArrow('Article')).toBe('down')
|
||||
|
||||
await click(headerButton('Updated'))
|
||||
await click(headerButton('Updated'))
|
||||
@@ -665,13 +655,11 @@ describe('8c Sort is kept across paging, page size and search', () => {
|
||||
})
|
||||
|
||||
describe('8d A sorted URL on load', () => {
|
||||
it('sends the sort and shows the Created arrow-down', async () => {
|
||||
await mountAt('/article?sort=created_at:desc&page=2')
|
||||
it('sends the sort and shows the Article arrow-down', async () => {
|
||||
await mountAt('/article?sort=title:desc&page=2')
|
||||
|
||||
expect(listCalls).toEqual([
|
||||
{ page: 2, size: 25, q: '', status: '', sort: 'created_at:desc' },
|
||||
])
|
||||
expect(headerArrow('Created')).toBe('down')
|
||||
expect(listCalls).toEqual([{ page: 2, size: 25, q: '', status: '', sort: 'title:desc' }])
|
||||
expect(headerArrow('Article')).toBe('down')
|
||||
})
|
||||
|
||||
it('falls back to the default for an invalid sort and replaces the URL', async () => {
|
||||
@@ -686,7 +674,7 @@ describe('8d A sorted URL on load', () => {
|
||||
describe('8e No client-side re-sorting', () => {
|
||||
it('renders the rows in exactly the order the server returned', async () => {
|
||||
listHandler = (params) => {
|
||||
const page = articleEnvelope(articles, params)
|
||||
const page = storyEnvelope(articles, params)
|
||||
return mockResponse(200, {
|
||||
...page,
|
||||
items: [...page.items].reverse(),
|
||||
@@ -697,7 +685,7 @@ describe('8e No client-side re-sorting', () => {
|
||||
const rendered = Array.from(document.body.querySelectorAll('table [data-row-title]')).map(
|
||||
(row) => Number(row.getAttribute('data-row-title')),
|
||||
)
|
||||
const expected = articleEnvelope(articles, { sort: 'title:asc' })
|
||||
const expected = storyEnvelope(articles, { sort: 'title:asc' })
|
||||
.items.map((article) => article.id)
|
||||
.reverse()
|
||||
expect(rendered).toEqual(expected)
|
||||
@@ -711,12 +699,12 @@ describe('8f Caption follows the sort', () => {
|
||||
'Articles, sorted by last updated, newest first',
|
||||
)
|
||||
|
||||
await click(headerButton('Title'))
|
||||
await click(headerButton('Article'))
|
||||
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
|
||||
'Articles, sorted by title, ascending',
|
||||
)
|
||||
expect(document.body.querySelector('th[aria-sort="ascending"]')?.textContent).toContain(
|
||||
'Title',
|
||||
'Article',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -730,7 +718,7 @@ describe('8g Mobile card list', () => {
|
||||
)
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll('ul button')).filter((button) =>
|
||||
/^(Title|Status|Created|Updated)$/.test(button.textContent?.trim() ?? ''),
|
||||
/^(Article|Updated)$/.test(button.textContent?.trim() ?? ''),
|
||||
),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user