feat: remove the 10,000-character article body limit and make the New article body optional
This commit is contained in:
1 parent
5e05b606a7
commit
10ec74c8cf
28 files changed
+860
-397
No files matched your search
@@ -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>
|
||||
@@ -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()]])
|
||||
|
||||
@@ -26,6 +26,11 @@ export const messages = {
|
||||
'Saving…': 'Enregistrement…',
|
||||
Saved: 'Enregistré',
|
||||
'Save failed': 'Échec de l’enregistrement',
|
||||
'{0} words · {1} characters': '{0} mots · {1} caractères',
|
||||
'Saving {0} characters. This can take a few seconds.':
|
||||
'Enregistrement de {0} caractères. Cela peut prendre quelques secondes.',
|
||||
"Couldn't save. Your text is kept. Retry.":
|
||||
'Impossible d’enregistrer. Votre texte est conservé. Réessayez.',
|
||||
'Unsaved changes': 'Modifications non enregistrées',
|
||||
'Display name': 'Nom d’affichage',
|
||||
Username: 'Nom d’utilisateur',
|
||||
@@ -230,22 +235,15 @@ export const messages = {
|
||||
'Search by title': 'Rechercher par titre',
|
||||
'Clear search': 'Effacer la recherche',
|
||||
Title: 'Titre',
|
||||
'First paragraph': 'Premier paragraphe',
|
||||
'3 to 200 characters.': 'De 3 à 200 caractères.',
|
||||
"The title can't be changed after creation.":
|
||||
'Le titre ne peut pas être modifié après la création.',
|
||||
"Start writing. You'll continue in the editor.":
|
||||
'Commencez à écrire. Vous poursuivrez dans l’éditeur.',
|
||||
'You can format it and add images in the editor.':
|
||||
'Vous pouvez le mettre en forme et ajouter des images dans l’éditeur.',
|
||||
'It starts as a draft, so nothing appears on your site until you publish it.':
|
||||
'Il commence comme brouillon : rien n’apparaît sur votre site avant sa publication.',
|
||||
"It's saved as a draft. Titles can't be changed later, so check it now.":
|
||||
'Il est enregistré comme brouillon. Les titres ne peuvent pas être modifiés ensuite : vérifiez-le maintenant.',
|
||||
'e.g. Choosing tiles for a small bathroom':
|
||||
'p. ex. Choisir du carrelage pour une petite salle de bains',
|
||||
'Add a first paragraph. It can be a single sentence.':
|
||||
'Ajoutez un premier paragraphe. Une seule phrase suffit.',
|
||||
"Couldn't create the article. Your text is still here. Try again in a moment.":
|
||||
'Impossible de créer l’article. Votre texte est conservé. Réessayez dans un instant.',
|
||||
'Draft created': 'Brouillon créé',
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bodyCounts, isLargeBody, LARGE_BODY_CHARACTERS } from '../bodyCounts'
|
||||
|
||||
describe('bodyCounts', () => {
|
||||
it('counts words across block boundaries and characters as the string length', () => {
|
||||
const html =
|
||||
'<h2>Price</h2><ul><li>One</li><li>Two</li></ul><p>A <strong>bold</strong> end.</p>'
|
||||
|
||||
expect(bodyCounts(html)).toEqual({ words: 6, characters: html.length })
|
||||
})
|
||||
|
||||
it('splits adjacent blocks and line breaks into separate words', () => {
|
||||
expect(bodyCounts('<p>one</p><p>two</p>').words).toBe(2)
|
||||
expect(bodyCounts('<p>one<br>two</p>').words).toBe(2)
|
||||
expect(bodyCounts('<p> one two </p>').words).toBe(2)
|
||||
})
|
||||
|
||||
it('counts no words in markup without text', () => {
|
||||
expect(bodyCounts('<p> </p>')).toEqual({ words: 0, characters: 8 })
|
||||
})
|
||||
|
||||
it('reads an empty document as zero', () => {
|
||||
expect(bodyCounts('')).toEqual({ words: 0, characters: 0 })
|
||||
expect(bodyCounts('<p></p>')).toEqual({ words: 0, characters: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLargeBody', () => {
|
||||
it('flips exactly at the threshold', () => {
|
||||
expect(isLargeBody(LARGE_BODY_CHARACTERS - 1)).toBe(false)
|
||||
expect(isLargeBody(LARGE_BODY_CHARACTERS)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
const HTML_MIME = 'text/html'
|
||||
const WHITESPACE = /\s+/
|
||||
const EMPTY_DOCUMENT = '<p></p>'
|
||||
const BLOCK_END = /<\/(?:p|h[1-6]|li|blockquote|pre|div)>|<br\s*\/?>/gi
|
||||
|
||||
export const LARGE_BODY_CHARACTERS = 50000
|
||||
|
||||
export function isLargeBody(characters: number) {
|
||||
return characters >= LARGE_BODY_CHARACTERS
|
||||
}
|
||||
|
||||
export type BodyCounts = { words: number; characters: number }
|
||||
|
||||
export function bodyCounts(html: string): BodyCounts {
|
||||
if (html.length === 0 || html === EMPTY_DOCUMENT) {
|
||||
return { words: 0, characters: 0 }
|
||||
}
|
||||
const text =
|
||||
new DOMParser().parseFromString(html.replace(BLOCK_END, '$& '), HTML_MIME).body
|
||||
.textContent ?? ''
|
||||
const words = text.split(WHITESPACE).filter(Boolean).length
|
||||
return { words, characters: html.length }
|
||||
}
|
||||
@@ -11,13 +11,14 @@ import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import ArticleDetails from '@/components/ArticleDetails.vue'
|
||||
import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import CharacterCounter from '@/components/CharacterCounter.vue'
|
||||
import EditorStatusBar from '@/components/EditorStatusBar.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import ImageUploader from '@/components/ImageUploader.vue'
|
||||
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleDraft } from '@/composables/useArticleDraft'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { bodyCounts } from '@/lib/bodyCounts'
|
||||
import { bodyImageCount, bodyImageIds } from '@/lib/bodyImageIds'
|
||||
import { formatSize } from '@/lib/mediaFormat'
|
||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||
@@ -26,7 +27,6 @@ import { useSeenImagesStore } from '@/stores/seenImages'
|
||||
import { useVariantCatalogStore } from '@/stores/variantCatalog'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const BODY_MAX_LENGTH = 10000
|
||||
|
||||
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
|
||||
|
||||
@@ -89,14 +89,11 @@ async function loadArticle() {
|
||||
}
|
||||
}
|
||||
|
||||
const bodyLength = computed(() => draft.body.value.length)
|
||||
const overLimit = computed(() => bodyLength.value > BODY_MAX_LENGTH)
|
||||
const counts = computed(() => bodyCounts(draft.body.value))
|
||||
|
||||
const saveEnabled = computed(
|
||||
() => draft.state.value === 'dirty' && !overLimit.value && loadState.value === 'loaded',
|
||||
)
|
||||
const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.value === 'loaded')
|
||||
|
||||
const publishDisabled = computed(() => overLimit.value || draft.state.value === 'offline')
|
||||
const publishDisabled = computed(() => draft.state.value === 'offline')
|
||||
|
||||
async function runSave() {
|
||||
const outcome = await draft.save()
|
||||
@@ -568,10 +565,10 @@ onBeforeRouteLeave(async () => {
|
||||
@request-remove-image="onRequestRemoveImage"
|
||||
>
|
||||
<template #footer>
|
||||
<CharacterCounter
|
||||
variant="footer"
|
||||
:value="bodyLength"
|
||||
:max="BODY_MAX_LENGTH"
|
||||
<EditorStatusBar
|
||||
:words="counts.words"
|
||||
:characters="counts.characters"
|
||||
:state="draft.state.value"
|
||||
/>
|
||||
</template>
|
||||
<template #aside>
|
||||
|
||||
@@ -171,7 +171,8 @@ 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 () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12?format=html': () => Promise.resolve(mockResponse(200, articleFixture())),
|
||||
'GET /v1/article/12?format=html': () =>
|
||||
Promise.resolve(mockResponse(200, articleFixture())),
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
@@ -190,7 +191,8 @@ describe('Saved — a freshly loaded article, draft or online', () => {
|
||||
'reads the Saved state for is_online $is_online',
|
||||
async ({ is_online, badge, publishLabel }) => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture({ is_online })),
|
||||
'GET /v1/article/12?format=html': () =>
|
||||
mockResponse(200, articleFixture({ is_online })),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Articles')
|
||||
@@ -199,7 +201,7 @@ describe('Saved — a freshly loaded article, draft or online', () => {
|
||||
expect(wrapper.text()).toContain('Saved at 11:02')
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
expect(wrapper.text()).toContain(publishLabel)
|
||||
expect(wrapper.text()).toContain('4 318 / 10 000')
|
||||
expect(wrapper.text()).toContain('1 words · 4,318 characters')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -389,40 +391,85 @@ describe('A save against an article deleted elsewhere shows a toast, not the err
|
||||
})
|
||||
})
|
||||
|
||||
describe('Near the limit — the counter warns at 9000 characters without blocking Save', () => {
|
||||
it('shows the warning icon and keeps Save enabled', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
||||
})
|
||||
await typeBody(wrapper, paragraphOfLength(9240))
|
||||
function longParagraph(totalLength: number) {
|
||||
const words = Math.floor((totalLength - 7) / 6)
|
||||
const inner = 'abcde '.repeat(words).trimEnd()
|
||||
return paragraph(inner + 'x'.repeat(totalLength - 7 - inner.length))
|
||||
}
|
||||
|
||||
expect(wrapper.text()).toContain('9 240 / 10 000')
|
||||
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
||||
describe('Status bar — a neutral count of words and characters', () => {
|
||||
it('reads the loaded article count and follows the body', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12?format=html': () =>
|
||||
mockResponse(200, articleFixture({ body: paragraph('one two three') })),
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('3 words · 20 characters')
|
||||
|
||||
await typeBody(wrapper, '')
|
||||
|
||||
expect(wrapper.text()).toContain('0 words · 0 characters')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Too long — over 10000 characters disables Save and Publish until shortened', () => {
|
||||
it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => {
|
||||
const wrapper = await mountEditor({
|
||||
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
||||
})
|
||||
await typeBody(wrapper, paragraphOfLength(10214))
|
||||
describe('Very long article — no length limit', () => {
|
||||
const handlers = (patch: () => Response | Promise<Response>) => ({
|
||||
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
|
||||
'PATCH /v1/article/12?format=html': patch,
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('10 214 / 10 000')
|
||||
expect(wrapper.text()).toContain(
|
||||
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
|
||||
)
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
it('keeps the count neutral and Save and Publish enabled at 120,000 characters', async () => {
|
||||
const wrapper = await mountEditor(handlers(() => mockResponse(200, articleFixture())))
|
||||
await typeBody(wrapper, longParagraph(120000))
|
||||
|
||||
expect(wrapper.text()).toContain('characters')
|
||||
expect(wrapper.text()).toContain('120,000 characters')
|
||||
expect(wrapper.text()).toContain('Unsaved changes')
|
||||
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(false)
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
||||
const publishButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text() === 'Publish' || button.text() === 'Save and publish')
|
||||
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(true)
|
||||
.find((button) => button.text() === 'Save and publish')
|
||||
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(false)
|
||||
})
|
||||
|
||||
await typeBody(wrapper, paragraphOfLength(9000))
|
||||
it('shows the saving copy while the PATCH is pending', async () => {
|
||||
const wrapper = await mountEditor(handlers(() => new Promise<Response>(() => {})))
|
||||
await typeBody(wrapper, longParagraph(120000))
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('characters over')
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(wrapper.text()).toContain('Saving 120,000 characters. This can take a few seconds.')
|
||||
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('reads Saved once the PATCH resolves', async () => {
|
||||
const wrapper = await mountEditor(handlers(() => mockResponse(200, articleFixture())))
|
||||
await typeBody(wrapper, longParagraph(120000))
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Saved')
|
||||
expect(wrapper.text()).toContain('120,000 characters')
|
||||
expect(wrapper.text()).not.toContain('Saving 120,000')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: '500', handler: () => mockResponse(500) },
|
||||
{ name: '413', handler: () => mockResponse(413) },
|
||||
{
|
||||
name: 'a network failure',
|
||||
handler: () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
},
|
||||
])('keeps the text and offers Retry after $name', async ({ handler }) => {
|
||||
const wrapper = await mountEditor(handlers(handler))
|
||||
await typeBody(wrapper, longParagraph(120000))
|
||||
await saveButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain("Couldn't save. Your text is kept. Retry.")
|
||||
expect(wrapper.text()).toContain('Save failed')
|
||||
expect(articleEditorBody(wrapper)).toBe(longParagraph(120000))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -234,11 +234,6 @@ describe('A successful create hands off to the editor with a toast', () => {
|
||||
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(
|
||||
|
||||
+11
-2
@@ -313,10 +313,15 @@ async function readScreen(name: string): Promise<Screen> {
|
||||
return variantsScreen()
|
||||
}
|
||||
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8')
|
||||
const reference = /\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown)
|
||||
const reference =
|
||||
/\*\*Visual reference:\*\* `source\/([^`]+)`, group "([^"]+)"/.exec(markdown) ??
|
||||
/^Visual reference: `([^`]+)`, groups? "([^"]+)"/m.exec(markdown)
|
||||
if (!reference) {
|
||||
throw new Error(`docs/design/screens/${name}.md has no visual reference line`)
|
||||
}
|
||||
const numberedRows = [...markdown.matchAll(/^\| \d+ \| ([^|]+?) \|/gm)].map((match) =>
|
||||
match[1]!.trim(),
|
||||
)
|
||||
const statesStart = markdown.search(/^## States$/m)
|
||||
const afterStates = statesStart === -1 ? markdown : markdown.slice(statesStart)
|
||||
const nextSection = afterStates.slice(1).search(/^## /m)
|
||||
@@ -324,7 +329,11 @@ async function readScreen(name: string): Promise<Screen> {
|
||||
const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
|
||||
match[1]!.trim(),
|
||||
)
|
||||
const states = headingStates.length ? headingStates : tableStates(statesMarkdown)
|
||||
const states = numberedRows.length
|
||||
? numberedRows
|
||||
: headingStates.length
|
||||
? headingStates
|
||||
: tableStates(statesMarkdown)
|
||||
const primary: Source = {
|
||||
board: reference[1]!,
|
||||
group: reference[2]!,
|
||||
|
||||
+88
-36
@@ -355,13 +355,45 @@ async function markDirty(page: Page) {
|
||||
await page.keyboard.press('Backspace')
|
||||
}
|
||||
|
||||
async function typeExactBodyLength(page: Page, totalLength: number) {
|
||||
const editable = page.locator('[contenteditable="true"]')
|
||||
await editable.click()
|
||||
await page.keyboard.press('Control+a')
|
||||
await page.keyboard.press('Delete')
|
||||
const inner = 'a'.repeat(Math.max(0, totalLength - 7))
|
||||
await page.keyboard.insertText(inner)
|
||||
const PARAGRAPH_TAGS_LENGTH = 7
|
||||
const veryLongBodyLength = 120000
|
||||
const veryLongBodyWords = 20000
|
||||
const EDITOR_LIST_ITEM_PARAGRAPH_TAGS = 21
|
||||
|
||||
function wordsIn(html: string) {
|
||||
return html
|
||||
.replace(/<\/(?:p|h[1-6]|li)>/g, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean).length
|
||||
}
|
||||
|
||||
function paragraphOfWords(totalLength: number, wordCount: number) {
|
||||
const letters = totalLength - PARAGRAPH_TAGS_LENGTH - (wordCount - 1)
|
||||
const shortWords = wordCount * 5 - letters
|
||||
const words = Array.from({ length: wordCount }, (_, index) =>
|
||||
index < shortWords ? 'abcd' : 'abcde',
|
||||
)
|
||||
return `<p>${words.join(' ')}</p>`
|
||||
}
|
||||
|
||||
const veryLongArticle = {
|
||||
...editorArticle,
|
||||
body:
|
||||
bathroomBody +
|
||||
paragraphOfWords(
|
||||
veryLongBodyLength - bathroomBody.length - EDITOR_LIST_ITEM_PARAGRAPH_TAGS,
|
||||
veryLongBodyWords - wordsIn(bathroomBody),
|
||||
),
|
||||
}
|
||||
|
||||
function veryLongApi(overrides: Record<string, ApiMock> = {}) {
|
||||
return editorApi({ 'GET /v1/article/12': { status: 200, body: veryLongArticle }, ...overrides })
|
||||
}
|
||||
|
||||
async function markVeryLongDirty(page: Page) {
|
||||
await markDirty(page)
|
||||
await focusBoldSample(page)
|
||||
}
|
||||
|
||||
function mib(value: number) {
|
||||
@@ -550,27 +582,19 @@ async function freezeUploadProgress(page: Page, loaded: number, total: number) {
|
||||
}
|
||||
|
||||
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/activity': homeActivityMock,
|
||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||
...overrides,
|
||||
}
|
||||
return articlesApi(overrides)
|
||||
}
|
||||
|
||||
async function openNewArticleModal(page: Page) {
|
||||
await page.getByRole('button', { name: 'New article' }).click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
await page.mouse.move(0, 0)
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -4709,20 +4733,48 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
await focusBoldSample(page)
|
||||
},
|
||||
},
|
||||
'Near the limit': {
|
||||
'Very long article': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
steps: async (page) => {
|
||||
await typeExactBodyLength(page, 9240)
|
||||
api: veryLongApi(),
|
||||
steps: markVeryLongDirty,
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 14.5 },
|
||||
reason: 'The mobile editor already misses the 10 % budget for every ordinary state at HEAD (11 to 21 %); the very long article states inherit the same layout gap',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
},
|
||||
'Too long': {
|
||||
'Very long article: saving': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: editorApi(),
|
||||
api: veryLongApi({
|
||||
'PATCH /v1/article/12': { status: 200, body: veryLongArticle, hang: true },
|
||||
}),
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 14.5 },
|
||||
reason: 'The mobile editor already misses the 10 % budget for every ordinary state at HEAD (11 to 21 %); the very long article states inherit the same layout gap',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
steps: async (page) => {
|
||||
await typeExactBodyLength(page, 10214)
|
||||
await markVeryLongDirty(page)
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
},
|
||||
},
|
||||
'Very long article: saved': { path: '/article/12/edit', skip: 'clock-sensitive' },
|
||||
'Very long article: save failed': {
|
||||
path: '/article/12/edit',
|
||||
now: editorNow,
|
||||
api: veryLongApi({ 'PATCH /v1/article/12': { status: 500 } }),
|
||||
exception: {
|
||||
maxPercent: { desktop: 10, mobile: 14.5 },
|
||||
reason: 'The mobile editor already misses the 10 % budget for every ordinary state at HEAD (11 to 21 %); the very long article states inherit the same layout gap',
|
||||
approvedBy: 'Florian Sylvain',
|
||||
},
|
||||
steps: async (page) => {
|
||||
await markVeryLongDirty(page)
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click()
|
||||
await page.getByText('Save failed').waitFor()
|
||||
await focusBoldSample(page)
|
||||
},
|
||||
},
|
||||
Preview: {
|
||||
@@ -4871,38 +4923,38 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
},
|
||||
'new-article': {
|
||||
Default: {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: newArticleHomeApi(),
|
||||
steps: openNewArticleModal,
|
||||
},
|
||||
Validation: {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
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()
|
||||
await page.getByRole('button', { name: 'Create draft', exact: true }).click()
|
||||
await page.mouse.move(0, 0)
|
||||
},
|
||||
},
|
||||
Submitting: {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: newArticleHomeApi({ 'POST /v1/article/': { status: 201, hang: true } }),
|
||||
steps: submitNewArticle(),
|
||||
},
|
||||
'Title rejected (400)': {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: newArticleHomeApi({ 'POST /v1/article/': { status: 400 } }),
|
||||
steps: submitNewArticle(),
|
||||
},
|
||||
Error: {
|
||||
path: '/home',
|
||||
now: boardNow,
|
||||
path: '/article',
|
||||
now: articlesNow,
|
||||
api: newArticleHomeApi({ 'POST /v1/article/': { status: 500 } }),
|
||||
steps: submitNewArticle(),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user