feat: article creation modal

This commit is contained in:
fsylvain committed 2026-09-27 06:26:41 +02:00
1 parent a4734b2c91
commit c47d33eaa9
8 files changed
+1019 -105

No files matched your search

+146
View File
@@ -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
+97
View File
@@ -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.
+11 -8
View File
@@ -21,7 +21,10 @@ function formatThousands(count: number) {
} }
const overBy = computed(() => props.value - props.max) 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) { if (overBy.value > 0) {
return 'over' return 'over'
} }
@@ -63,6 +66,8 @@ watch(status, (next, previous) => {
announcement.value = '' announcement.value = ''
} }
}) })
const isErrorStatus = computed(() => status.value === 'over' || status.value === 'under')
</script> </script>
<template> <template>
@@ -74,7 +79,7 @@ watch(status, (next, previous) => {
class="flex-1 text-xs" class="flex-1 text-xs"
:class="{ :class="{
'text-warning': status === 'warn', 'text-warning': status === 'warn',
'text-error': status === 'over', 'text-error': isErrorStatus,
'text-muted': status === 'normal', 'text-muted': status === 'normal',
}" }"
>{{ message }}</span >{{ message }}</span
@@ -83,7 +88,7 @@ watch(status, (next, previous) => {
class="flex items-center gap-1.5 font-mono text-xs" class="flex items-center gap-1.5 font-mono text-xs"
:class="{ :class="{
'text-warning': status === 'warn', 'text-warning': status === 'warn',
'text-error': status === 'over', 'text-error': isErrorStatus,
'text-muted': status === 'normal', 'text-muted': status === 'normal',
}" }"
> >
@@ -105,10 +110,10 @@ watch(status, (next, previous) => {
</div> </div>
<div v-else class="flex flex-col gap-1"> <div v-else class="flex flex-col gap-1">
<div <div
class="flex items-center gap-1.5 font-mono text-sm" class="flex items-center gap-1.5 font-mono text-xs"
:class="{ :class="{
'text-warning': status === 'warn', 'text-warning': status === 'warn',
'text-error': status === 'over', 'text-error': isErrorStatus,
'text-muted': status === 'normal', 'text-muted': status === 'normal',
}" }"
> >
@@ -124,9 +129,7 @@ watch(status, (next, previous) => {
class="size-4" class="size-4"
data-testid="character-counter-over-icon" data-testid="character-counter-over-icon"
/> />
<span <span>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span>
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
>
</div> </div>
<p v-if="status === 'over'" class="text-sm text-error"> <p v-if="status === 'over'" class="text-sm text-error">
{{ overText }} {{ overText }}
+229 -11
View File
@@ -1,42 +1,260 @@
<script setup lang="ts"> <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 open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ created: [article: Article] }>()
const api = useApi()
const title = ref('') const title = ref('')
const body = 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() { function onCancel() {
if (submitting.value) {
return
}
resetForm()
open.value = false open.value = false
} }
</script> </script>
<template> <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> <template #body>
<UForm :state="{ title, body }" class="space-y-4"> <div class="flex flex-col gap-4">
<UFormField label="Title" name="title"> <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 <UInput
v-model="title" 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" class="w-full"
@focus="titleFocused = true"
@blur="onTitleBlur"
/> />
</UFormField> </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 <UTextarea
v-model="body" 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" class="w-full"
@blur="onBodyBlur"
/> />
</UFormField> </UFormField>
</UForm> </UForm>
</div>
</template> </template>
<template #footer> <template #footer>
<div class="flex w-full justify-end gap-2"> <div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<UButton color="neutral" variant="outline" @click="onCancel" <UButton
>Cancel</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> </div>
</template> </template>
</UModal> </UModal>
@@ -0,0 +1,361 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, ref } from 'vue'
import { mount, flushPromises } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles'
import NewArticleModal from '../NewArticleModal.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
const TITLE = 'Choosing tiles for a small bathroom'
const BODY = 'Small rooms show every grout line, so we start with the floor.'
function articleFixture(): Article {
return {
id: 42,
title: TITLE,
body: BODY,
images: [],
is_online: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
}
}
const Host = defineComponent({
components: { NewArticleModal },
emits: ['created'],
setup(_props, { emit }) {
const open = ref(true)
return { open, onCreated: (article: Article) => emit('created', article) }
},
template: `<NewArticleModal v-model:open="open" @created="onCreated" />`,
})
function findButton(text: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === text,
)
}
async function clickButton(text: string) {
findButton(text)?.click()
await flushPromises()
}
let requestMock: ReturnType<typeof vi.fn>
function mountModal() {
requestMock = vi.fn()
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: requestMock,
})
return mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
}
function titleInput() {
return document.body.querySelector<HTMLInputElement>(
'input[placeholder="e.g. Choosing tiles for a small bathroom"]',
)!
}
function bodyTextarea() {
return document.body.querySelector<HTMLTextAreaElement>(
'textarea[placeholder="Start writing. You\'ll continue in the editor."]',
)!
}
function dispatch(element: HTMLElement, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true }))
}
async function fillTitle(value: string) {
const input = titleInput()
input.value = value
dispatch(input, 'input')
await flushPromises()
}
async function blurTitle() {
dispatch(titleInput(), 'blur')
await flushPromises()
}
async function fillBody(value: string) {
const textarea = bodyTextarea()
textarea.value = value
dispatch(textarea, 'input')
await flushPromises()
}
async function blurBody() {
dispatch(bodyTextarea(), 'blur')
await flushPromises()
}
async function fillValidForm() {
await fillTitle(TITLE)
await fillBody(BODY)
}
afterEach(() => {
document.body.innerHTML = ''
})
describe('Opening the modal shows the Default state', () => {
it('renders empty, focused fields with the Default copy', async () => {
mountModal()
await flushPromises()
expect(document.body.textContent).toContain('New article')
expect(document.body.textContent).toContain(
"It's saved as a draft. Titles can't be changed later, so check it now.",
)
expect(titleInput().value).toBe('')
expect(document.activeElement).toBe(titleInput())
expect(document.body.textContent).toContain('0 / 200')
expect(document.body.textContent).toContain('3 to 200 characters.')
expect(bodyTextarea().value).toBe('')
expect(document.body.textContent).toContain(
'You can format it and add images in the editor.',
)
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Create draft')).toBeTruthy()
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — a title under 3 characters on blur', () => {
it('shows the error in place of help and the counter in its error style', async () => {
mountModal()
await flushPromises()
await fillTitle('Ti')
await blurTitle()
expect(document.body.textContent).toContain('Use at least 3 characters.')
expect(document.body.textContent).not.toContain('3 to 200 characters.')
expect(document.body.textContent).toContain('2 / 200')
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — an empty first paragraph on blur', () => {
it('shows the error once the field is left empty and blurred', async () => {
mountModal()
await flushPromises()
await fillTitle(TITLE)
await blurTitle()
await blurBody()
expect(document.body.textContent).toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — submitting with both fields invalid shows both errors and sends nothing', () => {
it('shows both errors and never sends the request', async () => {
mountModal()
await flushPromises()
await fillTitle('Ti')
await clickButton('Create draft')
expect(document.body.textContent).toContain('Use at least 3 characters.')
expect(document.body.textContent).toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Submitting disables the form and cannot be dismissed', () => {
it('sends the request, disables the form and blocks Esc/backdrop dismissal', async () => {
let resolveRequest: ((article: Article) => void) | null = null
requestMock = vi.fn(
() =>
new Promise<Article>((resolve) => {
resolveRequest = resolve
}),
)
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
expect(requestMock).toHaveBeenCalledWith(
'/v1/article/',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: TITLE, body: BODY }),
}),
)
expect(titleInput().disabled).toBe(true)
expect(bodyTextarea().disabled).toBe(true)
expect((findButton('Cancel') as HTMLButtonElement).disabled).toBe(true)
expect(findButton('Creating…')).toBeTruthy()
document.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(document.querySelector('[role="dialog"]')).toBeTruthy()
resolveRequest?.(articleFixture())
await flushPromises()
wrapper.unmount()
})
})
describe('A successful create closes the modal and hands off to the editor with a toast', () => {
it('closes the modal and emits created with the response body', async () => {
requestMock = vi.fn().mockResolvedValue(articleFixture())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
})
})
describe("Title rejected — a 400 that got past this form's own check", () => {
it('keeps the values, shows the title error and the form alert', async () => {
requestMock = vi.fn().mockRejectedValue(new ApiError(400))
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
expect(titleInput().value).toBe(TITLE)
expect(bodyTextarea().value).toBe(BODY)
expect(document.body.textContent).toContain('Check this title and try again.')
expect(document.body.textContent).toContain(
"Something in the form isn't valid. Check the highlighted fields.",
)
expect(document.body.textContent).not.toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(titleInput().disabled).toBe(false)
wrapper.unmount()
})
})
describe('Error — a 500 or a network failure keeps the text and offers to retry', () => {
it.each([
{ name: 'a 500 response', error: () => new ApiError(500) },
{ name: 'a network failure', error: () => new NetworkError() },
])('keeps the values and shows the generic alert for $name', async ({ error }) => {
requestMock = vi.fn().mockRejectedValue(error())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
expect(titleInput().value).toBe(TITLE)
expect(bodyTextarea().value).toBe(BODY)
expect(document.body.textContent).toContain(
"Couldn't create the article. Your text is still here. Try again in a moment.",
)
expect(document.body.textContent).not.toContain('Check this title and try again.')
expect(document.body.textContent).not.toContain(
'Add a first paragraph. It can be a single sentence.',
)
wrapper.unmount()
})
})
describe('Retrying after an Error succeeds', () => {
it('sends the same body again and hands off to the editor on success', async () => {
requestMock = vi.fn().mockRejectedValueOnce(new ApiError(500))
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
const wrapper = mount(Host, {
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await flushPromises()
await fillValidForm()
await clickButton('Create draft')
await flushPromises()
requestMock.mockResolvedValueOnce(articleFixture())
await clickButton('Create draft')
await flushPromises()
expect(requestMock).toHaveBeenCalledTimes(2)
expect(requestMock).toHaveBeenNthCalledWith(
2,
'/v1/article/',
expect.objectContaining({
body: JSON.stringify({ title: TITLE, body: BODY }),
}),
)
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
wrapper.unmount()
})
})
describe('Cancel discards the fields with no confirmation', () => {
it('closes the modal immediately without sending a request', async () => {
mountModal()
await flushPromises()
await fillValidForm()
await clickButton('Cancel')
expect(document.querySelector('[role="dialog"]')).toBeFalsy()
expect(requestMock).not.toHaveBeenCalled()
})
})
+36 -61
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue' import DashboardShell from '@/components/DashboardShell.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue' import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue' import NewArticleModal from '@/components/NewArticleModal.vue'
@@ -12,6 +14,8 @@ const RECENT_LIST_SIZE = 5
type LoadState = 'loading' | 'default' | 'empty' | 'error' type LoadState = 'loading' | 'default' | 'empty' | 'error'
const router = useRouter()
const toast = useToast()
const session = useSessionStore() const session = useSessionStore()
const { listArticles } = useArticles() const { listArticles } = useArticles()
@@ -60,11 +64,7 @@ const statTiles = computed(() => [
const recentArticles = computed(() => const recentArticles = computed(() =>
[...articles.value] [...articles.value]
.sort( .sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
(a, b) =>
new Date(b.updated_at).getTime() -
new Date(a.updated_at).getTime(),
)
.slice(0, RECENT_LIST_SIZE), .slice(0, RECENT_LIST_SIZE),
) )
@@ -99,6 +99,17 @@ function openNewArticleModal() {
newArticleModalOpen.value = true 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) onMounted(loadArticles)
</script> </script>
@@ -119,10 +130,7 @@ onMounted(loadArticles)
<div class="flex max-w-244 flex-col gap-5 lg:gap-7"> <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 flex-wrap items-end gap-4">
<div class="flex min-w-50 flex-1 flex-col gap-1"> <div class="flex min-w-50 flex-1 flex-col gap-1">
<h1 <h1 data-testid="greeting" class="text-2xl font-semibold text-highlighted">
data-testid="greeting"
class="text-2xl font-semibold text-highlighted"
>
{{ greeting }} {{ greeting }}
</h1> </h1>
<p class="text-base/5 text-muted">{{ subtitle }}</p> <p class="text-base/5 text-muted">{{ subtitle }}</p>
@@ -136,12 +144,7 @@ onMounted(loadArticles)
</UButton> </UButton>
</div> </div>
<template <template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
v-if="
(loadState === 'loading' && showSkeleton) ||
loadState === 'default'
"
>
<div class="grid grid-cols-3 gap-3"> <div class="grid grid-cols-3 gap-3">
<div <div
v-for="tile in statTiles" v-for="tile in statTiles"
@@ -150,18 +153,13 @@ onMounted(loadArticles)
class="flex flex-col gap-1 border border-default bg-default p-4" class="flex flex-col gap-1 border border-default bg-default p-4"
> >
<template v-if="loadState === 'loading'"> <template v-if="loadState === 'loading'">
<USkeleton <USkeleton class="h-7 w-10 rounded-none bg-accented" />
class="h-7 w-10 rounded-none bg-accented" <USkeleton class="h-3 w-17.5 rounded-none bg-accented" />
/>
<USkeleton
class="h-3 w-17.5 rounded-none bg-accented"
/>
</template> </template>
<template v-else> <template v-else>
<span <span class="text-2xl font-semibold text-highlighted">{{
class="text-2xl font-semibold text-highlighted" tile.value
>{{ tile.value }}</span }}</span>
>
<span class="text-muted">{{ tile.label }}</span> <span class="text-muted">{{ tile.label }}</span>
</template> </template>
</div> </div>
@@ -169,13 +167,8 @@ onMounted(loadArticles)
<section class="flex flex-col gap-2.5"> <section class="flex flex-col gap-2.5">
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<h2 class="text-lg font-semibold text-highlighted"> <h2 class="text-lg font-semibold text-highlighted">Recently updated</h2>
Recently updated <RouterLink to="/article" class="text-toned underline underline-offset-3">
</h2>
<RouterLink
to="/article"
class="text-toned underline underline-offset-3"
>
All articles All articles
</RouterLink> </RouterLink>
</div> </div>
@@ -189,35 +182,21 @@ onMounted(loadArticles)
:key="n" :key="n"
class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11" class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
> >
<USkeleton <USkeleton class="h-3 max-w-[60%] flex-1 rounded-none bg-accented" />
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-5 w-14 rounded-none bg-accented"
/>
</div> </div>
</div> </div>
<ul <ul v-else data-testid="recent-list" class="border border-default bg-default">
v-else
data-testid="recent-list"
class="border border-default bg-default"
>
<li v-for="article in recentArticles" :key="article.id"> <li v-for="article in recentArticles" :key="article.id">
<RouterLink <RouterLink
:to="`/article/${article.id}/edit`" :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" class="flex min-h-13 items-center gap-3 border-t border-muted px-3 py-2 lg:min-h-11"
> >
<span <span class="min-w-0 flex-1 truncate font-medium text-highlighted">
class="min-w-0 flex-1 truncate font-medium text-highlighted"
>
{{ article.title }} {{ article.title }}
</span> </span>
<ArticleStatusBadge <ArticleStatusBadge :online="article.is_online" />
:online="article.is_online" <span class="hidden w-27.5 text-right text-muted lg:inline">
/>
<span
class="hidden w-27.5 text-right text-muted lg:inline"
>
{{ formatRelativeDate(article.updated_at) }} {{ formatRelativeDate(article.updated_at) }}
</span> </span>
</RouterLink> </RouterLink>
@@ -278,21 +257,17 @@ onMounted(loadArticles)
</template> </template>
</UAlert> </UAlert>
<div <div class="flex items-start gap-3.5 border border-default bg-default p-4">
class="flex items-start gap-3.5 border border-default bg-default p-4"
>
<div <div
class="flex size-9 shrink-0 items-center justify-center bg-secondary-200 text-primary-900 dark:bg-secondary-300" 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" /> <UIcon name="i-lucide-plug" class="size-4.5" />
</div> </div>
<div class="flex flex-1 flex-col gap-1"> <div class="flex flex-1 flex-col gap-1">
<h2 class="text-base/5 font-semibold text-highlighted"> <h2 class="text-base/5 font-semibold text-highlighted">Connect your website</h2>
Connect your website
</h2>
<p class="text-pretty text-muted"> <p class="text-pretty text-muted">
Your site reads published articles from RenewCMS. Share Your site reads published articles from RenewCMS. Share this guide with
this guide with whoever builds it. whoever builds it.
</p> </p>
<RouterLink <RouterLink
to="/integration" to="/integration"
@@ -304,6 +279,6 @@ onMounted(loadArticles)
</div> </div>
</div> </div>
<NewArticleModal v-model:open="newArticleModalOpen" /> <NewArticleModal v-model:open="newArticleModalOpen" @created="onArticleCreated" />
</DashboardShell> </DashboardShell>
</template> </template>
+69 -25
View File
@@ -7,11 +7,17 @@ import { useSessionStore } from '@/stores/session'
import type { Article } from '@/composables/useArticles' import type { Article } from '@/composables/useArticles'
import HomeView from '../HomeView.vue' import HomeView from '../HomeView.vue'
function mockResponse( let toastAdd: ReturnType<typeof vi.fn>
status: number,
body: unknown = null, vi.mock('@nuxt/ui/composables', async (importOriginal) => {
headers: Record<string, string> = {}, 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 { return {
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
status, status,
@@ -25,9 +31,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}` return `${method} ${url}`
} }
function fetchRouter( function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
handlers: Record<string, () => Response | Promise<Response>>,
) {
return vi.fn((url: string, init?: RequestInit) => { return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init) const key = routeFor(url, init)
const handler = handlers[key] const handler = handlers[key]
@@ -97,10 +101,9 @@ const sixArticles: Article[] = [
let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
async function mountHome( async function mountHome(handlers: Record<string, () => Response | Promise<Response>>) {
handlers: Record<string, () => Response | Promise<Response>>,
) {
setActivePinia(createPinia()) setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore() const session = useSessionStore()
session.session = { verified: true, username: 'marta' } session.session = { verified: true, username: 'marta' }
session.status = 'ready' 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 () => { it('never shows a skeleton when the response resolves in under 150 ms', async () => {
vi.useFakeTimers() vi.useFakeTimers()
const wrapper = await mountHome({ const wrapper = await mountHome({
'GET /v1/article/': () => 'GET /v1/article/': () => Promise.resolve(mockResponse(200, sixArticles)),
Promise.resolve(mockResponse(200, sixArticles)),
}) })
await vi.advanceTimersByTimeAsync(0) 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), 'GET /v1/article/': () => mockResponse(200, sixArticles),
}) })
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain("Here's where things stand on your site.")
"Here's where things stand on your site.",
)
const rows = wrapper.find('[data-testid="recent-list"]').findAll('li') const rows = wrapper.find('[data-testid="recent-list"]').findAll('li')
expect(rows).toHaveLength(5) 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('2')
expect(wrapper.text()).toContain('drafts') expect(wrapper.text()).toContain('drafts')
const guideLink = wrapper const guideLink = wrapper.findAll('a').find((a) => a.text() === 'Open the guide')
.findAll('a')
.find((a) => a.text() === 'Open the guide')
expect(guideLink?.attributes('href')).toBe('/integration') expect(guideLink?.attributes('href')).toBe('/integration')
await findButton(wrapper, 'New article')?.trigger('click') 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', () => { describe('Empty, first run — zero articles', () => {
it('shows the empty state and opens NewArticleModal', async () => { it('shows the empty state and opens NewArticleModal', async () => {
const wrapper = await mountHome({ 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, 'GET /v1/article/': handler,
}) })
expect(wrapper.text()).toContain( expect(wrapper.text()).toContain("Here's where things stand on your site.")
"Here's where things stand on your site.",
)
expect(wrapper.text()).toContain('Good') expect(wrapper.text()).toContain('Good')
const alert = wrapper.find('[role="alert"]') const alert = wrapper.find('[role="alert"]')
expect(alert.exists()).toBe(true) expect(alert.exists()).toBe(true)
@@ -282,9 +328,7 @@ describe('The list is fetched exactly once per navigation', () => {
expect(listCall).toHaveBeenCalledTimes(1) expect(listCall).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('6') expect(wrapper.text()).toContain('6')
expect( expect(wrapper.find('[data-testid="recent-list"]').findAll('li')).toHaveLength(5)
wrapper.find('[data-testid="recent-list"]').findAll('li'),
).toHaveLength(5)
}) })
}) })
+70
View File
@@ -203,6 +203,38 @@ async function typeExactBodyLength(page: Page, totalLength: number) {
await page.keyboard.insertText(inner) 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>> = { export const visualStates: Record<string, Record<string, VisualState>> = {
home: { home: {
Default: { 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: { verify: {
Verifying: { Verifying: {
path: '/register/validate?c=abc123', path: '/register/validate?c=abc123',