Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/mr-app/mr-app.js b/static_src/elements/mr-app/mr-app.js
new file mode 100644
index 0000000..a48d40f
--- /dev/null
+++ b/static_src/elements/mr-app/mr-app.js
@@ -0,0 +1,587 @@
+// 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/chops/chops-announcement/chops-announcement.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'];
+
+/**
+ * `<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>
+      <chops-announcement service="monorail"></chops-announcement>
+      <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();
+
+    // 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();
+  }
+
+  /**
+   * 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);
+  }
+
+  /**
+   * 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);