feat: built-in type read-only view,s chema list, states, spec files

This commit is contained in:
Florian Sylvain committed 2026-10-19 22:31:13 +02:00
1 parent 3c67cd2314
commit f514146fed
24 files changed
+1220 -88

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
Feature: Read-only view of a built-in content type
As an owner or admin
I want to open a built-in type from the Schema list and read its fields
So that I can see the rules RenewCMS applies without being able to change them
Background:
Given the SPA calls the API with relative URLs under /v1 and the "jwt" HttpOnly cookie
And GET /v1/builtin-type returns [{key, name, fields}] for owners and admins only
And each field is {key, label, type, required, unique, default, min, max, options}
And API errors are plain text
And the route /schema/:key has meta auth "verified" and title "Schema"
And the contentTypes store already exposes builtinItems, builtinStatus and load({builtin: true})
And the view takes its data from builtinItems, matched on the route key, and never calls GET /v1/builtin-type/{key}
And only owners and admins can use Schema; the SPA decides from the session role, the API still enforces it
And a session whose role is neither owner nor admin is a test double for the forbidden states
And the built-in types are articles ("Articles"), authors, sites, media and users with 5, 5, 8, 6 and 6 fields in the sample data
Scenario: A built-in row of the Schema list is a link
Given the Schema list is Populated
When the user looks at the five built-in rows
Then each row is a single link to /schema/<its key> whose accessible name is the type name
And the row keeps its lock icon, name, "Built-in" badge, key, field count and a chevron-right at the end
And on desktop its "Last updated" cell reads "Managed by RenewCMS"
And below 640 px the card of a built-in type shows "<key> · <n> fields" with no "Updated" text and a chevron-right
And custom rows are not links and show no chevron
Scenario: Opening a built-in type from the list
Given the Schema list is Populated
When the user activates the "Articles" row
Then the router navigates to /schema/articles
And GET /v1/builtin-type is not called a second time
Scenario: Read-only view of a built-in type (design state 6, desktop)
Given an owner or admin is signed in
And GET /v1/builtin-type returns the type "articles" named "Articles" with the fields
| label | key | type | required | unique | min | max | default |
| Title | title | text | true | false | | 200 | |
| Slug | slug | text | true | true | | | |
| Body | body | richtext | false | false | | 10000 | |
| Online | online | boolean | false | false | | | false |
| Published on | published_on | date | false | false | | | |
When they open /schema/articles
Then the breadcrumb is "Schema" (a link to /schema) then "Articles"
And the h1 "Articles" is shown with a lock icon and a "Built-in" badge
And an info alert titled "Managed by RenewCMS" says "Articles is a built-in type. You can read its fields here, but you can't edit it, delete it or add fields."
And a table has the columns "Label", "Key", "Type", "Rules"
And the rows are, in this order
| Label | Key | Type | Rules |
| Title | title | Text | Required · up to 200 characters |
| Slug | slug | Text | Required · unique |
| Body | body | Rich text | Up to 10,000 characters |
| Online | online | Yes or no | Off by default |
| Published on | published_on | Date | – |
And each row starts with the icon of its type
And the note "Entries of built-in types are managed from their own screens, like Articles and Authors." is shown under the table
And there is no "New content type", edit, delete, add field, save or discard action
And the keys are shown in the mono style
And GET /v1/content-type and GET /v1/builtin-type are each called once
Scenario: Read-only view of a built-in type on mobile
Given the same data and a 390 px wide viewport
When they open /schema/articles
Then the topbar shows an arrow-left and the title "Articles"
And the fields are cards in order, each with the icon box of its type, the label, and the key in mono followed by " · " and the rules
And the Type column is not shown
And the "Built-in" badge, the alert and the note are shown
Scenario Outline: Field types show their label and icon
Given a built-in field of type "<type>"
Then the Type cell reads "<label>" with the icon <icon>
Examples:
| type | label | icon |
| text | Text | type |
| richtext | Rich text | pilcrow |
| number | Number | hash |
| boolean | Yes or no | toggle-left |
| date | Date | calendar |
| select | Choice | list |
Scenario Outline: The Rules cell is derived from the field
Given a field of type "<type>" with <settings>
Then the Rules cell reads "<rules>"
Examples:
| type | settings | rules |
| text | required, max 200 | Required · up to 200 characters |
| text | required, unique | Required · unique |
| richtext | max 10000 | Up to 10,000 characters |
| boolean | default false | Off by default |
| boolean | default true | On by default |
| date | nothing | – |
| text | min 3, max 80 (proposed copy) | 3 to 80 characters |
| text | min 3 (proposed copy) | At least 3 characters |
| number | min 1, max 5000 (proposed copy) | Between 1 and 5000 |
| number | min 1 (proposed copy) | At least 1 |
| number | max 5000 (proposed copy) | At most 5000 |
| select | 3 options (proposed copy) | 3 choices |
| text | default "Draft" (proposed copy) | Default: Draft |
Scenario: Back to the list
Given the read-only view is shown
When the user activates the "Schema" breadcrumb, or the arrow-left on mobile
Then the router navigates to /schema
Scenario: Deep link and reload load what the view needs
Given the user opens /schema/articles as the first page of the session
Then the session is resolved, then GET /v1/builtin-type is called once
And the view shows the read-only view when it answers 200
Scenario: Loading
Given GET /v1/builtin-type has not answered
When an owner opens /schema/articles
Then nothing is shown in the content area during the first 150 ms
And after 150 ms skeleton rows are shown
And the not-found page and the 403 page are not shown
Scenario Outline: Unknown key
Given GET /v1/builtin-type returns the five built-in types
When an owner opens <path>
Then the page "Content type not found" is shown with "It may have been deleted, or the address may be mistyped."
And the URL stays <path> and nothing redirects
And no field table is shown
Examples:
| path | why |
| /schema/unknown | a mistyped key |
| /schema/events | a custom type; its builder is a later slice |
| /schema/new | the builder is a later slice |
Scenario Outline: Error
Given GET /v1/builtin-type <failure>
When an owner opens /schema/articles
Then an alert shows "Couldn't load content types"
And it shows "GET /v1/builtin-type failed. Check your connection and try again."
And a "Try again" action is shown
And no table is shown
Examples:
| failure |
| answers 500 |
| fails (network) |
Scenario: Retry recovers
Given the view is in the Error state
When the user activates "Try again"
Then GET /v1/builtin-type is called again
And when it answers 200 the read-only view replaces the alert
Scenario: Forbidden for a user who is not owner or admin
Given a verified user whose role is neither owner nor admin is signed in
When they open /schema/articles
Then the URL stays /schema/articles and nothing redirects
And the page shows "You don't have access to Schema"
And it shows "Only owners and admins can change content types. You can still add and edit entries from the Content menu."
And a "Go to Home" action navigates to /home
And GET /v1/builtin-type is never called
Scenario: The API answers 403
Given the session says owner or admin but GET /v1/builtin-type answers 403
When they open /schema/articles
Then the same "You don't have access to Schema" page is shown
Scenario: An unverified or signed-out user keeps the existing redirects
Given the user is not verified or not signed in
When they open /schema/articles
Then they are redirected to /register/pending or /login as for any other "verified" route
Scenario: The list and the view share one load
Given the user opens /schema, then /schema/articles, then goes back
Then GET /v1/builtin-type and GET /v1/content-type are each called once in the session
+42
View File
@@ -0,0 +1,42 @@
# QA: Read-only view of a built-in type (SPA slice 2)
Runner: Vitest + Vue Test Utils (`task test:web`). No Go test: there is no backend change.
Same pattern as features/schema_list_and_content_nav.qa.md: mock `/v1` through the existing api composable, mount with a test router (routes /schema, /schema/:key) and a resolved session whose role varies. The forbidden cases use a session with an unknown role.
## Automated
Fixtures: the five built-in types with the board's sample data; "articles" has the 5 fields of the board (Title, Slug, Body, Online, Published on), the others only need a `fields` array of the right length (5, 5, 8, 6, 6). Three custom types as in slice 1.
SchemaTable / list (extend SchemaView.spec and the SchemaTable spec):
1. Built-in rows are single links (`a` with href /schema/<key>), accessible name = type name, chevron present, "Managed by RenewCMS" in the desktop Last updated cell, no "Updated" text on the mobile card (use `components/__tests__/viewport.ts`).
2. Custom rows are not links and have no chevron. Replaces the slice-1 test "rows are not links".
3. Activating the "Articles" row navigates to /schema/articles; no second GET /v1/builtin-type.
Read-only view (new SchemaBuiltinView.spec), one test per state:
4. Read-only: owner and admin. Assert the breadcrumb, h1 with lock and "Built-in", alert title and text, columns, the five rows (label, key, type label, rules) in order, the note, and the absence of any new/edit/delete/add/save/discard button. Both /v1 calls made once.
5. Mobile: arrow-left + title, cards with key and rules, no Type column.
6. Type labels and icons (it.each over the 6 types).
7. Rules derivation (it.each over the Examples table of the feature, incl. 10,000 separator, the en dash for empty, and capitalisation of the first part). Cases tagged "proposed copy" are separate it.each rows so a design change touches them only.
8. Back: the breadcrumb and the mobile arrow push /schema.
9. Deep link: a store in its initial state loads the builtin list once on mount.
10. Loading: unresolved promise, fake timers. Nothing at 149 ms, skeleton at 150 ms, no not-found or 403 page. A fast response never shows the skeleton.
11. Unknown key: it.each over /schema/unknown, /schema/events, /schema/new. Assert the "Content type not found" page copy, URL unchanged, no table.
12. Error: 500 and network failure. Assert the alert copy ("GET /v1/builtin-type failed. ..."), "Try again", no table. Retry then returns 200 and the view replaces the alert (second call made).
13. Forbidden: unknown role gives the 403 page copy, "Go to Home" navigates to /home, URL stays /schema/articles, builtin-type never called. Plus a case where builtin-type answers 403 for an owner.
14. Shared load: navigating /schema -> /schema/articles -> back calls each list endpoint once.
Router and guard:
15. `/schema/:key` is registered with auth "verified" and title "Schema". Unverified and signed-out users get the existing redirects (extend routerGuard.spec). /schema (exact) is unchanged.
## Visual (task visual:check -- schema)
Run `task visual:check -- schema`; every capture within 10 % at desktop and mobile, light and dark. Nothing is clock-sensitive.
- Replace `skip: 'pending'` of state 6 in `web/visual/states.ts` with a real setup: open /schema/articles. Mock GET /v1/session, GET /v1/content-type (the 3 custom types), GET /v1/builtin-type with the five types. "articles" must carry the board's 5 fields: Title (text, required, max 200), Slug (text, required, unique), Body (richtext, max 10000), Online (boolean, default false), Published on (date, none). Field counts 5, 5, 8, 6, 6. Keys in the mock follow the board's sample text ("articles" etc.), since the view resolves the key from data. Use the sidebar mock of states 1-5 and mock every other /v1 call the screen makes.
- States 1 to 5 must still pass after the list-row change (chevron on built-in rows, "Managed by RenewCMS" in Last updated). If the board and the slice-1 rendering differ on custom rows' chevron, report it, do not fix it silently.
- Loading, Error, Unknown key and Forbidden-on-detail have no board frame: Vitest only, not in the visual check.
## Manual
- Keyboard: Tab order list rows (each row one stop), Enter on a focused built-in row opens it; visible focus ring; the breadcrumb and back arrow are reachable.
- Hover on a built-in row, and focus/hover motion.
- Real backend: log in as owner and as admin, open each of the five built-in rows, compare the rendered Rules with the actual fields returned by GET /v1/builtin-type (the board's rules are illustrative), and confirm a plain user gets the 403 page on /schema/article.
+4 -3
View File
@@ -105,7 +105,7 @@ Feature: Schema list and content navigation in the back-office
And a table has the columns "Name", "Key", "Fields", "Last updated"
And each custom row shows name, key, the number of fields and the updated date as an absolute date, using the board's sample data
And after the custom rows come the five built-in rows, each with a lock icon and a "Built-in" badge
And a built-in row shows its field count from the length of fields and an em dash as Last updated
And a built-in row shows its field count from the length of fields and "Managed by RenewCMS" as Last updated on desktop (no Updated text on mobile)
And the text "Built-in types are managed by RenewCMS. You can read their fields, but not change them." is shown
And the summary "3 content types · 5 built-in" is shown
And below 640 px the table is a list of cards with the same content
@@ -173,9 +173,10 @@ Feature: Schema list and content navigation in the back-office
When an owner activates "New content type"
Then the router navigates to /schema/new (the builder is a later slice)
Scenario: Rows are not links in this slice
Scenario: Custom rows are not links in this slice
Given the list is Populated
Then custom and built-in rows do not navigate (the editor and the read-only view are later slices)
Then custom rows do not navigate (the editor is a later slice)
And built-in rows are links, see schema_builtin_view.feature
Scenario Outline: The typed client maps responses
When the client calls <call>
+1 -1
View File
@@ -35,7 +35,7 @@ Client and store:
Run `task visual:check -- schema`, every capture within 10 % at desktop and mobile, light and dark. States to set up in `web/visual/states.ts`, reusing the board's sample data (names, keys, field counts, dates, counts "3 content types · 5 built-in" taken from SchemaFrame.dc.html):
- 1 Populated, 2 Loading, 3 Empty, 4 Error, 5 Forbidden (403).
- State 6 (built-in read-only view) is out of scope: report it as not set up in this slice, and flag it to the user if the check demands a setup for every state.
- State 6 (built-in read-only view) is covered by schema_builtin_view.qa.md.
- None of these is clock-sensitive; if the Last updated cell turns out to be relative on the board, mark it `skip: 'clock-sensitive'`.
- Mock every `/v1` call the screen makes, including the sidebar's GET /v1/content-type.
- The sidebar group and drawer frames belong to the later `content` group.
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import type { BuiltinField } from '@/composables/useContentTypes'
import { fieldIcon, fieldRules, fieldTypeLabel } from '@/lib/schemaFields'
import { SCHEMA_TABLE_QUERY } from '@/lib/schemaRows'
const NO_RULES = '–'
const COLUMNS = '28px minmax(0,1fr) minmax(0,1fr) 120px minmax(0,1.2fr)'
const props = defineProps<{ fields: BuiltinField[] }>()
const isTable = useMediaQuery(SCHEMA_TABLE_QUERY)
const rows = computed(() => props.fields.map((field) => ({ field, rules: fieldRules(field) })))
</script>
<template>
<div
:role="isTable ? 'table' : 'list'"
:aria-label="t('Fields')"
class="border border-default bg-default"
>
<div
v-if="isTable"
role="row"
class="grid h-9 items-center gap-3 bg-elevated px-3 font-medium text-muted"
:style="{ gridTemplateColumns: COLUMNS }"
>
<span role="presentation" />
<span role="columnheader">{{ t('Label') }}</span>
<span role="columnheader">{{ t('Key') }}</span>
<span role="columnheader">{{ t('Type') }}</span>
<span role="columnheader">{{ t('Rules') }}</span>
</div>
<template v-for="{ field, rules } in rows" :key="field.key">
<div
v-if="isTable"
role="row"
data-testid="schema-field-row"
class="grid h-12 items-center gap-3 border-t border-default px-3"
:style="{ gridTemplateColumns: COLUMNS }"
>
<span role="cell" class="flex">
<UIcon :name="fieldIcon(field.type)" class="size-4 text-toned" />
</span>
<span role="cell" class="truncate font-medium">{{ field.label }}</span>
<span role="cell" class="truncate font-mono text-xs text-muted">{{
field.key
}}</span>
<span role="cell">{{ fieldTypeLabel(field.type) }}</span>
<span role="cell" class="text-muted">{{ rules || NO_RULES }}</span>
</div>
<div
v-else
role="listitem"
data-testid="schema-field-card"
class="flex min-h-16 items-center gap-3 border-t border-default px-4 py-3"
>
<span class="flex size-7 shrink-0 items-center justify-center bg-elevated">
<UIcon :name="fieldIcon(field.type)" class="size-4" />
</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="font-medium">{{ field.label }}</span>
<span class="text-xs/5 text-muted"
><span class="font-mono">{{ field.key }}</span>
<template v-if="rules"> · {{ rules }}</template></span
>
</div>
</div>
</template>
</div>
</template>
+33 -10
View File
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { RouterLink } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import { fieldCountLabel, type SchemaRow } from '@/lib/schemaRows'
import { SCHEMA_TABLE_QUERY, fieldCountLabel, type SchemaRow } from '@/lib/schemaRows'
const TABLE_QUERY = '(min-width: 40rem)'
const COLUMNS = '24px minmax(0,1.4fr) minmax(0,1fr) 90px 150px 24px'
const SKELETON_ROWS = 6
@@ -15,7 +15,7 @@ defineProps<{
loading: boolean
}>()
const isTable = useMediaQuery(TABLE_QUERY)
const isTable = useMediaQuery(SCHEMA_TABLE_QUERY)
</script>
<template>
@@ -75,11 +75,19 @@ const isTable = useMediaQuery(TABLE_QUERY)
</span>
</div>
<template v-for="row in group" :key="row.id">
<div
<component
:is="row.to ? RouterLink : 'div'"
v-if="isTable"
role="row"
:to="row.to"
:aria-label="row.to ? row.name : undefined"
:role="row.to ? undefined : 'row'"
data-testid="schema-row"
class="grid h-12 items-center gap-3 border-t border-default px-3"
:class="
row.to
? 'hover:bg-elevated focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary'
: ''
"
:style="{ gridTemplateColumns: COLUMNS }"
>
<span role="cell" class="flex">
@@ -99,12 +107,21 @@ const isTable = useMediaQuery(TABLE_QUERY)
}}</span>
<span role="cell">{{ row.fieldCount }}</span>
<span role="cell" class="text-muted">{{ row.updated }}</span>
</div>
<div
<UIcon
v-if="row.to"
name="i-lucide-chevron-right"
class="size-4 justify-self-end text-muted"
/>
</component>
<component
:is="row.to ? RouterLink : 'div'"
v-else
role="listitem"
:to="row.to"
:aria-label="row.to ? row.name : undefined"
:role="row.to ? undefined : 'listitem'"
data-testid="schema-card"
class="flex min-h-17 items-center gap-3 border-t border-default px-4 py-3"
class="flex min-h-17 items-center gap-3 border-t border-default py-3 pl-4"
:class="row.to ? 'pr-2' : 'pr-4'"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
@@ -121,7 +138,13 @@ const isTable = useMediaQuery(TABLE_QUERY)
></span
>
</div>
</div>
<span
v-if="row.to"
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>
</component>
</template>
</template>
</div>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { t } from '@/i18n'
</script>
<template>
<div class="flex min-h-0 flex-1 items-center justify-center px-4 py-6">
<UEmpty
variant="naked"
icon="i-lucide-shapes"
:title="t('Content type not found')"
:description="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',
}"
/>
</div>
</template>
@@ -55,6 +55,21 @@ describe('listContentTypes', () => {
})
})
it.each([[{ error: 'nope' }], ['"text"'], [null]])(
'rejects a 200 answer that is not an array (%j)',
async (body) => {
answer(200, typeof body === 'string' ? body : JSON.stringify(body))
await expect(useContentTypes().listContentTypes()).rejects.toThrow(
'Unexpected response',
)
answer(200, typeof body === 'string' ? body : JSON.stringify(body))
await expect(useContentTypes().listBuiltinTypes()).rejects.toThrow(
'Unexpected response',
)
},
)
it('rejects with a network error', async () => {
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
+24 -4
View File
@@ -1,11 +1,23 @@
import { useApi } from '@/composables/useApi'
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: unknown[]
fields: BuiltinField[]
created_at: string
updated_at: string
}
@@ -13,19 +25,27 @@ export interface ContentType {
export interface BuiltinType {
key: string
name: string
fields: unknown[]
fields: BuiltinField[]
}
export function useContentTypes() {
const { request } = useApi()
const options = { onForbidden: 'passthrough' } as const
async function requestList<T>(url: string): Promise<T[]> {
const listed = await request<T[]>(url, options)
if (!Array.isArray(listed)) {
throw new Error(`Unexpected response from ${url}`)
}
return listed
}
function listContentTypes(): Promise<ContentType[]> {
return request<ContentType[]>('/v1/content-type', options)
return requestList<ContentType>('/v1/content-type')
}
function listBuiltinTypes(): Promise<BuiltinType[]> {
return request<BuiltinType[]>('/v1/builtin-type', options)
return requestList<BuiltinType>('/v1/builtin-type')
}
return { listContentTypes, listBuiltinTypes }
+61
View File
@@ -0,0 +1,61 @@
import { computed, watch } from 'vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
export type SchemaGateScope = 'all' | 'builtin'
export function useSchemaGate(scope: SchemaGateScope) {
const session = useSessionStore()
const store = useContentTypesStore()
const skeleton = useDelayedSkeleton()
const withCustom = scope === 'all'
const sessionReady = computed(() => session.status === 'ready')
const forbidden = computed(
() => (sessionReady.value && !session.canManageContent) || store.forbidden,
)
const loading = computed(
() => !forbidden.value && ((withCustom && store.customLoading) || store.builtinLoading),
)
const showSkeleton = computed(() => loading.value && skeleton.visible.value)
const customFailed = computed(() => withCustom && store.error)
const builtinFailed = computed(() => store.builtinStatus === 'failed')
const customItems = computed(() => store.items)
const builtinItems = computed(() => store.builtinItems)
function retry() {
const requests: Promise<void>[] = []
if (customFailed.value) {
requests.push(store.retryCustom())
}
if (builtinFailed.value) {
requests.push(store.retryBuiltin())
}
return Promise.all(requests).then(() => undefined)
}
watch(
loading,
(value) => {
if (value) {
skeleton.start()
} else {
skeleton.stop()
}
},
{ immediate: true },
)
return {
sessionReady,
forbidden,
loading,
showSkeleton,
customFailed,
builtinFailed,
customItems,
builtinItems,
retry,
}
}
+31 -1
View File
@@ -458,7 +458,6 @@ export const messages = {
'Used in': 'Utilisée dans',
Unused: 'Inutilisées',
unused: 'inutilisée',
Type: 'Type',
Vector: 'Vectoriel',
Selection: 'Sélection',
'Select all': 'Tout sélectionner',
@@ -2268,6 +2267,37 @@ export const messages = {
'Seuls les propriétaires et les administrateurs peuvent modifier les types de contenu. Vous pouvez toujours ajouter et modifier des entrées depuis le menu Contenu.',
'All content types': 'Tous les types de contenu',
'Show all {0}': 'Tout afficher ({0})',
'Managed by RenewCMS': 'Géré par RenewCMS',
"{0} is a built-in type. You can read its fields here, but you can't edit it, delete it or add fields.":
'{0} est un type intégré. Vous pouvez lire ses champs ici, mais vous ne pouvez ni le modifier, ni le supprimer, ni ajouter de champs.',
'Entries of built-in types are managed from their own screens, like Articles and Authors.':
'Les entrées des types intégrés se gèrent depuis leurs propres écrans, comme Articles et Auteurs.',
Label: 'Libellé',
Rules: 'Règles',
Type: 'Type',
Text: 'Texte',
'Rich text': 'Texte enrichi',
Number: 'Nombre',
'Yes or no': 'Oui ou non',
Date: 'Date',
Choice: 'Choix',
required: 'obligatoire',
unique: 'unique',
'{0} to {1} characters': '{0} à {1} caractères',
'up to {0} characters': 'jusqu’à {0} caractères',
'at least {0} characters': 'au moins {0} caractères',
'between {0} and {1}': 'entre {0} et {1}',
'at most {0}': 'au plus {0}',
'at least {0}': 'au moins {0}',
'{0} choices': '{0} choix',
'on by default': 'activé par défaut',
'off by default': 'désactivé par défaut',
'default: {0}': 'par défaut : {0}',
'Content type not found': 'Type de contenu introuvable',
'It may have been deleted, or the address may be mistyped.':
'Il a peut-être été supprimé, ou l’adresse est mal saisie.',
'GET /v1/builtin-type failed. Check your connection and try again.':
'GET /v1/builtin-type a échoué. Vérifiez votre connexion et réessayez.',
} as const
export type MessageKey = keyof typeof messages
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { BuiltinField } from '@/composables/useContentTypes'
import { fieldIcon, fieldRules, fieldTypeLabel } from '@/lib/schemaFields'
function field(overrides: Partial<BuiltinField>): BuiltinField {
return {
key: 'f',
label: 'F',
type: 'text',
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
...overrides,
}
}
describe('field types', () => {
it.each([
['text', 'Text', 'i-lucide-type'],
['richtext', 'Rich text', 'i-lucide-pilcrow'],
['number', 'Number', 'i-lucide-hash'],
['boolean', 'Yes or no', 'i-lucide-toggle-left'],
['date', 'Date', 'i-lucide-calendar'],
['select', 'Choice', 'i-lucide-list'],
])('%s reads %s with %s', (type, label, icon) => {
expect(fieldTypeLabel(type)).toBe(label)
expect(fieldIcon(type)).toBe(icon)
})
})
describe('field rules', () => {
it.each([
[{ type: 'text', required: true, max: 200 }, 'Required · up to 200 characters'],
[{ type: 'text', required: true, unique: true }, 'Required · unique'],
[{ type: 'richtext', max: 10000 }, 'Up to 10,000 characters'],
[{ type: 'boolean', default: false }, 'Off by default'],
[{ type: 'boolean', default: true }, 'On by default'],
[{ type: 'date' }, ''],
[{ type: 'text', min: 3, max: 80 }, '3 to 80 characters'],
[{ type: 'text', min: 3 }, 'At least 3 characters'],
[{ type: 'number', min: 1, max: 5000 }, 'Between 1 and 5000'],
[{ type: 'number', min: 1 }, 'At least 1'],
[{ type: 'number', max: 5000 }, 'At most 5000'],
[{ type: 'select', options: ['a', 'b', 'c'] }, '3 choices'],
[{ type: 'text', default: 'Draft' }, 'Default: Draft'],
])('%j reads %s', (settings, rules) => {
expect(fieldRules(field(settings as Partial<BuiltinField>))).toBe(rules)
})
it('reads a missing setting as absent', () => {
expect(fieldRules({ key: 'k', label: 'K', type: 'date' } as BuiltinField)).toBe('')
})
})
+104
View File
@@ -0,0 +1,104 @@
import { t, formatNumber } from '@/i18n'
import type { BuiltinField } from '@/composables/useContentTypes'
const RULE_SEPARATOR = ' · '
const FIELD_ICONS: Record<string, string> = {
text: 'i-lucide-type',
richtext: 'i-lucide-pilcrow',
number: 'i-lucide-hash',
boolean: 'i-lucide-toggle-left',
date: 'i-lucide-calendar',
select: 'i-lucide-list',
}
export function fieldIcon(type: string) {
return FIELD_ICONS[type] ?? FIELD_ICONS.text!
}
export function fieldTypeLabel(type: string) {
switch (type) {
case 'richtext':
return t('Rich text')
case 'number':
return t('Number')
case 'boolean':
return t('Yes or no')
case 'date':
return t('Date')
case 'select':
return t('Choice')
default:
return t('Text')
}
}
function present<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
function lengthRule(min: number | null | undefined, max: number | null | undefined) {
if (present(min) && present(max)) {
return t('{0} to {1} characters', [formatNumber(min), formatNumber(max)])
}
if (present(max)) {
return t('up to {0} characters', [formatNumber(max)])
}
if (present(min)) {
return t('at least {0} characters', [formatNumber(min)])
}
return ''
}
function rangeRule(min: number | null | undefined, max: number | null | undefined) {
if (present(min) && present(max)) {
return t('between {0} and {1}', [min, max])
}
if (present(max)) {
return t('at most {0}', [max])
}
if (present(min)) {
return t('at least {0}', [min])
}
return ''
}
function defaultRule(field: BuiltinField) {
if (field.type === 'boolean') {
return field.default === true
? t('on by default')
: field.default === false
? t('off by default')
: ''
}
return present(field.default) && field.default !== ''
? t('default: {0}', [String(field.default)])
: ''
}
function constraintRule(field: BuiltinField) {
if (field.type === 'text' || field.type === 'richtext') {
return lengthRule(field.min, field.max)
}
if (field.type === 'number') {
return rangeRule(field.min, field.max)
}
if (field.type === 'select' && Array.isArray(field.options) && field.options.length > 0) {
return t('{0} choices', [field.options.length])
}
return ''
}
export function fieldRules(field: BuiltinField) {
const parts = [
field.required ? t('required') : '',
constraintRule(field),
field.unique ? t('unique') : '',
defaultRule(field),
].filter(Boolean)
if (parts.length === 0) {
return ''
}
const joined = parts.join(RULE_SEPARATOR)
return joined.charAt(0).toUpperCase() + joined.slice(1)
}
+4 -2
View File
@@ -2,7 +2,7 @@ import { t, formatNumber } from '@/i18n'
import type { BuiltinType, ContentType } from '@/composables/useContentTypes'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export const EM_DASH = '—'
export const SCHEMA_TABLE_QUERY = '(min-width: 40rem)'
export interface SchemaRow {
id: string
@@ -11,6 +11,7 @@ export interface SchemaRow {
fieldCount: number
updated: string
builtin: boolean
to?: string
}
export function customRow(type: ContentType): SchemaRow {
@@ -30,8 +31,9 @@ export function builtinRow(type: BuiltinType): SchemaRow {
name: type.name,
key: type.key,
fieldCount: type.fields.length,
updated: EM_DASH,
updated: t('Managed by RenewCMS'),
builtin: true,
to: `/schema/${type.key}`,
}
}
@@ -155,6 +155,40 @@ describe('Nuxt UI shell foundation and router guards', () => {
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/schema&reason=expired')
})
it('The schema type route is registered as verified-only with the Schema title', () => {
const resolved = router.resolve('/schema/articles')
expect(resolved.matched).toHaveLength(1)
expect(resolved.params.key).toBe('articles')
expect(resolved.meta.auth).toBe('verified')
expect(resolved.meta.title).toBe('Schema')
})
it('The schema type route redirects an unverified session to the pending page', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/schema/articles')
mountApp()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('The schema type route redirects a signed-out visitor to login with the original path', async () => {
const getMe = vi.fn().mockRejectedValue(new ApiError(401))
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
await router.push('/schema/articles')
mountApp()
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe(
'/login?redirect=/schema/articles&reason=expired',
)
})
it('A session-only route admits an unverified session', async () => {
const getMe = vi.fn().mockResolvedValue({ verified: false })
vi.mocked(useApi).mockReturnValue({ getMe })
+5
View File
@@ -92,6 +92,11 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/SchemaView.vue'),
meta: { auth: 'verified', title: 'Schema' },
},
{
path: '/schema/:key',
component: () => import('@/views/SchemaTypeView.vue'),
meta: { auth: 'verified', title: 'Schema' },
},
{
path: '/activity',
component: () => import('@/views/ActivityView.vue'),
@@ -42,11 +42,11 @@ describe('content types store', () => {
const store = useContentTypesStore()
const loaded = store.load()
expect(store.loading).toBe(true)
expect(store.customLoading).toBe(true)
resolve([events])
await loaded
expect(store.loading).toBe(false)
expect(store.customLoading).toBe(false)
expect(store.items).toEqual([events])
expect(store.error).toBe(false)
})
@@ -69,7 +69,7 @@ describe('content types store', () => {
expect(store.error).toBe(true)
expect(store.items).toEqual([])
await store.retry()
await store.retryCustom()
expect(listContentTypes).toHaveBeenCalledTimes(2)
expect(store.error).toBe(false)
expect(store.items).toEqual([events])
@@ -129,7 +129,7 @@ describe('content types store', () => {
const store = useContentTypesStore()
const stale = store.load()
await store.retry()
await store.retryCustom()
first.resolve([{ ...events, key: 'stale' }])
await stale
@@ -173,7 +173,7 @@ describe('content types store', () => {
expect(store.builtinStatus).toBe('failed')
})
it('retry reloads both and reset clears both', async () => {
it('retryBuiltin reloads only the built-in types and reset clears both', async () => {
listContentTypes.mockResolvedValue([events])
listBuiltinTypes
.mockRejectedValueOnce(new ApiError(500))
@@ -182,8 +182,11 @@ describe('content types store', () => {
await store.load({ builtin: true })
expect(store.builtinStatus).toBe('failed')
await store.retry()
await store.retryBuiltin()
expect(store.builtinStatus).toBe('ready')
expect(listContentTypes).toHaveBeenCalledTimes(1)
expect(store.status).toBe('ready')
expect(store.items).toEqual([events])
store.reset()
expect(store.items).toEqual([])
+16 -7
View File
@@ -21,7 +21,7 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
if (token !== generation) {
return
}
items.value = Array.isArray(listed) ? (listed as never) : []
items.value = listed
status.value = 'ready'
} catch (error) {
if (token !== generation) {
@@ -64,7 +64,9 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
status.value = 'idle'
}
return { items, status, load, retry, reset }
const loading = computed(() => status.value === 'idle' || status.value === 'loading')
return { items, status, loading, load, retry, reset }
}
export const useContentTypesStore = defineStore('contentTypes', () => {
@@ -76,7 +78,8 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
const builtinItems = builtin.items
const builtinStatus = builtin.status
const loading = computed(() => status.value === 'loading')
const customLoading = custom.loading
const builtinLoading = builtin.loading
const error = computed(() => status.value === 'failed')
const forbidden = computed(
() => status.value === 'forbidden' || builtinStatus.value === 'forbidden',
@@ -90,8 +93,12 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
return Promise.all(requests).then(() => undefined)
}
function retry() {
return Promise.all([custom.retry(), builtin.retry()]).then(() => undefined)
function retryCustom() {
return custom.retry()
}
function retryBuiltin() {
return builtin.retry()
}
function reset() {
@@ -104,11 +111,13 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
status,
builtinItems,
builtinStatus,
loading,
customLoading,
builtinLoading,
error,
forbidden,
load,
retry,
retryCustom,
retryBuiltin,
reset,
}
})
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DashboardShell from '@/components/DashboardShell.vue'
import DirectoryBadge from '@/components/DirectoryBadge.vue'
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import SchemaFieldTable from '@/components/SchemaFieldTable.vue'
import SchemaForbidden from '@/components/SchemaForbidden.vue'
import SchemaTable from '@/components/SchemaTable.vue'
import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
import { useSchemaGate } from '@/composables/useSchemaGate'
const LIST_PATH = '/schema'
const route = useRoute()
const router = useRouter()
const { sessionReady, forbidden, loading, showSkeleton, builtinFailed, builtinItems, retry } =
useSchemaGate('builtin')
const failed = computed(() => !forbidden.value && builtinFailed.value)
const type = computed(() => builtinItems.value.find((item) => item.key === route.params.key))
const mobileTitle = computed(() => type.value?.name ?? t('Schema'))
const breadcrumb = computed(() => [
{ label: t('Schema'), to: LIST_PATH },
...(type.value ? [{ label: type.value.name }] : []),
])
function goHome() {
router.push('/home')
}
</script>
<template>
<DashboardShell
compact-padding
:full-bleed="forbidden || (sessionReady && !loading && !failed && !type)"
:mobile-title="mobileTitle"
:back-to="LIST_PATH"
:back-label="t('Schema')"
>
<template #breadcrumb>
<UBreadcrumb
:items="breadcrumb"
color="neutral"
class="hidden lg:block"
:ui="{ link: 'text-sm font-medium' }"
/>
</template>
<SchemaForbidden v-if="forbidden" @back="goHome" />
<template v-else-if="sessionReady">
<SchemaTable v-if="showSkeleton" :custom="[]" :builtin="[]" loading />
<DirectoryLoadError
v-else-if="failed"
:title="t('Couldn\'t load content types')"
:description="
t('GET /v1/builtin-type failed. Check your connection and try again.')
"
@retry="retry"
/>
<div v-else-if="type" class="flex flex-col gap-4">
<div class="flex items-center gap-3">
<UIcon name="i-lucide-lock" class="size-4.5 shrink-0 text-muted" />
<h1 class="text-xl font-semibold text-highlighted">{{ type.name }}</h1>
<DirectoryBadge kind="builtin" />
</div>
<UAlert
color="info"
variant="soft"
icon="i-lucide-info"
:title="t('Managed by RenewCMS')"
:description="
t(
'{0} is a built-in type. You can read its fields here, but you can\'t edit it, delete it or add fields.',
[type.name],
)
"
:ui="{
root: 'items-start gap-2.5 px-3.5 py-3',
title: 'font-semibold',
description: 'text-toned',
}"
/>
<SchemaFieldTable :fields="type.fields" />
<p class="text-xs/5 text-muted">
{{
t(
'Entries of built-in types are managed from their own screens, like Articles and Authors.',
)
}}
</p>
</div>
<SchemaTypeNotFound v-else-if="!loading" />
</template>
</DashboardShell>
</template>
+19 -44
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, watch } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useMediaQuery } from '@vueuse/core'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -10,60 +10,35 @@ import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
import SchemaEmpty from '@/components/SchemaEmpty.vue'
import SchemaForbidden from '@/components/SchemaForbidden.vue'
import SchemaTable from '@/components/SchemaTable.vue'
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
import { useSchemaGate } from '@/composables/useSchemaGate'
import { builtinRow, customRow, schemaSummary } from '@/lib/schemaRows'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
const DESKTOP_QUERY = '(min-width: 48rem)'
const NEW_TYPE_PATH = '/schema/new'
const router = useRouter()
const session = useSessionStore()
const store = useContentTypesStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const skeleton = useDelayedSkeleton()
const sessionReady = computed(() => session.status === 'ready')
const forbidden = computed(
() => (sessionReady.value && !session.canManageContent) || store.forbidden,
)
const loading = computed(
() =>
!forbidden.value &&
(store.status === 'idle' ||
store.status === 'loading' ||
store.builtinStatus === 'idle' ||
store.builtinStatus === 'loading'),
)
const failed = computed(
() => !forbidden.value && !loading.value && (store.error || store.builtinStatus === 'failed'),
)
const empty = computed(() => !loading.value && !failed.value && store.items.length === 0)
const populated = computed(() => !loading.value && !failed.value)
const showSkeleton = computed(() => loading.value && skeleton.visible.value)
const customRows = computed(() => store.items.map(customRow))
const builtinRows = computed(() => store.builtinItems.map(builtinRow))
const summary = computed(() => schemaSummary(store.items.length, store.builtinItems.length))
function retry() {
store.retry()
}
watch(
const {
sessionReady,
forbidden,
loading,
(isLoading) => {
if (isLoading) {
skeleton.start()
} else {
skeleton.stop()
}
},
{ immediate: true },
showSkeleton,
customFailed,
builtinFailed,
customItems,
builtinItems,
retry,
} = useSchemaGate('all')
const failed = computed(
() => !forbidden.value && !loading.value && (customFailed.value || builtinFailed.value),
)
const empty = computed(() => !loading.value && !failed.value && customItems.value.length === 0)
const populated = computed(() => !loading.value && !failed.value)
onBeforeUnmount(skeleton.stop)
const customRows = computed(() => customItems.value.map(customRow))
const builtinRows = computed(() => builtinItems.value.map(builtinRow))
const summary = computed(() => schemaSummary(customItems.value.length, builtinItems.value.length))
function createType() {
router.push(NEW_TYPE_PATH)
@@ -0,0 +1,265 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import router from '@/router'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
builtinTypes,
button,
calls,
failWith,
mountAt,
networkFailure,
pending,
text,
unmountAll,
} from './schemaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const SKELETON_DELAY_MS = 150
const ACCESS_TITLE = "You don't have access to Schema"
const NOT_FOUND_TITLE = 'Content type not found'
const NOT_FOUND_TEXT = 'It may have been deleted, or the address may be mistyped.'
const NOTE =
'Entries of built-in types are managed from their own screens, like Articles and Authors.'
addCollection(lucide)
afterEach(unmountAll)
function main() {
return document.body.querySelector('#dashboard-content')!
}
function fieldRows() {
return [...document.body.querySelectorAll('[data-testid="schema-field-row"]')].map(text)
}
describe('Schema list: built-in rows are links', () => {
it('links each built-in row to its view, named after the type, with a chevron', async () => {
await mountAt('/schema')
const links = [...document.body.querySelectorAll('a[data-testid="schema-row"]')]
expect(links.map((link) => link.getAttribute('href'))).toEqual(
builtinTypes.map((type) => `/schema/${type.key}`),
)
expect(links.map((link) => link.getAttribute('aria-label'))).toEqual(
builtinTypes.map((type) => type.name),
)
expect(links.every((link) => link.querySelectorAll('svg').length === 3)).toBe(true)
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
expect(rows.slice(0, 3).every((row) => row.tagName !== 'A')).toBe(true)
expect(
rows.slice(0, 3).every((row) => row.querySelector('[class*="chevron-right"]') === null),
).toBe(true)
expect(text(links[0])).toContain('Managed by RenewCMS')
})
it('shows built-in cards as links with a chevron and no Updated text on mobile', async () => {
await mountAt('/schema', { width: MOBILE_WIDTH })
const cards = [...document.body.querySelectorAll('[data-testid="schema-card"]')]
const links = cards.filter((card) => card.tagName === 'A')
expect(links).toHaveLength(5)
expect(text(links[0])).toBe('Articles Built-in articles · 5 fields')
expect(links[0]!.getAttribute('href')).toBe('/schema/articles')
expect(links[0]!.querySelector('svg:last-of-type')).not.toBeNull()
expect(cards.slice(0, 3).every((card) => card.tagName !== 'A')).toBe(true)
})
it('opens the type without loading the built-in types a second time', async () => {
await mountAt('/schema')
;(document.body.querySelector('a[data-testid="schema-row"]') as HTMLElement).click()
await flushPromises()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/articles'))
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
})
})
describe('Schema built-in view: read-only', () => {
it('shows the heading, the alert, the field table and the note', async () => {
await mountAt('/schema/articles')
expect(main().querySelector('h1')?.textContent).toBe('Articles')
expect(text(main())).toContain('Built-in')
expect(text(main())).toContain(
"Managed by RenewCMS Articles is a built-in type. You can read its fields here, but you can't edit it, delete it or add fields.",
)
expect([...document.body.querySelectorAll('[role="columnheader"]')].map(text)).toEqual([
'Label',
'Key',
'Type',
'Rules',
])
expect(fieldRows()).toEqual([
'Title title Text Required · up to 200 characters',
'Slug slug Text Required · unique',
'Body body Rich text Up to 10,000 characters',
'Online online Yes or no Off by default',
'Published on published_on Date –',
])
expect(text(main())).toContain(NOTE)
const rowIcons = [
...document.body.querySelectorAll('[data-testid="schema-field-row"]'),
].map((row) => row.querySelector('svg')!.innerHTML)
const expectedIcons = ['type', 'type', 'pilcrow', 'toggle-left', 'calendar'].map((name) => {
const holder = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
holder.innerHTML = (lucide.icons[name] as { body: string }).body
return holder.innerHTML
})
expect(rowIcons).toEqual(expectedIcons)
expect(new Set(expectedIcons).size).toBe(4)
expect(button('New content type')).toBeUndefined()
expect(document.body.querySelector('input, textarea')).toBeNull()
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
})
it('has the Schema breadcrumb linking back to the list, then the type name', async () => {
await mountAt('/schema/articles')
const crumbs = document.body.querySelector('nav[aria-label="breadcrumb"]')!
expect(text(crumbs)).toBe('Schema Articles')
const link = crumbs.querySelector('a')!
expect(link.getAttribute('href')).toBe('/schema')
link.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
})
it('shows cards without the Type column on mobile, with an arrow back to the list', async () => {
await mountAt('/schema/articles', { width: MOBILE_WIDTH })
expect(document.body.querySelectorAll('[role="columnheader"]')).toHaveLength(0)
const cards = [...document.body.querySelectorAll('[data-testid="schema-field-card"]')]
expect(cards.map(text)).toEqual([
'Title title · Required · up to 200 characters',
'Slug slug · Required · unique',
'Body body · Up to 10,000 characters',
'Online online · Off by default',
'Published on published_on',
])
expect(text(main())).toContain('Built-in')
expect(text(main())).toContain('Managed by RenewCMS')
expect(text(main())).toContain(NOTE)
const back = document.body.querySelector('a[href="/schema"][aria-label="Schema"]')!
expect(back).not.toBeNull()
expect(document.body.textContent).toContain('Articles')
;(back as HTMLElement).click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
})
})
describe('Schema built-in view: loading', () => {
it('shows nothing for 150 ms, then skeleton rows, never the not-found or 403 page', async () => {
vi.useFakeTimers()
const gate = pending()
await mountAt('/schema/articles', { builtin: () => gate.promise })
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="schema-skeleton-row"]').length,
).toBeGreaterThan(0)
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
expect(text(main())).not.toContain(ACCESS_TITLE)
})
})
describe('Schema built-in view: unknown key', () => {
it.each(['/schema/unknown', '/schema/events', '/schema/new'])(
'shows the not-found page at %s without redirecting',
async (path) => {
await mountAt(path)
expect(text(main())).toContain(NOT_FOUND_TITLE)
expect(text(main())).toContain(NOT_FOUND_TEXT)
expect(router.currentRoute.value.path).toBe(path)
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
expect(main().querySelector('button')).toBeNull()
},
)
})
describe('Schema built-in view: error', () => {
it.each([
['answers 500', failWith(500)],
['fails (network)', networkFailure],
])('shows the alert when the request %s, then recovers on Try again', async (_, failure) => {
let respond = failure
await mountAt('/schema/articles', { builtin: () => respond() })
expect(text(main())).toContain("Couldn't load content types")
expect(text(main())).toContain(
'GET /v1/builtin-type failed. Check your connection and try again.',
)
expect(button('Try again')).toBeDefined()
expect(document.body.querySelector('[data-testid="schema-field-row"]')).toBeNull()
respond = () => builtinTypes
button('Try again')!.click()
await flushPromises()
expect(fieldRows()).toHaveLength(5)
expect(text(main())).not.toContain("Couldn't load content types")
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(2)
})
})
describe('Schema built-in view: retry', () => {
it('retries only the built-in types, never the custom ones', async () => {
let respond = failWith(500)
await mountAt('/schema/articles', { builtin: () => respond() })
respond = () => builtinTypes
button('Try again')!.click()
await flushPromises()
expect(fieldRows()).toHaveLength(5)
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(2)
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
})
})
describe('Schema built-in view: forbidden', () => {
it('explains instead of redirecting for a user who is not owner or admin', async () => {
await mountAt('/schema/articles', { role: 'user' })
expect(router.currentRoute.value.path).toBe('/schema/articles')
expect(text(main())).toContain(ACCESS_TITLE)
expect(calls).not.toContain('/v1/builtin-type')
button('Go to Home')!.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it('shows the same page when the API answers 403', async () => {
await mountAt('/schema/articles', { builtin: failWith(403) })
expect(text(main())).toContain(ACCESS_TITLE)
expect(text(main())).not.toContain(NOT_FOUND_TITLE)
})
})
describe('Schema built-in view: shared load', () => {
it('loads each list once across list, view and back', async () => {
await mountAt('/schema')
await router.push('/schema/articles')
await flushPromises()
await router.back()
await flushPromises()
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
})
})
+72 -7
View File
@@ -4,6 +4,7 @@ import { addCollection } from '@iconify/vue'
import lucide from 'virtual:lucide-subset'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { useContentTypesStore } from '@/stores/contentTypes'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
builtinTypes,
@@ -60,11 +61,11 @@ describe('Schema list: populated', () => {
'Events events 7 15 Oct 2026',
'Recipes recipes 4 2 Oct 2026',
'Press mentions press-mentions 5 18 Sep 2026',
'Articles Built-in articles 5 —',
'Authors Built-in authors 5 —',
'Sites Built-in sites 8 —',
'Media Built-in media 6 —',
'Users Built-in users 6 —',
'Articles Built-in articles 5 Managed by RenewCMS',
'Authors Built-in authors 5 Managed by RenewCMS',
'Sites Built-in sites 8 Managed by RenewCMS',
'Media Built-in media 6 Managed by RenewCMS',
'Users Built-in users 6 Managed by RenewCMS',
])
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
const [rowIcon, badgeIcon] = rows[3]!.querySelectorAll('svg')
@@ -158,6 +159,13 @@ describe('Schema list: error', () => {
expect(document.body.querySelector('[role="table"]')).toBeNull()
})
it('shows the Error state when a 200 answer is not an array', async () => {
await mountAt('/schema', { content: () => ({ items: [] }) })
expect(text(main())).toContain("Couldn't load content types")
expect(document.body.querySelector('[role="table"]')).toBeNull()
})
it('retries: skeleton after 150 ms, a second request, then the populated state', async () => {
let attempt = 0
await mountAt('/schema', {
@@ -192,6 +200,56 @@ describe('Schema list: error', () => {
})
})
describe('Schema list: retry reloads only what failed', () => {
it('retries a failed built-in list without refetching ready custom types', async () => {
let respond = failWith(500)
await mountAt('/schema', { builtin: () => respond() })
expect(text(main())).toContain("Couldn't load content types")
respond = () => builtinTypes
button('Try again')!.click()
await flushPromises()
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(2)
expect(rowTexts()).toHaveLength(8)
})
it('keeps the ready custom items while the built-in retry is pending', async () => {
let respond = failWith(500)
await mountAt('/schema', { builtin: () => respond() })
const store = useContentTypesStore()
const gate = pending()
vi.mocked(useApi)().request = vi.fn(async (url: string) => {
calls.push(url)
return gate.promise
}) as never
button('Try again')!.click()
await flushPromises()
expect(store.status).toBe('ready')
expect(store.items).toHaveLength(3)
respond = () => builtinTypes
gate.resolve(builtinTypes)
await flushPromises()
expect(rowTexts()).toHaveLength(8)
})
it('retries only the failed custom list when the built-in types are ready', async () => {
let respond = failWith(500)
await mountAt('/schema', { content: () => respond() })
respond = () => customTypes
button('Try again')!.click()
await flushPromises()
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(2)
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
expect(rowTexts()).toHaveLength(8)
})
})
describe('Schema list: forbidden', () => {
it('explains instead of redirecting for a role that is neither owner nor admin', async () => {
await mountAt('/schema', { role: 'unknown' })
@@ -221,13 +279,20 @@ describe('Schema list: forbidden', () => {
})
describe('Schema list: accessibility', () => {
it('keeps every table child a row, every row child a cell, the divider included', async () => {
it('keeps every table child a row or a named link, every row child a cell, the divider included', async () => {
await mountAt('/schema')
const table = document.body.querySelector('[role="table"]')!
for (const row of table.children) {
expect(row.getAttribute('role')).toBe('row')
const isLink = row.tagName === 'A'
expect(isLink ? row.getAttribute('aria-label') : row.getAttribute('role')).toBeTruthy()
if (!isLink) {
expect(row.getAttribute('role')).toBe('row')
}
for (const cell of row.children) {
if (cell.getAttribute('aria-hidden') === 'true') {
continue
}
expect(['cell', 'columnheader', 'presentation']).toContain(
cell.getAttribute('role'),
)
+28 -1
View File
@@ -41,13 +41,40 @@ export const customTypes = [
},
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
function articleField(label: string, key: string, type: string, rest: object = {}) {
return {
key,
label,
type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
...rest,
}
}
export const articleFields = [
articleField('Title', 'title', 'text', { required: true, max: 200 }),
articleField('Slug', 'slug', 'text', { required: true, unique: true }),
articleField('Body', 'body', 'richtext', { max: 10000 }),
articleField('Online', 'online', 'boolean', { default: false }),
articleField('Published on', 'published_on', 'date'),
]
export const builtinTypes = [
['articles', 'Articles', 5],
['authors', 'Authors', 5],
['sites', 'Sites', 8],
['media', 'Media', 6],
['users', 'Users', 6],
].map(([key, name, count]) => ({ key, name, fields: new Array(count) }))
].map(([key, name, count]) => ({
key,
name,
fields: key === 'articles' ? articleFields : new Array(count),
}))
export function manyTypes(count: number) {
return Array.from({ length: count }, (_, index) => ({
+30 -2
View File
@@ -6459,6 +6459,29 @@ const schemaCustomTypes = [
updated_at: updatedAt,
}))
function schemaField(label: string, key: string, type: string, rest: object = {}) {
return {
key,
label,
type,
required: false,
unique: false,
default: null,
min: null,
max: null,
options: null,
...rest,
}
}
const schemaArticleFields = [
schemaField('Title', 'title', 'text', { required: true, max: 200 }),
schemaField('Slug', 'slug', 'text', { required: true, unique: true }),
schemaField('Body', 'body', 'richtext', { max: 10000 }),
schemaField('Online', 'online', 'boolean', { default: false }),
schemaField('Published on', 'published_on', 'date'),
]
const schemaBuiltinTypes = [
['articles', 'Articles', 5],
['authors', 'Authors', 5],
@@ -6468,7 +6491,12 @@ const schemaBuiltinTypes = [
].map(([key, name, fieldCount]) => ({
key,
name,
fields: Array.from({ length: fieldCount as number }, (_, field) => ({ key: `field_${field}` })),
fields:
key === 'articles'
? schemaArticleFields
: Array.from({ length: fieldCount as number }, (_, field) => ({
key: `field_${field}`,
})),
}))
function schemaSession(role: string): ApiMock {
@@ -6507,7 +6535,7 @@ const schemaStates: Record<string, VisualState> = {
'GET /v1/auth/me': schemaSession('user'),
}),
},
'Built-in type: read-only view': { path: '/schema', skip: 'pending' },
'Built-in type: read-only view': { path: '/schema/articles', api: schemaApi() },
}
export const visualStates: Record<string, Record<string, VisualState>> = {