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),
})