fix: technical debts left by the Sites, Authors and SEO slices, plus backend support for the new Sites and Authors pagination

This commit is contained in:
Florian Sylvain committed 2026-10-19 00:25:31 +02:00
1 parent 9f4ce426a8
commit daf3594316
86 files changed
+4223 -1485

No files matched your search

+1 -7
View File
@@ -7,12 +7,7 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import {
NEEDS_ATTENTION_ROUTE,
articleEnvelope,
articleListRoute,
needsAttentionCountPage,
} from './articleEnvelope'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
@@ -145,7 +140,6 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
[NEEDS_ATTENTION_ROUTE]: () => mockResponse(200, needsAttentionCountPage()),
...handlers,
}) as unknown as typeof fetch
await router.push('/article')
@@ -8,13 +8,7 @@ import type { Article } from '@/composables/useArticles'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import {
articleEnvelope,
isNeedsAttentionCount,
needsAttentionCountPage,
parseListUrl,
type EnvelopeParams,
} from './articleEnvelope'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const KEY = 'renewcms-page-size-articles'
const TOTAL = 312
@@ -51,9 +45,6 @@ function installFetch() {
json: async () => oneSite,
} as unknown as Response)
}
if (method === 'GET' && isNeedsAttentionCount(url)) {
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
}
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
@@ -9,13 +9,7 @@ import type { Article, ArticlePage } from '@/composables/useArticles'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import {
articleEnvelope,
isNeedsAttentionCount,
needsAttentionCountPage,
parseListUrl,
type EnvelopeParams,
} from './articleEnvelope'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const TOTAL = 312
const PLASTER_EVERY = 20
@@ -91,9 +85,6 @@ function installFetch() {
if (method === 'GET' && url === '/v1/site/') {
return Promise.resolve(mockResponse(200, oneSite))
}
if (method === 'GET' && isNeedsAttentionCount(url)) {
return Promise.resolve(mockResponse(200, needsAttentionCountPage()))
}
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
@@ -178,7 +178,7 @@ describe('Default, every card collapsed', () => {
['articles', 'seo', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([8, 2, 10, 6, 1])
).toEqual([8, 2, 11, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('8 routes')
expect(section(wrapper, 'seo').text()).toContain('2 routes')
expect(section(wrapper, 'sites').text()).toContain('1 route')
+54 -50
View File
@@ -309,87 +309,91 @@ describe('Needs attention toggle', () => {
})
describe('Needs attention count', () => {
it('reads the total of the extra request with the current q, status and site', async () => {
function envelope(counts: Record<string, number>) {
return { items: [], total: 0, page: 1, size: 25, counts }
}
it('reads counts.suggestions of the list response and sends no extra request', async () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
const calls = countCalls(backend)
expect(calls).toHaveLength(1)
expect(calls[0]!.raw).toBe(
'/v1/article/?seo=suggestions&page=1&size=5&q=Article&status=online&site=renew',
)
expect(textOf(toggle())).toBe('Needs attention 3')
expect(
callsTo(backend, 'GET', '/v1/article/').filter(
(call) => !call.query.has('format') || call.query.get('size') === '5',
),
).toHaveLength(0)
})
it.each([
{ total: 3, label: 'Needs attention 3' },
{ total: 0, label: 'Needs attention 0' },
])('shows $label', async ({ total, label }) => {
{ suggestions: 3, label: 'Needs attention 3' },
{ suggestions: 0, label: 'Needs attention 0' },
])('shows $label', async ({ suggestions, label }) => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = (call) =>
call.query.has('format')
? {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, online: 0, draft: 0 },
}
: { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
backend.responders['GET /v1/article/'] = () =>
envelope({ all: 5, online: 2, draft: 3, suggestions })
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe(label)
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('shows no count and no notice on $failure', async ({ responder }) => {
it('shows no count when counts.suggestions is absent', async () => {
const backend = backendWith(boardArticles())
const defaults = backend.responders
backend.responders['GET /v1/article/'] = (call) => {
if (!call.query.has('format')) {
return responder(call)
}
return defaults
? {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 5, online: 2, draft: 3 },
}
: undefined
}
backend.responders['GET /v1/article/'] = () => envelope({ all: 5, online: 2, draft: 3 })
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
})
it('shows no count before the first response arrives', async () => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('shows no count and no extra notice on $failure', async ({ responder }) => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = responder
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(currentQuery()).toEqual({})
})
it('refreshes when q, tab or site change and never for page, size or sort', async () => {
it('keeps the last count while a new request is in flight', async () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention 3')
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
await clickTab('Drafts')
expect(textOf(toggle())).toBe('Needs attention 3')
})
it('does not change when q, tab, site, sort, page or size change', async () => {
const backend = backendWith(
Array.from({ length: 30 }, (_, index) => withSeo(index + 1, 'suggestions', 1)),
'several',
)
await mountMeta('articles', '/article?size=5', { backend })
expect(countCalls(backend)).toHaveLength(1)
expect(textOf(toggle())).toBe('Needs attention 30')
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
.find((item) => textOf(item).startsWith('Title'))!
.click()
await flushPromises()
expect(countCalls(backend)).toHaveLength(1)
await clickTab('Drafts')
expect(countCalls(backend)).toHaveLength(2)
await search('Article 1')
expect(countCalls(backend)).toHaveLength(3)
expect(textOf(toggle())).toBe('Needs attention 30')
expect(countCalls(backend)).toHaveLength(0)
})
it('uses the main response total while the filter is on, with no extra request', async () => {
it('reads counts.suggestions while the filter is on, with no extra request', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions', { backend })
@@ -183,14 +183,6 @@ export function stripCards() {
)
}
export async function setSearch(value: string) {
const input = document.body.querySelector<HTMLInputElement>('input[type="text"]')!
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
return input
}
export function lastListQuery() {
const queries = callsTo(LIST_PATH)
return queries[queries.length - 1]
@@ -243,11 +235,3 @@ export function dialogButton(label: string) {
(button) => textOf(button) === label || button.getAttribute('aria-label') === label,
)
}
export function dialogButtons() {
return Array.from(
dialogElement()?.querySelectorAll<HTMLButtonElement>(
'footer button, [data-slot="footer"] button',
) ?? [],
).map(textOf)
}
@@ -10,9 +10,6 @@ export interface EnvelopeParams {
seo?: '' | 'suggestions'
}
export const NEEDS_ATTENTION_TOTAL = 3
export const NEEDS_ATTENTION_ROUTE = 'GET /v1/article/?seo=suggestions&page=1&size=5'
function fold(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
@@ -45,15 +42,6 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
}
}
export function isNeedsAttentionCount(url: string) {
const query = new URL(url, 'http://localhost').searchParams
return query.get('seo') === 'suggestions' && query.get('size') === '5' && !query.has('format')
}
export function needsAttentionCountPage(total = NEEDS_ATTENTION_TOTAL): ArticlePage {
return { items: [], total, page: 1, size: 5, counts: { all: 0, online: 0, draft: 0 } }
}
export function articleListUrl(params: EnvelopeParams = {}) {
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
const query = new URLSearchParams({
-5
View File
@@ -58,11 +58,6 @@ export const camille: Byline = {
slug: 'camille-roche',
}
export const paul: Byline = { id: 8, name: 'Paul Huber', slug: 'paul-huber' }
export const leaFontaine: Byline = {
id: 10,
name: 'Léa Fontaine',
slug: 'lea-fontaine',
}
export const articleMeta = { site_id: defaultSite.id, authors: [ines] }
+4
View File
@@ -189,6 +189,10 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
all: backend.articles.length,
online,
draft: backend.articles.length - online,
suggestions: backend.articles.filter(
(article) =>
article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0,
).length,
},
}
}
+3 -6
View File
@@ -112,6 +112,9 @@ export function seoBackend(
size: 100,
counts: { all: 2, unused: 0, duplicates: 0 },
})
for (const item of [NORTH_WALL, KITCHEN_AFTER]) {
backend.responders[`GET /v1/media/${item.id}`] = () => item
}
return backend
}
@@ -196,12 +199,6 @@ export async function openDisclosure(title: string) {
await flushPromises()
}
export function inputByPlaceholder(root: ParentNode, placeholder: string) {
return root.querySelector<HTMLInputElement | HTMLTextAreaElement>(
`[placeholder="${placeholder}"]`,
)!
}
export function fieldByLabel(root: ParentNode, label: string) {
const element = Array.from(root.querySelectorAll('label')).find((candidate) =>
textOf(candidate).startsWith(label),