feat: entry editor at /content/:key/new and /content/:key/:id
This commit is contained in:
1 parent
91d916fb74
commit
fba20f83c5
94 files changed
+7116
-423
No files matched your search
@@ -0,0 +1,469 @@
|
||||
Feature: Entry editor of a custom content type
|
||||
As a verified user
|
||||
I want to create, edit and delete an entry of a custom content type
|
||||
So that the content follows the fields of its type
|
||||
|
||||
Background:
|
||||
Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie
|
||||
And API errors are plain text
|
||||
And the routes are /content/:key/new and /content/:key/:id with meta auth "verified" and title "Content", per docs/design/screens/entry.md group "6 Entry editor", states 1 to 13
|
||||
And the structural prep of this slice is done first: ContentEntries is presentational with one owner of the list-view state, useContentTypes is split into entry and user-lookup parts, and one field-type registry (lib/fieldTypes) serves the schema builder, the list cells and this form
|
||||
And the page requests GET /v1/content-type/{key} (the type) and, on /content/:key/:id, GET /v1/content-type/{key}/entry/{id} at the same time
|
||||
And an entry is {id, content_type, data, created_by, updated_by, created_at, updated_at}, with content_type the key string
|
||||
And POST /v1/content-type/{key}/entry answers 201 with the entry, PUT .../entry/{id} answers 200 with the entry and DELETE answers 204 with no body
|
||||
And POST and PUT take {"data": {...}}; PUT replaces the whole entry and keeps created_by and created_at
|
||||
And the API refuses with 404 content_type_not_found, 404 entry_not_found, 400 for an id that is not a uint32, 401, 403, 409 "invalid_entry: <key>: unique" and 422 "invalid_entry: <key>: <required|type|min|max|options|unknown_field>", and the body names only one field per answer
|
||||
And any verified user may read and write entries; there is no owner or admin gate in the SPA
|
||||
And a "/content/:key/:id" whose id is not made of digits or does not fit a uint32 shows "Entry not found" with no entry request, and "new" is the literal route
|
||||
And the form is generated from the type's fields in schema order through the fieldTypes registry, with the six types text, richtext, number, boolean, date and select
|
||||
And the data sent holds exactly one key per field of the loaded type, with the value or null for an empty optional field, so no default is applied by the server to a field the user cleared and no unknown key is ever sent
|
||||
And a text or richtext value is counted in characters (code points) like the server, and is not trimmed
|
||||
And a required field is empty when it is null, undefined or an empty string; false and 0 are values
|
||||
And validation is done by the client against the loaded type before any request (required, type, min, max, options), one rule per field at a time in that order, and the server's single answer is mapped to its field when it gets past the client
|
||||
And "Created by" and "Updated by" names come from GET /v1/user/lookup?ids=<distinct created_by and updated_by> sent once after the entry answers, as in features/content_entry_list.feature; a missing name reads "–" with the date kept
|
||||
And dates in the footer are "12 Oct 2026, 09:14" (local time zone, 24 h)
|
||||
And the leave guard is useUnsavedChangesGuard, the same "Leave without saving?" dialog as features/schema_builder_edit.feature
|
||||
And the status chip is the builder's: "Saved", "Saved at HH:MM" (never "N min ago"), "Unsaved changes", "Saving…", "Save failed", plus "New entry" and "Not saved"
|
||||
And the SPA adds no beforeunload prompt, as nothing else in web/src does
|
||||
And Markdown is rendered with markdown-it with raw HTML disabled and only http, https and mailto links active
|
||||
And every string goes through t() and has a French entry in i18n/messages.ts
|
||||
And a copy tagged "(proposed copy)" is not in the design and is the coder's single point of change
|
||||
|
||||
Scenario: 1 New entry
|
||||
Given GET /v1/content-type/events resolves 200 with the 7 fields Title, Starts on, Ends on, Online, Capacity, Category, Description
|
||||
When the user navigates to /content/events/new
|
||||
Then the breadcrumb reads "Content", "Events", "New entry" and the title row reads "New entry" with the subtitle "Events · Fields marked * are required."
|
||||
And no entry request is sent
|
||||
And the chip reads "New entry", "Cancel" and "Save" are shown, and "Delete" is not
|
||||
And "Save" is enabled although nothing was typed
|
||||
And each required label carries " *"
|
||||
And the control of Online is Off and carries the tag "Default"
|
||||
And the help under Title reads "Each entry needs a different title.", under Capacity "Between 1 and 5000.", the placeholders read "Choose an option" (Category) and "Pick a date" (Starts on, Ends on)
|
||||
And the footer reads "Entry" "Not saved yet", "Created by" "–", "Updated by" "–"
|
||||
And on mobile the topbar reads "New entry" with an arrow-left and no action, the chip sits under the title row, and the bottom bar holds "Cancel" and "Save"
|
||||
|
||||
Scenario Outline: Defaults on a new entry
|
||||
Given the type has a field <field> with default <default>
|
||||
When the user opens /content/events/new
|
||||
Then the control holds <shown> and the tag "Default" shows next to the label
|
||||
When the user changes the value
|
||||
Then the tag goes
|
||||
And when the user restores the default value the tag shows again
|
||||
|
||||
Examples:
|
||||
| field | default | shown |
|
||||
| Online (boolean) | false | Off |
|
||||
| Capacity (number) | 100 | 100 |
|
||||
| Category (select) | Talk | Talk |
|
||||
| Starts on (date) | 2026-11-21 | 21 Nov 2026 |
|
||||
| Title (text) | Untitled | Untitled |
|
||||
| Description (richtext) | none | an empty textarea, no tag |
|
||||
|
||||
Scenario: Defaults are shown only on create
|
||||
When the user opens /content/events/42
|
||||
Then no field carries the tag "Default" and the values are those of the entry
|
||||
|
||||
Scenario: 2 Editing
|
||||
Given GET .../entry/42 resolves 200 with the entry "Lime plaster workshop" created by 3 on 2026-10-12 09:14 and updated by 5 on 2026-10-15 11:02, and the lookup names 3 "Inès Duarte" and 5 "Marta Alves"
|
||||
When the user navigates to /content/events/42
|
||||
Then the breadcrumb reads "Content", "Events", "Lime plaster workshop" and the title reads "Lime plaster workshop" with the subtitle "Events · Entry 42"
|
||||
And the chip reads "Saved"
|
||||
And "Delete", "Cancel" and "Save" are shown, "Save" is disabled
|
||||
And the controls hold the entry's values
|
||||
And the footer reads "Entry" "events / 42" (mono), "Created by" "Inès Duarte · 12 Oct 2026, 09:14", "Updated by" "Marta Alves · 15 Oct 2026, 11:02"
|
||||
And on mobile the topbar reads the title with an arrow-left and a trash-2 action, the chip sits under the title row, and the bottom bar holds "Cancel" and "Save" (disabled)
|
||||
|
||||
Scenario Outline: The page title
|
||||
Given the entry 42 whose first field <first> holds <value>
|
||||
When the user opens /content/events/42
|
||||
Then the title and the last breadcrumb read <title>
|
||||
|
||||
Examples:
|
||||
| first | value | title |
|
||||
| Title (text) | "Lime plaster workshop" | Lime plaster workshop |
|
||||
| Title (text) | null | Entry 42 |
|
||||
| Body (richtext) | "First line\n\nSecond" | First line |
|
||||
| Seats (number) | 18 | 18 |
|
||||
|
||||
Scenario: The title does not change while typing
|
||||
Given /content/events/42
|
||||
When the user types in Title
|
||||
Then the title row and the breadcrumb keep "Lime plaster workshop" until a save answers
|
||||
|
||||
Scenario Outline: Form controls per field type
|
||||
Given a field of type <type>
|
||||
Then the control is <control> with <detail>
|
||||
|
||||
Examples:
|
||||
| type | control | detail |
|
||||
| text | UInput | a counter "n / max" when max is set |
|
||||
| richtext | MarkdownField | tabs "Write" and "Preview", the toolbar bold, italic, heading, link, list, the placeholder "Write in Markdown", the help "Markdown. Switch to Preview to check the result." and a counter when max is set |
|
||||
| number | UInputNumber | the placeholder "Enter a number" and minus and plus buttons; decimals are accepted |
|
||||
| boolean | USwitch | the text "On" or "Off" next to it |
|
||||
| date | UInputDate | the placeholder "Pick a date" and a calendar; the value is "YYYY-MM-DD" with no time-zone shift |
|
||||
| select | USelect | the placeholder "Choose an option", the choices in schema order, and for an optional field a first item "None" (proposed copy) that clears it |
|
||||
|
||||
Scenario Outline: Help lines
|
||||
Given a field <field>
|
||||
Then the help under it reads <help>, and is replaced by the error when there is one
|
||||
|
||||
Examples:
|
||||
| field | help |
|
||||
| text, unique | Each entry needs a different title. (the label in lower case) |
|
||||
| number, min 1, max 5000 | Between 1 and 5000. |
|
||||
| number, min 1 | At least 1. (proposed copy) |
|
||||
| number, max 5000 | At most 5000. (proposed copy) |
|
||||
| date, min 2026-01-01, max 2027-12-31 | From 1 Jan 2026 until 31 Dec 2027. (proposed copy) |
|
||||
| richtext | Markdown. Switch to Preview to check the result. |
|
||||
| text, no unique, no number or date bounds | no help line |
|
||||
|
||||
Scenario: Layout of the generated form
|
||||
Given the 7 fields of Events on a 1440 px viewport
|
||||
Then Title is full width, Starts on and Ends on share a row, Online and Capacity share a row, Category and Description are each full width, in a column of at most 760 px
|
||||
And a date, number or boolean field pairs with the next one if that one is also a date, number or boolean, text, richtext and select are always full width
|
||||
And below md every field is full width
|
||||
|
||||
Scenario: 3 Unsaved changes and the counter
|
||||
Given /content/events/42 with Title "Lime plaster workshop" (21 characters, max 80)
|
||||
When the user types ", two days" in Title
|
||||
Then the counter beside the label reads "32 / 80" in mono, muted
|
||||
And the chip reads "Unsaved changes" and "Save" is enabled
|
||||
And a counter over the limit reads "91 / 80" in the error colour, and typing is not blocked
|
||||
And a counter shows only for a text or richtext field with a max
|
||||
|
||||
Scenario: An edit that is reverted is not an unsaved change
|
||||
Given the user changed Capacity from 18 to 20
|
||||
When the user sets it back to 18
|
||||
Then the chip reads "Saved", "Save" is disabled and the leave guard does not prompt
|
||||
|
||||
Scenario: Cancel
|
||||
Given /content/events/42, opened from the list /content/events?page=2&sort=id:asc
|
||||
When the user activates "Cancel"
|
||||
Then the router returns to /content/events?page=2&sort=id:asc when the previous history entry is the list of this type, else it navigates to /content/events
|
||||
And the leave guard runs first when there are unsaved changes
|
||||
|
||||
Scenario: 4 Markdown preview
|
||||
Given a richtext field holding "Two days of hands-on work with **lime putty** and old stone.\n\n- Bring work clothes\n- Lunch is included"
|
||||
When the user activates the "Preview" tab
|
||||
Then the textarea and the toolbar are replaced by the rendered text with a bold "lime putty" and a bulleted list of two items, in a box at least 96 px high (120 px on mobile)
|
||||
And raw HTML in the value is shown as text, never rendered, and only http, https and mailto links are active
|
||||
And the tab state is not an edit, and switching back to "Write" restores the textarea with its value, caret kept
|
||||
And an empty value in Preview reads "Nothing to preview yet." (proposed copy)
|
||||
|
||||
Scenario Outline: The Markdown toolbar
|
||||
Given the Write tab with the selection <selection>
|
||||
When the user activates <button>
|
||||
Then the textarea holds <result> and keeps the focus
|
||||
|
||||
Examples:
|
||||
| selection | button | result |
|
||||
| "lime" | Bold | **lime** |
|
||||
| "lime" | Italic | _lime_ |
|
||||
| "lime" | Heading | ## lime |
|
||||
| "lime" | Link | [lime](url) |
|
||||
| "lime" | List | - lime |
|
||||
|
||||
Scenario: Payload of a create
|
||||
Given /content/events/new with Title "Open day", Starts on 2026-11-21, Online left at its default false, Ends on, Capacity, Category and Description left empty
|
||||
When the user activates "Save"
|
||||
Then POST /v1/content-type/events/entry is sent once with {"data": {"title": "Open day", "starts_on": "2026-11-21", "ends_on": null, "online": false, "capacity": null, "category": null, "description": null}}
|
||||
And a number is sent as a JSON number, a boolean as true or false, a date as "YYYY-MM-DD", a select as the choice text
|
||||
|
||||
Scenario: Payload of an edit
|
||||
Given /content/events/42 with only Capacity changed from 18 to 20
|
||||
When the user activates "Save"
|
||||
Then PUT /v1/content-type/events/entry/42 is sent once with every field of the type, the unchanged ones with their loaded values
|
||||
And a key that the entry holds but the type no longer has is not sent
|
||||
|
||||
Scenario Outline: A boolean that was never set
|
||||
Given a boolean field <kind> and the user never touched it
|
||||
When the entry is saved
|
||||
Then the data holds <sent> for it
|
||||
|
||||
Examples:
|
||||
| kind | sent |
|
||||
| optional, no default | null |
|
||||
| optional, loaded as null | null |
|
||||
| required, no default | false |
|
||||
| optional, default true | true |
|
||||
|
||||
Scenario: 5 Saving
|
||||
Given the PUT has not answered
|
||||
Then the chip reads "Saving…" with a loader, "Save" reads "Saving…" with a loader and is disabled, "Cancel" and "Delete" are disabled
|
||||
And the form is dimmed to 70 % and every control is disabled
|
||||
And a second activation of "Save", or Enter in a field, sends no second request
|
||||
|
||||
Scenario: 6 Saved (edit)
|
||||
Given /content/events/42 with Title changed to "Lime plaster workshop, two days"
|
||||
When the PUT answers 200 with updated_by 5 and updated_at 2026-10-19 11:02 local
|
||||
Then a success toast "Entry saved" with "\"Lime plaster workshop, two days\" is up to date." is shown
|
||||
And the chip reads "Saved at 11:02", "Save" is disabled and the controls hold the answered values
|
||||
And the title, the breadcrumb and the footer "Updated by" read the answered entry ("Marta Alves · 19 Oct 2026, 11:02")
|
||||
And no "Leave without saving?" prompt is shown afterwards
|
||||
And a later edit returns the chip to "Unsaved changes"
|
||||
|
||||
Scenario: Saved (create)
|
||||
Given /content/events/new with the required fields filled
|
||||
When the POST answers 201 with id 43
|
||||
Then the router replaces the URL with /content/events/43 and no entry request is made for it, the answered entry is used
|
||||
And the same toast, the chip "Saved at HH:MM" and the footer ("Entry" "events / 43", "Created by" and "Updated by" the current user) show
|
||||
And "Delete" now shows and a Back from there goes to the page before the new form
|
||||
And no "Leave without saving?" prompt is shown
|
||||
|
||||
Scenario: Where "Saved at" comes from
|
||||
Given the answered entry has updated_at 2026-10-19T11:02:00 in the local time zone
|
||||
Then the chip text is "Saved at 11:02" built from that timestamp, never from the clock
|
||||
And a Saved chip never turns into "Saved N min ago"
|
||||
And a test builds its expected text with the same local formatter from the mocked updated_at and pins the time zone; no test freezes the system date
|
||||
|
||||
Scenario Outline: 7 Save failed
|
||||
Given the save <failure>
|
||||
Then an error alert titled "Couldn't save the entry" shows "Something went wrong on our side. Your changes are still here. Try again in a moment." with a "Try again" action
|
||||
And the chip reads "Save failed" in the error colour, the values stay, "Save" is enabled and the form is not dimmed
|
||||
When the user activates "Try again"
|
||||
Then the same body is sent again and the alert clears while it is in flight
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| answers 500 |
|
||||
| never reaches the server |
|
||||
| answers 400 |
|
||||
| answers 422 "invalid_entry" with no field |
|
||||
|
||||
Scenario: 8 Required fields empty
|
||||
Given /content/events/new with Title and Starts on empty
|
||||
When the user activates "Save"
|
||||
Then no request is sent
|
||||
And "Fill in Title." shows under Title and "Pick a date for Starts on." under Starts on, each with the error colour on the control
|
||||
And the chip reads "Not saved" in the error colour with a circle-alert icon
|
||||
And focus moves to the Title control, the first field in schema order with an error, and the page scrolls to it
|
||||
And the controls carry aria-invalid and the message is tied to them by aria-describedby
|
||||
|
||||
Scenario Outline: Required messages
|
||||
Given an empty required field of type <type> labelled <label>
|
||||
When the user saves
|
||||
Then the message under it reads <message>
|
||||
|
||||
Examples:
|
||||
| type | label | message |
|
||||
| text | Title | Fill in Title. |
|
||||
| richtext | Notes | Fill in Notes. (proposed copy) |
|
||||
| number | Capacity | Fill in Capacity. (proposed copy)|
|
||||
| date | Starts on | Pick a date for Starts on. |
|
||||
| select | Category | Choose an option for Category. |
|
||||
|
||||
Scenario: A required boolean refused by the server
|
||||
Given /content/events/42 where Online is a required boolean the user never touched
|
||||
When the server answers 422 "invalid_entry: online: required"
|
||||
Then the message under Online reads "Choose yes or no for Online."
|
||||
And a required boolean is never refused on the client: an untouched one is sent as false
|
||||
|
||||
Scenario: 9 Rule errors
|
||||
Given /content/events/42 with Title of 91 characters, Ends on holding a value the server refuses as a date, Capacity 6000 and Category "Seminar" which is not a choice
|
||||
When the user activates "Save"
|
||||
Then only one message shows per field: "Title can be up to 80 characters." with the counter "91 / 80", "Ends on must be a date, like 2026-11-21.", "Capacity must be at most 5000.", "Choose one of: Talk, Workshop or Meetup."
|
||||
And the chip reads "Not saved" and focus is on Title
|
||||
|
||||
Scenario Outline: Messages per rule and type
|
||||
Given a field <field> and the value <value>
|
||||
When the entry is saved
|
||||
Then the message under it reads <message>
|
||||
|
||||
Examples:
|
||||
| field | value | message |
|
||||
| Title, text, min 3 | "ab" | Title needs at least 3 characters. |
|
||||
| Title, text, max 80 | 91 characters | Title can be up to 80 characters. |
|
||||
| Title, text | 5 (server type) | Title must be text. |
|
||||
| Capacity, number, min 1 | 0 | Capacity must be at least 1. |
|
||||
| Capacity, number, max 5000 | 6000 | Capacity must be at most 5000. |
|
||||
| Capacity, number | "4" (server type) | Capacity must be a number. |
|
||||
| Online, boolean | "yes" (server type) | Online must be yes or no. |
|
||||
| Starts on, date, min 2026-01-01 | 2025-12-31 | Starts on can't be before 1 Jan 2026. |
|
||||
| Starts on, date, max 2027-12-31 | 2028-01-01 | Starts on can't be after 31 Dec 2027. |
|
||||
| Starts on, date | 2026-02-30 (server type)| Starts on must be a date, like 2026-11-21. |
|
||||
| Category, select | "Seminar" | Choose one of: Talk, Workshop or Meetup. |
|
||||
|
||||
Scenario Outline: A server answer that gets past the client
|
||||
Given the client found no error and the request is sent
|
||||
When the server answers <status> "invalid_entry: <key>: <rule>" for a key of the loaded type
|
||||
Then the message of the table above shows under the field of <key>, the chip reads "Not saved", focus moves to that field and the values stay
|
||||
|
||||
Examples:
|
||||
| status | key | rule |
|
||||
| 422 | title | min |
|
||||
| 422 | capacity | max |
|
||||
| 422 | ends_on | type |
|
||||
| 422 | category | options |
|
||||
| 422 | title | required|
|
||||
|
||||
Scenario: Errors clear field by field
|
||||
Given the errors of state 8 are shown
|
||||
When the user edits Title
|
||||
Then the message under Title goes and the chip reads "Unsaved changes"
|
||||
And the message under Starts on stays until Starts on is edited or the next save
|
||||
And nothing is validated on blur
|
||||
|
||||
Scenario: 10 Duplicate unique value (409)
|
||||
Given /content/events/new with Title "Heat pumps: what the numbers say" and another entry holds it
|
||||
When the POST answers 409 "invalid_entry: title: unique"
|
||||
Then an error alert titled "Not saved" shows "Another entry already uses this title. Change it and save again."
|
||||
And under Title "Another entry already uses this Title. Enter a different one." shows, with the counter "n / 80", and the chip reads "Not saved"
|
||||
And focus moves to Title and the values stay
|
||||
And the alert goes when Title is edited
|
||||
|
||||
Scenario Outline: The unique message per type
|
||||
Given a unique <type> field labelled <label>
|
||||
When the server answers 409 "invalid_entry: <key>: unique"
|
||||
Then the message under it reads <message> and the alert reads <alert>
|
||||
|
||||
Examples:
|
||||
| type | label | key | message | alert |
|
||||
| text | Title | title | Another entry already uses this Title. Enter a different one. | Another entry already uses this title. Change it and save again. |
|
||||
| select | Category | category | Another entry already uses this choice for Category. | Another entry already uses this choice. Change it and save again. (proposed copy) |
|
||||
| number | Capacity | capacity | Another entry already uses this Capacity. Enter a different one. (proposed copy) | Another entry already uses this capacity. Change it and save again. (proposed copy) |
|
||||
|
||||
Scenario Outline: 11 Entry not found
|
||||
Given <where>
|
||||
Then a centered state with the file-x icon reads "Entry not found" with "It may have been deleted by someone else." and a primary "Back to Events"
|
||||
And the breadcrumb reads "Content" then "Events" and the mobile title is "Events"
|
||||
And "Back to Events" navigates to /content/events
|
||||
And the leave guard does not prompt on that navigation
|
||||
|
||||
Examples:
|
||||
| where |
|
||||
| GET .../entry/999 answers 404 entry_not_found |
|
||||
| a PUT answers 404 entry_not_found (edits discarded with the page) |
|
||||
| the URL is /content/events/abc or /content/events/4294967296 (no request) |
|
||||
|
||||
Scenario Outline: Other answers while loading or saving
|
||||
Given any request of the editor
|
||||
When it answers <status> <body>
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| status | body | result |
|
||||
| 404 | content_type_not_found | the page "Content type not found" of features/content_entry_list.feature state 9 |
|
||||
| 403 | any | the page "You don't have access to Events" of features/content_entry_list.feature state 8 |
|
||||
| 401 | any | the existing redirect to /login with the expired reason |
|
||||
|
||||
Scenario Outline: Loading the entry fails (proposed, not drawn)
|
||||
Given GET .../entry/42 or the type <failure>
|
||||
Then an error alert reads "Couldn't load the entry" (proposed copy) with "GET /v1/content-type/events/entry/42 failed. Check your connection and try again." and a "Try again" action
|
||||
And no form shows and "Try again" sends only the failed request again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| answers 500 |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Loading (proposed, not drawn)
|
||||
Given the type and the entry have not resolved
|
||||
Then the title row shows a skeleton, the form is skeleton rows with aria-busy="true", and "Save" and "Delete" are not shown
|
||||
|
||||
Scenario: 12 Schema changed since load
|
||||
Given /content/events/new was opened with the 7 fields, then Room (required) was added and Description removed
|
||||
When the POST answers 422 "invalid_entry: room: required"
|
||||
Then a warning alert titled "The fields of Events changed" shows "A field was removed and a new required field, Room, was added after you opened this form. Reload the form to continue. What you typed here can't be saved against the old fields." with a "Reload form" action
|
||||
And every control is disabled, the form is dimmed, "Save" is disabled
|
||||
And the values stay visible
|
||||
And the chip reads "Not saved" (the board draws "Saved" here) and "Cancel" stays enabled
|
||||
|
||||
Scenario Outline: What counts as a schema change
|
||||
Given the editor holds the type as loaded
|
||||
When the server answers 422 "invalid_entry: <key>: <rule>"
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| key | rule | result |
|
||||
| room | required | the key is not a field of the loaded type: state 12 |
|
||||
| description | unknown_field | state 12 |
|
||||
| colour | unknown_field | state 12 |
|
||||
| title | required | state 8 on Title (the key is known) |
|
||||
| room | unique | state 12 (409) |
|
||||
|
||||
Scenario: The alert names what changed only when the SPA can tell
|
||||
Given the type is fetched again by "Reload form"
|
||||
Then the alert copy of the design is used as is; the SPA never builds a diff of fields, since the design text names Room and the removed field only as a sample
|
||||
|
||||
Scenario: Reload form
|
||||
Given state 12
|
||||
When the user activates "Reload form"
|
||||
Then GET /v1/content-type/events is sent again and, on /content/:key/:id, GET .../entry/42 too
|
||||
And the form is rebuilt from the new type with defaults on a new entry, or the stored values on an edit; what was typed is dropped
|
||||
And the alert goes, the chip reads "New entry" or "Saved", and the URL is unchanged
|
||||
And a failure of the reload shows the load error of this slice
|
||||
|
||||
Scenario: 13 Delete entry: confirm
|
||||
Given /content/events/42 with no unsaved changes
|
||||
When the user activates "Delete" (the trash-2 action on mobile)
|
||||
Then no request is sent
|
||||
And the dialog "Delete this entry?" opens with "\"Lime plaster workshop\" will be deleted from Events. This can't be undone." (a bottom sheet on mobile)
|
||||
And the actions are "Cancel" and "Delete entry" (error colour, never the initial focus)
|
||||
And Cancel, the close icon and Esc close it with nothing sent and focus back on "Delete"
|
||||
|
||||
Scenario: Deleting
|
||||
Given the dialog is open
|
||||
When the user activates "Delete entry"
|
||||
Then DELETE /v1/content-type/events/entry/42 is sent once
|
||||
And the button reads "Deleting…" with a loader, Cancel is disabled, Esc, the close icon and the overlay do not dismiss, and a second activation sends nothing
|
||||
|
||||
Scenario: Deleted
|
||||
Given DELETE answers 204
|
||||
Then the dialog closes and the router replaces the URL with /content/events with no "Leave without saving?" prompt
|
||||
And a success toast "Entry deleted" with "\"Lime plaster workshop\" was deleted from Events." (proposed copy) is shown
|
||||
And the list loads its own data, so the entry is gone from it
|
||||
|
||||
Scenario Outline: Other answers of DELETE
|
||||
Given the dialog is open and DELETE <outcome>
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| outcome | result |
|
||||
| answers 404 entry_not_found | the dialog closes and the page "Entry not found" shows |
|
||||
| answers 404 content_type_not_found| the dialog closes and the page "Content type not found" shows |
|
||||
| answers 403 | the dialog closes and the page "You don't have access to Events" shows |
|
||||
| answers 401 | the existing redirect to /login with the expired reason |
|
||||
| answers 500 or fails (network) | the dialog stays with an error alert "Couldn't delete the entry" (proposed copy) and "Something went wrong on our side. Nothing was deleted. Try again in a moment."; the buttons are enabled again |
|
||||
|
||||
Scenario: Delete with unsaved edits
|
||||
Given /content/events/42 with unsaved edits
|
||||
When the user opens the delete dialog
|
||||
Then its copy uses the saved title, it does not save or discard the edits, and Cancel leaves them exactly as they were
|
||||
And on 204 the router leaves with no leave prompt
|
||||
|
||||
Scenario Outline: Leaving with unsaved edits
|
||||
Given <state>
|
||||
When the user leaves through the breadcrumb, the mobile arrow, "Cancel", the sidebar or the browser back
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| state | result |
|
||||
| no edit | the router navigates with no prompt |
|
||||
| an edit on /content/events/42 | the dialog "Leave without saving?" with "Keep editing" and "Discard changes" |
|
||||
| any typed value on /content/events/new | the same dialog |
|
||||
| /content/events/new untouched, defaults only | no prompt |
|
||||
| after Save answered 200 or 201 | no prompt |
|
||||
| after Delete answered 204 | no prompt |
|
||||
| state 12 with typed values | the same dialog |
|
||||
|
||||
Scenario: Switching to another entry or type reuses nothing
|
||||
Given the user edited /content/events/42 and discarded the edits to leave
|
||||
When the user opens /content/events/43 or /content/recipes/new
|
||||
Then the form is built from that route alone, with no value, error or chip carried over
|
||||
|
||||
Scenario: Stale answers are ignored
|
||||
Given a GET for /content/events/42 is in flight
|
||||
When the user navigates to /content/events/43 and the first answer arrives later
|
||||
Then it is dropped
|
||||
|
||||
Scenario: Reaching the editor from the list
|
||||
Given the list of features/content_entry_list.feature
|
||||
When the user activates "New entry" or a row
|
||||
Then the editor opens at /content/events/new or /content/events/42, no longer the catch-all 404
|
||||
|
||||
Scenario: Accessibility
|
||||
Then every control has a visible label tied to it, the required mark " *" is hidden from assistive technology and the control carries aria-required
|
||||
And the chip is a polite live region
|
||||
And the Preview tabs are a tablist with arrow-key movement, and the toolbar buttons have accessible names (Bold, Italic, Heading, Link, List)
|
||||
And dialogs trap focus, open with focus on the title and return it to the opener on close; the destructive button is never the initial focus
|
||||
And controls are 44 px high on mobile and focus is a 2 px --ui-primary outline with a 2 px offset
|
||||
@@ -0,0 +1,69 @@
|
||||
# QA: Entry editor of a custom content type (SPA slice 7)
|
||||
|
||||
Runner: Vitest + Vue Test Utils (`task test:web`). No Go test: the five entry endpoints and /v1/user/lookup already exist and the backend does not change. Kit: reuse `views/__tests__/schemaTestKit.ts` and add GET/POST/PUT/DELETE handlers for `/v1/content-type/{key}/entry[/{id}]` with the plain-text bodies `entry_not_found`, `content_type_not_found`, `invalid_entry: <key>: <rule>`, and the Events type with its 7 fields and entry 42 of SchemaFrame.dc.html. Pin the time zone. Never freeze the system date: "Saved at" is derived from the mocked `updated_at`, so the expected text is built with the same local formatter from that value.
|
||||
|
||||
## Automated
|
||||
|
||||
1. Registry (lib/fieldTypes spec, it.each over the six types): empty value, default pre-fill, control component, help line, counter rule, validate (required, type, min, max, options), message text, payload value (null for empty, false for a required boolean). The builder and the list cells still pass on the same registry.
|
||||
2. Entry API (composable spec): getEntry, createEntry, saveEntry, deleteEntry; the body is {data}; error parsing of "invalid_entry: key: rule" with status 422 and 409 into {key, rule, status}; 404 bodies; 400, 401, 403 and NetworkError pass through; lookupUsers is sent once with distinct ids.
|
||||
3. Route resolution (it.each): "new", "42", "abc", "0" (resolves to id 0, sent as is: the API answers 404 entry_not_found), "4294967296", "-1".
|
||||
4. State 1 New entry: title, subtitle, chip "New entry", asterisks, Default tag on Online, helps, placeholders, footer "Not saved yet" and "–", Save enabled, Delete absent; mobile layout.
|
||||
5. Defaults (it.each from the feature table): value shown, tag shown, tag leaves on change and returns on restore; no tag in edit.
|
||||
6. State 2 Editing: breadcrumb, title, "Entry 42", chip "Saved", Save disabled, footer names and dates after the lookup; "–" with the date kept when the lookup fails or lacks an id; mobile trash-2 action.
|
||||
7. Page title (it.each): text, null → "Entry 42", richtext first line, number; unchanged while typing.
|
||||
8. Controls and help lines (it.each): one control per type, placeholders, Off/On text, empty select "None" item on optional select only.
|
||||
9. Layout rule (pairing of date, number, boolean; full width for text, richtext, select; single column below md).
|
||||
10. State 3 Unsaved: counter "32 / 80", "91 / 80" in the error colour without blocking, no counter without max, chip, Save enabled, revert returns to clean.
|
||||
11. State 4 Preview: rendering of bold and list, raw HTML shown as text, unsafe links inert, empty preview, tab switch keeps value and caret, toolbar inserts (it.each).
|
||||
12. Payloads: create and edit bodies exactly as in the feature, explicit nulls, no unknown key, boolean table.
|
||||
13. State 5 Saving: chip, button, dimming, disabled controls, no second request on click or Enter.
|
||||
14. State 6 Saved: edit and create; toast; chip "Saved at HH:MM" from the answer; footer update; create replaces the URL to /content/events/43 with no extra GET; no leave prompt; edit after save is dirty again.
|
||||
15. State 7 Save failed (500, network, 400, 422 with no field): alert copy, chip, values kept, Try again resends the same body.
|
||||
16. State 8 Required: no request, all messages, chip "Not saved", `document.activeElement` is the first erroring control in schema order for each type (input, textarea, select trigger, date segment), aria-invalid and aria-describedby, per-type required messages (it.each).
|
||||
17. State 9 Rules: one message per field, message table (it.each), server answers that pass the client map to their field and take focus.
|
||||
18. Errors clear per field; chip returns to "Unsaved changes"; no blur validation.
|
||||
19. State 10 Unique (409): alert and field message, per-type messages, alert clears when the field is edited.
|
||||
20. State 11 Not found: GET 404, PUT 404, DELETE 404 and bad ids with no request; "Back to Events" goes to /content/events with no prompt.
|
||||
21. Content type 404, 403 and 401; load failure variants with "Try again" resending only the failed request; loading skeleton with aria-busy.
|
||||
22. State 12 Schema changed: unknown key, unknown_field, required on an unknown key, 409 on an unknown key; read-only form; Save disabled; Cancel enabled; chip "Not saved"; "Reload form" refetches the type (and the entry) and rebuilds the form; reload failure.
|
||||
23. State 13 Delete: dialog copy, focus on Cancel, Cancel/Esc/close, no request; Deleting lock; 204 replaces the URL and shows the toast; 404, 403, 401, 500 and network outcomes; delete with unsaved edits.
|
||||
24. Leave guard (it.each from the feature table), including defaults-only new form untouched, and Cancel history rule (previous entry is the list vs not).
|
||||
25. Stale answers dropped; navigating between routes carries nothing.
|
||||
26. Reaching the editor from the list: "New entry" and row links resolve to the editor (update the slice-6 specs that expected the catch-all 404). The slice-6 Background wording about `content_type_id` and `sort` is checked against the Go handler and corrected with the list code and tests if it differs.
|
||||
27. Accessibility: labels, aria-required, live-region chip, tablist keyboard, toolbar names, dialog focus.
|
||||
28. French: every new string has an entry (i18n completeness spec passes).
|
||||
|
||||
## Visual (task visual:check -- entry)
|
||||
|
||||
Register the group "6 Entry editor" in `extraSources` if needed. Every capture within 10 %, desktop and mobile, light and dark. Mock the sidebar's GET /v1/content-type and /v1/user/lookup. No state is clock-sensitive: "Saved at" comes from the response timestamp. If the runner cannot pin the zone, build `updated_at` as the builder's Saved setup does.
|
||||
|
||||
| Board state | Setup in web/visual/states.ts | Board sample data |
|
||||
|---|---|---|
|
||||
| 1 New entry | /content/events/new, Events type | "New entry", "Not saved yet", Online Off + "Default" |
|
||||
| 2 Editing | /content/events/42, lookup 3 and 5 | "Lime plaster workshop", "Entry 42", "Inès Duarte · 12 Oct 2026, 09:14", "Marta Alves · 15 Oct 2026, 11:02" |
|
||||
| 3 Unsaved changes | step: type ", two days" in Title | "Lime plaster workshop, two days", "32 / 80", focus ring on Title |
|
||||
| 4 Markdown preview | step: click "Preview" on Description | "Two days of hands-on work with lime putty and old stone." and the two bullets |
|
||||
| 5 Saving | PUT `hang: true` after an edit | "Saving…" |
|
||||
| 6 Saved | PUT 200 with updated_at 2026-10-19 11:02 local, updated_by 5 | "Saved at 11:02", "Entry saved", "Marta Alves · 19 Oct 2026, 11:02" |
|
||||
| 7 Save failed | PUT 500 after an edit | "Couldn't save the entry" |
|
||||
| 8 Required fields empty | /content/events/new, click Save | "Fill in Title.", "Pick a date for Starts on." |
|
||||
| 9 Rule errors | /content/events/42 with the board's values typed, PUT 422 for any server-side one | "91 / 80", "Capacity must be at most 5000.", "Choose one of: Talk, Workshop or Meetup." |
|
||||
| 10 Duplicate (409) | /content/events/new, title "Heat pumps: what the numbers say", POST 409 | "Another entry already uses this Title. Enter a different one." |
|
||||
| 11 Entry not found | /content/events/999, entry 404 entry_not_found | "Entry not found" |
|
||||
| 12 Schema changed | /content/events/new, POST 422 invalid_entry: room: required | "The fields of Events changed" |
|
||||
| 13 Delete entry: confirm | /content/events/42, step: click "Delete" | "Delete this entry?" |
|
||||
|
||||
Board caveats to report, never hide:
|
||||
- State 9 draws `31/11/2026` as text in Ends on; a segmented date control cannot hold it, so that field will differ.
|
||||
- State 11's breadcrumb shows the entry title, unknown on a 404.
|
||||
- State 12's chip is drawn "Saved" and its Cancel is drawn disabled; the spec uses "Not saved" and an enabled Cancel (user decision).
|
||||
- Mobile in state 9 depends on the chosen layout rule.
|
||||
|
||||
## Manual
|
||||
|
||||
- Real backend with two users: create an entry; Back from it returns to the list, not the empty form; edit; delete; the list shows the change.
|
||||
- Two tabs: change the type's fields in one (add a required field), save the entry in the other: state 12, "Reload form". Delete the entry in one tab and save in the other: state 11.
|
||||
- Duplicate a unique title for a real 409.
|
||||
- Keyboard: tab order across fields, toolbar, tabs, footer buttons; Enter in a single-line field saves; focus lands on the first error; dialogs trap focus.
|
||||
- Markdown with raw HTML and `javascript:` links stays inert in Preview.
|
||||
- Reload on /content/events/42 keeps the page; the browser back from a saved new entry goes to the page before the new form.
|
||||
@@ -7,7 +7,7 @@ Feature: Entry list of a custom content type
|
||||
Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie
|
||||
And API errors are plain text
|
||||
And the route is /content/:key with meta auth "verified" and title "Content", per docs/design/screens/content.md group "5 Content and entry list", states 1 to 9
|
||||
And the page requests GET /v1/content-type/{key} (the type) and GET /v1/content-type/{key}/entry?page=P&size=S&sort=O (the entries) at the same time; the entries answer {items, total, page, size, sort} and each item is {id, content_type_id, data, created_by, updated_by, created_at, updated_at}
|
||||
And the page requests GET /v1/content-type/{key} (the type) and GET /v1/content-type/{key}/entry?page=P&size=S&sort=O (the entries) at the same time; the entries answer {items, total, page, size} (no sort) and each item is {id, content_type (the type key), data, created_by, updated_by, created_at, updated_at}
|
||||
And the sidebar Content group of features/schema_list_and_content_nav.feature is reused unchanged; this slice only checks it in the "Many types" state
|
||||
And page, size and sort live in the URL (/content/events?page=2&size=50&sort=created_at:desc), the defaults (page 1, size 25, sort updated_at:desc) are omitted from the URL, and the request always carries explicit page and size
|
||||
And size is one of 5, 25, 50, 100; sort is <id|created_at|updated_at>:<asc|desc>; an invalid value is replaced silently by the default in the URL and in the request, so the API never answers 400 here
|
||||
|
||||
Reference in new issue
Block a user