blob: 7c7e0028bd05f859fba2423fe6fe4c167b9b599b [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} from 'lit-element';
6import {repeat} from 'lit-html/directives/repeat';
7import page from 'page';
8import qs from 'qs';
9
10import {getServerStatusCron} from 'shared/cron.js';
11import 'elements/framework/mr-site-banner/mr-site-banner.js';
Adrià Vilanova Martínez7cc63372022-05-15 23:16:25 +020012import 'elements/framework/mr-vulnz-banner/mr-vulnz-banner.js';
Copybara854996b2021-09-07 19:36:02 +000013import {store, connectStore} from 'reducers/base.js';
14import * as projectV0 from 'reducers/projectV0.js';
15import {hotlists} from 'reducers/hotlists.js';
16import * as issueV0 from 'reducers/issueV0.js';
17import * as permissions from 'reducers/permissions.js';
18import * as users from 'reducers/users.js';
19import * as userv0 from 'reducers/userV0.js';
20import * as ui from 'reducers/ui.js';
21import * as sitewide from 'reducers/sitewide.js';
22import {arrayToEnglish} from 'shared/helpers.js';
23import {trackPageChange} from 'shared/ga-helpers.js';
Copybara854996b2021-09-07 19:36:02 +000024import 'elements/issue-list/mr-list-page/mr-list-page.js';
25import 'elements/issue-entry/mr-issue-entry-page.js';
26import 'elements/framework/mr-header/mr-header.js';
27import 'elements/help/mr-cue/mr-cue.js';
28import {cueNames} from 'elements/help/mr-cue/cue-helpers.js';
29import 'elements/chops/chops-snackbar/chops-snackbar.js';
30
31import {SHARED_STYLES} from 'shared/shared-styles.js';
32
33const QUERY_PARAMS_THAT_RESET_SCROLL = ['q', 'mode', 'id'];
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +020034const GOOGLE_EMAIL_SUFFIX = '@google.com';
Copybara854996b2021-09-07 19:36:02 +000035
36/**
37 * `<mr-app>`
38 *
39 * The container component for all pages under the Monorail SPA.
40 *
41 */
42export class MrApp extends connectStore(LitElement) {
43 /** @override */
44 render() {
45 if (this.page === 'wizard') {
46 return html`<div id="reactMount"></div>`;
47 }
48
49 return html`
50 <style>
51 ${SHARED_STYLES}
52 mr-app {
53 display: block;
54 padding-top: var(--monorail-header-height);
55 margin-top: -1px; /* Prevent a double border from showing up. */
56
57 /* From shared-styles.js. */
58 --mr-edit-field-padding: 0.125em 4px;
59 --mr-edit-field-width: 90%;
60 --mr-input-grid-gap: 6px;
61 font-family: var(--chops-font-family);
62 color: var(--chops-primary-font-color);
63 font-size: var(--chops-main-font-size);
64 }
65 main {
66 border-top: var(--chops-normal-border);
67 }
68 .snackbar-container {
69 position: fixed;
70 bottom: 1em;
71 left: 1em;
72 display: flex;
73 flex-direction: column;
74 align-items: flex-start;
75 z-index: 1000;
76 }
77 /** Unfix <chops-snackbar> to allow stacking. */
78 chops-snackbar {
79 position: static;
80 margin-top: 0.5em;
81 }
82 </style>
83 <mr-header
84 .userDisplayName=${this.userDisplayName}
85 .loginUrl=${this.loginUrl}
86 .logoutUrl=${this.logoutUrl}
87 ></mr-header>
Copybara854996b2021-09-07 19:36:02 +000088 <mr-site-banner></mr-site-banner>
Adrià Vilanova Martínez7cc63372022-05-15 23:16:25 +020089 <mr-vulnz-banner></mr-vulnz-banner>
Copybara854996b2021-09-07 19:36:02 +000090 <mr-cue
91 cuePrefName=${cueNames.SWITCH_TO_PARENT_ACCOUNT}
92 .loginUrl=${this.loginUrl}
93 centered
94 nondismissible
95 ></mr-cue>
96 <mr-cue
97 cuePrefName=${cueNames.SEARCH_FOR_NUMBERS}
98 centered
99 ></mr-cue>
100 <main>${this._renderPage()}</main>
101 <div class="snackbar-container" aria-live="polite">
102 ${repeat(this._snackbars, (snackbar) => html`
103 <chops-snackbar
104 @close=${this._closeSnackbar.bind(this, snackbar.id)}
105 >${snackbar.text}</chops-snackbar>
106 `)}
107 </div>
108 `;
109 }
110
111 /**
112 * @param {string} id The name of the snackbar to close.
113 */
114 _closeSnackbar(id) {
115 store.dispatch(ui.hideSnackbar(id));
116 }
117
118 /**
119 * Helper for determiing which page component to render.
120 * @return {TemplateResult}
121 */
122 _renderPage() {
123 switch (this.page) {
124 case 'detail':
125 return html`
126 <mr-issue-page
127 .userDisplayName=${this.userDisplayName}
128 .loginUrl=${this.loginUrl}
129 ></mr-issue-page>
130 `;
131 case 'entry':
132 return html`
133 <mr-issue-entry-page
134 .userDisplayName=${this.userDisplayName}
135 .loginUrl=${this.loginUrl}
136 ></mr-issue-entry-page>
137 `;
138 case 'grid':
139 return html`
140 <mr-grid-page
141 .userDisplayName=${this.userDisplayName}
142 ></mr-grid-page>
143 `;
144 case 'list':
145 return html`
146 <mr-list-page
147 .userDisplayName=${this.userDisplayName}
148 ></mr-list-page>
149 `;
150 case 'chart':
151 return html`<mr-chart-page></mr-chart-page>`;
152 case 'projects':
153 return html`<mr-projects-page></mr-projects-page>`;
154 case 'hotlist-issues':
155 return html`<mr-hotlist-issues-page></mr-hotlist-issues-page>`;
156 case 'hotlist-people':
157 return html`<mr-hotlist-people-page></mr-hotlist-people-page>`;
158 case 'hotlist-settings':
159 return html`<mr-hotlist-settings-page></mr-hotlist-settings-page>`;
160 default:
161 return;
162 }
163 }
164
165 /** @override */
166 static get properties() {
167 return {
168 /**
169 * Backend-generated URL for the page the user is directed to for login.
170 */
171 loginUrl: {type: String},
172 /**
173 * Backend-generated URL for the page the user is directed to for logout.
174 */
175 logoutUrl: {type: String},
176 /**
177 * The display name of the currently logged in user.
178 */
179 userDisplayName: {type: String},
180 /**
181 * The search parameters in the user's current URL.
182 */
183 queryParams: {type: Object},
184 /**
185 * A list of forms to check for "dirty" values when the user navigates
186 * across pages.
187 */
188 dirtyForms: {type: Array},
189 /**
190 * App Engine ID for the current version being viewed.
191 */
192 versionBase: {type: String},
193 /**
194 * A String identifier for the page that the user is viewing.
195 */
196 page: {type: String},
197 /**
198 * A String for the title of the page that the user will see in their
199 * browser tab. ie: equivalent to the <title> tag.
200 */
201 pageTitle: {type: String},
202 /**
203 * Array of snackbar objects to render.
204 */
205 _snackbars: {type: Array},
206 };
207 }
208
209 /** @override */
210 constructor() {
211 super();
212 this.queryParams = {};
213 this.dirtyForms = [];
214 this.userDisplayName = '';
215
216 /**
217 * @type {PageJS.Context}
218 * The context of the page. This should not be a LitElement property
219 * because we don't want to re-render when updating this.
220 */
221 this._lastContext = undefined;
222 }
223
224 /** @override */
225 createRenderRoot() {
226 return this;
227 }
228
229 /** @override */
230 stateChanged(state) {
231 this.dirtyForms = ui.dirtyForms(state);
232 this.queryParams = sitewide.queryParams(state);
233 this.pageTitle = sitewide.pageTitle(state);
234 this._snackbars = ui.snackbars(state);
235 }
236
237 /** @override */
238 updated(changedProperties) {
239 if (changedProperties.has('userDisplayName') && this.userDisplayName) {
240 // TODO(https://crbug.com/monorail/7238): Migrate userv0 calls to v3 API.
241 store.dispatch(userv0.fetch(this.userDisplayName));
242
243 // Typically we would prefer 'users/<userId>' instead.
244 store.dispatch(users.fetch(`users/${this.userDisplayName}`));
245 }
246
247 if (changedProperties.has('pageTitle')) {
248 // To ensure that changes to the page title are easy to reason about,
249 // we want to sync the current pageTitle in the Redux state to
250 // document.title in only one place in the code.
251 document.title = this.pageTitle;
252 }
253 if (changedProperties.has('page')) {
254 trackPageChange(this.page, this.userDisplayName);
255 }
256 }
257
258 /** @override */
259 connectedCallback() {
260 super.connectedCallback();
261
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200262 this._logGooglerUsage();
263
Copybara854996b2021-09-07 19:36:02 +0000264 // TODO(zhangtiff): Figure out some way to save Redux state between
265 // page loads.
266
267 // page doesn't handle users reloading the page or closing a tab.
268 window.onbeforeunload = this._confirmDiscardMessage.bind(this);
269
270 // Start a cron task to periodically request the status from the server.
271 getServerStatusCron.start();
272
273 const postRouteHandler = this._postRouteHandler.bind(this);
274
275 // Populate the project route parameter before _preRouteHandler runs.
276 page('/p/:project/*', (_ctx, next) => next());
277 page('*', this._preRouteHandler.bind(this));
278
279 page('/hotlists/:hotlist', (ctx) => {
280 page.redirect(`/hotlists/${ctx.params.hotlist}/issues`);
281 });
282 page('/hotlists/:hotlist/*', this._selectHotlist);
283 page('/hotlists/:hotlist/issues',
284 this._loadHotlistIssuesPage.bind(this), postRouteHandler);
285 page('/hotlists/:hotlist/people',
286 this._loadHotlistPeoplePage.bind(this), postRouteHandler);
287 page('/hotlists/:hotlist/settings',
288 this._loadHotlistSettingsPage.bind(this), postRouteHandler);
289
290 // Handle Monorail's landing page.
291 page('/p', '/');
292 page('/projects', '/');
293 page('/hosting', '/');
294 page('/', this._loadProjectsPage.bind(this), postRouteHandler);
295
296 page('/p/:project/issues/list', this._loadListPage.bind(this),
297 postRouteHandler);
298 page('/p/:project/issues/detail', this._loadIssuePage.bind(this),
299 postRouteHandler);
300 page('/p/:project/issues/entry_new', this._loadEntryPage.bind(this),
301 postRouteHandler);
302 page('/p/:project/issues/wizard', this._loadWizardPage.bind(this),
303 postRouteHandler);
304
305 // Redirects from old hotlist pages to SPA hotlist pages.
306 const hotlistRedirect = (pageName) => async (ctx) => {
307 const name =
308 await hotlists.getHotlistName(ctx.params.user, ctx.params.hotlist);
309 page.redirect(`/${name}/${pageName}`);
310 };
311 page('/users/:user/hotlists/:hotlist', hotlistRedirect('issues'));
312 page('/users/:user/hotlists/:hotlist/people', hotlistRedirect('people'));
313 page('/users/:user/hotlists/:hotlist/details', hotlistRedirect('settings'));
314
315 page();
316 }
317
318 /**
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200319 * Helper to log how often Googlers access Monorail.
320 */
321 _logGooglerUsage() {
322 const email = this.userDisplayName;
323 if (!email) return;
324 if (!email.endsWith(GOOGLE_EMAIL_SUFFIX)) return;
325
326 const username = email.replace(GOOGLE_EMAIL_SUFFIX, '');
327
328 // Context: b/229758140
329 window.fetch(`https://buganizer.corp.google.com/action/yes?monorail=yes&username=${username}`,
330 {mode: 'no-cors'});
331 }
332
333 /**
Copybara854996b2021-09-07 19:36:02 +0000334 * Handler that runs on every single route change, before the new page has
335 * loaded. This function should not use store.dispatch() or assign properties
336 * on this because running these actions causes extra re-renders to happen.
337 * @param {PageJS.Context} ctx A page.js Context containing routing state.
338 * @param {function} next Passes execution on to the next registered callback.
339 */
340 _preRouteHandler(ctx, next) {
341 // We're not really navigating anywhere, so don't do anything.
342 if (this._lastContext && this._lastContext.path &&
343 ctx.path === this._lastContext.path) {
344 Object.assign(ctx, this._lastContext);
345 // Set ctx.handled to false, so we don't push the state to browser's
346 // history.
347 ctx.handled = false;
348 return;
349 }
350
351 // Check if there were forms with unsaved data before loading the next
352 // page.
353 const discardMessage = this._confirmDiscardMessage();
354 if (discardMessage && !confirm(discardMessage)) {
355 Object.assign(ctx, this._lastContext);
356 // Set ctx.handled to false, so we don't push the state to browser's
357 // history.
358 ctx.handled = false;
359 // We don't call next to avoid loading whatever page was supposed to
360 // load next.
361 return;
362 }
363
364 // Run query string parsing on all routes. Query params must be parsed
365 // before routes are loaded because some routes use them to conditionally
366 // load bundles.
367 // Based on: https://visionmedia.github.io/page.js/#plugins
368 const params = qs.parse(ctx.querystring);
369
370 // Make sure queryParams are not case sensitive.
371 const lowerCaseParams = {};
372 Object.keys(params).forEach((key) => {
373 lowerCaseParams[key.toLowerCase()] = params[key];
374 });
375 ctx.queryParams = lowerCaseParams;
376
377 this._selectProject(ctx.params.project);
378
379 next();
380 }
381
382 /**
383 * Handler that runs on every single route change, after the new page has
384 * loaded.
385 * @param {PageJS.Context} ctx A page.js Context containing routing state.
386 * @param {function} next Passes execution on to the next registered callback.
387 */
388 _postRouteHandler(ctx, next) {
389 // Scroll to the requested element if a hash is present.
390 if (ctx.hash) {
391 store.dispatch(ui.setFocusId(ctx.hash));
392 }
393
394 // Sync queryParams to Redux after the route has loaded, rather than before,
395 // to avoid having extra queryParams update on the previously loaded
396 // component.
397 store.dispatch(sitewide.setQueryParams(ctx.queryParams));
398
399 // Increment the count of navigations in the Redux store.
400 store.dispatch(ui.incrementNavigationCount());
401
402 // Clear dirty forms when entering a new page.
403 store.dispatch(ui.clearDirtyForms());
404
405
406 if (!this._lastContext || this._lastContext.pathname !== ctx.pathname ||
407 this._hasReleventParamChanges(ctx.queryParams,
408 this._lastContext.queryParams)) {
409 // Reset the scroll position after a new page has rendered.
410 window.scrollTo(0, 0);
411 }
412
413 // Save the context of this page to be compared to later.
414 this._lastContext = ctx;
415 }
416
417 /**
418 * Finds if a route change changed query params in a way that should cause
419 * scrolling to reset.
420 * @param {Object} currentParams
421 * @param {Object} oldParams
422 * @param {Array<string>=} paramsToCompare Which params to check.
423 * @return {boolean} Whether any of the relevant query params changed.
424 */
425 _hasReleventParamChanges(currentParams, oldParams,
426 paramsToCompare = QUERY_PARAMS_THAT_RESET_SCROLL) {
427 return paramsToCompare.some((paramName) => {
428 return currentParams[paramName] !== oldParams[paramName];
429 });
430 }
431
432 /**
433 * Helper to manage syncing project route state to Redux.
434 * @param {string=} project displayName for a referenced project.
435 * Defaults to null for consistency with Redux.
436 */
437 _selectProject(project = null) {
438 if (projectV0.viewedProjectName(store.getState()) !== project) {
439 // Note: We want to update the project even if the new project
440 // is null.
441 store.dispatch(projectV0.select(project));
442 if (project) {
443 store.dispatch(projectV0.fetch(project));
444 }
445 }
446 }
447
448 /**
449 * Loads and triggers rendering for the list of all projects.
450 * @param {PageJS.Context} ctx A page.js Context containing routing state.
451 * @param {function} next Passes execution on to the next registered callback.
452 */
453 async _loadProjectsPage(ctx, next) {
454 await import(/* webpackChunkName: "mr-projects-page" */
455 '../projects/mr-projects-page/mr-projects-page.js');
456 this.page = 'projects';
457 next();
458 }
459
460 /**
461 * Loads and triggers render for the issue detail page.
462 * @param {PageJS.Context} ctx A page.js Context containing routing state.
463 * @param {function} next Passes execution on to the next registered callback.
464 */
465 async _loadIssuePage(ctx, next) {
466 performance.clearMarks('start load issue detail page');
467 performance.mark('start load issue detail page');
468
469 await import(/* webpackChunkName: "mr-issue-page" */
470 '../issue-detail/mr-issue-page/mr-issue-page.js');
471
472 const issueRef = {
473 localId: Number.parseInt(ctx.queryParams.id),
474 projectName: ctx.params.project,
475 };
476 store.dispatch(issueV0.viewIssue(issueRef));
477 store.dispatch(issueV0.fetchIssuePageData(issueRef));
478 this.page = 'detail';
479 next();
480 }
481
482 /**
483 * Loads and triggers render for the issue list page, including the list,
484 * grid, and chart modes.
485 * @param {PageJS.Context} ctx A page.js Context containing routing state.
486 * @param {function} next Passes execution on to the next registered callback.
487 */
488 async _loadListPage(ctx, next) {
489 performance.clearMarks('start load issue list page');
490 performance.mark('start load issue list page');
491 switch (ctx.queryParams && ctx.queryParams.mode &&
492 ctx.queryParams.mode.toLowerCase()) {
493 case 'grid':
494 await import(/* webpackChunkName: "mr-grid-page" */
495 '../issue-list/mr-grid-page/mr-grid-page.js');
496 this.page = 'grid';
497 break;
498 case 'chart':
499 await import(/* webpackChunkName: "mr-chart-page" */
500 '../issue-list/mr-chart-page/mr-chart-page.js');
501 this.page = 'chart';
502 break;
503 default:
504 this.page = 'list';
505 break;
506 }
507 next();
508 }
509
510 /**
511 * Load the issue entry page
512 * @param {PageJS.Context} ctx A page.js Context containing routing state.
513 * @param {function} next Passes execution on to the next registered callback.
514 */
515 _loadEntryPage(ctx, next) {
516 this.page = 'entry';
517 next();
518 }
519
520 /**
521 * Load the issue wizard
522 * @param {PageJS.Context} ctx A page.js Context containing routing state.
523 * @param {function} next Passes execution on to the next registered callback.
524 */
525 async _loadWizardPage(ctx, next) {
526 const {renderWizard} = await import(
527 /* webpackChunkName: "IssueWizard" */ '../../react/IssueWizard.tsx');
528
529 this.page = 'wizard';
530 next();
531
532 await this.updateComplete;
533
534 const mount = document.getElementById('reactMount');
535
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200536 renderWizard(mount, this.loginUrl, this.userDisplayName);
Copybara854996b2021-09-07 19:36:02 +0000537 }
538
539 /**
540 * Gets the currently viewed HotlistRef from the URL, selects
541 * it in the Redux store, and fetches the Hotlist data.
542 * @param {PageJS.Context} ctx A page.js Context containing routing state.
543 * @param {function} next Passes execution on to the next registered callback.
544 */
545 _selectHotlist(ctx, next) {
546 const name = 'hotlists/' + ctx.params.hotlist;
547 store.dispatch(hotlists.select(name));
548 store.dispatch(hotlists.fetch(name));
549 store.dispatch(hotlists.fetchItems(name));
550 store.dispatch(permissions.batchGet([name]));
551 next();
552 }
553
554 /**
555 * Loads mr-hotlist-issues-page.js and makes it the currently viewed page.
556 * @param {PageJS.Context} ctx A page.js Context containing routing state.
557 * @param {function} next Passes execution on to the next registered callback.
558 */
559 async _loadHotlistIssuesPage(ctx, next) {
560 await import(/* webpackChunkName: "mr-hotlist-issues-page" */
561 `../hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js`);
562 this.page = 'hotlist-issues';
563 next();
564 }
565
566 /**
567 * Loads mr-hotlist-people-page.js and makes it the currently viewed page.
568 * @param {PageJS.Context} ctx A page.js Context containing routing state.
569 * @param {function} next Passes execution on to the next registered callback.
570 */
571 async _loadHotlistPeoplePage(ctx, next) {
572 await import(/* webpackChunkName: "mr-hotlist-people-page" */
573 `../hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js`);
574 this.page = 'hotlist-people';
575 next();
576 }
577
578 /**
579 * Loads mr-hotlist-settings-page.js and makes it the currently viewed page.
580 * @param {PageJS.Context} ctx A page.js Context containing routing state.
581 * @param {function} next Passes execution on to the next registered callback.
582 */
583 async _loadHotlistSettingsPage(ctx, next) {
584 await import(/* webpackChunkName: "mr-hotlist-settings-page" */
585 `../hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js`);
586 this.page = 'hotlist-settings';
587 next();
588 }
589
590 /**
591 * Constructs a message to warn users about dirty forms when they navigate
592 * away from a page, to prevent them from loasing data.
593 * @return {string} Message shown to users to warn about in flight form
594 * changes.
595 */
596 _confirmDiscardMessage() {
597 if (!this.dirtyForms.length) return null;
598 const dirtyFormsMessage =
599 'Discard your changes in the following forms?\n' +
600 arrayToEnglish(this.dirtyForms);
601 return dirtyFormsMessage;
602 }
603}
604
605customElements.define('mr-app', MrApp);