Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js
new file mode 100644
index 0000000..9e932d6
--- /dev/null
+++ b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.js
@@ -0,0 +1,421 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import page from 'page';
+import qs from 'qs';
+import Mousetrap from 'mousetrap';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {issueRefToString} from 'shared/convertersV0.js';
+
+
+const SHORTCUT_DOC_GROUPS = [
+ {
+ title: 'Issue list',
+ keyDocs: [
+ {
+ keys: ['k', 'j'],
+ tip: 'up/down in the list',
+ },
+ {
+ keys: ['o', 'Enter'],
+ tip: 'open the current issue',
+ },
+ {
+ keys: ['Shift-O'],
+ tip: 'open issue in new tab',
+ },
+ {
+ keys: ['x'],
+ tip: 'select the current issue',
+ },
+ ],
+ },
+ {
+ title: 'Issue details',
+ keyDocs: [
+ {
+ keys: ['k', 'j'],
+ tip: 'prev/next issue in list',
+ },
+ {
+ keys: ['u'],
+ tip: 'up to issue list',
+ },
+ {
+ keys: ['r'],
+ tip: 'reply to current issue',
+ },
+ {
+ keys: ['Ctrl+Enter', '\u2318+Enter'],
+ tip: 'save issue reply (submit issue on issue filing page)',
+ },
+ ],
+ },
+ {
+ title: 'Anywhere',
+ keyDocs: [
+ {
+ keys: ['/'],
+ tip: 'focus on the issue search field',
+ },
+ {
+ keys: ['c'],
+ tip: 'compose a new issue',
+ },
+ {
+ keys: ['s'],
+ tip: 'star the current issue',
+ },
+ {
+ keys: ['?'],
+ tip: 'show this help dialog',
+ },
+ ],
+ },
+];
+
+/**
+ * `<mr-keystrokes>`
+ *
+ * Adds keybindings for Monorail, including a dialog for showing keystrokes.
+ * @extends {LitElement}
+ */
+export class MrKeystrokes extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return css`
+ h2 {
+ margin-top: 0;
+ display: flex;
+ justify-content: space-between;
+ font-weight: normal;
+ border-bottom: 2px solid white;
+ font-size: var(--chops-large-font-size);
+ padding-bottom: 0.5em;
+ }
+ .close-button {
+ border: 0;
+ background: 0;
+ text-decoration: underline;
+ cursor: pointer;
+ }
+ .keyboard-help {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-around;
+ flex-direction: row;
+ border-bottom: 2px solid white;
+ flex-wrap: wrap;
+ }
+ .keyboard-help-section {
+ width: 32%;
+ display: grid;
+ grid-template-columns: 40% 60%;
+ padding-bottom: 1em;
+ grid-gap: 4px;
+ min-width: 300px;
+ }
+ .help-title {
+ font-weight: bold;
+ }
+ .key-shortcut {
+ text-align: right;
+ padding-right: 8px;
+ font-weight: bold;
+ margin: 2px;
+ }
+ kbd {
+ background: var(--chops-gray-200);
+ padding: 2px 8px;
+ border-radius: 2px;
+ min-width: 28px;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <chops-dialog ?opened=${this._opened}>
+ <h2>
+ Issue tracker keyboard shortcuts
+ <button class="close-button" @click=${this._closeDialog}>
+ Close
+ </button>
+ </h2>
+ <div class="keyboard-help">
+ ${this._shortcutDocGroups.map((group) => html`
+ <div class="keyboard-help-section">
+ <span></span><span class="help-title">${group.title}</span>
+ ${group.keyDocs.map((keyDoc) => html`
+ <span class="key-shortcut">
+ ${keyDoc.keys.map((key, i) => html`
+ <kbd>${key}</kbd>
+ <span
+ class="key-separator"
+ ?hidden=${i === keyDoc.keys.length - 1}
+ > / </span>
+ `)}:
+ </span>
+ <span class="key-tip">${keyDoc.tip}</span>
+ `)}
+ </div>
+ `)}
+ </div>
+ <p>
+ Note: Only signed in users can star issues or add comments, and
+ only project members can select issues for bulk edits.
+ </p>
+ </chops-dialog>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issueEntryUrl: {type: String},
+ issueId: {type: Number},
+ _projectName: {type: String},
+ queryParams: {type: Object},
+ _fetchingIsStarred: {type: Boolean},
+ _isStarred: {type: Boolean},
+ _issuePermissions: {type: Array},
+ _opened: {type: Boolean},
+ _shortcutDocGroups: {type: Array},
+ _starringIssues: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this._shortcutDocGroups = SHORTCUT_DOC_GROUPS;
+ this._opened = false;
+ this._starringIssues = new Map();
+ this._projectName = undefined;
+ this._issuePermissions = [];
+ this.issueId = undefined;
+ this.queryParams = undefined;
+ this.issueEntryUrl = undefined;
+
+ this._page = page;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this._projectName = projectV0.viewedProjectName(state);
+ this._issuePermissions = issueV0.permissions(state);
+
+ const starredIssues = issueV0.starredIssues(state);
+ this._isStarred = starredIssues.has(issueRefToString(this._issueRef));
+ this._fetchingIsStarred = issueV0.requests(state).fetchIsStarred.requesting;
+ this._starringIssues = issueV0.starringIssues(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('_projectName') ||
+ changedProperties.has('issueEntryUrl')) {
+ this._bindProjectKeys(this._projectName, this.issueEntryUrl);
+ }
+ if (changedProperties.has('_projectName') ||
+ changedProperties.has('issueId') ||
+ changedProperties.has('_issuePermissions') ||
+ changedProperties.has('queryParams')) {
+ this._bindIssueDetailKeys(this._projectName, this.issueId,
+ this._issuePermissions, this.queryParams);
+ }
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._unbindProjectKeys();
+ this._unbindIssueDetailKeys();
+ }
+
+ /** @private */
+ get _isStarring() {
+ const requestKey = issueRefToString(this._issueRef);
+ if (this._starringIssues.has(requestKey)) {
+ return this._starringIssues.get(requestKey).requesting;
+ }
+ return false;
+ }
+
+ /** @private */
+ get _issueRef() {
+ return {
+ projectName: this._projectName,
+ localId: this.issueId,
+ };
+ }
+
+ /** @private */
+ _toggleDialog() {
+ this._opened = !this._opened;
+ }
+
+ /** @private */
+ _openDialog() {
+ this._opened = true;
+ }
+
+ /** @private */
+ _closeDialog() {
+ this._opened = false;
+ }
+
+ /**
+ * @param {string} projectName
+ * @param {string} issueEntryUrl
+ * @fires CustomEvent#focus-search
+ * @private
+ */
+ _bindProjectKeys(projectName, issueEntryUrl) {
+ this._unbindProjectKeys();
+
+ if (!projectName) return;
+
+ issueEntryUrl = issueEntryUrl || `/p/${projectName}/issues/entry`;
+
+ Mousetrap.bind('/', (e) => {
+ e.preventDefault();
+ // Focus search.
+ this.dispatchEvent(new CustomEvent('focus-search',
+ {composed: true, bubbles: true}));
+ });
+
+ Mousetrap.bind('?', () => {
+ // Toggle key help.
+ this._toggleDialog();
+ });
+
+ Mousetrap.bind('esc', () => {
+ // Close key help dialog if open.
+ this._closeDialog();
+ });
+
+ Mousetrap.bind('c', () => this._page(issueEntryUrl));
+ }
+
+ /** @private */
+ _unbindProjectKeys() {
+ Mousetrap.unbind('/');
+ Mousetrap.unbind('?');
+ Mousetrap.unbind('esc');
+ Mousetrap.unbind('c');
+ }
+
+ /**
+ * @param {string} projectName
+ * @param {string} issueId
+ * @param {Array<string>} issuePermissions
+ * @param {Object} queryParams
+ * @private
+ */
+ _bindIssueDetailKeys(projectName, issueId, issuePermissions, queryParams) {
+ this._unbindIssueDetailKeys();
+
+ if (!projectName || !issueId) return;
+
+ const projectHomeUrl = `/p/${projectName}`;
+
+ const queryString = qs.stringify(queryParams);
+
+ // TODO(zhangtiff): Update these links when mr-flipper's async request
+ // finishes.
+ const prevUrl = `${projectHomeUrl}/issues/detail/previous?${queryString}`;
+ const nextUrl = `${projectHomeUrl}/issues/detail/next?${queryString}`;
+ const canComment = issuePermissions.includes('addissuecomment');
+ const canStar = issuePermissions.includes('setstar');
+
+ // Previous issue in list.
+ Mousetrap.bind('k', () => this._page(prevUrl));
+
+ // Next issue in list.
+ Mousetrap.bind('j', () => this._page(nextUrl));
+
+ // Back to list.
+ Mousetrap.bind('u', () => this._backToList());
+
+ if (canComment) {
+ // Navigate to the form to make changes.
+ Mousetrap.bind('r', () => this._jumpToEditForm());
+ }
+
+ if (canStar) {
+ Mousetrap.bind('s', () => this._starIssue());
+ }
+ }
+
+ /**
+ * Navigates back to the issue list page.
+ * @private
+ */
+ _backToList() {
+ const params = {...this.queryParams,
+ cursor: issueRefToString(this._issueRef)};
+ const queryString = qs.stringify(params);
+ if (params['hotlist_id']) {
+ // Because hotlist URLs require a server look up to be built from a
+ // hotlist ID, we have to route the request through an extra endpoint
+ // that redirects to the appropriate hotlist.
+ const listUrl = `/p/${this._projectName}/issues/detail/list?${
+ queryString}`;
+ this._page(listUrl);
+
+ // TODO(crbug.com/monorail/6341): Switch to using the new hotlist URL once
+ // hotlists have migrated.
+ // this._page(`/hotlists/${params['hotlist_id']}`);
+ } else {
+ delete params.id;
+ const listUrl = `/p/${this._projectName}/issues/list?${queryString}`;
+ this._page(listUrl);
+ }
+ }
+
+ /**
+ * Scrolls the user to the issue editing form when they press
+ * the 'r' key.
+ * @private
+ */
+ _jumpToEditForm() {
+ // Force a hash change even the hash is already makechanges.
+ if (window.location.hash.toLowerCase() === '#makechanges') {
+ window.location.hash = ' ';
+ }
+ window.location.hash = '#makechanges';
+ }
+
+ /**
+ * Stars the current issue the user is viewing on the issue detail page.
+ * @private
+ */
+ _starIssue() {
+ if (!this._fetchingIsStarred && !this._isStarring) {
+ const newIsStarred = !this._isStarred;
+
+ store.dispatch(issueV0.star(this._issueRef, newIsStarred));
+ }
+ }
+
+
+ /** @private */
+ _unbindIssueDetailKeys() {
+ Mousetrap.unbind('k');
+ Mousetrap.unbind('j');
+ Mousetrap.unbind('u');
+ Mousetrap.unbind('r');
+ Mousetrap.unbind('s');
+ }
+}
+
+customElements.define('mr-keystrokes', MrKeystrokes);
diff --git a/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js
new file mode 100644
index 0000000..0d7468f
--- /dev/null
+++ b/static_src/elements/framework/mr-keystrokes/mr-keystrokes.test.js
@@ -0,0 +1,194 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {MrKeystrokes} from './mr-keystrokes.js';
+import Mousetrap from 'mousetrap';
+
+import {issueRefToString} from 'shared/convertersV0.js';
+
+/** @type {MrKeystrokes} */
+let element;
+
+describe('mr-keystrokes', () => {
+ beforeEach(() => {
+ element = /** @type {MrKeystrokes} */ (
+ document.createElement('mr-keystrokes'));
+ document.body.appendChild(element);
+
+ element._projectName = 'proj';
+ element.issueId = 11;
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrKeystrokes);
+ });
+
+ it('tracks if the issue is currently starring', async () => {
+ await element.updateComplete;
+ assert.isFalse(element._isStarring);
+
+ const issueRefStr = issueRefToString(element._issueRef);
+ element._starringIssues.set(issueRefStr, {requesting: true});
+ assert.isTrue(element._isStarring);
+ });
+
+ it('? and esc open and close dialog', async () => {
+ await element.updateComplete;
+ assert.isFalse(element._opened);
+
+ Mousetrap.trigger('?');
+
+ await element.updateComplete;
+ assert.isTrue(element._opened);
+
+ Mousetrap.trigger('esc');
+
+ await element.updateComplete;
+ assert.isFalse(element._opened);
+ });
+
+ describe('issue detail keys', () => {
+ beforeEach(() => {
+ sinon.stub(element, '_page');
+ sinon.stub(element, '_jumpToEditForm');
+ sinon.stub(element, '_starIssue');
+ });
+
+ it('not bound when _projectName not set', async () => {
+ element._projectName = '';
+ element.issueId = 1;
+
+ await element.updateComplete;
+
+ // Navigation hot keys.
+ Mousetrap.trigger('k');
+ Mousetrap.trigger('j');
+ Mousetrap.trigger('u');
+ sinon.assert.notCalled(element._page);
+
+ // Jump to edit form hot key.
+ Mousetrap.trigger('r');
+ sinon.assert.notCalled(element._jumpToEditForm);
+
+ // Star issue hotkey.
+ Mousetrap.trigger('s');
+ sinon.assert.notCalled(element._starIssue);
+ });
+
+ it('not bound when issueId not set', async () => {
+ element._projectName = 'proj';
+ element.issueId = 0;
+
+ await element.updateComplete;
+
+ // Navigation hot keys.
+ Mousetrap.trigger('k');
+ Mousetrap.trigger('j');
+ Mousetrap.trigger('u');
+ sinon.assert.notCalled(element._page);
+
+ // Jump to edit form hot key.
+ Mousetrap.trigger('r');
+ sinon.assert.notCalled(element._jumpToEditForm);
+
+ // Star issue hotkey.
+ Mousetrap.trigger('s');
+ sinon.assert.notCalled(element._starIssue);
+ });
+
+ it('binds j and k navigation hot keys', async () => {
+ element.queryParams = {q: 'something'};
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('k');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/detail/previous?q=something');
+
+ Mousetrap.trigger('j');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/detail/next?q=something');
+
+ Mousetrap.trigger('u');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/list?q=something&cursor=proj%3A11');
+ });
+
+ it('u key navigates back to issue list wth cursor set', async () => {
+ element.queryParams = {q: 'something'};
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('u');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/list?q=something&cursor=proj%3A11');
+ });
+
+ it('u key navigates back to hotlist when hotlist_id set', async () => {
+ element.queryParams = {hotlist_id: 1234};
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('u');
+ sinon.assert.calledWith(element._page,
+ '/p/proj/issues/detail/list?hotlist_id=1234&cursor=proj%3A11');
+ });
+
+ it('does not star when user does not have permission', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = [];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('s');
+ sinon.assert.notCalled(element._starIssue);
+ });
+
+ it('does star when user has permission', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = ['setstar'];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('s');
+ sinon.assert.calledOnce(element._starIssue);
+ });
+
+ it('does not star when user does not have permission', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = [];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('s');
+ sinon.assert.notCalled(element._starIssue);
+ });
+
+ it('does not jump to edit form when user cannot comment', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = [];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('r');
+ sinon.assert.notCalled(element._jumpToEditForm);
+ });
+
+ it('does jump to edit form when user can comment', async () => {
+ element.queryParams = {q: 'something'};
+ element._issuePermissions = ['addissuecomment'];
+
+ await element.updateComplete;
+
+ Mousetrap.trigger('r');
+ sinon.assert.calledOnce(element._jumpToEditForm);
+ });
+ });
+});