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