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,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) => ({
|
||||
|
||||
Reference in new issue
Block a user