// 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 = { [`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 { 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 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((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() }) })