feat: article creation modal
This commit is contained in:
1 parent
a4734b2c91
commit
c47d33eaa9
8 files changed
+1019
-105
No files matched your search
@@ -0,0 +1,146 @@
|
||||
Feature: Creating a new article from NewArticleModal
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want NewArticleModal to take a title and a first paragraph, create the article as a draft
|
||||
over POST /v1/article/, and take me straight to the editor to keep writing
|
||||
So that starting a new article is a two-field, low-friction step instead of a full form, and I
|
||||
land where I can actually write it
|
||||
|
||||
Background:
|
||||
Given NewArticleModal is a UModal opened from Home's "New article" action (mobile header
|
||||
button, desktop header button, and the empty state's "New article" action), per
|
||||
docs/design/routes.md and docs/design/screens/new-article.md; the Articles-page trigger and
|
||||
the /article/new deep link are out of scope for this slice, deferred until ArticlesPage exists
|
||||
And every time it opens, it starts in the Default state: title "New article", text "It's
|
||||
saved as a draft. Titles can't be changed later, so check it now.", an empty, focused Title
|
||||
field with placeholder "e.g. Choosing tiles for a small bathroom", a CharacterCounter
|
||||
reading "0 / 200" and help "3 to 200 characters.", an empty First paragraph textarea with
|
||||
placeholder "Start writing. You'll continue in the editor." and help "You can format it and
|
||||
add images in the editor.", a "Cancel" button and a "Create draft" button
|
||||
And the Title field is sent as the request's title and is validated required, 3 to 200
|
||||
characters, counted the same way as the CharacterCounter ([...str].length, which is rune
|
||||
count, matching article_title_rules.feature's server-side rune counting)
|
||||
And the First paragraph field is sent as the request's body and is validated required (at
|
||||
least one character)
|
||||
And each field validates on blur and again on submit; submitting while either field is
|
||||
invalid sends no request, and shows both fields' errors at once if both are invalid: Title
|
||||
under 3 characters shows "Use at least 3 characters." in place of its help text, with the
|
||||
counter in its error style; an empty First paragraph shows "Add a first paragraph. It can
|
||||
be a single sentence." in place of its help text
|
||||
And activating "Create draft" while both fields are valid sends POST /v1/article/ with JSON
|
||||
{"title": "<title>", "body": "<first paragraph>"}; while the request is in flight both
|
||||
fields and both buttons are disabled, the confirm button reads "Creating…" with a loading
|
||||
indicator, and the modal cannot be dismissed by Cancel, Esc or a backdrop click
|
||||
And a 201 response closes the modal, emits created(article) with the response body, and (for
|
||||
this slice) HomeView.vue's created handler navigates to /article/{id}/edit and raises a
|
||||
toast: color success, icon i-lucide-circle-check, title "Draft created", description
|
||||
"\"{title}\" is saved as a draft.", with id "article-created-{id}" per
|
||||
docs/design/components.md's toast-id convention
|
||||
And a 400 response — whether apperr.ArticleTitleErrorMessage for a title that passed this
|
||||
form's own check but still failed the server's, or any other 400 — is never shown verbatim;
|
||||
it re-enables the form with both fields' current values kept, shows "Check this title and
|
||||
try again." in place of the Title field's help text, and shows an alert "Something in the
|
||||
form isn't valid. Check the highlighted fields."; the First paragraph field shows no error
|
||||
And a 500 response or a request that never reaches the server re-enables the form with both
|
||||
fields' current values kept and shows an alert "Couldn't create the article. Your text is
|
||||
still here. Try again in a moment.", with no field-level error
|
||||
And "Cancel" always closes the modal immediately and discards both fields, with no
|
||||
confirmation prompt, whenever the form is not disabled by an in-flight request
|
||||
And a 401 response is handled entirely by useApi's existing global handling
|
||||
(features/api_client_global_error_handling.feature): the session is cleared and the app
|
||||
navigates to /login?redirect=<path>&reason=expired; this slice adds no draft-preservation
|
||||
behavior for the modal's fields, unlike the editor's session-expiry handling
|
||||
|
||||
Scenario: Opening the modal shows the Default state
|
||||
Given the user is on /home
|
||||
When the user activates "New article"
|
||||
Then NewArticleModal opens with title "New article" and the text "It's saved as a draft.
|
||||
Titles can't be changed later, so check it now."
|
||||
And the Title field is empty, focused, and its counter reads "0 / 200"
|
||||
And the First paragraph field is empty
|
||||
And "Create draft" is present but the form has not been submitted
|
||||
|
||||
Scenario: Validation — a title under 3 characters on blur
|
||||
Given the modal is open
|
||||
When the user types "Ti" into the Title field and moves focus away
|
||||
Then the Title field shows the error "Use at least 3 characters." in place of its help text
|
||||
And the counter reads "2 / 200" in its error style
|
||||
And no request is sent
|
||||
|
||||
Scenario: Validation — an empty first paragraph on blur
|
||||
Given the modal is open with a valid Title
|
||||
When the user leaves the First paragraph field empty and moves focus away
|
||||
Then the First paragraph field shows the error "Add a first paragraph. It can be a single
|
||||
sentence."
|
||||
And no request is sent
|
||||
|
||||
Scenario: Validation — submitting with both fields invalid shows both errors and sends nothing
|
||||
Given the modal is open with Title "Ti" and an empty First paragraph
|
||||
When the user activates "Create draft"
|
||||
Then the Title field shows "Use at least 3 characters." and the First paragraph field shows
|
||||
"Add a first paragraph. It can be a single sentence."
|
||||
And POST /v1/article/ is never sent
|
||||
|
||||
Scenario: Submitting disables the form and cannot be dismissed
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
|
||||
"Small rooms show every grout line, so we start with the floor."
|
||||
When the user activates "Create draft"
|
||||
Then POST /v1/article/ is sent with JSON {"title": "Choosing tiles for a small bathroom",
|
||||
"body": "Small rooms show every grout line, so we start with the floor."}
|
||||
And both fields become disabled, "Cancel" becomes disabled, and the confirm button reads
|
||||
"Creating…" with a loading indicator
|
||||
And pressing Esc or clicking the backdrop does not close the modal
|
||||
|
||||
Scenario: A successful create closes the modal and hands off to the editor with a toast
|
||||
Given the user is on /home with the modal open, Title "Choosing tiles for a small bathroom"
|
||||
and First paragraph "Small rooms show every grout line, so we start with the floor.", and
|
||||
"Create draft" has been activated
|
||||
When POST /v1/article/ resolves 201 with the created article, id 42, is_online false
|
||||
Then the modal closes
|
||||
And the app navigates to /article/42/edit
|
||||
And a toast appears: color success, icon i-lucide-circle-check, title "Draft created",
|
||||
description "\"Choosing tiles for a small bathroom\" is saved as a draft.", id
|
||||
"article-created-42"
|
||||
|
||||
Scenario: Title rejected — a 400 that got past this form's own check
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" (already valid by
|
||||
this form's own rule) and First paragraph "Small rooms show every grout line, so we start
|
||||
with the floor.", and "Create draft" has been activated
|
||||
When POST /v1/article/ resolves 400
|
||||
Then the modal re-enables, keeping both field values
|
||||
And the Title field shows "Check this title and try again." in place of its help text
|
||||
And an alert reads "Something in the form isn't valid. Check the highlighted fields."
|
||||
And the First paragraph field shows no error
|
||||
|
||||
Scenario Outline: Error — a 500 or a network failure keeps the text and offers to retry
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
|
||||
"Small rooms show every grout line, so we start with the floor.", and "Create draft" has
|
||||
been activated
|
||||
When POST /v1/article/ <failure>
|
||||
Then the modal re-enables, keeping both field values
|
||||
And an alert reads "Couldn't create the article. Your text is still here. Try again in a
|
||||
moment."
|
||||
And neither field shows a field-level error
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Retrying after an Error succeeds
|
||||
Given the modal is in the Error state after a 500, with Title "Choosing tiles for a small
|
||||
bathroom" and First paragraph "Small rooms show every grout line, so we start with the
|
||||
floor." still in the fields
|
||||
When the user activates "Create draft" again
|
||||
Then POST /v1/article/ is sent again with the same JSON body
|
||||
When it resolves 201 with the created article, id 42
|
||||
Then the modal closes, the app navigates to /article/42/edit, and a toast appears: color
|
||||
success, icon i-lucide-circle-check, title "Draft created", description "\"Choosing tiles
|
||||
for a small bathroom\" is saved as a draft.", id "article-created-42"
|
||||
|
||||
Scenario: Cancel discards the fields with no confirmation
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
|
||||
"Small rooms show every grout line, so we start with the floor."
|
||||
When the user activates "Cancel"
|
||||
Then the modal closes immediately, with no confirmation prompt
|
||||
And no request is sent
|
||||
@@ -0,0 +1,97 @@
|
||||
# QA: Creating a new article from NewArticleModal
|
||||
|
||||
## Implementation note for coder (informational, not a QA assertion)
|
||||
|
||||
Only HomeView.vue is wired as a real `created` consumer in this slice: it listens for
|
||||
`@created="article => { router.push(...); useToast().add(...) }"`. The Articles-page trigger and
|
||||
the `/article/new` route stay pointed at the current ArticleView.vue placeholder until that page
|
||||
exists; don't wire a `created` handler there yet.
|
||||
|
||||
The confirm button's label changes from the placeholder's "Create article" to the design's
|
||||
"Create draft" — this is a deliberate copy change, not a leftover.
|
||||
|
||||
The Created toast uses `useToast().add({ id: 'article-created-{id}', color: 'success', icon:
|
||||
'i-lucide-circle-check', title: 'Draft created', description: '"{title}" is saved as a draft.' })`,
|
||||
per docs/design/components.md's toast-id convention (action + article id) and the Toast rules
|
||||
section (success/info close after 5 s).
|
||||
|
||||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||||
|
||||
Mock `useApi`'s `request` for `POST /v1/article/` per case; no real backend, no httptest server.
|
||||
Mount HomePage (which owns NewArticleModal) inside DashboardShell with a resolved verified session,
|
||||
and a test router at `/home`. Sample data throughout: title "Choosing tiles for a small bathroom",
|
||||
first paragraph "Small rooms show every grout line, so we start with the floor.", created article
|
||||
id 42, is_online false.
|
||||
|
||||
1. **Default state on open** — click "New article"; assert the modal shows title "New article",
|
||||
the intro text, an empty focused Title field with counter "0 / 200" and help "3 to 200
|
||||
characters.", an empty First paragraph field with its placeholder and help, "Cancel" and
|
||||
"Create draft".
|
||||
2. **Blur validation, Title** — type "Ti" into Title and blur; assert the error "Use at least 3
|
||||
characters." replaces the help text and the counter "2 / 200" is in its error style; assert no
|
||||
`request` call.
|
||||
3. **Blur validation, First paragraph** — with a valid Title, leave First paragraph empty and
|
||||
blur; assert "Add a first paragraph. It can be a single sentence." appears; assert no
|
||||
`request` call.
|
||||
4. **Submit with both fields invalid** — Title "Ti", empty First paragraph, click "Create draft";
|
||||
assert both errors show and `request` is never called.
|
||||
5. **Submitting state** — fill both fields validly, click "Create draft"; assert `request` is
|
||||
called once with `POST /v1/article/` and the exact JSON body; assert both fields and Cancel
|
||||
become disabled, the confirm button reads "Creating…"; assert pressing Escape and clicking the
|
||||
backdrop do not close the modal while unresolved.
|
||||
6. **Successful create hands off** — from Submitting, resolve `request` 201 with the fixture
|
||||
article (id 42); assert the modal closes; assert the router navigated to `/article/42/edit`;
|
||||
assert a toast was raised with id "article-created-42", color success, icon
|
||||
"i-lucide-circle-check", title "Draft created", and description "\"Choosing tiles for a small
|
||||
bathroom\" is saved as a draft."
|
||||
7. **Title rejected (400)** — from Submitting, resolve `request` rejecting with `ApiError(400)`;
|
||||
assert both field values are kept, the Title field shows "Check this title and try again.",
|
||||
an alert reads "Something in the form isn't valid. Check the highlighted fields.", and the
|
||||
First paragraph field shows no error.
|
||||
8. **Error, Examples-driven** — from Submitting, (a) resolve `request` rejecting with
|
||||
`ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert both field values
|
||||
are kept, the alert reads "Couldn't create the article. Your text is still here. Try again in a
|
||||
moment.", and neither field shows an error.
|
||||
9. **Retry after Error succeeds** — from the Error state (500), click "Create draft" again; assert
|
||||
`request` is called a second time with the same JSON body; resolve 201 with the fixture; assert
|
||||
the modal closes, the router navigated to `/article/42/edit`, and the same "Draft created" toast
|
||||
(id "article-created-42") is raised.
|
||||
10. **Cancel discards** — fill both fields, click "Cancel"; assert the modal closes immediately
|
||||
with no confirmation UI, and `request` was never called.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/new-article.md` and `docs/design/source/RenewCMS Screens
|
||||
Back-office.dc.html`, group "New article". Run `task visual:check -- new-article` for each state
|
||||
below and compare against the board frame; add each state's mock setup to `web/visual/states.ts`
|
||||
first, reusing this slice's sample data (title "Choosing tiles for a small bathroom", first
|
||||
paragraph "Small rooms show every grout line, so we start with the floor."). This screen has no
|
||||
time-dependent content, so no fixed `now` is needed.
|
||||
|
||||
- [ ] Default: empty, focused Title with counter "0 / 200", empty First paragraph, help text on
|
||||
both fields, "Cancel" / "Create draft".
|
||||
- [ ] Validation: Title "Ti" with counter "2 / 200" in error and "Use at least 3 characters.";
|
||||
First paragraph error "Add a first paragraph. It can be a single sentence."
|
||||
- [ ] Submitting: both fields filled and disabled, confirm reads "Creating…" with its loading
|
||||
state.
|
||||
- [ ] Title rejected (400): both fields filled, Title error "Check this title and try again.",
|
||||
alert "Something in the form isn't valid. Check the highlighted fields."
|
||||
- [ ] Error: both fields filled, alert "Couldn't create the article. Your text is still here. Try
|
||||
again in a moment.", no field-level error.
|
||||
- [ ] Created: no board frame; verified by Vitest (toast copy + navigation).
|
||||
- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → First paragraph → Cancel →
|
||||
Create draft; Esc closes the modal in every dismissible state and is blocked while
|
||||
Submitting; the counter's live region announces only when its error/warn threshold is
|
||||
crossed, not on every keystroke, per CharacterCounter's a11y spec.
|
||||
- [ ] Motion: modal open/close follows the standard 240 ms timing and stops under reduced motion.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real POST /v1/article/ round trip against a running server** — Vitest only mocks `useApi`;
|
||||
confirm the real 201/400/500 responses and that a genuinely-created article is retrievable at
|
||||
`/article/{id}/edit` against `task dev` with a live SQLite file, since automated tests never
|
||||
exercise the real Go handler or `CreateArticleUseCase`.
|
||||
- **Toast placement and stacking on a live app** — confirm the "Draft created" toast's real
|
||||
position, duration and stacking behavior (per docs/design/components.md's Toast rules) against
|
||||
a running app, since Vitest only asserts that `useToast().add` was called with the right
|
||||
arguments, not the rendered/animated toast region.
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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">
|
||||
<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="Article 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="Body" name="body">
|
||||
<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…"
|
||||
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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -203,6 +203,38 @@ async function typeExactBodyLength(page: Page, totalLength: number) {
|
||||
await page.keyboard.insertText(inner)
|
||||
}
|
||||
|
||||
const newArticleTitle = 'Choosing tiles for a small bathroom'
|
||||
const newArticleBody = 'Small rooms show every grout line, so we start with the floor.'
|
||||
|
||||
function newArticleHomeApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': verifiedSession,
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function openNewArticleModal(page: Page) {
|
||||
await page.getByRole('button', { name: 'New article' }).click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
}
|
||||
|
||||
async function fillNewArticleValid(page: Page) {
|
||||
await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill(newArticleTitle)
|
||||
await page
|
||||
.getByPlaceholder("Start writing. You'll continue in the editor.")
|
||||
.fill(newArticleBody)
|
||||
}
|
||||
|
||||
function submitNewArticle() {
|
||||
return async (page: Page) => {
|
||||
await openNewArticleModal(page)
|
||||
await fillNewArticleValid(page)
|
||||
await page.getByRole('button', { name: 'Create draft', exact: true }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
home: {
|
||||
Default: {
|
||||
@@ -482,6 +514,44 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
'new-article': {
|
||||
Default: {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
api: newArticleHomeApi(),
|
||||
steps: openNewArticleModal,
|
||||
},
|
||||
Validation: {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
api: newArticleHomeApi(),
|
||||
steps: async (page) => {
|
||||
await openNewArticleModal(page)
|
||||
await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill('Ti')
|
||||
await page.getByPlaceholder(`e.g. ${newArticleTitle}`).blur()
|
||||
await page.getByPlaceholder("Start writing. You'll continue in the editor.").click()
|
||||
await page.getByPlaceholder("Start writing. You'll continue in the editor.").blur()
|
||||
},
|
||||
},
|
||||
Submitting: {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
api: newArticleHomeApi({ 'POST /v1/article/': { status: 201, hang: true } }),
|
||||
steps: submitNewArticle(),
|
||||
},
|
||||
'Title rejected (400)': {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
api: newArticleHomeApi({ 'POST /v1/article/': { status: 400 } }),
|
||||
steps: submitNewArticle(),
|
||||
},
|
||||
Error: {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
api: newArticleHomeApi({ 'POST /v1/article/': { status: 500 } }),
|
||||
steps: submitNewArticle(),
|
||||
},
|
||||
},
|
||||
verify: {
|
||||
Verifying: {
|
||||
path: '/register/validate?c=abc123',
|
||||
|
||||
Reference in new issue
Block a user