Skip to content
Snippets Groups Projects
Commit eba44228 authored by Sam Bigelow's avatar Sam Bigelow Committed by Paul Slaughter
Browse files

Add kbd shortcuts for discussion navigation

Add keyboard shortcuts `p` and `n` to navigate duscussions.
parent ff81c0a3
No related branches found
No related tags found
No related merge requests found
Loading
@@ -109,7 +109,7 @@ export const toggleLineDiscussions = ({ commit }, options) => {
Loading
@@ -109,7 +109,7 @@ export const toggleLineDiscussions = ({ commit }, options) => {
export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => { export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
const discussion = rootState.notes.discussions.find(d => d.id === discussionId); const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
   
if (discussion) { if (discussion && discussion.diff_file) {
const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash); const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash);
   
if (file) { if (file) {
Loading
Loading
Loading
@@ -3,6 +3,7 @@ import Vue from 'vue';
Loading
@@ -3,6 +3,7 @@ import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import store from 'ee_else_ce/mr_notes/stores'; import store from 'ee_else_ce/mr_notes/stores';
import notesApp from '../notes/components/notes_app.vue'; import notesApp from '../notes/components/notes_app.vue';
import discussionKeyboardNavigator from '../notes/components/discussion_keyboard_navigator.vue';
   
export default () => { export default () => {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
Loading
@@ -56,15 +57,19 @@ export default () => {
Loading
@@ -56,15 +57,19 @@ export default () => {
}, },
}, },
render(createElement) { render(createElement) {
return createElement('notes-app', { const isDiffView = this.activeTab === 'diffs';
props: {
noteableData: this.noteableData, return createElement(discussionKeyboardNavigator, { props: { isDiffView } }, [
notesData: this.notesData, createElement('notes-app', {
userData: this.currentUserData, props: {
shouldShow: this.activeTab === 'show', noteableData: this.noteableData,
helpPagePath: this.helpPagePath, notesData: this.notesData,
}, userData: this.currentUserData,
}); shouldShow: this.activeTab === 'show',
helpPagePath: this.helpPagePath,
},
}),
]);
}, },
}); });
}; };
<script>
/* global Mousetrap */
import 'mousetrap';
import { mapGetters, mapActions } from 'vuex';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
export default {
mixins: [discussionNavigation],
props: {
isDiffView: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
currentDiscussionId: null,
};
},
computed: {
...mapGetters(['nextUnresolvedDiscussionId', 'previousUnresolvedDiscussionId']),
},
mounted() {
Mousetrap.bind('n', () => this.jumpToNextDiscussion());
Mousetrap.bind('p', () => this.jumpToPreviousDiscussion());
},
methods: {
...mapActions(['expandDiscussion']),
jumpToNextDiscussion() {
const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
this.jumpToDiscussion(nextId);
this.currentDiscussionId = nextId;
},
jumpToPreviousDiscussion() {
const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
this.jumpToDiscussion(prevId);
this.currentDiscussionId = prevId;
},
},
render() {
return this.$slots.default;
},
};
</script>
Loading
@@ -183,6 +183,15 @@ export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, dif
Loading
@@ -183,6 +183,15 @@ export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, dif
return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0]; return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0];
}; };
   
