diff --git a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget.vue b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget.vue index 55e4de8183b3d0642698948783f3b61c618a3e59..28e70cc5636e20c8241306fd520b9b439a6ebde1 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_sidebar_dropdown_widget.vue @@ -1,7 +1,16 @@ <script> -import { GlButton, GlForm, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; +import { + GlButton, + GlForm, + GlLoadingIcon, + GlCollapsibleListbox, + GlTooltipDirective, +} from '@gitlab/ui'; import { isEmpty, debounce } from 'lodash'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { sanitize } from '~/lib/dompurify'; +import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; +import { keysFor } from '~/behaviors/shortcuts/keybindings'; import { s__, __, sprintf } from '~/locale'; @@ -19,6 +28,9 @@ export default { GlForm, GlCollapsibleListbox, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { canUpdate: { type: Boolean, @@ -108,6 +120,11 @@ export default { required: false, default: __('Search'), }, + shortcut: { + type: Object, + required: false, + default: () => ({}), + }, }, data() { return { @@ -131,6 +148,22 @@ export default { ? sprintf(__(`No %{label}`), { label: this.dropdownLabel.toLowerCase() }) : this.toggleDropdownText; }, + disableShortcuts() { + return shouldDisableShortcuts() || Object.keys(this.shortcut).length === 0; + }, + shortcutDescription() { + return this.disableShortcuts ? null : this.shortcut.description; + }, + shortcutKey() { + return this.disableShortcuts ? null : keysFor(this.shortcut)[0]; + }, + tooltipText() { + const description = this.shortcutDescription; + const key = this.shortcutKey; + return this.disableShortcuts + ? null + : sanitize(`${description} <kbd class="flat gl-ml-1" aria-hidden=true>${key}</kbd>`); + }, }, watch: { itemValue: { @@ -198,6 +231,8 @@ export default { <gl-loading-icon v-if="updateInProgress" /> <gl-button v-if="canUpdate && !isEditing" + v-gl-tooltip.viewport.html + :title="tooltipText" data-testid="edit-button" category="tertiary" size="small" diff --git a/app/assets/javascripts/work_items/components/work_item_assignees.vue b/app/assets/javascripts/work_items/components/work_item_assignees.vue index 73f882bc94678244c593298cf582a615ddeeba5d..9a0c646381e7361ec6bf68fd8a1ef81b382ec5f1 100644 --- a/app/assets/javascripts/work_items/components/work_item_assignees.vue +++ b/app/assets/javascripts/work_items/components/work_item_assignees.vue @@ -10,6 +10,7 @@ import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_ import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue'; import { s__, sprintf, __ } from '~/locale'; import Tracking from '~/tracking'; +import { ISSUE_MR_CHANGE_ASSIGNEE } from '~/behaviors/shortcuts/keybindings'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql'; import { i18n, TRACKING_CATEGORY_SHOW } from '../constants'; @@ -79,6 +80,7 @@ export default { currentUser: null, updateInProgress: false, localUsers: [], + shortcut: ISSUE_MR_CHANGE_ASSIGNEE, }; }, apollo: { @@ -337,6 +339,7 @@ export default { :header-text="headerText" :update-in-progress="updateInProgress" :reset-button-label="__('Clear')" + :shortcut="shortcut" clear-search-on-item-select data-testid="work-item-assignees" @dropdownShown="onDropdownShown" diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 5267b10620d6a51758d04672c59988e427252602..c60c93dee0a8cba2ba7b0be92efcc45e08655226 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -18,6 +18,9 @@ import { TYPENAME_GROUP } from '~/graphql_shared/constants'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { WORKSPACE_PROJECT } from '~/issues/constants'; import { addShortcutsExtension } from '~/behaviors/shortcuts'; +import { sanitize } from '~/lib/dompurify'; +import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; +import { keysFor, ISSUABLE_EDIT_DESCRIPTION } from '~/behaviors/shortcuts/keybindings'; import ShortcutsWorkItems from '~/behaviors/shortcuts/shortcuts_work_items'; import { i18n, @@ -462,6 +465,16 @@ export default { shouldShowEditButton() { return !this.editMode && this.canUpdate; }, + editShortcutKey() { + return shouldDisableShortcuts() ? null : keysFor(ISSUABLE_EDIT_DESCRIPTION)[0]; + }, + editTooltip() { + const description = __('Edit title and description'); + const key = this.editShortcutKey; + return shouldDisableShortcuts() + ? description + : sanitize(`${description} <kbd class="flat gl-ml-1" aria-hidden=true>${key}</kbd>`); + }, modalCloseButtonClass() { return { 'sm:gl-hidden': !this.error, @@ -933,6 +946,8 @@ export default { <div class="gl-ml-auto gl-mt-1 gl-flex gl-gap-3 gl-self-start"> <gl-button v-if="shouldShowEditButton" + v-gl-tooltip.bottom.html + :title="editTooltip" category="secondary" data-testid="work-item-edit-form-button" class="shortcut-edit-wi-description" diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 9e6d88687bf39f191926372944519dc1a42a3888..295fbf902d56ac9df24f03315bd718b650f84cbb 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -10,6 +10,7 @@ import groupLabelsQuery from '~/sidebar/components/labels/labels_select_widget/g import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; import { isScopedLabel } from '~/lib/utils/common_utils'; import Tracking from '~/tracking'; +import { ISSUABLE_CHANGE_LABEL } from '~/behaviors/shortcuts/keybindings'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateNewWorkItemMutation from '../graphql/update_new_work_item.mutation.graphql'; @@ -72,6 +73,7 @@ export default { addLabelIds: [], labelsCache: [], labelsToShowAtTopOfTheListbox: [], + shortcut: ISSUABLE_CHANGE_LABEL, }; }, computed: { @@ -334,6 +336,7 @@ export default { :toggle-dropdown-text="dropdownText" :header-text="__('Select labels')" :reset-button-label="__('Clear')" + :shortcut="shortcut" show-footer multi-select clear-search-on-item-select diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue index 26eb8a9abe1bc38884b4a187a27234f293a419c8..8a7fd3ba8aa319246618d6e2e0c9857d35482ca0 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -7,6 +7,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__, __ } from '~/locale'; import { MILESTONE_STATE } from '~/sidebar/constants'; import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue'; +import { ISSUE_MR_CHANGE_MILESTONE } from '~/behaviors/shortcuts/keybindings'; import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; import groupMilestonesQuery from '~/sidebar/queries/group_milestones.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; @@ -68,6 +69,7 @@ export default { updateInProgress: false, milestones: [], localMilestone: this.workItemMilestone, + shortcut: ISSUE_MR_CHANGE_MILESTONE, }; }, computed: { @@ -235,6 +237,7 @@ export default { :toggle-dropdown-text="dropdownText" :header-text="__('Select milestone')" :reset-button-label="__('Clear')" + :shortcut="shortcut" data-testid="work-item-milestone" @dropdownShown="onDropdownShown" @searchStarted="search" diff --git a/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_spec.js b/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_spec.js index 7893a75b5b4228ff11f5f5c9348fc0dd1ae38081..485939f4e44eae079709cc1f71bfa0762dcf6a80 100644 --- a/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_spec.js +++ b/spec/frontend/work_items/components/shared/work_item_sidebar_dropdown_widget_spec.js @@ -2,8 +2,13 @@ import { GlForm, GlCollapsibleListbox, GlLoadingIcon } from '@gitlab/ui'; import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { groupIterationsResponse } from 'jest/work_items/mock_data'; +import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; +import { keysFor } from '~/behaviors/shortcuts/keybindings'; import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue'; +jest.mock('~/behaviors/shortcuts/shortcuts_toggle'); +jest.mock('~/behaviors/shortcuts/keybindings'); + describe('WorkItemSidebarDropdownWidget component', () => { let wrapper; @@ -28,6 +33,7 @@ describe('WorkItemSidebarDropdownWidget component', () => { infiniteScrollLoading = false, clearSearchOnItemSelect = false, listItems = [], + shortcut = undefined, } = {}) => { wrapper = mountExtended(WorkItemSidebarDropdownWidget, { propsData: { @@ -43,6 +49,7 @@ describe('WorkItemSidebarDropdownWidget component', () => { infiniteScroll, infiniteScrollLoading, clearSearchOnItemSelect, + shortcut, }, slots, }); @@ -257,4 +264,47 @@ describe('WorkItemSidebarDropdownWidget component', () => { }); }); }); + describe('shortcut tooltip', () => { + const shortcut = { + description: 'Edit dropdown', + }; + + beforeEach(() => { + shouldDisableShortcuts.mockReturnValue(false); + keysFor.mockReturnValue(['e']); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('shows tooltip with key when shortcut is provided', () => { + createComponent({ + canUpdate: true, + shortcut, + }); + const expectedTooltip = 'Edit dropdown <kbd aria-hidden="true" class="flat gl-ml-1">e</kbd>'; + + expect(findEditButton().attributes('title')).toContain(expectedTooltip); + }); + + it('does not show tooltip when shortcut is not provided', () => { + createComponent({ + canUpdate: true, + }); + + expect(findEditButton().attributes('title')).toBeUndefined(); + }); + + it('does not show tooltip when shortcuts are disabled', () => { + shouldDisableShortcuts.mockReturnValue(true); + + createComponent({ + canUpdate: true, + shortcut, + }); + + expect(findEditButton().attributes('title')).toBeUndefined(); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index aba02393fd0c6ec755027066de707335fff764d7..b74f6f810db901f329bc1428a4c8aecd6d3380d8 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -26,6 +26,7 @@ import { workItemResponseFactory, } from 'jest/work_items/mock_data'; import { i18n, TRACKING_CATEGORY_SHOW, NEW_WORK_ITEM_IID } from '~/work_items/constants'; +import { ISSUE_MR_CHANGE_ASSIGNEE } from '~/behaviors/shortcuts/keybindings'; describe('WorkItemAssignees component', () => { Vue.use(VueApollo); @@ -126,6 +127,12 @@ describe('WorkItemAssignees component', () => { expect(findSidebarDropdownWidget().props('dropdownLabel')).toBe('Assignee'); }); + it('has key shortcut tooltip', () => { + createComponent(); + + expect(findSidebarDropdownWidget().props('shortcut')).toBe(ISSUE_MR_CHANGE_ASSIGNEE); + }); + describe('Dropdown search', () => { it('shows no items in the dropdown when no results matching', async () => { createComponent({ searchQueryHandler: successSearchWithNoMatchingUsers }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index b393932ebfd571e685a2f8ed61a9b7a1491f5760..68801489953ff21c945ae2debd50ded1b405f1ca 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -1141,6 +1141,7 @@ describe('WorkItemDetail component', () => { it('shows the edit button', () => { expect(findEditButton().exists()).toBe(true); + expect(findEditButton().attributes('title')).toContain('Edit title and description'); }); it('renders the work item title with edit component', () => { diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index 4e36531bc061d98f7a3d65e2102ea95caee57894..e389637cd500ea9b2e8fb2a6cff010801ec18c9a 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -13,6 +13,7 @@ import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutati import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemSidebarDropdownWidget from '~/work_items/components/shared/work_item_sidebar_dropdown_widget.vue'; +import { ISSUABLE_CHANGE_LABEL } from '~/behaviors/shortcuts/keybindings'; import { projectLabelsResponse, groupLabelsResponse, @@ -145,6 +146,7 @@ describe('WorkItemLabels component', () => { multiSelect: true, showFooter: true, itemValue: [], + shortcut: ISSUABLE_CHANGE_LABEL, }); expect(findAllLabels()).toHaveLength(0); }); diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js index 3e361f344d80d42503f32d34f96d7964bcef03a5..00b3fd8ad2c44aba9d6399183517120db00e3c5e 100644 --- a/spec/frontend/work_items/components/work_item_milestone_spec.js +++ b/spec/frontend/work_items/components/work_item_milestone_spec.js @@ -10,6 +10,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql'; +import { ISSUE_MR_CHANGE_MILESTONE } from '~/behaviors/shortcuts/keybindings'; import { projectMilestonesResponse, projectMilestonesResponseWithNoMilestones, @@ -68,6 +69,12 @@ describe('WorkItemMilestone component', () => { expect(findSidebarDropdownWidget().props('dropdownLabel')).toBe('Milestone'); }); + it('has key shortcut tooltip', () => { + createComponent(); + + expect(findSidebarDropdownWidget().props('shortcut')).toBe(ISSUE_MR_CHANGE_MILESTONE); + }); + describe('Default text with canUpdate false and milestone value', () => { describe.each` description | milestone | value