- useSidebar: collapsed below 1100px (matchMedia) or by the admin's choice, persisted as a boolean under summer-admin.sidebar; the viewport never overwrites the stored choice - SectionPanel collapse and rail expand buttons; SectionFlyout (role menu) opens on hover, focus, Enter or ArrowDown on a rail item, closes on Esc or about 200 ms after leaving and returns focus to the rail item - Breadcrumbs with plugin, controller and record crumbs; UserMenu (Reka DropdownMenu) with initials, name, role and Wyloguj - useAuth.logout POSTs /auth/logout, clears user, navigation and settings and routes to login even when the call fails - applyColorScheme toggles .dark from prefers-color-scheme (no toggle, A6) - newest toast first, 200 ms fade and scale for dialogs and toasts - one relation-manager type constant in the registry - shell smoke tests; tests default to a desktop, light matchMedia
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
|
import { setBundle } from '../../src/app/i18n'
|
|
import { applyColorScheme, DARK_QUERY } from '../../src/app/theme'
|
|
import { clearUser, currentUser } from '../../src/state/useAuth'
|
|
import { navigation, setNavigation } from '../../src/state/useNavigation'
|
|
import { setSettings } from '../../src/state/useSettings'
|
|
import { NARROW_QUERY, SIDEBAR_KEY } from '../../src/state/useSidebar'
|
|
import { clearToasts, showToast } from '../../src/state/useToasts'
|
|
import lang from '../fixtures/lang.json'
|
|
import formSchema from '../fixtures/widgets.form-schema.json'
|
|
import record from '../fixtures/widgets.record.json'
|
|
import listSchema from '../fixtures/widgets.list-schema.json'
|
|
import listRows from '../fixtures/widgets.list.json'
|
|
import { API, mountApp, requestsTo, type Route } from '../helpers'
|
|
|
|
const BASE = `${API}/acme/demo/widgets`
|
|
|
|
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
|
|
return {
|
|
[`GET ${BASE}/schema/list`]: { body: listSchema },
|
|
[`GET ${BASE}`]: { body: listRows },
|
|
[`GET ${BASE}/schema/form`]: { body: formSchema },
|
|
[`GET ${BASE}/1`]: { body: record },
|
|
[`POST ${API}/auth/logout`]: { body: { data: { status: 'logged_out' }, meta: {} } },
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
type Listener = (event: MediaQueryListEvent) => void
|
|
|
|
/** A matchMedia whose answers the test controls, with change events. */
|
|
function fakeMedia(initial: Record<string, boolean>) {
|
|
const state = { ...initial }
|
|
const listeners = new Map<string, Set<Listener>>()
|
|
const of = (query: string): Set<Listener> => {
|
|
let set = listeners.get(query)
|
|
if (!set) {
|
|
set = new Set()
|
|
listeners.set(query, set)
|
|
}
|
|
return set
|
|
}
|
|
const impl = (query: string): MediaQueryList =>
|
|
({
|
|
get matches() {
|
|
return state[query] ?? false
|
|
},
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: (_type: string, listener: Listener) => of(query).add(listener),
|
|
removeEventListener: (_type: string, listener: Listener) => of(query).delete(listener),
|
|
addListener: () => undefined,
|
|
removeListener: () => undefined,
|
|
dispatchEvent: () => false,
|
|
}) as unknown as MediaQueryList
|
|
vi.spyOn(window, 'matchMedia').mockImplementation(impl)
|
|
return {
|
|
change(query: string, matches: boolean): void {
|
|
state[query] = matches
|
|
for (const listener of of(query)) {
|
|
listener({ matches, media: query } as MediaQueryListEvent)
|
|
}
|
|
},
|
|
listeners: (query: string) => of(query).size,
|
|
}
|
|
}
|
|
|
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
|
|
function panel(wrapper: VueWrapper) {
|
|
return wrapper.find('aside')
|
|
}
|
|
|
|
function railItem(wrapper: VueWrapper, code = 'demo'): HTMLElement {
|
|
return wrapper.find(`[data-rail-item="${code}"]`).element as HTMLElement
|
|
}
|
|
|
|
function flyout(): HTMLElement | null {
|
|
return document.body.querySelector('[data-section-flyout]')
|
|
}
|
|
|
|
function key(target: Element, name: string): void {
|
|
target.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }))
|
|
}
|
|
|
|
beforeEach(() => {
|
|
clearUser()
|
|
setNavigation([])
|
|
setSettings([])
|
|
clearToasts()
|
|
setBundle(lang.data, 'pl')
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
document.documentElement.classList.remove('dark')
|
|
window.localStorage.clear()
|
|
})
|
|
|
|
// Registered after the cleanup above, so it runs first (hooks run as a
|
|
// stack): the app unmounts, with its timers and portals, before the body is
|
|
// cleared.
|
|
enableAutoUnmount(afterEach)
|
|
|
|
describe('collapsible section panel', () => {
|
|
it('persists the manual choice as a boolean under summer-admin.sidebar and restores it', async () => {
|
|
const first = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
|
expect(panel(first.wrapper).exists()).toBe(true)
|
|
expect(panel(first.wrapper).text()).toContain('Demo')
|
|
expect(first.wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
|
|
|
await first.wrapper.find('[data-sidebar-collapse]').trigger('click')
|
|
await flushPromises()
|
|
expect(panel(first.wrapper).exists()).toBe(false)
|
|
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('true')
|
|
expect(document.activeElement?.hasAttribute('data-sidebar-expand')).toBe(true)
|
|
first.wrapper.unmount()
|
|
|
|
const second = await mountApp('/acme/demo/widgets', routes())
|
|
expect(panel(second.wrapper).exists()).toBe(false)
|
|
const expand = second.wrapper.find('[data-sidebar-expand]')
|
|
expect(expand.attributes('aria-label')).toBe('Rozwiń menu')
|
|
await expand.trigger('click')
|
|
expect(panel(second.wrapper).exists()).toBe(true)
|
|
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
|
|
// The sidebar preference is the only thing in browser storage.
|
|
expect(Object.keys(window.localStorage)).toEqual([SIDEBAR_KEY])
|
|
})
|
|
|
|
it('collapses below 1100px without overwriting the stored preference', async () => {
|
|
window.localStorage.setItem(SIDEBAR_KEY, 'false')
|
|
const media = fakeMedia({ [NARROW_QUERY]: true })
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
|
|
|
expect(panel(wrapper).exists()).toBe(false)
|
|
// Expanding is impossible at this width, so no expand button.
|
|
expect(wrapper.find('[data-sidebar-expand]').exists()).toBe(false)
|
|
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
|
|
|
|
media.change(NARROW_QUERY, false)
|
|
await flushPromises()
|
|
expect(panel(wrapper).exists()).toBe(true)
|
|
expect(window.localStorage.getItem(SIDEBAR_KEY)).toBe('false')
|
|
|
|
wrapper.unmount()
|
|
expect(media.listeners(NARROW_QUERY)).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('section flyout', () => {
|
|
it('opens from a focused rail item with Enter, moves by arrows, and Esc returns focus', async () => {
|
|
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
|
const item = railItem(wrapper)
|
|
expect(item.getAttribute('aria-haspopup')).toBe('menu')
|
|
expect(item.getAttribute('aria-expanded')).toBe('false')
|
|
|
|
item.focus()
|
|
await flushPromises()
|
|
expect(flyout()).not.toBeNull()
|
|
key(item, 'Enter')
|
|
await flushPromises()
|
|
|
|
const menu = flyout()!
|
|
expect(menu.getAttribute('role')).toBe('menu')
|
|
expect(menu.getAttribute('aria-label')).toBe('Demo')
|
|
expect(item.getAttribute('aria-expanded')).toBe('true')
|
|
expect(item.getAttribute('aria-controls')).toBe(menu.id)
|
|
const items = [...menu.querySelectorAll<HTMLElement>('[role="menuitem"]')]
|
|
expect(items.map((element) => element.textContent?.trim())).toEqual(['Widgets', 'Gadgets'])
|
|
expect(items[0]!.getAttribute('aria-current')).toBe('page')
|
|
expect(document.activeElement).toBe(items[0])
|
|
|
|
key(items[0]!, 'ArrowDown')
|
|
expect(document.activeElement).toBe(items[1])
|
|
|
|
key(items[1]!, 'Escape')
|
|
await flushPromises()
|
|
expect(flyout()).toBeNull()
|
|
expect(document.activeElement).toBe(item)
|
|
// Returning focus does not reopen it.
|
|
await wait(250)
|
|
expect(flyout()).toBeNull()
|
|
})
|
|
|
|
it('opens with ArrowDown and on hover, and closes about 200 ms after mouse-leave', async () => {
|
|
window.localStorage.setItem(SIDEBAR_KEY, 'true')
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
|
|
|
const tools = railItem(wrapper, 'tools')
|
|
tools.focus()
|
|
key(tools, 'ArrowDown')
|
|
await flushPromises()
|
|
expect(flyout()!.getAttribute('aria-label')).toBe('Tools')
|
|
expect(document.activeElement?.textContent?.trim()).toBe('Hammers')
|
|
key(document.activeElement!, 'Escape')
|
|
await flushPromises()
|
|
|
|
const item = wrapper.find('[data-rail-item="demo"]')
|
|
await item.trigger('mouseenter')
|
|
expect(flyout()!.getAttribute('aria-label')).toBe('Demo')
|
|
await item.trigger('mouseleave')
|
|
await wait(100)
|
|
expect(flyout()).not.toBeNull()
|
|
// Moving onto the flyout keeps it open.
|
|
flyout()!.dispatchEvent(new MouseEvent('mouseenter'))
|
|
await wait(250)
|
|
expect(flyout()).not.toBeNull()
|
|
flyout()!.dispatchEvent(new MouseEvent('mouseleave'))
|
|
await wait(250)
|
|
await flushPromises()
|
|
expect(flyout()).toBeNull()
|
|
})
|
|
|
|
it('never opens while the panel is expanded', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
|
const item = railItem(wrapper)
|
|
item.focus()
|
|
key(item, 'Enter')
|
|
await wrapper.find('[data-rail-item="demo"]').trigger('mouseenter')
|
|
await flushPromises()
|
|
|
|
expect(flyout()).toBeNull()
|
|
expect(item.hasAttribute('aria-haspopup')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('header', () => {
|
|
it('shows plugin, controller and record crumbs on a record route', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
|
|
|
const crumbs = wrapper.findAll('header [data-crumb]')
|
|
expect(crumbs.map((crumb) => crumb.text())).toEqual(['Demo', 'Widgets', 'Blue widget'])
|
|
expect(crumbs[0]!.find('a').attributes('href')).toBe('/acme/demo/widgets')
|
|
expect(crumbs[1]!.find('a').attributes('href')).toBe('/acme/demo/widgets')
|
|
expect(crumbs[2]!.find('a').exists()).toBe(false)
|
|
expect(crumbs[2]!.find('[aria-current="page"]').text()).toBe('Blue widget')
|
|
expect(wrapper.findAll('header [data-crumb] svg')).toHaveLength(2)
|
|
})
|
|
|
|
it('ends on the controller crumb on a list route', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
|
|
|
const crumbs = wrapper.findAll('header [data-crumb]')
|
|
expect(crumbs.map((crumb) => crumb.text())).toEqual(['Demo', 'Widgets'])
|
|
expect(crumbs[1]!.find('[aria-current="page"]').text()).toBe('Widgets')
|
|
})
|
|
|
|
it('shows initials, name and role, and the menu with the e-mail and Wyloguj', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', routes(), { attach: true })
|
|
|
|
const trigger = wrapper.find('[data-user-menu]')
|
|
expect(trigger.find('[data-user-initials]').text()).toBe('DD')
|
|
expect(trigger.find('[data-user-identity]').text()).toContain('Dana Dev')
|
|
expect(trigger.find('[data-user-role]').text()).toBe('Developer')
|
|
expect(trigger.find('[data-user-identity]').classes()).toContain('max-[1099px]:hidden')
|
|
|
|
await trigger.trigger('keydown', { key: 'Enter' })
|
|
await flushPromises()
|
|
const menu = document.body.querySelector('[data-user-menu-content]')!
|
|
expect(menu.getAttribute('role')).toBe('menu')
|
|
expect(menu.textContent).toContain('dev@example.test')
|
|
expect(menu.querySelector('[data-action="logout"]')!.getAttribute('role')).toBe('menuitem')
|
|
expect(menu.querySelector('[data-action="logout"]')!.textContent?.trim()).toBe('Wyloguj')
|
|
})
|
|
|
|
it.each([
|
|
['succeeds', { body: { data: { status: 'logged_out' }, meta: {} } }],
|
|
['fails', { status: 500, body: { error: { code: 'server_error', message: 'Boom', details: {} } } }],
|
|
])('logs out when the call %s: POST /auth/logout, state cleared, login screen', async (_name, reply) => {
|
|
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets', routes({ [`POST ${API}/auth/logout`]: reply }), {
|
|
attach: true,
|
|
})
|
|
|
|
await wrapper.find('[data-user-menu]').trigger('keydown', { key: 'Enter' })
|
|
await flushPromises()
|
|
;(document.body.querySelector('[data-action="logout"]') as HTMLElement).click()
|
|
await flushPromises()
|
|
|
|
const [post] = requestsTo(calls, 'POST', `${API}/auth/logout`)
|
|
expect(post).toBeDefined()
|
|
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
|
expect(currentUser.value).toBeNull()
|
|
expect(navigation.value).toEqual([])
|
|
expect(router.currentRoute.value.name).toBe('login')
|
|
expect(wrapper.find('aside').exists()).toBe(false)
|
|
})
|
|
|
|
it('logs out to the login screen even when the network fails', async () => {
|
|
const { wrapper, router } = await mountApp('/acme/demo/widgets', routes({
|
|
[`POST ${API}/auth/logout`]: () => {
|
|
throw new TypeError('network down')
|
|
},
|
|
}), { attach: true })
|
|
|
|
await wrapper.find('[data-user-menu]').trigger('keydown', { key: 'Enter' })
|
|
await flushPromises()
|
|
;(document.body.querySelector('[data-action="logout"]') as HTMLElement).click()
|
|
await flushPromises()
|
|
|
|
expect(currentUser.value).toBeNull()
|
|
expect(router.currentRoute.value.name).toBe('login')
|
|
})
|
|
})
|
|
|
|
describe('theme and toasts', () => {
|
|
it('toggles the dark class from prefers-color-scheme and follows changes', () => {
|
|
const media = fakeMedia({ [DARK_QUERY]: true })
|
|
const stop = applyColorScheme()
|
|
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
|
|
|
media.change(DARK_QUERY, false)
|
|
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
|
media.change(DARK_QUERY, true)
|
|
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
|
|
|
stop()
|
|
expect(media.listeners(DARK_QUERY)).toBe(0)
|
|
})
|
|
|
|
it('stays light when the system prefers light', () => {
|
|
fakeMedia({ [DARK_QUERY]: false })
|
|
applyColorScheme()()
|
|
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
|
})
|
|
|
|
it('shows the most recent toast first', async () => {
|
|
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
|
|
showToast('First')
|
|
showToast('Second', 'danger')
|
|
await flushPromises()
|
|
|
|
const texts = wrapper.findAll('[data-tone]').map((toast) => toast.text())
|
|
expect(texts[0]).toContain('Second')
|
|
expect(texts[1]).toContain('First')
|
|
})
|
|
})
|