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.
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ColumnPickerList from '@/components/ColumnPickerList.vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { COLUMN_CAP } from '@/lib/entryColumns'
|
||||
import { BOTTOM_SHEET_UI } from '@/lib/sheetUi'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const FOCUS_RING =
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
|
||||
const TRIGGER = `h-11 w-11 justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 md:h-8 md:w-auto md:px-3 ${FOCUS_RING}`
|
||||
|
||||
defineProps<{ fields: BuiltinField[]; keys: string[]; atCap: boolean; disabled?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [key: string, on: boolean]; reset: [] }>()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const open = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPopover
|
||||
v-if="isDesktop"
|
||||
v-model:open="open"
|
||||
:content="{ align: 'start', side: 'bottom', sideOffset: 4 }"
|
||||
:ui="{
|
||||
content:
|
||||
'w-75 rounded-none bg-default p-2 shadow-(--shadow-popover) ring-0 border border-default',
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-columns-3"
|
||||
:disabled="disabled"
|
||||
:class="TRIGGER"
|
||||
>
|
||||
{{ t('Columns') }}
|
||||
</UButton>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-2" data-testid="column-picker">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="font-semibold text-highlighted">{{ t('Columns') }}</span>
|
||||
<span class="text-xs text-muted">{{
|
||||
t('{0} of {1}', [keys.length, COLUMN_CAP])
|
||||
}}</span>
|
||||
</div>
|
||||
<ColumnPickerList
|
||||
:fields="fields"
|
||||
:keys="keys"
|
||||
:at-cap="atCap"
|
||||
@toggle="(key, on) => emit('toggle', key, on)"
|
||||
/>
|
||||
<div class="flex justify-end px-2 py-1">
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-7 rounded-none border border-accented bg-default px-2.5 text-xs text-highlighted ring-0"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ t('Reset') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
<USlideover
|
||||
v-else
|
||||
v-model:open="open"
|
||||
side="bottom"
|
||||
:title="t('Columns')"
|
||||
:description="t('Columns')"
|
||||
:ui="BOTTOM_SHEET_UI"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-columns-3"
|
||||
:aria-label="t('Columns')"
|
||||
:disabled="disabled"
|
||||
:class="TRIGGER"
|
||||
/>
|
||||
<template #title>
|
||||
<span>{{ t('Columns') }}</span>
|
||||
<span class="ms-2 text-xs font-normal text-muted">{{
|
||||
t('{0} of {1}', [keys.length, COLUMN_CAP])
|
||||
}}</span>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="text-muted">
|
||||
{{ t('Choose up to {0} fields to show. {1} selected.', [COLUMN_CAP, keys.length]) }}
|
||||
</p>
|
||||
<ColumnPickerList
|
||||
touch
|
||||
:fields="fields"
|
||||
:keys="keys"
|
||||
:at-cap="atCap"
|
||||
@toggle="(key, on) => emit('toggle', key, on)"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 flex-1 justify-center rounded-none border border-accented bg-default text-highlighted ring-0"
|
||||
@click="emit('reset')"
|
||||
>
|
||||
{{ t('Reset') }}
|
||||
</UButton>
|
||||
<UButton class="h-11 flex-1 justify-center" @click="open = false">
|
||||
{{ t('Done') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</USlideover>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { fieldIcon } from '@/lib/schemaFields'
|
||||
import { COLUMN_CAP } from '@/lib/entryColumns'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: BuiltinField[]
|
||||
keys: string[]
|
||||
atCap: boolean
|
||||
touch?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ toggle: [key: string, on: boolean] }>()
|
||||
|
||||
function isLocked(key: string) {
|
||||
const checked = props.keys.includes(key)
|
||||
return checked ? props.keys.length === 1 : props.atCap
|
||||
}
|
||||
|
||||
const ROW = 'flex items-center gap-2.5 px-2'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
data-testid="column-row"
|
||||
:class="[ROW, touch ? 'min-h-12' : 'min-h-9']"
|
||||
>
|
||||
<UCheckbox
|
||||
:model-value="keys.includes(field.key)"
|
||||
:disabled="isLocked(field.key)"
|
||||
:ui="{
|
||||
root: 'w-full items-center',
|
||||
base: 'rounded-none',
|
||||
container: 'h-auto',
|
||||
wrapper: 'ms-2.5 min-w-0 flex-1',
|
||||
label: 'flex w-full items-center gap-2.5 font-normal text-default',
|
||||
}"
|
||||
@update:model-value="emit('toggle', field.key, $event === true)"
|
||||
>
|
||||
<template #label>
|
||||
<UIcon :name="fieldIcon(field.type)" class="size-3.5 shrink-0 text-muted" />
|
||||
<span class="min-w-0 flex-1 truncate">{{ field.label }}</span>
|
||||
<span class="font-mono text-xs text-muted">{{ field.key }}</span>
|
||||
</template>
|
||||
</UCheckbox>
|
||||
</div>
|
||||
<div data-testid="column-fixed-row" :class="[ROW, touch ? 'min-h-12' : 'min-h-9']">
|
||||
<UCheckbox
|
||||
:model-value="false"
|
||||
disabled
|
||||
:ui="{
|
||||
root: 'w-full items-center',
|
||||
base: 'rounded-none',
|
||||
container: 'h-auto',
|
||||
wrapper: 'ms-2.5 min-w-0 flex-1',
|
||||
label: 'flex w-full items-center gap-2.5 font-normal text-muted',
|
||||
}"
|
||||
>
|
||||
<template #label>
|
||||
<UIcon name="i-lucide-clock" class="size-3.5 shrink-0" />
|
||||
<span>{{ t('Created and updated dates always show') }}</span>
|
||||
</template>
|
||||
</UCheckbox>
|
||||
</div>
|
||||
<p v-if="atCap" role="status" class="px-2 pt-1 text-xs text-muted">
|
||||
{{ t('You can show up to {0} fields.', [COLUMN_CAP]) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ create: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-file-plus"
|
||||
:title="t('No entries yet')"
|
||||
:description="
|
||||
t(
|
||||
'Use New entry to add the first entry. Each entry follows the fields of this content type.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-10 sm:px-6 sm:py-10 lg:px-6 lg:py-10',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-md/7 font-semibold',
|
||||
description: 'max-w-90 text-muted text-pretty',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton icon="i-lucide-plus" class="h-8 px-4 max-md:h-11" @click="emit('create')">
|
||||
{{ t('New entry') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import ArticleListNotice from '@/components/ArticleListNotice.vue'
|
||||
import ContentEmpty from '@/components/ContentEmpty.vue'
|
||||
import ContentState from '@/components/ContentState.vue'
|
||||
import ContentToolbar from '@/components/ContentToolbar.vue'
|
||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
import EntryCardList from '@/components/EntryCardList.vue'
|
||||
import EntrySkeleton from '@/components/EntrySkeleton.vue'
|
||||
import EntryTable from '@/components/EntryTable.vue'
|
||||
import type { ContentType, ContentTypeDetailStatus } from '@/lib/contentTypes'
|
||||
import { useEntryColumns } from '@/composables/useEntryColumns'
|
||||
import { useEntryList } from '@/composables/useEntryList'
|
||||
import { resolveContentListState, type ContentListState } from '@/lib/contentListState'
|
||||
import { entryCaption } from '@/lib/entryListQuery'
|
||||
import { fieldCountLabel } from '@/lib/schemaRows'
|
||||
import { HOME_PATH, entryPath, schemaPath } from '@/lib/routePaths'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const DESKTOP_SKELETON_ROWS = 8
|
||||
const MOBILE_SKELETON_ROWS = 6
|
||||
|
||||
const props = defineProps<{
|
||||
typeKey: string
|
||||
type: ContentType | null
|
||||
typeStatus: ContentTypeDetailStatus
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ view: [view: ContentListState]; 'retry-type': [] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const list = useEntryList(props.typeKey)
|
||||
const columns = useEntryColumns(props.typeKey, () => props.type?.fields ?? [])
|
||||
|
||||
const view = computed(() =>
|
||||
resolveContentListState({
|
||||
type: props.typeStatus,
|
||||
entries: list.status.value,
|
||||
total: list.total.value,
|
||||
}),
|
||||
)
|
||||
const centered = computed(() => view.value === 'forbidden' || view.value === 'notFound')
|
||||
const failed = computed(() => view.value === 'typeError' || view.value === 'entriesError')
|
||||
const name = computed(() => props.type?.name ?? props.typeKey)
|
||||
const fields = computed(() => props.type?.fields ?? [])
|
||||
const subtitle = computed(() =>
|
||||
props.type ? `${props.type.key} · ${fieldCountLabel(props.type.fields.length)}` : '',
|
||||
)
|
||||
const editTo = computed(() => (props.canEdit ? schemaPath(props.typeKey) : undefined))
|
||||
const caption = computed(() => entryCaption(name.value, list.shown.value.sort))
|
||||
|
||||
const showSkeleton = computed(() =>
|
||||
view.value === 'loading'
|
||||
? list.showSkeleton.value || list.status.value === 'ready'
|
||||
: view.value === 'list' && list.skeletonActive.value,
|
||||
)
|
||||
const skeletonRows = computed(() => {
|
||||
if (view.value === 'list') {
|
||||
return list.items.value.length
|
||||
}
|
||||
return isDesktop.value ? DESKTOP_SKELETON_ROWS : MOBILE_SKELETON_ROWS
|
||||
})
|
||||
const showBody = computed(() => view.value === 'list' && !showSkeleton.value)
|
||||
const singlePageLabel = computed(() =>
|
||||
list.total.value === 1 ? t('1 entry') : t('{0} entries', [formatNumber(list.total.value)]),
|
||||
)
|
||||
const typeErrorLine = computed(() =>
|
||||
t('GET /v1/content-type/{0} failed. Check your connection and try again.', [props.typeKey]),
|
||||
)
|
||||
const entriesErrorLine = computed(() =>
|
||||
t('GET /v1/content-type/{0}/entry failed. Check your connection and try again.', [
|
||||
props.typeKey,
|
||||
]),
|
||||
)
|
||||
|
||||
function createEntry() {
|
||||
router.push(entryPath(props.typeKey, 'new'))
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
if (view.value === 'typeError') {
|
||||
emit('retry-type')
|
||||
return
|
||||
}
|
||||
list.retry()
|
||||
}
|
||||
|
||||
watch(view, (value) => emit('view', value), { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
list.start()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentState
|
||||
v-if="centered"
|
||||
:kind="view === 'forbidden' ? 'forbidden' : 'notFound'"
|
||||
:name="name"
|
||||
@home="goHome"
|
||||
/>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div class="flex min-w-50 flex-1 flex-col gap-0.5">
|
||||
<template v-if="typeStatus === 'loading'">
|
||||
<USkeleton class="h-7 w-40 rounded-none bg-accented max-lg:hidden" />
|
||||
<USkeleton class="h-5 w-28 rounded-none bg-accented" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
|
||||
{{ name }}
|
||||
</h1>
|
||||
<p v-if="type" class="text-muted">{{ subtitle }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="isDesktop"
|
||||
icon="i-lucide-plus"
|
||||
class="h-8 px-3.5"
|
||||
:disabled="view === 'loading'"
|
||||
@click="createEntry"
|
||||
>
|
||||
{{ t('New entry') }}
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<ContentToolbar
|
||||
v-if="!failed"
|
||||
:sort="list.desired.value.sort"
|
||||
:fields="fields"
|
||||
:keys="columns.keys.value"
|
||||
:at-cap="columns.atCap.value"
|
||||
:picker-disabled="type === null"
|
||||
:edit-to="editTo"
|
||||
@field="list.changeSortField"
|
||||
@direction="list.toggleDirection"
|
||||
@toggle="columns.toggle"
|
||||
@reset="columns.reset"
|
||||
/>
|
||||
|
||||
<DirectoryLoadError
|
||||
v-if="failed"
|
||||
:title="t('Couldn\'t load entries')"
|
||||
:description="view === 'typeError' ? typeErrorLine : entriesErrorLine"
|
||||
@retry="retry"
|
||||
/>
|
||||
|
||||
<ArticleListNotice
|
||||
v-if="list.notice.value"
|
||||
:kind="list.notice.value.kind"
|
||||
:failed-page="
|
||||
list.notice.value.kind === 'error'
|
||||
? list.notice.value.query.page
|
||||
: list.shown.value.page
|
||||
"
|
||||
:shown-page="list.shown.value.page"
|
||||
@retry="list.retryNotice"
|
||||
/>
|
||||
|
||||
<EntrySkeleton v-if="showSkeleton" :rows="skeletonRows" :cards="!isDesktop" />
|
||||
|
||||
<ContentEmpty v-else-if="view === 'empty'" @create="createEntry" />
|
||||
|
||||
<template v-else-if="showBody">
|
||||
<EntryTable
|
||||
v-if="isDesktop"
|
||||
:type-key="typeKey"
|
||||
:entries="list.items.value"
|
||||
:fields="columns.columns.value"
|
||||
:sort="list.shown.value.sort"
|
||||
:names="list.names.value"
|
||||
:caption="caption"
|
||||
/>
|
||||
<EntryCardList
|
||||
v-else
|
||||
:type-key="typeKey"
|
||||
:entries="list.items.value"
|
||||
:fields="fields"
|
||||
:names="list.names.value"
|
||||
:caption="caption"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<AppPaginator
|
||||
v-if="view === 'list'"
|
||||
flush
|
||||
:page="list.shown.value.page"
|
||||
:page-count="list.pageCount.value"
|
||||
:total="list.total.value"
|
||||
:size="list.shown.value.size"
|
||||
:loading-page="list.pendingPage.value"
|
||||
:single-page-label="singlePageLabel"
|
||||
@update:page="list.changePage"
|
||||
@update:size="list.changeSize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DirectoryAddButton
|
||||
v-if="!isDesktop && !centered && !failed && view !== 'loading'"
|
||||
pinned
|
||||
@click="createEntry"
|
||||
>
|
||||
{{ t('New entry') }}
|
||||
</DirectoryAddButton>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ kind: 'forbidden' | 'notFound'; name: string }>()
|
||||
|
||||
const emit = defineEmits<{ home: [] }>()
|
||||
|
||||
const forbidden = computed(() => props.kind === 'forbidden')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center px-6 py-6">
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
:icon="forbidden ? 'i-lucide-lock' : 'i-lucide-file-x'"
|
||||
:title="
|
||||
forbidden ? t('You don\'t have access to {0}', [name]) : t('Content type not found')
|
||||
"
|
||||
:description="
|
||||
forbidden
|
||||
? t(
|
||||
'Your account can\'t open this content right now. If you think this is a mistake, ask an owner or an admin.',
|
||||
)
|
||||
: t('It may have been deleted, or the address may be mistyped.')
|
||||
"
|
||||
:ui="{
|
||||
root: 'w-full max-w-105 gap-3 p-0 sm:p-0 lg:p-0',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-title font-semibold text-wrap',
|
||||
description: 'mt-1 w-full max-w-none px-0 text-base/5 text-wrap text-muted',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton class="h-8 px-4 max-md:h-11" @click="emit('home')">
|
||||
{{ t('Go to Home') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ColumnPicker from '@/components/ColumnPicker.vue'
|
||||
import SortSelect from '@/components/SortSelect.vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import type { EntrySortField } from '@/lib/entryListQuery'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
|
||||
defineProps<{
|
||||
sort: string
|
||||
fields: BuiltinField[]
|
||||
keys: string[]
|
||||
atCap: boolean
|
||||
pickerDisabled: boolean
|
||||
editTo?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
field: [field: EntrySortField]
|
||||
direction: []
|
||||
toggle: [key: string, on: boolean]
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex shrink-0 items-center gap-2" data-testid="entry-toolbar">
|
||||
<SortSelect :sort="sort" @field="emit('field', $event)" @direction="emit('direction')" />
|
||||
<ColumnPicker
|
||||
:fields="fields"
|
||||
:keys="keys"
|
||||
:at-cap="atCap"
|
||||
:disabled="pickerDisabled"
|
||||
@toggle="(key, on) => emit('toggle', key, on)"
|
||||
@reset="emit('reset')"
|
||||
/>
|
||||
<template v-if="isDesktop">
|
||||
<span class="flex-1" />
|
||||
<UButton
|
||||
v-if="editTo"
|
||||
:to="editTo"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
icon="i-lucide-settings-2"
|
||||
class="h-8 rounded-none px-3 text-highlighted"
|
||||
>
|
||||
{{ t('Edit content type') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import FieldCell from '@/components/FieldCell.vue'
|
||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, cardFields, describeCell, isEmptyCell, plainCellText } from '@/lib/entryCells'
|
||||
import { entryPath } from '@/lib/routePaths'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
typeKey: string
|
||||
entries: ContentEntry[]
|
||||
fields: BuiltinField[]
|
||||
names: Record<number, string>
|
||||
caption: string
|
||||
}>()
|
||||
|
||||
const shown = computed(() => cardFields(props.fields))
|
||||
|
||||
function textOf(field: BuiltinField | undefined, entry: ContentEntry) {
|
||||
return field ? plainCellText(describeCell(field, entry.data[field.key])) : ''
|
||||
}
|
||||
|
||||
const secondIsChoice = computed(
|
||||
() => shown.value.second !== undefined && shown.value.second === shown.value.choice,
|
||||
)
|
||||
|
||||
function hasChoice(entry: ContentEntry) {
|
||||
const { choice } = shown.value
|
||||
return choice !== undefined && !isEmptyCell(describeCell(choice, entry.data[choice.key]))
|
||||
}
|
||||
|
||||
function nameOf(entry: ContentEntry) {
|
||||
return props.names[entry.updated_by]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul :aria-label="caption" class="border border-default bg-default">
|
||||
<li
|
||||
v-for="(entry, index) in entries"
|
||||
:key="entry.id"
|
||||
:class="index > 0 ? 'border-t border-default' : ''"
|
||||
>
|
||||
<RouterLink
|
||||
:to="entryPath(typeKey, entry.id)"
|
||||
data-testid="entry-card"
|
||||
class="flex min-h-17 items-center gap-2 py-3 pr-1 pl-4 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span class="truncate text-md/5.25 font-medium text-highlighted">{{
|
||||
textOf(shown.first, entry)
|
||||
}}</span>
|
||||
<span class="flex items-center gap-2 text-xs text-muted">
|
||||
<template v-if="shown.second && !secondIsChoice">
|
||||
<span class="shrink-0">{{ textOf(shown.second, entry) }}</span>
|
||||
</template>
|
||||
<FieldCell
|
||||
v-if="shown.choice && hasChoice(entry)"
|
||||
:field="shown.choice"
|
||||
:value="entry.data[shown.choice.key]"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="truncate">· {{ nameOf(entry) ?? EMPTY_TEXT }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex h-11 w-9 shrink-0 items-center justify-center text-muted">
|
||||
<UIcon name="i-lucide-chevron-right" class="size-4.5" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ rows: number; cards?: boolean }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="status"
|
||||
:aria-label="t('Loading')"
|
||||
aria-busy="true"
|
||||
class="border border-default bg-default"
|
||||
>
|
||||
<div
|
||||
v-for="n in rows"
|
||||
:key="n"
|
||||
data-testid="entry-skeleton-row"
|
||||
class="flex items-center gap-5 px-4"
|
||||
:class="[cards ? 'h-17' : 'h-12', n > 1 ? 'border-t border-default' : '']"
|
||||
>
|
||||
<USkeleton v-if="!cards" class="h-3 w-8 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3.5 rounded-none bg-accented" :class="n % 2 ? 'w-1/4' : 'w-3/10'" />
|
||||
<template v-if="!cards">
|
||||
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-22.5 rounded-none bg-accented" />
|
||||
<USkeleton class="h-3 w-12.5 rounded-none bg-accented" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watchPostEffect } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import FieldCell from '@/components/FieldCell.vue'
|
||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
|
||||
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
|
||||
import { entryPath } from '@/lib/routePaths'
|
||||
|
||||
const ID_WIDTH = 'w-16.5'
|
||||
const NARROW_WIDTH = 'w-23'
|
||||
const FIELD_WIDTH = 'w-33'
|
||||
const CREATED_WIDTH = 'w-30.5'
|
||||
const UPDATED_WIDTH = 'w-40.5'
|
||||
const UPDATED_BY_WIDTH = 'w-37'
|
||||
const NARROW_TYPES = ['boolean', 'number']
|
||||
const FIELD_PREFIX = 'field:'
|
||||
|
||||
const props = defineProps<{
|
||||
typeKey: string
|
||||
entries: ContentEntry[]
|
||||
fields: BuiltinField[]
|
||||
sort: string
|
||||
names: Record<number, string>
|
||||
caption: string
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const sorted = computed(() => sortParts(props.sort))
|
||||
|
||||
const HEADER = 'h-9 py-0 font-medium text-muted'
|
||||
|
||||
function widthOf(field: BuiltinField, index: number) {
|
||||
if (index === 0) {
|
||||
return ''
|
||||
}
|
||||
return NARROW_TYPES.includes(field.type) ? NARROW_WIDTH : FIELD_WIDTH
|
||||
}
|
||||
|
||||
function column(id: string, header: string, width: string, edge = 'px-1.5') {
|
||||
return {
|
||||
id,
|
||||
header,
|
||||
meta: { class: { th: `${width} ${edge} ${HEADER}`, td: `${width} ${edge}` } },
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
column('id', t('ID'), ID_WIDTH, 'pr-1.5 pl-3'),
|
||||
...props.fields.map((field, index) =>
|
||||
column(`${FIELD_PREFIX}${field.key}`, field.label, widthOf(field, index)),
|
||||
),
|
||||
column('created_at', t('Created'), CREATED_WIDTH),
|
||||
column('updated_at', t('Updated'), UPDATED_WIDTH),
|
||||
column('updated_by', t('Updated by'), UPDATED_BY_WIDTH, 'pr-3 pl-1.5'),
|
||||
])
|
||||
|
||||
function fieldOf(columnId: string) {
|
||||
return props.fields.find((field) => `${FIELD_PREFIX}${field.key}` === columnId)
|
||||
}
|
||||
|
||||
function isRight(columnId: string) {
|
||||
return fieldOf(columnId)?.type === 'number'
|
||||
}
|
||||
|
||||
function sortIcon(columnId: string) {
|
||||
if (sorted.value.field !== columnId) {
|
||||
return undefined
|
||||
}
|
||||
return sorted.value.direction === 'asc' ? 'i-lucide-arrow-up' : 'i-lucide-arrow-down'
|
||||
}
|
||||
|
||||
function nameOf(entry: ContentEntry) {
|
||||
return props.names[entry.updated_by]
|
||||
}
|
||||
|
||||
watchPostEffect(() => {
|
||||
const headers = root.value?.querySelectorAll('thead th') ?? []
|
||||
headers.forEach((header, index) => {
|
||||
const id = columns.value[index]?.id
|
||||
if (
|
||||
id &&
|
||||
(ENTRY_SORT_FIELDS as readonly string[]).includes(id) &&
|
||||
id === sorted.value.field
|
||||
) {
|
||||
header.setAttribute(
|
||||
'aria-sort',
|
||||
sorted.value.direction === 'desc' ? 'descending' : 'ascending',
|
||||
)
|
||||
} else {
|
||||
header.removeAttribute('aria-sort')
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="border border-default bg-default">
|
||||
<UTable
|
||||
:data="entries"
|
||||
:columns="columns"
|
||||
:ui="{
|
||||
base: 'table-fixed',
|
||||
thead: 'bg-elevated',
|
||||
separator: 'bg-(--ui-border)',
|
||||
td: 'h-12 px-1.5 py-0 text-default',
|
||||
}"
|
||||
>
|
||||
<template #caption>
|
||||
<caption class="sr-only">
|
||||
{{
|
||||
caption
|
||||
}}
|
||||
</caption>
|
||||
</template>
|
||||
|
||||
<template v-for="column in columns" :key="column.id" #[`${column.id}-header`]>
|
||||
<span
|
||||
class="flex items-center gap-1"
|
||||
:class="isRight(column.id) ? 'justify-end' : ''"
|
||||
>
|
||||
{{ column.header }}
|
||||
<UIcon
|
||||
v-if="sortIcon(column.id)"
|
||||
:name="sortIcon(column.id)!"
|
||||
:data-sort-direction="sorted.direction"
|
||||
class="size-3"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #id-cell="{ row }">
|
||||
<span class="font-mono text-xs text-muted">{{ row.original.id }}</span>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-for="(field, index) in fields"
|
||||
:key="field.key"
|
||||
#[`${FIELD_PREFIX}${field.key}-cell`]="{ row }"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="index === 0"
|
||||
:to="entryPath(typeKey, row.original.id)"
|
||||
data-testid="entry-link"
|
||||
class="flex min-w-0 font-medium text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<FieldCell
|
||||
:field="field"
|
||||
:value="row.original.data[field.key]"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</RouterLink>
|
||||
<FieldCell v-else :field="field" :value="row.original.data[field.key]" />
|
||||
</template>
|
||||
|
||||
<template #created_at-cell="{ row }">
|
||||
<span class="text-muted">{{ formatEntryDate(row.original.created_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template #updated_at-cell="{ row }">
|
||||
<span class="text-muted">{{ formatEntryDate(row.original.updated_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template #updated_by-cell="{ row }">
|
||||
<span v-if="nameOf(row.original)" class="block truncate">{{
|
||||
nameOf(row.original)
|
||||
}}</span>
|
||||
<span v-else class="text-muted">{{ EMPTY_TEXT }}</span>
|
||||
</template>
|
||||
</UTable>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, describeCell, isEmptyCell } from '@/lib/entryCells'
|
||||
|
||||
const props = defineProps<{ field: BuiltinField; value: unknown }>()
|
||||
|
||||
const cell = computed(() => describeCell(props.field, props.value))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="isEmptyCell(cell)" class="text-muted">{{ EMPTY_TEXT }}</span>
|
||||
<span v-else-if="cell.kind === 'boolean'" class="flex items-center gap-1.5">
|
||||
<UIcon
|
||||
:name="cell.yes ? 'i-lucide-check' : 'i-lucide-minus'"
|
||||
class="size-3.5 shrink-0 text-muted"
|
||||
/>
|
||||
{{ cell.yes ? t('Yes') : t('No') }}
|
||||
</span>
|
||||
<UBadge
|
||||
v-else-if="cell.kind === 'choice'"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:label="cell.text"
|
||||
class="max-w-full border border-accented bg-transparent text-toned ring-0"
|
||||
:ui="{ base: 'h-5 rounded-none px-1.5 py-0 text-xs font-normal', label: 'truncate' }"
|
||||
/>
|
||||
<span v-else-if="cell.kind === 'number'" class="block text-right tabular-nums">{{
|
||||
cell.text
|
||||
}}</span>
|
||||
<span
|
||||
v-else
|
||||
class="block truncate"
|
||||
:class="cell.kind === 'richtext' ? 'text-toned' : ''"
|
||||
:title="cell.text"
|
||||
>{{ cell.text }}</span
|
||||
>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
ENTRY_SORT_FIELDS,
|
||||
sortDirectionLabel,
|
||||
sortFieldLabel,
|
||||
sortParts,
|
||||
type EntrySortField,
|
||||
} from '@/lib/entryListQuery'
|
||||
|
||||
const FOCUS_RING =
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary'
|
||||
|
||||
const props = defineProps<{ sort: string }>()
|
||||
|
||||
const emit = defineEmits<{ field: [field: EntrySortField]; direction: [] }>()
|
||||
|
||||
const parts = computed(() => sortParts(props.sort))
|
||||
const items = computed(() =>
|
||||
ENTRY_SORT_FIELDS.map((field) => ({ label: sortFieldLabel(field), value: field })),
|
||||
)
|
||||
const directionIcon = computed(() =>
|
||||
parts.value.direction === 'asc'
|
||||
? 'i-lucide-arrow-up-narrow-wide'
|
||||
: 'i-lucide-arrow-down-wide-narrow',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 md:flex-none">
|
||||
<USelect
|
||||
:model-value="parts.field"
|
||||
:items="items"
|
||||
value-key="value"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:aria-label="t('Sort by')"
|
||||
class="min-w-0 flex-1 md:w-55 md:flex-none"
|
||||
:ui="{
|
||||
base: `h-11 gap-2 rounded-none border border-accented bg-default px-2.5 text-sm text-highlighted ring-0 focus-visible:ring-0 md:h-8 ${FOCUS_RING}`,
|
||||
trailingIcon: 'size-3.5 text-muted',
|
||||
content: 'rounded-none shadow-(--shadow-popover)',
|
||||
item: 'min-h-11 items-center rounded-none px-2 py-0 data-[state=checked]:bg-(--rc-sand) data-[state=checked]:font-semibold data-[state=checked]:text-(--rc-on-sand) md:min-h-8',
|
||||
itemTrailingIcon: 'hidden',
|
||||
}"
|
||||
@update:model-value="emit('field', $event as EntrySortField)"
|
||||
>
|
||||
<template #default>
|
||||
<span class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span class="shrink-0 text-muted">{{ t('Sort by') }}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-left font-medium">{{
|
||||
sortFieldLabel(parts.field)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</USelect>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:icon="directionIcon"
|
||||
:aria-label="sortDirectionLabel(sort)"
|
||||
:data-sort-direction="parts.direction"
|
||||
:class="`size-11 shrink-0 justify-center rounded-none border border-accented bg-default p-0 text-highlighted ring-0 md:size-8 ${FOCUS_RING}`"
|
||||
@click="emit('direction')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { apiField } from '@/lib/__tests__/schemaFixtures'
|
||||
import { setLocale } from '@/i18n'
|
||||
import FieldCell from '../FieldCell.vue'
|
||||
import { mountInApp, text, unmountApp } from './directoryKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
afterEach(async () => {
|
||||
setLocale('en')
|
||||
unmountApp()
|
||||
})
|
||||
|
||||
const LONG = 'A very long title that would never fit on a single line of the table'
|
||||
|
||||
function iconPath(name: string) {
|
||||
return /d="([^"]+)"/.exec(lucide.icons[name]!.body)![1]
|
||||
}
|
||||
|
||||
function cell() {
|
||||
return document.body.firstElementChild!.firstElementChild!
|
||||
}
|
||||
|
||||
async function show(type: string, value: unknown) {
|
||||
await mountInApp(FieldCell, { field: apiField('Field', 'field', type), value })
|
||||
return cell()
|
||||
}
|
||||
|
||||
describe('FieldCell', () => {
|
||||
it('truncates text on one line with the full text as tooltip', async () => {
|
||||
const element = await show('text', LONG)
|
||||
|
||||
expect(text(element)).toBe(LONG)
|
||||
expect(element.className).toContain('truncate')
|
||||
expect(element.getAttribute('title')).toBe(LONG)
|
||||
})
|
||||
|
||||
it('shows the first line of a rich text in plain text, truncated, in the toned colour', async () => {
|
||||
const element = await show('richtext', '## **Two** days\n\nSecond paragraph')
|
||||
|
||||
expect(text(element)).toBe('Two days')
|
||||
expect(element.className).toContain('truncate')
|
||||
expect(element.className).toContain('text-toned')
|
||||
})
|
||||
|
||||
it('right-aligns a number with tabular figures', async () => {
|
||||
const element = await show('number', 500)
|
||||
|
||||
expect(text(element)).toBe('500')
|
||||
expect(element.className).toContain('text-right')
|
||||
expect(element.className).toContain('tabular-nums')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, 'Yes'],
|
||||
[false, 'No'],
|
||||
])('shows %s as an icon and %s', async (value, label) => {
|
||||
const element = await show('boolean', value)
|
||||
|
||||
expect(text(element)).toBe(label)
|
||||
expect(element.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('draws a check for yes and a minus for no', async () => {
|
||||
const yes = (await show('boolean', true)).querySelector('svg path')!.getAttribute('d')
|
||||
unmountApp()
|
||||
const no = (await show('boolean', false)).querySelector('svg path')!.getAttribute('d')
|
||||
|
||||
expect(yes).toBe(iconPath('check'))
|
||||
expect(no).toBe(iconPath('minus'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['2026-11-21', '21 Nov 2026'],
|
||||
['2027-01-01', '1 Jan 2027'],
|
||||
])('shows the day %s as %s', async (value, label) => {
|
||||
expect(text(await show('date', value))).toBe(label)
|
||||
})
|
||||
|
||||
it('shows the day in French', async () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(text(await show('date', '2026-11-21'))).toMatch(/^21 nov\.? 2026$/)
|
||||
})
|
||||
|
||||
it('shows a choice as a small outline tag', async () => {
|
||||
const element = await show('select', 'Workshop')
|
||||
|
||||
expect(text(element)).toBe('Workshop')
|
||||
expect(element.className).toContain('border-accented')
|
||||
expect(element.className).toContain('ring-0')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['text', ''],
|
||||
['richtext', null],
|
||||
['number', undefined],
|
||||
['date', ''],
|
||||
['select', null],
|
||||
])('shows a dash for an empty %s', async (type, value) => {
|
||||
const element = await show(type, value)
|
||||
|
||||
expect(text(element)).toBe('–')
|
||||
expect(element.className).toContain('text-muted')
|
||||
})
|
||||
})
|
||||
@@ -271,3 +271,129 @@ describe('countEntries', () => {
|
||||
await expect(useContentTypes().countEntries('events')).rejects.toBeInstanceOf(NetworkError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getContentType', () => {
|
||||
it('reads one type by key', async () => {
|
||||
answer(200, recipes)
|
||||
|
||||
await expect(useContentTypes().getContentType('recipes')).resolves.toMatchObject({
|
||||
key: 'recipes',
|
||||
})
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/content-type/recipes')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[403, 'forbidden'],
|
||||
[404, 'content_type_not_found'],
|
||||
[500, 'boom'],
|
||||
])('passes %i and its plain text body through', async (status, body) => {
|
||||
answer(status, body)
|
||||
|
||||
await expect(useContentTypes().getContentType('recipes')).rejects.toMatchObject({
|
||||
status,
|
||||
body,
|
||||
})
|
||||
})
|
||||
|
||||
it('lets a network failure through', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
await expect(useContentTypes().getContentType('recipes')).rejects.toBeInstanceOf(
|
||||
NetworkError,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listEntries', () => {
|
||||
const page = { items: [{ id: 1 }], total: 37, page: 2, size: 5, sort: 'id:asc' }
|
||||
|
||||
it('builds the query with explicit page, size and sort and returns the page', async () => {
|
||||
answer(200, page)
|
||||
|
||||
const listed = await useContentTypes().listEntries('events', {
|
||||
page: 2,
|
||||
size: 5,
|
||||
sort: 'id:asc',
|
||||
})
|
||||
|
||||
expect(listed).toEqual(page)
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe(
|
||||
'/v1/content-type/events/entry?page=2&size=5&sort=id:asc',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[403, 'forbidden'],
|
||||
[404, 'content_type_not_found'],
|
||||
[500, 'boom'],
|
||||
])('passes %i and its plain text body through', async (status, body) => {
|
||||
answer(status, body)
|
||||
|
||||
await expect(
|
||||
useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
|
||||
).rejects.toMatchObject({ status, body })
|
||||
})
|
||||
|
||||
it('lets a network failure through', async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||
|
||||
await expect(
|
||||
useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
|
||||
).rejects.toBeInstanceOf(NetworkError)
|
||||
})
|
||||
|
||||
it.each([[{ total: 1 }], [{ items: [] }], [{ items: [], total: '1' }], [null]])(
|
||||
'rejects a malformed page: %j',
|
||||
async (body) => {
|
||||
answer(200, body)
|
||||
|
||||
await expect(
|
||||
useContentTypes().listEntries('events', {
|
||||
page: 1,
|
||||
size: 25,
|
||||
sort: 'updated_at:desc',
|
||||
}),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
)
|
||||
|
||||
it('sends a 401 to the login page with the expired reason', async () => {
|
||||
answer(401, '')
|
||||
|
||||
await expect(
|
||||
useContentTypes().listEntries('events', { page: 1, size: 25, sort: 'updated_at:desc' }),
|
||||
).rejects.toMatchObject({ status: 401 })
|
||||
|
||||
const { default: router } = await import('@/router')
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
|
||||
expect(router.currentRoute.value.query.reason).toBe('expired')
|
||||
await router.push('/login')
|
||||
})
|
||||
})
|
||||
|
||||
describe('lookupUsers', () => {
|
||||
it('sends the distinct ids once and returns the display names', async () => {
|
||||
answer(200, [
|
||||
{ id: 3, display_name: 'Marta Alves' },
|
||||
{ id: 5, display_name: 'Jonas Pereira' },
|
||||
])
|
||||
|
||||
const names = await useContentTypes().lookupUsers([3, 3, 5, 3])
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(fetchMock.mock.calls[0]![0]).toBe('/v1/user/lookup?ids=3,5')
|
||||
expect(names.map((user) => user.display_name)).toEqual(['Marta Alves', 'Jonas Pereira'])
|
||||
})
|
||||
|
||||
it('rejects with the status on a 500', async () => {
|
||||
answer(500, 'boom')
|
||||
|
||||
await expect(useContentTypes().lookupUsers([3])).rejects.toMatchObject({ status: 500 })
|
||||
})
|
||||
|
||||
it('rejects a body that is not a list', async () => {
|
||||
answer(200, { id: 3 })
|
||||
|
||||
await expect(useContentTypes().lookupUsers([3])).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useContentTypes } from '@/composables/useContentTypes'
|
||||
import type { ContentType, ContentTypeDetailStatus } from '@/lib/contentTypes'
|
||||
|
||||
const FAILURES: Record<number, ContentTypeDetailStatus> = { 403: 'forbidden', 404: 'missing' }
|
||||
|
||||
export function useContentTypeDetail(key: () => string) {
|
||||
const { getContentType } = useContentTypes()
|
||||
const status = ref<ContentTypeDetailStatus>('loading')
|
||||
const type = ref<ContentType | null>(null)
|
||||
let generation = 0
|
||||
|
||||
async function load() {
|
||||
const current = ++generation
|
||||
status.value = 'loading'
|
||||
type.value = null
|
||||
try {
|
||||
const loaded = await getContentType(key())
|
||||
if (current === generation) {
|
||||
type.value = loaded
|
||||
status.value = 'ready'
|
||||
}
|
||||
} catch (error) {
|
||||
if (current === generation) {
|
||||
type.value = null
|
||||
status.value = (error instanceof ApiError && FAILURES[error.status]) || 'failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(key, load, { immediate: true })
|
||||
|
||||
return { status, type, retry: load }
|
||||
}
|
||||
@@ -1,34 +1,23 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { jsonInit } from '@/lib/directoryRequest'
|
||||
import type {
|
||||
BuiltinType,
|
||||
ContentType,
|
||||
EntryPage,
|
||||
EntryPageQuery,
|
||||
UserName,
|
||||
} from '@/lib/contentTypes'
|
||||
import type { ContentTypePayload, UpdatePayload } from '@/lib/schemaBuilderPayload'
|
||||
|
||||
export interface BuiltinField {
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
unique: boolean
|
||||
default: unknown
|
||||
min: number | null
|
||||
max: number | null
|
||||
options: unknown[] | null
|
||||
}
|
||||
|
||||
export interface ContentType {
|
||||
id: number
|
||||
key: string
|
||||
name: string
|
||||
icon: string | null
|
||||
fields: BuiltinField[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface BuiltinType {
|
||||
key: string
|
||||
name: string
|
||||
fields: BuiltinField[]
|
||||
}
|
||||
export type {
|
||||
BuiltinField,
|
||||
BuiltinType,
|
||||
ContentEntry,
|
||||
ContentType,
|
||||
EntryPage,
|
||||
EntryPageQuery,
|
||||
UserName,
|
||||
} from '@/lib/contentTypes'
|
||||
|
||||
export function useContentTypes() {
|
||||
const { request } = useApi()
|
||||
@@ -82,7 +71,33 @@ export function useContentTypes() {
|
||||
return page.total
|
||||
}
|
||||
|
||||
function getContentType(key: string): Promise<ContentType> {
|
||||
return request<ContentType>(`/v1/content-type/${encodeURIComponent(key)}`, options)
|
||||
}
|
||||
|
||||
async function listEntries(key: string, query: EntryPageQuery): Promise<EntryPage> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(query.page),
|
||||
size: String(query.size),
|
||||
sort: query.sort,
|
||||
})
|
||||
const url = `/v1/content-type/${encodeURIComponent(key)}/entry?${params.toString().replace(/%3A/g, ':')}`
|
||||
const page = await request<EntryPage>(url, options)
|
||||
if (!Array.isArray(page?.items) || typeof page.total !== 'number') {
|
||||
throw new Error(`Unexpected response from ${url}`)
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
async function lookupUsers(ids: number[]): Promise<UserName[]> {
|
||||
const distinct = [...new Set(ids)]
|
||||
return requestList<UserName>(`/v1/user/lookup?ids=${distinct.join(',')}`)
|
||||
}
|
||||
|
||||
return {
|
||||
getContentType,
|
||||
listEntries,
|
||||
lookupUsers,
|
||||
listContentTypes,
|
||||
listBuiltinTypes,
|
||||
createContentType,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { BuiltinField } from '@/composables/useContentTypes'
|
||||
import {
|
||||
COLUMN_CAP,
|
||||
clearStoredColumns,
|
||||
defaultColumnKeys,
|
||||
readStoredColumns,
|
||||
resolveColumnKeys,
|
||||
storeColumns,
|
||||
toggleColumnKeys,
|
||||
} from '@/lib/entryColumns'
|
||||
|
||||
export function useEntryColumns(typeKey: string, fields: () => BuiltinField[]) {
|
||||
const stored = readStoredColumns(typeKey)
|
||||
const chosen = ref<string[] | null>(null)
|
||||
|
||||
const keys = computed(() => chosen.value ?? resolveColumnKeys(fields(), stored))
|
||||
const columns = computed(() => fields().filter((field) => keys.value.includes(field.key)))
|
||||
const atCap = computed(() => keys.value.length >= COLUMN_CAP)
|
||||
|
||||
function toggle(key: string, on: boolean) {
|
||||
const next = toggleColumnKeys(fields(), keys.value, key, on)
|
||||
chosen.value = next
|
||||
storeColumns(typeKey, next)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
chosen.value = defaultColumnKeys(fields())
|
||||
clearStoredColumns(typeKey)
|
||||
}
|
||||
|
||||
return { keys, columns, atCap, toggle, reset }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useContentTypes } from '@/composables/useContentTypes'
|
||||
import type { ContentEntry, EntryListStatus } from '@/lib/contentTypes'
|
||||
import { usePagedQuery, type PagedOutcome } from '@/composables/usePagedQuery'
|
||||
import {
|
||||
parseEntryQuery,
|
||||
sameEntryQuery,
|
||||
toEntryRouteQuery,
|
||||
withOppositeDirection,
|
||||
withSortField,
|
||||
type EntryListQuery,
|
||||
type EntrySortField,
|
||||
} from '@/lib/entryListQuery'
|
||||
import { contentPath } from '@/lib/routePaths'
|
||||
|
||||
const UNAUTHORIZED = 401
|
||||
const FAILURES: Record<number, EntryListStatus> = { 403: 'forbidden', 404: 'missing' }
|
||||
|
||||
export function useEntryList(typeKey: string) {
|
||||
const route = useRoute()
|
||||
const { listEntries, lookupUsers } = useContentTypes()
|
||||
|
||||
const items = ref<ContentEntry[]>([])
|
||||
const status = ref<EntryListStatus>('loading')
|
||||
const names = ref<Record<number, string>>({})
|
||||
let lookupToken = 0
|
||||
|
||||
async function resolveNames(entries: ContentEntry[]) {
|
||||
const current = ++lookupToken
|
||||
const ids = entries.map((entry) => entry.updated_by).filter((id) => id > 0)
|
||||
if (ids.length === 0) {
|
||||
return
|
||||
}
|
||||
const found = await lookupUsers(ids).catch(() => [])
|
||||
if (current === lookupToken) {
|
||||
names.value = {
|
||||
...names.value,
|
||||
...Object.fromEntries(found.map((user) => [user.id, user.display_name])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function request(query: EntryListQuery, context: { loaded: boolean }) {
|
||||
try {
|
||||
const response = await listEntries(typeKey, query)
|
||||
return {
|
||||
kind: 'loaded',
|
||||
total: response.total,
|
||||
count: response.items.length,
|
||||
apply: () => {
|
||||
items.value = response.items
|
||||
status.value = 'ready'
|
||||
resolveNames(response.items)
|
||||
},
|
||||
} satisfies PagedOutcome
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === UNAUTHORIZED) {
|
||||
return { kind: 'stale' } satisfies PagedOutcome
|
||||
}
|
||||
if (!context.loaded) {
|
||||
items.value = []
|
||||
status.value = (error instanceof ApiError && FAILURES[error.status]) || 'failed'
|
||||
}
|
||||
return { kind: 'failed', notice: context.loaded } satisfies PagedOutcome
|
||||
}
|
||||
}
|
||||
|
||||
const paged = usePagedQuery<EntryListQuery>({
|
||||
path: contentPath(typeKey),
|
||||
initial: parseEntryQuery(route.query),
|
||||
initialLoading: true,
|
||||
request,
|
||||
parseRoute: parseEntryQuery,
|
||||
toRoute: toEntryRouteQuery,
|
||||
same: sameEntryQuery,
|
||||
})
|
||||
const { desired } = paged
|
||||
|
||||
function start() {
|
||||
return paged.load(paged.shown.value, 'replace')
|
||||
}
|
||||
|
||||
function retry() {
|
||||
status.value = 'loading'
|
||||
return paged.load(desired.value, 'replace')
|
||||
}
|
||||
|
||||
function changeSortField(field: EntrySortField) {
|
||||
return paged.load(
|
||||
{ ...desired.value, sort: withSortField(desired.value.sort, field), page: 1 },
|
||||
'push',
|
||||
)
|
||||
}
|
||||
|
||||
function toggleDirection() {
|
||||
return paged.load(
|
||||
{ ...desired.value, sort: withOppositeDirection(desired.value.sort), page: 1 },
|
||||
'push',
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
status,
|
||||
names,
|
||||
shown: paged.shown,
|
||||
desired,
|
||||
total: paged.total,
|
||||
hasLoaded: paged.hasLoaded,
|
||||
loading: paged.loading,
|
||||
pendingPage: paged.pendingPage,
|
||||
notice: paged.notice,
|
||||
showSkeleton: paged.showSkeleton,
|
||||
skeletonActive: paged.skeletonActive,
|
||||
pageCount: paged.pageCount,
|
||||
start,
|
||||
retry,
|
||||
changePage: paged.changePage,
|
||||
changeSize: paged.changeSize,
|
||||
changeSortField,
|
||||
toggleDirection,
|
||||
retryNotice: paged.retryNotice,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { messages } from './messages'
|
||||
|
||||
const SOURCES = [
|
||||
'components/ColumnPicker.vue',
|
||||
'components/ColumnPickerList.vue',
|
||||
'components/ContentEmpty.vue',
|
||||
'components/ContentEntries.vue',
|
||||
'components/ContentState.vue',
|
||||
'components/ContentToolbar.vue',
|
||||
'components/EntryCardList.vue',
|
||||
'components/EntrySkeleton.vue',
|
||||
'components/EntryTable.vue',
|
||||
'components/FieldCell.vue',
|
||||
'components/SortSelect.vue',
|
||||
'lib/entryCells.ts',
|
||||
'lib/entryListQuery.ts',
|
||||
'views/ContentListView.vue',
|
||||
]
|
||||
|
||||
const SAME_IN_FRENCH = new Set(['ID'])
|
||||
|
||||
const LITERAL = /\bt\(\s*(?:'((?:\\.|[^'\\])*)'|"((?:\\.|[^"\\])*)")/g
|
||||
|
||||
function keysIn(source: string) {
|
||||
return [...source.matchAll(LITERAL)].map((match) =>
|
||||
(match[1] ?? match[2] ?? '').replace(/\\(['"\\])/g, '$1'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Entry list messages', () => {
|
||||
const keys = SOURCES.flatMap((file) =>
|
||||
keysIn(readFileSync(join(import.meta.dirname, '..', file), 'utf8')).map((key) => ({
|
||||
file,
|
||||
key,
|
||||
})),
|
||||
)
|
||||
|
||||
it('finds the strings of the screen', () => {
|
||||
expect(keys.length).toBeGreaterThan(30)
|
||||
})
|
||||
|
||||
it.each(keys.map(({ file, key }) => [file, key]))(
|
||||
'%s: "%s" has its own French entry',
|
||||
(_file, key) => {
|
||||
const french = (messages as Record<string, string>)[key as string]
|
||||
|
||||
expect(french).toMatch(/\S/)
|
||||
if (!SAME_IN_FRENCH.has(key as string)) {
|
||||
expect(french).not.toBe(key)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -2503,6 +2503,34 @@ export const messages = {
|
||||
'View entries': 'Voir les entrées',
|
||||
'Content type deleted': 'Type de contenu supprimé',
|
||||
'{0} was deleted.': '{0} a été supprimé.',
|
||||
Columns: 'Colonnes',
|
||||
'Choose up to {0} fields to show. {1} selected.':
|
||||
'Choisissez jusqu’à {0} champs à afficher. {1} sélectionné(s).',
|
||||
'Created and updated dates always show':
|
||||
'Les dates de création et de modification sont toujours affichées',
|
||||
'You can show up to {0} fields.': 'Vous pouvez afficher jusqu’à {0} champs.',
|
||||
'No entries yet': 'Aucune entrée pour le moment',
|
||||
'Use New entry to add the first entry. Each entry follows the fields of this content type.':
|
||||
'Utilisez Nouvelle entrée pour ajouter la première entrée. Chaque entrée suit les champs de ce type de contenu.',
|
||||
'New entry': 'Nouvelle entrée',
|
||||
'1 entry': '1 entrée',
|
||||
'{0} entries': '{0} entrées',
|
||||
'GET /v1/content-type/{0} failed. Check your connection and try again.':
|
||||
'GET /v1/content-type/{0} a échoué. Vérifiez votre connexion et réessayez.',
|
||||
"Couldn't load entries": 'Impossible de charger les entrées',
|
||||
"You don't have access to {0}": 'Vous n’avez pas accès à {0}',
|
||||
"Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin.":
|
||||
'Votre compte ne peut pas ouvrir ce contenu pour le moment. Si vous pensez qu’il s’agit d’une erreur, contactez un propriétaire ou un administrateur.',
|
||||
'Edit content type': 'Modifier le type de contenu',
|
||||
ID: 'ID',
|
||||
'Updated by': 'Modifié par',
|
||||
'Sort by': 'Trier par',
|
||||
'Sort descending': 'Trier par ordre décroissant',
|
||||
'Sort ascending': 'Trier par ordre croissant',
|
||||
'{0}, sorted by last updated, newest first':
|
||||
'{0}, trié par dernière modification, plus récent en premier',
|
||||
'{0}, sorted by {1}, {2}': '{0}, trié par {1}, {2}',
|
||||
id: 'identifiant',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -34,9 +34,12 @@ describe('parseArticleQuery', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['abc', '0', '-2', '2.5', '', '1e2'])('treats page "%s" as 1', (page) => {
|
||||
expect(parseArticleQuery({ page }).page).toBe(1)
|
||||
})
|
||||
it.each(['abc', '0', '-2', '2.5', '', '1e2', '99999999999999999999999'])(
|
||||
'treats page "%s" as 1',
|
||||
(page) => {
|
||||
expect(parseArticleQuery({ page }).page).toBe(1)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['10', 'abc', '0', '26', ''])('treats size "%s" as 25', (size) => {
|
||||
expect(parseArticleQuery({ size }).size).toBe(25)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { apiField } from '@/lib/__tests__/schemaFixtures'
|
||||
import {
|
||||
cardFields,
|
||||
describeCell,
|
||||
formatDayValue,
|
||||
plainCellText,
|
||||
richTextFirstLine,
|
||||
} from '@/lib/entryCells'
|
||||
import { resolveContentListState } from '@/lib/contentListState'
|
||||
|
||||
const text = apiField('Title', 'title', 'text')
|
||||
const rich = apiField('Description', 'description', 'richtext')
|
||||
const number = apiField('Capacity', 'capacity', 'number')
|
||||
const flag = apiField('Online', 'online', 'boolean')
|
||||
const day = apiField('Starts on', 'starts_on', 'date')
|
||||
const choice = apiField('Category', 'category', 'select')
|
||||
|
||||
describe('describeCell', () => {
|
||||
it.each([
|
||||
['text', text, 'Lime plaster', { kind: 'text', text: 'Lime plaster' }],
|
||||
['number', number, 1500, { kind: 'number', text: '1,500' }],
|
||||
['number zero', number, 0, { kind: 'number', text: '0' }],
|
||||
['boolean true', flag, true, { kind: 'boolean', text: '', yes: true }],
|
||||
['boolean false', flag, false, { kind: 'boolean', text: '', yes: false }],
|
||||
['date', day, '2026-11-21', { kind: 'date', text: '21 Nov 2026' }],
|
||||
['choice', choice, 'Workshop', { kind: 'choice', text: 'Workshop' }],
|
||||
['rich text', rich, 'First line\nSecond', { kind: 'richtext', text: 'First line' }],
|
||||
])('describes %s', (_, field, value, expected) => {
|
||||
expect(describeCell(field, value)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each([undefined, null, ''])('treats %s as empty for every type', (value) => {
|
||||
for (const field of [text, rich, number, flag, day, choice]) {
|
||||
expect(describeCell(field, value).kind).toBe('empty')
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a rich text without a visible line as empty', () => {
|
||||
expect(describeCell(rich, '\n \n').kind).toBe('empty')
|
||||
expect(describeCell(rich, '##').kind).toBe('empty')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDayValue', () => {
|
||||
it.each([
|
||||
['2026-11-21', '21 Nov 2026'],
|
||||
['2027-01-01', '1 Jan 2027'],
|
||||
['2026-12-31', '31 Dec 2026'],
|
||||
['2026-11-21T23:30:00Z', '21 Nov 2026'],
|
||||
['soon', 'soon'],
|
||||
])('reads %s as %s without shifting the day', (value, expected) => {
|
||||
expect(formatDayValue(value)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('richTextFirstLine', () => {
|
||||
it.each([
|
||||
['Two days of **lime putty** and stone.', 'Two days of lime putty and stone.'],
|
||||
['# A heading\nBody', 'A heading'],
|
||||
['\n\n- First item\n- Second', 'First item'],
|
||||
['1. Ordered', 'Ordered'],
|
||||
['> Quote', 'Quote'],
|
||||
['See [the guide](https://example.test/guide)', 'See the guide'],
|
||||
['', 'alt text'],
|
||||
['`code` and ~~gone~~', 'code and gone'],
|
||||
['snake_case stays', 'snake_case stays'],
|
||||
])('turns "%s" into "%s"', (markdown, expected) => {
|
||||
expect(richTextFirstLine(markdown)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('plainCellText', () => {
|
||||
it('spells empty, yes and no and passes text through', () => {
|
||||
expect(plainCellText(describeCell(text, ''))).toBe('–')
|
||||
expect(plainCellText(describeCell(flag, true))).toBe('Yes')
|
||||
expect(plainCellText(describeCell(flag, false))).toBe('No')
|
||||
expect(plainCellText(describeCell(day, '2026-11-21'))).toBe('21 Nov 2026')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cardFields', () => {
|
||||
it('picks the first two fields and the first choice', () => {
|
||||
expect(cardFields([text, day, flag, choice])).toEqual({
|
||||
first: text,
|
||||
second: day,
|
||||
choice,
|
||||
})
|
||||
})
|
||||
|
||||
it('has no choice when the type has none, and never repeats the first field', () => {
|
||||
expect(cardFields([text, day]).choice).toBeUndefined()
|
||||
expect(cardFields([choice, day]).choice).toBeUndefined()
|
||||
})
|
||||
|
||||
it('copes with an empty type', () => {
|
||||
expect(cardFields([])).toEqual({ first: undefined, second: undefined, choice: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveContentListState', () => {
|
||||
it.each([
|
||||
['ready', 'ready', 7, 'list'],
|
||||
['ready', 'ready', 0, 'empty'],
|
||||
['loading', 'ready', 7, 'loading'],
|
||||
['ready', 'loading', 0, 'loading'],
|
||||
['ready', 'failed', 0, 'entriesError'],
|
||||
['failed', 'ready', 7, 'typeError'],
|
||||
['failed', 'failed', 0, 'typeError'],
|
||||
['forbidden', 'loading', 0, 'forbidden'],
|
||||
['ready', 'forbidden', 0, 'forbidden'],
|
||||
['missing', 'ready', 0, 'notFound'],
|
||||
['ready', 'missing', 0, 'notFound'],
|
||||
['forbidden', 'missing', 0, 'notFound'],
|
||||
] as const)('type %s, entries %s, total %s is %s', (type, entries, total, expected) => {
|
||||
expect(resolveContentListState({ type, entries, total })).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { eventsFields } from '@/lib/__tests__/schemaFixtures'
|
||||
import {
|
||||
clearStoredColumns,
|
||||
columnsStorageKey,
|
||||
defaultColumnKeys,
|
||||
readStoredColumns,
|
||||
resolveColumnKeys,
|
||||
storeColumns,
|
||||
toggleColumnKeys,
|
||||
} from '@/lib/entryColumns'
|
||||
|
||||
const KEY = 'renewcms-content-columns-events'
|
||||
const ALL = eventsFields.map((field) => field.key)
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('defaultColumnKeys', () => {
|
||||
it('takes the first four fields', () => {
|
||||
expect(defaultColumnKeys(eventsFields)).toEqual(['title', 'starts_on', 'ends_on', 'online'])
|
||||
})
|
||||
|
||||
it('takes fewer when the type has fewer', () => {
|
||||
expect(defaultColumnKeys(eventsFields.slice(0, 2))).toEqual(['title', 'starts_on'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveColumnKeys', () => {
|
||||
it.each([
|
||||
['nothing stored', null, ['title', 'starts_on', 'ends_on', 'online']],
|
||||
['not an array', 'title', ['title', 'starts_on', 'ends_on', 'online']],
|
||||
['an empty array', [], ['title', 'starts_on', 'ends_on', 'online']],
|
||||
['only unknown keys', ['gone'], ['title', 'starts_on', 'ends_on', 'online']],
|
||||
['unknown keys dropped', ['title', 'gone', 'capacity'], ['title', 'capacity']],
|
||||
['schema order, not stored order', ['capacity', 'title'], ['title', 'capacity']],
|
||||
['non-string items ignored', [1, null, 'title'], ['title']],
|
||||
['more than six, first six in schema order', [...ALL].reverse(), ALL.slice(0, 6)],
|
||||
['duplicates counted once', ['title', 'title', 'online'], ['title', 'online']],
|
||||
])('%s', (_, stored, expected) => {
|
||||
expect(resolveColumnKeys(eventsFields, stored)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleColumnKeys', () => {
|
||||
it('adds a key in schema order', () => {
|
||||
expect(toggleColumnKeys(eventsFields, ['title', 'capacity'], 'online', true)).toEqual([
|
||||
'title',
|
||||
'online',
|
||||
'capacity',
|
||||
])
|
||||
})
|
||||
|
||||
it('removes a key', () => {
|
||||
expect(toggleColumnKeys(eventsFields, ['title', 'online'], 'online', false)).toEqual([
|
||||
'title',
|
||||
])
|
||||
})
|
||||
|
||||
it('never removes the last field column', () => {
|
||||
const one = ['capacity']
|
||||
|
||||
expect(toggleColumnKeys(eventsFields, one, 'capacity', false)).toEqual(['capacity'])
|
||||
})
|
||||
|
||||
it('refuses a seventh column', () => {
|
||||
const six = ALL.slice(0, 6)
|
||||
|
||||
expect(toggleColumnKeys(eventsFields, six, 'description', true)).toBe(six)
|
||||
})
|
||||
})
|
||||
|
||||
describe('storage', () => {
|
||||
it('uses one key per type', () => {
|
||||
expect(columnsStorageKey('events')).toBe(KEY)
|
||||
expect(columnsStorageKey('recipes')).toBe('renewcms-content-columns-recipes')
|
||||
})
|
||||
|
||||
it('writes, reads and clears', () => {
|
||||
storeColumns('events', ['title', 'capacity'])
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBe('["title","capacity"]')
|
||||
expect(readStoredColumns('events')).toEqual(['title', 'capacity'])
|
||||
|
||||
clearStoredColumns('events')
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['not json', '{', ''])('reads "%s" as nothing usable', (raw) => {
|
||||
localStorage.setItem(KEY, raw)
|
||||
|
||||
expect(resolveColumnKeys(eventsFields, readStoredColumns('events'))).toEqual(
|
||||
defaultColumnKeys(eventsFields),
|
||||
)
|
||||
})
|
||||
|
||||
it('swallows a throwing or missing localStorage', () => {
|
||||
vi.stubGlobal('localStorage', undefined)
|
||||
|
||||
expect(readStoredColumns('events')).toBeNull()
|
||||
expect(() => storeColumns('events', ['title'])).not.toThrow()
|
||||
expect(() => clearStoredColumns('events')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
entryCaption,
|
||||
parseEntryQuery,
|
||||
sameEntryQuery,
|
||||
sortDirectionLabel,
|
||||
toEntryRouteQuery,
|
||||
withOppositeDirection,
|
||||
withSortField,
|
||||
} from '@/lib/entryListQuery'
|
||||
|
||||
describe('parseEntryQuery', () => {
|
||||
it('falls back to page 1, size 25 and updated_at:desc', () => {
|
||||
expect(parseEntryQuery({})).toEqual({
|
||||
page: 1,
|
||||
size: 25,
|
||||
q: '',
|
||||
sort: 'updated_at:desc',
|
||||
})
|
||||
})
|
||||
|
||||
it('reads valid values', () => {
|
||||
expect(parseEntryQuery({ page: '2', size: '50', sort: 'created_at:asc' })).toEqual({
|
||||
page: 2,
|
||||
size: 50,
|
||||
q: '',
|
||||
sort: 'created_at:asc',
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['abc', '0', '-2', '2.5', '', '1e2'])('treats page "%s" as 1', (page) => {
|
||||
expect(parseEntryQuery({ page }).page).toBe(1)
|
||||
})
|
||||
|
||||
it.each(['10', '0', 'abc', '', '500'])('treats size "%s" as 25', (size) => {
|
||||
expect(parseEntryQuery({ size }).size).toBe(25)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'title:asc',
|
||||
'data:asc',
|
||||
'id',
|
||||
'id:up',
|
||||
'ID:asc',
|
||||
':asc',
|
||||
'updated_at:',
|
||||
'created_at:asc:x',
|
||||
])('treats sort "%s" as the default', (sort) => {
|
||||
expect(parseEntryQuery({ sort }).sort).toBe('updated_at:desc')
|
||||
})
|
||||
|
||||
it.each(['99999999999999999999999', '9007199254740993', ' 5', '5e0', '0x5', '5 ', '+5', '05'])(
|
||||
'rejects the unsafe or non canonical number "%s"',
|
||||
(value) => {
|
||||
expect(parseEntryQuery({ page: value, size: value })).toMatchObject({
|
||||
page: 1,
|
||||
size: 25,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps the largest safe page', () => {
|
||||
expect(parseEntryQuery({ page: '9007199254740991' }).page).toBe(9007199254740991)
|
||||
})
|
||||
|
||||
it('takes the first of repeated values', () => {
|
||||
expect(parseEntryQuery({ page: ['3', '4'], size: ['5', '50'] })).toMatchObject({
|
||||
page: 3,
|
||||
size: 5,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toEntryRouteQuery', () => {
|
||||
it('omits the defaults', () => {
|
||||
expect(toEntryRouteQuery({ page: 1, size: 25, q: '', sort: 'updated_at:desc' })).toEqual({})
|
||||
})
|
||||
|
||||
it('writes size, then sort, then page', () => {
|
||||
const route = toEntryRouteQuery({ page: 2, size: 5, q: '', sort: 'id:asc' })
|
||||
|
||||
expect(Object.keys(route)).toEqual(['size', 'sort', 'page'])
|
||||
expect(route).toEqual({ size: '5', sort: 'id:asc', page: '2' })
|
||||
})
|
||||
|
||||
it('keeps the sort of an ascending default field', () => {
|
||||
expect(toEntryRouteQuery({ page: 1, size: 25, q: '', sort: 'updated_at:asc' })).toEqual({
|
||||
sort: 'updated_at:asc',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sameEntryQuery', () => {
|
||||
const base = { page: 1, size: 25, q: '', sort: 'updated_at:desc' }
|
||||
|
||||
it('compares page, size and sort', () => {
|
||||
expect(sameEntryQuery(base, { ...base })).toBe(true)
|
||||
expect(sameEntryQuery(base, { ...base, page: 2 })).toBe(false)
|
||||
expect(sameEntryQuery(base, { ...base, size: 5 })).toBe(false)
|
||||
expect(sameEntryQuery(base, { ...base, sort: 'id:desc' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sort helpers', () => {
|
||||
it.each([
|
||||
['updated_at:desc', 'id', 'id:desc'],
|
||||
['updated_at:asc', 'created_at', 'created_at:asc'],
|
||||
['id:asc', 'updated_at', 'updated_at:asc'],
|
||||
] as const)('moves %s to field %s as %s', (sort, field, expected) => {
|
||||
expect(withSortField(sort, field)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['updated_at:desc', 'updated_at:asc'],
|
||||
['updated_at:asc', 'updated_at:desc'],
|
||||
['id:desc', 'id:asc'],
|
||||
])('flips %s to %s', (sort, expected) => {
|
||||
expect(withOppositeDirection(sort)).toBe(expected)
|
||||
})
|
||||
|
||||
it('names the next action on the direction button', () => {
|
||||
expect(sortDirectionLabel('updated_at:desc')).toBe('Sort ascending')
|
||||
expect(sortDirectionLabel('id:asc')).toBe('Sort descending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('entryCaption', () => {
|
||||
it.each([
|
||||
['updated_at:desc', 'Events, sorted by last updated, newest first'],
|
||||
['updated_at:asc', 'Events, sorted by updated, ascending'],
|
||||
['id:desc', 'Events, sorted by id, descending'],
|
||||
['created_at:asc', 'Events, sorted by created, ascending'],
|
||||
])('reads %s as "%s"', (sort, caption) => {
|
||||
expect(entryCaption('Events', sort)).toBe(caption)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ContentTypeDetailStatus, EntryListStatus } from '@/lib/contentTypes'
|
||||
|
||||
export type ContentListState =
|
||||
| 'notFound'
|
||||
| 'forbidden'
|
||||
| 'typeError'
|
||||
| 'entriesError'
|
||||
| 'loading'
|
||||
| 'empty'
|
||||
| 'list'
|
||||
|
||||
interface ContentListInputs {
|
||||
type: ContentTypeDetailStatus
|
||||
entries: EntryListStatus
|
||||
total: number
|
||||
}
|
||||
|
||||
export function resolveContentListState({
|
||||
type,
|
||||
entries,
|
||||
total,
|
||||
}: ContentListInputs): ContentListState {
|
||||
if (type === 'missing' || entries === 'missing') {
|
||||
return 'notFound'
|
||||
}
|
||||
if (type === 'forbidden' || entries === 'forbidden') {
|
||||
return 'forbidden'
|
||||
}
|
||||
if (type === 'failed') {
|
||||
return 'typeError'
|
||||
}
|
||||
if (entries === 'failed') {
|
||||
return 'entriesError'
|
||||
}
|
||||
if (type === 'loading' || entries === 'loading') {
|
||||
return 'loading'
|
||||
}
|
||||
return total === 0 ? 'empty' : 'list'
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export interface BuiltinField {
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
required: boolean
|
||||
unique: boolean
|
||||
default: unknown
|
||||
min: number | null
|
||||
max: number | null
|
||||
options: unknown[] | null
|
||||
}
|
||||
|
||||
export interface ContentType {
|
||||
id: number
|
||||
key: string
|
||||
name: string
|
||||
icon: string | null
|
||||
fields: BuiltinField[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ContentEntry {
|
||||
id: number
|
||||
content_type_id: number
|
||||
data: Record<string, unknown>
|
||||
created_by: number
|
||||
updated_by: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface EntryPage {
|
||||
items: ContentEntry[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
sort: string
|
||||
}
|
||||
|
||||
export interface EntryPageQuery {
|
||||
page: number
|
||||
size: number
|
||||
sort: string
|
||||
}
|
||||
|
||||
export interface UserName {
|
||||
id: number
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface BuiltinType {
|
||||
key: string
|
||||
name: string
|
||||
fields: BuiltinField[]
|
||||
}
|
||||
|
||||
export type ContentTypeDetailStatus = 'loading' | 'ready' | 'failed' | 'forbidden' | 'missing'
|
||||
|
||||
export type EntryListStatus = 'loading' | 'ready' | 'failed' | 'forbidden' | 'missing'
|
||||
@@ -0,0 +1,95 @@
|
||||
import { formatNumber, t } from '@/i18n'
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
import { formatAbsoluteDate, formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
|
||||
export type CellKind = 'empty' | 'text' | 'richtext' | 'number' | 'boolean' | 'date' | 'choice'
|
||||
|
||||
export interface CellView {
|
||||
kind: CellKind
|
||||
text: string
|
||||
yes?: boolean
|
||||
}
|
||||
|
||||
export const EMPTY_TEXT = '–'
|
||||
|
||||
const EMPTY: CellView = { kind: 'empty', text: '' }
|
||||
const ISO_DAY = /^(\d{4})-(\d{2})-(\d{2})/
|
||||
const LINE_PREFIX = /^\s{0,3}(?:#{1,6}(?:\s+|$)|>\s?|[-*+]\s+|\d+[.)]\s+)+/
|
||||
const IMAGE_OR_LINK = /!?\[([^\]]*)\]\([^)]*\)/g
|
||||
const EMPHASIS = /(\*\*|__|\*|`|~~)/g
|
||||
|
||||
export function richTextFirstLine(markdown: string) {
|
||||
for (const line of markdown.split(/\r?\n/)) {
|
||||
const plain = line
|
||||
.replace(LINE_PREFIX, '')
|
||||
.replace(IMAGE_OR_LINK, '$1')
|
||||
.replace(EMPHASIS, '')
|
||||
.trim()
|
||||
if (plain) {
|
||||
return plain
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatDayValue(value: string) {
|
||||
const match = ISO_DAY.exec(value)
|
||||
if (!match) {
|
||||
return value
|
||||
}
|
||||
return formatAbsoluteDate(new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])))
|
||||
}
|
||||
|
||||
export function describeCell(field: BuiltinField, value: unknown): CellView {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return EMPTY
|
||||
}
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
return { kind: 'boolean', text: '', yes: value === true }
|
||||
case 'number':
|
||||
return typeof value === 'number'
|
||||
? { kind: 'number', text: formatNumber(value) }
|
||||
: { kind: 'number', text: String(value) }
|
||||
case 'date':
|
||||
return { kind: 'date', text: formatDayValue(String(value)) }
|
||||
case 'select':
|
||||
return { kind: 'choice', text: String(value) }
|
||||
case 'richtext': {
|
||||
const line = richTextFirstLine(String(value))
|
||||
return line ? { kind: 'richtext', text: line } : EMPTY
|
||||
}
|
||||
default:
|
||||
return { kind: 'text', text: String(value) }
|
||||
}
|
||||
}
|
||||
|
||||
export function formatEntryDate(iso: string) {
|
||||
return formatAbsoluteDateFromIso(iso)
|
||||
}
|
||||
|
||||
export function isEmptyCell(cell: CellView) {
|
||||
return cell.kind === 'empty'
|
||||
}
|
||||
|
||||
export function plainCellText(cell: CellView) {
|
||||
if (cell.kind === 'empty') {
|
||||
return EMPTY_TEXT
|
||||
}
|
||||
if (cell.kind === 'boolean') {
|
||||
return cell.yes ? t('Yes') : t('No')
|
||||
}
|
||||
return cell.text
|
||||
}
|
||||
|
||||
export interface CardFields {
|
||||
first?: BuiltinField
|
||||
second?: BuiltinField
|
||||
choice?: BuiltinField
|
||||
}
|
||||
|
||||
export function cardFields(fields: BuiltinField[]): CardFields {
|
||||
const [first, second] = fields
|
||||
const choice = fields.find((field) => field.type === 'select')
|
||||
return { first, second, choice: choice === first ? undefined : choice }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { BuiltinField } from '@/lib/contentTypes'
|
||||
|
||||
export const COLUMN_CAP = 6
|
||||
export const DEFAULT_COLUMN_COUNT = 4
|
||||
|
||||
const STORAGE_PREFIX = 'renewcms-content-columns-'
|
||||
|
||||
export function columnsStorageKey(typeKey: string) {
|
||||
return `${STORAGE_PREFIX}${typeKey}`
|
||||
}
|
||||
|
||||
export function defaultColumnKeys(fields: BuiltinField[]) {
|
||||
return fields.slice(0, DEFAULT_COLUMN_COUNT).map((field) => field.key)
|
||||
}
|
||||
|
||||
export function resolveColumnKeys(fields: BuiltinField[], stored: unknown) {
|
||||
if (!Array.isArray(stored)) {
|
||||
return defaultColumnKeys(fields)
|
||||
}
|
||||
const wanted = new Set(stored.filter((key): key is string => typeof key === 'string'))
|
||||
const kept = fields
|
||||
.map((field) => field.key)
|
||||
.filter((key) => wanted.has(key))
|
||||
.slice(0, COLUMN_CAP)
|
||||
return kept.length > 0 ? kept : defaultColumnKeys(fields)
|
||||
}
|
||||
|
||||
export function readStoredColumns(typeKey: string): unknown {
|
||||
try {
|
||||
const raw = localStorage.getItem(columnsStorageKey(typeKey))
|
||||
return raw === null ? null : JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function storeColumns(typeKey: string, keys: string[]) {
|
||||
try {
|
||||
localStorage.setItem(columnsStorageKey(typeKey), JSON.stringify(keys))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredColumns(typeKey: string) {
|
||||
try {
|
||||
localStorage.removeItem(columnsStorageKey(typeKey))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleColumnKeys(
|
||||
fields: BuiltinField[],
|
||||
current: string[],
|
||||
key: string,
|
||||
on: boolean,
|
||||
) {
|
||||
const wanted = new Set(current)
|
||||
if (on) {
|
||||
if (wanted.size >= COLUMN_CAP) {
|
||||
return current
|
||||
}
|
||||
wanted.add(key)
|
||||
} else if (wanted.size > 1) {
|
||||
wanted.delete(key)
|
||||
}
|
||||
return fields.map((field) => field.key).filter((fieldKey) => wanted.has(fieldKey))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { t } from '@/i18n'
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
PAGE_SIZES,
|
||||
firstQueryValue,
|
||||
parsePositiveInteger,
|
||||
} from '@/lib/pageQuery'
|
||||
|
||||
export const ENTRY_SORT_FIELDS = ['id', 'created_at', 'updated_at'] as const
|
||||
export type EntrySortField = (typeof ENTRY_SORT_FIELDS)[number]
|
||||
export type EntrySortDirection = 'asc' | 'desc'
|
||||
|
||||
export const DEFAULT_ENTRY_SORT = 'updated_at:desc'
|
||||
|
||||
export interface EntryListQuery {
|
||||
page: number
|
||||
size: number
|
||||
q: string
|
||||
sort: string
|
||||
}
|
||||
|
||||
const SORT_PATTERN = new RegExp(`^(${ENTRY_SORT_FIELDS.join('|')}):(asc|desc)$`)
|
||||
|
||||
export function parseEntryQuery(query: LocationQuery): EntryListQuery {
|
||||
const size = parsePositiveInteger(firstQueryValue(query.size))
|
||||
const sort = firstQueryValue(query.sort)
|
||||
return {
|
||||
page: parsePositiveInteger(firstQueryValue(query.page)) ?? 1,
|
||||
size: size !== null && PAGE_SIZES.includes(size) ? size : DEFAULT_PAGE_SIZE,
|
||||
q: '',
|
||||
sort: SORT_PATTERN.test(sort) ? sort : DEFAULT_ENTRY_SORT,
|
||||
}
|
||||
}
|
||||
|
||||
export function toEntryRouteQuery(query: EntryListQuery) {
|
||||
const route: Record<string, string> = {}
|
||||
if (query.size !== DEFAULT_PAGE_SIZE) {
|
||||
route.size = String(query.size)
|
||||
}
|
||||
if (query.sort !== DEFAULT_ENTRY_SORT) {
|
||||
route.sort = query.sort
|
||||
}
|
||||
if (query.page !== 1) {
|
||||
route.page = String(query.page)
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
export function sameEntryQuery(left: EntryListQuery, right: EntryListQuery) {
|
||||
return left.page === right.page && left.size === right.size && left.sort === right.sort
|
||||
}
|
||||
|
||||
export function sortParts(sort: string) {
|
||||
const [field, direction] = sort.split(':') as [EntrySortField, EntrySortDirection]
|
||||
return { field, direction }
|
||||
}
|
||||
|
||||
export function withSortField(sort: string, field: EntrySortField) {
|
||||
return `${field}:${sortParts(sort).direction}`
|
||||
}
|
||||
|
||||
export function withOppositeDirection(sort: string) {
|
||||
const { field, direction } = sortParts(sort)
|
||||
return `${field}:${direction === 'asc' ? 'desc' : 'asc'}`
|
||||
}
|
||||
|
||||
export function sortFieldLabel(field: EntrySortField) {
|
||||
if (field === 'id') {
|
||||
return t('ID')
|
||||
}
|
||||
return field === 'created_at' ? t('Created') : t('Last updated')
|
||||
}
|
||||
|
||||
export function sortDirectionLabel(sort: string) {
|
||||
return sortParts(sort).direction === 'asc' ? t('Sort descending') : t('Sort ascending')
|
||||
}
|
||||
|
||||
export function entryCaption(name: string, sort: string) {
|
||||
const { field, direction } = sortParts(sort)
|
||||
if (sort === DEFAULT_ENTRY_SORT) {
|
||||
return t('{0}, sorted by last updated, newest first', [name])
|
||||
}
|
||||
const column = { id: t('id'), created_at: t('created'), updated_at: t('updated') }[field]
|
||||
return t('{0}, sorted by {1}, {2}', [
|
||||
name,
|
||||
column,
|
||||
direction === 'desc' ? t('descending') : t('ascending'),
|
||||
])
|
||||
}
|
||||
@@ -12,7 +12,11 @@ export function firstQueryValue(value: LocationQuery[string] | undefined) {
|
||||
}
|
||||
|
||||
export function parsePositiveInteger(value: string) {
|
||||
return POSITIVE_INTEGER.test(value) ? Number(value) : null
|
||||
if (!POSITIVE_INTEGER.test(value)) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isSafeInteger(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
export function sameRouteQuery(left: LocationQuery, right: Record<string, string>) {
|
||||
|
||||
@@ -19,7 +19,7 @@ const MONTH_NAMES = [
|
||||
'Dec',
|
||||
]
|
||||
|
||||
function formatAbsoluteDate(date: Date) {
|
||||
export function formatAbsoluteDate(date: Date) {
|
||||
if (locale.value === 'fr') {
|
||||
return new Intl.DateTimeFormat('fr', {
|
||||
day: 'numeric',
|
||||
|
||||
@@ -1,2 +1,14 @@
|
||||
export const HOME_PATH = '/home'
|
||||
export const SCHEMA_PATH = '/schema'
|
||||
|
||||
export function contentPath(key: string) {
|
||||
return `/content/${encodeURIComponent(key)}`
|
||||
}
|
||||
|
||||
export function entryPath(typeKey: string, id: number | 'new') {
|
||||
return `${contentPath(typeKey)}/${id}`
|
||||
}
|
||||
|
||||
export function schemaPath(key: string) {
|
||||
return `${SCHEMA_PATH}/${encodeURIComponent(key)}`
|
||||
}
|
||||
@@ -102,6 +102,11 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/SchemaTypeView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
|
||||
},
|
||||
{
|
||||
path: '/content/:key',
|
||||
component: () => import('@/views/ContentListView.vue'),
|
||||
meta: { auth: 'verified', title: 'Content' },
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
component: () => import('@/views/ActivityView.vue'),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ContentEntries from '@/components/ContentEntries.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
|
||||
import type { ContentListState } from '@/lib/contentListState'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
|
||||
const key = computed(() => String(route.params.key))
|
||||
const detail = useContentTypeDetail(() => key.value)
|
||||
const listView = ref<ContentListState>('loading')
|
||||
|
||||
watch(key, () => {
|
||||
listView.value = 'loading'
|
||||
})
|
||||
|
||||
const notFound = computed(() => detail.status.value === 'missing' || listView.value === 'notFound')
|
||||
const centered = computed(
|
||||
() => notFound.value || detail.status.value === 'forbidden' || listView.value === 'forbidden',
|
||||
)
|
||||
const displayName = computed(() =>
|
||||
notFound.value ? undefined : (detail.type.value?.name ?? key.value),
|
||||
)
|
||||
const mobileTitle = computed(() => displayName.value ?? t('Content'))
|
||||
const breadcrumb = computed(() => [
|
||||
{ label: t('Content') },
|
||||
...(displayName.value ? [{ label: displayName.value }] : []),
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell
|
||||
compact-padding
|
||||
:full-bleed="centered"
|
||||
:mobile-title="mobileTitle"
|
||||
:no-current-nav="notFound"
|
||||
>
|
||||
<template #breadcrumb>
|
||||
<UBreadcrumb
|
||||
:items="breadcrumb"
|
||||
color="neutral"
|
||||
class="hidden lg:block"
|
||||
:ui="{ link: 'text-sm font-medium' }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ContentEntries
|
||||
v-if="session.status === 'ready'"
|
||||
:key="key"
|
||||
:type-key="key"
|
||||
:type="detail.type.value"
|
||||
:type-status="detail.status.value"
|
||||
:can-edit="session.canManageContent"
|
||||
@view="listView = $event"
|
||||
@retry-type="detail.retry"
|
||||
/>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,640 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
SKELETON_DELAY_MS,
|
||||
button,
|
||||
calls,
|
||||
customTypes,
|
||||
eventEntries,
|
||||
failWith,
|
||||
failWithBody,
|
||||
listPage,
|
||||
listRequests,
|
||||
lookupNames,
|
||||
lookupRequests,
|
||||
manyTypes,
|
||||
mountList,
|
||||
networkFailure,
|
||||
pending,
|
||||
populated,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './contentTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const NOT_FOUND = failWithBody(404, 'content_type_not_found')
|
||||
const LONG_NAME = 'Frequently asked questions and answers'
|
||||
|
||||
function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return [...main().querySelectorAll('thead th')].map(text)
|
||||
}
|
||||
|
||||
function rows() {
|
||||
return [...main().querySelectorAll('tbody tr')]
|
||||
}
|
||||
|
||||
function cells(row: Element) {
|
||||
return [...row.querySelectorAll('td')].map(text)
|
||||
}
|
||||
|
||||
function skeletonRows() {
|
||||
return main().querySelectorAll('[data-testid="entry-skeleton-row"]')
|
||||
}
|
||||
|
||||
function topBarTitle() {
|
||||
return text(document.body.querySelector('h1[data-slot="title"]'))
|
||||
}
|
||||
|
||||
function breadcrumb() {
|
||||
return text(document.body.querySelector('nav[aria-label="breadcrumb"]'))
|
||||
}
|
||||
|
||||
function currentSidebarRows() {
|
||||
return document.body.querySelectorAll('nav[aria-label="Content"] a[aria-current="page"]')
|
||||
}
|
||||
|
||||
async function afterDelay() {
|
||||
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('1 Populated, one page', () => {
|
||||
it('shows the breadcrumb, the title and the subtitle', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(breadcrumb()).toBe('Content Events')
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Events')
|
||||
expect(text(main())).toContain('events · 7 fields')
|
||||
})
|
||||
|
||||
it('puts New entry in the title row and the toolbar below it', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(button('New entry')?.hasAttribute('disabled')).toBe(false)
|
||||
const toolbar = main().querySelector('[data-testid="entry-toolbar"]')!
|
||||
expect(text(toolbar)).toBe('Sort by Last updated Columns Edit content type')
|
||||
expect(toolbar.querySelector('[aria-label="Sort ascending"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('reads the headers with the arrow on Updated', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(headers()).toEqual([
|
||||
'ID',
|
||||
'Title',
|
||||
'Starts on',
|
||||
'Ends on',
|
||||
'Online',
|
||||
'Created',
|
||||
'Updated',
|
||||
'Updated by',
|
||||
])
|
||||
const updated = [...main().querySelectorAll('thead th')][6]!
|
||||
expect(updated.querySelector('[data-sort-direction="desc"]')).not.toBeNull()
|
||||
expect(updated.getAttribute('aria-sort')).toBe('descending')
|
||||
expect(main().querySelectorAll('thead th [data-sort-direction]')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('leaves the first field column to take the free width and fixes the others', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
const [, first, second] = [...main().querySelectorAll('thead th')]
|
||||
|
||||
expect(first!.className).not.toMatch(/\bw-\d/)
|
||||
expect(second!.className).toMatch(/\bw-\d/)
|
||||
})
|
||||
|
||||
it('shows the seven rows, the first with its values', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(rows()).toHaveLength(7)
|
||||
expect(cells(rows()[0]!)).toEqual([
|
||||
'42',
|
||||
'Lime plaster workshop',
|
||||
'21 Nov 2026',
|
||||
'22 Nov 2026',
|
||||
'No',
|
||||
'12 Oct 2026',
|
||||
'15 Oct 2026',
|
||||
'Marta Alves',
|
||||
])
|
||||
expect(cells(rows()[1]!)).toContain('–')
|
||||
})
|
||||
|
||||
it('shows only the count, no pager and no page size select, and leaves the URL bare', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(text(main().querySelector('[data-testid="paginator-count"]'))).toBe('7 entries')
|
||||
expect(main().querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(document.body.querySelector('[aria-label="Rows per page"]')).toBeNull()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
expect(
|
||||
document.body.querySelector('nav[aria-label="Content"] a[aria-current="page"]')
|
||||
?.textContent,
|
||||
).toContain('Events')
|
||||
})
|
||||
|
||||
it('reads "1 entry" for a single entry', async () => {
|
||||
await mountList('/content/events', {
|
||||
list: (_, query) => listPage(eventEntries.slice(0, 1), 1, query),
|
||||
})
|
||||
|
||||
expect(text(main().querySelector('[data-testid="paginator-count"]'))).toBe('1 entry')
|
||||
})
|
||||
|
||||
it('reads "1 field" for a one-field type', async () => {
|
||||
await mountList('/content/events', {
|
||||
type: () => ({ ...customTypes[0]!, fields: customTypes[0]!.fields.slice(0, 1) }),
|
||||
})
|
||||
|
||||
expect(text(main())).toContain('events · 1 field')
|
||||
})
|
||||
|
||||
it('titles the top bar with the type name', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(topBarTitle()).toBe('Events')
|
||||
})
|
||||
|
||||
it('requests the type and the entries with explicit page, size and sort', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(calls).toContain('/v1/content-type/events')
|
||||
expect(listRequests(calls)).toEqual([
|
||||
'/v1/content-type/events/entry?page=1&size=25&sort=updated_at:desc',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Updated by lookup', () => {
|
||||
it('sends one lookup with the distinct ids after the entries answer', async () => {
|
||||
await mountList('/content/events', {
|
||||
list: (_, query) =>
|
||||
listPage(
|
||||
[
|
||||
{ ...eventEntries[0]!, updated_by: 3 },
|
||||
{ ...eventEntries[1]!, updated_by: 3 },
|
||||
{ ...eventEntries[2]!, updated_by: 5 },
|
||||
],
|
||||
3,
|
||||
query,
|
||||
),
|
||||
})
|
||||
|
||||
expect(lookupRequests(calls)).toEqual(['/v1/user/lookup?ids=3,5'])
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
|
||||
expect(cells(rows()[2]!).at(-1)).toBe('Jonas Pereira')
|
||||
})
|
||||
|
||||
it('sends no lookup before the entries answer', async () => {
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise })
|
||||
|
||||
expect(lookupRequests(calls)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows a muted dash until the lookup resolves', async () => {
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { lookup: () => gate.promise })
|
||||
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('–')
|
||||
|
||||
gate.resolve(lookupNames([3, 4, 5]))
|
||||
await flushPromises()
|
||||
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
|
||||
})
|
||||
|
||||
it('shows dashes and no error when the lookup fails', async () => {
|
||||
await mountList('/content/events', { lookup: failWith(500) })
|
||||
|
||||
expect(rows().map((row) => cells(row).at(-1))).toEqual(new Array(7).fill('–'))
|
||||
expect(main().querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows a dash for an id the lookup does not know', async () => {
|
||||
await mountList('/content/events', { lookup: () => lookupNames([3]) })
|
||||
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('Marta Alves')
|
||||
expect(cells(rows()[1]!).at(-1)).toBe('–')
|
||||
})
|
||||
|
||||
it('leaves entries without an author out of the lookup', async () => {
|
||||
await mountList('/content/events', {
|
||||
list: (_, query) =>
|
||||
listPage(
|
||||
[
|
||||
{ ...eventEntries[0]!, updated_by: 0 },
|
||||
{ ...eventEntries[1]!, updated_by: 3 },
|
||||
],
|
||||
2,
|
||||
query,
|
||||
),
|
||||
})
|
||||
|
||||
expect(lookupRequests(calls)).toEqual(['/v1/user/lookup?ids=3'])
|
||||
})
|
||||
|
||||
it('sends no lookup when no entry has an author', async () => {
|
||||
await mountList('/content/events', {
|
||||
list: (_, query) => listPage([{ ...eventEntries[0]!, updated_by: 0 }], 1, query),
|
||||
})
|
||||
|
||||
expect(lookupRequests(calls)).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores a lookup answer that a later page replaced', async () => {
|
||||
const slow = pending()
|
||||
await mountList('/content/events?size=5', {
|
||||
list: (_, query) =>
|
||||
query.get('page') === '2'
|
||||
? listPage([{ ...eventEntries[0]!, updated_by: 4 }], 37, query)
|
||||
: listPage([{ ...eventEntries[0]!, updated_by: 3 }], 37, query),
|
||||
lookup: (ids) => (ids[0] === 3 ? slow.promise : lookupNames([4])),
|
||||
})
|
||||
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
slow.resolve([{ id: 4, display_name: 'Stale Name' }])
|
||||
await flushPromises()
|
||||
|
||||
expect(cells(rows()[0]!).at(-1)).toBe('Inès Duarte')
|
||||
})
|
||||
|
||||
it('sends no lookup for an empty list', async () => {
|
||||
await mountList('/content/events', { list: (_, query) => listPage([], 0, query) })
|
||||
|
||||
expect(lookupRequests(calls)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('4 Many types in the sidebar', () => {
|
||||
const types = () => [
|
||||
customTypes[0]!,
|
||||
...manyTypes(13).map((type, index) => ({
|
||||
...type,
|
||||
name: index === 2 ? LONG_NAME : type.name,
|
||||
})),
|
||||
]
|
||||
|
||||
function contentLinks() {
|
||||
return [...document.body.querySelectorAll('nav[aria-label="Content"] a')].map(text)
|
||||
}
|
||||
|
||||
it('shows the first five types and Show all 14 on desktop, behaving as before', async () => {
|
||||
await mountList('/content/events', { content: types })
|
||||
|
||||
expect(contentLinks()).toEqual(['Events', 'Type 1', 'Type 2', LONG_NAME, 'Type 4'])
|
||||
expect(text(document.body.querySelector('nav[aria-label="Content"]'))).toContain(
|
||||
'Show all 14',
|
||||
)
|
||||
expect(currentSidebarRows()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shows the collapsed All content types 14 row in the mobile drawer', async () => {
|
||||
await mountList('/content/events', { content: types, width: MOBILE_WIDTH })
|
||||
document.body.querySelector<HTMLElement>('[data-slot="toggle"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const group = document.body.querySelector('nav[aria-label="Content"]')!
|
||||
expect(text(group)).toContain('All content types')
|
||||
expect(text(group)).toContain('14')
|
||||
expect(contentLinks()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('5 Loading', () => {
|
||||
it('shows the title once the type has resolved, a disabled New entry and the toolbar', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise })
|
||||
await afterDelay()
|
||||
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Events')
|
||||
expect(button('New entry')?.hasAttribute('disabled')).toBe(true)
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).not.toBeNull()
|
||||
expect(main().querySelector('table')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows no skeleton for 150 ms, then eight rows with aria-busy', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise })
|
||||
|
||||
expect(skeletonRows()).toHaveLength(0)
|
||||
|
||||
await afterDelay()
|
||||
|
||||
expect(skeletonRows()).toHaveLength(8)
|
||||
expect(main().querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows six rows on mobile', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise, width: MOBILE_WIDTH })
|
||||
await afterDelay()
|
||||
|
||||
expect(skeletonRows()).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('shows a skeleton for the title until the type resolves', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { type: () => gate.promise })
|
||||
|
||||
expect(main().querySelector('h1')).toBeNull()
|
||||
expect(main().querySelector('[data-slot="base"].bg-accented, .bg-accented')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('replaces the skeleton with the rows when both resolve', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { list: () => gate.promise })
|
||||
await afterDelay()
|
||||
|
||||
gate.resolve(populated('events', new URLSearchParams('page=1&size=25')))
|
||||
await flushPromises()
|
||||
|
||||
expect(skeletonRows()).toHaveLength(0)
|
||||
expect(rows()).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('keeps the skeleton while the type is slower than the entries', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { type: () => gate.promise })
|
||||
|
||||
expect(skeletonRows()).toHaveLength(8)
|
||||
expect(rows()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('6 Empty for this type', () => {
|
||||
const empty = (_: string, query: URLSearchParams) => listPage([], 0, query)
|
||||
|
||||
it('shows the empty state with its copy and action, and keeps the toolbar', async () => {
|
||||
await mountList('/content/events', { list: empty })
|
||||
|
||||
expect(text(main())).toContain('No entries yet')
|
||||
expect(text(main())).toContain(
|
||||
'Use New entry to add the first entry. Each entry follows the fields of this content type.',
|
||||
)
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).not.toBeNull()
|
||||
expect(main().querySelector('[data-testid="paginator-count"]')).toBeNull()
|
||||
expect(main().querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(text(main())).not.toContain('0 entries')
|
||||
})
|
||||
|
||||
it('sends the empty state New entry action to /content/events/new', async () => {
|
||||
await mountList('/content/events', { list: empty })
|
||||
|
||||
const action = [...main().querySelectorAll('button')].filter(
|
||||
(candidate) => text(candidate) === 'New entry',
|
||||
)
|
||||
expect(action).toHaveLength(2)
|
||||
action.at(-1)!.click()
|
||||
await vi.waitFor(() =>
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events/new'),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the empty state for an out-of-range page of an empty type and cleans the URL', async () => {
|
||||
await mountList('/content/events?page=3', { list: empty })
|
||||
|
||||
expect(text(main())).toContain('No entries yet')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('7 Error loading entries', () => {
|
||||
it.each([
|
||||
['a 500 response', failWith(500)],
|
||||
['a network failure', networkFailure],
|
||||
])('shows the alert for %s, without toolbar, list or pager', async (_, failure) => {
|
||||
await mountList('/content/events', { list: failure })
|
||||
|
||||
expect(text(main())).toContain("Couldn't load entries")
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/content-type/events/entry failed. Check your connection and try again.',
|
||||
)
|
||||
expect(button('Try again')).toBeDefined()
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
|
||||
expect(main().querySelector('table')).toBeNull()
|
||||
expect(main().querySelector('nav[aria-label="Pagination"]')).toBeNull()
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Events')
|
||||
})
|
||||
|
||||
it('shows the skeleton on Try again and sends the failed request again, not the type', async () => {
|
||||
vi.useFakeTimers()
|
||||
let attempt = 0
|
||||
const gate = pending()
|
||||
await mountList('/content/events', {
|
||||
list: (key, query) => {
|
||||
attempt++
|
||||
if (attempt === 1) {
|
||||
throw new Error('x')
|
||||
}
|
||||
return attempt === 2 ? gate.promise : populated(key, query)
|
||||
},
|
||||
})
|
||||
|
||||
button('Try again')!.click()
|
||||
await afterDelay()
|
||||
|
||||
expect(skeletonRows()).toHaveLength(8)
|
||||
expect(listRequests(calls)).toHaveLength(2)
|
||||
expect(calls.filter((url) => url === '/v1/content-type/events')).toHaveLength(1)
|
||||
|
||||
gate.resolve(populated('events', new URLSearchParams('page=1&size=25')))
|
||||
await flushPromises()
|
||||
|
||||
expect(rows()).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('shows no raw error body', async () => {
|
||||
await mountList('/content/events', { list: failWithBody(500, 'sql: secret detail') })
|
||||
|
||||
expect(text(main())).not.toContain('secret detail')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error loading the type', () => {
|
||||
it('shows the alert naming the type request and retries only that request', async () => {
|
||||
let attempt = 0
|
||||
await mountList('/content/events', {
|
||||
type: (key) => {
|
||||
attempt++
|
||||
if (attempt === 1) {
|
||||
throw new Error('x')
|
||||
}
|
||||
return customTypes.find((type) => type.key === key)
|
||||
},
|
||||
})
|
||||
|
||||
expect(text(main())).toContain("Couldn't load entries")
|
||||
expect(text(main())).toContain(
|
||||
'GET /v1/content-type/events failed. Check your connection and try again.',
|
||||
)
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
|
||||
|
||||
button('Try again')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(calls.filter((url) => url === '/v1/content-type/events')).toHaveLength(2)
|
||||
expect(listRequests(calls)).toHaveLength(1)
|
||||
expect(rows()).toHaveLength(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('8 Forbidden (403)', () => {
|
||||
it.each([
|
||||
['the entries request', { list: failWith(403) }, 'Events'],
|
||||
['the type request', { type: failWith(403) }, 'events'],
|
||||
])('shows the lock state when %s answers 403, titled %s', async (_, options, name) => {
|
||||
await mountList('/content/events', options)
|
||||
|
||||
expect(text(main())).toContain(`You don't have access to ${name}`)
|
||||
expect(text(main())).toContain(
|
||||
"Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin.",
|
||||
)
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
|
||||
expect(main().querySelector('table')).toBeNull()
|
||||
expect(button('New entry')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('goes to Home from Go to Home', async () => {
|
||||
await mountList('/content/events', { list: failWith(403) })
|
||||
|
||||
button('Go to Home')!.click()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('9 Type not found', () => {
|
||||
it.each([
|
||||
['the type request', { type: NOT_FOUND }],
|
||||
['the entries request', { list: NOT_FOUND }],
|
||||
])('shows the file-x state when %s answers 404', async (_, options) => {
|
||||
await mountList('/content/unknown', options)
|
||||
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
expect(text(main())).toContain('It may have been deleted, or the address may be mistyped.')
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).toBeNull()
|
||||
expect(breadcrumb()).toBe('Content')
|
||||
expect(currentSidebarRows()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('titles the mobile top bar Content', async () => {
|
||||
await mountList('/content/unknown', {
|
||||
type: NOT_FOUND,
|
||||
list: NOT_FOUND,
|
||||
width: MOBILE_WIDTH,
|
||||
})
|
||||
|
||||
expect(topBarTitle()).toBe('Content')
|
||||
})
|
||||
|
||||
it('goes to Home from Go to Home', async () => {
|
||||
await mountList('/content/unknown', { type: NOT_FOUND, list: NOT_FOUND })
|
||||
|
||||
button('Go to Home')!.click()
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
|
||||
it('sends no lookup for a type that was deleted behind the sidebar', async () => {
|
||||
await mountList('/content/recipes', { type: NOT_FOUND, list: NOT_FOUND })
|
||||
|
||||
expect(text(main())).toContain('Content type not found')
|
||||
expect(lookupRequests(calls)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Session expired and stale answers', () => {
|
||||
it('shows no error alert while a 401 sends the user to the login page', async () => {
|
||||
await mountList('/content/events', { list: failWith(401) })
|
||||
|
||||
expect(text(main())).not.toContain("Couldn't load entries")
|
||||
})
|
||||
|
||||
it('drops the answer of a request a later sort change replaced', async () => {
|
||||
const stale = pending()
|
||||
await mountList('/content/events?size=5', {
|
||||
list: (_, query) =>
|
||||
query.get('page') === '2'
|
||||
? stale.promise
|
||||
: listPage(eventEntries.slice(0, 5), 37, query),
|
||||
})
|
||||
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Next page"]')!.click()
|
||||
await flushPromises()
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Sort ascending"]')!.click()
|
||||
await flushPromises()
|
||||
stale.resolve(listPage(eventEntries.slice(2, 4), 37, new URLSearchParams('page=2&size=5')))
|
||||
await flushPromises()
|
||||
|
||||
expect(rows()).toHaveLength(5)
|
||||
expect(router.currentRoute.value.fullPath).toBe(
|
||||
'/content/events?size=5&sort=updated_at:asc',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Switching to another type resets the page', () => {
|
||||
it('ignores the type answer of the type the user left', async () => {
|
||||
const gate = pending()
|
||||
await mountList('/content/events', {
|
||||
type: (key) =>
|
||||
key === 'events' ? gate.promise : customTypes.find((t) => t.key === key),
|
||||
})
|
||||
|
||||
await router.push('/content/recipes')
|
||||
await flushPromises()
|
||||
gate.resolve(customTypes.find((type) => type.key === 'events'))
|
||||
await flushPromises()
|
||||
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Recipes')
|
||||
expect(text(main())).toContain('recipes')
|
||||
})
|
||||
|
||||
it('requests Recipes with page 1, size 25 and the default sort, and shows no Events row', async () => {
|
||||
await mountList('/content/events?page=3&size=50&sort=id:asc')
|
||||
expect(listRequests(calls).at(-1)).toContain('page=3&size=50&sort=id:asc')
|
||||
|
||||
await router.push('/content/recipes')
|
||||
await flushPromises()
|
||||
|
||||
expect(listRequests(calls).at(-1)).toBe(
|
||||
'/v1/content-type/recipes/entry?page=1&size=25&sort=updated_at:desc',
|
||||
)
|
||||
expect(text(main())).not.toContain('Lime plaster workshop')
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Recipes')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/recipes')
|
||||
})
|
||||
|
||||
it('shows no Events row while Recipes is loading', async () => {
|
||||
await mountList('/content/events')
|
||||
const gate = pending()
|
||||
await router.push('/content/recipes')
|
||||
|
||||
expect(text(main())).not.toContain('Lime plaster workshop')
|
||||
expect(headers()).toEqual([])
|
||||
gate.resolve(undefined)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,454 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
calls,
|
||||
customTypes,
|
||||
eventEntries,
|
||||
installSelectPolyfills,
|
||||
listPage,
|
||||
listRequests,
|
||||
mountList,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './contentTestKit'
|
||||
import { eventsFields } from '@/lib/__tests__/schemaFixtures'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(installSelectPolyfills)
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
await unmountAll()
|
||||
})
|
||||
|
||||
const KEY = 'renewcms-content-columns-events'
|
||||
const DEFAULT_HEADERS = ['ID', 'Title', 'Starts on', 'Ends on', 'Online']
|
||||
const TAIL_HEADERS = ['Created', 'Updated', 'Updated by']
|
||||
const ALL_KEYS = eventsFields.map((field) => field.key)
|
||||
|
||||
function seed(value: string, key = KEY) {
|
||||
return () => localStorage.setItem(key, value)
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return [...document.body.querySelectorAll('thead th')].map(text)
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) =>
|
||||
text(candidate) === 'Columns' || candidate.getAttribute('aria-label') === 'Columns',
|
||||
) as HTMLElement
|
||||
}
|
||||
|
||||
async function openPicker() {
|
||||
trigger().click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function picker() {
|
||||
return document.body.querySelector('[data-testid="column-picker"], [role="dialog"]')
|
||||
}
|
||||
|
||||
function pickerRows() {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[data-testid="column-row"]')]
|
||||
}
|
||||
|
||||
function checkbox(label: string) {
|
||||
const row = pickerRows().find((candidate) => text(candidate).startsWith(label))!
|
||||
return row.querySelector<HTMLElement>('[role="checkbox"]')!
|
||||
}
|
||||
|
||||
function checkedLabels() {
|
||||
return pickerRows()
|
||||
.filter(
|
||||
(row) =>
|
||||
row.querySelector('[role="checkbox"]')?.getAttribute('aria-checked') === 'true',
|
||||
)
|
||||
.map((row) =>
|
||||
text(row).split(' ')[0] === 'Starts' ? 'Starts on' : text(row).split(' ')[0]!,
|
||||
)
|
||||
}
|
||||
|
||||
function isDisabled(label: string) {
|
||||
return (
|
||||
checkbox(label).hasAttribute('disabled') ||
|
||||
checkbox(label).getAttribute('data-disabled') !== null
|
||||
)
|
||||
}
|
||||
|
||||
async function toggle(label: string) {
|
||||
checkbox(label).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function stored() {
|
||||
return localStorage.getItem(KEY)
|
||||
}
|
||||
|
||||
function button(label: string) {
|
||||
return [...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === label,
|
||||
)
|
||||
}
|
||||
|
||||
describe('3 Columns menu', () => {
|
||||
it('opens a popover titled Columns with 4 of 6, a Reset and no Done', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
await openPicker()
|
||||
|
||||
const popover = picker()!
|
||||
expect(popover.getAttribute('role')).toBe('dialog')
|
||||
expect(text(popover)).toContain('Columns')
|
||||
expect(text(popover)).toContain('4 of 6')
|
||||
expect(button('Reset')).toBeDefined()
|
||||
expect(button('Done')).toBeUndefined()
|
||||
expect(text(popover)).not.toContain('Choose up to 6 fields to show')
|
||||
})
|
||||
|
||||
it('lists the seven fields in schema order with icon, label and key, four checked', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
await openPicker()
|
||||
|
||||
expect(pickerRows().map(text)).toEqual([
|
||||
'Title title',
|
||||
'Starts on starts_on',
|
||||
'Ends on ends_on',
|
||||
'Online online',
|
||||
'Capacity capacity',
|
||||
'Category category',
|
||||
'Description description',
|
||||
])
|
||||
expect(
|
||||
pickerRows().map((row) =>
|
||||
row.querySelector('[role="checkbox"]')?.getAttribute('aria-checked'),
|
||||
),
|
||||
).toEqual(['true', 'true', 'true', 'true', 'false', 'false', 'false'])
|
||||
expect(pickerRows().every((row) => row.querySelector('svg') !== null)).toBe(true)
|
||||
})
|
||||
|
||||
it('ends with a disabled, unchecked clock row about the dates', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
await openPicker()
|
||||
|
||||
const row = document.body.querySelector('[data-testid="column-fixed-row"]')!
|
||||
expect(text(row)).toBe('Created and updated dates always show')
|
||||
const box = row.querySelector('[role="checkbox"]')!
|
||||
expect(box.getAttribute('aria-checked')).toBe('false')
|
||||
expect(box.hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('adds a right-aligned Capacity column at once, with no request, and counts 5 of 6', async () => {
|
||||
await mountList('/content/events')
|
||||
const before = listRequests(calls).length
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, 'Capacity', ...TAIL_HEADERS])
|
||||
const capacity = [...document.body.querySelectorAll('thead th')][5]!
|
||||
expect(capacity.querySelector('span')?.className).toContain('justify-end')
|
||||
expect(document.body.querySelector('tbody tr td:nth-child(6) span')?.className).toContain(
|
||||
'text-right',
|
||||
)
|
||||
expect(text(picker())).toContain('5 of 6')
|
||||
expect(listRequests(calls)).toHaveLength(before)
|
||||
})
|
||||
|
||||
it('removes a column when it is unchecked', async () => {
|
||||
await mountList('/content/events')
|
||||
await openPicker()
|
||||
|
||||
await toggle('Online')
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Starts on', 'Ends on', ...TAIL_HEADERS])
|
||||
expect(text(picker())).toContain('3 of 6')
|
||||
})
|
||||
|
||||
it('closes on Escape and returns focus to Columns', async () => {
|
||||
await mountList('/content/events')
|
||||
await openPicker()
|
||||
|
||||
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
picker()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="column-picker"]')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger())
|
||||
})
|
||||
|
||||
it('closes on a click outside', async () => {
|
||||
await mountList('/content/events')
|
||||
await openPicker()
|
||||
|
||||
const outside = document.querySelector('h1')!
|
||||
outside.dispatchEvent(
|
||||
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
outside.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[data-testid="column-picker"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('3 Columns menu on mobile', () => {
|
||||
it('opens a sheet with the sentence, Reset and Done, and Done closes it', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
await openPicker()
|
||||
|
||||
const sheet = document.body.querySelector('[role="dialog"]')!
|
||||
expect(text(sheet)).toContain('Columns')
|
||||
expect(text(sheet)).toContain('4 of 6')
|
||||
expect(text(sheet)).toContain('Choose up to 6 fields to show. 4 selected.')
|
||||
expect(button('Reset')).toBeDefined()
|
||||
expect(button('Done')).toBeDefined()
|
||||
expect(pickerRows()).toHaveLength(7)
|
||||
|
||||
button('Done')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger())
|
||||
})
|
||||
|
||||
it('counts the selection in the sentence', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(text(document.body.querySelector('[role="dialog"]'))).toContain(
|
||||
'Choose up to 6 fields to show. 5 selected.',
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the cards unchanged when the selection changes', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
const before = [...document.body.querySelectorAll('[data-testid="entry-card"]')].map(text)
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
await toggle('Title')
|
||||
|
||||
expect([...document.body.querySelectorAll('[data-testid="entry-card"]')].map(text)).toEqual(
|
||||
before,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The column cap', () => {
|
||||
const SIX = JSON.stringify(ALL_KEYS.slice(0, 6))
|
||||
|
||||
it('disables the unchecked fields and explains the cap at six', async () => {
|
||||
await mountList('/content/events', { before: seed(SIX) })
|
||||
|
||||
await openPicker()
|
||||
|
||||
expect(isDisabled('Description')).toBe(true)
|
||||
expect(isDisabled('Title')).toBe(false)
|
||||
expect(text(picker())).toContain('You can show up to 6 fields.')
|
||||
expect(text(picker())).toContain('6 of 6')
|
||||
})
|
||||
|
||||
it('shows no cap message below six', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
await openPicker()
|
||||
|
||||
expect(text(picker())).not.toContain('You can show up to 6 fields.')
|
||||
expect(isDisabled('Description')).toBe(false)
|
||||
})
|
||||
|
||||
it('checks a sixth and disables the others', async () => {
|
||||
await mountList('/content/events', { before: seed(JSON.stringify(ALL_KEYS.slice(0, 5))) })
|
||||
await openPicker()
|
||||
|
||||
await toggle('Description')
|
||||
|
||||
expect(checkedLabels()).toHaveLength(6)
|
||||
expect(isDisabled('Category')).toBe(true)
|
||||
expect(isDisabled('Title')).toBe(false)
|
||||
expect(text(picker())).toContain('6 of 6')
|
||||
})
|
||||
|
||||
it('enables the others again when one is unchecked', async () => {
|
||||
await mountList('/content/events', { before: seed(SIX) })
|
||||
await openPicker()
|
||||
|
||||
await toggle('Title')
|
||||
|
||||
expect(isDisabled('Description')).toBe(false)
|
||||
expect(text(picker())).not.toContain('You can show up to 6 fields.')
|
||||
})
|
||||
|
||||
it('shows both field columns and still reads the cap for a type with two fields', async () => {
|
||||
await mountList('/content/events', {
|
||||
type: () => ({ ...customTypes[0]!, fields: eventsFields.slice(0, 2) }),
|
||||
})
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Starts on', ...TAIL_HEADERS])
|
||||
|
||||
await openPicker()
|
||||
|
||||
expect(text(picker())).toContain('2 of 6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('The last field column cannot be unchecked', () => {
|
||||
it('disables the only checked field and keeps its column', async () => {
|
||||
await mountList('/content/events', { before: seed('["capacity"]') })
|
||||
await openPicker()
|
||||
|
||||
expect(isDisabled('Capacity')).toBe(true)
|
||||
expect(isDisabled('Title')).toBe(false)
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Capacity', ...TAIL_HEADERS])
|
||||
expect(text(picker())).toContain('1 of 6')
|
||||
expect(document.body.querySelectorAll('[data-testid="entry-link"]')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it('unlocks it again once a second field is checked', async () => {
|
||||
await mountList('/content/events', { before: seed('["capacity"]') })
|
||||
await openPicker()
|
||||
|
||||
await toggle('Title')
|
||||
|
||||
expect(isDisabled('Capacity')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The stored choice', () => {
|
||||
it('writes the default four plus Capacity when Capacity is checked and nothing was stored', async () => {
|
||||
await mountList('/content/events')
|
||||
expect(stored()).toBeNull()
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(stored()).toBe('["title","starts_on","ends_on","online","capacity"]')
|
||||
})
|
||||
|
||||
it('removes the stored key and applies the default four on Reset', async () => {
|
||||
await mountList('/content/events', { before: seed('["title","capacity"]') })
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Capacity', ...TAIL_HEADERS])
|
||||
await openPicker()
|
||||
|
||||
button('Reset')!.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, ...TAIL_HEADERS])
|
||||
expect(stored()).toBeNull()
|
||||
})
|
||||
|
||||
it('drops unknown keys on load and writes nothing', async () => {
|
||||
let setItem!: ReturnType<typeof vi.spyOn>
|
||||
let removeItem!: ReturnType<typeof vi.spyOn>
|
||||
await mountList('/content/events', {
|
||||
before: () => {
|
||||
seed('["title","gone","capacity"]')()
|
||||
setItem = vi.spyOn(Storage.prototype, 'setItem')
|
||||
removeItem = vi.spyOn(Storage.prototype, 'removeItem')
|
||||
},
|
||||
})
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Capacity', ...TAIL_HEADERS])
|
||||
expect(stored()).toBe('["title","gone","capacity"]')
|
||||
expect(setItem.mock.calls.filter(([key]) => key === KEY)).toEqual([])
|
||||
expect(removeItem.mock.calls.filter(([key]) => key === KEY)).toEqual([])
|
||||
})
|
||||
|
||||
it('shows the first six in schema order for more stored keys than the cap', async () => {
|
||||
await mountList('/content/events', {
|
||||
before: seed(JSON.stringify([...ALL_KEYS, 'title'])),
|
||||
})
|
||||
|
||||
expect(headers()).toEqual([
|
||||
'ID',
|
||||
'Title',
|
||||
'Starts on',
|
||||
'Ends on',
|
||||
'Online',
|
||||
'Capacity',
|
||||
'Category',
|
||||
...TAIL_HEADERS,
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['not json', '[]', '{"title":true}', '"title"', 'null'])(
|
||||
'shows the default four for the stored value %s',
|
||||
async (raw) => {
|
||||
await mountList('/content/events', { before: seed(raw) })
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, ...TAIL_HEADERS])
|
||||
expect(stored()).toBe(raw)
|
||||
},
|
||||
)
|
||||
|
||||
it('changes the columns for this visit when localStorage throws, with no error', async () => {
|
||||
await mountList('/content/events')
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('quota')
|
||||
})
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, 'Capacity', ...TAIL_HEADERS])
|
||||
expect(document.body.querySelector('[role="alert"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('reads and changes columns when localStorage is undefined', async () => {
|
||||
await mountList('/content/events', {
|
||||
before: () => vi.stubGlobal('localStorage', undefined),
|
||||
})
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, ...TAIL_HEADERS])
|
||||
await openPicker()
|
||||
|
||||
await toggle('Capacity')
|
||||
|
||||
expect(headers()).toEqual([...DEFAULT_HEADERS, 'Capacity', ...TAIL_HEADERS])
|
||||
})
|
||||
|
||||
it('gives Recipes its own key and its default when only Events has a stored choice', async () => {
|
||||
await mountList('/content/recipes', {
|
||||
before: seed('["title","capacity"]'),
|
||||
list: (_, query) => listPage(eventEntries, 7, query),
|
||||
})
|
||||
|
||||
expect(headers()).toEqual([
|
||||
'ID',
|
||||
'Title',
|
||||
'Servings',
|
||||
'Vegetarian',
|
||||
'Method',
|
||||
...TAIL_HEADERS,
|
||||
])
|
||||
})
|
||||
|
||||
it('applies the Events choice again after switching from Recipes to Events', async () => {
|
||||
await mountList('/content/recipes', {
|
||||
before: seed('["title","capacity"]'),
|
||||
list: (_, query) => listPage(eventEntries, 7, query),
|
||||
})
|
||||
await router.push('/content/events')
|
||||
await flushPromises()
|
||||
|
||||
expect(headers()).toEqual(['ID', 'Title', 'Capacity', ...TAIL_HEADERS])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import { eventsFields } from '@/lib/__tests__/schemaFixtures'
|
||||
import {
|
||||
SKELETON_DELAY_MS,
|
||||
button,
|
||||
customTypes,
|
||||
eventEntries,
|
||||
failWith,
|
||||
installSelectPolyfills,
|
||||
listPage,
|
||||
mountList,
|
||||
pending,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './contentTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(installSelectPolyfills)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
function link(label: string) {
|
||||
return [...main().querySelectorAll<HTMLAnchorElement>('a')].find(
|
||||
(candidate) => text(candidate) === label,
|
||||
)
|
||||
}
|
||||
|
||||
function cards() {
|
||||
return [...main().querySelectorAll<HTMLAnchorElement>('[data-testid="entry-card"]')]
|
||||
}
|
||||
|
||||
function floatingButton() {
|
||||
return [...main().querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'New entry' && candidate.className.includes('fixed'),
|
||||
)
|
||||
}
|
||||
|
||||
async function arrived(path: string) {
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.fullPath).toBe(path))
|
||||
}
|
||||
|
||||
describe('Navigation actions', () => {
|
||||
it('sends New entry to /content/events/new', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
button('New entry')!.click()
|
||||
|
||||
await arrived('/content/events/new')
|
||||
})
|
||||
|
||||
it('links the first cell of every row to /content/events/:id and navigates through the router', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
const links = [...main().querySelectorAll<HTMLAnchorElement>('[data-testid="entry-link"]')]
|
||||
expect(links.map((anchor) => anchor.getAttribute('href'))).toEqual(
|
||||
eventEntries.map((entry) => `/content/events/${entry.id}`),
|
||||
)
|
||||
expect(text(links[0])).toBe('Lime plaster workshop')
|
||||
|
||||
links[0]!.click()
|
||||
|
||||
await arrived('/content/events/42')
|
||||
})
|
||||
|
||||
it('links the ID cell when no field column is chosen', async () => {
|
||||
await mountList('/content/events', {
|
||||
before: () => localStorage.setItem('renewcms-content-columns-events', '["gone"]'),
|
||||
})
|
||||
|
||||
expect(main().querySelectorAll('[data-testid="entry-link"]')).toHaveLength(7)
|
||||
})
|
||||
|
||||
it.each(['owner', 'admin'])(
|
||||
'sends Edit content type to /schema/events for a %s',
|
||||
async (role) => {
|
||||
await mountList('/content/events', { role })
|
||||
|
||||
const edit = link('Edit content type')!
|
||||
expect(edit.getAttribute('href')).toBe('/schema/events')
|
||||
|
||||
edit.click()
|
||||
|
||||
await arrived('/schema/events')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['user', 'editor', 'unknown'])(
|
||||
'does not render Edit content type for the role %s',
|
||||
async (role) => {
|
||||
await mountList('/content/events', { role })
|
||||
|
||||
expect(link('Edit content type')).toBeUndefined()
|
||||
expect(main().querySelector('[data-testid="entry-toolbar"]')).not.toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('does not render Edit content type below md', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(link('Edit content type')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('puts the toolbar in the order select, direction, Columns, Edit', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
const toolbar = main().querySelector('[data-testid="entry-toolbar"]')!
|
||||
const order = [...toolbar.querySelectorAll('button, a')].map(
|
||||
(element) => element.getAttribute('aria-label') ?? text(element),
|
||||
)
|
||||
expect(order).toEqual(['Sort by', 'Sort ascending', 'Columns', 'Edit content type'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile cards', () => {
|
||||
it('shows a card per entry with the first field, the second field, the tag and the author', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(main().querySelector('table')).toBeNull()
|
||||
expect(cards()).toHaveLength(7)
|
||||
expect(text(cards()[0])).toBe('Lime plaster workshop 21 Nov 2026 Workshop · Marta Alves')
|
||||
expect(cards()[0]!.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('links the whole card to /content/events/:id through the router', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(cards()[1]!.getAttribute('href')).toBe('/content/events/41')
|
||||
|
||||
cards()[1]!.click()
|
||||
|
||||
await arrived('/content/events/41')
|
||||
})
|
||||
|
||||
it('shows a muted dash for the author until the lookup resolves', async () => {
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH, lookup: () => gate.promise })
|
||||
|
||||
expect(text(cards()[0])).toBe('Lime plaster workshop 21 Nov 2026 Workshop · –')
|
||||
})
|
||||
|
||||
it('shows no tag for a type without a choice field and no tag when the choice is empty', async () => {
|
||||
await mountList('/content/events', {
|
||||
width: MOBILE_WIDTH,
|
||||
type: () => ({ ...customTypes[0]!, fields: eventsFields.slice(0, 3) }),
|
||||
})
|
||||
|
||||
expect(text(cards()[0])).toBe('Lime plaster workshop 21 Nov 2026 · Marta Alves')
|
||||
})
|
||||
|
||||
it('shows the choice once, as the tag, when the second field is the choice', async () => {
|
||||
const [title, , , , , category] = eventsFields
|
||||
await mountList('/content/events', {
|
||||
width: MOBILE_WIDTH,
|
||||
type: () => ({ ...customTypes[0]!, fields: [title, category] }),
|
||||
})
|
||||
|
||||
expect(text(cards()[0])).toBe('Lime plaster workshop Workshop · Marta Alves')
|
||||
})
|
||||
|
||||
it('shows a floating New entry button that goes to /content/events/new', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(button('New entry')).toBe(floatingButton())
|
||||
|
||||
floatingButton()!.click()
|
||||
|
||||
await arrived('/content/events/new')
|
||||
})
|
||||
|
||||
it('shows the floating button on the empty state', async () => {
|
||||
await mountList('/content/events', {
|
||||
width: MOBILE_WIDTH,
|
||||
list: (_, query) => listPage([], 0, query),
|
||||
})
|
||||
|
||||
expect(floatingButton()).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides the floating button while loading', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH, list: () => gate.promise })
|
||||
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(floatingButton()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('hides the floating button in the error state', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH, list: failWith(500) })
|
||||
|
||||
expect(floatingButton()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows no New entry in the title row on mobile besides the floating one', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(
|
||||
[...main().querySelectorAll('button')].filter(
|
||||
(candidate) => text(candidate) === 'New entry',
|
||||
),
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it.each([
|
||||
['/content/events', 'Events, sorted by last updated, newest first'],
|
||||
['/content/events?sort=updated_at:asc', 'Events, sorted by updated, ascending'],
|
||||
['/content/events?sort=id:desc', 'Events, sorted by id, descending'],
|
||||
['/content/events?sort=created_at:desc', 'Events, sorted by created, descending'],
|
||||
])('captions the table for %s as "%s"', async (path, caption) => {
|
||||
await mountList(path)
|
||||
|
||||
expect(text(main().querySelector('table caption'))).toBe(caption)
|
||||
expect(main().querySelector('table caption')?.className).toContain('sr-only')
|
||||
})
|
||||
|
||||
it('names Columns, the direction button and the select', async () => {
|
||||
await mountList('/content/events')
|
||||
|
||||
const toolbar = main().querySelector('[data-testid="entry-toolbar"]')!
|
||||
expect(toolbar.querySelector('[aria-label="Sort by"]')).not.toBeNull()
|
||||
expect(toolbar.querySelector('[aria-label="Sort ascending"]')).not.toBeNull()
|
||||
expect(button('Columns')).toBeDefined()
|
||||
})
|
||||
|
||||
it('names the icon-only Columns button on mobile', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(main().querySelector('button[aria-label="Columns"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('makes the controls 44 px high on mobile', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
expect(main().querySelector('[aria-label="Sort by"]')?.className).toContain('h-11')
|
||||
expect(main().querySelector('[aria-label="Sort ascending"]')?.className).toContain(
|
||||
'size-11',
|
||||
)
|
||||
expect(main().querySelector('button[aria-label="Columns"]')?.className).toContain('h-11')
|
||||
})
|
||||
|
||||
it('moves focus into the sheet on mobile and keeps it there', async () => {
|
||||
await mountList('/content/events', { width: MOBILE_WIDTH })
|
||||
|
||||
main().querySelector<HTMLElement>('button[aria-label="Columns"]')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const sheet = document.body.querySelector('[role="dialog"]')!
|
||||
expect(sheet.contains(document.activeElement)).toBe(true)
|
||||
})
|
||||
|
||||
it('toggles a field from its checkbox without closing the picker', async () => {
|
||||
await mountList('/content/events')
|
||||
button('Columns')!.click()
|
||||
await flushPromises()
|
||||
|
||||
const box = document.body.querySelector<HTMLElement>(
|
||||
'[data-testid="column-row"] [role="checkbox"]',
|
||||
)!
|
||||
box.focus()
|
||||
box.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
|
||||
box.dispatchEvent(new KeyboardEvent('keyup', { key: ' ', bubbles: true }))
|
||||
box.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(box.getAttribute('aria-checked')).toBe('false')
|
||||
expect(document.body.querySelector('[data-testid="column-picker"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('French', () => {
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
it('translates the toolbar, the headers, the count and the caption', async () => {
|
||||
setLocale('fr')
|
||||
await mountList('/content/events')
|
||||
|
||||
expect(text(main().querySelector('[data-testid="entry-toolbar"]'))).toBe(
|
||||
'Trier par Dernière mise à jour Colonnes Modifier le type de contenu',
|
||||
)
|
||||
expect([...main().querySelectorAll('thead th')].map(text).slice(-3)).toEqual([
|
||||
'Créé',
|
||||
'Modifié',
|
||||
'Modifié par',
|
||||
])
|
||||
expect(text(main().querySelector('[data-testid="paginator-count"]'))).toBe('7 entrées')
|
||||
expect(text(main().querySelector('table caption'))).toBe(
|
||||
'Events, trié par dernière modification, plus récent en premier',
|
||||
)
|
||||
expect(button('Nouvelle entrée')).toBeDefined()
|
||||
})
|
||||
|
||||
it('translates the empty state', async () => {
|
||||
setLocale('fr')
|
||||
await mountList('/content/events', { list: (_, query) => listPage([], 0, query) })
|
||||
|
||||
expect(text(main())).toContain('Aucune entrée pour le moment')
|
||||
expect(text(main())).toContain('ajouter la première entrée. Chaque entrée suit')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import router from '@/router'
|
||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||
import {
|
||||
SKELETON_DELAY_MS,
|
||||
calls,
|
||||
chooseSize,
|
||||
eventEntries,
|
||||
failWith,
|
||||
installSelectPolyfills,
|
||||
listPage,
|
||||
listRequests,
|
||||
mountList,
|
||||
pending,
|
||||
press,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './contentTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(installSelectPolyfills)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
const TOTAL = 37
|
||||
|
||||
function paged(_: string, query: URLSearchParams) {
|
||||
const size = Number(query.get('size'))
|
||||
const page = Number(query.get('page'))
|
||||
const remaining = Math.max(0, Math.min(size, TOTAL - (page - 1) * size))
|
||||
return listPage(
|
||||
Array.from({ length: Math.min(remaining, 7) }, (__, index) => eventEntries[index]),
|
||||
TOTAL,
|
||||
query,
|
||||
)
|
||||
}
|
||||
|
||||
function main() {
|
||||
return document.body.querySelector('#dashboard-content')!
|
||||
}
|
||||
|
||||
function summary() {
|
||||
return text(document.body.querySelector('[data-testid="paginator-summary"]'))
|
||||
}
|
||||
|
||||
function pagerButtons() {
|
||||
return [
|
||||
...document.body.querySelectorAll<HTMLButtonElement>('nav[aria-label="Pagination"] button'),
|
||||
]
|
||||
}
|
||||
|
||||
function pagerLabels() {
|
||||
return [...document.body.querySelectorAll('nav[aria-label="Pagination"] > *')].map(
|
||||
(element) => element.getAttribute('aria-label') ?? text(element),
|
||||
)
|
||||
}
|
||||
|
||||
function lastRequest() {
|
||||
return listRequests(calls).at(-1)
|
||||
}
|
||||
|
||||
function alertText() {
|
||||
return text(main().querySelector('[role="alert"], [role="status"]'))
|
||||
}
|
||||
|
||||
describe('2 Paginated, 5 per page', () => {
|
||||
it('shows the summary, the page size and the paginator', async () => {
|
||||
await mountList('/content/events?size=5', { list: paged })
|
||||
|
||||
expect(summary()).toBe('Showing 1–5 of 37')
|
||||
expect(text(document.body.querySelector('[aria-label="Rows per page"]'))).toBe('5 per page')
|
||||
expect(pagerLabels()).toEqual([
|
||||
'First page',
|
||||
'Previous page',
|
||||
'1',
|
||||
'2',
|
||||
'…',
|
||||
'8',
|
||||
'Next page',
|
||||
'Last page',
|
||||
])
|
||||
expect(
|
||||
pagerButtons()
|
||||
.slice(0, 2)
|
||||
.every((button) => button.disabled),
|
||||
).toBe(true)
|
||||
expect(
|
||||
main()
|
||||
.querySelector('nav[aria-label="Pagination"] [aria-current="page"]')
|
||||
?.textContent?.trim(),
|
||||
).toBe('1')
|
||||
expect(main().querySelector('[data-testid="paginator-count"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows Previous, Page 1 of 8 and Next on mobile', async () => {
|
||||
await mountList('/content/events?size=5', { list: paged, width: MOBILE_WIDTH })
|
||||
|
||||
const nav = text(main().querySelector('nav[aria-label="Pagination"]'))
|
||||
expect(nav).toBe('Previous Page 1 of 8 Next')
|
||||
expect(
|
||||
main().querySelector<HTMLButtonElement>('[aria-label="Previous page"]')?.disabled,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('sends page 2 on Next and updates the URL on success', async () => {
|
||||
await mountList('/content/events?size=5', { list: paged })
|
||||
|
||||
await press('Next page')
|
||||
|
||||
expect(lastRequest()).toBe(
|
||||
'/v1/content-type/events/entry?page=2&size=5&sort=updated_at:desc',
|
||||
)
|
||||
expect(summary()).toBe('Showing 6–10 of 37')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=2')
|
||||
})
|
||||
|
||||
it('goes to the last page with Last', async () => {
|
||||
await mountList('/content/events?size=5', { list: paged })
|
||||
|
||||
await press('Last page')
|
||||
|
||||
expect(summary()).toBe('Showing 36–37 of 37')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=8')
|
||||
expect(
|
||||
pagerButtons()
|
||||
.filter((button) =>
|
||||
['Next page', 'Last page'].includes(button.getAttribute('aria-label')!),
|
||||
)
|
||||
.every((button) => button.disabled),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Page size select', () => {
|
||||
it.each([
|
||||
['5 per page', 5, '/content/events?size=5'],
|
||||
['25 per page', 25, '/content/events'],
|
||||
['50 per page', 50, '/content/events?size=50'],
|
||||
['100 per page', 100, '/content/events?size=100'],
|
||||
])('choosing "%s" resets to page 1 and ends on %s', async (label, size, url) => {
|
||||
await mountList('/content/events?size=5&page=3', { list: paged })
|
||||
|
||||
await chooseSize(label)
|
||||
|
||||
expect(lastRequest()).toBe(
|
||||
`/v1/content-type/events/entry?page=1&size=${size}&sort=updated_at:desc`,
|
||||
)
|
||||
expect(router.currentRoute.value.fullPath).toBe(url)
|
||||
})
|
||||
|
||||
it('does not remember the page size', async () => {
|
||||
await mountList('/content/events', { list: paged })
|
||||
await chooseSize('50 per page')
|
||||
|
||||
await unmountAll()
|
||||
await mountList('/content/events', { list: paged })
|
||||
|
||||
expect(lastRequest()).toBe(
|
||||
'/v1/content-type/events/entry?page=1&size=25&sort=updated_at:desc',
|
||||
)
|
||||
expect(Object.keys(localStorage).filter((key) => key.includes('page-size'))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loading a page', () => {
|
||||
it('shows Loading page 2…, disables the controls and marks the list busy', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gate = pending()
|
||||
await mountList('/content/events?size=5', {
|
||||
list: (key, query) => (query.get('page') === '2' ? gate.promise : paged(key, query)),
|
||||
})
|
||||
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Next page"]')!.click()
|
||||
vi.advanceTimersByTime(SKELETON_DELAY_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(summary()).toBe('Loading page 2…')
|
||||
expect(pagerButtons().every((button) => button.disabled)).toBe(true)
|
||||
expect(
|
||||
document.body.querySelector<HTMLButtonElement>('[aria-label="Rows per page"]')
|
||||
?.disabled,
|
||||
).toBe(true)
|
||||
expect(main().querySelector('[aria-busy="true"]')).not.toBeNull()
|
||||
expect(main().querySelectorAll('[data-testid="entry-skeleton-row"]')).toHaveLength(5)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error loading a page', () => {
|
||||
it('keeps the rows, shows the notice with Retry and leaves the URL', async () => {
|
||||
await mountList('/content/events?size=5', {
|
||||
list: (key, query) => {
|
||||
if (query.get('page') === '2') {
|
||||
throw new Error('x')
|
||||
}
|
||||
return paged(key, query)
|
||||
},
|
||||
})
|
||||
|
||||
await press('Next page')
|
||||
|
||||
expect(alertText()).toContain("Couldn't load page 2. Showing page 1.")
|
||||
expect(main().querySelectorAll('tbody tr')).toHaveLength(5)
|
||||
expect(summary()).toBe('Showing 1–5 of 37')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5')
|
||||
expect(main().querySelector('h1')?.textContent).toBe('Events')
|
||||
})
|
||||
|
||||
it('Retry sends the failed page again and updates the URL on success', async () => {
|
||||
let attempt = 0
|
||||
await mountList('/content/events?size=5', {
|
||||
list: (key, query) => {
|
||||
if (query.get('page') === '2' && attempt++ === 0) {
|
||||
throw new Error('x')
|
||||
}
|
||||
return paged(key, query)
|
||||
},
|
||||
})
|
||||
await press('Next page')
|
||||
|
||||
const retry = [...main().querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Retry',
|
||||
)!
|
||||
retry.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(summary()).toBe('Showing 6–10 of 37')
|
||||
expect(alertText()).toBe('')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=2')
|
||||
})
|
||||
|
||||
it('shows the error state, not a notice, when the first load fails', async () => {
|
||||
await mountList('/content/events?page=2', { list: failWith(500) })
|
||||
|
||||
expect(text(main())).toContain("Couldn't load entries")
|
||||
expect(text(main())).not.toContain("Couldn't load page")
|
||||
})
|
||||
})
|
||||
|
||||
describe('Out-of-range page', () => {
|
||||
it('loads the last page, shows the notice and replaces the URL', async () => {
|
||||
await mountList('/content/events?size=5&page=99', {
|
||||
list: (key, query) =>
|
||||
Number(query.get('page')) > 8 ? listPage([], TOTAL, query) : paged(key, query),
|
||||
})
|
||||
|
||||
expect(lastRequest()).toBe(
|
||||
'/v1/content-type/events/entry?page=8&size=5&sort=updated_at:desc',
|
||||
)
|
||||
expect(alertText()).toContain("That page doesn't exist. Showing the last page.")
|
||||
expect(summary()).toBe('Showing 36–37 of 37')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&page=8')
|
||||
})
|
||||
|
||||
it('shows the empty state and cleans the URL when the total is 0', async () => {
|
||||
await mountList('/content/events?page=3', { list: (_, query) => listPage([], 0, query) })
|
||||
|
||||
expect(text(main())).toContain('No entries yet')
|
||||
expect(main().querySelector('[data-testid="paginator-summary"]')).toBeNull()
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Focus-visible', () => {
|
||||
it('gives the paginator, the select and the toolbar controls a 2 px primary outline with a 2 px offset', async () => {
|
||||
await mountList('/content/events?size=5&page=3', { list: paged })
|
||||
|
||||
const controls = [
|
||||
...pagerButtons(),
|
||||
document.body.querySelector('[aria-label="Rows per page"]'),
|
||||
document.body.querySelector('[aria-label="Sort by"]'),
|
||||
document.body.querySelector('[aria-label="Sort ascending"]'),
|
||||
[...document.body.querySelectorAll('button')].find(
|
||||
(candidate) => text(candidate) === 'Columns',
|
||||
),
|
||||
]
|
||||
expect(controls.length).toBeGreaterThan(8)
|
||||
for (const element of controls) {
|
||||
expect(element?.className).toContain('focus-visible:outline-2')
|
||||
expect(element?.className).toContain('focus-visible:outline-offset-2')
|
||||
expect(element?.className).toContain('focus-visible:outline-primary')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import router from '@/router'
|
||||
import {
|
||||
calls,
|
||||
chooseSize,
|
||||
chooseSort,
|
||||
eventEntries,
|
||||
installSelectPolyfills,
|
||||
listPage,
|
||||
listRequests,
|
||||
mountList,
|
||||
press,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './contentTestKit'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
return { ...actual, useApi: vi.fn() }
|
||||
})
|
||||
|
||||
beforeAll(installSelectPolyfills)
|
||||
|
||||
afterEach(unmountAll)
|
||||
|
||||
function paged(_: string, query: URLSearchParams) {
|
||||
return listPage(eventEntries.slice(0, Number(query.get('size')) > 5 ? 7 : 5), 37, query)
|
||||
}
|
||||
|
||||
function iconPath(name: string) {
|
||||
return /d="([^"]+)"/.exec(lucide.icons[name]!.body)![1]
|
||||
}
|
||||
|
||||
function lastRequest() {
|
||||
return listRequests(calls).at(-1)
|
||||
}
|
||||
|
||||
function selectText() {
|
||||
return text(document.body.querySelector('[aria-label="Sort by"]'))
|
||||
}
|
||||
|
||||
function directionButton() {
|
||||
return document.body.querySelector<HTMLElement>(
|
||||
'[aria-label="Sort ascending"], [aria-label="Sort descending"]',
|
||||
)!
|
||||
}
|
||||
|
||||
function sortedHeader() {
|
||||
return [...document.body.querySelectorAll('thead th')].find((header) =>
|
||||
header.querySelector('[data-sort-direction]'),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sort control', () => {
|
||||
it.each([
|
||||
['ID', 'id:desc', '/content/events?sort=id:desc', 'ID'],
|
||||
['Created', 'created_at:desc', '/content/events?sort=created_at:desc', 'Created'],
|
||||
['Last updated', 'updated_at:desc', '/content/events', 'Updated'],
|
||||
])('choosing %s sends %s with page 1 and ends on %s', async (option, sort, url, header) => {
|
||||
await mountList('/content/events?page=3', { list: paged })
|
||||
|
||||
await chooseSort(option)
|
||||
|
||||
expect(lastRequest()).toBe(`/v1/content-type/events/entry?page=1&size=25&sort=${sort}`)
|
||||
expect(router.currentRoute.value.fullPath).toBe(url)
|
||||
expect(text(sortedHeader())).toBe(header)
|
||||
})
|
||||
|
||||
it('offers ID, Created and Last updated only', async () => {
|
||||
await mountList('/content/events', { list: paged })
|
||||
|
||||
expect(selectText()).toBe('Sort by Last updated')
|
||||
expect(document.body.querySelector('[aria-label="Sort by"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the direction when the field changes', async () => {
|
||||
await mountList('/content/events?sort=updated_at:asc', { list: paged })
|
||||
|
||||
await chooseSort('ID')
|
||||
|
||||
expect(lastRequest()).toContain('sort=id:asc')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?sort=id:asc')
|
||||
})
|
||||
|
||||
it('changes the URL when the response succeeds, not when the request is sent', async () => {
|
||||
let release!: (value: unknown) => void
|
||||
const gate = new Promise((resolve) => (release = resolve))
|
||||
await mountList('/content/events', {
|
||||
list: (key, query) => (query.get('sort') === 'id:desc' ? gate : paged(key, query)),
|
||||
})
|
||||
|
||||
await chooseSort('ID')
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
|
||||
release(listPage(eventEntries, 7, new URLSearchParams('page=1&size=25&sort=id:desc')))
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?sort=id:desc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Direction button', () => {
|
||||
it.each([
|
||||
['/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'],
|
||||
])('from %s sends %s and ends on %s', async (from, sort, url) => {
|
||||
await mountList(from, { list: paged })
|
||||
|
||||
await press(directionButton().getAttribute('aria-label')!)
|
||||
|
||||
expect(lastRequest()).toBe(`/v1/content-type/events/entry?page=1&size=25&sort=${sort}`)
|
||||
expect(router.currentRoute.value.fullPath).toBe(url)
|
||||
})
|
||||
|
||||
it('goes back to page 1', async () => {
|
||||
await mountList('/content/events?size=5&page=3', { list: paged })
|
||||
|
||||
await press('Sort ascending')
|
||||
|
||||
expect(lastRequest()).toBe(
|
||||
'/v1/content-type/events/entry?page=1&size=5&sort=updated_at:asc',
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/content/events', 'Sort ascending', 'arrow-down-wide-narrow'],
|
||||
['/content/events?sort=updated_at:asc', 'Sort descending', 'arrow-up-narrow-wide'],
|
||||
])('on %s is named "%s" and draws %s', async (from, name, icon) => {
|
||||
await mountList(from, { list: paged })
|
||||
|
||||
const button = directionButton()
|
||||
expect(button.getAttribute('aria-label')).toBe(name)
|
||||
expect(button.querySelector('svg path')?.getAttribute('d')).toBe(iconPath(icon))
|
||||
})
|
||||
|
||||
it('shows the arrow of the direction on the sorted header', async () => {
|
||||
await mountList('/content/events?sort=created_at:asc', { list: paged })
|
||||
|
||||
expect(sortedHeader()?.querySelector('[data-sort-direction="asc"]')).not.toBeNull()
|
||||
expect(text(sortedHeader())).toBe('Created')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sort values in the URL', () => {
|
||||
it('reads created_at:asc into the select and the direction icon', async () => {
|
||||
await mountList('/content/events?sort=created_at:asc', { list: paged })
|
||||
|
||||
expect(lastRequest()).toContain('sort=created_at:asc')
|
||||
expect(selectText()).toBe('Sort by Created')
|
||||
expect(directionButton().getAttribute('aria-label')).toBe('Sort descending')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?sort=created_at:asc')
|
||||
})
|
||||
|
||||
it.each(['title:asc', 'data:asc', 'id', 'id:sideways', 'UPDATED_AT:DESC'])(
|
||||
'replaces sort=%s by the default in the request and the URL',
|
||||
async (value) => {
|
||||
await mountList(`/content/events?sort=${value}`, { list: paged })
|
||||
|
||||
expect(lastRequest()).toContain('sort=updated_at:desc')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
expect(selectText()).toBe('Sort by Last updated')
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
['page=abc', 'page=1&size=25'],
|
||||
['page=0', 'page=1&size=25'],
|
||||
['size=10', 'page=1&size=25'],
|
||||
['size=100&page=2', 'page=2&size=100'],
|
||||
])('reads ?%s as %s and cleans the URL when needed', async (query, requested) => {
|
||||
await mountList(`/content/events?${query}`, { list: paged })
|
||||
|
||||
expect(lastRequest()).toContain(requested)
|
||||
})
|
||||
|
||||
it('replaces an invalid size and page in the URL', async () => {
|
||||
await mountList('/content/events?size=10&page=abc', { list: paged })
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sort is kept across paging and size changes', () => {
|
||||
it('carries sort=id:asc on every request and keeps it in the URL', async () => {
|
||||
await mountList('/content/events?sort=id:asc&size=5', { list: paged })
|
||||
|
||||
await press('Next page')
|
||||
|
||||
expect(lastRequest()).toBe('/v1/content-type/events/entry?page=2&size=5&sort=id:asc')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=5&sort=id:asc&page=2')
|
||||
|
||||
await chooseSize('50 per page')
|
||||
|
||||
expect(lastRequest()).toBe('/v1/content-type/events/entry?page=1&size=50&sort=id:asc')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/content/events?size=50&sort=id:asc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
import { vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import lucide from 'virtual:lucide-subset'
|
||||
import { mountAt, type SchemaMount } from './schemaTestKit'
|
||||
|
||||
export {
|
||||
button,
|
||||
calls,
|
||||
customTypes,
|
||||
failWith,
|
||||
failWithBody,
|
||||
manyTypes,
|
||||
networkFailure,
|
||||
pending,
|
||||
text,
|
||||
unmountAll,
|
||||
} from './schemaTestKit'
|
||||
|
||||
addCollection(lucide)
|
||||
|
||||
export const SKELETON_DELAY_MS = 150
|
||||
export const DEFAULT_SORT = 'updated_at:desc'
|
||||
|
||||
const MARTA = 3
|
||||
const INES = 4
|
||||
const JONAS = 5
|
||||
|
||||
type Row = [
|
||||
number,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
boolean,
|
||||
number | null,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
number,
|
||||
]
|
||||
|
||||
const ROWS: Row[] = [
|
||||
[
|
||||
42,
|
||||
'Lime plaster workshop',
|
||||
'2026-11-21',
|
||||
'2026-11-22',
|
||||
false,
|
||||
18,
|
||||
'Workshop',
|
||||
'Two days of hands-on work with lime putty and old stone.',
|
||||
'2026-10-12',
|
||||
'2026-10-15',
|
||||
MARTA,
|
||||
],
|
||||
[
|
||||
41,
|
||||
'Heat pumps: what the numbers say',
|
||||
'2026-11-03',
|
||||
'',
|
||||
true,
|
||||
500,
|
||||
'Talk',
|
||||
'A look at running costs after two winters.',
|
||||
'2026-10-08',
|
||||
'2026-10-14',
|
||||
JONAS,
|
||||
],
|
||||
[
|
||||
40,
|
||||
'Open day at the Rue Verte site',
|
||||
'2026-11-12',
|
||||
'2026-11-12',
|
||||
false,
|
||||
120,
|
||||
'Meetup',
|
||||
'',
|
||||
'2026-10-02',
|
||||
'2026-10-12',
|
||||
INES,
|
||||
],
|
||||
[
|
||||
39,
|
||||
'Winter windows clinic',
|
||||
'2026-12-05',
|
||||
'',
|
||||
false,
|
||||
null,
|
||||
'Workshop',
|
||||
'Bring a window, leave with a plan.',
|
||||
'2026-09-30',
|
||||
'2026-10-09',
|
||||
MARTA,
|
||||
],
|
||||
[
|
||||
38,
|
||||
'Timber joinery for beginners',
|
||||
'2027-01-14',
|
||||
'2027-01-15',
|
||||
false,
|
||||
12,
|
||||
'Workshop',
|
||||
'Mortise, tenon and a lot of patience.',
|
||||
'2026-09-28',
|
||||
'2026-10-07',
|
||||
INES,
|
||||
],
|
||||
[
|
||||
37,
|
||||
'Q&A with the site managers',
|
||||
'2026-10-28',
|
||||
'',
|
||||
true,
|
||||
null,
|
||||
'Meetup',
|
||||
'',
|
||||
'2026-09-21',
|
||||
'2026-10-02',
|
||||
JONAS,
|
||||
],
|
||||
[
|
||||
36,
|
||||
'Reclaimed floorboards talk',
|
||||
'2026-10-17',
|
||||
'',
|
||||
false,
|
||||
60,
|
||||
'Talk',
|
||||
'Where to find them and how to lay them.',
|
||||
'2026-09-14',
|
||||
'2026-09-29',
|
||||
MARTA,
|
||||
],
|
||||
]
|
||||
|
||||
export function entry(row: Row) {
|
||||
const [
|
||||
id,
|
||||
title,
|
||||
startsOn,
|
||||
endsOn,
|
||||
online,
|
||||
capacity,
|
||||
category,
|
||||
description,
|
||||
created,
|
||||
updated,
|
||||
by,
|
||||
] = row
|
||||
return {
|
||||
id,
|
||||
content_type_id: 1,
|
||||
data: {
|
||||
title,
|
||||
starts_on: startsOn,
|
||||
ends_on: endsOn,
|
||||
online,
|
||||
capacity,
|
||||
category,
|
||||
description,
|
||||
},
|
||||
created_by: by,
|
||||
updated_by: by,
|
||||
created_at: `${created}T09:14:00Z`,
|
||||
updated_at: `${updated}T11:02:00Z`,
|
||||
}
|
||||
}
|
||||
|
||||
export const eventEntries = ROWS.map(entry)
|
||||
|
||||
export const userNames = [
|
||||
{ id: MARTA, display_name: 'Marta Alves' },
|
||||
{ id: INES, display_name: 'Inès Duarte' },
|
||||
{ id: JONAS, display_name: 'Jonas Pereira' },
|
||||
]
|
||||
|
||||
export function listPage(items: unknown[], total: number, query: URLSearchParams) {
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: Number(query.get('page')),
|
||||
size: Number(query.get('size')),
|
||||
sort: query.get('sort'),
|
||||
}
|
||||
}
|
||||
|
||||
export function populated(key: string, query: URLSearchParams) {
|
||||
return key === 'events' ? listPage(eventEntries, 7, query) : listPage([], 0, query)
|
||||
}
|
||||
|
||||
export function lookupNames(ids: number[]) {
|
||||
return userNames.filter((user) => ids.includes(user.id))
|
||||
}
|
||||
|
||||
export function mountList(path: string, options: SchemaMount = {}) {
|
||||
return mountAt(path, { list: populated, lookup: lookupNames, ...options })
|
||||
}
|
||||
|
||||
export async function settle() {
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export function listRequests(calls: string[]) {
|
||||
return calls.filter((url) => /\/entry\?/.test(url) && !/size=5$/.test(url))
|
||||
}
|
||||
|
||||
export function lookupRequests(calls: string[]) {
|
||||
return calls.filter((url) => url.startsWith('/v1/user/lookup'))
|
||||
}
|
||||
|
||||
export function installSelectPolyfills() {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
}
|
||||
|
||||
export function openSelect(trigger: Element | null) {
|
||||
trigger?.dispatchEvent(
|
||||
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export async function chooseSort(label: string) {
|
||||
openSelect(document.body.querySelector('[aria-label="Sort by"]'))
|
||||
await flushPromises()
|
||||
const option = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
)!
|
||||
option.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
|
||||
option.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function chooseSize(label: string) {
|
||||
document.body.querySelector<HTMLElement>('[aria-label="Rows per page"]')!.click()
|
||||
await flushPromises()
|
||||
const option = [...document.body.querySelectorAll<HTMLElement>('[role="option"]')].find(
|
||||
(candidate) => candidate.textContent?.trim() === label,
|
||||
)!
|
||||
option.click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
export async function press(label: string) {
|
||||
document.body.querySelector<HTMLElement>(`[aria-label="${label}"]`)!.click()
|
||||
await flushPromises()
|
||||
}
|
||||
@@ -126,6 +126,14 @@ export function entryPage(total: number) {
|
||||
return { items: [], total, page: 1, size: 1 }
|
||||
}
|
||||
|
||||
function defaultType(key: string) {
|
||||
const found = customTypes.find((type) => type.key === key)
|
||||
if (!found) {
|
||||
throw new ApiError(404, undefined, 'content_type_not_found')
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
export interface SchemaMount {
|
||||
role?: string
|
||||
create?: (body: PostedRequest['body']) => unknown
|
||||
@@ -137,6 +145,10 @@ export interface SchemaMount {
|
||||
builtin?: Responder
|
||||
verified?: boolean
|
||||
actions?: string[]
|
||||
type?: (key: string) => unknown
|
||||
list?: (key: string, query: URLSearchParams) => unknown
|
||||
lookup?: (ids: number[]) => unknown
|
||||
before?: () => void
|
||||
}
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
@@ -156,6 +168,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
puts.length = 0
|
||||
deletes.length = 0
|
||||
localStorage.clear()
|
||||
options.before?.()
|
||||
setViewportWidth(options.width ?? DESKTOP_WIDTH)
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
@@ -199,6 +212,21 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
|
||||
if (counted) {
|
||||
return (options.entries ?? ((key) => entryPage(entryCounts[key] ?? 0)))(counted[1]!)
|
||||
}
|
||||
const listed = url.match(/^\/v1\/content-type\/([^/?]+)\/entry\?(.*)$/)
|
||||
if (listed) {
|
||||
return (options.list ?? (() => entryPage(0)))(
|
||||
listed[1]!,
|
||||
new URLSearchParams(listed[2]),
|
||||
)
|
||||
}
|
||||
const looked = url.match(/^\/v1\/user\/lookup\?ids=(.*)$/)
|
||||
if (looked) {
|
||||
return (options.lookup ?? (() => []))(looked[1]!.split(',').map(Number))
|
||||
}
|
||||
const detailed = url.match(/^\/v1\/content-type\/([^/?]+)$/)
|
||||
if (detailed) {
|
||||
return (options.type ?? defaultType)(detailed[1]!)
|
||||
}
|
||||
if (url === '/v1/builtin-type') {
|
||||
return (options.builtin ?? (() => builtinTypes))()
|
||||
}
|
||||
|
||||
@@ -646,6 +646,9 @@ const boardGroupLabels: Record<string, Record<string, string>> = {
|
||||
'Review and delete': '3 Review and delete',
|
||||
'Field types': '4 Field types',
|
||||
},
|
||||
content: {
|
||||
'Content and entry list': '5 Content and entry list',
|
||||
},
|
||||
}
|
||||
|
||||
const referenceGroups: Record<string, string> = {
|
||||
|
||||
@@ -6988,9 +6988,248 @@ const schemaStates: Record<string, VisualState> = {
|
||||
},
|
||||
}
|
||||
|
||||
const contentRows = [
|
||||
[
|
||||
42,
|
||||
'Lime plaster workshop',
|
||||
'2026-11-21',
|
||||
'2026-11-22',
|
||||
false,
|
||||
18,
|
||||
'Workshop',
|
||||
'Two days of hands-on work with lime putty and old stone.',
|
||||
'2026-10-12',
|
||||
'2026-10-15',
|
||||
3,
|
||||
],
|
||||
[
|
||||
41,
|
||||
'Heat pumps: what the numbers say',
|
||||
'2026-11-03',
|
||||
'',
|
||||
true,
|
||||
500,
|
||||
'Talk',
|
||||
'A look at running costs after two winters.',
|
||||
'2026-10-08',
|
||||
'2026-10-14',
|
||||
5,
|
||||
],
|
||||
[
|
||||
40,
|
||||
'Open day at the Rue Verte site',
|
||||
'2026-11-12',
|
||||
'2026-11-12',
|
||||
false,
|
||||
120,
|
||||
'Meetup',
|
||||
'',
|
||||
'2026-10-02',
|
||||
'2026-10-12',
|
||||
4,
|
||||
],
|
||||
[
|
||||
39,
|
||||
'Winter windows clinic',
|
||||
'2026-12-05',
|
||||
'',
|
||||
false,
|
||||
null,
|
||||
'Workshop',
|
||||
'Bring a window, leave with a plan.',
|
||||
'2026-09-30',
|
||||
'2026-10-09',
|
||||
3,
|
||||
],
|
||||
[
|
||||
38,
|
||||
'Timber joinery for beginners',
|
||||
'2027-01-14',
|
||||
'2027-01-15',
|
||||
false,
|
||||
12,
|
||||
'Workshop',
|
||||
'Mortise, tenon and a lot of patience.',
|
||||
'2026-09-28',
|
||||
'2026-10-07',
|
||||
4,
|
||||
],
|
||||
[
|
||||
37,
|
||||
'Q&A with the site managers',
|
||||
'2026-10-28',
|
||||
'',
|
||||
true,
|
||||
null,
|
||||
'Meetup',
|
||||
'',
|
||||
'2026-09-21',
|
||||
'2026-10-02',
|
||||
5,
|
||||
],
|
||||
[
|
||||
36,
|
||||
'Reclaimed floorboards talk',
|
||||
'2026-10-17',
|
||||
'',
|
||||
false,
|
||||
60,
|
||||
'Talk',
|
||||
'Where to find them and how to lay them.',
|
||||
'2026-09-14',
|
||||
'2026-09-29',
|
||||
3,
|
||||
],
|
||||
].map(
|
||||
([
|
||||
id,
|
||||
title,
|
||||
startsOn,
|
||||
endsOn,
|
||||
online,
|
||||
capacity,
|
||||
category,
|
||||
description,
|
||||
created,
|
||||
updated,
|
||||
by,
|
||||
]) => ({
|
||||
id,
|
||||
content_type_id: 1,
|
||||
data: {
|
||||
title,
|
||||
starts_on: startsOn,
|
||||
ends_on: endsOn,
|
||||
online,
|
||||
capacity,
|
||||
category,
|
||||
description,
|
||||
},
|
||||
created_by: by,
|
||||
updated_by: by,
|
||||
created_at: `${created}T09:14:00Z`,
|
||||
updated_at: `${updated}T11:02:00Z`,
|
||||
}),
|
||||
)
|
||||
|
||||
const contentTypeNames = [
|
||||
'Events',
|
||||
'Recipes',
|
||||
'Press mentions',
|
||||
'Frequently asked questions and answers',
|
||||
'Job openings',
|
||||
'Testimonials',
|
||||
'Suppliers',
|
||||
'Certifications',
|
||||
'Case studies',
|
||||
'Glossary terms',
|
||||
'Partners',
|
||||
'Downloads',
|
||||
'Locations',
|
||||
'Awards',
|
||||
]
|
||||
|
||||
const contentManyTypes = contentTypeNames.map((name, index) => ({
|
||||
id: index + 1,
|
||||
key: name === 'Events' ? 'events' : name.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
name,
|
||||
icon: 'files',
|
||||
fields: index === 0 ? schemaEventsFields : [],
|
||||
created_at: '2026-09-01T09:30:00Z',
|
||||
updated_at: '2026-10-15T09:30:00Z',
|
||||
}))
|
||||
|
||||
const contentUsers = [
|
||||
{ id: 3, display_name: 'Marta Alves' },
|
||||
{ id: 4, display_name: 'Inès Duarte' },
|
||||
{ id: 5, display_name: 'Jonas Pereira' },
|
||||
]
|
||||
|
||||
const contentEventsType = schemaCustomTypes[0]!
|
||||
|
||||
function contentEntries(url: URL) {
|
||||
const size = Number(url.searchParams.get('size'))
|
||||
if (size === 5) {
|
||||
return {
|
||||
items: contentRows.slice(0, 5),
|
||||
total: 37,
|
||||
page: 1,
|
||||
size: 5,
|
||||
sort: 'updated_at:desc',
|
||||
}
|
||||
}
|
||||
return { items: contentRows, total: 7, page: 1, size: 25, sort: 'updated_at:desc' }
|
||||
}
|
||||
|
||||
function contentApi(overrides: Record<string, ApiMock> = {}): Record<string, ApiMock> {
|
||||
return {
|
||||
'GET /v1/auth/me': schemaSession('owner'),
|
||||
'GET /v1/content-type': { status: 200, body: schemaCustomTypes },
|
||||
'GET /v1/content-type/events': { status: 200, body: contentEventsType },
|
||||
'GET /v1/content-type/events/entry': { status: 200, bodyFor: contentEntries },
|
||||
'GET /v1/user/lookup': { status: 200, body: contentUsers },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const contentStates: Record<string, VisualState> = {
|
||||
Populated: { path: '/content/events', api: contentApi() },
|
||||
'Paginated, 5 per page': { path: '/content/events?size=5', api: contentApi() },
|
||||
'Columns menu': {
|
||||
path: '/content/events',
|
||||
api: contentApi(),
|
||||
steps: async (page) => {
|
||||
await page.getByRole('button', { name: 'Columns' }).click()
|
||||
await page.getByText('Created and updated dates always show').waitFor()
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
'Many types in the sidebar': {
|
||||
path: '/content/events',
|
||||
api: contentApi({ 'GET /v1/content-type': { status: 200, body: contentManyTypes } }),
|
||||
steps: async (page) => {
|
||||
if (schemaIsMobile(page)) {
|
||||
await page.locator('[data-slot="toggle"]').first().click()
|
||||
await page.getByRole('button', { name: 'All content types' }).click()
|
||||
await page.getByText('Frequently asked questions and answers').first().waitFor()
|
||||
}
|
||||
await schemaSettle(page)
|
||||
},
|
||||
},
|
||||
Loading: {
|
||||
path: '/content/events',
|
||||
api: contentApi({ 'GET /v1/content-type/events/entry': { status: 200, hang: true } }),
|
||||
},
|
||||
'Empty for this type': {
|
||||
path: '/content/events',
|
||||
api: contentApi({
|
||||
'GET /v1/content-type/events/entry': {
|
||||
status: 200,
|
||||
body: { items: [], total: 0, page: 1, size: 25, sort: 'updated_at:desc' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
Error: {
|
||||
path: '/content/events',
|
||||
api: contentApi({ 'GET /v1/content-type/events/entry': { status: 500 } }),
|
||||
},
|
||||
'Forbidden (403)': {
|
||||
path: '/content/events',
|
||||
api: contentApi({ 'GET /v1/content-type/events/entry': { status: 403 } }),
|
||||
},
|
||||
'Type not found': {
|
||||
path: '/content/unknown',
|
||||
api: contentApi({
|
||||
'GET /v1/content-type/unknown': { status: 404, body: 'content_type_not_found' },
|
||||
'GET /v1/content-type/unknown/entry': { status: 404, body: 'content_type_not_found' },
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||
sites: sitesStates,
|
||||
schema: schemaStates,
|
||||
content: contentStates,
|
||||
authors: authorsStates,
|
||||
activity: activityStates,
|
||||
profile: {
|
||||
|
||||
Reference in new issue
Block a user