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:
1 parent
b146dfead7
commit
162fea4afb
26 files changed
+805
-45
No files matched your search
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user