feat: editorial article list

This commit is contained in:
Florian Sylvain committed 2026-11-02 07:28:44 +01:00
1 parent a0b3c58c52
commit a671b81f80
103 files changed
+6493 -1805

No files matched your search

@@ -0,0 +1,619 @@
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 { customAccess } from '@/test/access'
import {
LIMITED_VIEWER,
siteLanguagesBody,
storyAuthorsBody,
storyPageFor,
type StoryViewer,
} from '@/test/storyLists'
import {
callsTo,
createMetaBackend,
failWith,
mountMeta,
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
const SKELETON_WAIT_MS = 220
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
setLocale('en')
await unmountMeta()
})
function backendFor(viewer: StoryViewer = {}, reader = false): MetaBackend {
const [first] = renewSites()
const sites = ['journal', 'atelier', 'press'].map((key, index) => ({
...first!,
id: index + 1,
key,
name: key.charAt(0).toUpperCase() + key.slice(1),
is_default: index === 0,
}))
const backend = createMetaBackend({ sites })
if (reader) {
backend.me.access = customAccess('content.read', 'Reader')
}
backend.responders['GET /v1/article/'] = (call) =>
storyPageFor(new URL(call.raw, 'http://localhost'), viewer)
backend.responders['GET /v1/site/journal/languages'] = () => siteLanguagesBody('journal')
backend.responders['GET /v1/site/atelier/languages'] = () => siteLanguagesBody('atelier')
backend.responders['GET /v1/author/'] = () => storyAuthorsBody()
return backend
}
function listCalls(backend: MetaBackend) {
return callsTo(backend, 'GET', '/v1/article/')
}
function lastCall(backend: MetaBackend) {
return listCalls(backend).at(-1)!
}
function unitLabel() {
return textOf(document.body.querySelector('[data-testid="unit-label"]'))
}
function tabs() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).map(textOf)
}
function rowTitles() {
return Array.from(document.body.querySelectorAll('[data-row-title]')).map(textOf)
}
function coverageOf(title: string) {
const row = Array.from(document.body.querySelectorAll<HTMLElement>('tbody tr, li')).find(
(candidate) => textOf(candidate).includes(title),
)!
return Array.from(
row.querySelectorAll<HTMLElement>('[data-testid="story-coverage"] [aria-label]'),
).map((chip) => chip.getAttribute('aria-label'))
}
function currentQuery() {
return { ...router.currentRoute.value.query }
}
function summary() {
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
}
async function openMenu(title: string) {
document.body.querySelector<HTMLElement>(`button[aria-label="Actions for ${title}"]`)!.click()
await flushPromises()
}
function menuLabels() {
return Array.from(document.body.querySelectorAll('[role="menuitem"]')).map(textOf)
}
async function openSelect(label: string) {
document.body.querySelector<HTMLElement>(`button[aria-label="${label}"]`)!.click()
await flushPromises()
return Array.from(document.body.querySelectorAll('[role="option"]')).map(textOf)
}
async function chooseOption(label: string, option: string) {
await openSelect(label)
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith(option))!
.click()
await flushPromises()
}
function buttonNamed(label: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('button')).find(
(item) => textOf(item) === label || item.getAttribute('aria-label') === label,
)
}
describe('loc.l7.all, All languages, page 1', () => {
it('shows 5 of 8 rows, the unit, two tabs and the pager', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&size=5', { backend })
expect(lastCall(backend).query.get('group')).toBe('story')
expect(unitLabel()).toBe('8 articles')
expect(tabs()).toEqual(['All articles 8', 'Needs translation 7'])
expect(rowTitles()).toHaveLength(5)
expect(summary()).toBe('Showing 1–5 of 8')
})
it('renders the coverage chips in the order of the response, dashed for missing', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
expect(coverageOf('How we price a bathroom renovation')).toEqual([
'English: published',
'French: draft',
'German: published',
'Arabic: draft',
])
expect(coverageOf('Winter checklist for timber windows')).toEqual([
'English: published',
'French: published',
'German: no version',
'Arabic: no version',
])
})
it('names the language of a representative that is not the default', async () => {
await mountMeta('articles', '/article?site=journal&page=2&size=5', {
backend: backendFor(),
})
await flushPromises()
expect(textOf(document.body)).toContain('Shown in Spanish · language disabled')
})
})
describe('loc.l7.page2, All languages, page 2', () => {
it('shows the last 3 rows and keeps page in the URL', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend })
expect(rowTitles()).toEqual([
'Why we reuse original floorboards',
'Our first passive-house retrofit',
'Cómo elegir un contratista',
])
expect(summary()).toBe('Showing 6–8 of 8')
expect(currentQuery()).toEqual({ site: 'journal', page: '2', size: '5' })
})
})
describe('loc.l7.fr, French selected', () => {
it('shows one row per French version, four tabs and the French unit', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr&size=5', { backend })
expect(lastCall(backend).query.get('lang')).toBe('fr')
expect(unitLabel()).toBe('4 French versions')
expect(tabs()).toEqual(['All 4', 'Published 2', 'Drafts 2', 'Not in French 4'])
expect(rowTitles()).toEqual([
'Comment nous chiffrons une rénovation de salle de bain',
"Guide de l'enduit à la chaux",
"Liste d'hiver pour les fenêtres en bois",
"Pourquoi nous réutilisons les lames de parquet d'origine",
])
expect(
document.body.querySelector<HTMLInputElement>('input[type="search"]')!.placeholder,
).toBe('Search French titles')
})
it('asks for status=draft with the Drafts tab and drops page', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr&size=5&page=1', { backend })
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
(item) => textOf(item).startsWith('Drafts'),
)!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
expect(lastCall(backend).query.get('status')).toBe('draft')
expect(unitLabel()).toBe('2 French drafts')
expect(currentQuery()).toEqual({
site: 'journal',
lang: 'fr',
size: '5',
status: 'draft',
})
})
})
describe('loc.l7.frMissing, Not in French', () => {
it('lists the articles without a French version with an Add French translation button', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr&tab=missing&size=5', {
backend,
})
expect(lastCall(backend).query.get('tab')).toBe('missing')
expect(unitLabel()).toBe('4 articles not in French')
expect(rowTitles()).toEqual([
'Spring newsletter',
'Roof inspection checklist',
'Our first passive-house retrofit',
'Cómo elegir un contratista',
])
expect(textOf(document.body)).toContain('Opens English · draft')
expect(textOf(document.body)).toContain('Opens Spanish · published · language disabled')
expect(
document.body.querySelectorAll('button[aria-label^="Add French translation to"]'),
).toHaveLength(4)
expect(tabs().at(-1)).toBe('Not in French 4')
})
})
describe('loc.l7.emptyMissing, Nothing missing', () => {
it('shows the nothing-missing state with no pager and no summary', async () => {
await mountMeta('articles', '/article?site=press&lang=fr&tab=missing', {
backend: backendFor(),
})
expect(textOf(document.body)).toContain('Every article you can see has a French version')
expect(textOf(document.body)).toContain(
'Articles without a French version will appear here.',
)
expect(unitLabel()).toBe('0 articles not in French')
expect(summary()).toBeUndefined()
expect(document.body.querySelector('nav[aria-label="Pagination"]')).toBeNull()
expect(document.body.querySelector('table')).toBeNull()
})
it('names no language when none is selected', async () => {
await mountMeta('articles', '/article?site=press&tab=missing', { backend: backendFor() })
expect(textOf(document.body)).toContain('Every article you can see is fully translated')
expect(unitLabel()).toBe('0 articles need translation')
})
})
describe('loc.l7.noResults, No results', () => {
it('shows the search empty state and every count at 0', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&q=zinc', { backend })
expect(textOf(document.body)).toContain('No articles match "zinc"')
expect(textOf(document.body)).toContain(
'Search covers titles in every language you can read.',
)
expect(tabs()).toEqual(['All articles 0', 'Needs translation 0'])
expect(summary()).toBeUndefined()
expect(lastCall(backend).query.get('q')).toBe('zinc')
})
})
describe('loc.l7.frLoading, Loading', () => {
it('shows the counts as a dash, skeleton rows and aria-busy while the request is pending', async () => {
const backend = backendFor()
backend.responders['GET /v1/article/'] = () => new Promise(() => undefined)
await mountMeta('articles', '/article?site=journal&lang=fr', { backend })
await new Promise((resolve) => setTimeout(resolve, SKELETON_WAIT_MS))
await flushPromises()
expect(tabs()).toEqual(['All –', 'Published –', 'Drafts –', 'Not in French –'])
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(unitLabel()).toBe('')
})
})
describe('loc.l7.listFail, Failed', () => {
it('shows the failure with the filters kept and retries the same request', async () => {
const backend = backendFor()
backend.responders['GET /v1/article/'] = failWith(500)
await mountMeta('articles', '/article?site=journal&lang=fr&q=chaux', { backend })
expect(textOf(document.body)).toContain("Couldn't load articles")
expect(textOf(document.body)).toContain('Your filters and page are kept. Try again.')
expect(tabs()).toEqual(['All –', 'Published –', 'Drafts –', 'Not in French –'])
expect(document.body.querySelector<HTMLInputElement>('input[type="search"]')!.value).toBe(
'chaux',
)
expect(currentQuery()).toEqual({ site: 'journal', lang: 'fr', q: 'chaux' })
backend.responders['GET /v1/article/'] = (call) =>
storyPageFor(new URL(call.raw, 'http://localhost'))
buttonNamed('Try again')!.click()
await flushPromises()
expect(textOf(document.body)).not.toContain("Couldn't load articles")
expect(lastCall(backend).query.get('q')).toBe('chaux')
expect(rowTitles()).toHaveLength(1)
})
})
describe('loc.l7.limited, Limited access', () => {
it('shows no dashed chips, no Add translation item and the limited-access banner', async () => {
const backend = backendFor(LIMITED_VIEWER, true)
await mountMeta('articles', '/article?site=journal', { backend })
expect(textOf(document.body)).toContain('Showing only what you can read')
expect(
document.body.querySelectorAll('[data-testid="story-coverage"] button'),
).toHaveLength(0)
const chips = Array.from(
document.body.querySelectorAll('[data-testid="story-coverage"] [aria-label]'),
).map((chip) => chip.getAttribute('aria-label') ?? '')
expect(chips.some((label) => label.includes('no version'))).toBe(false)
await openMenu(rowTitles()[0]!)
expect(menuLabels().some((label) => label.startsWith('Add translation'))).toBe(false)
})
})
describe('loc.l7.author, Author filter', () => {
it('narrows to Marta Alves, shows the chip and clears everything with Clear all', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&author=1', { backend })
expect(lastCall(backend).query.get('author')).toBe('1')
expect(unitLabel()).toBe('3 articles')
const chips = Array.from(
document.body.querySelectorAll('[data-testid="article-filter-chip"]'),
).map(textOf)
expect(chips).toEqual(['Site: Journal', 'Author: Marta Alves'])
buttonNamed('Clear all')!.click()
await flushPromises()
expect(currentQuery()).toEqual({})
expect(lastCall(backend).query.has('author')).toBe(false)
expect(lastCall(backend).query.has('site')).toBe(false)
expect(document.body.querySelector('[data-testid="article-filter-chip"]')).toBeNull()
})
it('removes one chip and keeps the others', async () => {
await mountMeta('articles', '/article?site=journal&author=1&status=draft', {
backend: backendFor(),
})
buttonNamed('Remove filter Author: Marta Alves')!.click()
await flushPromises()
expect(currentQuery()).toEqual({ site: 'journal', status: 'draft' })
})
it('picks an author from the Author select with the counts of articles', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal', { backend })
expect(await openSelect('Author')).toEqual([
'Any author',
'Marta Alves 3',
'Camille Roche 2',
'Luis Ferrer 3',
])
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((item) => textOf(item).startsWith('Marta'))!
.click()
await flushPromises()
expect(currentQuery().author).toBe('1')
expect(lastCall(backend).query.get('page')).toBe('1')
})
})
describe('loc.l7.langFilter, Language filter', () => {
it('groups the Enabled and Disabled languages with their version counts', async () => {
await mountMeta('articles', '/article?site=journal', { backend: backendFor() })
const options = await openSelect('Language')
expect(options).toEqual([
'All languages',
'English 7 versions',
'French 4 versions',
'German 4 versions',
'Arabic 1 version',
'Spanish 1 version',
'Italian 0 versions',
])
expect(textOf(document.body)).toContain('Enabled')
expect(textOf(document.body)).toContain('Disabled')
expect(textOf(document.body)).toContain(
'Disabled languages keep their versions. They can’t get new ones.',
)
})
it('selects a language, drops the page and shows the French tabs', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend })
await chooseOption('Language', 'French')
expect(currentQuery()).toEqual({ site: 'journal', size: '5', lang: 'fr' })
expect(lastCall(backend).query.get('page')).toBe('1')
expect(tabs()).toEqual(['All 4', 'Published 2', 'Drafts 2', 'Not in French 4'])
})
})
describe('loc.l7.rowMenu, Row menu', () => {
const expected = [
'Open English',
'Open another language…',
'Add translation…',
'Unpublish English',
'Copy API URL',
'Delete English version',
]
it('lists the same actions on desktop', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
await openMenu('Winter checklist for timber windows')
expect(menuLabels()).toEqual(expected)
})
it('lists the same actions on mobile', async () => {
await mountMeta('articles', '/article?site=journal&size=5', {
width: MOBILE_WIDTH,
backend: backendFor(),
})
await openMenu('Winter checklist for timber windows')
expect(menuLabels()).toEqual(expected)
})
it('hides Add translation on an article that has every language', async () => {
await mountMeta('articles', '/article?site=journal&size=5', { backend: backendFor() })
await openMenu('How we price a bathroom renovation')
expect(menuLabels()).not.toContain('Add translation…')
expect(menuLabels()).toContain('Open another language…')
})
it('gives every row a menu button', async () => {
await mountMeta('articles', '/article?site=journal&lang=fr&tab=missing&size=5', {
backend: backendFor(),
})
expect(document.body.querySelectorAll('button[aria-label^="Actions for"]')).toHaveLength(4)
})
})
describe('loc.l7.filters, Mobile filter sheet', () => {
it('opens a sheet with Journal, French and Draft and shows the result count', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr&status=draft', {
width: MOBILE_WIDTH,
backend,
})
const trigger = document.body.querySelector<HTMLElement>(
'button[aria-label="Filters, 3 active"]',
)!
expect(textOf(trigger)).toBe('Filters · 3')
expect(document.body.querySelector('button[aria-label="Language"]')).toBeNull()
trigger.click()
await flushPromises()
const sheet = document.body.querySelector<HTMLElement>(
'[data-testid="article-filters-sheet"]',
)!
expect(
['Site', 'Language', 'Status', 'Author', 'SEO'].every((label) =>
textOf(sheet).includes(label),
),
).toBe(true)
expect(sheet.querySelector('button[aria-label="Language"]')!.textContent).toContain(
'French',
)
expect(sheet.querySelector('button[aria-label="Status"]')!.textContent).toContain('Draft')
expect(buttonNamed('Clear all')).toBeDefined()
expect(buttonNamed('Show 2 results')).toBeDefined()
})
})
describe('loc.l7.tabsScroll, Tabs scrolled', () => {
it('keeps Not in French reachable with the scroll buttons beside the tabs', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=fr', {
width: MOBILE_WIDTH,
backend,
})
expect(buttonNamed('Scroll tabs left')).toBeDefined()
expect(buttonNamed('Scroll tabs right')).toBeDefined()
const tab = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tab"]')).find(
(item) => textOf(item).startsWith('Not in French'),
)!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
expect(lastCall(backend).query.get('tab')).toBe('missing')
expect(currentQuery()).toEqual({ site: 'journal', lang: 'fr', tab: 'missing' })
})
})
describe('loc.l7.long, Eight languages', () => {
it('collapses the chips to +3 on desktop and +4 on mobile with named overflow chips', async () => {
await mountMeta('articles', '/article?site=atelier&status=draft&seo=suggestions', {
backend: backendFor(),
})
const desktopOverflow = document.body.querySelector<HTMLElement>(
'[data-testid="story-coverage"] [aria-label^="3 more languages"]',
)!
expect(textOf(desktopOverflow)).toBe('+3')
expect(desktopOverflow.getAttribute('aria-label')).toBe(
'3 more languages: Chinese (Simplified), Japanese, Arabic',
)
await unmountMeta()
await mountMeta('articles', '/article?site=atelier&status=draft&seo=suggestions', {
width: MOBILE_WIDTH,
backend: backendFor(),
})
const mobileOverflow = document.body.querySelector<HTMLElement>(
'[data-testid="story-coverage"] [aria-label$="Arabic"]',
)!
expect(textOf(mobileOverflow)).toBe('+4')
})
})
describe('URL state', () => {
it.each([
{ query: 'lang=english', expected: {} },
{ query: 'tab=review', expected: {} },
{ query: 'author=abc', expected: {} },
{ query: 'page=abc', expected: {} },
{ query: 'lang=fr&tab=missing&status=draft', expected: { lang: 'fr', tab: 'missing' } },
])('falls back from ?$query and replaces the URL', async ({ query, expected }) => {
const backend = backendFor()
await mountMeta('articles', `/article?site=journal&${query}`, { backend })
expect(currentQuery()).toEqual({ site: 'journal', ...expected })
const first = listCalls(backend)[0]!.query
expect(first.get('group')).toBe('story')
expect(first.get('tab')).toBe(expected.tab ?? null)
})
it('drops a language the site does not have once the languages are known', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&lang=pt', { backend })
await flushPromises()
expect(currentQuery()).toEqual({ site: 'journal' })
expect(lastCall(backend).query.has('lang')).toBe(false)
})
it('drops page on every filter change', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?site=journal&page=2&size=5', { backend })
await chooseOption('Status', 'Draft')
expect(currentQuery().page).toBeUndefined()
expect(lastCall(backend).query.get('page')).toBe('1')
await chooseOption('Language', 'French')
expect(currentQuery().page).toBeUndefined()
await typeInto(
document.body.querySelector<HTMLInputElement>('input[type="search"]')!,
'chaux',
)
await new Promise((resolve) => setTimeout(resolve, SEARCH_WAIT_MS))
await flushPromises()
expect(currentQuery()).toMatchObject({ q: 'chaux', lang: 'fr', status: 'draft' })
expect(currentQuery().page).toBeUndefined()
})
it('reads the language names in French', async () => {
setLocale('fr')
await mountMeta('articles', '/article?site=journal&lang=fr&tab=missing&size=5', {
backend: backendFor(),
})
expect(unitLabel()).toBe('4 articles sans version en français')
expect(tabs().at(-1)).toBe('Pas en français 4')
})
it('sends group=story on the very first request and nothing for the hidden review tab', async () => {
const backend = backendFor()
await mountMeta('articles', '/article?tab=review', { backend })
expect(tabs().some((label) => label.startsWith('Needs review'))).toBe(false)
expect(listCalls(backend)[0]!.query.get('tab')).toBeNull()
})
})
@@ -1,9 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { customAccess, writerAccess } from '@/test/access'
import type { Access } from '@/lib/permissions'
import { writerAccess } from '@/test/access'
import type { ArticleCapability } from '@/composables/useArticles'
import {
articleOf,
createMetaBackend,
@@ -27,18 +26,17 @@ beforeEach(() => {
afterEach(unmountMeta)
const SESSION_USER = 1
const EVERYTHING: ArticleCapability[] = ['read', 'edit', 'publish', 'delete', 'create_translation']
function grants(publish: string, remove: string, update: string): Access {
return customAccess(
`content.read:article=all, content.create:article=all, content.publish:article=${publish}, content.delete:article=${remove}, content.update:article=${update}`,
'Contributor',
)
}
async function mountArticles(access: Access, articles = [articleOf(1, 'Article 1')]) {
async function mountArticles(
capabilitiesFor: MetaBackend['capabilitiesFor'],
articles = [articleOf(1, 'Article 1')],
configure: (backend: MetaBackend) => void = () => undefined,
) {
const backend: MetaBackend = createMetaBackend()
backend.articles = articles
backend.me.access = access
backend.capabilitiesFor = capabilitiesFor
configure(backend)
await mountMeta('articles', '/article', { backend })
return backend
}
@@ -48,103 +46,79 @@ async function openMenu(title: string) {
await flushPromises()
}
function menuItem(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
(item) => textOf(item).startsWith(label),
function menuLabels() {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).map(
(item) => textOf(item),
)
}
function isDisabled(item: HTMLElement | undefined) {
return item?.getAttribute('aria-disabled') === 'true' || item?.hasAttribute('data-disabled')
}
describe('Article controls under all and none', () => {
it('offers every control to a role that holds all of them', async () => {
await mountArticles(grants('all', 'all', 'all'))
describe('Row actions follow the capabilities of the response', () => {
it('offers every action when the row carries every capability', async () => {
await mountArticles(() => EVERYTHING)
await openMenu('Article 1')
expect(menuItem('Publish')).toBeDefined()
expect(isDisabled(menuItem('Delete'))).toBe(false)
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(menuLabels()).toEqual(['Edit', 'Publish', 'Copy API URL', 'Delete…'])
expect(textOf(document.body)).not.toContain('Ask an editor to publish.')
})
it('hides Publish, keeps the Draft status with the editor hint and disables Delete', async () => {
await mountArticles(grants('none', 'none', 'all'))
it('hides Publish and Delete and keeps the editor hint on a draft without those capabilities', async () => {
await mountArticles(() => ['read', 'edit'])
expect(document.body.querySelector('[data-testid="publish-hint"]')?.textContent).toBe(
'Ask an editor to publish.',
)
await openMenu('Article 1')
expect(menuItem('Publish')).toBeUndefined()
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't delete articles.")
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
})
it('disables Edit as well when the role cannot update', async () => {
await mountArticles(grants('none', 'none', 'none'))
it('keeps only Open and Copy API URL on a read-only row', async () => {
await mountArticles(() => ['read'])
await openMenu('Article 1')
expect(isDisabled(menuItem('Edit'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't edit articles.")
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
})
})
describe('The Writer built-in Articles list', () => {
it('reads the Writer header, enables Edit and disables Delete with its reason', async () => {
await mountArticles(writerAccess())
it('reads the Writer header and the server decides the row actions', async () => {
await mountArticles(
() => ['read', 'edit', 'create_translation'],
undefined,
(backend) => {
backend.me.access = writerAccess()
},
)
expect(textOf(document.body)).toContain(
'You can write and edit all articles. Publishing is done by an editor.',
)
await openMenu('Article 1')
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't delete articles.")
expect(menuItem('Publish')).toBeUndefined()
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
})
})
describe('Article controls under own scope', () => {
const OWN = grants('own', 'own', 'own')
describe('Article actions on the rows of other people', () => {
const articles = [
articleOf(1, 'Article 1', { created_by: SESSION_USER }),
articleOf(2, 'Article 2', { created_by: 9 }),
]
const own = (article: { created_by?: number }): ArticleCapability[] =>
article.created_by === SESSION_USER ? EVERYTHING : ['read']
it('makes Edit, Delete and Publish available on the article the person created', async () => {
await mountArticles(OWN, articles)
it('shows the actions the response grants on the article the person created', async () => {
await mountArticles(own, articles)
await openMenu('Article 1')
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(isDisabled(menuItem('Delete'))).toBe(false)
expect(menuItem('Publish')).toBeDefined()
expect(menuLabels()).toEqual(['Edit', 'Publish', 'Copy API URL', 'Delete…'])
})
it('disables Edit and Delete and hides Publish on an article by someone else', async () => {
await mountArticles(OWN, articles)
it('hides Publish and Delete on an article by someone else', async () => {
await mountArticles(own, articles)
await openMenu('Article 2')
expect(isDisabled(menuItem('Edit'))).toBe(true)
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain('You can only edit content you created.')
expect(menuItem('Publish')).toBeUndefined()
expect(menuLabels()).toEqual(['Edit', 'Copy API URL'])
expect(document.body.querySelector('tr [data-testid="publish-hint"]')?.textContent).toBe(
'Ask an editor to publish.',
)
})
})
describe('A stale menu', () => {
it('disables Delete with its reason once the session loses the permission', async () => {
await mountArticles(grants('all', 'all', 'all'))
const session = useSessionStore()
session.session = { ...session.session!, access: grants('all', 'none', 'all') }
await flushPromises()
await openMenu('Article 1')
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't delete articles.")
})
})
+78 -87
View File
@@ -7,8 +7,9 @@ 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 { storyEnvelope, storyListRoute } from './articleEnvelope'
import { ownerAccess } from '@/test/access'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
@@ -133,7 +134,10 @@ function sixArticleFixture(): Article[] {
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticles(handlers: Record<string, () => Response | Promise<Response>>) {
async function mountArticles(
handlers: Record<string, () => Response | Promise<Response>>,
path = '/article',
) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
toastAdd = vi.fn()
@@ -143,7 +147,7 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
global.fetch = fetchRouter({
...handlers,
}) as unknown as typeof fetch
await router.push('/article')
await router.push(path)
const wrapper = mount(ArticleView, {
attachTo: document.body,
global: {
@@ -159,21 +163,6 @@ function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text().trim() === label)
}
async function clickTab(label: string) {
const button = Array.from(document.body.querySelectorAll('button')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
}
async function openRowMenu(wrapper: Awaited<ReturnType<typeof mount>>, title: string) {
const button = document.body.querySelector<HTMLButtonElement>(
`button[aria-label="Actions for ${title}"]`,
@@ -191,6 +180,7 @@ async function chooseMenuItem(label: string) {
}
beforeEach(() => {
setViewportWidth(DESKTOP_WIDTH)
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
@@ -205,12 +195,12 @@ 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({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
expect(wrapper.text()).toContain('All articles 6')
expect(wrapper.text()).toContain('Needs translation 0')
expect(wrapper.find('[data-testid="unit-label"]').text()).toBe('6 articles')
const titles = sixArticleFixture().map((a) => a.title)
const rendered = wrapper.text()
@@ -231,10 +221,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({
[articleListRoute()]: () =>
[storyListRoute()]: () =>
new Promise((resolve) => {
resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticleFixture())))
resolve(mockResponse(200, storyEnvelope(sixArticleFixture())))
}),
})
@@ -253,7 +243,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({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -274,7 +264,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', (
})
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -301,7 +291,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
})
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -319,12 +309,10 @@ 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({
[articleListRoute()]: () => mockResponse(200, articleEnvelope([])),
[storyListRoute()]: () => mockResponse(200, storyEnvelope([])),
})
expect(wrapper.text()).toContain('All 0')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('All articles 0')
expect(wrapper.text()).toContain('No articles yet')
expect(wrapper.text()).toContain(
'Write your first one. It stays a draft until you publish it.',
@@ -340,20 +328,20 @@ 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({
[articleListRoute()]: () => mockResponse(500),
[storyListRoute()]: () => mockResponse(500),
})
expect(wrapper.text()).not.toContain('No articles yet')
})
})
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
describe('No results — a search matches nothing and the counts follow the search', () => {
it('shows the no-results empty state, then clears the search', async () => {
const all = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(all)),
[articleListRoute({ q: 'tiles' })]: () =>
mockResponse(200, articleEnvelope(all, { q: 'tiles' })),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(all)),
[storyListRoute({ q: 'tiles' })]: () =>
mockResponse(200, storyEnvelope(all, { q: 'tiles' })),
})
const searchInput = wrapper.find('input[type="search"]')
@@ -362,38 +350,32 @@ describe('No results — a search matches nothing, but the tab counts still show
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('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
expect(wrapper.text()).toContain('Search covers titles in every language you can read.')
expect(wrapper.text()).toContain('All articles 0')
await findButton(wrapper, 'Clear search')?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('How we price a bathroom renovation')
const inputEl = document.querySelector('input[placeholder="Search by title"]')
const inputEl = document.querySelector('input[type="search"]')
expect(inputEl).toBe(document.activeElement)
})
})
describe('An empty tab shows its own EmptyState, while the other tab still counts correctly', () => {
describe('An empty status filter shows its own EmptyState and the counts stay', () => {
it('shows No drafts when every article is online', async () => {
const onlineOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(onlineOnly)),
[articleListRoute({ status: 'draft' })]: () =>
mockResponse(200, articleEnvelope(onlineOnly, { status: 'draft' })),
})
const wrapper = await mountArticles(
{
[storyListRoute({ status: 'draft' })]: () =>
mockResponse(200, storyEnvelope(onlineOnly, { status: 'draft' })),
},
'/article?status=draft',
)
await clickTab('Drafts 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('All articles 4')
expect(wrapper.text()).toContain('No drafts')
expect(wrapper.text()).toContain("Everything you've written is online.")
})
@@ -402,17 +384,15 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
const draftsOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(draftsOnly)),
[articleListRoute({ status: 'online' })]: () =>
mockResponse(200, articleEnvelope(draftsOnly, { status: 'online' })),
})
const wrapper = await mountArticles(
{
[storyListRoute({ status: 'online' })]: () =>
mockResponse(200, storyEnvelope(draftsOnly, { status: 'online' })),
},
'/article?status=online',
)
await clickTab('Online 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 4')
expect(wrapper.text()).toContain('All articles 4')
expect(wrapper.text()).toContain('Nothing is online yet')
expect(wrapper.text()).toContain('Publish a draft and it will appear on your website.')
})
@@ -421,7 +401,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({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
})
@@ -441,7 +421,7 @@ describe('Published — a 200 updates the row in place and raises a toast', () =
updated_at: iso(0),
}
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
})
@@ -469,7 +449,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({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': patchCall,
})
@@ -508,7 +488,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({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': patchCall,
})
@@ -537,9 +517,13 @@ 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 () => {
let current = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
'PATCH /v1/article/2/publish?format=html': () => {
current = current.filter((article) => article.id !== 2)
return mockResponse(404)
},
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
@@ -547,9 +531,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
await flushPromises()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(wrapper.text()).toContain('All articles 5')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-2',
color: 'info',
@@ -560,9 +542,13 @@ 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 () => {
let current = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/2': () => mockResponse(404),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
'DELETE /v1/article/2': () => {
current = current.filter((article) => article.id !== 2)
return mockResponse(404)
},
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
@@ -571,6 +557,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
await flushPromises()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(toastAdd).toHaveBeenCalledWith({
@@ -585,9 +572,13 @@ 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 () => {
let current = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
'PATCH /v1/article/3/unpublish?format=html': () => {
current = current.filter((article) => article.id !== 3)
return mockResponse(404)
},
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
@@ -595,9 +586,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
await flushPromises()
expect(wrapper.text()).not.toContain('Before and after: the Rue Verte kitchen')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 3')
expect(wrapper.text()).toContain('Drafts 2')
expect(wrapper.text()).toContain('All articles 5')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-3',
color: 'info',
@@ -611,7 +600,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({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -631,9 +620,13 @@ 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 () => {
let current = sixArticleFixture()
const wrapper = await mountArticles({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(204),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(current)),
'DELETE /v1/article/4': () => {
current = current.filter((article) => article.id !== 4)
return mockResponse(204)
},
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -645,9 +638,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
await flushPromises()
expect(wrapper.text()).not.toContain('Winter checklist for timber windows')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(wrapper.text()).toContain('All articles 5')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-deleted-4',
color: 'success',
@@ -661,7 +652,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({
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
[storyListRoute()]: () => mockResponse(200, storyEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(500),
})
@@ -8,7 +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, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import { storyEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import { ownerAccess } from '@/test/access'
const KEY = 'renewcms-page-size-articles'
@@ -53,7 +53,7 @@ function installFetch() {
ok: true,
status: 200,
headers: { get: () => null },
json: async () => articleEnvelope(articles, params),
json: async () => storyEnvelope(articles, params),
} as unknown as Response)
}
throw new Error(`Unexpected fetch call: ${method} ${url}`)
@@ -5,11 +5,11 @@ 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 type { Article, StoryPage } 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 { storyEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import { ownerAccess } from '@/test/access'
const TOTAL = 312
@@ -77,7 +77,7 @@ 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))
return mockResponse(200, storyEnvelope(articles, params))
}
function installFetch() {
@@ -256,7 +256,7 @@ describe('3 Last page', () => {
})
describe('4 Single page', () => {
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
it('keeps the summary and the page-size select, hides the paginator and reads the tab count', async () => {
articles = buildArticles(6).map((article, index) => ({
...article,
is_online: index < 4,
@@ -266,9 +266,7 @@ describe('4 Single page', () => {
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')
expect(wrapper.text()).toContain('All articles 6')
})
})
@@ -351,7 +349,7 @@ describe('7a A shrinking server cannot loop the last-page redirect', () => {
listHandler = (params) => {
calls += 1
return mockResponse(200, {
...articleEnvelope(articles, params),
...storyEnvelope(articles, params),
items: [],
total: 25 * (30 - calls),
})
@@ -375,7 +373,7 @@ describe('7b Out of range with total 0 shows the empty state, no redirect', () =
})
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 () => {
it('sends q with page 1, updates the URL on success and follows the search in the tab count', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?page=7')
listCalls = []
@@ -389,14 +387,12 @@ describe('8 Search resets to page 1', () => {
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')
expect(wrapper.text()).toContain('All articles 15')
})
})
describe('9 Empty after search', () => {
it('hides the paginator and the summary and keeps the tab counts', async () => {
it('hides the paginator and the summary and shows every count at 0', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article')
@@ -405,15 +401,11 @@ describe('9 Empty after search', () => {
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('Search covers titles in every language you can read.')
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')
expect(wrapper.text()).toContain('All articles 0')
})
})
@@ -494,12 +486,12 @@ describe('Keyboard, the page-size list', () => {
})
describe('Tabs filter on the server', () => {
it('sends status=draft with page 1, updates the URL on success and keeps the counts', async () => {
it('sends tab=missing with page 1 and updates the URL on success', async () => {
const wrapper = await mountAt('/article?page=3')
listCalls = []
const tab = Array.from(document.body.querySelectorAll('button')).find((button) =>
button.textContent?.trim().startsWith('Drafts'),
button.textContent?.trim().startsWith('Needs translation'),
)
tab?.focus()
tab?.dispatchEvent(
@@ -511,11 +503,11 @@ describe('Tabs filter on the server', () => {
)
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')
expect(listCalls).toEqual([
{ page: 1, size: 25, q: '', status: '', sort: '', tab: 'missing' },
])
expect(router.currentRoute.value.fullPath).toBe('/article?tab=missing')
expect(wrapper.text()).toContain('All articles 312')
})
})
@@ -559,13 +551,13 @@ describe('Deleting the last row of the last page', () => {
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')
expect(document.body.textContent).toContain('All articles 25')
})
})
describe('The envelope is the only shape read', () => {
it('renders rows from items', async () => {
const page: ArticlePage = articleEnvelope(buildArticles(3))
const page: StoryPage = storyEnvelope(buildArticles(3))
listHandler = () => mockResponse(200, page)
const wrapper = await mountAt('/article')
@@ -591,9 +583,7 @@ function headerArrow(label: string) {
describe('8a Sort headers, server-side, reset to page 1', () => {
it.each([
['Title', 'title:asc'],
['Status', 'status:asc'],
['Created', 'created_at:asc'],
['Article', 'title: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')
@@ -604,14 +594,14 @@ describe('8a Sort headers, server-side, reset to page 1', () => {
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')
expect(wrapper.text()).toContain('All articles 312')
})
it('keeps the URL unchanged until the response arrives', async () => {
await mountAt('/article?page=7')
listHandler = () => new Promise(() => undefined)
await click(headerButton('Title'))
await click(headerButton('Article'))
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
})
@@ -622,10 +612,10 @@ describe('8b Toggling the sorted header', () => {
await mountAt('/article?sort=title:asc')
listCalls = []
await click(headerButton('Title'))
await click(headerButton('Article'))
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')
expect(headerArrow('Article')).toBe('down')
await click(headerButton('Updated'))
await click(headerButton('Updated'))
@@ -665,13 +655,11 @@ describe('8c Sort is kept across paging, page size and search', () => {
})
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')
it('sends the sort and shows the Article arrow-down', async () => {
await mountAt('/article?sort=title:desc&page=2')
expect(listCalls).toEqual([
{ page: 2, size: 25, q: '', status: '', sort: 'created_at:desc' },
])
expect(headerArrow('Created')).toBe('down')
expect(listCalls).toEqual([{ page: 2, size: 25, q: '', status: '', sort: 'title:desc' }])
expect(headerArrow('Article')).toBe('down')
})
it('falls back to the default for an invalid sort and replaces the URL', async () => {
@@ -686,7 +674,7 @@ describe('8d A sorted URL on load', () => {
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)
const page = storyEnvelope(articles, params)
return mockResponse(200, {
...page,
items: [...page.items].reverse(),
@@ -697,7 +685,7 @@ describe('8e No client-side re-sorting', () => {
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' })
const expected = storyEnvelope(articles, { sort: 'title:asc' })
.items.map((article) => article.id)
.reverse()
expect(rendered).toEqual(expected)
@@ -711,12 +699,12 @@ describe('8f Caption follows the sort', () => {
'Articles, sorted by last updated, newest first',
)
await click(headerButton('Title'))
await click(headerButton('Article'))
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by title, ascending',
)
expect(document.body.querySelector('th[aria-sort="ascending"]')?.textContent).toContain(
'Title',
'Article',
)
})
})
@@ -730,7 +718,7 @@ describe('8g Mobile card list', () => {
)
expect(
Array.from(document.body.querySelectorAll('ul button')).filter((button) =>
/^(Title|Status|Created|Updated)$/.test(button.textContent?.trim() ?? ''),
/^(Article|Updated)$/.test(button.textContent?.trim() ?? ''),
),
).toHaveLength(0)
})
@@ -63,7 +63,7 @@ describe('Articles list: Read = Own with drafts of their own', () => {
expect(page()).toContain(
'Your role reads online articles and the drafts you created. The counts follow the same rule.',
)
expect(tabs()).toEqual(expect.arrayContaining(['All 5', 'Online 3', 'Drafts 2']))
expect(tabs()).toEqual(['All articles 5', 'Needs translation 0'])
})
it('keeps New article enabled when the role can create', async () => {
@@ -83,16 +83,16 @@ describe('Articles list: Read = Own with drafts of their own', () => {
total: 2,
page: 1,
size: 25,
counts: { all: 2, online: 2, draft: 0, suggestions: 0 },
counts: { all: 2, online: 2, draft: 0, suggestions: 0, missing: 0 },
})
await mountMeta('articles', '/article', { backend })
expect(tabs()).toEqual(expect.arrayContaining(['All 2', 'Online 2', 'Drafts 0']))
expect(tabs()).toEqual(['All articles 2', 'Needs translation 0'])
})
})
describe('Articles list: no drafts of their own', () => {
it('shows the own-drafts empty state on the Draft tab with the counts 3, 3, 0', async () => {
it('shows the own-drafts empty state on the Draft tab with the count 3', async () => {
await mountMeta('articles', '/article?status=draft', {
backend: backendFor(READ_OWN, BOARD_ARTICLES.slice(0, 3)),
})
@@ -101,7 +101,7 @@ describe('Articles list: no drafts of their own', () => {
expect(page()).toContain(
"Drafts you create appear here. Your role can't see drafts by other people.",
)
expect(tabs()).toEqual(expect.arrayContaining(['All 3', 'Online 3', 'Drafts 0']))
expect(tabs()).toEqual(['All articles 3', 'Needs translation 0'])
const action = Array.from(
document.body.querySelectorAll('main button, #dashboard-content button'),
).find((candidate) => textOf(candidate) === 'New article')
@@ -81,44 +81,22 @@ function currentQuery() {
return { ...router.currentRoute.value.query }
}
describe('Articles: the Authors column on every frame', () => {
describe('Articles: the Author column on every frame', () => {
it.each([
{ authors: 'no authors', names: [], avatars: 0, more: '', text: '—' },
{
authors: 'one author',
names: FIVE.slice(0, 1),
avatars: 1,
more: '',
text: '',
},
{
authors: 'three',
names: FIVE.slice(0, 3),
avatars: 3,
more: '',
text: '',
},
{ authors: 'five', names: FIVE, avatars: 3, more: '+2', text: '' },
])('$authors: $avatars avatar(s) and "$more"', async ({ names, avatars, more, text }) => {
{ authors: 'no authors', names: [], text: 'No authors' },
{ authors: 'one author', names: FIVE.slice(0, 1), text: 'Inès Duarte' },
{ authors: 'three', names: FIVE.slice(0, 3), text: 'Inès Duarte +2' },
{ authors: 'five', names: FIVE, text: 'Inès Duarte +4' },
])('$authors: reads "$text"', async ({ names, text }) => {
await mountMeta('articles', '/article', {
backend: backendWith('one', [withAuthors(1, names)]),
})
const authorsCell = cell(bodyRows()[0]!, 1)
const stack = authorsCell.querySelector('[data-testid="author-stack"]')
expect(stack?.querySelectorAll('[aria-hidden="true"]').length ?? 0).toBe(avatars)
expect(
authorsCell.querySelector('[data-testid="author-stack-more"]')?.textContent ?? '',
).toBe(more)
if (text) {
expect(textOf(authorsCell)).toBe(text)
expect(authorsCell.querySelector('[aria-label="No authors"]')).not.toBeNull()
}
expect(headers()[1]).toBe('Authors')
expect(authorsCell.querySelector('[title]')?.getAttribute('title')).toBe(names.join(', '))
expect(textOf(cell(bodyRows()[0]!, 2))).toBe(text)
expect(headers()[2]).toBe('Author')
})
it('draws avatars with the first name and no site on the mobile cards', async () => {
it('reads the first name and no site on the mobile cards', async () => {
await mountMeta('articles', '/article', {
width: MOBILE_WIDTH,
backend: backendWith('several', [
@@ -131,27 +109,24 @@ describe('Articles: the Authors column on every frame', () => {
document.body.querySelectorAll<HTMLElement>('li [data-row-title]'),
).map((title) => title.closest('li') as HTMLElement)
expect(textOf(cards[0])).toContain('Inès Duarte +2')
expect(
cards[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(3)
expect(textOf(cards[1])).toContain('No authors')
expect(textOf(cards[0])).not.toContain('Journal')
})
})
describe('Articles: the Site column and the Site filter', () => {
it('has neither with one site', async () => {
describe('Articles: the Site filter', () => {
it('has no Site filter with one site', async () => {
await mountMeta('articles', '/article', {
backend: backendWith('one', [withAuthors(1, ['Inès Duarte'])]),
})
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
expect(headers()).toEqual(['Article', 'Languages', 'Author', 'Updated', 'Actions'])
expect(siteSelect()).toBeNull()
expect(textOf(document.body)).not.toContain('All sites')
})
it.each(['owner', 'admin', 'verified'] as const)(
'shows a read-only Site column and a Site select for a %s with several sites',
'shows a Site select for a %s with several sites',
async (role) => {
const backend = backendWith('several', [
withAuthors(1, ['Inès Duarte'], { site_id: 2 }),
@@ -160,17 +135,7 @@ describe('Articles: the Site column and the Site filter', () => {
backend.me.access = accessFor(role)
await mountMeta('articles', '/article', { backend })
expect(headers()).toEqual([
'Title',
'Authors',
'Site',
'Status',
'Created',
'Updated',
'',
])
expect(bodyRows().map((row) => textOf(cell(row, 2)))).toEqual(['Journal', 'Renew'])
expect(cell(bodyRows()[0]!, 2).querySelector('button, a, input')).toBeNull()
expect(headers()).toEqual(['Article', 'Languages', 'Author', 'Updated', 'Actions'])
siteSelect()!.click()
await flushPromises()
expect(
@@ -179,12 +144,11 @@ describe('Articles: the Site column and the Site filter', () => {
},
)
it('drops the Site column and filter when the sites cannot be loaded', async () => {
it('drops the Site filter when the sites cannot be loaded', async () => {
const backend = backendWith('several', [withAuthors(1, ['Inès Duarte'])])
backend.responders['GET /v1/site/'] = failWith(500)
await mountMeta('articles', '/article', { backend })
expect(headers()).toEqual(['Title', 'Authors', 'Status', 'Created', 'Updated', ''])
expect(siteSelect()).toBeNull()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
@@ -205,8 +169,6 @@ describe('Articles: choosing a site', () => {
await mountMeta('articles', '/article?page=3&size=5&status=online&sort=title:asc', {
backend,
})
const before = textOf(document.body.querySelector('[role="tablist"]'))
await chooseSite('Journal')
expect(currentQuery()).toEqual({
@@ -220,7 +182,9 @@ describe('Articles: choosing a site', () => {
expect(last.query.get('page')).toBe('1')
expect(last.query.get('status')).toBe('online')
expect(last.query.get('sort')).toBe('title:asc')
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(before)
expect(textOf(document.body.querySelector('[role="tablist"]'))).toBe(
'All articles 20 Needs translation 0',
)
expect(siteSelect()!.textContent).toContain('Journal')
})
@@ -294,7 +258,7 @@ describe('Articles: choosing a site', () => {
act: async () => {
const tab = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="tab"]'),
).find((item) => textOf(item).startsWith('Drafts'))!
).find((item) => textOf(item).startsWith('Needs translation'))!
tab.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
tab.click()
await flushPromises()
@@ -304,7 +268,7 @@ describe('Articles: choosing a site', () => {
action: 'changes the sort',
act: async () => {
Array.from(document.body.querySelectorAll<HTMLElement>('thead button'))
.find((item) => textOf(item).startsWith('Title'))!
.find((item) => textOf(item).startsWith('Article'))!
.click()
await flushPromises()
},
+48 -187
View File
@@ -2,7 +2,6 @@ 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,
@@ -66,12 +65,24 @@ function backendWith(articles: Article[], sites: 'one' | 'several' = 'one'): Met
return backend
}
function toggle() {
return document.body.querySelector<HTMLElement>('[data-testid="needs-attention-toggle"]')!
function seoSelect() {
return document.body.querySelector<HTMLElement>('button[aria-label="SEO"]')!
}
function chips(root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>('[data-testid="seo-status-chip"]'))
async function openSeoOptions() {
seoSelect().click()
await flushPromises()
return Array.from(document.body.querySelectorAll('[role="option"]')).map((option) =>
textOf(option),
)
}
async function chooseSeo(label: string) {
await openSeoOptions()
Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
.find((option) => textOf(option).startsWith(label))!
.click()
await flushPromises()
}
function bodyRows() {
@@ -90,135 +101,19 @@ 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')
describe('The SEO filter', () => {
it('offers Any SEO state and Needs attention with the count of the response', async () => {
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)')
expect(seoSelect().textContent).toContain('Any SEO state')
const options = await openSeoOptions()
expect(options).toEqual(['Any SEO state', 'Needs attention 3'])
})
it('turns on keeping q, status, sort, site and size, and resets the page', async () => {
@@ -228,7 +123,7 @@ describe('Needs attention toggle', () => {
'/article?page=1&size=50&q=Article&status=online&sort=title:asc&site=renew',
{ backend },
)
await clickToggle()
await chooseSeo('Needs attention')
expect(currentQuery()).toEqual({
size: '50',
@@ -249,21 +144,22 @@ describe('Needs attention toggle', () => {
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()
await chooseSeo('Any SEO state')
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 () => {
it('opens a URL with the filter already on and shows its chip', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=1', { backend })
expect(toggle().getAttribute('aria-checked')).toBe('true')
expect(seoSelect().textContent).toContain('Needs attention')
expect(mainCalls(backend)[0]!.query.get('seo')).toBe('suggestions')
expect(bodyRows()).toHaveLength(3)
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All 5')
expect(textOf(document.body.querySelector('[role="tablist"]'))).toContain('All articles 3')
expect(textOf(document.body)).toContain('SEO: Needs attention')
})
it.each([
@@ -278,19 +174,16 @@ describe('Needs attention toggle', () => {
await mountMeta('articles', `/article${query}`, { backend })
expect(currentQuery()).toEqual({})
expect(toggle().getAttribute('aria-checked')).toBe('false')
expect(seoSelect().textContent).toContain('Any SEO state')
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 }) => {
it('keeps ?seo=suggestions and resets the page when the user searches', async () => {
const backend = backendWith(boardArticles())
await mountMeta('articles', '/article?seo=suggestions&page=2&size=5', { backend })
await act()
await search('Article')
expect(currentQuery().seo).toBe('suggestions')
expect(currentQuery().page).toBeUndefined()
@@ -302,13 +195,20 @@ describe('Needs attention toggle', () => {
await mountMeta('articles', '/article', { backend })
expect(document.body.querySelector('button[aria-label="Site"]')).toBeNull()
await clickToggle()
await chooseSeo('Needs attention')
expect(currentQuery()).toEqual({ seo: 'suggestions' })
expect(bodyRows()).toHaveLength(3)
})
it('reads the options in French', async () => {
setLocale('fr')
await mountMeta('articles', '/article', { backend: backendWith(boardArticles()) })
expect(await openSeoOptions()).toEqual(['Tout état SEO', 'À traiter 3'])
})
})
describe('Needs attention count', () => {
describe('The Needs attention count', () => {
function envelope(counts: Record<string, number>) {
return { items: [], total: 0, page: 1, size: 25, counts }
}
@@ -317,12 +217,8 @@ describe('Needs attention count', () => {
const backend = backendWith(boardArticles(), 'several')
await mountMeta('articles', '/article?q=Article&status=online&site=renew', { backend })
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)
expect((await openSeoOptions())[1]).toBe('Needs attention 3')
expect(countCalls(backend)).toHaveLength(0)
})
it.each([
@@ -331,18 +227,19 @@ describe('Needs attention count', () => {
])('shows $label', async ({ suggestions, label }) => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = () =>
envelope({ all: 5, online: 2, draft: 3, suggestions })
envelope({ all: 5, online: 2, draft: 3, suggestions, missing: 0 })
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe(label)
expect((await openSeoOptions())[1]).toBe(label)
})
it('shows no count when counts.suggestions is absent', async () => {
const backend = backendWith(boardArticles())
backend.responders['GET /v1/article/'] = () => envelope({ all: 5, online: 2, draft: 3 })
backend.responders['GET /v1/article/'] = () =>
envelope({ all: 5, online: 2, draft: 3, missing: 0 })
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
expect((await openSeoOptions())[1]).toBe('Needs attention')
})
it('shows no count before the first response arrives', async () => {
@@ -350,7 +247,7 @@ describe('Needs attention count', () => {
backend.responders['GET /v1/article/'] = () => new Promise(() => {})
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
expect((await openSeoOptions())[1]).toBe('Needs attention')
})
it.each([
@@ -361,45 +258,9 @@ describe('Needs attention count', () => {
backend.responders['GET /v1/article/'] = responder
await mountMeta('articles', '/article', { backend })
expect(textOf(toggle())).toBe('Needs attention')
expect((await openSeoOptions())[1]).toBe('Needs attention')
expect(currentQuery()).toEqual({})
})
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(textOf(toggle())).toBe('Needs attention 30')
document.body.querySelector<HTMLElement>('button[aria-label="Next page"]')!.click()
await flushPromises()
await clickTab('Drafts')
await search('Article 1')
expect(textOf(toggle())).toBe('Needs attention 30')
expect(countCalls(backend)).toHaveLength(0)
})
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 })
expect(countCalls(backend)).toHaveLength(0)
expect(textOf(toggle())).toBe('Needs attention 3')
})
})
describe('Needs attention: empty states', () => {
@@ -97,12 +97,10 @@ describe('Sites and authors across Editor, Articles and Profile', () => {
expect(useSessionStore().session?.author).toMatchObject({ id: 200 })
await navigate('/article')
expect(
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(1)
expect(textOf(stackRows()[0]!.children[2])).toBe('Zoe Lambert')
})
it('a verified account creates its profile from the hint, saves, and the Authors column shows the avatar', async () => {
it('a verified account creates its profile from the hint, saves, and the Author column shows the name', async () => {
const backend = createMetaBackend({
sites: renewSites().slice(0, 1),
access: accessFor('verified'),
@@ -118,9 +116,7 @@ describe('Sites and authors across Editor, Articles and Profile', () => {
expect(patchCalls(backend)[0]!.body).toEqual({ author_ids: [300] })
await navigate('/article')
expect(
stackRows()[0]!.querySelectorAll('[data-testid="author-stack"] [aria-hidden="true"]'),
).toHaveLength(1)
expect(textOf(stackRows()[0]!.children[2])).toBe('Marta Alves')
await navigate('/profile')
expect(textOf(document.body)).toContain('marta-alves')
})
+118 -1
View File
@@ -1,4 +1,11 @@
import type { Article, ArticlePage, ArticleStatus } from '@/composables/useArticles'
import type {
Article,
ArticleCapability,
ArticlePage,
ArticleStatus,
StoryItem,
StoryPage,
} from '@/composables/useArticles'
export interface EnvelopeParams {
page?: number
@@ -8,6 +15,28 @@ export interface EnvelopeParams {
sort?: string
site?: string
seo?: '' | 'suggestions'
lang?: string
tab?: '' | 'missing'
author?: number
}
export const ALL_CAPABILITIES: ArticleCapability[] = [
'read',
'edit',
'publish',
'delete',
'create_translation',
]
export function storyOf(article: Article, capabilities = ALL_CAPABILITIES): StoryItem {
return {
...article,
story_updated_at: article.updated_at,
story_authors: article.authors ?? [],
coverage: [],
missing_langs: [],
capabilities,
}
}
function fold(value: string) {
@@ -42,6 +71,41 @@ export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): Ar
}
}
export function storyEnvelope(all: Article[], params: EnvelopeParams = {}): StoryPage {
const { page = 1, size = 25, q = '', status = '', sort = '', seo = '' } = params
const matching = all
.filter(
(article) =>
!seo ||
(article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0),
)
.filter((article) => !q || fold(article.title).includes(fold(q)))
const filtered = matching.filter(
(article) => !status || article.is_online === (status === 'online'),
)
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 = matching.filter((article) => article.is_online).length
return {
items: filtered.slice((page - 1) * size, page * size).map((article) => storyOf(article)),
total: filtered.length,
page,
size,
counts: {
all: matching.length,
online,
draft: matching.length - online,
missing: 0,
},
}
}
export function articleListUrl(params: EnvelopeParams = {}) {
const { page = 1, size = 25, q = '', status = '', sort = '', site = '', seo = '' } = params
const query = new URLSearchParams({
@@ -81,5 +145,58 @@ export function parseListUrl(url: string): EnvelopeParams {
sort: query.get('sort') ?? '',
...(query.get('site') ? { site: query.get('site') ?? '' } : {}),
...(query.get('seo') ? { seo: 'suggestions' as const } : {}),
...(query.get('lang') ? { lang: query.get('lang') ?? '' } : {}),
...(query.get('tab') ? { tab: 'missing' as const } : {}),
...(query.get('author') ? { author: Number(query.get('author')) } : {}),
}
}
export function storyListUrl(params: EnvelopeParams = {}) {
const {
page = 1,
size = 25,
q = '',
status = '',
sort = '',
site = '',
seo = '',
lang = '',
tab = '',
author = 0,
} = 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)
}
if (site) {
query.set('site', site)
}
if (seo) {
query.set('seo', seo)
}
query.set('group', 'story')
if (lang) {
query.set('lang', lang)
}
if (tab) {
query.set('tab', tab)
}
if (author) {
query.set('author', String(author))
}
return `/v1/article/?${query.toString()}`
}
export function storyListRoute(params: EnvelopeParams = {}) {
return `GET ${storyListUrl(params)}`
}
+19 -7
View File
@@ -5,7 +5,8 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import App from '@/App.vue'
import router from '@/router'
import { ApiError, useApi, type MeAuthor, type MeResult } from '@/composables/useApi'
import type { Article, Byline } from '@/composables/useArticles'
import type { Article, ArticleCapability, Byline } from '@/composables/useArticles'
import { storyOf } from './articleEnvelope'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
@@ -39,6 +40,7 @@ export type MetaRole = 'owner' | 'admin' | 'verified'
export interface MetaBackend extends Backend {
article: Article
articles: Article[]
capabilitiesFor?: (article: Article) => ArticleCapability[]
me: MeResult
meAuthorSlug?: string
meSlugAdjusted?: boolean
@@ -169,31 +171,41 @@ function listArticles(backend: MetaBackend, query: URLSearchParams) {
const page = Number(query.get('page') ?? 1)
const siteKey = query.get('site')
const status = query.get('status')
const grouped = query.get('group') === 'story'
const q = (query.get('q') ?? '').toLowerCase()
const siteId = backend.sites.find((site) => site.key === siteKey)?.id
const matching = backend.articles
const base = backend.articles
.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
const matching = base.filter(
(article) => !status || article.is_online === (status === 'online'),
)
const countable = grouped ? base : backend.articles
const online = countable.filter((article) => article.is_online).length
const rows = matching.slice((page - 1) * size, page * size)
return {
items: structuredClone(matching.slice((page - 1) * size, page * size)),
items: structuredClone(
grouped
? rows.map((article) => storyOf(article, backend.capabilitiesFor?.(article)))
: rows,
),
total: matching.length,
page,
size,
counts: {
all: backend.articles.length,
all: countable.length,
online,
draft: backend.articles.length - online,
draft: countable.length - online,
suggestions: backend.articles.filter(
(article) =>
article.seo_status === 'suggestions' && (article.seo_suggestions ?? 0) > 0,
).length,
...(grouped ? { missing: 0 } : {}),
},
}
}