feat(SPA): profile page, display name and the new account menu

This commit is contained in:
fsylvain committed 2026-09-29 10:13:09 +02:00
1 parent 55525c06d6
commit b0f7b7892b
29 files changed
+3166 -469

No files matched your search

@@ -15,16 +15,8 @@ describe('Below the warning threshold', () => {
const wrapper = mountCounter(4318)
expect(wrapper.text()).toContain('4 318 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(false)
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(false)
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(false)
})
})
@@ -33,11 +25,7 @@ describe('Near the limit — 90% or more', () => {
const wrapper = mountCounter(9240)
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('characters over')
})
})
@@ -47,13 +35,31 @@ describe('Too long — over the max', () => {
const wrapper = mountCounter(10214)
expect(wrapper.text()).toContain('10 214 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(true)
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(true)
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
})
})
describe('The field variant is a bare mono count', () => {
function mountField(value: number) {
return mount(CharacterCounter, {
props: { value, max: 60, variant: 'field' },
global: { plugins: [uiPlugin] },
})
}
it.each([
[0, 'text-muted'],
[60, 'text-muted'],
[61, 'text-error'],
])('shows %i / 60 with the %s color and no icon or message', (value, colorClass) => {
const wrapper = mountField(value)
expect(wrapper.text()).toBe(`${value} / 60`)
expect(wrapper.classes()).toContain(colorClass)
expect(wrapper.classes()).toContain('font-mono')
expect(wrapper.find('svg').exists()).toBe(false)
})
})
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
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 { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -14,10 +15,27 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
}
})
async function mountShell(path: string, username = 'marta') {
interface ShellOptions {
username?: string
displayName?: string
email?: string
width?: number
}
let mountedShell: VueWrapper | null = null
async function mountShell(path: string, options: ShellOptions = {}) {
const {
username = 'marta',
displayName = '',
email = 'marta@renew.example',
width = DESKTOP_WIDTH,
} = options
localStorage.clear()
setViewportWidth(width)
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username }),
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
@@ -27,11 +45,14 @@ async function mountShell(path: string, username = 'marta') {
plugins: [router, uiPlugin],
},
})
mountedShell = wrapper
await flushPromises()
return wrapper
}
afterEach(async () => {
mountedShell?.unmount()
mountedShell = null
await router.push('/login')
document.body.innerHTML = ''
})
@@ -166,3 +187,112 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
})
describe('The account chip shows the display name and no item is current on /profile', () => {
it('shows the display name and its initials in the sidebar chip', async () => {
await mountShell('/home', { displayName: 'Marta Alves' })
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).toContain('Marta Alves')
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
it('keeps the collapsed accessible name on the display name', async () => {
await mountShell('/home', { displayName: 'Marta Alves' })
const collapseButton = document.body.querySelector(
'[data-testid="sidebar-collapse"]',
) as HTMLButtonElement
collapseButton.click()
await flushPromises()
const trigger = document.body.querySelector('[data-slot="footer"] button[aria-haspopup]')
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the four navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
describe('Account menu: opened from the drawer on mobile', () => {
it('closes the drawer and opens a bottom sheet with a grab handle, the header and 48 px items', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
const accountRow = document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
expect(accountRow).not.toBeNull()
accountRow.click()
await flushPromises()
const sheets = Array.from(document.body.querySelectorAll('[role="dialog"]'))
expect(sheets).toHaveLength(1)
const menu = document.body.querySelector('[role="menu"]') as HTMLElement
expect(
menu.querySelector('[aria-hidden="true"].w-9.h-1, [aria-hidden="true"].h-1'),
).not.toBeNull()
expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(menu.textContent).toContain('marta@renew.example')
const labels = Array.from(
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
).map((item) => item.textContent?.trim())
expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) =>
expect(item.className).toContain('h-12'),
)
})
it('navigates to /profile and closes the sheet from Profile', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
await flushPromises()
;(
document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
).click()
await flushPromises()
const profile = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
profile.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.querySelector('[role="menu"]')).toBeNull()
})
it('logs out from the sheet', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
await flushPromises()
;(
document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
).click()
await flushPromises()
const logOut = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Log out')!
logOut.click()
await flushPromises()
expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
'/v1/auth/logout',
expect.objectContaining({ method: 'POST' }),
)
expect(router.currentRoute.value.path).toBe('/login')
})
})
+259 -130
View File
@@ -1,175 +1,304 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { useSessionStore } from '@/stores/session'
import router from '@/router'
import UserMenu from '../UserMenu.vue'
import { useColorMode } from '@/composables/useColorMode'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
function mountMenu(props: { username: string; collapsed: boolean }) {
interface MenuProps {
username: string
displayName?: string
email?: string
collapsed: boolean
}
const baseProps: MenuProps = {
username: 'marta',
displayName: '',
email: 'marta@renew.example',
collapsed: false,
}
async function mountMenu(props: Partial<MenuProps> = {}, path = '/home') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(path)
return mount(UserMenu, {
attachTo: document.body,
props,
global: {
plugins: [uiPlugin],
},
props: { ...baseProps, ...props },
global: { plugins: [router, uiPlugin] },
})
}
function findTriggerButton() {
return document.body.querySelector('button')
function chip() {
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
}
async function openMenu() {
findTriggerButton()?.click()
chip().click()
await flushPromises()
}
function findMenuItemsByRole(role: string) {
return Array.from(document.body.querySelectorAll(`[role="${role}"]`))
function menu() {
return document.body.querySelector('[role="menu"]') as HTMLElement | null
}
function findCheckboxItem(label: string) {
return findMenuItemsByRole('menuitemcheckbox').find(
(el) => el.textContent?.trim() === label,
)
function itemsIn(container: ParentNode) {
return Array.from(
container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
) as HTMLElement[]
}
function itemLabels() {
return itemsIn(menu()!).map((el) => el.textContent?.trim())
}
function findItem(label: string) {
return itemsIn(menu()!).find((el) => el.textContent?.trim() === label) as HTMLElement
}
function activate(element: HTMLElement) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
setViewportWidth(DESKTOP_WIDTH)
})
afterEach(() => {
afterEach(async () => {
document.body.innerHTML = ''
await router.push('/login')
})
describe("UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out", () => {
it('shows avatar initials MA, the username, and the three ordered groups', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false })
describe('The chip opens the account menu instead of logging out', () => {
it('exposes aria-haspopup, aria-expanded, the title and the name, and opens on activation', async () => {
const wrapper = await mountMenu()
const avatar = document.body.querySelector('[data-slot="fallback"]')
expect(avatar?.textContent).toBe('MA')
expect(avatar?.getAttribute('aria-hidden')).toBe('true')
expect(wrapper.text()).toContain('marta')
expect(chip().getAttribute('aria-haspopup')).toBe('menu')
expect(chip().getAttribute('aria-expanded')).toBe('false')
expect(chip().getAttribute('title')).toBe('Account')
expect(chip().textContent).toContain('marta')
expect(wrapper.emitted('logout')).toBeUndefined()
await openMenu()
const groups = findMenuItemsByRole('group')
expect(groups.length).toBe(3)
expect(groups[0].textContent).toContain('marta')
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
(el) => el.textContent?.trim(),
)
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
expect(groups[2].textContent?.trim()).toBe('Log out')
wrapper.unmount()
})
it('derives initials from the first letter of the first two words for a multi-word username', () => {
const wrapper = mountMenu({ username: 'jane doe', collapsed: false })
const avatar = document.body.querySelector('[data-slot="fallback"]')
expect(avatar?.textContent).toBe('JD')
wrapper.unmount()
})
it('checks exactly the item matching the current color-mode preference', async () => {
const colorMode = useColorMode()
colorMode.preference = 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
'false',
)
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
expect(chip().getAttribute('aria-expanded')).toBe('true')
expect(menu()).not.toBeNull()
expect(wrapper.emitted('logout')).toBeUndefined()
})
})
describe('Selecting a color-mode item updates the preference and its checked state', () => {
it.each([
['system', 'light'],
['light', 'dark'],
['dark', 'system'],
])('moves from %s to %s', async (from, to) => {
const colorMode = useColorMode()
colorMode.preference = from as 'system' | 'light' | 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
describe('Account menu: opened on desktop', () => {
it('lists the header, Profile, System, Light, Dark and Log out with a divider between its three groups', async () => {
await mountMenu()
await openMenu()
const label = to.charAt(0).toUpperCase() + to.slice(1)
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('pointerup', { bubbles: true }),
)
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
)
await flushPromises()
expect(colorMode.preference).toBe(to)
await openMenu()
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
})
})
describe('A successful logout clears the session and navigates to /login', () => {
it('emits logout when the user activates Log out', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
const logOutItem = Array.from(
document.body.querySelectorAll('[role="menuitem"]'),
).find((el) => el.textContent?.trim() === 'Log out')
;(logOutItem as HTMLElement)?.click()
await flushPromises()
expect(wrapper.emitted('logout')).toBeTruthy()
wrapper.unmount()
})
})
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
it('hides the visible username but keeps it in the accessible name, and opens the same dropdown', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: true })
expect(wrapper.text()).not.toContain('marta')
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
'marta — account menu',
)
const header = menu()!.querySelector('[role="group"]')!
expect(header.textContent).toContain('marta')
expect(header.textContent).toContain('marta@renew.example')
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
expect(
document.body.querySelector('[data-slot="fallback"]')?.textContent,
).toBe('MA')
menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
).toBe(true)
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3)
})
it('shows the display name in the header and the chip when there is one', async () => {
await mountMenu({ displayName: 'Marta Alves' })
await openMenu()
expect(chip().textContent).toContain('Marta Alves')
expect(menu()!.textContent).toContain('Marta Alves')
expect(menu()!.textContent).toContain('marta@renew.example')
})
it('does not highlight Profile away from /profile', async () => {
await mountMenu()
await openMenu()
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(false)
})
it('gives the chip the sand fill only while the menu is open', async () => {
await mountMenu()
expect(chip().className).not.toContain('bg-secondary-200')
await openMenu()
const groups = findMenuItemsByRole('group')
expect(groups.length).toBe(3)
expect(groups[0].textContent).toContain('marta')
expect(chip().className).toContain('bg-secondary-200')
expect(chip().className).toContain('text-primary-900')
})
})
wrapper.unmount()
describe('The chip is sand while on /profile even with the menu closed', () => {
it('keeps the sand fill on /profile and drops it elsewhere', async () => {
await mountMenu({}, '/profile')
expect(chip().className).toContain('bg-secondary-200')
expect(chip().getAttribute('aria-expanded')).toBe('false')
await router.push('/home')
await flushPromises()
expect(chip().className).not.toContain('bg-secondary-200')
})
})
describe('Closing the menu', () => {
it('closes on Escape and returns focus to the chip', async () => {
await mountMenu()
await openMenu()
menu()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(menu()).toBeNull()
expect(chip().getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(chip())
})
it('closes on a click outside', async () => {
await mountMenu()
await openMenu()
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(menu()).toBeNull()
expect(chip().getAttribute('aria-expanded')).toBe('false')
})
it('moves between items with the arrow keys', async () => {
await mountMenu()
await openMenu()
const first = itemsIn(menu()!)[0]!
first.focus()
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
await flushPromises()
expect(document.activeElement).toBe(itemsIn(menu()!)[1])
document.activeElement?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
)
await flushPromises()
expect(document.activeElement).toBe(itemsIn(menu()!)[0])
})
})
describe('Menu items', () => {
it('navigates to /profile and closes the menu from Profile', async () => {
await mountMenu()
await openMenu()
activate(findItem('Profile'))
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(menu()).toBeNull()
})
it('highlights Profile on /profile and only closes when activated there', async () => {
await mountMenu({}, '/profile')
await openMenu()
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(true)
activate(findItem('Profile'))
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(menu()).toBeNull()
})
it('emits logout from Log out', async () => {
const wrapper = await mountMenu()
await openMenu()
activate(findItem('Log out'))
await flushPromises()
expect(wrapper.emitted('logout')).toHaveLength(1)
})
})
describe('Theme choice stays in the menu', () => {
it('checks the current preference, sets Dark, closes, and shows Dark checked on reopening', async () => {
const colorMode = useColorMode()
colorMode.preference = 'system'
await mountMenu()
await openMenu()
expect(findItem('System').getAttribute('aria-checked')).toBe('true')
activate(findItem('Dark'))
await flushPromises()
expect(colorMode.preference).toBe('dark')
expect(menu()).toBeNull()
await openMenu()
expect(findItem('Dark').getAttribute('aria-checked')).toBe('true')
expect(findItem('System').getAttribute('aria-checked')).toBe('false')
})
})
describe('Avatar initials', () => {
it.each([
['marta', '', 'MA', 'marta'],
['marta', 'Marta Alves', 'MA', 'Marta Alves'],
['marta', 'Marta Alves Costa', 'MA', 'Marta Alves Costa'],
['ines', 'Inès Duarte', 'ID', 'Inès Duarte'],
['elodie', 'Élodie Martin', 'ÉM', 'Élodie Martin'],
['marta', 'Marta', 'MA', 'Marta'],
['m.alves', '', 'M.', 'm.alves'],
])('shows %s / %j as %s and the name %s', async (username, displayName, initials, shown) => {
await mountMenu({ username, displayName })
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe(initials)
expect(chip().textContent).toContain(shown)
await openMenu()
expect(menu()!.textContent).toContain(shown)
})
})
describe('The collapsed rail keeps the menu and an accessible name', () => {
it('shows only the avatar, names the chip and opens the same menu', async () => {
await mountMenu({ displayName: 'Marta Alves', collapsed: true })
expect(chip().textContent).not.toContain('Marta Alves')
expect(chip().getAttribute('aria-label')).toBe('Marta Alves — account menu')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
await openMenu()
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
})
})
describe('Below lg the chip hands the menu to the bottom sheet', () => {
it('emits open-sheet instead of opening the popover', async () => {
setViewportWidth(MOBILE_WIDTH)
const wrapper = await mountMenu()
await openMenu()
expect(wrapper.emitted('open-sheet')).toHaveLength(1)
expect(menu()).toBeNull()
})
})
+30
View File
@@ -0,0 +1,30 @@
const REM_PX = 16
function widthLimit(query: string, feature: 'min-width' | 'max-width') {
const match = query.match(new RegExp(`\\(${feature}:\\s*([\\d.]+)(rem|px)\\)`))
if (!match) {
return null
}
return Number(match[1]) * (match[2] === 'rem' ? REM_PX : 1)
}
export function setViewportWidth(width: number) {
window.matchMedia = ((query: string) => {
const min = widthLimit(query, 'min-width')
const max = widthLimit(query, 'max-width')
const matches = (min === null || width >= min) && (max === null || width <= max)
return {
matches,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}
}) as unknown as typeof window.matchMedia
}
export const DESKTOP_WIDTH = 1440
export const MOBILE_WIDTH = 390