diff --git a/admin/tests/app/hotkeys.test.ts b/admin/tests/app/hotkeys.test.ts new file mode 100644 index 00000000..20f1819f --- /dev/null +++ b/admin/tests/app/hotkeys.test.ts @@ -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): 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 = ` + +
x
+
x
+ ` + 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() + }) +}) diff --git a/admin/tests/helpers.ts b/admin/tests/helpers.ts index d09b67e2..a71850e1 100644 --- a/admin/tests/helpers.ts +++ b/admin/tests/helpers.ts @@ -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 diff --git a/admin/tests/shell/ShortcutHelpDialog.test.ts b/admin/tests/shell/ShortcutHelpDialog.test.ts new file mode 100644 index 00000000..50a15155 --- /dev/null +++ b/admin/tests/shell/ShortcutHelpDialog.test.ts @@ -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('[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('[data-action="close"]')! + expect(close.getAttribute('aria-label')).toBe('Zamknij') + close.click() + await flushPromises() + expect(shortcutHelp.value).toBeNull() + expect(help()).toBeNull() + }) +}) diff --git a/admin/tests/smoke/shortcuts.smoke.test.ts b/admin/tests/smoke/shortcuts.smoke.test.ts index 8470e1c7..953048e6 100644 --- a/admin/tests/smoke/shortcuts.smoke.test.ts +++ b/admin/tests/smoke/shortcuts.smoke.test.ts @@ -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 = { - [`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 { + 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('[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((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 = { + ...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 { + 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('[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 { + 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() }) })