test(quick-261022-k9k): keyboard shortcut coverage
- hotkey core: combos, AltGr and layouts, labels, aria-keyshortcuts, editable targets, modal root, refcounted install, disabled and repeat - smoke: record, create, Create and new (new session key), Esc, modal scope, settings Save and close, My account, list search and the help dialog - ShortcutHelpDialog rows, keys and close button
This commit is contained in:
@@ -1,27 +1,45 @@
|
||||
// Keyboard shortcuts on the whole app (mounted with attachTo, since the
|
||||
// shortcut listener reads the document): the save keys on record forms.
|
||||
// shortcut listener reads the document): save keys on record forms, settings
|
||||
// forms and My account, Create and new, Esc, the list search slash, the help
|
||||
// dialog and modal forms taking the save keys from the page underneath.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises } from '@vue/test-utils'
|
||||
import { enableAutoUnmount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { SESSION_HEADER } from '../../src/app/sessionKey'
|
||||
import { setBundle } from '../../src/app/i18n'
|
||||
import { clearUser } from '../../src/state/useAuth'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { loadSettings, setSettings } from '../../src/state/useSettings'
|
||||
import { clearToasts } 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 { API, mountApp, requestsTo, type Route } from '../helpers'
|
||||
import listSchema from '../fixtures/widgets.list-schema.json'
|
||||
import listRows from '../fixtures/widgets.list.json'
|
||||
import relationSchema from '../fixtures/widgets.relation-schema.json'
|
||||
import linked from '../fixtures/widgets.relation-linked.json'
|
||||
import candidates from '../fixtures/widgets.relation-candidates.json'
|
||||
import settings from '../fixtures/settings.json'
|
||||
import { API, mockApi, mountApp, press, profile, requestsTo, wait, type Reply, type Route } from '../helpers'
|
||||
|
||||
const RECORD = `${API}/acme/demo/widgets/1`
|
||||
const BASE = `${API}/acme/demo/widgets`
|
||||
const RECORD = `${BASE}/1`
|
||||
const LINKED = `${RECORD}/relations/members`
|
||||
const SETTING = `${API}/settings/search`
|
||||
const ACCOUNT = `${API}/account`
|
||||
|
||||
const formRoutes: Record<string, Route> = {
|
||||
[`GET ${API}/acme/demo/widgets/schema/form`]: { body: formSchema },
|
||||
[`GET ${BASE}/schema/form`]: { body: formSchema },
|
||||
[`GET ${RECORD}`]: { body: record },
|
||||
[`PUT ${RECORD}`]: { body: record },
|
||||
[`POST ${BASE}`]: { body: record },
|
||||
[`GET ${BASE}/schema/list`]: { body: listSchema },
|
||||
[`GET ${BASE}`]: { body: listRows },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
setSettings([])
|
||||
clearToasts()
|
||||
setBundle(lang.data, 'pl')
|
||||
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64')
|
||||
@@ -36,18 +54,30 @@ afterEach(() => {
|
||||
// is cleared.
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function press(target: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
const ctrl = (key: string, init: KeyboardEventInit = {}) => press(document.body, key, { ctrlKey: true, ...init })
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await flushPromises()
|
||||
await wait(0)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function confirmDialog(): HTMLElement | null {
|
||||
return document.body.querySelector('[data-confirm-dialog]')
|
||||
}
|
||||
|
||||
function helpRows(): string[] {
|
||||
return [...document.body.querySelectorAll<HTMLElement>('[data-shortcut-help] [data-shortcut]')].map(
|
||||
(row) => row.dataset.shortcut ?? '',
|
||||
)
|
||||
}
|
||||
|
||||
describe('record form save shortcuts', () => {
|
||||
it('saves an update form on Ctrl+S and Cmd+S without the browser Save page', async () => {
|
||||
const { calls } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
const ctrl = press(document.body, 's', { ctrlKey: true })
|
||||
expect(ctrl.defaultPrevented).toBe(true)
|
||||
const event = ctrl('s')
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
|
||||
@@ -57,12 +87,342 @@ describe('record form save shortcuts', () => {
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the shortcut in the save button tooltip', async () => {
|
||||
it('shows the shortcuts in the save button tooltips', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
const save = wrapper.get('[data-action="save"]')
|
||||
expect(save.attributes('title')).toBe('Zapisz (Ctrl+S)')
|
||||
expect(save.attributes('aria-keyshortcuts')).toBe('Control+S Meta+S')
|
||||
expect(wrapper.get('[data-action="save-close"]').attributes('title')).toBe('Zapisz i zamknij (Ctrl+Enter)')
|
||||
expect(wrapper.get('[data-action="cancel"]').attributes('aria-keyshortcuts')).toBe('Escape')
|
||||
})
|
||||
|
||||
it('saves on Ctrl+S while typing in a text input or a textarea', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
expect(press(wrapper.get('#field-name').element, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(press(wrapper.get('#field-notes').element, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('sends nothing while the record is still loading', async () => {
|
||||
let release: (reply: Reply) => void = () => undefined
|
||||
const pending = new Promise<Reply>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const { calls } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
{ ...formRoutes, [`GET ${RECORD}`]: () => pending },
|
||||
{ attach: true },
|
||||
)
|
||||
|
||||
expect(ctrl('s').defaultPrevented).toBe(true)
|
||||
ctrl('Enter')
|
||||
await flushPromises()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
|
||||
|
||||
release({ body: record })
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('saves and closes an update form on Ctrl+Enter', async () => {
|
||||
const { calls, router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
expect(ctrl('Enter').defaultPrevented).toBe(true)
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
|
||||
})
|
||||
|
||||
it('creates on Ctrl+S and follows create.redirect', async () => {
|
||||
const { calls, router } = await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
|
||||
|
||||
ctrl('s')
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
|
||||
})
|
||||
|
||||
it('creates and closes on Ctrl+Enter and follows create.redirectClose', async () => {
|
||||
const { calls, router } = await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
|
||||
|
||||
ctrl('Enter')
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'POST', BASE)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
|
||||
})
|
||||
|
||||
it('shows Create and new on the create form only', async () => {
|
||||
const create = await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
|
||||
const button = create.wrapper.get('[data-action="save-new"]')
|
||||
expect(button.text()).toBe('Utwórz i nowy')
|
||||
expect(button.attributes('title')).toBe('Utwórz i nowy (Ctrl+Shift+S)')
|
||||
expect(button.attributes('aria-keyshortcuts')).toBe('Control+Shift+S Meta+Shift+S')
|
||||
create.wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
const update = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
expect(update.wrapper.find('[data-action="save-new"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the button', (wrapper: VueWrapper) => (wrapper.get('[data-action="save-new"]').element as HTMLElement).click()],
|
||||
['Ctrl+Shift+S', () => ctrl('S', { shiftKey: true })],
|
||||
])('Create and new through %s saves and opens an empty create form with a new session', async (_name, trigger) => {
|
||||
const { wrapper, calls, router } = await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
|
||||
await wrapper.get('#field-name').setValue('Fresh widget')
|
||||
|
||||
trigger(wrapper)
|
||||
await settle()
|
||||
|
||||
const [first] = requestsTo(calls, 'POST', BASE)
|
||||
expect(first).toBeDefined()
|
||||
expect(((await first!.clone().json()) as { name: string }).name).toBe('Fresh widget')
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/create')
|
||||
expect((wrapper.get('#field-name').element as HTMLInputElement).value).toBe('')
|
||||
|
||||
ctrl('s')
|
||||
await settle()
|
||||
const posts = requestsTo(calls, 'POST', BASE)
|
||||
expect(posts).toHaveLength(2)
|
||||
const firstKey = posts[0]!.headers.get(SESSION_HEADER)
|
||||
const secondKey = posts[1]!.headers.get(SESSION_HEADER)
|
||||
expect(firstKey).toBeTruthy()
|
||||
expect(secondKey).toBeTruthy()
|
||||
expect(secondKey).not.toBe(firstKey)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Esc on a record form', () => {
|
||||
it('leaves a clean form for its back path', async () => {
|
||||
const { router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
expect(press(document.body, 'Escape').defaultPrevented).toBe(true)
|
||||
await settle()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets')
|
||||
})
|
||||
|
||||
it('asks before leaving a dirty form, and Esc on the question closes only the question', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
await wrapper.get('#field-name').setValue('Changed')
|
||||
|
||||
press(document.body, 'Escape')
|
||||
await settle()
|
||||
expect(confirmDialog()).not.toBeNull()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
|
||||
|
||||
press(document.body, 'Escape')
|
||||
await settle()
|
||||
expect(confirmDialog()).toBeNull()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
|
||||
})
|
||||
|
||||
it('does nothing in an input or after a component handled the key', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
expect(press(wrapper.get('#field-name').element, 'Escape').defaultPrevented).toBe(false)
|
||||
await settle()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
|
||||
|
||||
const handler = document.createElement('div')
|
||||
handler.addEventListener('keydown', (event) => event.preventDefault())
|
||||
document.body.appendChild(handler)
|
||||
press(handler, 'Escape')
|
||||
await settle()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('modal forms', () => {
|
||||
const relationRoutes: Record<string, Route> = {
|
||||
...formRoutes,
|
||||
[`GET ${BASE}/schema/relation/members`]: { body: relationSchema },
|
||||
[`GET ${LINKED}`]: { body: linked.all },
|
||||
[`GET ${LINKED}/candidates`]: { body: candidates.page1 },
|
||||
[`POST ${LINKED}/link`]: async (request) => ({
|
||||
body: { data: { linked: ((await request.json()) as { ids: number[] }).ids.length }, meta: {} },
|
||||
}),
|
||||
}
|
||||
|
||||
async function openPicker(wrapper: VueWrapper): Promise<HTMLElement> {
|
||||
const tab = wrapper.findAll('[role="tab"]').find((item) => item.text() === 'Members')
|
||||
await tab!.trigger('click')
|
||||
await flushPromises()
|
||||
;(wrapper.get('[data-relation-manager] [data-action="link"]').element as HTMLElement).click()
|
||||
await settle()
|
||||
return document.body.querySelector<HTMLElement>('[data-relation-picker]')!
|
||||
}
|
||||
|
||||
it('submits the picker on Ctrl+S and never saves the record underneath', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', relationRoutes, { attach: true })
|
||||
const picker = await openPicker(wrapper)
|
||||
const confirm = picker.querySelector('[data-action="confirm-link"]')!
|
||||
expect(confirm.getAttribute('aria-keyshortcuts')).toBe('Control+S Meta+S Control+Enter Meta+Enter')
|
||||
;(picker.querySelector('[role="option"]') as HTMLElement).click()
|
||||
await flushPromises()
|
||||
|
||||
expect(ctrl('s').defaultPrevented).toBe(true)
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'POST', `${LINKED}/link`)).toHaveLength(1)
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sends nothing while the picker has no selection', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', relationRoutes, { attach: true })
|
||||
await openPicker(wrapper)
|
||||
|
||||
expect(ctrl('s').defaultPrevented).toBe(true)
|
||||
ctrl('Enter')
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'POST', `${LINKED}/link`)).toHaveLength(0)
|
||||
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settings forms and My account', () => {
|
||||
function settingsRoutes(put?: Route): Record<string, Route> {
|
||||
return {
|
||||
[`GET ${API}/settings`]: { body: settings.list },
|
||||
[`GET ${SETTING}/schema`]: { body: settings.schema },
|
||||
[`GET ${SETTING}`]: { body: settings.values },
|
||||
[`PUT ${SETTING}`]: put ?? { body: { data: settings.values.data, meta: {} } },
|
||||
}
|
||||
}
|
||||
|
||||
async function openSettings(put?: Route) {
|
||||
mockApi(settingsRoutes(put))
|
||||
await loadSettings()
|
||||
return mountApp('/settings/search', settingsRoutes(put), { attach: true })
|
||||
}
|
||||
|
||||
it('saves a settings form on Ctrl+S and stays', async () => {
|
||||
const { calls, router } = await openSettings()
|
||||
|
||||
expect(ctrl('s').defaultPrevented).toBe(true)
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'PUT', SETTING)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/settings/search')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Ctrl+Enter', () => ctrl('Enter')],
|
||||
['the button', () => (document.body.querySelector('[data-action="save-close"]') as HTMLElement).click()],
|
||||
])('saves and closes a settings form through %s', async (_name, trigger) => {
|
||||
const { wrapper, calls, router } = await openSettings()
|
||||
const button = wrapper.get('[data-action="save-close"]')
|
||||
expect(button.text()).toBe('Zapisz i zamknij')
|
||||
expect(button.attributes('title')).toBe('Zapisz i zamknij (Ctrl+Enter)')
|
||||
|
||||
trigger()
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'PUT', SETTING)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/settings')
|
||||
})
|
||||
|
||||
it('stays on the settings form when Save and close fails validation', async () => {
|
||||
const { calls, router } = await openSettings({
|
||||
status: 422,
|
||||
body: { error: { code: 'validation_failed', message: 'Validation failed', details: { engine_host: ['Required'] } } },
|
||||
})
|
||||
|
||||
ctrl('Enter')
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'PUT', SETTING)).toHaveLength(1)
|
||||
expect(router.currentRoute.value.path).toBe('/settings/search')
|
||||
})
|
||||
|
||||
it('does not leave a settings form on Esc', async () => {
|
||||
const { router } = await openSettings()
|
||||
|
||||
press(document.body, 'Escape')
|
||||
await settle()
|
||||
expect(router.currentRoute.value.path).toBe('/settings/search')
|
||||
})
|
||||
|
||||
it('saves My account on Ctrl+S and ignores Esc', async () => {
|
||||
const { calls, router } = await mountApp(
|
||||
'/account',
|
||||
{
|
||||
[`GET ${API}/account/tokens`]: { body: { data: [], meta: {} } },
|
||||
[`PUT ${ACCOUNT}`]: { body: { data: profile.data, meta: {} } },
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
|
||||
expect(ctrl('s').defaultPrevented).toBe(true)
|
||||
await settle()
|
||||
expect(requestsTo(calls, 'PUT', ACCOUNT)).toHaveLength(1)
|
||||
|
||||
press(document.body, 'Escape')
|
||||
await settle()
|
||||
expect(router.currentRoute.value.path).toBe('/account')
|
||||
})
|
||||
})
|
||||
|
||||
describe('list search and the help dialog', () => {
|
||||
it('focuses the list search on / without typing it', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets', formRoutes, { attach: true })
|
||||
const search = wrapper.get('input[data-list-search]').element
|
||||
|
||||
expect(press(document.body, '/').defaultPrevented).toBe(true)
|
||||
expect(document.activeElement).toBe(search)
|
||||
|
||||
const other = document.createElement('input')
|
||||
document.body.appendChild(other)
|
||||
other.focus()
|
||||
expect(press(other, '/').defaultPrevented).toBe(false)
|
||||
expect(document.activeElement).toBe(other)
|
||||
})
|
||||
|
||||
it('has no search target on a list without search', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets',
|
||||
{ ...formRoutes, [`GET ${BASE}/schema/list`]: { body: { ...listSchema, data: { ...listSchema.data, showSearch: false } } } },
|
||||
{ attach: true },
|
||||
)
|
||||
expect(wrapper.find('input[data-list-search]').exists()).toBe(false)
|
||||
expect(press(document.body, '/').defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('lists the shortcuts of a list', async () => {
|
||||
await mountApp('/acme/demo/widgets', formRoutes, { attach: true })
|
||||
|
||||
expect(press(document.body, '?', { shiftKey: true }).defaultPrevented).toBe(true)
|
||||
await settle()
|
||||
expect(helpRows()).toEqual(['/', '?'])
|
||||
})
|
||||
|
||||
it('lists the shortcuts of an update form and closes on Esc without leaving the form', async () => {
|
||||
const { router } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
press(document.body, '?', { shiftKey: true })
|
||||
await settle()
|
||||
expect(helpRows()).toEqual(['mod+s', 'mod+enter', 'escape', '?'])
|
||||
|
||||
// The help dialog is the innermost modal: Ctrl+S saves nothing under it.
|
||||
expect(ctrl('s').defaultPrevented).toBe(true)
|
||||
|
||||
press(document.activeElement ?? document.body, 'Escape')
|
||||
await settle()
|
||||
expect(document.body.querySelector('[data-shortcut-help]')).toBeNull()
|
||||
expect(router.currentRoute.value.path).toBe('/acme/demo/widgets/1')
|
||||
})
|
||||
|
||||
it('lists Create and new on a create form', async () => {
|
||||
await mountApp('/acme/demo/widgets/create', formRoutes, { attach: true })
|
||||
|
||||
press(document.body, '?', { shiftKey: true })
|
||||
await settle()
|
||||
expect(helpRows()).toEqual(['mod+s', 'mod+enter', 'mod+shift+s', 'escape', '?'])
|
||||
})
|
||||
|
||||
it('does not open the help while typing', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes, { attach: true })
|
||||
|
||||
expect(press(wrapper.get('#field-name').element, '?', { shiftKey: true }).defaultPrevented).toBe(false)
|
||||
await settle()
|
||||
expect(document.body.querySelector('[data-shortcut-help]')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user