diff options
Diffstat (limited to 'apps/files/src/composables')
-rw-r--r-- | apps/files/src/composables/useHotKeys.spec.ts | 213 | ||||
-rw-r--r-- | apps/files/src/composables/useHotKeys.ts | 86 | ||||
-rw-r--r-- | apps/files/src/composables/useNavigation.spec.ts | 7 |
3 files changed, 306 insertions, 0 deletions
diff --git a/apps/files/src/composables/useHotKeys.spec.ts b/apps/files/src/composables/useHotKeys.spec.ts new file mode 100644 index 00000000000..9c001e8b5ff --- /dev/null +++ b/apps/files/src/composables/useHotKeys.spec.ts @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Location } from 'vue-router' + +import { File, Folder, Permission, View } from '@nextcloud/files' +import { enableAutoDestroy, mount } from '@vue/test-utils' +import { describe, it, vi, expect, beforeEach, afterEach } from 'vitest' +import { defineComponent, nextTick } from 'vue' +import axios from '@nextcloud/axios' + +import { getPinia } from '../store/index.ts' +import { useActiveStore } from '../store/active.ts' +import { useFilesStore } from '../store/files' + +import { action as deleteAction } from '../actions/deleteAction.ts' +import { action as favoriteAction } from '../actions/favoriteAction.ts' +import { action as renameAction } from '../actions/renameAction.ts' +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useHotKeys } from './useHotKeys.ts' +import { useUserConfigStore } from '../store/userconfig.ts' + +// this is the mocked current route +const route = vi.hoisted(() => ({ + name: 'test', + params: { + fileId: 123, + }, + query: { + openFile: 'false', + dir: '/parent/dir', + }, +})) + +// mocked router +const router = vi.hoisted(() => ({ + push: vi.fn<(route: Location) => void>(), +})) + +vi.mock('../actions/sidebarAction.ts', { spy: true }) +vi.mock('../actions/deleteAction.ts', { spy: true }) +vi.mock('../actions/favoriteAction.ts', { spy: true }) +vi.mock('../actions/renameAction.ts', { spy: true }) + +vi.mock('vue-router/composables', () => ({ + useRoute: vi.fn(() => route), + useRouter: vi.fn(() => router), +})) + +let file: File +const view = { + id: 'files', + name: 'Files', +} as View + +const TestComponent = defineComponent({ + name: 'test', + setup() { + useHotKeys() + }, + template: '<div />', +}) + +describe('HotKeysService testing', () => { + const activeStore = useActiveStore(getPinia()) + + let initialState: HTMLInputElement + + enableAutoDestroy(afterEach) + + afterEach(() => { + document.body.removeChild(initialState) + }) + + beforeEach(() => { + // Make sure the router is reset before each test + router.push.mockClear() + + // Make sure the file is reset before each test + file = new File({ + id: 2, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + }) + + const root = new Folder({ owner: 'test', source: 'https://cloud.domain.com/remote.php/dav/files/admin/', id: 1, permissions: Permission.CREATE }) + const files = useFilesStore(getPinia()) + files.setRoot({ service: 'files', root }) + + // Setting the view first as it reset the active node + activeStore.activeView = view + activeStore.activeNode = file + + window.OCA = { Files: { Sidebar: { open: () => {}, setActiveTab: () => {} } } } + initialState = document.createElement('input') + initialState.setAttribute('type', 'hidden') + initialState.setAttribute('id', 'initial-state-files_trashbin-config') + initialState.setAttribute('value', btoa(JSON.stringify({ + allow_delete: true, + }))) + document.body.appendChild(initialState) + + mount(TestComponent) + }) + + it('Pressing d should open the sidebar once', () => { + dispatchEvent({ key: 'd', code: 'KeyD' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 'd', code: 'KeyD', ctrlKey: true }) + dispatchEvent({ key: 'd', code: 'KeyD', altKey: true }) + dispatchEvent({ key: 'd', code: 'KeyD', shiftKey: true }) + dispatchEvent({ key: 'd', code: 'KeyD', metaKey: true }) + + expect(sidebarAction.enabled).toHaveReturnedWith(true) + expect(sidebarAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing F2 should rename the file', () => { + dispatchEvent({ key: 'F2', code: 'F2' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 'F2', code: 'F2', ctrlKey: true }) + dispatchEvent({ key: 'F2', code: 'F2', altKey: true }) + dispatchEvent({ key: 'F2', code: 'F2', shiftKey: true }) + dispatchEvent({ key: 'F2', code: 'F2', metaKey: true }) + + expect(renameAction.enabled).toHaveReturnedWith(true) + expect(renameAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing s should toggle favorite', () => { + vi.spyOn(axios, 'post').mockImplementationOnce(() => Promise.resolve()) + dispatchEvent({ key: 's', code: 'KeyS' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 's', code: 'KeyS', ctrlKey: true }) + dispatchEvent({ key: 's', code: 'KeyS', altKey: true }) + dispatchEvent({ key: 's', code: 'KeyS', shiftKey: true }) + dispatchEvent({ key: 's', code: 'KeyS', metaKey: true }) + + expect(favoriteAction.enabled).toHaveReturnedWith(true) + expect(favoriteAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing Delete should delete the file', async () => { + // @ts-expect-error unit testing + vi.spyOn(deleteAction._action, 'exec').mockResolvedValue(() => true) + + dispatchEvent({ key: 'Delete', code: 'Delete' }) + + // Modifier keys should not trigger the action + dispatchEvent({ key: 'Delete', code: 'Delete', ctrlKey: true }) + dispatchEvent({ key: 'Delete', code: 'Delete', altKey: true }) + dispatchEvent({ key: 'Delete', code: 'Delete', shiftKey: true }) + dispatchEvent({ key: 'Delete', code: 'Delete', metaKey: true }) + + expect(deleteAction.enabled).toHaveReturnedWith(true) + expect(deleteAction.exec).toHaveBeenCalledOnce() + }) + + it('Pressing alt+up should go to parent directory', () => { + expect(router.push).toHaveBeenCalledTimes(0) + dispatchEvent({ key: 'ArrowUp', code: 'ArrowUp', altKey: true }) + + expect(router.push).toHaveBeenCalledOnce() + expect(router.push.mock.calls[0][0].query?.dir).toBe('/parent') + }) + + it('Pressing v should toggle grid view', async () => { + vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve()) + + const userConfigStore = useUserConfigStore(getPinia()) + userConfigStore.userConfig.grid_view = false + expect(userConfigStore.userConfig.grid_view).toBe(false) + + dispatchEvent({ key: 'v', code: 'KeyV' }) + expect(userConfigStore.userConfig.grid_view).toBe(true) + }) + + it.each([ + ['ctrlKey'], + ['altKey'], + // those meta keys are still triggering... + // ['shiftKey'], + // ['metaKey'] + ])('Pressing v with modifier key %s should not toggle grid view', async (modifier: string) => { + vi.spyOn(axios, 'put').mockImplementationOnce(() => Promise.resolve()) + + const userConfigStore = useUserConfigStore(getPinia()) + userConfigStore.userConfig.grid_view = false + expect(userConfigStore.userConfig.grid_view).toBe(false) + + dispatchEvent(new KeyboardEvent('keydown', { key: 'v', code: 'KeyV', [modifier]: true })) + + await nextTick() + + expect(userConfigStore.userConfig.grid_view).toBe(false) + }) +}) + +/** + * Helper to dispatch the correct event. + * + * @param init - KeyboardEvent options + */ +function dispatchEvent(init: KeyboardEventInit) { + document.body.dispatchEvent(new KeyboardEvent('keydown', { ...init, bubbles: true })) +} diff --git a/apps/files/src/composables/useHotKeys.ts b/apps/files/src/composables/useHotKeys.ts new file mode 100644 index 00000000000..ff56627b2f9 --- /dev/null +++ b/apps/files/src/composables/useHotKeys.ts @@ -0,0 +1,86 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { useHotKey } from '@nextcloud/vue/composables/useHotKey' +import { dirname } from 'path' +import { useRoute, useRouter } from 'vue-router/composables' + +import { action as deleteAction } from '../actions/deleteAction.ts' +import { action as favoriteAction } from '../actions/favoriteAction.ts' +import { action as renameAction } from '../actions/renameAction.ts' +import { action as sidebarAction } from '../actions/sidebarAction.ts' +import { useUserConfigStore } from '../store/userconfig.ts' +import { useRouteParameters } from './useRouteParameters.ts' +import { executeAction } from '../utils/actionUtils.ts' +import logger from '../logger.ts' + +/** + * This register the hotkeys for the Files app. + * As much as possible, we try to have all the hotkeys in one place. + * Please make sure to add tests for the hotkeys after adding a new one. + */ +export function useHotKeys(): void { + const userConfigStore = useUserConfigStore() + const { directory } = useRouteParameters() + const router = useRouter() + const route = useRoute() + + // d opens the sidebar + useHotKey('d', () => executeAction(sidebarAction), { + stop: true, + prevent: true, + }) + + // F2 renames the file + useHotKey('F2', () => executeAction(renameAction), { + stop: true, + prevent: true, + }) + + // s toggle favorite + useHotKey('s', () => executeAction(favoriteAction), { + stop: true, + prevent: true, + }) + + // Delete deletes the file + useHotKey('Delete', () => executeAction(deleteAction), { + stop: true, + prevent: true, + }) + + // alt+up go to parent directory + useHotKey('ArrowUp', goToParentDir, { + stop: true, + prevent: true, + alt: true, + }) + + // v toggle grid view + useHotKey('v', toggleGridView, { + stop: true, + prevent: true, + }) + + logger.debug('Hotkeys registered') + + /** + * Use the router to go to the parent directory + */ + function goToParentDir() { + const dir = dirname(directory.value) + + logger.debug('Navigating to parent directory', { dir }) + router.push({ params: { ...route.params }, query: { ...route.query, dir } }) + } + + /** + * Toggle the grid view + */ + function toggleGridView() { + const value = userConfigStore.userConfig.grid_view + logger.debug('Toggling grid view', { old: value, new: !value }) + userConfigStore.update('grid_view', !value) + } +} diff --git a/apps/files/src/composables/useNavigation.spec.ts b/apps/files/src/composables/useNavigation.spec.ts index 569e61825e1..b9eb671a181 100644 --- a/apps/files/src/composables/useNavigation.spec.ts +++ b/apps/files/src/composables/useNavigation.spec.ts @@ -29,6 +29,7 @@ describe('Composables: useNavigation', () => { describe('currentView', () => { beforeEach(() => { + // eslint-disable-next-line import/namespace navigation = new nextcloudFiles.Navigation() spy.mockImplementation(() => navigation) }) @@ -39,6 +40,7 @@ describe('Composables: useNavigation', () => { }) it('should return already active navigation', async () => { + // eslint-disable-next-line import/namespace const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) navigation.register(view) navigation.setActive(view) @@ -48,6 +50,7 @@ describe('Composables: useNavigation', () => { }) it('should be reactive on updating active navigation', async () => { + // eslint-disable-next-line import/namespace const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) navigation.register(view) const wrapper = mount(TestComponent) @@ -63,6 +66,7 @@ describe('Composables: useNavigation', () => { describe('views', () => { beforeEach(() => { + // eslint-disable-next-line import/namespace navigation = new nextcloudFiles.Navigation() spy.mockImplementation(() => navigation) }) @@ -73,6 +77,7 @@ describe('Composables: useNavigation', () => { }) it('should return already registered views', () => { + // eslint-disable-next-line import/namespace const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) // register before mount navigation.register(view) @@ -82,7 +87,9 @@ describe('Composables: useNavigation', () => { }) it('should be reactive on registering new views', () => { + // eslint-disable-next-line import/namespace const view = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-1', name: 'My View 1', order: 0 }) + // eslint-disable-next-line import/namespace const view2 = new nextcloudFiles.View({ getContents: () => Promise.reject(new Error()), icon: '<svg></svg>', id: 'view-2', name: 'My View 2', order: 1 }) // register before mount |