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

+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)
})
})