feat: entry list at /content/:key

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

No files matched your search

+224
View File
@@ -0,0 +1,224 @@
Feature: Entry list of a custom content type
As a verified user
I want to browse the entries of a custom content type
So that I can find an entry before editing it
Background:
Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie
And API errors are plain text
And the route is /content/:key with meta auth "verified" and title "Content", per docs/design/screens/content.md group "5 Content and entry list", states 1 to 9
And the page requests GET /v1/content-type/{key} (the type) and GET /v1/content-type/{key}/entry?page=P&size=S&sort=O (the entries) at the same time; the entries answer {items, total, page, size, sort} and each item is {id, content_type_id, data, created_by, updated_by, created_at, updated_at}
And the sidebar Content group of features/schema_list_and_content_nav.feature is reused unchanged; this slice only checks it in the "Many types" state
And page, size and sort live in the URL (/content/events?page=2&size=50&sort=created_at:desc), the defaults (page 1, size 25, sort updated_at:desc) are omitted from the URL, and the request always carries explicit page and size
And size is one of 5, 25, 50, 100; sort is <id|created_at|updated_at>:<asc|desc>; an invalid value is replaced silently by the default in the URL and in the request, so the API never answers 400 here
And a size or sort change resets page to 1; the URL changes when a response succeeds, not when the request is sent
And the pager, the page-size select, the summary, the loading page, the page error notice and the out-of-range redirect behave as in features/articles_pagination_screen.feature scenarios 1 to 7, 10, 10b and 11, with the content URL and request
And the page size is not remembered
And the columns are ID, up to 6 chosen field columns (default: the first 4 fields of the type, fewer if the type has fewer), Created, Updated and Updated by; ID, Created, Updated and Updated by are always shown
And a field cell shows: Text truncated on one line with the full text as tooltip; Rich text as its first line in plain text, truncated; Number right-aligned with tabular figures; Yes or no as an icon plus "Yes" or "No"; Date as day, month and year ("21 Nov 2026") with no time-zone shift; Choice as a small outline tag; an empty value as "–"
And Created reads "12 Oct 2026" and Updated reads "15 Oct 2026" (date only, local time zone), ID is mono and muted
And "Updated by" is the display_name from one GET /v1/user/lookup?ids=<distinct updated_by of the page> sent after the entries answer; until it resolves, and if it fails or does not know the id, the cell shows a muted "–"
And the desktop list is a table with a 36 px header and 48 px rows; below md it is a card list with 68 px cards, each card a link with a chevron-right, showing the first field, the second field, the first Choice field as a tag if the type has one, and "· {Updated by}"; cards ignore the column picker
And every row is a link to /content/:key/:id (a link on the first field cell on desktop, the whole card on mobile); the click uses the router
And "New entry" (primary, plus icon, in the title row on desktop; a floating button on mobile) navigates to /content/:key/new
And /content/:key/new and /content/:key/:id are not routed in this slice and land on the catch-all 404 until the entry editor slice
And "Edit content type" (ghost, settings-2 icon, right of the toolbar) navigates to /schema/:key; it is shown to owner and admin only and not on mobile
And no filter or search exists
And every string goes through t() and has a French entry in i18n/messages.ts
And a copy tagged "(proposed copy)" is not in the design and is the coder's single point of change
Scenario: 1 Populated, one page
Given GET /v1/content-type/events resolves 200 with 7 fields and GET .../entry?page=1&size=25&sort=updated_at:desc resolves 200 with 7 items and total 7
When the user navigates to /content/events
Then the breadcrumb reads "Content" then "Events", the title "Events" with the subtitle "events · 7 fields"
And "New entry" is in the title row and the toolbar reads "Sort by" "Last updated", a direction button, "Columns", then "Edit content type" at the right
And the table headers read "ID" "Title" "Starts on" "Ends on" "Online" "Created" "Updated" "Updated by" and the Updated header shows an arrow-down icon
And 7 rows show, the first row ID 42 "Lime plaster workshop" "21 Nov 2026" "22 Nov 2026" "No" "12 Oct 2026" "15 Oct 2026" "Marta Alves"
And no pager, no page-size select, only the text "7 entries" ("1 entry" for one, proposed copy)
And the sidebar row Events is the current page and the URL has no query
Scenario: 2 Paginated, 5 per page
Given GET .../entry?page=1&size=5&sort=updated_at:desc resolves 200 with 5 items and total 37
When the user navigates to /content/events?size=5
Then the summary reads "Showing 1–5 of 37", the page-size select reads "5 per page" and the paginator shows First, Previous (both disabled), 1 (current), 2, an ellipsis, 8, Next, Last
And on mobile the paginator reads Previous (disabled), "Page 1 of 8", Next
And activating Next sends page=2 and, on success, the URL is /content/events?size=5&page=2
Scenario: 3 Columns menu
Given the populated state of the 7-field type Events
When the user activates "Columns"
Then a popover opens on desktop and a bottom sheet on mobile, titled "Columns" with "4 of 6"
And the sheet adds the text "Choose up to 6 fields to show. 4 selected." and the actions "Reset" and "Done"; the popover has "Reset" only
And each of the 7 fields is a checkbox row with its type icon, its label and its key in mono, in schema order; Title, Starts on, Ends on and Online are checked
And a last row with a clock icon reads "Created and updated dates always show" with a disabled, unchecked checkbox
When the user checks "Capacity"
Then the table gets a Capacity column (right-aligned) at once, with no request, and the count reads "5 of 6"
And Esc, a click outside or "Done" closes it and returns focus to "Columns"
Scenario Outline: The column cap and the stored choice
Given the 7-field type Events and <before>
When <action>
Then <after>
Examples:
| before | action | after |
| 6 fields checked | the user opens the menu | the unchecked fields are disabled and the text "You can show up to 6 fields." (proposed copy) shows |
| 5 fields checked | the user checks a 6th | it is checked and the others become disabled |
| 6 fields checked | the user unchecks one | the others are enabled again |
| the default 4 and nothing stored | the user checks "Capacity" | localStorage renewcms-content-columns-events is ["title","starts_on","ends_on","online","capacity"] |
| a stored selection | the user activates "Reset" | the default 4 apply and the stored key is removed |
| stored ["title","gone","capacity"] | the page loads | the columns are Title and Capacity, and nothing is written |
| stored with 8 valid keys | the page loads | the first 6 in schema order show |
| stored "not json" or [] | the page loads | the default 4 show |
| localStorage throws or is undefined | the user changes the selection | the columns change for this visit and no error shows |
| stored for events | the user opens /content/recipes | Recipes uses its own key and its default |
Scenario: The last field column cannot be unchecked
Given the 7-field type Events with exactly one field checked
When the user opens the Columns menu
Then that field's checkbox is disabled, so at least one field column always shows and every row has its link
And a stored empty selection loads as the default 4
Scenario: A type with fewer than 4 fields
Given a type with 2 fields
When the user opens its list
Then both field columns show and "of 6" in the menu still reads the cap
Scenario Outline: Sort control
Given GET .../entry resolves 200 and the user is on /content/events?page=3
When the user chooses "<option>" in the "Sort by" select
Then GET ...&sort=<sort>&page=1 is sent and, on success, the URL is <url>
And the sorted column header shows the arrow icon of its direction
Examples:
| option | sort | url |
| ID | id:desc | /content/events?sort=id:desc |
| Created | created_at:desc | /content/events?sort=created_at:desc |
| Last updated | updated_at:desc | /content/events |
Scenario Outline: Direction button
Given the user is on <from>
When the user activates the direction button
Then GET ...&sort=<sort>&page=1 is sent and, on success, the URL is <url>
And the button shows arrow-up-narrow-wide when ascending and arrow-down-wide-narrow when descending, with the accessible name "Sort ascending" or "Sort descending" for the next action (proposed copy)
Examples:
| from | sort | url |
| /content/events | updated_at:asc | /content/events?sort=updated_at:asc |
| /content/events?sort=updated_at:asc | updated_at:desc | /content/events |
| /content/events?sort=id:desc | id:asc | /content/events?sort=id:asc |
Scenario Outline: Sort values in the URL
When the user navigates to /content/events?sort=<value>
Then <result>
Examples:
| value | result |
| created_at:asc | GET carries sort=created_at:asc and the select reads "Created" with the ascending direction icon |
| title:asc | GET carries sort=updated_at:desc and the URL is replaced by /content/events |
| data:asc | GET carries sort=updated_at:desc and the URL is replaced by /content/events |
| id | GET carries sort=updated_at:desc and the URL is replaced by /content/events |
Scenario: Sort is kept across paging and size changes
Given the URL is /content/events?sort=id:asc
When the user activates Next, or changes the size
Then every request carries sort=id:asc and the URL keeps it (a size change also resets the page)
Scenario: Switching to another type resets the page
Given the user is on /content/events?page=3&size=50&sort=id:asc
When the user opens /content/recipes from the sidebar
Then the requests are for recipes with page=1, size=25 and sort=updated_at:desc, its own columns apply and no Events row shows meanwhile
Scenario: 4 Many types in the sidebar
Given GET /v1/content-type returns 14 custom types, the 4th named "Frequently asked questions and answers"
When the user opens /content/events
Then on desktop the Content group shows "Content", the first 5 types and the selected one, the long name truncated, and "Show all 14"
And on mobile the drawer shows the group expanded with "All content types 14" collapsed row
And behaviour is as in features/schema_list_and_content_nav.feature
Scenario: 5 Loading
Given the type and the entries have not resolved
When the user navigates to /content/events
Then the title row shows "Events" once the type has resolved and a skeleton until then, "New entry" is disabled and the toolbar is shown
And the list is 8 skeleton rows on desktop (6 on mobile) with a 48 px row (68 px on mobile) and aria-busy="true"
And when both resolve the rows replace the skeleton
Scenario: 6 Empty for this type
Given GET .../entry resolves 200 with items [] and total 0
When the user navigates to /content/events
Then the empty state reads "No entries yet" with "Use New entry to add the first entry. Each entry follows the fields of this content type." and a "New entry" action
And the toolbar stays, the pager, the summary and the count text do not show
And "New entry" navigates to /content/events/new
And an out-of-range page with total 0 shows this state and the URL becomes /content/events
Scenario Outline: 7 Error loading entries
Given the type resolves 200 and GET .../entry <failure>
When the user navigates to /content/events
Then an error alert reads "Couldn't load entries" with "GET /v1/content-type/events/entry failed. Check your connection and try again." and a "Try again" action
And no toolbar, no list and no pager show; the title row stays
When the user activates "Try again"
Then the skeleton (state 5) shows and the failed request is sent again
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: Error loading the type
Given GET /v1/content-type/events answers 500
Then the same alert shows with "GET /v1/content-type/events failed. Check your connection and try again." (proposed copy)
And "Try again" sends only the failed request again
Scenario Outline: 8 Forbidden (403)
Given <which> answers 403
When the user navigates to /content/events
Then a centered state with the lock icon reads "You don't have access to Events" with "Your account can't open this content right now. If you think this is a mistake, ask an owner or an admin." and "Go to Home"
And no toolbar or list shows and "Go to Home" navigates to /home
And the title uses the type name when the type loaded, else the key
Examples:
| which |
| the entries request |
| the type request |
Scenario Outline: 9 Type not found
Given <which> answers 404 content_type_not_found
When the user navigates to /content/unknown
Then a centered state with the file-x icon reads "Content type not found" with "It may have been deleted, or the address may be mistyped." and "Go to Home"
And the breadcrumb reads "Content" only, the mobile title is "Content" and no sidebar row is current
And "Go to Home" navigates to /home
Examples:
| which |
| the type request |
| the entries request |
Scenario: A deleted type behind the sidebar
Given Recipes was deleted in features/schema_builder_delete.feature
When the user opens /content/recipes
Then the "Content type not found" state shows with no further request than the type's
Scenario: Session expired
Given any of the requests answers 401
Then the existing redirect to /login with the expired reason happens
Scenario: Stale answers are ignored
Given a request for page 2 is in flight
When the user changes the sort and that request resolves later
Then its answer is dropped and only the latest request updates the list and the URL
Scenario: Updated by lookup
Given the page has entries by users 3, 3 and 5
Then GET /v1/user/lookup?ids=3,5 is sent once after the entries answer and the cells read the display names
And when it answers 500 or lacks id 5 the cells read "–" and no error shows
Scenario: Navigation actions
Given the populated state and an owner
Then "New entry" and the first-cell links go to /content/events/new and /content/events/42, "Edit content type" goes to /schema/events
And with a role other than owner or admin "Edit content type" is not rendered
And below md "Edit content type" is not rendered and the New entry button is a floating button (hidden while loading and in the error state)
Scenario: Accessibility
Then the table has a visually hidden caption "Events, sorted by last updated, newest first" ("sorted by {id|created|updated}, {ascending|descending}" otherwise; proposed copy, French entry)
And "Columns", the direction button and the select have accessible names; the picker traps focus on mobile and is operable with Space and Esc
And controls are 44 px on mobile and focus is a 2 px --ui-primary outline with a 2 px offset
+52
View File
@@ -0,0 +1,52 @@
# QA: Entry list of a custom content type (SPA slice 6)
Runner: Vitest + Vue Test Utils (`task test:web`). No Go test: both endpoints and /v1/user/lookup already exist and the backend does not change. Kit: reuse `views/__tests__/schemaTestKit.ts` (add GET /v1/content-type/{key}, GET .../entry with page/size/sort, and /v1/user/lookup handlers; Events with the 7 fields and 7/37 entries of SchemaFrame.dc.html). Pin the time zone; never freeze the system date.
## Automated
1. Composable (useContentTypes.spec): `getContentType(key)`; `listEntries(key, {page,size,sort})` builds the query, returns {items,total,page,size,sort}, passes status and plain text through (403, 404, 500), NetworkError passes through. `lookupUsers(ids)` sends the distinct ids once and returns display names.
2. URL parsing (it.each): invalid/missing page, size, sort fall back to 1, 25, updated_at:desc and are replaced in the URL; defaults omitted; size and sort change reset page; the URL changes on success only.
3. State 1 Populated: title, subtitle "events · 7 fields", toolbar, headers, first row values, "7 entries", no pager; singular "1 entry".
4. State 2 Paginated (size=5, total 37): "Showing 1–5 of 37", "5 per page", pager; mobile "Page 1 of 8"; Next sends page=2 and updates the URL on success.
5. Reuse of Articles pager behaviours (it.each): last page, loading page (aria-busy, disabled controls, "Loading page 2…"), error keeps rows with Retry, out-of-range redirect with notice, total 0 on out-of-range shows empty.
6. Cells (it.each per field type): truncation, rich text first line, number alignment, Yes/No icon, date without shift, Choice tag, empty "–"; Created and Updated formats.
7. Updated by: one lookup with distinct ids after the entries; "–" before it resolves, on failure and for an unknown id.
8. State 3 Columns: popover on desktop (Reset only), sheet on mobile (Reset and Done); "4 of 6", sentence, rows with icon, label and key; the Created/updated row disabled; live column change with no request; cap at 6 with message and disabled rows; Esc/outside/Done close and return focus.
9. Column persistence (it.each from the feature table): writes, Reset removes, unknown keys dropped, more than 6 truncated, invalid JSON, per-type keys, throwing/undefined localStorage, load never writes.
10. Sort: the three options and the direction button (it.each), accessible names, URL and request, header arrow, sort kept across paging, invalid URL values replaced.
11. State 4: with 14 types the sidebar shows 5 + selected + "Show all 14"; mobile drawer collapsed row "All content types 14" (sidebar logic itself stays in ContentNav.spec).
12. State 5 Loading: skeleton rows (8 / 6), aria-busy, "New entry" disabled.
13. State 6 Empty: copy, "New entry" navigates to /content/events/new, no pager.
14. State 7 Error (500 and network): copy, "Try again" sends the failed request again; type-load failure variant.
15. State 8 Forbidden (entries 403, type 403): copy, name fallback to the key, "Go to Home" goes to /home.
16. State 9 Not found (type 404, entries 404): copy, breadcrumb "Content", no current sidebar row; deleted-type scenario.
17. Navigation: New entry, first-cell links, mobile card link, "Edit content type" to /schema/events; hidden for a non-owner/admin and on mobile; mobile floating button hidden in loading and error.
18. 401 redirect; stale answer dropped after a sort change; switching type resets page/size/sort and columns.
19. Accessibility: caption text per sort, accessible names, keyboard on the picker and the select.
20. French: every new string has an entry (i18n completeness spec passes).
## Visual (task visual:check -- content)
Register the group "5 Content and entry list" in `extraSources` if not registered. Every capture within 10 %, desktop and mobile, light and dark. Nothing is clock-sensitive. Mock every /v1 call including the sidebar's GET /v1/content-type and /v1/user/lookup.
| Board state | Setup in web/visual/states.ts | Board sample data |
|---|---|---|
| 1 Populated | /content/events, 7 entries (ROWS of SchemaFrame.dc.html), owner session | "Lime plaster workshop", "Marta Alves", "events · 7 fields", "7 entries" |
| 2 Paginated, 5 per page | /content/events?size=5, total 37, 5 items | "Showing 1–5 of 37", "Page 1 of 8" (mobile) |
| 3 Columns menu | /content/events, step: click "Columns" | "4 of 6", Title, Starts on, Ends on, Online checked |
| 4 Many types | /content/events with 14 types (CT list of the board); mobile: open the drawer and expand the group | "Frequently asked questions and answers", "Show all 14" |
| 5 Loading | entries `hang: true` | skeleton rows |
| 6 Empty | entries total 0 | "No entries yet" |
| 7 Error | entries 500 | "Couldn't load entries" |
| 8 Forbidden | entries 403 | "You don't have access to Events" |
| 9 Type not found | /content/unknown, type 404 content_type_not_found | "Content type not found" |
Board caveats to report, never hide: the mobile card shows a Category tag that the default 4 columns do not include; the desktop picker is drawn without Done; the mobile frames hide "Edit content type", so mobile has no path to the builder (design follow-up).
## Manual
- Real backend, owner and a non-owner/admin if one exists: list a type with 37 entries; sort, page, change size; reload keeps the URL state.
- Columns: choose 6, reload, switch type and back; clear localStorage and see the default.
- Click "New entry" and a row: the 404 page is expected until the editor slice; Back returns to the list with its URL.
- Keyboard: tab order toolbar (select, direction, Columns, Edit), table links, pager; the picker and the sheet focus order; hover tooltip on truncated text.
- Delete a type in another tab, then reload /content/<key>: not found page.
+119
View File
@@ -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>
+74
View File
@@ -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>
+30
View File
@@ -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>
+218
View File
@@ -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>
+42
View File
@@ -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>
+56
View File
@@ -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>
+72
View File
@@ -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>
+30
View File
@@ -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>
+174
View File
@@ -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>
+40
View File
@@ -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>
+69
View File
@@ -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) 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 }
}
+42 -27
View File
@@ -1,34 +1,23 @@
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { jsonInit } from '@/lib/directoryRequest' import { jsonInit } from '@/lib/directoryRequest'
import type {
BuiltinType,
ContentType,
EntryPage,
EntryPageQuery,
UserName,
} from '@/lib/contentTypes'
import type { ContentTypePayload, UpdatePayload } from '@/lib/schemaBuilderPayload' import type { ContentTypePayload, UpdatePayload } from '@/lib/schemaBuilderPayload'
export interface BuiltinField { export type {
key: string BuiltinField,
label: string BuiltinType,
type: string ContentEntry,
required: boolean ContentType,
unique: boolean EntryPage,
default: unknown EntryPageQuery,
min: number | null UserName,
max: number | null } from '@/lib/contentTypes'
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 function useContentTypes() { export function useContentTypes() {
const { request } = useApi() const { request } = useApi()
@@ -82,7 +71,33 @@ export function useContentTypes() {
return page.total 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 { return {
getContentType,
listEntries,
lookupUsers,
listContentTypes, listContentTypes,
listBuiltinTypes, listBuiltinTypes,
createContentType, createContentType,
+33
View File
@@ -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 }
}
+126
View File
@@ -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,
}
}
+56
View File
@@ -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)
}
},
)
})
+28
View File
@@ -2503,6 +2503,34 @@ export const messages = {
'View entries': 'Voir les entrées', 'View entries': 'Voir les entrées',
'Content type deleted': 'Type de contenu supprimé', 'Content type deleted': 'Type de contenu supprimé',
'{0} was deleted.': '{0} a été 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 } as const
export type MessageKey = keyof typeof messages 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) => { it.each(['abc', '0', '-2', '2.5', '', '1e2', '99999999999999999999999'])(
'treats page "%s" as 1',
(page) => {
expect(parseArticleQuery({ page }).page).toBe(1) expect(parseArticleQuery({ page }).page).toBe(1)
}) },
)
it.each(['10', 'abc', '0', '26', ''])('treats size "%s" as 25', (size) => { it.each(['10', 'abc', '0', '26', ''])('treats size "%s" as 25', (size) => {
expect(parseArticleQuery({ size }).size).toBe(25) expect(parseArticleQuery({ size }).size).toBe(25)
+118
View File
@@ -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](https://example.test/a.png)', '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)
})
})
+107
View File
@@ -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)
})
})
+39
View File
@@ -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'
}
+60
View File
@@ -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'
+95
View File
@@ -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 }
}
+69
View File
@@ -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))
}
+90
View File
@@ -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'),
])
}
+5 -1
View File
@@ -12,7 +12,11 @@ export function firstQueryValue(value: LocationQuery[string] | undefined) {
} }
export function parsePositiveInteger(value: string) { 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>) { export function sameRouteQuery(left: LocationQuery, right: Record<string, string>) {
+1 -1
View File
@@ -19,7 +19,7 @@ const MONTH_NAMES = [
'Dec', 'Dec',
] ]
function formatAbsoluteDate(date: Date) { export function formatAbsoluteDate(date: Date) {
if (locale.value === 'fr') { if (locale.value === 'fr') {
return new Intl.DateTimeFormat('fr', { return new Intl.DateTimeFormat('fr', {
day: 'numeric', day: 'numeric',
+12
View File
@@ -1,2 +1,14 @@
export const HOME_PATH = '/home' export const HOME_PATH = '/home'
export const SCHEMA_PATH = '/schema' 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)}`
}
+5
View File
@@ -102,6 +102,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/SchemaTypeView.vue'), component: () => import('@/views/SchemaTypeView.vue'),
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true }, meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
}, },
{
path: '/content/:key',
component: () => import('@/views/ContentListView.vue'),
meta: { auth: 'verified', title: 'Content' },
},
{ {
path: '/activity', path: '/activity',
component: () => import('@/views/ActivityView.vue'), component: () => import('@/views/ActivityView.vue'),
+64
View File
@@ -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>
+640
View File
@@ -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')
})
})
+250
View File
@@ -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()
}
+28
View File
@@ -126,6 +126,14 @@ export function entryPage(total: number) {
return { items: [], total, page: 1, size: 1 } 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 { export interface SchemaMount {
role?: string role?: string
create?: (body: PostedRequest['body']) => unknown create?: (body: PostedRequest['body']) => unknown
@@ -137,6 +145,10 @@ export interface SchemaMount {
builtin?: Responder builtin?: Responder
verified?: boolean verified?: boolean
actions?: string[] actions?: string[]
type?: (key: string) => unknown
list?: (key: string, query: URLSearchParams) => unknown
lookup?: (ids: number[]) => unknown
before?: () => void
} }
let wrapper: VueWrapper | undefined let wrapper: VueWrapper | undefined
@@ -156,6 +168,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
puts.length = 0 puts.length = 0
deletes.length = 0 deletes.length = 0
localStorage.clear() localStorage.clear()
options.before?.()
setViewportWidth(options.width ?? DESKTOP_WIDTH) setViewportWidth(options.width ?? DESKTOP_WIDTH)
const pinia = createPinia() const pinia = createPinia()
setActivePinia(pinia) setActivePinia(pinia)
@@ -199,6 +212,21 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
if (counted) { if (counted) {
return (options.entries ?? ((key) => entryPage(entryCounts[key] ?? 0)))(counted[1]!) 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') { if (url === '/v1/builtin-type') {
return (options.builtin ?? (() => builtinTypes))() return (options.builtin ?? (() => builtinTypes))()
} }
+3
View File
@@ -646,6 +646,9 @@ const boardGroupLabels: Record<string, Record<string, string>> = {
'Review and delete': '3 Review and delete', 'Review and delete': '3 Review and delete',
'Field types': '4 Field types', 'Field types': '4 Field types',
}, },
content: {
'Content and entry list': '5 Content and entry list',
},
} }
const referenceGroups: Record<string, string> = { const referenceGroups: Record<string, string> = {
+239
View File
@@ -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>> = { export const visualStates: Record<string, Record<string, VisualState>> = {
sites: sitesStates, sites: sitesStates,
schema: schemaStates, schema: schemaStates,
content: contentStates,
authors: authorsStates, authors: authorsStates,
activity: activityStates, activity: activityStates,
profile: { profile: {