Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.js
new file mode 100644
index 0000000..0d04d32
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.js
@@ -0,0 +1,379 @@
+// 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, css} from 'lit-element';
+
+import 'elements/issue-detail/mr-flipper/mr-flipper.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import {userIsMember} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/framework/links/mr-crbug-link/mr-crbug-link.js';
+import 'elements/framework/mr-pref-toggle/mr-pref-toggle.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_DELETE_PERMISSION,
+  ISSUE_FLAGSPAM_PERMISSION} from 'shared/consts/permissions.js';
+import {issueToIssueRef} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {AVAILABLE_MD_PROJECTS, DEFAULT_MD_PROJECTS} from 'shared/md-helper.js';
+
+const DELETE_ISSUE_CONFIRMATION_NOTICE = `\
+Normally, you would just close issues by setting their status to a closed value.
+Are you sure you want to delete this issue?`;
+
+
+/**
+ * `<mr-issue-header>`
+ *
+ * The header for a given launch issue.
+ *
+ */
+export class MrIssueHeader extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          width: 100%;
+          margin-top: 0;
+          font-size: var(--chops-large-font-size);
+          background-color: var(--monorail-metadata-toggled-bg);
+          border-bottom: var(--chops-normal-border);
+          padding: 0.25em 8px;
+          box-sizing: border-box;
+          display: flex;
+          flex-direction: row;
+          justify-content: space-between;
+          align-items: center;
+        }
+        h1 {
+          font-size: 100%;
+          line-height: 140%;
+          font-weight: bolder;
+          padding: 0;
+          margin: 0;
+        }
+        mr-flipper {
+          border-left: var(--chops-normal-border);
+          padding-left: 8px;
+          margin-left: 4px;
+          font-size: var(--chops-main-font-size);
+        }
+        mr-pref-toggle {
+          margin-right: 2px;
+        }
+        .issue-actions {
+          min-width: fit-content;
+          display: flex;
+          flex-direction: row;
+          align-items: center;
+          font-size: var(--chops-main-font-size);
+        }
+        .issue-actions div {
+          min-width: 70px;
+          display: flex;
+          justify-content: space-between;
+        }
+        .spam-notice {
+          display: inline-flex;
+          align-items: center;
+          justify-content: center;
+          padding: 1px 6px;
+          border-radius: 3px;
+          background: #F44336;
+          color: var(--chops-white);
+          font-weight: bold;
+          font-size: var(--chops-main-font-size);
+          margin-right: 4px;
+        }
+        .byline {
+          display: block;
+          font-size: var(--chops-main-font-size);
+          width: 100%;
+          line-height: 140%;
+          color: var(--chops-primary-font-color);
+        }
+        .role-label {
+          background-color: var(--chops-gray-600);
+          border-radius: 3px;
+          color: var(--chops-white);
+          display: inline-block;
+          padding: 2px 4px;
+          font-size: 75%;
+          font-weight: bold;
+          line-height: 14px;
+          vertical-align: text-bottom;
+          margin-left: 16px;
+        }
+        .main-text-outer {
+          flex-basis: 100%;
+          display: flex;
+          justify-content: flex-start;
+          flex-direction: row;
+          align-items: center;
+        }
+        .main-text {
+          flex-basis: 100%;
+        }
+        @media (max-width: 840px) {
+          :host {
+            flex-wrap: wrap;
+            justify-content: center;
+          }
+          .main-text {
+            width: 100%;
+            margin-bottom: 0.5em;
+          }
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    const reporterIsMember = userIsMember(
+        this.issue.reporterRef, this.issue.projectName, this.usersProjects);
+    const markdownEnabled = AVAILABLE_MD_PROJECTS.has(this.projectName);
+    const markdownDefaultOn = DEFAULT_MD_PROJECTS.has(this.projectName);
+    return html`
+      <div class="main-text-outer">
+        <div class="main-text">
+          <h1>
+            ${this.issue.isSpam ? html`
+              <span class="spam-notice">Spam</span>
+            `: ''}
+            Issue ${this.issue.localId}: ${this.issue.summary}
+          </h1>
+          <small class="byline">
+            Reported by
+            <mr-user-link
+              .userRef=${this.issue.reporterRef}
+              aria-label="issue reporter"
+            ></mr-user-link>
+            on <chops-timestamp .timestamp=${this.issue.openedTimestamp}></chops-timestamp>
+            ${reporterIsMember ? html`
+              <span class="role-label">Project Member</span>` : ''}
+          </small>
+        </div>
+      </div>
+      <div class="issue-actions">
+        <div>
+          <mr-crbug-link .issue=${this.issue}></mr-crbug-link>
+          <mr-pref-toggle
+            .userDisplayName=${this.userDisplayName}
+            label="Code"
+            title="Code font"
+            prefName="code_font"
+          ></mr-pref-toggle>
+          ${markdownEnabled ? html`
+            <mr-pref-toggle
+              .userDisplayName=${this.userDisplayName}
+              initialValue=${markdownDefaultOn}
+              label="Markdown"
+              title="Render in markdown"
+              prefName="render_markdown"
+            ></mr-pref-toggle> ` : ''}
+        </div>
+        ${this._issueOptions.length ? html`
+          <mr-dropdown
+            .items=${this._issueOptions}
+            icon="more_vert"
+            label="Issue options"
+          ></mr-dropdown>
+        ` : ''}
+        <mr-flipper></mr-flipper>
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      userDisplayName: {type: String},
+      issue: {type: Object},
+      issuePermissions: {type: Object},
+      isRestricted: {type: Boolean},
+      projectTemplates: {type: Array},
+      projectName: {type: String},
+      usersProjects: {type: Object},
+      _action: {type: String},
+      _targetProjectError: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.issuePermissions = [];
+    this.projectTemplates = [];
+    this.projectName = '';
+    this.issue = {};
+    this.usersProjects = new Map();
+    this.isRestricted = false;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issue = issueV0.viewedIssue(state);
+    this.issuePermissions = issueV0.permissions(state);
+    this.projectTemplates = projectV0.viewedTemplates(state);
+    this.projectName = projectV0.viewedProjectName(state);
+    this.usersProjects = userV0.projectsPerUser(state);
+
+    const restrictions = issueV0.restrictions(state);
+    this.isRestricted = restrictions && Object.keys(restrictions).length;
+  }
+
+  /**
+   * @return {Array<MenuItem>} Actions the user can take on the issue.
+   * @private
+   */
+  get _issueOptions() {
+    // We create two edit Arrays for the top and bottom half of the menu,
+    // to be separated by a separator in the UI.
+    const editOptions = [];
+    const riskyOptions = [];
+    const isSpam = this.issue.isSpam;
+    const isRestricted = this.isRestricted;
+
+    const permissions = this.issuePermissions;
+    const templates = this.projectTemplates;
+
+
+    if (permissions.includes(ISSUE_EDIT_PERMISSION)) {
+      editOptions.push({
+        text: 'Edit issue description',
+        handler: this._openEditDescription.bind(this),
+      });
+      if (templates.length) {
+        riskyOptions.push({
+          text: 'Convert issue template',
+          handler: this._openConvertIssue.bind(this),
+        });
+      }
+    }
+
+    if (permissions.includes(ISSUE_DELETE_PERMISSION)) {
+      riskyOptions.push({
+        text: 'Delete issue',
+        handler: this._deleteIssue.bind(this),
+      });
+      if (!isRestricted) {
+        editOptions.push({
+          text: 'Move issue',
+          handler: this._openMoveCopyIssue.bind(this, 'Move'),
+        });
+        editOptions.push({
+          text: 'Copy issue',
+          handler: this._openMoveCopyIssue.bind(this, 'Copy'),
+        });
+      }
+    }
+
+    if (permissions.includes(ISSUE_FLAGSPAM_PERMISSION)) {
+      const text = (isSpam ? 'Un-flag' : 'Flag') + ' issue as spam';
+      riskyOptions.push({
+        text,
+        handler: this._markIssue.bind(this),
+      });
+    }
+
+    if (editOptions.length && riskyOptions.length) {
+      editOptions.push({separator: true});
+    }
+    return editOptions.concat(riskyOptions);
+  }
+
+  /**
+   * Marks an issue as either spam or not spam based on whether the issue
+   * was spam.
+   */
+  _markIssue() {
+    prpcClient.call('monorail.Issues', 'FlagIssues', {
+      issueRefs: [{
+        projectName: this.issue.projectName,
+        localId: this.issue.localId,
+      }],
+      flag: !this.issue.isSpam,
+    }).then(() => {
+      store.dispatch(issueV0.fetch({
+        projectName: this.issue.projectName,
+        localId: this.issue.localId,
+      }));
+    });
+  }
+
+  /**
+   * Deletes an issue.
+   */
+  _deleteIssue() {
+    const ok = confirm(DELETE_ISSUE_CONFIRMATION_NOTICE);
+    if (ok) {
+      const issueRef = issueToIssueRef(this.issue);
+      // TODO(crbug.com/monorail/7374): Delete for the v0 -> v3 migration.
+      prpcClient.call('monorail.Issues', 'DeleteIssue', {
+        issueRef,
+        delete: true,
+      }).then(() => {
+        store.dispatch(issueV0.fetch(issueRef));
+      });
+    }
+  }
+
+  /**
+   * Launches the dialog to edit an issue's description.
+   * @fires CustomEvent#open-dialog
+   * @private
+   */
+  _openEditDescription() {
+    this.dispatchEvent(new CustomEvent('open-dialog', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        dialogId: 'edit-description',
+        fieldName: '',
+      },
+    }));
+  }
+
+  /**
+   * Opens dialog to either move or copy an issue.
+   * @param {"move"|"copy"} action
+   * @fires CustomEvent#open-dialog
+   * @private
+   */
+  _openMoveCopyIssue(action) {
+    this.dispatchEvent(new CustomEvent('open-dialog', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        dialogId: 'move-copy-issue',
+        action,
+      },
+    }));
+  }
+
+  /**
+   * Opens dialog for converting an issue.
+   * @fires CustomEvent#open-dialog
+   * @private
+   */
+  _openConvertIssue() {
+    this.dispatchEvent(new CustomEvent('open-dialog', {
+      bubbles: true,
+      composed: true,
+      detail: {
+        dialogId: 'convert-issue',
+      },
+    }));
+  }
+}
+
+customElements.define('mr-issue-header', MrIssueHeader);
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.test.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.test.js
new file mode 100644
index 0000000..25ab0e7
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-header.test.js
@@ -0,0 +1,167 @@
+// 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 {assert} from 'chai';
+import {MrIssueHeader} from './mr-issue-header.js';
+import {store, resetState} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_DELETE_PERMISSION,
+  ISSUE_FLAGSPAM_PERMISSION} from 'shared/consts/permissions.js';
+
+let element;
+
+describe('mr-issue-header', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+    element = document.createElement('mr-issue-header');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssueHeader);
+  });
+
+  it('updating issue id changes header', () => {
+    store.dispatch({type: issueV0.VIEW_ISSUE,
+      issueRef: {localId: 1, projectName: 'test'}});
+    store.dispatch({type: issueV0.FETCH_SUCCESS,
+      issue: {localId: 1, projectName: 'test', summary: 'test'}});
+
+    assert.deepEqual(element.issue, {localId: 1, projectName: 'test',
+      summary: 'test'});
+  });
+
+  it('_issueOptions toggles spam', () => {
+    element.issuePermissions = [ISSUE_FLAGSPAM_PERMISSION];
+    element.issue = {isSpam: false};
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Flag issue as spam'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Un-flag issue as spam'));
+
+    element.issue = {isSpam: true};
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Flag issue as spam'));
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Un-flag issue as spam'));
+
+    element.issuePermissions = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Flag issue as spam'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Un-flag issue as spam'));
+
+    element.issue = {isSpam: false};
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Flag issue as spam'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Un-flag issue as spam'));
+  });
+
+  it('_issueOptions toggles convert issue', () => {
+    element.issuePermissions = [];
+    element.projectTemplates = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Convert issue template'));
+
+    element.projectTemplates = [{templateName: 'test'}];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Convert issue template'));
+
+    element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+    element.projectTemplates = [];
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Convert issue template'));
+
+    element.projectTemplates = [{templateName: 'test'}];
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Convert issue template'));
+  });
+
+  it('_issueOptions toggles delete', () => {
+    element.issuePermissions = [ISSUE_DELETE_PERMISSION];
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Delete issue'));
+
+    element.issuePermissions = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Delete issue'));
+  });
+
+  it('_issueOptions toggles move and copy', () => {
+    element.issuePermissions = [ISSUE_DELETE_PERMISSION];
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Move issue'));
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Copy issue'));
+
+    element.isRestricted = true;
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Move issue'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Copy issue'));
+
+    element.issuePermissions = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Move issue'));
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Copy issue'));
+  });
+
+  it('_issueOptions toggles edit description', () => {
+    element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+    assert.isDefined(findOptionWithText(element._issueOptions,
+        'Edit issue description'));
+
+    element.issuePermissions = [];
+
+    assert.isUndefined(findOptionWithText(element._issueOptions,
+        'Edit issue description'));
+  });
+
+  it('markdown toggle renders on enabled projects', async () => {
+    element.projectName = 'monkeyrail';
+
+    await element.updateComplete;
+
+    // This looks for how many mr-pref-toggle buttons there are,
+    // if there are two then this project also renders on markdown.
+    const chopsToggles = element.shadowRoot.querySelectorAll('mr-pref-toggle');
+    assert.equal(chopsToggles.length, 2);
+
+  });
+
+  it('markdown toggle does not render on disabled projects', async () => {
+    element.projectName = 'moneyrail';
+
+    await element.updateComplete;
+
+    const chopsToggles = element.shadowRoot.querySelectorAll('mr-pref-toggle');
+    assert.equal(chopsToggles.length, 1);
+  });
+
+  it('markdown toggle is on by default on enabled projects', async () => {
+    element.projectName = 'monkeyrail';
+
+    await element.updateComplete;
+    
+    const chopsToggles = element.shadowRoot.querySelectorAll('mr-pref-toggle');
+    const markdownButton = chopsToggles[1];
+    assert.equal("true", markdownButton.getAttribute('initialvalue'));
+  });
+});
+
+function findOptionWithText(issueOptions, text) {
+  return issueOptions.find((option) => option.text === text);
+}
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.js
new file mode 100644
index 0000000..a93822b
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.js
@@ -0,0 +1,393 @@
+// 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 page from 'page';
+import {LitElement, html} from 'lit-element';
+
+import 'elements/chops/chops-button/chops-button.js';
+import './mr-issue-header.js';
+import './mr-restriction-indicator';
+import '../mr-issue-details/mr-issue-details.js';
+import '../metadata/mr-metadata/mr-issue-metadata.js';
+import '../mr-launch-overview/mr-launch-overview.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import {ISSUE_DELETE_PERMISSION} from 'shared/consts/permissions.js';
+
+// eslint-disable-next-line max-len
+import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js';
+import '../dialogs/mr-edit-description/mr-edit-description.js';
+import '../dialogs/mr-move-copy-issue/mr-move-copy-issue.js';
+import '../dialogs/mr-convert-issue/mr-convert-issue.js';
+import '../dialogs/mr-related-issues/mr-related-issues.js';
+import '../../help/mr-click-throughs/mr-click-throughs.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+const APPROVAL_COMMENT_COUNT = 5;
+const DETAIL_COMMENT_COUNT = 100;
+
+/**
+ * `<mr-issue-page>`
+ *
+ * The main entry point for a Monorail issue detail page.
+ *
+ */
+export class MrIssuePage extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+      <style>
+        mr-issue-page {
+          --mr-issue-page-horizontal-padding: 12px;
+          --mr-toggled-font-family: inherit;
+          --monorail-metadata-toggled-bg: var(--monorail-metadata-open-bg);
+        }
+        mr-issue-page[issueClosed] {
+          --monorail-metadata-toggled-bg: var(--monorail-metadata-closed-bg);
+        }
+        mr-issue-page[codeFont] {
+          --mr-toggled-font-family: Monospace;
+        }
+        .container-issue {
+          width: 100%;
+          flex-direction: column;
+          align-items: stretch;
+          justify-content: flex-start;
+          z-index: 200;
+        }
+        .container-issue-content {
+          padding: 0;
+          flex-grow: 1;
+          display: flex;
+          align-items: stretch;
+          justify-content: space-between;
+          flex-direction: row;
+          flex-wrap: nowrap;
+          box-sizing: border-box;
+          padding-top: 0.5em;
+        }
+        .container-outside {
+          box-sizing: border-box;
+          width: 100%;
+          max-width: 100%;
+          margin: auto;
+          padding: 0;
+          display: flex;
+          align-items: stretch;
+          justify-content: space-between;
+          flex-direction: row;
+          flex-wrap: no-wrap;
+        }
+        .container-no-issue {
+          padding: 0.5em 16px;
+          font-size: var(--chops-large-font-size);
+        }
+        .metadata-container {
+          font-size: var(--chops-main-font-size);
+          background: var(--monorail-metadata-toggled-bg);
+          border-right: var(--chops-normal-border);
+          border-bottom: var(--chops-normal-border);
+          width: 24em;
+          min-width: 256px;
+          flex-grow: 0;
+          flex-shrink: 0;
+          box-sizing: border-box;
+          z-index: 100;
+        }
+        .issue-header-container {
+          z-index: 10;
+          position: sticky;
+          top: var(--monorail-header-height);
+          margin-bottom: 0.25em;
+          width: 100%;
+        }
+        mr-issue-details {
+          min-width: 50%;
+          max-width: 1000px;
+          flex-grow: 1;
+          box-sizing: border-box;
+          min-height: 100%;
+          padding-left: var(--mr-issue-page-horizontal-padding);
+          padding-right: var(--mr-issue-page-horizontal-padding);
+        }
+        mr-issue-metadata {
+          position: sticky;
+          overflow-y: auto;
+          top: var(--monorail-header-height);
+          height: calc(100vh - var(--monorail-header-height));
+        }
+        mr-launch-overview {
+          border-left: var(--chops-normal-border);
+          padding-left: var(--mr-issue-page-horizontal-padding);
+          padding-right: var(--mr-issue-page-horizontal-padding);
+          flex-grow: 0;
+          flex-shrink: 0;
+          width: 50%;
+          box-sizing: border-box;
+          min-height: 100%;
+        }
+        @media (max-width: 1126px) {
+          .container-issue-content {
+            flex-direction: column;
+            padding: 0 var(--mr-issue-page-horizontal-padding);
+          }
+          mr-issue-details, mr-launch-overview {
+            width: 100%;
+            padding: 0;
+            border: 0;
+          }
+        }
+        @media (max-width: 840px) {
+          .container-outside {
+            flex-direction: column;
+          }
+          .metadata-container {
+            width: 100%;
+            height: auto;
+            border: 0;
+            border-bottom: var(--chops-normal-border);
+          }
+          mr-issue-metadata {
+            min-width: auto;
+            max-width: auto;
+            width: 100%;
+            padding: 0;
+            min-height: 0;
+            border: 0;
+          }
+          mr-issue-metadata, .issue-header-container {
+            position: static;
+          }
+        }
+      </style>
+      <mr-click-throughs
+         .userDisplayName=${this.userDisplayName}></mr-click-throughs>
+      ${this._renderIssue()}
+    `;
+  }
+
+  /**
+   * Render the issue.
+   * @return {TemplateResult}
+   */
+  _renderIssue() {
+    const issueIsEmpty = !this.issue || !this.issue.localId;
+    const movedToRef = this.issue.movedToRef;
+    const commentShown = this.issue.approvalValues ? APPROVAL_COMMENT_COUNT :
+      DETAIL_COMMENT_COUNT;
+
+    if (this.fetchIssueError) {
+      return html`
+        <div class="container-no-issue" id="fetch-error">
+          ${this.fetchIssueError.description}
+        </div>
+      `;
+    }
+
+    if (this.fetchingIssue && issueIsEmpty) {
+      return html`
+        <div class="container-no-issue" id="loading">
+          Loading...
+        </div>
+      `;
+    }
+
+    if (this.issue.isDeleted) {
+      return html`
+        <div class="container-no-issue" id="deleted">
+          <p>Issue ${this.issueRef.localId} has been deleted.</p>
+          ${this.issuePermissions.includes(ISSUE_DELETE_PERMISSION) ? html`
+            <chops-button
+              @click=${this._undeleteIssue}
+              class="undelete emphasized"
+            >
+              Undelete Issue
+            </chops-button>
+          `: ''}
+        </div>
+      `;
+    }
+
+    if (movedToRef && movedToRef.localId) {
+      return html`
+        <div class="container-no-issue" id="moved">
+          <h2>Issue has moved.</h2>
+          <p>
+            This issue was moved to ${movedToRef.projectName}.
+            <a
+              class="new-location"
+              href="/p/${movedToRef.projectName}/issues/detail?id=${movedToRef.localId}"
+            >
+              Go to issue</a>.
+          </p>
+        </div>
+      `;
+    }
+
+    if (!issueIsEmpty) {
+      return html`
+        <div
+          class="container-outside"
+          @open-dialog=${this._openDialog}
+          id="issue"
+        >
+          <aside class="metadata-container">
+            <mr-issue-metadata></mr-issue-metadata>
+          </aside>
+          <div class="container-issue">
+            <div class="issue-header-container">
+              <mr-issue-header
+                .userDisplayName=${this.userDisplayName}
+              ></mr-issue-header>
+              <mr-restriction-indicator></mr-restriction-indicator>
+            </div>
+            <div class="container-issue-content">
+              <mr-issue-details
+                class="main-item"
+                .commentsShownCount=${commentShown}
+              ></mr-issue-details>
+              <mr-launch-overview class="main-item"></mr-launch-overview>
+            </div>
+          </div>
+        </div>
+        <mr-edit-description id="edit-description"></mr-edit-description>
+        <mr-move-copy-issue id="move-copy-issue"></mr-move-copy-issue>
+        <mr-convert-issue id="convert-issue"></mr-convert-issue>
+        <mr-related-issues id="reorder-related-issues"></mr-related-issues>
+        <mr-update-issue-hotlists-dialog
+          id="update-issue-hotlists"
+          .issueRefs=${[this.issueRef]}
+          .issueHotlists=${this.issueHotlists}
+        ></mr-update-issue-hotlists-dialog>
+      `;
+    }
+
+    return '';
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      userDisplayName: {type: String},
+      // Redux state.
+      fetchIssueError: {type: String},
+      fetchingIssue: {type: Boolean},
+      fetchingProjectConfig: {type: Boolean},
+      issue: {type: Object},
+      issueHotlists: {type: Array},
+      issueClosed: {
+        type: Boolean,
+        reflect: true,
+      },
+      codeFont: {
+        type: Boolean,
+        reflect: true,
+      },
+      issuePermissions: {type: Object},
+      issueRef: {type: Object},
+      prefs: {type: Object},
+      loginUrl: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.issue = {};
+    this.issueRef = {};
+    this.issuePermissions = [];
+    this.prefs = {};
+    this.codeFont = false;
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.projectName = projectV0.viewedProjectName(state);
+    this.issue = issueV0.viewedIssue(state);
+    this.issueHotlists = issueV0.hotlists(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.fetchIssueError = issueV0.requests(state).fetch.error;
+    this.fetchingIssue = issueV0.requests(state).fetch.requesting;
+    this.fetchingProjectConfig = projectV0.fetchingConfig(state);
+    this.issueClosed = !issueV0.isOpen(state);
+    this.issuePermissions = issueV0.permissions(state);
+    this.prefs = userV0.prefs(state);
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('prefs')) {
+      this.codeFont = !!this.prefs.get('code_font');
+    }
+    if (changedProperties.has('fetchIssueError') &&
+      !this.userDisplayName && this.fetchIssueError &&
+      this.fetchIssueError.codeName === 'PERMISSION_DENIED') {
+      page(this.loginUrl);
+    }
+    super.update(changedProperties);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('issueRef') || changedProperties.has('issue')) {
+      const title = this._pageTitle(this.issueRef, this.issue);
+      store.dispatch(sitewide.setPageTitle(title));
+    }
+  }
+
+  /**
+   * Generates a title for the currently viewed page based on issue data.
+   * @param {IssueRef} issueRef
+   * @param {Issue} issue
+   * @return {string}
+   */
+  _pageTitle(issueRef, issue) {
+    const titlePieces = [];
+    if (issueRef.localId) {
+      titlePieces.push(issueRef.localId);
+    }
+    if (!issue || !issue.localId) {
+      // Issue is not loaded.
+      titlePieces.push('Loading issue...');
+    } else {
+      if (issue.isDeleted) {
+        titlePieces.push('Deleted issue');
+      } else if (issue.summary) {
+        titlePieces.push(issue.summary);
+      }
+    }
+    return titlePieces.join(' - ');
+  }
+
+  /**
+   * Opens a dialog with a specific ID based on an Event.
+   * @param {CustomEvent} e
+   */
+  _openDialog(e) {
+    this.querySelector('#' + e.detail.dialogId).open(e);
+  }
+
+  /**
+   * Undeletes the current issue.
+   */
+  _undeleteIssue() {
+    prpcClient.call('monorail.Issues', 'DeleteIssue', {
+      issueRef: this.issueRef,
+      delete: false,
+    }).then(() => {
+      store.dispatch(issueV0.fetchIssuePageData(this.issueRef));
+    });
+  }
+}
+
+customElements.define('mr-issue-page', MrIssuePage);
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.test.js b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.test.js
new file mode 100644
index 0000000..31edd4c
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-issue-page.test.js
@@ -0,0 +1,272 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+import {MrIssuePage} from './mr-issue-page.js';
+import {store, resetState} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let loadingElement;
+let fetchErrorElement;
+let deletedElement;
+let movedElement;
+let issueElement;
+
+function populateElementReferences() {
+  loadingElement = element.querySelector('#loading');
+  fetchErrorElement = element.querySelector('#fetch-error');
+  deletedElement = element.querySelector('#deleted');
+  movedElement = element.querySelector('#moved');
+  issueElement = element.querySelector('#issue');
+}
+
+describe('mr-issue-page', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+    element = document.createElement('mr-issue-page');
+    document.body.appendChild(element);
+    sinon.stub(prpcClient, 'call');
+    // TODO(ehmaldonado): Remove once the old autocomplete code is deprecated.
+    window.TKR_populateAutocomplete = () => {};
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+    // TODO(ehmaldonado): Remove once the old autocomplete code is deprecated.
+    window.TKR_populateAutocomplete = undefined;
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrIssuePage);
+  });
+
+  describe('_pageTitle', () => {
+    it('displays loading when no issue', () => {
+      assert.equal(element._pageTitle({}, {}), 'Loading issue...');
+    });
+
+    it('display issue ID when available', () => {
+      assert.equal(element._pageTitle({projectName: 'test', localId: 1}, {}),
+          '1 - Loading issue...');
+    });
+
+    it('display deleted issues', () => {
+      assert.equal(element._pageTitle({projectName: 'test', localId: 1},
+          {projectName: 'test', localId: 1, isDeleted: true},
+      ), '1 - Deleted issue');
+    });
+
+    it('displays loaded issue', () => {
+      assert.equal(element._pageTitle({projectName: 'test', localId: 2},
+          {projectName: 'test', localId: 2, summary: 'test'}), '2 - test');
+    });
+  });
+
+  it('issue not loaded yet', async () => {
+    // Prevent unrelated Redux changes from affecting this test.
+    // TODO(zhangtiff): Find a more canonical way to test components
+    // in and out of Redux.
+    sinon.stub(store, 'dispatch');
+
+    element.fetchingIssue = true;
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNotNull(loadingElement);
+    assert.isNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNull(issueElement);
+
+    store.dispatch.restore();
+  });
+
+  it('no loading on future issue fetches', async () => {
+    element.issue = {localId: 222};
+    element.fetchingIssue = true;
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNotNull(issueElement);
+  });
+
+  it('fetch error', async () => {
+    element.fetchingIssue = false;
+    element.fetchIssueError = 'error';
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNotNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNull(issueElement);
+  });
+
+  it('deleted issue', async () => {
+    element.fetchingIssue = false;
+    element.issue = {isDeleted: true};
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNull(fetchErrorElement);
+    assert.isNotNull(deletedElement);
+    assert.isNull(issueElement);
+  });
+
+  it('normal issue', async () => {
+    element.fetchingIssue = false;
+    element.issue = {localId: 111};
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNotNull(issueElement);
+  });
+
+  it('code font pref toggles attribute', async () => {
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('codeFont'));
+
+    element.prefs = new Map([['code_font', true]]);
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('codeFont'));
+
+    element.prefs = new Map([['code_font', false]]);
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('codeFont'));
+  });
+
+  it('undeleting issue only shown if you have permissions', async () => {
+    sinon.stub(store, 'dispatch');
+
+    element.issue = {isDeleted: true};
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNotNull(deletedElement);
+
+    let button = element.querySelector('.undelete');
+    assert.isNull(button);
+
+    element.issuePermissions = ['deleteissue'];
+    await element.updateComplete;
+
+    button = element.querySelector('.undelete');
+    assert.isNotNull(button);
+
+    store.dispatch.restore();
+  });
+
+  it('undeleting issue updates page with issue', async () => {
+    const issueRef = {localId: 111, projectName: 'test'};
+    const deletedIssuePromise = Promise.resolve({
+      issue: {isDeleted: true},
+    });
+    const issuePromise = Promise.resolve({
+      issue: {localId: 111, projectName: 'test'},
+    });
+    const deletePromise = Promise.resolve({});
+
+    sinon.spy(element, '_undeleteIssue');
+
+    prpcClient.call.withArgs('monorail.Issues', 'GetIssue', {issueRef})
+        .onFirstCall().returns(deletedIssuePromise)
+        .onSecondCall().returns(issuePromise);
+    prpcClient.call.withArgs('monorail.Issues', 'DeleteIssue',
+        {delete: false, issueRef}).returns(deletePromise);
+
+    store.dispatch(issueV0.viewIssue(issueRef));
+    store.dispatch(issueV0.fetchIssuePageData(issueRef));
+
+    await deletedIssuePromise;
+    await element.updateComplete;
+
+    populateElementReferences();
+
+    assert.deepEqual(element.issue,
+        {isDeleted: true, localId: 111, projectName: 'test'});
+    assert.isNull(issueElement);
+    assert.isNotNull(deletedElement);
+
+    // Make undelete button visible. This must be after deletedIssuePromise
+    // resolves since issuePermissions are cleared by Redux after that promise.
+    element.issuePermissions = ['deleteissue'];
+    await element.updateComplete;
+
+    const button = element.querySelector('.undelete');
+    button.click();
+
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Issues', 'GetIssue',
+        {issueRef});
+    sinon.assert.calledWith(prpcClient.call, 'monorail.Issues', 'DeleteIssue',
+        {delete: false, issueRef});
+
+    await deletePromise;
+    await issuePromise;
+    await element.updateComplete;
+
+    assert.isTrue(element._undeleteIssue.calledOnce);
+
+    assert.deepEqual(element.issue, {localId: 111, projectName: 'test'});
+
+    await element.updateComplete;
+
+    populateElementReferences();
+    assert.isNotNull(issueElement);
+
+    element._undeleteIssue.restore();
+  });
+
+  it('issue has moved', async () => {
+    element.fetchingIssue = false;
+    element.issue = {movedToRef: {projectName: 'hello', localId: 10}};
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(issueElement);
+    assert.isNull(deletedElement);
+    assert.isNotNull(movedElement);
+
+    const link = movedElement.querySelector('.new-location');
+    assert.equal(link.getAttribute('href'), '/p/hello/issues/detail?id=10');
+  });
+
+  it('moving to a restricted issue', async () => {
+    element.fetchingIssue = false;
+    element.issue = {localId: 111};
+
+    await element.updateComplete;
+
+    element.issue = {localId: 222};
+    element.fetchIssueError = 'error';
+
+    await element.updateComplete;
+    populateElementReferences();
+
+    assert.isNull(loadingElement);
+    assert.isNotNull(fetchErrorElement);
+    assert.isNull(deletedElement);
+    assert.isNull(movedElement);
+    assert.isNull(issueElement);
+  });
+});
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.js b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.js
new file mode 100644
index 0000000..af558a4
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.js
@@ -0,0 +1,178 @@
+// 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, css} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {arrayToEnglish} from 'shared/helpers.js';
+
+
+/**
+ * `<mr-restriction-indicator>`
+ *
+ * Display for showing whether an issue is restricted.
+ *
+ */
+export class MrRestrictionIndicator extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        width: 100%;
+        margin-top: 0;
+        background-color: var(--monorail-metadata-toggled-bg);
+        border-bottom: var(--chops-normal-border);
+        font-size: var(--chops-main-font-size);
+        padding: 0.25em 8px;
+        box-sizing: border-box;
+        display: flex;
+        flex-direction: row;
+        justify-content: flex-start;
+        align-items: center;
+      }
+      :host([showWarning]) {
+        background-color: var(--chops-red-700);
+        color: var(--chops-white);
+        font-weight: bold;
+      }
+      :host([showWarning]) i {
+        color: var(--chops-white);
+      }
+      :host([hidden]) {
+        display: none;
+      }
+      i.material-icons {
+        color: var(--chops-primary-icon-color);
+        font-size: var(--chops-icon-font-size);
+      }
+      .lock-icon {
+        margin-right: 4px;
+      }
+      i.warning-icon {
+        margin-right: 4px;
+      }
+      i[hidden] {
+        display: none;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <i
+        class="lock-icon material-icons"
+        icon="lock"
+        ?hidden=${!this._restrictionText}
+        title=${this._restrictionText}
+      >
+        lock
+      </i>
+      <i
+        class="warning-icon material-icons"
+        icon="warning"
+        ?hidden=${!this.showWarning}
+        title=${this._warningText}
+      >
+        warning
+      </i>
+      ${this._combinedText}
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      restrictions: Object,
+      prefs: Object,
+      hidden: {
+        type: Boolean,
+        reflect: true,
+      },
+      showWarning: {
+        type: Boolean,
+        reflect: true,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.hidden = true;
+    this.showWarning = false;
+    this.prefs = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.restrictions = issueV0.restrictions(state);
+    this.prefs = userV0.prefs(state);
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('prefs') ||
+        changedProperties.has('restrictions')) {
+      this.hidden = !this._combinedText;
+
+      this.showWarning = !!this._warningText;
+    }
+
+    super.update(changedProperties);
+  }
+
+  /**
+   * Checks if the user should see a corp mode warning about an issue being
+   * public.
+   * @return {string}
+   */
+  get _warningText() {
+    const {restrictions, prefs} = this;
+    if (!prefs) return '';
+    if (!restrictions) return '';
+    if ('view' in restrictions && restrictions['view'].length) return '';
+    if (prefs.get('public_issue_notice')) {
+      return 'Public issue: Please do not post confidential information.';
+    }
+    return '';
+  }
+
+  /**
+   * Gets either corp mode or restricted issue text depending on which
+   * is relevant to the issue.
+   * @return {string}
+   */
+  get _combinedText() {
+    if (this._warningText) return this._warningText;
+    return this._restrictionText;
+  }
+
+  /**
+   * Computes the text to show users on a restricted issue.
+   * @return {string}
+   */
+  get _restrictionText() {
+    const {restrictions} = this;
+    if (!restrictions) return;
+    if ('view' in restrictions && restrictions['view'].length) {
+      return `Only users with ${arrayToEnglish(restrictions['view'])
+      } permission or issue reporter may view.`;
+    } else if ('edit' in restrictions && restrictions['edit'].length) {
+      return `Only users with ${arrayToEnglish(restrictions['edit'])
+      } permission may edit.`;
+    } else if ('comment' in restrictions && restrictions['comment'].length) {
+      return `Only users with ${arrayToEnglish(restrictions['comment'])
+      } permission or issue reporter may comment.`;
+    }
+    return '';
+  }
+}
+
+customElements.define('mr-restriction-indicator', MrRestrictionIndicator);
diff --git a/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.test.js b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.test.js
new file mode 100644
index 0000000..3afbbcb
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-page/mr-restriction-indicator.test.js
@@ -0,0 +1,130 @@
+// 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 {assert} from 'chai';
+import {MrRestrictionIndicator} from './mr-restriction-indicator.js';
+
+let element;
+
+describe('mr-restriction-indicator', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-restriction-indicator');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrRestrictionIndicator);
+  });
+
+  it('shows element only when restricted or showWarning', async () => {
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('hidden'));
+
+    element.restrictions = {view: ['Google']};
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('hidden'));
+
+    element.restrictions = {};
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('hidden'));
+
+    element.prefs = new Map([['public_issue_notice', true]]);
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('hidden'));
+
+    element.prefs = new Map([['public_issue_notice', false]]);
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('hidden'));
+
+    element.prefs = new Map([]);
+    await element.updateComplete;
+
+    assert.isTrue(element.hasAttribute('hidden'));
+
+    // It is possible to have an edit or comment restriction on
+    // a public issue when the user is opted in to public issue notices.
+    // In that case, the lock icon is shown, plus a warning icon and the
+    // public issue notice.
+    element.restrictions = new Map([['edit', ['Google']]]);
+    element.prefs = new Map([['public_issue_notice', true]]);
+    await element.updateComplete;
+
+    assert.isFalse(element.hasAttribute('hidden'));
+  });
+
+  it('displays view restrictions', async () => {
+    element.restrictions = {
+      view: ['Google', 'hello'],
+      edit: ['Editor', 'world'],
+      comment: ['commentor'],
+    };
+
+    await element.updateComplete;
+
+    const restrictString =
+      'Only users with Google and hello permission or issue reporter may view.';
+    assert.equal(element._restrictionText, restrictString);
+
+    assert.include(element.shadowRoot.textContent, restrictString);
+  });
+
+  it('displays edit restrictions', async () => {
+    element.restrictions = {
+      view: [],
+      edit: ['Editor', 'world'],
+      comment: ['commentor'],
+    };
+
+    await element.updateComplete;
+
+    const restrictString =
+      'Only users with Editor and world permission may edit.';
+    assert.equal(element._restrictionText, restrictString);
+
+    assert.include(element.shadowRoot.textContent, restrictString);
+  });
+
+  it('displays comment restrictions', async () => {
+    element.restrictions = {
+      view: [],
+      edit: [],
+      comment: ['commentor'],
+    };
+
+    await element.updateComplete;
+
+    const restrictString =
+      'Only users with commentor permission or issue reporter may comment.';
+    assert.equal(element._restrictionText, restrictString);
+
+    assert.include(element.shadowRoot.textContent, restrictString);
+  });
+
+  it('displays public issue notice, if the user has that pref', async () => {
+    element.restrictions = {};
+
+    element.prefs = new Map();
+    assert.equal(element._restrictionText, '');
+    assert.include(element.shadowRoot.textContent, '');
+
+    element.prefs = new Map([['public_issue_notice', true]]);
+
+    await element.updateComplete;
+
+    const noticeString =
+      'Public issue: Please do not post confidential information.';
+    assert.equal(element._warningText, noticeString);
+
+    assert.include(element.shadowRoot.textContent, noticeString);
+  });
+});