mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-08-31 12:45:51 -05:00
refactor(frontend): centralize menu shortcuts (#2832)
This commit is contained in:
@@ -64,7 +64,7 @@
|
||||
</Modal>
|
||||
|
||||
<BaseButton
|
||||
v-shortcut="'Shift+Slash'"
|
||||
v-shortcut="SHORTCUTS.showKeyboardShortcuts"
|
||||
class="keyboard-shortcuts-button d-print-none"
|
||||
@click="showKeyboardShortcuts()"
|
||||
>
|
||||
@@ -80,6 +80,7 @@
|
||||
import {watch, computed, onBeforeUnmount} from 'vue'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
|
||||
import {SHORTCUTS} from '@/constants/shortcuts'
|
||||
import Navigation from '@/components/home/Navigation.vue'
|
||||
import QuickActions from '@/components/quick-actions/QuickActions.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseButton
|
||||
v-shortcut="'Mod+KeyE'"
|
||||
v-shortcut="SHORTCUTS.toggleMenu"
|
||||
class="menu-show-button"
|
||||
:title="$t('keyboardShortcuts.toggleMenu')"
|
||||
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
import {SHORTCUTS} from '@/constants/shortcuts'
|
||||
import {useBaseStore} from '@/stores/base'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<menu class="menu-list other-menu-items">
|
||||
<li>
|
||||
<RouterLink
|
||||
v-shortcut="'KeyG KeyO'"
|
||||
v-shortcut="SHORTCUTS.navigation.overview"
|
||||
:to="{ name: 'home'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
@@ -32,7 +32,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink
|
||||
v-shortcut="'KeyG KeyU'"
|
||||
v-shortcut="SHORTCUTS.navigation.upcoming"
|
||||
:to="{ name: 'tasks.range'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
@@ -43,7 +43,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink
|
||||
v-shortcut="'KeyG KeyP'"
|
||||
v-shortcut="SHORTCUTS.navigation.projects"
|
||||
:to="{ name: 'projects.index'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
@@ -54,7 +54,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink
|
||||
v-shortcut="'KeyG KeyA'"
|
||||
v-shortcut="SHORTCUTS.navigation.labels"
|
||||
:to="{ name: 'labels.index'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
@@ -65,7 +65,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<RouterLink
|
||||
v-shortcut="'KeyG KeyM'"
|
||||
v-shortcut="SHORTCUTS.navigation.teams"
|
||||
:to="{ name: 'teams.index'}"
|
||||
>
|
||||
<span class="menu-item-icon icon">
|
||||
@@ -143,6 +143,7 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from 'vue'
|
||||
|
||||
import {SHORTCUTS} from '@/constants/shortcuts'
|
||||
import PoweredByLink from '@/components/home/PoweredByLink.vue'
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import Loading from '@/components/misc/Loading.vue'
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import {mount} from '@vue/test-utils'
|
||||
|
||||
import Shortcut from './Shortcut.vue'
|
||||
|
||||
describe('Shortcut.vue', () => {
|
||||
it('renders a regular key combination with the default separator', () => {
|
||||
const wrapper = mount(Shortcut, {
|
||||
props: {
|
||||
keys: ['ctrl', 'e'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('kbd').map(kbd => kbd.text())).toEqual(['ctrl', 'e'])
|
||||
expect(wrapper.text()).toContain('ctrl+e')
|
||||
})
|
||||
|
||||
it('renders a key sequence with a custom separator', () => {
|
||||
const wrapper = mount(Shortcut, {
|
||||
props: {
|
||||
keys: ['g', 'o'],
|
||||
combination: 'then',
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('kbd').map(kbd => kbd.text())).toEqual(['g', 'o'])
|
||||
expect(wrapper.text()).toContain('gtheno')
|
||||
})
|
||||
|
||||
it('renders special characters as visible shortcut keys', () => {
|
||||
const wrapper = mount(Shortcut, {
|
||||
props: {
|
||||
keys: ['shift', '/'],
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('kbd').map(kbd => kbd.text())).toEqual(['shift', '/'])
|
||||
expect(wrapper.text()).toContain('shift+/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
|
||||
import {SHORTCUTS} from '@/constants/shortcuts'
|
||||
import {shortcutBindingToDisplay} from '@/helpers/shortcut'
|
||||
|
||||
import {KEYBOARD_SHORTCUTS} from './shortcuts'
|
||||
|
||||
function getGroup(title: string) {
|
||||
const group = KEYBOARD_SHORTCUTS.find(group => group.title === title)
|
||||
expect(group).toBeDefined()
|
||||
return group!
|
||||
}
|
||||
|
||||
function getShortcut(title: string, groupTitle: string) {
|
||||
const group = getGroup(groupTitle)
|
||||
const shortcut = group.shortcuts.find(shortcut => shortcut.title === title)
|
||||
expect(shortcut).toBeDefined()
|
||||
return shortcut!
|
||||
}
|
||||
|
||||
describe('keyboard shortcuts help data', () => {
|
||||
it('uses the centralized toggle menu shortcut for the general help entry', () => {
|
||||
const shortcut = getShortcut('keyboardShortcuts.toggleMenu', 'keyboardShortcuts.general')
|
||||
|
||||
expect(shortcut.keys).toEqual(shortcutBindingToDisplay(SHORTCUTS.toggleMenu).keys)
|
||||
expect(shortcut.combination).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the centralized show keyboard shortcuts binding for the help dialog trigger', () => {
|
||||
expect(shortcutBindingToDisplay(SHORTCUTS.showKeyboardShortcuts)).toEqual({
|
||||
keys: ['shift', '/'],
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the centralized overview navigation shortcut with sequence display', () => {
|
||||
const shortcut = getShortcut('keyboardShortcuts.navigation.overview', 'keyboardShortcuts.navigation.title')
|
||||
const expected = shortcutBindingToDisplay(SHORTCUTS.navigation.overview)
|
||||
|
||||
expect(shortcut.keys).toEqual(expected.keys)
|
||||
expect(shortcut.combination).toEqual(expected.combination)
|
||||
})
|
||||
|
||||
it('uses the centralized due date shortcut for the task detail help entry', () => {
|
||||
const shortcut = getShortcut('keyboardShortcuts.task.dueDate', 'keyboardShortcuts.task.title')
|
||||
const expected = shortcutBindingToDisplay(SHORTCUTS.taskDetail.dueDate)
|
||||
|
||||
expect(shortcut.keys).toEqual(expected.keys)
|
||||
expect(shortcut.combination).toEqual(expected.combination)
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,9 @@
|
||||
import type {RouteLocation} from 'vue-router'
|
||||
|
||||
import {isAppleDevice} from '@/helpers/isAppleDevice'
|
||||
import {PRIMARY_MODIFIER_KEY, SHORTCUTS} from '@/constants/shortcuts'
|
||||
import {shortcutBindingToDisplay} from '@/helpers/shortcut'
|
||||
|
||||
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
|
||||
const reminderModifier = isAppleDevice() ? 'shift' : 'alt'
|
||||
const deleteKey = isAppleDevice() ? 'backspace' : 'delete'
|
||||
const ctrl = PRIMARY_MODIFIER_KEY
|
||||
|
||||
export interface Shortcut {
|
||||
title: string
|
||||
@@ -24,11 +23,11 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.toggleMenu',
|
||||
keys: [ctrl, 'e'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.toggleMenu),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.quickSearch',
|
||||
keys: [ctrl, 'k'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.quickSearch),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -37,28 +36,23 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.overview',
|
||||
keys: ['g', 'o'],
|
||||
combination: 'then',
|
||||
...shortcutBindingToDisplay(SHORTCUTS.navigation.overview),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.upcoming',
|
||||
keys: ['g', 'u'],
|
||||
combination: 'then',
|
||||
...shortcutBindingToDisplay(SHORTCUTS.navigation.upcoming),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.projects',
|
||||
keys: ['g', 'p'],
|
||||
combination: 'then',
|
||||
...shortcutBindingToDisplay(SHORTCUTS.navigation.projects),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.labels',
|
||||
keys: ['g', 'a'],
|
||||
combination: 'then',
|
||||
...shortcutBindingToDisplay(SHORTCUTS.navigation.labels),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.navigation.teams',
|
||||
keys: ['g', 'm'],
|
||||
combination: 'then',
|
||||
...shortcutBindingToDisplay(SHORTCUTS.navigation.teams),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -152,39 +146,39 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
|
||||
shortcuts: [
|
||||
{
|
||||
title: 'keyboardShortcuts.task.done',
|
||||
keys: ['t'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.done),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.assign',
|
||||
keys: ['a'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.assignees),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.labels',
|
||||
keys: ['l'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.labels),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.dueDate',
|
||||
keys: ['d'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.dueDate),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.attachment',
|
||||
keys: ['f'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.attachments),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.related',
|
||||
keys: ['r'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.relatedTasks),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.move',
|
||||
keys: ['m'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.moveProject),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.color',
|
||||
keys: ['c'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.color),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.reminder',
|
||||
keys: [reminderModifier, 'r'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.reminder),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.description',
|
||||
@@ -192,19 +186,19 @@ export const KEYBOARD_SHORTCUTS: ShortcutGroup[] = [
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.priority',
|
||||
keys: ['p'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.priority),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.delete',
|
||||
keys: [deleteKey],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.delete),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.favorite',
|
||||
keys: ['s'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.favorite),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.openProject',
|
||||
keys: ['u'],
|
||||
...shortcutBindingToDisplay(SHORTCUTS.taskDetail.openProject),
|
||||
},
|
||||
{
|
||||
title: 'keyboardShortcuts.task.save',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import {isAppleDevice} from '@/helpers/isAppleDevice'
|
||||
|
||||
export const PRIMARY_MODIFIER_KEY = isAppleDevice() ? '⌘' : 'ctrl'
|
||||
|
||||
export const SHORTCUTS = {
|
||||
toggleMenu: 'Mod+KeyE',
|
||||
quickSearch: 'Mod+KeyK',
|
||||
showKeyboardShortcuts: 'Shift+Slash',
|
||||
navigation: {
|
||||
overview: 'KeyG KeyO',
|
||||
upcoming: 'KeyG KeyU',
|
||||
projects: 'KeyG KeyP',
|
||||
labels: 'KeyG KeyA',
|
||||
teams: 'KeyG KeyM',
|
||||
},
|
||||
taskDetail: {
|
||||
openProject: 'KeyU',
|
||||
done: 'KeyT',
|
||||
favorite: 'KeyS',
|
||||
labels: 'KeyL',
|
||||
priority: 'KeyP',
|
||||
color: 'KeyC',
|
||||
assignees: 'KeyA',
|
||||
attachments: 'KeyF',
|
||||
relatedTasks: 'KeyR',
|
||||
moveProject: 'KeyM',
|
||||
dueDate: 'KeyD',
|
||||
reminder: isAppleDevice() ? 'Shift+KeyR' : 'Alt+KeyR',
|
||||
delete: isAppleDevice() ? 'Backspace' : 'Delete',
|
||||
},
|
||||
} as const
|
||||
@@ -1,7 +1,7 @@
|
||||
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
|
||||
|
||||
import * as appleDevice from '@/helpers/isAppleDevice'
|
||||
import {parseKey, matchesKey, eventToShortcutString, isFormField, install, uninstall} from './shortcut'
|
||||
import {parseKey, matchesKey, eventToShortcutString, isFormField, install, uninstall, shortcutBindingToDisplay} from './shortcut'
|
||||
|
||||
// Helper to create a partial KeyboardEvent with sensible defaults
|
||||
function makeEvent(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
|
||||
@@ -290,6 +290,71 @@ describe('eventToShortcutString', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('shortcutBindingToDisplay', () => {
|
||||
it('should convert a simple key binding to display keys', () => {
|
||||
expect(shortcutBindingToDisplay('KeyT')).toEqual({
|
||||
keys: ['t'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert Mod bindings to ctrl on non-Apple devices', () => {
|
||||
const spy = vi.spyOn(appleDevice, 'isAppleDevice').mockReturnValue(false)
|
||||
|
||||
expect(shortcutBindingToDisplay('Mod+KeyE')).toEqual({
|
||||
keys: ['ctrl', 'e'],
|
||||
})
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('should convert Mod bindings to command on Apple devices', () => {
|
||||
const spy = vi.spyOn(appleDevice, 'isAppleDevice').mockReturnValue(true)
|
||||
|
||||
expect(shortcutBindingToDisplay('Mod+KeyE')).toEqual({
|
||||
keys: ['⌘', 'e'],
|
||||
})
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
|
||||
it('should convert Shift+Slash to display keys', () => {
|
||||
expect(shortcutBindingToDisplay('Shift+Slash')).toEqual({
|
||||
keys: ['shift', '/'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert sequences to display keys with then combination', () => {
|
||||
expect(shortcutBindingToDisplay('KeyG KeyO')).toEqual({
|
||||
keys: ['g', 'o'],
|
||||
combination: 'then',
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert Alt bindings to display keys', () => {
|
||||
expect(shortcutBindingToDisplay('Alt+KeyR')).toEqual({
|
||||
keys: ['alt', 'r'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert Backspace to a display key', () => {
|
||||
expect(shortcutBindingToDisplay('Backspace')).toEqual({
|
||||
keys: ['backspace'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert Delete to a display key', () => {
|
||||
expect(shortcutBindingToDisplay('Delete')).toEqual({
|
||||
keys: ['delete'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should convert Period bindings to display keys', () => {
|
||||
expect(shortcutBindingToDisplay('Period')).toEqual({
|
||||
keys: ['.'],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// --- isFormField ---
|
||||
|
||||
describe('isFormField', () => {
|
||||
|
||||
@@ -72,6 +72,82 @@ export function eventToShortcutString(event: KeyboardEvent): string {
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
export interface ShortcutDisplay {
|
||||
keys: string[]
|
||||
combination?: 'then'
|
||||
}
|
||||
|
||||
function modifierToDisplayKey(modifier: string): string {
|
||||
switch (modifier) {
|
||||
case 'Control':
|
||||
return 'ctrl'
|
||||
case 'Meta':
|
||||
return '⌘'
|
||||
case 'Alt':
|
||||
return 'alt'
|
||||
case 'Shift':
|
||||
return 'shift'
|
||||
case 'Mod':
|
||||
return isAppleDevice() ? '⌘' : 'ctrl'
|
||||
default:
|
||||
return modifier.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
function codeToDisplayKey(code: string): string {
|
||||
if (/^Key[A-Z]$/.test(code)) {
|
||||
return code.slice(3).toLowerCase()
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'Slash':
|
||||
return '/'
|
||||
case 'Period':
|
||||
return '.'
|
||||
case 'Backspace':
|
||||
return 'backspace'
|
||||
case 'Delete':
|
||||
return 'delete'
|
||||
case 'Enter':
|
||||
return 'enter'
|
||||
case 'ArrowLeft':
|
||||
return '←'
|
||||
case 'ArrowRight':
|
||||
return '→'
|
||||
case 'ArrowUp':
|
||||
return '↑'
|
||||
case 'ArrowDown':
|
||||
return '↓'
|
||||
default:
|
||||
return code.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
function bindingStepToDisplayKeys(step: string): string[] {
|
||||
const parts = step.split('+')
|
||||
const code = parts.pop() || ''
|
||||
|
||||
return [
|
||||
...parts.map(modifierToDisplayKey),
|
||||
codeToDisplayKey(code),
|
||||
]
|
||||
}
|
||||
|
||||
export function shortcutBindingToDisplay(binding: string): ShortcutDisplay {
|
||||
const steps = binding.split(' ')
|
||||
|
||||
if (steps.length > 1) {
|
||||
return {
|
||||
keys: steps.flatMap(bindingStepToDisplayKeys),
|
||||
combination: 'then',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
keys: bindingStepToDisplayKeys(binding),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Form field detection ---
|
||||
|
||||
export function isFormField(target: EventTarget | null): boolean {
|
||||
|
||||
@@ -39,14 +39,14 @@
|
||||
>
|
||||
<a
|
||||
v-if="router.options.history.state?.back?.includes('/projects/'+p.id+'/') || false"
|
||||
v-shortcut="p.id === project?.id ? 'KeyU' : ''"
|
||||
v-shortcut="p.id === project?.id ? SHORTCUTS.taskDetail.openProject : ''"
|
||||
@click="router.back()"
|
||||
>
|
||||
{{ getProjectTitle(p) }}
|
||||
</a>
|
||||
<RouterLink
|
||||
v-else
|
||||
v-shortcut="p.id === project?.id ? 'KeyU' : ''"
|
||||
v-shortcut="p.id === project?.id ? SHORTCUTS.taskDetail.openProject : ''"
|
||||
:to="{ name: 'project.index', params: { projectId: p.id } }"
|
||||
>
|
||||
{{ getProjectTitle(p) }}
|
||||
@@ -445,7 +445,7 @@
|
||||
>
|
||||
<template v-if="canWrite">
|
||||
<XButton
|
||||
v-shortcut="'KeyT'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.done"
|
||||
:class="{'is-pending': !task.done}"
|
||||
class="button--mark-done"
|
||||
icon="check-double"
|
||||
@@ -461,7 +461,7 @@
|
||||
@update:modelValue="sub => task.subscription = sub"
|
||||
/>
|
||||
<XButton
|
||||
v-shortcut="'KeyS'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.favorite"
|
||||
variant="secondary"
|
||||
:icon="task.isFavorite ? 'star' : ['far', 'star']"
|
||||
@click="toggleFavorite"
|
||||
@@ -474,7 +474,7 @@
|
||||
<span class="action-heading">{{ $t('task.detail.organization') }}</span>
|
||||
|
||||
<XButton
|
||||
v-shortcut="'KeyL'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.labels"
|
||||
variant="secondary"
|
||||
icon="tags"
|
||||
@click="setFieldActive('labels')"
|
||||
@@ -482,7 +482,7 @@
|
||||
{{ $t('task.detail.actions.label') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-shortcut="'KeyP'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.priority"
|
||||
variant="secondary"
|
||||
icon="exclamation-circle"
|
||||
@click="setFieldActive('priority')"
|
||||
@@ -497,7 +497,7 @@
|
||||
{{ $t('task.detail.actions.percentDone') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-shortcut="'KeyC'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.color"
|
||||
variant="secondary"
|
||||
icon="fill-drip"
|
||||
:icon-color="color"
|
||||
@@ -509,7 +509,7 @@
|
||||
<span class="action-heading">{{ $t('task.detail.management') }}</span>
|
||||
|
||||
<XButton
|
||||
v-shortcut="'KeyA'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.assignees"
|
||||
v-cy="'taskDetail.assign'"
|
||||
variant="secondary"
|
||||
icon="users"
|
||||
@@ -518,7 +518,7 @@
|
||||
{{ $t('task.detail.actions.assign') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-shortcut="'KeyF'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.attachments"
|
||||
variant="secondary"
|
||||
icon="paperclip"
|
||||
@click="openAttachments()"
|
||||
@@ -526,7 +526,7 @@
|
||||
{{ $t('task.detail.actions.attachments') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-shortcut="'KeyR'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.relatedTasks"
|
||||
variant="secondary"
|
||||
icon="sitemap"
|
||||
@click="setRelatedTasksActive()"
|
||||
@@ -534,7 +534,7 @@
|
||||
{{ $t('task.detail.actions.relatedTasks') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-shortcut="'KeyM'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.moveProject"
|
||||
variant="secondary"
|
||||
icon="list"
|
||||
@click="setFieldActive('moveProject')"
|
||||
@@ -562,7 +562,7 @@
|
||||
</XButton>
|
||||
|
||||
<XButton
|
||||
v-shortcut="'KeyD'"
|
||||
v-shortcut="SHORTCUTS.taskDetail.dueDate"
|
||||
variant="secondary"
|
||||
icon="calendar"
|
||||
@click="setFieldActive('dueDate')"
|
||||
@@ -584,7 +584,7 @@
|
||||
{{ $t('task.detail.actions.endDate') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-shortcut="reminderShortcut"
|
||||
v-shortcut="SHORTCUTS.taskDetail.reminder"
|
||||
variant="secondary"
|
||||
:icon="['far', 'clock']"
|
||||
@click="setFieldActive('reminders')"
|
||||
@@ -599,7 +599,7 @@
|
||||
{{ $t('task.detail.actions.repeatAfter') }}
|
||||
</XButton>
|
||||
<XButton
|
||||
v-shortcut="deleteShortcut"
|
||||
v-shortcut="SHORTCUTS.taskDetail.delete"
|
||||
icon="trash-alt"
|
||||
:shadow="false"
|
||||
class="is-danger is-outlined has-no-border"
|
||||
@@ -668,6 +668,7 @@ import type {IProject} from '@/modelTypes/IProject'
|
||||
import {PRIORITIES, type Priority} from '@/constants/priorities'
|
||||
import {PERMISSIONS} from '@/constants/permissions'
|
||||
import {PRO_FEATURE} from '@/constants/proFeatures'
|
||||
import {SHORTCUTS} from '@/constants/shortcuts'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
@@ -697,7 +698,6 @@ import Reactions from '@/components/input/Reactions.vue'
|
||||
|
||||
import {uploadFile} from '@/helpers/attachments'
|
||||
import {getProjectTitle} from '@/helpers/getProjectTitle'
|
||||
import {isAppleDevice} from '@/helpers/isAppleDevice'
|
||||
import {scrollIntoView} from '@/helpers/scrollIntoView'
|
||||
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
|
||||
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
|
||||
@@ -773,13 +773,6 @@ const lastProject = computed(() => {
|
||||
|
||||
const lastProjectOrTaskProject = computed(() => lastProject.value ?? project.value)
|
||||
|
||||
// Use Shift+R on macOS (Alt+R produces special characters depending on keyboard layout)
|
||||
// Use Alt+r on other platforms
|
||||
const reminderShortcut = computed(() => isAppleDevice() ? 'Shift+KeyR' : 'Alt+KeyR')
|
||||
|
||||
// Match native OS conventions for "delete the selected item"
|
||||
const deleteShortcut = isAppleDevice() ? 'Backspace' : 'Delete'
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (taskNotFound.value) {
|
||||
return
|
||||
|
||||
@@ -16,6 +16,17 @@ test.describe('The Menu', () => {
|
||||
await expect(page.locator('.menu-container')).not.toHaveClass(/is-active/)
|
||||
})
|
||||
|
||||
test('Can be toggled with keyboard shortcut on desktop', async ({authenticatedPage: page}) => {
|
||||
await expect(page.locator('.menu-container')).toHaveClass(/is-active/)
|
||||
await page.locator('body').click()
|
||||
|
||||
await page.locator('body').press('ControlOrMeta+e')
|
||||
await expect(page.locator('.menu-container')).not.toHaveClass(/is-active/)
|
||||
|
||||
await page.locator('body').press('ControlOrMeta+e')
|
||||
await expect(page.locator('.menu-container')).toHaveClass(/is-active/)
|
||||
})
|
||||
|
||||
test('Is hidden by default on mobile', async ({authenticatedPage: page}) => {
|
||||
await page.setViewportSize(iPhone8)
|
||||
await expect(page.locator('.menu-container')).not.toHaveClass(/is-active/)
|
||||
|
||||
@@ -628,6 +628,21 @@ test.describe('Task', () => {
|
||||
await expect(labelWrapper).not.toContainText(labels[0].title)
|
||||
})
|
||||
|
||||
test('Can open due date with keyboard shortcut in task detail', async ({authenticatedPage: page}) => {
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
done: false,
|
||||
})
|
||||
await page.goto(`/tasks/${tasks[0].id}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const dueDateColumn = page.locator('.task-view .columns.details .column').filter({hasText: 'Due Date'})
|
||||
await expect(dueDateColumn).not.toBeVisible()
|
||||
await page.locator('.task-view .action-buttons').click()
|
||||
await page.locator('body').press('d')
|
||||
await expect(dueDateColumn).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can set a due date for a task', async ({authenticatedPage: page}) => {
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
|
||||
Reference in New Issue
Block a user