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

@@ -0,0 +1,312 @@
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 { useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { contentLink, selectedContentKey } from '@/lib/contentNav'
import { contentTypeIcon } from '@/lib/contentTypeIcon'
import { MOBILE_WIDTH } from './viewport'
import {
calls,
customTypes,
failWith,
manyTypes,
mountAt,
networkFailure,
text,
unmountAll,
} from '@/views/__tests__/schemaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
addCollection(lucide)
afterEach(unmountAll)
const LONG_NAME = 'Frequently asked questions and answers'
function mainLinks() {
return [...document.body.querySelectorAll('nav[aria-label="Main"] a')]
}
function contentLinks() {
return [...document.body.querySelectorAll('nav[aria-label="Content"] a')]
}
function showAll() {
return [
...document.body.querySelectorAll(
'nav[aria-label="Content"] button, nav[aria-label="Content"] a',
),
].find((candidate) => /^Show all/.test(text(candidate))) as HTMLElement | undefined
}
async function openDrawer() {
document.body.querySelector<HTMLElement>('[data-slot="toggle"]')!.click()
await flushPromises()
}
describe('The Schema entry', () => {
it.each([
['owner', true],
['admin', true],
['unknown', false],
])('role %s: shown %s, after Users, linking to /schema', async (role, shown) => {
await mountAt('/home', { role }, true)
const hrefs = mainLinks().map((link) => link.getAttribute('href'))
expect(hrefs.includes('/schema')).toBe(shown)
if (shown) {
expect(hrefs.indexOf('/schema')).toBe(hrefs.indexOf('/users') + 1)
}
expect(hrefs.filter((href) => href !== '/schema')).toEqual([
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
'/settings',
])
})
it('uses the shapes icon', async () => {
await mountAt('/home', {}, true)
const icon = document.body.querySelector('nav[aria-label="Main"] a[href="/schema"] svg')
expect(icon?.innerHTML).toBeTruthy()
expect(icon?.innerHTML).not.toBe(
document.body.querySelector('nav[aria-label="Main"] a[href="/users"] svg')?.innerHTML,
)
})
})
describe('The Content group', () => {
it('lists one link per type in API order and marks the current route', async () => {
await mountAt('/content/recipes', { role: 'unknown' }, true)
expect(contentLinks().map((link) => link.getAttribute('href'))).toEqual([
'/content/events',
'/content/recipes',
'/content/press-mentions',
])
expect(contentLinks().map(text)).toEqual(['Events', 'Recipes', 'Press mentions'])
expect(contentLinks().map((link) => link.getAttribute('aria-current'))).toEqual([
null,
'page',
null,
])
expect(text(document.body.querySelector('[data-testid="content-nav"]'))).toMatch(/^Content/)
})
it('is hidden without any type', async () => {
await mountAt('/home', { content: () => [] }, true)
expect(document.body.querySelector('[data-testid="content-nav"]')).toBeNull()
})
it.each([
['answers 500', failWith(500)],
['fails (network)', networkFailure],
])(
'is hidden, and the rest of the sidebar renders, when GET /v1/content-type %s',
async (_, failure) => {
await mountAt('/home', { content: failure }, true)
expect(document.body.querySelector('[data-testid="content-nav"]')).toBeNull()
expect(mainLinks().length).toBeGreaterThanOrEqual(8)
expect(document.body.textContent).not.toContain('failed')
},
)
it('is loaded once for the whole session, shared with the Schema list', async () => {
await mountAt('/schema')
const { default: router } = await import('@/router')
await router.push('/home')
await flushPromises()
await router.push('/schema')
await flushPromises()
expect(calls.filter((url) => url === '/v1/content-type')).toHaveLength(1)
expect(vi.mocked(useApi)).toHaveBeenCalled()
})
it('truncates a long name on one line and keeps the full name as accessible name', async () => {
await mountAt('/home', { content: () => [{ ...customTypes[0]!, name: LONG_NAME }] }, true)
const link = contentLinks()[0]!
expect(link.textContent).toContain(LONG_NAME)
expect(link.querySelector('.truncate')?.textContent).toBe(LONG_NAME)
})
it('shows the type icon when bundled and the default icon otherwise', async () => {
await mountAt(
'/home',
{
content: () => [
{ ...customTypes[0]!, key: 'bundled', name: 'Bundled', icon: 'feather' },
{ ...customTypes[0]!, key: 'none', name: 'None', icon: null },
{ ...customTypes[0]!, key: 'other', name: 'Other', icon: 'not-bundled' },
],
},
true,
)
const icons = contentLinks().map((link) => link.querySelector('svg')?.innerHTML)
const feather = document.body.querySelector('nav[aria-label="Main"] a[href="/authors"] svg')
expect(icons[0]).toBe(feather?.innerHTML)
expect(icons[1]).toBeTruthy()
expect(icons[1]).not.toBe(icons[0])
expect(icons[2]).toBe(icons[1])
})
})
describe('Icon choice', () => {
it.each([
['feather', 'i-lucide-feather'],
[null, 'i-lucide-files'],
['not-bundled', 'i-lucide-files'],
['constructor', 'i-lucide-files'],
['toString', 'i-lucide-files'],
['__proto__', 'i-lucide-files'],
['hasOwnProperty', 'i-lucide-files'],
])('maps %s to %s', (icon, expected) => {
expect(contentTypeIcon(icon)).toBe(expected)
})
})
describe('Content type loading trigger', () => {
it('does not reset or load on an unverified mount', async () => {
const actions: string[] = []
await mountAt('/home', { verified: false, actions }, true)
expect(actions).toEqual([])
expect(calls).not.toContain('/v1/content-type')
})
it('resets once when the session is cleared', async () => {
const actions: string[] = []
await mountAt('/home', { actions }, true)
expect(actions).toEqual(['load'])
useSessionStore().clear()
await flushPromises()
expect(actions).toEqual(['load', 'reset'])
expect(document.body.querySelector('[data-testid="content-nav"]')).toBeNull()
})
it('loads built-in types only on the schema page', async () => {
await mountAt('/home', {}, true)
expect(calls).not.toContain('/v1/builtin-type')
await unmountAll()
await mountAt('/schema')
expect(calls.filter((url) => url === '/v1/builtin-type')).toHaveLength(1)
})
it('hides the group silently when the content types answer 403', async () => {
await mountAt('/home', { content: failWith(403) }, true)
expect(document.body.querySelector('[data-testid="content-nav"]')).toBeNull()
expect(mainLinks().length).toBeGreaterThanOrEqual(8)
})
})
describe('Content route segment', () => {
it.each([
['/content/events', 'events'],
['/content/press%20mentions', 'press mentions'],
['/content/a%2Fb/extra', 'a/b'],
['/content/%E0%A4%A', '%E0%A4%A'],
['/content/', ''],
['/home', undefined],
])('parses %s', (path, expected) => {
expect(selectedContentKey(path)).toBe(expected)
})
it.each([
['events', '/content/events'],
['a b/c', '/content/a%20b%2Fc'],
])('links %s to %s', (key, expected) => {
expect(contentLink(key)).toBe(expected)
})
it('does not throw on a malformed percent sequence in the route', async () => {
await mountAt('/content/%E0%A4%A', {}, true)
expect(contentLinks().map(text)).toEqual(['Events', 'Recipes', 'Press mentions'])
expect(contentLinks().map((link) => link.getAttribute('aria-current'))).toEqual([
null,
null,
null,
])
})
it('encodes keys in the generated links', async () => {
await mountAt(
'/home',
{ content: () => [{ ...customTypes[0]!, key: 'a b', name: 'Spaced' }] },
true,
)
expect(contentLinks()[0]!.getAttribute('href')).toBe('/content/a%20b')
})
})
describe('More than 5 types on desktop', () => {
it('shows the first 5 and the selected one, then expands in place', async () => {
await mountAt('/content/type-9', { content: () => manyTypes(14) }, true)
expect(contentLinks().map(text)).toEqual([
'Type 1',
'Type 2',
'Type 3',
'Type 4',
'Type 5',
'Type 9',
])
expect(text(showAll())).toBe('Show all 14')
showAll()!.click()
await flushPromises()
expect(contentLinks()).toHaveLength(14)
expect(showAll()).toBeUndefined()
})
it('shows no Show all action with 5 types', async () => {
await mountAt('/home', { content: () => manyTypes(5) }, true)
expect(contentLinks()).toHaveLength(5)
expect(showAll()).toBeUndefined()
})
})
describe('On mobile', () => {
it('shows one collapsed row with the count, then lists the types', async () => {
await mountAt('/home', { width: MOBILE_WIDTH, content: () => manyTypes(14) }, true)
await openDrawer()
const group = document.body.querySelector('[role="dialog"] [data-testid="content-nav"]')!
expect(contentLinks()).toHaveLength(0)
expect(text(group)).toContain('All content types')
expect(text(group)).toContain('14')
const row = [...group.querySelectorAll('button, a')].find((candidate) =>
text(candidate).startsWith('All content types'),
) as HTMLElement
row.click()
await flushPromises()
const links = [...group.querySelectorAll('nav[aria-label="Content"] a')]
expect(links).toHaveLength(14)
expect(links[0]!.className).toContain('max-lg:h-11')
})
})
@@ -761,7 +761,7 @@ describe('DashboardShell mobile drawer rows', () => {
const drawer = await openDrawer('/settings')
const links = Array.from(drawer.querySelectorAll('nav[aria-label="Main"] a'))
expect(links).toHaveLength(11)
expect(links).toHaveLength(12)
for (const link of links) {
expect(classesOf(link)).toContain('max-lg:h-11')
}