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',
+ },
+ ],
+ },
+ ]);
+ });
+});