Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js
new file mode 100644
index 0000000..c317d39
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.js
@@ -0,0 +1,260 @@
+// 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 debounce from 'debounce';
+import {LitElement, html, css} from 'lit-element';
+
+import {userV3ToRef} from 'shared/convertersV0.js';
+
+import {store, connectStore} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as users from 'reducers/users.js';
+
+import 'elements/framework/links/mr-user-link/mr-user-link.js';
+import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
+
+/** Hotlist People page */
+class _MrHotlistPeoplePage extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      section {
+        margin: 16px 24px;
+      }
+      h2 {
+        font-weight: normal;
+      }
+
+      ul {
+        padding: 0;
+      }
+      li {
+        list-style-type: none;
+      }
+      p, li, form {
+        display: flex;
+      }
+      p, ul, li, form {
+        margin: 12px 0;
+      }
+
+      input {
+        margin-left: -6px;
+        padding: 4px;
+        width: 320px;
+      }
+
+      button {
+        align-items: center;
+        background-color: transparent;
+        border: 0;
+        cursor: pointer;
+        display: inline-flex;
+        margin: 0 4px;
+        padding: 0;
+      }
+      .material-icons {
+        font-size: 18px;
+      }
+
+      .placeholder::before {
+        animation: pulse 1s infinite ease-in-out;
+        border-radius: 3px;
+        content: " ";
+        height: 10px;
+        margin: 4px 0;
+        width: 200px;
+      }
+      @keyframes pulse {
+        0% {background-color: var(--chops-blue-50);}
+        50% {background-color: var(--chops-blue-75);}
+        100% {background-color: var(--chops-blue-50);}
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <mr-hotlist-header selected=1></mr-hotlist-header>
+      ${this._renderPage()}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderPage() {
+    if (this._fetchError) {
+      return html`<section>${this._fetchError.description}</section>`;
+    }
+
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+
+      <section>
+        <h2>Owner</h2>
+        ${this._renderOwner(this._owner)}
+      </section>
+
+      <section>
+        <h2>Editors</h2>
+        ${this._renderEditors(this._editors)}
+
+        ${this._permissions.includes(hotlists.ADMINISTER) ? html`
+          <form @submit=${this._onAddEditors}>
+            <input id="add" placeholder="List of email addresses"></input>
+            <button><i class="material-icons">add</i></button>
+          </form>
+        ` : html``}
+      </section>
+    `;
+  }
+
+  /**
+   * @param {?User} owner
+   * @return {TemplateResult}
+   */
+  _renderOwner(owner) {
+    if (!owner) return html`<p class="placeholder"></p>`;
+    return html`
+      <p><mr-user-link .userRef=${userV3ToRef(owner)}></mr-user-link></p>
+    `;
+  }
+
+  /**
+   * @param {?Array<User>} editors
+   * @return {TemplateResult}
+   */
+  _renderEditors(editors) {
+    if (!editors) return html`<p class="placeholder"></p>`;
+    if (!editors.length) return html`<p>No editors.</p>`;
+
+    return html`
+      <ul>${editors.map((editor) => this._renderEditor(editor))}</ul>
+    `;
+  }
+
+  /**
+   * @param {?User} editor
+   * @return {TemplateResult}
+   */
+  _renderEditor(editor) {
+    if (!editor) return html`<li class="placeholder"></li>`;
+
+    const canRemove = this._permissions.includes(hotlists.ADMINISTER) ||
+        editor.name === this._currentUserName;
+
+    return html`
+      <li>
+        <mr-user-link .userRef=${userV3ToRef(editor)}></mr-user-link>
+        ${canRemove ? html`
+          <button @click=${this._removeEditor.bind(this, editor.name)}>
+            <i class="material-icons">clear</i>
+          </button>
+        ` : html``}
+      </li>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // Populated from Redux.
+      _hotlist: {type: Object},
+      _owner: {type: Object},
+      _editors: {type: Array},
+      _permissions: {type: Array},
+      _currentUserName: {type: String},
+      _fetchError: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    // Populated from Redux.
+    /** @type {?Hotlist} */ this._hotlist = null;
+    /** @type {?User} */ this._owner = null;
+    /** @type {Array<User>} */ this._editors = null;
+    /** @type {Array<Permission>} */ this._permissions = [];
+    /** @type {?String} */ this._currentUserName = null;
+    /** @type {?Error} */ this._fetchError = null;
+
+    this._debouncedAddEditors = debounce(this._addEditors, 400, true);
+  }
+
+  /** Adds hotlist editors.
+   * @param {Event} event
+   */
+  async _onAddEditors(event) {
+    event.preventDefault();
+
+    const input =
+      /** @type {HTMLInputElement} */ (this.shadowRoot.getElementById('add'));
+    const emails = input.value.split(/[\s,;]/).filter((e) => e);
+    if (!emails.length) return;
+    const editors = emails.map((email) => 'users/' + email);
+    try {
+      await this._debouncedAddEditors(editors);
+      input.value = '';
+    } catch (error) {
+      // The `hotlists.update()` call shows a snackbar on errors.
+    }
+  }
+
+  /** Adds hotlist editors.
+   * @param {Array<string>} editors An Array of User resource names.
+   */
+  async _addEditors(editors) {}
+
+  /**
+   * Removes a hotlist editor.
+   * @param {string} name A User resource name.
+  */
+  async _removeEditor(name) {}
+};
+
+/** Redux-connected version of _MrHotlistPeoplePage. */
+export class MrHotlistPeoplePage extends connectStore(_MrHotlistPeoplePage) {
+  /** @override */
+  stateChanged(state) {
+    this._hotlist = hotlists.viewedHotlist(state);
+    this._owner = hotlists.viewedHotlistOwner(state);
+    this._editors = hotlists.viewedHotlistEditors(state);
+    this._permissions = hotlists.viewedHotlistPermissions(state);
+    this._currentUserName = users.currentUserName(state);
+    this._fetchError = hotlists.requests(state).fetch.error;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has('_hotlist') && this._hotlist) {
+      const pageTitle = 'People - ' + this._hotlist.displayName;
+      store.dispatch(sitewide.setPageTitle(pageTitle));
+      const headerTitle = 'Hotlist ' + this._hotlist.displayName;
+      store.dispatch(sitewide.setHeaderTitle(headerTitle));
+    }
+  }
+
+  /** @override */
+  async _addEditors(editors) {
+    await store.dispatch(hotlists.update(this._hotlist.name, {editors}));
+  }
+
+  /** @override */
+  async _removeEditor(name) {
+    await store.dispatch(hotlists.removeEditors(this._hotlist.name, [name]));
+  }
+}
+
+customElements.define('mr-hotlist-people-page-base', _MrHotlistPeoplePage);
+customElements.define('mr-hotlist-people-page', MrHotlistPeoplePage);
diff --git a/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.test.js b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.test.js
new file mode 100644
index 0000000..b7dd6dc
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-people-page/mr-hotlist-people-page.test.js
@@ -0,0 +1,176 @@
+// 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 sinon from 'sinon';
+
+import {store, resetState} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+
+import {MrHotlistPeoplePage} from './mr-hotlist-people-page.js';
+
+/** @type {MrHotlistPeoplePage} */
+let element;
+
+describe('mr-hotlist-people-page (unconnected)', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-people-page-base');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('shows hotlist fetch error', async () => {
+    element._fetchError = new Error('This is an important error');
+    element._fetchError.description = 'This is an important error';
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'important error');
+  });
+
+  it('renders placeholders with no data', async () => {
+    await element.updateComplete;
+
+    const placeholders = element.shadowRoot.querySelectorAll('.placeholder');
+    assert.equal(placeholders.length, 2);
+  });
+
+  it('renders placeholders with editors list but no user data', async () => {
+    element._editors = [null, null];
+    await element.updateComplete;
+
+    const placeholders = element.shadowRoot.querySelectorAll('.placeholder');
+    assert.equal(placeholders.length, 3);
+  });
+
+  it('renders "No editors"', async () => {
+    element._editors = [];
+    await element.updateComplete;
+
+    assert.include(element.shadowRoot.innerHTML, 'No editors');
+  });
+
+  it('renders hotlist', async () => {
+    element._hotlist = example.HOTLIST;
+    element._owner = exampleUsers.USER;
+    element._editors = [exampleUsers.USER_2];
+    await element.updateComplete;
+  });
+
+  it('shows controls iff user has admin permissions', async () => {
+    element._editors = [exampleUsers.USER_2];
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelectorAll('button').length, 0);
+
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelectorAll('button').length, 2);
+  });
+
+  it('shows remove button if user is editing themselves', async () => {
+    element._editors = [exampleUsers.USER, exampleUsers.USER_2];
+    element._currentUserName = exampleUsers.USER_2.name;
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelectorAll('button').length, 1);
+  });
+});
+
+describe('mr-hotlist-people-page (connected)', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-people-page');
+    document.body.appendChild(element);
+
+    // Stop Redux from overriding values being tested.
+    sinon.stub(element, 'stateChanged');
+  });
+
+  afterEach(() => {
+    element.stateChanged.restore();
+    document.body.removeChild(element);
+  });
+
+  it('initializes', async () => {
+    assert.instanceOf(element, MrHotlistPeoplePage);
+  });
+
+  it('updates page title and header', async () => {
+    element._hotlist = {...example.HOTLIST, displayName: 'Hotlist-Name'};
+    await element.updateComplete;
+
+    const state = store.getState();
+    assert.deepEqual(sitewide.pageTitle(state), 'People - Hotlist-Name');
+    assert.deepEqual(sitewide.headerTitle(state), 'Hotlist Hotlist-Name');
+  });
+
+  it('adds editors', async () => {
+    element._hotlist = example.HOTLIST;
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+
+    const input = /** @type {HTMLInputElement} */
+        (element.shadowRoot.getElementById('add'));
+    input.value = 'test@example.com, test2@example.com';
+
+    const update = sinon.spy(hotlists, 'update');
+    try {
+      await element._onAddEditors(new Event('submit'));
+
+      const editors = ['users/test@example.com', 'users/test2@example.com'];
+      sinon.assert.calledWith(update, example.HOTLIST.name, {editors});
+    } finally {
+      update.restore();
+    }
+  });
+
+  it('_onAddEditors ignores empty input', async () => {
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+
+    const input = /** @type {HTMLInputElement} */
+        (element.shadowRoot.getElementById('add'));
+    input.value = '  ';
+
+    const update = sinon.spy(hotlists, 'update');
+    try {
+      await element._onAddEditors(new Event('submit'));
+      sinon.assert.notCalled(update);
+    } finally {
+      update.restore();
+    }
+  });
+
+  it('removes editors', async () => {
+    element._hotlist = example.HOTLIST;
+
+    const removeEditors = sinon.spy(hotlists, 'removeEditors');
+    try {
+      await element._removeEditor(exampleUsers.NAME_2);
+
+      sinon.assert.calledWith(
+          removeEditors, example.HOTLIST.name, [exampleUsers.NAME_2]);
+    } finally {
+      removeEditors.restore();
+    }
+  });
+});
+
+it('mr-hotlist-people-page (stateChanged)', () => {
+  // @ts-ignore
+  element = document.createElement('mr-hotlist-people-page');
+  document.body.appendChild(element);
+  assert.instanceOf(element, MrHotlistPeoplePage);
+  document.body.removeChild(element);
+});