Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
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);
+ });
+ });
+});