| // Copyright 2019 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {LitElement, html} from 'lit-element'; |
| import {repeat} from 'lit-html/directives/repeat'; |
| import page from 'page'; |
| import qs from 'qs'; |
| |
| import {getServerStatusCron} from 'shared/cron.js'; |
| import 'elements/framework/mr-site-banner/mr-site-banner.js'; |
| import {store, connectStore} from 'reducers/base.js'; |
| import * as projectV0 from 'reducers/projectV0.js'; |
| import {hotlists} from 'reducers/hotlists.js'; |
| import * as issueV0 from 'reducers/issueV0.js'; |
| import * as permissions from 'reducers/permissions.js'; |
| import * as users from 'reducers/users.js'; |
| import * as userv0 from 'reducers/userV0.js'; |
| import * as ui from 'reducers/ui.js'; |
| import * as sitewide from 'reducers/sitewide.js'; |
| import {arrayToEnglish} from 'shared/helpers.js'; |
| import {trackPageChange} from 'shared/ga-helpers.js'; |
| import 'elements/issue-list/mr-list-page/mr-list-page.js'; |
| import 'elements/issue-entry/mr-issue-entry-page.js'; |
| import 'elements/framework/mr-header/mr-header.js'; |
| import 'elements/help/mr-cue/mr-cue.js'; |
| import {cueNames} from 'elements/help/mr-cue/cue-helpers.js'; |
| import 'elements/chops/chops-snackbar/chops-snackbar.js'; |
| |
| import {SHARED_STYLES} from 'shared/shared-styles.js'; |
| |
| const QUERY_PARAMS_THAT_RESET_SCROLL = ['q', 'mode', 'id']; |
| const GOOGLE_EMAIL_SUFFIX = '@google.com'; |
| |
| /** |
| * `<mr-app>` |
| * |
| * The container component for all pages under the Monorail SPA. |
| * |
| */ |
| export class MrApp extends connectStore(LitElement) { |
| /** @override */ |
| render() { |
| if (this.page === 'wizard') { |
| return html`<div id="reactMount"></div>`; |
| } |
| |
| return html` |
| <style> |
| ${SHARED_STYLES} |
| mr-app { |
| display: block; |
| padding-top: var(--monorail-header-height); |
| margin-top: -1px; /* Prevent a double border from showing up. */ |
| |
| /* From shared-styles.js. */ |
| --mr-edit-field-padding: 0.125em 4px; |
| --mr-edit-field-width: 90%; |
| --mr-input-grid-gap: 6px; |
| font-family: var(--chops-font-family); |
| color: var(--chops-primary-font-color); |
| font-size: var(--chops-main-font-size); |
| } |
| main { |
| border-top: var(--chops-normal-border); |
| } |
| .snackbar-container { |
| position: fixed; |
| bottom: 1em; |
| left: 1em; |
| display: flex; |
| flex-direction: column; |
| align-items: flex-start; |
| z-index: 1000; |
| } |
| /** Unfix <chops-snackbar> to allow stacking. */ |
| chops-snackbar { |
| position: static; |
| margin-top: 0.5em; |
| } |
| </style> |
| <mr-header |
| .userDisplayName=${this.userDisplayName} |
| .loginUrl=${this.loginUrl} |
| .logoutUrl=${this.logoutUrl} |
| ></mr-header> |
| <mr-site-banner></mr-site-banner> |
| <mr-cue |
| cuePrefName=${cueNames.SWITCH_TO_PARENT_ACCOUNT} |
| .loginUrl=${this.loginUrl} |
| centered |
| nondismissible |
| ></mr-cue> |
| <mr-cue |
| cuePrefName=${cueNames.SEARCH_FOR_NUMBERS} |
| centered |
| ></mr-cue> |
| <main>${this._renderPage()}</main> |
| <div class="snackbar-container" aria-live="polite"> |
| ${repeat(this._snackbars, (snackbar) => html` |
| <chops-snackbar |
| @close=${this._closeSnackbar.bind(this, snackbar.id)} |
| >${snackbar.text}</chops-snackbar> |
| `)} |
| </div> |
| `; |
| } |
| |
| /** |
| * @param {string} id The name of the snackbar to close. |
| */ |
| _closeSnackbar(id) { |
| store.dispatch(ui.hideSnackbar(id)); |
| } |
| |
| /** |
| * Helper for determiing which page component to render. |
| * @return {TemplateResult} |
| */ |
| _renderPage() { |
| switch (this.page) { |
| case 'detail': |
| return html` |
| <mr-issue-page |
| .userDisplayName=${this.userDisplayName} |
| .loginUrl=${this.loginUrl} |
| ></mr-issue-page> |
| `; |
| case 'entry': |
| return html` |
| <mr-issue-entry-page |
| .userDisplayName=${this.userDisplayName} |
| .loginUrl=${this.loginUrl} |
| ></mr-issue-entry-page> |
| `; |
| case 'grid': |
| return html` |
| <mr-grid-page |
| .userDisplayName=${this.userDisplayName} |
| ></mr-grid-page> |
| `; |
| case 'list': |
| return html` |
| <mr-list-page |
| .userDisplayName=${this.userDisplayName} |
| ></mr-list-page> |
| `; |
| case 'chart': |
| return html`<mr-chart-page></mr-chart-page>`; |
| case 'projects': |
| return html`<mr-projects-page></mr-projects-page>`; |
| case 'hotlist-issues': |
| return html`<mr-hotlist-issues-page></mr-hotlist-issues-page>`; |
| case 'hotlist-people': |
| return html`<mr-hotlist-people-page></mr-hotlist-people-page>`; |
| case 'hotlist-settings': |
| return html`<mr-hotlist-settings-page></mr-hotlist-settings-page>`; |
| default: |
| return; |
| } |
| } |
| |
| /** @override */ |
| static get properties() { |
| return { |
| /** |
| * Backend-generated URL for the page the user is directed to for login. |
| */ |
| loginUrl: {type: String}, |
| /** |
| * Backend-generated URL for the page the user is directed to for logout. |
| */ |
| logoutUrl: {type: String}, |
| /** |
| * The display name of the currently logged in user. |
| */ |
| userDisplayName: {type: String}, |
| /** |
| * The search parameters in the user's current URL. |
| */ |
| queryParams: {type: Object}, |
| /** |
| * A list of forms to check for "dirty" values when the user navigates |
| * across pages. |
| */ |
| dirtyForms: {type: Array}, |
| /** |
| * App Engine ID for the current version being viewed. |
| */ |
| versionBase: {type: String}, |
| /** |
| * A String identifier for the page that the user is viewing. |
| */ |
| page: {type: String}, |
| /** |
| * A String for the title of the page that the user will see in their |
| * browser tab. ie: equivalent to the <title> tag. |
| */ |
| pageTitle: {type: String}, |
| /** |
| * Array of snackbar objects to render. |
| */ |
| _snackbars: {type: Array}, |
| }; |
| } |
| |
| /** @override */ |
| constructor() { |
| super(); |
| this.queryParams = {}; |
| this.dirtyForms = []; |
| this.userDisplayName = ''; |
| |
| /** |
| * @type {PageJS.Context} |
| * The context of the page. This should not be a LitElement property |
| * because we don't want to re-render when updating this. |
| */ |
| this._lastContext = undefined; |
| } |
| |
| /** @override */ |
| createRenderRoot() { |
| return this; |
| } |
| |
| /** @override */ |
| stateChanged(state) { |
| this.dirtyForms = ui.dirtyForms(state); |
| this.queryParams = sitewide.queryParams(state); |
| this.pageTitle = sitewide.pageTitle(state); |
| this._snackbars = ui.snackbars(state); |
| } |
| |
| /** @override */ |
| updated(changedProperties) { |
| if (changedProperties.has('userDisplayName') && this.userDisplayName) { |
| // TODO(https://crbug.com/monorail/7238): Migrate userv0 calls to v3 API. |
| store.dispatch(userv0.fetch(this.userDisplayName)); |
| |
| // Typically we would prefer 'users/<userId>' instead. |
| store.dispatch(users.fetch(`users/${this.userDisplayName}`)); |
| } |
| |
| if (changedProperties.has('pageTitle')) { |
| // To ensure that changes to the page title are easy to reason about, |
| // we want to sync the current pageTitle in the Redux state to |
| // document.title in only one place in the code. |
| document.title = this.pageTitle; |
| } |
| if (changedProperties.has('page')) { |
| trackPageChange(this.page, this.userDisplayName); |
| } |
| } |
| |
| /** @override */ |
| connectedCallback() { |
| super.connectedCallback(); |
| |
| this._logGooglerUsage(); |
| |
| // TODO(zhangtiff): Figure out some way to save Redux state between |
| // page loads. |
| |
| // page doesn't handle users reloading the page or closing a tab. |
| window.onbeforeunload = this._confirmDiscardMessage.bind(this); |
| |
| // Start a cron task to periodically request the status from the server. |
| getServerStatusCron.start(); |
| |
| const postRouteHandler = this._postRouteHandler.bind(this); |
| |
| // Populate the project route parameter before _preRouteHandler runs. |
| page('/p/:project/*', (_ctx, next) => next()); |
| page('*', this._preRouteHandler.bind(this)); |
| |
| page('/hotlists/:hotlist', (ctx) => { |
| page.redirect(`/hotlists/${ctx.params.hotlist}/issues`); |
| }); |
| page('/hotlists/:hotlist/*', this._selectHotlist); |
| page('/hotlists/:hotlist/issues', |
| this._loadHotlistIssuesPage.bind(this), postRouteHandler); |
| page('/hotlists/:hotlist/people', |
| this._loadHotlistPeoplePage.bind(this), postRouteHandler); |
| page('/hotlists/:hotlist/settings', |
| this._loadHotlistSettingsPage.bind(this), postRouteHandler); |
| |
| // Handle Monorail's landing page. |
| page('/p', '/'); |
| page('/projects', '/'); |
| page('/hosting', '/'); |
| page('/', this._loadProjectsPage.bind(this), postRouteHandler); |
| |
| page('/p/:project/issues/list', this._loadListPage.bind(this), |
| postRouteHandler); |
| page('/p/:project/issues/detail', this._loadIssuePage.bind(this), |
| postRouteHandler); |
| page('/p/:project/issues/entry_new', this._loadEntryPage.bind(this), |
| postRouteHandler); |
| page('/p/:project/issues/wizard', this._loadWizardPage.bind(this), |
| postRouteHandler); |
| |
| // Redirects from old hotlist pages to SPA hotlist pages. |
| const hotlistRedirect = (pageName) => async (ctx) => { |
| const name = |
| await hotlists.getHotlistName(ctx.params.user, ctx.params.hotlist); |
| page.redirect(`/${name}/${pageName}`); |
| }; |
| page('/users/:user/hotlists/:hotlist', hotlistRedirect('issues')); |
| page('/users/:user/hotlists/:hotlist/people', hotlistRedirect('people')); |
| page('/users/:user/hotlists/:hotlist/details', hotlistRedirect('settings')); |
| |
| page(); |
| } |
| |
| /** |
| * Helper to log how often Googlers access Monorail. |
| */ |
| _logGooglerUsage() { |
| const email = this.userDisplayName; |
| if (!email) return; |
| if (!email.endsWith(GOOGLE_EMAIL_SUFFIX)) return; |
| |
| const username = email.replace(GOOGLE_EMAIL_SUFFIX, ''); |
| |
| // Context: b/229758140 |
| window.fetch(`https://buganizer.corp.google.com/action/yes?monorail=yes&username=${username}`, |
| {mode: 'no-cors'}); |
| } |
| |
| /** |
| * Handler that runs on every single route change, before the new page has |
| * loaded. This function should not use store.dispatch() or assign properties |
| * on this because running these actions causes extra re-renders to happen. |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| _preRouteHandler(ctx, next) { |
| // We're not really navigating anywhere, so don't do anything. |
| if (this._lastContext && this._lastContext.path && |
| ctx.path === this._lastContext.path) { |
| Object.assign(ctx, this._lastContext); |
| // Set ctx.handled to false, so we don't push the state to browser's |
| // history. |
| ctx.handled = false; |
| return; |
| } |
| |
| // Check if there were forms with unsaved data before loading the next |
| // page. |
| const discardMessage = this._confirmDiscardMessage(); |
| if (discardMessage && !confirm(discardMessage)) { |
| Object.assign(ctx, this._lastContext); |
| // Set ctx.handled to false, so we don't push the state to browser's |
| // history. |
| ctx.handled = false; |
| // We don't call next to avoid loading whatever page was supposed to |
| // load next. |
| return; |
| } |
| |
| // Run query string parsing on all routes. Query params must be parsed |
| // before routes are loaded because some routes use them to conditionally |
| // load bundles. |
| // Based on: https://visionmedia.github.io/page.js/#plugins |
| const params = qs.parse(ctx.querystring); |
| |
| // Make sure queryParams are not case sensitive. |
| const lowerCaseParams = {}; |
| Object.keys(params).forEach((key) => { |
| lowerCaseParams[key.toLowerCase()] = params[key]; |
| }); |
| ctx.queryParams = lowerCaseParams; |
| |
| this._selectProject(ctx.params.project); |
| |
| next(); |
| } |
| |
| /** |
| * Handler that runs on every single route change, after the new page has |
| * loaded. |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| _postRouteHandler(ctx, next) { |
| // Scroll to the requested element if a hash is present. |
| if (ctx.hash) { |
| store.dispatch(ui.setFocusId(ctx.hash)); |
| } |
| |
| // Sync queryParams to Redux after the route has loaded, rather than before, |
| // to avoid having extra queryParams update on the previously loaded |
| // component. |
| store.dispatch(sitewide.setQueryParams(ctx.queryParams)); |
| |
| // Increment the count of navigations in the Redux store. |
| store.dispatch(ui.incrementNavigationCount()); |
| |
| // Clear dirty forms when entering a new page. |
| store.dispatch(ui.clearDirtyForms()); |
| |
| |
| if (!this._lastContext || this._lastContext.pathname !== ctx.pathname || |
| this._hasReleventParamChanges(ctx.queryParams, |
| this._lastContext.queryParams)) { |
| // Reset the scroll position after a new page has rendered. |
| window.scrollTo(0, 0); |
| } |
| |
| // Save the context of this page to be compared to later. |
| this._lastContext = ctx; |
| } |
| |
| /** |
| * Finds if a route change changed query params in a way that should cause |
| * scrolling to reset. |
| * @param {Object} currentParams |
| * @param {Object} oldParams |
| * @param {Array<string>=} paramsToCompare Which params to check. |
| * @return {boolean} Whether any of the relevant query params changed. |
| */ |
| _hasReleventParamChanges(currentParams, oldParams, |
| paramsToCompare = QUERY_PARAMS_THAT_RESET_SCROLL) { |
| return paramsToCompare.some((paramName) => { |
| return currentParams[paramName] !== oldParams[paramName]; |
| }); |
| } |
| |
| /** |
| * Helper to manage syncing project route state to Redux. |
| * @param {string=} project displayName for a referenced project. |
| * Defaults to null for consistency with Redux. |
| */ |
| _selectProject(project = null) { |
| if (projectV0.viewedProjectName(store.getState()) !== project) { |
| // Note: We want to update the project even if the new project |
| // is null. |
| store.dispatch(projectV0.select(project)); |
| if (project) { |
| store.dispatch(projectV0.fetch(project)); |
| } |
| } |
| } |
| |
| /** |
| * Loads and triggers rendering for the list of all projects. |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| async _loadProjectsPage(ctx, next) { |
| await import(/* webpackChunkName: "mr-projects-page" */ |
| '../projects/mr-projects-page/mr-projects-page.js'); |
| this.page = 'projects'; |
| next(); |
| } |
| |
| /** |
| * Loads and triggers render for the issue detail page. |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| async _loadIssuePage(ctx, next) { |
| performance.clearMarks('start load issue detail page'); |
| performance.mark('start load issue detail page'); |
| |
| await import(/* webpackChunkName: "mr-issue-page" */ |
| '../issue-detail/mr-issue-page/mr-issue-page.js'); |
| |
| const issueRef = { |
| localId: Number.parseInt(ctx.queryParams.id), |
| projectName: ctx.params.project, |
| }; |
| store.dispatch(issueV0.viewIssue(issueRef)); |
| store.dispatch(issueV0.fetchIssuePageData(issueRef)); |
| this.page = 'detail'; |
| next(); |
| } |
| |
| /** |
| * Loads and triggers render for the issue list page, including the list, |
| * grid, and chart modes. |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| async _loadListPage(ctx, next) { |
| performance.clearMarks('start load issue list page'); |
| performance.mark('start load issue list page'); |
| switch (ctx.queryParams && ctx.queryParams.mode && |
| ctx.queryParams.mode.toLowerCase()) { |
| case 'grid': |
| await import(/* webpackChunkName: "mr-grid-page" */ |
| '../issue-list/mr-grid-page/mr-grid-page.js'); |
| this.page = 'grid'; |
| break; |
| case 'chart': |
| await import(/* webpackChunkName: "mr-chart-page" */ |
| '../issue-list/mr-chart-page/mr-chart-page.js'); |
| this.page = 'chart'; |
| break; |
| default: |
| this.page = 'list'; |
| break; |
| } |
| next(); |
| } |
| |
| /** |
| * Load the issue entry page |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| _loadEntryPage(ctx, next) { |
| this.page = 'entry'; |
| next(); |
| } |
| |
| /** |
| * Load the issue wizard |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| async _loadWizardPage(ctx, next) { |
| const {renderWizard} = await import( |
| /* webpackChunkName: "IssueWizard" */ '../../react/IssueWizard.tsx'); |
| |
| this.page = 'wizard'; |
| next(); |
| |
| await this.updateComplete; |
| |
| const mount = document.getElementById('reactMount'); |
| |
| renderWizard(mount, this.loginUrl, this.userDisplayName); |
| } |
| |
| /** |
| * Gets the currently viewed HotlistRef from the URL, selects |
| * it in the Redux store, and fetches the Hotlist data. |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| _selectHotlist(ctx, next) { |
| const name = 'hotlists/' + ctx.params.hotlist; |
| store.dispatch(hotlists.select(name)); |
| store.dispatch(hotlists.fetch(name)); |
| store.dispatch(hotlists.fetchItems(name)); |
| store.dispatch(permissions.batchGet([name])); |
| next(); |
| } |
| |
| /** |
| * Loads mr-hotlist-issues-page.js and makes it the currently viewed page. |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| async _loadHotlistIssuesPage(ctx, next) { |
| await import(/* webpackChunkName: "mr-hotlist-issues-page" */ |
| `../hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js`); |
| this.page = 'hotlist-issues'; |
| next(); |
| } |
| |
| /** |
| * Loads mr-hotlist-people-page.js and makes it the currently viewed page. |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| async _loadHotlistPeoplePage(ctx, next) { |
| await import(/* webpackChunkName: "mr-hotlist-people-page" */ |
| `../hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js`); |
| this.page = 'hotlist-people'; |
| next(); |
| } |
| |
| /** |
| * Loads mr-hotlist-settings-page.js and makes it the currently viewed page. |
| * @param {PageJS.Context} ctx A page.js Context containing routing state. |
| * @param {function} next Passes execution on to the next registered callback. |
| */ |
| async _loadHotlistSettingsPage(ctx, next) { |
| await import(/* webpackChunkName: "mr-hotlist-settings-page" */ |
| `../hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js`); |
| this.page = 'hotlist-settings'; |
| next(); |
| } |
| |
| /** |
| * Constructs a message to warn users about dirty forms when they navigate |
| * away from a page, to prevent them from loasing data. |
| * @return {string} Message shown to users to warn about in flight form |
| * changes. |
| */ |
| _confirmDiscardMessage() { |
| if (!this.dirtyForms.length) return null; |
| const dirtyFormsMessage = |
| 'Discard your changes in the following forms?\n' + |
| arrayToEnglish(this.dirtyForms); |
| return dirtyFormsMessage; |
| } |
| } |
| |
| customElements.define('mr-app', MrApp); |