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:
Jakub Zych
2026-10-22 21:07:40 +02:00
parent 66bb96eebd
commit 058738efdf
4 changed files with 760 additions and 12 deletions

View File

@@ -0,0 +1,307 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ariaKeyshortcuts,
availableShortcuts,
closeShortcutHelp,
comboOf,
hotkeyRoot,
installHotkeys,
isEditableTarget,
isMacPlatform,
shortcutHelp,
shortcutHint,
shortcutLabel,
shortcutParts,
} from '../../src/app/hotkeys'
import { setBundle } from '../../src/app/i18n'
import lang from '../fixtures/lang.json'
import { press } from '../helpers'
const key = (init: KeyboardEventInit) => new KeyboardEvent('keydown', init)
let uninstalls: Array<() => void> = []
function install(): () => void {
const uninstall = installHotkeys()
uninstalls.push(uninstall)
return uninstall
}
function button(hotkey: string, parent: ParentNode = document.body): HTMLButtonElement {
const element = document.createElement('button')
element.setAttribute('data-hotkey', hotkey)
parent.appendChild(element)
return element
}
function dialog(attrs: Record<string, string>): HTMLDivElement {
const element = document.createElement('div')
for (const [name, value] of Object.entries(attrs)) {
element.setAttribute(name, value)
}
document.body.appendChild(element)
return element
}
beforeEach(() => {
setBundle(lang.data, 'pl')
})
afterEach(() => {
for (const uninstall of uninstalls) {
uninstall()
}
uninstalls = []
closeShortcutHelp()
document.body.innerHTML = ''
})
describe('comboOf', () => {
it('maps the modifier combos with Ctrl or Cmd', () => {
expect(comboOf(key({ key: 's', ctrlKey: true }))).toBe('mod+s')
expect(comboOf(key({ key: 's', metaKey: true }))).toBe('mod+s')
expect(comboOf(key({ key: 'S', ctrlKey: true, shiftKey: true }))).toBe('mod+shift+s')
expect(comboOf(key({ key: 'Enter', ctrlKey: true }))).toBe('mod+enter')
expect(comboOf(key({ key: 'Enter', metaKey: true }))).toBe('mod+enter')
})
it('maps the plain keys whatever the shift state', () => {
expect(comboOf(key({ key: 'Escape' }))).toBe('escape')
expect(comboOf(key({ key: '/' }))).toBe('/')
expect(comboOf(key({ key: '?', shiftKey: true }))).toBe('?')
})
it('ignores AltGr, Alt, plain letters, Ctrl+Shift+Enter and composition', () => {
expect(comboOf(key({ key: 's', ctrlKey: true, altKey: true }))).toBeNull()
expect(comboOf(key({ key: 's', altKey: true }))).toBeNull()
expect(comboOf(key({ key: 's' }))).toBeNull()
expect(comboOf(key({ key: 'Enter', ctrlKey: true, shiftKey: true }))).toBeNull()
expect(comboOf(key({ key: 's', ctrlKey: true, isComposing: true }))).toBeNull()
})
it('reads the physical key of a non-Latin layout', () => {
expect(comboOf(key({ key: 'ы', code: 'KeyS', ctrlKey: true }))).toBe('mod+s')
expect(comboOf(key({ key: 'ы', code: 'KeyD', ctrlKey: true }))).toBeNull()
})
})
describe('labels', () => {
it('detects Apple platforms', () => {
expect(isMacPlatform('MacIntel')).toBe(true)
expect(isMacPlatform('iPhone')).toBe(true)
expect(isMacPlatform('iPad')).toBe(true)
expect(isMacPlatform('Linux x86_64')).toBe(false)
expect(isMacPlatform('Win32')).toBe(false)
})
it('reads navigator.platform by default', () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel')
expect(isMacPlatform()).toBe(true)
expect(shortcutLabel('mod+s')).toBe('⌘S')
})
it('names the keys with words on Linux and glyphs on macOS', () => {
expect(shortcutLabel('mod+s', false)).toBe('Ctrl+S')
expect(shortcutLabel('mod+enter', false)).toBe('Ctrl+Enter')
expect(shortcutLabel('mod+shift+s', false)).toBe('Ctrl+Shift+S')
expect(shortcutLabel('escape', false)).toBe('Esc')
expect(shortcutLabel('mod+s', true)).toBe('⌘S')
expect(shortcutLabel('mod+enter', true)).toBe('⌘↩')
expect(shortcutLabel('mod+shift+s', true)).toBe('⇧⌘S')
expect(shortcutParts('mod+shift+s', true)).toEqual(['⇧', '⌘', 'S'])
expect(shortcutParts('/', false)).toEqual(['/'])
expect(shortcutParts('?', true)).toEqual(['?'])
})
it('builds the tooltip hint from the phrasebook string', () => {
expect(shortcutHint('Zapisz', 'mod+s', false)).toBe('Zapisz (Ctrl+S)')
expect(shortcutHint('Zapisz', 'mod+s', true)).toBe('Zapisz (⌘S)')
})
it('lists the Control and Meta forms for aria-keyshortcuts', () => {
expect(ariaKeyshortcuts('mod+s mod+enter')).toBe('Control+S Meta+S Control+Enter Meta+Enter')
expect(ariaKeyshortcuts('mod+shift+s')).toBe('Control+Shift+S Meta+Shift+S')
expect(ariaKeyshortcuts('escape')).toBe('Escape')
expect(ariaKeyshortcuts('/')).toBe('/')
})
})
describe('isEditableTarget', () => {
it('is true inside inputs, textareas, selects and contenteditable', () => {
document.body.innerHTML = `
<input id="i"><textarea id="t"></textarea><select id="s"></select>
<div contenteditable="true"><span id="c">x</span></div>
<div contenteditable="false"><span id="n">x</span></div>
<button id="b"></button>`
for (const id of ['i', 't', 's', 'c']) {
expect(isEditableTarget(document.getElementById(id))).toBe(true)
}
expect(isEditableTarget(document.getElementById('b'))).toBe(false)
expect(isEditableTarget(document.getElementById('n'))).toBe(false)
expect(isEditableTarget(document.body)).toBe(false)
expect(isEditableTarget(null)).toBe(false)
})
})
describe('hotkeyRoot', () => {
it('is the document without a modal', () => {
expect(hotkeyRoot(document)).toBe(document)
})
it('is the last open aria-modal dialog or alert dialog', () => {
dialog({ role: 'dialog', 'aria-modal': 'true', 'data-state': 'open' })
const second = dialog({ role: 'dialog', 'aria-modal': 'true', 'data-state': 'open' })
expect(hotkeyRoot(document)).toBe(second)
const alert = dialog({ role: 'alertdialog', 'data-state': 'open' })
expect(hotkeyRoot(document)).toBe(alert)
})
it('ignores popovers (no aria-modal) and closing dialogs', () => {
const modal = dialog({ role: 'dialog', 'aria-modal': 'true' })
dialog({ role: 'dialog' })
dialog({ role: 'dialog', 'aria-modal': 'true', 'data-state': 'closed' })
expect(hotkeyRoot(document)).toBe(modal)
})
})
describe('availableShortcuts', () => {
it('lists the targets of the current root plus ?', () => {
button('mod+s')
button('escape')
expect(availableShortcuts().map((entry) => entry.combo)).toEqual(['mod+s', 'escape', '?'])
const modal = dialog({ role: 'dialog', 'aria-modal': 'true' })
button('mod+s mod+enter', modal)
expect(availableShortcuts().map((entry) => entry.combo)).toEqual(['mod+s', 'mod+enter', '?'])
})
})
describe('installHotkeys', () => {
it('clicks the target once per keydown however often it is installed, and stops after every uninstall', () => {
const save = button('mod+s')
const click = vi.spyOn(save, 'click')
const first = install()
const second = install()
press(document.body, 's', { ctrlKey: true })
expect(click).toHaveBeenCalledTimes(1)
first()
first()
press(document.body, 's', { ctrlKey: true })
expect(click).toHaveBeenCalledTimes(2)
second()
const event = press(document.body, 's', { ctrlKey: true })
expect(click).toHaveBeenCalledTimes(2)
expect(event.defaultPrevented).toBe(false)
})
it('consumes the key without clicking a disabled target or on auto-repeat', () => {
install()
const save = button('mod+s')
const click = vi.spyOn(save, 'click')
expect(press(document.body, 's', { ctrlKey: true, repeat: true }).defaultPrevented).toBe(true)
expect(click).not.toHaveBeenCalled()
save.disabled = true
expect(press(document.body, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
expect(click).not.toHaveBeenCalled()
save.disabled = false
save.setAttribute('aria-disabled', 'true')
press(document.body, 's', { ctrlKey: true })
expect(click).not.toHaveBeenCalled()
})
it('prevents the browser Save page without a target, and lets other modifier keys through', () => {
install()
expect(press(document.body, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
expect(press(document.body, 'Enter', { ctrlKey: true }).defaultPrevented).toBe(false)
expect(press(document.body, 'S', { ctrlKey: true, shiftKey: true }).defaultPrevented).toBe(false)
})
it('stops a handled modifier key before an editor sees it, also inside an editable control', () => {
install()
const save = button('mod+enter')
const click = vi.spyOn(save, 'click')
const editor = document.createElement('div')
editor.setAttribute('contenteditable', 'true')
document.body.appendChild(editor)
const editorKeys = vi.fn()
editor.addEventListener('keydown', editorKeys)
const event = press(editor, 'Enter', { ctrlKey: true })
expect(event.defaultPrevented).toBe(true)
expect(click).toHaveBeenCalledTimes(1)
expect(editorKeys).not.toHaveBeenCalled()
})
it('searches only the innermost modal', () => {
install()
const page = button('mod+s')
const pageClick = vi.spyOn(page, 'click')
const modal = dialog({ role: 'dialog', 'aria-modal': 'true', 'data-state': 'open' })
const submit = button('mod+s mod+enter', modal)
const submitClick = vi.spyOn(submit, 'click')
press(document.body, 's', { ctrlKey: true })
press(document.body, 'Enter', { metaKey: true })
expect(submitClick).toHaveBeenCalledTimes(2)
expect(pageClick).not.toHaveBeenCalled()
dialog({ role: 'alertdialog', 'data-state': 'open' })
expect(press(document.body, 's', { ctrlKey: true }).defaultPrevented).toBe(true)
expect(submitClick).toHaveBeenCalledTimes(2)
expect(pageClick).not.toHaveBeenCalled()
})
it('runs plain keys outside editable controls and open layers only', () => {
install()
const cancel = button('escape')
const click = vi.spyOn(cancel, 'click')
const input = document.createElement('input')
document.body.appendChild(input)
expect(press(input, 'Escape').defaultPrevented).toBe(false)
expect(click).not.toHaveBeenCalled()
expect(press(document.body, 'Escape').defaultPrevented).toBe(true)
expect(click).toHaveBeenCalledTimes(1)
const handled = document.createElement('div')
document.body.appendChild(handled)
handled.addEventListener('keydown', (event) => event.preventDefault())
press(handled, 'Escape')
expect(click).toHaveBeenCalledTimes(1)
const layer = dialog({ 'data-dismissable-layer': '' })
press(document.body, 'Escape')
expect(click).toHaveBeenCalledTimes(1)
layer.remove()
cancel.disabled = true
expect(press(document.body, 'Escape').defaultPrevented).toBe(false)
})
it('focuses an input target and opens the help on ?', () => {
install()
const search = document.createElement('input')
search.setAttribute('data-hotkey', '/')
search.value = 'abc'
document.body.appendChild(search)
button('mod+s')
expect(press(document.body, '/').defaultPrevented).toBe(true)
expect(document.activeElement).toBe(search)
expect(press(search, '?', { shiftKey: true }).defaultPrevented).toBe(false)
expect(shortcutHelp.value).toBeNull()
search.blur()
expect(press(document.body, '?', { shiftKey: true }).defaultPrevented).toBe(true)
expect(shortcutHelp.value?.map((entry) => entry.combo)).toEqual(['mod+s', '/', '?'])
closeShortcutHelp()
expect(shortcutHelp.value).toBeNull()
})
})

View File

@@ -117,6 +117,13 @@ export function keydown(target: Element, key: string): KeyboardEvent {
return event
}
/** Dispatches a bubbling, cancelable keydown with extra init (modifiers, code, repeat) and returns the event. */
export 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
}
export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
type MediaListener = (event: MediaQueryListEvent) => void

View File

@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import ShortcutHelpDialog from '../../src/components/shell/ShortcutHelpDialog.vue'
import { closeShortcutHelp, openShortcutHelp, shortcutHelp } from '../../src/app/hotkeys'
import { setBundle } from '../../src/app/i18n'
import lang from '../fixtures/lang.json'
beforeEach(() => {
setBundle(lang.data, 'pl')
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Linux x86_64')
})
afterEach(() => {
closeShortcutHelp()
document.body.innerHTML = ''
})
// Registered after the cleanup above, so it runs first (hooks run as a stack).
enableAutoUnmount(afterEach)
function marker(hotkey: string): void {
const element = document.createElement('button')
element.setAttribute('data-hotkey', hotkey)
document.body.appendChild(element)
}
function help(): HTMLElement | null {
return document.body.querySelector('[data-shortcut-help]')
}
describe('ShortcutHelpDialog', () => {
it('stays closed until the help is opened', async () => {
mount(ShortcutHelpDialog, { attachTo: document.body })
await flushPromises()
expect(help()).toBeNull()
})
it('lists the shortcuts the page answers with their keys and labels', async () => {
mount(ShortcutHelpDialog, { attachTo: document.body })
marker('mod+s')
marker('mod+shift+s')
marker('escape')
openShortcutHelp()
await flushPromises()
const dialog = help()!
expect(dialog.getAttribute('aria-modal')).toBe('true')
expect(dialog.textContent).toContain('Skróty klawiaturowe')
expect(dialog.textContent).toContain('Skróty dostępne na tym ekranie.')
const rows = [...dialog.querySelectorAll<HTMLElement>('[data-shortcut]')]
expect(rows.map((row) => row.dataset.shortcut)).toEqual(['mod+s', 'mod+shift+s', 'escape', '?'])
const keys = (row: HTMLElement) => [...row.querySelectorAll('kbd')].map((kbd) => kbd.textContent)
expect(keys(rows[0]!)).toEqual(['Ctrl', 'S'])
expect(keys(rows[1]!)).toEqual(['Ctrl', 'Shift', 'S'])
expect(keys(rows[2]!)).toEqual(['Esc'])
expect(rows[0]!.querySelector('dd')!.textContent).toBe('Zapisz')
expect(rows[1]!.querySelector('dd')!.textContent).toBe('Utwórz i nowy')
expect(rows[2]!.querySelector('dd')!.textContent).toBe('Anuluj i wróć')
expect(rows[3]!.querySelector('dd')!.textContent).toBe('Pokaż skróty klawiaturowe')
})
it('closes through the close button', async () => {
mount(ShortcutHelpDialog, { attachTo: document.body })
openShortcutHelp()
await flushPromises()
const close = help()!.querySelector<HTMLButtonElement>('[data-action="close"]')!
expect(close.getAttribute('aria-label')).toBe('Zamknij')
close.click()
await flushPromises()
expect(shortcutHelp.value).toBeNull()
expect(help()).toBeNull()
})
})

View File

@@ -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()
})
})