feat: articles list screen
This commit is contained in:
1 parent
ca3fe20519
commit
f6d0d01092
14 files changed
+2353
-25
No files matched your search
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user