Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/ezt/ezt-app-base.js b/static_src/elements/ezt/ezt-app-base.js
new file mode 100644
index 0000000..0dc3eae
--- /dev/null
+++ b/static_src/elements/ezt/ezt-app-base.js
@@ -0,0 +1,62 @@
+// 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} from 'lit-element';
+import qs from 'qs';
+import {store, connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+/**
+ * `<ezt-app-base>`
+ *
+ * Base component meant to simulate a subset of the work mr-app does on
+ * EZT pages in order to allow us to more easily glue web components
+ * on EZT pages to SPA web components.
+ *
+ */
+export class EztAppBase extends connectStore(LitElement) {
+  /** @override */
+  static get properties() {
+    return {
+      projectName: {type: String},
+      userDisplayName: {type: String},
+    };
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    this.mapUrlToQueryParams();
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('userDisplayName') && this.userDisplayName) {
+      this.fetchUserData(this.userDisplayName);
+    }
+
+    if (changedProperties.has('projectName') && this.projectName) {
+      this.fetchProjectData(this.projectName);
+    }
+  }
+
+  fetchUserData(displayName) {
+    store.dispatch(userV0.fetch(displayName));
+  }
+
+  fetchProjectData(projectName) {
+    store.dispatch(projectV0.select(projectName));
+    store.dispatch(projectV0.fetch(projectName));
+  }
+
+  mapUrlToQueryParams() {
+    const params = qs.parse((window.location.search || '').substr(1));
+
+    store.dispatch(sitewide.setQueryParams(params));
+  }
+}
+customElements.define('ezt-app-base', EztAppBase);
diff --git a/static_src/elements/ezt/ezt-app-base.test.js b/static_src/elements/ezt/ezt-app-base.test.js
new file mode 100644
index 0000000..86eb5b1
--- /dev/null
+++ b/static_src/elements/ezt/ezt-app-base.test.js
@@ -0,0 +1,65 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {EztAppBase} from './ezt-app-base.js';
+
+
+let element;
+
+describe('ezt-app-base', () => {
+  beforeEach(() => {
+    element = document.createElement('ezt-app-base');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, EztAppBase);
+  });
+
+  it('fetches user data when userDisplayName set', async () => {
+    sinon.stub(element, 'fetchUserData');
+
+    element.userDisplayName = 'test@example.com';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetchUserData);
+    sinon.assert.calledWith(element.fetchUserData, 'test@example.com');
+  });
+
+  it('does not fetch data when userDisplayName is empty', async () => {
+    sinon.stub(element, 'fetchUserData');
+    element.userDisplayName = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.fetchUserData);
+  });
+
+  it('fetches project data when projectName set', async () => {
+    sinon.stub(element, 'fetchProjectData');
+
+    element.projectName = 'chromium';
+
+    await element.updateComplete;
+
+    sinon.assert.calledOnce(element.fetchProjectData);
+    sinon.assert.calledWith(element.fetchProjectData, 'chromium');
+  });
+
+  it('does not fetch data when projectName is empty', async () => {
+    sinon.stub(element, 'fetchProjectData');
+    element.projectName = '';
+
+    await element.updateComplete;
+
+    sinon.assert.notCalled(element.fetchProjectData);
+  });
+});
diff --git a/static_src/elements/ezt/ezt-element-package.js b/static_src/elements/ezt/ezt-element-package.js
new file mode 100644
index 0000000..90ffadb
--- /dev/null
+++ b/static_src/elements/ezt/ezt-element-package.js
@@ -0,0 +1,29 @@
+// 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.
+
+// This file bundles together all web components elements used on the
+// legacy EZT pages. This is to avoid having issues with registering
+// duplicate versions of dependencies.
+
+import page from 'page';
+
+import 'elements/framework/mr-dropdown/mr-account-dropdown.js';
+import 'elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js';
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/chops/chops-timestamp/chops-timestamp.js';
+
+import 'elements/framework/mr-header/mr-header.js';
+import 'elements/issue-list/mr-chart/mr-chart.js';
+import 'elements/issue-detail/mr-flipper/mr-flipper.js';
+import 'elements/framework/mr-pref-toggle/mr-pref-toggle.js';
+import 'elements/ezt/ezt-show-columns-connector.js';
+import 'elements/ezt/ezt-app-base.js';
+
+// Register an empty set of page.js routes to allow the page() navigation
+// function to work.
+// Note: The EZT pages should NOT register the routes used by the SPA pages
+// without significant refactoring because doing so will lead to unexpected
+// routing behavior where the SPA is loaded on top of a server-rendered page
+// rather than instead of.
+page();
diff --git a/static_src/elements/ezt/ezt-footer-scripts-package.js b/static_src/elements/ezt/ezt-footer-scripts-package.js
new file mode 100644
index 0000000..85eeaa0
--- /dev/null
+++ b/static_src/elements/ezt/ezt-footer-scripts-package.js
@@ -0,0 +1,14 @@
+// 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.
+
+// This file bundles together scripts to be loaded through the legacy
+// EZT footer.
+
+import 'monitoring/client-logger.js';
+import 'monitoring/track-copy.js';
+
+// Allow EZT pages to import AutoRefreshPrpcClient.
+import AutoRefreshPrpcClient from 'prpc.js';
+
+window.AutoRefreshPrpcClient = AutoRefreshPrpcClient;
diff --git a/static_src/elements/ezt/ezt-show-columns-connector.js b/static_src/elements/ezt/ezt-show-columns-connector.js
new file mode 100644
index 0000000..c6b3347
--- /dev/null
+++ b/static_src/elements/ezt/ezt-show-columns-connector.js
@@ -0,0 +1,117 @@
+/**
+ * @fileoverview Description of this file.
+ */
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+import {store, connectStore} from 'reducers/base.js';
+import qs from 'qs';
+import * as sitewide from 'reducers/sitewide.js';
+import 'elements/framework/mr-issue-list/mr-show-columns-dropdown.js';
+import {parseColSpec} from 'shared/issue-fields.js';
+import {equalsIgnoreCase} from 'shared/helpers.js';
+import {DEFAULT_ISSUE_FIELD_LIST} from 'shared/issue-fields.js';
+
+/**
+ * `<ezt-show-columns-connector>`
+ *
+ * Glue component to make "Show columns" dropdown work on EZT.
+ *
+ */
+export class EztShowColumnsConnector extends connectStore(LitElement) {
+  /** @override */
+  render() {
+    return html`
+      <mr-show-columns-dropdown
+        .defaultFields=${DEFAULT_ISSUE_FIELD_LIST}
+        .columns=${this.columns}
+        .phaseNames=${this.phaseNames}
+        .onHideColumn=${(name) => this.onHideColumn(name)}
+        .onShowColumn=${(name) => this.onShowColumn(name)}
+      ></mr-show-columns-dropdown>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      initialColumns: {type: Array},
+      hiddenColumns: {type: Object},
+      queryParams: {type: Object},
+      colspec: {type: String},
+      phasespec: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.hiddenColumns = new Set();
+    this.queryParams = {};
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.queryParams = sitewide.queryParams(state);
+  }
+
+  get columns() {
+    return this.initialColumns.filter((_, i) =>
+      !this.hiddenColumns.has(i));
+  }
+
+  get initialColumns() {
+    // EZT will always pass in a colspec.
+    return parseColSpec(this.colspec);
+  }
+
+  get phaseNames() {
+    return parseColSpec(this.phasespec);
+  }
+
+  onHideColumn(colName) {
+    // Custom column hiding logic to avoid reloading the
+    // EZT list page when a user hides a column.
+    const colIndex = this.initialColumns.findIndex(
+        (col) => equalsIgnoreCase(col, colName));
+
+    // Legacy code integration.
+    TKR_toggleColumn('hide_col_' + colIndex);
+
+    this.hiddenColumns.add(colIndex);
+
+    this.reflectColumnsToQueryParams();
+    this.requestUpdate();
+
+    // Don't continue navigation.
+    return false;
+  }
+
+  onShowColumn(colName) {
+    const colIndex = this.initialColumns.findIndex(
+        (col) => equalsIgnoreCase(col, colName));
+    if (colIndex >= 0) {
+      this.hiddenColumns.delete(colIndex);
+      TKR_toggleColumn('hide_col_' + colIndex);
+
+      this.reflectColumnsToQueryParams();
+      this.requestUpdate();
+      return false;
+    }
+    // Reload the page if this column is not part of the initial
+    // table render.
+    return true;
+  }
+
+  reflectColumnsToQueryParams() {
+    this.queryParams.colspec = this.columns.join(' ');
+
+    // Make sure the column changes in the URL.
+    window.history.replaceState({}, '', '?' + qs.stringify(this.queryParams));
+
+    store.dispatch(sitewide.setQueryParams(this.queryParams));
+  }
+}
+customElements.define('ezt-show-columns-connector', EztShowColumnsConnector);
diff --git a/static_src/elements/ezt/ezt-show-columns-connector.test.js b/static_src/elements/ezt/ezt-show-columns-connector.test.js
new file mode 100644
index 0000000..62bd13b
--- /dev/null
+++ b/static_src/elements/ezt/ezt-show-columns-connector.test.js
@@ -0,0 +1,41 @@
+// 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 {EztShowColumnsConnector} from './ezt-show-columns-connector.js';
+
+
+let element;
+
+describe('ezt-show-columns-connector', () => {
+  beforeEach(() => {
+    element = document.createElement('ezt-show-columns-connector');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, EztShowColumnsConnector);
+  });
+
+  it('initialColumns parses colspec', () => {
+    element.colspec = 'Summary ID Owner';
+    assert.deepEqual(element.initialColumns, ['Summary', 'ID', 'Owner']);
+  });
+
+  it('filters columns based on column mask', () => {
+    sinon.stub(element, 'initialColumns').get(() => ['ID', 'Summary']);
+    element.hiddenColumns = new Set([1]);
+
+    assert.deepEqual(element.columns, ['ID']);
+  });
+
+  it('phaseNames parses phasespec', () => {
+    element.phasespec = 'stable beta stable-exp';
+    assert.deepEqual(element.phaseNames, ['stable', 'beta', 'stable-exp']);
+  });
+});
diff --git a/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js
new file mode 100644
index 0000000..d9318fc
--- /dev/null
+++ b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.js
@@ -0,0 +1,283 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+import 'elements/issue-detail/metadata/mr-edit-field/mr-edit-field.js';
+import 'elements/framework/mr-error/mr-error.js';
+import 'react/mr-react-autocomplete.tsx';
+import {prpcClient} from 'prpc-client-instance.js';
+import {EMPTY_FIELD_VALUE} from 'shared/issue-fields.js';
+import {TEXT_TO_STATUS_ENUM} from 'shared/consts/approval.js';
+
+
+export const NO_UPDATES_MESSAGE =
+  'User lacks approver perms for approval in all issues.';
+export const NO_APPROVALS_MESSAGE = 'These issues don\'t have any approvals.';
+
+export class MrBulkApprovalUpdate extends LitElement {
+  /** @override */
+  render() {
+    return html`
+      <style>
+        mr-bulk-approval-update {
+          display: block;
+          margin-top: 30px;
+          position: relative;
+        }
+        button.clickable-text {
+          background: none;
+          border: 0;
+          color: hsl(0, 0%, 39%);
+          cursor: pointer;
+          text-decoration: underline;
+        }
+        .hidden {
+          display: none; !important;
+        }
+        .message {
+          background-color: beige;
+          width: 500px;
+        }
+        .note {
+          color: hsl(0, 0%, 25%);
+          font-size: 0.85em;
+          font-style: italic;
+        }
+        mr-bulk-approval-update table {
+          border: 1px dotted black;
+          cellspacing: 0;
+          cellpadding: 3;
+        }
+        #approversInput {
+          border-style: none;
+        }
+      </style>
+      <button
+        class="js-showApprovals clickable-text"
+        ?hidden=${this.approvalsFetched}
+        @click=${this.fetchApprovals}
+      >Show Approvals</button>
+      ${this.approvals.length ? html`
+        <form>
+          <table>
+            <tbody><tr>
+              <th><label for="approvalSelect">Approval:</label></th>
+              <td>
+                <select
+                  id="approvalSelect"
+                  @change=${this._changeHandlers.approval}
+                >
+                  ${this.approvals.map(({fieldRef}) => html`
+                    <option
+                      value=${fieldRef.fieldName}
+                      .selected=${fieldRef.fieldName === this._values.approval}
+                    >
+                      ${fieldRef.fieldName}
+                    </option>
+                  `)}
+                </select>
+              </td>
+            </tr>
+            <tr>
+              <th><label for="approversInput">Approvers:</label></th>
+              <td>
+                <mr-react-autocomplete
+                  label="approversInput"
+                  vocabularyName="member"
+                  .multiple=${true}
+                  .value=${this._values.approvers}
+                  .onChange=${this._changeHandlers.approvers}
+                ></mr-react-autocomplete>
+              </td>
+            </tr>
+            <tr><th><label for="statusInput">Status:</label></th>
+              <td>
+                <select
+                  id="statusInput"
+                  @change=${this._changeHandlers.status}
+                >
+                  <option .selected=${!this._values.status}>
+                    ${EMPTY_FIELD_VALUE}
+                  </option>
+                  ${this.statusOptions.map((status) => html`
+                    <option
+                      value=${status}
+                      .selected=${status === this._values.status}
+                    >${status}</option>
+                  `)}
+                </select>
+              </td>
+            </tr>
+            <tr>
+              <th><label for="commentText">Comment:</label></th>
+              <td colspan="4">
+                <textarea
+                  cols="30"
+                  rows="3"
+                  id="commentText"
+                  placeholder="Add an approval comment"
+                  .value=${this._values.comment || ''}
+                  @change=${this._changeHandlers.comment}
+                ></textarea>
+              </td>
+            </tr>
+            <tr>
+              <td>
+                <button
+                  class="js-save"
+                  @click=${this.save}
+                >Update Approvals only</button>
+              </td>
+              <td>
+                <span class="note">
+                 Note: Some approvals may not be updated if you lack
+                 approver perms.
+                </span>
+              </td>
+            </tr>
+          </tbody></table>
+        </form>
+      `: ''}
+      <div class="message">
+        ${this.responseMessage}
+        ${this.errorMessage ? html`
+          <mr-error>${this.errorMessage}</mr-error>
+        ` : ''}
+      </div>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      approvals: {type: Array},
+      approvalsFetched: {type: Boolean},
+      statusOptions: {type: Array},
+      localIdsStr: {type: String},
+      projectName: {type: String},
+      responseMessage: {type: String},
+      _values: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.approvals = [];
+    this.statusOptions = Object.keys(TEXT_TO_STATUS_ENUM);
+    this.responseMessage = '';
+
+    this._values = {};
+    this._changeHandlers = {
+      approval: this._onChange.bind(this, 'approval'),
+      approvers: this._onChange.bind(this, 'approvers'),
+      status: this._onChange.bind(this, 'status'),
+      comment: this._onChange.bind(this, 'comment'),
+    };
+  }
+
+  /** @override */
+  createRenderRoot() {
+    return this;
+  }
+
+  get issueRefs() {
+    const {projectName, localIdsStr} = this;
+    if (!projectName || !localIdsStr) return [];
+    const issueRefs = [];
+    const localIds = localIdsStr.split(',');
+    localIds.forEach((localId) => {
+      issueRefs.push({projectName: projectName, localId: localId});
+    });
+    return issueRefs;
+  }
+
+  fetchApprovals(evt) {
+    const message = {issueRefs: this.issueRefs};
+    prpcClient.call('monorail.Issues', 'ListApplicableFieldDefs', message).then(
+        (resp) => {
+          if (resp.fieldDefs) {
+            this.approvals = resp.fieldDefs.filter((fieldDef) => {
+              return fieldDef.fieldRef.type == 'APPROVAL_TYPE';
+            });
+          }
+          if (!this.approvals.length) {
+            this.errorMessage = NO_APPROVALS_MESSAGE;
+          }
+          this.approvalsFetched = true;
+        }, (error) => {
+          this.approvalsFetched = true;
+          this.errorMessage = error;
+        });
+  }
+
+  save(evt) {
+    this.responseMessage = '';
+    this.errorMessage = '';
+    this.toggleDisableForm();
+    const selectedFieldDef = this.approvals.find(
+        (approval) => approval.fieldRef.fieldName === this._values.approval
+    ) || this.approvals[0];
+    const message = {
+      issueRefs: this.issueRefs,
+      fieldRef: selectedFieldDef.fieldRef,
+      send_email: true,
+    };
+    message.commentContent = this._values.comment;
+    const delta = {};
+    if (this._values.status !== EMPTY_FIELD_VALUE) {
+      delta.status = TEXT_TO_STATUS_ENUM[this._values.status];
+    }
+    const approversAdded = this._values.approvers;
+    if (approversAdded) {
+      delta.approverRefsAdd = approversAdded.map(
+          (name) => ({'displayName': name}));
+    }
+    if (Object.keys(delta).length) {
+      message.approvalDelta = delta;
+    }
+    prpcClient.call('monorail.Issues', 'BulkUpdateApprovals', message).then(
+        (resp) => {
+          if (resp.issueRefs && resp.issueRefs.length) {
+            const idsStr = Array.from(resp.issueRefs,
+                (ref) => ref.localId).join(', ');
+            this.responseMessage = `${this.getTimeStamp()}: Updated ${
+              selectedFieldDef.fieldRef.fieldName} in issues: ${idsStr} (${
+              resp.issueRefs.length} of ${this.issueRefs.length}).`;
+            this._values = {};
+          } else {
+            this.errorMessage = NO_UPDATES_MESSAGE;
+          };
+          this.toggleDisableForm();
+        }, (error) => {
+          this.errorMessage = error;
+          this.toggleDisableForm();
+        });
+  }
+
+  getTimeStamp() {
+    const date = new Date();
+    return `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
+  }
+
+  toggleDisableForm() {
+    this.querySelectorAll('input, textarea, select, button').forEach(
+        (input) => {
+          input.disabled = !input.disabled;
+        });
+  }
+
+  /**
+   * Generic onChange handler to be bound to each form field.
+   * @param {string} key Unique name for the form field we're binding this
+   *   handler to. For example, 'owner', 'cc', or the name of a custom field.
+   * @param {Event | React.SyntheticEvent} event
+   * @param {string} value The new form value.
+   */
+  _onChange(key, event, value) {
+    this._values = {...this._values, [key]: value || event.target.value};
+  }
+}
+
+customElements.define('mr-bulk-approval-update', MrBulkApprovalUpdate);
diff --git a/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.test.js b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.test.js
new file mode 100644
index 0000000..a0689e1
--- /dev/null
+++ b/static_src/elements/ezt/mr-bulk-approval-update/mr-bulk-approval-update.test.js
@@ -0,0 +1,185 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import sinon from 'sinon';
+import {fireEvent} from '@testing-library/react';
+
+import {MrBulkApprovalUpdate, NO_APPROVALS_MESSAGE,
+  NO_UPDATES_MESSAGE} from './mr-bulk-approval-update.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+
+describe('mr-bulk-approval-update', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-bulk-approval-update');
+    document.body.appendChild(element);
+
+    sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+    prpcClient.call.restore();
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrBulkApprovalUpdate);
+  });
+
+  it('_computeIssueRefs: missing information', () => {
+    element.projectName = 'chromium';
+    assert.equal(element.issueRefs.length, 0);
+
+    element.projectName = null;
+    element.localIdsStr = '1,2,3,5';
+    assert.equal(element.issueRefs.length, 0);
+
+    element.localIdsStr = null;
+    assert.equal(element.issueRefs.length, 0);
+  });
+
+  it('_computeIssueRefs: normal', () => {
+    const project = 'chromium';
+    element.projectName = project;
+    element.localIdsStr = '1,2,3';
+    assert.deepEqual(element.issueRefs, [
+      {projectName: project, localId: '1'},
+      {projectName: project, localId: '2'},
+      {projectName: project, localId: '3'},
+    ]);
+  });
+
+  it('fetchApprovals: applicable fields exist', async () => {
+    const responseFieldDefs = [
+      {fieldRef: {type: 'INT_TYPE'}},
+      {fieldRef: {type: 'APPROVAL_TYPE'}},
+      {fieldRef: {type: 'APPROVAL_TYPE'}},
+    ];
+    const promise = Promise.resolve({fieldDefs: responseFieldDefs});
+    prpcClient.call.returns(promise);
+
+    sinon.spy(element, 'fetchApprovals');
+
+    await element.updateComplete;
+
+    element.querySelector('.js-showApprovals').click();
+    assert.isTrue(element.fetchApprovals.calledOnce);
+
+    // Wait for promise in fetchApprovals to resolve.
+    await promise;
+
+    assert.deepEqual([
+      {fieldRef: {type: 'APPROVAL_TYPE'}},
+      {fieldRef: {type: 'APPROVAL_TYPE'}},
+    ], element.approvals);
+    assert.equal(null, element.errorMessage);
+  });
+
+  it('fetchApprovals: applicable fields dont exist', async () => {
+    const promise = Promise.resolve({fieldDefs: []});
+    prpcClient.call.returns(promise);
+
+    await element.updateComplete;
+
+    element.querySelector('.js-showApprovals').click();
+
+    await promise;
+
+    assert.equal(element.approvals.length, 0);
+    assert.equal(NO_APPROVALS_MESSAGE, element.errorMessage);
+  });
+
+  it('save: normal', async () => {
+    const promise =
+      Promise.resolve({issueRefs: [{localId: '1'}, {localId: '3'}]});
+    prpcClient.call.returns(promise);
+    const fieldDefs = [
+      {fieldRef: {fieldName: 'Approval-One', type: 'APPROVAL_TYPE'}},
+      {fieldRef: {fieldName: 'Approval-Two', type: 'APPROVAL_TYPE'}},
+    ];
+    element.approvals = fieldDefs;
+    element.projectName = 'chromium';
+    element.localIdsStr = '1,2,3';
+
+    await element.updateComplete;
+
+    fireEvent.change(element.querySelector('#commentText'), {target: {value: 'comment'}});
+    fireEvent.change(element.querySelector('#statusInput'), {target: {value: 'NotApproved'}});
+    element.querySelector('.js-save').click();
+
+    // Wait for promise in save() to resolve.
+    await promise;
+    await element.updateComplete;
+
+    // Assert messages correct
+    assert.equal(
+        true,
+        element.responseMessage.includes(
+            'Updated Approval-One in issues: 1, 3 (2 of 3).'));
+    assert.equal('', element.errorMessage);
+
+    // Assert all inputs not disabled.
+    element.querySelectorAll('input, textarea, select').forEach((input) => {
+      assert.equal(input.disabled, false);
+    });
+
+    // Assert all inputs cleared.
+    element.querySelectorAll('input, textarea').forEach((input) => {
+      assert.equal(input.value, '');
+    });
+    element.querySelectorAll('select').forEach((select) => {
+      assert.equal(select.selectedIndex, 0);
+    });
+
+    // Assert BulkUpdateApprovals correctly called.
+    const expectedMessage = {
+      approvalDelta: {status: 'NOT_APPROVED'},
+      commentContent: 'comment',
+      fieldRef: fieldDefs[0].fieldRef,
+      issueRefs: element.issueRefs,
+      send_email: true,
+    };
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Issues',
+        'BulkUpdateApprovals',
+        expectedMessage);
+  });
+
+  it('save: no updates', async () => {
+    const promise = Promise.resolve({issueRefs: []});
+    prpcClient.call.returns(promise);
+    const fieldDefs = [
+      {fieldRef: {fieldName: 'Approval-One', type: 'APPROVAL_TYPE'}},
+      {fieldRef: {fieldName: 'Approval-Two', type: 'APPROVAL_TYPE'}},
+    ];
+    element.approvals = fieldDefs;
+    element.projectName = 'chromium';
+    element.localIdsStr = '1,2,3';
+
+    await element.updateComplete;
+
+    element.querySelector('#commentText').value = 'comment';
+    element.querySelector('#statusInput').value = 'NotApproved';
+    element.querySelector('.js-save').click();
+
+    // Wait for promise in save() to resolve
+    await promise;
+
+    // Assert messages correct.
+    assert.equal('', element.responseMessage);
+    assert.equal(NO_UPDATES_MESSAGE, element.errorMessage);
+
+    // Assert inputs not cleared.
+    assert.equal(element.querySelector('#commentText').value, 'comment');
+    assert.equal(element.querySelector('#statusInput').value, 'NotApproved');
+
+    // Assert inputs not disabled.
+    element.querySelectorAll('input, textarea, select').forEach((input) => {
+      assert.equal(input.disabled, false);
+    });
+  });
+});