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);