Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | // 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 | |
| 5 | import {LitElement, html} from 'lit-element'; |
| 6 | import {repeat} from 'lit-html/directives/repeat'; |
| 7 | import page from 'page'; |
| 8 | import qs from 'qs'; |
| 9 | |
| 10 | import {getServerStatusCron} from 'shared/cron.js'; |
| 11 | import 'elements/framework/mr-site-banner/mr-site-banner.js'; |
| 12 | import {store, connectStore} from 'reducers/base.js'; |
| 13 | import * as projectV0 from 'reducers/projectV0.js'; |
| 14 | import {hotlists} from 'reducers/hotlists.js'; |
| 15 | import * as issueV0 from 'reducers/issueV0.js'; |
| 16 | import * as permissions from 'reducers/permissions.js'; |
| 17 | import * as users from 'reducers/users.js'; |
| 18 | import * as userv0 from 'reducers/userV0.js'; |
| 19 | import * as ui from 'reducers/ui.js'; |
| 20 | import * as sitewide from 'reducers/sitewide.js'; |
| 21 | import {arrayToEnglish} from 'shared/helpers.js'; |
| 22 | import {trackPageChange} from 'shared/ga-helpers.js'; |
| 23 | import 'elements/chops/chops-announcement/chops-announcement.js'; |
| 24 | import 'elements/issue-list/mr-list-page/mr-list-page.js'; |
| 25 | import 'elements/issue-entry/mr-issue-entry-page.js'; |
| 26 | import 'elements/framework/mr-header/mr-header.js'; |
| 27 | import 'elements/help/mr-cue/mr-cue.js'; |
| 28 | import {cueNames} from 'elements/help/mr-cue/cue-helpers.js'; |
| 29 | import 'elements/chops/chops-snackbar/chops-snackbar.js'; |
| 30 | |
| 31 | import {SHARED_STYLES} from 'shared/shared-styles.js'; |
| 32 | |
| 33 | const 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 | */ |
| 41 | export 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 | |
| 587 | customElements.define('mr-app', MrApp); |