Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 1 | // Copyright 2019 The Chromium Authors |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 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'; |
Adrià Vilanova Martínez | 7cc6337 | 2022-05-15 23:16:25 +0200 | [diff] [blame] | 12 | import 'elements/framework/mr-vulnz-banner/mr-vulnz-banner.js'; |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 13 | import {store, connectStore} from 'reducers/base.js'; |
| 14 | import * as projectV0 from 'reducers/projectV0.js'; |
| 15 | import {hotlists} from 'reducers/hotlists.js'; |
| 16 | import * as issueV0 from 'reducers/issueV0.js'; |
| 17 | import * as permissions from 'reducers/permissions.js'; |
| 18 | import * as users from 'reducers/users.js'; |
| 19 | import * as userv0 from 'reducers/userV0.js'; |
| 20 | import * as ui from 'reducers/ui.js'; |
| 21 | import * as sitewide from 'reducers/sitewide.js'; |
| 22 | import {arrayToEnglish} from 'shared/helpers.js'; |
| 23 | import {trackPageChange} from 'shared/ga-helpers.js'; |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 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']; |
Adrià Vilanova Martínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 34 | const GOOGLE_EMAIL_SUFFIX = '@google.com'; |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 35 | |
| 36 | /** |
| 37 | * `<mr-app>` |
| 38 | * |
| 39 | * The container component for all pages under the Monorail SPA. |
| 40 | * |
| 41 | */ |
| 42 | export 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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 82 | .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 | } |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 89 | </style> |
| 90 | <mr-header |
| 91 | .userDisplayName=${this.userDisplayName} |
| 92 | .loginUrl=${this.loginUrl} |
| 93 | .logoutUrl=${this.logoutUrl} |
| 94 | ></mr-header> |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 95 | <mr-site-banner></mr-site-banner> |
Adrià Vilanova Martínez | 7cc6337 | 2022-05-15 23:16:25 +0200 | [diff] [blame] | 96 | <mr-vulnz-banner></mr-vulnz-banner> |
Adrià Vilanova Martínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 97 | <div class="project-alert" ?hidden=${!this.projectAlert}> |
| 98 | ${this.projectAlert} |
| 99 | </div> |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 100 | <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ínez | f19ea43 | 2024-01-23 20:20:52 +0100 | [diff] [blame] | 204 | * A string explaining the project state. |
| 205 | */ |
| 206 | projectAlert: {type: String}, |
| 207 | /** |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 208 | * 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ínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 276 | this._logGooglerUsage(); |
| 277 | |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 278 | // 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ínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 333 | * 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 | /** |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 348 | * 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ínez | ac4a644 | 2022-05-15 19:05:13 +0200 | [diff] [blame] | 550 | renderWizard(mount, this.loginUrl, this.userDisplayName); |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 551 | } |
| 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 | |
| 619 | customElements.define('mr-app', MrApp); |