Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js
new file mode 100644
index 0000000..a7870f6
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.js
@@ -0,0 +1,151 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import page from 'page';
+import qs from 'qs';
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/chops/chops-dialog/chops-dialog.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+import {parseColSpec} from 'shared/issue-fields.js';
+
+/**
+ * `<mr-change-columns>`
+ *
+ * Dialog where the user can change columns on the list view.
+ *
+ */
+export class MrChangeColumns extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        .edit-actions {
+          margin: 0.5em 0;
+          text-align: right;
+        }
+        .input-grid {
+          align-items: center;
+          width: 800px;
+          max-width: 100%;
+        }
+        input {
+          box-sizing: border-box;
+          padding: 0.25em 4px;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <chops-dialog closeOnOutsideClick>
+        <h3 class="medium-heading">Change list columns</h3>
+        <form id="changeColumns" @submit=${this._save}>
+          <div class="input-grid">
+            <label for="columnsInput">Columns: </label>
+            <input
+              id="columnsInput"
+              placeholder="Edit columns..."
+              value=${this.columns.join(' ')}
+            />
+          </div>
+          <div class="edit-actions">
+            <chops-button
+              @click=${this.close}
+              class="de-emphasized discard-button"
+            >
+              Discard
+            </chops-button>
+            <chops-button
+              @click=${this._save}
+              class="emphasized"
+            >
+              Update columns
+            </chops-button>
+          </div>
+        </form>
+      </chops-dialog>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      /**
+       * Array of the currently configured issue columns, used to set
+       * the default value.
+       */
+      columns: {type: Array},
+      /**
+       * Parsed query params for the current page, to be used in
+       * navigation.
+       */
+      queryParams: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.columns = [];
+    this.queryParams = {};
+
+    this._page = page;
+  }
+
+  /**
+   * Abstract out the computation of the current page. Useful for testing.
+   */
+  get _currentPage() {
+    return window.location.pathname;
+  }
+
+  /** Updates the URL query params with the new columns. */
+  save() {
+    const input = this.shadowRoot.querySelector('#columnsInput');
+    const newColumns = parseColSpec(input.value);
+
+    const params = {...this.queryParams};
+    params.colspec = newColumns.join('+');
+
+    // TODO(zhangtiff): Create a shared function to change only
+    // query params in a URL.
+    this._page(`${this._currentPage}?${qs.stringify(params)}`);
+
+    this.close();
+  }
+
+  /**
+   * Handles form submit events.
+   * @param {Event} e A click or submit event.
+   */
+  _save(e) {
+    e.preventDefault();
+    this.save();
+  }
+
+  /** Opens and resets this dialog. */
+  open() {
+    this.reset();
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.open();
+  }
+
+  /** Closes this dialog. */
+  close() {
+    const dialog = this.shadowRoot.querySelector('chops-dialog');
+    dialog.close();
+  }
+
+  /** Resets the form in this dialog. */
+  reset() {
+    this.shadowRoot.querySelector('form').reset();
+  }
+}
+
+customElements.define('mr-change-columns', MrChangeColumns);
diff --git a/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js
new file mode 100644
index 0000000..82e529d
--- /dev/null
+++ b/static_src/elements/framework/dialogs/mr-change-columns/mr-change-columns.test.js
@@ -0,0 +1,75 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {MrChangeColumns} from './mr-change-columns.js';
+
+
+let element;
+
+describe('mr-change-columns', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-change-columns');
+    document.body.appendChild(element);
+
+    element._page = sinon.stub();
+    sinon.stub(element, '_currentPage').get(() => '/test');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrChangeColumns);
+  });
+
+  it('input initializes with currently set columns', async () => {
+    element.columns = ['ID', 'Summary'];
+
+    await element.updateComplete;
+
+    const input = element.shadowRoot.querySelector('#columnsInput');
+
+    assert.equal(input.value, 'ID Summary');
+  });
+
+  it('editing input and saving updates columns in URL', async () => {
+    element.columns = ['ID', 'Summary'];
+    element.queryParams = {};
+
+    await element.updateComplete;
+
+    const input = element.shadowRoot.querySelector('#columnsInput');
+    input.value = 'ID Summary Owner';
+
+    element.save();
+
+    sinon.assert.calledWith(element._page,
+        '/test?colspec=ID%2BSummary%2BOwner');
+  });
+
+  it('submitting form updates colspec', async () => {
+    element.columns = ['ID', 'Summary'];
+    element.queryParams = {};
+
+    await element.updateComplete;
+
+    const input = element.shadowRoot.querySelector('#columnsInput');
+    input.value = 'ID Summary Component';
+
+    // Note: HTMLFormElement.submit() does not fire event listeners.
+    const submitEvent = new Event('submit');
+    sinon.spy(submitEvent, 'preventDefault');
+    const form = element.shadowRoot.querySelector('form');
+    form.dispatchEvent(submitEvent);
+
+    // Preventing default is important to prevent native browser form submit
+    // from causing an additional navigation.
+    sinon.assert.calledOnce(submitEvent.preventDefault);
+
+    sinon.assert.calledWith(element._page,
+        '/test?colspec=ID%2BSummary%2BComponent');
+  });
+});
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);
+  });
+});