feat: article creation modal
This commit is contained in:
1 parent
a4734b2c91
commit
c47d33eaa9
8 files changed
+1030
-116
No files matched your search
+36
-61
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user