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