- 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
429 lines
17 KiB
TypeScript
429 lines
17 KiB
TypeScript
// Keyboard shortcuts on the whole app (mounted with attachTo, since the
|
|
// 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, 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 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 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 ${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')
|
|
})
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = ''
|
|
})
|
|
|
|
// Registered after the cleanup above, so it runs first (hooks run as a
|
|
// stack): the app unmounts, with its listeners and portals, before the body
|
|
// is cleared.
|
|
enableAutoUnmount(afterEach)
|
|
|
|
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 event = ctrl('s')
|
|
expect(event.defaultPrevented).toBe(true)
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(1)
|
|
|
|
const cmd = press(document.body, 's', { metaKey: true })
|
|
expect(cmd.defaultPrevented).toBe(true)
|
|
await flushPromises()
|
|
expect(requestsTo(calls, 'PUT', RECORD)).toHaveLength(2)
|
|
})
|
|
|
|
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()
|
|
})
|
|
})
|