feat: articles pagination

This commit is contained in:
Florian Sylvain committed 2026-10-11 19:14:37 +02:00
1 parent a2d224240c
commit 73f4b539fe
45 files changed
+3528 -317

No files matched your search

+200
View File
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { t, formatNumber } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { pageItems, rangeFor } from '@/lib/pagination'
import { ARTICLE_PAGE_SIZES } from '@/lib/articleListQuery'
const DESKTOP_QUERY = '(min-width: 48rem)'
const props = defineProps<{
page: number
pageCount: number
total: number
size: number
loadingPage: number | null
}>()
const emit = defineEmits<{
'update:page': [page: number]
'update:size': [size: number]
}>()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const busy = computed(() => props.loadingPage !== null)
const atStart = computed(() => busy.value || props.page <= 1)
const atEnd = computed(() => busy.value || props.page >= props.pageCount)
const showControls = computed(() => props.pageCount > 1)
const items = computed(() => pageItems(props.page, props.pageCount))
const summary = computed(() => {
if (props.loadingPage !== null) {
return t('Loading page {0}…', [formatNumber(props.loadingPage)])
}
const { from, to } = rangeFor(props.page, props.size, props.total)
return t('Showing {0}–{1} of {2}', [
formatNumber(from),
formatNumber(to),
formatNumber(props.total),
])
})
const sizeItems = computed(() =>
ARTICLE_PAGE_SIZES.map((value) => ({ label: t('{0} per page', [value]), value })),
)
const FOCUS_RING =
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
const CONTROL = `justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 disabled:opacity-40 aria-disabled:opacity-40 ${FOCUS_RING}`
const ICON_CONTROL = `size-11 md:size-8 ${CONTROL}`
const CURRENT_PAGE = 'border-primary bg-(--rc-sand) font-semibold text-(--rc-on-sand)'
function numberClass(number: number) {
const sizing = `h-11 min-w-11 px-1.5 md:h-8 md:min-w-8 ${CONTROL}`
return number === props.page ? `${sizing} ${CURRENT_PAGE}` : `${sizing} font-normal`
}
</script>
<template>
<div v-if="isDesktop" data-testid="paginator" class="flex items-center gap-4 pt-3">
<span aria-live="polite" data-testid="paginator-summary" class="flex-1 text-muted">{{
summary
}}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:disabled="busy"
:aria-label="t('Rows per page')"
:content="{ side: 'top', align: 'start' }"
color="neutral"
variant="outline"
class="w-30"
:ui="{
base: `h-8 rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 ${FOCUS_RING}`,
trailingIcon: 'size-3.5 text-muted',
content: 'w-40 rounded-none',
itemTrailingIcon: 'hidden',
item: 'h-8 rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)',
}"
@update:model-value="emit('update:size', $event as number)"
/>
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-1">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevrons-left"
:disabled="atStart"
:aria-label="t('First page')"
:class="ICON_CONTROL"
@click="emit('update:page', 1)"
/>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-left"
:disabled="atStart"
:aria-label="t('Previous page')"
:class="ICON_CONTROL"
@click="emit('update:page', page - 1)"
/>
<template v-for="(item, index) in items" :key="`${item}-${index}`">
<span
v-if="item === 'gap'"
aria-hidden="true"
class="flex h-8 min-w-8 items-center justify-center text-muted"
>…</span
>
<UButton
v-else
color="neutral"
variant="outline"
:disabled="busy"
:aria-current="item === page ? 'page' : undefined"
:class="numberClass(item)"
@click="emit('update:page', item)"
>
{{ formatNumber(item) }}
</UButton>
</template>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-right"
:disabled="atEnd"
:aria-label="t('Next page')"
:class="ICON_CONTROL"
@click="emit('update:page', page + 1)"
/>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevrons-right"
:disabled="atEnd"
:aria-label="t('Last page')"
:class="ICON_CONTROL"
@click="emit('update:page', pageCount)"
/>
</nav>
</div>
<div v-else data-testid="paginator" class="flex flex-col gap-3 pt-3 pb-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<span aria-live="polite" data-testid="paginator-summary" class="text-muted">{{
summary
}}</span>
<USelectMenu
:model-value="size"
:items="sizeItems"
value-key="value"
:search-input="false"
:disabled="busy"
:aria-label="t('Rows per page')"
:content="{ side: 'top', align: 'end' }"
color="neutral"
variant="outline"
class="w-30"
:ui="{
base: `h-11 rounded-none border border-accented bg-default pl-3 pr-2 text-sm ring-0 focus-visible:ring-0 ${FOCUS_RING}`,
trailingIcon: 'size-3.5 text-muted',
content: 'w-40 rounded-none',
itemTrailingIcon: 'hidden',
item: 'h-11 rounded-none data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand)',
}"
@update:model-value="emit('update:size', $event as number)"
/>
</div>
<nav v-if="showControls" :aria-label="t('Pagination')" class="flex items-center gap-2">
<UButton
color="neutral"
variant="outline"
icon="i-lucide-chevron-left"
:disabled="atStart"
:aria-label="t('Previous page')"
:class="`h-11 flex-1 ${CONTROL}`"
@click="emit('update:page', page - 1)"
>
{{ t('Previous') }}
</UButton>
<span
aria-current="page"
class="shrink-0 px-1 font-medium whitespace-nowrap text-highlighted"
>{{ t('Page {0} of {1}', [formatNumber(page), formatNumber(pageCount)]) }}</span
>
<UButton
color="neutral"
variant="outline"
trailing-icon="i-lucide-chevron-right"
:disabled="atEnd"
:aria-label="t('Next page')"
:class="`h-11 flex-1 ${CONTROL}`"
@click="emit('update:page', page + 1)"
>
{{ t('Next') }}
</UButton>
</nav>
</div>
</template>
+11 -7
View File
@@ -6,12 +6,16 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const props = withDefaults(
defineProps<{
articles: Article[]
loading: boolean
skeletonRows?: number
busyIds: Set<number>
now?: Date
}>(),
{ skeletonRows: 5 },
)
const emit = defineEmits<{
publish: [id: number]
@@ -97,7 +101,7 @@ function onRowClick(article: Article, event: MouseEvent) {
>
<template v-if="loading">
<li
v-for="n in 5"
v-for="n in skeletonRows"
:key="n"
class="flex min-h-17 flex-col justify-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
>
+95 -34
View File
@@ -1,24 +1,31 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { ref } from 'vue'
import { computed, ref, watchPostEffect } from 'vue'
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const props = withDefaults(
defineProps<{
articles: Article[]
loading: boolean
skeletonRows?: number
sort?: string
busyIds: Set<number>
now?: Date
}>(),
{ skeletonRows: 5, sort: DEFAULT_ARTICLE_SORT },
)
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
sort: [field: ArticleSortField]
}>()
const router = useRouter()
@@ -26,7 +33,42 @@ const router = useRouter()
const columnWidths =
'[&:nth-child(2)]:w-27.5 [&:nth-child(3)]:w-32.5 [&:nth-child(4)]:w-35 [&:nth-child(5)]:w-11'
const sorting = ref([{ id: 'updated_at', desc: true }])
const sorting = computed(() => {
const [id, direction] = props.sort.split(':')
return [{ id: id!, desc: direction === 'desc' }]
})
const tableRoot = ref<HTMLElement | null>(null)
const SORTABLE_COLUMNS = ['title', 'status', 'created_at', 'updated_at']
watchPostEffect(() => {
const [active, direction] = props.sort.split(':')
tableRoot.value?.querySelectorAll('thead th').forEach((header, index) => {
if (SORTABLE_COLUMNS[index] === active) {
header.setAttribute('aria-sort', direction === 'desc' ? 'descending' : 'ascending')
} else {
header.removeAttribute('aria-sort')
}
})
})
const COLUMN_NAMES = {
title: () => t('title'),
status: () => t('status'),
created_at: () => t('created'),
updated_at: () => t('updated'),
}
const caption = computed(() => {
if (props.sort === DEFAULT_ARTICLE_SORT) {
return t('Articles, sorted by last updated, newest first')
}
const [field, direction] = props.sort.split(':') as [ArticleSortField, string]
return t('Articles, sorted by {0}, {1}', [
COLUMN_NAMES[field](),
direction === 'desc' ? t('descending') : t('ascending'),
])
})
const columns = [
{
@@ -34,15 +76,16 @@ const columns = [
get header() {
return t('Title')
},
enableSorting: true,
enableSorting: false,
},
{
accessorKey: 'is_online',
id: 'status',
get header() {
return t('Status')
},
size: 110,
enableSorting: true,
enableSorting: false,
},
{
accessorKey: 'created_at',
@@ -50,7 +93,7 @@ const columns = [
return t('Created')
},
size: 130,
enableSorting: true,
enableSorting: false,
},
{
accessorKey: 'updated_at',
@@ -58,20 +101,17 @@ const columns = [
return t('Updated')
},
size: 140,
enableSorting: true,
enableSorting: false,
},
{ id: 'actions', header: '', size: 44 },
]
function sortIcon(column: { getIsSorted: () => false | 'asc' | 'desc' }) {
const sorted = column.getIsSorted()
if (sorted === 'asc') {
return 'i-lucide-arrow-up'
function sortIcon(field: string) {
const [id, direction] = props.sort.split(':')
if (id !== field) {
return undefined
}
if (sorted === 'desc') {
return 'i-lucide-arrow-down'
}
return undefined
return direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
}
function editPath(article: Article) {
@@ -143,7 +183,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">{{ t('Loading articles') }}</span>
<div
v-for="n in 5"
v-for="n in skeletonRows"
:key="n"
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
>
@@ -153,7 +193,7 @@ function onSelect(_event: Event, row: { original: Article }) {
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</div>
<div v-else class="hidden border border-default bg-default md:block">
<div v-else ref="tableRoot" class="hidden border border-default bg-default md:block">
<UTable
:data="articles"
:columns="columns"
@@ -163,13 +203,14 @@ function onSelect(_event: Event, row: { original: Article }) {
th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`,
}"
v-model:sorting="sorting"
:sorting="sorting"
:sorting-options="{ manualSorting: true }"
@select="onSelect"
>
<template #caption>
<caption class="sr-only">
{{
t('Articles, sorted by last updated, newest first')
caption
}}
</caption>
</template>
@@ -178,21 +219,31 @@ function onSelect(_event: Event, row: { original: Article }) {
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
{{ t('Title') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
<template #is_online-header="{ column }">
<template #status-header="{ column }">
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
{{ t('Status') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
@@ -200,10 +251,15 @@ function onSelect(_event: Event, row: { original: Article }) {
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
{{ t('Created') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
@@ -211,10 +267,15 @@ function onSelect(_event: Event, row: { original: Article }) {
<button
type="button"
class="inline-flex items-center gap-1 text-highlighted"
@click.stop="column.toggleSorting()"
@click.stop="emit('sort', column.id as ArticleSortField)"
>
{{ t('Updated') }}
<UIcon v-if="sortIcon(column)" :name="sortIcon(column)!" class="size-3" />
<UIcon
v-if="sortIcon(column.id)"
:name="sortIcon(column.id)!"
:data-sort-direction="props.sort.split(':')[1]"
class="size-3"
/>
</button>
</template>
@@ -229,7 +290,7 @@ function onSelect(_event: Event, row: { original: Article }) {
</RouterLink>
</template>
<template #is_online-cell="{ row }">
<template #status-cell="{ row }">
<ArticleStatusBadge
:online="row.original.is_online"
size="sm"
@@ -8,16 +8,51 @@ vi.mock('@/composables/useApi', () => ({
import { useArticles } from '@/composables/useArticles'
const envelope = {
items: [],
total: 0,
page: 1,
size: 25,
counts: { all: 0, online: 0, draft: 0 },
}
describe('useArticles', () => {
beforeEach(() => {
request.mockReset()
request.mockResolvedValue(envelope)
})
it('asks the API for html bodies when listing articles', async () => {
request.mockResolvedValue([])
it('asks the API for html bodies with explicit paging when listing articles', async () => {
await useArticles().listArticles({ page: 1, size: 25 })
await useArticles().listArticles()
expect(request).toHaveBeenCalledWith('/v1/article/?format=html&page=1&size=25')
})
expect(request).toHaveBeenCalledWith('/v1/article/?format=html')
it('sends the search text and the status when they are set', async () => {
await useArticles().listArticles({ page: 2, size: 50, q: 'plaster', status: 'draft' })
expect(request).toHaveBeenCalledWith(
'/v1/article/?format=html&page=2&size=50&q=plaster&status=draft',
)
})
it('sends the sort when it is set', async () => {
await useArticles().listArticles({ page: 1, size: 25, sort: 'title:asc' })
expect(request).toHaveBeenCalledWith(
'/v1/article/?format=html&page=1&size=25&sort=title%3Aasc',
)
})
it('omits an empty search and an empty status', async () => {
await useArticles().listArticles({ page: 3, size: 5, q: '', status: '' })
expect(request).toHaveBeenCalledWith('/v1/article/?format=html&page=3&size=5')
})
it('returns the envelope untouched', async () => {
const result = await useArticles().listArticles({ page: 1, size: 25 })
expect(result).toEqual(envelope)
})
})
+41 -2
View File
@@ -1,4 +1,5 @@
import { useApi } from '@/composables/useApi'
import type { ArticleStatus } from '@/lib/articleListQuery'
export interface Article {
id: number
@@ -9,11 +10,49 @@ export interface Article {
updated_at: string
}
export type { ArticleStatus }
export interface ArticleCounts {
all: number
online: number
draft: number
}
export interface ArticlePage {
items: Article[]
total: number
page: number
size: number
counts: ArticleCounts
}
export interface ListArticlesParams {
page: number
size: number
q?: string
status?: ArticleStatus | ''
sort?: string
}
export function useArticles() {
const { request } = useApi()
function listArticles(): Promise<Article[]> {
return request<Article[]>('/v1/article/?format=html')
function listArticles(params: ListArticlesParams): Promise<ArticlePage> {
const query = new URLSearchParams({
format: 'html',
page: String(params.page),
size: String(params.size),
})
if (params.q) {
query.set('q', params.q)
}
if (params.status) {
query.set('status', params.status)
}
if (params.sort) {
query.set('sort', params.sort)
}
return request<ArticlePage>(`/v1/article/?${query.toString()}`)
}
return { listArticles }
+25 -7
View File
@@ -266,9 +266,9 @@ describe('Response samples use the real domain JSON keys', () => {
expect(single).not.toHaveProperty('images')
expect(single).not.toHaveProperty('status')
const list = parsed(find('GET /v1/article/'))
expectOnlyKeys(list[0], ARTICLE_KEYS)
expect(list[0]).not.toHaveProperty('images')
expect(list[0]).not.toHaveProperty('status')
expectOnlyKeys(list.items[0], ARTICLE_KEYS)
expect(list.items[0]).not.toHaveProperty('images')
expect(list.items[0]).not.toHaveProperty('status')
})
it('returns articles from create, update, publish and unpublish', () => {
@@ -393,13 +393,31 @@ describe('Request bodies and query parameters', () => {
it('lists format on every article route', () => {
for (const endpoint of API_GROUPS[0]!.endpoints) {
expect(
endpoint.query.map((param) => param.name),
label(endpoint),
).toEqual(['format'])
expect(endpoint.query.map((param) => param.name)).toContain('format')
}
})
it('lists page, size, q, status and sort on the article list only', () => {
expect(find('GET /v1/article/').query.map((param) => param.name)).toEqual([
'format',
'page',
'size',
'q',
'status',
'sort',
])
expect(find('GET /v1/article/{id}').query.map((param) => param.name)).toEqual(['format'])
})
it('documents the article list as a page envelope with a 400 for invalid paging', () => {
const list = find('GET /v1/article/')
const body = JSON.parse(list.response)
expect(Object.keys(body)).toEqual(['items', 'total', 'page', 'size', 'counts'])
expect(Object.keys(body.counts)).toEqual(['all', 'online', 'draft'])
expect(list.errors.map((error) => error.status)).toContain(400)
expect(Array.isArray(JSON.parse(list.response))).toBe(false)
})
it('lists q, hash, cursor, filter, sort and limit on the media list only', () => {
expect(find('GET /v1/media/').query.map((param) => param.name)).toEqual([
'q',
+40 -4
View File
@@ -95,6 +95,25 @@ const FORMAT_QUERY: ApiField = {
rule: 'markdown, html or json. Default markdown.',
}
const ARTICLE_LIST_QUERY: ApiField[] = [
FORMAT_QUERY,
{ name: 'page', type: 'number', required: false, rule: 'A whole number from 1. Default 1.' },
{ name: 'size', type: 'number', required: false, rule: '5, 25, 50 or 100. Default 25.' },
{
name: 'q',
type: 'string',
required: false,
rule: 'Up to 200 characters, matched anywhere in the title, ignoring case and accents.',
},
{ name: 'status', type: 'string', required: false, rule: 'online or draft. Default both.' },
{
name: 'sort',
type: 'string',
required: false,
rule: 'field:direction, with field title, status, created_at or updated_at and direction asc or desc. Default updated_at:desc.',
},
]
const MEDIA_QUERY: ApiField[] = [
{ name: 'q', type: 'string', required: false, rule: 'Search text.' },
{
@@ -188,6 +207,23 @@ const ownerOrAdminErrors = (...own: ApiError[]) => [
e(403, OWNER_OR_ADMIN_MESSAGE),
]
const articlePage = () => ({
items: [
article(),
article({
id: 13,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line.',
is_online: false,
updated_at: '2026-09-09T07:00:00Z',
}),
],
total: 30,
page: 1,
size: 25,
counts: { all: 30, online: 28, draft: 2 },
})
const articleIdErrors = [e(400, ID_FORMAT), e(404, 'Article not found')]
const none: ApiField[] = []
@@ -260,11 +296,11 @@ export const API_GROUPS: ApiGroup[] = [
'art-list',
'GET',
'/v1/article/',
'List articles. Anonymous callers get online articles only.',
'List one page of articles, newest update first, with the total and the tab counts. Anonymous callers get online articles only.',
'Public',
articleErrors(),
json([article()]),
{ query: [FORMAT_QUERY] },
articleErrors(e(400, BODY_MISMATCH)),
json(articlePage()),
{ query: ARTICLE_LIST_QUERY },
),
endpoint(
'art-get',
+18
View File
@@ -285,6 +285,13 @@ export const messages = {
"Couldn't copy the link": 'Impossible de copier le lien',
'Articles, sorted by last updated, newest first':
'Articles triés par dernière modification, du plus récent au plus ancien',
'Articles, sorted by {0}, {1}': 'Articles triés par {0}, {1}',
title: 'titre',
status: 'statut',
created: 'date de création',
updated: 'dernière modification',
ascending: 'croissant',
descending: 'décroissant',
'Loading articles': 'Chargement des articles',
Loading: 'Chargement',
"Couldn't load your articles": 'Impossible de charger vos articles',
@@ -1062,6 +1069,17 @@ export const messages = {
'Rows per page': 'Lignes par page',
'Previous page': 'Page précédente',
'Next page': 'Page suivante',
'First page': 'Première page',
'Last page': 'Dernière page',
Pagination: 'Pagination',
Previous: 'Précédent',
Next: 'Suivant',
'{0} per page': '{0} par page',
'Loading page {0}…': 'Chargement de la page {0}…',
"Couldn't load page {0}. Showing page {1}.":
'Impossible de charger la page {0}. Affichage de la page {1}.',
"That page doesn't exist. Showing the last page.":
'Cette page n’existe pas. Affichage de la dernière page.',
'Start date': 'Date de début',
'End date': 'Date de fin',
When: 'Quand',
+15 -11
View File
@@ -13,6 +13,7 @@ import { initializeLocale, setLocale, t } from './index'
import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
import { articleEnvelope, articleListUrl } from '@/views/__tests__/articleEnvelope'
vi.mock('@/composables/useApi', async () => {
const { ref } = await import('vue')
@@ -349,16 +350,19 @@ describe('Hardener localization outcomes', () => {
expect(document.activeElement).toBe(wrapper!.find('input[autocomplete="username"]').element)
})
it('focuses article search after clearing French no-results', async () => {
request.mockResolvedValue([
{
id: 12,
title: 'Data title',
body: '',
is_online: false,
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
},
])
const article = {
id: 12,
title: 'Data title',
body: '',
is_online: false,
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
}
request.mockImplementation(async (url: string) =>
articleEnvelope([article], {
q: new URL(url, 'http://localhost').searchParams.get('q') ?? '',
}),
)
setLocale('fr')
await mountRoute('/article?q=absent')
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
@@ -414,7 +418,7 @@ describe('Hardener localization outcomes', () => {
updated_at: '2025-02-14T12:00:00Z',
}
request.mockImplementation(async (url: string) =>
url === '/v1/article/?format=html' ? [article] : article,
url === articleListUrl() ? articleEnvelope([article]) : article,
)
await mountRoute(path)
if (path === '/article') {
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest'
import { nextSort, parseArticleQuery, sameArticleQuery, toRouteQuery } from '@/lib/articleListQuery'
describe('parseArticleQuery', () => {
it('falls back to page 1, size 25, no search and the All tab', () => {
expect(parseArticleQuery({})).toEqual({ page: 1, size: 25, q: '', status: '', sort: '' })
})
it('reads valid values', () => {
expect(
parseArticleQuery({
page: '7',
size: '50',
q: 'plaster',
status: 'draft',
sort: 'title:asc',
}),
).toEqual({ page: 7, size: 50, q: 'plaster', status: 'draft', sort: 'title:asc' })
})
it.each(['abc', '0', '-2', '2.5', '', '1e2'])('treats page "%s" as 1', (page) => {
expect(parseArticleQuery({ page }).page).toBe(1)
})
it.each(['10', 'abc', '0', '26', ''])('treats size "%s" as 25', (size) => {
expect(parseArticleQuery({ size }).size).toBe(25)
})
it('ignores an unknown status', () => {
expect(parseArticleQuery({ status: 'archived' }).status).toBe('')
})
it('reads the first value of a repeated parameter and caps the search at 200 characters', () => {
expect(parseArticleQuery({ page: ['3', '4'] }).page).toBe(3)
expect(parseArticleQuery({ q: 'a'.repeat(250) }).q).toHaveLength(200)
})
})
describe('sort parameter', () => {
it.each(['title:asc', 'status:desc', 'created_at:asc', 'created_at:desc'])(
'keeps %s',
(sort) => {
expect(parseArticleQuery({ sort }).sort).toBe(sort)
},
)
it.each(['body:asc', 'title', 'title:up', 'TITLE:ASC', ''])('drops invalid "%s"', (sort) => {
expect(parseArticleQuery({ sort }).sort).toBe('')
})
it('treats the default as no sort', () => {
expect(parseArticleQuery({ sort: 'updated_at:desc' }).sort).toBe('')
})
it('omits the default from the route and treats it as equal to no sort', () => {
const base = { page: 1, size: 25, q: '', status: '' as const }
expect(toRouteQuery({ ...base, sort: 'updated_at:desc' })).toEqual({})
expect(toRouteQuery({ ...base, sort: 'title:asc' })).toEqual({ sort: 'title:asc' })
expect(sameArticleQuery({ ...base, sort: '' }, { ...base, sort: 'updated_at:desc' })).toBe(
true,
)
expect(sameArticleQuery({ ...base, sort: 'title:asc' }, { ...base, sort: '' })).toBe(false)
})
it('toggles: first click ascending, second descending, other column restarts', () => {
expect(nextSort('', 'title')).toBe('title:asc')
expect(nextSort('title:asc', 'title')).toBe('title:desc')
expect(nextSort('title:desc', 'title')).toBe('title:asc')
expect(nextSort('title:asc', 'status')).toBe('status:asc')
expect(nextSort('', 'updated_at')).toBe('updated_at:asc')
expect(nextSort('updated_at:asc', 'updated_at')).toBe('updated_at:desc')
})
})
describe('toRouteQuery', () => {
it('omits every default', () => {
expect(toRouteQuery({ page: 1, size: 25, q: '', status: '', sort: '' })).toEqual({})
})
it('keeps what differs from the defaults', () => {
expect(
toRouteQuery({ page: 2, size: 5, q: 'plaster', status: 'online', sort: 'title:desc' }),
).toEqual({
page: '2',
size: '5',
q: 'plaster',
status: 'online',
sort: 'title:desc',
})
})
})
describe('sameArticleQuery', () => {
it('compares every field', () => {
const base = { page: 1, size: 25, q: '', status: '' as const, sort: '' }
expect(sameArticleQuery(base, { ...base })).toBe(true)
expect(sameArticleQuery(base, { ...base, page: 2 })).toBe(false)
expect(sameArticleQuery(base, { ...base, size: 5 })).toBe(false)
expect(sameArticleQuery(base, { ...base, q: 'a' })).toBe(false)
expect(sameArticleQuery(base, { ...base, status: 'draft' })).toBe(false)
})
})
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { pageItems, pageCountFor, rangeFor } from '@/lib/pagination'
describe('pageItems', () => {
it('lists every page when there are seven or fewer', () => {
expect(pageItems(3, 7)).toEqual([1, 2, 3, 4, 5, 6, 7])
expect(pageItems(1, 1)).toEqual([1])
})
it('marks the skipped range after the first page', () => {
expect(pageItems(1, 13)).toEqual([1, 2, 'gap', 13])
})
it('marks skipped ranges on both sides of a middle page', () => {
expect(pageItems(7, 13)).toEqual([1, 'gap', 6, 7, 8, 'gap', 13])
})
it('marks the skipped range before the last page', () => {
expect(pageItems(13, 13)).toEqual([1, 'gap', 12, 13])
})
it('does not mark a gap between adjacent pages', () => {
expect(pageItems(3, 13)).toEqual([1, 2, 3, 4, 'gap', 13])
})
})
describe('pageCountFor', () => {
it('rounds up and never drops below one', () => {
expect(pageCountFor(312, 25)).toBe(13)
expect(pageCountFor(6, 25)).toBe(1)
expect(pageCountFor(0, 25)).toBe(1)
})
})
describe('rangeFor', () => {
it('computes the shown range', () => {
expect(rangeFor(1, 25, 312)).toEqual({ from: 1, to: 25 })
expect(rangeFor(7, 25, 312)).toEqual({ from: 151, to: 175 })
expect(rangeFor(13, 25, 312)).toEqual({ from: 301, to: 312 })
expect(rangeFor(1, 25, 0)).toEqual({ from: 0, to: 0 })
})
})
+91
View File
@@ -0,0 +1,91 @@
import type { LocationQuery } from 'vue-router'
export type ArticleStatus = 'online' | 'draft'
export const ARTICLE_PAGE_SIZES = [5, 25, 50, 100]
export const DEFAULT_ARTICLE_PAGE_SIZE = 25
export const ARTICLE_SEARCH_MAX_LENGTH = 200
export const DEFAULT_ARTICLE_SORT = 'updated_at:desc'
export const ARTICLE_SORT_FIELDS = ['title', 'status', 'created_at', 'updated_at'] as const
export type ArticleSortField = (typeof ARTICLE_SORT_FIELDS)[number]
const POSITIVE_INTEGER = /^[1-9]\d*$/
export interface ArticleListQuery {
page: number
size: number
q: string
status: ArticleStatus | ''
sort: string
}
function first(value: LocationQuery[string] | undefined) {
const single = Array.isArray(value) ? value[0] : value
return single ?? ''
}
const SORT_PATTERN = new RegExp(`^(${ARTICLE_SORT_FIELDS.join('|')}):(asc|desc)$`)
function validSort(value: string) {
return SORT_PATTERN.test(value) && value !== DEFAULT_ARTICLE_SORT ? value : ''
}
export function effectiveSort(sort: string) {
return sort || DEFAULT_ARTICLE_SORT
}
export function nextSort(current: string, field: ArticleSortField) {
const [activeField, direction] = effectiveSort(current).split(':')
return `${field}:${activeField === field && direction === 'asc' ? 'desc' : 'asc'}`
}
export function parseArticleQuery(query: LocationQuery): ArticleListQuery {
const page = first(query.page)
const size = Number(first(query.size))
const status = first(query.status)
return {
page: POSITIVE_INTEGER.test(page) ? Number(page) : 1,
size: ARTICLE_PAGE_SIZES.includes(size) ? size : DEFAULT_ARTICLE_PAGE_SIZE,
q: first(query.q).slice(0, ARTICLE_SEARCH_MAX_LENGTH),
status: status === 'online' || status === 'draft' ? status : '',
sort: validSort(first(query.sort)),
}
}
export function toRouteQuery(query: ArticleListQuery) {
const route: Record<string, string> = {}
if (query.page !== 1) {
route.page = String(query.page)
}
if (query.size !== DEFAULT_ARTICLE_PAGE_SIZE) {
route.size = String(query.size)
}
if (query.q) {
route.q = query.q
}
if (query.status) {
route.status = query.status
}
if (effectiveSort(query.sort) !== DEFAULT_ARTICLE_SORT) {
route.sort = query.sort
}
return route
}
export function sameArticleQuery(left: ArticleListQuery, right: ArticleListQuery) {
return (
left.page === right.page &&
left.size === right.size &&
left.q === right.q &&
left.status === right.status &&
effectiveSort(left.sort) === effectiveSort(right.sort)
)
}
export function sameRouteQuery(left: LocationQuery, right: Record<string, string>) {
const leftKeys = Object.keys(left)
return (
leftKeys.length === Object.keys(right).length &&
leftKeys.every((key) => first(left[key]) === right[key])
)
}
+30
View File
@@ -0,0 +1,30 @@
const FULL_LIST_LIMIT = 7
export type PageItem = number | 'gap'
export function pageCountFor(total: number, size: number) {
return Math.max(1, Math.ceil(total / size))
}
export function rangeFor(page: number, size: number, total: number) {
if (total === 0) {
return { from: 0, to: 0 }
}
return { from: (page - 1) * size + 1, to: Math.min(page * size, total) }
}
export function pageItems(page: number, pageCount: number): PageItem[] {
const visible =
pageCount <= FULL_LIST_LIMIT
? Array.from({ length: pageCount }, (_, index) => index + 1)
: [
...new Set(
[1, page - 1, page, page + 1, pageCount].filter(
(candidate) => candidate >= 1 && candidate <= pageCount,
),
),
].sort((left, right) => left - right)
return visible.flatMap((number, index) =>
index > 0 && number - visible[index - 1]! > 1 ? ['gap' as const, number] : [number],
)
}
+287 -90
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -10,12 +10,34 @@ import ArticleCardList from '@/components/ArticleCardList.vue'
import EmptyState from '@/components/EmptyState.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import AppPaginator from '@/components/AppPaginator.vue'
import { ApiError, useApi } from '@/composables/useApi'
import { useArticles, type Article } from '@/composables/useArticles'
import {
useArticles,
type Article,
type ArticleCounts,
type ArticleStatus,
} from '@/composables/useArticles'
import { bodyImageCount } from '@/lib/bodyImageIds'
import {
effectiveSort,
nextSort,
parseArticleQuery,
type ArticleSortField,
sameArticleQuery,
sameRouteQuery,
toRouteQuery,
type ArticleListQuery,
} from '@/lib/articleListQuery'
import { pageCountFor } from '@/lib/pagination'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const DEFAULT_SKELETON_ROWS = 5
const ARTICLES_PATH = '/article'
type UrlMode = 'push' | 'replace'
type Notice = { kind: 'range' } | { kind: 'error'; query: ArticleListQuery; mode: UrlMode }
const route = useRoute()
const router = useRouter()
@@ -24,51 +46,40 @@ const { listArticles } = useArticles()
const overlay = useLocalizedOverlay()
const toast = useLocalizedToast()
const articles = ref<Article[]>([])
const items = ref<Article[]>([])
const total = ref(0)
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const shown = ref<ArticleListQuery>(parseArticleQuery(route.query))
const desired = ref<ArticleListQuery>(shown.value)
const hasLoaded = ref(false)
const loading = ref(true)
const loadFailed = ref(false)
const pendingPage = ref<number | null>(null)
const notice = ref<Notice | null>(null)
const showSkeleton = ref(false)
const busyIds = ref<Set<number>>(new Set())
const newArticleModalOpen = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0
const searchInput = ref(String(route.query.q ?? ''))
const debouncedSearch = ref(searchInput.value)
const searchInput = ref(shown.value.q)
function normalize(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
function clearSearchTimer() {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
watch(searchInput, (value) => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
debouncedSearch.value = value
router.replace({
query: { ...route.query, q: value || undefined },
})
}, SEARCH_DEBOUNCE_MS)
clearSearchTimer()
searchDebounceTimer = setTimeout(() => changeSearch(value), SEARCH_DEBOUNCE_MS)
})
const activeTab = computed<'' | 'online' | 'draft'>({
get: () =>
route.query.status === 'online' || route.query.status === 'draft' ? route.query.status : '',
set: (value) => {
router.replace({
query: { ...route.query, status: value || undefined },
})
},
})
const counts = computed(() => {
const online = articles.value.filter((article) => article.is_online).length
return {
all: articles.value.length,
online,
drafts: articles.value.length - online,
}
const activeTab = computed<'' | ArticleStatus>({
get: () => desired.value.status,
set: (value) => changeStatus(value),
})
const tabItems = computed(() => [
@@ -86,46 +97,44 @@ const tabItems = computed(() => [
},
{
get label() {
return t('Drafts {0}', [counts.value.drafts])
return t('Drafts {0}', [counts.value.draft])
},
value: 'draft',
},
])
const statusFilteredArticles = computed(() => {
if (activeTab.value === 'online') {
return articles.value.filter((article) => article.is_online)
}
if (activeTab.value === 'draft') {
return articles.value.filter((article) => !article.is_online)
}
return articles.value
})
const displayedArticles = computed(() => {
const search = normalize(debouncedSearch.value.trim())
if (!search) {
return statusFilteredArticles.value
}
return statusFilteredArticles.value.filter((article) =>
normalize(article.title).includes(search),
)
})
const settled = computed(() => hasLoaded.value && !loadFailed.value)
const skeletonActive = computed(() => loading.value && showSkeleton.value)
const skeletonRows = computed(() => items.value.length || DEFAULT_SKELETON_ROWS)
const emptyVariant = computed(() => {
if (loading.value || loadFailed.value) {
if (!settled.value || skeletonActive.value || total.value > 0) {
return null
}
if (articles.value.length === 0) {
return 'no-articles' as const
}
if (debouncedSearch.value.trim() && displayedArticles.value.length === 0) {
if (shown.value.q) {
return 'no-results' as const
}
if (statusFilteredArticles.value.length === 0) {
return activeTab.value === 'draft' ? ('no-drafts' as const) : ('no-online' as const)
if (shown.value.status === 'draft') {
return 'no-drafts' as const
}
return null
if (shown.value.status === 'online') {
return 'no-online' as const
}
return 'no-articles' as const
})
const showPaginator = computed(() => settled.value && total.value > 0)
const pageCount = computed(() => pageCountFor(total.value, shown.value.size))
const noticeText = computed(() => {
const current = notice.value
if (!current) {
return ''
}
if (current.kind === 'range') {
return t("That page doesn't exist. Showing the last page.")
}
return t("Couldn't load page {0}. Showing page {1}.", [current.query.page, shown.value.page])
})
function clearSkeletonTimer() {
@@ -135,27 +144,125 @@ function clearSkeletonTimer() {
}
}
async function loadArticles() {
async function syncUrl(query: ArticleListQuery, mode: UrlMode) {
if (route.path !== ARTICLES_PATH) {
return
}
const target = toRouteQuery(query)
if (sameRouteQuery(route.query, target)) {
return
}
await router[mode]({ query: target })
}
async function load(query: ArticleListQuery, mode: UrlMode, successNotice: Notice | null = null) {
const token = ++requestToken
desired.value = query
loading.value = true
showSkeleton.value = false
pendingPage.value = hasLoaded.value ? query.page : null
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
loadFailed.value = false
try {
articles.value = await listArticles()
const response = await listArticles(query)
if (token !== requestToken) {
return
}
const lastPage = pageCountFor(response.total, query.size)
if (
successNotice === null &&
response.items.length === 0 &&
response.total > 0 &&
query.page > lastPage
) {
await load({ ...query, page: lastPage }, 'replace', { kind: 'range' })
return
}
const effective = { ...query, page: response.total === 0 ? 1 : query.page }
items.value = response.items
total.value = response.total
counts.value = response.counts
shown.value = effective
desired.value = effective
hasLoaded.value = true
loadFailed.value = false
notice.value = successNotice
await syncUrl(effective, mode)
} catch {
articles.value = []
loadFailed.value = true
if (token !== requestToken) {
return
}
if (hasLoaded.value) {
notice.value = { kind: 'error', query, mode }
} else {
items.value = []
loadFailed.value = true
}
} finally {
loading.value = false
clearSkeletonTimer()
showSkeleton.value = false
if (token === requestToken) {
loading.value = false
pendingPage.value = null
clearSkeletonTimer()
showSkeleton.value = false
}
}
}
function changePage(page: number) {
load({ ...desired.value, page }, 'push')
}
function changeSize(size: number) {
load({ ...desired.value, size, page: 1 }, 'push')
}
function changeStatus(status: '' | ArticleStatus) {
if (status === desired.value.status) {
return
}
load({ ...desired.value, status, page: 1 }, 'push')
}
function changeSort(field: ArticleSortField) {
load({ ...desired.value, sort: nextSort(desired.value.sort, field), page: 1 }, 'push')
}
function changeSearch(value: string) {
const q = value.trim()
if (q === desired.value.q) {
return
}
load({ ...desired.value, q, page: 1 }, 'replace')
}
function retryNotice() {
if (notice.value?.kind === 'error') {
load(notice.value.query, notice.value.mode)
}
}
watch(
() => route.query,
() => {
if (route.path !== ARTICLES_PATH) {
return
}
const next = parseArticleQuery(route.query)
if (hasLoaded.value && sameArticleQuery(next, shown.value)) {
syncUrl(shown.value, 'replace')
return
}
if (sameArticleQuery(next, desired.value) && loading.value) {
return
}
searchInput.value = next.q
load(next, 'replace')
},
)
function setBusy(id: number, busy: boolean) {
const next = new Set(busyIds.value)
if (busy) {
@@ -166,8 +273,46 @@ function setBusy(id: number, busy: boolean) {
busyIds.value = next
}
function removeArticle(id: number) {
articles.value = articles.value.filter((article) => article.id !== id)
function shiftCounts(article: Article, delta: number) {
const key = article.is_online ? 'online' : 'draft'
counts.value = {
...counts.value,
all: Math.max(0, counts.value.all + delta),
[key]: Math.max(0, counts.value[key] + delta),
}
}
function detachRow(id: number) {
if (!items.value.some((article) => article.id === id)) {
return
}
items.value = items.value.filter((article) => article.id !== id)
total.value = Math.max(0, total.value - 1)
if (items.value.length === 0 && total.value > 0) {
const page = Math.min(shown.value.page, pageCountFor(total.value, shown.value.size))
load({ ...shown.value, page }, 'replace')
}
}
function removeArticle(article: Article) {
shiftCounts(article, -1)
detachRow(article.id)
}
function replaceArticle(updated: Article, previous: Article) {
items.value = items.value.map((existing) => (existing.id === updated.id ? updated : existing))
if (updated.is_online === previous.is_online) {
return
}
counts.value = {
...counts.value,
online: counts.value.online + (updated.is_online ? 1 : -1),
draft: counts.value.draft + (updated.is_online ? -1 : 1),
}
const filter = shown.value.status
if (filter && (filter === 'online') !== updated.is_online) {
detachRow(updated.id)
}
}
function notFoundToast(article: Article) {
@@ -191,9 +336,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}?format=html`,
{ method: 'PATCH' },
)
articles.value = articles.value.map((existing) =>
existing.id === updated.id ? updated : existing,
)
replaceArticle(updated, article)
toast.add(() => ({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
@@ -209,7 +352,7 @@ async function setOnline(article: Article, makeOnline: boolean) {
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
removeArticle(article)
toast.add(() => notFoundToast(article))
return
}
@@ -238,22 +381,26 @@ async function setOnline(article: Article, makeOnline: boolean) {
}
}
function findItem(id: number) {
return items.value.find((existing) => existing.id === id)
}
function handlePublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
const article = findItem(id)
if (article) {
setOnline(article, true)
}
}
function handleUnpublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
const article = findItem(id)
if (article) {
setOnline(article, false)
}
}
async function handleCopyUrl(id: number) {
const article = articles.value.find((existing) => existing.id === id)
const article = findItem(id)
if (!article) {
return
}
@@ -295,7 +442,7 @@ function deleteDescription(article: Article) {
async function performDelete(article: Article) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article.id)
removeArticle(article)
toast.add(() => ({
id: `article-deleted-${article.id}`,
color: 'success',
@@ -309,7 +456,7 @@ async function performDelete(article: Article) {
}))
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
removeArticle(article)
toast.add(() => notFoundToast(article))
return
}
@@ -331,7 +478,7 @@ async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
}
async function handleDelete(article: Article) {
const idsAtOpen = displayedArticles.value.map((existing) => existing.id)
const idsAtOpen = items.value.map((existing) => existing.id)
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
@@ -369,9 +516,9 @@ function onArticleCreated(article: Article) {
}
function clearSearch() {
clearSearchTimer()
searchInput.value = ''
debouncedSearch.value = ''
router.replace({ query: { ...route.query, q: undefined } })
changeSearch('')
nextTick(() => {
document.querySelector<HTMLInputElement>('input[type="search"]')?.focus()
})
@@ -389,7 +536,13 @@ function onEmptyAction() {
openNewArticleModal()
}
onMounted(loadArticles)
onMounted(() => load(shown.value, 'replace'))
onBeforeUnmount(() => {
requestToken++
clearSearchTimer()
clearSkeletonTimer()
})
</script>
<template>
@@ -436,26 +589,59 @@ onMounted(loadArticles)
/>
</div>
<template v-if="!loading || showSkeleton">
<UAlert
v-if="notice"
:role="notice.kind === 'error' ? 'alert' : 'status'"
:color="notice.kind === 'error' ? 'error' : 'info'"
variant="soft"
orientation="horizontal"
:close="false"
:icon="notice.kind === 'error' ? 'i-lucide-circle-x' : 'i-lucide-info'"
:description="noticeText"
:ui="{
root: 'items-center gap-2.5 px-3.5 py-2.5',
icon: 'size-4',
description: 'text-toned',
}"
>
<template v-if="notice.kind === 'error'" #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="retryNotice"
>
{{ t('Retry') }}
</UButton>
</template>
</UAlert>
<template v-if="hasLoaded || loadFailed || showSkeleton">
<EmptyState
v-if="emptyVariant"
:variant="emptyVariant"
:query="debouncedSearch"
:query="shown.q"
@action="onEmptyAction"
/>
<template v-else>
<ArticleTable
:articles="displayedArticles"
:loading="loading && showSkeleton"
:articles="items"
:loading="skeletonActive"
:skeleton-rows="skeletonRows"
:sort="effectiveSort(shown.sort)"
:busy-ids="busyIds"
@sort="changeSort"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
/>
<ArticleCardList
:articles="displayedArticles"
:loading="loading && showSkeleton"
:articles="items"
:loading="skeletonActive"
:skeleton-rows="skeletonRows"
:busy-ids="busyIds"
@publish="handlePublish"
@unpublish="handleUnpublish"
@@ -464,6 +650,17 @@ onMounted(loadArticles)
/>
</template>
</template>
<AppPaginator
v-if="showPaginator"
:page="shown.page"
:page-count="pageCount"
:total="total"
:size="shown.size"
:loading-page="pendingPage"
@update:page="changePage"
@update:size="changeSize"
/>
</div>
<UButton
+17 -19
View File
@@ -9,7 +9,7 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import RecentActivityCard from '@/components/RecentActivityCard.vue'
import UpdateBanner from '@/components/UpdateBanner.vue'
import { useArticles, type Article } from '@/composables/useArticles'
import { useArticles, type Article, type ArticleCounts } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
import { accountName } from '@/lib/accountName'
@@ -55,14 +55,13 @@ const subtitle = computed(() =>
: t("Here's where things stand on your site."),
)
const stats = computed(() => {
const online = articles.value.filter((article) => article.is_online).length
return {
total: articles.value.length,
online,
drafts: articles.value.length - online,
}
})
const counts = ref<ArticleCounts>({ all: 0, online: 0, draft: 0 })
const stats = computed(() => ({
total: counts.value.all,
online: counts.value.online,
drafts: counts.value.draft,
}))
const statTiles = computed(() => [
{
@@ -85,12 +84,6 @@ const statTiles = computed(() => [
},
])
const recentArticles = computed(() =>
[...articles.value]
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, RECENT_LIST_SIZE),
)
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
@@ -107,9 +100,14 @@ async function loadArticles() {
}, SKELETON_DELAY_MS)
try {
const result = await listArticles()
articles.value = result
loadState.value = result.length === 0 ? 'empty' : 'default'
const result = await listArticles({
page: 1,
size: RECENT_LIST_SIZE,
sort: 'updated_at:desc',
})
articles.value = result.items
counts.value = result.counts
loadState.value = result.counts.all === 0 ? 'empty' : 'default'
} catch {
loadState.value = 'error'
} finally {
@@ -263,7 +261,7 @@ onMounted(loadArticles)
data-testid="recent-list"
class="border border-default bg-default"
>
<li v-for="article in recentArticles" :key="article.id">
<li v-for="article in articles" :key="article.id">
<RouterLink
:to="`/article/${article.id}/edit`"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
+30 -21
View File
@@ -6,6 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
@@ -192,7 +193,7 @@ afterEach(async () => {
describe('Default — the list loads sorted by last updated, newest first', () => {
it('shows the breadcrumb, tab counts and every row in fixture order', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
expect(wrapper.text()).toContain('All 6')
@@ -218,9 +219,10 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
it('shows skeleton rows and aria-busy, then replaces them once resolved', async () => {
let resolveList: (() => void) | null = null
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () =>
[articleListRoute()]: () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticleFixture()))
resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticleFixture())))
}),
})
@@ -239,7 +241,7 @@ describe('Loading — skeleton rows while the first request is in flight', () =>
describe('Row menu open shows Edit, the status-appropriate action, Copy API URL and Delete…', () => {
it('opens a menu named "Actions for Winter checklist for timber windows"', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -260,7 +262,7 @@ describe('Copy API URL succeeds — the absolute URL is copied and confirmed', (
})
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -287,7 +289,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
})
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -305,7 +307,7 @@ describe('Copy API URL fails — the clipboard write itself is rejected', () =>
describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, []),
[articleListRoute()]: () => mockResponse(200, articleEnvelope([])),
})
expect(wrapper.text()).toContain('All 0')
@@ -326,7 +328,7 @@ describe('Empty — zero articles', () => {
describe('Initial load failure — an error must not render the no-articles empty state', () => {
it('does not show "No articles yet" when GET /v1/article/ fails', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(500),
[articleListRoute()]: () => mockResponse(500),
})
expect(wrapper.text()).not.toContain('No articles yet')
@@ -335,8 +337,11 @@ describe('Initial load failure — an error must not render the no-articles empt
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
it('shows the no-results empty state, then clears the search', async () => {
const all = sixArticleFixture()
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(all)),
[articleListRoute({ q: 'tiles' })]: () =>
mockResponse(200, articleEnvelope(all, { q: 'tiles' })),
})
const searchInput = wrapper.find('input[type="search"]')
@@ -367,7 +372,9 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4)
.map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, onlineOnly),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(onlineOnly)),
[articleListRoute({ status: 'draft' })]: () =>
mockResponse(200, articleEnvelope(onlineOnly, { status: 'draft' })),
})
await clickTab('Drafts 0')
@@ -384,7 +391,9 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
.slice(0, 4)
.map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, draftsOnly),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(draftsOnly)),
[articleListRoute({ status: 'online' })]: () =>
mockResponse(200, articleEnvelope(draftsOnly, { status: 'online' })),
})
await clickTab('Online 0')
@@ -400,7 +409,7 @@ describe('An empty tab shows its own EmptyState, while the other tab still count
describe('Action in progress — publishing a row disables only that row', () => {
it('sends the PATCH and shows the busy label for that row alone', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => new Promise(() => {}),
})
@@ -420,7 +429,7 @@ describe('Published — a 200 updates the row in place and raises a toast', () =
updated_at: iso(0),
}
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': () => mockResponse(200, updated),
})
@@ -448,7 +457,7 @@ describe('Action failed, publish — a 500 or a network error leaves the row unc
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/4/publish?format=html': patchCall,
})
@@ -487,7 +496,7 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': patchCall,
})
@@ -517,7 +526,7 @@ describe('Action failed, unpublish — a 500 or a network error leaves the row u
describe('Row vanished, draft row — a 404 on publish or delete removes the row and shows an info toast', () => {
it('removes the row on a 404 publish response', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/2/publish?format=html': () => mockResponse(404),
})
@@ -540,7 +549,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
it('removes the row when the delete confirmation resolves 404', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/2': () => mockResponse(404),
})
@@ -565,7 +574,7 @@ describe('Row vanished, draft row — a 404 on publish or delete removes the row
describe('Row vanished, online row — a 404 on unpublish removes the row and shows an info toast', () => {
it('removes the row and updates the tab counts', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'PATCH /v1/article/3/unpublish?format=html': () => mockResponse(404),
})
@@ -590,7 +599,7 @@ describe('Row vanished, online row — a 404 on unpublish removes the row and sh
describe('Delete confirm — opens the dialog with the row title and image count', () => {
it('opens ConfirmDialog with the title and 3 images', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
@@ -611,7 +620,7 @@ describe('Delete confirm — opens the dialog with the row title and image count
describe('Deleted — a 204 closes the dialog, removes the row, and moves focus to the next row', () => {
it('removes the row, recomputes tabs and raises the success toast', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(204),
})
@@ -640,7 +649,7 @@ describe('Deleted — a 204 closes the dialog, removes the row, and moves focus
describe('Delete failed — a 500 keeps the article and rejects with the inline message', () => {
it('throws the inline error message and leaves the article in the list', async () => {
const wrapper = await mountArticles({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticleFixture()),
[articleListRoute()]: () => mockResponse(200, articleEnvelope(sixArticleFixture())),
'DELETE /v1/article/4': () => mockResponse(500),
})
@@ -0,0 +1,703 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import type { Article, ArticlePage } from '@/composables/useArticles'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ArticleView from '../ArticleView.vue'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
const TOTAL = 312
const PLASTER_EVERY = 20
function createOverlayMock() {
let resolveOpen: ((value: unknown) => void) | null = null
let props: Record<string, unknown> | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
let overlayMock: ReturnType<typeof createOverlayMock>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useOverlay: () => overlayMock,
useToast: () => ({ add: vi.fn() }),
}
})
function buildArticles(count: number): Article[] {
return Array.from({ length: count }, (_, index) => {
const id = index + 1
return {
id,
title: id % PLASTER_EVERY === 0 ? `Lime plaster notes ${id}` : `Article ${id}`,
body: '',
is_online: id % 3 !== 0,
created_at: '2026-03-12T09:14:00Z',
updated_at: '2026-08-18T09:00:00Z',
}
})
}
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
type ListHandler = (params: EnvelopeParams, url: string) => Response | Promise<Response>
let articles: Article[]
let listCalls: EnvelopeParams[]
let listHandler: ListHandler
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))
}
function installFetch() {
global.fetch = vi.fn((url: string, init?: RequestInit) => {
const method = init?.method ?? 'GET'
if (method === 'GET' && url.startsWith('/v1/article/?')) {
const params = parseListUrl(url)
listCalls.push(params)
return Promise.resolve(listHandler(params, url))
}
const deleted = url.match(/^\/v1\/article\/(\d+)$/)
if (method === 'DELETE' && deleted) {
return Promise.resolve(deleteHandler(Number(deleted[1])))
}
throw new Error(`Unexpected fetch call: ${method} ${url}`)
}) as unknown as typeof fetch
}
async function mountAt(path: string) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(path)
wrapper = mount(ArticleView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function summary() {
return document.body.querySelector('[data-testid="paginator-summary"]')?.textContent?.trim()
}
function control(label: string) {
return document.body.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
}
function pageButtons() {
return Array.from(
document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
).filter((button) => /^\d+$/.test(button.textContent?.trim() ?? ''))
}
function rowIds() {
return new Set(
Array.from(document.body.querySelectorAll('[data-row-title]')).map((row) =>
row.getAttribute('data-row-title'),
),
)
}
function paginatorControls() {
return Array.from(
document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
)
}
async function click(button: HTMLElement | null | undefined) {
button?.click()
await flushPromises()
}
function alertText() {
return document.body.querySelector('[role="alert"], [role="status"]')?.textContent ?? ''
}
function pageSizeTrigger() {
return document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')
}
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn()
})
beforeEach(() => {
articles = buildArticles(TOTAL)
listCalls = []
listHandler = (params) => respondFromFixture(params)
deleteHandler = () => mockResponse(204)
setViewportWidth(DESKTOP_WIDTH)
installFetch()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
vi.restoreAllMocks()
await router.push('/login')
})
describe('French caption', () => {
it('renders the range summary in French and restores English', async () => {
setLocale('fr')
try {
await mountAt('/article')
expect(summary()).toBe('Affichage de 1 à 25 sur 312')
} finally {
setLocale('en')
}
})
})
describe('1 First page', () => {
it('shows the first 25 of 312 with First and Previous disabled and no page or size in the URL', async () => {
await mountAt('/article')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(summary()).toBe('Showing 1–25 of 312')
expect(rowIds().size).toBe(25)
expect(control('First page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Next page')?.disabled).toBe(false)
expect(control('Last page')?.disabled).toBe(false)
expect(pageButtons()[0]?.getAttribute('aria-current')).toBe('page')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('Disabled controls', () => {
it('carry the 40 % opacity class on every paginator button', async () => {
await mountAt('/article')
const disabled = paginatorControls().filter((button) => button.disabled)
expect(disabled.length).toBeGreaterThan(1)
for (const button of disabled) {
expect(button.className).toContain('disabled:opacity-40')
}
})
it('carry it on the mobile Previous and Next buttons', async () => {
setViewportWidth(MOBILE_WIDTH)
await mountAt('/article')
expect(control('Previous page')?.disabled).toBe(true)
expect(control('Previous page')?.className).toContain('disabled:opacity-40')
expect(control('Next page')?.className).toContain('disabled:opacity-40')
})
})
describe('2 Middle page', () => {
it('shows page 7 as current with ellipses and every control enabled', async () => {
await mountAt('/article?page=7')
expect(summary()).toBe('Showing 151–175 of 312')
const current = pageButtons().filter((b) => b.getAttribute('aria-current') === 'page')
expect(current.map((b) => b.textContent?.trim())).toEqual(['7'])
expect(current[0]?.className).toContain('bg-(--rc-sand)')
expect(current[0]?.className).toContain('text-(--rc-on-sand)')
expect(current[0]?.className).toContain('border-primary')
expect(document.body.querySelector('nav[aria-label="Pagination"]')?.textContent).toContain(
'…',
)
expect(paginatorControls().every((button) => !button.disabled)).toBe(true)
})
})
describe('3 Last page', () => {
it('shows 301–312 of 312 with Next and Last disabled', async () => {
await mountAt('/article?page=13')
expect(summary()).toBe('Showing 301–312 of 312')
expect(rowIds().size).toBe(12)
expect(control('Next page')?.disabled).toBe(true)
expect(control('Last page')?.disabled).toBe(true)
expect(control('Previous page')?.disabled).toBe(false)
})
})
describe('4 Single page', () => {
it('keeps the summary and the page-size select, hides the paginator and reads the tab counts', async () => {
articles = buildArticles(6).map((article, index) => ({ ...article, is_online: index < 4 }))
const wrapper = await mountAt('/article')
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')
})
})
describe('5 Loading next page', () => {
it('shows skeleton rows, disables every control, and moves the URL only once the response arrives', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountAt('/article')
let resolvePage2: (() => void) | null = null
listHandler = (params) =>
new Promise((resolve) => {
resolvePage2 = () => resolve(respondFromFixture(params))
})
await click(control('Next page'))
await vi.advanceTimersByTimeAsync(150)
expect(document.body.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(summary()).toBe('Loading page 2…')
expect(paginatorControls().every((button) => button.disabled)).toBe(true)
expect(router.currentRoute.value.fullPath).toBe('/article')
resolvePage2?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(summary()).toBe('Showing 26–50 of 312')
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/article?page=2')
})
})
describe.each([
['resolves with a 500 response', () => mockResponse(500)],
['never reaches the server', () => Promise.reject(new TypeError('Failed to fetch'))],
])('6 Error loading a page keeps the previous rows (%s)', (_failure, failure) => {
it('keeps rows and URL, shows the notice, and Retry loads the page', async () => {
await mountAt('/article?page=6')
const previousRows = rowIds()
listHandler = () => failure() as Promise<Response>
await click(control('Next page'))
expect(rowIds()).toEqual(previousRows)
expect(router.currentRoute.value.fullPath).toBe('/article?page=6')
expect(alertText()).toContain("Couldn't load page 7. Showing page 6.")
expect(control('Next page')?.disabled).toBe(false)
listHandler = (params) => respondFromFixture(params)
const retry = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Retry',
)
await click(retry)
expect(listCalls.at(-1)).toMatchObject({ page: 7 })
expect(summary()).toBe('Showing 151–175 of 312')
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
expect(alertText()).not.toContain("Couldn't load page")
})
})
describe('7 Page out of range redirects to the last page with a notice', () => {
it('replaces the URL with the last page and tells the user', async () => {
const push = vi.spyOn(router, 'push')
const replace = vi.spyOn(router, 'replace')
await mountAt('/article?page=99')
expect(listCalls.map((call) => call.page)).toEqual([99, 13])
expect(replace).toHaveBeenCalledWith({ query: { page: '13' } })
expect(push).toHaveBeenCalledTimes(1)
expect(router.currentRoute.value.fullPath).toBe('/article?page=13')
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
expect(summary()).toBe('Showing 301–312 of 312')
})
})
describe('7a A shrinking server cannot loop the last-page redirect', () => {
it('redirects once and then settles', async () => {
let calls = 0
listHandler = (params) => {
calls += 1
return mockResponse(200, {
...articleEnvelope(articles, params),
items: [],
total: 25 * (30 - calls),
})
}
await mountAt('/article?page=99')
expect(listCalls.map((call) => call.page)).toEqual([99, 29])
})
})
describe('7b Out of range with total 0 shows the empty state, no redirect', () => {
it('shows No articles yet and an /article URL', async () => {
articles = []
const wrapper = await mountAt('/article?page=5')
expect(listCalls).toHaveLength(1)
expect(wrapper.text()).toContain('No articles yet')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
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 () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?page=7')
listCalls = []
await wrapper.find('input[type="search"]').setValue('plaster')
await vi.advanceTimersByTimeAsync(119)
expect(listCalls).toHaveLength(0)
await vi.advanceTimersByTimeAsync(1)
await flushPromises()
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')
})
})
describe('9 Empty after search', () => {
it('hides the paginator and the summary and keeps the tab counts', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article')
await wrapper.find('input[type="search"]').setValue('tiles')
await vi.advanceTimersByTimeAsync(120)
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('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')
})
})
describe('10 Page size menu', () => {
it.each([
['5 per page', 5, '/article?size=5'],
['25 per page', 25, '/article'],
['50 per page', 50, '/article?size=50'],
['100 per page', 100, '/article?size=100'],
])('choosing "%s" resets to page 1 and ends on %s', async (label, size, url) => {
await mountAt('/article?page=3')
listCalls = []
pageSizeTrigger()?.click()
await flushPromises()
const option = Array.from(document.body.querySelectorAll('[role="option"]')).find(
(element) => element.textContent?.trim() === label,
)
expect(option).toBeTruthy()
;(option as HTMLElement).click()
await flushPromises()
expect(listCalls.at(-1)).toMatchObject({ page: 1, size })
expect(router.currentRoute.value.fullPath).toBe(url)
})
})
describe('10b Invalid URL values fall back', () => {
it('requests page 1 and size 25 and replaces the URL', async () => {
await mountAt('/article?size=10&page=abc')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
describe('11 Focus-visible', () => {
it('gives every paginator control and the select the 2 px primary outline with a 2 px offset', async () => {
await mountAt('/article?page=7')
const controls = [...paginatorControls(), pageSizeTrigger()]
expect(controls.length).toBeGreaterThan(5)
for (const element of controls) {
expect(element?.className).toContain('focus-visible:outline-2')
expect(element?.className).toContain('focus-visible:outline-offset-2')
expect(element?.className).toContain('focus-visible:outline-primary')
}
})
})
describe('Keyboard, the page-size list', () => {
it('opens on ArrowDown, selects on Enter and closes on Escape', async () => {
await mountAt('/article')
const trigger = pageSizeTrigger()!
trigger.focus()
trigger.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).not.toBeNull()
const focused = document.activeElement ?? document.body
focused.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }),
)
await flushPromises()
expect(document.body.querySelector('[role="listbox"]')).toBeNull()
})
})
describe('Tabs filter on the server', () => {
it('sends status=draft with page 1, updates the URL on success and keeps the counts', async () => {
const wrapper = await mountAt('/article?page=3')
listCalls = []
const tab = Array.from(document.body.querySelectorAll('button')).find((button) =>
button.textContent?.trim().startsWith('Drafts'),
)
tab?.focus()
tab?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
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')
})
})
describe('Mobile, Previous, "Page N of M", Next', () => {
it('shows three controls and no page numbers below md', async () => {
setViewportWidth(MOBILE_WIDTH)
await mountAt('/article?page=2')
const nav = document.body.querySelector('nav[aria-label="Pagination"]')!
expect(nav.textContent).toContain('Previous')
expect(nav.textContent).toContain('Page 2 of 13')
expect(nav.textContent).toContain('Next')
expect(pageButtons()).toHaveLength(0)
expect(nav.querySelectorAll('button')).toHaveLength(2)
})
})
describe('Deleting the last row of the last page', () => {
it('reloads the previous page and recomputes the counts from the new response', async () => {
articles = buildArticles(26)
await mountAt('/article?page=2')
expect(summary()).toBe('Showing 26–26 of 26')
listCalls = []
const menu = document.body.querySelector<HTMLButtonElement>(
'button[aria-label="Actions for Article 26"]',
)
menu?.click()
await flushPromises()
const remove = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(item) => item.textContent?.trim() === 'Delete…',
) as HTMLElement
remove.click()
await flushPromises()
const action = overlayMock.getProps()?.action as () => Promise<void>
articles = buildArticles(25)
await action()
overlayMock.resolveOpen(true)
await flushPromises()
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')
})
})
describe('The envelope is the only shape read', () => {
it('renders rows from items', async () => {
const page: ArticlePage = articleEnvelope(buildArticles(3))
listHandler = () => mockResponse(200, page)
const wrapper = await mountAt('/article')
expect(wrapper.text()).toContain('Article 1')
expect(wrapper.text()).toContain('Article 3')
})
})
function headerButton(label: string) {
return Array.from(document.body.querySelectorAll<HTMLButtonElement>('th button')).find(
(button) => button.textContent?.trim() === label,
)
}
function headerArrow(label: string) {
const direction = headerButton(label)?.querySelector('[data-sort-direction]')
const value = direction?.getAttribute('data-sort-direction')
if (value === 'asc') {
return 'up'
}
return value === 'desc' ? 'down' : 'none'
}
describe('8a Sort headers, server-side, reset to page 1', () => {
it.each([
['Title', 'title:asc'],
['Status', 'status:asc'],
['Created', 'created_at: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')
listCalls = []
await click(headerButton(label))
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')
})
it('keeps the URL unchanged until the response arrives', async () => {
await mountAt('/article?page=7')
listHandler = () => new Promise(() => undefined)
await click(headerButton('Title'))
expect(router.currentRoute.value.fullPath).toBe('/article?page=7')
})
})
describe('8b Toggling the sorted header', () => {
it('toggles to descending, and returning to updated_at:desc drops sort from the URL', async () => {
await mountAt('/article?sort=title:asc')
listCalls = []
await click(headerButton('Title'))
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')
await click(headerButton('Updated'))
await click(headerButton('Updated'))
expect(listCalls.at(-1)).toMatchObject({ sort: 'updated_at:desc' })
expect(router.currentRoute.value.fullPath).toBe('/article')
expect(headerArrow('Updated')).toBe('down')
})
})
describe('8c Sort is kept across paging, page size and search', () => {
it('carries sort on Next and on a search, and the URL keeps it', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = await mountAt('/article?sort=title:asc')
listCalls = []
await click(control('Next page'))
expect(listCalls.at(-1)).toMatchObject({ page: 2, sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({ page: '2', sort: 'title:asc' })
await wrapper.find('input[type="search"]').setValue('plaster')
await vi.advanceTimersByTimeAsync(120)
await flushPromises()
expect(listCalls.at(-1)).toMatchObject({ page: 1, q: 'plaster', sort: 'title:asc' })
expect(router.currentRoute.value.query).toMatchObject({ q: 'plaster', sort: 'title:asc' })
expect(router.currentRoute.value.query.page).toBeUndefined()
})
})
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')
expect(listCalls).toEqual([
{ page: 2, size: 25, q: '', status: '', sort: 'created_at:desc' },
])
expect(headerArrow('Created')).toBe('down')
})
it('falls back to the default for an invalid sort and replaces the URL', async () => {
await mountAt('/article?sort=body:asc')
expect(listCalls).toEqual([{ page: 1, size: 25, q: '', status: '', sort: '' }])
expect(headerArrow('Updated')).toBe('down')
expect(router.currentRoute.value.fullPath).toBe('/article')
})
})
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)
return mockResponse(200, { ...page, items: [...page.items].reverse() })
}
await mountAt('/article?sort=title:asc')
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' })
.items.map((article) => article.id)
.reverse()
expect(rendered).toEqual(expected)
})
})
describe('8f Caption follows the sort', () => {
it('reads the default caption, then the sorted one', async () => {
await mountAt('/article')
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by last updated, newest first',
)
await click(headerButton('Title'))
expect(document.body.querySelector('caption')?.textContent?.trim()).toBe(
'Articles, sorted by title, ascending',
)
expect(document.body.querySelector('th[aria-sort="ascending"]')?.textContent).toContain(
'Title',
)
})
})
describe('8g Mobile card list', () => {
it('has no sort control', async () => {
await mountAt('/article')
expect(document.body.querySelectorAll('ul button[aria-sort], ul [aria-sort]')).toHaveLength(
0,
)
expect(
Array.from(document.body.querySelectorAll('ul button')).filter((button) =>
/^(Title|Status|Created|Updated)$/.test(button.textContent?.trim() ?? ''),
),
).toHaveLength(0)
})
})
@@ -8,9 +8,10 @@ import { useSessionStore } from '@/stores/session'
import type { ActivityItem } from '@/lib/activity'
import HomeView from '../HomeView.vue'
import { anonymous, makeItem, target } from './activityFixtures'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
const ARTICLES_ROUTE = 'GET /v1/article/?format=html'
const ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
type Handler = () => Response | Promise<Response>
@@ -95,7 +96,7 @@ describe('Home, owner or admin, the last five events with the actor name', () =>
it.each(['owner', 'admin'] as const)('shows five rows with names for %s', async (role) => {
await mountHome(role, {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(card().find('h2').text()).toBe('Recent activity')
@@ -125,7 +126,7 @@ describe('Home, regular user, own events only and no actor name', () => {
it.each([['editor' as const], [undefined]])('shows only the time for %s', async (role) => {
await mountHome(role as undefined, {
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(card().find('h2').text()).toBe('Your recent activity')
@@ -146,7 +147,7 @@ describe('Home rows, failure and security markers', () => {
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const marks = rows().map((row) => ({
@@ -177,7 +178,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(rows()[0]!.text()).toContain('article.archive')
@@ -188,7 +189,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
it('has no button or link inside a row and keeps the route on a click', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(2)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const row = rows()[0]!
@@ -202,7 +203,7 @@ describe('Home rows, unknown action, failed login and not interactive', () => {
const items = [makeItem({ id: 1, target: target('article', '8', 'T'.repeat(150)) })]
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(items),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(rows()[0]!.find('[data-testid="recent-activity-sentence"]').classes()).toContain(
@@ -223,7 +224,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
new Promise((done) => {
resolve = () => done(page(recentItems(5)))
}),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(card().attributes('aria-busy')).toBeUndefined()
@@ -246,7 +247,7 @@ describe('Home card, skeleton rows only after 150 ms', () => {
vi.useFakeTimers()
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
await vi.advanceTimersByTimeAsync(200)
@@ -267,7 +268,7 @@ describe('Home card, error shows a retryable alert that leaves the page alone',
attempt += 1
return attempt === 1 ? (failure() as Response) : page(recentItems(2))
},
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const alert = card().find('[role="alert"]')
@@ -292,7 +293,7 @@ describe('Home card, empty when there are no events yet', () => {
])('says so for %s', async (role, text) => {
await mountHome(role as 'owner', {
[ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const empty = card().find('[data-testid="recent-activity-empty"]')
@@ -316,7 +317,7 @@ describe('Home card and article list are independent', () => {
it('shows the articles empty state and the card error together', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => response(500),
[ARTICLES_ROUTE]: () => response(200, []),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope([], { size: 5 })),
})
expect(wrapper!.text()).toContain('Write your first article')
@@ -328,7 +329,7 @@ describe('Home layout puts Recent activity full width below Recently updated', (
it('renders the section after the recent list inside the same single column', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const column = card().element.parentElement!
@@ -344,7 +345,7 @@ describe('Home card, the request URL', () => {
it('asks for exactly /v1/activity?size=5 and nothing else', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(5)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const activityCalls = calls.filter((call) => call.includes('/v1/activity'))
@@ -356,7 +357,7 @@ describe('Home card, "View all" opens the activity page', () => {
it('navigates to /activity', async () => {
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page(recentItems(1)),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
const push = vi.spyOn(router, 'push')
@@ -373,7 +374,7 @@ describe('Home card, the French interface', () => {
setLocale('fr')
await mountHome('owner', {
[ACTIVITY_ROUTE]: () => page([], 0),
[ARTICLES_ROUTE]: () => response(200, articles),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(card().find('h2').text()).toBe('Activité récente')
@@ -10,6 +10,7 @@ import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import HomeView from '../HomeView.vue'
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
function mockResponse(status: number, body: unknown = null) {
return {
@@ -35,8 +36,8 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, []))
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
}
if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status))
@@ -17,6 +17,7 @@ import {
startable,
verifying,
} from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
function mockResponse(status: number, body: unknown = null) {
return {
@@ -35,8 +36,8 @@ async function mountHome(role: UserRole, status: UpdateStatus) {
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/?format=html') {
return Promise.resolve(mockResponse(200, []))
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
return Promise.resolve(mockResponse(200, articleEnvelope([], { size: 5 })))
}
if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status))
+36 -23
View File
@@ -6,6 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.vue'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
let toastAdd: ReturnType<typeof vi.fn>
@@ -42,6 +43,8 @@ function fetchRouter(handlers: Record<string, () => Response | Promise<Response>
})
}
const HOME_ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
const sixArticles: Article[] = [
{
id: 1,
@@ -140,9 +143,10 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
vi.useFakeTimers()
let resolveList: (() => void) | null = null
const wrapper = await mountHome({
'GET /v1/article/?format=html': () =>
[HOME_ARTICLES_ROUTE]: () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticles))
resolveList = () =>
resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
}),
})
@@ -164,7 +168,8 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => Promise.resolve(mockResponse(200, sixArticles)),
[HOME_ARTICLES_ROUTE]: () =>
Promise.resolve(mockResponse(200, articleEnvelope(sixArticles, { size: 5 }))),
})
await vi.advanceTimersByTimeAsync(0)
@@ -178,7 +183,8 @@ describe('Skeletons appear only after 150 ms, never for a fast response', () =>
describe('Default — the list loaded with a mix of online and draft articles', () => {
it('shows the subtitle, stat tiles and the first 5 articles in order', async () => {
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -221,7 +227,8 @@ describe('A successful create hands off to the editor with a toast', () => {
updated_at: '2026-01-01T00:00:00Z',
}
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
'POST /v1/article/?format=html': () => mockResponse(201, created),
})
@@ -257,7 +264,7 @@ describe('A successful create hands off to the editor with a toast', () => {
describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, []),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
})
expect(wrapper.text()).toContain('Welcome to RenewCMS.')
@@ -283,7 +290,7 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
},
])('shows the alert with identical copy for $name', async ({ handler }) => {
const wrapper = await mountHome({
'GET /v1/article/?format=html': handler,
[HOME_ARTICLES_ROUTE]: handler,
})
expect(wrapper.text()).toContain("Here's where things stand on your site.")
@@ -301,9 +308,9 @@ describe('Retry re-issues the request and recovers into the Default state', () =
const listCall = vi
.fn()
.mockResolvedValueOnce(mockResponse(500))
.mockResolvedValueOnce(mockResponse(200, sixArticles))
.mockResolvedValueOnce(mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
const wrapper = await mountHome({
'GET /v1/article/?format=html': listCall,
[HOME_ARTICLES_ROUTE]: listCall,
})
expect(wrapper.find('[role="alert"]').exists()).toBe(true)
@@ -320,9 +327,9 @@ describe('Retry re-issues the request and recovers into the Default state', () =
describe('The list is fetched exactly once per navigation', () => {
it('calls GET /v1/article/ exactly once and derives both stats and the list from it', async () => {
const listCall = vi.fn(() => mockResponse(200, sixArticles))
const listCall = vi.fn(() => mockResponse(200, articleEnvelope(sixArticles, { size: 5 })))
const wrapper = await mountHome({
'GET /v1/article/?format=html': listCall,
[HOME_ARTICLES_ROUTE]: listCall,
})
expect(listCall).toHaveBeenCalledTimes(1)
@@ -343,7 +350,8 @@ describe('The greeting matches the hour of day', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date(2026, 0, 1, hour, 0, 0))
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
})
expect(wrapper.text()).toContain(`${greeting}, marta`)
@@ -356,23 +364,24 @@ describe('The greeting is identical across every load state at a fixed hour', ()
vi.setSystemTime(new Date(2026, 0, 1, 9, 0, 0))
const loadingWrapper = await mountHome({
'GET /v1/article/?format=html': () => new Promise(() => {}),
[HOME_ARTICLES_ROUTE]: () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(loadingWrapper.text()).toContain('Good morning, marta')
const defaultWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
})
expect(defaultWrapper.text()).toContain('Good morning, marta')
const emptyWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, []),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
})
expect(emptyWrapper.text()).toContain('Good morning, marta')
const errorWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(500),
[HOME_ARTICLES_ROUTE]: () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Good morning, marta')
})
@@ -393,7 +402,8 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -413,7 +423,8 @@ describe('The greeting names the person by display name, else username', () => {
session.session = { verified: true, username: 'marta', displayName: '' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -439,7 +450,8 @@ describe('The greeting handles a missing username', () => {
session.session = { verified: true }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
@@ -460,7 +472,7 @@ describe('The "Connect your website" card renders in every state', () => {
it('renders the same card while loading, in Default, Empty and Error', async () => {
vi.useFakeTimers()
const wrapper = await mountHome({
'GET /v1/article/?format=html': () => new Promise(() => {}),
[HOME_ARTICLES_ROUTE]: () => new Promise(() => {}),
})
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.text()).toContain('Connect your website')
@@ -470,17 +482,18 @@ describe('The "Connect your website" card renders in every state', () => {
vi.useRealTimers()
const defaultWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, sixArticles),
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
})
expect(defaultWrapper.text()).toContain('Connect your website')
const emptyWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(200, []),
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
})
expect(emptyWrapper.text()).toContain('Connect your website')
const errorWrapper = await mountHome({
'GET /v1/article/?format=html': () => mockResponse(500),
[HOME_ARTICLES_ROUTE]: () => mockResponse(500),
})
expect(errorWrapper.text()).toContain('Connect your website')
})
@@ -0,0 +1,70 @@
import type { Article, ArticlePage, ArticleStatus } from '@/composables/useArticles'
export interface EnvelopeParams {
page?: number
size?: number
q?: string
status?: ArticleStatus | ''
sort?: string
}
function fold(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
export function articleEnvelope(all: Article[], params: EnvelopeParams = {}): ArticlePage {
const { page = 1, size = 25, q = '', status = '', sort = '' } = params
const filtered = all
.filter((article) => !status || article.is_online === (status === 'online'))
.filter((article) => !q || fold(article.title).includes(fold(q)))
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 = all.filter((article) => article.is_online).length
return {
items: filtered.slice((page - 1) * size, page * size),
total: filtered.length,
page,
size,
counts: { all: all.length, online, draft: all.length - online },
}
}
export function articleListUrl(params: EnvelopeParams = {}) {
const { page = 1, size = 25, q = '', status = '', sort = '' } = 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)
}
return `/v1/article/?${query.toString()}`
}
export function articleListRoute(params: EnvelopeParams = {}) {
return `GET ${articleListUrl(params)}`
}
export function parseListUrl(url: string): EnvelopeParams {
const query = new URL(url, 'http://localhost').searchParams
return {
page: Number(query.get('page')),
size: Number(query.get('size')),
q: query.get('q') ?? '',
status: (query.get('status') ?? '') as ArticleStatus | '',
sort: query.get('sort') ?? '',
}
}