feat: articles pagination
This commit is contained in:
1 parent
a2d224240c
commit
73f4b539fe
45 files changed
+3528
-317
No files matched your search
@@ -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>
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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') ?? '',
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user