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