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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user