feat: articles pagination

This commit is contained in:
Florian Sylvain committed 2026-10-11 19:14:37 +02:00
1 parent a2d224240c
commit 73f4b539fe
45 files changed
+3528 -317

No files matched your search

+30 -21
View File
@@ -6,6 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
@@ -192,7 +193,7 @@ afterEach(async () => {
describe('Default — the list loads sorted by last updated, newest first', () => {
it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
expect(wrapper.text()).toContain('All 6')
@@ -218,9 +219,10 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
let resolveList: (() => void) | null = null
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () =>
[articleListRoute()]: () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticleFixture()))
resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticleFixture())))
}),
})
@@ -239,7 +241,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -260,7 +262,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', (
})
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -287,7 +289,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
})
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -305,7 +307,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, []),
[articleListRoute()]: () => mockResponse(200, articleEnvelope([])),
})
expect(wrapper.text()).toContain('All 0')
@@ -326,7 +328,7 @@ describe('Empty — zero articles', () => {
describe('Initial load failure — an error must not render the no-articles empty state', () => {
it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(500),
[articleListRoute()]: () => mockResponse(500),
})
expect(wrapper.text()).not.toContain('No articles yet')
@@ -335,8 +337,11 @@ describe('Initial load failure — an error must not render the no-articles empt
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
it('shows the no-results empty state, then clears the search', async () => {
const all = sixArticleFixture()
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(all)),
[articleListRoute({ q: 'tiles' })]: () =>
mockResponse(200, articleEnvelope(all, { q: 'tiles' })),
})
const searchInput = wrapper.find('input[type="search"]')
@@ -367,7 +372,9 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4)
.map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, onlineOnly),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(onlineOnly)),
[articleListRoute({ status: 'draft' })]: () =>
mockResponse(200, articleEnvelope(onlineOnly, { status: 'draft' })),
})
await clickTab('Drafts 0')
@@ -384,7 +391,9 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4)
.map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, draftsOnly),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(draftsOnly)),
[articleListRoute({ status: 'online' })]: () =>
mockResponse(200, articleEnvelope(draftsOnly, { status: 'online' })),
})
await clickTab('Online 0')
@@ -400,7 +409,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
describe('Action in progress — publishing a row disables only that row', () => {
it('sends the PATCH and shows the busy label for that row alone', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
})
@@ -420,7 +429,7 @@ describe('Published — a 200 updates the row in place and raises a toast', () =
updated_at: iso(0),
}
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
})
@@ -448,7 +457,7 @@ describe('Action failed, publish — a 500 or a network error leaves the row unc
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': patchCall,
})
@@ -487,7 +496,7 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': patchCall,
})
@@ -517,7 +526,7 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
it('removes the row on a 404 publish response', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
})
@@ -540,7 +549,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
it('removes the row when the delete confirmation resolves 404', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/2': () => mockResponse(404),
})
@@ -565,7 +574,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
it('removes the row and updates the tab counts', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
})
@@ -590,7 +599,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
describe('Delete confirm — opens the dialog with the row title and image count', () => {
it('opens ConfirmDialog with the title and 3 images', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -611,7 +620,7 @@ describe('Delete confirm — opens the dialog with the row title and image count
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
it('removes the row, recomputes tabs and raises the success toast', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(204),
})
@@ -640,7 +649,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
it('throws the inline error message and leaves the article in the list', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(500),
})
@@ -0,0 +1,703 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
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 { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const TOTAL = 312
const PLASTER_EVERY = 20
function createOverlayMock() {
let resolveOpen: ((value: unknown) => void) | null = null
let props: Record<string, unknown> | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useOverlay: () => overlayMock,
useToast: () => ({ add: vi.fn() }),
}
})
function buildArticles(count: number): Article[] {
return Array.from({ length: count }, (_, index) => {
const id = index + 1
return {
id,
title: id % PLASTER_EVERY === 0 ? `Lime plaster notes ${id}` : `Article ${id}`,
body: '',
is_online: id % 3 !== 0,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-08-18T09:00:00Z',
}
})
}
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
type ListHandler = (params: EnvelopeParams, url: string) => Response | Promise<Response>
let articles: Article[]
let listCalls: EnvelopeParams[]
let listHandler: ListHandler
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))
}
function installFetch() {
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET'
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
return Promise.resolve(listHandler(params, url))
}
const deleted = url.match(/^\/v1\/article\/(\d+)$/)
if (method === 'DELETE' && deleted) {
return Promise.resolve(deleteHandler(Number(deleted[1])))
}
throw new Error(`Unexpected fetch call: ${method} ${url}`)
}) as unknown as typeof fetch
}
async function mountAt(path: string) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(path)
wrapper = mount(ArticleView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function summary() {
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
}
function control(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
function pageButtons() {
return Array.from(
document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
).filter((button) => /^\d+$/.test(button.textContent?.trim() ?? ''))
}
function rowIds() {
return new Set(
Array.from(document.body.querySelectorAll('[data-row-title]')).map((row) =>
row.getAttribute('data-row-title'),
),
)
}
function paginatorControls() {
return Array.from(
document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
)
}
async function click(button: HTMLElement | null | undefined) {
button?.click()
await flushPromises()
}
function alertText() {
return document.body.querySelector('[role="alert"], [role="status"]')?.textContent ?? ''
}
function pageSizeTrigger() {
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
}
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
beforeEach(() => {
articles = buildArticles(TOTAL)
listCalls = []
listHandler = (params) => respondFromFixture(params)
deleteHandler = () => mockResponse(204)
setViewportWidth(DESKTOP_WIDTH)
installFetch()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
vi.restoreAllMocks()
await router.push('/login')
})
describe('French caption', () => {
it('renders the range summary in French and restores English', async () => {
setLocale('fr')
try {
await mountAt('/article')
expect(summary()).toBe('Affichage de 1 à 25 sur 312')
} finally {
setLocale('en')
}
})
})
describe('1 First page', () => {
it('shows the first 25 of 312 with First and Previous disabled and no page or size in the URL', async () => {
await mountAt('/article')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(summary()).toBe('Showing 1–25 of 312')
expect(rowIds().size).toBe(25)
expect(control('First page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Next page')?.disabled).toBe(false)
expect(control('Last page')?.disabled).toBe(false)
expect(pageButtons()[0]?.getAttribute('aria-current')).toBe('page')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('Disabled controls', () => {
it('carry the 40 % opacity class on every paginator button', async () => {
await mountAt('/article')
const disabled = paginatorControls().filter((button) => button.disabled)
expect(disabled.length).toBeGreaterThan(1)
for (const button of disabled) {
expect(button.className).toContain('disabled:opacity-40')
}
})
it('carry it on the mobile Previous and Next buttons', async () => {
setViewportWidth(MOBILE_WIDTH)
await mountAt('/article')
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Previous page')?.className).toContain('disabled:opacity-40')
expect(control('Next page')?.className).toContain('disabled:opacity-40')
})
})
describe('2 Middle page', () => {
it('shows page 7 as current with ellipses and every control enabled', async () => {
await mountAt('/article?page=7')
expect(summary()).toBe('Showing 151–175 of 312')
const current = pageButtons().filter((b) => b.getAttribute('aria-current') === 'page')
expect(current.map((b) => b.textContent?.trim())).toEqual(['7'])
expect(current[0]?.className).toContain('bg-(--rc-sand)')
expect(current[0]?.className).toContain('text-(--rc-on-sand)')
expect(current[0]?.className).toContain('border-primary')
expect(document.body.querySelector('nav[aria-label="Pagination"]')?.textContent).toContain(
'…',
)
expect(paginatorControls().every((button) => !button.disabled)).toBe(true)
})
})
describe('3 Last page', () => {
it('shows 301–312 of 312 with Next and Last disabled', async () => {
await mountAt('/article?page=13')
expect(summary()).toBe('Showing 301–312 of 312')
expect(rowIds().size).toBe(12)
expect(control('Next page')?.disabled).toBe(true)
expect(control('Last page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(false)
})
})
describe('4 Single page', () => {
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
articles = buildArticles(6).map((article, index) => ({ ...article, is_online: index < 4 }))
const wrapper = await mountAt('/article')
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')
})
})
describe('5 Loading next page', () => {
it('shows skeleton rows, disables every control, and moves the URL only once the response arrives', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountAt('/article')
let resolvePage2: (() => void) | null = null
listHandler = (params) =>
new Promise((resolve) => {
resolvePage2 = () => resolve(respondFromFixture(params))
})
await click(control('Next page'))
await vi.advanceTimersByTimeAsync(150)
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(summary()).toBe('Loading page 2…')
expect(paginatorControls().every((button) => button.disabled)).toBe(true)
expect(router.currentRoute.value.fullPath).toBe('/article')
resolvePage2?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(summary()).toBe('Showing 26–50 of 312')
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/article?page=2')
})
})
describe.each([
['resolves with a 500 response', () => mockResponse(500)],
['never reaches the server', () => Promise.reject(new TypeError('Failed to fetch'))],
])('6 Error loading a page keeps the previous rows (%s)', (_failure, failure) => {
it('keeps rows and URL, shows the notice, and Retry loads the page', async () => {
await mountAt('/article?page=6')
const previousRows = rowIds()
listHandler = () => failure() as Promise<Response>
await click(control('Next page'))
expect(rowIds()).toEqual(previousRows)
expect(router.currentRoute.value.fullPath).toBe('/article?page=6')
expect(alertText()).toContain("Couldn't load page 7. Showing page 6.")
expect(control('Next page')?.disabled).toBe(false)
listHandler = (params) => respondFromFixture(params)
const retry = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Retry',
)
await click(retry)
expect(listCalls.at(-1)).toMatchObject({ page: 7 })
expect(summary()).toBe('Showing 151–175 of 312')
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
expect(alertText()).not.toContain("Couldn't load page")
})
})
describe('7 Page out of range redirects to the last page with a notice', () => {
it('replaces the URL with the last page and tells the user', async () => {
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await mountAt('/article?page=99')
expect(listCalls.map((call) => call.page)).toEqual([99, 13])
expect(replace).toHaveBeenCalledWith({ query: { page: '13' } })
expect(push).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.fullPath).toBe('/article?page=13')
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
expect(summary()).toBe('Showing 301–312 of 312')
})
})
describe('7a A shrinking server cannot loop the last-page redirect', () => {
it('redirects once and then settles', async () => {
let calls = 0
listHandler = (params) => {
calls += 1
return mockResponse(200, {
...articleEnvelope(articles, params),
items: [],
total: 25 * (30 - calls),
})
}
await mountAt('/article?page=99')
expect(listCalls.map((call) => call.page)).toEqual([99, 29])
})
})
describe('7b Out of range with total 0 shows the empty state, no redirect', () => {
it('shows No articles yet and an /article URL', async () => {
articles = []
const wrapper = await mountAt('/article?page=5')
expect(listCalls).toHaveLength(1)
expect(wrapper.text()).toContain('No articles yet')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
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 () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?page=7')
listCalls = []
await wrapper.find('input[type="search"]').setValue('plaster')
await vi.advanceTimersByTimeAsync(119)
expect(listCalls).toHaveLength(0)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
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')
})
})
describe('9 Empty after search', () => {
it('hides the paginator and the summary and keeps the tab counts', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article')
await wrapper.find('input[type="search"]').setValue('tiles')
await vi.advanceTimersByTimeAsync(120)
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('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')
})
})
describe('10 Page size menu', () => {
it.each([
['5 per page', 5, '/article?size=5'],
['25 per page', 25, '/article'],
['50 per page', 50, '/article?size=50'],
['100 per page', 100, '/article?size=100'],
])('choosing "%s" resets to page 1 and ends on %s', async (label, size, url) => {
await mountAt('/article?page=3')
listCalls = []
pageSizeTrigger()?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
expect(option).toBeTruthy()
;(option as HTMLElement).click()
await flushPromises()
expect(listCalls.at(-1)).toMatchObject({ page: 1, size })
expect(router.currentRoute.value.fullPath).toBe(url)
})
})
describe('10b Invalid URL values fall back', () => {
it('requests page 1 and size 25 and replaces the URL', async () => {
await mountAt('/article?size=10&page=abc')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('11 Focus-visible', () => {
it('gives every paginator control and the select the 2 px primary outline with a 2 px offset', async () => {
await mountAt('/article?page=7')
const controls = [...paginatorControls(), pageSizeTrigger()]
expect(controls.length).toBeGreaterThan(5)
for (const element of controls) {
expect(element?.className).toContain('focus-visible:outline-2')
expect(element?.className).toContain('focus-visible:outline-offset-2')
expect(element?.className).toContain('focus-visible:outline-primary')
}
})
})
describe('Keyboard, the page-size list', () => {
it('opens on ArrowDown, selects on Enter and closes on Escape', async () => {
await mountAt('/article')
const trigger = pageSizeTrigger()!
trigger.focus()
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
const focused = document.activeElement ?? document.body
focused.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
})
})
describe('Tabs filter on the server', () => {
it('sends status=draft with page 1, updates the URL on success and keeps the counts', async () => {
const wrapper = await mountAt('/article?page=3')
listCalls = []
const tab = Array.from(document.body.querySelectorAll('button')).find((button) =>
button.textContent?.trim().startsWith('Drafts'),
)
tab?.focus()
tab?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
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')
})
})
describe('Mobile, Previous, "Page N of M", Next', () => {
it('shows three controls and no page numbers below md', async () => {
setViewportWidth(MOBILE_WIDTH)
await mountAt('/article?page=2')
const nav = document.body.querySelector('nav[aria-label="Pagination"]')!
expect(nav.textContent).toContain('Previous')
expect(nav.textContent).toContain('Page 2 of 13')
expect(nav.textContent).toContain('Next')
expect(pageButtons()).toHaveLength(0)
expect(nav.querySelectorAll('button')).toHaveLength(2)
})
})
describe('Deleting the last row of the last page', () => {
it('reloads the previous page and recomputes the counts from the new response', async () => {
articles = buildArticles(26)
await mountAt('/article?page=2')
expect(summary()).toBe('Showing 26–26 of 26')
listCalls = []
const menu = document.body.querySelector<HTMLButtonElement>(
'button[aria-label="Actions for Article 26"]',
)
menu?.click()
await flushPromises()
const remove = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(item) => item.textContent?.trim() === 'Delete…',
) as HTMLElement
remove.click()
await flushPromises()
const action = overlayMock.getProps()?.action as () => Promise<void>
articles = buildArticles(25)
await action()
overlayMock.resolveOpen(true)
await flushPromises()
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')
})
})
describe('The envelope is the only shape read', () => {
it('renders rows from items', async () => {
const page: ArticlePage = articleEnvelope(buildArticles(3))
listHandler = () => mockResponse(200, page)
const wrapper = await mountAt('/article')
expect(wrapper.text()).toContain('Article 1')
expect(wrapper.text()).toContain('Article 3')
})
})
function headerButton(label: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('th button')).find(
(button) => button.textContent?.trim() === label,
)
}
function headerArrow(label: string) {
const direction = headerButton(label)?.querySelector('[data-sort-direction]')
const value = direction?.getAttribute('data-sort-direction')
if (value === 'asc') {
return 'up'
}
return value === 'desc' ? 'down' : 'none'
}
describe('8a Sort headers, server-side, reset to page 1', () => {
it.each([
['Title', 'title:asc'],
['Status', 'status:asc'],
['Created', 'created_at: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')
listCalls = []
await click(headerButton(label))
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')
})
it('keeps the URL unchanged until the response arrives', async () => {
await mountAt('/article?page=7')
listHandler = () => new Promise(() => undefined)
await click(headerButton('Title'))
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
})
})
describe('8b Toggling the sorted header', () => {
it('toggles to descending, and returning to updated_at:desc drops sort from the URL', async () => {
await mountAt('/article?sort=title:asc')
listCalls = []
await click(headerButton('Title'))
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')
await click(headerButton('Updated'))
await click(headerButton('Updated'))
expect(listCalls.at(-1)).toMatchObject({ sort: 'updated_at:desc' })
expect(router.currentRoute.value.fullPath).toBe('/article')
expect(headerArrow('Updated')).toBe('down')
})
})
describe('8c Sort is kept across paging, page size and search', () => {
it('carries sort on Next and on a search, and the URL keeps it', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?sort=title:asc')
listCalls = []
await click(control('Next page'))
expect(listCalls.at(-1)).toMatchObject({ page: 2, sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({ page: '2', sort: 'title:asc' })
await wrapper.find('input[type="search"]').setValue('plaster')
await vi.advanceTimersByTimeAsync(120)
await flushPromises()
expect(listCalls.at(-1)).toMatchObject({ page: 1, q: 'plaster', sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({ q: 'plaster', sort: 'title:asc' })
expect(router.currentRoute.value.query.page).toBeUndefined()
})
})
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')
expect(listCalls).toEqual([
{ page: 2, size: 25, q: '', status: '', sort: 'created_at:desc' },
])
expect(headerArrow('Created')).toBe('down')
})
it('falls back to the default for an invalid sort and replaces the URL', async () => {
await mountAt('/article?sort=body:asc')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(headerArrow('Updated')).toBe('down')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
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)
return mockResponse(200, { ...page, items: [...page.items].reverse() })
}
await mountAt('/article?sort=title:asc')
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' })
.items.map((article) => article.id)
.reverse()
expect(rendered).toEqual(expected)
})
})
describe('8f Caption follows the sort', () => {
it('reads the default caption, then the sorted one', async () => {
await mountAt('/article')
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by last updated, newest first',
)
await click(headerButton('Title'))
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by title, ascending',
)
expect(document.body.querySelector('th[aria-sort="ascending"]')?.textContent).toContain(
'Title',
)
})
})
describe('8g Mobile card list', () => {
it('has no sort control', async () => {
await mountAt('/article')
expect(document.body.querySelectorAll('ul button[aria-sort], ul [aria-sort]')).toHaveLength(
0,
)
expect(
Array.from(document.body.querySelectorAll('ul button')).filter((button) =>
/^(Title|Status|Created|Updated)$/.test(button.textContent?.trim() ?? ''),
),
).toHaveLength(0)
})
})
@@ -8,9 +8,10 @@ import { useSessionStore } from '@/stores/session'
import type { ActivityItem } from '@/lib/activity'
import HomeView from '../HomeView.vue'
import { anonymous, makeItem, target } from './activityFixtures'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
const ARTICLES_ROUTE = 'GET /v1/article/?format=html'
const ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
type Handler = () => Response | Promise<Response>
@@ -95,7 +96,7 @@ describe('Home, owner or admin, the last five events with the actor name', () =>
it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => {
await mountHome(role, {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(card().find('h2').text()).toBe('Recent activity')
@@ -125,7 +126,7 @@ describe('Home, regular user, own events only and no actor name', () => {
it.each([['editor' as const], [undefined]])('shows only the time for %s', async (role) => {
await mountHome(role as undefined, {
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(card().find('h2').text()).toBe('Your recent activity')
@@ -146,7 +147,7 @@ describe('Home rows, failure and security markers', () => {
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const marks = rows().map((row) => ({
@@ -177,7 +178,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(rows()[0]!.text()).toContain('article.archive')
@@ -188,7 +189,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
it('has no button or link inside a row and keeps the route on a click', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(2)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const row = rows()[0]!
@@ -202,7 +203,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain(
@@ -223,7 +224,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
new Promise((done) => {
resolve = () => done(page(recentItems(5)))
}),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(card().attributes('aria-busy')).toBeUndefined()
@@ -246,7 +247,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
vi.useFakeTimers()
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
await vi.advanceTimersByTimeAsync(200)
@@ -267,7 +268,7 @@ describe('Home card, error shows a retryable alert that leaves the page alone',
attempt += 1
return attempt === 1 ? (failure() as Response) : page(recentItems(2))
},
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const alert = card().find('[role="alert"]')
@@ -292,7 +293,7 @@ describe('Home card, empty when there are no events yet', () => {
])('says so for %s', async (role, text) => {
await mountHome(role as 'owner', {
[ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const empty = card().find('[data-testid="recent-activity-empty"]')
@@ -316,7 +317,7 @@ describe('Home card and article list are independent', () => {
it('shows the articles empty state and the card error together', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => response(500),
[ARTICLES_ROUTE]: () => response(200, []),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope([], { size: 5 })),
})
expect(wrapper!.text()).toContain('Write your first article')
@@ -328,7 +329,7 @@ describe('Home layout puts Recent activity full width below Recently updated', (
it('renders the section after the recent list inside the same single column', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const column = card().element.parentElement!
@@ -344,7 +345,7 @@ describe('Home card, the request URL', () => {
it('asks for exactly /v1/activity?size=5 and nothing else', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const activityCalls = calls.filter((call) => call.includes('/v1/activity'))
@@ -356,7 +357,7 @@ describe('Home card, "View all" opens the activity page', () => {
it('navigates to /activity', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const push = vi.spyOn(router, 'push')
@@ -373,7 +374,7 @@ describe('Home card, the French interface', () => {
setLocale('fr')
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(card().find('h2').text()).toBe('Activité récente')
@@ -10,6 +10,7 @@ import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import HomeView from '../HomeView.vue'
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
function mockResponse(status: number, body: unknown = null) {
return {
@@ -35,8 +36,8 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, []))
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
}
if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status))
@@ -17,6 +17,7 @@ import {
startable,
verifying,
} from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
function mockResponse(status: number, body: unknown = null) {
return {
@@ -35,8 +36,8 @@ async function mountHome(role: UserRole, status: UpdateStatus) {
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, []))
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
}
if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status))
+36 -23
View File
@@ -6,6 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.vue'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
let toastAdd: ReturnType<typeof vi.fn>
@@ -42,6 +43,8 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
})
}
const HOME_ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
const sixArticles: Article[] = [
{
id: 1,
@@ -140,9 +143,10 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
vi.useFakeTimers()
let resolveList: (() => void) | null = null
const wrapper = await mountHome({
'GET /v1/article/?format=html': () =>
[HOME_ARTICLES_ROUTE]: () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticles))
resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
}),
})
@@ -164,7 +168,8 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => Promise.resolve(mockResponse(200, sixArticles)),
[HOME_ARTICLES_ROUTE]: () =>
Promise.resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 }))),
})
await vi.advanceTimersByTimeAsync(0)
@@ -178,7 +183,8 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
describe('Default — the list loaded with a mix of online and draft articles', () => {
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -221,7 +227,8 @@ describe('A successful create hands off to the editor with a toast', () => {
updated_at: '2026-01-01T00:00:00Z',
}
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
'POST /v1/article/?format=html': () => mockResponse(201, created),
})
@@ -257,7 +264,7 @@ describe('A successful create hands off to the editor with a toast', () => {
describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, []),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
})
expect(wrapper.text()).toContain('Welcome to RenewCMS.')
@@ -283,7 +290,7 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
},
])('shows the alert with identical copy for $name', async ({ handler }) => {
const wrapper = await mountHome({
'GET /v1/article/?format=html': handler,
[HOME_ARTICLES_ROUTE]: handler,
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -301,9 +308,9 @@ describe('Retry re-issues the request and recovers into the Default state', () =
const listCall = vi
.fn()
.mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(mockResponse(200, sixArticles))
.mockResolvedValueOnce(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
const wrapper = await mountHome({
'GET /v1/article/?format=html': listCall,
[HOME_ARTICLES_ROUTE]: listCall,
})
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
@@ -320,9 +327,9 @@ describe('Retry re-issues the request and recovers into the Default state', () =
describe('The list is fetched exactly once per navigation', () => {
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
const listCall = vi.fn(() => mockResponse(200, sixArticles))
const listCall = vi.fn(() => mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
const wrapper = await mountHome({
'GET /v1/article/?format=html': listCall,
[HOME_ARTICLES_ROUTE]: listCall,
})
expect(listCall).toHaveBeenCalledTimes(1)
@@ -343,7 +350,8 @@ describe('The greeting matches the hour of day', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
})
expect(wrapper.text()).toContain(`${greeting}, marta`)
@@ -356,23 +364,24 @@ describe('The greeting is identical across every load state at a fixed hour', ()
vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0))
const loadingWrapper = await mountHome({
'GET /v1/article/?format=html': () => new Promise(() => {}),
[HOME_ARTICLES_ROUTE]: () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(loadingWrapper.text()).toContain('Good morning, marta')
const defaultWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
})
expect(defaultWrapper.text()).toContain('Good morning, marta')
const emptyWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, []),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
})
expect(emptyWrapper.text()).toContain('Good morning, marta')
const errorWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(500),
[HOME_ARTICLES_ROUTE]: () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Good morning, marta')
})
@@ -393,7 +402,8 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -413,7 +423,8 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName: '' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -439,7 +450,8 @@ describe('The greeting handles a missing username', () => {
session.session = { verified: true }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -460,7 +472,7 @@ describe('The "Connect your website" card renders in every state', () => {
it('renders the same card while loading, in Default, Empty and Error', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => new Promise(() => {}),
[HOME_ARTICLES_ROUTE]: () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.text()).toContain('Connect your website')
@@ -470,17 +482,18 @@ describe('The "Connect your website" card renders in every state', () => {
vi.useRealTimers()
const defaultWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
})
expect(defaultWrapper.text()).toContain('Connect your website')
const emptyWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, []),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
})
expect(emptyWrapper.text()).toContain('Connect your website')
const errorWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(500),
[HOME_ARTICLES_ROUTE]: () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Connect your website')
})
@@ -0,0 +1,70 @@
import type { Article, ArticlePage, ArticleStatus } from '@/composables/useArticles'
export interface EnvelopeParams {
page?: number
size?: number
q?: string
status?: ArticleStatus | ''
sort?: string
}
function fold(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): ArticlePage {
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
const filtered = all
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || fold(article.title).includes(fold(q)))
if (sort) {
const [name, direction] = sort.split(':') as [string, string]
const field = (name === 'status' ? 'is_online' : name) as keyof Article
filtered.sort((left, right) => {
const order = String(left[field]).localeCompare(String(right[field]))
return direction === 'desc' ? -order : order
})
}
const online = all.filter((article) => article.is_online).length
return {
items: filtered.slice((page - 1) * size, page * size),
total: filtered.length,
page,
size,
counts: { all: all.length, online, draft: all.length - online },
}
}
export function articleListUrl(params: EnvelopeParams = {}) {
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
const query = new URLSearchParams({
format: 'html',
page: String(page),
size: String(size),
})
if (q) {
query.set('q', q)
}
if (status) {
query.set('status', status)
}
if (sort) {
query.set('sort', sort)
}
return `/v1/article/?${query.toString()}`
}
export function articleListRoute(params: EnvelopeParams = {}) {
return `GET ${articleListUrl(params)}`
}
export function parseListUrl(url: string): EnvelopeParams {
const query = new URL(url, 'http://localhost').searchParams
return {
page: Number(query.get('page')),
size: Number(query.get('size')),
q: query.get('q') ?? '',
status: (query.get('status') ?? '') as ArticleStatus | '',
sort: query.get('sort') ?? '',
}
}