Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6566743a7 | ||
|
|
10ec74c8cf |
No files matched your search
@@ -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"
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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").
|
||||||
|
|||||||
@@ -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" |
|
||||||
@@ -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
|
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 |
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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" |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,23 +88,20 @@ 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 |
|
||||||
@@ -129,8 +110,7 @@ Feature: Creating a new article from NewArticleModal
|
|||||||
|
|
||||||
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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { isLargeBody } from '@/lib/bodyCounts'
|
||||||
|
|
||||||
|
const NUMBER_LOCALE = 'en-US'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
words: number
|
||||||
|
characters: number
|
||||||
|
state: 'clean' | 'dirty' | 'saving' | 'saved' | 'error' | 'offline'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function formatCount(count: number) {
|
||||||
|
return count.toLocaleString(NUMBER_LOCALE)
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = computed(() => {
|
||||||
|
if (props.state === 'saving' && isLargeBody(props.characters)) {
|
||||||
|
return t('Saving {0} characters. This can take a few seconds.', [
|
||||||
|
formatCount(props.characters),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (props.state === 'error' && isLargeBody(props.characters)) {
|
||||||
|
return t("Couldn't save. Your text is kept. Retry.")
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const countText = computed(() =>
|
||||||
|
t('{0} words · {1} characters', [formatCount(props.words), formatCount(props.characters)]),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-3 border-t border-default bg-default px-6 py-2">
|
||||||
|
<span class="flex-1 text-xs text-muted" :class="{ 'text-error': state === 'error' }">{{
|
||||||
|
message
|
||||||
|
}}</span>
|
||||||
|
<span class="font-mono text-xs text-muted">{{ countText }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
import { t, translate } from '@/i18n'
|
import { 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()]])
|
||||||
|
|||||||
@@ -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éé',
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { bodyCounts, isLargeBody, LARGE_BODY_CHARACTERS } from '../bodyCounts'
|
||||||
|
|
||||||
|
describe('bodyCounts', () => {
|
||||||
|
it('counts words across block boundaries and characters as the string length', () => {
|
||||||
|
const html =
|
||||||
|
'<h2>Price</h2><ul><li>One</li><li>Two</li></ul><p>A <strong>bold</strong> end.</p>'
|
||||||
|
|
||||||
|
expect(bodyCounts(html)).toEqual({ words: 6, characters: html.length })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits adjacent blocks and line breaks into separate words', () => {
|
||||||
|
expect(bodyCounts('<p>one</p><p>two</p>').words).toBe(2)
|
||||||
|
expect(bodyCounts('<p>one<br>two</p>').words).toBe(2)
|
||||||
|
expect(bodyCounts('<p> one two </p>').words).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts no words in markup without text', () => {
|
||||||
|
expect(bodyCounts('<p> </p>')).toEqual({ words: 0, characters: 8 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads an empty document as zero', () => {
|
||||||
|
expect(bodyCounts('')).toEqual({ words: 0, characters: 0 })
|
||||||
|
expect(bodyCounts('<p></p>')).toEqual({ words: 0, characters: 0 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isLargeBody', () => {
|
||||||
|
it('flips exactly at the threshold', () => {
|
||||||
|
expect(isLargeBody(LARGE_BODY_CHARACTERS - 1)).toBe(false)
|
||||||
|
expect(isLargeBody(LARGE_BODY_CHARACTERS)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
const HTML_MIME = 'text/html'
|
||||||
|
const WHITESPACE = /\s+/
|
||||||
|
const EMPTY_DOCUMENT = '<p></p>'
|
||||||
|
const BLOCK_END = /<\/(?:p|h[1-6]|li|blockquote|pre|div)>|<br\s*\/?>/gi
|
||||||
|
|
||||||
|
export const LARGE_BODY_CHARACTERS = 50000
|
||||||
|
|
||||||
|
export function isLargeBody(characters: number) {
|
||||||
|
return characters >= LARGE_BODY_CHARACTERS
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BodyCounts = { words: number; characters: number }
|
||||||
|
|
||||||
|
export function bodyCounts(html: string): BodyCounts {
|
||||||
|
if (html.length === 0 || html === EMPTY_DOCUMENT) {
|
||||||
|
return { words: 0, characters: 0 }
|
||||||
|
}
|
||||||
|
const text =
|
||||||
|
new DOMParser().parseFromString(html.replace(BLOCK_END, '$& '), HTML_MIME).body
|
||||||
|
.textContent ?? ''
|
||||||
|
const words = text.split(WHITESPACE).filter(Boolean).length
|
||||||
|
return { words, characters: html.length }
|
||||||
|
}
|
||||||
@@ -11,13 +11,14 @@ import ArticleEditor from '@/components/ArticleEditor.vue'
|
|||||||
import ArticleDetails from '@/components/ArticleDetails.vue'
|
import 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>
|
||||||
|
|||||||
@@ -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))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user