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

+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>> = {