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

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