feat: settings page, sidebar and drawer destination "Settings" (icon settings, after Integration), route /settings (verified), showing only the running build's Version

This commit is contained in:
fsylvain committed 2026-09-30 00:19:29 +02:00
1 parent b146dfead7
commit 162fea4afb
26 files changed
+805 -45

No files matched your search

+2 -2
View File
@@ -279,11 +279,11 @@ describe('Default: the usual first visit with no display name', () => {
expect(request).not.toHaveBeenCalled()
})
it('marks none of the four navigation items as the current page', async () => {
it('marks none of the five navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
expect(links).toHaveLength(5)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
@@ -0,0 +1,183 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
const SETTINGS_PATH = '/v1/settings/'
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
let requestMock: ReturnType<typeof vi.fn>
let wrapper: VueWrapper | null = null
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
async function mountPage(settings: () => Promise<unknown>) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1 }
session.status = 'ready'
requestMock = vi.fn((path: string) => {
if (path === SETTINGS_PATH) {
return settings()
}
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
request: requestMock,
})
await router.push('/settings')
wrapper = mount(SettingsView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function card() {
return document.body.querySelector('[data-testid="settings-card"]')!
}
function versionValue() {
return document.body.querySelector('[data-testid="settings-version"]')
}
function settingsCalls() {
return requestMock.mock.calls.filter(([path]) => path === SETTINGS_PATH)
}
function tryAgainButton() {
return Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Try again',
)
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Default: the Version row shows the server version', () => {
it('shows the breadcrumb, the header, the label and the value, with Settings current', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }))
expect(
document.body.querySelector('nav[aria-label="breadcrumb"]')?.textContent?.trim(),
).toBe('Settings')
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Settings')
expect(document.body.textContent).toContain('Workspace information.')
expect(card().textContent).toContain('Version')
expect(versionValue()?.textContent?.trim()).toBe('v2.2.0-beta')
expect(card().hasAttribute('aria-busy')).toBe(false)
expect(settingsCalls()).toHaveLength(1)
const current = document.body.querySelectorAll(
'nav[aria-label="Main"] a[aria-current="page"]',
)
expect(current).toHaveLength(1)
expect(current[0].getAttribute('href')).toBe('/settings')
})
it('shows the value exactly as reported, with no prefix added', async () => {
await mountPage(() => Promise.resolve({ version: 'dev' }))
expect(versionValue()?.textContent?.trim()).toBe('dev')
})
it('holds no action, link or input inside the card', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }))
expect(card().querySelector('button, a, input')).toBeNull()
})
})
describe('Router meta', () => {
it('declares /settings as a verified route titled Settings', () => {
const route = router.getRoutes().find((candidate) => candidate.path === '/settings')
expect(route?.meta.auth).toBe('verified')
expect(route?.meta.title).toBe('Settings')
})
})
describe('Loading: skeleton for the value after 150 ms', () => {
it('shows the label at once, a skeleton from 150 ms, then the value', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const pending = deferred<{ version: string }>()
await mountPage(() => pending.promise)
expect(card().textContent).toContain('Version')
vi.advanceTimersByTime(149)
await flushPromises()
expect(card().hasAttribute('aria-busy')).toBe(false)
expect(card().querySelector('.animate-pulse')).toBeNull()
vi.advanceTimersByTime(1)
await flushPromises()
expect(card().getAttribute('aria-busy')).toBe('true')
expect(card().querySelector('.animate-pulse')).not.toBeNull()
expect(card().textContent).toContain('Version')
pending.resolve({ version: 'v2.2.0-beta' })
await flushPromises()
expect(card().querySelector('.animate-pulse')).toBeNull()
expect(card().hasAttribute('aria-busy')).toBe(false)
expect(versionValue()?.textContent?.trim()).toBe('v2.2.0-beta')
})
})
describe('Error: a 500 or a network failure replaces the row with an alert and a retry', () => {
it.each([
{ name: 'a 500 response', failure: () => Promise.reject(new ApiError(500)) },
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
])('shows the alert and recovers after Try again on $name', async ({ failure }) => {
await mountPage(failure)
const alert = card().querySelector('[role="alert"]')!
expect(alert.textContent).toContain(UNREACHABLE_COPY)
expect(alert.querySelector('button[aria-label="Close"]')).toBeNull()
expect(tryAgainButton()).toBeTruthy()
expect(card().textContent).not.toContain('Version')
expect(document.body.querySelector('h1')?.textContent?.trim()).toBe('Settings')
expect(document.body.textContent).toContain('Workspace information.')
requestMock.mockImplementation(() => Promise.resolve({ version: 'v2.2.0-beta' }))
tryAgainButton()?.click()
await flushPromises()
expect(settingsCalls()).toHaveLength(2)
expect(card().querySelector('[role="alert"]')).toBeNull()
expect(versionValue()?.textContent?.trim()).toBe('v2.2.0-beta')
})
})
describe('A 401 or an unverified 403 is handled globally, not by the page', () => {
it.each([401, 403])('shows no error alert of its own on a %i', async (status) => {
await mountPage(() => Promise.reject(new ApiError(status)))
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(tryAgainButton()).toBeUndefined()
})
})