Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js
new file mode 100644
index 0000000..54565cf
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.js
@@ -0,0 +1,233 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import * as userV0 from 'reducers/userV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {connectStore} from 'reducers/base.js';
+
+/**
+ * `<mr-issue-hotlists-dialog>`
+ *
+ * The base dialog that <mr-move-issue-hotlists-dialog> and
+ * <mr-update-issue-hotlists-dialog> inherits common methods and behaviors from.
+ * <mr-update-issue-hotlists-dialog> is used across multiple pages where as
+ * <mr-move-issue-hotlists-dialog> is largely used within Hotlists.
+ *
+ * Important: The `render` method should be overridden by child classes.
+ */
+export class MrIssueHotlistsDialog extends connectStore(LitElement) {
+ /** @override */
+ static get styles() {
+ return [
+ SHARED_STYLES,
+ css`
+ :host {
+ font-size: var(--chops-main-font-size);
+ --chops-dialog-max-width: 500px;
+ }
+ .error {
+ max-width: 100%;
+ color: red;
+ margin-bottom: 1px;
+ }
+ select,
+ input {
+ box-sizing: border-box;
+ width: var(--mr-edit-field-width);
+ padding: var(--mr-edit-field-padding);
+ font-size: var(--chops-main-font-size);
+ }
+ input#filter {
+ margin-top: 4px;
+ width: 85%;
+ max-width: 240px;
+ }
+ .user-hotlists {
+ max-height: 240px;
+ overflow: auto;
+ }
+ .hotlist.filter-fail {
+ display: none;
+ }
+ i.material-icons {
+ font-size: 20px;
+ margin-right: 4px;
+ vertical-align: bottom;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ render() {
+ return html`
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+ <chops-dialog closeOnOutsideClick>
+ ${this.renderHeader()}
+ ${this.renderContent()}
+ </chops-dialog>
+ `;
+ }
+
+ /**
+ * Renders the dialog header.
+ * @return {TemplateResult}
+ */
+ renderHeader() {
+ return html`
+ <h3 class="medium-heading">Dialog elements below:</h3>
+ `;
+ }
+
+ /**
+ * Renders the dialog content.
+ * @return {TemplateResult}
+ */
+ renderContent() {
+ return html`
+ ${this.renderFilter()}
+ ${this.renderHotlists()}
+ ${this.renderError()}
+ `;
+ }
+
+ /**
+ * Renders the Hotlist filter.
+ * @return {TemplateResult}
+ */
+ renderFilter() {
+ return html`
+ <input id="filter" type="text" @keyup=${this.filterHotlists}>
+ <i class="material-icons">search</i>
+ `;
+ }
+
+ /**
+ * Renders the user's Hotlists.
+ * @return {TemplateResult}
+ */
+ renderHotlists() {
+ return html`
+ <div class="user-hotlists">
+ ${this.filteredHotlists.length ?
+ this.filteredHotlists.map(this.renderFilteredHotlist, this) : ''}
+ </div>
+ `;
+ }
+
+ /**
+ * Renders a user's filtered Hotlist.
+ * @param {HotlistV0} hotlist The user Hotlist to render.
+ * @return {TemplateResult}
+ */
+ renderFilteredHotlist(hotlist) {
+ return html`
+ <div
+ class="hotlist"
+ data-hotlist-name="${hotlist.name}"
+ >
+ ${hotlist.name}
+ </div>`;
+ }
+
+ /**
+ * Renders dialog error.
+ * @return {TemplateResult}
+ */
+ renderError() {
+ return html`
+ <br>
+ ${this.error ? html`
+ <div class="error">${this.error}</div>
+ `: ''}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ // Populated from Redux.
+ userHotlists: {type: Array},
+ filteredHotlists: {type: Array},
+ issueRefs: {type: Array},
+ error: {type: String},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ this.userHotlists = userV0.currentUser(state).hotlists;
+ // TODO(https://crbug.com/monorail/7778): Switch to users.js and use V3 API
+ // to make a call to GatherHotlistsForUser.
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /** @type {Array} */
+ this.userHotlists = [];
+
+ /** @type {Array} */
+ this.filteredHotlists = this.userHotlists;
+
+ /** @type {Array<IssueRef>} */
+ this.issueRefs = [];
+
+ /** @type {string} */
+ this.error = '';
+ }
+
+ /**
+ * Opens the dialog.
+ */
+ open() {
+ this.reset();
+ this.shadowRoot.querySelector('chops-dialog').open();
+ }
+
+ /**
+ * Resets any changes to the form and error.
+ */
+ reset() {
+ this.error = '';
+ const filter = this.shadowRoot.querySelector('#filter');
+ filter.value = '';
+ this.filterHotlists();
+ }
+
+ /**
+ * Closes the dialog.
+ */
+ close() {
+ this.shadowRoot.querySelector('chops-dialog').close();
+ }
+
+ /**
+ * Filters the visible Hotlists with the given user input.
+ * Requires filter to be an input element with its id as "filter".
+ */
+ filterHotlists() {
+ const input = this.shadowRoot.querySelector('#filter');
+ if (!input) {
+ // Short circuit because there's no filter.
+ this.filteredHotlists = this.userHotlists;
+ } else {
+ const filter = input.value.toLowerCase();
+ const visibleHotlists = [];
+ this.userHotlists.forEach((hotlist) => {
+ const hotlistName = hotlist.name.toLowerCase();
+ if (hotlistName.includes(filter)) {
+ visibleHotlists.push(hotlist);
+ }
+ });
+ this.filteredHotlists = visibleHotlists;
+ }
+ }
+}
+
+customElements.define('mr-issue-hotlists-dialog', MrIssueHotlistsDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..911c1a0
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-issue-hotlists-dialog.test.js
@@ -0,0 +1,78 @@
+// Copyright 2020 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 {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog.js';
+
+let element;
+const EXAMPLE_USER_HOTLISTS = [
+ {name: 'Hotlist-1'},
+ {name: 'Hotlist-2'},
+ {name: 'ac-apple-1'},
+ {name: 'ac-frita-1'},
+];
+
+describe('mr-issue-hotlists-dialog', () => {
+ beforeEach(async () => {
+ element = document.createElement('mr-issue-hotlists-dialog');
+ document.body.appendChild(element);
+
+ await element.updateComplete;
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrIssueHotlistsDialog);
+ assert.include(element.shadowRoot.innerHTML, 'Dialog elements below');
+ });
+
+ it('filters hotlists', async () => {
+ element.userHotlists = EXAMPLE_USER_HOTLISTS;
+ element.open();
+ await element.updateComplete;
+
+ const initialHotlists = element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(initialHotlists.length, 4);
+ const filterInput = element.shadowRoot.querySelector('#filter');
+ filterInput.value = 'list';
+ element.filterHotlists();
+ await element.updateComplete;
+ let visibleHotlists =
+ element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(visibleHotlists.length, 2);
+
+ filterInput.value = '2';
+ element.filterHotlists();
+ await element.updateComplete;
+ visibleHotlists =
+ element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(visibleHotlists.length, 1);
+ });
+
+ it('resets filter on open', async () => {
+ element.userHotlists = EXAMPLE_USER_HOTLISTS;
+ element.open();
+ await element.updateComplete;
+
+ const filterInput = element.shadowRoot.querySelector('#filter');
+ filterInput.value = 'ac';
+ element.filterHotlists();
+ await element.updateComplete;
+ let visibleHotlists =
+ element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(visibleHotlists.length, 2);
+
+ element.close();
+ element.open();
+ await element.updateComplete;
+
+ assert.equal(filterInput.value, '');
+ visibleHotlists =
+ element.shadowRoot.querySelectorAll('.hotlist');
+ assert.equal(visibleHotlists.length, 4);
+ });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js
new file mode 100644
index 0000000..e7c1cd3
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js
@@ -0,0 +1,141 @@
+// Copyright 2020 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 {html, css} from 'lit-element';
+
+import 'elements/framework/mr-warning/mr-warning.js';
+import {hotlists} from 'reducers/hotlists.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog';
+
+/**
+ * `<mr-move-issue-hotlists-dialog>`
+ *
+ * Displays a dialog to select the Hotlist to move the provided Issues.
+ */
+export class MrMoveIssueDialog extends MrIssueHotlistsDialog {
+ /** @override */
+ static get styles() {
+ return [
+ super.styles,
+ css`
+ .hotlist {
+ padding: 4px;
+ }
+ .hotlist:hover {
+ background: var(--chops-active-choice-bg);
+ cursor: pointer;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ renderHeader() {
+ const warningText =
+ `Moving issues will remove them from ${this._viewedHotlist ?
+ this._viewedHotlist.displayName : 'this hotlist'}.`;
+ return html`
+ <h3 class="medium-heading">Move issues to hotlist</h3>
+ <mr-warning title=${warningText}>${warningText}</mr-warning>
+ `;
+ }
+
+ /** @override */
+ renderFilteredHotlist(hotlist) {
+ if (this._viewedHotlist &&
+ hotlist.name === this._viewedHotlist.displayName) return;
+ return html`
+ <div
+ class="hotlist"
+ data-hotlist-name="${hotlist.name}"
+ @click=${this._targetHotlistPicked}>
+ ${hotlist.name}
+ </div>`;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ ...MrIssueHotlistsDialog.properties,
+ // Populated from Redux.
+ _viewedHotlist: {type: Object},
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ super.stateChanged(state);
+ this._viewedHotlist = hotlists.viewedHotlist(state);
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /**
+ * The currently viewed Hotlist.
+ * @type {?Hotlist}
+ **/
+ this._viewedHotlist = null;
+ }
+
+ /**
+ * Handles picking a Hotlist to move to.
+ * @param {Event} e
+ */
+ async _targetHotlistPicked(e) {
+ const targetHotlistName = e.target.dataset.hotlistName;
+ const changes = {
+ added: [],
+ removed: [],
+ };
+
+ for (const hotlist of this.userHotlists) {
+ // We move from the current Hotlist to the target Hotlist.
+ if (changes.added.length === 1 && changes.removed.length === 1) break;
+ const change = {
+ name: hotlist.name,
+ owner: hotlist.ownerRef,
+ };
+ if (hotlist.name === targetHotlistName) {
+ changes.added.push(change);
+ } else if (hotlist.name === this._viewedHotlist.displayName) {
+ changes.removed.push(change);
+ }
+ }
+
+ const issueRefs = this.issueRefs;
+ if (!issueRefs) return;
+
+ // TODO(https://crbug.com/monorail/7778): Use action creators.
+ const promises = [];
+ if (changes.added && changes.added.length) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'AddIssuesToHotlists', {
+ hotlistRefs: changes.added,
+ issueRefs,
+ },
+ ));
+ }
+ if (changes.removed && changes.removed.length) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'RemoveIssuesFromHotlists', {
+ hotlistRefs: changes.removed,
+ issueRefs,
+ },
+ ));
+ }
+
+ try {
+ await Promise.all(promises);
+ this.dispatchEvent(new Event('saveSuccess'));
+ this.close();
+ } catch (error) {
+ this.error = error.message || error.description;
+ }
+ }
+}
+
+customElements.define('mr-move-issue-hotlists-dialog', MrMoveIssueDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..7a2dd5c
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.test.js
@@ -0,0 +1,105 @@
+// 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 {MrMoveIssueDialog} from './mr-move-issue-hotlists-dialog.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import * as example from 'shared/test/constants-hotlists.js';
+
+let element;
+let waitForPromises;
+
+describe('mr-move-issue-hotlists-dialog', () => {
+ beforeEach(async () => {
+ element = document.createElement('mr-move-issue-hotlists-dialog');
+ document.body.appendChild(element);
+
+ // We need to wait for promisees to resolve. Alone, the updateComplete
+ // returns without allowing our Promise.all to resolve.
+ waitForPromises = async () => element.updateComplete;
+
+ element.userHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+ {name: 'Hotlist-3', ownerRef: {userId: 67890}},
+ {name: example.HOTLIST.displayName, ownerRef: {userId: 67890}},
+ ];
+ element.user = {userId: 67890};
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+ element._viewedHotlist = example.HOTLIST;
+ await element.updateComplete;
+
+ sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ prpcClient.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrMoveIssueDialog);
+ });
+
+ it('clicking a hotlist moves the issue', async () => {
+ element.open();
+ await element.updateComplete;
+
+ const targetHotlist =element.shadowRoot.querySelector(
+ '.hotlist[data-hotlist-name="Hotlist-2"]');
+ assert.isNotNull(targetHotlist);
+ targetHotlist.click();
+ await element.updateComplete;
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'AddIssuesToHotlists', {
+ hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'RemoveIssuesFromHotlists', {
+ hotlistRefs: [{
+ name: example.HOTLIST.displayName,
+ owner: {userId: 67890},
+ }],
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+ });
+
+ it('dispatches event upon successfully moving', async () => {
+ element.open();
+ const savedStub = sinon.stub();
+ element.addEventListener('saveSuccess', savedStub);
+ sinon.stub(element, 'close');
+ await element.updateComplete;
+
+ const targetHotlist =element.shadowRoot.querySelector(
+ '.hotlist[data-hotlist-name="Hotlist-2"]');
+ targetHotlist.click();
+
+ await waitForPromises();
+ sinon.assert.calledOnce(savedStub);
+ sinon.assert.calledOnce(element.close);
+ });
+
+ it('dispatches no event upon error saving', async () => {
+ const mistakes = 'Mistakes were made';
+ const error = new Error(mistakes);
+ prpcClient.call.returns(Promise.reject(error));
+ const savedStub = sinon.stub();
+ element.addEventListener('saveSuccess', savedStub);
+ element.open();
+ await element.updateComplete;
+
+ const targetHotlist =element.shadowRoot.querySelector(
+ '.hotlist[data-hotlist-name="Hotlist-2"]');
+ targetHotlist.click();
+
+ await waitForPromises();
+ sinon.assert.notCalled(savedStub);
+ assert.include(element.shadowRoot.innerHTML, mistakes);
+ });
+});
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js
new file mode 100644
index 0000000..08a8b25
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js
@@ -0,0 +1,340 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {html, css} from 'lit-element';
+import deepEqual from 'deep-equal';
+
+import 'elements/chops/chops-checkbox/chops-checkbox.js';
+import {store} from 'reducers/base.js';
+import * as issueV0 from 'reducers/issueV0.js';
+import * as userV0 from 'reducers/userV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrIssueHotlistsDialog} from './mr-issue-hotlists-dialog';
+
+/**
+ * `<mr-update-issue-hotlists-dialog>`
+ *
+ * Displays a dialog with the current hotlists's issues allowing the user to
+ * update which hotlists the issues are a member of.
+ */
+export class MrUpdateIssueDialog extends MrIssueHotlistsDialog {
+ /** @override */
+ static get styles() {
+ return [
+ ...super.styles,
+ css`
+ input[type="checkbox"] {
+ width: auto;
+ height: auto;
+ }
+ button.toggle {
+ background: none;
+ color: hsl(240, 100%, 40%);
+ border: 0;
+ width: 100%;
+ padding: 0.25em 0;
+ text-align: left;
+ }
+ button.toggle:hover {
+ cursor: pointer;
+ text-decoration: underline;
+ }
+ label, chops-checkbox {
+ display: flex;
+ line-height: 200%;
+ align-items: center;
+ width: 100%;
+ text-align: left;
+ font-weight: normal;
+ padding: 0.25em 8px;
+ box-sizing: border-box;
+ }
+ label input[type="checkbox"] {
+ margin-right: 8px;
+ }
+ .discard-button {
+ margin-right: 16px;
+ }
+ .edit-actions {
+ width: 100%;
+ margin: 0.5em 0;
+ text-align: right;
+ }
+ .input-grid {
+ align-items: center;
+ }
+ .input-grid > input {
+ width: 200px;
+ max-width: 100%;
+ }
+ `,
+ ];
+ }
+
+ /** @override */
+ renderHeader() {
+ return html`
+ <h3 class="medium-heading">Add issue to hotlists</h3>
+ `;
+ }
+
+ /** @override */
+ renderContent() {
+ return html`
+ ${this.renderFilter()}
+ <form id="issueHotlistsForm">
+ ${this.renderHotlists()}
+ <h3 class="medium-heading">Create new hotlist</h3>
+ <div class="input-grid">
+ <label for="newHotlistName">New hotlist name:</label>
+ <input type="text" name="newHotlistName">
+ </div>
+ ${this.renderError()}
+ <div class="edit-actions">
+ <chops-button
+ class="de-emphasized discard-button"
+ ?disabled=${this.disabled}
+ @click=${this.discard}
+ >
+ Discard
+ </chops-button>
+ <chops-button
+ class="emphasized"
+ ?disabled=${this.disabled}
+ @click=${this.save}
+ >
+ Save changes
+ </chops-button>
+ </div>
+ </form>
+ `;
+ }
+
+ /** @override */
+ renderFilteredHotlist(hotlist) {
+ return html`
+ <chops-checkbox
+ class="hotlist"
+ title=${this._checkboxTitle(hotlist, this.issueHotlists)}
+ data-hotlist-name="${hotlist.name}"
+ ?checked=${this.hotlistsToAdd.has(hotlist.name)}
+ @checked-change=${this._targetHotlistChecked}
+ >
+ ${hotlist.name}
+ </chops-checkbox>`;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ ...super.properties,
+ viewedIssueRef: {type: Object},
+ issueHotlists: {type: Array},
+ user: {type: Object},
+ hotlistsToAdd: {
+ type: Object,
+ hasChanged(newVal, oldVal) {
+ return !deepEqual(newVal, oldVal);
+ },
+ },
+ };
+ }
+
+ /** @override */
+ stateChanged(state) {
+ super.stateChanged(state);
+ this.viewedIssueRef = issueV0.viewedIssueRef(state);
+ this.user = userV0.currentUser(state);
+ }
+
+ /** @override */
+ constructor() {
+ super();
+
+ /** The list of Hotlists attached to the issueRefs. */
+ this.issueHotlists = [];
+
+ /** The Set of Hotlist names that the Issues will be added to. */
+ this.hotlistsToAdd = this._initializeHotlistsToAdd();
+ }
+
+ /** @override */
+ reset() {
+ const form = this.shadowRoot.querySelector('#issueHotlistsForm');
+ form.reset();
+ // LitElement's hasChanged needs an assignment to verify Set objects.
+ // https://lit-element.polymer-project.org/guide/properties#haschanged
+ this.hotlistsToAdd = this._initializeHotlistsToAdd();
+ super.reset();
+ }
+
+ /**
+ * An alias to the close method.
+ */
+ discard() {
+ this.close();
+ }
+
+ /**
+ * Saves all changes that were found in the dialog and issues async requests
+ * to update the issues.
+ * @fires Event#saveSuccess
+ */
+ async save() {
+ const changes = this.changes;
+ const issueRefs = this.issueRefs;
+ const viewedRef = this.viewedIssueRef;
+
+ if (!issueRefs || !changes) return;
+
+ // TODO(https://crbug.com/monorail/7778): Use action creators.
+ const promises = [];
+ if (changes.added && changes.added.length) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'AddIssuesToHotlists', {
+ hotlistRefs: changes.added,
+ issueRefs,
+ },
+ ));
+ }
+ if (changes.removed && changes.removed.length) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'RemoveIssuesFromHotlists', {
+ hotlistRefs: changes.removed,
+ issueRefs,
+ },
+ ));
+ }
+ if (changes.created) {
+ promises.push(prpcClient.call(
+ 'monorail.Features', 'CreateHotlist', {
+ name: changes.created.name,
+ summary: changes.created.summary,
+ issueRefs,
+ },
+ ));
+ }
+
+ try {
+ await Promise.all(promises);
+
+ // Refresh the viewed issue's hotlists only if there is a viewed issue.
+ if (viewedRef) {
+ const viewedIssueWasUpdated = issueRefs.find((ref) =>
+ ref.projectName === viewedRef.projectName &&
+ ref.localId === viewedRef.localId);
+ if (viewedIssueWasUpdated) {
+ store.dispatch(issueV0.fetchHotlists(viewedRef));
+ }
+ }
+ store.dispatch(userV0.fetchHotlists({userId: this.user.userId}));
+ this.dispatchEvent(new Event('saveSuccess'));
+ this.close();
+ } catch (error) {
+ this.error = error.description;
+ }
+ }
+
+ /**
+ * Returns whether a given hotlist matches any of the given issue's hotlists.
+ * @param {Hotlist} hotlist Hotlist to look for.
+ * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
+ * @return {boolean}
+ */
+ _issueInHotlist(hotlist, issueHotlists) {
+ return issueHotlists.some((issueHotlist) => {
+ // TODO(https://crbug.com/monorail/7451): use `===`.
+ return (hotlist.ownerRef.userId == issueHotlist.ownerRef.userId &&
+ hotlist.name === issueHotlist.name);
+ });
+ }
+
+ /**
+ * Get a Set of Hotlists to add the Issues to based on the
+ * Get the initial Set of Hotlists that Issues will be added to. Calculated
+ * using userHotlists and issueHotlists.
+ * @return {!Set<string>}
+ */
+ _initializeHotlistsToAdd() {
+ const userHotlistsInIssueHotlists = this.userHotlists.reduce(
+ (acc, hotlist) => {
+ if (this._issueInHotlist(hotlist, this.issueHotlists)) {
+ acc.push(hotlist.name);
+ }
+ return acc;
+ }, []);
+ return new Set(userHotlistsInIssueHotlists);
+ }
+
+ /**
+ * Gets the checkbox title, depending on the checked state.
+ * @param {boolean} isChecked Whether the input is checked.
+ * @return {string}
+ */
+ _getCheckboxTitle(isChecked) {
+ return (isChecked ? 'Remove issue from' : 'Add issue to') + ' this hotlist';
+ }
+
+ /**
+ * The checkbox title for the issue, shown on hover and for a11y.
+ * @param {Hotlist} hotlist Hotlist to look for.
+ * @param {Array<Hotlist>} issueHotlists Issue's hotlists to compare to.
+ * @return {string}
+ */
+ _checkboxTitle(hotlist, issueHotlists) {
+ return this._getCheckboxTitle(this._issueInHotlist(hotlist, issueHotlists));
+ }
+
+ /**
+ * Handles when the target Hotlist chops-checkbox has been checked.
+ * @param {Event} e
+ */
+ _targetHotlistChecked(e) {
+ const hotlistName = e.target.dataset.hotlistName;
+ const currentHotlistsToAdd = new Set(this.hotlistsToAdd);
+ if (hotlistName && e.detail.checked) {
+ currentHotlistsToAdd.add(hotlistName);
+ } else {
+ currentHotlistsToAdd.delete(hotlistName);
+ }
+ // LitElement's hasChanged needs an assignment to verify Set objects.
+ // https://lit-element.polymer-project.org/guide/properties#haschanged
+ this.hotlistsToAdd = currentHotlistsToAdd;
+ e.target.title = this._getCheckboxTitle(e.target.checked);
+ }
+
+ /**
+ * Gets the changes between the added, removed, and created hotlists .
+ */
+ get changes() {
+ const changes = {
+ added: [],
+ removed: [],
+ };
+ const form = this.shadowRoot.querySelector('#issueHotlistsForm');
+ this.userHotlists.forEach((hotlist) => {
+ const issueInHotlist = this._issueInHotlist(hotlist, this.issueHotlists);
+ if (issueInHotlist && !this.hotlistsToAdd.has(hotlist.name)) {
+ changes.removed.push({
+ name: hotlist.name,
+ owner: hotlist.ownerRef,
+ });
+ } else if (!issueInHotlist && this.hotlistsToAdd.has(hotlist.name)) {
+ changes.added.push({
+ name: hotlist.name,
+ owner: hotlist.ownerRef,
+ });
+ }
+ });
+ if (form.newHotlistName.value) {
+ changes.created = {
+ name: form.newHotlistName.value,
+ summary: 'Hotlist created from issue.',
+ };
+ }
+ return changes;
+ }
+}
+
+customElements.define('mr-update-issue-hotlists-dialog', MrUpdateIssueDialog);
diff --git a/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js
new file mode 100644
index 0000000..954b8b9
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.test.js
@@ -0,0 +1,193 @@
+// 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 {MrUpdateIssueDialog} from './mr-update-issue-hotlists-dialog.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let form;
+
+describe('mr-update-issue-hotlists-dialog', () => {
+ beforeEach(async () => {
+ element = document.createElement('mr-update-issue-hotlists-dialog');
+ document.body.appendChild(element);
+
+ await element.updateComplete;
+ form = element.shadowRoot.querySelector('#issueHotlistsForm');
+
+ sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+
+ prpcClient.call.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, MrUpdateIssueDialog);
+ });
+
+ it('no changes', () => {
+ assert.deepEqual(element.changes, {added: [], removed: []});
+ });
+
+ it('clicking on issues produces changes', async () => {
+ element.issueHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 12345}},
+ {name: 'Hotlist-2', ownerRef: {userId: 12345}},
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ ];
+ element.userHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+ ];
+ element.user = {userId: 67890};
+
+ element.open();
+ await element.updateComplete;
+
+ const chopsCheckboxes = form.querySelectorAll('chops-checkbox');
+ chopsCheckboxes[0].click();
+ chopsCheckboxes[1].click();
+ assert.deepEqual(element.changes, {
+ added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ removed: [{name: 'Hotlist-1', owner: {userId: 67890}}],
+ });
+ });
+
+ it('adding new hotlist produces changes', async () => {
+ await element.updateComplete;
+ form.newHotlistName.value = 'New-Hotlist';
+ assert.deepEqual(element.changes, {
+ added: [],
+ removed: [],
+ created: {
+ name: 'New-Hotlist',
+ summary: 'Hotlist created from issue.',
+ },
+ });
+ });
+
+ it('reset changes', async () => {
+ element.issueHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 12345}},
+ {name: 'Hotlist-2', ownerRef: {userId: 12345}},
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ ];
+ element.userHotlists = [
+ {name: 'Hotlist-1', ownerRef: {userId: 67890}},
+ {name: 'Hotlist-2', ownerRef: {userId: 67890}},
+ ];
+ element.user = {userId: 67890};
+
+ element.open();
+ await element.updateComplete;
+
+ const chopsCheckboxes = form.querySelectorAll('chops-checkbox');
+ const checkbox1 = chopsCheckboxes[0];
+ const checkbox2 = chopsCheckboxes[1];
+ checkbox1.click();
+ checkbox2.click();
+ form.newHotlisName = 'New-Hotlist';
+ await element.reset();
+ assert.isTrue(checkbox1.checked);
+ assert.isNotTrue(checkbox2.checked); // Falsey property.
+ assert.equal(form.newHotlistName.value, '');
+ });
+
+ it('saving adds issues to hotlist', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ await element.save();
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'AddIssuesToHotlists', {
+ hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+ });
+
+ it('saving removes issues from hotlist', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ removed: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ await element.save();
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'RemoveIssuesFromHotlists', {
+ hotlistRefs: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+ });
+
+ it('saving creates new hotlist with issues', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ created: {name: 'MyHotlist', summary: 'the best hotlist'},
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ await element.save();
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'CreateHotlist', {
+ name: 'MyHotlist',
+ summary: 'the best hotlist',
+ issueRefs: [{localId: 22, projectName: 'test'}],
+ });
+ });
+
+ it('saving refreshes issue hotlises if viewed issue is updated', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ created: {name: 'MyHotlist', summary: 'the best hotlist'},
+ }));
+ element.issueRefs = [
+ {localId: 22, projectName: 'test'},
+ {localId: 32, projectName: 'test'},
+ ];
+ element.viewedIssueRef = {localId: 32, projectName: 'test'};
+
+ await element.save();
+
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Features',
+ 'ListHotlistsByIssue', {issue: {localId: 32, projectName: 'test'}});
+ });
+
+ it('dispatches event upon successfully saving', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ const savedStub = sinon.stub();
+ element.addEventListener('saveSuccess', savedStub);
+
+ await element.save();
+
+ sinon.assert.calledOnce(savedStub);
+ });
+
+ it('dispatches no event upon error saving', async () => {
+ sinon.stub(element, 'changes').get(() => ({
+ added: [{name: 'Hotlist-2', owner: {userId: 67890}}],
+ }));
+ element.issueRefs = [{localId: 22, projectName: 'test'}];
+
+ const error = new Error('Mistakes were made');
+ prpcClient.call.returns(Promise.reject(error));
+
+ const savedStub = sinon.stub();
+ element.addEventListener('saveSuccess', savedStub);
+
+ await element.save();
+
+ sinon.assert.notCalled(savedStub);
+ });
+});