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

+75 -28
View File
@@ -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))
})
})
-5
View File
@@ -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(