Commit 0aff8e27 authored by Denys Mishunov's avatar Denys Mishunov Committed by Phil Hughes

Set up basic keyboard next/previous navigation in diff list

Mousetrap is used as the help-tool to listen to keystrokes

Added currentDiffIndex getter to store that holds
the index of currently active diff file in the list

Instead of computing it on the component, we will take advantage of it
being available for all components in DiffsApp

Testing keyboard navigation and jumpToFile()
parent d40a3809
...@@ -5,6 +5,7 @@ import { __ } from '~/locale'; ...@@ -5,6 +5,7 @@ import { __ } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import Mousetrap from 'mousetrap';
import eventHub from '../../notes/event_hub'; import eventHub from '../../notes/event_hub';
import CompareVersions from './compare_versions.vue'; import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue'; import DiffFile from './diff_file.vue';
...@@ -87,7 +88,7 @@ export default { ...@@ -87,7 +88,7 @@ export default {
emailPatchPath: state => state.diffs.emailPatchPath, emailPatchPath: state => state.diffs.emailPatchPath,
}), }),
...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']), ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']),
...mapGetters('diffs', ['isParallelView']), ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']), ...mapGetters(['isNotesFetched', 'getNoteableData']),
targetBranch() { targetBranch() {
return { return {
...@@ -149,6 +150,7 @@ export default { ...@@ -149,6 +150,7 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('fetchDiffData', this.fetchData); eventHub.$off('fetchDiffData', this.fetchData);
this.removeEventListeners();
}, },
methods: { methods: {
...mapActions(['startTaskList']), ...mapActions(['startTaskList']),
...@@ -159,6 +161,7 @@ export default { ...@@ -159,6 +161,7 @@ export default {
'assignDiscussionsToDiff', 'assignDiscussionsToDiff',
'setHighlightedRow', 'setHighlightedRow',
'cacheTreeListWidth', 'cacheTreeListWidth',
'scrollToFile',
]), ]),
fetchData() { fetchData() {
this.fetchDiffFiles() this.fetchDiffFiles()
...@@ -197,7 +200,35 @@ export default { ...@@ -197,7 +200,35 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
window.mrTabs.resetViewContainer(); window.mrTabs.resetViewContainer();
window.mrTabs.expandViewContainer(this.showTreeList); window.mrTabs.expandViewContainer(this.showTreeList);
this.setEventListeners();
}); });
} else {
this.removeEventListeners();
}
},
setEventListeners() {
Mousetrap.bind(['[', 'k', ']', 'j'], (e, combo) => {
switch (combo) {
case '[':
case 'k':
this.jumpToFile(-1);
break;
case ']':
case 'j':
this.jumpToFile(+1);
break;
default:
break;
}
});
},
removeEventListeners() {
Mousetrap.unbind(['[', 'k', ']', 'j']);
},
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
if (targetIndex >= 0 && targetIndex < this.diffFiles.length) {
this.scrollToFile(this.diffFiles[targetIndex].file_path);
} }
}, },
}, },
......
...@@ -52,7 +52,9 @@ export const fetchDiffFiles = ({ state, commit }) => { ...@@ -52,7 +52,9 @@ export const fetchDiffFiles = ({ state, commit }) => {
}; };
export const setHighlightedRow = ({ commit }, lineCode) => { export const setHighlightedRow = ({ commit }, lineCode) => {
const fileHash = lineCode.split('_')[0];
commit(types.SET_HIGHLIGHTED_ROW, lineCode); commit(types.SET_HIGHLIGHTED_ROW, lineCode);
commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash);
}; };
// This is adding line discussions to the actual lines in the diff tree // This is adding line discussions to the actual lines in the diff tree
...@@ -262,8 +264,6 @@ export const scrollToFile = ({ state, commit }, path) => { ...@@ -262,8 +264,6 @@ export const scrollToFile = ({ state, commit }, path) => {
document.location.hash = fileHash; document.location.hash = fileHash;
commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash);
setTimeout(() => commit(types.UPDATE_CURRENT_DIFF_FILE_ID, ''), 1000);
}; };
export const toggleShowTreeList = ({ commit, state }) => { export const toggleShowTreeList = ({ commit, state }) => {
......
...@@ -100,5 +100,12 @@ export const diffFilesLength = state => state.diffFiles.length; ...@@ -100,5 +100,12 @@ export const diffFilesLength = state => state.diffFiles.length;
export const getCommentFormForDiffFile = state => fileHash => export const getCommentFormForDiffFile = state => fileHash =>
state.commentForms.find(form => form.fileHash === fileHash); state.commentForms.find(form => form.fileHash === fileHash);
/**
* Returns index of a currently selected diff in diffFiles
* @returns {number}
*/
export const currentDiffIndex = state =>
Math.max(0, state.diffFiles.findIndex(diff => diff.file_hash === state.currentDiffFileId));
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -356,6 +356,18 @@ ...@@ -356,6 +356,18 @@
%td.shortcut %td.shortcut
%kbd l %kbd l
%td Change Label %td Change Label
%tr
%td.shortcut
%kbd ]
\/
%kbd j
%td Move to next file
%tr
%td.shortcut
%kbd [
\/
%kbd k
%td Move to previous file
%tbody.hidden-shortcut{ style: 'display:none' } %tbody.hidden-shortcut{ style: 'display:none' }
%tr %tr
%th %th
......
---
title: Next/previous navigation between files in MR review
merge_request: 25355
author:
type: added
\ No newline at end of file
...@@ -82,6 +82,8 @@ You can see GitLab's keyboard shortcuts by using 'shift + ?' ...@@ -82,6 +82,8 @@ You can see GitLab's keyboard shortcuts by using 'shift + ?'
| <kbd>r</kbd> | Reply (quoting selected text) | | <kbd>r</kbd> | Reply (quoting selected text) |
| <kbd>e</kbd> | Edit issue/merge request | | <kbd>e</kbd> | Edit issue/merge request |
| <kbd>l</kbd> | Change label | | <kbd>l</kbd> | Change label |
| <kbd>]</kbd> or <kbd>j</kbd> | Move to next file |
| <kbd>[</kbd> or <kbd>k</kbd> | Move to previous file |
## Wiki pages ## Wiki pages
......
...@@ -4,12 +4,13 @@ import { TEST_HOST } from 'spec/test_constants'; ...@@ -4,12 +4,13 @@ import { TEST_HOST } from 'spec/test_constants';
import App from '~/diffs/components/app.vue'; import App from '~/diffs/components/app.vue';
import NoChanges from '~/diffs/components/no_changes.vue'; import NoChanges from '~/diffs/components/no_changes.vue';
import DiffFile from '~/diffs/components/diff_file.vue'; import DiffFile from '~/diffs/components/diff_file.vue';
import Mousetrap from 'mousetrap';
import createDiffsStore from '../create_diffs_store'; import createDiffsStore from '../create_diffs_store';
describe('diffs/components/app', () => { describe('diffs/components/app', () => {
const oldMrTabs = window.mrTabs; const oldMrTabs = window.mrTabs;
let store; let store;
let vm; let wrapper;
function createComponent(props = {}, extendStore = () => {}) { function createComponent(props = {}, extendStore = () => {}) {
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -21,7 +22,7 @@ describe('diffs/components/app', () => { ...@@ -21,7 +22,7 @@ describe('diffs/components/app', () => {
extendStore(store); extendStore(store);
vm = shallowMount(localVue.extend(App), { wrapper = shallowMount(localVue.extend(App), {
localVue, localVue,
propsData: { propsData: {
endpoint: `${TEST_HOST}/diff/endpoint`, endpoint: `${TEST_HOST}/diff/endpoint`,
...@@ -38,7 +39,6 @@ describe('diffs/components/app', () => { ...@@ -38,7 +39,6 @@ describe('diffs/components/app', () => {
// setup globals (needed for component to mount :/) // setup globals (needed for component to mount :/)
window.mrTabs = jasmine.createSpyObj('mrTabs', ['resetViewContainer']); window.mrTabs = jasmine.createSpyObj('mrTabs', ['resetViewContainer']);
window.mrTabs.expandViewContainer = jasmine.createSpy(); window.mrTabs.expandViewContainer = jasmine.createSpy();
window.location.hash = 'ABC_123';
}); });
afterEach(() => { afterEach(() => {
...@@ -46,25 +46,44 @@ describe('diffs/components/app', () => { ...@@ -46,25 +46,44 @@ describe('diffs/components/app', () => {
window.mrTabs = oldMrTabs; window.mrTabs = oldMrTabs;
// reset component // reset component
vm.destroy(); wrapper.destroy();
}); });
it('does not show commit info', () => { it('does not show commit info', () => {
createComponent(); createComponent();
expect(vm.contains('.blob-commit-info')).toBe(false); expect(wrapper.contains('.blob-commit-info')).toBe(false);
}); });
it('sets highlighted row if hash exists in location object', done => { describe('row highlighting', () => {
createComponent({ beforeEach(() => {
shouldShow: true, window.location.hash = 'ABC_123';
}); });
// Component uses $nextTick so we wait until that has finished it('sets highlighted row if hash exists in location object', done => {
setTimeout(() => { createComponent({
expect(store.state.diffs.highlightedRow).toBe('ABC_123'); shouldShow: true,
});
done(); // Component uses $nextTick so we wait until that has finished
setTimeout(() => {
expect(store.state.diffs.highlightedRow).toBe('ABC_123');
done();
});
});
it('marks current diff file based on currently highlighted row', done => {
createComponent({
shouldShow: true,
});
// Component uses $nextTick so we wait until that has finished
setTimeout(() => {
expect(store.state.diffs.currentDiffFileId).toBe('ABC');
done();
});
}); });
}); });
...@@ -76,7 +95,7 @@ describe('diffs/components/app', () => { ...@@ -76,7 +95,7 @@ describe('diffs/components/app', () => {
it('sets initial width when no localStorage has been set', () => { it('sets initial width when no localStorage has been set', () => {
createComponent(); createComponent();
expect(vm.vm.treeWidth).toEqual(320); expect(wrapper.vm.treeWidth).toEqual(320);
}); });
it('sets initial width to localStorage size', () => { it('sets initial width to localStorage size', () => {
...@@ -84,13 +103,26 @@ describe('diffs/components/app', () => { ...@@ -84,13 +103,26 @@ describe('diffs/components/app', () => {
createComponent(); createComponent();
expect(vm.vm.treeWidth).toEqual(200); expect(wrapper.vm.treeWidth).toEqual(200);
}); });
it('sets width of tree list', () => { it('sets width of tree list', () => {
createComponent(); createComponent();
expect(vm.find('.js-diff-tree-list').element.style.width).toEqual('320px'); expect(wrapper.find('.js-diff-tree-list').element.style.width).toEqual('320px');
});
});
it('marks current diff file based on currently highlighted row', done => {
createComponent({
shouldShow: true,
});
// Component uses $nextTick so we wait until that has finished
setTimeout(() => {
expect(store.state.diffs.currentDiffFileId).toBe('ABC');
done();
}); });
}); });
...@@ -98,7 +130,7 @@ describe('diffs/components/app', () => { ...@@ -98,7 +130,7 @@ describe('diffs/components/app', () => {
it('renders empty state when no diff files exist', () => { it('renders empty state when no diff files exist', () => {
createComponent(); createComponent();
expect(vm.contains(NoChanges)).toBe(true); expect(wrapper.contains(NoChanges)).toBe(true);
}); });
it('does not render empty state when diff files exist', () => { it('does not render empty state when diff files exist', () => {
...@@ -108,8 +140,8 @@ describe('diffs/components/app', () => { ...@@ -108,8 +140,8 @@ describe('diffs/components/app', () => {
}); });
}); });
expect(vm.contains(NoChanges)).toBe(false); expect(wrapper.contains(NoChanges)).toBe(false);
expect(vm.findAll(DiffFile).length).toBe(1); expect(wrapper.findAll(DiffFile).length).toBe(1);
}); });
it('does not render empty state when versions match', () => { it('does not render empty state when versions match', () => {
...@@ -118,7 +150,161 @@ describe('diffs/components/app', () => { ...@@ -118,7 +150,161 @@ describe('diffs/components/app', () => {
store.state.diffs.mergeRequestDiff = { version_index: 1 }; store.state.diffs.mergeRequestDiff = { version_index: 1 };
}); });
expect(vm.contains(NoChanges)).toBe(false); expect(wrapper.contains(NoChanges)).toBe(false);
});
});
describe('keyboard shortcut navigation', () => {
const mappings = {
'[': -1,
k: -1,
']': +1,
j: +1,
};
let spy;
describe('visible app', () => {
beforeEach(() => {
spy = jasmine.createSpy('spy');
createComponent({
shouldShow: true,
});
wrapper.setMethods({
jumpToFile: spy,
});
});
it('calls `jumpToFile()` with correct parameter whenever pre-defined key is pressed', done => {
wrapper.vm
.$nextTick()
.then(() => {
Object.keys(mappings).forEach(function(key) {
Mousetrap.trigger(key);
expect(spy.calls.mostRecent().args).toEqual([mappings[key]]);
});
expect(spy.calls.count()).toEqual(Object.keys(mappings).length);
})
.then(done)
.catch(done.fail);
});
it('does not call `jumpToFile()` when unknown key is pressed', done => {
wrapper.vm
.$nextTick()
.then(() => {
Mousetrap.trigger('d');
expect(spy).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
});
describe('hideen app', () => {
beforeEach(() => {
spy = jasmine.createSpy('spy');
createComponent({
shouldShow: false,
});
wrapper.setMethods({
jumpToFile: spy,
});
});
it('stops calling `jumpToFile()` when application is hidden', done => {
wrapper.vm
.$nextTick()
.then(() => {
Object.keys(mappings).forEach(function(key) {
Mousetrap.trigger(key);
expect(spy).not.toHaveBeenCalled();
});
})
.then(done)
.catch(done.fail);
});
});
});
describe('jumpToFile', () => {
let spy;
beforeEach(() => {
spy = jasmine.createSpy();
createComponent({}, () => {
store.state.diffs.diffFiles = [
{ file_hash: '111', file_path: '111.js' },
{ file_hash: '222', file_path: '222.js' },
{ file_hash: '333', file_path: '333.js' },
];
});
wrapper.setMethods({
scrollToFile: spy,
});
});
afterEach(() => {
wrapper.destroy();
});
it('jumps to next and previous files in the list', done => {
wrapper.vm
.$nextTick()
.then(() => {
wrapper.vm.jumpToFile(+1);
expect(spy.calls.mostRecent().args).toEqual(['222.js']);
store.state.diffs.currentDiffFileId = '222';
wrapper.vm.jumpToFile(+1);
expect(spy.calls.mostRecent().args).toEqual(['333.js']);
store.state.diffs.currentDiffFileId = '333';
wrapper.vm.jumpToFile(-1);
expect(spy.calls.mostRecent().args).toEqual(['222.js']);
})
.then(done)
.catch(done.fail);
});
it('does not jump to previous file from the first one', done => {
wrapper.vm
.$nextTick()
.then(() => {
store.state.diffs.currentDiffFileId = '333';
expect(wrapper.vm.currentDiffIndex).toEqual(2);
wrapper.vm.jumpToFile(+1);
expect(wrapper.vm.currentDiffIndex).toEqual(2);
expect(spy).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('does not jump to next file from the last one', done => {
wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.vm.currentDiffIndex).toEqual(0);
wrapper.vm.jumpToFile(-1);
expect(wrapper.vm.currentDiffIndex).toEqual(0);
expect(spy).not.toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
}); });
}); });
}); });
...@@ -100,9 +100,10 @@ describe('DiffsStoreActions', () => { ...@@ -100,9 +100,10 @@ describe('DiffsStoreActions', () => {
}); });
describe('setHighlightedRow', () => { describe('setHighlightedRow', () => {
it('should set lineHash and fileHash of highlightedRow', () => { it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => {
testAction(setHighlightedRow, 'ABC_123', {}, [ testAction(setHighlightedRow, 'ABC_123', {}, [
{ type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' }, { type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' },
{ type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'ABC' },
]); ]);
}); });
}); });
...@@ -713,22 +714,6 @@ describe('DiffsStoreActions', () => { ...@@ -713,22 +714,6 @@ describe('DiffsStoreActions', () => {
expect(commit).toHaveBeenCalledWith(types.UPDATE_CURRENT_DIFF_FILE_ID, 'test'); expect(commit).toHaveBeenCalledWith(types.UPDATE_CURRENT_DIFF_FILE_ID, 'test');
}); });
it('resets currentDiffId after timeout', () => {
const state = {
treeEntries: {
path: {
fileHash: 'test',
},
},
};
scrollToFile({ state, commit }, 'path');
jasmine.clock().tick(1000);
expect(commit.calls.argsFor(1)).toEqual([types.UPDATE_CURRENT_DIFF_FILE_ID, '']);
});
}); });
describe('toggleShowTreeList', () => { describe('toggleShowTreeList', () => {
......
...@@ -270,4 +270,24 @@ describe('Diffs Module Getters', () => { ...@@ -270,4 +270,24 @@ describe('Diffs Module Getters', () => {
expect(getters.diffFilesLength(localState)).toBe(2); expect(getters.diffFilesLength(localState)).toBe(2);
}); });
}); });
describe('currentDiffIndex', () => {
it('returns index of currently selected diff in diffList', () => {
localState.diffFiles = [{ file_hash: '111' }, { file_hash: '222' }, { file_hash: '333' }];
localState.currentDiffFileId = '222';
expect(getters.currentDiffIndex(localState)).toEqual(1);
localState.currentDiffFileId = '333';
expect(getters.currentDiffIndex(localState)).toEqual(2);
});
it('returns 0 if no diff is selected yet or diff is not found', () => {
localState.diffFiles = [{ file_hash: '111' }, { file_hash: '222' }, { file_hash: '333' }];
localState.currentDiffFileId = '';
expect(getters.currentDiffIndex(localState)).toEqual(0);
});
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment