Files
RenewCMS/features/content_entry_editor.feature
T

470 lines
32 KiB
Gherkin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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