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