From 91d916fb74d90fae5495b3c1ffca081517bc28cf Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Tue, 20 Oct 2026 21:05:03 +0200 Subject: [PATCH] feat: entry list at /content/:key --- features/content_entry_list.feature | 224 ++++++ features/content_entry_list.qa.md | 52 ++ web/src/components/ColumnPicker.vue | 119 ++++ web/src/components/ColumnPickerList.vue | 74 ++ web/src/components/ContentEmpty.vue | 30 + web/src/components/ContentEntries.vue | 218 ++++++ web/src/components/ContentState.vue | 42 ++ web/src/components/ContentToolbar.vue | 56 ++ web/src/components/EntryCardList.vue | 72 ++ web/src/components/EntrySkeleton.vue | 30 + web/src/components/EntryTable.vue | 174 +++++ web/src/components/FieldCell.vue | 40 ++ web/src/components/SortSelect.vue | 69 ++ .../components/__tests__/FieldCell.spec.ts | 113 ++++ .../__tests__/useContentTypes.spec.ts | 126 ++++ web/src/composables/useContentTypeDetail.ts | 35 + web/src/composables/useContentTypes.ts | 69 +- web/src/composables/useEntryColumns.ts | 33 + web/src/composables/useEntryList.ts | 126 ++++ web/src/i18n/contentMessages.spec.ts | 56 ++ web/src/i18n/messages.ts | 28 + .../lib/__tests__/articleListQuery.spec.ts | 9 +- web/src/lib/__tests__/entryCells.spec.ts | 118 ++++ web/src/lib/__tests__/entryColumns.spec.ts | 107 +++ web/src/lib/__tests__/entryListQuery.spec.ts | 136 ++++ web/src/lib/contentListState.ts | 39 ++ web/src/lib/contentTypes.ts | 60 ++ web/src/lib/entryCells.ts | 95 +++ web/src/lib/entryColumns.ts | 69 ++ web/src/lib/entryListQuery.ts | 90 +++ web/src/lib/pageQuery.ts | 6 +- web/src/lib/relativeDate.ts | 2 +- web/src/lib/routePaths.ts | 12 + web/src/router/index.ts | 5 + web/src/views/ContentListView.vue | 64 ++ web/src/views/__tests__/ContentList.spec.ts | 640 ++++++++++++++++++ .../__tests__/ContentListColumns.spec.ts | 454 +++++++++++++ .../__tests__/ContentListNavigation.spec.ts | 314 +++++++++ .../views/__tests__/ContentListPaging.spec.ts | 289 ++++++++ .../views/__tests__/ContentListSort.spec.ts | 201 ++++++ web/src/views/__tests__/contentTestKit.ts | 250 +++++++ web/src/views/__tests__/schemaTestKit.ts | 28 + web/visual/check.ts | 3 + web/visual/states.ts | 239 +++++++ 44 files changed, 4984 insertions(+), 32 deletions(-) create mode 100644 features/content_entry_list.feature create mode 100644 features/content_entry_list.qa.md create mode 100644 web/src/components/ColumnPicker.vue create mode 100644 web/src/components/ColumnPickerList.vue create mode 100644 web/src/components/ContentEmpty.vue create mode 100644 web/src/components/ContentEntries.vue create mode 100644 web/src/components/ContentState.vue create mode 100644 web/src/components/ContentToolbar.vue create mode 100644 web/src/components/EntryCardList.vue create mode 100644 web/src/components/EntrySkeleton.vue create mode 100644 web/src/components/EntryTable.vue create mode 100644 web/src/components/FieldCell.vue create mode 100644 web/src/components/SortSelect.vue create mode 100644 web/src/components/__tests__/FieldCell.spec.ts create mode 100644 web/src/composables/useContentTypeDetail.ts create mode 100644 web/src/composables/useEntryColumns.ts create mode 100644 web/src/composables/useEntryList.ts create mode 100644 web/src/i18n/contentMessages.spec.ts create mode 100644 web/src/lib/__tests__/entryCells.spec.ts create mode 100644 web/src/lib/__tests__/entryColumns.spec.ts create mode 100644 web/src/lib/__tests__/entryListQuery.spec.ts create mode 100644 web/src/lib/contentListState.ts create mode 100644 web/src/lib/contentTypes.ts create mode 100644 web/src/lib/entryCells.ts create mode 100644 web/src/lib/entryColumns.ts create mode 100644 web/src/lib/entryListQuery.ts create mode 100644 web/src/views/ContentListView.vue create mode 100644 web/src/views/__tests__/ContentList.spec.ts create mode 100644 web/src/views/__tests__/ContentListColumns.spec.ts create mode 100644 web/src/views/__tests__/ContentListNavigation.spec.ts create mode 100644 web/src/views/__tests__/ContentListPaging.spec.ts create mode 100644 web/src/views/__tests__/ContentListSort.spec.ts create mode 100644 web/src/views/__tests__/contentTestKit.ts diff --git a/features/content_entry_list.feature b/features/content_entry_list.feature new file mode 100644 index 0000000..f19889a --- /dev/null +++ b/features/content_entry_list.feature @@ -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 :; 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= 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 + When + Then + + 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 "