aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files/src/composables
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files/src/composables')
-rw-r--r--apps/files/src/composables/useHotKeys.spec.ts213
-rw-r--r--apps/files/src/composables/useHotKeys.ts86
-rw-r--r--apps/files/src/composables/useNavigation.spec.ts7
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