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

+2 -2
View File
@@ -28,7 +28,7 @@ type Handler struct {
type PostArticle struct {
Title string `json:"title" validate:"required,min=3,max=200"`
Body string `json:"body" validate:"required,max=10000"`
Body string `json:"body"`
}
func NewHandler(create *useCases.CreateArticleUseCase, get *useCases.GetArticleUseCase, list *useCases.ListArticlesUseCase, delete *useCases.DeleteArticleUseCase, update *useCases.UpdateArticleUseCase, getUser *useCases.GetUserUseCase) *Handler {
@@ -211,7 +211,7 @@ func (h *Handler) setIsOnline(w http.ResponseWriter, r *http.Request, isOnline b
}
type UpdateArticleBody struct {
Body string `json:"body" validate:"required,max=10000"`
Body string `json:"body"`
}
func (h *Handler) UpdateArticleBody(w http.ResponseWriter, r *http.Request) {
-1
View File
@@ -118,5 +118,4 @@ Back-office total 341. Auth board 50 (Log in 9, Register 6, Pending 6, Verify 6,
- Contrast was not computed for every hover and active background, only the pairs listed in foundations.md.
- The glossary pass was a search for known patterns, not a line-by-line read of every string. Titles in Title Case were not searched.
- The Components, Handoff and Foundations boards were not updated to mention the new tokens, CodeSnippet or the matrix.
- Integration still lists "max 10000" for article bodies, as the backend does today (change-list item 11).
- The sandbox cannot export; no files were bundled.
+2 -2
View File
@@ -42,8 +42,8 @@ Plain text, one sentence. Used in the table and in the per-endpoint error captio
- 413 and 415 on `POST /v1/media/` keep their earlier plain captions; no real message is known.
## Request bodies
- `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, required, max 10000 today; the limit is being removed, see change-list.md item 3).
- `PATCH /v1/article/{id}`: `body` (string, required, max 10000 today; same).
- `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, optional, no length limit).
- `PATCH /v1/article/{id}`: `body` (string, optional, no length limit).
- `PATCH /v1/media/{id}` (rename): `filename` (string, required).
- `POST /v1/media/merge`: `keep_id` (number, required, > 0), `merge_ids` (number[], required, at least 1, each > 0).
- `POST /v1/media/`: multipart/form-data, 1 file field (shown as "multipart/form-data, 1 file field").
+3 -3
View File
@@ -20,6 +20,6 @@ Recommendation: a modal. Title is the only field. The body is not asked for here
|---|---|---|---|
| 1 | Default | "New article" from Home, Articles or the empty state. | |
| 2 | Validation | On submit or blur. Nothing is sent. | "Use at least 3 characters." |
| 3 | Submitting | Request in flight. Fields are disabled and the dialog can't be dismissed. | "Creating…" / "Choosing tiles for a small bathroom" / "Small rooms show every grout line, so we start with the floor." |
| 4 | Title rejected (400) | A server 400 that got past the browser checks. The raw error isn't shown. | "Something in the form isn't valid. Check the highlighted fields." / "Choosing tiles for a small bathroom" / "Small rooms show every grout line, so we start with the floor." / "Check this title and try again." |
| 5 | Error | 500 or network failure. | "Couldn't create the article. Your text is still here. Try again in a moment." / "Choosing tiles for a small bathroom" / "Small rooms show every grout line, so we start with the floor." |
| 3 | Submitting | Request in flight. Fields are disabled and the dialog can't be dismissed. | "Creating…" / "Choosing tiles for a small bathroom" |
| 4 | Title rejected (400) | A server 400 that got past the browser checks. The raw error isn't shown. | "Something in the form isn't valid. Check the highlighted fields." / "Choosing tiles for a small bathroom" / "Check this title and try again." |
| 5 | Error | 500 or network failure. | "Couldn't create the article. Your text is still here. Try again in a moment." / "Choosing tiles for a small bathroom" |
+124
View File
@@ -0,0 +1,124 @@
Feature: Article bodies have no length limit and the body is optional
As a verified RenewCMS owner
I want to create an article with only a title and write articles of any length
So that starting a draft is one field and long articles are never blocked
Background:
Given POST /v1/article/ and PATCH /v1/article/{id} sit inside the jwt-protected group
And the request structs PostArticle and UpdateArticleBody validate through validator.Validate.Struct
And PostArticle.Title keeps validate "required,min=3,max=200"
And PostArticle.Body and UpdateArticleBody.Body carry no validate tag: no max, no required
And a body is stored and returned as given; an absent "body" key is treated as ""
And success bodies are the article JSON (id, title, body, body_format, is_online, created_at, updated_at)
And a new article is created offline (is_online false)
Scenario Outline: Create with only a title succeeds in every body format
Given a valid jwt cookie for a verified user
When the client sends POST /v1/article/<query> with JSON <payload>
Then the status is 201
And the response JSON has title "Choosing tiles for a small bathroom", is_online false, body_format "<format>" and body <rendered>
And the fake article repository holds one article with Body ""
Examples:
| query | payload | format | rendered |
| | {"title": "Choosing tiles for a small bathroom"} | markdown | "" |
| ?format=html | {"title": "Choosing tiles for a small bathroom"} | html | "" |
| ?format=json | {"title": "Choosing tiles for a small bathroom"} | json | [] |
| | {"title": "Choosing tiles for a small bathroom", "body": ""} | markdown | "" |
Scenario: A body of 120,000 characters is accepted on create
Given a valid jwt cookie for a verified user
When the client sends POST /v1/article/ with a title of 3 characters or more and a body of 120000 characters
Then the status is 201
And the repository holds the full 120000-character body, unmodified
Scenario Outline: Update the body with no length limit
Given an article with id 12 exists
And a valid jwt cookie for a verified user
When the client sends PATCH /v1/article/12 with a body of <length> characters
Then the status is 200
And the repository body for article 12 has <length> characters
Examples:
| length |
| 10001 |
| 120000 |
Scenario: Update the body to empty
Given an article with id 12 exists with a non-empty body
When the client sends PATCH /v1/article/12 with JSON {"body": ""}
Then the status is 200 and the repository body is ""
And GET /v1/article/12?format=json returns body []
Scenario Outline: Title rules are unchanged
Given a valid jwt cookie for a verified user
When the client sends POST /v1/article/ with JSON <payload>
Then the status is 400, the response is plain text from http.Error, and no article is created
Examples:
| payload |
| {"body": "Hello"} |
| {"title": "Ti", "body": "Hello"} |
| {"title": "<201 characters>"} |
Scenario: Existing guards on PATCH are unchanged
When PATCH /v1/article/abc is sent
Then the status is 400 with the uint32 id error text
When PATCH /v1/article/999 is sent and no such article exists
Then the status is 404 with the article-not-found text
When PATCH /v1/article/12 is sent with malformed JSON
Then the status is 400 with apperr.BodyErrorMessage
Scenario: The SPA no longer limits or counts the body
Given the Vue SPA
Then the constant BODY_MAX_LENGTH no longer exists
And the CharacterCounter component stays (ProfileView and CreateApiKeyModal still use it) but EditorView and NewArticleModal no longer import it
And the editor has no "Near the limit" or "Too long" state
Scenario: Editor status bar shows a neutral count
Given the editor is open on /article/12/edit with a loaded article whose body has 1204 words and 7318 characters
Then the EditorStatusBar reads "1,204 words · 7,318 characters"
And the count updates as the body changes
And a new empty article reads "0 words · 0 characters"
And the count carries no warning, error colour or icon at any length
Scenario: Editor states at 120,000 characters
Given the editor holds an article body of 120000 characters (19,845 words)
Then the count reads "19,845 words · 120,000 characters", neutral
When the body is edited
Then the state is Unsaved changes, and Save and Publish ("Save and publish") are enabled
When the user saves and PATCH /v1/article/12 is pending
Then the status shows the spinner with "Saving 120,000 characters. This can take a few seconds.", Save is disabled and the page stays editable
When PATCH resolves 200
Then the status reads Saved with the time and the count is unchanged
When PATCH instead resolves 500, 413 or a network failure
Then the editor shows "Couldn't save. Your text is kept. Retry." and the body is unchanged
And Retry resends the same content
Scenario: NewArticleModal is title-only
Given NewArticleModal is open
Then it shows the Title field with its "0 / 200" counter and "3 to 200 characters." help, "Cancel" and "Create draft"
And there is no First paragraph field
When the user submits a valid title
Then POST /v1/article/ is sent with JSON {"title": "<title>"} (no body key)
Scenario Outline: NewArticleModal states
Given NewArticleModal is open with Title "<title>"
When <trigger>
Then <outcome>
Examples:
| title | trigger | outcome |
| | it opens | Default (empty): Title focused, counter "0 / 200" |
| Ti | the field blurs or Create draft is pressed | Validation: "Use at least 3 characters.", counter in error style, no request |
| Choosing tiles for a small bathroom | Create draft is pressed | Submitting: "Creating…", Title and buttons disabled, not dismissible |
| Choosing tiles for a small bathroom | POST resolves 201 (id 42) | modal closes, navigates to /article/42/edit, toast "Draft created" id "article-created-42" |
| Choosing tiles for a small bathroom | POST resolves 400 | Title rejected: "Check this title and try again." and alert "Something in the form isn't valid. Check the highlighted fields." |
| Choosing tiles for a small bathroom | POST resolves 500 or never reaches server | Error: alert "Couldn't create the article. Your text is still here. Try again in a moment." |
Scenario: Integration API reference no longer says max 10000
Given the Integration screen's request-body tables for POST /v1/article/ and PATCH /v1/article/{id}
Then POST lists: title string required 3-200; body string optional, no length limit
And PATCH lists: body string optional, no length limit
And no text on the screen contains "max 10000"
+36
View File
@@ -0,0 +1,36 @@
# QA: unlimited, optional article body
## Part A. HTTP (Go, test/article_body_unlimited_http_test.go)
Setup: in-memory fake article repository plus the real markdown converter as BodyRenderer (so the "json" format of an empty body is genuinely exercised). Build CreateArticle, GetArticle, UpdateArticle use cases and the article handler via NewHandler(...). Mount on a fresh chi.NewRouter() that mirrors api/router.go: POST /v1/article/ and PATCH /v1/article/{id} inside jwtauth.Verifier + Authenticator with a test jwtauth.New("HS256", ...) token. Call validator.InitValidator(). Use httptest. No live server, no real SQLite, no real SMTP.
1. POST title-only, one request per format (default markdown, html, json): assert 201, body_format, the body value in the JSON response (the Examples table), and that the fake repo has Body "". The json case must produce valid JSON (`[]`) and not fail encoding.
2. POST with "body": "" explicitly: same as 1.
3. POST with strings.Repeat("a", 120000): 201 and the repo body length is 120000.
4. PATCH with 10001 and 120000 characters: 200 and the stored length matches.
5. PATCH with "body": "": 200, then GET ?format=json returns body [].
6. POST title missing, "Ti", and 201 characters: 400, plain text, repo count unchanged.
7. PATCH guards: bad id gives 400, unknown id gives 404, malformed JSON gives 400.
8. Update existing tests: in test/edit_article_body_http_test.go remove the case "body over 10000 characters" (line 172) from the 400 table, and any missing-body rejection case (flip it to accepted if present).
9. Grep that the Integration source no longer contains "max 10000" (see Part C).
## Part B. SPA
Automated (Vitest, `task test:web`). Until the SPA runner exists, report NOT RUN rather than pass.
- EditorView.spec.ts: delete the "Near the limit" and "Too long" describes (around lines 392 and 405). Add: the neutral count "1,204 words · 7,318 characters" with mocked GET (fixture of 1,204 words / 7,318 characters); "0 words · 0 characters" for an empty article; at 120,000 characters (19,845 words): neutral count, Save and Publish enabled, then Saving ("Saving 120,000 characters. This can take a few seconds."), Saved (count unchanged), and Save failed (PATCH 500, 413 and network error, body kept, Retry resending the same payload).
- CharacterCounter.vue and web/src/components/__tests__/CharacterCounter.spec.ts stay (ProfileView and CreateApiKeyModal use them).
- NewArticleModal spec: Default (empty), Validation ("Ti"), Submitting, 201 handoff, Title rejected (400), Server error (500 and network). Assert the POST payload is exactly {"title": "..."} and that no First paragraph field exists.
- Static check: grep web/src for BODY_MAX_LENGTH, and for CharacterCounter imports in EditorView and NewArticleModal, returns nothing.
Visual (`task visual:check -- editor`, `task visual:check -- new-article`). Hard gate: every capture within 10 %, desktop and mobile, light and dark.
- editor: states Saved, Unsaved changes, Saving, Save failed, and Very long article (6-9). Reuse the board's sample data ("1,204 words · 7,318 characters", "19,845 words · 120,000 characters", "Saving 120,000 characters. This can take a few seconds."). States showing time-dependent content (the "Saved at" time, "Updated Today, 11:02") are `skip: clock-sensitive`.
- new-article: Default, Validation, Submitting, Title rejected, Error. Mocks reuse "Choosing tiles for a small bathroom". The user will update the design .md files only; the board .dc.html frames may still draw a body line in Submitting / Title rejected / Error, so a visual diff there is a known follow-up to raise, not a reason to keep the field.
- Remove the "Near the limit" and "Too long" entries from web/visual/states.ts (around lines 4712 and 4720) if they belong to the editor group.
Manual: keyboard and focus order in the modal (Title focused on open, Esc blocked while submitting); hover and motion; typing responsiveness in a real browser at 120,000 characters.
## Part C. Docs consistency
- The Integration screen reference text shows no "max 10000" (view /integration).
+8 -8
View File
@@ -19,9 +19,9 @@ Feature: An authenticated user can edit an article's body
DELETE /v1/article/{id}, PATCH /v1/article/{id}/publish and
PATCH /v1/article/{id}/unpublish in api/router.go, so it also requires a valid
jwt cookie
And the request body is JSON {"body": "..."}, validated with
validate:"required,max=10000" on the Body field, the same tag PostArticle.Body
already carries
And the request body is JSON {"body": "..."}; the Body field carries no validate tag
(no max, not required), as PostArticle.Body, per
features/article_body_unlimited.feature
And a successful edit answers 200 with the JSON body of the updated article.Article,
the same shape GetArticle, PostArticle, PublishArticle and UnpublishArticle
already return; the article's title, is_online flag and images are unchanged
@@ -30,9 +30,8 @@ Feature: An authenticated user can edit an article's body
PublishArticle and UnpublishArticle
And a body that is not valid JSON answers 400 with body apperr.BodyErrorMessage,
the same as PostArticle
And a body that fails its own required/max=10000 validation answers 400 with the
raw validator error text, the same as PostArticle already does for its own
fields; no new apperr message is introduced for this
And no body value fails validation: an empty or absent body and a body of any length
are accepted
And an id absent from the article repository answers 404 with body
apperr.ArticleNotFoundErrorMessage, backed by
errors.Is(err, article.ErrArticleNotFound), the same as DeleteArticle,
@@ -84,11 +83,12 @@ Feature: An authenticated user can edit an article's body
Then the response status is 400
And the response body is the text apperr.BodyErrorMessage
Scenario Outline: A body value that fails validation answers 400
Scenario Outline: A body of any length, including empty, is accepted
Given a valid jwt cookie for a user with id 1
And the article repository contains an article with id 1
When the client sends PATCH /v1/article/1 with that cookie and body <payload>
Then the response status is 400
Then the response status is 200
And the stored body equals the sent body
Examples:
| payload |
+3 -4
View File
@@ -41,11 +41,10 @@ Package: `test` (black-box, extending `article_test.go`).
string `not-json`), repo seeded with article id 1 -> status `400`, body matching
`apperr.BodyErrorMessage + "\n"`.
13. `PATCH /v1/article/1` with the valid cookie and JSON body `{"body": ""}`, repo
seeded with article id 1 -> status `400`; assert `repo.articles[1].Body` is
unchanged (nothing stored).
seeded with article id 1 -> status `200`; assert `repo.articles[1].Body` is `""`.
14. `PATCH /v1/article/1` with the valid cookie and JSON body `{"body": "<10001-char
string>"}`, repo seeded with article id 1 -> status `400`; assert
`repo.articles[1].Body` is unchanged.
string>"}`, repo seeded with article id 1 -> status `200`; assert
`repo.articles[1].Body` equals the sent string.
15. `PATCH /v1/article/1` with **no** cookie, repo seeded with article id 1 -> status
`401`.
+14 -24
View File
@@ -23,11 +23,11 @@ Feature: Editor screen — load, edit and save an article's body
the article is a draft; clicking PublishControl itself is out of scope for this slice and
is specified in a follow-on slice (editor_publish_and_delete)
And Save is enabled only when useArticleDraft's state is "dirty", the request is not already
in flight, the app is not offline, and the body's HTML length is at most 10000 characters;
it is disabled in every other case
in flight, and the app is not offline, whatever the body's length; it is disabled in every
other case
And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "..."},
the same contract features/edit_article_body.feature already gives the API: 200 with the
updated article.Article, 400 for a body over 10000 characters, 404 for an id absent from
updated article.Article (any body length, including empty), 404 for an id absent from
the repository, 401 with no valid session
And a successful save (200) sets useArticleDraft's state to "saved" and records the save
time; SaveStatus shows "Saved at {HH:MM}" for the first hour and "Saved {n} min ago" after
@@ -43,11 +43,11 @@ Feature: Editor screen — load, edit and save an article's body
(features/api_client_global_error_handling.feature): before the redirect, useArticleDraft
mirrors the current dirty body to sessionStorage keyed by the article's id, so it survives
the redirect to /login?redirect=/article/{id}/edit&reason=expired
And CharacterCounter shows "{len} / 10 000" below the editor, using the body's HTML string
length (formatting and images count); at 9000 characters or more (90%, "Near the limit")
it adds a warning icon and stays enabled; above 10000 ("Too long") it adds an icon and the
text "{over} characters over. Shorten the text or remove an image. Formatting counts too.",
and disables both Save and PublishControl until the length is 10000 or fewer again
And EditorStatusBar shows a neutral "{words} words · {characters} characters" count with
en-US thousands separators (e.g. "1,204 words · 7,318 characters", "0 words · 0 characters"
for an empty body); characters is the body's HTML string length, words are the
whitespace-separated tokens of its text content; the count has no limit, warning or error
styling, per features/article_body_unlimited.feature
And a Preview switch, when on, replaces the editable ArticleEditor and its toolbar with a
read-only rendering of the title and body in the content type scale; switching it off
restores the editable surface with the same content, unchanged
@@ -93,7 +93,7 @@ Feature: Editor screen — load, edit and save an article's body
And SaveStatus reads "Saved at 11:02"
And the Save button is disabled
And PublishControl reads "<publish_label>"
And the character counter reads "4 318 / 10 000"
And the count reads "{words} words · 4,318 characters"
Examples:
| is_online | badge | publish_label |
@@ -161,22 +161,12 @@ Feature: Editor screen — load, edit and save an article's body
Then a toast reads "That article no longer exists."
And SaveStatus does not read "Save failed"
Scenario: Near the limit — the counter warns at 9000 characters without blocking Save
Scenario: A very long body keeps a neutral count and leaves Save and Publish enabled
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
9240 characters
Then the character counter reads "9 240 / 10 000" with a warning icon
And the Save button remains enabled
Scenario: Too long — over 10000 characters disables Save and Publish until shortened
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
10214 characters
Then the character counter reads "10 214 / 10 000" with the text "214 characters over.
Shorten the text or remove an image. Formatting counts too."
And the Save button is disabled
And PublishControl is disabled
When the user shortens the body to 10000 characters or fewer
Then the character counter no longer shows the over-limit text
And the Save button is enabled again
120000 characters
Then the count reads "19,845 words · 120,000 characters" with no warning styling
And the Save button is enabled
And PublishControl is enabled
Scenario: Preview shows the rendered title and body without the editing surface
Given the user is on /article/12/edit, article titled "How we price a bathroom renovation"
+7 -10
View File
@@ -35,7 +35,7 @@ Fixture article (reused across cases unless a scenario overrides a field): id 12
2. **Saved state, draft and online** — Examples-driven on `is_online`: mock `GET
/v1/article/12` resolving 200 with the fixture at each `is_online` value; assert the
breadcrumb, ArticleStatusBadge ("Draft"/"Online"), SaveStatus "Saved at 11:02", Save
disabled, PublishControl ("Publish"/"Unpublish"), and character counter "4 318 / 10 000".
disabled, PublishControl ("Publish"/"Unpublish"), and the neutral count ending "4,318 characters".
3. **Unsaved changes** — from the Saved state, type into the body; assert SaveStatus "Unsaved
changes", Save enabled, PublishControl now "Save and publish".
4. **Saving via click and via Ctrl/⌘ S** — from Unsaved changes, (a) click Save, (b) press
@@ -53,12 +53,10 @@ Fixture article (reused across cases unless a scenario overrides a field): id 12
9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve
`PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
reads "Save failed".
10. **Near the limit** — set the body's HTML length to 9240 (via a fixture string of that
length); assert counter "9 240 / 10 000" with a warning icon and Save still enabled.
11. **Too long** — set the body's HTML length to 10214; assert counter "10 214 / 10 000" with
"214 characters over. Shorten the text or remove an image. Formatting counts too.", Save
disabled, PublishControl disabled; shorten to 10000; assert both re-enable and the over-limit
text disappears.
10. **Very long body** — set the body's HTML length to 120000 (via a fixture string of that
length); assert the count "19,845 words · 120,000 characters" with no warning styling, Save
and PublishControl enabled.
11. **No limit states** — assert no "Near the limit" or "Too long" text is ever rendered.
12. **Preview toggle** — from a loaded article with body content, switch Preview on; assert the
toolbar and editable surface are gone and a read-only rendering of title+body appears;
switch off; assert the editable surface returns with the same content.
@@ -97,12 +95,11 @@ reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updat
- [ ] Loading: skeletons for title, content and details, shaped like the real layout.
- [ ] Saved: breadcrumb, "Draft" badge, "Saved at 11:02", disabled Save, "Publish", counter
"4 318 / 10 000", details panel (Created/Updated/ID/Images/Public API URL).
"N words · 4,318 characters", details panel (Created/Updated/ID/Images/Public API URL).
- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
- [ ] Saving: "Saving…" SaveStatus, disabled Save.
- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec.
- [ ] Near the limit: counter "9 240 / 10 000" with the warning icon/style.
- [ ] Too long: counter "10 214 / 10 000" with the over-limit text and disabled Save/Publish.
- [ ] Very long article: count "19,845 words · 120,000 characters", neutral, Save/Publish enabled.
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden.
- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
Save/Publish, OfflineBanner visible.
+3 -3
View File
@@ -69,7 +69,7 @@ Feature: Integration screen (API reference)
And a "Request body" table lists:
| Name | Type | Required | Rule |
| title | string | Yes | 3 to 200 characters. Can't be changed later. |
| body | string | Yes | Up to 10 000 characters. |
| body | string | No | No length limit. |
And tabs fetch, curl and Response show, fetch active, with a CodeBlock labelled "JavaScript" and a ghost "Copy" button
And an "Errors" list shows a status chip with a one-line caption for 400, 401
@@ -90,8 +90,8 @@ Feature: Integration screen (API reference)
Then <method> <path> lists these fields: <fields>
Examples:
| method | path | fields |
| POST | /v1/article/ | title string required 3-200; body string required max 10000 |
| PATCH | /v1/article/{id} | body string required max 10000 |
| POST | /v1/article/ | title string required 3-200; body string optional, no length limit |
| PATCH | /v1/article/{id} | body string optional, no length limit |
| PATCH | /v1/media/{id} | filename string required |
| POST | /v1/media/merge | keep_id number required >0; merge_ids number[] required min 1 each >0 |
| POST | /v1/media/ | multipart/form-data, 1 file field named "file" |
+1 -1
View File
@@ -28,7 +28,7 @@ Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
- invite: id, kind, email, status, created_at, expires_at;
- settings: version.
Any other key fails the spec. "images" and "status" are explicitly rejected on articles.
5. Request-body tables equal the validate tags (title 3-200, body max 10000, filename, keep_id,
5. Request-body tables equal the validate tags (title 3-200, body optional with no length limit, filename, keep_id,
merge_ids, display_name, email). The multipart field is "file". Query tables: `format` on every
article route, and q, hash, cursor, filter, sort, limit on GET /v1/media/.
6. The three invite routes carry "Owner only", a 403 "Only the owner can do this." caption and the
+42 -63
View File
@@ -1,10 +1,10 @@
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
I want NewArticleModal to take only a title, create the article as a draft
over POST /v1/article/, and take me straight to the editor to write it
So that starting a new article is a one-field, low-friction step and I land where I can
actually write it
Background:
Given NewArticleModal is a UModal opened from Home's "New article" action (mobile header
@@ -14,42 +14,37 @@ Feature: Creating a new article from NewArticleModal
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
reading "0 / 200" and help "3 to 200 characters.", a "Cancel" button and a "Create draft"
button; there is no body or First paragraph field
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 the request carries no body key; the server treats an absent body as "" per
features/article_body_unlimited.feature
And the Title field validates on blur and again on submit; submitting while it is invalid
sends no request: a title under 3 characters shows "Use at least 3 characters." in place of
its help text, with the counter in its error style
And activating "Create draft" while the title is valid sends POST /v1/article/ with JSON
{"title": "<title>"}; while the request is in flight the Title field 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
And a 400 response is never shown verbatim; it re-enables the form with the title 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."
And a 500 response or a request that never reaches the server re-enables the form with the
title 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 the title, 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
behavior for the modal's field, unlike the editor's session-expiry handling
Scenario: Opening the modal shows the Default state
Given the user is on /home
@@ -57,7 +52,7 @@ Feature: Creating a new article from NewArticleModal
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 there is no First paragraph field
And "Create draft" is present but the form has not been submitted
Scenario: Validation — a title under 3 characters on blur
@@ -67,34 +62,23 @@ Feature: Creating a new article from NewArticleModal
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
Scenario: Validation — submitting an invalid title sends nothing
Given the modal is open with Title "Ti"
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."
Then the Title field shows "Use at least 3 characters."
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."
Given the modal is open with Title "Choosing tiles for a small bathroom"
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
Then POST /v1/article/ is sent with JSON {"title": "Choosing tiles for a small bathroom"}
And the Title field becomes 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
Given the user is on /home with the modal open, Title "Choosing tiles for a small bathroom",
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
@@ -104,33 +88,29 @@ Feature: Creating a new article from NewArticleModal
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
this form's own rule), and "Create draft" has been activated
When POST /v1/article/ resolves 400
Then the modal re-enables, keeping both field values
Then the modal re-enables, keeping the title
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
Given the modal is open with Title "Choosing tiles for a small bathroom", and "Create draft"
has been activated
When POST /v1/article/ <failure>
Then the modal re-enables, keeping both field values
Then the modal re-enables, keeping the title
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
And the Title field shows no field-level error
Examples:
| failure |
| 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
bathroom" still in the field
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
@@ -138,9 +118,8 @@ Feature: Creating a new article from NewArticleModal
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."
Scenario: Cancel discards the title with no confirmation
Given the modal is open with Title "Choosing tiles for a small bathroom"
When the user activates "Cancel"
Then the modal closes immediately, with no confirmation prompt
And no request is sent
+42 -47
View File
@@ -7,8 +7,10 @@ Only HomeView.vue is wired as a real `created` consumer in this slice: it listen
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 modal is title-only: the First paragraph field is removed and the POST body is `{"title": "..."}`
with no `body` key (see features/article_body_unlimited.feature).
The confirm button's label is the design's "Create draft".
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.' })`,
@@ -20,77 +22,70 @@ section (success/info close after 5 s).
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.
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.", "Cancel" and "Create draft", and no First paragraph field.
2. **Blur validation** — 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
3. **Submit with an invalid title** — Title "Ti", click "Create draft"; assert the error shows and
`request` is never called.
4. **Submitting state** — fill Title validly, click "Create draft"; assert `request` is called
once with `POST /v1/article/` and the JSON body exactly `{"title": "Choosing tiles for a small
bathroom"}`; assert Title and Cancel become disabled, the confirm button reads "Creating…";
assert pressing Escape and clicking the backdrop do not close the modal while unresolved.
5. **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
6. **Title rejected (400)** — from Submitting, resolve `request` rejecting with `ApiError(400)`;
assert the title is kept, the Title field shows "Check this title and try again.", and an alert
reads "Something in the form isn't valid. Check the highlighted fields."
7. **Error, Examples-driven** — from Submitting, (a) resolve `request` rejecting with
`ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert the title is
kept, the alert reads "Couldn't create the article. Your text is still here. Try again in a
moment.", and the field shows no error.
8. **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.
9. **Cancel discards** — fill Title, 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.
first, reusing this slice's sample data (title "Choosing tiles for a small bathroom"). This screen
has no time-dependent content, so no fixed `now` is needed. The board frames may still draw a body
line in Submitting, Title rejected and Error; the design .md files are authoritative for the
title-only modal and the boards are a known follow-up.
- [ ] 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
- [ ] Default: empty, focused Title with counter "0 / 200", help text, "Cancel" / "Create draft".
- [ ] Validation: Title "Ti" with counter "2 / 200" in error and "Use at least 3 characters."
- [ ] Submitting: Title filled and disabled, confirm reads "Creating…" with its loading state.
- [ ] Title rejected (400): Title filled, error "Check this title and try again.", alert
"Something in the form isn't valid. Check the highlighted fields."
- [ ] Error: Title 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.
- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → 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 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`.
confirm the real 201/400/500 responses, that a title-only create returns 201 with an empty body,
and that the 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
+204
View File
@@ -0,0 +1,204 @@
package test
import (
"RenewCMS/api/handlers/article"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/infrastructure/markdown"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
)
func newUnlimitedBodyRouter(articleRepo *mockArticleRepo, token *jwtauth.JWTAuth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(newVerifiedUserRepo())
renderer := markdown.NewConverter()
articleHandler := article.NewHandler(
useCases.NewCreateArticleUseCase(articleRepo, renderer, discardRecorder),
useCases.NewGetArticleUseCase(articleRepo, renderer),
useCases.NewListArticlesUseCase(articleRepo, renderer),
useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
useCases.NewUpdateArticleUseCase(articleRepo, renderer, discardRecorder),
getUserUseCase,
)
r := chi.NewRouter()
r.Route("/v1", func(r chi.Router) {
r.Route("/article", func(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(jwtauth.Verifier(token))
r.Group(func(r chi.Router) {
r.Use(jwtauth.Authenticator(token))
r.Use(middleware.RequireVerifiedUser(getUserUseCase))
r.Post("/", articleHandler.PostArticle)
r.Get("/{id}", articleHandler.GetArticle)
r.Patch("/{id}", articleHandler.UpdateArticleBody)
})
})
})
})
return r
}
func serveUnlimitedBody(t *testing.T, router *chi.Mux, token *jwtauth.JWTAuth, method, target, payload string) *httptest.ResponseRecorder {
t.Helper()
req := newEditArticleBodyRequest(method, target, payload)
setEditArticleBodyJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestArticleBodyUnlimitedHttp(t *testing.T) {
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
const title = "Choosing tiles for a small bathroom"
createCases := []struct {
name string
query string
payload string
format string
rendered string
}{
{"markdown without body key", "", `{"title": "` + title + `"}`, "markdown", `""`},
{"html without body key", "?format=html", `{"title": "` + title + `"}`, "html", `""`},
{"json without body key", "?format=json", `{"title": "` + title + `"}`, "json", `[]`},
{"markdown with empty body", "", `{"title": "` + title + `", "body": ""}`, "markdown", `""`},
}
for _, tc := range createCases {
t.Run("Create with only a title succeeds in every body format: "+tc.name, func(t *testing.T) {
repo := newMockArticleRepo()
router := newUnlimitedBodyRouter(repo, token)
rec := serveUnlimitedBody(t, router, token, http.MethodPost, "/v1/article/"+tc.query, tc.payload)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var got struct {
Title string `json:"title"`
IsOnline bool `json:"is_online"`
BodyFormat string `json:"body_format"`
Body json.RawMessage `json:"body"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if got.Title != title || got.IsOnline || got.BodyFormat != tc.format || string(got.Body) != tc.rendered {
t.Errorf("unexpected response: %s", rec.Body.String())
}
if len(repo.articles) != 1 {
t.Fatalf("expected one article, got %d", len(repo.articles))
}
for _, stored := range repo.articles {
if stored.Body != "" {
t.Errorf("expected empty stored body, got %q", stored.Body)
}
}
})
}
t.Run("A body of 120,000 characters is accepted on create", func(t *testing.T) {
repo := newMockArticleRepo()
router := newUnlimitedBodyRouter(repo, token)
body := strings.Repeat("a", 120000)
rec := serveUnlimitedBody(t, router, token, http.MethodPost, "/v1/article/", `{"title": "`+title+`", "body": "`+body+`"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", rec.Code)
}
for _, stored := range repo.articles {
if stored.Body != body {
t.Errorf("stored body differs, length %d", len(stored.Body))
}
}
})
for _, length := range []int{10001, 120000} {
t.Run("Update the body with no length limit: "+strconv.Itoa(length), func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newUnlimitedBodyRouter(repo, token)
body := strings.Repeat("a", length)
rec := serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/1", `{"body": "`+body+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if len(repo.articles[1].Body) != length {
t.Errorf("expected %d characters, got %d", length, len(repo.articles[1].Body))
}
})
}
t.Run("Update the body to empty", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newUnlimitedBodyRouter(repo, token)
rec := serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/1", `{"body": ""}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
if repo.articles[1].Body != "" {
t.Errorf("expected empty body, got %q", repo.articles[1].Body)
}
get := serveUnlimitedBody(t, router, token, http.MethodGet, "/v1/article/1?format=json", "")
var got struct {
Body json.RawMessage `json:"body"`
}
if err := json.Unmarshal(get.Body.Bytes(), &got); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if string(got.Body) != "[]" {
t.Errorf("expected [], got %s", got.Body)
}
})
titleCases := []struct {
name string
payload string
}{
{"missing title", `{"body": "Hello"}`},
{"title too short", `{"title": "Ti", "body": "Hello"}`},
{"title too long", `{"title": "` + strings.Repeat("a", 201) + `"}`},
}
for _, tc := range titleCases {
t.Run("Title rules are unchanged: "+tc.name, func(t *testing.T) {
repo := newMockArticleRepo()
router := newUnlimitedBodyRouter(repo, token)
rec := serveUnlimitedBody(t, router, token, http.MethodPost, "/v1/article/", tc.payload)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
if len(repo.articles) != 0 {
t.Errorf("expected no article, got %d", len(repo.articles))
}
})
}
t.Run("Existing guards on PATCH are unchanged", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newUnlimitedBodyRouter(repo, token)
rec := serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/abc", `{"body": "x"}`)
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" {
t.Errorf("unexpected id guard: %d %q", rec.Code, rec.Body.String())
}
rec = serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/999", `{"body": "x"}`)
if rec.Code != http.StatusNotFound || rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" {
t.Errorf("unexpected not found guard: %d %q", rec.Code, rec.Body.String())
}
rec = serveUnlimitedBody(t, router, token, http.MethodPatch, "/v1/article/1", `not json`)
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.BodyErrorMessage+"\n" {
t.Errorf("unexpected malformed guard: %d %q", rec.Code, rec.Body.String())
}
})
}
-25
View File
@@ -164,31 +164,6 @@ func TestEditArticleBodyHttp(t *testing.T) {
}
})
validationCases := []struct {
name string
payload string
}{
{"empty body", `{"body": ""}`},
{"body over 10000 characters", `{"body": "` + strings.Repeat("a", 10001) + `"}`},
}
for _, tc := range validationCases {
t.Run("A body value that fails validation answers 400: "+tc.name, func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
req := newEditArticleBodyRequest(http.MethodPatch, "/v1/article/1", tc.payload)
setEditArticleBodyJwtCookie(t, req, token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
if repo.articles[1].Body != "Old body text" {
t.Errorf("expected stored body to be unchanged, got %q", repo.articles[1].Body)
}
})
}
t.Run("A response write failure is logged", func(t *testing.T) {
repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token)
+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()]])
+5 -7
View File
@@ -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éé',
+33
View File
@@ -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)
})
})
+23
View File
@@ -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 }
}
+9 -12
View File
@@ -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>
+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(
+11 -2
View File
@@ -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
View File
@@ -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(),
},