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,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">
|
||||
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>
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
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('')
|
||||
})
|
||||
})
|
||||
@@ -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 { 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 })
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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">
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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'),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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>> = {
|
||||
|
||||
Reference in new issue
Block a user