feat: articles pagination
This commit is contained in:
1 parent
a2d224240c
commit
73f4b539fe
45 files changed
+3528
-317
No files matched your search
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user