feat: entry list at /content/:key
This commit is contained in:
1 parent
b4cbcb0395
commit
91d916fb74
44 files changed
+4984
-32
No files matched your search
@@ -0,0 +1,224 @@
|
||||
Feature: Entry list of a custom content type
|
||||
As a verified user
|
||||
I want to browse the entries of a custom content type
|
||||
So that I can find an entry before editing it
|
||||
|
||||
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 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 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
|
||||
And a size or sort change resets page to 1; the URL changes when a response succeeds, not when the request is sent
|
||||
And the pager, the page-size select, the summary, the loading page, the page error notice and the out-of-range redirect behave as in features/articles_pagination_screen.feature scenarios 1 to 7, 10, 10b and 11, with the content URL and request
|
||||
And the page size is not remembered
|
||||
And the columns are ID, up to 6 chosen field columns (default: the first 4 fields of the type, fewer if the type has fewer), Created, Updated and Updated by; ID, Created, Updated and Updated by are always shown
|
||||
And a field cell shows: Text truncated on one line with the full text as tooltip; Rich text as its first line in plain text, truncated; Number right-aligned with tabular figures; Yes or no as an icon plus "Yes" or "No"; Date as day, month and year ("21 Nov 2026") with no time-zone shift; Choice as a small outline tag; an empty value as "–"
|
||||
And Created reads "12 Oct 2026" and Updated reads "15 Oct 2026" (date only, local time zone), ID is mono and muted
|
||||
And "Updated by" is the display_name from one GET /v1/user/lookup?ids=<distinct updated_by of the page> sent after the entries answer; until it resolves, and if it fails or does not know the id, the cell shows a muted "–"
|
||||
And the desktop list is a table with a 36 px header and 48 px rows; below md it is a card list with 68 px cards, each card a link with a chevron-right, showing the first field, the second field, the first Choice field as a tag if the type has one, and "· {Updated by}"; cards ignore the column picker
|
||||
And every row is a link to /content/:key/:id (a link on the first field cell on desktop, the whole card on mobile); the click uses the router
|
||||
And "New entry" (primary, plus icon, in the title row on desktop; a floating button on mobile) navigates to /content/:key/new
|
||||
And /content/:key/new and /content/:key/:id are not routed in this slice and land on the catch-all 404 until the entry editor slice
|
||||
And "Edit content type" (ghost, settings-2 icon, right of the toolbar) navigates to /schema/:key; it is shown to owner and admin only and not on mobile
|
||||
And no filter or search exists
|
||||
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 Populated, one page
|
||||
Given GET /v1/content-type/events resolves 200 with 7 fields and GET .../entry?page=1&size=25&sort=updated_at:desc resolves 200 with 7 items and total 7
|
||||
When the user navigates to /content/events
|
||||
Then the breadcrumb reads "Content" then "Events", the title "Events" with the subtitle "events · 7 fields"
|
||||
And "New entry" is in the title row and the toolbar reads "Sort by" "Last updated", a direction button, "Columns", then "Edit content type" at the right
|
||||
And the table headers read "ID" "Title" "Starts on" "Ends on" "Online" "Created" "Updated" "Updated by" and the Updated header shows an arrow-down icon
|
||||
And 7 rows show, the first row ID 42 "Lime plaster workshop" "21 Nov 2026" "22 Nov 2026" "No" "12 Oct 2026" "15 Oct 2026" "Marta Alves"
|
||||
And no pager, no page-size select, only the text "7 entries" ("1 entry" for one, proposed copy)
|
||||
And the sidebar row Events is the current page and the URL has no query
|
||||
|
||||
Scenario: 2 Paginated, 5 per page
|
||||
Given GET .../entry?page=1&size=5&sort=updated_at:desc resolves 200 with 5 items and total 37
|
||||
When the user navigates to /content/events?size=5
|
||||
Then the summary reads "Showing 1–5 of 37", the page-size select reads "5 per page" and the paginator shows First, Previous (both disabled), 1 (current), 2, an ellipsis, 8, Next, Last
|
||||
And on mobile the paginator reads Previous (disabled), "Page 1 of 8", Next
|
||||
And activating Next sends page=2 and, on success, the URL is /content/events?size=5&page=2
|
||||
|
||||
Scenario: 3 Columns menu
|
||||
Given the populated state of the 7-field type Events
|
||||
When the user activates "Columns"
|
||||
Then a popover opens on desktop and a bottom sheet on mobile, titled "Columns" with "4 of 6"
|
||||
And the sheet adds the text "Choose up to 6 fields to show. 4 selected." and the actions "Reset" and "Done"; the popover has "Reset" only
|
||||
And each of the 7 fields is a checkbox row with its type icon, its label and its key in mono, in schema order; Title, Starts on, Ends on and Online are checked
|
||||
And a last row with a clock icon reads "Created and updated dates always show" with a disabled, unchecked checkbox
|
||||
When the user checks "Capacity"
|
||||
Then the table gets a Capacity column (right-aligned) at once, with no request, and the count reads "5 of 6"
|
||||
And Esc, a click outside or "Done" closes it and returns focus to "Columns"
|
||||
|
||||
Scenario Outline: The column cap and the stored choice
|
||||
Given the 7-field type Events and <before>
|
||||
When <action>
|
||||
Then <after>
|
||||
|
||||
Examples:
|
||||
| before | action | after |
|
||||
| 6 fields checked | the user opens the menu | the unchecked fields are disabled and the text "You can show up to 6 fields." (proposed copy) shows |
|
||||
| 5 fields checked | the user checks a 6th | it is checked and the others become disabled |
|
||||
| 6 fields checked | the user unchecks one | the others are enabled again |
|
||||
| the default 4 and nothing stored | the user checks "Capacity" | localStorage renewcms-content-columns-events is ["title","starts_on","ends_on","online","capacity"] |
|
||||
| a stored selection | the user activates "Reset" | the default 4 apply and the stored key is removed |
|
||||
| stored ["title","gone","capacity"] | the page loads | the columns are Title and Capacity, and nothing is written |
|
||||
| stored with 8 valid keys | the page loads | the first 6 in schema order show |
|
||||
| stored "not json" or [] | the page loads | the default 4 show |
|
||||
| localStorage throws or is undefined | the user changes the selection | the columns change for this visit and no error shows |
|
||||
| stored for events | the user opens /content/recipes | Recipes uses its own key and its default |
|
||||
|
||||
Scenario: The last field column cannot be unchecked
|
||||
Given the 7-field type Events with exactly one field checked
|
||||
When the user opens the Columns menu
|
||||
Then that field's checkbox is disabled, so at least one field column always shows and every row has its link
|
||||
And a stored empty selection loads as the default 4
|
||||
|
||||
Scenario: A type with fewer than 4 fields
|
||||
Given a type with 2 fields
|
||||
When the user opens its list
|
||||
Then both field columns show and "of 6" in the menu still reads the cap
|
||||
|
||||
Scenario Outline: Sort control
|
||||
Given GET .../entry resolves 200 and the user is on /content/events?page=3
|
||||
When the user chooses "<option>" in the "Sort by" select
|
||||
Then GET ...&sort=<sort>&page=1 is sent and, on success, the URL is <url>
|
||||
And the sorted column header shows the arrow icon of its direction
|
||||
|
||||
Examples:
|
||||
| option | sort | url |
|
||||
| ID | id:desc | /content/events?sort=id:desc |
|
||||
| Created | created_at:desc | /content/events?sort=created_at:desc |
|
||||
| Last updated | updated_at:desc | /content/events |
|
||||
|
||||
Scenario Outline: Direction button
|
||||
Given the user is on <from>
|
||||
When the user activates the direction button
|
||||
Then GET ...&sort=<sort>&page=1 is sent and, on success, the URL is <url>
|
||||
And the button shows arrow-up-narrow-wide when ascending and arrow-down-wide-narrow when descending, with the accessible name "Sort ascending" or "Sort descending" for the next action (proposed copy)
|
||||
|
||||
Examples:
|
||||
| from | sort | url |
|
||||
| /content/events | updated_at:asc | /content/events?sort=updated_at:asc |
|
||||
| /content/events?sort=updated_at:asc | updated_at:desc | /content/events |
|
||||
| /content/events?sort=id:desc | id:asc | /content/events?sort=id:asc |
|
||||
|
||||
Scenario Outline: Sort values in the URL
|
||||
When the user navigates to /content/events?sort=<value>
|
||||
Then <result>
|
||||
|
||||
Examples:
|
||||
| value | result |
|
||||
| created_at:asc | GET carries sort=created_at:asc and the select reads "Created" with the ascending direction icon |
|
||||
| title:asc | GET carries sort=updated_at:desc and the URL is replaced by /content/events |
|
||||
| data:asc | GET carries sort=updated_at:desc and the URL is replaced by /content/events |
|
||||
| id | GET carries sort=updated_at:desc and the URL is replaced by /content/events |
|
||||
|
||||
Scenario: Sort is kept across paging and size changes
|
||||
Given the URL is /content/events?sort=id:asc
|
||||
When the user activates Next, or changes the size
|
||||
Then every request carries sort=id:asc and the URL keeps it (a size change also resets the page)
|
||||
|
||||
Scenario: Switching to another type resets the page
|
||||
Given the user is on /content/events?page=3&size=50&sort=id:asc
|
||||
When the user opens /content/recipes from the sidebar
|
||||
Then the requests are for recipes with page=1, size=25 and sort=updated_at:desc, its own columns apply and no Events row shows meanwhile
|
||||
|
||||
Scenario: 4 Many types in the sidebar
|
||||
Given GET /v1/content-type returns 14 custom types, the 4th named "Frequently asked questions and answers"
|
||||
When the user opens /content/events
|
||||
Then on desktop the Content group shows "Content", the first 5 types and the selected one, the long name truncated, and "Show all 14"
|
||||
And on mobile the drawer shows the group expanded with "All content types 14" collapsed row
|
||||
And behaviour is as in features/schema_list_and_content_nav.feature
|
||||
|
||||
Scenario: 5 Loading
|
||||
Given the type and the entries have not resolved
|
||||
When the user navigates to /content/events
|
||||
Then the title row shows "Events" once the type has resolved and a skeleton until then, "New entry" is disabled and the toolbar is shown
|
||||
And the list is 8 skeleton rows on desktop (6 on mobile) with a 48 px row (68 px on mobile) and aria-busy="true"
|
||||
And when both resolve the rows replace the skeleton
|
||||
|
||||
Scenario: 6 Empty for this type
|
||||
Given GET .../entry resolves 200 with items [] and total 0
|
||||
When the user navigates to /content/events
|
||||
Then the empty state reads "No entries yet" with "Use New entry to add the first entry. Each entry follows the fields of this content type." and a "New entry" action
|
||||
And the toolbar stays, the pager, the summary and the count text do not show
|
||||
And "New entry" navigates to /content/events/new
|
||||
And an out-of-range page with total 0 shows this state and the URL becomes /content/events
|
||||
|
||||
Scenario Outline: 7 Error loading entries
|
||||
Given the type resolves 200 and GET .../entry <failure>
|
||||
When the user navigates to /content/events
|
||||
Then an error alert reads "Couldn't load entries" with "GET /v1/content-type/events/entry failed. Check your connection and try again." and a "Try again" action
|
||||
And no toolbar, no list and no pager show; the title row stays
|
||||
When the user activates "Try again"
|
||||
Then the skeleton (state 5) shows and the failed request is sent again
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Error loading the type
|
||||
Given GET /v1/content-type/events answers 500
|
||||
Then the same alert shows with "GET /v1/content-type/events failed. Check your connection and try again." (proposed copy)
|
||||
And "Try again" sends only the failed request again
|
||||
|
||||
Scenario Outline: 8 Forbidden (403)
|
||||
Given <which> answers 403
|
||||
When the user navigates to /content/events
|
||||
Then a centered state with the lock icon reads "You don't have access to Events" with "Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin." and "Go to Home"
|
||||
And no toolbar or list shows and "Go to Home" navigates to /home
|
||||
And the title uses the type name when the type loaded, else the key
|
||||
|
||||
Examples:
|
||||
| which |
|
||||
| the entries request |
|
||||
| the type request |
|
||||
|
||||
Scenario Outline: 9 Type not found
|
||||
Given <which> answers 404 content_type_not_found
|
||||
When the user navigates to /content/unknown
|
||||
Then a centered state with the file-x icon reads "Content type not found" with "It may have been deleted, or the address may be mistyped." and "Go to Home"
|
||||
And the breadcrumb reads "Content" only, the mobile title is "Content" and no sidebar row is current
|
||||
And "Go to Home" navigates to /home
|
||||
|
||||
Examples:
|
||||
| which |
|
||||
| the type request |
|
||||
| the entries request |
|
||||
|
||||
Scenario: A deleted type behind the sidebar
|
||||
Given Recipes was deleted in features/schema_builder_delete.feature
|
||||
When the user opens /content/recipes
|
||||
Then the "Content type not found" state shows with no further request than the type's
|
||||
|
||||
Scenario: Session expired
|
||||
Given any of the requests answers 401
|
||||
Then the existing redirect to /login with the expired reason happens
|
||||
|
||||
Scenario: Stale answers are ignored
|
||||
Given a request for page 2 is in flight
|
||||
When the user changes the sort and that request resolves later
|
||||
Then its answer is dropped and only the latest request updates the list and the URL
|
||||
|
||||
Scenario: Updated by lookup
|
||||
Given the page has entries by users 3, 3 and 5
|
||||
Then GET /v1/user/lookup?ids=3,5 is sent once after the entries answer and the cells read the display names
|
||||
And when it answers 500 or lacks id 5 the cells read "–" and no error shows
|
||||
|
||||
Scenario: Navigation actions
|
||||
Given the populated state and an owner
|
||||
Then "New entry" and the first-cell links go to /content/events/new and /content/events/42, "Edit content type" goes to /schema/events
|
||||
And with a role other than owner or admin "Edit content type" is not rendered
|
||||
And below md "Edit content type" is not rendered and the New entry button is a floating button (hidden while loading and in the error state)
|
||||
|
||||
Scenario: Accessibility
|
||||
Then the table has a visually hidden caption "Events, sorted by last updated, newest first" ("sorted by {id|created|updated}, {ascending|descending}" otherwise; proposed copy, French entry)
|
||||
And "Columns", the direction button and the select have accessible names; the picker traps focus on mobile and is operable with Space and Esc
|
||||
And controls are 44 px on mobile and focus is a 2 px --ui-primary outline with a 2 px offset
|
||||
Reference in new issue
Block a user