blob: 1a61faeef852c0635346dabdf8f6b4bc6f0fd9e2 [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// 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);