feat: remove the 10,000-character article body limit and make the New article body optional
This commit is contained in:
1 parent
5e05b606a7
commit
10ec74c8cf
28 files changed
+860
-397
No files matched your search
@@ -0,0 +1,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"
|
||||
@@ -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).
|
||||
@@ -19,9 +19,9 @@ Feature: An authenticated user can edit an article's body
|
||||
DELETE /v1/article/{id}, PATCH /v1/article/{id}/publish and
|
||||
PATCH /v1/article/{id}/unpublish in api/router.go, so it also requires a valid
|
||||
jwt cookie
|
||||
And the request body is JSON {"body": "..."}, validated with
|
||||
validate:"required,max=10000" on the Body field, the same tag PostArticle.Body
|
||||
already carries
|
||||
And the request body is JSON {"body": "..."}; the Body field carries no validate tag
|
||||
(no max, not required), as PostArticle.Body, per
|
||||
features/article_body_unlimited.feature
|
||||
And a successful edit answers 200 with the JSON body of the updated article.Article,
|
||||
the same shape GetArticle, PostArticle, PublishArticle and UnpublishArticle
|
||||
already return; the article's title, is_online flag and images are unchanged
|
||||
@@ -30,9 +30,8 @@ Feature: An authenticated user can edit an article's body
|
||||
PublishArticle and UnpublishArticle
|
||||
And a body that is not valid JSON answers 400 with body apperr.BodyErrorMessage,
|
||||
the same as PostArticle
|
||||
And a body that fails its own required/max=10000 validation answers 400 with the
|
||||
raw validator error text, the same as PostArticle already does for its own
|
||||
fields; no new apperr message is introduced for this
|
||||
And no body value fails validation: an empty or absent body and a body of any length
|
||||
are accepted
|
||||
And an id absent from the article repository answers 404 with body
|
||||
apperr.ArticleNotFoundErrorMessage, backed by
|
||||
errors.Is(err, article.ErrArticleNotFound), the same as DeleteArticle,
|
||||
@@ -84,11 +83,12 @@ Feature: An authenticated user can edit an article's body
|
||||
Then the response status is 400
|
||||
And the response body is the text apperr.BodyErrorMessage
|
||||
|
||||
Scenario Outline: A body value that fails validation answers 400
|
||||
Scenario Outline: A body of any length, including empty, is accepted
|
||||
Given a valid jwt cookie for a user with id 1
|
||||
And the article repository contains an article with id 1
|
||||
When the client sends PATCH /v1/article/1 with that cookie and body <payload>
|
||||
Then the response status is 400
|
||||
Then the response status is 200
|
||||
And the stored body equals the sent body
|
||||
|
||||
Examples:
|
||||
| payload |
|
||||
|
||||
@@ -41,11 +41,10 @@ Package: `test` (black-box, extending `article_test.go`).
|
||||
string `not-json`), repo seeded with article id 1 -> status `400`, body matching
|
||||
`apperr.BodyErrorMessage + "\n"`.
|
||||
13. `PATCH /v1/article/1` with the valid cookie and JSON body `{"body": ""}`, repo
|
||||
seeded with article id 1 -> status `400`; assert `repo.articles[1].Body` is
|
||||
unchanged (nothing stored).
|
||||
seeded with article id 1 -> status `200`; assert `repo.articles[1].Body` is `""`.
|
||||
14. `PATCH /v1/article/1` with the valid cookie and JSON body `{"body": "<10001-char
|
||||
string>"}`, repo seeded with article id 1 -> status `400`; assert
|
||||
`repo.articles[1].Body` is unchanged.
|
||||
string>"}`, repo seeded with article id 1 -> status `200`; assert
|
||||
`repo.articles[1].Body` equals the sent string.
|
||||
15. `PATCH /v1/article/1` with **no** cookie, repo seeded with article id 1 -> status
|
||||
`401`.
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ Feature: Editor screen — load, edit and save an article's body
|
||||
the article is a draft; clicking PublishControl itself is out of scope for this slice and
|
||||
is specified in a follow-on slice (editor_publish_and_delete)
|
||||
And Save is enabled only when useArticleDraft's state is "dirty", the request is not already
|
||||
in flight, the app is not offline, and the body's HTML length is at most 10000 characters;
|
||||
it is disabled in every other case
|
||||
in flight, and the app is not offline, whatever the body's length; it is disabled in every
|
||||
other case
|
||||
And Save (a click, or Ctrl/⌘ S) sends PATCH /v1/article/{id} with JSON {"body": "..."},
|
||||
the same contract features/edit_article_body.feature already gives the API: 200 with the
|
||||
updated article.Article, 400 for a body over 10000 characters, 404 for an id absent from
|
||||
updated article.Article (any body length, including empty), 404 for an id absent from
|
||||
the repository, 401 with no valid session
|
||||
And a successful save (200) sets useArticleDraft's state to "saved" and records the save
|
||||
time; SaveStatus shows "Saved at {HH:MM}" for the first hour and "Saved {n} min ago" after
|
||||
@@ -43,11 +43,11 @@ Feature: Editor screen — load, edit and save an article's body
|
||||
(features/api_client_global_error_handling.feature): before the redirect, useArticleDraft
|
||||
mirrors the current dirty body to sessionStorage keyed by the article's id, so it survives
|
||||
the redirect to /login?redirect=/article/{id}/edit&reason=expired
|
||||
And CharacterCounter shows "{len} / 10 000" below the editor, using the body's HTML string
|
||||
length (formatting and images count); at 9000 characters or more (90%, "Near the limit")
|
||||
it adds a warning icon and stays enabled; above 10000 ("Too long") it adds an icon and the
|
||||
text "{over} characters over. Shorten the text or remove an image. Formatting counts too.",
|
||||
and disables both Save and PublishControl until the length is 10000 or fewer again
|
||||
And EditorStatusBar shows a neutral "{words} words · {characters} characters" count with
|
||||
en-US thousands separators (e.g. "1,204 words · 7,318 characters", "0 words · 0 characters"
|
||||
for an empty body); characters is the body's HTML string length, words are the
|
||||
whitespace-separated tokens of its text content; the count has no limit, warning or error
|
||||
styling, per features/article_body_unlimited.feature
|
||||
And a Preview switch, when on, replaces the editable ArticleEditor and its toolbar with a
|
||||
read-only rendering of the title and body in the content type scale; switching it off
|
||||
restores the editable surface with the same content, unchanged
|
||||
@@ -93,7 +93,7 @@ Feature: Editor screen — load, edit and save an article's body
|
||||
And SaveStatus reads "Saved at 11:02"
|
||||
And the Save button is disabled
|
||||
And PublishControl reads "<publish_label>"
|
||||
And the character counter reads "4 318 / 10 000"
|
||||
And the count reads "{words} words · 4,318 characters"
|
||||
|
||||
Examples:
|
||||
| is_online | badge | publish_label |
|
||||
@@ -161,22 +161,12 @@ Feature: Editor screen — load, edit and save an article's body
|
||||
Then a toast reads "That article no longer exists."
|
||||
And SaveStatus does not read "Save failed"
|
||||
|
||||
Scenario: Near the limit — the counter warns at 9000 characters without blocking Save
|
||||
Scenario: A very long body keeps a neutral count and leaves Save and Publish enabled
|
||||
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
|
||||
9240 characters
|
||||
Then the character counter reads "9 240 / 10 000" with a warning icon
|
||||
And the Save button remains enabled
|
||||
|
||||
Scenario: Too long — over 10000 characters disables Save and Publish until shortened
|
||||
Given the user is on /article/12/edit with unsaved changes making the body's HTML length
|
||||
10214 characters
|
||||
Then the character counter reads "10 214 / 10 000" with the text "214 characters over.
|
||||
Shorten the text or remove an image. Formatting counts too."
|
||||
And the Save button is disabled
|
||||
And PublishControl is disabled
|
||||
When the user shortens the body to 10000 characters or fewer
|
||||
Then the character counter no longer shows the over-limit text
|
||||
And the Save button is enabled again
|
||||
120000 characters
|
||||
Then the count reads "19,845 words · 120,000 characters" with no warning styling
|
||||
And the Save button is enabled
|
||||
And PublishControl is enabled
|
||||
|
||||
Scenario: Preview shows the rendered title and body without the editing surface
|
||||
Given the user is on /article/12/edit, article titled "How we price a bathroom renovation"
|
||||
|
||||
@@ -35,7 +35,7 @@ Fixture article (reused across cases unless a scenario overrides a field): id 12
|
||||
2. **Saved state, draft and online** — Examples-driven on `is_online`: mock `GET
|
||||
/v1/article/12` resolving 200 with the fixture at each `is_online` value; assert the
|
||||
breadcrumb, ArticleStatusBadge ("Draft"/"Online"), SaveStatus "Saved at 11:02", Save
|
||||
disabled, PublishControl ("Publish"/"Unpublish"), and character counter "4 318 / 10 000".
|
||||
disabled, PublishControl ("Publish"/"Unpublish"), and the neutral count ending "4,318 characters".
|
||||
3. **Unsaved changes** — from the Saved state, type into the body; assert SaveStatus "Unsaved
|
||||
changes", Save enabled, PublishControl now "Save and publish".
|
||||
4. **Saving via click and via Ctrl/⌘ S** — from Unsaved changes, (a) click Save, (b) press
|
||||
@@ -53,12 +53,10 @@ Fixture article (reused across cases unless a scenario overrides a field): id 12
|
||||
9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve
|
||||
`PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
|
||||
reads "Save failed".
|
||||
10. **Near the limit** — set the body's HTML length to 9240 (via a fixture string of that
|
||||
length); assert counter "9 240 / 10 000" with a warning icon and Save still enabled.
|
||||
11. **Too long** — set the body's HTML length to 10214; assert counter "10 214 / 10 000" with
|
||||
"214 characters over. Shorten the text or remove an image. Formatting counts too.", Save
|
||||
disabled, PublishControl disabled; shorten to 10000; assert both re-enable and the over-limit
|
||||
text disappears.
|
||||
10. **Very long body** — set the body's HTML length to 120000 (via a fixture string of that
|
||||
length); assert the count "19,845 words · 120,000 characters" with no warning styling, Save
|
||||
and PublishControl enabled.
|
||||
11. **No limit states** — assert no "Near the limit" or "Too long" text is ever rendered.
|
||||
12. **Preview toggle** — from a loaded article with body content, switch Preview on; assert the
|
||||
toolbar and editable surface are gone and a read-only rendering of title+body appears;
|
||||
switch off; assert the editable surface returns with the same content.
|
||||
@@ -97,12 +95,11 @@ reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updat
|
||||
|
||||
- [ ] Loading: skeletons for title, content and details, shaped like the real layout.
|
||||
- [ ] Saved: breadcrumb, "Draft" badge, "Saved at 11:02", disabled Save, "Publish", counter
|
||||
"4 318 / 10 000", details panel (Created/Updated/ID/Images/Public API URL).
|
||||
"N words · 4,318 characters", details panel (Created/Updated/ID/Images/Public API URL).
|
||||
- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
|
||||
- [ ] Saving: "Saving…" SaveStatus, disabled Save.
|
||||
- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec.
|
||||
- [ ] Near the limit: counter "9 240 / 10 000" with the warning icon/style.
|
||||
- [ ] Too long: counter "10 214 / 10 000" with the over-limit text and disabled Save/Publish.
|
||||
- [ ] Very long article: count "19,845 words · 120,000 characters", neutral, Save/Publish enabled.
|
||||
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden.
|
||||
- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
|
||||
Save/Publish, OfflineBanner visible.
|
||||
|
||||
@@ -69,7 +69,7 @@ Feature: Integration screen (API reference)
|
||||
And a "Request body" table lists:
|
||||
| Name | Type | Required | Rule |
|
||||
| title | string | Yes | 3 to 200 characters. Can't be changed later. |
|
||||
| body | string | Yes | Up to 10 000 characters. |
|
||||
| body | string | No | No length limit. |
|
||||
And tabs fetch, curl and Response show, fetch active, with a CodeBlock labelled "JavaScript" and a ghost "Copy" button
|
||||
And an "Errors" list shows a status chip with a one-line caption for 400, 401
|
||||
|
||||
@@ -90,8 +90,8 @@ Feature: Integration screen (API reference)
|
||||
Then <method> <path> lists these fields: <fields>
|
||||
Examples:
|
||||
| method | path | fields |
|
||||
| POST | /v1/article/ | title string required 3-200; body string required max 10000 |
|
||||
| PATCH | /v1/article/{id} | body string required max 10000 |
|
||||
| POST | /v1/article/ | title string required 3-200; body string optional, no length limit |
|
||||
| PATCH | /v1/article/{id} | body string optional, no length limit |
|
||||
| PATCH | /v1/media/{id} | filename string required |
|
||||
| POST | /v1/media/merge | keep_id number required >0; merge_ids number[] required min 1 each >0 |
|
||||
| POST | /v1/media/ | multipart/form-data, 1 file field named "file" |
|
||||
|
||||
@@ -28,7 +28,7 @@ Catalogue spec (pure data, no mount, imports web/src/data/apiCatalogue.ts):
|
||||
- invite: id, kind, email, status, created_at, expires_at;
|
||||
- settings: version.
|
||||
Any other key fails the spec. "images" and "status" are explicitly rejected on articles.
|
||||
5. Request-body tables equal the validate tags (title 3-200, body max 10000, filename, keep_id,
|
||||
5. Request-body tables equal the validate tags (title 3-200, body optional with no length limit, filename, keep_id,
|
||||
merge_ids, display_name, email). The multipart field is "file". Query tables: `format` on every
|
||||
article route, and q, hash, cursor, filter, sort, limit on GET /v1/media/.
|
||||
6. The three invite routes carry "Owner only", a 403 "Only the owner can do this." caption and the
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
Feature: Creating a new article from NewArticleModal
|
||||
|
||||
As a verified RenewCMS owner
|
||||
I want NewArticleModal to take a title and a first paragraph, create the article as a draft
|
||||
over POST /v1/article/, and take me straight to the editor to keep writing
|
||||
So that starting a new article is a two-field, low-friction step instead of a full form, and I
|
||||
land where I can actually write it
|
||||
I want NewArticleModal to take only a title, create the article as a draft
|
||||
over POST /v1/article/, and take me straight to the editor to write it
|
||||
So that starting a new article is a one-field, low-friction step and I land where I can
|
||||
actually write it
|
||||
|
||||
Background:
|
||||
Given NewArticleModal is a UModal opened from Home's "New article" action (mobile header
|
||||
@@ -14,42 +14,37 @@ Feature: Creating a new article from NewArticleModal
|
||||
And every time it opens, it starts in the Default state: title "New article", text "It's
|
||||
saved as a draft. Titles can't be changed later, so check it now.", an empty, focused Title
|
||||
field with placeholder "e.g. Choosing tiles for a small bathroom", a CharacterCounter
|
||||
reading "0 / 200" and help "3 to 200 characters.", an empty First paragraph textarea with
|
||||
placeholder "Start writing. You'll continue in the editor." and help "You can format it and
|
||||
add images in the editor.", a "Cancel" button and a "Create draft" button
|
||||
reading "0 / 200" and help "3 to 200 characters.", a "Cancel" button and a "Create draft"
|
||||
button; there is no body or First paragraph field
|
||||
And the Title field is sent as the request's title and is validated required, 3 to 200
|
||||
characters, counted the same way as the CharacterCounter ([...str].length, which is rune
|
||||
count, matching article_title_rules.feature's server-side rune counting)
|
||||
And the First paragraph field is sent as the request's body and is validated required (at
|
||||
least one character)
|
||||
And each field validates on blur and again on submit; submitting while either field is
|
||||
invalid sends no request, and shows both fields' errors at once if both are invalid: Title
|
||||
under 3 characters shows "Use at least 3 characters." in place of its help text, with the
|
||||
counter in its error style; an empty First paragraph shows "Add a first paragraph. It can
|
||||
be a single sentence." in place of its help text
|
||||
And activating "Create draft" while both fields are valid sends POST /v1/article/ with JSON
|
||||
{"title": "<title>", "body": "<first paragraph>"}; while the request is in flight both
|
||||
fields and both buttons are disabled, the confirm button reads "Creating…" with a loading
|
||||
indicator, and the modal cannot be dismissed by Cancel, Esc or a backdrop click
|
||||
And the request carries no body key; the server treats an absent body as "" per
|
||||
features/article_body_unlimited.feature
|
||||
And the Title field validates on blur and again on submit; submitting while it is invalid
|
||||
sends no request: a title under 3 characters shows "Use at least 3 characters." in place of
|
||||
its help text, with the counter in its error style
|
||||
And activating "Create draft" while the title is valid sends POST /v1/article/ with JSON
|
||||
{"title": "<title>"}; while the request is in flight the Title field and both buttons are
|
||||
disabled, the confirm button reads "Creating…" with a loading indicator, and the modal
|
||||
cannot be dismissed by Cancel, Esc or a backdrop click
|
||||
And a 201 response closes the modal, emits created(article) with the response body, and (for
|
||||
this slice) HomeView.vue's created handler navigates to /article/{id}/edit and raises a
|
||||
toast: color success, icon i-lucide-circle-check, title "Draft created", description
|
||||
"\"{title}\" is saved as a draft.", with id "article-created-{id}" per
|
||||
docs/design/components.md's toast-id convention
|
||||
And a 400 response — whether apperr.ArticleTitleErrorMessage for a title that passed this
|
||||
form's own check but still failed the server's, or any other 400 — is never shown verbatim;
|
||||
it re-enables the form with both fields' current values kept, shows "Check this title and
|
||||
try again." in place of the Title field's help text, and shows an alert "Something in the
|
||||
form isn't valid. Check the highlighted fields."; the First paragraph field shows no error
|
||||
And a 500 response or a request that never reaches the server re-enables the form with both
|
||||
fields' current values kept and shows an alert "Couldn't create the article. Your text is
|
||||
still here. Try again in a moment.", with no field-level error
|
||||
And "Cancel" always closes the modal immediately and discards both fields, with no
|
||||
And a 400 response is never shown verbatim; it re-enables the form with the title kept,
|
||||
shows "Check this title and try again." in place of the Title field's help text, and shows
|
||||
an alert "Something in the form isn't valid. Check the highlighted fields."
|
||||
And a 500 response or a request that never reaches the server re-enables the form with the
|
||||
title kept and shows an alert "Couldn't create the article. Your text is still here. Try
|
||||
again in a moment.", with no field-level error
|
||||
And "Cancel" always closes the modal immediately and discards the title, with no
|
||||
confirmation prompt, whenever the form is not disabled by an in-flight request
|
||||
And a 401 response is handled entirely by useApi's existing global handling
|
||||
(features/api_client_global_error_handling.feature): the session is cleared and the app
|
||||
navigates to /login?redirect=<path>&reason=expired; this slice adds no draft-preservation
|
||||
behavior for the modal's fields, unlike the editor's session-expiry handling
|
||||
behavior for the modal's field, unlike the editor's session-expiry handling
|
||||
|
||||
Scenario: Opening the modal shows the Default state
|
||||
Given the user is on /home
|
||||
@@ -57,7 +52,7 @@ Feature: Creating a new article from NewArticleModal
|
||||
Then NewArticleModal opens with title "New article" and the text "It's saved as a draft.
|
||||
Titles can't be changed later, so check it now."
|
||||
And the Title field is empty, focused, and its counter reads "0 / 200"
|
||||
And the First paragraph field is empty
|
||||
And there is no First paragraph field
|
||||
And "Create draft" is present but the form has not been submitted
|
||||
|
||||
Scenario: Validation — a title under 3 characters on blur
|
||||
@@ -67,34 +62,23 @@ Feature: Creating a new article from NewArticleModal
|
||||
And the counter reads "2 / 200" in its error style
|
||||
And no request is sent
|
||||
|
||||
Scenario: Validation — an empty first paragraph on blur
|
||||
Given the modal is open with a valid Title
|
||||
When the user leaves the First paragraph field empty and moves focus away
|
||||
Then the First paragraph field shows the error "Add a first paragraph. It can be a single
|
||||
sentence."
|
||||
And no request is sent
|
||||
|
||||
Scenario: Validation — submitting with both fields invalid shows both errors and sends nothing
|
||||
Given the modal is open with Title "Ti" and an empty First paragraph
|
||||
Scenario: Validation — submitting an invalid title sends nothing
|
||||
Given the modal is open with Title "Ti"
|
||||
When the user activates "Create draft"
|
||||
Then the Title field shows "Use at least 3 characters." and the First paragraph field shows
|
||||
"Add a first paragraph. It can be a single sentence."
|
||||
Then the Title field shows "Use at least 3 characters."
|
||||
And POST /v1/article/ is never sent
|
||||
|
||||
Scenario: Submitting disables the form and cannot be dismissed
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
|
||||
"Small rooms show every grout line, so we start with the floor."
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom"
|
||||
When the user activates "Create draft"
|
||||
Then POST /v1/article/ is sent with JSON {"title": "Choosing tiles for a small bathroom",
|
||||
"body": "Small rooms show every grout line, so we start with the floor."}
|
||||
And both fields become disabled, "Cancel" becomes disabled, and the confirm button reads
|
||||
Then POST /v1/article/ is sent with JSON {"title": "Choosing tiles for a small bathroom"}
|
||||
And the Title field becomes disabled, "Cancel" becomes disabled, and the confirm button reads
|
||||
"Creating…" with a loading indicator
|
||||
And pressing Esc or clicking the backdrop does not close the modal
|
||||
|
||||
Scenario: A successful create closes the modal and hands off to the editor with a toast
|
||||
Given the user is on /home with the modal open, Title "Choosing tiles for a small bathroom"
|
||||
and First paragraph "Small rooms show every grout line, so we start with the floor.", and
|
||||
"Create draft" has been activated
|
||||
Given the user is on /home with the modal open, Title "Choosing tiles for a small bathroom",
|
||||
and "Create draft" has been activated
|
||||
When POST /v1/article/ resolves 201 with the created article, id 42, is_online false
|
||||
Then the modal closes
|
||||
And the app navigates to /article/42/edit
|
||||
@@ -104,33 +88,29 @@ Feature: Creating a new article from NewArticleModal
|
||||
|
||||
Scenario: Title rejected — a 400 that got past this form's own check
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" (already valid by
|
||||
this form's own rule) and First paragraph "Small rooms show every grout line, so we start
|
||||
with the floor.", and "Create draft" has been activated
|
||||
this form's own rule), and "Create draft" has been activated
|
||||
When POST /v1/article/ resolves 400
|
||||
Then the modal re-enables, keeping both field values
|
||||
Then the modal re-enables, keeping the title
|
||||
And the Title field shows "Check this title and try again." in place of its help text
|
||||
And an alert reads "Something in the form isn't valid. Check the highlighted fields."
|
||||
And the First paragraph field shows no error
|
||||
|
||||
Scenario Outline: Error — a 500 or a network failure keeps the text and offers to retry
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
|
||||
"Small rooms show every grout line, so we start with the floor.", and "Create draft" has
|
||||
been activated
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom", and "Create draft"
|
||||
has been activated
|
||||
When POST /v1/article/ <failure>
|
||||
Then the modal re-enables, keeping both field values
|
||||
Then the modal re-enables, keeping the title
|
||||
And an alert reads "Couldn't create the article. Your text is still here. Try again in a
|
||||
moment."
|
||||
And neither field shows a field-level error
|
||||
And the Title field shows no field-level error
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Retrying after an Error succeeds
|
||||
Given the modal is in the Error state after a 500, with Title "Choosing tiles for a small
|
||||
bathroom" and First paragraph "Small rooms show every grout line, so we start with the
|
||||
floor." still in the fields
|
||||
bathroom" still in the field
|
||||
When the user activates "Create draft" again
|
||||
Then POST /v1/article/ is sent again with the same JSON body
|
||||
When it resolves 201 with the created article, id 42
|
||||
@@ -138,9 +118,8 @@ Feature: Creating a new article from NewArticleModal
|
||||
success, icon i-lucide-circle-check, title "Draft created", description "\"Choosing tiles
|
||||
for a small bathroom\" is saved as a draft.", id "article-created-42"
|
||||
|
||||
Scenario: Cancel discards the fields with no confirmation
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom" and First paragraph
|
||||
"Small rooms show every grout line, so we start with the floor."
|
||||
Scenario: Cancel discards the title with no confirmation
|
||||
Given the modal is open with Title "Choosing tiles for a small bathroom"
|
||||
When the user activates "Cancel"
|
||||
Then the modal closes immediately, with no confirmation prompt
|
||||
And no request is sent
|
||||
@@ -7,8 +7,10 @@ Only HomeView.vue is wired as a real `created` consumer in this slice: it listen
|
||||
the `/article/new` route stay pointed at the current ArticleView.vue placeholder until that page
|
||||
exists; don't wire a `created` handler there yet.
|
||||
|
||||
The confirm button's label changes from the placeholder's "Create article" to the design's
|
||||
"Create draft" — this is a deliberate copy change, not a leftover.
|
||||
The modal is title-only: the First paragraph field is removed and the POST body is `{"title": "..."}`
|
||||
with no `body` key (see features/article_body_unlimited.feature).
|
||||
|
||||
The confirm button's label is the design's "Create draft".
|
||||
|
||||
The Created toast uses `useToast().add({ id: 'article-created-{id}', color: 'success', icon:
|
||||
'i-lucide-circle-check', title: 'Draft created', description: '"{title}" is saved as a draft.' })`,
|
||||
@@ -20,77 +22,70 @@ section (success/info close after 5 s).
|
||||
Mock `useApi`'s `request` for `POST /v1/article/` per case; no real backend, no httptest server.
|
||||
Mount HomePage (which owns NewArticleModal) inside DashboardShell with a resolved verified session,
|
||||
and a test router at `/home`. Sample data throughout: title "Choosing tiles for a small bathroom",
|
||||
first paragraph "Small rooms show every grout line, so we start with the floor.", created article
|
||||
id 42, is_online false.
|
||||
created article id 42, is_online false.
|
||||
|
||||
1. **Default state on open** — click "New article"; assert the modal shows title "New article",
|
||||
the intro text, an empty focused Title field with counter "0 / 200" and help "3 to 200
|
||||
characters.", an empty First paragraph field with its placeholder and help, "Cancel" and
|
||||
"Create draft".
|
||||
2. **Blur validation, Title** — type "Ti" into Title and blur; assert the error "Use at least 3
|
||||
characters.", "Cancel" and "Create draft", and no First paragraph field.
|
||||
2. **Blur validation** — type "Ti" into Title and blur; assert the error "Use at least 3
|
||||
characters." replaces the help text and the counter "2 / 200" is in its error style; assert no
|
||||
`request` call.
|
||||
3. **Blur validation, First paragraph** — with a valid Title, leave First paragraph empty and
|
||||
blur; assert "Add a first paragraph. It can be a single sentence." appears; assert no
|
||||
`request` call.
|
||||
4. **Submit with both fields invalid** — Title "Ti", empty First paragraph, click "Create draft";
|
||||
assert both errors show and `request` is never called.
|
||||
5. **Submitting state** — fill both fields validly, click "Create draft"; assert `request` is
|
||||
called once with `POST /v1/article/` and the exact JSON body; assert both fields and Cancel
|
||||
become disabled, the confirm button reads "Creating…"; assert pressing Escape and clicking the
|
||||
backdrop do not close the modal while unresolved.
|
||||
6. **Successful create hands off** — from Submitting, resolve `request` 201 with the fixture
|
||||
3. **Submit with an invalid title** — Title "Ti", click "Create draft"; assert the error shows and
|
||||
`request` is never called.
|
||||
4. **Submitting state** — fill Title validly, click "Create draft"; assert `request` is called
|
||||
once with `POST /v1/article/` and the JSON body exactly `{"title": "Choosing tiles for a small
|
||||
bathroom"}`; assert Title and Cancel become disabled, the confirm button reads "Creating…";
|
||||
assert pressing Escape and clicking the backdrop do not close the modal while unresolved.
|
||||
5. **Successful create hands off** — from Submitting, resolve `request` 201 with the fixture
|
||||
article (id 42); assert the modal closes; assert the router navigated to `/article/42/edit`;
|
||||
assert a toast was raised with id "article-created-42", color success, icon
|
||||
"i-lucide-circle-check", title "Draft created", and description "\"Choosing tiles for a small
|
||||
bathroom\" is saved as a draft."
|
||||
7. **Title rejected (400)** — from Submitting, resolve `request` rejecting with `ApiError(400)`;
|
||||
assert both field values are kept, the Title field shows "Check this title and try again.",
|
||||
an alert reads "Something in the form isn't valid. Check the highlighted fields.", and the
|
||||
First paragraph field shows no error.
|
||||
8. **Error, Examples-driven** — from Submitting, (a) resolve `request` rejecting with
|
||||
`ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert both field values
|
||||
are kept, the alert reads "Couldn't create the article. Your text is still here. Try again in a
|
||||
moment.", and neither field shows an error.
|
||||
9. **Retry after Error succeeds** — from the Error state (500), click "Create draft" again; assert
|
||||
6. **Title rejected (400)** — from Submitting, resolve `request` rejecting with `ApiError(400)`;
|
||||
assert the title is kept, the Title field shows "Check this title and try again.", and an alert
|
||||
reads "Something in the form isn't valid. Check the highlighted fields."
|
||||
7. **Error, Examples-driven** — from Submitting, (a) resolve `request` rejecting with
|
||||
`ApiError(500)`, (b) resolve it rejecting with `NetworkError`; in each, assert the title is
|
||||
kept, the alert reads "Couldn't create the article. Your text is still here. Try again in a
|
||||
moment.", and the field shows no error.
|
||||
8. **Retry after Error succeeds** — from the Error state (500), click "Create draft" again; assert
|
||||
`request` is called a second time with the same JSON body; resolve 201 with the fixture; assert
|
||||
the modal closes, the router navigated to `/article/42/edit`, and the same "Draft created" toast
|
||||
(id "article-created-42") is raised.
|
||||
10. **Cancel discards** — fill both fields, click "Cancel"; assert the modal closes immediately
|
||||
with no confirmation UI, and `request` was never called.
|
||||
9. **Cancel discards** — fill Title, click "Cancel"; assert the modal closes immediately with no
|
||||
confirmation UI, and `request` was never called.
|
||||
|
||||
## Visual (manual verification — desktop and mobile, light and dark)
|
||||
|
||||
Reference: `docs/design/screens/new-article.md` and `docs/design/source/RenewCMS Screens
|
||||
Back-office.dc.html`, group "New article". Run `task visual:check -- new-article` for each state
|
||||
below and compare against the board frame; add each state's mock setup to `web/visual/states.ts`
|
||||
first, reusing this slice's sample data (title "Choosing tiles for a small bathroom", first
|
||||
paragraph "Small rooms show every grout line, so we start with the floor."). This screen has no
|
||||
time-dependent content, so no fixed `now` is needed.
|
||||
first, reusing this slice's sample data (title "Choosing tiles for a small bathroom"). This screen
|
||||
has no time-dependent content, so no fixed `now` is needed. The board frames may still draw a body
|
||||
line in Submitting, Title rejected and Error; the design .md files are authoritative for the
|
||||
title-only modal and the boards are a known follow-up.
|
||||
|
||||
- [ ] Default: empty, focused Title with counter "0 / 200", empty First paragraph, help text on
|
||||
both fields, "Cancel" / "Create draft".
|
||||
- [ ] Validation: Title "Ti" with counter "2 / 200" in error and "Use at least 3 characters.";
|
||||
First paragraph error "Add a first paragraph. It can be a single sentence."
|
||||
- [ ] Submitting: both fields filled and disabled, confirm reads "Creating…" with its loading
|
||||
state.
|
||||
- [ ] Title rejected (400): both fields filled, Title error "Check this title and try again.",
|
||||
alert "Something in the form isn't valid. Check the highlighted fields."
|
||||
- [ ] Error: both fields filled, alert "Couldn't create the article. Your text is still here. Try
|
||||
- [ ] Default: empty, focused Title with counter "0 / 200", help text, "Cancel" / "Create draft".
|
||||
- [ ] Validation: Title "Ti" with counter "2 / 200" in error and "Use at least 3 characters."
|
||||
- [ ] Submitting: Title filled and disabled, confirm reads "Creating…" with its loading state.
|
||||
- [ ] Title rejected (400): Title filled, error "Check this title and try again.", alert
|
||||
"Something in the form isn't valid. Check the highlighted fields."
|
||||
- [ ] Error: Title filled, alert "Couldn't create the article. Your text is still here. Try
|
||||
again in a moment.", no field-level error.
|
||||
- [ ] Created: no board frame; verified by Vitest (toast copy + navigation).
|
||||
- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → First paragraph → Cancel →
|
||||
Create draft; Esc closes the modal in every dismissible state and is blocked while
|
||||
Submitting; the counter's live region announces only when its error/warn threshold is
|
||||
crossed, not on every keystroke, per CharacterCounter's a11y spec.
|
||||
- [ ] Keyboard/focus: Tab reaches Title (already focused on open) → Cancel → Create draft; Esc
|
||||
closes the modal in every dismissible state and is blocked while Submitting; the counter's
|
||||
live region announces only when its error threshold is crossed, not on every keystroke,
|
||||
per CharacterCounter's a11y spec.
|
||||
- [ ] Motion: modal open/close follows the standard 240 ms timing and stops under reduced motion.
|
||||
|
||||
## Manual verification
|
||||
|
||||
- **Real POST /v1/article/ round trip against a running server** — Vitest only mocks `useApi`;
|
||||
confirm the real 201/400/500 responses and that a genuinely-created article is retrievable at
|
||||
`/article/{id}/edit` against `task dev` with a live SQLite file, since automated tests never
|
||||
exercise the real Go handler or `CreateArticleUseCase`.
|
||||
confirm the real 201/400/500 responses, that a title-only create returns 201 with an empty body,
|
||||
and that the created article is retrievable at `/article/{id}/edit` against `task dev` with a
|
||||
live SQLite file, since automated tests never exercise the real Go handler or
|
||||
`CreateArticleUseCase`.
|
||||
- **Toast placement and stacking on a live app** — confirm the "Draft created" toast's real
|
||||
position, duration and stacking behavior (per docs/design/components.md's Toast rules) against
|
||||
a running app, since Vitest only asserts that `useToast().add` was called with the right
|
||||
|
||||
Reference in new issue
Block a user