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',
+ },
+ ],
+ },
+ ]);
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js
new file mode 100644
index 0000000..18bd963
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js
@@ -0,0 +1,288 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import deepEqual from 'deep-equal';
+import {fieldTypes, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {arrayDifference, equalsIgnoreCase} from 'shared/helpers.js';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+
+import './mr-multi-checkbox.js';
+import 'react/mr-react-autocomplete.tsx';
+
+const AUTOCOMPLETE_INPUT = 'AUTOCOMPLETE_INPUT';
+const CHECKBOX_INPUT = 'CHECKBOX_INPUT';
+const SELECT_INPUT = 'SELECT_INPUT';
+
+/**
+ * `<mr-edit-field>`
+ *
+ * A single edit input for a fieldDef + the values of the field.
+ *
+ */
+export class MrEditField extends LitElement {
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <style>
+ mr-edit-field {
+ display: block;
+ }
+ mr-edit-field[hidden] {
+ display: none;
+ }
+ mr-edit-field input,
+ mr-edit-field select {
+ width: var(--mr-edit-field-width);
+ padding: var(--mr-edit-field-padding);
+ }
+ </style>
+ ${this._renderInput()}
+ `;
+ }
+
+ /**
+ * Renders a single input field.
+ * @return {TemplateResult}
+ */
+ _renderInput() {
+ switch (this._widgetType) {
+ case CHECKBOX_INPUT:
+ return html`
+ <mr-multi-checkbox
+ .options=${this.options}
+ .values=${[...this.values]}
+ @change=${this._changeHandler}
+ ></mr-multi-checkbox>
+ `;
+ case SELECT_INPUT:
+ return html`
+ <select
+ id="${this.label}"
+ class="editSelect"
+ aria-label=${this.name}
+ @change=${this._changeHandler}
+ >
+ <option value="">${EMPTY_FIELD_VALUE}</option>
+ ${this.options.map((option) => html`
+ <option
+ value=${option.optionName}
+ .selected=${this.value === option.optionName}
+ >
+ ${option.optionName}
+ ${option.docstring ? ' = ' + option.docstring : ''}
+ </option>
+ `)}
+ </select>
+ `;
+ case AUTOCOMPLETE_INPUT:
+ return html`
+ <mr-react-autocomplete
+ .label=${this.label}
+ .vocabularyName=${this.acType || ''}
+ .inputType=${this._html5InputType}
+ .fixedValues=${this.derivedValues}
+ .value=${this.multi ? this.values : this.value}
+ .multiple=${this.multi}
+ .onChange=${this._changeHandlerReact.bind(this)}
+ ></mr-react-autocomplete>
+ `;
+ default:
+ return '';
+ }
+ }
+
+
+ /** @override */
+ static get properties() {
+ return {
+ // TODO(zhangtiff): Redesign this a bit so we don't need two separate
+ // ways of specifying "type" for a field. Right now, "type" is mapped to
+ // the Monorail custom field types whereas "acType" includes additional
+ // data types such as components, and labels.
+ // String specifying what kind of autocomplete to add to this field.
+ acType: {type: String},
+ // "type" is based on the various custom field types available in
+ // Monorail.
+ type: {type: String},
+ label: {type: String},
+ multi: {type: Boolean},
+ name: {type: String},
+ // Only used for basic, non-repeated fields.
+ placeholder: {type: String},
+ initialValues: {
+ type: Array,
+ hasChanged(newVal, oldVal) {
+ // Prevent extra recomputations of the same initial value causing
+ // values to be reset.
+ return !deepEqual(newVal, oldVal);
+ },
+ },
+ // The current user-inputted values for a field.
+ values: {type: Array},
+ derivedValues: {type: Array},
+ // For enum fields, the possible options that you have. Each entry is a
+ // label type with an additional optionName field added.
+ options: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.initialValues = [];
+ this.values = [];
+ this.derivedValues = [];
+ this.options = [];
+ this.multi = false;
+
+ this.actType = '';
+ this.placeholder = '';
+ this.type = '';
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('initialValues')) {
+ // Assume we always want to reset the user's input when initial
+ // values change.
+ this.reset();
+ }
+ super.update(changedProperties);
+ }
+
+ /**
+ * @return {string}
+ */
+ get value() {
+ return _getSingleValue(this.values);
+ }
+
+ /**
+ * @return {string}
+ */
+ get _widgetType() {
+ const type = this.type;
+ const multi = this.multi;
+ if (type === fieldTypes.ENUM_TYPE) {
+ if (multi) {
+ return CHECKBOX_INPUT;
+ }
+ return SELECT_INPUT;
+ } else {
+ return AUTOCOMPLETE_INPUT;
+ }
+ }
+
+ /**
+ * @return {string} HTML type for the input.
+ */
+ get _html5InputType() {
+ const type = this.type;
+ if (type === fieldTypes.INT_TYPE) {
+ return 'number';
+ } else if (type === fieldTypes.DATE_TYPE) {
+ return 'date';
+ }
+ return 'text';
+ }
+
+ /**
+ * Reset form values to initial state.
+ */
+ reset() {
+ this.values = _wrapInArray(this.initialValues);
+ }
+
+ /**
+ * Return the values that the user added to this input.
+ * @return {Array<string>}åß
+ */
+ getValuesAdded() {
+ if (!this.values || !this.values.length) return [];
+ return arrayDifference(
+ this.values, this.initialValues, equalsIgnoreCase);
+ }
+
+ /**
+ * Return the values that the userremoved from this input.
+ * @return {Array<string>}
+ */
+ getValuesRemoved() {
+ if (!this.multi && (!this.values || this.values.length > 0)) return [];
+ return arrayDifference(
+ this.initialValues, this.values, equalsIgnoreCase);
+ }
+
+ /**
+ * Syncs form values and fires a change event as the user edits the form.
+ * @param {Event} e
+ * @fires Event#change
+ * @private
+ */
+ _changeHandler(e) {
+ if (e instanceof KeyboardEvent) {
+ if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+ }
+ const input = e.target;
+
+ if (input.getValues) {
+ // <mr-multi-checkbox> support.
+ this.values = input.getValues();
+ } else {
+ // Is a native input element.
+ const value = input.value.trim();
+ this.values = _wrapInArray(value);
+ }
+
+ this.dispatchEvent(new Event('change'));
+ }
+
+ /**
+ * Syncs form values and fires a change event as the user edits the form.
+ * @param {React.SyntheticEvent} _e
+ * @param {string|Array<string>|null} value React autcoomplete form value.
+ * @fires Event#change
+ * @private
+ */
+ _changeHandlerReact(_e, value) {
+ this.values = _wrapInArray(value);
+
+ this.dispatchEvent(new Event('change'));
+ }
+}
+
+/**
+ * Returns the string value for a single field.
+ * @param {Array<string>} arr
+ * @return {string}
+ */
+function _getSingleValue(arr) {
+ return (arr && arr.length) ? arr[0] : '';
+}
+
+/**
+ * Returns the string value for a single field.
+ * @param {Array<string>|string} v
+ * @return {string}
+ */
+function _wrapInArray(v) {
+ if (!v) return [];
+
+ let values = v;
+ if (!Array.isArray(v)) {
+ values = !!v ? [v] : [];
+ }
+ return [...values];
+}
+
+customElements.define('mr-edit-field', MrEditField);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.test.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.test.js
new file mode 100644
index 0000000..a718203
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-field.test.js
@@ -0,0 +1,215 @@
+// 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 userEvent from '@testing-library/user-event';
+
+import {MrEditField} from './mr-edit-field.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+
+import {enterInput} from 'shared/test/helpers.js';
+
+
+let element;
+let input;
+
+xdescribe('mr-edit-field', () => {
+ beforeEach(async () => {
+ element = document.createElement('mr-edit-field');
+ document.body.appendChild(element);
+
+ element.label = 'testInput';
+ await element.updateComplete;
+
+ input = element.querySelector('#testInput');
+ });
+
+ afterEach(async () => {
+ userEvent.clear(input);
+
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrEditField);
+ });
+
+ it('reset input value', async () => {
+ element.initialValues = [];
+ await element.updateComplete;
+
+ enterInput(input, 'jackalope');
+ await element.updateComplete;
+
+ assert.equal(element.value, 'jackalope');
+
+ element.reset();
+ await element.updateComplete;
+
+ assert.equal(element.value, '');
+ });
+
+ it('input updates when initialValues change', async () => {
+ element.initialValues = ['hello'];
+
+ await element.updateComplete;
+
+ assert.equal(element.value, 'hello');
+ });
+
+ it('initial value does not change after value set', async () => {
+ element.initialValues = ['hello'];
+ element.label = 'testInput';
+ await element.updateComplete;
+
+ input = element.querySelector('#testInput');
+
+ enterInput(input, 'world');
+ await element.updateComplete;
+
+ assert.deepEqual(element.initialValues, ['hello']);
+ assert.equal(element.value, 'world');
+ });
+
+ it('value updates when input is updated', async () => {
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, 'world');
+ await element.updateComplete;
+
+ assert.equal(element.value, 'world');
+ });
+
+ it('initial value does not change after user input', async () => {
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, 'jackalope');
+ await element.updateComplete;
+
+ assert.deepEqual(element.initialValues, ['hello']);
+ assert.equal(element.value, 'jackalope');
+ });
+
+ it('get value after user input', async () => {
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, 'jackalope');
+ await element.updateComplete;
+
+ assert.equal(element.value, 'jackalope');
+ });
+
+ it('input value was added', async () => {
+ // Simulate user input.
+ await element.updateComplete;
+
+ enterInput(input, 'jackalope');
+ await element.updateComplete;
+
+ assert.deepEqual(element.getValuesAdded(), ['jackalope']);
+ assert.deepEqual(element.getValuesRemoved(), []);
+ });
+
+ it('input value was removed', async () => {
+ await element.updateComplete;
+
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, '');
+ await element.updateComplete;
+
+ assert.deepEqual(element.getValuesAdded(), []);
+ assert.deepEqual(element.getValuesRemoved(), ['hello']);
+ });
+
+ it('input value was changed', async () => {
+ element.initialValues = ['hello'];
+ await element.updateComplete;
+
+ enterInput(input, 'world');
+ await element.updateComplete;
+
+ assert.deepEqual(element.getValuesAdded(), ['world']);
+ });
+
+ it('edit select updates value when initialValues change', async () => {
+ element.multi = false;
+ element.type = fieldTypes.ENUM_TYPE;
+
+ element.options = [
+ {optionName: 'hello'},
+ {optionName: 'jackalope'},
+ {optionName: 'text'},
+ ];
+
+ element.initialValues = ['hello'];
+
+ await element.updateComplete;
+
+ assert.equal(element.value, 'hello');
+
+ const select = element.querySelector('select');
+ userEvent.selectOptions(select, 'jackalope');
+
+ // User input should not be overridden by the initialValue variable.
+ assert.equal(element.value, 'jackalope');
+ // Initial values should not change based on user input.
+ assert.deepEqual(element.initialValues, ['hello']);
+
+ element.initialValues = ['text'];
+ await element.updateComplete;
+
+ assert.equal(element.value, 'text');
+
+ element.initialValues = [];
+ await element.updateComplete;
+
+ assert.deepEqual(element.value, '');
+ });
+
+ it('multi enum updates value on reset', async () => {
+ element.multi = true;
+ element.type = fieldTypes.ENUM_TYPE;
+ element.options = [
+ {optionName: 'hello'},
+ {optionName: 'world'},
+ {optionName: 'fake'},
+ ];
+
+ await element.updateComplete;
+
+ element.initialValues = ['hello'];
+ element.reset();
+ await element.updateComplete;
+
+ assert.deepEqual(element.values, ['hello']);
+
+ const checkboxes = element.querySelector('mr-multi-checkbox');
+
+ // User checks all boxes.
+ checkboxes._inputRefs.forEach(
+ (checkbox) => {
+ checkbox.checked = true;
+ },
+ );
+ checkboxes._changeHandler();
+
+ await element.updateComplete;
+
+ // User input should not be overridden by the initialValues variable.
+ assert.deepEqual(element.values, ['hello', 'world', 'fake']);
+ // Initial values should not change based on user input.
+ assert.deepEqual(element.initialValues, ['hello']);
+
+ element.initialValues = ['hello', 'world'];
+ element.reset();
+ await element.updateComplete;
+
+ assert.deepEqual(element.values, ['hello', 'world']);
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.js
new file mode 100644
index 0000000..5303c57
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.js
@@ -0,0 +1,183 @@
+// 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 {SHARED_STYLES} from 'shared/shared-styles';
+import './mr-edit-field.js';
+
+/**
+ * `<mr-edit-status>`
+ *
+ * Editing form for either an approval or the overall issue.
+ *
+ */
+export class MrEditStatus extends LitElement {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ width: 100%;
+ }
+ select {
+ width: var(--mr-edit-field-width);
+ padding: var(--mr-edit-field-padding);
+ }
+ .grid-input {
+ margin-top: 8px;
+ display: grid;
+ grid-gap: var(--mr-input-grid-gap);
+ grid-template-columns: auto 1fr;
+ }
+ .grid-input[hidden] {
+ display: none;
+ }
+ label {
+ font-weight: bold;
+ word-wrap: break-word;
+ text-align: left;
+ }
+ #mergedIntoInput {
+ width: 160px;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <select
+ @change=${this._selectChangeHandler}
+ aria-label="Status"
+ id="statusInput"
+ >
+ ${this._statusesGrouped.map((group) => html`
+ <optgroup label=${group.name} ?hidden=${!group.name}>
+ ${group.statuses.map((item) => html`
+ <option
+ value=${item.status}
+ .selected=${this.status === item.status}
+ >
+ ${item.status}
+ ${item.docstring ? `= ${item.docstring}` : ''}
+ </option>
+ `)}
+ </optgroup>
+
+ ${!group.name ? html`
+ ${group.statuses.map((item) => html`
+ <option
+ value=${item.status}
+ .selected=${this.status === item.status}
+ >
+ ${item.status}
+ ${item.docstring ? `= ${item.docstring}` : ''}
+ </option>
+ `)}
+ ` : ''}
+ `)}
+ </select>
+
+ <div class="grid-input" ?hidden=${!this._showMergedInto}>
+ <label for="mergedIntoInput" id="mergedIntoLabel">Merged into:</label>
+ <input
+ id="mergedIntoInput"
+ value=${this.mergedInto || ''}
+ @change=${this._changeHandler}
+ ></input>
+ </div>`;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ initialStatus: {type: String},
+ status: {type: String},
+ statuses: {type: Array},
+ isApproval: {type: Boolean},
+ mergedInto: {type: String},
+ };
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('initialStatus')) {
+ this.status = this.initialStatus;
+ }
+ super.update(changedProperties);
+ }
+
+ get _showMergedInto() {
+ const status = this.status || this.initialStatus;
+ return (status === 'Duplicate');
+ }
+
+ get _statusesGrouped() {
+ const statuses = this.statuses;
+ const isApproval = this.isApproval;
+ if (!statuses) return [];
+ if (isApproval) {
+ return [{statuses: statuses}];
+ }
+ return [
+ {
+ name: 'Open',
+ statuses: statuses.filter((s) => s.meansOpen),
+ },
+ {
+ name: 'Closed',
+ statuses: statuses.filter((s) => !s.meansOpen),
+ },
+ ];
+ }
+
+ async reset() {
+ await this.updateComplete;
+ const mergedIntoInput = this.shadowRoot.querySelector('#mergedIntoInput');
+ if (mergedIntoInput) {
+ mergedIntoInput.value = this.mergedInto || '';
+ }
+ this.status = this.initialStatus;
+ }
+
+ get delta() {
+ const result = {};
+
+ if (this.status !== this.initialStatus) {
+ result['status'] = this.status;
+ }
+
+ if (this._showMergedInto) {
+ const newMergedInto = this.shadowRoot.querySelector(
+ '#mergedIntoInput').value;
+ if (newMergedInto !== this.mergedInto) {
+ result['mergedInto'] = newMergedInto;
+ }
+ } else if (this.initialStatus === 'Duplicate') {
+ result['mergedInto'] = '';
+ }
+
+ return result;
+ }
+
+ _selectChangeHandler(e) {
+ const statusInput = e.target;
+ this.status = statusInput.value;
+ this._changeHandler(e);
+ }
+
+ /**
+ * @param {Event} e
+ * @fires CustomEvent#change
+ * @private
+ */
+ _changeHandler(e) {
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+}
+
+customElements.define('mr-edit-status', MrEditStatus);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.test.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.test.js
new file mode 100644
index 0000000..ffa25e5
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-edit-status.test.js
@@ -0,0 +1,83 @@
+// 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 {MrEditStatus} from './mr-edit-status.js';
+
+
+let element;
+
+describe('mr-edit-status', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-edit-status');
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Old'},
+ {'status': 'Duplicate'},
+ ];
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrEditStatus);
+ });
+
+ it('delta empty when no changes', () => {
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('change status', async () => {
+ element.initialStatus = 'New';
+
+ await element.updateComplete;
+
+ const statusInput = element.shadowRoot.querySelector('select');
+ statusInput.value = 'Old';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {status: 'Old'});
+ });
+
+ it('mark as duplicate', async () => {
+ element.initialStatus = 'New';
+
+ await element.updateComplete;
+
+ const statusInput = element.shadowRoot.querySelector('select');
+ statusInput.value = 'Duplicate';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ element.shadowRoot.querySelector('#mergedIntoInput').value = 'proj:123';
+ assert.deepEqual(element.delta, {
+ status: 'Duplicate',
+ mergedInto: 'proj:123',
+ });
+ });
+
+ it('remove mark as duplicate', async () => {
+ element.initialStatus = 'Duplicate';
+ element.mergedInto = 'chromium:1234';
+
+ await element.updateComplete;
+
+ const statusInput = element.shadowRoot.querySelector('select');
+ statusInput.value = 'New';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ status: 'New',
+ mergedInto: '',
+ });
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.js
new file mode 100644
index 0000000..881cced
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.js
@@ -0,0 +1,96 @@
+// 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';
+
+/**
+ * `<mr-multi-checkbox>`
+ *
+ * A web component for managing values in a set of checkboxes.
+ *
+ */
+export class MrMultiCheckbox extends LitElement {
+ /** @override */
+ static get styles() {
+ return css`
+ input[type="checkbox"] {
+ width: auto;
+ height: auto;
+ }
+ `;
+ }
+
+ /** @override */
+ render() {
+ return html`
+ ${this.options.map((option) => html`
+ <label title=${option.docstring}>
+ <input
+ type="checkbox"
+ name=${this.name}
+ value=${option.optionName}
+ ?checked=${this.values.includes(option.optionName)}
+ @change=${this._changeHandler}
+ />
+ ${option.optionName}
+ </label>
+ `)}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ values: {type: Array},
+ options: {type: Array},
+ _inputRefs: {type: Object},
+ };
+ }
+
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('options')) {
+ this._inputRefs = this.shadowRoot.querySelectorAll('input');
+ }
+
+ if (changedProperties.has('values')) {
+ this.reset();
+ }
+ }
+
+ reset() {
+ this.setValues(this.values);
+ }
+
+ getValues() {
+ if (!this._inputRefs) return;
+ const valueList = [];
+ this._inputRefs.forEach((c) => {
+ if (c.checked) {
+ valueList.push(c.value.trim());
+ }
+ });
+ return valueList;
+ }
+
+ setValues(values) {
+ if (!this._inputRefs) return;
+ this._inputRefs.forEach(
+ (checkbox) => {
+ checkbox.checked = values.includes(checkbox.value);
+ },
+ );
+ }
+
+ /**
+ * @fires CustomEvent#change
+ * @private
+ */
+ _changeHandler() {
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+}
+
+customElements.define('mr-multi-checkbox', MrMultiCheckbox);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.test.js b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.test.js
new file mode 100644
index 0000000..33cce9e
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-field/mr-multi-checkbox.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 {MrMultiCheckbox} from './mr-multi-checkbox.js';
+
+let element;
+
+describe('mr-multi-checkbox', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-multi-checkbox');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrMultiCheckbox);
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js
new file mode 100644
index 0000000..69ef43f
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.js
@@ -0,0 +1,360 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+import debounce from 'debounce';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as ui from 'reducers/ui.js';
+import {arrayToEnglish} from 'shared/helpers.js';
+import './mr-edit-metadata.js';
+import 'shared/typedef.js';
+
+import ClientLogger from 'monitoring/client-logger.js';
+
+const DEBOUNCED_PRESUBMIT_TIME_OUT = 400;
+
+/**
+ * `<mr-edit-issue>`
+ *
+ * Edit form for a single issue. Wraps <mr-edit-metadata>.
+ *
+ */
+export class MrEditIssue extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ const issue = this.issue || {};
+ let blockedOnRefs = issue.blockedOnIssueRefs || [];
+ if (issue.danglingBlockedOnRefs && issue.danglingBlockedOnRefs.length) {
+ blockedOnRefs = blockedOnRefs.concat(issue.danglingBlockedOnRefs);
+ }
+
+ let blockingRefs = issue.blockingIssueRefs || [];
+ if (issue.danglingBlockingRefs && issue.danglingBlockingRefs.length) {
+ blockingRefs = blockingRefs.concat(issue.danglingBlockingRefs);
+ }
+
+ return html`
+ <h2 id="makechanges" class="medium-heading">
+ <a href="#makechanges">Add a comment and make changes</a>
+ </h2>
+ <mr-edit-metadata
+ formName="Issue Edit"
+ .ownerName=${this._ownerDisplayName(this.issue.ownerRef)}
+ .cc=${issue.ccRefs}
+ .status=${issue.statusRef && issue.statusRef.status}
+ .statuses=${this._availableStatuses(this.projectConfig.statusDefs, this.issue.statusRef)}
+ .summary=${issue.summary}
+ .components=${issue.componentRefs}
+ .fieldDefs=${this._fieldDefs}
+ .fieldValues=${issue.fieldValues}
+ .blockedOn=${blockedOnRefs}
+ .blocking=${blockingRefs}
+ .mergedInto=${issue.mergedIntoIssueRef}
+ .labelNames=${this._labelNames}
+ .derivedLabels=${this._derivedLabels}
+ .error=${this.updateError}
+ ?saving=${this.updatingIssue}
+ @save=${this.save}
+ @discard=${this.reset}
+ @change=${this._onChange}
+ ></mr-edit-metadata>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * All comments, including descriptions.
+ */
+ comments: {
+ type: Array,
+ },
+ /**
+ * The issue being updated.
+ */
+ issue: {
+ type: Object,
+ },
+ /**
+ * The issueRef for the currently viewed issue.
+ */
+ issueRef: {
+ type: Object,
+ },
+ /**
+ * The config of the currently viewed project.
+ */
+ projectConfig: {
+ type: Object,
+ },
+ /**
+ * Whether the issue is currently being updated.
+ */
+ updatingIssue: {
+ type: Boolean,
+ },
+ /**
+ * An error response, if one exists.
+ */
+ updateError: {
+ type: String,
+ },
+ /**
+ * Hash from the URL, used to support the 'r' hot key for making changes.
+ */
+ focusId: {
+ type: String,
+ },
+ _fieldDefs: {
+ type: Array,
+ },
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.clientLogger = new ClientLogger('issues');
+ this.updateError = '';
+
+ this.presubmitDebounceTimeOut = DEBOUNCED_PRESUBMIT_TIME_OUT;
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ // Prevent debounced logic from running after the component has been
+ // removed from the UI.
+ if (this._debouncedPresubmit) {
+ this._debouncedPresubmit.clear();
+ }
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issue = issueV0.viewedIssue(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.comments = issueV0.comments(state);
+ this.projectConfig = projectV0.viewedConfig(state);
+ this.updatingIssue = issueV0.requests(state).update.requesting;
+
+ const error = issueV0.requests(state).update.error;
+ this.updateError = error && (error.description || error.message);
+ this.focusId = ui.focusId(state);
+ this._fieldDefs = issueV0.fieldDefs(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (this.focusId && changedProperties.has('focusId')) {
+ // TODO(zhangtiff): Generalize logic to focus elements based on ID
+ // to a reuseable class mixin.
+ if (this.focusId.toLowerCase() === 'makechanges') {
+ this.focus();
+ }
+ }
+
+ if (changedProperties.has('updatingIssue')) {
+ const isUpdating = this.updatingIssue;
+ const wasUpdating = changedProperties.get('updatingIssue');
+
+ // When an issue finishes updating, we want to show a snackbar, record
+ // issue update time metrics, and reset the edit form.
+ if (!isUpdating && wasUpdating) {
+ if (!this.updateError) {
+ this._showCommentAddedSnackbar();
+ // Reset the edit form when a user's action finishes.
+ this.reset();
+ }
+
+ // Record metrics on when the issue editing event finished.
+ if (this.clientLogger.started('issue-update')) {
+ this.clientLogger.logEnd('issue-update', 'computer-time', 120 * 1000);
+ }
+ }
+ }
+ }
+
+ // TODO(crbug.com/monorail/6933): Remove the need for this wrapper.
+ /**
+ * Snows a snackbar telling the user they added a comment to the issue.
+ */
+ _showCommentAddedSnackbar() {
+ store.dispatch(ui.showSnackbar(ui.snackbarNames.ISSUE_COMMENT_ADDED,
+ 'Your comment was added.'));
+ }
+
+ /**
+ * Resets all form fields to their initial values.
+ */
+ reset() {
+ const form = this.querySelector('mr-edit-metadata');
+ if (!form) return;
+ form.reset();
+ }
+
+ /**
+ * Dispatches an action to save issue changes on the server.
+ */
+ async save() {
+ const form = this.querySelector('mr-edit-metadata');
+ if (!form) return;
+
+ const delta = form.delta;
+ if (!allowRemovedRestrictions(delta.labelRefsRemove)) {
+ return;
+ }
+
+ const message = {
+ issueRef: this.issueRef,
+ delta: delta,
+ commentContent: form.getCommentContent(),
+ sendEmail: form.sendEmail,
+ };
+
+ // Add files to message.
+ const uploads = await form.getAttachments();
+
+ if (uploads && uploads.length) {
+ message.uploads = uploads;
+ }
+
+ if (message.commentContent || message.delta || message.uploads) {
+ this.clientLogger.logStart('issue-update', 'computer-time');
+
+ store.dispatch(issueV0.update(message));
+ }
+ }
+
+ /**
+ * Focuses the edit form in response to the 'r' hotkey.
+ */
+ focus() {
+ const editHeader = this.querySelector('#makechanges');
+ editHeader.scrollIntoView();
+
+ const editForm = this.querySelector('mr-edit-metadata');
+ editForm.focus();
+ }
+
+ /**
+ * Turns all LabelRef Objects attached to an issue into an Array of strings
+ * containing only the names of those labels that aren't derived.
+ * @return {Array<string>} Array of label names.
+ */
+ get _labelNames() {
+ if (!this.issue || !this.issue.labelRefs) return [];
+ const labels = this.issue.labelRefs;
+ return labels.filter((l) => !l.isDerived).map((l) => l.label);
+ }
+
+ /**
+ * Finds only the derived labels attached to an issue and returns only
+ * their names.
+ * @return {Array<string>} Array of label names.
+ */
+ get _derivedLabels() {
+ if (!this.issue || !this.issue.labelRefs) return [];
+ const labels = this.issue.labelRefs;
+ return labels.filter((l) => l.isDerived).map((l) => l.label);
+ }
+
+ /**
+ * Gets the displayName of the owner. Only uses the displayName if a
+ * userId also exists in the ref.
+ * @param {UserRef} ownerRef The owner of the issue.
+ * @return {string} The name of the owner for the edited issue.
+ */
+ _ownerDisplayName(ownerRef) {
+ return (ownerRef && ownerRef.userId) ? ownerRef.displayName : '';
+ }
+
+ /**
+ * Dispatches an action against the server to run "issue presubmit", a feature
+ * that warns the user about issue changes that violate configured rules.
+ * @param {Object=} issueDelta Changes currently present in the edit form.
+ * @param {string} commentContent Text the user is inputting for a comment.
+ */
+ _presubmitIssue(issueDelta = {}, commentContent) {
+ // Don't run this functionality if the element has disconnected. Important
+ // for preventing debounced code from running after an element no longer
+ // exists.
+ if (!this.isConnected) return;
+
+ if (Object.keys(issueDelta).length || commentContent) {
+ // TODO(crbug.com/monorail/8638): Make filter rules actually process
+ // the text for comments on the backend.
+ store.dispatch(issueV0.presubmit(this.issueRef, issueDelta));
+ }
+ }
+
+ /**
+ * Form change handler that runs presubmit on the form.
+ * @param {CustomEvent} evt
+ */
+ _onChange(evt) {
+ const {delta, commentContent} = evt.detail || {};
+
+ if (!this._debouncedPresubmit) {
+ this._debouncedPresubmit = debounce(
+ (delta, commentContent) => this._presubmitIssue(delta, commentContent),
+ this.presubmitDebounceTimeOut);
+ }
+ this._debouncedPresubmit(delta, commentContent);
+ }
+
+ /**
+ * Creates the list of statuses that the user sees in the status dropdown.
+ * @param {Array<StatusDef>} statusDefsArg The project configured StatusDefs.
+ * @param {StatusRef} currentStatusRef The status that the issue currently
+ * uses. Note that Monorail supports free text statuses that do not exist in
+ * a project config. Because of this, currentStatusRef may not exist in
+ * statusDefsArg.
+ * @return {Array<StatusRef|StatusDef>} Array of statuses a user can edit this
+ * issue to have.
+ */
+ _availableStatuses(statusDefsArg, currentStatusRef) {
+ let statusDefs = statusDefsArg || [];
+ statusDefs = statusDefs.filter((status) => !status.deprecated);
+ if (!currentStatusRef || statusDefs.find(
+ (status) => status.status === currentStatusRef.status)) {
+ return statusDefs;
+ }
+ return [currentStatusRef, ...statusDefs];
+ }
+}
+
+/**
+ * Asks the user for confirmation when they try to remove retriction labels.
+ * eg. Restrict-View-Google.
+ * @param {Array<LabelRef>} labelRefsRemoved The labels a user is removing
+ * from this issue.
+ * @return {boolean} Whether removing these labels is okay. ie: true if there
+ * are either no restrictions being removed or if the user approved the
+ * removal of the restrictions.
+ */
+export function allowRemovedRestrictions(labelRefsRemoved) {
+ if (!labelRefsRemoved) return true;
+ const removedRestrictions = labelRefsRemoved
+ .map(({label}) => label)
+ .filter((label) => label.toLowerCase().startsWith('restrict-'));
+ const removeRestrictionsMessage =
+ 'You are removing these restrictions:\n' +
+ arrayToEnglish(removedRestrictions) + '\n' +
+ 'This might allow more people to access this issue. Are you sure?';
+ return !removedRestrictions.length || confirm(removeRestrictionsMessage);
+}
+
+customElements.define('mr-edit-issue', MrEditIssue);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.test.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.test.js
new file mode 100644
index 0000000..a3216ca
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-issue.test.js
@@ -0,0 +1,298 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrEditIssue, allowRemovedRestrictions} from './mr-edit-issue.js';
+import {clientLoggerFake} from 'shared/test/fakes.js';
+
+let element;
+let clock;
+
+describe('mr-edit-issue', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-edit-issue');
+ document.body.appendChild(element);
+ sinon.stub(prpcClient, 'call');
+
+ element.clientLogger = clientLoggerFake();
+ clock = sinon.useFakeTimers();
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ prpcClient.call.restore();
+
+ clock.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrEditIssue);
+ });
+
+ it('scrolls into view on #makechanges hash', async () => {
+ await element.updateComplete;
+
+ const header = element.querySelector('#makechanges');
+ sinon.stub(header, 'scrollIntoView');
+
+ element.focusId = 'makechanges';
+ await element.updateComplete;
+
+ assert.isTrue(header.scrollIntoView.calledOnce);
+
+ header.scrollIntoView.restore();
+ });
+
+ it('shows snackbar and resets form when editing finishes', async () => {
+ sinon.stub(element, 'reset');
+ sinon.stub(element, '_showCommentAddedSnackbar');
+
+ element.updatingIssue = true;
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element._showCommentAddedSnackbar);
+ sinon.assert.notCalled(element.reset);
+
+ element.updatingIssue = false;
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element._showCommentAddedSnackbar);
+ sinon.assert.calledOnce(element.reset);
+ });
+
+ it('does not show snackbar or reset form on edit error', async () => {
+ sinon.stub(element, 'reset');
+ sinon.stub(element, '_showCommentAddedSnackbar');
+
+ element.updatingIssue = true;
+ await element.updateComplete;
+
+ element.updateError = 'The save failed';
+ element.updatingIssue = false;
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element._showCommentAddedSnackbar);
+ sinon.assert.notCalled(element.reset);
+ });
+
+ it('shows current status even if not defined for project', async () => {
+ await element.updateComplete;
+
+ const editMetadata = element.querySelector('mr-edit-metadata');
+ assert.deepEqual(editMetadata.statuses, []);
+
+ element.projectConfig = {statusDefs: [
+ {status: 'hello'},
+ {status: 'world'},
+ ]};
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'hello'},
+ {status: 'world'},
+ ]);
+
+ element.issue = {
+ statusRef: {status: 'hello'},
+ };
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'hello'},
+ {status: 'world'},
+ ]);
+
+ element.issue = {
+ statusRef: {status: 'weirdStatus'},
+ };
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'weirdStatus'},
+ {status: 'hello'},
+ {status: 'world'},
+ ]);
+ });
+
+ it('ignores deprecated statuses, unless used on current issue', async () => {
+ await element.updateComplete;
+
+ const editMetadata = element.querySelector('mr-edit-metadata');
+ assert.deepEqual(editMetadata.statuses, []);
+
+ element.projectConfig = {statusDefs: [
+ {status: 'new'},
+ {status: 'accepted', deprecated: false},
+ {status: 'compiling', deprecated: true},
+ ]};
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'new'},
+ {status: 'accepted', deprecated: false},
+ ]);
+
+
+ element.issue = {
+ statusRef: {status: 'compiling'},
+ };
+
+ await editMetadata.updateComplete;
+
+ assert.deepEqual(editMetadata.statuses, [
+ {status: 'compiling'},
+ {status: 'new'},
+ {status: 'accepted', deprecated: false},
+ ]);
+ });
+
+ it('filter out empty or deleted user owners', () => {
+ assert.equal(
+ element._ownerDisplayName({displayName: 'a_deleted_user'}),
+ '');
+ assert.equal(
+ element._ownerDisplayName({
+ displayName: 'test@example.com',
+ userId: '1234',
+ }),
+ 'test@example.com');
+ });
+
+ it('logs issue-update metrics', async () => {
+ await element.updateComplete;
+
+ const editMetadata = element.querySelector('mr-edit-metadata');
+
+ sinon.stub(editMetadata, 'delta').get(() => ({summary: 'test'}));
+
+ await element.save();
+
+ sinon.assert.calledOnce(element.clientLogger.logStart);
+ sinon.assert.calledWith(element.clientLogger.logStart,
+ 'issue-update', 'computer-time');
+
+ // Simulate a response updating the UI.
+ element.issue = {summary: 'test'};
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.clientLogger.logEnd);
+ sinon.assert.calledWith(element.clientLogger.logEnd,
+ 'issue-update', 'computer-time', 120 * 1000);
+ });
+
+ it('presubmits issue on metadata change', async () => {
+ element.issueRef = {};
+
+ await element.updateComplete;
+ const editMetadata = element.querySelector('mr-edit-metadata');
+ editMetadata.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ delta: {
+ summary: 'Summary',
+ },
+ },
+ }));
+
+ // Wait for debouncer.
+ clock.tick(element.presubmitDebounceTimeOut + 1);
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+ 'PresubmitIssue',
+ {issueDelta: {summary: 'Summary'}, issueRef: {}});
+ });
+
+ it('presubmits issue on comment change', async () => {
+ element.issueRef = {};
+
+ await element.updateComplete;
+ const editMetadata = element.querySelector('mr-edit-metadata');
+ editMetadata.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ delta: {},
+ commentContent: 'test',
+ },
+ }));
+
+ // Wait for debouncer.
+ clock.tick(element.presubmitDebounceTimeOut + 1);
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+ 'PresubmitIssue',
+ {issueDelta: {}, issueRef: {}});
+ });
+
+
+ it('does not presubmit issue when no changes', () => {
+ element._presubmitIssue({});
+
+ sinon.assert.notCalled(prpcClient.call);
+ });
+
+ it('editing form runs _presubmitIssue debounced', async () => {
+ sinon.stub(element, '_presubmitIssue');
+
+ await element.updateComplete;
+
+ // User makes some changes.
+ const comment = element.querySelector('#commentText');
+ comment.value = 'Value';
+ comment.dispatchEvent(new Event('keyup'));
+
+ clock.tick(5);
+
+ // User makes more changes before debouncer timeout is done.
+ comment.value = 'more changes';
+ comment.dispatchEvent(new Event('keyup'));
+
+ clock.tick(10);
+
+ sinon.assert.notCalled(element._presubmitIssue);
+
+ // Wait for debouncer.
+ clock.tick(element.presubmitDebounceTimeOut + 1);
+
+ sinon.assert.calledOnce(element._presubmitIssue);
+ });
+});
+
+describe('allowRemovedRestrictions', () => {
+ beforeEach(() => {
+ sinon.stub(window, 'confirm');
+ });
+
+ afterEach(() => {
+ window.confirm.restore();
+ });
+
+ it('returns true if no restrictions removed', () => {
+ assert.isTrue(allowRemovedRestrictions([
+ {label: 'not-restricted'},
+ {label: 'fine'},
+ ]));
+ });
+
+ it('returns false if restrictions removed and confirmation denied', () => {
+ window.confirm.returns(false);
+ assert.isFalse(allowRemovedRestrictions([
+ {label: 'not-restricted'},
+ {label: 'restrict-view-people'},
+ ]));
+ });
+
+ it('returns true if restrictions removed and confirmation accepted', () => {
+ window.confirm.returns(true);
+ assert.isTrue(allowRemovedRestrictions([
+ {label: 'not-restricted'},
+ {label: 'restrict-view-people'},
+ ]));
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js
new file mode 100644
index 0000000..804c8d1
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js
@@ -0,0 +1,1188 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/framework/mr-upload/mr-upload.js';
+import 'elements/framework/mr-star/mr-issue-star.js';
+import 'elements/chops/chops-checkbox/chops-checkbox.js';
+import 'elements/chops/chops-chip/chops-chip.js';
+import 'elements/framework/mr-error/mr-error.js';
+import 'elements/framework/mr-warning/mr-warning.js';
+import 'elements/help/mr-cue/mr-cue.js';
+import 'react/mr-react-autocomplete.tsx';
+import {cueNames} from 'elements/help/mr-cue/cue-helpers.js';
+import {store, connectStore} from 'reducers/base.js';
+import {UserInputError} from 'shared/errors.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+import {displayNameToUserRef, labelStringToRef, componentStringToRef,
+ componentRefsToStrings, issueStringToRef, issueStringToBlockingRef,
+ issueRefToString, issueRefsToStrings, filteredUserDisplayNames,
+ valueToFieldValue, fieldDefToName,
+} from 'shared/convertersV0.js';
+import {arrayDifference, isEmptyObject, equalsIgnoreCase} from 'shared/helpers.js';
+import {NON_EDITING_KEY_EVENTS} from 'shared/dom-helpers.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as permissions from 'reducers/permissions.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as ui from 'reducers/ui.js';
+import '../mr-edit-field/mr-edit-field.js';
+import '../mr-edit-field/mr-edit-status.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_EDIT_SUMMARY_PERMISSION,
+ ISSUE_EDIT_STATUS_PERMISSION, ISSUE_EDIT_OWNER_PERMISSION,
+ ISSUE_EDIT_CC_PERMISSION,
+} from 'shared/consts/permissions.js';
+import {fieldDefsWithGroup, fieldDefsWithoutGroup, valuesForField,
+ HARDCODED_FIELD_GROUPS} from 'shared/metadata-helpers.js';
+import {renderMarkdown, shouldRenderMarkdown} from 'shared/md-helper.js';
+import {unsafeHTML} from 'lit-html/directives/unsafe-html.js';
+import {MD_PREVIEW_STYLES, MD_STYLES} from 'shared/shared-styles.js';
+
+
+
+/**
+ * `<mr-edit-metadata>`
+ *
+ * Editing form for either an approval or the overall issue.
+ *
+ */
+export class MrEditMetadata extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <style>
+ ${MD_PREVIEW_STYLES}
+ ${MD_STYLES}
+ mr-edit-metadata {
+ display: block;
+ font-size: var(--chops-main-font-size);
+ }
+ mr-edit-metadata.edit-actions-right .edit-actions {
+ flex-direction: row-reverse;
+ text-align: right;
+ }
+ mr-edit-metadata.edit-actions-right .edit-actions chops-checkbox {
+ text-align: left;
+ }
+ .edit-actions chops-checkbox {
+ max-width: 200px;
+ margin-top: 2px;
+ flex-grow: 2;
+ text-align: right;
+ }
+ .edit-actions {
+ width: 100%;
+ max-width: 500px;
+ margin: 0.5em 0;
+ text-align: left;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ }
+ .edit-actions chops-button {
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+ .edit-actions .emphasized {
+ margin-left: 0;
+ }
+ input {
+ box-sizing: border-box;
+ width: var(--mr-edit-field-width);
+ padding: var(--mr-edit-field-padding);
+ font-size: var(--chops-main-font-size);
+ }
+ mr-upload {
+ margin-bottom: 0.25em;
+ }
+ textarea {
+ font-family: var(--mr-toggled-font-family);
+ width: 100%;
+ margin: 0.25em 0;
+ box-sizing: border-box;
+ border: var(--chops-accessible-border);
+ height: 8em;
+ transition: height 0.1s ease-in-out;
+ padding: 0.5em 4px;
+ grid-column-start: 1;
+ grid-column-end: 2;
+ }
+ button.toggle {
+ background: none;
+ color: var(--chops-link-color);
+ border: 0;
+ width: 100%;
+ padding: 0.25em 0;
+ text-align: left;
+ }
+ button.toggle:hover {
+ cursor: pointer;
+ text-decoration: underline;
+ }
+ .presubmit-derived {
+ color: gray;
+ font-style: italic;
+ text-decoration-line: underline;
+ text-decoration-style: dotted;
+ }
+ .presubmit-derived-header {
+ color: gray;
+ font-weight: bold;
+ }
+ .discard-button {
+ margin-right: 16px;
+ margin-left: 16px;
+ }
+ .group {
+ width: 100%;
+ border: 1px solid hsl(0, 0%, 83%);
+ grid-column: 1 / -1;
+ margin: 0;
+ margin-bottom: 0.5em;
+ padding: 0;
+ padding-bottom: 0.5em;
+ }
+ .group legend {
+ margin-left: 130px;
+ }
+ .group-title {
+ text-align: center;
+ font-style: oblique;
+ margin-top: 4px;
+ margin-bottom: -8px;
+ }
+ .star-line {
+ display: flex;
+ align-items: center;
+ background: var(--chops-notice-bubble-bg);
+ border: var(--chops-notice-border);
+ justify-content: flex-start;
+ margin-top: 4px;
+ padding: 2px 4px 2px 8px;
+ }
+ mr-issue-star {
+ margin-right: 4px;
+ }
+ </style>
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <form id="editForm"
+ @submit=${this._save}
+ @keydown=${this._saveOnCtrlEnter}
+ >
+ <mr-cue cuePrefName=${cueNames.CODE_OF_CONDUCT}></mr-cue>
+ ${this._renderStarLine()}
+ <textarea
+ id="commentText"
+ placeholder="Add a comment"
+ @keyup=${this._processChanges}
+ aria-label="Comment"
+ ></textarea>
+ ${(this._renderMarkdown)
+ ? html`
+ <div class="markdown-preview preview-height-comment">
+ <div class="markdown">
+ ${unsafeHTML(renderMarkdown(this.getCommentContent()))}
+ </div>
+ </div>`: ''}
+ <mr-upload
+ ?hidden=${this.disableAttachments}
+ @change=${this._processChanges}
+ ></mr-upload>
+ <div class="input-grid">
+ ${this._renderEditFields()}
+ ${this._renderErrorsAndWarnings()}
+
+ <span></span>
+ <div class="edit-actions">
+ <chops-button
+ @click=${this._save}
+ class="save-changes emphasized"
+ ?disabled=${this.disabled}
+ title="Save changes (Ctrl+Enter / \u2318+Enter)"
+ >
+ Save changes
+ </chops-button>
+ <chops-button
+ @click=${this.discard}
+ class="de-emphasized discard-button"
+ ?disabled=${this.disabled}
+ >
+ Discard
+ </chops-button>
+
+ <chops-checkbox
+ id="sendEmail"
+ @checked-change=${this._sendEmailChecked}
+ ?checked=${this.sendEmail}
+ >Send email</chops-checkbox>
+ </div>
+
+ ${!this.isApproval ? this._renderPresubmitChanges() : ''}
+ </div>
+ </form>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderStarLine() {
+ if (this._canEditIssue || this.isApproval) return '';
+
+ return html`
+ <div class="star-line">
+ <mr-issue-star
+ .issueRef=${this.issueRef}
+ ></mr-issue-star>
+ <span>
+ ${this.isStarred ? `
+ You have voted for this issue and will receive notifications.
+ ` : `
+ Star this issue instead of commenting "+1 Me too!" to add a vote
+ and get notifications.`}
+ </span>
+ </div>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderPresubmitChanges() {
+ const {derivedCcs, derivedLabels} = this.presubmitResponse || {};
+ const hasCcs = derivedCcs && derivedCcs.length;
+ const hasLabels = derivedLabels && derivedLabels.length;
+ const hasDerivedValues = hasCcs || hasLabels;
+ return html`
+ ${hasDerivedValues ? html`
+ <span></span>
+ <div class="presubmit-derived-header">
+ Filter rules and components will add
+ </div>
+ ` : ''}
+
+ ${hasCcs? html`
+ <label
+ for="derived-ccs"
+ class="presubmit-derived-header"
+ >CC:</label>
+ <div id="derived-ccs">
+ ${derivedCcs.map((cc) => html`
+ <span
+ title=${cc.why}
+ class="presubmit-derived"
+ >${cc.value}</span>
+ `)}
+ </div>
+ ` : ''}
+
+ ${hasLabels ? html`
+ <label
+ for="derived-labels"
+ class="presubmit-derived-header"
+ >Labels:</label>
+ <div id="derived-labels">
+ ${derivedLabels.map((label) => html`
+ <span
+ title=${label.why}
+ class="presubmit-derived"
+ >${label.value}</span>
+ `)}
+ </div>
+ ` : ''}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderErrorsAndWarnings() {
+ const presubmitResponse = this.presubmitResponse || {};
+ const presubmitWarnings = presubmitResponse.warnings || [];
+ const presubmitErrors = presubmitResponse.errors || [];
+ return (this.error || presubmitWarnings.length || presubmitErrors.length) ?
+ html`
+ <span></span>
+ <div>
+ ${presubmitWarnings.map((warning) => html`
+ <mr-warning title=${warning.why}>${warning.value}</mr-warning>
+ `)}
+ <!-- TODO(ehmaldonado): Look into blocking submission on presubmit
+ -->
+ ${presubmitErrors.map((error) => html`
+ <mr-error title=${error.why}>${error.value}</mr-error>
+ `)}
+ ${this.error ? html`
+ <mr-error>${this.error}</mr-error>` : ''}
+ </div>
+ ` : '';
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderEditFields() {
+ if (this.isApproval) {
+ return html`
+ ${this._renderStatus()}
+ ${this._renderApprovers()}
+ ${this._renderFieldDefs()}
+
+ ${this._renderNicheFieldToggle()}
+ `;
+ }
+
+ return html`
+ ${this._canEditSummary ? this._renderSummary() : ''}
+ ${this._canEditStatus ? this._renderStatus() : ''}
+ ${this._canEditOwner ? this._renderOwner() : ''}
+ ${this._canEditCC ? this._renderCC() : ''}
+ ${this._canEditIssue ? html`
+ ${this._renderComponents()}
+
+ ${this._renderFieldDefs()}
+ ${this._renderRelatedIssues()}
+ ${this._renderLabels()}
+
+ ${this._renderNicheFieldToggle()}
+ ` : ''}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderSummary() {
+ return html`
+ <label for="summaryInput">Summary:</label>
+ <input
+ id="summaryInput"
+ value=${this.summary}
+ @keyup=${this._processChanges}
+ />
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderOwner() {
+ const ownerPresubmit = this._ownerPresubmit;
+ return html`
+ <label for="ownerInput">
+ ${ownerPresubmit.message ? html`
+ <i
+ class=${`material-icons inline-${ownerPresubmit.icon}`}
+ title=${ownerPresubmit.message}
+ >${ownerPresubmit.icon}</i>
+ ` : ''}
+ Owner:
+ </label>
+ <mr-react-autocomplete
+ label="ownerInput"
+ vocabularyName="owner"
+ .placeholder=${ownerPresubmit.placeholder}
+ .value=${this._values.owner}
+ .onChange=${this._changeHandlers.owner}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderCC() {
+ return html`
+ <label for="ccInput">CC:</label>
+ <mr-react-autocomplete
+ label="ccInput"
+ vocabularyName="member"
+ .multiple=${true}
+ .fixedValues=${this._derivedCCs}
+ .value=${this._values.cc}
+ .onChange=${this._changeHandlers.cc}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderComponents() {
+ return html`
+ <label for="componentsInput">Components:</label>
+ <mr-react-autocomplete
+ label="componentsInput"
+ vocabularyName="component"
+ .multiple=${true}
+ .value=${this._values.components}
+ .onChange=${this._changeHandlers.components}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderApprovers() {
+ return this.hasApproverPrivileges && this.isApproval ? html`
+ <label for="approversInput_react">Approvers:</label>
+ <mr-edit-field
+ id="approversInput"
+ label="approversInput_react"
+ .type=${'USER_TYPE'}
+ .initialValues=${filteredUserDisplayNames(this.approvers)}
+ .name=${'approver'}
+ .acType=${'member'}
+ @change=${this._processChanges}
+ multi
+ ></mr-edit-field>
+ ` : '';
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderStatus() {
+ return this.statuses && this.statuses.length ? html`
+ <label for="statusInput">Status:</label>
+
+ <mr-edit-status
+ id="statusInput"
+ .initialStatus=${this.status}
+ .statuses=${this.statuses}
+ .mergedInto=${issueRefToString(this.mergedInto, this.projectName)}
+ ?isApproval=${this.isApproval}
+ @change=${this._processChanges}
+ ></mr-edit-status>
+ ` : '';
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderFieldDefs() {
+ return html`
+ ${fieldDefsWithGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((group) => html`
+ <fieldset class="group">
+ <legend>${group.groupName}</legend>
+ <div class="input-grid">
+ ${group.fieldDefs.map((field) => this._renderCustomField(field))}
+ </div>
+ </fieldset>
+ `)}
+
+ ${fieldDefsWithoutGroup(this.fieldDefs, this.fieldGroups, this.issueType).map((field) => this._renderCustomField(field))}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderRelatedIssues() {
+ return html`
+ <label for="blockedOnInput">BlockedOn:</label>
+ <mr-react-autocomplete
+ label="blockedOnInput"
+ vocabularyName="component"
+ .multiple=${true}
+ .value=${this._values.blockedOn}
+ .onChange=${this._changeHandlers.blockedOn}
+ ></mr-react-autocomplete>
+
+ <label for="blockingInput">Blocking:</label>
+ <mr-react-autocomplete
+ label="blockingInput"
+ vocabularyName="component"
+ .multiple=${true}
+ .value=${this._values.blocking}
+ .onChange=${this._changeHandlers.blocking}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderLabels() {
+ return html`
+ <label for="labelsInput">Labels:</label>
+ <mr-react-autocomplete
+ label="labelsInput"
+ vocabularyName="label"
+ .multiple=${true}
+ .fixedValues=${this.derivedLabels}
+ .value=${this._values.labels}
+ .onChange=${this._changeHandlers.labels}
+ ></mr-react-autocomplete>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @param {FieldDef} field The custom field beinf rendered.
+ * @private
+ */
+ _renderCustomField(field) {
+ if (!field || !field.fieldRef) return '';
+ const userCanEdit = this._userCanEdit(field);
+ const {fieldRef, isNiche, docstring, isMultivalued} = field;
+ const isHidden = (!this.showNicheFields && isNiche) || !userCanEdit;
+
+ let acType;
+ if (fieldRef.type === fieldTypes.USER_TYPE) {
+ acType = isMultivalued ? 'member' : 'owner';
+ }
+ return html`
+ <label
+ ?hidden=${isHidden}
+ for=${this._idForField(fieldRef.fieldName) + '_react'}
+ title=${docstring}
+ >
+ ${fieldRef.fieldName}:
+ </label>
+ <mr-edit-field
+ ?hidden=${isHidden}
+ id=${this._idForField(fieldRef.fieldName)}
+ .label=${this._idForField(fieldRef.fieldName) + '_react'}
+ .name=${fieldRef.fieldName}
+ .type=${fieldRef.type}
+ .options=${this._optionsForField(this.optionsPerEnumField, this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
+ .initialValues=${valuesForField(this.fieldValueMap, fieldRef.fieldName, this.phaseName)}
+ .acType=${acType}
+ ?multi=${isMultivalued}
+ @change=${this._processChanges}
+ ></mr-edit-field>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderNicheFieldToggle() {
+ return this._nicheFieldCount ? html`
+ <span></span>
+ <button type="button" class="toggle" @click=${this.toggleNicheFields}>
+ <span ?hidden=${this.showNicheFields}>
+ Show all fields (${this._nicheFieldCount} currently hidden)
+ </span>
+ <span ?hidden=${!this.showNicheFields}>
+ Hide niche fields (${this._nicheFieldCount} currently shown)
+ </span>
+ </button>
+ ` : '';
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ fieldDefs: {type: Array},
+ formName: {type: String},
+ approvers: {type: Array},
+ setter: {type: Object},
+ summary: {type: String},
+ cc: {type: Array},
+ components: {type: Array},
+ status: {type: String},
+ statuses: {type: Array},
+ blockedOn: {type: Array},
+ blocking: {type: Array},
+ mergedInto: {type: Object},
+ ownerName: {type: String},
+ labelNames: {type: Array},
+ derivedLabels: {type: Array},
+ _permissions: {type: Array},
+ phaseName: {type: String},
+ projectConfig: {type: Object},
+ projectName: {type: String},
+ isApproval: {type: Boolean},
+ isStarred: {type: Boolean},
+ issuePermissions: {type: Object},
+ issueRef: {type: Object},
+ hasApproverPrivileges: {type: Boolean},
+ showNicheFields: {type: Boolean},
+ disableAttachments: {type: Boolean},
+ error: {type: String},
+ sendEmail: {type: Boolean},
+ presubmitResponse: {type: Object},
+ fieldValueMap: {type: Object},
+ issueType: {type: String},
+ optionsPerEnumField: {type: String},
+ fieldGroups: {type: Object},
+ prefs: {type: Object},
+ saving: {type: Boolean},
+ isDirty: {type: Boolean},
+ _values: {type: Object},
+ _initialValues: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.summary = '';
+ this.ownerName = '';
+ this.sendEmail = true;
+ this.mergedInto = {};
+ this.issueRef = {};
+ this.fieldGroups = HARDCODED_FIELD_GROUPS;
+
+ this._permissions = {};
+ this.saving = false;
+ this.isDirty = false;
+ this.prefs = {};
+ this._values = {};
+ this._initialValues = {};
+
+ // Memoize change handlers so property updates don't cause excess rerenders.
+ this._changeHandlers = {
+ owner: this._onChange.bind(this, 'owner'),
+ cc: this._onChange.bind(this, 'cc'),
+ components: this._onChange.bind(this, 'components'),
+ labels: this._onChange.bind(this, 'labels'),
+ blockedOn: this._onChange.bind(this, 'blockedOn'),
+ blocking: this._onChange.bind(this, 'blocking'),
+ };
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ firstUpdated() {
+ this.hasRendered = true;
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('ownerName') || changedProperties.has('cc')
+ || changedProperties.has('components')
+ || changedProperties.has('labelNames')
+ || changedProperties.has('blockedOn')
+ || changedProperties.has('blocking')
+ || changedProperties.has('projectName')) {
+ this._initialValues.owner = this.ownerName;
+ this._initialValues.cc = this._ccNames;
+ this._initialValues.components = componentRefsToStrings(this.components);
+ this._initialValues.labels = this.labelNames;
+ this._initialValues.blockedOn = issueRefsToStrings(this.blockedOn, this.projectName);
+ this._initialValues.blocking = issueRefsToStrings(this.blocking, this.projectName);
+
+ this._values = {...this._initialValues};
+ }
+ }
+
+ /**
+ * Getter for checking if the user has Markdown enabled.
+ * @return {boolean} Whether Markdown preview should be rendered or not.
+ */
+ get _renderMarkdown() {
+ if (!this.getCommentContent()) {
+ return false;
+ }
+ const enabled = this.prefs.get('render_markdown');
+ return shouldRenderMarkdown({project: this.projectName, enabled});
+ }
+
+ /**
+ * @return {boolean} Whether the "Save changes" button is disabled.
+ */
+ get disabled() {
+ return !this.isDirty || this.saving;
+ }
+
+ /**
+ * Set isDirty to a property instead of only using a getter to cause
+ * lit-element to re-render when dirty state change.
+ */
+ _updateIsDirty() {
+ if (!this.hasRendered) return;
+
+ const commentContent = this.getCommentContent();
+ const attachmentsElement = this.querySelector('mr-upload');
+ this.isDirty = !isEmptyObject(this.delta) || Boolean(commentContent) ||
+ attachmentsElement.hasAttachments;
+ }
+
+ get _nicheFieldCount() {
+ const fieldDefs = this.fieldDefs || [];
+ return fieldDefs.reduce((acc, fd) => acc + (fd.isNiche | 0), 0);
+ }
+
+ get _canEditIssue() {
+ const issuePermissions = this.issuePermissions || [];
+ return issuePermissions.includes(ISSUE_EDIT_PERMISSION);
+ }
+
+ get _canEditSummary() {
+ const issuePermissions = this.issuePermissions || [];
+ return this._canEditIssue ||
+ issuePermissions.includes(ISSUE_EDIT_SUMMARY_PERMISSION);
+ }
+
+ get _canEditStatus() {
+ const issuePermissions = this.issuePermissions || [];
+ return this._canEditIssue ||
+ issuePermissions.includes(ISSUE_EDIT_STATUS_PERMISSION);
+ }
+
+ get _canEditOwner() {
+ const issuePermissions = this.issuePermissions || [];
+ return this._canEditIssue ||
+ issuePermissions.includes(ISSUE_EDIT_OWNER_PERMISSION);
+ }
+
+ get _canEditCC() {
+ const issuePermissions = this.issuePermissions || [];
+ return this._canEditIssue ||
+ issuePermissions.includes(ISSUE_EDIT_CC_PERMISSION);
+ }
+
+ /**
+ * @return {Array<string>}
+ */
+ get _ccNames() {
+ const users = this.cc || [];
+ return filteredUserDisplayNames(users.filter((u) => !u.isDerived));
+ }
+
+ get _derivedCCs() {
+ const users = this.cc || [];
+ return filteredUserDisplayNames(users.filter((u) => u.isDerived));
+ }
+
+ get _ownerPresubmit() {
+ const response = this.presubmitResponse;
+ if (!response) return {};
+
+ const ownerView = {message: '', placeholder: '', icon: ''};
+
+ if (response.ownerAvailability) {
+ ownerView.message = response.ownerAvailability;
+ ownerView.icon = 'warning';
+ } else if (response.derivedOwners && response.derivedOwners.length) {
+ ownerView.placeholder = response.derivedOwners[0].value;
+ ownerView.message = response.derivedOwners[0].why;
+ ownerView.icon = 'info';
+ }
+ return ownerView;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.fieldValueMap = issueV0.fieldValueMap(state);
+ this.issueType = issueV0.type(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this._permissions = permissions.byName(state);
+ this.presubmitResponse = issueV0.presubmitResponse(state);
+ this.projectConfig = projectV0.viewedConfig(state);
+ this.projectName = issueV0.viewedIssueRef(state).projectName;
+ this.issuePermissions = issueV0.permissions(state);
+ this.optionsPerEnumField = projectV0.optionsPerEnumField(state);
+ // Access boolean value from allStarredIssues
+ const starredIssues = issueV0.starredIssues(state);
+ this.isStarred = starredIssues.has(issueRefToString(this.issueRef));
+ this.prefs = userV0.prefs(state);
+ }
+
+ /** @override */
+ disconnectedCallback() {
+ super.disconnectedCallback();
+
+ store.dispatch(ui.reportDirtyForm(this.formName, false));
+ }
+
+ /**
+ * Resets the edit form values to their default values.
+ */
+ async reset() {
+ this._values = {...this._initialValues};
+
+ const form = this.querySelector('#editForm');
+ if (!form) return;
+
+ form.reset();
+ const statusInput = this.querySelector('#statusInput');
+ if (statusInput) {
+ statusInput.reset();
+ }
+
+ // Since custom elements containing <input> elements have the inputs
+ // wrapped in ShadowDOM, those inputs don't get reset with the rest of
+ // the form. Haven't been able to figure out a way to replicate form reset
+ // behavior with custom input elements.
+ if (this.isApproval) {
+ if (this.hasApproverPrivileges) {
+ const approversInput = this.querySelector(
+ '#approversInput');
+ if (approversInput) {
+ approversInput.reset();
+ }
+ }
+ }
+ this.querySelectorAll('mr-edit-field').forEach((el) => {
+ el.reset();
+ });
+
+ const uploader = this.querySelector('mr-upload');
+ if (uploader) {
+ uploader.reset();
+ }
+
+ // TODO(dtu, zhangtiff): Remove once all form fields are controlled.
+ await this.updateComplete;
+
+ this._processChanges();
+ }
+
+ /**
+ * @param {MouseEvent|SubmitEvent} event
+ * @private
+ */
+ _save(event) {
+ event.preventDefault();
+ this.save();
+ }
+
+ /**
+ * Users may use either Ctrl+Enter or Command+Enter to save an issue edit
+ * while the issue edit form is focused.
+ * @param {KeyboardEvent} event
+ * @private
+ */
+ _saveOnCtrlEnter(event) {
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+ event.preventDefault();
+ this.save();
+ }
+ }
+
+ /**
+ * Tells the parent to save the current edited values in the form.
+ * @fires CustomEvent#save
+ */
+ save() {
+ this.dispatchEvent(new CustomEvent('save'));
+ }
+
+ /**
+ * Tells the parent component that the user is trying to discard the form,
+ * if they confirm that that's what they're doing. The parent decides what
+ * to do in order to quit the editing session.
+ * @fires CustomEvent#discard
+ */
+ discard() {
+ const isDirty = this.isDirty;
+ if (!isDirty || confirm('Discard your changes?')) {
+ this.dispatchEvent(new CustomEvent('discard'));
+ }
+ }
+
+ /**
+ * Focuses the comment form.
+ */
+ async focus() {
+ await this.updateComplete;
+ this.querySelector('#commentText').focus();
+ }
+
+ /**
+ * Retrieves the value of the comment that the user added from the DOM.
+ * @return {string}
+ */
+ getCommentContent() {
+ if (!this.querySelector('#commentText')) {
+ return '';
+ }
+ return this.querySelector('#commentText').value;
+ }
+
+ async getAttachments() {
+ try {
+ return await this.querySelector('mr-upload').loadFiles();
+ } catch (e) {
+ this.error = `Error while loading file for attachment: ${e.message}`;
+ }
+ }
+
+ /**
+ * @param {FieldDef} field
+ * @return {boolean}
+ * @private
+ */
+ _userCanEdit(field) {
+ const fieldName = fieldDefToName(this.projectName, field);
+ if (!this._permissions[fieldName] ||
+ !this._permissions[fieldName].permissions) return false;
+ const userPerms = this._permissions[fieldName].permissions;
+ return userPerms.includes(permissions.FIELD_DEF_VALUE_EDIT);
+ }
+
+ /**
+ * Shows or hides custom fields with the "isNiche" attribute set to true.
+ */
+ toggleNicheFields() {
+ this.showNicheFields = !this.showNicheFields;
+ }
+
+ /**
+ * @return {IssueDelta}
+ * @throws {UserInputError}
+ */
+ get delta() {
+ try {
+ this.error = '';
+ return this._getDelta();
+ } catch (e) {
+ if (!(e instanceof UserInputError)) throw e;
+ this.error = e.message;
+ return {};
+ }
+ }
+
+ /**
+ * Generates a change between the initial Issue state and what the user
+ * inputted.
+ * @return {IssueDelta}
+ */
+ _getDelta() {
+ let result = {};
+
+ const {projectName, localId} = this.issueRef;
+
+ const statusInput = this.querySelector('#statusInput');
+ if (this._canEditStatus && statusInput) {
+ const statusDelta = statusInput.delta;
+ if (statusDelta.mergedInto) {
+ result.mergedIntoRef = issueStringToBlockingRef(
+ {projectName, localId}, statusDelta.mergedInto);
+ }
+ if (statusDelta.status) {
+ result.status = statusDelta.status;
+ }
+ }
+
+ if (this.isApproval) {
+ if (this._canEditIssue && this.hasApproverPrivileges) {
+ result = {
+ ...result,
+ ...this._changedValuesDom(
+ 'approvers', 'approverRefs', displayNameToUserRef),
+ };
+ }
+ } else {
+ // TODO(zhangtiff): Consider representing baked-in fields such as owner,
+ // cc, and status similarly to custom fields to reduce repeated code.
+
+ if (this._canEditSummary) {
+ const summaryInput = this.querySelector('#summaryInput');
+ if (summaryInput) {
+ const newSummary = summaryInput.value;
+ if (newSummary !== this.summary) {
+ result.summary = newSummary;
+ }
+ }
+ }
+
+ if (this._values.owner !== this._initialValues.owner) {
+ result.ownerRef = displayNameToUserRef(this._values.owner);
+ }
+
+ const blockerAddFn = (refString) =>
+ issueStringToBlockingRef({projectName, localId}, refString);
+ const blockerRemoveFn = (refString) =>
+ issueStringToRef(refString, projectName);
+
+ result = {
+ ...result,
+ ...this._changedValuesControlled(
+ 'cc', 'ccRefs', displayNameToUserRef),
+ ...this._changedValuesControlled(
+ 'components', 'compRefs', componentStringToRef),
+ ...this._changedValuesControlled(
+ 'labels', 'labelRefs', labelStringToRef),
+ ...this._changedValuesControlled(
+ 'blockedOn', 'blockedOnRefs', blockerAddFn, blockerRemoveFn),
+ ...this._changedValuesControlled(
+ 'blocking', 'blockingRefs', blockerAddFn, blockerRemoveFn),
+ };
+ }
+
+ if (this._canEditIssue) {
+ const fieldDefs = this.fieldDefs || [];
+ fieldDefs.forEach(({fieldRef}) => {
+ const {fieldValsAdd = [], fieldValsRemove = []} =
+ this._changedValuesDom(fieldRef.fieldName, 'fieldVals',
+ valueToFieldValue.bind(null, fieldRef));
+
+ // Because multiple custom fields share the same "fieldVals" key in
+ // delta, we hav to make sure to concatenate updated delta values with
+ // old delta values.
+ if (fieldValsAdd.length) {
+ result.fieldValsAdd = [...(result.fieldValsAdd || []),
+ ...fieldValsAdd];
+ }
+
+ if (fieldValsRemove.length) {
+ result.fieldValsRemove = [...(result.fieldValsRemove || []),
+ ...fieldValsRemove];
+ }
+ });
+ }
+
+ return result;
+ }
+
+ /**
+ * Computes delta values for a controlled input.
+ * @param {string} fieldName The key in the values property to retrieve data.
+ * from.
+ * @param {string} responseKey The key in the delta Object that changes will be
+ * saved in.
+ * @param {function(string): any} addFn A function to specify how to format
+ * the message for a given added field.
+ * @param {function(string): any} removeFn A function to specify how to format
+ * the message for a given removed field.
+ * @return {Object} delta fragment for added and removed values.
+ */
+ _changedValuesControlled(fieldName, responseKey, addFn, removeFn) {
+ const values = this._values[fieldName];
+ const initialValues = this._initialValues[fieldName];
+
+ const valuesAdd = arrayDifference(values, initialValues, equalsIgnoreCase);
+ const valuesRemove =
+ arrayDifference(initialValues, values, equalsIgnoreCase);
+
+ return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
+ }
+
+ /**
+ * Gets changes values when reading from a legacy <mr-edit-field> element.
+ * @param {string} fieldName Name of the form input we're checking values on.
+ * @param {string} responseKey The key in the delta Object that changes will be
+ * saved in.
+ * @param {function(string): any} addFn A function to specify how to format
+ * the message for a given added field.
+ * @param {function(string): any} removeFn A function to specify how to format
+ * the message for a given removed field.
+ * @return {Object} delta fragment for added and removed values.
+ */
+ _changedValuesDom(fieldName, responseKey, addFn, removeFn) {
+ const input = this.querySelector(`#${this._idForField(fieldName)}`);
+ if (!input) return;
+
+ const valuesAdd = input.getValuesAdded();
+ const valuesRemove = input.getValuesRemoved();
+
+ return this._changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn);
+ }
+
+ /**
+ * Shared helper function for computing added and removed values for a
+ * single field in a delta.
+ * @param {Array<string>} valuesAdd The added values. For example, new CCed
+ * users.
+ * @param {Array<string>} valuesRemove Values that were removed in this edit.
+ * @param {string} responseKey The key in the delta Object that changes will be
+ * saved in.
+ * @param {function(string): any} addFn A function to specify how to format
+ * the message for a given added field.
+ * @param {function(string): any} removeFn A function to specify how to format
+ * the message for a given removed field.
+ * @return {Object} delta fragment for added and removed values.
+ */
+ _changedValues(valuesAdd, valuesRemove, responseKey, addFn, removeFn) {
+ const delta = {};
+
+ if (valuesAdd && valuesAdd.length) {
+ delta[responseKey + 'Add'] = valuesAdd.map(addFn);
+ }
+
+ if (valuesRemove && valuesRemove.length) {
+ delta[responseKey + 'Remove'] = valuesRemove.map(removeFn || addFn);
+ }
+
+ return delta;
+ }
+
+ /**
+ * Generic onChange handler to be bound to each form field.
+ * @param {string} key Unique name for the form field we're binding this
+ * handler to. For example, 'owner', 'cc', or the name of a custom field.
+ * @param {Event} event
+ * @param {string|Array<string>} value The new form value.
+ * @param {*} _reason
+ */
+ _onChange(key, event, value, _reason) {
+ this._values = {...this._values, [key]: value};
+ this._processChanges(event);
+ }
+
+ /**
+ * Event handler for running filter rules presubmit logic.
+ * @param {Event} e
+ */
+ _processChanges(e) {
+ if (e instanceof KeyboardEvent) {
+ if (NON_EDITING_KEY_EVENTS.has(e.key)) return;
+ }
+ this._updateIsDirty();
+
+ store.dispatch(ui.reportDirtyForm(this.formName, this.isDirty));
+
+ this.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ delta: this.delta,
+ commentContent: this.getCommentContent(),
+ },
+ }));
+ }
+
+ _idForField(name) {
+ return `${name}Input`;
+ }
+
+ _optionsForField(optionsPerEnumField, fieldValueMap, fieldName, phaseName) {
+ if (!optionsPerEnumField || !fieldName) return [];
+ const key = fieldName.toLowerCase();
+ if (!optionsPerEnumField.has(key)) return [];
+ const options = [...optionsPerEnumField.get(key)];
+ const values = valuesForField(fieldValueMap, fieldName, phaseName);
+ values.forEach((v) => {
+ const optionExists = options.find(
+ (opt) => equalsIgnoreCase(opt.optionName, v));
+ if (!optionExists) {
+ // Note that enum fields which are not explicitly defined can be set,
+ // such as in the case when an issue is moved.
+ options.push({optionName: v});
+ }
+ });
+ return options;
+ }
+
+ _sendEmailChecked(evt) {
+ this.sendEmail = evt.detail.checked;
+ }
+}
+
+customElements.define('mr-edit-metadata', MrEditMetadata);
diff --git a/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.test.js b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.test.js
new file mode 100644
index 0000000..2e4554f
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.test.js
@@ -0,0 +1,1078 @@
+// 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 {fireEvent} from '@testing-library/react';
+
+import {MrEditMetadata} from './mr-edit-metadata.js';
+import {ISSUE_EDIT_PERMISSION, ISSUE_EDIT_SUMMARY_PERMISSION,
+ ISSUE_EDIT_STATUS_PERMISSION, ISSUE_EDIT_OWNER_PERMISSION,
+ ISSUE_EDIT_CC_PERMISSION,
+} from 'shared/consts/permissions.js';
+import {FIELD_DEF_VALUE_EDIT} from 'reducers/permissions.js';
+import {store, resetState} from 'reducers/base.js';
+import {enterInput} from 'shared/test/helpers.js';
+
+let element;
+
+xdescribe('mr-edit-metadata', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+ element = document.createElement('mr-edit-metadata');
+ document.body.appendChild(element);
+
+ element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+
+ sinon.stub(store, 'dispatch');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ store.dispatch.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrEditMetadata);
+ });
+
+ describe('updated sets initial values', () => {
+ it('updates owner', async () => {
+ element.ownerName = 'goose@bird.org';
+ await element.updateComplete;
+
+ assert.equal(element._values.owner, 'goose@bird.org');
+ });
+
+ it('updates cc', async () => {
+ element.cc = [
+ {displayName: 'initial-cc@bird.org', userId: '1234'},
+ ];
+ await element.updateComplete;
+
+ assert.deepEqual(element._values.cc, ['initial-cc@bird.org']);
+ });
+
+ it('updates components', async () => {
+ element.components = [{path: 'Hello>World'}];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element._values.components, ['Hello>World']);
+ });
+
+ it('updates labels', async () => {
+ element.labelNames = ['test-label'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element._values.labels, ['test-label']);
+ });
+ });
+
+ describe('saves edit form', () => {
+ let saveStub;
+
+ beforeEach(() => {
+ saveStub = sinon.stub();
+ element.addEventListener('save', saveStub);
+ });
+
+ it('saves on form submit', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new Event('submit', {bubbles: true, cancelable: true}));
+
+ sinon.assert.calledOnce(saveStub);
+ });
+
+ it('saves when clicking the save button', async () => {
+ await element.updateComplete;
+
+ element.querySelector('.save-changes').click();
+
+ sinon.assert.calledOnce(saveStub);
+ });
+
+ it('does not save on random keydowns', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'a', ctrlKey: true}));
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'b', ctrlKey: false}));
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'c', metaKey: true}));
+
+ sinon.assert.notCalled(saveStub);
+ });
+
+ it('does not save on Enter without Ctrl', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'Enter', ctrlKey: false}));
+
+ sinon.assert.notCalled(saveStub);
+ });
+
+ it('saves on Ctrl+Enter', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'Enter', ctrlKey: true}));
+
+ sinon.assert.calledOnce(saveStub);
+ });
+
+ it('saves on Ctrl+Meta', async () => {
+ await element.updateComplete;
+
+ element.querySelector('#editForm').dispatchEvent(
+ new KeyboardEvent('keydown', {key: 'Enter', metaKey: true}));
+
+ sinon.assert.calledOnce(saveStub);
+ });
+ });
+
+ it('disconnecting element reports form is not dirty', () => {
+ element.formName = 'test';
+
+ assert.isFalse(store.dispatch.calledOnce);
+
+ document.body.removeChild(element);
+
+ assert.isTrue(store.dispatch.calledOnce);
+ sinon.assert.calledWith(
+ store.dispatch,
+ {
+ type: 'REPORT_DIRTY_FORM',
+ name: 'test',
+ isDirty: false,
+ },
+ );
+
+ document.body.appendChild(element);
+ });
+
+ it('_processChanges fires change event', async () => {
+ await element.updateComplete;
+
+ const changeStub = sinon.stub();
+ element.addEventListener('change', changeStub);
+
+ element._processChanges();
+
+ sinon.assert.calledOnce(changeStub);
+ });
+
+ it('save button disabled when disabled is true', async () => {
+ // Check that save button is initially disabled.
+ await element.updateComplete;
+
+ const button = element.querySelector('.save-changes');
+
+ assert.isTrue(element.disabled);
+ assert.isTrue(button.disabled);
+
+ element.isDirty = true;
+
+ await element.updateComplete;
+
+ assert.isFalse(element.disabled);
+ assert.isFalse(button.disabled);
+ });
+
+ it('editing form sets isDirty to true or false', async () => {
+ await element.updateComplete;
+
+ assert.isFalse(element.isDirty);
+
+ // User makes some changes.
+ const comment = element.querySelector('#commentText');
+ comment.value = 'Value';
+ comment.dispatchEvent(new Event('keyup'));
+
+ assert.isTrue(element.isDirty);
+
+ // User undoes the changes.
+ comment.value = '';
+ comment.dispatchEvent(new Event('keyup'));
+
+ assert.isFalse(element.isDirty);
+ });
+
+ it('reseting form disables save button', async () => {
+ // Check that save button is initially disabled.
+ assert.isTrue(element.disabled);
+
+ // User makes some changes.
+ element.isDirty = true;
+
+ // Check that save button is not disabled.
+ assert.isFalse(element.disabled);
+
+ // Reset form.
+ await element.updateComplete;
+ await element.reset();
+
+ // Check that save button is still disabled.
+ assert.isTrue(element.disabled);
+ });
+
+ it('save button is enabled if request fails', async () => {
+ // Check that save button is initially disabled.
+ assert.isTrue(element.disabled);
+
+ // User makes some changes.
+ element.isDirty = true;
+
+ // Check that save button is not disabled.
+ assert.isFalse(element.disabled);
+
+ // User submits the change.
+ element.saving = true;
+
+ // Check that save button is disabled.
+ assert.isTrue(element.disabled);
+
+ // Request fails.
+ element.saving = false;
+ element.error = 'error';
+
+ // Check that save button is re-enabled.
+ assert.isFalse(element.disabled);
+ });
+
+ it('delta empty when no changes', async () => {
+ await element.updateComplete;
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('toggling checkbox toggles sendEmail', async () => {
+ element.sendEmail = false;
+
+ await element.updateComplete;
+ const checkbox = element.querySelector('#sendEmail');
+
+ await checkbox.updateComplete;
+
+ checkbox.click();
+ await element.updateComplete;
+
+ assert.equal(checkbox.checked, true);
+ assert.equal(element.sendEmail, true);
+
+ checkbox.click();
+ await element.updateComplete;
+
+ assert.equal(checkbox.checked, false);
+ assert.equal(element.sendEmail, false);
+
+ checkbox.click();
+ await element.updateComplete;
+
+ assert.equal(checkbox.checked, true);
+ assert.equal(element.sendEmail, true);
+ });
+
+ it('changing status produces delta change (lit-element)', async () => {
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Old'},
+ {'status': 'Test'},
+ ];
+ element.status = 'New';
+
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ statusComponent.status = 'Old';
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ status: 'Old',
+ });
+ });
+
+ it('changing owner produces delta change (React)', async () => {
+ element.ownerName = 'initial-owner@bird.org';
+ await element.updateComplete;
+
+ const input = element.querySelector('#ownerInput');
+ enterInput(input, 'new-owner@bird.org');
+ await element.updateComplete;
+
+ const expected = {ownerRef: {displayName: 'new-owner@bird.org'}};
+ assert.deepEqual(element.delta, expected);
+ });
+
+ it('adding CC produces delta change (React)', async () => {
+ element.cc = [
+ {displayName: 'initial-cc@bird.org', userId: '1234'},
+ ];
+
+ await element.updateComplete;
+
+ const input = element.querySelector('#ccInput');
+ enterInput(input, 'another@bird.org');
+ await element.updateComplete;
+
+ const expected = {
+ ccRefsAdd: [{displayName: 'another@bird.org'}],
+ ccRefsRemove: [{displayName: 'initial-cc@bird.org'}],
+ };
+ assert.deepEqual(element.delta, expected);
+ });
+
+ it('invalid status throws', async () => {
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Old'},
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ statusComponent.shadowRoot.querySelector('#mergedIntoInput').value = 'xx';
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ 'Invalid issue ref: xx. Expected [projectName:]issueId.');
+ });
+
+ it('cannot block an issue on itself', async () => {
+ element.projectName = 'proj';
+ element.issueRef = {projectName: 'proj', localId: 123};
+
+ await element.updateComplete;
+
+ for (const fieldName of ['blockedOn', 'blocking']) {
+ const input =
+ element.querySelector(`#${fieldName}Input`);
+ enterInput(input, '123');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid issue ref: 123. Cannot merge or block an issue on itself.`);
+ fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+
+ enterInput(input, 'proj:123');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid issue ref: proj:123. ` +
+ 'Cannot merge or block an issue on itself.');
+ fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+
+ enterInput(input, 'proj2:123');
+ await element.updateComplete;
+
+ assert.notDeepEqual(element.delta, {});
+ assert.equal(element.error, '');
+
+ fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+ }
+ });
+
+ it('cannot merge an issue into itself', async () => {
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'New';
+ element.projectName = 'proj';
+ element.issueRef = {projectName: 'proj', localId: 123};
+
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ const root = statusComponent.shadowRoot;
+ const statusInput = root.querySelector('#statusInput');
+ statusInput.value = 'Duplicate';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ root.querySelector('#mergedIntoInput').value = 'proj:123';
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid issue ref: proj:123. Cannot merge or block an issue on itself.`);
+
+ root.querySelector('#mergedIntoInput').value = '123';
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid issue ref: 123. Cannot merge or block an issue on itself.`);
+
+ root.querySelector('#mergedIntoInput').value = 'proj2:123';
+ assert.notDeepEqual(element.delta, {});
+ assert.equal(element.error, '');
+ });
+
+ it('cannot set invalid emails', async () => {
+ await element.updateComplete;
+
+ const ccInput = element.querySelector('#ccInput');
+ enterInput(ccInput, 'invalid!email');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid email address: invalid!email`);
+
+ const input = element.querySelector('#ownerInput');
+ enterInput(input, 'invalid!email2');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ assert.equal(
+ element.error,
+ `Invalid email address: invalid!email2`);
+ });
+
+ it('can remove invalid values', async () => {
+ element.projectName = 'proj';
+ element.issueRef = {projectName: 'proj', localId: 123};
+
+ element.statuses = [
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+ element.mergedInto = element.issueRef;
+
+ element.blockedOn = [element.issueRef];
+ element.blocking = [element.issueRef];
+
+ await element.updateComplete;
+
+ const blockedOnInput = element.querySelector('#blockedOnInput');
+ const blockingInput = element.querySelector('#blockingInput');
+ const statusInput = element.querySelector('#statusInput');
+
+ await element.updateComplete;
+
+ const mergedIntoInput =
+ statusInput.shadowRoot.querySelector('#mergedIntoInput');
+
+ fireEvent.keyDown(blockedOnInput, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+ fireEvent.keyDown(blockingInput, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+ mergedIntoInput.value = 'proj:124';
+ await element.updateComplete;
+
+ assert.deepEqual(
+ element.delta,
+ {
+ blockedOnRefsRemove: [{projectName: 'proj', localId: 123}],
+ blockingRefsRemove: [{projectName: 'proj', localId: 123}],
+ mergedIntoRef: {projectName: 'proj', localId: 124},
+ });
+ assert.equal(element.error, '');
+ });
+
+ it('not changing status produces no delta', async () => {
+ element.statuses = [
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+
+ element.mergedInto = {
+ projectName: 'chromium',
+ localId: 1234,
+ };
+
+ element.projectName = 'chromium';
+
+ await element.updateComplete;
+ await element.updateComplete; // Merged input updates its value.
+
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('changing status to duplicate produces delta change', async () => {
+ element.statuses = [
+ {'status': 'New'},
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'New';
+
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector(
+ '#statusInput');
+ const root = statusComponent.shadowRoot;
+ const statusInput = root.querySelector('#statusInput');
+ statusInput.value = 'Duplicate';
+ statusInput.dispatchEvent(new Event('change'));
+
+ await element.updateComplete;
+
+ root.querySelector('#mergedIntoInput').value = 'chromium:1234';
+ assert.deepEqual(element.delta, {
+ status: 'Duplicate',
+ mergedIntoRef: {
+ projectName: 'chromium',
+ localId: 1234,
+ },
+ });
+ });
+
+ it('changing summary produces delta change', async () => {
+ element.summary = 'Old summary';
+
+ await element.updateComplete;
+
+ element.querySelector(
+ '#summaryInput').value = 'newfangled fancy summary';
+ assert.deepEqual(element.delta, {
+ summary: 'newfangled fancy summary',
+ });
+ });
+
+ it('custom fields the user cannot edit should be hidden', async () => {
+ element.projectName = 'proj';
+ const fieldName = 'projects/proj/fieldDefs/1';
+ const restrictedFieldName = 'projects/proj/fieldDefs/2';
+ element._permissions = {
+ [fieldName]: {permissions: [FIELD_DEF_VALUE_EDIT]},
+ [restrictedFieldName]: {permissions: []}};
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'normalFd',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ },
+ {
+ fieldRef: {
+ fieldName: 'cantEditFd',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ },
+ ];
+
+ await element.updateComplete;
+ assert.isFalse(element.querySelector('#normalFdInput').hidden);
+ assert.isTrue(element.querySelector('#cantEditFdInput').hidden);
+ });
+
+ it('changing enum custom fields produces delta', async () => {
+ element.fieldValueMap = new Map([['fakefield', ['prev value']]]);
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'testField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ },
+ {
+ fieldRef: {
+ fieldName: 'fakeField',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ },
+ ];
+
+ await element.updateComplete;
+
+ const input1 = element.querySelector('#testFieldInput');
+ const input2 = element.querySelector('#fakeFieldInput');
+
+ input1.values = ['test value'];
+ input2.values = [];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ fieldValsAdd: [
+ {
+ fieldRef: {
+ fieldName: 'testField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ value: 'test value',
+ },
+ ],
+ fieldValsRemove: [
+ {
+ fieldRef: {
+ fieldName: 'fakeField',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ value: 'prev value',
+ },
+ ],
+ });
+ });
+
+ it('changing approvers produces delta', async () => {
+ element.isApproval = true;
+ element.hasApproverPrivileges = true;
+ element.approvers = [
+ {displayName: 'foo@example.com', userId: '1'},
+ {displayName: 'bar@example.com', userId: '2'},
+ {displayName: 'baz@example.com', userId: '3'},
+ ];
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ element.querySelector('#approversInput').values =
+ ['chicken@example.com', 'foo@example.com', 'dog@example.com'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ approverRefsAdd: [
+ {displayName: 'chicken@example.com'},
+ {displayName: 'dog@example.com'},
+ ],
+ approverRefsRemove: [
+ {displayName: 'bar@example.com'},
+ {displayName: 'baz@example.com'},
+ ],
+ });
+ });
+
+ it('changing blockedon produces delta change (React)', async () => {
+ element.blockedOn = [
+ {projectName: 'chromium', localId: '1234'},
+ {projectName: 'monorail', localId: '4567'},
+ ];
+ element.projectName = 'chromium';
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ const input = element.querySelector('#blockedOnInput');
+
+ fireEvent.keyDown(input, {key: 'Backspace', code: 'Backspace'});
+ await element.updateComplete;
+
+ enterInput(input, 'v8:5678');
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ blockedOnRefsAdd: [{
+ projectName: 'v8',
+ localId: 5678,
+ }],
+ blockedOnRefsRemove: [{
+ projectName: 'monorail',
+ localId: 4567,
+ }],
+ });
+ });
+
+ it('_optionsForField computes options', () => {
+ const optionsPerEnumField = new Map([
+ ['enumfield', [{optionName: 'one'}, {optionName: 'two'}]],
+ ]);
+ assert.deepEqual(
+ element._optionsForField(optionsPerEnumField, new Map(), 'enumField'), [
+ {
+ optionName: 'one',
+ },
+ {
+ optionName: 'two',
+ },
+ ]);
+ });
+
+ it('changing enum fields produces delta', async () => {
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ isMultivalued: true,
+ },
+ ];
+
+ element.optionsPerEnumField = new Map([
+ ['enumfield', [{optionName: 'one'}, {optionName: 'two'}]],
+ ]);
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ element.querySelector(
+ '#enumFieldInput').values = ['one', 'two'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ fieldValsAdd: [
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ value: 'one',
+ },
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ value: 'two',
+ },
+ ],
+ });
+ });
+
+ it('changing multiple single valued enum fields', async () => {
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ },
+ {
+ fieldRef: {
+ fieldName: 'enumField2',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ },
+ ];
+
+ element.optionsPerEnumField = new Map([
+ ['enumfield', [{optionName: 'one'}, {optionName: 'two'}]],
+ ['enumfield2', [{optionName: 'three'}, {optionName: 'four'}]],
+ ]);
+
+ await element.updateComplete;
+
+ element.querySelector('#enumFieldInput').values = ['two'];
+ element.querySelector('#enumField2Input').values = ['three'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ fieldValsAdd: [
+ {
+ fieldRef: {
+ fieldName: 'enumField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ value: 'two',
+ },
+ {
+ fieldRef: {
+ fieldName: 'enumField2',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ value: 'three',
+ },
+ ],
+ });
+ });
+
+ it('adding components produces delta', async () => {
+ await element.updateComplete;
+
+ element.isApproval = false;
+ element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+
+ element.components = [];
+
+ await element.updateComplete;
+
+ element._values.components = ['Hello>World'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ compRefsAdd: [
+ {path: 'Hello>World'},
+ ],
+ });
+
+ element._values.components = ['Hello>World', 'Test', 'Multi'];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ compRefsAdd: [
+ {path: 'Hello>World'},
+ {path: 'Test'},
+ {path: 'Multi'},
+ ],
+ });
+
+ element._values.components = [];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('removing components produces delta', async () => {
+ await element.updateComplete;
+
+ element.isApproval = false;
+ element.issuePermissions = [ISSUE_EDIT_PERMISSION];
+
+ element.components = [{path: 'Hello>World'}];
+
+ await element.updateComplete;
+
+ element._values.components = [];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element.delta, {
+ compRefsRemove: [
+ {path: 'Hello>World'},
+ ],
+ });
+ });
+
+ it('approver input appears when user has privileges', async () => {
+ assert.isNull(element.querySelector('#approversInput'));
+ element.isApproval = true;
+ element.hasApproverPrivileges = true;
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.querySelector('#approversInput'));
+ });
+
+ it('reset sets controlled values to default', async () => {
+ element.ownerName = 'burb@bird.com';
+ element.cc = [
+ {displayName: 'flamingo@bird.com', userId: '1234'},
+ {displayName: 'penguin@bird.com', userId: '5678'},
+ ];
+ element.components = [{path: 'Bird>Penguin'}];
+ element.labelNames = ['chickadee-chirp'];
+ element.blockedOn = [{localId: 1234, projectName: 'project'}];
+ element.blocking = [{localId: 5678, projectName: 'other-project'}];
+ element.projectName = 'project';
+
+ // Update cycle is needed because <mr-edit-metadata> initializes
+ // this.values in updated().
+ await element.updateComplete;
+
+ const initialValues = {
+ owner: 'burb@bird.com',
+ cc: ['flamingo@bird.com', 'penguin@bird.com'],
+ components: ['Bird>Penguin'],
+ labels: ['chickadee-chirp'],
+ blockedOn: ['1234'],
+ blocking: ['other-project:5678'],
+ };
+
+ assert.deepEqual(element._values, initialValues);
+
+ element._values = {
+ owner: 'newburb@hello.com',
+ cc: ['noburbs@wings.com'],
+ };
+ element.reset();
+
+ assert.deepEqual(element._values, initialValues);
+ })
+
+ it('reset empties form values', async () => {
+ element.fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'testField',
+ fieldId: 1,
+ type: 'ENUM_TYPE',
+ },
+ },
+ {
+ fieldRef: {
+ fieldName: 'fakeField',
+ fieldId: 2,
+ type: 'ENUM_TYPE',
+ },
+ },
+ ];
+
+ await element.updateComplete;
+
+ const uploader = element.querySelector('mr-upload');
+ uploader.files = [
+ {name: 'test.png'},
+ {name: 'rutabaga.png'},
+ ];
+
+ element.querySelector('#testFieldInput').values = 'testy test';
+ element.querySelector('#fakeFieldInput').values = 'hello world';
+
+ await element.reset();
+
+ assert.lengthOf(element.querySelector('#testFieldInput').value, 0);
+ assert.lengthOf(element.querySelector('#fakeFieldInput').value, 0);
+ assert.lengthOf(uploader.files, 0);
+ });
+
+ it('reset results in empty delta', async () => {
+ element.ownerName = 'goose@bird.org';
+ await element.updateComplete;
+
+ element._values.owner = 'penguin@bird.org';
+ const expected = {ownerRef: {displayName: 'penguin@bird.org'}};
+ assert.deepEqual(element.delta, expected);
+
+ await element.reset();
+ assert.deepEqual(element.delta, {});
+ });
+
+ it('edit issue permissions', async () => {
+ const allFields = ['summary', 'status', 'owner', 'cc'];
+ const testCases = [
+ {permissions: [], nonNull: []},
+ {permissions: [ISSUE_EDIT_PERMISSION], nonNull: allFields},
+ {permissions: [ISSUE_EDIT_SUMMARY_PERMISSION], nonNull: ['summary']},
+ {permissions: [ISSUE_EDIT_STATUS_PERMISSION], nonNull: ['status']},
+ {permissions: [ISSUE_EDIT_OWNER_PERMISSION], nonNull: ['owner']},
+ {permissions: [ISSUE_EDIT_CC_PERMISSION], nonNull: ['cc']},
+ ];
+ element.statuses = [{'status': 'Foo'}];
+
+ for (const testCase of testCases) {
+ element.issuePermissions = testCase.permissions;
+ await element.updateComplete;
+
+ allFields.forEach((fieldName) => {
+ const field = element.querySelector(`#${fieldName}Input`);
+ if (testCase.nonNull.includes(fieldName)) {
+ assert.isNotNull(field);
+ } else {
+ assert.isNull(field);
+ }
+ });
+ }
+ });
+
+ it('duplicate issue is rendered correctly', async () => {
+ element.statuses = [
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+ element.projectName = 'chromium';
+ element.mergedInto = {
+ projectName: 'chromium',
+ localId: 1234,
+ };
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ const root = statusComponent.shadowRoot;
+ assert.equal(
+ root.querySelector('#mergedIntoInput').value, '1234');
+ });
+
+ it('duplicate issue on different project is rendered correctly', async () => {
+ element.statuses = [
+ {'status': 'Duplicate'},
+ ];
+ element.status = 'Duplicate';
+ element.projectName = 'chromium';
+ element.mergedInto = {
+ projectName: 'monorail',
+ localId: 1234,
+ };
+
+ await element.updateComplete;
+ await element.updateComplete;
+
+ const statusComponent = element.querySelector('#statusInput');
+ const root = statusComponent.shadowRoot;
+ assert.equal(
+ root.querySelector('#mergedIntoInput').value, 'monorail:1234');
+ });
+
+ it('filter out deleted users', async () => {
+ element.cc = [
+ {displayName: 'test@example.com', userId: '1234'},
+ {displayName: 'a_deleted_user'},
+ {displayName: 'someone@example.com', userId: '5678'},
+ ];
+
+ await element.updateComplete;
+
+ assert.deepEqual(element._values.cc, [
+ 'test@example.com',
+ 'someone@example.com',
+ ]);
+ });
+
+ it('renders valid markdown description with preview', async () => {
+ await element.updateComplete;
+
+ element.prefs = new Map([['render_markdown', true]]);
+ element.projectName = 'monkeyrail';
+ sinon.stub(element, 'getCommentContent').returns('# h1');
+
+ await element.updateComplete;
+
+ assert.isTrue(element._renderMarkdown);
+
+ const previewMarkdown = element.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.prefs = new Map([['render_markdown', false]]);
+ element.projectName = 'monkeyrail';
+ sinon.stub(element, 'getCommentContent').returns('# h1');
+
+ await element.updateComplete;
+
+ const previewMarkdown = element.querySelector('.markdown-preview');
+ assert.isNull(previewMarkdown);
+ });
+
+ it('does not show preview when no input', async () => {
+ element.prefs = new Map([['render_markdown', true]]);
+ element.projectName = 'monkeyrail';
+ sinon.stub(element, 'getCommentContent').returns('');
+
+ await element.updateComplete;
+
+ const previewMarkdown = element.querySelector('.markdown-preview');
+ assert.isNull(previewMarkdown);
+ });
+});
+
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.js
new file mode 100644
index 0000000..ba68c39
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.js
@@ -0,0 +1,58 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import {fieldTypes, EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {displayNameToUserRef} from 'shared/convertersV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-field-values>`
+ *
+ * Takes in a list of field values and a single fieldDef and displays them
+ * according to their type.
+ *
+ */
+export class MrFieldValues extends LitElement {
+ /** @override */
+ static get styles() {
+ return SHARED_STYLES;
+ }
+
+ /** @override */
+ render() {
+ if (!this.values || !this.values.length) {
+ return html`${EMPTY_FIELD_VALUE}`;
+ }
+ switch (this.type) {
+ case fieldTypes.URL_TYPE:
+ return html`${this.values.map((value) => html`
+ <a href=${value} target="_blank" rel="nofollow">${value}</a>
+ `)}`;
+ case fieldTypes.USER_TYPE:
+ return html`${this.values.map((value) => html`
+ <mr-user-link .userRef=${displayNameToUserRef(value)}></mr-user-link>
+ `)}`;
+ default:
+ return html`${this.values.map((value, i) => html`
+ <a href="/p/${this.projectName}/issues/list?q=${this.name}="${value}"">
+ ${value}</a>${this.values.length - 1 > i ? ', ' : ''}
+ `)}`;
+ }
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ name: {type: String},
+ type: {type: Object},
+ projectName: {type: String},
+ values: {type: Array},
+ };
+ }
+}
+
+customElements.define('mr-field-values', MrFieldValues);
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.test.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.test.js
new file mode 100644
index 0000000..e334841
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-field-values.test.js
@@ -0,0 +1,86 @@
+// 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 {MrFieldValues} from './mr-field-values.js';
+
+import {fieldTypes} from 'shared/issue-fields.js';
+
+
+let element;
+
+describe('mr-field-values', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-field-values');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrFieldValues);
+ });
+
+ it('renders empty if no values', async () => {
+ element.values = [];
+
+ await element.updateComplete;
+
+ assert.equal('----', element.shadowRoot.textContent.trim());
+ });
+
+ it('renders user links when type is user', async () => {
+ element.type = fieldTypes.USER_TYPE;
+ element.values = ['test@example.com', 'hello@world.com'];
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('mr-user-link');
+
+ await links.updateComplete;
+
+ assert.equal(2, links.length);
+ assert.include(links[0].shadowRoot.textContent, 'test@example.com');
+ assert.include(links[1].shadowRoot.textContent, 'hello@world.com');
+ });
+
+ it('renders URLs when type is url', async () => {
+ element.type = fieldTypes.URL_TYPE;
+ element.values = ['http://hello.world', 'go/link'];
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('a');
+
+ assert.equal(2, links.length);
+ assert.include(links[0].textContent, 'http://hello.world');
+ assert.include(links[0].href, 'http://hello.world');
+ assert.include(links[1].textContent, 'go/link');
+ assert.include(links[1].href, 'go/link');
+ });
+
+ it('renders generic field when field is string', async () => {
+ element.type = fieldTypes.STR_TYPE;
+ element.values = ['blah', 'random value', 'nothing here'];
+ element.name = 'fieldName';
+ element.projectName = 'project';
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('a');
+
+ assert.equal(3, links.length);
+ assert.include(links[0].textContent, 'blah');
+ assert.include(links[0].href,
+ '/p/project/issues/list?q=fieldName=%22blah%22');
+ assert.include(links[1].textContent, 'random value');
+ assert.include(links[1].href,
+ '/p/project/issues/list?q=fieldName=%22random%20value%22');
+ assert.include(links[2].textContent, 'nothing here');
+ assert.include(links[2].href,
+ '/p/project/issues/list?q=fieldName=%22nothing%20here%22');
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.js
new file mode 100644
index 0000000..60d570c
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.js
@@ -0,0 +1,352 @@
+// 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 projectV0 from 'reducers/projectV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import 'elements/framework/mr-star/mr-issue-star.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/framework/links/mr-hotlist-link/mr-hotlist-link.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {pluralize} from 'shared/helpers.js';
+import './mr-metadata.js';
+
+
+/**
+ * `<mr-issue-metadata>`
+ *
+ * The metadata view for a single issue. Contains information such as the owner.
+ *
+ */
+export class MrIssueMetadata extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ box-sizing: border-box;
+ padding: 0.25em 8px;
+ max-width: 100%;
+ display: block;
+ }
+ h3 {
+ display: block;
+ font-size: var(--chops-main-font-size);
+ margin: 0;
+ line-height: 160%;
+ width: 40%;
+ height: 100%;
+ overflow: ellipsis;
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+ a.label {
+ color: hsl(120, 100%, 25%);
+ text-decoration: none;
+ }
+ a.label[data-derived] {
+ font-style: italic;
+ }
+ button.linkify {
+ display: flex;
+ align-items: center;
+ text-decoration: none;
+ padding: 0.25em 0;
+ }
+ button.linkify i.material-icons {
+ margin-right: 4px;
+ font-size: var(--chops-icon-font-size);
+ }
+ mr-hotlist-link {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ display: block;
+ width: 100%;
+ }
+ .bottom-section-cell, .labels-container {
+ padding: 0.5em 4px;
+ width: 100%;
+ box-sizing: border-box;
+ }
+ .bottom-section-cell {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: flex-start;
+ }
+ .bottom-section-content {
+ max-width: 60%;
+ }
+ .star-line {
+ width: 100%;
+ text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+ mr-issue-star {
+ margin-right: 4px;
+ padding-bottom: 2px;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ const hotlistsByRole = this._hotlistsByRole;
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <div class="star-line">
+ <mr-issue-star
+ .issueRef=${this.issueRef}
+ ></mr-issue-star>
+ Starred by ${this.issue.starCount || 0} ${pluralize(this.issue.starCount, 'user')}
+ </div>
+ <mr-metadata
+ aria-label="Issue Metadata"
+ .owner=${this.issue.ownerRef}
+ .cc=${this.issue.ccRefs}
+ .issueStatus=${this.issue.statusRef}
+ .components=${this._components}
+ .fieldDefs=${this._fieldDefs}
+ .mergedInto=${this.mergedInto}
+ .modifiedTimestamp=${this.issue.modifiedTimestamp}
+ ></mr-metadata>
+
+ <div class="labels-container">
+ ${this.issue.labelRefs && this.issue.labelRefs.map((label) => html`
+ <a
+ title="${_labelTitle(this.labelDefMap, label)}"
+ href="/p/${this.issueRef.projectName}/issues/list?q=label:${label.label}"
+ class="label"
+ ?data-derived=${label.isDerived}
+ >${label.label}</a>
+ <br>
+ `)}
+ </div>
+
+ ${this.sortedBlockedOn.length ? html`
+ <div class="bottom-section-cell">
+ <h3>BlockedOn:</h3>
+ <div class="bottom-section-content">
+ ${this.sortedBlockedOn.map((issue) => html`
+ <mr-issue-link
+ .projectName=${this.issueRef.projectName}
+ .issue=${issue}
+ >
+ </mr-issue-link>
+ <br />
+ `)}
+ <button
+ class="linkify"
+ @click=${this.openViewBlockedOn}
+ >
+ <i class="material-icons" role="presentation">list</i>
+ View details
+ </button>
+ </div>
+ </div>
+ `: ''}
+
+ ${this.blocking.length ? html`
+ <div class="bottom-section-cell">
+ <h3>Blocking:</h3>
+ <div class="bottom-section-content">
+ ${this.blocking.map((issue) => html`
+ <mr-issue-link
+ .projectName=${this.issueRef.projectName}
+ .issue=${issue}
+ >
+ </mr-issue-link>
+ <br />
+ `)}
+ </div>
+ </div>
+ `: ''}
+
+ ${this._userId ? html`
+ <div class="bottom-section-cell">
+ <h3>Your Hotlists:</h3>
+ <div class="bottom-section-content" id="user-hotlists">
+ ${this._renderHotlists(hotlistsByRole.user)}
+ <button
+ class="linkify"
+ @click=${this.openUpdateHotlists}
+ >
+ <i class="material-icons" role="presentation">create</i> Update your hotlists
+ </button>
+ </div>
+ </div>
+ `: ''}
+
+ ${hotlistsByRole.participants.length ? html`
+ <div class="bottom-section-cell">
+ <h3>Participant's Hotlists:</h3>
+ <div class="bottom-section-content">
+ ${this._renderHotlists(hotlistsByRole.participants)}
+ </div>
+ </div>
+ ` : ''}
+
+ ${hotlistsByRole.others.length ? html`
+ <div class="bottom-section-cell">
+ <h3>Other Hotlists:</h3>
+ <div class="bottom-section-content">
+ ${this._renderHotlists(hotlistsByRole.others)}
+ </div>
+ </div>
+ ` : ''}
+ `;
+ }
+
+ /**
+ * Helper to render hotlists.
+ * @param {Array<Hotlist>} hotlists
+ * @return {Array<TemplateResult>}
+ * @private
+ */
+ _renderHotlists(hotlists) {
+ return hotlists.map((hotlist) => html`
+ <mr-hotlist-link .hotlist=${hotlist}></mr-hotlist-link>
+ `);
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issue: {type: Object},
+ issueRef: {type: Object},
+ projectConfig: String,
+ user: {type: Object},
+ issueHotlists: {type: Array},
+ blocking: {type: Array},
+ sortedBlockedOn: {type: Array},
+ relatedIssues: {type: Object},
+ labelDefMap: {type: Object},
+ _components: {type: Array},
+ _fieldDefs: {type: Array},
+ _type: {type: String},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issue = issueV0.viewedIssue(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.user = userV0.currentUser(state);
+ this.projectConfig = projectV0.viewedConfig(state);
+ this.blocking = issueV0.blockingIssues(state);
+ this.sortedBlockedOn = issueV0.sortedBlockedOn(state);
+ this.mergedInto = issueV0.mergedInto(state);
+ this.relatedIssues = issueV0.relatedIssues(state);
+ this.issueHotlists = issueV0.hotlists(state);
+ this.labelDefMap = projectV0.labelDefMap(state);
+ this._components = issueV0.components(state);
+ this._fieldDefs = issueV0.fieldDefs(state);
+ this._type = issueV0.type(state);
+ }
+
+ /**
+ * @return {string|number} The current user's userId.
+ * @private
+ */
+ get _userId() {
+ return this.user && this.user.userId;
+ }
+
+ /**
+ * @return {Object<string, Array<Hotlist>>}
+ * @private
+ */
+ get _hotlistsByRole() {
+ const issueHotlists = this.issueHotlists;
+ const owner = this.issue && this.issue.ownerRef;
+ const cc = this.issue && this.issue.ccRefs;
+
+ const hotlists = {
+ user: [],
+ participants: [],
+ others: [],
+ };
+ (issueHotlists || []).forEach((hotlist) => {
+ if (hotlist.ownerRef.userId === this._userId) {
+ hotlists.user.push(hotlist);
+ } else if (_userIsParticipant(hotlist.ownerRef, owner, cc)) {
+ hotlists.participants.push(hotlist);
+ } else {
+ hotlists.others.push(hotlist);
+ }
+ });
+ return hotlists;
+ }
+
+ /**
+ * Opens dialog for updating ths issue's hotlists.
+ * @fires CustomEvent#open-dialog
+ */
+ openUpdateHotlists() {
+ this.dispatchEvent(new CustomEvent('open-dialog', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ dialogId: 'update-issue-hotlists',
+ },
+ }));
+ }
+
+ /**
+ * Opens dialog with detailed view of blocked on issues.
+ * @fires CustomEvent#open-dialog
+ */
+ openViewBlockedOn() {
+ this.dispatchEvent(new CustomEvent('open-dialog', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ dialogId: 'reorder-related-issues',
+ },
+ }));
+ }
+}
+
+/**
+ * @param {UserRef} user
+ * @param {UserRef} owner
+ * @param {Array<UserRef>} cc
+ * @return {boolean} Whether a given user is a participant of
+ * a given hotlist attached to an issue. Used to sort hotlists into
+ * "My hotlists" and "Other hotlists".
+ * @private
+ */
+function _userIsParticipant(user, owner, cc) {
+ if (owner && owner.userId === user.userId) {
+ return true;
+ }
+ return cc && cc.some((ccUser) => ccUser && ccUser.userId === user.userId);
+}
+
+/**
+ * @param {Map.<string, LabelDef>} labelDefMap
+ * @param {LabelDef} label
+ * @return {string} Tooltip shown to the user when hovering over a
+ * given label.
+ * @private
+ */
+function _labelTitle(labelDefMap, label) {
+ if (!label) return '';
+ let docstring = '';
+ const key = label.label.toLowerCase();
+ if (labelDefMap && labelDefMap.has(key)) {
+ docstring = labelDefMap.get(key).docstring;
+ }
+ return (label.isDerived ? 'Derived: ' : '') + label.label +
+ (docstring ? ` = ${docstring}` : '');
+}
+
+customElements.define('mr-issue-metadata', MrIssueMetadata);
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.test.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.test.js
new file mode 100644
index 0000000..c328057
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-issue-metadata.test.js
@@ -0,0 +1,60 @@
+// 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 {MrIssueMetadata} from './mr-issue-metadata.js';
+
+let element;
+
+describe('mr-issue-metadata', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-issue-metadata');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueMetadata);
+ });
+
+ it('labels render', async () => {
+ element.issue = {
+ labelRefs: [
+ {label: 'test'},
+ {label: 'hello-world', isDerived: true},
+ ],
+ };
+
+ element.labelDefMap = new Map([
+ ['test', {label: 'test', docstring: 'this is a docstring'}],
+ ]);
+
+ await element.updateComplete;
+
+ const labels = element.shadowRoot.querySelectorAll('.label');
+
+ assert.equal(labels.length, 2);
+ assert.equal(labels[0].textContent.trim(), 'test');
+ assert.equal(labels[0].getAttribute('title'), 'test = this is a docstring');
+ assert.isUndefined(labels[0].dataset.derived);
+
+ assert.equal(labels[1].textContent.trim(), 'hello-world');
+ assert.equal(labels[1].getAttribute('title'), 'Derived: hello-world');
+ assert.isDefined(labels[1].dataset.derived);
+ });
+
+ it('update hotlist button is shown to users', async () => {
+ element.user = {userId: 1234};
+ await element.updateComplete;
+ assert.isNotNull(element.shadowRoot.querySelector('#user-hotlists'));
+ });
+
+ it('update hotlist button is not shown to anon', async () => {
+ await element.updateComplete;
+ assert.isNull(element.shadowRoot.querySelector('#user-hotlists'));
+ });
+});
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.js
new file mode 100644
index 0000000..0ce172d
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.js
@@ -0,0 +1,357 @@
+// 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 'elements/chops/chops-timestamp/chops-timestamp.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/framework/mr-issue-slo/mr-issue-slo.js';
+
+import * as issueV0 from 'reducers/issueV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as userV0 from 'reducers/userV0.js';
+import './mr-field-values.js';
+import {isExperimentEnabled, SLO_EXPERIMENT} from 'shared/experiments.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {HARDCODED_FIELD_GROUPS, valuesForField, fieldDefsWithGroup,
+ fieldDefsWithoutGroup} from 'shared/metadata-helpers.js';
+import 'shared/typedef.js';
+import {AVAILABLE_CUES, cueNames, specToCueName,
+ cueNameToSpec} from 'elements/help/mr-cue/cue-helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+
+/**
+ * `<mr-metadata>`
+ *
+ * Generalized metadata components, used for either approvals or issues.
+ *
+ */
+export class MrMetadata extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: table;
+ table-layout: fixed;
+ width: 100%;
+ }
+ td, th {
+ padding: 0.5em 4px;
+ vertical-align: top;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ td {
+ width: 60%;
+ }
+ td.allow-overflow {
+ overflow: visible;
+ }
+ th {
+ text-align: left;
+ width: 40%;
+ }
+ .group-separator {
+ border-top: var(--chops-normal-border);
+ }
+ .group-title {
+ font-weight: normal;
+ font-style: oblique;
+ border-bottom: var(--chops-normal-border);
+ text-align: center;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ ${this._renderBuiltInFields()}
+ ${this._renderCustomFieldGroups()}
+ `;
+ }
+
+ /**
+ * Helper for handling the rendering of built in fields.
+ * @return {Array<TemplateResult>}
+ */
+ _renderBuiltInFields() {
+ return this.builtInFieldSpec.map((fieldName) => {
+ const fieldKey = fieldName.toLowerCase();
+
+ // Adding classes to table rows based on field names makes selecting
+ // rows with specific values easier, for example in tests.
+ let className = `row-${fieldKey}`;
+
+ const cueName = specToCueName(fieldKey);
+ if (cueName) {
+ className = `cue-${cueName}`;
+
+ if (!AVAILABLE_CUES.has(cueName)) return '';
+
+ return html`
+ <tr class=${className}>
+ <td colspan="2">
+ <mr-cue cuePrefName=${cueName}></mr-cue>
+ </td>
+ </tr>
+ `;
+ }
+
+ const isApprovalStatus = fieldKey === 'approvalstatus';
+ const isMergedInto = fieldKey === 'mergedinto';
+
+ const fieldValueTemplate = this._renderBuiltInFieldValue(fieldName);
+
+ if (!fieldValueTemplate) return '';
+
+ // Allow overflow to enable the FedRef popup to expand.
+ // TODO(jeffcarp): Look into a more elegant solution.
+ return html`
+ <tr class=${className}>
+ <th>${isApprovalStatus ? 'Status' : fieldName}:</th>
+ <td class=${isMergedInto ? 'allow-overflow' : ''}>
+ ${fieldValueTemplate}
+ </td>
+ </tr>
+ `;
+ });
+ }
+
+ /**
+ * A helper to display a single built-in field.
+ *
+ * @param {string} fieldName The name of the built in field to render.
+ * @return {TemplateResult|undefined} lit-html template for displaying the
+ * value of the built in field. If undefined, the rendering code assumes
+ * that the field should be hidden if empty.
+ */
+ _renderBuiltInFieldValue(fieldName) {
+ // TODO(zhangtiff): Merge with code in shared/issue-fields.js for further
+ // de-duplication.
+ switch (fieldName.toLowerCase()) {
+ case 'approvalstatus':
+ return this.approvalStatus || EMPTY_FIELD_VALUE;
+ case 'approvers':
+ return this.approvers && this.approvers.length ?
+ this.approvers.map((approver) => html`
+ <mr-user-link
+ .userRef=${approver}
+ showAvailabilityIcon
+ ></mr-user-link>
+ <br />
+ `) : EMPTY_FIELD_VALUE;
+ case 'setter':
+ return this.setter ? html`
+ <mr-user-link
+ .userRef=${this.setter}
+ showAvailabilityIcon
+ ></mr-user-link>
+ ` : undefined; // Hide the field when empty.
+ case 'owner':
+ return this.owner ? html`
+ <mr-user-link
+ .userRef=${this.owner}
+ showAvailabilityIcon
+ showAvailabilityText
+ ></mr-user-link>
+ ` : EMPTY_FIELD_VALUE;
+ case 'cc':
+ return this.cc && this.cc.length ?
+ this.cc.map((cc) => html`
+ <mr-user-link
+ .userRef=${cc}
+ showAvailabilityIcon
+ ></mr-user-link>
+ <br />
+ `) : EMPTY_FIELD_VALUE;
+ case 'status':
+ return this.issueStatus ? html`
+ ${this.issueStatus.status} <em>${
+ this.issueStatus.meansOpen ? '(Open)' : '(Closed)'}
+ </em>` : EMPTY_FIELD_VALUE;
+ case 'mergedinto':
+ // TODO(zhangtiff): This should use the project config to determine if a
+ // field allows merging rather than used a hard-coded value.
+ return this.issueStatus && this.issueStatus.status === 'Duplicate' ?
+ html`
+ <mr-issue-link
+ .projectName=${this.issueRef.projectName}
+ .issue=${this.mergedInto}
+ ></mr-issue-link>
+ `: undefined; // Hide the field when empty.
+ case 'components':
+ return (this.components && this.components.length) ?
+ this.components.map((comp) => html`
+ <a
+ href="/p/${this.issueRef.projectName
+ }/issues/list?q=component:${comp.path}"
+ title="${comp.path}${comp.docstring ?
+ ' = ' + comp.docstring : ''}"
+ >
+ ${comp.path}</a><br />
+ `) : EMPTY_FIELD_VALUE;
+ case 'modified':
+ return this.modifiedTimestamp ? html`
+ <chops-timestamp
+ .timestamp=${this.modifiedTimestamp}
+ short
+ ></chops-timestamp>
+ ` : EMPTY_FIELD_VALUE;
+ case 'slo':
+ if (isExperimentEnabled(
+ SLO_EXPERIMENT, this.currentUser, this.queryParams)) {
+ return html`<mr-issue-slo .issue=${this.issue}></mr-issue-slo>`;
+ } else {
+ return;
+ }
+ }
+
+ // Non-existent field.
+ return;
+ }
+
+ /**
+ * Helper for handling the rendering of custom fields defined in a project
+ * config.
+ * @return {TemplateResult} lit-html template.
+ */
+ _renderCustomFieldGroups() {
+ const grouped = fieldDefsWithGroup(this.fieldDefs,
+ this.fieldGroups, this.issueType);
+ const ungrouped = fieldDefsWithoutGroup(this.fieldDefs,
+ this.fieldGroups, this.issueType);
+ return html`
+ ${grouped.map((group) => html`
+ <tr>
+ <th class="group-title" colspan="2">
+ ${group.groupName}
+ </th>
+ </tr>
+ ${this._renderCustomFields(group.fieldDefs)}
+ <tr>
+ <th class="group-separator" colspan="2"></th>
+ </tr>
+ `)}
+
+ ${this._renderCustomFields(ungrouped)}
+ `;
+ }
+
+ /**
+ * Helper for handling the rendering of built in fields.
+ *
+ * @param {Array<FieldDef>} fieldDefs Arrays of configurations Objects
+ * for fields to render.
+ * @return {Array<TemplateResult>} Array of lit-html templates to render, each
+ * representing a single table row for a custom field.
+ */
+ _renderCustomFields(fieldDefs) {
+ if (!fieldDefs || !fieldDefs.length) return [];
+ return fieldDefs.map((field) => {
+ const fieldValues = valuesForField(
+ this.fieldValueMap, field.fieldRef.fieldName) || [];
+ return html`
+ <tr ?hidden=${field.isNiche && !fieldValues.length}>
+ <th title=${field.docstring}>${field.fieldRef.fieldName}:</th>
+ <td>
+ <mr-field-values
+ .name=${field.fieldRef.fieldName}
+ .type=${field.fieldRef.type}
+ .values=${fieldValues}
+ .projectName=${this.issueRef.projectName}
+ ></mr-field-values>
+ </td>
+ </tr>
+ `;
+ });
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /**
+ * An Array of Strings to specify which built in fields to display.
+ */
+ builtInFieldSpec: {type: Array},
+ approvalStatus: {type: Array},
+ approvers: {type: Array},
+ setter: {type: Object},
+ cc: {type: Array},
+ components: {type: Array},
+ fieldDefs: {type: Array},
+ fieldGroups: {type: Array},
+ issue: {type: Object},
+ issueStatus: {type: String},
+ issueType: {type: String},
+ mergedInto: {type: Object},
+ modifiedTimestamp: {type: Number},
+ owner: {type: Object},
+ isApproval: {type: Boolean},
+ issueRef: {type: Object},
+ fieldValueMap: {type: Object},
+ currentUser: {type: Object},
+ queryParams: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.isApproval = false;
+ this.fieldGroups = HARDCODED_FIELD_GROUPS;
+ this.issueRef = {};
+
+ // Default built in fields used by issue metadata.
+ this.builtInFieldSpec = [
+ 'Owner', 'CC', cueNameToSpec(cueNames.AVAILABILITY_MSGS),
+ 'Status', 'MergedInto', 'Components', 'Modified', 'SLO',
+ ];
+ this.fieldValueMap = new Map();
+
+ this.approvalStatus = undefined;
+ this.approvers = undefined;
+ this.setter = undefined;
+ this.cc = undefined;
+ this.components = undefined;
+ this.fieldDefs = undefined;
+ this.issue = undefined;
+ this.issueStatus = undefined;
+ this.issueType = undefined;
+ this.mergedInto = undefined;
+ this.owner = undefined;
+ this.modifiedTimestamp = undefined;
+ this.currentUser = undefined;
+ this.queryParams = {};
+ }
+
+ /** @override */
+ connectedCallback() {
+ super.connectedCallback();
+
+ // This is set for accessibility. Do not override.
+ this.setAttribute('role', 'table');
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.fieldValueMap = issueV0.fieldValueMap(state);
+ this.issue = issueV0.viewedIssue(state);
+ this.issueType = issueV0.type(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.relatedIssues = issueV0.relatedIssues(state);
+ this.currentUser = userV0.currentUser(state);
+ this.queryParams = sitewide.queryParams(state);
+ }
+}
+
+customElements.define('mr-metadata', MrMetadata);
diff --git a/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.test.js b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.test.js
new file mode 100644
index 0000000..d9dcd25
--- /dev/null
+++ b/static_src/elements/issue-detail/metadata/mr-metadata/mr-metadata.test.js
@@ -0,0 +1,345 @@
+// 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 {MrMetadata} from './mr-metadata.js';
+
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+
+let element;
+
+describe('mr-metadata', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-metadata');
+ document.body.appendChild(element);
+
+ element.issueRef = {projectName: 'proj'};
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrMetadata);
+ });
+
+ it('has table role set', () => {
+ assert.equal(element.getAttribute('role'), 'table');
+ });
+
+ describe('default issue fields', () => {
+ it('renders empty Owner', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-owner');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Owner:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders populated Owner', async () => {
+ element.owner = {displayName: 'test@example.com'};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-owner');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('mr-user-link');
+
+ assert.equal(labelElement.textContent, 'Owner:');
+ assert.include(dataElement.shadowRoot.textContent.trim(),
+ 'test@example.com');
+ });
+
+ it('renders empty CC', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-cc');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'CC:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders multiple CCed users', async () => {
+ element.cc = [
+ {displayName: 'test@example.com'},
+ {displayName: 'hello@example.com'},
+ ];
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-cc');
+ const labelElement = tr.querySelector('th');
+ const dataElements = tr.querySelectorAll('mr-user-link');
+
+ assert.equal(labelElement.textContent, 'CC:');
+ assert.include(dataElements[0].shadowRoot.textContent.trim(),
+ 'test@example.com');
+ assert.include(dataElements[1].shadowRoot.textContent.trim(),
+ 'hello@example.com');
+ });
+
+ it('renders empty Status', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-status');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Status:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders populated Status', async () => {
+ element.issueStatus = {status: 'Fixed', meansOpen: false};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-status');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Status:');
+ assert.equal(dataElement.textContent.trim(), 'Fixed (Closed)');
+ });
+
+ it('hides empty MergedInto', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-mergedinto');
+ assert.isNull(tr);
+ });
+
+ it('hides MergedInto when Status is not Duplicate', async () => {
+ element.issueStatus = {status: 'test'};
+ element.mergedInto = {projectName: 'chromium', localId: 22};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-mergedinto');
+ assert.isNull(tr);
+ });
+
+ it('shows MergedInto when Status is Duplicate', async () => {
+ element.issueStatus = {status: 'Duplicate'};
+ element.mergedInto = {projectName: 'chromium', localId: 22};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-mergedinto');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('mr-issue-link');
+
+ assert.equal(labelElement.textContent, 'MergedInto:');
+ assert.equal(dataElement.shadowRoot.textContent.trim(),
+ 'Issue chromium:22');
+ });
+
+ it('renders empty Components', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-components');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Components:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders multiple Components', async () => {
+ element.components = [
+ {path: 'Test', docstring: 'i got docs'},
+ {path: 'Test>Nothing'},
+ ];
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-components');
+ const labelElement = tr.querySelector('th');
+ const dataElements = tr.querySelectorAll('td > a');
+
+ assert.equal(labelElement.textContent, 'Components:');
+
+ assert.equal(dataElements[0].textContent.trim(), 'Test');
+ assert.equal(dataElements[0].title, 'Test = i got docs');
+
+ assert.equal(dataElements[1].textContent.trim(), 'Test>Nothing');
+ assert.equal(dataElements[1].title, 'Test>Nothing');
+ });
+
+ it('renders empty Modified', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-modified');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Modified:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders populated Modified', async () => {
+ element.modifiedTimestamp = 1234;
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-modified');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('chops-timestamp');
+
+ assert.equal(labelElement.textContent, 'Modified:');
+ assert.equal(dataElement.timestamp, 1234);
+ });
+
+ it('does not render SLO if user not in experiment', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-slo');
+ assert.isNull(tr);
+ });
+
+ it('renders SLO if user in experiment', async () => {
+ element.currentUser = {displayName: 'jessan@google.com'};
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-slo');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('mr-issue-slo');
+
+ assert.equal(labelElement.textContent, 'SLO:');
+ assert.equal(dataElement.shadowRoot.textContent.trim(), 'N/A');
+ });
+ });
+
+ describe('approval fields', () => {
+ beforeEach(() => {
+ element.builtInFieldSpec = ['ApprovalStatus', 'Approvers', 'Setter',
+ 'cue.availability_msgs'];
+ });
+
+ it('renders empty ApprovalStatus', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-approvalstatus');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Status:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders populated ApprovalStatus', async () => {
+ element.approvalStatus = 'Approved';
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-approvalstatus');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Status:');
+ assert.equal(dataElement.textContent.trim(), 'Approved');
+ });
+
+ it('renders empty Approvers', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-approvers');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'Approvers:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('renders multiple Approvers', async () => {
+ element.approvers = [
+ {displayName: 'test@example.com'},
+ {displayName: 'hello@example.com'},
+ ];
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-approvers');
+ const labelElement = tr.querySelector('th');
+ const dataElements = tr.querySelectorAll('mr-user-link');
+
+ assert.equal(labelElement.textContent, 'Approvers:');
+ assert.include(dataElements[0].shadowRoot.textContent.trim(),
+ 'test@example.com');
+ assert.include(dataElements[1].shadowRoot.textContent.trim(),
+ 'hello@example.com');
+ });
+
+ it('hides empty Setter', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-setter');
+
+ assert.isNull(tr);
+ });
+
+ it('renders populated Setter', async () => {
+ element.setter = {displayName: 'test@example.com'};
+
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-setter');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('mr-user-link');
+
+ assert.equal(labelElement.textContent, 'Setter:');
+ assert.include(dataElement.shadowRoot.textContent.trim(),
+ 'test@example.com');
+ });
+
+ it('renders cue.availability_msgs', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector(
+ 'tr.cue-availability_msgs');
+ const cueElement = tr.querySelector('mr-cue');
+
+ assert.isDefined(cueElement);
+ });
+ });
+
+ describe('custom config', () => {
+ beforeEach(() => {
+ element.builtInFieldSpec = ['owner', 'fakefield'];
+ });
+
+ it('owner still renders when lowercase', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-owner');
+ const labelElement = tr.querySelector('th');
+ const dataElement = tr.querySelector('td');
+
+ assert.equal(labelElement.textContent, 'owner:');
+ assert.equal(dataElement.textContent.trim(), EMPTY_FIELD_VALUE);
+ });
+
+ it('fakefield does not render', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.row-fakefield');
+
+ assert.isNull(tr);
+ });
+
+ it('cue.availability_msgs does not render when not configured', async () => {
+ await element.updateComplete;
+
+ const tr = element.shadowRoot.querySelector('tr.cue-availability_msgs');
+
+ assert.isNull(tr);
+ });
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.js b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.js
new file mode 100644
index 0000000..2d74c10
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.js
@@ -0,0 +1,452 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import 'elements/chops/chops-collapse/chops-collapse.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 ui from 'reducers/ui.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+import 'elements/framework/mr-comment-content/mr-description.js';
+import '../mr-comment-list/mr-comment-list.js';
+import 'elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js';
+import 'elements/issue-detail/metadata/mr-metadata/mr-metadata.js';
+import {APPROVER_RESTRICTED_STATUSES, STATUS_ENUM_TO_TEXT, TEXT_TO_STATUS_ENUM,
+ STATUS_CLASS_MAP, CLASS_ICON_MAP, APPROVAL_STATUSES,
+} from 'shared/consts/approval.js';
+import {commentListToDescriptionList} from 'shared/convertersV0.js';
+import {cueNames, cueNameToSpec} from 'elements/help/mr-cue/cue-helpers.js';
+
+
+/**
+ * @type {Array<string>} The list of built in metadata fields to show on
+ * issue approvals.
+ */
+const APPROVAL_METADATA_FIELDS = ['ApprovalStatus', 'Approvers', 'Setter',
+ cueNameToSpec(cueNames.AVAILABILITY_MSGS)];
+
+/**
+ * `<mr-approval-card>`
+ *
+ * This element shows a card for a single approval.
+ *
+ */
+export class MrApprovalCard extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <style>
+ mr-approval-card {
+ width: 100%;
+ background-color: var(--chops-white);
+ font-size: var(--chops-main-font-size);
+ border-bottom: var(--chops-normal-border);
+ box-sizing: border-box;
+ display: block;
+ border-left: 4px solid var(--approval-bg-color);
+
+ /* Default styles are for the NotSet/NeedsReview case. */
+ --approval-bg-color: var(--chops-purple-50);
+ --approval-accent-color: var(--chops-purple-700);
+ }
+ mr-approval-card.status-na {
+ --approval-bg-color: hsl(227, 20%, 92%);
+ --approval-accent-color: hsl(227, 80%, 40%);
+ }
+ mr-approval-card.status-approved {
+ --approval-bg-color: hsl(78, 55%, 90%);
+ --approval-accent-color: hsl(78, 100%, 30%);
+ }
+ mr-approval-card.status-pending {
+ --approval-bg-color: hsl(40, 75%, 90%);
+ --approval-accent-color: hsl(33, 100%, 39%);
+ }
+ mr-approval-card.status-rejected {
+ --approval-bg-color: hsl(5, 60%, 92%);
+ --approval-accent-color: hsl(357, 100%, 39%);
+ }
+ mr-approval-card chops-button.edit-survey {
+ border: var(--chops-normal-border);
+ margin: 0;
+ }
+ mr-approval-card h3 {
+ margin: 0;
+ padding: 0;
+ display: inline;
+ font-weight: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ }
+ mr-approval-card mr-description {
+ display: block;
+ margin-bottom: 0.5em;
+ }
+ .approver-notice {
+ padding: 0.25em 0;
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+ justify-content: space-between;
+ border-bottom: 1px dotted hsl(0, 0%, 83%);
+ }
+ .card-content {
+ box-sizing: border-box;
+ padding: 0.5em 16px;
+ padding-bottom: 1em;
+ }
+ .expand-icon {
+ display: block;
+ margin-right: 8px;
+ color: hsl(0, 0%, 45%);
+ }
+ mr-approval-card .header {
+ margin: 0;
+ width: 100%;
+ border: 0;
+ font-size: var(--chops-large-font-size);
+ font-weight: normal;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ padding: 0.5em 8px;
+ background-color: var(--approval-bg-color);
+ cursor: pointer;
+ }
+ mr-approval-card .status {
+ font-size: var(--chops-main-font-size);
+ color: var(--approval-accent-color);
+ display: inline-flex;
+ align-items: center;
+ margin-left: 32px;
+ }
+ mr-approval-card .survey {
+ padding: 0.5em 0;
+ max-height: 500px;
+ overflow-y: auto;
+ max-width: 100%;
+ box-sizing: border-box;
+ }
+ mr-approval-card [role="heading"] {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: flex-end;
+ }
+ mr-approval-card .edit-header {
+ margin-top: 40px;
+ }
+ </style>
+ <button
+ class="header"
+ @click=${this.toggleCard}
+ aria-expanded=${(this.opened || false).toString()}
+ >
+ <i class="material-icons expand-icon">
+ ${this.opened ? 'expand_less' : 'expand_more'}
+ </i>
+ <h3>${this.fieldName}</h3>
+ <span class="status">
+ <i class="material-icons status-icon" role="presentation">
+ ${CLASS_ICON_MAP[this._statusClass]}
+ </i>
+ ${this._status}
+ </span>
+ </button>
+ <chops-collapse class="card-content" ?opened=${this.opened}>
+ <div class="approver-notice">
+ ${this._isApprover ? html`
+ You are an approver for this bit.
+ `: ''}
+ ${this.user && this.user.isSiteAdmin ? html`
+ Your site admin privileges give you full access to edit this approval.
+ `: ''}
+ </div>
+ <mr-metadata
+ aria-label="${this.fieldName} Approval Metadata"
+ .approvalStatus=${this._status}
+ .approvers=${this.approvers}
+ .setter=${this.setter}
+ .fieldDefs=${this.fieldDefs}
+ .builtInFieldSpec=${APPROVAL_METADATA_FIELDS}
+ isApproval
+ ></mr-metadata>
+ <h4
+ class="medium-heading"
+ role="heading"
+ >
+ ${this.fieldName} Survey
+ <chops-button class="edit-survey" @click=${this._openSurveyEditor}>
+ Edit responses
+ </chops-button>
+ </h4>
+ <mr-description
+ class="survey"
+ .descriptionList=${this._allSurveys}
+ ></mr-description>
+ <mr-comment-list
+ headingLevel=4
+ .comments=${this.comments}
+ ></mr-comment-list>
+ ${this.issuePermissions.includes('addissuecomment') ? html`
+ <h4 id="edit${this.fieldName}" class="medium-heading edit-header">
+ Editing approval: ${this.phaseName} > ${this.fieldName}
+ </h4>
+ <mr-edit-metadata
+ .formName="${this.phaseName} > ${this.fieldName}"
+ .approvers=${this.approvers}
+ .fieldDefs=${this.fieldDefs}
+ .statuses=${this._availableStatuses}
+ .status=${this._status}
+ .error=${this.updateError && (this.updateError.description || this.updateError.message)}
+ ?saving=${this.updatingApproval}
+ ?hasApproverPrivileges=${this._hasApproverPrivileges}
+ isApproval
+ @save=${this.save}
+ @discard=${this.reset}
+ ></mr-edit-metadata>
+ ` : ''}
+ </chops-collapse>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ fieldName: {type: String},
+ approvers: {type: Array},
+ phaseName: {type: String},
+ setter: {type: Object},
+ fieldDefs: {type: Array},
+ focusId: {type: String},
+ user: {type: Object},
+ issue: {type: Object},
+ issueRef: {type: Object},
+ issuePermissions: {type: Array},
+ projectConfig: {type: Object},
+ comments: {type: String},
+ opened: {
+ type: Boolean,
+ reflect: true,
+ },
+ statusEnum: {type: String},
+ updatingApproval: {type: Boolean},
+ updateError: {type: Object},
+ _allSurveys: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.opened = false;
+ this.comments = [];
+ this.fieldDefs = [];
+ this.issuePermissions = [];
+ this._allSurveys = [];
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ const fieldDefsByApproval = projectV0.fieldDefsByApprovalName(state);
+ if (fieldDefsByApproval && this.fieldName &&
+ fieldDefsByApproval.has(this.fieldName)) {
+ this.fieldDefs = fieldDefsByApproval.get(this.fieldName);
+ }
+ const commentsByApproval = issueV0.commentsByApprovalName(state);
+ if (commentsByApproval && this.fieldName &&
+ commentsByApproval.has(this.fieldName)) {
+ const comments = commentsByApproval.get(this.fieldName);
+ this.comments = comments.slice(1);
+ this._allSurveys = commentListToDescriptionList(comments);
+ }
+ this.focusId = ui.focusId(state);
+ this.user = userV0.currentUser(state);
+ this.issue = issueV0.viewedIssue(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.issuePermissions = issueV0.permissions(state);
+ this.projectConfig = projectV0.viewedConfig(state);
+ this.updatingApproval = issueV0.requests(state).updateApproval.requesting;
+ this.updateError = issueV0.requests(state).updateApproval.error;
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if ((changedProperties.has('comments') ||
+ changedProperties.has('focusId')) && this.comments) {
+ const focused = this.comments.find(
+ (comment) => `c${comment.sequenceNum}` === this.focusId);
+ if (focused) {
+ // Make sure to open the card when a comment is focused.
+ this.opened = true;
+ }
+ }
+ if (changedProperties.has('statusEnum')) {
+ this.setAttribute('class', this._statusClass);
+ }
+ if (changedProperties.has('user') || changedProperties.has('approvers')) {
+ if (this._isApprover) {
+ // Open the card by default if the user is an approver.
+ this.opened = true;
+ }
+ }
+ super.update(changedProperties);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('issue')) {
+ this.reset();
+ }
+ }
+
+ /**
+ * Resets the approval edit form.
+ */
+ reset() {
+ const form = this.querySelector('mr-edit-metadata');
+ if (!form) return;
+ form.reset();
+ }
+
+ /**
+ * Saves the user's changes in the approval update form.
+ */
+ async save() {
+ const form = this.querySelector('mr-edit-metadata');
+ const delta = form.delta;
+
+ if (delta.status) {
+ delta.status = TEXT_TO_STATUS_ENUM[delta.status];
+ }
+
+ // TODO(ehmaldonado): Show snackbar on change, and prevent starring issues
+ // to resetting the form.
+
+ const message = {
+ issueRef: this.issueRef,
+ fieldRef: {
+ type: fieldTypes.APPROVAL_TYPE,
+ fieldName: this.fieldName,
+ },
+ approvalDelta: delta,
+ commentContent: form.getCommentContent(),
+ sendEmail: form.sendEmail,
+ };
+
+ // Add files to message.
+ const uploads = await form.getAttachments();
+
+ if (uploads && uploads.length) {
+ message.uploads = uploads;
+ }
+
+ if (message.commentContent || message.approvalDelta || message.uploads) {
+ store.dispatch(issueV0.updateApproval(message));
+ }
+ }
+
+ /**
+ * Opens and closes the approval card.
+ */
+ toggleCard() {
+ this.opened = !this.opened;
+ }
+
+ /**
+ * @return {string} The CSS class used to style the approval card,
+ * given its status.
+ * @private
+ */
+ get _statusClass() {
+ return STATUS_CLASS_MAP[this._status];
+ }
+
+ /**
+ * @return {string} The human readable value of an approval status.
+ * @private
+ */
+ get _status() {
+ return STATUS_ENUM_TO_TEXT[this.statusEnum || ''];
+ }
+
+ /**
+ * @return {boolean} Whether the user is an approver or not.
+ * @private
+ */
+ get _isApprover() {
+ // Assumption: Since a user who is an approver should always be a project
+ // member, displayNames should be visible to them if they are an approver.
+ if (!this.approvers || !this.user || !this.user.displayName) return false;
+ const userGroups = this.user.groups || [];
+ return !!this.approvers.find((a) => {
+ return a.displayName === this.user.displayName || userGroups.find(
+ (group) => group.displayName === a.displayName,
+ );
+ });
+ }
+
+ /**
+ * @return {boolean} Whether the user can approver the approval or not.
+ * Not the same as _isApprover because site admins can approve approvals
+ * even if they are not approvers.
+ * @private
+ */
+ get _hasApproverPrivileges() {
+ return (this.user && this.user.isSiteAdmin) || this._isApprover;
+ }
+
+ /**
+ * @return {Array<StatusDef>}
+ * @private
+ */
+ get _availableStatuses() {
+ return APPROVAL_STATUSES.filter((s) => {
+ if (s.status === this._status) {
+ // The current status should always appear as an option.
+ return true;
+ }
+
+ if (!this._hasApproverPrivileges &&
+ APPROVER_RESTRICTED_STATUSES.has(s.status)) {
+ // If you are not an approver and and this status is restricted,
+ // you can't change to this status.
+ return false;
+ }
+
+ // No one can set statuses to NotSet, not even approvers.
+ return s.status !== 'NotSet';
+ });
+ }
+
+ /**
+ * Launches the description editing dialog for the survey.
+ * @fires CustomEvent#open-dialog
+ * @private
+ */
+ _openSurveyEditor() {
+ this.dispatchEvent(new CustomEvent('open-dialog', {
+ bubbles: true,
+ composed: true,
+ detail: {
+ dialogId: 'edit-description',
+ fieldName: this.fieldName,
+ },
+ }));
+ }
+}
+
+customElements.define('mr-approval-card', MrApprovalCard);
diff --git a/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.test.js b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.test.js
new file mode 100644
index 0000000..0424c21
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-approval-card/mr-approval-card.test.js
@@ -0,0 +1,245 @@
+// 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 {MrApprovalCard} from './mr-approval-card.js';
+
+let element;
+
+describe('mr-approval-card', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-approval-card');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrApprovalCard);
+ });
+
+ it('_isApprover true when user is an approver', () => {
+ // User not in approver list.
+ element.approvers = [
+ {displayName: 'tester@user.com'},
+ {displayName: 'test@notuser.com'},
+ {displayName: 'hello@world.com'},
+ ];
+ element.user = {displayName: 'test@user.com', groups: []};
+ assert.isFalse(element._isApprover);
+
+ // Use is in approver list.
+ element.approvers = [
+ {displayName: 'tester@user.com'},
+ {displayName: 'test@notuser.com'},
+ {displayName: 'hello@world.com'},
+ {displayName: 'test@user.com'},
+ ];
+ assert.isTrue(element._isApprover);
+
+ // User's group is not in the list.
+ element.approvers = [
+ {displayName: 'tester@user.com'},
+ {displayName: 'nongroup@group.com'},
+ {displayName: 'group@nongroup.com'},
+ {displayName: 'ignore@test.com'},
+ ];
+ element.user = {
+ displayName: 'test@user.com',
+ groups: [
+ {displayName: 'group@group.com'},
+ {displayName: 'test@group.com'},
+ {displayName: 'group@user.com'},
+ ],
+ };
+ assert.isFalse(element._isApprover);
+
+ // User's group is in the list.
+ element.approvers = [
+ {displayName: 'tester@user.com'},
+ {displayName: 'group@group.com'},
+ {displayName: 'test@notuser.com'},
+ ];
+ element.user = {
+ displayName: 'test@user.com',
+ groups: [
+ {displayName: 'group@group.com'},
+ ],
+ };
+ assert.isTrue(element._isApprover);
+ });
+
+ it('approvals change color based on status', async () => {
+ // Initialize dependent CSS property from a stylesheet not included in
+ // our testing environment.
+ element.style.setProperty('--chops-purple-50', '#f3e5f5');
+
+ element.statusEnum = 'NEEDS_REVIEW';
+ await element.updateComplete;
+
+ const header = element.querySelector('button.header');
+
+ // Purple. Note that Chrome uses RGB for computed styles regardless of
+ // underlying CSS.
+ assert.equal(
+ window.getComputedStyle(header).getPropertyValue('background-color'),
+ 'rgb(243, 229, 245)');
+
+ element.statusEnum = 'APPROVED';
+ await element.updateComplete;
+
+ // Green.
+ assert.equal(
+ window.getComputedStyle(header).getPropertyValue('background-color'),
+ 'rgb(235, 244, 215)');
+ });
+
+ it('site admins have approver privileges', async () => {
+ await element.updateComplete;
+
+ const notice = element.querySelector('.approver-notice');
+ assert.equal(notice.textContent.trim(), '');
+
+ element.user = {isSiteAdmin: true};
+ await element.updateComplete;
+
+ assert.isTrue(element._hasApproverPrivileges);
+
+ assert.equal(notice.textContent.trim(),
+ 'Your site admin privileges give you full access to edit this approval.',
+ );
+ });
+
+ it('site admins see all approval statuses except NotSet', () => {
+ element.user = {isSiteAdmin: true};
+
+ assert.isFalse(element._isApprover);
+
+ element.statusEnum = 'NEEDS_REVIEW';
+
+ assert.equal(element._availableStatuses.length, 7);
+ assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+ assert.equal(element._availableStatuses[1].status, 'NA');
+ assert.equal(element._availableStatuses[2].status, 'ReviewRequested');
+ assert.equal(element._availableStatuses[3].status, 'ReviewStarted');
+ assert.equal(element._availableStatuses[4].status, 'NeedInfo');
+ assert.equal(element._availableStatuses[5].status, 'Approved');
+ assert.equal(element._availableStatuses[6].status, 'NotApproved');
+ });
+
+ it('approvers see all approval statuses except NotSet', () => {
+ element.user = {isSiteAdmin: false, displayName: 'test@email.com'};
+ element.approvers = [{displayName: 'test@email.com'}];
+
+ assert.isTrue(element._isApprover);
+
+ element.statusEnum = 'NEEDS_REVIEW';
+
+ assert.equal(element._availableStatuses.length, 7);
+ assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+ assert.equal(element._availableStatuses[1].status, 'NA');
+ assert.equal(element._availableStatuses[2].status, 'ReviewRequested');
+ assert.equal(element._availableStatuses[3].status, 'ReviewStarted');
+ assert.equal(element._availableStatuses[4].status, 'NeedInfo');
+ assert.equal(element._availableStatuses[5].status, 'Approved');
+ assert.equal(element._availableStatuses[6].status, 'NotApproved');
+ });
+
+ it('non-approvers see non-restricted approval statuses', () => {
+ element.user = {isSiteAdmin: false, displayName: 'test@email.com'};
+ element.approvers = [{displayName: 'test@otheremail.com'}];
+
+ assert.isFalse(element._isApprover);
+
+ element.statusEnum = 'NEEDS_REVIEW';
+
+ assert.equal(element._availableStatuses.length, 4);
+ assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+ assert.equal(element._availableStatuses[1].status, 'ReviewRequested');
+ assert.equal(element._availableStatuses[2].status, 'ReviewStarted');
+ assert.equal(element._availableStatuses[3].status, 'NeedInfo');
+ });
+
+ it('non-approvers see restricted approval status when set', () => {
+ element.user = {isSiteAdmin: false, displayName: 'test@email.com'};
+ element.approvers = [{displayName: 'test@otheremail.com'}];
+
+ assert.isFalse(element._isApprover);
+
+ element.statusEnum = 'APPROVED';
+
+ assert.equal(element._availableStatuses.length, 5);
+ assert.equal(element._availableStatuses[0].status, 'NeedsReview');
+ assert.equal(element._availableStatuses[1].status, 'ReviewRequested');
+ assert.equal(element._availableStatuses[2].status, 'ReviewStarted');
+ assert.equal(element._availableStatuses[3].status, 'NeedInfo');
+ assert.equal(element._availableStatuses[4].status, 'Approved');
+ });
+
+ it('expands to show focused comment', async () => {
+ element.focusId = 'c4';
+ element.fieldName = 'field';
+ element.comments = [
+ {
+ sequenceNum: 1,
+ approvalRef: {fieldName: 'other-field'},
+ },
+ {
+ sequenceNum: 2,
+ approvalRef: {fieldName: 'field'},
+ },
+ {
+ sequenceNum: 3,
+ },
+ {
+ sequenceNum: 4,
+ approvalRef: {fieldName: 'field'},
+ },
+ ];
+
+ await element.updateComplete;
+
+ assert.isTrue(element.opened);
+ });
+
+ it('does not expand to show focused comment on other elements', async () => {
+ element.focusId = 'c3';
+ element.comments = [
+ {
+ sequenceNum: 1,
+ approvalRef: {fieldName: 'other-field'},
+ },
+ {
+ sequenceNum: 2,
+ approvalRef: {fieldName: 'field'},
+ },
+ {
+ sequenceNum: 4,
+ approvalRef: {fieldName: 'field'},
+ },
+ ];
+
+ await element.updateComplete;
+
+ assert.isFalse(element.opened);
+ });
+
+ it('mr-edit-metadata is displayed if user has addissuecomment', async () => {
+ element.issuePermissions = ['addissuecomment'];
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.querySelector('mr-edit-metadata'));
+ });
+
+ it('mr-edit-metadata is hidden if user has no addissuecomment', async () => {
+ element.issuePermissions = [];
+
+ await element.updateComplete;
+
+ assert.isNull(element.querySelector('mr-edit-metadata'));
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js
new file mode 100644
index 0000000..aad9a8a
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.js
@@ -0,0 +1,163 @@
+// 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 {cache} from 'lit-html/directives/cache.js';
+import {LitElement, html, css} from 'lit-element';
+
+import '../../chops/chops-button/chops-button.js';
+import './mr-comment.js';
+import {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as ui from 'reducers/ui.js';
+import {userIsMember} from 'shared/helpers.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * `<mr-comment-list>`
+ *
+ * Display a list of Monorail comments.
+ *
+ */
+export class MrCommentList extends connectStore(LitElement) {
+ /** @override */
+ constructor() {
+ super();
+
+ this.commentsShownCount = 2;
+ this.comments = [];
+ this.headingLevel = 4;
+
+ this.focusId = null;
+
+ this.usersProjects = new Map();
+
+ this._hideComments = true;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ commentsShownCount: {type: Number},
+ comments: {type: Array},
+ headingLevel: {type: Number},
+
+ focusId: {type: String},
+
+ usersProjects: {type: Object},
+
+ _hideComments: {type: Boolean},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.focusId = ui.focusId(state);
+ this.usersProjects = userV0.projectsPerUser(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+
+ if (!this._hideComments) return;
+
+ // If any hidden comment is focused, show all hidden comments.
+ const hiddenCount =
+ _hiddenCount(this.comments.length, this.commentsShownCount);
+ const hiddenComments = this.comments.slice(0, hiddenCount);
+ for (const comment of hiddenComments) {
+ if ('c' + comment.sequenceNum === this.focusId) {
+ this._hideComments = false;
+ break;
+ }
+ };
+ }
+
+ /** @override */
+ static get styles() {
+ return [SHARED_STYLES, css`
+ button.toggle {
+ background: none;
+ color: var(--chops-link-color);
+ border: 0;
+ border-bottom: var(--chops-normal-border);
+ border-top: var(--chops-normal-border);
+ width: 100%;
+ padding: 0.5em 8px;
+ text-align: left;
+ font-size: var(--chops-main-font-size);
+ }
+ button.toggle:hover {
+ cursor: pointer;
+ text-decoration: underline;
+ }
+ button.toggle[hidden] {
+ display: none;
+ }
+ .edit-slot {
+ margin-top: 3em;
+ }
+ `];
+ }
+
+ /** @override */
+ render() {
+ const hiddenCount =
+ _hiddenCount(this.comments.length, this.commentsShownCount);
+ return html`
+ <button @click=${this._toggleHide}
+ class="toggle"
+ ?hidden=${hiddenCount <= 0}>
+ ${this._hideComments ? 'Show' : 'Hide'}
+ ${hiddenCount}
+ older
+ ${hiddenCount == 1 ? 'comment' : 'comments'}
+ </button>
+ ${cache(this._hideComments ? '' :
+ html`${this.comments.slice(0, hiddenCount).map(
+ this.renderComment.bind(this))}`)}
+ ${this.comments.slice(hiddenCount).map(this.renderComment.bind(this))}
+ `;
+ }
+
+ /**
+ * Helper to render a single comment.
+ * @param {Comment} comment
+ * @return {TemplateResult}
+ */
+ renderComment(comment) {
+ const commenterIsMember = userIsMember(
+ comment.commenter, comment.projectName, this.usersProjects);
+ return html`
+ <mr-comment
+ .comment=${comment}
+ headingLevel=${this.headingLevel}
+ ?highlighted=${'c' + comment.sequenceNum === this.focusId}
+ ?commenterIsMember=${commenterIsMember}
+ ></mr-comment>`;
+ }
+
+ /**
+ * Hides or unhides comments that are hidden by default. For example,
+ * if an issue has 200 comments, the first 100 comments are shown initially,
+ * then the last 100 can be toggled to be shown.
+ * @private
+ */
+ _toggleHide() {
+ this._hideComments = !this._hideComments;
+ }
+}
+
+/**
+ * Computes how many comments the user is able to expand.
+ * @param {number} commentCount Total comments.
+ * @param {number} commentsShownCount The number of comments shown.
+ * @return {number} The number of hidden comments.
+ * @private
+ */
+function _hiddenCount(commentCount, commentsShownCount) {
+ return Math.max(commentCount - commentsShownCount, 0);
+}
+
+customElements.define('mr-comment-list', MrCommentList);
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js
new file mode 100644
index 0000000..548b7a7
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment-list.test.js
@@ -0,0 +1,108 @@
+// 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 {MrCommentList} from './mr-comment-list.js';
+
+
+let element;
+
+describe('mr-comment-list', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-comment-list');
+ document.body.appendChild(element);
+ element.comments = [
+ {
+ canFlag: true,
+ localId: 898395,
+ canDelete: true,
+ projectName: 'chromium',
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ content: 'foo',
+ sequenceNum: 1,
+ timestamp: 1549319989,
+ },
+ {
+ canFlag: true,
+ localId: 898395,
+ canDelete: true,
+ projectName: 'chromium',
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ content: 'foo',
+ sequenceNum: 2,
+ timestamp: 1549320089,
+ },
+ {
+ canFlag: true,
+ localId: 898395,
+ canDelete: true,
+ projectName: 'chromium',
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ content: 'foo',
+ sequenceNum: 3,
+ timestamp: 1549320189,
+ },
+ ];
+
+ // Stub RAF to execute immediately.
+ sinon.stub(window, 'requestAnimationFrame').callsFake((func) => func());
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ window.requestAnimationFrame.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrCommentList);
+ });
+
+ it('scrolls to comment', async () => {
+ await element.updateComplete;
+
+ const commentElements = element.shadowRoot.querySelectorAll('mr-comment');
+ const commentElement = commentElements[commentElements.length - 1];
+ sinon.stub(commentElement, 'scrollIntoView');
+
+ element.focusId = 'c3';
+
+ await element.updateComplete;
+
+ assert.isTrue(element._hideComments);
+ assert.isTrue(commentElement.scrollIntoView.calledOnce);
+
+ commentElement.scrollIntoView.restore();
+ });
+
+ it('scrolls to hidden comment', async () => {
+ await element.updateComplete;
+
+ element.focusId = 'c1';
+
+ await element.updateComplete;
+
+ assert.isFalse(element._hideComments);
+ // TODO: Check that the comment has been scrolled into view.
+ });
+
+ it('doesnt scroll to unknown comment', async () => {
+ await element.updateComplete;
+
+ element.focusId = 'c100';
+
+ await element.updateComplete;
+
+ assert.isTrue(element._hideComments);
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment.js
new file mode 100644
index 0000000..e56bef3
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment.js
@@ -0,0 +1,416 @@
+// 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} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+import 'elements/framework/mr-comment-content/mr-comment-content.js';
+import 'elements/framework/mr-comment-content/mr-attachment.js';
+import 'elements/framework/mr-dropdown/mr-dropdown.js';
+import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {issueStringToRef} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+const ISSUE_REF_FIELD_NAMES = [
+ 'Blocking',
+ 'Blockedon',
+ 'Mergedinto',
+];
+
+/**
+ * `<mr-comment>`
+ *
+ * A component for an individual comment.
+ *
+ */
+export class MrComment extends LitElement {
+ /** @override */
+ constructor() {
+ super();
+
+ this._isExpandedIfDeleted = false;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ comment: {type: Object},
+ headingLevel: {type: String},
+ highlighted: {
+ type: Boolean,
+ reflect: true,
+ },
+ commenterIsMember: {type: Boolean},
+ _isExpandedIfDeleted: {type: Boolean},
+ _showOriginalContent: {type: Boolean},
+ };
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+
+ if (changedProperties.has('highlighted') && this.highlighted) {
+ window.requestAnimationFrame(() => {
+ this.scrollIntoView();
+ // TODO(ehmaldonado): Figure out a way to get the height from the issue
+ // header, and scroll by that amount.
+ window.scrollBy(0, -150);
+ });
+ }
+ }
+
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: block;
+ margin: 1.5em 0 0 0;
+ }
+ :host([highlighted]) {
+ border: 1px solid var(--chops-primary-accent-color);
+ box-shadow: 0 0 4px 4px var(--chops-active-choice-bg);
+ }
+ :host([hidden]) {
+ display: none;
+ }
+ .comment-header {
+ background: var(--chops-card-heading-bg);
+ padding: 3px 1px 1px 8px;
+ width: 100%;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ box-sizing: border-box;
+ }
+ .comment-header a {
+ display: inline-flex;
+ }
+ .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;
+ }
+ .comment-options {
+ float: right;
+ text-align: right;
+ text-decoration: none;
+ }
+ .comment-body {
+ margin: 4px;
+ box-sizing: border-box;
+ }
+ .deleted-comment-notice {
+ margin-left: 4px;
+ }
+ .issue-diff {
+ background: var(--chops-card-details-bg);
+ display: inline-block;
+ padding: 4px 8px;
+ width: 100%;
+ box-sizing: border-box;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ ${this._renderHeading()}
+ ${_shouldShowComment(this._isExpandedIfDeleted, this.comment) ? html`
+ ${this._renderDiff()}
+ ${this._renderBody()}
+ ` : ''}
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderHeading() {
+ return html`
+ <div
+ role="heading"
+ aria-level=${this.headingLevel}
+ class="comment-header">
+ <div>
+ <a
+ href="?id=${this.comment.localId}#c${this.comment.sequenceNum}"
+ class="comment-link"
+ >Comment ${this.comment.sequenceNum}</a>
+
+ ${this._renderByline()}
+ </div>
+ ${_shouldOfferCommentOptions(this.comment) ? html`
+ <div class="comment-options">
+ <mr-dropdown
+ .items=${this._commentOptions}
+ label="Comment options"
+ icon="more_vert"
+ ></mr-dropdown>
+ </div>
+ ` : ''}
+ </div>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderByline() {
+ if (_shouldShowComment(this._isExpandedIfDeleted, this.comment)) {
+ return html`
+ by
+ <mr-user-link .userRef=${this.comment.commenter}></mr-user-link>
+ on
+ <chops-timestamp
+ .timestamp=${this.comment.timestamp}
+ ></chops-timestamp>
+ ${this.commenterIsMember && !this.comment.isDeleted ? html`
+ <span class="role-label">Project Member</span>` : ''}
+ `;
+ } else {
+ return html`<span class="deleted-comment-notice">Deleted</span>`;
+ }
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderDiff() {
+ if (!(this.comment.descriptionNum || this.comment.amendments)) return '';
+
+ return html`
+ <div class="issue-diff">
+ ${(this.comment.amendments || []).map((delta) => html`
+ <strong>${delta.fieldName}:</strong>
+ ${_issuesForAmendment(delta, this.comment.projectName).map((issueForAmendment) => html`
+ <mr-issue-link
+ projectName=${this.comment.projectName}
+ .issue=${issueForAmendment.issue}
+ text=${issueForAmendment.text}
+ ></mr-issue-link>
+ `)}
+ ${!_amendmentHasIssueRefs(delta.fieldName) ? delta.newOrDeltaValue : ''}
+ ${delta.oldValue ? `(was: ${delta.oldValue})` : ''}
+ <br>
+ `)}
+ ${this.comment.descriptionNum ? 'Description was changed.' : ''}
+ </div><br>
+ `;
+ }
+
+ /**
+ * @return {TemplateResult}
+ */
+ _renderBody() {
+ const commentContent = this._showOriginalContent ?
+ this.comment.inboundMessage :
+ this.comment.content;
+ return html`
+ <div class="comment-body">
+ <mr-comment-content
+ ?hidden=${this.comment.descriptionNum}
+ .content=${commentContent}
+ .author=${this.comment.commenter.displayName}
+ ?isDeleted=${this.comment.isDeleted}
+ ></mr-comment-content>
+ <div ?hidden=${this.comment.descriptionNum}>
+ ${(this.comment.attachments || []).map((attachment) => html`
+ <mr-attachment
+ .attachment=${attachment}
+ projectName=${this.comment.projectName}
+ localId=${this.comment.localId}
+ sequenceNum=${this.comment.sequenceNum}
+ ?canDelete=${this.comment.canDelete}
+ ></mr-attachment>
+ `)}
+ </div>
+ </div>
+ `;
+ }
+
+ /**
+ * Displays three dot menu options available to the current user for a given
+ * comment.
+ * @return {Array<MenuItem>}
+ */
+ get _commentOptions() {
+ const options = [];
+ if (_canExpandDeletedComment(this.comment)) {
+ const text =
+ (this._isExpandedIfDeleted ? 'Hide' : 'Show') + ' comment content';
+ options.push({
+ text: text,
+ handler: this._toggleHideDeletedComment.bind(this),
+ });
+ options.push({separator: true});
+ }
+ if (this.comment.canDelete) {
+ const text =
+ (this.comment.isDeleted ? 'Undelete' : 'Delete') + ' comment';
+ options.push({
+ text: text,
+ handler: _deleteComment.bind(null, this.comment),
+ });
+ }
+ if (this.comment.canFlag) {
+ const text = (this.comment.isSpam ? 'Unflag' : 'Flag') + ' comment';
+ options.push({
+ text: text,
+ handler: _flagComment.bind(null, this.comment),
+ });
+ }
+ if (this.comment.inboundMessage) {
+ const text =
+ (this._showOriginalContent ? 'Hide' : 'Show') + ' original email';
+ options.push({
+ text: text,
+ handler: this._toggleShowOriginalContent.bind(this),
+ });
+ }
+ return options;
+ }
+
+ /**
+ * Toggles whether the email of the user who deleted the comment should be
+ * shown.
+ */
+ _toggleShowOriginalContent() {
+ this._showOriginalContent = !this._showOriginalContent;
+ }
+
+ /**
+ * Change if deleted content for a comment is shown or not.
+ */
+ _toggleHideDeletedComment() {
+ this._isExpandedIfDeleted = !this._isExpandedIfDeleted;
+ }
+}
+
+/**
+ * Says whether a comment should be shown or not.
+ * @param {boolean} isExpandedIfDeleted If the user has chosen to see the
+ * deleted comment.
+ * @param {IssueComment} comment
+ * @return {boolean} If the comment should be shown.
+ */
+function _shouldShowComment(isExpandedIfDeleted, comment) {
+ return !comment.isDeleted || isExpandedIfDeleted;
+}
+
+/**
+ * Whether the user can view additional comment options like flagging or
+ * deleting.
+ * @param {IssueComment} comment
+ * @return {boolean}
+ */
+function _shouldOfferCommentOptions(comment) {
+ return comment.canDelete || comment.canFlag;
+}
+
+/**
+ * Whether a user has permission to view a given deleted comment.
+ * @param {IssueComment} comment
+ * @return {boolean}
+ */
+function _canExpandDeletedComment(comment) {
+ return ((comment.isSpam && comment.canFlag) ||
+ (comment.isDeleted && comment.canDelete));
+}
+
+/**
+ * Deletes a given comment or undeletes it if it's already deleted.
+ * @param {IssueComment} comment The comment to delete.
+ */
+async function _deleteComment(comment) {
+ const issueRef = {
+ projectName: comment.projectName,
+ localId: comment.localId,
+ };
+ await prpcClient.call('monorail.Issues', 'DeleteIssueComment', {
+ issueRef,
+ sequenceNum: comment.sequenceNum,
+ delete: comment.isDeleted === undefined,
+ });
+ store.dispatch(issueV0.fetchComments(issueRef));
+}
+
+/**
+ * Sends a request to flag a comment as spam. Flags or unflags based on
+ * the comments existing isSpam state.
+ * @param {IssueComment} comment The comment to flag.
+ */
+async function _flagComment(comment) {
+ const issueRef = {
+ projectName: comment.projectName,
+ localId: comment.localId,
+ };
+ await prpcClient.call('monorail.Issues', 'FlagComment', {
+ issueRef,
+ sequenceNum: comment.sequenceNum,
+ flag: comment.isSpam === undefined,
+ });
+ store.dispatch(issueV0.fetchComments(issueRef));
+}
+
+/**
+ * Finds if a given change in a comment contains issues (ie: for Blocking or
+ * BlockedOn edits), then formats those issues into a list to be rendered by the
+ * frontend.
+ * @param {Amendment} delta
+ * @param {string} projectName The project name the user is currently viewing.
+ * @return {Array<{issue: Issue, text: string}>}
+ */
+function _issuesForAmendment(delta, projectName) {
+ if (!_amendmentHasIssueRefs(delta.fieldName) ||
+ !delta.newOrDeltaValue) {
+ return [];
+ }
+ // TODO(ehmaldonado): Request the issue to check for permissions and display
+ // the issue summary.
+ return delta.newOrDeltaValue.split(' ').map((deltaValue) => {
+ let refString = deltaValue;
+
+ // When an issue is removed, its ID is prepended with a minus sign.
+ if (refString.startsWith('-')) {
+ refString = refString.substr(1);
+ }
+ const issueRef = issueStringToRef(refString, projectName);
+ return {
+ issue: {
+ ...issueRef,
+ },
+ text: deltaValue,
+ };
+ });
+}
+
+/**
+ * Check if a field is one of the field types that accepts issues as input.
+ * @param {string} fieldName
+ * @return {boolean} If the field contains issues.
+ */
+function _amendmentHasIssueRefs(fieldName) {
+ return ISSUE_REF_FIELD_NAMES.includes(fieldName);
+}
+
+customElements.define('mr-comment', MrComment);
diff --git a/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js b/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js
new file mode 100644
index 0000000..6933825
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-comment-list/mr-comment.test.js
@@ -0,0 +1,257 @@
+// 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 {MrComment} from './mr-comment.js';
+
+
+let element;
+
+/**
+ * Testing helper to find if an Array of options has an option with some
+ * text.
+ * @param {Array<MenuItem>} options Dropdown options to look through.
+ * @param {string} needle The text to search for.
+ * @return {boolean} Whether the option exists or not.
+ */
+const hasOptionWithText = (options, needle) => {
+ return options.some(({text}) => text === needle);
+};
+
+describe('mr-comment', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-comment');
+ element.comment = {
+ canFlag: true,
+ localId: 898395,
+ canDelete: true,
+ projectName: 'chromium',
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ content: 'foo',
+ sequenceNum: 3,
+ timestamp: 1549319989,
+ };
+ document.body.appendChild(element);
+
+ // Stub RAF to execute immediately.
+ sinon.stub(window, 'requestAnimationFrame').callsFake((func) => func());
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ window.requestAnimationFrame.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrComment);
+ });
+
+ it('scrolls to comment', async () => {
+ sinon.stub(element, 'scrollIntoView');
+
+ element.highlighted = true;
+ await element.updateComplete;
+
+ assert.isTrue(element.scrollIntoView.calledOnce);
+
+ element.scrollIntoView.restore();
+ });
+
+ it('comment header renders self link to comment', async () => {
+ element.comment = {
+ localId: 1,
+ projectName: 'test',
+ sequenceNum: 2,
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ };
+
+ await element.updateComplete;
+
+ const link = element.shadowRoot.querySelector('.comment-link');
+
+ assert.equal(link.textContent, 'Comment 2');
+ assert.include(link.href, '?id=1#c2');
+ });
+
+ it('renders issue links for Blockedon issue amendments', async () => {
+ element.comment = {
+ projectName: 'test',
+ amendments: [
+ {
+ fieldName: 'Blockedon',
+ newOrDeltaValue: '-2 3',
+ },
+ ],
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ };
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+ assert.equal(links.length, 2);
+
+ assert.equal(links[0].text, '-2');
+ assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+ assert.equal(links[1].text, '3');
+ assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+ });
+
+ it('renders issue links for Blocking issue amendments', async () => {
+ element.comment = {
+ projectName: 'test',
+ amendments: [
+ {
+ fieldName: 'Blocking',
+ newOrDeltaValue: '-2 3',
+ },
+ ],
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ };
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+ assert.equal(links.length, 2);
+
+ assert.equal(links[0].text, '-2');
+ assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+ assert.equal(links[1].text, '3');
+ assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+ });
+
+ it('renders issue links for Mergedinto issue amendments', async () => {
+ element.comment = {
+ projectName: 'test',
+ amendments: [
+ {
+ fieldName: 'Mergedinto',
+ newOrDeltaValue: '-2 3',
+ },
+ ],
+ commenter: {
+ displayName: 'user@example.com',
+ userId: '12345',
+ },
+ };
+
+ await element.updateComplete;
+
+ const links = element.shadowRoot.querySelectorAll('mr-issue-link');
+
+ assert.equal(links.length, 2);
+
+ assert.equal(links[0].text, '-2');
+ assert.deepEqual(links[0].href, '/p/test/issues/detail?id=2');
+
+ assert.equal(links[1].text, '3');
+ assert.deepEqual(links[1].href, '/p/test/issues/detail?id=3');
+ });
+
+ describe('3-dot menu options', () => {
+ it('allows showing deleted comment content', () => {
+ element._isExpandedIfDeleted = false;
+
+ // The comment is deleted.
+ element.comment = {content: 'test', isDeleted: true, canDelete: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Show comment content'));
+
+ // The comment is spam.
+ element.comment = {content: 'test', isSpam: true, canFlag: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Show comment content'));
+ });
+
+ it('allows hiding deleted comment content', () => {
+ element._isExpandedIfDeleted = true;
+
+ // The comment is deleted.
+ element.comment = {content: 'test', isDeleted: true, canDelete: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Hide comment content'));
+
+ // The comment is spam.
+ element.comment = {content: 'test', isSpam: true, canFlag: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Hide comment content'));
+ });
+
+ it('disallows showing deleted comment content', () => {
+ // The comment is deleted.
+ element.comment = {content: 'test', isDeleted: true, canDelete: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Hide comment content'));
+
+ // The comment is spam.
+ element.comment = {content: 'test', isSpam: true, canFlag: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Hide comment content'));
+ });
+
+ it('allows deleting comment', () => {
+ element.comment = {content: 'test', isDeleted: false, canDelete: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Delete comment'));
+ });
+
+ it('disallows deleting comment', () => {
+ element.comment = {content: 'test', isDeleted: false, canDelete: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Delete comment'));
+ });
+
+ it('allows undeleting comment', () => {
+ element.comment = {content: 'test', isDeleted: true, canDelete: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Undelete comment'));
+ });
+
+ it('disallows undeleting comment', () => {
+ element.comment = {content: 'test', isDeleted: true, canDelete: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Undelete comment'));
+ });
+
+ it('allows flagging comment as spam', () => {
+ element.comment = {content: 'test', isSpam: false, canFlag: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Flag comment'));
+ });
+
+ it('disallows flagging comment as spam', () => {
+ element.comment = {content: 'test', isSpam: false, canFlag: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Flag comment'));
+ });
+
+ it('allows unflagging comment as spam', () => {
+ element.comment = {content: 'test', isSpam: true, canFlag: true};
+ assert.isTrue(hasOptionWithText(element._commentOptions,
+ 'Unflag comment'));
+ });
+
+ it('disallows unflagging comment as spam', () => {
+ element.comment = {content: 'test', isSpam: true, canFlag: false};
+ assert.isFalse(hasOptionWithText(element._commentOptions,
+ 'Unflag comment'));
+ });
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-flipper/mr-flipper.js b/static_src/elements/issue-detail/mr-flipper/mr-flipper.js
new file mode 100644
index 0000000..8159e01
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-flipper/mr-flipper.js
@@ -0,0 +1,151 @@
+// 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 qs from 'qs';
+import {connectStore} from 'reducers/base.js';
+import * as sitewide from 'reducers/sitewide.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+/**
+ * Class for displaying a single flipper.
+ * @extends {LitElement}
+ */
+export default class MrFlipper extends connectStore(LitElement) {
+ /** @override */
+ static get properties() {
+ return {
+ currentIndex: {type: Number},
+ totalCount: {type: Number},
+ prevUrl: {type: String},
+ nextUrl: {type: String},
+ listUrl: {type: String},
+ queryParams: {type: Object},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.currentIndex = null;
+ this.totalCount = null;
+ this.prevUrl = null;
+ this.nextUrl = null;
+ this.listUrl = null;
+
+ this.queryParams = {};
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.queryParams = sitewide.queryParams(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('queryParams')) {
+ this.fetchFlipperData(qs.stringify(this.queryParams));
+ }
+ }
+
+ // Eventually this should be replaced with pRPC.
+ fetchFlipperData(query) {
+ const options = {
+ credentials: 'include',
+ method: 'GET',
+ };
+ fetch(`detail/flipper?${query}`, options).then(
+ (response) => response.text(),
+ ).then(
+ (responseBody) => {
+ let responseData;
+ try {
+ // Strip XSSI prefix from response.
+ responseData = JSON.parse(responseBody.substr(5));
+ } catch (e) {
+ console.error(`Error parsing JSON response for flipper: ${e}`);
+ return;
+ }
+ this._populateResponseData(responseData);
+ },
+ );
+ }
+
+ _populateResponseData(data) {
+ this.totalCount = data.total_count;
+ this.currentIndex = data.cur_index;
+ this.prevUrl = data.prev_url;
+ this.nextUrl = data.next_url;
+ this.listUrl = data.list_url;
+ }
+
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ display: flex;
+ justify-content: center;
+ flex-direction: column;
+ }
+ /* Use visibility instead of display:hidden for hiding in order to
+ * avoid popping when elements are made visible. */
+ .row a[hidden], .counts[hidden] {
+ visibility: hidden;
+ }
+ .counts[hidden] {
+ display: block;
+ }
+ .row a {
+ display: block;
+ padding: 0.25em 0;
+ }
+ .row a, .row div {
+ flex: 1;
+ white-space: nowrap;
+ padding: 0 2px;
+ }
+ .row .counts {
+ padding: 0 16px;
+ }
+ .row {
+ display: flex;
+ align-items: baseline;
+ text-align: center;
+ flex-direction: row;
+ }
+ @media (max-width: 960px) {
+ :host {
+ display: inline-block;
+ }
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <div class="row">
+ <a href="${this.prevUrl}" ?hidden="${!this.prevUrl}" title="Prev" class="prev-url">
+ ‹ Prev
+ </a>
+ <div class="counts" ?hidden=${!this.totalCount}>
+ ${this.currentIndex + 1} of ${this.totalCount}
+ </div>
+ <a href="${this.nextUrl}" ?hidden="${!this.nextUrl}" title="Next" class="next-url">
+ Next ›
+ </a>
+ </div>
+ <div class="row">
+ <a href="${this.listUrl}" ?hidden="${!this.listUrl}" title="Back to list" class="list-url">
+ Back to list
+ </a>
+ </div>
+ `;
+ }
+}
+
+window.customElements.define('mr-flipper', MrFlipper);
diff --git a/static_src/elements/issue-detail/mr-flipper/mr-flipper.test.js b/static_src/elements/issue-detail/mr-flipper/mr-flipper.test.js
new file mode 100644
index 0000000..183a8d5
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-flipper/mr-flipper.test.js
@@ -0,0 +1,75 @@
+// 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 MrFlipper from './mr-flipper.js';
+import sinon from 'sinon';
+
+const xssiPrefix = ')]}\'';
+
+let element;
+
+describe('mr-flipper', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-flipper');
+ document.body.appendChild(element);
+
+ sinon.stub(window, 'fetch');
+
+ const response = new window.Response(`${xssiPrefix}{"message": "Ok"}`, {
+ status: 201,
+ headers: {
+ 'Content-type': 'application/json',
+ },
+ });
+ window.fetch.returns(Promise.resolve(response));
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ window.fetch.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrFlipper);
+ });
+
+ it('renders links', async () => {
+ // Test DOM after properties are updated.
+ element._populateResponseData({
+ cur_index: 4,
+ total_count: 13,
+ prev_url: 'http://prevurl/',
+ next_url: 'http://nexturl/',
+ list_url: 'http://listurl/',
+ });
+
+ await element.updateComplete;
+
+ const prevUrlEl = element.shadowRoot.querySelector('a.prev-url');
+ const nextUrlEl = element.shadowRoot.querySelector('a.next-url');
+ const listUrlEl = element.shadowRoot.querySelector('a.list-url');
+ const countsEl = element.shadowRoot.querySelector('div.counts');
+
+ assert.equal(prevUrlEl.href, 'http://prevurl/');
+ assert.equal(nextUrlEl.href, 'http://nexturl/');
+ assert.equal(listUrlEl.href, 'http://listurl/');
+ assert.include(countsEl.innerText, '5 of 13');
+ });
+
+ it('fetches flipper data when queryParams change', async () => {
+ await element.updateComplete;
+
+ sinon.stub(element, 'fetchFlipperData');
+
+ element.queryParams = {id: 21, q: 'owner:me'};
+
+ sinon.assert.notCalled(element.fetchFlipperData);
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchFlipperData, 'id=21&q=owner%3Ame');
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.js b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.js
new file mode 100644
index 0000000..bd88b3f
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.js
@@ -0,0 +1,162 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as ui from 'reducers/ui.js';
+import 'elements/framework/mr-comment-content/mr-description.js';
+import '../mr-comment-list/mr-comment-list.js';
+import '../metadata/mr-edit-metadata/mr-edit-issue.js';
+import {commentListToDescriptionList} from 'shared/convertersV0.js';
+
+/**
+ * `<mr-issue-details>`
+ *
+ * This is the main details section for a given issue.
+ *
+ */
+export class MrIssueDetails extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ let comments = [];
+ let descriptions = [];
+
+ if (this.commentsByApproval && this.commentsByApproval.has('')) {
+ // Comments without an approval go into the main view.
+ const mainComments = this.commentsByApproval.get('');
+ comments = mainComments.slice(1);
+ descriptions = commentListToDescriptionList(mainComments);
+ }
+
+ return html`
+ <style>
+ mr-issue-details {
+ font-size: var(--chops-main-font-size);
+ background-color: var(--chops-white);
+ padding-bottom: 1em;
+ display: flex;
+ align-items: stretch;
+ justify-content: flex-start;
+ flex-direction: column;
+ margin: 0;
+ box-sizing: border-box;
+ }
+ h3 {
+ margin-top: 1em;
+ }
+ mr-description {
+ margin-bottom: 1em;
+ }
+ mr-edit-issue {
+ margin-top: 40px;
+ }
+ </style>
+ <mr-description .descriptionList=${descriptions}></mr-description>
+ <mr-comment-list
+ headingLevel="2"
+ .comments=${comments}
+ .commentsShownCount=${this.commentsShownCount}
+ ></mr-comment-list>
+ ${this.issuePermissions.includes('addissuecomment') ?
+ html`<mr-edit-issue></mr-edit-issue>` : ''}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ commentsByApproval: {type: Object},
+ commentsShownCount: {type: Number},
+ issuePermissions: {type: Array},
+ };
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.commentsByApproval = new Map();
+ this.issuePermissions = [];
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.commentsByApproval = issueV0.commentsByApprovalName(state);
+ this.issuePermissions = issueV0.permissions(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ super.updated(changedProperties);
+ this._measureCommentLoadTime(changedProperties);
+ }
+
+ async _measureCommentLoadTime(changedProperties) {
+ if (!changedProperties.has('commentsByApproval')) {
+ return;
+ }
+ if (!this.commentsByApproval || this.commentsByApproval.size === 0) {
+ // For cold loads, if the GetIssue call returns before ListComments,
+ // commentsByApproval is initially set to an empty Map. Filter that out.
+ return;
+ }
+ const fullAppLoad = ui.navigationCount(store.getState()) === 1;
+ if (!(fullAppLoad || changedProperties.get('commentsByApproval'))) {
+ // For hot loads, the previous issue data is still in the Redux store, so
+ // the first update sets the comments to the previous issue's comments.
+ // We need to wait for the following update.
+ return;
+ }
+ const startMark = fullAppLoad ? undefined : 'start load issue detail page';
+ if (startMark && !performance.getEntriesByName(startMark).length) {
+ // Modifying the issue template, description, comments, or attachments
+ // triggers a comment update. We only want to include full issue loads.
+ return;
+ }
+
+ await Promise.all(_subtreeUpdateComplete(this));
+
+ const endMark = 'finish load issue detail comments';
+ performance.mark(endMark);
+
+ const measurementType = fullAppLoad ? 'from outside app' : 'within app';
+ const measurementName = `load issue detail page (${measurementType})`;
+ performance.measure(measurementName, startMark, endMark);
+
+ const measurement =
+ performance.getEntriesByName(measurementName)[0].duration;
+ window.getTSMonClient().recordIssueCommentsLoadTiming(
+ measurement, fullAppLoad);
+
+ // Be sure to clear this mark even on full page navigations.
+ performance.clearMarks('start load issue detail page');
+ performance.clearMarks(endMark);
+ performance.clearMeasures(measurementName);
+ }
+}
+
+/**
+ * Recursively traverses all shadow DOMs in an element subtree and returns an
+ * Array containing the updateComplete Promises for all lit-element nodes.
+ * @param {!LitElement} element
+ * @return {!Array<Promise<Boolean>>}
+ */
+function _subtreeUpdateComplete(element) {
+ if (!element.updateComplete) {
+ return [];
+ }
+
+ const context = element.shadowRoot ? element.shadowRoot : element;
+ const children = context.querySelectorAll('*');
+ const childPromises = Array.from(children, (e) => _subtreeUpdateComplete(e));
+ return [element.updateComplete].concat(...childPromises);
+}
+
+customElements.define('mr-issue-details', MrIssueDetails);
diff --git a/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.test.js b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.test.js
new file mode 100644
index 0000000..3919e15
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-issue-details/mr-issue-details.test.js
@@ -0,0 +1,39 @@
+// 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 {MrIssueDetails} from './mr-issue-details.js';
+
+let element;
+
+describe('mr-issue-details', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-issue-details');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueDetails);
+ });
+
+ it('mr-edit-issue is displayed if user has addissuecomment', async () => {
+ element.issuePermissions = ['addissuecomment'];
+
+ await element.updateComplete;
+
+ assert.isNotNull(element.querySelector('mr-edit-issue'));
+ });
+
+ it('mr-edit-issue is hidden if user has no addissuecomment', async () => {
+ element.issuePermissions = [];
+
+ await element.updateComplete;
+
+ assert.isNull(element.querySelector('mr-edit-issue'));
+ });
+});
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);
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js
new file mode 100644
index 0000000..741baaa
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.js
@@ -0,0 +1,102 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import {connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import './mr-phase.js';
+
+/**
+ * `<mr-launch-overview>`
+ *
+ * This is a shorthand view of the phases for a user to see a quick overview.
+ *
+ */
+export class MrLaunchOverview extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons"
+ rel="stylesheet">
+ <style>
+ mr-launch-overview {
+ max-width: 100%;
+ display: flex;
+ flex-flow: column;
+ justify-content: flex-start;
+ align-items: stretch;
+ }
+ mr-launch-overview[hidden] {
+ display: none;
+ }
+ mr-phase {
+ margin-bottom: 0.75em;
+ }
+ </style>
+ ${this.phases.map((phase) => html`
+ <mr-phase
+ .phaseName=${phase.phaseRef.phaseName}
+ .approvals=${this._approvalsForPhase(this.approvals, phase.phaseRef.phaseName)}
+ ></mr-phase>
+ `)}
+ ${this._phaselessApprovals.length ? html`
+ <mr-phase .approvals=${this._phaselessApprovals}></mr-phase>
+ `: ''}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ approvals: {type: Array},
+ phases: {type: Array},
+ hidden: {
+ type: Boolean,
+ reflect: true,
+ },
+ };
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ constructor() {
+ super();
+ this.approvals = [];
+ this.phases = [];
+ this.hidden = true;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ if (!issueV0.viewedIssue(state)) return;
+
+ this.approvals = issueV0.viewedIssue(state).approvalValues || [];
+ this.phases = issueV0.viewedIssue(state).phases || [];
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('phases') || changedProperties.has('approvals')) {
+ this.hidden = !this.phases.length && !this.approvals.length;
+ }
+ super.update(changedProperties);
+ }
+
+ get _phaselessApprovals() {
+ return this._approvalsForPhase(this.approvals);
+ }
+
+ _approvalsForPhase(approvals, phaseName) {
+ return (approvals || []).filter((a) => {
+ // We can assume phase names will be unique.
+ return a.phaseRef.phaseName == phaseName;
+ });
+ }
+}
+customElements.define('mr-launch-overview', MrLaunchOverview);
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js
new file mode 100644
index 0000000..3e2ff46
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-launch-overview.test.js
@@ -0,0 +1,24 @@
+// 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 {MrLaunchOverview} from './mr-launch-overview.js';
+
+
+let element;
+
+describe('mr-launch-overview', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-launch-overview');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrLaunchOverview);
+ });
+});
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js
new file mode 100644
index 0000000..a81be65
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.js
@@ -0,0 +1,460 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {store, connectStore} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import '../mr-approval-card/mr-approval-card.js';
+import {valueForField, valuesForField} from 'shared/metadata-helpers.js';
+import 'elements/issue-detail/metadata/mr-edit-metadata/mr-edit-metadata.js';
+import 'elements/issue-detail/metadata/mr-metadata/mr-field-values.js';
+
+const TARGET_PHASE_MILESTONE_MAP = {
+ 'Beta': 'feature_freeze',
+ 'Stable-Exp': 'final_beta_cut',
+ 'Stable': 'stable_cut',
+ 'Stable-Full': 'stable_cut',
+};
+
+const APPROVED_PHASE_MILESTONE_MAP = {
+ 'Beta': 'earliest_beta',
+ 'Stable-Exp': 'final_beta',
+ 'Stable': 'stable_date',
+ 'Stable-Full': 'stable_date',
+};
+
+// The following milestones are unique to ios.
+const IOS_APPROVED_PHASE_MILESTONE_MAP = {
+ 'Beta': 'earliest_beta_ios',
+};
+
+// See monorail:4692 and the use of PHASES_WITH_MILESTONES
+// in tracker/issueentry.py
+const PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full'];
+
+/**
+ * `<mr-phase>`
+ *
+ * This is the component for a single phase.
+ *
+ */
+export class MrPhase extends connectStore(LitElement) {
+ /** @override */
+ render() {
+ const isPhaseWithMilestone = PHASES_WITH_MILESTONES.includes(
+ this.phaseName);
+ const noApprovals = !this.approvals || !this.approvals.length;
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <style>
+ mr-phase {
+ display: block;
+ }
+ mr-phase chops-dialog {
+ --chops-dialog-theme: {
+ width: 500px;
+ max-width: 100%;
+ };
+ }
+ mr-phase h2 {
+ margin: 0;
+ font-size: var(--chops-large-font-size);
+ font-weight: normal;
+ padding: 0.5em 8px;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: space-between;
+ }
+ mr-phase h2 em {
+ margin-left: 16px;
+ font-size: var(--chops-main-font-size);
+ }
+ mr-phase .chip {
+ display: inline-block;
+ font-size: var(--chops-main-font-size);
+ padding: 0.25em 8px;
+ margin: 0 2px;
+ border-radius: 16px;
+ background: var(--chops-blue-gray-50);
+ }
+ .phase-edit {
+ padding: 0.1em 8px;
+ }
+ </style>
+ <h2>
+ <div>
+ Approvals<span ?hidden=${!this.phaseName || !this.phaseName.length}>:
+ ${this.phaseName}
+ </span>
+ ${isPhaseWithMilestone ? html`${this.fieldDefs &&
+ this.fieldDefs.map((field) => this._renderPhaseField(field))}
+ <em ?hidden=${!this._nextDate}>
+ ${this._dateDescriptor}
+ <chops-timestamp .timestamp=${this._nextDate}></chops-timestamp>
+ </em>
+ <em ?hidden=${!this._nextUniqueiOSDate}>
+ <b>iOS</b> ${this._dateDescriptor}
+ <chops-timestamp .timestamp=${this._nextUniqueiOSDate}
+ ></chops-timestamp>
+ </em>
+ `: ''}
+ </div>
+ ${isPhaseWithMilestone ? html`
+ <chops-button @click=${this.edit} class="de-emphasized phase-edit">
+ <i class="material-icons" role="presentation">create</i>
+ Edit
+ </chops-button>
+ `: ''}
+ </h2>
+ ${this.approvals && this.approvals.map((approval) => html`
+ <mr-approval-card
+ .approvers=${approval.approverRefs}
+ .setter=${approval.setterRef}
+ .fieldName=${approval.fieldRef.fieldName}
+ .phaseName=${this.phaseName}
+ .statusEnum=${approval.status}
+ .survey=${approval.survey}
+ .surveyTemplate=${approval.surveyTemplate}
+ .urls=${approval.urls}
+ .labels=${approval.labels}
+ .users=${approval.users}
+ ></mr-approval-card>
+ `)}
+ ${noApprovals ? html`No tasks for this phase.` : ''}
+ <!-- TODO(ehmaldonado): Move to /issue-detail/dialogs -->
+ <chops-dialog id="editPhase" aria-labelledby="phaseDialogTitle">
+ <h3 id="phaseDialogTitle" class="medium-heading">
+ Editing phase: ${this.phaseName}
+ </h3>
+ <mr-edit-metadata
+ id="metadataForm"
+ class="edit-actions-right"
+ .formName=${this.phaseName}
+ .fieldDefs=${this.fieldDefs}
+ .phaseName=${this.phaseName}
+ ?disabled=${this._updatingIssue}
+ .error=${this._updateIssueError && this._updateIssueError.description}
+ @save=${this.save}
+ @discard=${this.cancel}
+ isApproval
+ disableAttachments
+ ></mr-edit-metadata>
+ </chops-dialog>
+ `;
+ }
+
+ /**
+ *
+ * @param {FieldDef} field The field to be rendered.
+ * @return {TemplateResult}
+ * @private
+ */
+ _renderPhaseField(field) {
+ const values = valuesForField(this._fieldValueMap, field.fieldRef.fieldName,
+ this.phaseName);
+ return html`
+ <div class="chip">
+ ${field.fieldRef.fieldName}:
+ <mr-field-values
+ .name=${field.fieldRef.fieldName}
+ .type=${field.fieldRef.type}
+ .values=${values}
+ .projectName=${this.issueRef.projectName}
+ ></mr-field-values>
+ </div>
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ issue: {type: Object},
+ issueRef: {type: Object},
+ phaseName: {type: String},
+ approvals: {type: Array},
+ fieldDefs: {type: Array},
+
+ _updatingIssue: {type: Boolean},
+ _updateIssueError: {type: Object},
+ _fieldValueMap: {type: Object},
+ _milestoneData: {type: Object},
+ _isFetchingMilestone: {type: Boolean},
+ _fetchedMilestone: {type: String},
+ };
+ }
+
+ /** @override */
+ createRenderRoot() {
+ return this;
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ this.issue = {};
+ this.issueRef = {};
+ this.phaseName = '';
+ this.approvals = [];
+ this.fieldDefs = [];
+
+ this._updatingIssue = false;
+ this._updateIssueError = undefined;
+
+ // A response Object from
+ // https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+ this._milestoneData = {};
+ this._isFetchingMilestone = false;
+ this._fetchedMilestone = undefined;
+ /**
+ * @type {Promise} Used for testing to allow waiting for milestone
+ * fetch operations to finish.
+ */
+ this._fetchMilestoneComplete = undefined;
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.issue = issueV0.viewedIssue(state);
+ this.issueRef = issueV0.viewedIssueRef(state);
+ this.fieldDefs = projectV0.fieldDefsForPhases(state);
+ this._updatingIssue = issueV0.requests(state).update.requesting;
+ this._updateIssueError = issueV0.requests(state).update.error;
+ this._fieldValueMap = issueV0.fieldValueMap(state);
+ }
+
+ /** @override */
+ updated(changedProperties) {
+ if (changedProperties.has('issue')) {
+ this.reset();
+ }
+ if (changedProperties.has('_updatingIssue')) {
+ if (!this._updatingIssue && !this._updateIssueError) {
+ // Close phase edit modal only after a request finishes without errors.
+ this.cancel();
+ }
+ }
+
+ if (!this._isFetchingMilestone) {
+ const milestoneToFetch = this._milestoneToFetch;
+ if (milestoneToFetch && this._fetchedMilestone !== milestoneToFetch) {
+ this._fetchMilestoneComplete = this.fetchMilestoneData(
+ milestoneToFetch);
+ }
+ }
+ }
+
+ /**
+ * Makes an XHR request to Chromium Dash to find Chrome-specific launch data.
+ * eg. when certain Chrome milestones are planned for release.
+ * @param {string} milestone A string containing a Chrome milestone number.
+ * @return {Promise<void>}
+ */
+ async fetchMilestoneData(milestone) {
+ this._isFetchingMilestone = true;
+
+ try {
+ const resp = await window.fetch(
+ `https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=${
+ milestone}`);
+ this._milestoneData = await resp.json();
+ } catch (error) {
+ console.error(`Error when fetching milestone data: ${error}`);
+ }
+ this._fetchedMilestone = milestone;
+ this._isFetchingMilestone = false;
+ }
+
+ /**
+ * Opens the phase editing dialog when the user clicks the edit button.
+ */
+ edit() {
+ this.reset();
+ this.querySelector('#editPhase').open();
+ }
+
+ /**
+ * Stops editing the phase.
+ */
+ cancel() {
+ this.querySelector('#editPhase').close();
+ this.reset();
+ }
+
+ /**
+ * Resets the edit form to its default values.
+ */
+ reset() {
+ const form = this.querySelector('#metadataForm');
+ form.reset();
+ }
+
+ /**
+ * Saves the changes the user has made.
+ */
+ save() {
+ const form = this.querySelector('#metadataForm');
+ const delta = form.delta;
+
+ if (delta.fieldValsAdd) {
+ delta.fieldValsAdd = delta.fieldValsAdd.map(
+ (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
+ }
+ if (delta.fieldValsRemove) {
+ delta.fieldValsRemove = delta.fieldValsRemove.map(
+ (fv) => Object.assign({phaseRef: {phaseName: this.phaseName}}, fv));
+ }
+
+ const message = {
+ issueRef: this.issueRef,
+ delta: delta,
+ sendEmail: form.sendEmail,
+ commentContent: form.getCommentContent(),
+ };
+
+ if (message.commentContent || message.delta) {
+ store.dispatch(issueV0.update(message));
+ }
+ }
+
+ /**
+ * Shows the next relevant Chrome Milestone date for this phase. Depending
+ * on the M-Target, M-Approved, or M-Launched values, this date means
+ * different things.
+ * @return {number} Unix timestamp in seconds.
+ * @private
+ */
+ get _nextDate() {
+ const phaseName = this.phaseName;
+ const status = this._status;
+ let data = this._milestoneData && this._milestoneData.mstones;
+ // Data pulled from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+ if (!phaseName || !status || !data || !data.length) return 0;
+ data = data[0];
+
+ let key = TARGET_PHASE_MILESTONE_MAP[phaseName];
+ if (['Approved', 'Launched'].includes(status)) {
+ const osValues = this._fieldValueMap.get('OS');
+ // If iOS is the only OS and the phase is one where iOS has unique
+ // milestones, the only date we show should be this._nextUniqueiOSDate.
+ if (osValues && osValues.every((os) => {
+ return os === 'iOS';
+ }) && phaseName in IOS_APPROVED_PHASE_MILESTONE_MAP) {
+ return 0;
+ }
+ key = APPROVED_PHASE_MILESTONE_MAP[phaseName];
+ }
+ if (!key || !(key in data)) return 0;
+ return Math.floor((new Date(data[key])).getTime() / 1000);
+ }
+
+ /**
+ * For issues where iOS is the OS, this function finds the relevant iOS
+ * launch date.
+ * @return {number} Unix timestamp in seconds.
+ * @private
+ */
+ get _nextUniqueiOSDate() {
+ const phaseName = this.phaseName;
+ const status = this._status;
+ let data = this._milestoneData && this._milestoneData.mstones;
+ // Data pull from https://chromiumdash.appspot.com/fetch_milestone_schedule?mstone=xx
+ if (!phaseName || !status || !data || !data.length) return 0;
+ data = data[0];
+
+ const osValues = this._fieldValueMap.get('OS');
+ if (['Approved', 'Launched'].includes(status) &&
+ osValues && osValues.includes('iOS')) {
+ const key = IOS_APPROVED_PHASE_MILESTONE_MAP[phaseName];
+ if (key) {
+ return Math.floor((new Date(data[key])).getTime() / 1000);
+ }
+ }
+ return 0;
+ }
+
+ /**
+ * Depending on what kind of date we're showing, we want to include
+ * different text to describe the date.
+ * @return {string}
+ * @private
+ */
+ get _dateDescriptor() {
+ const status = this._status;
+ if (status === 'Approved') {
+ return 'Launching on ';
+ } else if (status === 'Launched') {
+ return 'Launched on ';
+ }
+ return 'Due by ';
+ }
+
+ /**
+ * The Chrome-specific status of a gate, computed from M-Approved,
+ * M-Launched, and M-Target fields.
+ * @return {string}
+ * @private
+ */
+ get _status() {
+ const target = this._targetMilestone;
+ const approved = this._approvedMilestone;
+ const launched = this._launchedMilestone;
+ if (approved >= target) {
+ if (launched >= approved) {
+ return 'Launched';
+ }
+ return 'Approved';
+ }
+ return 'Target';
+ }
+
+ /**
+ * The Chrome Milestone that this phase was approved for.
+ * @return {string}
+ * @private
+ */
+ get _approvedMilestone() {
+ return valueForField(this._fieldValueMap, 'M-Approved', this.phaseName);
+ }
+
+ /**
+ * The Chrome Milestone that this phase was launched on.
+ * @return {string}
+ * @private
+ */
+ get _launchedMilestone() {
+ return valueForField(this._fieldValueMap, 'M-Launched', this.phaseName);
+ }
+
+ /**
+ * The Chrome Milestone that this phase is targeting.
+ * @return {string}
+ * @private
+ */
+ get _targetMilestone() {
+ return valueForField(this._fieldValueMap, 'M-Target', this.phaseName);
+ }
+
+ /**
+ * The Chrome Milestone that's used to decide what date to show the user.
+ * @return {string}
+ * @private
+ */
+ get _milestoneToFetch() {
+ const target = Number.parseInt(this._targetMilestone) || 0;
+ const approved = Number.parseInt(this._approvedMilestone) || 0;
+ const launched = Number.parseInt(this._launchedMilestone) || 0;
+
+ const latestMilestone = Math.max(target, approved, launched);
+ return latestMilestone > 0 ? `${latestMilestone}` : '';
+ }
+}
+
+
+customElements.define('mr-phase', MrPhase);
diff --git a/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js
new file mode 100644
index 0000000..d55897e
--- /dev/null
+++ b/static_src/elements/issue-detail/mr-launch-overview/mr-phase.test.js
@@ -0,0 +1,209 @@
+// 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 {MrPhase} from './mr-phase.js';
+
+
+let element;
+
+describe('mr-phase', () => {
+ beforeEach(() => {
+ element = document.createElement('mr-phase');
+ document.body.appendChild(element);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrPhase);
+ });
+
+ it('clicking edit button opens edit dialog', async () => {
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ const editDialog = element.querySelector('#editPhase');
+ assert.isFalse(editDialog.opened);
+
+ element.querySelector('.phase-edit').click();
+
+ await element.updateComplete;
+
+ assert.isTrue(editDialog.opened);
+ });
+
+ it('discarding form changes closes dialog', async () => {
+ await element.updateComplete;
+
+ // Open the edit dialog.
+ element.edit();
+ const editDialog = element.querySelector('#editPhase');
+ const editForm = element.querySelector('#metadataForm');
+
+ await element.updateComplete;
+
+ assert.isTrue(editDialog.opened);
+ editForm.discard();
+
+ await element.updateComplete;
+
+ assert.isFalse(editDialog.opened);
+ });
+
+ describe('milestone fetching', () => {
+ beforeEach(() => {
+ sinon.stub(element, 'fetchMilestoneData');
+ });
+
+ it('_launchedMilestone extracts M-Launched for phase', () => {
+ element._fieldValueMap = new Map([['m-launched beta', ['87']]]);
+ element.phaseName = 'Beta';
+
+ assert.equal(element._launchedMilestone, '87');
+ assert.equal(element._approvedMilestone, undefined);
+ assert.equal(element._targetMilestone, undefined);
+ });
+
+ it('_approvedMilestone extracts M-Approved for phase', () => {
+ element._fieldValueMap = new Map([['m-approved beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ assert.equal(element._launchedMilestone, undefined);
+ assert.equal(element._approvedMilestone, '86');
+ assert.equal(element._targetMilestone, undefined);
+ });
+
+ it('_targetMilestone extracts M-Target for phase', () => {
+ element._fieldValueMap = new Map([['m-target beta', ['85']]]);
+ element.phaseName = 'Beta';
+
+ assert.equal(element._launchedMilestone, undefined);
+ assert.equal(element._approvedMilestone, undefined);
+ assert.equal(element._targetMilestone, '85');
+ });
+
+ it('_milestoneToFetch returns empty when no relevant milestone', () => {
+ element._fieldValueMap = new Map([['m-target beta', ['85']]]);
+ element.phaseName = 'Stable';
+
+ assert.equal(element._milestoneToFetch, '');
+ });
+
+ it('_milestoneToFetch selects highest milestone', () => {
+ element._fieldValueMap = new Map([
+ ['m-target beta', ['84']],
+ ['m-approved beta', ['85']],
+ ['m-launched beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ assert.equal(element._milestoneToFetch, '86');
+ });
+
+ it('does not fetch when no milestones specified', async () => {
+ element.issue = {projectName: 'chromium', localId: 12};
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.fetchMilestoneData);
+ });
+
+ it('does not fetch when milestone to fetch is unchanged', async () => {
+ element._fetchedMilestone = '86';
+ element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.fetchMilestoneData);
+ });
+
+ it('fetches when milestone found', async () => {
+ element._fetchedMilestone = undefined;
+ element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchMilestoneData, '86');
+ });
+
+ it('re-fetches when new milestone found', async () => {
+ element._fetchedMilestone = '86';
+ element._fieldValueMap = new Map([
+ ['m-target beta', ['86']],
+ ['m-launched beta', ['87']]]);
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchMilestoneData, '87');
+ });
+
+ it('re-fetches only after last stale fetch finishes', async () => {
+ element._fetchedMilestone = '84';
+ element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+ element.phaseName = 'Beta';
+ element._isFetchingMilestone = true;
+
+ await element.updateComplete;
+
+ sinon.assert.notCalled(element.fetchMilestoneData);
+
+ // Previous in flight fetch finishes.
+ element._fetchedMilestone = '85';
+ element._isFetchingMilestone = false;
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchMilestoneData, '86');
+ });
+ });
+
+ describe('milestone fetching with fake server responses', () => {
+ beforeEach(() => {
+ sinon.stub(window, 'fetch');
+ sinon.spy(element, 'fetchMilestoneData');
+ });
+
+ afterEach(() => {
+ window.fetch.restore();
+ });
+
+ it('does not refetch when server response finishes', async () => {
+ const response = new window.Response('{"mstones": [{"mstone": 86}]}', {
+ status: 200,
+ headers: {
+ 'Content-type': 'application/json',
+ },
+ });
+
+ window.fetch.returns(Promise.resolve(response));
+
+ element._fieldValueMap = new Map([['m-target beta', ['86']]]);
+ element.phaseName = 'Beta';
+
+ await element.updateComplete;
+
+ sinon.assert.calledWith(element.fetchMilestoneData, '86');
+
+ assert.isTrue(element._isFetchingMilestone);
+
+ await element._fetchMilestoneComplete;
+
+ assert.deepEqual(element._milestoneData, {'mstones': [{'mstone': 86}]});
+ assert.equal(element._fetchedMilestone, '86');
+ assert.isFalse(element._isFetchingMilestone);
+
+ await element.updateComplete;
+
+ sinon.assert.calledOnce(element.fetchMilestoneData);
+ });
+ });
+});