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

+1
View File
@@ -41,6 +41,7 @@ const navItems = computed(() =>
{ label: 'Articles', to: '/article', icon: 'i-lucide-file-text' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' },
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
@@ -58,14 +58,14 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('DashboardShell renders the four navigation destinations', () => {
it('lists Home, Articles, Users and Integration in order, with only Articles carrying aria-current', async () => {
describe('DashboardShell renders the five navigation destinations', () => {
it('lists Home, Articles, Users, Integration and Settings in order, with only Articles carrying aria-current', async () => {
const wrapper = await mountShell('/article')
const hrefs = wrapper
.findAll('nav[aria-label="Main"] a')
.map((link) => link.attributes('href'))
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration'])
expect(hrefs).toEqual(['/home', '/article', '/users', '/integration', '/settings'])
const home = wrapper.find('a[href="/home"]')
const articles = wrapper.find('a[href="/article"]')
@@ -78,6 +78,27 @@ describe('DashboardShell renders the four navigation destinations', () => {
expect(integration.attributes('aria-current')).toBeUndefined()
})
it('lists the same five 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
toggle.click()
await flushPromises()
const links = Array.from(
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
)
expect(links.map((link) => link.getAttribute('href'))).toEqual([
'/home',
'/article',
'/users',
'/integration',
'/settings',
])
expect(links[4].querySelector('[class*="lucide"], svg')).not.toBeNull()
expect(links[4].getAttribute('aria-current')).toBe('page')
})
it('marks Users with aria-current on /users', async () => {
const wrapper = await mountShell('/users')
@@ -211,11 +232,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 four navigation items current on /profile', async () => {
it('marks none of the five navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
expect(links).toHaveLength(5)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
+15
View File
@@ -0,0 +1,15 @@
import { useApi } from '@/composables/useApi'
export interface WorkspaceSettings {
version: string
}
export function useSettings() {
const { request } = useApi()
function getSettings(): Promise<WorkspaceSettings> {
return request<WorkspaceSettings>('/v1/settings/')
}
return { getSettings }
}
+8 -22
View File
@@ -65,10 +65,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
it('The first navigation to a non-public route resolves the session and shows AppBoot meanwhile', async () => {
let resolveMe: (value: { verified: boolean }) => void = () => {}
const getMe = vi.fn(
() =>
new Promise<{ verified: boolean }>(
(resolve) => (resolveMe = resolve),
),
() => new Promise<{ verified: boolean }>((resolve) => (resolveMe = resolve)),
)
vi.mocked(useApi).mockReturnValue({ getMe })
setUpSession()
@@ -124,9 +121,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
await router.push('/article')
mountApp()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
})
it('A session-only route admits an unverified session', async () => {
@@ -201,9 +196,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
)
expect(app.text()).toContain('Home')
const primaryButton = app
.findAll('button')
.find((button) => button.text() === 'Try again')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
@@ -219,16 +212,12 @@ describe('Nuxt UI shell foundation and router guards', () => {
const app = mountApp()
await vi.waitFor(() =>
expect(app.find('[role="alert"] h1').text()).toBe(
'Something went wrong',
),
expect(app.find('[role="alert"] h1').text()).toBe('Something went wrong'),
)
expect(router.currentRoute.value.path).toBe('/article')
const primaryButton = app
.findAll('button')
.find((button) => button.text() === 'Try again')
const primaryButton = app.findAll('button').find((button) => button.text() === 'Try again')
await primaryButton?.trigger('click')
await flushPromises()
@@ -239,6 +228,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
{ path: '/home', title: 'Home' },
{ path: '/article', title: 'Articles' },
{ path: '/integration', title: 'Integration' },
{ path: '/settings', title: 'Settings' },
])(
'Placeholder verified route $path renders inside DashboardShell with title $title',
async ({ path, title }) => {
@@ -251,14 +241,10 @@ describe('Nuxt UI shell foundation and router guards', () => {
await flushPromises()
const headings = app.findAll('h1')
expect(headings.some((heading) => heading.text() === title)).toBe(
true,
)
expect(headings.some((heading) => heading.text() === title)).toBe(true)
expect(app.find('nav[aria-label="breadcrumb"]').text()).toBe(title)
const activeLink = app.find(
`a[href="${path}"][aria-current="page"]`,
)
const activeLink = app.find(`a[href="${path}"][aria-current="page"]`)
expect(activeLink.exists()).toBe(true)
},
)
+5
View File
@@ -86,6 +86,11 @@ const router = createRouter({
component: () => import('@/views/IntegrationView.vue'),
meta: { auth: 'verified', title: 'Integration' },
},
{
path: '/settings',
component: () => import('@/views/SettingsView.vue'),
meta: { auth: 'verified', title: 'Settings' },
},
{
path: '/:pathMatch(.*)*',
component: () => import('@/views/NotFoundView.vue'),
+111
View File
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import DashboardShell from '@/components/DashboardShell.vue'
import { ApiError } from '@/composables/useApi'
import { useSettings } from '@/composables/useSettings'
const SKELETON_DELAY_MS = 150
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
type LoadState = 'loading' | 'ready' | 'error'
const { getSettings } = useSettings()
const loadState = ref<LoadState>('loading')
const version = ref('')
const showSkeleton = ref(false)
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
function clearSkeletonTimer() {
if (skeletonTimer !== null) {
clearTimeout(skeletonTimer)
skeletonTimer = null
}
}
function isHandledGlobally(error: unknown) {
return error instanceof ApiError && (error.status === 401 || error.status === 403)
}
async function loadSettings() {
loadState.value = 'loading'
showSkeleton.value = false
clearSkeletonTimer()
skeletonTimer = setTimeout(() => {
showSkeleton.value = true
}, SKELETON_DELAY_MS)
try {
const settings = await getSettings()
version.value = settings.version
loadState.value = 'ready'
} catch (error) {
if (!isHandledGlobally(error)) {
loadState.value = 'error'
}
} finally {
clearSkeletonTimer()
showSkeleton.value = false
}
}
onMounted(loadSettings)
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<DashboardShell>
<div class="flex max-w-180 flex-col gap-5">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
<p class="text-muted">Workspace information.</p>
</div>
<div
data-testid="settings-card"
:aria-busy="loadState === 'loading' && showSkeleton ? 'true' : undefined"
class="border border-default bg-default p-4 lg:p-5"
>
<UAlert
v-if="loadState === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:description="UNREACHABLE_COPY"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-8"
:ui="{ leadingIcon: 'size-3.5' }"
@click="loadSettings"
>
Try again
</UButton>
</template>
</UAlert>
<div v-else class="flex items-center justify-between gap-4">
<span class="text-lg font-semibold text-highlighted">Version</span>
<USkeleton
v-if="loadState === 'loading' && showSkeleton"
data-testid="settings-skeleton"
class="h-5 w-24 rounded-none bg-accented"
/>
<span
v-else-if="loadState === 'ready'"
data-testid="settings-version"
class="font-mono text-base text-toned"
>{{ version }}</span
>
</div>
</div>
</div>
</DashboardShell>
</template>
+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()
})
})