Implement keyboard shortcuts for the change view file list

+ This introduces an app-level viewState that is used to persist
  the position of the carets across DOM restamps.
+ Additionally, the current view is placed in app.params in order
  for the view itself to see whether changes to the params should
  affect it.

Bug: Issue 3775
Change-Id: Idee167fee4ddac5354908a61b9e8138525924907
This commit is contained in:
Andrew Bonventre 2016-01-26 13:39:34 -05:00
parent 8f3fb5c434
commit 5d5c2a8173
7 changed files with 140 additions and 20 deletions

View File

@ -113,7 +113,7 @@ limitations under the License.
</template> </template>
<template is="dom-if" if="{{_showChangeView}}" restamp="true"> <template is="dom-if" if="{{_showChangeView}}" restamp="true">
<gr-change-view server-config="[[config]]" <gr-change-view server-config="[[config]]"
params="[[params]]"></gr-change-view> params="[[params]]" view-state="{{_viewState.changeView}}"></gr-change-view>
</template> </template>
<template is="dom-if" if="{{_showDiffView}}" restamp="true"> <template is="dom-if" if="{{_showDiffView}}" restamp="true">
<gr-diff-view prefs="{{_diffPreferences}}" params="[[params]]"></gr-diff-view> <gr-diff-view prefs="{{_diffPreferences}}" params="[[params]]"></gr-diff-view>
@ -180,27 +180,38 @@ limitations under the License.
reflectToAttribute: true, reflectToAttribute: true,
}, },
params: Object, params: Object,
route: {
type: String,
observer: '_routeChanged',
},
_diffPreferences: Object, _diffPreferences: Object,
_showChangeListView: Boolean, _showChangeListView: Boolean,
_showDashboardView: Boolean, _showDashboardView: Boolean,
_showChangeView: Boolean, _showChangeView: Boolean,
_showDiffView: Boolean, _showDiffView: Boolean,
_viewState: Object,
}, },
listeners: { listeners: {
'title-change': '_handleTitleChange', 'title-change': '_handleTitleChange',
}, },
observers: [ '_viewChanged(params.view)' ],
get loggedIn() { get loggedIn() {
return !!(this.account && Object.keys(this.account).length > 0); return !!(this.account && Object.keys(this.account).length > 0);
}, },
ready: function() {
this._viewState = {
changeView: {
changeNum: null,
patchNum: null,
selectedFileIndex: 0,
},
changeListView: {
selectedChangeIndex: 0,
},
}
},
_accountChanged: function() { _accountChanged: function() {
this._resolveAccountReady(); this._resolveAccountReady();
this.$.accountContainer.classList.toggle('loggedIn', this.loggedIn); this.$.accountContainer.classList.toggle('loggedIn', this.loggedIn);
@ -221,12 +232,12 @@ limitations under the License.
this._resolveConfigReady(config); this._resolveConfigReady(config);
}, },
_routeChanged: function(route) { _viewChanged: function(view) {
this.set('_showChangeListView', route == 'gr-change-list-view'); this.set('_showChangeListView', view == 'gr-change-list-view');
this.set('_showDashboardView', route == 'gr-dashboard-view'); this.set('_showDashboardView', view == 'gr-dashboard-view');
this.set('_showChangeView', route == 'gr-change-view'); this.set('_showChangeView', view == 'gr-change-view');
this.set('_showDiffView', route == 'gr-diff-view'); this.set('_showDiffView', view == 'gr-diff-view');
this.constrained = route == 'gr-change-view'; this.constrained = view == 'gr-change-view';
}, },
_loginTapHandler: function(e) { _loginTapHandler: function(e) {

View File

@ -129,6 +129,8 @@ limitations under the License.
}, },
_paramsChanged: function(value) { _paramsChanged: function(value) {
if (value.view != this.tagName.toLowerCase()) { return; }
this.query = value.query; this.query = value.query;
this.offset = value.offset || 0; this.offset = value.offset || 0;

View File

@ -252,7 +252,8 @@ limitations under the License.
<gr-file-list id="fileList" <gr-file-list id="fileList"
change-num="[[_changeNum]]" change-num="[[_changeNum]]"
patch-num="[[_patchNum]]" patch-num="[[_patchNum]]"
comments="[[_comments]]"></gr-file-list> comments="[[_comments]]"
selected-index="{{viewState.selectedFileIndex}}"></gr-file-list>
<gr-messages-list id="messageList" <gr-messages-list id="messageList"
change-num="[[_changeNum]]" change-num="[[_changeNum]]"
messages="[[_change.messages]]" messages="[[_change.messages]]"
@ -281,6 +282,10 @@ limitations under the License.
type: Object, type: Object,
observer: '_paramsChanged', observer: '_paramsChanged',
}, },
viewState: {
type: Object,
notify: true,
},
serverConfig: Object, serverConfig: Object,
_comments: Object, _comments: Object,
@ -369,8 +374,16 @@ limitations under the License.
}, },
_paramsChanged: function(value) { _paramsChanged: function(value) {
if (value.view != this.tagName.toLowerCase()) { return; }
this._changeNum = value.changeNum; this._changeNum = value.changeNum;
this._patchNum = value.patchNum; this._patchNum = value.patchNum;
if (this.viewState.changeNum != this._changeNum ||
this.viewState.patchNum != this._patchNum) {
this.set('viewState.selectedFileIndex', 0);
this.set('viewState.changeNum', this._changeNum);
this.set('viewState.patchNum', this._patchNum);
}
if (!this._changeNum) { if (!this._changeNum) {
return; return;
} }

View File

@ -223,6 +223,8 @@ limitations under the License.
}, },
_paramsChanged: function(value) { _paramsChanged: function(value) {
if (value.view != this.tagName.toLowerCase()) { return; }
this._changeNum = value.changeNum; this._changeNum = value.changeNum;
this._patchRange = { this._patchRange = {
patchNum: value.patchNum, patchNum: value.patchNum,

View File

@ -40,6 +40,16 @@ limitations under the License.
th { th {
text-align: left; text-align: left;
} }
.positionIndicator {
padding-left: .25em;
visibility: hidden;
}
tr[selected] {
background-color: #ebf5fb;
}
tr[selected] .positionIndicator {
visibility: visible;
}
.status { .status {
width: 20px; width: 20px;
} }
@ -65,8 +75,8 @@ limitations under the License.
<th>Stats</th> <th>Stats</th>
</tr> </tr>
<template is="dom-repeat" items="{{files}}" as="file"> <template is="dom-repeat" items="{{files}}" as="file">
<tr> <tr class="fileRow" selected$="[[_computeFileSelected(index, selectedIndex)]]">
<td></td> <td class="positionIndicator">&#x25b6;</td>
<td class="status">[[file.status]]</td> <td class="status">[[file.status]]</td>
<td class="path"> <td class="path">
<a class="file" <a class="file"
@ -97,8 +107,24 @@ limitations under the License.
changeNum: String, changeNum: String,
comments: Object, comments: Object,
files: Array, files: Array,
selectedIndex: {
type: Number,
notify: true,
},
_drafts: Object, _drafts: Object,
_boundKeyHandler: {
type: Function,
value: function() { return this._handleKey.bind(this); },
}
},
attached: function() {
document.body.addEventListener('keydown', this._boundKeyHandler);
},
detached: function() {
document.body.removeEventListener('keydown', this._boundKeyHandler);
}, },
reload: function() { reload: function() {
@ -153,6 +179,32 @@ limitations under the License.
this.files = files; this.files = files;
}, },
_handleKey: function(e) {
if (util.shouldSupressKeyboardShortcut(e)) { return; }
switch(e.keyCode) {
case 74: // 'j'
e.preventDefault();
this.selectedIndex =
Math.min(this.files.length - 1, this.selectedIndex + 1);
break;
case 75: // 'k'
e.preventDefault();
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
break;
case 13: // <enter>
case 79: // 'o'
e.preventDefault();
page(this._computeDiffURL(this.changeNum, this.patchNum,
this.files[this.selectedIndex].__path));
break;
}
},
_computeFileSelected: function(index, selectedIndex) {
return index == selectedIndex;
},
_computeDiffURL: function(changeNum, patchNum, path) { _computeDiffURL: function(changeNum, patchNum, path) {
return '/c/' + changeNum + '/' + patchNum + '/' + path; return '/c/' + changeNum + '/' + patchNum + '/' + path;
}, },

View File

@ -47,7 +47,7 @@ window.addEventListener('WebComponentsReady', function() {
page('/dashboard/(.*)', loadUser, function(data) { page('/dashboard/(.*)', loadUser, function(data) {
if (app.loggedIn) { if (app.loggedIn) {
app.route = 'gr-dashboard-view'; data.params.view = 'gr-dashboard-view';
app.params = data.params; app.params = data.params;
} else { } else {
page.redirect('/login/' + encodeURIComponent(data.canonicalPath)); page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
@ -55,7 +55,7 @@ window.addEventListener('WebComponentsReady', function() {
}); });
function queryHandler(data) { function queryHandler(data) {
app.route = 'gr-change-list-view'; data.params.view = 'gr-change-list-view';
app.params = data.params; app.params = data.params;
} }
@ -67,17 +67,17 @@ window.addEventListener('WebComponentsReady', function() {
}); });
page('/c/:changeNum/:patchNum?', function(data) { page('/c/:changeNum/:patchNum?', function(data) {
app.route = 'gr-change-view'; data.params.view = 'gr-change-view';
app.params = data.params; app.params = data.params;
}); });
page(/^\/c\/(\d+)\/((\d+)(\.\.(\d+))?)\/(.+)/, function(ctx) { page(/^\/c\/(\d+)\/((\d+)(\.\.(\d+))?)\/(.+)/, function(ctx) {
app.route = 'gr-diff-view';
var params = { var params = {
changeNum: ctx.params[0], changeNum: ctx.params[0],
basePatchNum: ctx.params[2], basePatchNum: ctx.params[2],
patchNum: ctx.params[4], patchNum: ctx.params[4],
path: ctx.params[5] path: ctx.params[5],
view: 'gr-diff-view',
}; };
// Don't allow diffing the same patch number against itself because WHY? // Don't allow diffing the same patch number against itself because WHY?
if (params.basePatchNum == params.patchNum) { if (params.basePatchNum == params.patchNum) {

View File

@ -123,6 +123,46 @@ limitations under the License.
}, 1); }, 1);
}); });
test('keyboard shortcuts', function(done) {
element.changeNum = '42';
element.patchNum = '2';
element.selectedIndex = 0;
element.reload();
server.respond();
element.async(function() {
var elementItems = Polymer.dom(element.root).querySelectorAll(
'.fileRow');
assert.equal(elementItems.length, 3);
assert.isTrue(elementItems[0].hasAttribute('selected'));
assert.isFalse(elementItems[1].hasAttribute('selected'));
assert.isFalse(elementItems[2].hasAttribute('selected'));
MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
assert.equal(element.selectedIndex, 1);
MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
var showStub = sinon.stub(page, 'show');
assert.equal(element.selectedIndex, 2);
MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
'Should navigate to /c/42/2/myfile.txt');
MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
assert.equal(element.selectedIndex, 1);
MockInteractions.pressAndReleaseKeyOn(element, 79); // 'o'
assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
'Should navigate to /c/42/2/file_added_in_rev2.txt');
MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
assert.equal(element.selectedIndex, 0);
showStub.restore();
done();
}, 1);
});
test('comment filtering', function() { test('comment filtering', function() {
var comments = { var comments = {
'/COMMIT_MSG': [ '/COMMIT_MSG': [