export const previousUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => {
const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
const currentIndex = idsOrdered.indexOf(discussionId);
const slicedIds = idsOrdered.slice(currentIndex - 1, currentIndex);
// Get the last ID if there is none after the currentIndex
return slicedIds.length ? slicedIds[0] : idsOrdered[idsOrdered.length - 1];
};
// @param {Boolean} diffOrder - is ordered by diff? // @param {Boolean} diffOrder - is ordered by diff?
export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => { export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => {
if (diffOrder) { if (diffOrder) {
Loading
Loading
Loading
@@ -332,7 +332,7 @@
Loading
@@ -332,7 +332,7 @@
%td.shortcut %td.shortcut
%kbd l %kbd l
%td Change Label %td Change Label
%tbody.hidden-shortcut{ style: 'display:none' } %tbody.hidden-shortcut.merge_requests{ style: 'display:none' }
%tr %tr
%th %th
%th Merge Requests %th Merge Requests
Loading
@@ -368,6 +368,14 @@
Loading
@@ -368,6 +368,14 @@
\/ \/
%kbd k %kbd k
%td Move to previous file %td Move to previous file
%tr
%td.shortcut
%kbd n
%td= _("Move to next unresolved discussion")
%tr
%td.shortcut
%kbd p
%td= _("Move to previous unresolved discussion")
%tbody.hidden-shortcut{ style: 'display:none' } %tbody.hidden-shortcut{ style: 'display:none' }
%tr %tr
%th %th
Loading
Loading
---
title: Resolve Keyboard shortcut for jump to NEXT unresolved discussion
merge_request: 30144
author:
type: added
Loading
@@ -88,6 +88,11 @@ Jump button next to the Reply field on a thread.
Loading
@@ -88,6 +88,11 @@ Jump button next to the Reply field on a thread.
You can also jump to the first unresolved thread from the button next to the You can also jump to the first unresolved thread from the button next to the
resolved threads tracker. resolved threads tracker.
   
You can also use keyboard shortcuts to navigate among threads:
- Use <kbd>n</kbd> to jump to the next unresolved thread.
- Use <kbd>p</kbd> to jump to the previous unresolved thread.
!["8/9 threads resolved"](img/threads_resolved.png) !["8/9 threads resolved"](img/threads_resolved.png)
   
### Marking a comment or thread as resolved ### Marking a comment or thread as resolved
Loading
Loading
Loading
@@ -84,6 +84,8 @@ You can see GitLab's keyboard shortcuts by using <kbd>shift</kbd> + <kbd>?</kbd>
Loading
@@ -84,6 +84,8 @@ You can see GitLab's keyboard shortcuts by using <kbd>shift</kbd> + <kbd>?</kbd>
| <kbd>l</kbd> | Change label | | <kbd>l</kbd> | Change label |
| <kbd>]</kbd> or <kbd>j</kbd> | Move to next file | | <kbd>]</kbd> or <kbd>j</kbd> | Move to next file |
| <kbd>[</kbd> or <kbd>k</kbd> | Move to previous file | | <kbd>[</kbd> or <kbd>k</kbd> | Move to previous file |
| <kbd>n</kbd> | Move to next unresolved discussion |
| <kbd>p</kbd> | Move to previous unresolved discussion |
   
## Epics **(ULTIMATE)** ## Epics **(ULTIMATE)**
   
Loading
Loading
Loading
@@ -6952,6 +6952,12 @@ msgstr ""
Loading
@@ -6952,6 +6952,12 @@ msgstr ""
msgid "Move this issue to another project." msgid "Move this issue to another project."
msgstr "" msgstr ""
   
msgid "Move to next unresolved discussion"
msgstr ""
msgid "Move to previous unresolved discussion"
msgstr ""
msgid "MoveIssue|Cannot move issue due to insufficient permissions!" msgid "MoveIssue|Cannot move issue due to insufficient permissions!"
msgstr "" msgstr ""
   
Loading
Loading
/* global Mousetrap */
import 'mousetrap';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import DiscussionKeyboardNavigator from '~/notes/components/discussion_keyboard_navigator.vue';
import notesModule from '~/notes/stores/modules';
const localVue = createLocalVue();
localVue.use(Vuex);
const NEXT_ID = 'abc123';
const PREV_ID = 'def456';
const NEXT_DIFF_ID = 'abc123_diff';
const PREV_DIFF_ID = 'def456_diff';
describe('notes/components/discussion_keyboard_navigator', () => {
let storeOptions;
let wrapper;
let store;
const createComponent = (options = {}) => {
store = new Vuex.Store(storeOptions);
wrapper = shallowMount(DiscussionKeyboardNavigator, {
localVue,
store,
...options,
});
wrapper.vm.jumpToDiscussion = jest.fn();
};
beforeEach(() => {
const notes = notesModule();
notes.getters.nextUnresolvedDiscussionId = () => (currId, isDiff) =>
isDiff ? NEXT_DIFF_ID : NEXT_ID;
notes.getters.previousUnresolvedDiscussionId = () => (currId, isDiff) =>
isDiff ? PREV_DIFF_ID : PREV_ID;
storeOptions = {
modules: {
notes,
},
};
});
afterEach(() => {
wrapper.destroy();
storeOptions = null;
store = null;
});
describe.each`
isDiffView | expectedNextId | expectedPrevId
${true} | ${NEXT_DIFF_ID} | ${PREV_DIFF_ID}
${false} | ${NEXT_ID} | ${PREV_ID}
`('when isDiffView is $isDiffView', ({ isDiffView, expectedNextId, expectedPrevId }) => {
beforeEach(() => {
createComponent({ propsData: { isDiffView } });
});
it('calls jumpToNextDiscussion when pressing `n`', () => {
Mousetrap.trigger('n');
expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith(expectedNextId);
expect(wrapper.vm.currentDiscussionId).toEqual(expectedNextId);
});
it('calls jumpToPreviousDiscussion when pressing `p`', () => {
Mousetrap.trigger('p');
expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith(expectedPrevId);
expect(wrapper.vm.currentDiscussionId).toEqual(expectedPrevId);
});
});
});
Loading
@@ -256,6 +256,54 @@ describe('Getters Notes Store', () => {
Loading
@@ -256,6 +256,54 @@ describe('Getters Notes Store', () => {
}); });
}); });
   
describe('previousUnresolvedDiscussionId', () => {
describe('with unresolved discussions', () => {
const localGetters = {
unresolvedDiscussionsIdsOrdered: () => ['123', '456', '789'],
};
it('with bogus returns falsey', () => {
expect(getters.previousUnresolvedDiscussionId(state, localGetters)('bogus')).toBe('456');
});
[
{ id: '123', expected: '789' },
{ id: '456', expected: '123' },
{ id: '789', expected: '456' },
].forEach(({ id, expected }) => {
it(`with ${id}, returns previous value`, () => {
expect(getters.previousUnresolvedDiscussionId(state, localGetters)(id)).toBe(expected);
});
});
});
describe('with 1 unresolved discussion', () => {
const localGetters = {
unresolvedDiscussionsIdsOrdered: () => ['123'],
};
it('with bogus returns id', () => {
expect(getters.previousUnresolvedDiscussionId(state, localGetters)('bogus')).toBe('123');
});
it('with match, returns value', () => {
expect(getters.previousUnresolvedDiscussionId(state, localGetters)('123')).toEqual('123');
});
});
describe('with 0 unresolved discussions', () => {
const localGetters = {
unresolvedDiscussionsIdsOrdered: () => [],
};
it('returns undefined', () => {
expect(
getters.previousUnresolvedDiscussionId(state, localGetters)('bogus'),
).toBeUndefined();
});
});
});
describe('firstUnresolvedDiscussionId', () => { describe('firstUnresolvedDiscussionId', () => {
const localGetters = { const localGetters = {
unresolvedDiscussionsIdsByDate: ['123', '456'], unresolvedDiscussionsIdsByDate: ['123', '456'],
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment