From c47d33eaa92e2ea30fad7490a7e040707857840f Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sun, 27 Sep 2026 06:26:41 +0200 Subject: [PATCH] feat: article creation modal --- features/new_article_modal.feature | 146 +++++++ features/new_article_modal.qa.md | 97 +++++ web/src/components/CharacterCounter.vue | 19 +- web/src/components/NewArticleModal.vue | 262 +++++++++++-- .../__tests__/NewArticleModal.spec.ts | 361 ++++++++++++++++++ web/src/views/HomeView.vue | 97 ++--- web/src/views/__tests__/HomeView.spec.ts | 94 +++-- web/visual/states.ts | 70 ++++ 8 files changed, 1030 insertions(+), 116 deletions(-) create mode 100644 features/new_article_modal.feature create mode 100644 features/new_article_modal.qa.md create mode 100644 web/src/components/__tests__/NewArticleModal.spec.ts diff --git a/features/new_article_modal.feature b/features/new_article_modal.feature new file mode 100644 index 0000000..b3d49e7 --- /dev/null +++ b/features/new_article_modal.feature @@ -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": "", "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 diff --git a/features/new_article_modal.qa.md b/features/new_article_modal.qa.md new file mode 100644 index 0000000..eb9df42 --- /dev/null +++ b/features/new_article_modal.qa.md @@ -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. diff --git a/web/src/components/CharacterCounter.vue b/web/src/components/CharacterCounter.vue index 94f759f..fefdfcc 100644 --- a/web/src/components/CharacterCounter.vue +++ b/web/src/components/CharacterCounter.vue @@ -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 }} diff --git a/web/src/components/NewArticleModal.vue b/web/src/components/NewArticleModal.vue index d5c3e6b..7cc6fe1 100644 --- a/web/src/components/NewArticleModal.vue +++ b/web/src/components/NewArticleModal.vue @@ -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> diff --git a/web/src/components/__tests__/NewArticleModal.spec.ts b/web/src/components/__tests__/NewArticleModal.spec.ts new file mode 100644 index 0000000..ebe5749 --- /dev/null +++ b/web/src/components/__tests__/NewArticleModal.spec.ts @@ -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() + }) +}) diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index 0c59c42..7b22cc6 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -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> diff --git a/web/src/views/__tests__/HomeView.spec.ts b/web/src/views/__tests__/HomeView.spec.ts index ef35228..eb068a6 100644 --- a/web/src/views/__tests__/HomeView.spec.ts +++ b/web/src/views/__tests__/HomeView.spec.ts @@ -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) }) }) diff --git a/web/visual/states.ts b/web/visual/states.ts index 3bcbbfa..1ffc08a 100644 --- a/web/visual/states.ts +++ b/web/visual/states.ts @@ -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',