feat: remove the 10,000-character article body limit and make the New article body optional

This commit is contained in:
Florian Sylvain committed 2026-10-11 08:49:11 +02:00
1 parent 5e05b606a7
commit 10ec74c8cf
28 files changed
+860 -397

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { isLargeBody } from '@/lib/bodyCounts'
const NUMBER_LOCALE = 'en-US'
const props = defineProps<{
words: number
characters: number
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
}>()
function formatCount(count: number) {
return count.toLocaleString(NUMBER_LOCALE)
}
const message = computed(() => {
if (props.state === 'saving' && isLargeBody(props.characters)) {
return t('Saving {0} characters. This can take a few seconds.', [
formatCount(props.characters),
])
}
if (props.state === 'error' && isLargeBody(props.characters)) {
return t("Couldn't save. Your text is kept. Retry.")
}
return ''
})
const countText = computed(() =>
t('{0} words · {1} characters', [formatCount(props.words), formatCount(props.characters)]),
)
</script>
<template>
<div class="flex items-center gap-3 border-t border-default bg-default px-6 py-2">
<span class="flex-1 text-xs text-muted" :class="{ 'text-error': state === 'error' }">{{
message
}}</span>
<span class="font-mono text-xs text-muted">{{ countText }}</span>
</div>
</template>
+16 -59
View File
@@ -2,7 +2,6 @@
import { t, translate } from '@/i18n'
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'
@@ -17,9 +16,7 @@ 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('')
@@ -29,7 +26,6 @@ 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) {
@@ -46,14 +42,12 @@ const titleLengthError = computed(() => {
const titleError = computed(() => titleServerError.value || titleLengthError.value)
const bodyError = computed(() =>
bodyTouched.value && !bodyValid.value
? t('Add a first paragraph. It can be a single sentence.')
: '',
const titleCounterInvalid = computed(
() =>
titleLength.value > TITLE_MAX_LENGTH ||
(titleTouched.value && titleLength.value < TITLE_MIN_LENGTH),
)
const titleCounterMin = computed(() => (titleTouched.value ? TITLE_MIN_LENGTH : 0))
const showHelp = computed(() => !submitting.value && formAlert.value === '')
const alertMessage = computed(() => {
@@ -68,9 +62,7 @@ const alertMessage = computed(() => {
function resetForm() {
title.value = ''
body.value = ''
titleTouched.value = false
bodyTouched.value = false
titleFocused.value = false
submitting.value = false
titleServerError.value = ''
@@ -103,16 +95,11 @@ function onTitleBlur() {
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) {
if (!titleValid.value) {
return
}
@@ -123,7 +110,7 @@ async function onSubmit() {
const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }),
body: JSON.stringify({ title: title.value }),
})
open.value = false
emit('created', article)
@@ -156,9 +143,10 @@ function onCancel() {
: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',
'divide-y-0 shadow-(--shadow-modal) ring-0 sm:top-35 sm:max-w-140 sm:translate-y-0 max-sm:!inset-x-0 max-sm:!top-auto max-sm:!bottom-0 max-sm:!left-0 max-sm:!w-auto max-sm:!max-w-none max-sm:before:absolute max-sm:before:top-2 max-sm:before:left-1/2 max-sm:before:h-1 max-sm:before:w-9 max-sm:before:-translate-x-1/2 max-sm:before:bg-accented max-sm:!translate-x-0 max-sm:!translate-y-0',
header: 'items-start gap-3 px-6 pt-7.5 pb-0 sm:px-6 sm:pt-5',
wrapper: 'pe-10 max-sm:pe-18',
close: 'max-sm:end-8 max-sm:top-9',
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',
@@ -176,19 +164,18 @@ function onCancel() {
:ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage"
/>
<UForm :state="{ title, body }" class="flex flex-col gap-4">
<UForm :state="{ title }" class="flex flex-col gap-4">
<UFormField
:label="t('Title')"
name="title"
:error="translate(titleError) || undefined"
>
<template #hint>
<CharacterCounter
:value="titleLength"
:max="TITLE_MAX_LENGTH"
:min="titleCounterMin"
plain-text
/>
<span
class="font-mono text-xs leading-tight"
:class="titleCounterInvalid ? 'text-error' : 'text-muted'"
>{{ titleLength }} / {{ TITLE_MAX_LENGTH }}</span
>
</template>
<template v-if="titleError" #error="{ error }">
<span class="flex items-center gap-1.5">
@@ -214,36 +201,6 @@ function onCancel() {
@blur="onTitleBlur"
/>
</UFormField>
<UFormField
:label="t('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"
/>
{{ t(error) }}
</span>
</template>
<template v-else-if="showHelp" #help>
{{ t('You can format it and add images in the editor.') }}
</template>
<UTextarea
v-model="body"
:placeholder="t('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>
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import EditorStatusBar from '../EditorStatusBar.vue'
import { LARGE_BODY_CHARACTERS } from '@/lib/bodyCounts'
type State = 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
function mountBar(state: State, characters: number, words = 10) {
return mount(EditorStatusBar, { props: { state, characters, words } })
}
describe('Count', () => {
it('is neutral with thousands separators', () => {
const wrapper = mountBar('clean', 120000, 19845)
expect(wrapper.text()).toBe('19,845 words · 120,000 characters')
})
it('reads zero for an empty body', () => {
expect(mountBar('clean', 0, 0).text()).toBe('0 words · 0 characters')
})
})
describe('Saving note appears from the size threshold', () => {
it('shows no note just below the threshold', () => {
const wrapper = mountBar('saving', LARGE_BODY_CHARACTERS - 1)
expect(wrapper.text()).not.toContain('This can take a few seconds.')
})
it('shows the note at the threshold', () => {
const wrapper = mountBar('saving', LARGE_BODY_CHARACTERS)
expect(wrapper.text()).toContain('Saving 50,000 characters. This can take a few seconds.')
})
it('shows the note at 120,000 characters', () => {
expect(mountBar('saving', 120000).text()).toContain(
'Saving 120,000 characters. This can take a few seconds.',
)
})
})
describe('Failed note appears from the size threshold', () => {
it('shows no note just below the threshold', () => {
const wrapper = mountBar('error', LARGE_BODY_CHARACTERS - 1)
expect(wrapper.text()).not.toContain("Couldn't save.")
})
it('shows the note at the threshold', () => {
const wrapper = mountBar('error', LARGE_BODY_CHARACTERS)
expect(wrapper.text()).toContain("Couldn't save. Your text is kept. Retry.")
})
it('shows no note in other states, however long', () => {
expect(mountBar('dirty', 120000).text()).not.toContain("Couldn't save.")
})
})
@@ -70,12 +70,6 @@ function titleInput() {
)!
}
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 }))
}
@@ -92,21 +86,8 @@ async function blurTitle() {
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(() => {
@@ -128,10 +109,8 @@ describe('Opening the modal shows the Default state', () => {
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(document.body.textContent).not.toContain('First paragraph')
expect(document.querySelector('textarea')).toBeNull()
expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Create draft')).toBeTruthy()
@@ -154,24 +133,8 @@ describe('Validation — a title under 3 characters on blur', () => {
})
})
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 () => {
describe('Validation — submitting with an invalid title shows the error and sends nothing', () => {
it('shows the error and never sends the request', async () => {
mountModal()
await flushPromises()
@@ -179,9 +142,6 @@ describe('Validation — submitting with both fields invalid shows both errors a
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()
})
})
@@ -209,12 +169,11 @@ describe('Submitting disables the form and cannot be dismissed', () => {
'/v1/article/?format=html',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ title: TITLE, body: BODY }),
body: JSON.stringify({ title: TITLE }),
}),
)
expect(titleInput().disabled).toBe(true)
expect(bodyTextarea().disabled).toBe(true)
expect((findButton('Cancel') as HTMLButtonElement).disabled).toBe(true)
expect(findButton('Creating…')).toBeTruthy()
@@ -272,14 +231,10 @@ describe("Title rejected — a 400 that got past this form's own check", () => {
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()
})
@@ -303,14 +258,10 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr
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()
})
})
@@ -338,7 +289,7 @@ describe('Retrying after an Error succeeds', () => {
2,
'/v1/article/?format=html',
expect.objectContaining({
body: JSON.stringify({ title: TITLE, body: BODY }),
body: JSON.stringify({ title: TITLE }),
}),
)
expect(wrapper.emitted('created')).toEqual([[articleFixture()]])