Compare commits

..
2 Commits
29 changed files with 864 additions and 401 deletions

No files matched your search

+4 -4
View File
@@ -1,11 +1,11 @@
root = "." root = "."
tmp_dir = "tmp" tmp_dir = "tmp/air"
[build] [build]
cmd = "go build -tags nofrontend -o ./tmp/renewcms ./cmd/server" cmd = "go build -tags nofrontend -o ./tmp/air/renewcms ./cmd/server"
bin = "tmp/renewcms" bin = "tmp/air/renewcms"
delay = 500 delay = 500
exclude_dir = ["tmp", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"] exclude_dir = ["tmp", "tmp/air", "bin", "data", "web", "features", "docs", "internal/infrastructure/assets/dist", ".claude"]
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
include_ext = ["go", "html"] include_ext = ["go", "html"]
log = "build-errors.log" log = "build-errors.log"
+2 -2
View File
@@ -28,7 +28,7 @@ type Handler struct {
type PostArticle struct { type PostArticle struct {
Title string `json:"title" validate:"required,min=3,max=200"` 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 { 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 { 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) { 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. - 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 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. - 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. - 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. - 413 and 415 on `POST /v1/media/` keep their earlier plain captions; no real message is known.
## Request bodies ## 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). - `POST /v1/article/`: `title` (string, required, 3 to 200 characters), `body` (string, optional, no length limit).
- `PATCH /v1/article/{id}`: `body` (string, required, max 10000 today; same). - `PATCH /v1/article/{id}`: `body` (string, optional, no length limit).
- `PATCH /v1/media/{id}` (rename): `filename` (string, required). - `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/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"). - `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. | | | 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." | | 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." | | 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" / "Small rooms show every grout line, so we start with the floor." / "Check this title and try again." | | 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" / "Small rooms show every grout line, so we start with the floor." | | 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 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 PATCH /v1/article/{id}/unpublish in api/router.go, so it also requires a valid
jwt cookie jwt cookie
And the request body is JSON {"body": "..."}, validated with And the request body is JSON {"body": "..."}; the Body field carries no validate tag
validate:"required,max=10000" on the Body field, the same tag PostArticle.Body (no max, not required), as PostArticle.Body, per
already carries features/article_body_unlimited.feature
And a successful edit answers 200 with the JSON body of the updated article.Article, And a successful edit answers 200 with the JSON body of the updated article.Article,
the same shape GetArticle, PostArticle, PublishArticle and UnpublishArticle the same shape GetArticle, PostArticle, PublishArticle and UnpublishArticle
already return; the article's title, is_online flag and images are unchanged 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 PublishArticle and UnpublishArticle
And a body that is not valid JSON answers 400 with body apperr.BodyErrorMessage, And a body that is not valid JSON answers 400 with body apperr.BodyErrorMessage,
the same as PostArticle the same as PostArticle
And a body that fails its own required/max=10000 validation answers 400 with the And no body value fails validation: an empty or absent body and a body of any length
raw validator error text, the same as PostArticle already does for its own are accepted
fields; no new apperr message is introduced for this
And an id absent from the article repository answers 404 with body And an id absent from the article repository answers 404 with body
apperr.ArticleNotFoundErrorMessage, backed by apperr.ArticleNotFoundErrorMessage, backed by
errors.Is(err, article.ErrArticleNotFound), the same as DeleteArticle, 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 Then the response status is 400
And the response body is the text apperr.BodyErrorMessage 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 Given a valid jwt cookie for a user with id 1
And the article repository contains an article 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> 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: Examples:
| payload | | 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 string `not-json`), repo seeded with article id 1 -> status `400`, body matching
`apperr.BodyErrorMessage + "\n"`. `apperr.BodyErrorMessage + "\n"`.
13. `PATCH /v1/article/1` with the valid cookie and JSON body `{"body": ""}`, repo 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 seeded with article id 1 -> status `200`; assert `repo.articles[1].Body` is `""`.
unchanged (nothing stored).
14. `PATCH /v1/article/1` with the valid cookie and JSON body `{"body": "<10001-char 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 string>"}`, repo seeded with article id 1 -> status `200`; assert
`repo.articles[1].Body` is unchanged. `repo.articles[1].Body` equals the sent string.
15. `PATCH /v1/article/1` with **no** cookie, repo seeded with article id 1 -> status 15. `PATCH /v1/article/1` with **no** cookie, repo seeded with article id 1 -> status
`401`. `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 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) 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 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; in flight, and the app is not offline, whatever the body's length; it is disabled in every
it is disabled in every other case other case
And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "..."}, 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 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 the repository, 401 with no valid session
And a successful save (200) sets useArticleDraft's state to "saved" and records the save 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 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 (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 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 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 And EditorStatusBar shows a neutral "{words} words · {characters} characters" count with
length (formatting and images count); at 9000 characters or more (90%, "Near the limit") en-US thousands separators (e.g. "1,204 words · 7,318 characters", "0 words · 0 characters"
it adds a warning icon and stays enabled; above 10000 ("Too long") it adds an icon and the for an empty body); characters is the body's HTML string length, words are the
text "{over} characters over. Shorten the text or remove an image. Formatting counts too.", whitespace-separated tokens of its text content; the count has no limit, warning or error
and disables both Save and PublishControl until the length is 10000 or fewer again styling, per features/article_body_unlimited.feature
And a Preview switch, when on, replaces the editable ArticleEditor and its toolbar with a 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 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 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 SaveStatus reads "Saved at 11:02"
And the Save button is disabled And the Save button is disabled
And PublishControl reads "<publish_label>" And PublishControl reads "<publish_label>"
And the character counter reads "4 318 / 10 000" And the count reads "{words} words · 4,318 characters"
Examples: Examples:
| is_online | badge | publish_label | | 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." Then a toast reads "That article no longer exists."
And SaveStatus does not read "Save failed" 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 Given the user is on /article/12/edit with unsaved changes making the body's HTML length
9240 characters 120000 characters
Then the character counter reads "9 240 / 10 000" with a warning icon Then the count reads "19,845 words · 120,000 characters" with no warning styling
And the Save button remains enabled And the Save button is enabled
And PublishControl is 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
Scenario: Preview shows the rendered title and body without the editing surface 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" 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 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 /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 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 3. **Unsaved changes** — from the Saved state, type into the body; assert SaveStatus "Unsaved
changes", Save enabled, PublishControl now "Save and publish". changes", Save enabled, PublishControl now "Save and publish".
4. **Saving via click and via Ctrl/⌘ S** — from Unsaved changes, (a) click Save, (b) press 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 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 `PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
reads "Save failed". reads "Save failed".
10. **Near the limit** — set the body's HTML length to 9240 (via a fixture string of that 10. **Very long body** — set the body's HTML length to 120000 (via a fixture string of that
length); assert counter "9 240 / 10 000" with a warning icon and Save still enabled. length); assert the count "19,845 words · 120,000 characters" with no warning styling, Save
11. **Too long** — set the body's HTML length to 10214; assert counter "10 214 / 10 000" with and PublishControl enabled.
"214 characters over. Shorten the text or remove an image. Formatting counts too.", Save 11. **No limit states** — assert no "Near the limit" or "Too long" text is ever rendered.
disabled, PublishControl disabled; shorten to 10000; assert both re-enable and the over-limit
text disappears.
12. **Preview toggle** — from a loaded article with body content, switch Preview on; assert the 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; 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. 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. - [ ] Loading: skeletons for title, content and details, shaped like the real layout.
- [ ] Saved: breadcrumb, "Draft" badge, "Saved at 11:02", disabled Save, "Publish", counter - [ ] 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". - [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
- [ ] Saving: "Saving…" SaveStatus, disabled Save. - [ ] Saving: "Saving…" SaveStatus, disabled Save.
- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec. - [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec.
- [ ] Near the limit: counter "9 240 / 10 000" with the warning icon/style. - [ ] Very long article: count "19,845 words · 120,000 characters", neutral, Save/Publish enabled.
- [ ] Too long: counter "10 214 / 10 000" with the over-limit text and disabled Save/Publish.
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden. - [ ] 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 - [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
Save/Publish, OfflineBanner visible. Save/Publish, OfflineBanner visible.
+3 -3
View File
@@ -69,7 +69,7 @@ Feature: Integration screen (API reference)
And a "Request body" table lists: And a "Request body" table lists:
| Name | Type | Required | Rule | | Name | Type | Required | Rule |
| title | string | Yes | 3 to 200 characters. Can't be changed later. | | 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 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 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> Then <method> <path> lists these fields: <fields>
Examples: Examples:
| method | path | fields | | method | path | fields |
| POST | /v1/article/ | title string required 3-200; 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 required max 10000 | | PATCH | /v1/article/{id} | body string optional, no length limit |
| PATCH | /v1/media/{id} | filename string required | | 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/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" | | 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; - invite: id, kind, email, status, created_at, expires_at;
- settings: version. - settings: version.
Any other key fails the spec. "images" and "status" are explicitly rejected on articles. 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 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/. 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 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 Feature: Creating a new article from NewArticleModal
As a verified RenewCMS owner As a verified RenewCMS owner
I want NewArticleModal to take a title and a first paragraph, create the article as a draft 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 keep writing over POST /v1/article/, and take me straight to the editor to write it
So that starting a new article is a two-field, low-friction step instead of a full form, and I So that starting a new article is a one-field, low-friction step and I land where I can
land where I can actually write it actually write it
Background: Background:
Given NewArticleModal is a UModal opened from Home's "New article" action (mobile header 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 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 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 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 reading "0 / 200" and help "3 to 200 characters.", a "Cancel" button and a "Create draft"
placeholder "Start writing. You'll continue in the editor." and help "You can format it and button; there is no body or First paragraph field
add images in the editor.", a "Cancel" button and a "Create draft" button
And the Title field is sent as the request's title and is validated required, 3 to 200 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 characters, counted the same way as the CharacterCounter ([...str].length, which is rune
count, matching article_title_rules.feature's server-side rune counting) 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 And the request carries no body key; the server treats an absent body as "" per
least one character) features/article_body_unlimited.feature
And each field validates on blur and again on submit; submitting while either field is And the Title field validates on blur and again on submit; submitting while it is invalid
invalid sends no request, and shows both fields' errors at once if both are invalid: Title sends no request: a title under 3 characters shows "Use at least 3 characters." in place of
under 3 characters shows "Use at least 3 characters." in place of its help text, with the its help text, with the counter in its error style
counter in its error style; an empty First paragraph shows "Add a first paragraph. It can And activating "Create draft" while the title is valid sends POST /v1/article/ with JSON
be a single sentence." in place of its help text {"title": "<title>"}; while the request is in flight the Title field and both buttons are
And activating "Create draft" while both fields are valid sends POST /v1/article/ with JSON disabled, the confirm button reads "Creating…" with a loading indicator, and the modal
{"title": "<title>", "body": "<first paragraph>"}; while the request is in flight both cannot be dismissed by Cancel, Esc or a backdrop click
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 a 201 response closes the modal, emits created(article) with the response body, and (for 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 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 toast: color success, icon i-lucide-circle-check, title "Draft created", description
"\"{title}\" is saved as a draft.", with id "article-created-{id}" per "\"{title}\" is saved as a draft.", with id "article-created-{id}" per
docs/design/components.md's toast-id convention docs/design/components.md's toast-id convention
And a 400 response — whether apperr.ArticleTitleErrorMessage for a title that passed this And a 400 response is never shown verbatim; it re-enables the form with the title kept,
form's own check but still failed the server's, or any other 400 — is never shown verbatim; shows "Check this title and try again." in place of the Title field's help text, and shows
it re-enables the form with both fields' current values kept, shows "Check this title and an alert "Something in the form isn't valid. Check the highlighted fields."
try again." in place of the Title field's help text, and shows an alert "Something in the And a 500 response or a request that never reaches the server re-enables the form with the
form isn't valid. Check the highlighted fields."; the First paragraph field shows no error title kept and shows an alert "Couldn't create the article. Your text is still here. Try
And a 500 response or a request that never reaches the server re-enables the form with both again in a moment.", with no field-level error
fields' current values kept and shows an alert "Couldn't create the article. Your text is And "Cancel" always closes the modal immediately and discards the title, with no
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
confirmation prompt, whenever the form is not disabled by an in-flight request 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 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 (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 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 Scenario: Opening the modal shows the Default state
Given the user is on /home 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. 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." 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 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 And "Create draft" is present but the form has not been submitted
Scenario: Validation — a title under 3 characters on blur 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 the counter reads "2 / 200" in its error style
And no request is sent And no request is sent
Scenario: Validation — an empty first paragraph on blur Scenario: Validation — submitting an invalid title sends nothing
Given the modal is open with a valid Title Given the modal is open with Title "Ti"
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
When the user activates "Create draft" When the user activates "Create draft"
Then the Title field shows "Use at least 3 characters." and the First paragraph field shows Then the Title field shows "Use at least 3 characters."
"Add a first paragraph. It can be a single sentence."
And POST /v1/article/ is never sent And POST /v1/article/ is never sent
Scenario: Submitting disables the form and cannot be dismissed 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 Given the modal is open with Title "Choosing tiles for a small bathroom"
"Small rooms show every grout line, so we start with the floor."
When the user activates "Create draft" When the user activates "Create draft"
Then POST /v1/article/ is sent with JSON {"title": "Choosing tiles for a small bathroom", 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 the Title field becomes disabled, "Cancel" becomes disabled, and the confirm button reads
And both fields become disabled, "Cancel" becomes disabled, and the confirm button reads
"Creating…" with a loading indicator "Creating…" with a loading indicator
And pressing Esc or clicking the backdrop does not close the modal 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 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" 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 and "Create draft" has been activated
"Create draft" has been activated
When POST /v1/article/ resolves 201 with the created article, id 42, is_online false When POST /v1/article/ resolves 201 with the created article, id 42, is_online false
Then the modal closes Then the modal closes
And the app navigates to /article/42/edit 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 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 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 this form's own rule), and "Create draft" has been activated
with the floor.", and "Create draft" has been activated
When POST /v1/article/ resolves 400 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 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 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 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 Given the modal is open with Title "Choosing tiles for a small bathroom", and "Create draft"
"Small rooms show every grout line, so we start with the floor.", and "Create draft" has has been activated
been activated
When POST /v1/article/ <failure> 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 And an alert reads "Couldn't create the article. Your text is still here. Try again in a
moment." moment."
And neither field shows a field-level error And the Title field shows no field-level error
Examples: Examples:
| failure | | failure |
| resolves with a 500 response | | resolves with a 500 response |
| never reaches the server | | never reaches the server |
Scenario: Retrying after an Error succeeds Scenario: Retrying after an Error succeeds
Given the modal is in the Error state after a 500, with Title "Choosing tiles for a small 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 bathroom" still in the field
floor." still in the fields
When the user activates "Create draft" again When the user activates "Create draft" again
Then POST /v1/article/ is sent again with the same JSON body Then POST /v1/article/ is sent again with the same JSON body
When it resolves 201 with the created article, id 42 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 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" for a small bathroom\" is saved as a draft.", id "article-created-42"
Scenario: Cancel discards the fields with no confirmation Scenario: Cancel discards the title with no confirmation
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph Given the modal is open with Title "Choosing tiles for a small bathroom"
"Small rooms show every grout line, so we start with the floor."
When the user activates "Cancel" When the user activates "Cancel"
Then the modal closes immediately, with no confirmation prompt Then the modal closes immediately, with no confirmation prompt
And no request is sent 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 the `/article/new` route stay pointed at the current ArticleView.vue placeholder until that page
exists; don't wire a `created` handler there yet. 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 The modal is title-only: the First paragraph field is removed and the POST body is `{"title": "..."}`
"Create draft" — this is a deliberate copy change, not a leftover. 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: 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.' })`, '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. 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, 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", 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 created article id 42, is_online false.
id 42, is_online false.
1. **Default state on open** — click "New article"; assert the modal shows title "New article", 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 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 characters.", "Cancel" and "Create draft", and no First paragraph field.
"Create draft". 2. **Blur validation** — type "Ti" into Title and blur; assert the error "Use at least 3
2. **Blur validation, Title** — 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 characters." replaces the help text and the counter "2 / 200" is in its error style; assert no
`request` call. `request` call.
3. **Blur validation, First paragraph** — with a valid Title, leave First paragraph empty and 3. **Submit with an invalid title** — Title "Ti", click "Create draft"; assert the error shows and
blur; assert "Add a first paragraph. It can be a single sentence." appears; assert no `request` is never called.
`request` call. 4. **Submitting state** — fill Title validly, click "Create draft"; assert `request` is called
4. **Submit with both fields invalid** — Title "Ti", empty First paragraph, click "Create draft"; once with `POST /v1/article/` and the JSON body exactly `{"title": "Choosing tiles for a small
assert both errors show and `request` is never called. bathroom"}`; assert Title and Cancel become disabled, the confirm button reads "Creating…";
5. **Submitting state** — fill both fields validly, click "Create draft"; assert `request` is assert pressing Escape and clicking the backdrop do not close the modal while unresolved.
called once with `POST /v1/article/` and the exact JSON body; assert both fields and Cancel 5. **Successful create hands off** — from Submitting, resolve `request` 201 with the fixture
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
article (id 42); assert the modal closes; assert the router navigated to `/article/42/edit`; 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 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 "i-lucide-circle-check", title "Draft created", and description "\"Choosing tiles for a small
bathroom\" is saved as a draft." bathroom\" is saved as a draft."
7. **Title rejected (400)** — from Submitting, resolve `request` rejecting with `ApiError(400)`; 6. **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.", assert the title is kept, the Title field shows "Check this title and try again.", and an alert
an alert reads "Something in the form isn't valid. Check the highlighted fields.", and the reads "Something in the form isn't valid. Check the highlighted fields."
First paragraph field shows no error. 7. **Error, Examples-driven** — from Submitting, (a) resolve `request` rejecting with
8. **Error, Examples-driven** — from Submitting, (a) resolve `request` rejecting with `ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert the title is
`ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert both field values kept, the alert reads "Couldn't create the article. Your text is still here. Try again in a
are 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.
moment.", and neither field shows an error. 8. **Retry after Error succeeds** — from the Error state (500), click "Create draft" again; assert
9. **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 `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 the modal closes, the router navigated to `/article/42/edit`, and the same "Draft created" toast
(id "article-created-42") is raised. (id "article-created-42") is raised.
10. **Cancel discards** — fill both fields, click "Cancel"; assert the modal closes immediately 9. **Cancel discards** — fill Title, click "Cancel"; assert the modal closes immediately with no
with no confirmation UI, and `request` was never called. confirmation UI, and `request` was never called.
## Visual (manual verification — desktop and mobile, light and dark) ## Visual (manual verification — desktop and mobile, light and dark)
Reference: `docs/design/screens/new-article.md` and `docs/design/source/RenewCMS Screens 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 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` 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 first, reusing this slice's sample data (title "Choosing tiles for a small bathroom"). This screen
paragraph "Small rooms show every grout line, so we start with the floor."). This screen has no has no time-dependent content, so no fixed `now` is needed. The board frames may still draw a body
time-dependent content, so no fixed `now` is needed. 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 - [ ] Default: empty, focused Title with counter "0 / 200", help text, "Cancel" / "Create draft".
both fields, "Cancel" / "Create draft". - [ ] Validation: Title "Ti" with counter "2 / 200" in error and "Use at least 3 characters."
- [ ] 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.
First paragraph error "Add a first paragraph. It can be a single sentence." - [ ] Title rejected (400): Title filled, error "Check this title and try again.", alert
- [ ] Submitting: both fields filled and disabled, confirm reads "Creating…" with its loading "Something in the form isn't valid. Check the highlighted fields."
state. - [ ] Error: Title filled, alert "Couldn't create the article. Your text is still here. Try
- [ ] 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
again in a moment.", no field-level error. again in a moment.", no field-level error.
- [ ] Created: no board frame; verified by Vitest (toast copy + navigation). - [ ] Created: no board frame; verified by Vitest (toast copy + navigation).
- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → First paragraph → Cancel → - [ ] Keyboard/focus: Tab reaches Title (already focused on open) → Cancel → Create draft; Esc
Create draft; Esc closes the modal in every dismissible state and is blocked while closes the modal in every dismissible state and is blocked while Submitting; the counter's
Submitting; the counter's live region announces only when its error/warn threshold is live region announces only when its error threshold is crossed, not on every keystroke,
crossed, not on every keystroke, per CharacterCounter's a11y spec. per CharacterCounter's a11y spec.
- [ ] Motion: modal open/close follows the standard 240 ms timing and stops under reduced motion. - [ ] Motion: modal open/close follows the standard 240 ms timing and stops under reduced motion.
## Manual verification ## Manual verification
- **Real POST /v1/article/ round trip against a running server** — Vitest only mocks `useApi`; - **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 confirm the real 201/400/500 responses, that a title-only create returns 201 with an empty body,
`/article/{id}/edit` against `task dev` with a live SQLite file, since automated tests never and that the created article is retrievable at `/article/{id}/edit` against `task dev` with a
exercise the real Go handler or `CreateArticleUseCase`. 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 - **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 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 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) { t.Run("A response write failure is logged", func(t *testing.T) {
repo := newEditArticleBodyTestRepo() repo := newEditArticleBodyTestRepo()
router := newEditArticleBodyRouter(repo, token) 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 { t, translate } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import CharacterCounter from '@/components/CharacterCounter.vue'
import { ApiError, useApi } from '@/composables/useApi' import { ApiError, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles' import type { Article } from '@/composables/useArticles'
@@ -17,9 +16,7 @@ const emit = defineEmits<{ created: [article: Article] }>()
const api = useApi() const api = useApi()
const title = ref('') const title = ref('')
const body = ref('')
const titleTouched = ref(false) const titleTouched = ref(false)
const bodyTouched = ref(false)
const titleFocused = ref(false) const titleFocused = ref(false)
const submitting = ref(false) const submitting = ref(false)
const titleServerError = ref('') const titleServerError = ref('')
@@ -29,7 +26,6 @@ const titleLength = computed(() => [...title.value].length)
const titleValid = computed( const titleValid = computed(
() => titleLength.value >= TITLE_MIN_LENGTH && titleLength.value <= TITLE_MAX_LENGTH, () => titleLength.value >= TITLE_MIN_LENGTH && titleLength.value <= TITLE_MAX_LENGTH,
) )
const bodyValid = computed(() => body.value.length > 0)
const titleLengthError = computed(() => { const titleLengthError = computed(() => {
if (!titleTouched.value) { if (!titleTouched.value) {
@@ -46,14 +42,12 @@ const titleLengthError = computed(() => {
const titleError = computed(() => titleServerError.value || titleLengthError.value) const titleError = computed(() => titleServerError.value || titleLengthError.value)
const bodyError = computed(() => const titleCounterInvalid = computed(
bodyTouched.value && !bodyValid.value () =>
? t('Add a first paragraph. It can be a single sentence.') 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 showHelp = computed(() => !submitting.value && formAlert.value === '')
const alertMessage = computed(() => { const alertMessage = computed(() => {
@@ -68,9 +62,7 @@ const alertMessage = computed(() => {
function resetForm() { function resetForm() {
title.value = '' title.value = ''
body.value = ''
titleTouched.value = false titleTouched.value = false
bodyTouched.value = false
titleFocused.value = false titleFocused.value = false
submitting.value = false submitting.value = false
titleServerError.value = '' titleServerError.value = ''
@@ -103,16 +95,11 @@ function onTitleBlur() {
titleFocused.value = false titleFocused.value = false
} }
function onBodyBlur() {
bodyTouched.value = true
}
async function onSubmit() { async function onSubmit() {
titleTouched.value = true titleTouched.value = true
bodyTouched.value = true
titleFocused.value = false titleFocused.value = false
if (!titleValid.value || !bodyValid.value) { if (!titleValid.value) {
return return
} }
@@ -123,7 +110,7 @@ async function onSubmit() {
const article = await api.request<Article>('/v1/article/?format=html', { const article = await api.request<Article>('/v1/article/?format=html', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: title.value, body: body.value }), body: JSON.stringify({ title: title.value }),
}) })
open.value = false open.value = false
emit('created', article) emit('created', article)
@@ -156,9 +143,10 @@ function onCancel() {
:dismissible="!submitting" :dismissible="!submitting"
:ui="{ :ui="{
content: 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', '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-5 pb-0 sm:px-6', header: 'items-start gap-3 px-6 pt-7.5 pb-0 sm:px-6 sm:pt-5',
wrapper: 'pe-10', wrapper: 'pe-10 max-sm:pe-18',
close: 'max-sm:end-8 max-sm:top-9',
title: 'text-lg/6 font-semibold', title: 'text-lg/6 font-semibold',
body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0', body: 'px-6 pt-5 pb-0 sm:px-6 sm:pt-5 sm:pb-0',
footer: 'px-6 py-5 sm:px-6', footer: 'px-6 py-5 sm:px-6',
@@ -176,19 +164,18 @@ function onCancel() {
:ui="{ root: 'gap-2 px-3 py-2.5' }" :ui="{ root: 'gap-2 px-3 py-2.5' }"
:description="alertMessage" :description="alertMessage"
/> />
<UForm :state="{ title, body }" class="flex flex-col gap-4"> <UForm :state="{ title }" class="flex flex-col gap-4">
<UFormField <UFormField
:label="t('Title')" :label="t('Title')"
name="title" name="title"
:error="translate(titleError) || undefined" :error="translate(titleError) || undefined"
> >
<template #hint> <template #hint>
<CharacterCounter <span
:value="titleLength" class="font-mono text-xs leading-tight"
:max="TITLE_MAX_LENGTH" :class="titleCounterInvalid ? 'text-error' : 'text-muted'"
:min="titleCounterMin" >{{ titleLength }} / {{ TITLE_MAX_LENGTH }}</span
plain-text >
/>
</template> </template>
<template v-if="titleError" #error="{ error }"> <template v-if="titleError" #error="{ error }">
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
@@ -214,36 +201,6 @@ function onCancel() {
@blur="onTitleBlur" @blur="onTitleBlur"
/> />
</UFormField> </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> </UForm>
</div> </div>
</template> </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) { function dispatch(element: HTMLElement, type: string) {
element.dispatchEvent(new Event(type, { bubbles: true })) element.dispatchEvent(new Event(type, { bubbles: true }))
} }
@@ -92,21 +86,8 @@ async function blurTitle() {
await flushPromises() 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() { async function fillValidForm() {
await fillTitle(TITLE) await fillTitle(TITLE)
await fillBody(BODY)
} }
afterEach(() => { 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('0 / 200')
expect(document.body.textContent).toContain('3 to 200 characters.') expect(document.body.textContent).toContain('3 to 200 characters.')
expect(bodyTextarea().value).toBe('') expect(document.body.textContent).not.toContain('First paragraph')
expect(document.body.textContent).toContain( expect(document.querySelector('textarea')).toBeNull()
'You can format it and add images in the editor.',
)
expect(findButton('Cancel')).toBeTruthy() expect(findButton('Cancel')).toBeTruthy()
expect(findButton('Create draft')).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', () => { describe('Validation — submitting with an invalid title shows the error and sends nothing', () => {
it('shows the error once the field is left empty and blurred', async () => { it('shows the error and never sends the request', async () => {
mountModal()
await flushPromises()
await fillTitle(TITLE)
await blurTitle()
await blurBody()
expect(document.body.textContent).toContain(
'Add a first paragraph. It can be a single sentence.',
)
expect(requestMock).not.toHaveBeenCalled()
})
})
describe('Validation — submitting with both fields invalid shows both errors and sends nothing', () => {
it('shows both errors and never sends the request', async () => {
mountModal() mountModal()
await flushPromises() await flushPromises()
@@ -179,9 +142,6 @@ describe('Validation — submitting with both fields invalid shows both errors a
await clickButton('Create draft') await clickButton('Create draft')
expect(document.body.textContent).toContain('Use at least 3 characters.') 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() expect(requestMock).not.toHaveBeenCalled()
}) })
}) })
@@ -209,12 +169,11 @@ describe('Submitting disables the form and cannot be dismissed', () => {
'/v1/article/?format=html', '/v1/article/?format=html',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
body: JSON.stringify({ title: TITLE, body: BODY }), body: JSON.stringify({ title: TITLE }),
}), }),
) )
expect(titleInput().disabled).toBe(true) expect(titleInput().disabled).toBe(true)
expect(bodyTextarea().disabled).toBe(true)
expect((findButton('Cancel') as HTMLButtonElement).disabled).toBe(true) expect((findButton('Cancel') as HTMLButtonElement).disabled).toBe(true)
expect(findButton('Creating…')).toBeTruthy() expect(findButton('Creating…')).toBeTruthy()
@@ -272,14 +231,10 @@ describe("Title rejected — a 400 that got past this form's own check", () => {
await flushPromises() await flushPromises()
expect(titleInput().value).toBe(TITLE) 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('Check this title and try again.')
expect(document.body.textContent).toContain( expect(document.body.textContent).toContain(
"Something in the form isn't valid. Check the highlighted fields.", "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) expect(titleInput().disabled).toBe(false)
wrapper.unmount() wrapper.unmount()
}) })
@@ -303,14 +258,10 @@ describe('Error — a 500 or a network failure keeps the text and offers to retr
await flushPromises() await flushPromises()
expect(titleInput().value).toBe(TITLE) expect(titleInput().value).toBe(TITLE)
expect(bodyTextarea().value).toBe(BODY)
expect(document.body.textContent).toContain( expect(document.body.textContent).toContain(
"Couldn't create the article. Your text is still here. Try again in a moment.", "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('Check this title and try again.')
expect(document.body.textContent).not.toContain(
'Add a first paragraph. It can be a single sentence.',
)
wrapper.unmount() wrapper.unmount()
}) })
}) })
@@ -338,7 +289,7 @@ describe('Retrying after an Error succeeds', () => {
2, 2,
'/v1/article/?format=html', '/v1/article/?format=html',
expect.objectContaining({ expect.objectContaining({
body: JSON.stringify({ title: TITLE, body: BODY }), body: JSON.stringify({ title: TITLE }),
}), }),
) )
expect(wrapper.emitted('created')).toEqual([[articleFixture()]]) expect(wrapper.emitted('created')).toEqual([[articleFixture()]])
+5 -7
View File
@@ -26,6 +26,11 @@ export const messages = {
'Saving…': 'Enregistrement…', 'Saving…': 'Enregistrement…',
Saved: 'Enregistré', Saved: 'Enregistré',
'Save failed': 'Échec de l’enregistrement', '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', 'Unsaved changes': 'Modifications non enregistrées',
'Display name': 'Nom d’affichage', 'Display name': 'Nom d’affichage',
Username: 'Nom d’utilisateur', Username: 'Nom d’utilisateur',
@@ -230,22 +235,15 @@ export const messages = {
'Search by title': 'Rechercher par titre', 'Search by title': 'Rechercher par titre',
'Clear search': 'Effacer la recherche', 'Clear search': 'Effacer la recherche',
Title: 'Titre', Title: 'Titre',
'First paragraph': 'Premier paragraphe',
'3 to 200 characters.': 'De 3 à 200 caractères.', '3 to 200 characters.': 'De 3 à 200 caractères.',
"The title can't be changed after creation.": "The title can't be changed after creation.":
'Le titre ne peut pas être modifié après la création.', '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.': '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.', '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.": "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.', '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': 'e.g. Choosing tiles for a small bathroom':
'p. ex. Choisir du carrelage pour une petite salle de bains', '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.": "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.', 'Impossible de créer l’article. Votre texte est conservé. Réessayez dans un instant.',
'Draft created': 'Brouillon créé', '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 ArticleDetails from '@/components/ArticleDetails.vue'
import SaveStatus from '@/components/SaveStatus.vue' import SaveStatus from '@/components/SaveStatus.vue'
import PublishControl from '@/components/PublishControl.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 ConfirmDialog from '@/components/ConfirmDialog.vue'
import ImageUploader from '@/components/ImageUploader.vue' import ImageUploader from '@/components/ImageUploader.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi' import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import type { Article } from '@/composables/useArticles' import type { Article } from '@/composables/useArticles'
import { useArticleDraft } from '@/composables/useArticleDraft' import { useArticleDraft } from '@/composables/useArticleDraft'
import type { MediaItem } from '@/composables/useMedia' import type { MediaItem } from '@/composables/useMedia'
import { bodyCounts } from '@/lib/bodyCounts'
import { bodyImageCount, bodyImageIds } from '@/lib/bodyImageIds' import { bodyImageCount, bodyImageIds } from '@/lib/bodyImageIds'
import { formatSize } from '@/lib/mediaFormat' import { formatSize } from '@/lib/mediaFormat'
import { useCanManageVariants } from '@/composables/useCanManageVariants' import { useCanManageVariants } from '@/composables/useCanManageVariants'
@@ -26,7 +27,6 @@ import { useSeenImagesStore } from '@/stores/seenImages'
import { useVariantCatalogStore } from '@/stores/variantCatalog' import { useVariantCatalogStore } from '@/stores/variantCatalog'
const SKELETON_DELAY_MS = 150 const SKELETON_DELAY_MS = 150
const BODY_MAX_LENGTH = 10000
type LoadState = 'loading' | 'loaded' | 'notFound' | 'error' type LoadState = 'loading' | 'loaded' | 'notFound' | 'error'
@@ -89,14 +89,11 @@ async function loadArticle() {
} }
} }
const bodyLength = computed(() => draft.body.value.length) const counts = computed(() => bodyCounts(draft.body.value))
const overLimit = computed(() => bodyLength.value > BODY_MAX_LENGTH)
const saveEnabled = computed( const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.value === 'loaded')
() => draft.state.value === 'dirty' && !overLimit.value && loadState.value === 'loaded',
)
const publishDisabled = computed(() => overLimit.value || draft.state.value === 'offline') const publishDisabled = computed(() => draft.state.value === 'offline')
async function runSave() { async function runSave() {
const outcome = await draft.save() const outcome = await draft.save()
@@ -568,10 +565,10 @@ onBeforeRouteLeave(async () => {
@request-remove-image="onRequestRemoveImage" @request-remove-image="onRequestRemoveImage"
> >
<template #footer> <template #footer>
<CharacterCounter <EditorStatusBar
variant="footer" :words="counts.words"
:value="bodyLength" :characters="counts.characters"
:max="BODY_MAX_LENGTH" :state="draft.state.value"
/> />
</template> </template>
<template #aside> <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 () => { it('never shows a skeleton when the response resolves in under 150 ms', async () => {
const wrapper = await mountEditor({ 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) 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', 'reads the Saved state for is_online $is_online',
async ({ is_online, badge, publishLabel }) => { async ({ is_online, badge, publishLabel }) => {
const wrapper = await mountEditor({ 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') 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(wrapper.text()).toContain('Saved at 11:02')
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true) expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true)
expect(wrapper.text()).toContain(publishLabel) 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', () => { function longParagraph(totalLength: number) {
it('shows the warning icon and keeps Save enabled', async () => { const words = Math.floor((totalLength - 7) / 6)
const wrapper = await mountEditor({ const inner = 'abcde '.repeat(words).trimEnd()
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), return paragraph(inner + 'x'.repeat(totalLength - 7 - inner.length))
}) }
await typeBody(wrapper, paragraphOfLength(9240))
expect(wrapper.text()).toContain('9 240 / 10 000') describe('Status bar — a neutral count of words and characters', () => {
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true) it('reads the loaded article count and follows the body', async () => {
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false) 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', () => { describe('Very long article — no length limit', () => {
it('shows the over-limit text and disables Save and Publish, then re-enables once shortened', async () => { const handlers = (patch: () => Response | Promise<Response>) => ({
const wrapper = await mountEditor({ 'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()),
'GET /v1/article/12?format=html': () => mockResponse(200, articleFixture()), 'PATCH /v1/article/12?format=html': patch,
}) })
await typeBody(wrapper, paragraphOfLength(10214))
expect(wrapper.text()).toContain('10 214 / 10 000') it('keeps the count neutral and Save and Publish enabled at 120,000 characters', async () => {
expect(wrapper.text()).toContain( const wrapper = await mountEditor(handlers(() => mockResponse(200, articleFixture())))
'214 characters over. Shorten the text or remove an image. Formatting counts too.', await typeBody(wrapper, longParagraph(120000))
)
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(true) 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 const publishButton = wrapper
.findAll('button') .findAll('button')
.find((button) => button.text() === 'Publish' || button.text() === 'Save and publish') .find((button) => button.text() === 'Save and publish')
expect((publishButton?.element as HTMLButtonElement).disabled).toBe(true) 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(wrapper.text()).toContain('Saving 120,000 characters. This can take a few seconds.')
expect((saveButton(wrapper).element as HTMLButtonElement).disabled).toBe(false) 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.value = created.title
titleInput.dispatchEvent(new Event('input', { bubbles: true })) 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() await flushPromises()
const confirmButton = Array.from(document.body.querySelectorAll('button')).find( 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() return variantsScreen()
} }
const markdown = await readFile(join(designRoot, 'screens', `${name}.md`), 'utf8') 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) { if (!reference) {
throw new Error(`docs/design/screens/${name}.md has no visual reference line`) 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 statesStart = markdown.search(/^## States$/m)
const afterStates = statesStart === -1 ? markdown : markdown.slice(statesStart) const afterStates = statesStart === -1 ? markdown : markdown.slice(statesStart)
const nextSection = afterStates.slice(1).search(/^## /m) 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) => const headingStates = [...statesMarkdown.matchAll(/^### (.+)$/gm)].map((match) =>
match[1]!.trim(), match[1]!.trim(),
) )
const states = headingStates.length ? headingStates : tableStates(statesMarkdown) const states = numberedRows.length
? numberedRows
: headingStates.length
? headingStates
: tableStates(statesMarkdown)
const primary: Source = { const primary: Source = {
board: reference[1]!, board: reference[1]!,
group: reference[2]!, group: reference[2]!,
+88 -36
View File
@@ -355,13 +355,45 @@ async function markDirty(page: Page) {
await page.keyboard.press('Backspace') await page.keyboard.press('Backspace')
} }
async function typeExactBodyLength(page: Page, totalLength: number) { const PARAGRAPH_TAGS_LENGTH = 7
const editable = page.locator('[contenteditable="true"]') const veryLongBodyLength = 120000
await editable.click() const veryLongBodyWords = 20000
await page.keyboard.press('Control+a') const EDITOR_LIST_ITEM_PARAGRAPH_TAGS = 21
await page.keyboard.press('Delete')
const inner = 'a'.repeat(Math.max(0, totalLength - 7)) function wordsIn(html: string) {
await page.keyboard.insertText(inner) 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) { 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 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> { function newArticleHomeApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
return { return articlesApi(overrides)
'GET /v1/auth/me': verifiedSession,
'GET /v1/activity': homeActivityMock,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
...overrides,
}
} }
async function openNewArticleModal(page: Page) { async function openNewArticleModal(page: Page) {
await page.getByRole('button', { name: 'New article' }).click() await page.getByRole('button', { name: 'New article' }).click()
await page.getByRole('dialog').waitFor() await page.getByRole('dialog').waitFor()
await page.mouse.move(0, 0)
} }
async function fillNewArticleValid(page: Page) { async function fillNewArticleValid(page: Page) {
await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill(newArticleTitle) await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill(newArticleTitle)
await page
.getByPlaceholder("Start writing. You'll continue in the editor.")
.fill(newArticleBody)
} }
function submitNewArticle() { function submitNewArticle() {
@@ -4709,20 +4733,48 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
await focusBoldSample(page) await focusBoldSample(page)
}, },
}, },
'Near the limit': { 'Very long article': {
path: '/article/12/edit', path: '/article/12/edit',
now: editorNow, now: editorNow,
api: editorApi(), api: veryLongApi(),
steps: async (page) => { steps: markVeryLongDirty,
await typeExactBodyLength(page, 9240) 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', path: '/article/12/edit',
now: editorNow, 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) => { 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: { Preview: {
@@ -4871,38 +4923,38 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
}, },
'new-article': { 'new-article': {
Default: { Default: {
path: '/home', path: '/article',
now: boardNow, now: articlesNow,
api: newArticleHomeApi(), api: newArticleHomeApi(),
steps: openNewArticleModal, steps: openNewArticleModal,
}, },
Validation: { Validation: {
path: '/home', path: '/article',
now: boardNow, now: articlesNow,
api: newArticleHomeApi(), api: newArticleHomeApi(),
steps: async (page) => { steps: async (page) => {
await openNewArticleModal(page) await openNewArticleModal(page)
await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill('Ti') await page.getByPlaceholder(`e.g. ${newArticleTitle}`).fill('Ti')
await page.getByPlaceholder(`e.g. ${newArticleTitle}`).blur() await page.getByPlaceholder(`e.g. ${newArticleTitle}`).blur()
await page.getByPlaceholder("Start writing. You'll continue in the editor.").click() await page.getByRole('button', { name: 'Create draft', exact: true }).click()
await page.getByPlaceholder("Start writing. You'll continue in the editor.").blur() await page.mouse.move(0, 0)
}, },
}, },
Submitting: { Submitting: {
path: '/home', path: '/article',
now: boardNow, now: articlesNow,
api: newArticleHomeApi({ 'POST /v1/article/': { status: 201, hang: true } }), api: newArticleHomeApi({ 'POST /v1/article/': { status: 201, hang: true } }),
steps: submitNewArticle(), steps: submitNewArticle(),
}, },
'Title rejected (400)': { 'Title rejected (400)': {
path: '/home', path: '/article',
now: boardNow, now: articlesNow,
api: newArticleHomeApi({ 'POST /v1/article/': { status: 400 } }), api: newArticleHomeApi({ 'POST /v1/article/': { status: 400 } }),
steps: submitNewArticle(), steps: submitNewArticle(),
}, },
Error: { Error: {
path: '/home', path: '/article',
now: boardNow, now: articlesNow,
api: newArticleHomeApi({ 'POST /v1/article/': { status: 500 } }), api: newArticleHomeApi({ 'POST /v1/article/': { status: 500 } }),
steps: submitNewArticle(), steps: submitNewArticle(),
}, },