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: : unique" and 422 "invalid_entry: : ", 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= 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 with default When the user opens /content/events/new Then the control holds 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 holds When the user opens /content/events/42 Then the title and the last breadcrumb read 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