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

@@ -11,6 +11,9 @@ describe('parseArticleQuery', () => {
sort: '',
site: '',
seo: '',
lang: '',
tab: '',
author: 0,
})
})
@@ -31,6 +34,9 @@ describe('parseArticleQuery', () => {
sort: 'title:asc',
site: '',
seo: '',
lang: '',
tab: '',
author: 0,
})
})
@@ -55,6 +61,51 @@ describe('parseArticleQuery', () => {
})
})
describe('localized list parameters', () => {
it('reads lang, tab and author', () => {
expect(parseArticleQuery({ lang: 'fr', tab: 'missing', author: '12' })).toMatchObject({
lang: 'fr',
tab: 'missing',
author: 12,
})
})
it.each(['english', 'x', '', 'fr_FR', '12'])('falls back from the lang "%s"', (lang) => {
expect(parseArticleQuery({ lang }).lang).toBe('')
})
it.each(['review', 'all', 'published', ''])('falls back from the tab "%s"', (tab) => {
expect(parseArticleQuery({ tab }).tab).toBe('')
})
it.each(['abc', '0', '-1', '', '1.5'])('falls back from the author "%s"', (author) => {
expect(parseArticleQuery({ author }).author).toBe(0)
})
it('drops the status when the tab is Not in language', () => {
expect(parseArticleQuery({ tab: 'missing', status: 'draft' }).status).toBe('')
})
it('writes lang, tab and author and omits their defaults', () => {
expect(
toRouteQuery({
...parseArticleQuery({}),
lang: 'fr',
tab: 'missing',
author: 4,
}),
).toEqual({ lang: 'fr', tab: 'missing', author: '4' })
expect(toRouteQuery(parseArticleQuery({}))).toEqual({})
})
it('tells queries apart by lang, tab and author', () => {
const base = parseArticleQuery({})
expect(sameArticleQuery(base, { ...base, lang: 'fr' })).toBe(false)
expect(sameArticleQuery(base, { ...base, tab: 'missing' })).toBe(false)
expect(sameArticleQuery(base, { ...base, author: 3 })).toBe(false)
})
})
describe('sort parameter', () => {
it.each(['title:asc', 'status:desc', 'created_at:asc', 'created_at:desc'])(
'keeps %s',
+125
View File
@@ -0,0 +1,125 @@
import { afterEach, describe, expect, it } from 'vitest'
import { setLocale } from '@/i18n'
import { parseArticleQuery } from '@/lib/articleListQuery'
import {
activeTabKey,
coverageChips,
listTabs,
overflowLabel,
queryForTab,
unitLabel,
} from '@/lib/storyList'
import type { CoverageEntry } from '@/composables/useArticles'
const counts = { all: 4, online: 2, draft: 2, suggestions: 0, missing: 4 }
function query(raw: Record<string, string> = {}) {
return parseArticleQuery(raw)
}
function entry(lang: string, isOnline: boolean, enabled = true): CoverageEntry {
return {
id: 1,
lang,
title: lang,
is_online: isOnline,
language_enabled: enabled,
capabilities: ['read'],
}
}
afterEach(() => setLocale('en'))
describe('listTabs', () => {
it('offers All articles and Needs translation when no language is selected', () => {
expect(
listTabs(query(), { ...counts, all: 8, missing: 7 }).map((tab) => [
tab.label,
tab.count,
]),
).toEqual([
['All articles', 8],
['Needs translation', 7],
])
})
it('offers All, Published, Drafts and Not in French for a language', () => {
expect(
listTabs(query({ lang: 'fr' }), counts).map((tab) => [tab.label, tab.count]),
).toEqual([
['All', 4],
['Published', 2],
['Drafts', 2],
['Not in French', 4],
])
})
it('has no count before the first response', () => {
expect(listTabs(query(), null).map((tab) => tab.count)).toEqual([null, null])
})
it('names the language in French lowercase', () => {
setLocale('fr')
expect(listTabs(query({ lang: 'de' }), counts)[3]!.label).toBe('Pas en allemand')
})
})
describe('tab selection', () => {
it('reads the active tab from tab and status', () => {
expect(activeTabKey(query())).toBe('all')
expect(activeTabKey(query({ lang: 'fr', status: 'draft' }))).toBe('draft')
expect(activeTabKey(query({ lang: 'fr', tab: 'missing' }))).toBe('missing')
expect(activeTabKey(query({ status: 'draft' }))).toBe('all')
})
it('resets page and the other tab filters when a tab is chosen', () => {
const base = { ...query({ lang: 'fr', status: 'draft' }), page: 3 }
expect(queryForTab(base, 'missing')).toMatchObject({ tab: 'missing', status: '', page: 1 })
expect(queryForTab(base, 'online')).toMatchObject({ tab: '', status: 'online', page: 1 })
expect(queryForTab(base, 'all')).toMatchObject({ tab: '', status: '', page: 1 })
})
})
describe('unitLabel', () => {
it.each([
[{}, 8, '8 articles'],
[{}, 1, '1 article'],
[{ lang: 'fr' }, 4, '4 French versions'],
[{ lang: 'fr' }, 1, '1 French version'],
[{ lang: 'fr', tab: 'missing' }, 4, '4 articles not in French'],
[{ tab: 'missing' }, 7, '7 articles need translation'],
[{ tab: 'missing' }, 1, '1 article needs translation'],
[{ lang: 'fr', status: 'draft' }, 2, '2 French drafts'],
[{ lang: 'fr', status: 'online' }, 2, '2 published French versions'],
])('%j with %i rows reads "%s"', (raw, total, label) => {
expect(unitLabel(query(raw), total)).toBe(label)
})
})
describe('coverageChips', () => {
it('keeps the response order, then the dashed missing languages', () => {
const chips = coverageChips([entry('en', true), entry('es', true, false)], ['fr', 'de'])
expect(chips.map((chip) => [chip.code, chip.status, chip.disabled])).toEqual([
['EN', 'published', false],
['ES', 'published', true],
['FR', 'missing', false],
['DE', 'missing', false],
])
expect(chips.map((chip) => chip.label)).toEqual([
'English: published',
'Spanish: published, language disabled',
'French: no version',
'German: no version',
])
})
it('names the hidden languages of the overflow chip', () => {
const chips = coverageChips([], ['fr', 'de', 'ar'])
expect(overflowLabel(chips)).toBe('3 more languages: French, German, Arabic')
expect(overflowLabel(chips.slice(0, 1))).toBe('1 more language: French')
})
})
+50
View File
@@ -0,0 +1,50 @@
import { locale, t } from '@/i18n'
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import type { ArticleListQuery } from '@/lib/articleListQuery'
import { languageName } from '@/lib/siteLanguages'
export type FilterChipKey = 'site' | 'lang' | 'status' | 'author' | 'seo'
export interface FilterChip {
key: FilterChipKey
label: string
}
export function filterChips(
query: ArticleListQuery,
sites: Site[],
authors: Author[],
): FilterChip[] {
if (!query.status && !query.author && !query.seo) {
return []
}
const chips: FilterChip[] = []
const site = sites.find((entry) => entry.key === query.site)
if (query.site) {
chips.push({ key: 'site', label: t('Site: {0}', [site?.name ?? query.site]) })
}
if (query.lang) {
chips.push({
key: 'lang',
label: t('Language: {0}', [languageName(query.lang, locale.value)]),
})
}
if (query.status) {
chips.push({
key: 'status',
label: t('Status: {0}', [query.status === 'draft' ? t('Draft') : t('Published')]),
})
}
if (query.author) {
const author = authors.find((entry) => entry.id === query.author)
chips.push({
key: 'author',
label: t('Author: {0}', [author?.name ?? String(query.author)]),
})
}
if (query.seo) {
chips.push({ key: 'seo', label: t('SEO: Needs attention') })
}
return chips
}
+17
View File
@@ -0,0 +1,17 @@
import type { Author } from '@/composables/useAuthors'
import type { Site } from '@/composables/useSites'
import type { ArticleStatus, SEO_FILTER } from '@/lib/articleListQuery'
import type { SiteLanguage } from '@/lib/siteLanguages'
export interface ArticleFilterFieldProps {
site: string
sites: Site[]
severalSites: boolean
lang: string
languages: SiteLanguage[]
status: '' | ArticleStatus
author: number
authors: Author[]
seo: '' | typeof SEO_FILTER
suggestions: number | null
}
+24 -1
View File
@@ -9,6 +9,9 @@ import {
} from '@/lib/pageQuery'
export type ArticleStatus = 'online' | 'draft'
export type ArticleTab = '' | 'missing'
const LANGUAGE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/
export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
@@ -24,6 +27,9 @@ export interface ArticleListQuery {
sort: string
site: string
seo: '' | typeof SEO_FILTER
lang: string
tab: ArticleTab
author: number
}
const SORT_PATTERN = new RegExp(`^(${ARTICLE_SORT_FIELDS.join('|')}):(asc|desc)$`)
@@ -44,15 +50,20 @@ export function nextSort(current: string, field: ArticleSortField) {
export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
const page = firstQueryValue(query.page)
const size = Number(firstQueryValue(query.size))
const tab = firstQueryValue(query.tab) === 'missing' ? 'missing' : ''
const status = firstQueryValue(query.status)
const lang = firstQueryValue(query.lang)
return {
page: parsePositiveInteger(page) ?? 1,
size: PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE,
q: firstQueryValue(query.q).slice(0, SEARCH_MAX_LENGTH),
status: status === 'online' || status === 'draft' ? status : '',
status: tab === '' && (status === 'online' || status === 'draft') ? status : '',
sort: validSort(firstQueryValue(query.sort)),
site: firstQueryValue(query.site).slice(0, SITE_KEY_MAX),
seo: firstQueryValue(query.seo) === SEO_FILTER ? SEO_FILTER : '',
lang: LANGUAGE_TAG.test(lang) ? lang : '',
tab,
author: parsePositiveInteger(firstQueryValue(query.author)) ?? 0,
}
}
@@ -79,6 +90,15 @@ export function toRouteQuery(query: ArticleListQuery) {
if (query.seo) {
route.seo = query.seo
}
if (query.lang) {
route.lang = query.lang
}
if (query.tab) {
route.tab = query.tab
}
if (query.author) {
route.author = String(query.author)
}
return route
}
@@ -90,6 +110,9 @@ export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery
left.status === right.status &&
left.site === right.site &&
left.seo === right.seo &&
left.lang === right.lang &&
left.tab === right.tab &&
left.author === right.author &&
effectiveSort(left.sort) === effectiveSort(right.sort)
)
}
-17
View File
@@ -1,17 +0,0 @@
export type SeoChip = { kind: 'suggestions'; count: number } | { kind: 'hidden' }
interface SeoStatusSource {
seo_status?: unknown
seo_suggestions?: unknown
}
export function seoChipOf(article: SeoStatusSource): SeoChip | null {
if (article.seo_status === 'hidden') {
return { kind: 'hidden' }
}
const count = article.seo_suggestions
if (article.seo_status === 'suggestions' && typeof count === 'number' && count > 0) {
return { kind: 'suggestions', count }
}
return null
}
+196
View File
@@ -0,0 +1,196 @@
import { locale, t } from '@/i18n'
import type {
ArticleCapability,
CoverageEntry,
StoryCounts,
StoryItem,
} from '@/composables/useArticles'
import type { ArticleListQuery, ArticleStatus } from '@/lib/articleListQuery'
import { authorsSummary } from '@/lib/articleMeta'
import { formatAbsoluteDate, formatRelativeDate } from '@/lib/relativeDate'
import { languageName } from '@/lib/siteLanguages'
export type ListTabKey = 'all' | 'online' | 'draft' | 'missing'
export interface ListTab {
key: ListTabKey
label: string
count: number | null
}
export interface CoverageChip {
lang: string
code: string
status: 'published' | 'draft' | 'missing'
disabled: boolean
label: string
entry: CoverageEntry | null
}
export const COVERAGE_LIMIT_DESKTOP = 5
export const COVERAGE_LIMIT_NARROW = 4
export function nameInSentence(tag: string) {
const name = languageName(tag, locale.value)
return locale.value === 'fr' ? name.toLocaleLowerCase('fr') : name
}
export function activeTabKey(query: ArticleListQuery): ListTabKey {
if (query.tab === 'missing') {
return 'missing'
}
if (query.lang && query.status) {
return query.status
}
return 'all'
}
export function listTabs(query: ArticleListQuery, counts: StoryCounts | null): ListTab[] {
const count = (key: keyof StoryCounts) => (counts ? (counts[key] ?? 0) : null)
if (!query.lang) {
return [
{ key: 'all', label: t('All articles'), count: count('all') },
{ key: 'missing', label: t('Needs translation'), count: count('missing') },
]
}
return [
{ key: 'all', label: t('All'), count: count('all') },
{ key: 'online', label: t('Published'), count: count('online') },
{ key: 'draft', label: t('Drafts'), count: count('draft') },
{
key: 'missing',
label: t('Not in {0}', [nameInSentence(query.lang)]),
count: count('missing'),
},
]
}
export function queryForTab(query: ArticleListQuery, key: ListTabKey): ArticleListQuery {
const status: '' | ArticleStatus = key === 'online' || key === 'draft' ? key : ''
return { ...query, tab: key === 'missing' ? 'missing' : '', status, page: 1 }
}
export function unitLabel(query: ArticleListQuery, total: number) {
const plural = total !== 1
if (!query.lang) {
if (query.tab === 'missing') {
return plural
? t('{0} articles need translation', [total])
: t('1 article needs translation')
}
return plural ? t('{0} articles', [total]) : t('1 article')
}
const name = nameInSentence(query.lang)
if (query.tab === 'missing') {
return plural
? t('{0} articles not in {1}', [total, name])
: t('1 article not in {0}', [name])
}
if (query.status === 'draft') {
return plural ? t('{0} {1} drafts', [total, name]) : t('1 {0} draft', [name])
}
if (query.status === 'online') {
return plural
? t('{0} published {1} versions', [total, name])
: t('1 published {0} version', [name])
}
return plural ? t('{0} {1} versions', [total, name]) : t('1 {0} version', [name])
}
function chipLabel(chip: Omit<CoverageChip, 'label'>) {
const status =
chip.status === 'published'
? t('published')
: chip.status === 'draft'
? t('draft')
: t('no version')
const suffix = chip.disabled ? `, ${t('language disabled')}` : ''
return `${t('{0}: {1}', [languageName(chip.lang, locale.value), status])}${suffix}`
}
export function coverageChips(coverage: CoverageEntry[], missing: string[]): CoverageChip[] {
const existing = coverage.map((entry) => {
const chip = {
lang: entry.lang,
code: entry.lang.toUpperCase(),
status: entry.is_online ? ('published' as const) : ('draft' as const),
disabled: !entry.language_enabled,
entry,
}
return { ...chip, label: chipLabel(chip) }
})
const absent = missing.map((lang) => {
const chip = {
lang,
code: lang.toUpperCase(),
status: 'missing' as const,
disabled: false,
entry: null,
}
return { ...chip, label: chipLabel(chip) }
})
return [...existing, ...absent]
}
export function overflowLabel(hidden: CoverageChip[]) {
const names = hidden.map((chip) => languageName(chip.lang, locale.value)).join(', ')
return hidden.length === 1
? t('1 more language: {0}', [names])
: t('{0} more languages: {1}', [hidden.length, names])
}
export function can(capabilities: ArticleCapability[] | undefined, capability: ArticleCapability) {
return (capabilities ?? []).includes(capability)
}
export type ListMode = 'story' | 'version' | 'missing'
export function listMode(query: ArticleListQuery): ListMode {
if (query.lang) {
return query.tab === 'missing' ? 'missing' : 'version'
}
return 'story'
}
export function shownInLine(item: StoryItem, defaultLang: string) {
if (!item.lang || !defaultLang || item.lang === defaultLang) {
return ''
}
const name = languageName(item.lang, locale.value)
const disabled =
item.coverage?.find((entry) => entry.id === item.id)?.language_enabled === false
return disabled ? t('Shown in {0} · language disabled', [name]) : t('Shown in {0}', [name])
}
export function opensLine(item: StoryItem) {
const name = languageName(item.lang ?? '', locale.value)
const disabled =
item.coverage?.find((entry) => entry.id === item.id)?.language_enabled === false
if (disabled) {
return item.is_online
? t('Opens {0} · published · language disabled', [name])
: t('Opens {0} · draft · language disabled', [name])
}
return item.is_online ? t('Opens {0} · published', [name]) : t('Opens {0} · draft', [name])
}
export function storyUpdated(iso: string, now: Date = new Date()) {
const text = formatRelativeDate(iso, now)
const date = new Date(iso)
if (text !== formatAbsoluteDate(date) || date.getFullYear() !== now.getFullYear()) {
return text
}
return formatAbsoluteDate(date).replace(/\s*\d{4}$/, '')
}
export function editPath(item: StoryItem) {
return `/article/${item.id}/edit`
}
export function rowAuthors(item: StoryItem, mode: ListMode) {
return authorsSummary((mode === 'story' ? item.story_authors : item.authors) ?? [])
}
export function rowUpdated(item: StoryItem, mode: ListMode, now?: Date) {
return storyUpdated(mode === 'story' ? item.story_updated_at : item.updated_at, now)
}
+94
View File
@@ -0,0 +1,94 @@
import { locale, t } from '@/i18n'
import type { StoryItem } from '@/composables/useArticles'
import { can } from '@/lib/storyList'
import { languageName } from '@/lib/siteLanguages'
export interface StoryMenuItem {
key: string
label: string
icon: string
destructive?: boolean
children?: StoryMenuItem[]
onSelect: () => void
}
export interface StoryMenuHandlers {
open: (id: number) => void
addTranslation: (item: StoryItem) => void
publish: (item: StoryItem) => void
unpublish: (item: StoryItem) => void
copyUrl: (item: StoryItem) => void
remove: (item: StoryItem) => void
}
export function versionName(item: Pick<StoryItem, 'lang'>) {
return item.lang ? languageName(item.lang, locale.value) : ''
}
export function storyMenuItems(item: StoryItem, handlers: StoryMenuHandlers): StoryMenuItem[] {
const name = versionName(item)
const others = (item.coverage ?? []).filter((entry) => entry.id !== item.id)
const items: StoryMenuItem[] = [
{
key: 'open',
label: name ? t('Open {0}', [name]) : t('Edit'),
icon: 'i-lucide-file-pen',
onSelect: () => handlers.open(item.id),
},
]
if ((item.coverage ?? []).length > 1) {
items.push({
key: 'open-another',
label: t('Open another language…'),
icon: 'i-lucide-languages',
onSelect: () => undefined,
children: others.map((entry) => ({
key: `open-${entry.id}`,
label: languageName(entry.lang, locale.value),
icon: 'i-lucide-file-pen',
onSelect: () => handlers.open(entry.id),
})),
})
}
if (can(item.capabilities, 'create_translation') && (item.missing_langs ?? []).length > 0) {
items.push({
key: 'add-translation',
label: t('Add translation…'),
icon: 'i-lucide-plus',
onSelect: () => handlers.addTranslation(item),
})
}
if (can(item.capabilities, 'publish')) {
items.push(
item.is_online
? {
key: 'unpublish',
label: name ? t('Unpublish {0}', [name]) : t('Unpublish'),
icon: 'i-lucide-eye-off',
onSelect: () => handlers.unpublish(item),
}
: {
key: 'publish',
label: name ? t('Publish {0}', [name]) : t('Publish'),
icon: 'i-lucide-eye',
onSelect: () => handlers.publish(item),
},
)
}
items.push({
key: 'copy-url',
label: t('Copy API URL'),
icon: 'i-lucide-copy',
onSelect: () => handlers.copyUrl(item),
})
if (can(item.capabilities, 'delete')) {
items.push({
key: 'delete',
label: name ? t('Delete {0} version', [name]) : t('Delete…'),
icon: 'i-lucide-trash-2',
destructive: true,
onSelect: () => handlers.remove(item),
})
}
return items
}