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