feat: entry list at /content/:key

This commit is contained in:
Florian Sylvain committed 2026-10-20 21:05:03 +02:00
1 parent b4cbcb0395
commit 91d916fb74
44 files changed
+4984 -32

No files matched your search

+224
View File
@@ -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
+52
View File
@@ -0,0 +1,52 @@
# QA: Entry list of a custom content type (SPA slice 6)
Runner: Vitest + Vue Test Utils (`task test:web`). No Go test: both endpoints and /v1/user/lookup already exist and the backend does not change. Kit: reuse `views/__tests__/schemaTestKit.ts` (add GET /v1/content-type/{key}, GET .../entry with page/size/sort, and /v1/user/lookup handlers; Events with the 7 fields and 7/37 entries of SchemaFrame.dc.html). Pin the time zone; never freeze the system date.
## Automated
1. Composable (useContentTypes.spec): `getContentType(key)`; `listEntries(key, {page,size,sort})` builds the query, returns {items,total,page,size,sort}, passes status and plain text through (403, 404, 500), NetworkError passes through. `lookupUsers(ids)` sends the distinct ids once and returns display names.
2. URL parsing (it.each): invalid/missing page, size, sort fall back to 1, 25, updated_at:desc and are replaced in the URL; defaults omitted; size and sort change reset page; the URL changes on success only.
3. State 1 Populated: title, subtitle "events · 7 fields", toolbar, headers, first row values, "7 entries", no pager; singular "1 entry".
4. State 2 Paginated (size=5, total 37): "Showing 1–5 of 37", "5 per page", pager; mobile "Page 1 of 8"; Next sends page=2 and updates the URL on success.
5. Reuse of Articles pager behaviours (it.each): last page, loading page (aria-busy, disabled controls, "Loading page 2…"), error keeps rows with Retry, out-of-range redirect with notice, total 0 on out-of-range shows empty.
6. Cells (it.each per field type): truncation, rich text first line, number alignment, Yes/No icon, date without shift, Choice tag, empty "–"; Created and Updated formats.
7. Updated by: one lookup with distinct ids after the entries; "–" before it resolves, on failure and for an unknown id.
8. State 3 Columns: popover on desktop (Reset only), sheet on mobile (Reset and Done); "4 of 6", sentence, rows with icon, label and key; the Created/updated row disabled; live column change with no request; cap at 6 with message and disabled rows; Esc/outside/Done close and return focus.
9. Column persistence (it.each from the feature table): writes, Reset removes, unknown keys dropped, more than 6 truncated, invalid JSON, per-type keys, throwing/undefined localStorage, load never writes.
10. Sort: the three options and the direction button (it.each), accessible names, URL and request, header arrow, sort kept across paging, invalid URL values replaced.
11. State 4: with 14 types the sidebar shows 5 + selected + "Show all 14"; mobile drawer collapsed row "All content types 14" (sidebar logic itself stays in ContentNav.spec).
12. State 5 Loading: skeleton rows (8 / 6), aria-busy, "New entry" disabled.
13. State 6 Empty: copy, "New entry" navigates to /content/events/new, no pager.
14. State 7 Error (500 and network): copy, "Try again" sends the failed request again; type-load failure variant.
15. State 8 Forbidden (entries 403, type 403): copy, name fallback to the key, "Go to Home" goes to /home.
16. State 9 Not found (type 404, entries 404): copy, breadcrumb "Content", no current sidebar row; deleted-type scenario.
17. Navigation: New entry, first-cell links, mobile card link, "Edit content type" to /schema/events; hidden for a non-owner/admin and on mobile; mobile floating button hidden in loading and error.
18. 401 redirect; stale answer dropped after a sort change; switching type resets page/size/sort and columns.
19. Accessibility: caption text per sort, accessible names, keyboard on the picker and the select.
20. French: every new string has an entry (i18n completeness spec passes).
## Visual (task visual:check -- content)
Register the group "5 Content and entry list" in `extraSources` if not registered. Every capture within 10 %, desktop and mobile, light and dark. Nothing is clock-sensitive. Mock every /v1 call including the sidebar's GET /v1/content-type and /v1/user/lookup.
| Board state | Setup in web/visual/states.ts | Board sample data |
|---|---|---|
| 1 Populated | /content/events, 7 entries (ROWS of SchemaFrame.dc.html), owner session | "Lime plaster workshop", "Marta Alves", "events · 7 fields", "7 entries" |
| 2 Paginated, 5 per page | /content/events?size=5, total 37, 5 items | "Showing 1–5 of 37", "Page 1 of 8" (mobile) |
| 3 Columns menu | /content/events, step: click "Columns" | "4 of 6", Title, Starts on, Ends on, Online checked |
| 4 Many types | /content/events with 14 types (CT list of the board); mobile: open the drawer and expand the group | "Frequently asked questions and answers", "Show all 14" |
| 5 Loading | entries `hang: true` | skeleton rows |
| 6 Empty | entries total 0 | "No entries yet" |
| 7 Error | entries 500 | "Couldn't load entries" |
| 8 Forbidden | entries 403 | "You don't have access to Events" |
| 9 Type not found | /content/unknown, type 404 content_type_not_found | "Content type not found" |
Board caveats to report, never hide: the mobile card shows a Category tag that the default 4 columns do not include; the desktop picker is drawn without Done; the mobile frames hide "Edit content type", so mobile has no path to the builder (design follow-up).
## Manual
- Real backend, owner and a non-owner/admin if one exists: list a type with 37 entries; sort, page, change size; reload keeps the URL state.
- Columns: choose 6, reload, switch type and back; clear localStorage and see the default.
- Click "New entry" and a row: the 404 page is expected until the editor slice; Back returns to the list with its URL.
- Keyboard: tab order toolbar (select, direction, Columns, Edit), table links, pager; the picker and the sheet focus order; hover tooltip on truncated text.
- Delete a type in another tab, then reload /content/<key>: not found page.