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