feat: editorial article list

This commit is contained in:
Florian Sylvain committed 2026-11-02 07:28:44 +01:00
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)
})