Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.js b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.js
new file mode 100644
index 0000000..8da3083
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.js
@@ -0,0 +1,186 @@
+// 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 {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import 'elements/framework/mr-error/mr-error.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+// TODO(zhangtiff): Make dialog components subclass chops-dialog instead of
+// using slots/containment once we switch to LitElement.
+/**
+ * `<mr-convert-issue>`
+ *
+ * This allows a user to update the structure of an issue to that of
+ * a chosen project template.
+ *
+ */
+export class MrConvertIssue extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        label {
+          font-weight: bold;
+          text-align: right;
+        }
+        form {
+          padding: 1em 8px;
+          display: block;
+          font-size: var(--chops-main-font-size);
+        }
+        textarea {
+          font-family: var(--mr-toggled-font-family);
+          min-height: 80px;
+          border: var(--chops-accessible-border);
+          padding: 0.5em 4px;
+        }
+        .edit-actions {
+          width: 100%;
+          margin: 0.5em 0;
+          text-align: right;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <chops-dialog closeOnOutsideClick>
+        <h3 class="medium-heading">Convert issue to new template structure</h3>
+        <form id="convertIssueForm">
+          <div class="input-grid">
+            <label for="templateInput">Pick a template: </label>
+            <select id="templateInput" @change=${this._templateInputChanged}>
+              <option value="">--Please choose a project template--</option>
+              ${this.projectTemplates.map((projTempl) => html`
+                <option value=${projTempl.templateName}>
+                  ${projTempl.templateName}
+                </option>`)}
+            </select>
+            <label for="commentContent">Comment: </label>
+            <textarea id="commentContent" placeholder="Add a comment"></textarea>
+            <span></span>
+            <chops-checkbox
+              @checked-change=${this._sendEmailChecked}
+              checked=${this.sendEmail}
+            >Send email</chops-checkbox>
+          </div>
+          <mr-error ?hidden=${!this.convertIssueError}>
+            ${this.convertIssueError && this.convertIssueError.description}
+          </mr-error>
+          <div class="edit-actions">
+            <chops-button @click=${this.close} class="de-emphasized discard-button">
+              Discard
+            </chops-button>
+            <chops-button @click=${this.save} class="emphasized" ?disabled=${!this.selectedTemplate}>
+              Convert issue
+            </chops-button>
+          </div>
+        </form>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      convertingIssue: {
+        type: Boolean,
+      },
+      convertIssueError: {
+        type: Object,
+      },
+      issuePermissions: {
+        type: Object,
+      },
+      issueRef: {
+        type: Object,
+      },
+      projectTemplates: {
+        type: Array,
+      },
+      selectedTemplate: {
+        type: String,
+      },
+      sendEmail: {
+        type: Boolean,
+      },
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.convertingIssue = issueV0.requests(state).convert.requesting;
+    this.convertIssueError = issueV0.requests(state).convert.error;
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.issuePermissions = issueV0.permissions(state);
+    this.projectTemplates = projectV0.viewedTemplates(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.selectedTemplate = '';
+    this.sendEmail = true;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('convertingIssue')) {
+      if (!this.convertingIssue && !this.convertIssueError) {
+        this.close();
+      }
+    }
+  }
+
+  open() {
+    this.reset();
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.open();
+  }
+
+  close() {
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.close();
+  }
+
+  /**
+   * Resets the user's input.
+   */
+  reset() {
+    this.shadowRoot.querySelector('#convertIssueForm').reset();
+  }
+
+  /**
+   * Dispatches a Redux action to convert the issue to a new template.
+   */
+  save() {
+    const commentContent = this.shadowRoot.querySelector('#commentContent');
+    store.dispatch(issueV0.convert(this.issueRef, {
+      templateName: this.selectedTemplate,
+      commentContent: commentContent.value,
+      sendEmail: this.sendEmail,
+    }));
+  }
+
+  _sendEmailChecked(evt) {
+    this.sendEmail = evt.detail.checked;
+  }
+
+  _templateInputChanged() {
+    this.selectedTemplate = this.shadowRoot.querySelector(
+        '#templateInput').value;
+  }
+}
+
+customElements.define('mr-convert-issue', MrConvertIssue);
diff --git a/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.test.js b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.test.js
new file mode 100644
index 0000000..b68e274
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-convert-issue/mr-convert-issue.test.js
@@ -0,0 +1,30 @@
+// 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 {MrConvertIssue} from './mr-convert-issue.js';
+
+let element;
+
+describe('mr-convert-issue', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-convert-issue');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrConvertIssue);
+  });
+
+  it('no template chosen', async () => {
+    await element.updateComplete;
+
+    const buttons = element.shadowRoot.querySelectorAll('chops-button');
+    assert.isTrue(buttons[buttons.length - 1].disabled);
+  });
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.js b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.js
new file mode 100644
index 0000000..2a34b8f
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.js
@@ -0,0 +1,340 @@
+// 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/framework/mr-upload/mr-upload.js';
+import 'elements/framework/mr-error/mr-error.js';
+import {fieldTypes} from 'shared/issue-fields.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 'elements/chops/chops-checkbox/chops-checkbox.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES, MD_PREVIEW_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+import {commentListToDescriptionList} from 'shared/convertersV0.js';
+import {renderMarkdown, shouldRenderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+
+
+/**
+ * `<mr-edit-description>`
+ *
+ * A dialog to edit descriptions.
+ *
+ */
+export class MrEditDescription extends connectStore(LitElement) {
+  /** @override */
+  constructor() {
+    super();
+    this._editedDescription = '';
+  }
+
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      MD_PREVIEW_STYLES,
+      MD_STYLES,
+      css`
+        chops-dialog {
+          --chops-dialog-width: 800px;
+        }
+        textarea {
+          font-family: var(--mr-toggled-font-family);
+          min-height: 300px;
+          max-height: 500px;
+          border: var(--chops-accessible-border);
+          padding: 0.5em 4px;
+          margin: 0.5em 0;
+        }
+        .attachments {
+          margin: 0.5em 0;
+        }
+        .content {
+          padding: 0.5em 0.5em;
+          width: 100%;
+          box-sizing: border-box;
+        }
+        .edit-controls {
+          display: flex;
+          justify-content: space-between;
+          align-items: center;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <chops-dialog aria-labelledby="editDialogTitle">
+        <h3 id="editDialogTitle" class="medium-heading">
+          Edit ${this._title}
+        </h3>
+        <textarea
+          id="description"
+          class="content"
+          @keyup=${this._setEditedDescription}
+          @change=${this._setEditedDescription}
+          .value=${this._editedDescription}
+        ></textarea>
+        ${this._renderMarkdown ? html`
+          <div class="markdown-preview preview-height-description">
+            <div class="markdown">
+              ${unsafeHTML(renderMarkdown(this._editedDescription))}
+            </div>
+          </div>`: ''}
+        <h3 class="medium-heading">
+          Add attachments
+        </h3>
+        <div class="attachments">
+          ${this._attachments && this._attachments.map((attachment) => html`
+            <label>
+              <chops-checkbox
+                type="checkbox"
+                checked="true"
+                class="kept-attachment"
+                data-attachment-id=${attachment.attachmentId}
+                @checked-change=${this._keptAttachmentIdsChanged}
+              />
+              <a href=${attachment.viewUrl} target="_blank">
+                ${attachment.filename}
+              </a>
+            </label>
+            <br>
+          `)}
+          <mr-upload></mr-upload>
+        </div>
+        <mr-error
+          ?hidden=${!this._attachmentError}
+        >${this._attachmentError}</mr-error>
+        <div class="edit-controls">
+          <chops-checkbox
+            id="sendEmail"
+            ?checked=${this._sendEmail}
+            @checked-change=${this._setSendEmail}
+          >Send email</chops-checkbox>
+          <div>
+            <chops-button id="discard" @click=${this.cancel} class="de-emphasized">
+              Discard
+            </chops-button>
+            <chops-button id="save" @click=${this.save} class="emphasized">
+              Save changes
+            </chops-button>
+          </div>
+        </div>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      commentsByApproval: {type: Array},
+      issueRef: {type: Object},
+      fieldName: {type: String},
+      projectName: {type: String},
+      _attachmentError: {type: String},
+      _attachments: {type: Array},
+      _boldLines: {type: Array},
+      _editedDescription: {type: String},
+      _title: {type: String},
+      _keptAttachmentIds: {type: Object},
+      _sendEmail: {type: Boolean},
+      _prefs: {type: Object},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.commentsByApproval = issueV0.commentsByApprovalName(state);
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.projectName = projectV0.viewedProjectName(state);
+    this._prefs = userV0.prefs(state);
+  }
+
+  /**
+   * Public function to open the issue description editing dialog.
+   * @param {Event} e
+   */
+  async open(e) {
+    await this.updateComplete;
+    this.shadowRoot.querySelector('chops-dialog').open();
+    this.fieldName = e.detail.fieldName;
+    this.reset();
+  }
+
+  /**
+   * Resets edit form.
+   */
+  async reset() {
+    await this.updateComplete;
+    this._attachmentError = '';
+    this._attachments = [];
+    this._boldLines = [];
+    this._keptAttachmentIds = new Set();
+
+    const uploader = this.shadowRoot.querySelector('mr-upload');
+    if (uploader) {
+      uploader.reset();
+    }
+
+    // Sets _editedDescription and _title.
+    this._initializeView(this.commentsByApproval, this.fieldName);
+
+    this.shadowRoot.querySelectorAll('.kept-attachment').forEach((checkbox) => {
+      checkbox.checked = true;
+    });
+    this.shadowRoot.querySelector('#sendEmail').checked = true;
+
+    this._sendEmail = true;
+  }
+
+  /**
+   * Cancels in-flight edit data.
+   */
+  async cancel() {
+    await this.updateComplete;
+    this.shadowRoot.querySelector('chops-dialog').close();
+  }
+
+  /**
+   * Sends the user's edit to Monorail's backend to be saved.
+   */
+  async save() {
+    const commentContent = this._markupNewContent();
+    const sendEmail = this._sendEmail;
+    const keptAttachments = Array.from(this._keptAttachmentIds);
+    const message = {
+      issueRef: this.issueRef,
+      isDescription: true,
+      commentContent,
+      keptAttachments,
+      sendEmail,
+    };
+
+    try {
+      const uploader = this.shadowRoot.querySelector('mr-upload');
+      const uploads = await uploader.loadFiles();
+      if (uploads && uploads.length) {
+        message.uploads = uploads;
+      }
+
+      if (!this.fieldName) {
+        store.dispatch(issueV0.update(message));
+      } else {
+        // This is editing an approval if there is no field name.
+        message.fieldRef = {
+          type: fieldTypes.APPROVAL_TYPE,
+          fieldName: this.fieldName,
+        };
+        store.dispatch(issueV0.updateApproval(message));
+      }
+      this.shadowRoot.querySelector('chops-dialog').close();
+    } catch (e) {
+      this._attachmentError = `Error while loading file for attachment: ${
+        e.message}`;
+    }
+  }
+
+  /**
+   * Getter for checking if the user has Markdown enabled.
+   * @return {boolean} Whether Markdown preview should be rendered or not.
+   */
+   get _renderMarkdown() {
+    const enabled = this._prefs.get('render_markdown');
+    return shouldRenderMarkdown({project: this.projectName, enabled});
+  }
+
+  /**
+   * Event handler for keeping <mr-edit-description>'s copy of
+   * _editedDescription in sync.
+   * @param {Event} e
+   */
+  _setEditedDescription(e) {
+    const target = e.target;
+    this._editedDescription = target.value;
+  }
+
+  /**
+   * Event handler for keeping attachment state in sync.
+   * @param {Event} e
+   */
+  _keptAttachmentIdsChanged(e) {
+    e.target.checked = e.detail.checked;
+    const attachmentId = Number.parseInt(e.target.dataset.attachmentId);
+    if (e.target.checked) {
+      this._keptAttachmentIds.add(attachmentId);
+    } else {
+      this._keptAttachmentIds.delete(attachmentId);
+    }
+  }
+
+  _initializeView(commentsByApproval, fieldName) {
+    this._title = fieldName ? `${fieldName} Survey` : 'Description';
+    const key = fieldName || '';
+    if (!commentsByApproval || !commentsByApproval.has(key)) return;
+    const comments = commentListToDescriptionList(commentsByApproval.get(key));
+
+    const comment = comments[comments.length - 1];
+
+    if (comment.attachments) {
+      this._keptAttachmentIds = new Set(comment.attachments.map(
+          (attachment) => Number.parseInt(attachment.attachmentId)));
+      this._attachments = comment.attachments;
+    }
+
+    this._processRawContent(comment.content);
+  }
+
+  _processRawContent(content) {
+    const chunks = content.trim().split(/(<b>[^<\n]+<\/b>)/m);
+    const boldLines = [];
+    let cleanContent = '';
+    chunks.forEach((chunk) => {
+      if (chunk.startsWith('<b>') && chunk.endsWith('</b>')) {
+        const cleanChunk = chunk.slice(3, -4).trim();
+        cleanContent += cleanChunk;
+        // Don't add whitespace to boldLines.
+        if (/\S/.test(cleanChunk)) {
+          boldLines.push(cleanChunk);
+        }
+      } else {
+        cleanContent += chunk;
+      }
+    });
+
+    this._boldLines = boldLines;
+    this._editedDescription = cleanContent;
+  }
+
+  _markupNewContent() {
+    const lines = this._editedDescription.trim().split('\n');
+    const markedLines = lines.map((line) => {
+      let markedLine = line;
+      const matchingBoldLine = this._boldLines.find(
+          (boldLine) => (line.startsWith(boldLine)));
+      if (matchingBoldLine) {
+        markedLine =
+          `<b>${matchingBoldLine}</b>${line.slice(matchingBoldLine.length)}`;
+      }
+      return markedLine;
+    });
+    return markedLines.join('\n');
+  }
+
+  /**
+   * Event handler for keeping email state in sync.
+   * @param {Event} e
+   */
+  _setSendEmail(e) {
+    this._sendEmail = e.detail.checked;
+  }
+}
+
+customElements.define('mr-edit-description', MrEditDescription);
diff --git a/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.test.js b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.test.js
new file mode 100644
index 0000000..e3fe9d2
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-edit-description/mr-edit-description.test.js
@@ -0,0 +1,136 @@
+// 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 {MrEditDescription} from './mr-edit-description.js';
+
+let element;
+
+describe('mr-edit-description', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-edit-description');
+
+    document.body.appendChild(element);
+    element.commentsByApproval = new Map([
+      ['', [
+        {
+          descriptionNum: 1,
+          content: 'first description',
+        },
+        {
+          content: 'first comment',
+        },
+        {
+          descriptionNum: 2,
+          content: '<b>last</b> description',
+        },
+        {
+          content: 'second comment',
+        },
+        {
+          content: 'third comment',
+        },
+      ]], ['foo', [
+        {
+          descriptionNum: 1,
+          content: 'first foo survey',
+          approvalRef: {
+            fieldName: 'foo',
+          },
+        },
+        {
+          descriptionNum: 2,
+          content: 'last foo survey',
+          approvalRef: {
+            fieldName: 'foo',
+          },
+        },
+      ]], ['bar', [
+        {
+          descriptionNum: 1,
+          content: 'bar survey',
+          approvalRef: {
+            fieldName: 'bar',
+          },
+        },
+      ]],
+    ]);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrEditDescription);
+  });
+
+  it('selects last issue description', async () => {
+    element.fieldName = '';
+    element.reset();
+
+    await element.updateComplete;
+
+    assert.equal(element._editedDescription, 'last description');
+    assert.equal(element._title, 'Description');
+  });
+
+  it('selects last survey', async () => {
+    element.fieldName = 'foo';
+    element.reset();
+
+    await element.updateComplete;
+
+    assert.equal(element._editedDescription, 'last foo survey');
+    assert.equal(element._title, 'foo Survey');
+  });
+
+  it('toggle sendEmail', async () => {
+    element.reset();
+    await element.updateComplete;
+
+    const sendEmail = element.shadowRoot.querySelector('#sendEmail');
+
+    await sendEmail.updateComplete;
+
+    sendEmail.click();
+    await element.updateComplete;
+    assert.isFalse(element._sendEmail);
+
+    sendEmail.click();
+    await element.updateComplete;
+    assert.isTrue(element._sendEmail);
+
+    sendEmail.click();
+    await element.updateComplete;
+    assert.isFalse(element._sendEmail);
+  });
+
+  it('renders valid markdown description with preview class', async () => {
+    element.projectName = 'monkeyrail';
+    element._prefs = new Map([['render_markdown', true]]);
+    element.reset();
+
+    element._editedDescription = '# h1';
+
+    await element.updateComplete;
+
+    const previewMarkdown = element.shadowRoot.querySelector('.markdown-preview');
+    assert.isNotNull(previewMarkdown);
+
+    const headerText = previewMarkdown.querySelector('h1').textContent;
+    assert.equal(headerText, 'h1');
+  });
+
+  it('does not show preview when markdown is disabled', async () => {
+    element.projectName = 'disabled_project';
+    element._prefs = new Map([['render_markdown', true]]);
+    element.reset();
+
+    await element.updateComplete;
+
+    const previewMarkdown = element.shadowRoot.querySelector('.markdown-preview');
+    assert.isNull(previewMarkdown);
+  });
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.js b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.js
new file mode 100644
index 0000000..e97f203
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.js
@@ -0,0 +1,129 @@
+// 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, css} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import 'elements/framework/mr-autocomplete/mr-autocomplete.js';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+export class MrMoveCopyIssue extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        .target-project-dialog {
+          display: block;
+          font-size: var(--chops-main-font-size);
+        }
+        .error {
+          max-width: 100%;
+          color: red;
+          margin-bottom: 1em;
+        }
+        .edit-actions {
+          width: 100%;
+          margin: 0.5em 0;
+          text-align: right;
+        }
+        input {
+          box-sizing: border-box;
+          width: 95%;
+          padding: 0.25em 4px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+            rel="stylesheet">
+      <chops-dialog closeOnOutsideClick>
+        <div class="target-project-dialog">
+          <h3 class="medium-heading">${this._action} issue</h3>
+          <div class="input-grid">
+            <label for="targetProjectInput">Target project:</label>
+            <div>
+              <input id="targetProjectInput" />
+              <mr-autocomplete
+                vocabularyName="project"
+                for="targetProjectInput"
+              ></mr-autocomplete>
+            </div>
+          </div>
+
+          ${this._targetProjectError ? html`
+            <div class="error">
+              ${this._targetProjectError}
+            </div>
+          ` : ''}
+
+          <div class="edit-actions">
+            <chops-button @click=${this.cancel} class="de-emphasized">
+              Cancel
+            </chops-button>
+            <chops-button @click=${this.save} class="emphasized">
+              ${this._action} issue
+            </chops-button>
+          </div>
+        </div>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      issueRef: {type: Object},
+      _action: {type: String},
+      _targetProjectError: {type: String},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issueRef = issueV0.viewedIssueRef(state);
+  }
+
+  open(e) {
+    this.shadowRoot.querySelector('chops-dialog').open();
+    this._action = e.detail.action;
+    this.reset();
+  }
+
+  reset() {
+    this.shadowRoot.querySelector('#targetProjectInput').value = '';
+    this._targetProjectError = '';
+  }
+
+  cancel() {
+    this.shadowRoot.querySelector('chops-dialog').close();
+  }
+
+  save() {
+    const method = this._action + 'Issue';
+    prpcClient.call('monorail.Issues', method, {
+      issueRef: this.issueRef,
+      targetProjectName: this.shadowRoot.querySelector(
+          '#targetProjectInput').value,
+    }).then((response) => {
+      const projectName = response.newIssueRef.projectName;
+      const localId = response.newIssueRef.localId;
+      page(`/p/${projectName}/issues/detail?id=${localId}`);
+      this.cancel();
+    }, (error) => {
+      this._targetProjectError = error;
+    });
+  }
+}
+
+customElements.define('mr-move-copy-issue', MrMoveCopyIssue);
diff --git a/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.test.js b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.test.js
new file mode 100644
index 0000000..5fdfb39
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-move-copy-issue/mr-move-copy-issue.test.js
@@ -0,0 +1,23 @@
+// 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 {MrMoveCopyIssue} from './mr-move-copy-issue.js';
+
+let element;
+
+describe('mr-move-copy-issue', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-move-copy-issue');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrMoveCopyIssue);
+  });
+});
diff --git a/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.js b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.js
new file mode 100644
index 0000000..e859bef
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.js
@@ -0,0 +1,316 @@
+// 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/chops/chops-dialog/chops-dialog.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {ISSUE_EDIT_PERMISSION} from 'shared/consts/permissions';
+import {prpcClient} from 'prpc-client-instance.js';
+
+/**
+ * `<mr-related-issues>`
+ *
+ * Component for showing a mini list view of blocking issues to users.
+ */
+export class MrRelatedIssues extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: block;
+          font-size: var(--chops-main-font-size);
+        }
+        table {
+          word-wrap: break-word;
+          width: 100%;
+        }
+        tr {
+          font-weight: normal;
+          text-align: left;
+          margin: 0 auto;
+          padding: 2em 1em;
+          height: 20px;
+        }
+        td {
+          background: #f8f8f8;
+          padding: 4px;
+          padding-left: 8px;
+          text-overflow: ellipsis;
+        }
+        th {
+          text-decoration: none;
+          margin-right: 0;
+          padding-right: 0;
+          padding-left: 8px;
+          white-space: nowrap;
+          background: #e3e9ff;
+          text-align: left;
+          border-right: 1px solid #fff;
+          border-top: 1px solid #fff;
+        }
+        tr.dragged td {
+          background: #eee;
+        }
+        h3.medium-heading {
+          display: flex;
+          justify-content: space-between;
+          align-items: flex-end;
+        }
+        button {
+          background: none;
+          border: none;
+          color: inherit;
+          cursor: pointer;
+          margin: 0;
+          padding: 0;
+        }
+        i.material-icons {
+          font-size: var(--chops-icon-font-size);
+          color: var(--chops-primary-icon-color);
+        }
+        .draggable {
+          cursor: grab;
+        }
+        .error {
+          max-width: 100%;
+          color: red;
+          margin-bottom: 1em;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    const rerankEnabled = (this.issuePermissions ||
+      []).includes(ISSUE_EDIT_PERMISSION);
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <chops-dialog closeOnOutsideClick>
+        <h3 class="medium-heading">
+          <span>Blocked on issues</span>
+          <button aria-label="close" @click=${this.close}>
+            <i class="material-icons">close</i>
+          </button>
+        </h3>
+        ${this.error ? html`
+          <div class="error">${this.error}</div>
+        ` : ''}
+        <table><tbody>
+          <tr>
+            ${rerankEnabled ? html`<th></th>` : ''}
+            ${this.columns.map((column) => html`
+              <th>${column}</th>
+            `)}
+          </tr>
+
+          ${this._renderedRows.map((row, index) => html`
+            <tr
+              class=${index === this.srcIndex ? 'dragged' : ''}
+              draggable=${rerankEnabled && row.draggable}
+              data-index=${index}
+              @dragstart=${this._dragstart}
+              @dragend=${this._dragend}
+              @dragover=${this._dragover}
+              @drop=${this._dragdrop}
+            >
+              ${rerankEnabled ? html`
+                <td>
+                  ${rerankEnabled && row.draggable ? html`
+                    <i class="material-icons draggable">drag_indicator</i>
+                  ` : ''}
+                </td>
+              ` : ''}
+
+              ${row.cells.map((cell) => html`
+                <td>
+                  ${cell.type === 'issue' ? html`
+                    <mr-issue-link
+                      .projectName=${this.issueRef.projectName}
+                      .issue=${cell.issue}
+                    ></mr-issue-link>
+                  ` : ''}
+                  ${cell.type === 'text' ? cell.content : ''}
+                </td>
+              `)}
+            </tr>
+          `)}
+        </tbody></table>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      columns: {type: Array},
+      error: {type: String},
+      srcIndex: {type: Number},
+      issueRef: {type: Object},
+      issuePermissions: {type: Array},
+      sortedBlockedOn: {type: Array},
+      _renderedRows: {type: Array},
+    };
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.issueRef = issueV0.viewedIssueRef(state);
+    this.issuePermissions = issueV0.permissions(state);
+    this.sortedBlockedOn = issueV0.sortedBlockedOn(state);
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.columns = ['Issue', 'Summary'];
+  }
+
+  /** @override */
+  update(changedProperties) {
+    if (changedProperties.has('sortedBlockedOn')) {
+      this.reset();
+    }
+    super.update(changedProperties);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('issueRef')) {
+      this.close();
+    }
+  }
+
+  get _rows() {
+    const blockedOn = this.sortedBlockedOn;
+    if (!blockedOn) return [];
+    return blockedOn.map((issue) => {
+      const isClosed = issue.statusRef ? !issue.statusRef.meansOpen : false;
+      let summary = issue.summary;
+      if (issue.extIdentifier) {
+        // Some federated references will have summaries.
+        summary = issue.summary || '(not available)';
+      }
+      const row = {
+        // Disallow reranking FedRefs/DanglingIssueRelations.
+        draggable: !isClosed && !issue.extIdentifier,
+        cells: [
+          {
+            type: 'issue',
+            issue: issue,
+            isClosed: Boolean(isClosed),
+          },
+          {
+            type: 'text',
+            content: summary,
+          },
+        ],
+      };
+      return row;
+    });
+  }
+
+  async open() {
+    await this.updateComplete;
+    this.reset();
+    this.shadowRoot.querySelector('chops-dialog').open();
+  }
+
+  close() {
+    this.shadowRoot.querySelector('chops-dialog').close();
+  }
+
+  reset() {
+    this.error = null;
+    this.srcIndex = null;
+    this._renderedRows = this._rows.slice();
+  }
+
+  _dragstart(e) {
+    if (e.currentTarget.draggable) {
+      this.srcIndex = Number(e.currentTarget.dataset.index);
+      e.dataTransfer.setDragImage(new Image(), 0, 0);
+    }
+  }
+
+  _dragover(e) {
+    if (e.currentTarget.draggable && this.srcIndex !== null) {
+      e.preventDefault();
+      const targetIndex = Number(e.currentTarget.dataset.index);
+      this._reorderRows(this.srcIndex, targetIndex);
+      this.srcIndex = targetIndex;
+    }
+  }
+
+  _dragend(e) {
+    if (this.srcIndex !== null) {
+      this.reset();
+    }
+  }
+
+  _dragdrop(e) {
+    if (e.currentTarget.draggable && this.srcIndex !== null) {
+      const src = this._renderedRows[this.srcIndex];
+      if (this.srcIndex > 0) {
+        const target = this._renderedRows[this.srcIndex - 1];
+        const above = false;
+        this._reorderBlockedOn(src, target, above);
+      } else if (this.srcIndex === 0 &&
+                 this._renderedRows[1] && this._renderedRows[1].draggable) {
+        const target = this._renderedRows[1];
+        const above = true;
+        this._reorderBlockedOn(src, target, above);
+      }
+      this.srcIndex = null;
+    }
+  }
+
+  _reorderBlockedOn(srcArg, targetArg, above) {
+    const src = srcArg.cells[0].issue;
+    const target = targetArg.cells[0].issue;
+
+    const reorderRequest = prpcClient.call(
+        'monorail.Issues', 'RerankBlockedOnIssues', {
+          issueRef: this.issueRef,
+          movedRef: {
+            projectName: src.projectName,
+            localId: src.localId,
+          },
+          targetRef: {
+            projectName: target.projectName,
+            localId: target.localId,
+          },
+          splitAbove: above,
+        });
+
+    reorderRequest.then((response) => {
+      store.dispatch(issueV0.fetch(this.issueRef));
+    }, (error) => {
+      this.reset();
+      this.error = error.description;
+    });
+  }
+
+  _reorderRows(srcIndex, toIndex) {
+    if (srcIndex <= toIndex) {
+      this._renderedRows = this._renderedRows.slice(0, srcIndex).concat(
+          this._renderedRows.slice(srcIndex + 1, toIndex + 1),
+          [this._renderedRows[srcIndex]],
+          this._renderedRows.slice(toIndex + 1));
+    } else {
+      this._renderedRows = this._renderedRows.slice(0, toIndex).concat(
+          [this._renderedRows[srcIndex]],
+          this._renderedRows.slice(toIndex, srcIndex),
+          this._renderedRows.slice(srcIndex + 1));
+    }
+  }
+}
+
+customElements.define('mr-related-issues', MrRelatedIssues);
diff --git a/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.test.js b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.test.js
new file mode 100644
index 0000000..69ce7ee
--- /dev/null
+++ b/static_src/elements/issue-detail/dialogs/mr-related-issues/mr-related-issues.test.js
@@ -0,0 +1,191 @@
+// 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 {MrRelatedIssues} from './mr-related-issues.js';
+
+
+let element;
+
+describe('mr-related-issues', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-related-issues');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrRelatedIssues);
+  });
+
+  it('dialog closes when issueRef changes', async () => {
+    element.issueRef = {projectName: 'chromium', localId: 22};
+    await element.updateComplete;
+
+    const dialog = element.shadowRoot.querySelector('chops-dialog');
+
+    element.open();
+    await element.updateComplete;
+
+    assert.isTrue(dialog.opened);
+
+    element.issueRef = {projectName: 'chromium', localId: 23};
+    await element.updateComplete;
+
+    assert.isFalse(dialog.opened);
+  });
+
+  it('computes blocked on table rows', () => {
+    element.projectName = 'proj';
+    element.sortedBlockedOn = [
+      {projectName: 'proj', localId: 1, statusRef: {meansOpen: true},
+        summary: 'Issue 1'},
+      {projectName: 'proj', localId: 2, statusRef: {meansOpen: true},
+        summary: 'Issue 2'},
+      {projectName: 'proj', localId: 3,
+        summary: 'Issue 3'},
+      {projectName: 'proj2', localId: 4,
+        summary: 'Issue 4 on another project'},
+      {extIdentifier: 'b/123456', statusRef: {meansOpen: true}},
+      {extIdentifier: 'b/987654', statusRef: {meansOpen: false},
+        summary: 'FedRef with a summary'},
+      {projectName: 'proj', localId: 5, statusRef: {meansOpen: false},
+        summary: 'Issue 5'},
+      {projectName: 'proj2', localId: 6, statusRef: {meansOpen: false},
+        summary: 'Issue 6 on another project'},
+    ];
+    assert.deepEqual(element._rows, [
+      {
+        draggable: true,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj', localId: 1, statusRef: {meansOpen: true},
+              summary: 'Issue 1'},
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: 'Issue 1',
+          },
+        ],
+      },
+      {
+        draggable: true,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj', localId: 2, statusRef: {meansOpen: true},
+              summary: 'Issue 2'},
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: 'Issue 2',
+          },
+        ],
+      },
+      {
+        draggable: true,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj', localId: 3,
+              summary: 'Issue 3'},
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: 'Issue 3',
+          },
+        ],
+      },
+      {
+        draggable: true,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj2', localId: 4,
+              summary: 'Issue 4 on another project'},
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: 'Issue 4 on another project',
+          },
+        ],
+      },
+      {
+        draggable: false,
+        cells: [
+          {
+            type: 'issue',
+            issue: {
+              extIdentifier: 'b/123456',
+              statusRef: {meansOpen: true},
+            },
+            isClosed: false,
+          },
+          {
+            type: 'text',
+            content: '(not available)',
+          },
+        ],
+      },
+      {
+        draggable: false,
+        cells: [
+          {
+            type: 'issue',
+            issue: {
+              extIdentifier: 'b/987654',
+              statusRef: {meansOpen: false},
+              summary: 'FedRef with a summary',
+            },
+            isClosed: true,
+          },
+          {
+            type: 'text',
+            content: 'FedRef with a summary',
+          },
+        ],
+      },
+      {
+        draggable: false,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj', localId: 5,
+              statusRef: {meansOpen: false},
+              summary: 'Issue 5'},
+            isClosed: true,
+          },
+          {
+            type: 'text',
+            content: 'Issue 5',
+          },
+        ],
+      },
+      {
+        draggable: false,
+        cells: [
+          {
+            type: 'issue',
+            issue: {projectName: 'proj2', localId: 6,
+              statusRef: {meansOpen: false},
+              summary: 'Issue 6 on another project'},
+            isClosed: true,
+          },
+          {
+            type: 'text',
+            content: 'Issue 6 on another project',
+          },
+        ],
+      },
+    ]);
+  });
+});