blob: 9e932d626162c42a58962216da27b972d92c0fd2 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6import page from 'page';
7import qs from 'qs';
8import Mousetrap from 'mousetrap';
9
10import {store, connectStore} from 'reducers/base.js';
11import * as issueV0 from 'reducers/issueV0.js';
12import * as projectV0 from 'reducers/projectV0.js';
13import 'elements/chops/chops-dialog/chops-dialog.js';
14import {issueRefToString} from 'shared/convertersV0.js';
15
16
17const SHORTCUT_DOC_GROUPS = [
18 {
19 title: 'Issue list',
20 keyDocs: [
21 {
22 keys: ['k', 'j'],
23 tip: 'up/down in the list',
24 },
25 {
26 keys: ['o', 'Enter'],
27 tip: 'open the current issue',
28 },
29 {
30 keys: ['Shift-O'],
31 tip: 'open issue in new tab',
32 },
33 {
34 keys: ['x'],
35 tip: 'select the current issue',
36 },
37 ],
38 },
39 {
40 title: 'Issue details',
41 keyDocs: [
42 {
43 keys: ['k', 'j'],
44 tip: 'prev/next issue in list',
45 },
46 {
47 keys: ['u'],
48 tip: 'up to issue list',
49 },
50 {
51 keys: ['r'],
52 tip: 'reply to current issue',
53 },
54 {
55 keys: ['Ctrl+Enter', '\u2318+Enter'],
56 tip: 'save issue reply (submit issue on issue filing page)',
57 },
58 ],
59 },
60 {
61 title: 'Anywhere',
62 keyDocs: [
63 {
64 keys: ['/'],
65 tip: 'focus on the issue search field',
66 },
67 {
68 keys: ['c'],
69 tip: 'compose a new issue',
70 },
71 {
72 keys: ['s'],
73 tip: 'star the current issue',
74 },
75 {
76 keys: ['?'],
77 tip: 'show this help dialog',
78 },
79 ],
80 },
81];
82
83/**
84 * `<mr-keystrokes>`
85 *
86 * Adds keybindings for Monorail, including a dialog for showing keystrokes.
87 * @extends {LitElement}
88 */
89export class MrKeystrokes extends connectStore(LitElement) {
90 /** @override */
91 static get styles() {
92 return css`
93 h2 {
94 margin-top: 0;
95 display: flex;
96 justify-content: space-between;
97 font-weight: normal;
98 border-bottom: 2px solid white;
99 font-size: var(--chops-large-font-size);
100 padding-bottom: 0.5em;
101 }
102 .close-button {
103 border: 0;
104 background: 0;
105 text-decoration: underline;
106 cursor: pointer;
107 }
108 .keyboard-help {
109 display: flex;
110 align-items: flex-start;
111 justify-content: space-around;
112 flex-direction: row;
113 border-bottom: 2px solid white;
114 flex-wrap: wrap;
115 }
116 .keyboard-help-section {
117 width: 32%;
118 display: grid;
119 grid-template-columns: 40% 60%;
120 padding-bottom: 1em;
121 grid-gap: 4px;
122 min-width: 300px;
123 }
124 .help-title {
125 font-weight: bold;
126 }
127 .key-shortcut {
128 text-align: right;
129 padding-right: 8px;
130 font-weight: bold;
131 margin: 2px;
132 }
133 kbd {
134 background: var(--chops-gray-200);
135 padding: 2px 8px;
136 border-radius: 2px;
137 min-width: 28px;
138 }
139 `;
140 }
141
142 /** @override */
143 render() {
144 return html`
145 <chops-dialog ?opened=${this._opened}>
146 <h2>
147 Issue tracker keyboard shortcuts
148 <button class="close-button" @click=${this._closeDialog}>
149 Close
150 </button>
151 </h2>
152 <div class="keyboard-help">
153 ${this._shortcutDocGroups.map((group) => html`
154 <div class="keyboard-help-section">
155 <span></span><span class="help-title">${group.title}</span>
156 ${group.keyDocs.map((keyDoc) => html`
157 <span class="key-shortcut">
158 ${keyDoc.keys.map((key, i) => html`
159 <kbd>${key}</kbd>
160 <span
161 class="key-separator"
162 ?hidden=${i === keyDoc.keys.length - 1}
163 > / </span>
164 `)}:
165 </span>
166 <span class="key-tip">${keyDoc.tip}</span>
167 `)}
168 </div>
169 `)}
170 </div>
171 <p>
172 Note: Only signed in users can star issues or add comments, and
173 only project members can select issues for bulk edits.
174 </p>
175 </chops-dialog>
176 `;
177 }
178
179 /** @override */
180 static get properties() {
181 return {
182 issueEntryUrl: {type: String},
183 issueId: {type: Number},
184 _projectName: {type: String},
185 queryParams: {type: Object},
186 _fetchingIsStarred: {type: Boolean},
187 _isStarred: {type: Boolean},
188 _issuePermissions: {type: Array},
189 _opened: {type: Boolean},
190 _shortcutDocGroups: {type: Array},
191 _starringIssues: {type: Object},
192 };
193 }
194
195 /** @override */
196 constructor() {
197 super();
198
199 this._shortcutDocGroups = SHORTCUT_DOC_GROUPS;
200 this._opened = false;
201 this._starringIssues = new Map();
202 this._projectName = undefined;
203 this._issuePermissions = [];
204 this.issueId = undefined;
205 this.queryParams = undefined;
206 this.issueEntryUrl = undefined;
207
208 this._page = page;
209 }
210
211 /** @override */
212 stateChanged(state) {
213 this._projectName = projectV0.viewedProjectName(state);
214 this._issuePermissions = issueV0.permissions(state);
215
216 const starredIssues = issueV0.starredIssues(state);
217 this._isStarred = starredIssues.has(issueRefToString(this._issueRef));
218 this._fetchingIsStarred = issueV0.requests(state).fetchIsStarred.requesting;
219 this._starringIssues = issueV0.starringIssues(state);
220 }
221
222 /** @override */
223 updated(changedProperties) {
224 if (changedProperties.has('_projectName') ||
225 changedProperties.has('issueEntryUrl')) {
226 this._bindProjectKeys(this._projectName, this.issueEntryUrl);
227 }
228 if (changedProperties.has('_projectName') ||
229 changedProperties.has('issueId') ||
230 changedProperties.has('_issuePermissions') ||
231 changedProperties.has('queryParams')) {
232 this._bindIssueDetailKeys(this._projectName, this.issueId,
233 this._issuePermissions, this.queryParams);
234 }
235 }
236
237 /** @override */
238 disconnectedCallback() {
239 super.disconnectedCallback();
240 this._unbindProjectKeys();
241 this._unbindIssueDetailKeys();
242 }
243
244 /** @private */
245 get _isStarring() {
246 const requestKey = issueRefToString(this._issueRef);
247 if (this._starringIssues.has(requestKey)) {
248 return this._starringIssues.get(requestKey).requesting;
249 }
250 return false;
251 }
252
253 /** @private */
254 get _issueRef() {
255 return {
256 projectName: this._projectName,
257 localId: this.issueId,
258 };
259 }
260
261 /** @private */
262 _toggleDialog() {
263 this._opened = !this._opened;
264 }
265
266 /** @private */
267 _openDialog() {
268 this._opened = true;
269 }
270
271 /** @private */
272 _closeDialog() {
273 this._opened = false;
274 }
275
276 /**
277 * @param {string} projectName
278 * @param {string} issueEntryUrl
279 * @fires CustomEvent#focus-search
280 * @private
281 */
282 _bindProjectKeys(projectName, issueEntryUrl) {
283 this._unbindProjectKeys();
284
285 if (!projectName) return;
286
287 issueEntryUrl = issueEntryUrl || `/p/${projectName}/issues/entry`;
288
289 Mousetrap.bind('/', (e) => {
290 e.preventDefault();
291 // Focus search.
292 this.dispatchEvent(new CustomEvent('focus-search',
293 {composed: true, bubbles: true}));
294 });
295
296 Mousetrap.bind('?', () => {
297 // Toggle key help.
298 this._toggleDialog();
299 });
300
301 Mousetrap.bind('esc', () => {
302 // Close key help dialog if open.
303 this._closeDialog();
304 });
305
306 Mousetrap.bind('c', () => this._page(issueEntryUrl));
307 }
308
309 /** @private */
310 _unbindProjectKeys() {
311 Mousetrap.unbind('/');
312 Mousetrap.unbind('?');
313 Mousetrap.unbind('esc');
314 Mousetrap.unbind('c');
315 }
316
317 /**
318 * @param {string} projectName
319 * @param {string} issueId
320 * @param {Array<string>} issuePermissions
321 * @param {Object} queryParams
322 * @private
323 */
324 _bindIssueDetailKeys(projectName, issueId, issuePermissions, queryParams) {
325 this._unbindIssueDetailKeys();
326
327 if (!projectName || !issueId) return;
328
329 const projectHomeUrl = `/p/${projectName}`;
330
331 const queryString = qs.stringify(queryParams);
332
333 // TODO(zhangtiff): Update these links when mr-flipper's async request
334 // finishes.
335 const prevUrl = `${projectHomeUrl}/issues/detail/previous?${queryString}`;
336 const nextUrl = `${projectHomeUrl}/issues/detail/next?${queryString}`;
337 const canComment = issuePermissions.includes('addissuecomment');
338 const canStar = issuePermissions.includes('setstar');
339
340 // Previous issue in list.
341 Mousetrap.bind('k', () => this._page(prevUrl));
342
343 // Next issue in list.
344 Mousetrap.bind('j', () => this._page(nextUrl));
345
346 // Back to list.
347 Mousetrap.bind('u', () => this._backToList());
348
349 if (canComment) {
350 // Navigate to the form to make changes.
351 Mousetrap.bind('r', () => this._jumpToEditForm());
352 }
353
354 if (canStar) {
355 Mousetrap.bind('s', () => this._starIssue());
356 }
357 }
358
359 /**
360 * Navigates back to the issue list page.
361 * @private
362 */
363 _backToList() {
364 const params = {...this.queryParams,
365 cursor: issueRefToString(this._issueRef)};
366 const queryString = qs.stringify(params);
367 if (params['hotlist_id']) {
368 // Because hotlist URLs require a server look up to be built from a
369 // hotlist ID, we have to route the request through an extra endpoint
370 // that redirects to the appropriate hotlist.
371 const listUrl = `/p/${this._projectName}/issues/detail/list?${
372 queryString}`;
373 this._page(listUrl);
374
375 // TODO(crbug.com/monorail/6341): Switch to using the new hotlist URL once
376 // hotlists have migrated.
377 // this._page(`/hotlists/${params['hotlist_id']}`);
378 } else {
379 delete params.id;
380 const listUrl = `/p/${this._projectName}/issues/list?${queryString}`;
381 this._page(listUrl);
382 }
383 }
384
385 /**
386 * Scrolls the user to the issue editing form when they press
387 * the 'r' key.
388 * @private
389 */
390 _jumpToEditForm() {
391 // Force a hash change even the hash is already makechanges.
392 if (window.location.hash.toLowerCase() === '#makechanges') {
393 window.location.hash = ' ';
394 }
395 window.location.hash = '#makechanges';
396 }
397
398 /**
399 * Stars the current issue the user is viewing on the issue detail page.
400 * @private
401 */
402 _starIssue() {
403 if (!this._fetchingIsStarred && !this._isStarring) {
404 const newIsStarred = !this._isStarred;
405
406 store.dispatch(issueV0.star(this._issueRef, newIsStarred));
407 }
408 }
409
410
411 /** @private */
412 _unbindIssueDetailKeys() {
413 Mousetrap.unbind('k');
414 Mousetrap.unbind('j');
415 Mousetrap.unbind('u');
416 Mousetrap.unbind('r');
417 Mousetrap.unbind('s');
418 }
419}
420
421customElements.define('mr-keystrokes', MrKeystrokes);