feat: built-in type read-only view,s chema list, states, spec files
This commit is contained in:
1 parent
3c67cd2314
commit
f514146fed
24 files changed
+1220
-88
No files matched your search
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 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 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 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 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 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
|
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"
|
When an owner activates "New content type"
|
||||||
Then the router navigates to /schema/new (the builder is a later slice)
|
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
|
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
|
Scenario Outline: The typed client maps responses
|
||||||
When the client calls <call>
|
When the client calls <call>
|
||||||
|
|||||||
@@ -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):
|
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).
|
- 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'`.
|
- 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.
|
- 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.
|
- The sidebar group and drawer frames belong to the later `content` group.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
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 COLUMNS = '24px minmax(0,1.4fr) minmax(0,1fr) 90px 150px 24px'
|
||||||
const SKELETON_ROWS = 6
|
const SKELETON_ROWS = 6
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ defineProps<{
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isTable = useMediaQuery(TABLE_QUERY)
|
const isTable = useMediaQuery(SCHEMA_TABLE_QUERY)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -75,11 +75,19 @@ const isTable = useMediaQuery(TABLE_QUERY)
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="row in group" :key="row.id">
|
<template v-for="row in group" :key="row.id">
|
||||||
<div
|
<component
|
||||||
|
:is="row.to ? RouterLink : 'div'"
|
||||||
v-if="isTable"
|
v-if="isTable"
|
||||||
role="row"
|
:to="row.to"
|
||||||
|
:aria-label="row.to ? row.name : undefined"
|
||||||
|
:role="row.to ? undefined : 'row'"
|
||||||
data-testid="schema-row"
|
data-testid="schema-row"
|
||||||
class="grid h-12 items-center gap-3 border-t border-default px-3"
|
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 }"
|
:style="{ gridTemplateColumns: COLUMNS }"
|
||||||
>
|
>
|
||||||
<span role="cell" class="flex">
|
<span role="cell" class="flex">
|
||||||
@@ -99,12 +107,21 @@ const isTable = useMediaQuery(TABLE_QUERY)
|
|||||||
}}</span>
|
}}</span>
|
||||||
<span role="cell">{{ row.fieldCount }}</span>
|
<span role="cell">{{ row.fieldCount }}</span>
|
||||||
<span role="cell" class="text-muted">{{ row.updated }}</span>
|
<span role="cell" class="text-muted">{{ row.updated }}</span>
|
||||||
</div>
|
<UIcon
|
||||||
<div
|
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
|
v-else
|
||||||
role="listitem"
|
:to="row.to"
|
||||||
|
:aria-label="row.to ? row.name : undefined"
|
||||||
|
:role="row.to ? undefined : 'listitem'"
|
||||||
data-testid="schema-card"
|
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 min-w-0 flex-1 flex-col gap-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@@ -121,7 +138,13 @@ const isTable = useMediaQuery(TABLE_QUERY)
|
|||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
</div>
|
</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>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 () => {
|
it('rejects with a network error', async () => {
|
||||||
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import { useApi } from '@/composables/useApi'
|
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 {
|
export interface ContentType {
|
||||||
id: number
|
id: number
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
icon: string | null
|
icon: string | null
|
||||||
fields: unknown[]
|
fields: BuiltinField[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -13,19 +25,27 @@ export interface ContentType {
|
|||||||
export interface BuiltinType {
|
export interface BuiltinType {
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
fields: unknown[]
|
fields: BuiltinField[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useContentTypes() {
|
export function useContentTypes() {
|
||||||
const { request } = useApi()
|
const { request } = useApi()
|
||||||
const options = { onForbidden: 'passthrough' } as const
|
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[]> {
|
function listContentTypes(): Promise<ContentType[]> {
|
||||||
return request<ContentType[]>('/v1/content-type', options)
|
return requestList<ContentType>('/v1/content-type')
|
||||||
}
|
}
|
||||||
|
|
||||||
function listBuiltinTypes(): Promise<BuiltinType[]> {
|
function listBuiltinTypes(): Promise<BuiltinType[]> {
|
||||||
return request<BuiltinType[]>('/v1/builtin-type', options)
|
return requestList<BuiltinType>('/v1/builtin-type')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { listContentTypes, listBuiltinTypes }
|
return { listContentTypes, listBuiltinTypes }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -458,7 +458,6 @@ export const messages = {
|
|||||||
'Used in': 'Utilisée dans',
|
'Used in': 'Utilisée dans',
|
||||||
Unused: 'Inutilisées',
|
Unused: 'Inutilisées',
|
||||||
unused: 'inutilisée',
|
unused: 'inutilisée',
|
||||||
Type: 'Type',
|
|
||||||
Vector: 'Vectoriel',
|
Vector: 'Vectoriel',
|
||||||
Selection: 'Sélection',
|
Selection: 'Sélection',
|
||||||
'Select all': 'Tout sélectionner',
|
'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.',
|
'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',
|
'All content types': 'Tous les types de contenu',
|
||||||
'Show all {0}': 'Tout afficher ({0})',
|
'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
|
} as const
|
||||||
|
|
||||||
export type MessageKey = keyof typeof messages
|
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('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { t, formatNumber } from '@/i18n'
|
|||||||
import type { BuiltinType, ContentType } from '@/composables/useContentTypes'
|
import type { BuiltinType, ContentType } from '@/composables/useContentTypes'
|
||||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||||
|
|
||||||
export const EM_DASH = '—'
|
export const SCHEMA_TABLE_QUERY = '(min-width: 40rem)'
|
||||||
|
|
||||||
export interface SchemaRow {
|
export interface SchemaRow {
|
||||||
id: string
|
id: string
|
||||||
@@ -11,6 +11,7 @@ export interface SchemaRow {
|
|||||||
fieldCount: number
|
fieldCount: number
|
||||||
updated: string
|
updated: string
|
||||||
builtin: boolean
|
builtin: boolean
|
||||||
|
to?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function customRow(type: ContentType): SchemaRow {
|
export function customRow(type: ContentType): SchemaRow {
|
||||||
@@ -30,8 +31,9 @@ export function builtinRow(type: BuiltinType): SchemaRow {
|
|||||||
name: type.name,
|
name: type.name,
|
||||||
key: type.key,
|
key: type.key,
|
||||||
fieldCount: type.fields.length,
|
fieldCount: type.fields.length,
|
||||||
updated: EM_DASH,
|
updated: t('Managed by RenewCMS'),
|
||||||
builtin: true,
|
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')
|
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 () => {
|
it('A session-only route admits an unverified session', async () => {
|
||||||
const getMe = vi.fn().mockResolvedValue({ verified: false })
|
const getMe = vi.fn().mockResolvedValue({ verified: false })
|
||||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||||
|
|||||||
@@ -92,6 +92,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/SchemaView.vue'),
|
component: () => import('@/views/SchemaView.vue'),
|
||||||
meta: { auth: 'verified', title: 'Schema' },
|
meta: { auth: 'verified', title: 'Schema' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/schema/:key',
|
||||||
|
component: () => import('@/views/SchemaTypeView.vue'),
|
||||||
|
meta: { auth: 'verified', title: 'Schema' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/activity',
|
path: '/activity',
|
||||||
component: () => import('@/views/ActivityView.vue'),
|
component: () => import('@/views/ActivityView.vue'),
|
||||||
|
|||||||
@@ -42,11 +42,11 @@ describe('content types store', () => {
|
|||||||
const store = useContentTypesStore()
|
const store = useContentTypesStore()
|
||||||
|
|
||||||
const loaded = store.load()
|
const loaded = store.load()
|
||||||
expect(store.loading).toBe(true)
|
expect(store.customLoading).toBe(true)
|
||||||
resolve([events])
|
resolve([events])
|
||||||
await loaded
|
await loaded
|
||||||
|
|
||||||
expect(store.loading).toBe(false)
|
expect(store.customLoading).toBe(false)
|
||||||
expect(store.items).toEqual([events])
|
expect(store.items).toEqual([events])
|
||||||
expect(store.error).toBe(false)
|
expect(store.error).toBe(false)
|
||||||
})
|
})
|
||||||
@@ -69,7 +69,7 @@ describe('content types store', () => {
|
|||||||
expect(store.error).toBe(true)
|
expect(store.error).toBe(true)
|
||||||
expect(store.items).toEqual([])
|
expect(store.items).toEqual([])
|
||||||
|
|
||||||
await store.retry()
|
await store.retryCustom()
|
||||||
expect(listContentTypes).toHaveBeenCalledTimes(2)
|
expect(listContentTypes).toHaveBeenCalledTimes(2)
|
||||||
expect(store.error).toBe(false)
|
expect(store.error).toBe(false)
|
||||||
expect(store.items).toEqual([events])
|
expect(store.items).toEqual([events])
|
||||||
@@ -129,7 +129,7 @@ describe('content types store', () => {
|
|||||||
const store = useContentTypesStore()
|
const store = useContentTypesStore()
|
||||||
|
|
||||||
const stale = store.load()
|
const stale = store.load()
|
||||||
await store.retry()
|
await store.retryCustom()
|
||||||
first.resolve([{ ...events, key: 'stale' }])
|
first.resolve([{ ...events, key: 'stale' }])
|
||||||
await stale
|
await stale
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ describe('content types store', () => {
|
|||||||
expect(store.builtinStatus).toBe('failed')
|
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])
|
listContentTypes.mockResolvedValue([events])
|
||||||
listBuiltinTypes
|
listBuiltinTypes
|
||||||
.mockRejectedValueOnce(new ApiError(500))
|
.mockRejectedValueOnce(new ApiError(500))
|
||||||
@@ -182,8 +182,11 @@ describe('content types store', () => {
|
|||||||
|
|
||||||
await store.load({ builtin: true })
|
await store.load({ builtin: true })
|
||||||
expect(store.builtinStatus).toBe('failed')
|
expect(store.builtinStatus).toBe('failed')
|
||||||
await store.retry()
|
await store.retryBuiltin()
|
||||||
expect(store.builtinStatus).toBe('ready')
|
expect(store.builtinStatus).toBe('ready')
|
||||||
|
expect(listContentTypes).toHaveBeenCalledTimes(1)
|
||||||
|
expect(store.status).toBe('ready')
|
||||||
|
expect(store.items).toEqual([events])
|
||||||
|
|
||||||
store.reset()
|
store.reset()
|
||||||
expect(store.items).toEqual([])
|
expect(store.items).toEqual([])
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
|||||||
if (token !== generation) {
|
if (token !== generation) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items.value = Array.isArray(listed) ? (listed as never) : []
|
items.value = listed
|
||||||
status.value = 'ready'
|
status.value = 'ready'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (token !== generation) {
|
if (token !== generation) {
|
||||||
@@ -64,7 +64,9 @@ function createSlice<T>(fetcher: () => Promise<T[]>) {
|
|||||||
status.value = 'idle'
|
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', () => {
|
export const useContentTypesStore = defineStore('contentTypes', () => {
|
||||||
@@ -76,7 +78,8 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
|
|||||||
const builtinItems = builtin.items
|
const builtinItems = builtin.items
|
||||||
const builtinStatus = builtin.status
|
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 error = computed(() => status.value === 'failed')
|
||||||
const forbidden = computed(
|
const forbidden = computed(
|
||||||
() => status.value === 'forbidden' || builtinStatus.value === 'forbidden',
|
() => status.value === 'forbidden' || builtinStatus.value === 'forbidden',
|
||||||
@@ -90,8 +93,12 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
|
|||||||
return Promise.all(requests).then(() => undefined)
|
return Promise.all(requests).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
function retry() {
|
function retryCustom() {
|
||||||
return Promise.all([custom.retry(), builtin.retry()]).then(() => undefined)
|
return custom.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryBuiltin() {
|
||||||
|
return builtin.retry()
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
@@ -104,11 +111,13 @@ export const useContentTypesStore = defineStore('contentTypes', () => {
|
|||||||
status,
|
status,
|
||||||
builtinItems,
|
builtinItems,
|
||||||
builtinStatus,
|
builtinStatus,
|
||||||
loading,
|
customLoading,
|
||||||
|
builtinLoading,
|
||||||
error,
|
error,
|
||||||
forbidden,
|
forbidden,
|
||||||
load,
|
load,
|
||||||
retry,
|
retryCustom,
|
||||||
|
retryBuiltin,
|
||||||
reset,
|
reset,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -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>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
import { computed, onBeforeUnmount, watch } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useMediaQuery } from '@vueuse/core'
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
import DashboardShell from '@/components/DashboardShell.vue'
|
import DashboardShell from '@/components/DashboardShell.vue'
|
||||||
@@ -10,60 +10,35 @@ import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
|||||||
import SchemaEmpty from '@/components/SchemaEmpty.vue'
|
import SchemaEmpty from '@/components/SchemaEmpty.vue'
|
||||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||||
import SchemaTable from '@/components/SchemaTable.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 { builtinRow, customRow, schemaSummary } from '@/lib/schemaRows'
|
||||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
|
||||||
import { useSessionStore } from '@/stores/session'
|
|
||||||
|
|
||||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||||
const NEW_TYPE_PATH = '/schema/new'
|
const NEW_TYPE_PATH = '/schema/new'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const session = useSessionStore()
|
|
||||||
const store = useContentTypesStore()
|
|
||||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||||
const skeleton = useDelayedSkeleton()
|
|
||||||
|
|
||||||
const sessionReady = computed(() => session.status === 'ready')
|
const {
|
||||||
const forbidden = computed(
|
sessionReady,
|
||||||
() => (sessionReady.value && !session.canManageContent) || store.forbidden,
|
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(
|
|
||||||
loading,
|
loading,
|
||||||
(isLoading) => {
|
showSkeleton,
|
||||||
if (isLoading) {
|
customFailed,
|
||||||
skeleton.start()
|
builtinFailed,
|
||||||
} else {
|
customItems,
|
||||||
skeleton.stop()
|
builtinItems,
|
||||||
}
|
retry,
|
||||||
},
|
} = useSchemaGate('all')
|
||||||
{ immediate: true },
|
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() {
|
function createType() {
|
||||||
router.push(NEW_TYPE_PATH)
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,7 @@ import { addCollection } from '@iconify/vue'
|
|||||||
import lucide from 'virtual:lucide-subset'
|
import lucide from 'virtual:lucide-subset'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||||
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
|
||||||
import {
|
import {
|
||||||
builtinTypes,
|
builtinTypes,
|
||||||
@@ -60,11 +61,11 @@ describe('Schema list: populated', () => {
|
|||||||
'Events events 7 15 Oct 2026',
|
'Events events 7 15 Oct 2026',
|
||||||
'Recipes recipes 4 2 Oct 2026',
|
'Recipes recipes 4 2 Oct 2026',
|
||||||
'Press mentions press-mentions 5 18 Sep 2026',
|
'Press mentions press-mentions 5 18 Sep 2026',
|
||||||
'Articles Built-in articles 5 —',
|
'Articles Built-in articles 5 Managed by RenewCMS',
|
||||||
'Authors Built-in authors 5 —',
|
'Authors Built-in authors 5 Managed by RenewCMS',
|
||||||
'Sites Built-in sites 8 —',
|
'Sites Built-in sites 8 Managed by RenewCMS',
|
||||||
'Media Built-in media 6 —',
|
'Media Built-in media 6 Managed by RenewCMS',
|
||||||
'Users Built-in users 6 —',
|
'Users Built-in users 6 Managed by RenewCMS',
|
||||||
])
|
])
|
||||||
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
|
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
|
||||||
const [rowIcon, badgeIcon] = rows[3]!.querySelectorAll('svg')
|
const [rowIcon, badgeIcon] = rows[3]!.querySelectorAll('svg')
|
||||||
@@ -158,6 +159,13 @@ describe('Schema list: error', () => {
|
|||||||
expect(document.body.querySelector('[role="table"]')).toBeNull()
|
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 () => {
|
it('retries: skeleton after 150 ms, a second request, then the populated state', async () => {
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
await mountAt('/schema', {
|
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', () => {
|
describe('Schema list: forbidden', () => {
|
||||||
it('explains instead of redirecting for a role that is neither owner nor admin', async () => {
|
it('explains instead of redirecting for a role that is neither owner nor admin', async () => {
|
||||||
await mountAt('/schema', { role: 'unknown' })
|
await mountAt('/schema', { role: 'unknown' })
|
||||||
@@ -221,13 +279,20 @@ describe('Schema list: forbidden', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('Schema list: accessibility', () => {
|
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')
|
await mountAt('/schema')
|
||||||
|
|
||||||
const table = document.body.querySelector('[role="table"]')!
|
const table = document.body.querySelector('[role="table"]')!
|
||||||
for (const row of table.children) {
|
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) {
|
for (const cell of row.children) {
|
||||||
|
if (cell.getAttribute('aria-hidden') === 'true') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
expect(['cell', 'columnheader', 'presentation']).toContain(
|
expect(['cell', 'columnheader', 'presentation']).toContain(
|
||||||
cell.getAttribute('role'),
|
cell.getAttribute('role'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,13 +41,40 @@ export const customTypes = [
|
|||||||
},
|
},
|
||||||
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
|
].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 = [
|
export const builtinTypes = [
|
||||||
['articles', 'Articles', 5],
|
['articles', 'Articles', 5],
|
||||||
['authors', 'Authors', 5],
|
['authors', 'Authors', 5],
|
||||||
['sites', 'Sites', 8],
|
['sites', 'Sites', 8],
|
||||||
['media', 'Media', 6],
|
['media', 'Media', 6],
|
||||||
['users', 'Users', 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) {
|
export function manyTypes(count: number) {
|
||||||
return Array.from({ length: count }, (_, index) => ({
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
|||||||
+30
-2
@@ -6459,6 +6459,29 @@ const schemaCustomTypes = [
|
|||||||
updated_at: updatedAt,
|
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 = [
|
const schemaBuiltinTypes = [
|
||||||
['articles', 'Articles', 5],
|
['articles', 'Articles', 5],
|
||||||
['authors', 'Authors', 5],
|
['authors', 'Authors', 5],
|
||||||
@@ -6468,7 +6491,12 @@ const schemaBuiltinTypes = [
|
|||||||
].map(([key, name, fieldCount]) => ({
|
].map(([key, name, fieldCount]) => ({
|
||||||
key,
|
key,
|
||||||
name,
|
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 {
|
function schemaSession(role: string): ApiMock {
|
||||||
@@ -6507,7 +6535,7 @@ const schemaStates: Record<string, VisualState> = {
|
|||||||
'GET /v1/auth/me': schemaSession('user'),
|
'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>> = {
|
export const visualStates: Record<string, Record<string, VisualState>> = {
|
||||||
|
|||||||
Reference in new issue
Block a user