feat: SEO SPA Sites, Articles and Integration (4/4)

This commit is contained in:
Florian Sylvain committed 2026-10-18 15:51:28 +02:00
1 parent cb737b7ae3
commit 02fb8751fd
47 files changed
+3218 -227

No files matched your search

+10 -2
View File
@@ -7,7 +7,12 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
import {
NEEDS_ATTENTION_ROUTE,
articleEnvelope,
articleListRoute,
needsAttentionCountPage,
} from './articleEnvelope'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
@@ -139,7 +144,10 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
global.fetch = fetchRouter({
[NEEDS_ATTENTION_ROUTE]: () => mockResponse(200, needsAttentionCountPage()),
...handlers,
}) as unknown as typeof fetch
await router.push('/article')
const wrapper = mount(ArticleView, {
attachTo: document.body,
@@ -8,7 +8,13 @@ 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, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import {
articleEnvelope,
isNeedsAttentionCount,
needsAttentionCountPage,
parseListUrl,
type EnvelopeParams,
} from './articleEnvelope'
const KEY = 'renewcms-page-size-articles'
const TOTAL = 312
@@ -45,6 +51,9 @@ 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,7 +9,13 @@ 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, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import {
articleEnvelope,
isNeedsAttentionCount,
needsAttentionCountPage,
parseListUrl,
type EnvelopeParams,
} from './articleEnvelope'
const TOTAL = 312
const PLASTER_EVERY = 20
@@ -85,6 +91,9 @@ 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)
@@ -64,7 +64,7 @@ function siteSelect() {
}
function listCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/')
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
}
async function chooseSite(label: string) {
@@ -19,6 +19,7 @@ const STUB_ORIGIN = 'https://cms.renew.example'
const SECTION_LABELS = [
'Authentication',
'Articles',
'Search and sharing',
'Media',
'Users and invites',
'Settings',
@@ -170,15 +171,17 @@ describe('Default, every card collapsed', () => {
expect(row(wrapper, 'POST /v1/article/').text()).toContain('POST')
})
it('groups the cards as 9, 10, 6 and 1', async () => {
it('groups the cards as 8, 2, 10, 6 and 1', async () => {
const wrapper = await mountIntegration()
expect(
['articles', 'media', 'users', 'settings'].map(
['articles', 'seo', 'media', 'users', 'settings'].map(
(id) => section(wrapper, id).findAllComponents(EndpointCard).length,
),
).toEqual([9, 10, 6, 1])
expect(section(wrapper, 'articles').text()).toContain('9 routes')
).toEqual([8, 2, 10, 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')
expect(section(wrapper, 'settings').text()).toContain('1 route')
})
+442
View File
@@ -0,0 +1,442 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { setLocale } from '@/i18n'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { Article } from '@/composables/useArticles'
import {
articleOf,
callsTo,
createMetaBackend,
failWith,
mountMeta,
networkFailure,
renewSites,
textOf,
typeInto,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SEARCH_WAIT_MS = 320
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
setLocale('en')
await unmountMeta()
})
function withSeo(
id: number,
status: string | undefined,
count: number | undefined,
overrides: Partial<Article> = {},
) {
return articleOf(id, `Article ${id}`, {
is_online: id % 2 === 0,
...(status === undefined ? {} : { seo_status: status }),
...(count === undefined ? {} : { seo_suggestions: count }),
...overrides,
})
}
function boardArticles() {
return [
withSeo(1, 'ready', 0),
withSeo(2, 'suggestions', 2),
withSeo(3, 'hidden', 0),
withSeo(4, 'suggestions', 3),
withSeo(5, 'suggestions', 1),
]
}
function backendWith(articles: Article[], sites: 'one' | 'several' = 'one'): MetaBackend {
const backend = createMetaBackend({
sites: sites === 'one' ? renewSites().slice(0, 1) : renewSites(),
})
backend.articles = articles
return backend
}
function toggle() {
return document.body.querySelector<HTMLElement>('[data-testid="needs-attention-toggle"]')!
}
function chips(root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="seo-status-chip"]'))
}
function bodyRows() {
return Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr'))
}
function mainCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/').filter((call) => call.query.has('format'))
}
function countCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/').filter((call) => !call.query.has('format'))
}
function currentQuery() {
return { ...router.currentRoute.value.query }
}
async function clickToggle() {
toggle().click()
await flushPromises()
}
async function search(value: string) {
await typeInto(document.body.querySelector<HTMLInputElement>('input[type="search"]')!, value)
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
await flushPromises()
}
async function clickTab(label: string) {
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
(item) => textOf(item).startsWith(label),
)!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
}
describe('SeoStatusChip: status table', () => {
it.each([
{ status: 'ready', count: 0, text: '', label: '' },
{ status: 'suggestions', count: 2, text: '2', label: '2 suggestions' },
{ status: 'suggestions', count: 1, text: '1', label: '1 suggestion' },
{ status: 'suggestions', count: 3, text: '3', label: '3 suggestions' },
{ status: 'hidden', count: 0, text: 'Hidden', label: 'Hidden from search engines' },
{ status: 'hidden', count: 2, text: 'Hidden', label: 'Hidden from search engines' },
])('$status with $count shows "$text"', async ({ status, count, text, label }) => {
const backend = backendWith([withSeo(1, status, count)])
await mountMeta('articles', '/article', { backend })
const found = chips(bodyRows()[0])
if (!label) {
expect(found).toHaveLength(0)
return
}
expect(found).toHaveLength(1)
const chip = found[0]!
expect(textOf(chip)).toBe(text)
expect(chip.getAttribute('title')).toBe(label)
expect(chip.getAttribute('aria-label')).toBe(label)
expect(chip.getAttribute('role')).toBe('img')
})
it.each([
{ item: 'without seo_status', article: withSeo(1, undefined, undefined) },
{ item: 'with suggestions and a count of 0', article: withSeo(1, 'suggestions', 0) },
{ item: 'with an unknown status', article: withSeo(1, 'surprise', 4) },
])('shows nothing for an item $item', async ({ article }) => {
await mountMeta('articles', '/article', { backend: backendWith([article]) })
expect(chips()).toHaveLength(0)
expect(bodyRows()).toHaveLength(1)
})
it('sits in the Status cell next to the badge on desktop and in the meta row on mobile', async () => {
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
const statusCell = bodyRows()[1]!.children[2] as HTMLElement
expect(chips(statusCell)).toHaveLength(1)
expect(textOf(statusCell)).toContain('Online')
expect(chips(document.body.querySelector('tbody')!)).toHaveLength(4)
await unmountMeta()
await mountMeta('articles', '/article', {
width: MOBILE_WIDTH,
backend: backendWith(boardArticles()),
})
const cards = Array.from(document.body.querySelectorAll<HTMLElement>('li'))
const second = cards.find((card) => textOf(card).includes('Article 2'))!
expect(chips(second)).toHaveLength(1)
expect(chips(second)[0]!.className).toContain('h-5')
})
it('reads the chip in French', async () => {
setLocale('fr')
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
const labels = chips().map((chip) => chip.getAttribute('aria-label'))
expect(labels).toContain('2 suggestions')
expect(labels).toContain('1 suggestion')
expect(labels).toContain('Masqué des moteurs de recherche')
expect(textOf(toggle())).toContain('À traiter')
})
it('keeps the chip when a row is published', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article', { backend })
document.body
.querySelector<HTMLElement>('button[aria-label="Actions for Article 3"]')!
.click()
await flushPromises()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
.find((item) => textOf(item) === 'Publish')!
.click()
await flushPromises()
expect(chips(bodyRows()[2])).toHaveLength(1)
})
})
describe('Needs attention toggle', () => {
it('is a switch with the board count, left of the search on desktop', async () => {
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
expect(toggle().getAttribute('role')).toBe('switch')
expect(toggle().getAttribute('aria-checked')).toBe('false')
expect(textOf(toggle())).toBe('Needs attention 3')
expect(toggle().className).toContain('md:-order-1')
expect(toggle().className).toContain('md:h-8')
})
it('is 44 px high on mobile', async () => {
await mountMeta('articles', '/article', {
width: MOBILE_WIDTH,
backend: backendWith(boardArticles()),
})
expect(toggle().className).toContain('h-11')
})
it('uses the sand fill when on', async () => {
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
await clickToggle()
expect(toggle().getAttribute('aria-checked')).toBe('true')
expect(toggle().className).toContain('bg-(--rc-sand)')
expect(toggle().className).toContain('text-(--rc-on-sand)')
})
it('turns on keeping q, status, sort, site and size, and resets the page', async () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta(
'articles',
'/article?page=1&size=50&q=Article&status=online&sort=title:asc&site=renew',
{ backend },
)
await clickToggle()
expect(currentQuery()).toEqual({
size: '50',
q: 'Article',
status: 'online',
sort: 'title:asc',
site: 'renew',
seo: 'suggestions',
})
const last = mainCalls(backend).at(-1)!
expect(last.query.get('seo')).toBe('suggestions')
expect(last.query.get('page')).toBe('1')
expect(last.query.get('size')).toBe('50')
expect(last.query.get('q')).toBe('Article')
expect(last.query.get('sort')).toBe('title:asc')
})
it('turns off, drops seo, resets the page and requests the list without seo', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
await clickToggle()
expect(currentQuery()).toEqual({ size: '5' })
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
expect(mainCalls(backend).at(-1)!.query.get('page')).toBe('1')
})
it('opens a URL with the filter already on', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=1', { backend })
expect(toggle().getAttribute('aria-checked')).toBe('true')
expect(mainCalls(backend)[0]!.query.get('seo')).toBe('suggestions')
expect(bodyRows()).toHaveLength(3)
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All 5')
})
it.each([
'?seo=maybe',
'?seo=',
'?seo=READY',
'?seo=ready',
'?seo=hidden',
'?seo=suggestions,x',
])('drops %s silently', async (query) => {
const backend = backendWith(boardArticles())
await mountMeta('articles', `/article${query}`, { backend })
expect(currentQuery()).toEqual({})
expect(toggle().getAttribute('aria-checked')).toBe('false')
expect(mainCalls(backend).every((call) => !call.query.has('seo'))).toBe(true)
expect(document.body.querySelector('[role="alert"], [role="status"]')).toBeNull()
})
it.each([
{ action: 'searches', act: () => search('Article') },
{ action: 'changes the tab', act: () => clickTab('Drafts') },
])('keeps ?seo=suggestions and resets the page when the user $action', async ({ act }) => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
await act()
expect(currentQuery().seo).toBe('suggestions')
expect(currentQuery().page).toBeUndefined()
expect(mainCalls(backend).at(-1)!.query.get('seo')).toBe('suggestions')
})
it('behaves the same on a single-site install', async () => {
const backend = backendWith(boardArticles(), 'one')
await mountMeta('articles', '/article', { backend })
expect(document.body.querySelector('button[aria-label="Site"]')).toBeNull()
await clickToggle()
expect(currentQuery()).toEqual({ seo: 'suggestions' })
expect(bodyRows()).toHaveLength(3)
})
})
describe('Needs attention count', () => {
it('reads the total of the extra request with the current q, status and site', 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',
)
})
it.each([
{ total: 3, label: 'Needs attention 3' },
{ total: 0, label: 'Needs attention 0' },
])('shows $label', async ({ total, 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 } }
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 }) => {
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
}
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 () => {
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)
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)
})
it('uses the main response total while the filter is on, with no extra request', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions', { backend })
expect(countCalls(backend)).toHaveLength(0)
expect(textOf(toggle())).toBe('Needs attention 3')
})
})
describe('Needs attention: empty states', () => {
it('shows Nothing needs attention and returns to the list with Show all articles', async () => {
const backend = backendWith([withSeo(1, 'ready', 0), withSeo(2, 'ready', 0)])
await mountMeta('articles', '/article?seo=suggestions&q=', { backend })
const text = textOf(document.body)
expect(text).toContain('Nothing needs attention')
expect(text).toContain(
'Every article has a clean check. Suggestions are optional, so this is just a bonus.',
)
expect(document.body.querySelector('[data-testid="paginator-summary"]')).toBeNull()
Array.from(document.body.querySelectorAll<HTMLElement>('button'))
.find((item) => textOf(item) === 'Show all articles')!
.click()
await flushPromises()
expect(currentQuery()).toEqual({})
expect(mainCalls(backend).at(-1)!.query.has('seo')).toBe(false)
expect(bodyRows()).toHaveLength(2)
})
it.each([
{ query: 'q=tiles', title: 'No articles match "tiles"' },
{ query: 'status=draft', title: 'No drafts' },
{ query: 'status=online', title: 'Nothing is online yet' },
])('keeps the existing state for $query with the filter on', async ({ query, title }) => {
const backend = backendWith([withSeo(1, 'ready', 0)])
await mountMeta('articles', `/article?seo=suggestions&${query}`, { backend })
expect(textOf(document.body)).toContain(title)
expect(currentQuery().seo).toBe('suggestions')
})
it('prefers Nothing needs attention to the site state', async () => {
const backend = backendWith([withSeo(1, 'ready', 0)], 'several')
await mountMeta('articles', '/article?seo=suggestions&site=archive', { backend })
expect(textOf(document.body)).toContain('Nothing needs attention')
expect(textOf(document.body)).not.toContain('No articles on "Archive"')
})
})
+284
View File
@@ -0,0 +1,284 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import type { SuggestionCode } from '@/lib/seoTypes'
import { failWith, networkFailure, pending, textOf, unmountMeta } from './metaTestKit'
import {
checkAnswer,
checkCalls,
mountSeo,
openPanel,
openSection,
patchBodies,
sectionBody,
sectionSummary,
seoBackend,
suggestion,
} from './seoTestKit'
const emptyRectList = () => Object.assign([], { item: () => null }) as unknown as DOMRectList
Range.prototype.getClientRects = emptyRectList
Range.prototype.getBoundingClientRect = () => new DOMRect()
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
afterEach(unmountMeta)
const TITLE_MESSAGE =
'The meta title is 74 characters. Search engines may cut it off after about 60.'
const ALL = [
suggestion('title_long', 'seo_title', { length: 74, max: 60 }),
suggestion('cover_alt', 'cover_image_alt'),
suggestion('no_h', 'body'),
]
function backendWithDismissals(saved: SuggestionCode[] = [], all = ALL) {
const backend = seoBackend({ article: { seo_dismissed: saved } })
const state = { saved }
backend.responders['POST /v1/article/12/seo/check'] = () =>
checkAnswer(all.filter((item) => !state.saved.includes(item.code)))
backend.responders['PATCH /v1/article/12'] = (call) => {
state.saved = [...new Set(call.body!.seo_dismissed as SuggestionCode[])]
return { ...backend.article, seo_dismissed: state.saved }
}
return backend
}
function dismissButtons(root: ParentNode = document.body) {
return Array.from(
root.querySelectorAll<HTMLButtonElement>('[data-testid="suggestion-dismiss"]'),
)
}
function panelRows(root: ParentNode) {
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="suggestion"]'))
}
async function dismiss(index: number) {
dismissButtons()[index]!.click()
await flushPromises()
}
function toastText() {
return textOf(document.body.querySelector('[data-slot="viewport"]'))
}
describe('Dismiss x: rendering', () => {
it('draws one button per panel row and a footnote, and none in the compact list', async () => {
const backend = backendWithDismissals()
await mountSeo(backend)
const compact = await openSection()
expect(dismissButtons(compact)).toHaveLength(0)
expect(textOf(compact)).not.toContain('Dismiss one and it stays hidden')
const panel = await openPanel()
expect(dismissButtons(panel)).toHaveLength(3)
expect(textOf(panel)).toContain(
'Suggestions never block saving or publishing. Dismiss one and it stays hidden for this article.',
)
})
it('names the button with the message and orders it after the Go to link', async () => {
await mountSeo(backendWithDismissals())
const panel = await openPanel()
const first = dismissButtons(panel)[0]!
expect(first.getAttribute('aria-label')).toBe(`Dismiss suggestion: ${TITLE_MESSAGE}`)
expect(first.className).toContain('focus-visible:outline-2')
const row = panelRows(panel)[0]!
const link = Array.from(row.querySelectorAll('button')).find((button) =>
textOf(button).startsWith('Go to'),
)!
expect(link.compareDocumentPosition(first) & Node.DOCUMENT_POSITION_FOLLOWING).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
)
})
it('is 44 px on mobile and 24 px on desktop', async () => {
await mountSeo(backendWithDismissals(), { width: MOBILE_WIDTH })
const panel = await openPanel()
expect(dismissButtons(panel)[0]!.className).toContain('size-11')
expect(dismissButtons(panel)[0]!.className).toContain('md:size-6')
})
})
describe('Dismiss x: behaviour', () => {
it('sends one PATCH with the saved list plus the code and nothing else', async () => {
const backend = backendWithDismissals(['no_au'])
await mountSeo(backend)
await openPanel()
const before = checkCalls(backend).length
await dismiss(0)
expect(patchBodies(backend)).toEqual([{ seo_dismissed: ['no_au', 'title_long'] }])
expect(checkCalls(backend)).toHaveLength(before + 1)
})
it('keeps the row and shows a busy button until the PATCH answers', async () => {
const backend = backendWithDismissals()
const hold = pending<unknown>()
const answer = backend.responders['PATCH /v1/article/12']!
backend.responders['PATCH /v1/article/12'] = async (call) => {
await hold.promise
return answer(call)
}
await mountSeo(backend)
const panel = await openPanel()
await dismiss(0)
expect(panelRows(panel)).toHaveLength(3)
expect(dismissButtons(panel)[0]!.disabled).toBe(true)
expect(dismissButtons(panel)[0]!.getAttribute('aria-busy')).toBe('true')
hold.resolve(undefined)
await flushPromises()
expect(panelRows(panel)).toHaveLength(2)
})
it('removes the row, keeps the order and updates the summary', async () => {
const backend = backendWithDismissals()
await mountSeo(backend)
const panel = await openPanel()
await dismiss(0)
const rows = panelRows(panel).map(textOf)
expect(rows).toHaveLength(2)
expect(rows[0]).toContain('The cover image has no alt text')
expect(rows[1]).toContain('The body has no headings')
})
it('shows All good with the dismissed line when the last one goes, and focuses the link', async () => {
const backend = backendWithDismissals([], [ALL[0]!])
await mountSeo(backend)
const panel = await openPanel()
await dismiss(0)
const text = textOf(panel)
expect(text).toContain('All good')
expect(text).toContain('1 dismissed suggestion is hidden.')
const restore = panel.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
expect(textOf(restore)).toBe('Show dismissed')
expect(document.activeElement).toBe(restore)
})
it.each([
{ position: 'first', index: 0, target: 0 },
{ position: 'second', index: 1, target: 1 },
{ position: 'last', index: 2, target: 1 },
])('moves focus after dismissing the $position row', async ({ index, target }) => {
const backend = backendWithDismissals()
await mountSeo(backend)
const panel = await openPanel()
await dismiss(index)
await flushPromises()
expect(document.activeElement).toBe(dismissButtons(panel)[target])
})
it.each([
{ failure: 'status 500', responder: failWith(500) },
{ failure: 'a network error', responder: networkFailure },
])('keeps the row and toasts on $failure', async ({ responder }) => {
const backend = backendWithDismissals()
backend.responders['PATCH /v1/article/12'] = responder
await mountSeo(backend)
const panel = await openPanel()
const before = checkCalls(backend).length
await dismiss(0)
expect(panelRows(panel)).toHaveLength(3)
expect(dismissButtons(panel)[0]!.disabled).toBe(false)
expect(toastText()).toContain("Couldn't dismiss the suggestion")
expect(toastText()).toContain('Try again in a moment.')
expect(checkCalls(backend)).toHaveLength(before)
})
it('shows the Not found state on a 404', async () => {
const backend = backendWithDismissals()
backend.responders['PATCH /v1/article/12'] = failWith(404)
await mountSeo(backend)
await openPanel()
await dismiss(0)
expect(textOf(document.body)).toContain("This article doesn't exist.")
})
it('shows nothing and sends no check on a 401', async () => {
const backend = backendWithDismissals()
backend.responders['PATCH /v1/article/12'] = failWith(401)
await mountSeo(backend)
const panel = await openPanel()
const before = checkCalls(backend).length
const toastsBefore = toastText()
await dismiss(0)
expect(panelRows(panel)).toHaveLength(3)
expect(toastText()).toBe(toastsBefore)
expect(checkCalls(backend)).toHaveLength(before)
})
it('restores every code with Show dismissed and checks again', async () => {
const backend = backendWithDismissals(['title_long', 'short'], [ALL[0]!])
await mountSeo(backend)
const compact = await openSection()
expect(textOf(compact)).toContain('2 dismissed suggestions are hidden.')
const link = compact.querySelector<HTMLElement>('[data-testid="suggestions-restore"]')!
link.click()
await flushPromises()
expect(patchBodies(backend)).toEqual([{ seo_dismissed: [] }])
expect(sectionBody()!.querySelectorAll('[data-testid="suggestion"]')).toHaveLength(1)
expect(sectionSummary()).toBeNull()
})
it.each(['owner', 'admin', 'verified'] as const)('is available to the %s', async (role) => {
const backend = backendWithDismissals()
backend.me.role = role === 'verified' ? undefined : role
await mountSeo(backend)
const panel = await openPanel()
expect(dismissButtons(panel)).toHaveLength(3)
})
it('keeps Save enabled and does not touch the draft', async () => {
const backend = backendWithDismissals()
await mountSeo(backend)
await openPanel()
await dismiss(0)
expect(patchBodies(backend)).toHaveLength(1)
const save = document.body.querySelector<HTMLButtonElement>('[data-testid="save-button"]')
expect(save?.getAttribute('data-state') ?? 'saved').not.toBe('error')
})
})
describe('Dismiss x: French', () => {
it('reads the name, footnote and plural in French', async () => {
const { setLocale } = await import('@/i18n')
setLocale('fr')
try {
await mountSeo(backendWithDismissals(['no_au', 'short'], ALL))
const panel = await openPanel()
expect(dismissButtons(panel)[0]!.getAttribute('aria-label')).toMatch(
/^Ignorer la suggestion : /,
)
expect(textOf(panel)).toContain(
'Ignorez-en une et elle reste masquée pour cet article.',
)
expect(textOf(panel)).toContain('2 suggestions ignorées sont masquées.')
} finally {
setLocale('en')
}
})
})
@@ -0,0 +1,216 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } 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 EndpointCard from '@/components/EndpointCard.vue'
import { API_GROUPS, ERROR_ROWS } from '@/data/apiCatalogue'
import IntegrationView from '../IntegrationView.vue'
const ORIGIN = 'https://cms.renew.example'
const BODY_MISMATCH = 'The request cannot be processed due to a mismatch in the format of the body.'
const NOTE =
'Site SEO defaults are owner only. Article SEO fields need only the right to edit the article.'
const GROUP_DESCRIPTION =
"RenewCMS doesn't render pages or add meta tags. These endpoints give your frontend the resolved SEO data and the list of published URLs."
let currentWrapper: VueWrapper | null = null
let fetchSpy: ReturnType<typeof vi.fn>
async function mountIntegration(path = '/integration') {
vi.stubGlobal('location', { ...window.location, origin: ORIGIN })
fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
Element.prototype.scrollIntoView = vi.fn()
await router.push(path)
currentWrapper = mount(IntegrationView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return currentWrapper
}
function section(wrapper: VueWrapper, id: string) {
return wrapper.get(`[data-anchor="${id}"]`)
}
function cardOf(wrapper: VueWrapper, route: string) {
const [method, path] = route.split(' ')
return wrapper
.findAllComponents(EndpointCard)
.find(
(item) =>
item.props('endpoint').method === method && item.props('endpoint').path === path,
)!
}
async function expand(wrapper: VueWrapper, route: string) {
const found = wrapper
.findAll('button[aria-expanded]')
.find((button) => button.attributes('aria-label')?.startsWith(`${route}. `))!
await found.trigger('click')
await flushPromises()
return cardOf(wrapper, route)
}
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.unstubAllGlobals()
setLocale('en')
await router.push('/login')
})
describe('Search and sharing group', () => {
it('sits directly after Articles with its description and 2 routes', async () => {
const wrapper = await mountIntegration()
const keys = API_GROUPS.map((group) => group.key)
expect(keys.indexOf('seo')).toBe(keys.indexOf('articles') + 1)
const group = section(wrapper, 'seo')
expect(group.text()).toContain('Search and sharing')
expect(group.text()).toContain(GROUP_DESCRIPTION)
expect(group.text()).toContain('2 routes')
})
it('holds both cards, Public and API key OK, collapsed, with no duplicate route', async () => {
const wrapper = await mountIntegration()
const group = section(wrapper, 'seo')
const cards = group.findAllComponents(EndpointCard)
expect(
cards.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`),
).toEqual(['GET /v1/article/{id}/seo', 'GET /v1/site/{key}/sitemap'])
for (const item of cards) {
expect(item.text()).toContain('Public')
expect(item.text()).toContain('API key OK')
expect(item.find('button[aria-expanded]').attributes('aria-expanded')).toBe('false')
}
const all = wrapper
.findAllComponents(EndpointCard)
.map((item) => `${item.props('endpoint').method} ${item.props('endpoint').path}`)
expect(all.filter((route) => route.endsWith('/seo'))).toHaveLength(1)
expect(all.filter((route) => route.endsWith('/sitemap'))).toHaveLength(1)
expect(
section(wrapper, 'articles')
.findAllComponents(EndpointCard)
.map((item) => item.props('endpoint').path),
).toContain('/v1/article/{id}/seo/check')
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
expect(section(wrapper, 'sites').text()).toContain('1 route')
})
it('describes the resolved SEO object and shows the real error rows', async () => {
const wrapper = await mountIntegration()
const seo = await expand(wrapper, 'GET /v1/article/{id}/seo')
const text = seo.text()
expect(text).toContain('Drafts answer 404 to anonymous callers.')
for (const name of [
'canonical_url',
'robots',
'og',
'twitter',
'image',
'authors',
'published_at',
'modified_at',
'fallbacks',
'json_ld',
]) {
expect(text).toContain(name)
}
expect(text).toContain('No request body.')
for (const tab of ['fetch', 'curl', 'Response']) {
expect(seo.findAll('[role="tab"]').map((item) => item.text())).toContain(tab)
}
expect(text).toContain('Copy')
expect(text).toContain('400')
expect(text).toContain('401')
expect(text).toContain('404')
})
it('shows the sitemap query rows, item shape, total header and real error strings', async () => {
const wrapper = await mountIntegration()
const sitemap = await expand(wrapper, 'GET /v1/site/{key}/sitemap')
const text = sitemap.text()
expect(text).toContain('A whole number from 1. Default 1.')
expect(text).toContain('25, 50, 100 or 500. Default 100.')
expect(text).toContain('{ url, modified_at, noindex }')
expect(text).toContain('X-Total-Count')
expect(text).toContain('/v1/site/renew-blog/sitemap')
expect(text).toContain(BODY_MISMATCH)
expect(text).toContain('site_not_found')
expect(text).not.toContain('Invalid page or size.')
})
it('lists the index in order and scrolls to the group with the deep link #seo', async () => {
const wrapper = await mountIntegration('/integration#seo')
const labels = wrapper
.get('nav[aria-label="On this page"]')
.findAll('button')
.map((button) => button.text())
expect(labels).toEqual([
'Authentication',
'Articles',
'Search and sharing',
'Media',
'Users and invites',
'Settings',
'Sites',
'Authors',
'Images and variants',
'Session only',
'Errors',
'Notes',
])
expect(Element.prototype.scrollIntoView).toHaveBeenCalled()
const current = wrapper
.get('nav[aria-label="On this page"]')
.findAll('button[aria-current]')
.map((button) => button.text())
expect(current).toEqual(['Search and sharing'])
})
it('adds the 403 note under the messages and leaves them unchanged', async () => {
const wrapper = await mountIntegration()
const forbidden = ERROR_ROWS.find((row) => row.status === 403)!
expect(forbidden.messages).toHaveLength(3)
expect(forbidden.note).toBe(NOTE)
const notes = wrapper.findAll('[data-testid="error-note"]').map((item) => item.text())
expect(notes).toEqual([NOTE, NOTE])
expect(ERROR_ROWS.filter((row) => row.note)).toHaveLength(1)
})
it('makes no /v1 request when opening and expanding the new cards', async () => {
const wrapper = await mountIntegration()
await expand(wrapper, 'GET /v1/article/{id}/seo')
await expand(wrapper, 'GET /v1/site/{key}/sitemap')
expect(fetchSpy).not.toHaveBeenCalled()
})
it('reads the group, the cards and the note in French', async () => {
setLocale('fr')
const wrapper = await mountIntegration()
const group = section(wrapper, 'seo')
expect(group.text()).toContain('Recherche et partage')
expect(group.text()).toContain('RenewCMS n’affiche pas de pages')
expect(group.text()).toContain('2 routes')
expect(wrapper.text()).toContain(
'Les valeurs SEO par défaut d’un site sont réservées au propriétaire.',
)
expect(section(wrapper, 'sites').text()).not.toContain('sitemap')
})
})
@@ -0,0 +1,342 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { useApi } from '@/composables/useApi'
import {
blur,
button,
callsTo,
chooseMenuItem,
click,
createBackend,
dialog,
failWith,
field,
mountDirectory,
networkFailure,
openRowMenu,
textOf,
typeInto,
unmountDirectory,
} from './directoryTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const TEMPLATE_ERROR = 'The template must contain %title%.'
const SAVE_FAILED = "Couldn't save the site. Your changes are still here. Try again in a moment."
const SITE_NAME_HELP = 'Shown in titles through the template, and sent as the Open Graph site name.'
const DESCRIPTION_HELP = 'Used when an article has no description, excerpt or body text.'
afterEach(unmountDirectory)
function box() {
return dialog()!
}
function templateField() {
return box().querySelector<HTMLInputElement>('input[maxlength="200"]')!
}
function siteNameField() {
return field('e.g. Renew')
}
function descriptionField() {
return field('One or two sentences about the site.') as unknown as HTMLTextAreaElement
}
async function openEdit(name = 'Renew', backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await openRowMenu(name)
await chooseMenuItem('Edit')
return backend
}
async function openAdd(backend = createBackend()) {
await mountDirectory('sites', '/settings/sites', { backend })
await click('Add site')
return backend
}
function submit(label = 'Save changes') {
return click(label, box())
}
describe('Sites SEO defaults: field order and copy', () => {
it('lists the fields in the board order, then the preview and the defaults block', async () => {
await openEdit()
const text = textOf(box())
const order = [
'Name',
'Key',
'Base URL',
'Default social image',
'Site name',
'Title template',
'Example',
'Default description',
'Default robots policy',
'Locale',
'Default site',
'Canonical URL preview',
'How articles use these defaults',
]
const positions = order.map((label) => text.indexOf(label))
expect(positions.every((position) => position >= 0)).toBe(true)
expect([...positions].sort((a, b) => a - b)).toEqual(positions)
})
it('edit: shows the saved values, helpers, example, counter and the defaults block', async () => {
await openEdit()
const text = textOf(box())
expect(siteNameField().value).toBe('Renew')
expect(text).toContain(SITE_NAME_HELP)
expect(text).not.toContain('Site name (optional)')
expect(templateField().value).toBe('%title% | %site%')
expect(text).toContain('Use %title% for the article title and %site% for the site name.')
expect(text).toContain('Example How we price a bathroom renovation | Renew')
expect(text).toContain('Default description (optional)')
expect(text).toContain('0 / 320')
expect(text).toContain(DESCRIPTION_HELP)
for (const copy of [
'Title Article title, then the template.',
"Description Used last, after the article's description, excerpt and body.",
'Image Used when an article has no cover image.',
'Robots Used unless an article is hidden from search engines.',
]) {
expect(text).toContain(copy)
}
expect(templateField().className).toContain('font-mono')
})
it('add: starts empty with the default template', async () => {
await openAdd()
expect(siteNameField().value).toBe('')
expect(templateField().value).toBe('%title% | %site%')
expect(descriptionField().value).toBe('')
expect(textOf(box())).toContain('How articles use these defaults')
})
})
describe.each([
['Journal', 'Journal', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
['Journal', '', '%title% – %site%', 'How we price a bathroom renovation – Journal'],
['Journal', 'Le Journal', '%site%: %title%', 'Le Journal: How we price a bathroom renovation'],
['Journal', 'Journal', '%title%', 'How we price a bathroom renovation'],
['Journal', 'Journal', '%site% / %site%', 'Journal / Journal'],
['', '', '%title% | %site%', 'How we price a bathroom renovation | Renew'],
])(
'Sites SEO defaults: example for name "%s", site name "%s", template "%s"',
(name, siteName, template, example) => {
it('follows the typing and sends nothing', async () => {
const backend = await openAdd()
const before = backend.calls.length
await typeInto(field('e.g. Journal'), name)
await typeInto(siteNameField(), siteName)
await typeInto(templateField(), template)
expect(textOf(box())).toContain(`Example ${example}`)
expect(backend.calls).toHaveLength(before)
})
},
)
describe.each(['just text', '%site%', '%TITLE%', ''])(
'Sites SEO defaults: template "%s"',
(template) => {
it('is refused on blur and on submit, with nothing sent', async () => {
const backend = await openEdit()
await typeInto(templateField(), template)
await blur(templateField())
expect(textOf(box())).toContain(TEMPLATE_ERROR)
expect(textOf(box())).not.toContain('Example')
await typeInto(templateField(), template)
await submit()
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
expect(dialog()).not.toBeNull()
expect(document.activeElement).toBe(templateField())
})
},
)
describe('Sites SEO defaults: template errors', () => {
it('clears the error and shows the example again on a valid value', async () => {
await openEdit()
await typeInto(templateField(), 'nope')
await blur(templateField())
await typeInto(templateField(), '%title%')
expect(textOf(box())).not.toContain(TEMPLATE_ERROR)
expect(textOf(box())).toContain('Example')
})
it('shows the same message when the API answers 400 invalid_title_template', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = failWith(400, 'invalid_title_template')
await openEdit('Renew', backend)
await typeInto(templateField(), '%title% – %site%')
await submit()
expect(textOf(box())).toContain(TEMPLATE_ERROR)
expect(templateField().value).toBe('%title% – %site%')
})
})
describe('Sites SEO defaults: limits and counter', () => {
it('stops the inputs at 100, 200 and 320 characters', async () => {
await openAdd()
expect(siteNameField().getAttribute('maxlength')).toBe('100')
expect(templateField().getAttribute('maxlength')).toBe('200')
expect(descriptionField().getAttribute('maxlength')).toBe('320')
})
it.each([0, 45, 320])('counts %i characters without blocking', async (count) => {
await openAdd()
await typeInto(descriptionField(), 'a'.repeat(count))
expect(textOf(box())).toContain(`${count} / 320`)
})
})
describe('Sites SEO defaults: save payloads', () => {
it('sends only the changed template', async () => {
const backend = await openEdit()
await typeInto(templateField(), '%title% – %site%')
await submit()
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({ title_template: '%title% – %site%' })
expect(dialog()).toBeNull()
})
it('sends site name, description and robots together, trimmed', async () => {
const backend = await openEdit()
await typeInto(siteNameField(), ' Renew Journal ')
await typeInto(descriptionField(), 'Notes on renewal work. ')
box().querySelector<HTMLElement>('[role="radio"][value="noindex"]')!.click()
await flushPromises()
await submit()
const patches = callsTo(backend, 'PATCH', '/v1/site/1')
expect(patches).toHaveLength(1)
expect(patches[0]!.body).toEqual({
site_name: 'Renew Journal',
default_description: 'Notes on renewal work.',
default_robots: 'noindex',
})
})
it('creates a site without the three fields when untouched', async () => {
const backend = await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
await typeInto(field('https://blog.example.com'), 'https://notes.example')
await submit('Add site')
const body = callsTo(backend, 'POST', '/v1/site/')[0]!.body!
expect(body).not.toHaveProperty('site_name')
expect(body).not.toHaveProperty('title_template')
expect(body).not.toHaveProperty('default_description')
})
it('creates a site with the SEO values', async () => {
const backend = await openAdd()
await typeInto(field('e.g. Journal'), 'Notes')
await typeInto(field('https://blog.example.com'), 'https://notes.example')
await typeInto(siteNameField(), 'Renew Shop')
await typeInto(templateField(), '%title% - %site%')
await typeInto(descriptionField(), 'A shop about renewal.')
await submit('Add site')
expect(callsTo(backend, 'POST', '/v1/site/')[0]!.body).toMatchObject({
site_name: 'Renew Shop',
title_template: '%title% - %site%',
default_description: 'A shop about renewal.',
})
})
it('opens a site without the new fields on the defaults and sends nothing on save', async () => {
const backend = createBackend()
for (const site of backend.sites) {
delete site.site_name
delete site.title_template
delete site.default_description
}
await openEdit('Renew', backend)
expect(siteNameField().value).toBe('')
expect(descriptionField().value).toBe('')
expect(templateField().value).toBe('%title% | %site%')
await submit()
expect(callsTo(backend, 'PATCH', '/v1/site/1')).toHaveLength(0)
})
it.each(['site name', 'template', 'description'])(
'asks to discard a changed %s',
async (which) => {
await openEdit()
const target =
which === 'site name'
? siteNameField()
: which === 'template'
? templateField()
: descriptionField()
await typeInto(target, 'changed %title%')
await click('Cancel', box())
expect(textOf(document.body.querySelector('[role="alertdialog"]'))).toContain(
'Discard changes?',
)
},
)
})
describe.each([
['status 500', failWith(500)],
['a network error', networkFailure],
])('Sites SEO defaults: save failed with %s', (_name, responder) => {
it('keeps the panel, the notice and the three values', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = responder
await openEdit('Renew', backend)
await typeInto(siteNameField(), 'Renew Journal')
await typeInto(templateField(), '%title% – %site%')
await typeInto(descriptionField(), 'Notes.')
await submit()
expect(textOf(box().querySelector('[role="alert"]'))).toContain(SAVE_FAILED)
expect(siteNameField().value).toBe('Renew Journal')
expect(templateField().value).toBe('%title% – %site%')
expect(descriptionField().value).toBe('Notes.')
expect(button('Save changes', box())?.disabled).toBe(false)
})
})
describe('Sites SEO defaults: sessions', () => {
it('shows the owner-only toast when the PATCH answers 403', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = failWith(403, 'owner_only')
await openEdit('Renew', backend)
await typeInto(templateField(), '%title% – %site%')
await submit()
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
})
it('shows the 403 page to an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
expect(document.body.textContent).toContain("You don't have access to Sites")
expect(document.body.textContent).not.toContain('Title template')
expect(vi.mocked(useApi)).toHaveBeenCalled()
})
})
+29 -2
View File
@@ -6,15 +6,25 @@ export interface EnvelopeParams {
q?: string
status?: ArticleStatus | ''
sort?: string
site?: string
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()
}
export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): ArticlePage {
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
const { page = 1, size = 25, q = '', status = '', sort = '', seo = '' } = params
const filtered = all
.filter(
(article) =>
!seo ||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
)
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || fold(article.title).includes(fold(q)))
if (sort) {
@@ -35,8 +45,17 @@ 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 = '' } = params
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
const query = new URLSearchParams({
format: 'html',
page: String(page),
@@ -51,6 +70,12 @@ export function articleListUrl(params: EnvelopeParams = {}) {
if (sort) {
query.set('sort', sort)
}
if (site) {
query.set('site', site)
}
if (seo) {
query.set('seo', seo)
}
return `/v1/article/?${query.toString()}`
}
@@ -66,5 +91,7 @@ export function parseListUrl(url: string): EnvelopeParams {
q: query.get('q') ?? '',
status: (query.get('status') ?? '') as ArticleStatus | '',
sort: query.get('sort') ?? '',
...(query.get('site') ? { site: query.get('site') ?? '' } : {}),
...(query.get('seo') ? { seo: 'suggestions' as const } : {}),
}
}
@@ -36,12 +36,15 @@ export function sitesFixture(): Site[] {
default_social_image_id: null,
default_robots: 'index' as const,
locale: null,
title_template: '%title% | %site%',
default_description: '',
}
return [
{
...base,
id: 1,
name: 'Renew',
site_name: 'Renew',
key: 'renew',
base_url: 'https://renew.example',
is_default: true,
@@ -51,6 +54,7 @@ export function sitesFixture(): Site[] {
...base,
id: 2,
name: 'Journal',
site_name: 'Journal',
key: 'journal',
base_url: 'https://journal.example',
is_default: false,
@@ -60,6 +64,7 @@ export function sitesFixture(): Site[] {
...base,
id: 3,
name: 'Projects',
site_name: 'Projects',
key: 'projects',
base_url: 'https://projects.example',
default_robots: 'noindex',
@@ -70,6 +75,7 @@ export function sitesFixture(): Site[] {
...base,
id: 4,
name: 'Archive',
site_name: 'Archive',
key: 'archive',
base_url: 'https://archive.example',
is_default: false,
+5
View File
@@ -174,6 +174,11 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
.filter((article) => !siteKey || article.site_id === siteId)
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || article.title.toLowerCase().includes(q))
.filter(
(article) =>
query.get('seo') !== 'suggestions' ||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
)
const online = backend.articles.filter((article) => article.is_online).length
return {
items: structuredClone(matching.slice((page - 1) * size, page * size)),