feat(SPA): schema list and the sidebar Content group

This commit is contained in:
Florian Sylvain committed 2026-10-19 19:34:45 +02:00
1 parent 73a75d5cb9
commit 3c67cd2314
34 files changed
+2181 -18

No files matched your search

+2 -1
View File
@@ -81,11 +81,12 @@ describe('Navigation', () => {
'/media',
'/authors',
'/users',
'/schema',
'/activity',
'/integration',
'/settings',
])
const activity = links[5]!
const activity = links[6]!
expect(activity.getAttribute('aria-current')).toBe('page')
await expectIcon(activity, 'history')
expect(textOf(document.body.querySelector('nav[aria-label="breadcrumb"]'))).toBe('Activity')
+4 -1
View File
@@ -672,7 +672,10 @@ async function requestRemoveImage(wrapper: Awaited<ReturnType<typeof mount>>, im
function requestCalls(wrapper: Awaited<ReturnType<typeof mount>>) {
void wrapper
return vi.mocked(global.fetch).mock.calls.map(([url, init]) => routeFor(url as string, init))
return vi
.mocked(global.fetch)
.mock.calls.filter(([url]) => url !== '/v1/content-type')
.map(([url, init]) => routeFor(url as string, init))
}
describe('Requesting removal of an inline image opens the neutral confirm dialog', () => {
@@ -144,7 +144,9 @@ function listCalls() {
}
function requestedPaths() {
return requestMock.mock.calls.map(([path]) => path)
return requestMock.mock.calls
.map(([path]) => path)
.filter((path) => path !== '/v1/content-type')
}
function images() {
@@ -191,7 +191,7 @@ describe('Default, every card collapsed', () => {
await expand(wrapper, 'GET /v1/media/')
expect(globalThis.fetch).toBe(fetchSpy)
expect(fetchSpy).not.toHaveBeenCalled()
expect(fetchSpy.mock.calls.filter(([url]) => url !== '/v1/content-type')).toEqual([])
})
})
@@ -1557,6 +1557,7 @@ describe('Sidebar', () => {
'/media',
'/authors',
'/users',
'/schema',
'/activity',
'/integration',
'/settings',
+7 -3
View File
@@ -230,6 +230,10 @@ afterEach(async () => {
await router.push('/login')
})
function shellFreeCalls(mock: { mock: { calls: unknown[][] } }) {
return mock.mock.calls.filter(([path]) => path !== '/v1/content-type')
}
describe('Route guards for /profile', () => {
it('renders ProfilePage for a resolved, verified session', async () => {
await mountApp()
@@ -287,7 +291,7 @@ describe('Default: the usual first visit with no display name', () => {
)
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(byTestId('reset-note')).toBeNull()
expect(request).not.toHaveBeenCalled()
expect(shellFreeCalls(request)).toEqual([])
})
it('marks none of the eight navigation items as the current page', async () => {
@@ -364,7 +368,7 @@ describe('Editing: typing turns Save on', () => {
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(footer().classList.contains('text-warning')).toBe(true)
expect(isEnabled(saveButton())).toBe(true)
expect(request).not.toHaveBeenCalled()
expect(shellFreeCalls(request)).toEqual([])
})
})
@@ -513,7 +517,7 @@ describe('Saved: a 200 updates the session, the chip, the footer and raises a to
title: 'Display name saved',
description: 'You now appear as Marta Alves across RenewCMS and in emails.',
})
expect(request).toHaveBeenCalledTimes(1)
expect(shellFreeCalls(request)).toHaveLength(1)
})
it('opens no leave confirmation when navigating away afterwards', async () => {
+290
View File
@@ -0,0 +1,290 @@
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 { useApi } from '@/composables/useApi'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import {
builtinTypes,
button,
calls,
customTypes,
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"
addCollection(lucide)
afterEach(unmountAll)
function main() {
return document.body.querySelector('#dashboard-content')!
}
function rowTexts() {
return [...document.body.querySelectorAll('[data-testid="schema-row"]')].map(text)
}
describe('Schema list: populated', () => {
it('shows the title, the subtitle, an enabled New content type action and the columns', async () => {
await mountAt('/schema')
expect(main().querySelector('h1')?.textContent).toBe('Schema')
expect(text(main())).toContain('Content types and the fields they are made of.')
expect(button('New content type')?.hasAttribute('disabled')).toBe(false)
expect([...document.body.querySelectorAll('[role="columnheader"]')].map(text)).toEqual([
'Name',
'Key',
'Fields',
'Last updated',
])
})
it('lists custom rows then the five built-in rows with the lock, badge and em dash', async () => {
await mountAt('/schema')
expect(rowTexts()).toEqual([
'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 —',
])
const rows = [...document.body.querySelectorAll('[data-testid="schema-row"]')]
const [rowIcon, badgeIcon] = rows[3]!.querySelectorAll('svg')
expect(rowIcon!.innerHTML).toBeTruthy()
expect(rowIcon!.innerHTML).toBe(badgeIcon!.innerHTML)
expect(rows[0]!.querySelector('svg')!.innerHTML).not.toBe(rowIcon!.innerHTML)
})
it('shows the built-in note and the summary', async () => {
await mountAt('/schema')
expect(text(main())).toContain(
'Built-in types are managed by RenewCMS. You can read their fields, but not change them.',
)
expect(text(main())).toContain('3 content types · 5 built-in')
})
it('does not make rows links', async () => {
await mountAt('/schema')
expect(document.body.querySelectorAll('[data-testid="schema-row"] a')).toHaveLength(0)
await document.body.querySelector<HTMLElement>('[data-testid="schema-row"]')!.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/schema')
})
it('turns into cards below 640 px with the same content', async () => {
await mountAt('/schema', { width: 390 })
const cards = [...document.body.querySelectorAll('[data-testid="schema-card"]')].map(text)
expect(cards).toHaveLength(8)
expect(cards[0]).toBe('Events events · 7 fields · Updated 15 Oct 2026')
expect(cards[3]).toBe('Articles Built-in articles · 5 fields')
expect(document.body.querySelector('[data-testid="schema-row"]')).toBeNull()
})
})
describe('Schema list: loading', () => {
it('shows no skeleton for 150 ms, then skeleton rows, with New content type disabled', async () => {
vi.useFakeTimers()
const gate = pending()
await mountAt('/schema', { content: () => gate.promise })
expect(document.body.querySelector('[data-testid="schema-skeleton-row"]')).toBeNull()
expect(button('New content type')?.hasAttribute('disabled')).toBe(true)
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="schema-skeleton-row"]').length,
).toBeGreaterThan(0)
expect(button('New content type')?.hasAttribute('disabled')).toBe(true)
expect(text(main())).not.toContain(ACCESS_TITLE)
expect(text(main())).not.toContain('No content types yet')
})
})
describe('Schema list: empty', () => {
it('shows the empty state, an enabled action and the built-in types below', async () => {
await mountAt('/schema', { content: () => [] })
expect(text(main())).toContain('No content types yet')
expect(text(main())).toContain(
'Create a content type to manage your own kind of content, like events or recipes. The built-in types stay as they are.',
)
expect(button('New content type')?.hasAttribute('disabled')).toBe(false)
expect(rowTexts()).toHaveLength(5)
})
})
describe('Schema list: error', () => {
it.each([
['answers 500', failWith(500)],
['fails (network)', networkFailure],
])('shows the alert and no table when GET /v1/content-type %s', async (_, failure) => {
await mountAt('/schema', { content: failure })
expect(text(main())).toContain("Couldn't load content types")
expect(text(main())).toContain(
'GET /v1/content-type failed. Check your connection and try again.',
)
expect(text(button('Try again'))).toBe('Try again')
expect(document.body.querySelector('[role="table"]')).toBeNull()
})
it('shows the Error state when only the built-in types fail', async () => {
await mountAt('/schema', { builtin: failWith(500) })
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', {
content: () => {
attempt++
if (attempt === 1) {
throw new (class extends Error {})('x')
}
return customTypes
},
})
vi.useFakeTimers()
const gate = pending()
vi.mocked(useApi)().request = vi.fn(async (url: string) => {
calls.push(url)
return url === '/v1/builtin-type' ? builtinTypes : gate.promise
}) as never
button('Try again')!.click()
await flushPromises()
vi.advanceTimersByTime(SKELETON_DELAY_MS)
await flushPromises()
expect(
document.body.querySelectorAll('[data-testid="schema-skeleton-row"]').length,
).toBeGreaterThan(0)
gate.resolve(customTypes)
await flushPromises()
expect(text(main())).not.toContain("Couldn't load content types")
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' })
expect(router.currentRoute.value.path).toBe('/schema')
expect(text(main())).toContain(ACCESS_TITLE)
expect(text(main())).toContain(
'Only owners and admins can change content types. You can still add and edit entries from the Content menu.',
)
expect(calls).not.toContain('/v1/builtin-type')
expect(document.body.querySelector('a[href="/schema"]')).toBeNull()
})
it('Go to Home navigates to /home', async () => {
await mountAt('/schema', { role: 'unknown' })
button('Go to Home')!.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
it('shows the same page when the built-in request answers 403', async () => {
await mountAt('/schema', { builtin: failWith(403) })
expect(text(main())).toContain(ACCESS_TITLE)
})
})
describe('Schema list: accessibility', () => {
it('keeps every table child a row, 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')
for (const cell of row.children) {
expect(['cell', 'columnheader', 'presentation']).toContain(
cell.getAttribute('role'),
)
}
}
})
it('shows the same page when the content types answer 403', async () => {
await mountAt('/schema', { content: failWith(403) })
expect(text(main())).toContain(ACCESS_TITLE)
})
it('loads each list once', async () => {
await mountAt('/schema')
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
})
})
describe('Schema list: New content type', () => {
it('navigates to /schema/new', async () => {
await mountAt('/schema')
button('New content type')!.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
})
it('is a pinned button on mobile', async () => {
await mountAt('/schema', { width: MOBILE_WIDTH })
const pinned = [...document.body.querySelectorAll('button')].find((candidate) =>
candidate.className.includes('fixed'),
)!
expect(text(pinned)).toBe('New content type')
expect(pinned.className).toContain('fixed')
expect(pinned.className).toContain('bottom-4')
pinned.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/schema/new'))
})
})
describe('Schema route', () => {
it('redirects an unverified user like any verified route', async () => {
const { resolveAuthRedirect } = await import('@/router')
const { useSessionStore } = await import('@/stores/session')
const { createPinia, setActivePinia } = await import('pinia')
setActivePinia(createPinia())
const session = useSessionStore()
session.status = 'ready'
const to = router.resolve('/schema')
expect(to.meta).toMatchObject({ auth: 'verified', title: 'Schema' })
expect(resolveAuthRedirect(to, session)).toMatchObject({ path: '/login' })
session.session = { verified: false, displayName: '' }
expect(resolveAuthRedirect(to, session)).toEqual({ path: '/register/pending' })
})
})
@@ -197,7 +197,7 @@ describe('Search and sharing group', () => {
await expand(wrapper, 'GET /v1/article/{id}/seo')
await expand(wrapper, 'GET /v1/site/{key}/sitemap')
expect(fetchSpy).not.toHaveBeenCalled()
expect(fetchSpy.mock.calls.filter(([url]) => url !== '/v1/content-type')).toEqual([])
})
it('reads the group, the cards and the note in French', async () => {
+167
View File
@@ -0,0 +1,167 @@
import { vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { defineComponent, h } from 'vue'
import { RouterView } from 'vue-router'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
export type Responder = () => unknown
export const customTypes = [
{
id: 1,
key: 'events',
name: 'Events',
icon: 'files',
fields: new Array(7),
updated_at: '2026-10-15T09:30:00Z',
},
{
id: 2,
key: 'recipes',
name: 'Recipes',
icon: null,
fields: new Array(4),
updated_at: '2026-10-02T09:30:00Z',
},
{
id: 3,
key: 'press-mentions',
name: 'Press mentions',
icon: null,
fields: new Array(5),
updated_at: '2026-09-18T09:30:00Z',
},
].map((type) => ({ ...type, created_at: '2026-09-01T09:30:00Z' }))
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) }))
export function manyTypes(count: number) {
return Array.from({ length: count }, (_, index) => ({
...customTypes[0]!,
id: index + 1,
key: `type-${index + 1}`,
name: `Type ${index + 1}`,
icon: null,
}))
}
export function failWith(status: number): Responder {
return () => {
throw new ApiError(status)
}
}
export const networkFailure: Responder = () => {
throw new NetworkError()
}
export interface SchemaMount {
role?: string
width?: number
content?: Responder
builtin?: Responder
verified?: boolean
actions?: string[]
}
let wrapper: VueWrapper | undefined
export const calls: string[] = []
export async function mountAt(path: string, options: SchemaMount = {}, shellOnly = false) {
calls.length = 0
localStorage.clear()
setViewportWidth(options.width ?? DESKTOP_WIDTH)
const pinia = createPinia()
setActivePinia(pinia)
if (options.actions) {
const actions = options.actions
useContentTypesStore().$onAction(({ name }) => {
actions.push(name)
})
}
const session = useSessionStore()
session.session = {
verified: options.verified ?? true,
username: 'marta',
id: 1,
email: 'marta@renew.example',
displayName: 'Marta Alves',
role: (options.role ?? 'owner') as never,
}
session.status = 'ready'
const request = vi.fn(async (url: string) => {
calls.push(url)
if (url === '/v1/content-type') {
return (options.content ?? (() => customTypes))()
}
if (url === '/v1/builtin-type') {
return (options.builtin ?? (() => builtinTypes))()
}
return undefined
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(path)
wrapper = mount(
defineComponent({
render: () =>
h(UApp, null, {
default: () => (shellOnly ? h(DashboardShell) : h(RouterView)),
}),
}),
{
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
},
)
await flushPromises()
return wrapper
}
export async function unmountAll() {
wrapper?.unmount()
wrapper = undefined
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
}
export function pending() {
let resolve!: (value: unknown) => void
const promise = new Promise((done) => (resolve = done))
return { promise, resolve }
}
export function text(element: Element | null | undefined) {
if (!element) {
return ''
}
const parts: string[] = []
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
parts.push(node.textContent ?? '')
}
return parts.join(' ').replace(/\s+/g, ' ').trim()
}
export function button(label: string) {
return [...document.body.querySelectorAll('button')].find(
(candidate) => text(candidate) === label,
)
}