feat(SPA): screens for Sites and Authors

This commit is contained in:
Florian Sylvain committed 2026-10-14 23:00:50 +02:00
1 parent b34a173d83
commit 3d81d2c717
82 files changed
+10676 -37

No files matched your search

@@ -62,8 +62,8 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the seven navigation destinations', () => {
it('lists Home, Articles, Media, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the eight navigation destinations', () => {
it('lists Home, Articles, Media, Authors, Users, Activity, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
@@ -73,6 +73,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
@@ -90,7 +91,7 @@ describe('DashboardShell renders the seven navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('lists the same seven destinations in the mobile drawer and Settings current', async () => {
it('lists the same eight destinations in the mobile drawer and Settings current', async () => {
await mountShell('/settings', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
@@ -104,13 +105,14 @@ describe('DashboardShell renders the seven navigation destinations', () => {
'/home',
'/article',
'/media',
'/authors',
'/users',
'/activity',
'/integration',
'/settings',
])
expect(links[6].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[6].getAttribute('aria-current')).toBe('page')
expect(links[7].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[7].getAttribute('aria-current')).toBe('page')
})
it('marks Media with aria-current on /media, in the sidebar and in the mobile drawer', async () => {
@@ -269,11 +271,11 @@ describe('The account chip shows the display name and no item is current on /pro
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the seven navigation items current on /profile', async () => {
it('marks none of the eight navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(7)
expect(links).toHaveLength(8)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
@@ -747,7 +749,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(9)
expect(links).toHaveLength(11)
for (const link of links) {
expect(classesOf(link)).toContain('max-lg:h-11')
}
@@ -0,0 +1,115 @@
import { afterEach, describe, expect, it } from 'vitest'
import { h } from 'vue'
import { flushPromises } from '@vue/test-utils'
import DirectoryList from '../DirectoryList.vue'
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
import { MOBILE_WIDTH } from './viewport'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
interface Row {
id: number
name: string
}
const ROWS: Row[] = [
{ id: 1, name: 'First' },
{
id: 2,
name: 'A very long name that must be truncated in the row and shown in full on hover',
},
]
function menu(row: Row): DirectoryMenuItem[] {
return [
{ key: 'edit', label: 'Edit', icon: 'i-lucide-pencil', onSelect: () => undefined },
{
key: 'delete',
label: 'Delete…',
icon: 'i-lucide-trash-2',
destructive: true,
separated: true,
unavailable: row.id === 1,
hint: 'Not on the first row.',
onSelect: () => undefined,
},
]
}
function props(extra: Record<string, unknown> = {}) {
return {
items: ROWS,
columns: 'minmax(0,1fr) 44px',
headings: ['Name', ''],
label: 'Rows',
rowTitle: (row: Row) => row.name,
menuItems: menu,
...extra,
}
}
const slots = {
cells: ({ item }: { item: Row }) =>
h('span', { class: 'truncate', title: item.name }, item.name),
card: ({ item }: { item: Row }) =>
h('span', { class: 'truncate', title: item.name }, item.name),
}
afterEach(unmountApp)
describe('DirectoryList', () => {
it('draws a header and one row per item with a menu button on desktop', async () => {
await mountInApp(DirectoryList, props(), slots)
expect(
text(document.querySelector('[data-testid="directory-row"]')!.previousElementSibling),
).toBe('Name')
expect(document.querySelectorAll('[data-testid="directory-row"]')).toHaveLength(2)
expect(byText('button', 'Actions for First')).toBeTruthy()
})
it('keeps the full name available on hover for a truncated row', async () => {
await mountInApp(DirectoryList, props(), slots)
const long = document.querySelectorAll<HTMLElement>('span.truncate')[1]!
expect(long.getAttribute('title')).toBe(ROWS[1]!.name)
})
it('draws cards below md', async () => {
await mountInApp(DirectoryList, props(), slots, MOBILE_WIDTH)
expect(document.querySelectorAll('[data-testid="directory-card"]')).toHaveLength(2)
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
})
it('draws skeleton rows with aria-busy while loading', async () => {
await mountInApp(DirectoryList, props({ loading: true, skeletonRows: 4 }), slots)
expect(document.querySelector('[aria-busy="true"]')).not.toBeNull()
expect(document.querySelectorAll('[data-testid="directory-skeleton-row"]')).toHaveLength(4)
expect(document.querySelector('[data-testid="directory-row"]')).toBeNull()
})
it('swaps the menu for a chevron and emits open in the read-only variant', async () => {
const wrapper = await mountInApp(DirectoryList, props({ readOnly: true }), slots)
expect(document.querySelector('button[aria-label^="Actions for"]')).toBeNull()
byText('button', 'View First')!.click()
await flushPromises()
const list = wrapper.findComponent(DirectoryList)
expect(list.emitted('open')![0]).toEqual([ROWS[0]])
})
it('keeps an unavailable item focusable with aria-disabled and its hint', async () => {
await mountInApp(DirectoryList, props(), slots)
byText('button', 'Actions for First')!.click()
await flushPromises()
const item = byText('[role="menuitem"]', /^Delete…/)!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(item.hasAttribute('disabled')).toBe(false)
expect(text(document.getElementById(item.getAttribute('aria-describedby')!))).toBe(
'Not on the first row.',
)
})
})
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it } from 'vitest'
import { h } from 'vue'
import { flushPromises } from '@vue/test-utils'
import DirectoryPanel from '../DirectoryPanel.vue'
import { MOBILE_WIDTH } from './viewport'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
function body() {
return h('input', { 'data-autofocus': '', placeholder: 'Name' })
}
function content() {
return document.body.querySelector<HTMLElement>('[role="dialog"]')!
}
function escape() {
content().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
return flushPromises()
}
afterEach(unmountApp)
describe('DirectoryPanel', () => {
it('opens a right slide-over on desktop with a title, a Cancel and a submit button', async () => {
await mountInApp(
DirectoryPanel,
{ title: 'Add thing', submitLabel: 'Add thing' },
{ default: body },
)
expect(text(content().querySelector('h2'))).toBe('Add thing')
expect(content().className).toContain('max-w-120')
expect(byText('button', 'Cancel', content())).toBeTruthy()
expect(byText('button', 'Add thing', content())).toBeTruthy()
})
it('opens a 92 percent bottom sheet below md', async () => {
await mountInApp(
DirectoryPanel,
{ title: 'Add thing', submitLabel: 'Add thing' },
{ default: body },
MOBILE_WIDTH,
)
expect(content().className).toContain('h-23/25')
expect(content().className).toContain('before:w-9')
})
it('moves focus into the panel', async () => {
await mountInApp(
DirectoryPanel,
{ title: 'Add thing', submitLabel: 'Add' },
{ default: body },
)
await flushPromises()
expect(content().contains(document.activeElement)).toBe(true)
})
it('closes straight away when nothing changed', async () => {
const wrapper = await mountInApp(
DirectoryPanel,
{ title: 'T', submitLabel: 'Add' },
{ default: body },
)
await escape()
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
})
it('asks Discard changes? when the form is dirty and closes only on confirmation', async () => {
const wrapper = await mountInApp(
DirectoryPanel,
{ title: 'T', submitLabel: 'Add', dirty: true },
{ default: body },
)
await escape()
const ask = document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
expect(text(ask)).toContain('Discard changes?')
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
byText('button', 'Discard changes', ask)!.click()
await flushPromises()
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toHaveLength(1)
})
it('ignores Escape while a request is running and labels the button Saving…', async () => {
const wrapper = await mountInApp(
DirectoryPanel,
{ title: 'T', submitLabel: 'Add', saving: true, dirty: true },
{ default: body },
)
await escape()
expect(wrapper.findComponent(DirectoryPanel).emitted('close')).toBeUndefined()
expect(document.body.querySelector('[role="alertdialog"]')).toBeNull()
expect(byText('button', 'Saving…', content())).toBeTruthy()
expect(byText('button', 'Cancel', content())!.hasAttribute('disabled')).toBe(true)
})
it('shows the failure notice inside the panel', async () => {
await mountInApp(
DirectoryPanel,
{ title: 'T', submitLabel: 'Add', notice: { description: 'Could not save.' } },
{ default: body },
)
expect(text(content().querySelector('[role="alert"]'))).toBe('Could not save.')
})
it('offers Close as the only action in the read-only variant', async () => {
await mountInApp(DirectoryPanel, { title: 'Detail', readOnly: true }, { default: body })
const labels = Array.from(content().querySelectorAll('button'))
.map((button) => text(button) || button.getAttribute('aria-label'))
.filter(Boolean)
expect(labels.filter((label) => label !== 'Close')).toEqual([])
expect(byText('button', 'Cancel', content())).toBeUndefined()
})
})
@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import MediaField from '../MediaField.vue'
import { useApi } from '@/composables/useApi'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
const ITEM = {
id: 55,
filename: 'face.jpg',
url: '/media/face.jpg',
size: 1000,
content_type: 'image/jpeg',
width: 400,
height: 400,
sha256: '0abc',
uploaded_by: 'Marta Alves',
created_at: '2026-09-12T12:00:00Z',
used_in: 0,
duplicate: false,
variant_count: 0,
variant_size: 0,
}
const PICKER = {
title: 'Choose an image',
description: 'The default social image for Journal.',
hint: 'Shown as a square.',
confirmLabel: 'Use image',
}
beforeEach(() => {
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn(),
request: vi.fn().mockResolvedValue({
items: [ITEM],
total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 1, duplicates: 0 },
}),
} as unknown as ReturnType<typeof useApi>)
})
afterEach(unmountApp)
describe('MediaField', () => {
it('shows an optional label, its help and a Choose image button when empty', async () => {
await mountInApp(MediaField, {
label: 'Default social image',
help: 'Used when an article has no image of its own.',
picker: PICKER,
})
const content = text(document.body)
expect(content).toContain('Default social image')
expect(content).toContain('(optional)')
expect(content).toContain('Used when an article has no image of its own.')
expect(byText('button', 'Choose image')).toBeTruthy()
expect(byText('button', 'Remove')).toBeUndefined()
})
it('opens the existing picker with the given title, description, hint and confirm label', async () => {
await mountInApp(MediaField, {
label: 'Avatar',
picker: PICKER,
square: true,
avatarName: 'Camille',
})
byText('button', 'Choose image')!.click()
await flushPromises()
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
expect(text(picker)).toContain('Choose an image')
expect(text(picker)).toContain('The default social image for Journal.')
expect(text(picker)).toContain('Shown as a square.')
expect(byText('button', 'Use image', picker)!.hasAttribute('disabled')).toBe(true)
})
it('returns the picked image id through Use image and offers Change image and Remove', async () => {
const wrapper = await mountInApp(MediaField, { label: 'Avatar', picker: PICKER })
byText('button', 'Choose image')!.click()
await flushPromises()
const picker = document.body.querySelector<HTMLElement>('[role="dialog"]')!
picker.querySelector<HTMLElement>('[data-picker-tile]')!.click()
await flushPromises()
byText('button', 'Use image', picker)!.click()
await flushPromises()
const field = wrapper.findComponent(MediaField)
expect(field.emitted('update:modelValue')![0]).toEqual([55])
expect(byText('button', 'Change image')).toBeTruthy()
expect(byText('button', 'Remove')).toBeTruthy()
expect(text(document.body)).toContain('face.jpg')
})
it('shows the chosen filename, Change image and Remove, and Remove clears the id', async () => {
const wrapper = await mountInApp(MediaField, {
label: 'Avatar',
picker: PICKER,
modelValue: 55,
item: ITEM,
})
expect(text(document.body)).toContain('face.jpg')
expect(byText('button', 'Change image')).toBeTruthy()
byText('button', 'Remove')!.click()
await flushPromises()
expect(wrapper.findComponent(MediaField).emitted('update:modelValue')![0]).toEqual([null])
})
it('draws the avatar variant as a square with initials when no image is chosen', async () => {
await mountInApp(MediaField, {
label: 'Avatar',
picker: PICKER,
square: true,
avatarName: 'Camille Roche',
})
const avatar = document.body.querySelector<HTMLElement>(
'[data-slot="root"] [style*="64px"]',
)
expect(avatar ?? document.body.querySelector('[style*="width: 64px"]')).not.toBeNull()
expect(text(document.body)).toContain('CR')
})
})
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import ReassignDialog from '../ReassignDialog.vue'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
const OPTIONS = [
{ value: 1, label: 'Renew', description: 'renew.example', display: 'Renew · renew.example' },
{
value: 3,
label: 'Projects',
description: 'projects.example',
display: 'Projects · projects.example',
},
]
function props(extra: Record<string, unknown> = {}) {
return {
title: 'Delete this thing?',
description: 'It will be deleted.',
confirmLabel: 'Move 5 and delete',
pendingLabel: 'Moving…',
selectLabel: 'Move to',
options: OPTIONS,
selected: 1,
summary: '5 will move to Renew.',
...extra,
}
}
function dialog() {
return document.body.querySelector<HTMLElement>('[role="alertdialog"]')!
}
afterEach(unmountApp)
describe('ReassignDialog', () => {
it('shows the copy, the selected option, the summary and both buttons', async () => {
await mountInApp(ReassignDialog, props())
const content = text(dialog())
expect(content).toContain('Delete this thing?')
expect(content).toContain('It will be deleted.')
expect(content).toContain('Move to')
expect(text(dialog().querySelector('button[aria-haspopup="listbox"]'))).toBe(
'Renew · renew.example',
)
expect(content).toContain('5 will move to Renew.')
expect(byText('button', 'Cancel', dialog())).toBeTruthy()
expect(byText('button', 'Move 5 and delete', dialog())).toBeTruthy()
})
it('lists the options it is given with a name and a description', async () => {
await mountInApp(ReassignDialog, props())
dialog().querySelector<HTMLElement>('button[aria-haspopup="listbox"]')!.click()
await flushPromises()
const labels = Array.from(dialog().querySelectorAll('[role="option"]')).map(text)
expect(labels).toEqual(['Renew renew.example', 'Projects projects.example'])
})
it('draws no select when there are no options', async () => {
await mountInApp(ReassignDialog, props({ options: [], summary: '' }))
expect(dialog().querySelector('button[aria-haspopup="listbox"]')).toBeNull()
})
it('switches the confirm label and disables the buttons while pending', async () => {
await mountInApp(ReassignDialog, props({ pending: true }))
expect(byText('button', 'Moving…', dialog())).toBeTruthy()
expect(byText('button', 'Cancel', dialog())!.hasAttribute('disabled')).toBe(true)
expect(dialog().getAttribute('aria-busy')).toBe('true')
})
it('shows the failure notice with its title and text', async () => {
await mountInApp(
ReassignDialog,
props({ notice: { title: 'Failed', description: 'Nothing was changed.' } }),
)
expect(text(dialog().querySelector('[role="alert"]'))).toBe('Failed Nothing was changed.')
})
it('emits confirm from the primary button', async () => {
const wrapper = await mountInApp(ReassignDialog, props())
byText('button', 'Move 5 and delete', dialog())!.click()
await flushPromises()
expect(wrapper.findComponent(ReassignDialog).emitted('confirm')).toHaveLength(1)
})
})
@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import SlugField from '../SlugField.vue'
import { byText, mountInApp, text, unmountApp } from './directoryKit'
function props(extra: Record<string, unknown> = {}) {
return {
label: 'Slug',
mode: 'empty',
value: '',
help: 'Generated from the name.',
manualHelp: 'Lowercase letters.',
...extra,
}
}
function input() {
return document.body.querySelector<HTMLInputElement>('input')!
}
afterEach(unmountApp)
describe('SlugField', () => {
it('shows a read-only placeholder when there is no name yet', async () => {
await mountInApp(SlugField, props())
expect(input().readOnly).toBe(true)
expect(input().placeholder).toBe('Generated from the name')
expect(text(document.body)).toContain('Generated from the name.')
})
it('shows the automatic value with the Auto badge and a pencil that unlocks it', async () => {
const wrapper = await mountInApp(SlugField, props({ mode: 'auto', value: 'camille-roche' }))
expect(input().value).toBe('camille-roche')
expect(input().readOnly).toBe(true)
expect(text(document.body)).toContain('Auto')
byText('button', 'Edit slug')!.click()
await flushPromises()
expect(wrapper.findComponent(SlugField).emitted('unlock')).toHaveLength(1)
})
it('is editable in manual mode and offers Reset to match the name', async () => {
const wrapper = await mountInApp(SlugField, props({ mode: 'manual', value: 'custom' }))
const field = wrapper.findComponent(SlugField)
expect(input().readOnly).toBe(false)
expect(text(document.body)).toContain('Lowercase letters.')
input().value = 'custom-2'
input().dispatchEvent(new Event('input', { bubbles: true }))
expect(field.emitted('update:value')![0]).toEqual(['custom-2'])
byText('button', 'Reset to match the name')!.click()
await flushPromises()
expect(field.emitted('reset')).toHaveLength(1)
})
it('shows the error and a suggestion that emits its value', async () => {
const wrapper = await mountInApp(
SlugField,
props({
mode: 'manual',
value: 'camille-roche',
error: 'Already used.',
suggestion: 'camille-roche-2',
}),
)
expect(text(document.body)).toContain('Already used.')
byText('button', 'Use camille-roche-2')!.click()
await flushPromises()
expect(wrapper.findComponent(SlugField).emitted('suggest')![0]).toEqual(['camille-roche-2'])
})
it('draws the locked variant with a lock and its own help, with no pencil', async () => {
await mountInApp(
SlugField,
props({ mode: 'saved', value: 'renew', locked: true, lockedHelp: "Can't change." }),
)
expect(input().readOnly).toBe(true)
expect(document.querySelector('[data-testid="slug-lock"]')).not.toBeNull()
expect(text(document.body)).toContain("Can't change.")
expect(byText('button', 'Edit slug')).toBeUndefined()
})
})
@@ -0,0 +1,60 @@
import { defineComponent, h, type Component } from 'vue'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UApp from '@nuxt/ui/components/App.vue'
import router from '@/router'
import { DESKTOP_WIDTH, setViewportWidth } from './viewport'
export const holder: { wrapper: VueWrapper | null } = { wrapper: null }
export async function mountInApp(
component: Component,
props: Record<string, unknown> = {},
slots: Record<string, () => unknown> = {},
width = DESKTOP_WIDTH,
) {
setViewportWidth(width)
Element.prototype.scrollIntoView = () => undefined
const pinia = createPinia()
setActivePinia(pinia)
holder.wrapper = mount(
defineComponent({
render: () =>
h(UApp, null, {
default: () => h(component, props, slots as never),
}),
}),
{ attachTo: document.body, global: { plugins: [router, uiPlugin, pinia] } },
)
await flushPromises()
return holder.wrapper
}
export function unmountApp() {
holder.wrapper?.unmount()
holder.wrapper = null
document.body.innerHTML = ''
}
export function text(element: Element | null | undefined) {
if (!element) {
return ''
}
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
const parts: string[] = []
while (walker.nextNode()) {
const value = walker.currentNode.textContent?.replace(/\s+/g, ' ').trim()
if (value) {
parts.push(value)
}
}
return parts.join(' ')
}
export function byText(selector: string, label: string | RegExp, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll<HTMLElement>(selector)).find((element) => {
const value = element.getAttribute('aria-label') || text(element)
return typeof label === 'string' ? value === label : label.test(value)
})
}