feat(SPA): schema list and the sidebar Content group
This commit is contained in:
1 parent
73a75d5cb9
commit
3c67cd2314
34 files changed
+2181
-18
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user