feat: article creation modal

This commit is contained in:
fsylvain committed 2026-09-27 06:26:41 +02:00
1 parent a4734b2c91
commit c47d33eaa9
8 files changed
+1030 -116

No files matched your search

+36 -61
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
@@ -12,6 +14,8 @@ const RECENT_LIST_SIZE = 5
type LoadState = 'loading' | 'default' | 'empty' | 'error'
const router = useRouter()
const toast = useToast()
const session = useSessionStore()
const { listArticles } = useArticles()
@@ -60,11 +64,7 @@ const statTiles = computed(() => [
const recentArticles = computed(() =>
[...articles.value]
.sort(
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
.slice(0, RECENT_LIST_SIZE),
)
@@ -99,6 +99,17 @@ function openNewArticleModal() {
newArticleModalOpen.value = true
}
function onArticleCreated(article: Article) {
router.push(`/article/${article.id}/edit`)
toast.add({
id: `article-created-${article.id}`,
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Draft created',
description: `"${article.title}" is saved as a draft.`,
})
}
onMounted(loadArticles)
</script>
@@ -119,10 +130,7 @@ onMounted(loadArticles)
<div class="flex max-w-244 flex-col gap-5 lg:gap-7">
<div class="flex flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-1">
<h1
data-testid="greeting"
class="text-2xl font-semibold text-highlighted"
>
<h1 data-testid="greeting" class="text-2xl font-semibold text-highlighted">
{{ greeting }}
</h1>
<p class="text-base/5 text-muted">{{ subtitle }}</p>
@@ -136,12 +144,7 @@ onMounted(loadArticles)
</UButton>
</div>
<template
v-if="
(loadState === 'loading' && showSkeleton) ||
loadState === 'default'
"
>
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
<div class="grid grid-cols-3 gap-3">
<div
v-for="tile in statTiles"
@@ -150,18 +153,13 @@ onMounted(loadArticles)
class="flex flex-col gap-1 border border-default bg-default p-4"
>
<template v-if="loadState === 'loading'">
<USkeleton
class="h-7 w-10 rounded-none bg-accented"
/>
<USkeleton
class="h-3 w-17.5 rounded-none bg-accented"
/>
<USkeleton class="h-7 w-10 rounded-none bg-accented" />
<USkeleton class="h-3 w-17.5 rounded-none bg-accented" />
</template>
<template v-else>
<span
class="text-2xl font-semibold text-highlighted"
>{{ tile.value }}</span
>
<span class="text-2xl font-semibold text-highlighted">{{
tile.value
}}</span>
<span class="text-muted">{{ tile.label }}</span>
</template>
</div>
@@ -169,13 +167,8 @@ onMounted(loadArticles)
<section class="flex flex-col gap-2.5">
<div class="flex items-baseline justify-between">
<h2 class="text-lg font-semibold text-highlighted">
Recently updated
</h2>
<RouterLink
to="/article"
class="text-toned underline underline-offset-3"
>
<h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
<RouterLink to="/article" class="text-toned underline underline-offset-3">
All articles
</RouterLink>
</div>
@@ -189,35 +182,21 @@ onMounted(loadArticles)
:key="n"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
>
<USkeleton
class="h-3 max-w-[60%] flex-1 rounded-none bg-accented"
/>
<USkeleton
class="h-5 w-14 rounded-none bg-accented"
/>
<USkeleton class="h-3 max-w-[60%] flex-1 rounded-none bg-accented" />
<USkeleton class="h-5 w-14 rounded-none bg-accented" />
</div>
</div>
<ul
v-else
data-testid="recent-list"
class="border border-default bg-default"
>
<ul v-else data-testid="recent-list" class="border border-default bg-default">
<li v-for="article in recentArticles" :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"
>
<span
class="min-w-0 flex-1 truncate font-medium text-highlighted"
>
<span class="min-w-0 flex-1 truncate font-medium text-highlighted">
{{ article.title }}
</span>
<ArticleStatusBadge
:online="article.is_online"
/>
<span
class="hidden w-27.5 text-right text-muted lg:inline"
>
<ArticleStatusBadge :online="article.is_online" />
<span class="hidden w-27.5 text-right text-muted lg:inline">
{{ formatRelativeDate(article.updated_at) }}
</span>
</RouterLink>
@@ -278,21 +257,17 @@ onMounted(loadArticles)
</template>
</UAlert>
<div
class="flex items-start gap-3.5 border border-default bg-default p-4"
>
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
<div
class="flex size-9 shrink-0 items-center justify-center bg-secondary-200 text-primary-900 dark:bg-secondary-300"
>
<UIcon name="i-lucide-plug" class="size-4.5" />
</div>
<div class="flex flex-1 flex-col gap-1">
<h2 class="text-base/5 font-semibold text-highlighted">
Connect your website
</h2>
<h2 class="text-base/5 font-semibold text-highlighted">Connect your website</h2>
<p class="text-pretty text-muted">
Your site reads published articles from RenewCMS. Share
this guide with whoever builds it.
Your site reads published articles from RenewCMS. Share this guide with
whoever builds it.
</p>
<RouterLink
to="/integration"
@@ -304,6 +279,6 @@ onMounted(loadArticles)
</div>
</div>
<NewArticleModal v-model:open="newArticleModalOpen" />
<NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
</DashboardShell>
</template>
+69 -25
View File
@@ -7,11 +7,17 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.vue'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useToast: () => ({ add: toastAdd }),
}
})
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -25,9 +31,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -97,10 +101,9 @@ const sixArticles: Article[] = [
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountHome(
handlers: Record<string, () => Response | Promise<Response>>,
) {
async function mountHome(handlers: Record<string, () => Response | Promise<Response>>) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
@@ -157,8 +160,7 @@ 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/': () =>
Promise.resolve(mockResponse(200, sixArticles)),
'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)),
})
await vi.advanceTimersByTimeAsync(0)
@@ -175,9 +177,7 @@ describe('Default — the list loaded with a mix of online and draft articles',
'GET /v1/article/': () => mockResponse(200, sixArticles),
})
expect(wrapper.text()).toContain(
"Here's where things stand on your site.",
)
expect(wrapper.text()).toContain("Here's where things stand on your site.")
const rows = wrapper.find('[data-testid="recent-list"]').findAll('li')
expect(rows).toHaveLength(5)
@@ -196,9 +196,7 @@ describe('Default — the list loaded with a mix of online and draft articles',
expect(wrapper.text()).toContain('2')
expect(wrapper.text()).toContain('drafts')
const guideLink = wrapper
.findAll('a')
.find((a) => a.text() === 'Open the guide')
const guideLink = wrapper.findAll('a').find((a) => a.text() === 'Open the guide')
expect(guideLink?.attributes('href')).toBe('/integration')
await findButton(wrapper, 'New article')?.trigger('click')
@@ -207,6 +205,56 @@ describe('Default — the list loaded with a mix of online and draft articles',
})
})
describe('A successful create hands off to the editor with a toast', () => {
it('navigates to /article/{id}/edit and raises the Draft created toast', async () => {
const created = {
id: 42,
title: 'Choosing tiles for a small bathroom',
body: 'Small rooms show every grout line, so we start with the floor.',
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
const wrapper = await mountHome({
'GET /v1/article/': () => mockResponse(200, sixArticles),
'POST /v1/article/': () => mockResponse(201, created),
})
await findButton(wrapper, 'New article')?.trigger('click')
await flushPromises()
const titleInput = document.body.querySelector<HTMLInputElement>(
'input[placeholder="e.g. Choosing tiles for a small bathroom"]',
)!
titleInput.value = created.title
titleInput.dispatchEvent(new Event('input', { bubbles: true }))
const bodyTextarea = document.body.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="Start writing. You\'ll continue in the editor."]',
)!
bodyTextarea.value = created.body
bodyTextarea.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
const confirmButton = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Create draft',
)
confirmButton?.click()
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article/42/edit'))
expect(toastAdd).toHaveBeenCalledWith({
id: 'article-created-42',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Draft created',
description: '"Choosing tiles for a small bathroom" is saved as a draft.',
})
})
})
describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({
@@ -239,9 +287,7 @@ describe('Error — a 500 or a network failure shows the same inline alert', ()
'GET /v1/article/': handler,
})
expect(wrapper.text()).toContain(
"Here's where things stand on your site.",
)
expect(wrapper.text()).toContain("Here's where things stand on your site.")
expect(wrapper.text()).toContain('Good')
const alert = wrapper.find('[role="alert"]')
expect(alert.exists()).toBe(true)
@@ -282,9 +328,7 @@ describe('The list is fetched exactly once per navigation', () => {
expect(listCall).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('6')
expect(
wrapper.find('[data-testid="recent-list"]').findAll('li'),
).toHaveLength(5)
expect(wrapper.find('[data-testid="recent-list"]').findAll('li')).toHaveLength(5)
})
})