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:
307
admin/tests/app/hotkeys.test.ts
Normal file
307
admin/tests/app/hotkeys.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
74
admin/tests/shell/ShortcutHelpDialog.test.ts
Normal file
74
admin/tests/shell/ShortcutHelpDialog.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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