feat: articles list screen

This commit is contained in:
fsylvain committed 2026-09-27 17:12:23 +02:00
1 parent ca3fe20519
commit f6d0d01092
14 files changed
+2353 -25

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
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 emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
}>()
const router = useRouter()
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
if (article.is_online) {
emit('unpublish', article.id)
} else {
emit('publish', article.id)
}
},
},
{
label: 'Copy API URL',
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
},
],
]
}
function onRowClick(article: Article, event: MouseEvent) {
if ((event.target as HTMLElement).closest('[data-row-menu]')) {
return
}
router.push(editPath(article))
}
</script>
<template>
<ul
class="-mx-4 border-y border-default bg-default md:hidden"
:aria-busy="loading ? 'true' : undefined"
>
<template v-if="loading">
<li
v-for="n in 5"
: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"
>
<USkeleton class="h-3.5 w-3/4 rounded-none bg-accented" />
<USkeleton class="h-3 w-2/5 rounded-none bg-accented" />
</li>
</template>
<template v-else>
<li v-if="articles.length === 0">
<slot name="empty" />
</li>
<li
v-for="article in articles"
:key="article.id"
class="relative flex min-h-17 items-center gap-2 border-t border-muted py-3 pl-4 pr-1 first:border-t-0"
:class="{ 'opacity-55': isBusy(article) }"
@click="onRowClick(article, $event)"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5">
<RouterLink
:to="editPath(article)"
:data-row-title="article.id"
class="truncate text-[0.9375rem]/[21px] font-medium text-highlighted"
@click.stop
>
{{ article.title }}
</RouterLink>
<div class="flex items-center gap-2.5 text-xs text-muted">
<ArticleStatusBadge
:online="article.is_online"
size="sm"
:pending="pendingFor(article)"
/>
<span :title="article.updated_at"
>Updated {{ formatRelativeDate(article.updated_at, now) }}</span
>
</div>
</div>
<UDropdownMenu
:items="actionsFor(article)"
:content="{ align: 'end' }"
data-row-menu
>
<UButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${article.title}`"
class="size-11 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</li>
</template>
</ul>
</template>
+9 -3
View File
@@ -3,6 +3,7 @@ withDefaults(
defineProps<{
online: boolean
size?: 'sm' | 'md' | 'lg'
pending?: 'publish' | 'unpublish'
}>(),
{
size: 'md',
@@ -12,12 +13,17 @@ withDefaults(
<template>
<UBadge
v-if="online"
v-if="pending"
color="neutral"
variant="solid"
icon="i-lucide-globe"
variant="outline"
icon="i-lucide-loader-circle"
:size="size"
:class="size === 'lg' ? undefined : 'px-2'"
:ui="{ leadingIcon: 'animate-spin' }"
>
{{ pending === 'publish' ? 'Publishing…' : 'Unpublishing…' }}
</UBadge>
<UBadge v-else-if="online" color="neutral" variant="solid" icon="i-lucide-globe" :size="size">
Online
</UBadge>
<UBadge
+180
View File
@@ -0,0 +1,180 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
import type { Article } from '@/composables/useArticles'
const props = defineProps<{
articles: Article[]
loading: boolean
busyIds: Set<number>
now?: Date
}>()
const emit = defineEmits<{
publish: [id: number]
unpublish: [id: number]
delete: [article: Article]
'copy-url': [id: number]
}>()
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 columns = [
{ accessorKey: 'title', header: 'Title' },
{ accessorKey: 'is_online', header: 'Status', size: 110 },
{ accessorKey: 'created_at', header: 'Created', size: 130 },
{ accessorKey: 'updated_at', header: 'Updated', size: 140 },
{ id: 'actions', header: '', size: 44 },
]
function editPath(article: Article) {
return `/article/${article.id}/edit`
}
function isBusy(article: Article) {
return props.busyIds.has(article.id)
}
function pendingFor(article: Article) {
if (!isBusy(article)) {
return undefined
}
return article.is_online ? ('unpublish' as const) : ('publish' as const)
}
function actionsFor(article: Article) {
return [
[
{
label: 'Edit',
icon: 'i-lucide-pencil',
onSelect: () => router.push(editPath(article)),
},
{
label: article.is_online ? 'Unpublish' : 'Publish',
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
disabled: isBusy(article),
onSelect: () => {
if (article.is_online) {
emit('unpublish', article.id)
} else {
emit('publish', article.id)
}
},
},
{
label: 'Copy API URL',
icon: 'i-lucide-link',
onSelect: () => emit('copy-url', article.id),
},
],
[
{
label: 'Delete…',
icon: 'i-lucide-trash-2',
color: 'error' as const,
onSelect: () => emit('delete', article),
},
],
]
}
function onSelect(row: { original: Article }) {
router.push(editPath(row.original))
}
</script>
<template>
<div v-if="loading" aria-busy="true" class="hidden border border-default bg-default md:block">
<span role="status" class="sr-only">Loading articles</span>
<div
v-for="n in 5"
:key="n"
class="flex h-11 items-center gap-3 border-t border-muted px-3 first:border-t-0"
>
<USkeleton class="h-3 max-w-[55%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-5 w-15 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
<USkeleton class="h-3 w-20 rounded-none bg-accented" />
</div>
</div>
<div v-else class="hidden border border-default bg-default md:block">
<UTable
:data="articles"
:columns="columns"
:ui="{
base: 'table-fixed',
tr: 'cursor-pointer',
th: `px-3 ${columnWidths}`,
td: `h-11 px-3 py-0 ${columnWidths}`,
}"
@select="onSelect"
>
<template #caption>
<caption class="sr-only">
Articles, sorted by last updated, newest first
</caption>
</template>
<template #title-cell="{ row }">
<RouterLink
:to="editPath(row.original)"
:data-row-title="row.original.id"
class="font-medium text-highlighted"
@click.stop
>
{{ row.original.title }}
</RouterLink>
</template>
<template #is_online-cell="{ row }">
<ArticleStatusBadge
:online="row.original.is_online"
size="sm"
:pending="pendingFor(row.original)"
:aria-busy="isBusy(row.original) ? 'true' : undefined"
/>
</template>
<template #updated_at-header>
<span class="inline-flex items-center gap-1 text-highlighted">
Updated
<UIcon name="i-lucide-arrow-down" class="size-3" />
</span>
</template>
<template #created_at-cell="{ row }">
<span class="text-muted">{{
formatAbsoluteDateFromIso(row.original.created_at)
}}</span>
</template>
<template #updated_at-cell="{ row }">
<span class="text-muted" :title="row.original.updated_at">{{
formatRelativeDate(row.original.updated_at, now)
}}</span>
</template>
<template #actions-cell="{ row }">
<UDropdownMenu :items="actionsFor(row.original)" :content="{ align: 'end' }">
<UButton
icon="i-lucide-ellipsis"
color="neutral"
variant="ghost"
:aria-label="`Actions for ${row.original.title}`"
class="size-8 justify-center text-toned"
@click.stop
/>
</UDropdownMenu>
</template>
<template #empty>
<slot name="empty" />
</template>
</UTable>
</div>
</template>
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue'
const QUERY_MAX_LENGTH = 40
const props = defineProps<{
variant: 'no-articles' | 'no-results' | 'no-drafts' | 'no-online'
query?: string
}>()
const emit = defineEmits<{ action: [] }>()
const truncatedQuery = computed(() => {
const value = props.query ?? ''
return value.length > QUERY_MAX_LENGTH ? value.slice(0, QUERY_MAX_LENGTH) : value
})
const variants = computed(() => ({
'no-articles': {
icon: 'i-lucide-file-text',
title: 'No articles yet',
description: 'Write your first one. It stays a draft until you publish it.',
action: 'New article',
primary: true,
},
'no-results': {
icon: 'i-lucide-search',
title: `No articles match "${truncatedQuery.value}"`,
description: 'Check the spelling, or search for a shorter part of the title.',
action: 'Clear search',
primary: false,
},
'no-drafts': {
icon: 'i-lucide-pencil-line',
title: 'No drafts',
description: "Everything you've written is online.",
action: 'New article',
primary: false,
},
'no-online': {
icon: 'i-lucide-globe',
title: 'Nothing is online yet',
description: 'Publish a draft and it will appear on your website.',
action: 'Show drafts',
primary: false,
},
}))
const current = computed(() => variants.value[props.variant])
</script>
<template>
<UEmpty
variant="naked"
:icon="current.icon"
:title="current.title"
:description="current.description"
:ui="{
root: 'gap-3 border border-default bg-default px-6 py-14',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-[0.9375rem] font-semibold',
description: 'max-w-70 text-muted text-pretty',
}"
>
<template #actions>
<UButton
:color="current.primary ? 'primary' : 'neutral'"
:variant="current.primary ? 'solid' : 'outline'"
class="h-8 px-3"
@click="emit('action')"
>
{{ current.action }}
</UButton>
</template>
</UEmpty>
</template>
+10 -19
View File
@@ -61,15 +61,8 @@ if (typeof window !== 'undefined') {
}
export function useApi() {
async function request<T>(
path: string,
options: RequestOptions = {},
): Promise<T> {
const {
onUnauthorized = 'redirect',
onForbidden = 'redirect',
...init
} = options
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
let response: Response
try {
@@ -99,10 +92,7 @@ export function useApi() {
if (response.status === 403) {
const session = useSessionStore()
if (
onForbidden !== 'passthrough' &&
session.session?.verified === false
) {
if (onForbidden !== 'passthrough' && session.session?.verified === false) {
router.push('/register/pending')
}
throw new ApiError(403)
@@ -115,16 +105,17 @@ export function useApi() {
throw new ApiError(response.status)
}
if (response.status === 204) {
return undefined as T
}
return response.json()
}
async function getMe(): Promise<MeResult> {
const body = await request<{ is_verified: boolean; username?: string }>(
'/v1/auth/me',
{
onUnauthorized: 'passthrough',
},
)
const body = await request<{ is_verified: boolean; username?: string }>('/v1/auth/me', {
onUnauthorized: 'passthrough',
})
return { verified: Boolean(body.is_verified), username: body.username }
}
+4
View File
@@ -22,6 +22,10 @@ function formatAbsoluteDate(date: Date) {
return `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`
}
export function formatAbsoluteDateFromIso(iso: string) {
return formatAbsoluteDate(new Date(iso))
}
export function formatRelativeDate(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const diffMs = now.getTime() - date.getTime()
+406 -1
View File
@@ -1,9 +1,414 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleTable from '@/components/ArticleTable.vue'
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 { ApiError, useApi } from '@/composables/useApi'
import { useArticles, type Article } from '@/composables/useArticles'
const SKELETON_DELAY_MS = 150
const SEARCH_DEBOUNCE_MS = 120
const route = useRoute()
const router = useRouter()
const { request } = useApi()
const { listArticles } = useArticles()
const overlay = useOverlay()
const toast = useToast()
const articles = ref<Article[]>([])
const loading = ref(true)
const loadFailed = ref(false)
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
const searchInput = ref(String(route.query.q ?? ''))
const debouncedSearch = ref(searchInput.value)
function normalize(value: string) {
return value.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
}
watch(searchInput, (value) => {
if (searchDebounceTimer !== null) {
clearTimeout(searchDebounceTimer)
}
searchDebounceTimer = setTimeout(() => {
debouncedSearch.value = value
router.replace({
query: { ...route.query, q: value || undefined },
})
}, 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 tabItems = computed(() => [
{ label: `All ${counts.value.all}`, value: '' },
{ label: `Online ${counts.value.online}`, value: 'online' },
{ label: `Drafts ${counts.value.drafts}`, 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 emptyVariant = computed(() => {
if (loading.value || loadFailed.value) {
return null
}
if (articles.value.length === 0) {
return 'no-articles' as const
}
if (debouncedSearch.value.trim() && displayedArticles.value.length === 0) {
return 'no-results' as const
}
if (statusFilteredArticles.value.length === 0) {
return activeTab.value === 'draft' ? ('no-drafts' as const) : ('no-online' as const)
}
return null
})
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
async function loadArticles() {
loading.value = true
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
loadFailed.value = false
try {
articles.value = await listArticles()
} catch {
articles.value = []
loadFailed.value = true
} finally {
loading.value = false
clearSkeletonTimer()
showSkeleton.value = false
}
}
function setBusy(id: number, busy: boolean) {
const next = new Set(busyIds.value)
if (busy) {
next.add(id)
} else {
next.delete(id)
}
busyIds.value = next
}
function removeArticle(id: number) {
articles.value = articles.value.filter((article) => article.id !== id)
}
function notFoundToast(article: Article) {
return {
id: `article-not-found-${article.id}`,
color: 'info' as const,
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
}
}
async function setOnline(article: Article, makeOnline: boolean) {
setBusy(article.id, true)
try {
const updated = await request<Article>(
`/v1/article/${article.id}/${makeOnline ? 'publish' : 'unpublish'}`,
{ method: 'PATCH' },
)
articles.value = articles.value.map((existing) =>
existing.id === updated.id ? updated : existing,
)
toast.add({
id: `article-${makeOnline ? 'published' : 'unpublished'}-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: makeOnline ? 'Article published' : 'Article unpublished',
description: makeOnline
? `"${article.title}" is now online.`
: `"${article.title}" is now offline.`,
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
return
}
toast.add({
id: `article-${makeOnline ? 'publish' : 'unpublish'}-failed-${article.id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: makeOnline ? "Couldn't publish" : "Couldn't unpublish",
description: "The server didn't respond. Nothing changed.",
duration: 0,
actions: [{ label: 'Retry', onClick: () => setOnline(article, makeOnline) }],
})
} finally {
setBusy(article.id, false)
}
}
function handlePublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
if (article) {
setOnline(article, true)
}
}
function handleUnpublish(id: number) {
const article = articles.value.find((existing) => existing.id === id)
if (article) {
setOnline(article, false)
}
}
async function handleCopyUrl(id: number) {
const article = articles.value.find((existing) => existing.id === id)
if (!article) {
return
}
const url = `${window.location.origin}/v1/article/${id}`
try {
await navigator.clipboard.writeText(url)
toast.add({
id: `article-link-copied-${id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description: `The API URL for "${article.title}" is on your clipboard.`,
})
} catch {
toast.add({
id: `article-link-copy-failed-${id}`,
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
}
}
function deleteDescription(article: Article) {
const count = article.images.length
const imagesPhrase =
count === 0 ? '' : count === 1 ? ' and its image' : ` and its ${count} images`
return `"${article.title}"${imagesPhrase} will be deleted. This can't be undone.`
}
async function performDelete(article: Article) {
try {
await request(`/v1/article/${article.id}`, { method: 'DELETE' })
removeArticle(article.id)
toast.add({
id: `article-deleted-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: `"${article.title}" and its images are gone.`,
})
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
removeArticle(article.id)
toast.add(notFoundToast(article))
return
}
throw new Error("Couldn't delete the article, so it's still there. Try again in a moment.")
}
}
async function focusRowOrHeading(idsAtOpen: number[], deletedId: number) {
await nextTick()
const index = idsAtOpen.indexOf(deletedId)
const nextId = idsAtOpen[index + 1] ?? idsAtOpen.slice(0, index).reverse()[0]
if (nextId != null) {
document.querySelector<HTMLElement>(`[data-row-title="${nextId}"]`)?.focus()
return
}
document.querySelector<HTMLElement>('[data-testid="articles-heading"]')?.focus()
}
async function handleDelete(article: Article) {
const idsAtOpen = displayedArticles.value.map((existing) => existing.id)
const confirmed = await overlay
.create(ConfirmDialog, {
props: {
title: 'Delete this article?',
description: deleteDescription(article),
confirmLabel: 'Delete article',
cancelLabel: 'Cancel',
pendingLabel: 'Deleting…',
tone: 'danger',
action: () => performDelete(article),
},
})
.open()
if (confirmed) {
focusRowOrHeading(idsAtOpen, article.id)
}
}
function openNewArticleModal() {
newArticleModalOpen.value = true
}
function onArticleCreated(article: Article) {
router.push(`/article/${article.id}/edit`)
}
function clearSearch() {
searchInput.value = ''
debouncedSearch.value = ''
router.replace({ query: { ...route.query, q: undefined } })
nextTick(() => {
document.querySelector<HTMLInputElement>('input[placeholder="Search by title"]')?.focus()
})
}
function onEmptyAction() {
if (emptyVariant.value === 'no-results') {
clearSearch()
return
}
if (emptyVariant.value === 'no-online') {
activeTab.value = 'draft'
return
}
openNewArticleModal()
}
onMounted(loadArticles)
</script>
<template>
<DashboardShell>
<p>Articles placeholder.</p>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
<h1
data-testid="articles-heading"
tabindex="-1"
class="text-xl font-semibold text-highlighted"
>
Articles
</h1>
<p class="text-muted">Everything you've written, online and in draft.</p>
</div>
<UButton
icon="i-lucide-plus"
class="hidden h-8 px-3 sm:flex"
@click="openNewArticleModal"
>
New article
</UButton>
</div>
<div class="flex flex-col-reverse gap-4 sm:flex-row sm:items-end sm:justify-between">
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="neutral"
:content="false"
:ui="{ list: 'gap-5', trigger: 'px-0' }"
/>
<UInput
v-model="searchInput"
type="search"
icon="i-lucide-search"
placeholder="Search by title"
class="w-full sm:w-65"
:ui="{ base: 'h-11 text-lg sm:h-8 sm:text-sm' }"
/>
</div>
<template v-if="!loading || showSkeleton">
<EmptyState
v-if="emptyVariant"
:variant="emptyVariant"
:query="debouncedSearch"
@action="onEmptyAction"
/>
<template v-else>
<ArticleTable
:articles="displayedArticles"
:loading="loading && showSkeleton"
:busy-ids="busyIds"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
/>
<ArticleCardList
:articles="displayedArticles"
:loading="loading && showSkeleton"
:busy-ids="busyIds"
@publish="handlePublish"
@unpublish="handleUnpublish"
@delete="handleDelete"
@copy-url="handleCopyUrl"
/>
</template>
</template>
</div>
<UButton
icon="i-lucide-plus"
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
@click="openNewArticleModal"
>
New article
</UButton>
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
</DashboardShell>
</template>
+667
View File
@@ -0,0 +1,667 @@
import { afterEach, 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 { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
const DAY_MS = 24 * HOUR_MS
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | 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>
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useOverlay: () => overlayMock,
useToast: () => ({ add: toastAdd }),
}
})
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
function routeFor(url: string, init?: RequestInit) {
const method = init?.method ?? 'GET'
return `${method} ${url}`
}
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
if (!handler) {
throw new Error(`Unexpected fetch call: ${key}`)
}
return handler()
})
}
function iso(offsetMs: number) {
return new Date(NOW.getTime() - offsetMs).toISOString()
}
function sixArticleFixture(): Article[] {
return [
{
id: 1,
title: 'How we price a bathroom renovation',
body: '',
images: [],
is_online: true,
created_at: '2026-03-12T09:14:00Z',
updated_at: iso(2 * HOUR_MS),
},
{
id: 2,
title: 'Choosing lime plaster for old stone walls',
body: '',
images: [],
is_online: false,
created_at: '2026-09-09T09:00:00Z',
updated_at: iso(1 * DAY_MS),
},
{
id: 3,
title: 'Before and after: the Rue Verte kitchen',
body: '',
images: [],
is_online: true,
created_at: '2026-08-28T09:00:00Z',
updated_at: iso(3 * DAY_MS),
},
{
id: 4,
title: 'Winter checklist for timber windows',
body: '',
images: [
{ id: 10, url: '/i/10.png' },
{ id: 11, url: '/i/11.png' },
{ id: 12, url: '/i/12.png' },
],
is_online: false,
created_at: '2026-09-02T09:00:00Z',
updated_at: iso(5 * DAY_MS),
},
{
id: 5,
title: 'Why we reuse original floorboards',
body: '',
images: [],
is_online: true,
created_at: '2026-06-14T09:00:00Z',
updated_at: '2026-08-18T09:00:00Z',
},
{
id: 6,
title: 'Our first passive-house retrofit',
body: '',
images: [],
is_online: true,
created_at: '2026-04-03T09:00:00Z',
updated_at: '2026-07-02T09:00:00Z',
},
]
}
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountArticles(handlers: Record<string, () => Response | Promise<Response>>) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push('/article')
const wrapper = mount(ArticleView, {
attachTo: document.body,
global: {
plugins: [router, uiPlugin],
},
})
currentWrapper = wrapper
await flushPromises()
return wrapper
}
function findButton(wrapper: Awaited<ReturnType<typeof mount>>, label: string) {
return wrapper.findAll('button').find((button) => button.text().trim() === label)
}
async function clickTab(label: string) {
const button = Array.from(document.body.querySelectorAll('button')).find(
(el) => el.textContent?.trim() === label,
) as HTMLButtonElement | undefined
button?.focus()
button?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
await flushPromises()
}
async function openRowMenu(wrapper: Awaited<ReturnType<typeof mount>>, title: string) {
const button = document.body.querySelector<HTMLButtonElement>(
`button[aria-label="Actions for ${title}"]`,
)
button?.click()
await flushPromises()
}
async function chooseMenuItem(label: string) {
const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(el) => el.textContent?.trim() === label,
)
;(item as HTMLElement | undefined)?.click()
await flushPromises()
}
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(NOW)
})
afterEach(async () => {
currentWrapper?.unmount()
currentWrapper = null
vi.useRealTimers()
await router.push('/login')
})
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/': () => mockResponse(200, sixArticleFixture()),
})
expect(wrapper.text()).toContain('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
const titles = sixArticleFixture().map((a) => a.title)
const rendered = wrapper.text()
for (const title of titles) {
expect(rendered).toContain(title)
}
expect(wrapper.find('caption').text()).toBe(
'Articles, sorted by last updated, newest first',
)
const firstLink = wrapper.find('[data-row-title="1"]')
expect(firstLink.attributes('href')).toBe('/article/1/edit')
})
})
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/': () =>
new Promise((resolve) => {
resolveList = () => resolve(mockResponse(200, sixArticleFixture()))
}),
})
await vi.advanceTimersByTimeAsync(150)
expect(wrapper.find('[aria-busy="true"]').exists()).toBe(true)
resolveList?.()
await vi.advanceTimersByTimeAsync(0)
await flushPromises()
expect(wrapper.text()).toContain('How we price a bathroom renovation')
})
})
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/': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
const items = Array.from(document.body.querySelectorAll('[role="menuitem"]')).map((el) =>
el.textContent?.trim(),
)
expect(items).toEqual(['Edit', 'Publish', 'Copy API URL', 'Delete…'])
})
})
describe('Copy API URL succeeds — the absolute URL is copied and confirmed', () => {
it('writes the URL to the clipboard and raises a success toast', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Copy API URL')
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/v1/article/4`)
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-link-copied-4',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Link copied',
description:
'The API URL for "Winter checklist for timber windows" is on your clipboard.',
})
})
})
describe('Copy API URL fails — the clipboard write itself is rejected', () => {
it('raises an error toast with no text line', async () => {
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
})
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Copy API URL')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-link-copy-failed-4',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't copy the link",
})
})
})
describe('Empty — zero articles', () => {
it('shows the no-articles empty state and opens NewArticleModal', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, []),
})
expect(wrapper.text()).toContain('All 0')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('No articles yet')
expect(wrapper.text()).toContain(
'Write your first one. It stays a draft until you publish it.',
)
await findButton(wrapper, 'New article')?.trigger('click')
await flushPromises()
expect(document.body.querySelector('[role="dialog"]')).toBeTruthy()
})
})
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/': () => mockResponse(500),
})
expect(wrapper.text()).not.toContain('No articles yet')
})
})
describe('No results — a search matches nothing, but the tab counts still show the full list', () => {
it('shows the no-results empty state, then clears the search', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
})
const searchInput = wrapper.find('input[type="search"]')
await searchInput.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('All 6')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 2')
await findButton(wrapper, 'Clear search')?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('How we price a bathroom renovation')
const inputEl = document.querySelector('input[placeholder="Search by title"]')
expect(inputEl).toBe(document.activeElement)
})
})
describe('An empty tab shows its own EmptyState, while the other tab still counts correctly', () => {
it('shows No drafts when every article is online', async () => {
const onlineOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: true }))
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, onlineOnly),
})
await clickTab('Drafts 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 0')
expect(wrapper.text()).toContain('No drafts')
expect(wrapper.text()).toContain("Everything you've written is online.")
})
it('shows Nothing is online yet when every article is a draft', async () => {
const draftsOnly = sixArticleFixture()
.slice(0, 4)
.map((a) => ({ ...a, is_online: false }))
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, draftsOnly),
})
await clickTab('Online 0')
expect(wrapper.text()).toContain('All 4')
expect(wrapper.text()).toContain('Online 0')
expect(wrapper.text()).toContain('Drafts 4')
expect(wrapper.text()).toContain('Nothing is online yet')
expect(wrapper.text()).toContain('Publish a draft and it will appear on your website.')
})
})
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/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': () => new Promise(() => {}),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Publish')
expect(wrapper.text()).toContain('Publishing…')
expect(wrapper.text()).toContain('Choosing lime plaster for old stone walls')
})
})
describe('Published — a 200 updates the row in place and raises a toast', () => {
it('shows the online badge, "Just now" and the success toast', async () => {
const updated = {
...sixArticleFixture()[3],
is_online: true,
updated_at: iso(0),
}
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': () => mockResponse(200, updated),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Publish')
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-published-4',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article published',
description: '"Winter checklist for timber windows" is now online.',
})
})
})
describe('Action failed, publish — a 500 or a network error leaves the row unchanged and offers Retry', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/4/publish': patchCall,
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Publish')
await flushPromises()
expect(wrapper.text()).toContain('Winter checklist for timber windows')
expect(wrapper.text()).not.toContain('Publishing…')
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
id: 'article-publish-failed-4',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't publish",
description: "The server didn't respond. Nothing changed.",
duration: 0,
}),
)
const call = toastAdd.mock.calls.find(([arg]) => arg.id === 'article-publish-failed-4')
expect(patchCall).toHaveBeenCalledTimes(1)
call?.[0].actions[0].onClick()
await flushPromises()
expect(patchCall).toHaveBeenCalledTimes(2)
})
})
describe('Action failed, unpublish — a 500 or a network error leaves the row unchanged and offers Retry', () => {
it.each([
{ name: 'a 500 response', handler: () => mockResponse(500) },
{
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])('shows Retry for $name and re-sends the same request', async ({ handler }) => {
const patchCall = vi.fn(handler)
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish': patchCall,
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
await chooseMenuItem('Unpublish')
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
id: 'article-unpublish-failed-3',
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't unpublish",
description: "The server didn't respond. Nothing changed.",
duration: 0,
}),
)
const call = toastAdd.mock.calls.find(([arg]) => arg.id === 'article-unpublish-failed-3')
expect(patchCall).toHaveBeenCalledTimes(1)
call?.[0].actions[0].onClick()
await flushPromises()
expect(patchCall).toHaveBeenCalledTimes(2)
})
})
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/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/2/publish': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
await chooseMenuItem('Publish')
await flushPromises()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-2',
color: 'info',
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
})
})
it('removes the row when the delete confirmation resolves 404', async () => {
const wrapper = await mountArticles({
'GET /v1/article/': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/2': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Choosing lime plaster for old stone walls')
await chooseMenuItem('Delete…')
await flushPromises()
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(wrapper.text()).not.toContain('Choosing lime plaster for old stone walls')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-2',
color: 'info',
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
})
})
})
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/': () => mockResponse(200, sixArticleFixture()),
'PATCH /v1/article/3/unpublish': () => mockResponse(404),
})
await openRowMenu(wrapper, 'Before and after: the Rue Verte kitchen')
await chooseMenuItem('Unpublish')
await flushPromises()
expect(wrapper.text()).not.toContain('Before and after: the Rue Verte kitchen')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 3')
expect(wrapper.text()).toContain('Drafts 2')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-not-found-3',
color: 'info',
icon: 'i-lucide-info',
title: 'That article no longer exists',
description: 'It was deleted somewhere else, so we removed it from the list.',
})
})
})
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/': () => mockResponse(200, sixArticleFixture()),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Delete…')
const props = overlayMock.getProps() as Record<string, unknown>
expect(props.title).toBe('Delete this article?')
expect(props.description).toBe(
'"Winter checklist for timber windows" and its 3 images will be deleted. This can\'t be undone.',
)
expect(props.confirmLabel).toBe('Delete article')
expect(props.cancelLabel).toBe('Cancel')
expect(props.pendingLabel).toBe('Deleting…')
expect(props.tone).toBe('danger')
})
})
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/': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(204),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Delete…')
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
overlayMock.resolveOpen(true)
await flushPromises()
expect(wrapper.text()).not.toContain('Winter checklist for timber windows')
expect(wrapper.text()).toContain('All 5')
expect(wrapper.text()).toContain('Online 4')
expect(wrapper.text()).toContain('Drafts 1')
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-deleted-4',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Article deleted',
description: '"Winter checklist for timber windows" and its images are gone.',
})
})
})
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/': () => mockResponse(200, sixArticleFixture()),
'DELETE /v1/article/4': () => mockResponse(500),
})
await openRowMenu(wrapper, 'Winter checklist for timber windows')
await chooseMenuItem('Delete…')
const props = overlayMock.getProps() as { action: () => Promise<void> }
await expect(props.action()).rejects.toThrow(
"Couldn't delete the article, so it's still there. Try again in a moment.",
)
expect(wrapper.text()).toContain('Winter checklist for timber windows')
})
})