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