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

+11 -8
View File
@@ -21,7 +21,10 @@ function formatThousands(count: number) {
}
const overBy = computed(() => props.value - props.max)
const status = computed<'normal' | 'warn' | 'over'>(() => {
const status = computed<'normal' | 'warn' | 'over' | 'under'>(() => {
if (props.min > 0 && props.value < props.min) {
return 'under'
}
if (overBy.value > 0) {
return 'over'
}
@@ -63,6 +66,8 @@ watch(status, (next, previous) => {
announcement.value = ''
}
})
const isErrorStatus = computed(() => status.value === 'over' || status.value === 'under')
</script>
<template>
@@ -74,7 +79,7 @@ watch(status, (next, previous) => {
class="flex-1 text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'text-error': isErrorStatus,
'text-muted': status === 'normal',
}"
>{{ message }}</span
@@ -83,7 +88,7 @@ watch(status, (next, previous) => {
class="flex items-center gap-1.5 font-mono text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'text-error': isErrorStatus,
'text-muted': status === 'normal',
}"
>
@@ -105,10 +110,10 @@ watch(status, (next, previous) => {
</div>
<div v-else class="flex flex-col gap-1">
<div
class="flex items-center gap-1.5 font-mono text-sm"
class="flex items-center gap-1.5 font-mono text-xs"
:class="{
'text-warning': status === 'warn',
'text-error': status === 'over',
'text-error': isErrorStatus,
'text-muted': status === 'normal',
}"
>
@@ -124,9 +129,7 @@ watch(status, (next, previous) => {
class="size-4"
data-testid="character-counter-over-icon"
/>
<span
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
>
<span>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span>
</div>
<p v-if="status === 'over'" class="text-sm text-error">
{{ overText }}
+240 -22
View File
@@ -1,42 +1,260 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
const TITLE_MIN_LENGTH = 3
const TITLE_MAX_LENGTH = 200
const TITLE_PLACEHOLDER = 'e.g. Choosing tiles for a small bathroom'
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [article: Article] }>()
const api = useApi()
const title = ref('')
const body = ref('')
const titleTouched = ref(false)
const bodyTouched = ref(false)
const titleFocused = ref(false)
const submitting = ref(false)
const titleServerError = ref('')
const formAlert = ref<'' | 'invalid' | 'failed'>('')
const titleLength = computed(() => [...title.value].length)
const titleValid = computed(
() => titleLength.value >= TITLE_MIN_LENGTH && titleLength.value <= TITLE_MAX_LENGTH,
)
const bodyValid = computed(() => body.value.length > 0)
const titleLengthError = computed(() => {
if (!titleTouched.value) {
return ''
}
if (titleLength.value < TITLE_MIN_LENGTH) {
return 'Use at least 3 characters.'
}
if (titleLength.value > TITLE_MAX_LENGTH) {
return 'Use 3 to 200 characters.'
}
return ''
})
const titleError = computed(() => titleServerError.value || titleLengthError.value)
const bodyError = computed(() =>
bodyTouched.value && !bodyValid.value
? 'Add a first paragraph. It can be a single sentence.'
: '',
)
const titleCounterMin = computed(() => (titleTouched.value ? TITLE_MIN_LENGTH : 0))
const showHelp = computed(() => !submitting.value && formAlert.value === '')
const alertMessage = computed(() => {
if (formAlert.value === 'invalid') {
return "Something in the form isn't valid. Check the highlighted fields."
}
if (formAlert.value === 'failed') {
return "Couldn't create the article. Your text is still here. Try again in a moment."
}
return ''
})
function resetForm() {
title.value = ''
body.value = ''
titleTouched.value = false
bodyTouched.value = false
titleFocused.value = false
submitting.value = false
titleServerError.value = ''
formAlert.value = ''
}
async function focusTitleField() {
await nextTick()
await nextTick()
document.querySelector<HTMLInputElement>(`input[placeholder="${TITLE_PLACEHOLDER}"]`)?.focus()
titleFocused.value = true
}
watch(
open,
(isOpen) => {
if (!isOpen) {
return
}
resetForm()
focusTitleField()
},
{ immediate: true },
)
function onTitleBlur() {
titleTouched.value = true
titleFocused.value = false
}
function onBodyBlur() {
bodyTouched.value = true
}
async function onSubmit() {
titleTouched.value = true
bodyTouched.value = true
titleFocused.value = false
if (!titleValid.value || !bodyValid.value) {
return
}
titleServerError.value = ''
formAlert.value = ''
submitting.value = true
try {
const article = await api.request<Article>('/v1/article/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }),
})
open.value = false
emit('created', article)
} catch (error) {
if (error instanceof ApiError && error.status === 400) {
titleServerError.value = 'Check this title and try again.'
formAlert.value = 'invalid'
} else {
formAlert.value = 'failed'
}
} finally {
submitting.value = false
}
}
function onCancel() {
if (submitting.value) {
return
}
resetForm()
open.value = false
}
</script>
<template>
<UModal v-model:open="open" title="New article">
<UModal
v-model:open="open"
title="New article"
description="It's saved as a draft. Titles can't be changed later, so check it now."
:dismissible="!submitting"
:ui="{
content:
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-140 sm:translate-y-0 max-sm:!inset-x-4 max-sm:!top-auto max-sm:!bottom-4 max-sm:!left-4 max-sm:!w-auto max-sm:!max-w-none max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'items-start gap-3 px-6 pt-5 pb-0 sm:px-6',
wrapper: 'pe-10',
title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6',
}"
>
<template #body>
<UForm :state="{ title, body }" class="space-y-4">
<UFormField label="Title" name="title">
<UInput
v-model="title"
placeholder="Article title"
class="w-full"
/>
</UFormField>
<UFormField label="Body" name="body">
<UTextarea
v-model="body"
placeholder="Start writing…"
class="w-full"
/>
</UFormField>
</UForm>
<div class="flex flex-col gap-4">
<UAlert
v-if="alertMessage"
role="alert"
color="error"
variant="soft"
:close="false"
icon="i-lucide-circle-x"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ title, body }" class="flex flex-col gap-4">
<UFormField label="Title" name="title" :error="titleError || undefined">
<template #hint>
<CharacterCounter
:value="titleLength"
:max="TITLE_MAX_LENGTH"
:min="titleCounterMin"
/>
</template>
<template v-if="titleError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<template v-else-if="showHelp" #help>3 to 200 characters.</template>
<UInput
v-model="title"
:placeholder="TITLE_PLACEHOLDER"
:disabled="submitting"
color="primary"
:highlight="titleFocused || !!titleError"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-base' }"
class="w-full"
@focus="titleFocused = true"
@blur="onTitleBlur"
/>
</UFormField>
<UFormField label="First paragraph" name="body" :error="bodyError || undefined">
<template v-if="bodyError" #error="{ error }">
<span class="flex items-center gap-1.5">
<UIcon
name="i-lucide-circle-alert"
class="size-3.5 shrink-0 text-error"
/>
{{ error }}
</span>
</template>
<template v-else-if="showHelp" #help>
You can format it and add images in the editor.
</template>
<UTextarea
v-model="body"
placeholder="Start writing. You'll continue in the editor."
:disabled="submitting"
:highlight="!!bodyError"
:rows="3"
:ui="{
base: 'h-30 text-lg disabled:bg-elevated disabled:text-dimmed disabled:opacity-100 md:h-24 md:text-base',
}"
class="w-full"
@blur="onBodyBlur"
/>
</UFormField>
</UForm>
</div>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton color="neutral" variant="outline" @click="onCancel"
>Cancel</UButton
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton
color="neutral"
variant="outline"
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:disabled="submitting"
@click="onCancel"
>
<UButton disabled>Create article</UButton>
Cancel
</UButton>
<UButton
block
:ui="{ base: 'h-11 md:h-8' }"
class="sm:w-auto"
:loading="submitting"
:disabled="submitting"
@click="onSubmit"
>
{{ submitting ? 'Creating…' : 'Create draft' }}
</UButton>
</div>
</template>
</UModal>
@@ -0,0 +1,361 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import NewArticleModal from '../NewArticleModal.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
const TITLE = 'Choosing tiles for a small bathroom'
const BODY = 'Small rooms show every grout line, so we start with the floor.'
function articleFixture(): Article {
return {
id: 42,
title: TITLE,
body: BODY,
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
}
const Host = defineComponent({
components: { NewArticleModal },
emits: ['created'],
setup(_props, { emit }) {
const open = ref(true)
return { open, onCreated: (article: Article) => emit('created', article) }
},
template: `<NewArticleModal v-model:open="open" @created="onCreated" />`,
})
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButton(text)?.click()
await flushPromises()
}
let requestMock: ReturnType<typeof vi.fn>
function mountModal() {
requestMock = vi.fn()
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: requestMock,
})
return mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
}
function titleInput() {
return document.body.querySelector<HTMLInputElement>(
'input[placeholder="e.g. Choosing tiles for a small bathroom"]',
)!
}
function bodyTextarea() {
return document.body.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="Start writing. You\'ll continue in the editor."]',
)!
}
function dispatch(element: HTMLElement, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true }))
}
async function fillTitle(value: string) {
const input = titleInput()
input.value = value
dispatch(input, 'input')
await flushPromises()
}
async function blurTitle() {
dispatch(titleInput(), 'blur')
await flushPromises()
}
async function fillBody(value: string) {
const textarea = bodyTextarea()
textarea.value = value
dispatch(textarea, 'input')
await flushPromises()
}
async function blurBody() {
dispatch(bodyTextarea(), 'blur')
await flushPromises()
}
async function fillValidForm() {
await fillTitle(TITLE)
await fillBody(BODY)
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('Opening the modal shows the Default state', () => {
it('renders empty, focused fields with the Default copy', async () => {
mountModal()
await flushPromises()
expect(document.body.textContent).toContain('New article')
expect(document.body.textContent).toContain(
"It's saved as a draft. Titles can't be changed later, so check it now.",
)
expect(titleInput().value).toBe('')
expect(document.activeElement).toBe(titleInput())
expect(document.body.textContent).toContain('0 / 200')
expect(document.body.textContent).toContain('3 to 200 characters.')
expect(bodyTextarea().value).toBe('')
expect(document.body.textContent).toContain(
'You can format it and add images in the editor.',
)
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Create draft')).toBeTruthy()
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — a title under 3 characters on blur', () => {
it('shows the error in place of help and the counter in its error style', async () => {
mountModal()
await flushPromises()
await fillTitle('Ti')
await blurTitle()
expect(document.body.textContent).toContain('Use at least 3 characters.')
expect(document.body.textContent).not.toContain('3 to 200 characters.')
expect(document.body.textContent).toContain('2 / 200')
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — an empty first paragraph on blur', () => {
it('shows the error once the field is left empty and blurred', async () => {
mountModal()
await flushPromises()
await fillTitle(TITLE)
await blurTitle()
await blurBody()
expect(document.body.textContent).toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — submitting with both fields invalid shows both errors and sends nothing', () => {
it('shows both errors and never sends the request', async () => {
mountModal()
await flushPromises()
await fillTitle('Ti')
await clickButton('Create draft')
expect(document.body.textContent).toContain('Use at least 3 characters.')
expect(document.body.textContent).toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Submitting disables the form and cannot be dismissed', () => {
it('sends the request, disables the form and blocks Esc/backdrop dismissal', async () => {
let resolveRequest: ((article: Article) => void) | null = null
requestMock = vi.fn(
() =>
new Promise<Article>((resolve) => {
resolveRequest = resolve
}),
)
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
expect(requestMock).toHaveBeenCalledWith(
'/v1/article/',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: TITLE, body: BODY }),
}),
)
expect(titleInput().disabled).toBe(true)
expect(bodyTextarea().disabled).toBe(true)
expect((findButton('Cancel') as HTMLButtonElement).disabled).toBe(true)
expect(findButton('Creating…')).toBeTruthy()
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
resolveRequest?.(articleFixture())
await flushPromises()
wrapper.unmount()
})
})
describe('A successful create closes the modal and hands off to the editor with a toast', () => {
it('closes the modal and emits created with the response body', async () => {
requestMock = vi.fn().mockResolvedValue(articleFixture())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
})
})
describe("Title rejected — a 400 that got past this form's own check", () => {
it('keeps the values, shows the title error and the form alert', async () => {
requestMock = vi.fn().mockRejectedValue(new ApiError(400))
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
expect(titleInput().value).toBe(TITLE)
expect(bodyTextarea().value).toBe(BODY)
expect(document.body.textContent).toContain('Check this title and try again.')
expect(document.body.textContent).toContain(
"Something in the form isn't valid. Check the highlighted fields.",
)
expect(document.body.textContent).not.toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(titleInput().disabled).toBe(false)
wrapper.unmount()
})
})
describe('Error — a 500 or a network failure keeps the text and offers to retry', () => {
it.each([
{ name: 'a 500 response', error: () => new ApiError(500) },
{ name: 'a network failure', error: () => new NetworkError() },
])('keeps the values and shows the generic alert for $name', async ({ error }) => {
requestMock = vi.fn().mockRejectedValue(error())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
expect(titleInput().value).toBe(TITLE)
expect(bodyTextarea().value).toBe(BODY)
expect(document.body.textContent).toContain(
"Couldn't create the article. Your text is still here. Try again in a moment.",
)
expect(document.body.textContent).not.toContain('Check this title and try again.')
expect(document.body.textContent).not.toContain(
'Add a first paragraph. It can be a single sentence.',
)
wrapper.unmount()
})
})
describe('Retrying after an Error succeeds', () => {
it('sends the same body again and hands off to the editor on success', async () => {
requestMock = vi.fn().mockRejectedValueOnce(new ApiError(500))
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
requestMock.mockResolvedValueOnce(articleFixture())
await clickButton('Create draft')
await flushPromises()
expect(requestMock).toHaveBeenCalledTimes(2)
expect(requestMock).toHaveBeenNthCalledWith(
2,
'/v1/article/',
expect.objectContaining({
body: JSON.stringify({ title: TITLE, body: BODY }),
}),
)
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
})
})
describe('Cancel discards the fields with no confirmation', () => {
it('closes the modal immediately without sending a request', async () => {
mountModal()
await flushPromises()
await fillValidForm()
await clickButton('Cancel')
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
expect(requestMock).not.toHaveBeenCalled()
})
})
+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)
})
})