Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.js b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.js
new file mode 100644
index 0000000..b7087a9
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.js
@@ -0,0 +1,72 @@
+// 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/framework/mr-tabs/mr-tabs.js';
+
+/** @type {readonly MenuItem[]} */
+const _MENU_ITEMS = Object.freeze([
+  {
+    icon: 'list',
+    text: 'Issues',
+    url: 'issues',
+  },
+  {
+    icon: 'people',
+    text: 'People',
+    url: 'people',
+  },
+  {
+    icon: 'settings',
+    text: 'Settings',
+    url: 'settings',
+  },
+]);
+
+// TODO(dtu): Put this inside <mr-header>. Currently, we can't do this because
+// the sticky table headers rely on having a fixed header height. We need to
+// add a scrolling context to the page in order to have a dynamic-height
+// sticky, and to do that the footer needs to be in the scrolling context. So,
+// the footer needs to be SPA-ified.
+/** Hotlist Issues page */
+export class MrHotlistHeader extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      h1 {
+        font-size: 20px;
+        font-weight: normal;
+        margin: 16px 24px;
+      }
+      nav {
+        border-bottom: solid #ddd 1px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <nav>
+        <mr-tabs .items=${_MENU_ITEMS} .selected=${this.selected}></mr-tabs>
+      </nav>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      selected: {type: Number},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+    /** @type {number} */
+    this.selected = 0;
+  }
+}
+
+customElements.define('mr-hotlist-header', MrHotlistHeader);
diff --git a/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.test.js b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.test.js
new file mode 100644
index 0000000..9321d59
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-header/mr-hotlist-header.test.js
@@ -0,0 +1,32 @@
+// 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 {MrHotlistHeader} from './mr-hotlist-header.js';
+
+/** @type {MrHotlistHeader} */
+let element;
+
+describe('mr-hotlist-header', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-header');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrHotlistHeader);
+  });
+
+  it('renders', async () => {
+    element.selected = 2;
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelector('mr-tabs').selected, 2);
+  });
+});
diff --git a/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js
new file mode 100644
index 0000000..fa76477
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.js
@@ -0,0 +1,361 @@
+// 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 {defaultMemoize} from 'reselect';
+
+import {relativeTime}
+  from 'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
+import {issueNameToRef, issueToName, userNameToId}
+  from 'shared/convertersV0.js';
+import {DEFAULT_ISSUE_FIELD_LIST} from 'shared/issue-fields.js';
+
+import {store, connectStore} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as ui from 'reducers/ui.js';
+
+import 'elements/chops/chops-filter-chips/chops-filter-chips.js';
+import 'elements/framework/dialogs/mr-change-columns/mr-change-columns.js';
+// eslint-disable-next-line max-len
+import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js';
+// eslint-disable-next-line max-len
+import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js';
+import 'elements/framework/mr-button-bar/mr-button-bar.js';
+import 'elements/framework/mr-issue-list/mr-issue-list.js';
+import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
+
+const DEFAULT_HOTLIST_FIELDS = Object.freeze([
+  ...DEFAULT_ISSUE_FIELD_LIST,
+  'Added',
+  'Adder',
+  'Rank',
+]);
+
+/** Hotlist Issues page */
+export class _MrHotlistIssuesPage extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      section, p, div {
+        margin: 16px 24px;
+      }
+      div {
+        align-items: center;
+        display: flex;
+      }
+      chops-filter-chips {
+        margin-left: 6px;
+      }
+      mr-button-bar {
+        margin: 16px 24px 8px 24px;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <mr-hotlist-header selected=0></mr-hotlist-header>
+      ${this._renderPage()}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderPage() {
+    if (!this._hotlist) {
+      if (this._fetchError) {
+        return html`<section>${this._fetchError.description}</section>`;
+      } else {
+        return html`<section>Loading...</section>`;
+      }
+    }
+
+    // Memoize the issues passed to <mr-issue-list> so that
+    // out property updates don't cause it to re-render.
+    const items = _filterIssues(this._filter, this._items);
+
+    const allProjectNamesEqual = items.length && items.every(
+        (issue) => issue.projectName === items[0].projectName);
+    const projectName = allProjectNamesEqual ? items[0].projectName : null;
+
+    /** @type {HotlistV0} */
+    // Populates <mr-update-issue-hotlists-dialog>' issueHotlists property.
+    const hotlistV0 = {
+      ownerRef: {userId: userNameToId(this._hotlist.owner)},
+      name: this._hotlist.displayName,
+    };
+
+    const mayEdit = this._permissions.includes(hotlists.ADMINISTER) ||
+                    this._permissions.includes(hotlists.EDIT);
+    // TODO(https://crbug.com/monorail/7776): The UI to allow reranking of
+    // Issues should reflect user permissions.
+
+    return html`
+      <p>${this._hotlist.summary}</p>
+
+      <div>
+        Filter by Status
+        <chops-filter-chips
+            .options=${['Open', 'Closed']}
+            .selected=${this._filter}
+            @change=${this._onFilterChange}
+        ></chops-filter-chips>
+      </div>
+
+      <mr-button-bar .items=${this._buttonBarItems()}></mr-button-bar>
+
+      <mr-issue-list
+        .issues=${items}
+        .projectName=${projectName}
+        .columns=${this._columns}
+        .defaultFields=${DEFAULT_HOTLIST_FIELDS}
+        .extractFieldValues=${this._extractFieldValues.bind(this)}
+        .rerank=${mayEdit ? this._rerankItems.bind(this) : null}
+        ?selectionEnabled=${mayEdit}
+        @selectionChange=${this._onSelectionChange}
+      ></mr-issue-list>
+
+      <mr-change-columns .columns=${this._columns}></mr-change-columns>
+      <mr-update-issue-hotlists-dialog
+        .issueRefs=${this._selected.map(issueNameToRef)}
+        .issueHotlists=${[hotlistV0]}
+        @saveSuccess=${this._handleHotlistSaveSuccess}
+      ></mr-update-issue-hotlists-dialog>
+      <mr-move-issue-hotlists-dialog
+        .issueRefs=${this._selected.map(issueNameToRef)}
+        @saveSuccess=${this._handleHotlistSaveSuccess}
+      ><mr-move-issue-hotlists-dialog>
+    `;
+  }
+
+  /**
+   * @return {Array<MenuItem>}
+   */
+  _buttonBarItems() {
+    if (this._selected.length) {
+      return [
+        {
+          icon: 'remove_circle_outline',
+          text: 'Remove',
+          handler: this._removeItems.bind(this)},
+        {
+          icon: 'edit',
+          text: 'Update',
+          handler: this._openUpdateIssuesHotlistsDialog.bind(this),
+        },
+        {
+          icon: 'forward',
+          text: 'Move to...',
+          handler: this._openMoveToHotlistDialog.bind(this),
+        },
+      ];
+    } else {
+      return [
+        // TODO(dtu): Implement this action.
+        // {icon: 'add', text: 'Add issues'},
+        {
+          icon: 'table_chart',
+          text: 'Change columns',
+          handler: this._openColumnsDialog.bind(this),
+        },
+      ];
+    }
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // Populated from Redux.
+      _hotlist: {type: Object},
+      _permissions: {type: Array},
+      _items: {type: Array},
+      _columns: {type: Array},
+      _fetchError: {type: Object},
+      _extractFieldValuesFromIssue: {type: Object},
+
+      // Populated from events.
+      _filter: {type: Object},
+      _selected: {type: Array},
+    };
+  };
+
+  /** @override */
+  constructor() {
+    super();
+
+    // Populated from Redux.
+    /** @type {?Hotlist} */
+    this._hotlist = null;
+    /** @type {Array<Permission>} */
+    this._permissions = [];
+    /** @type {Array<HotlistIssue>} */
+    this._items = [];
+    /** @type {Array<string>} */
+    this._columns = [];
+    /** @type {?Error} */
+    this._fetchError = null;
+    /**
+     * @param {Issue} _issue
+     * @param {string} _fieldName
+     * @return {Array<string>}
+     */
+    this._extractFieldValuesFromIssue = (_issue, _fieldName) => [];
+
+    // Populated from events.
+    /** @type {Object<string, boolean>} */
+    this._filter = {Open: true};
+    /**
+     * An array of selected Issue Names.
+     * TODO(https://crbug.com/monorail/7440): Update typedef.
+     * @type {Array<string>}
+     */
+    this._selected = [];
+  }
+
+  /**
+   * @param {HotlistIssue} hotlistIssue
+   * @param {string} fieldName
+   * @return {Array<string>}
+   */
+  _extractFieldValues(hotlistIssue, fieldName) {
+    switch (fieldName) {
+      case 'Added':
+        return [relativeTime(new Date(hotlistIssue.createTime))];
+      case 'Adder':
+        return [hotlistIssue.adder.displayName];
+      case 'Rank':
+        return [String(hotlistIssue.rank + 1)];
+      default:
+        return this._extractFieldValuesFromIssue(hotlistIssue, fieldName);
+    }
+  }
+
+  /**
+   * @param {Event} e A change event fired by <chops-filter-chips>.
+   */
+  _onFilterChange(e) {
+    this._filter = e.target.selected;
+  }
+
+  /**
+   * @param {CustomEvent} e A selectionChange event fired by <mr-issue-list>.
+   */
+  _onSelectionChange(e) {
+    this._selected = e.target.selectedIssues.map(issueToName);
+  }
+
+  /** Opens a dialog to change the columns shown in the issue list. */
+  _openColumnsDialog() {
+    this.shadowRoot.querySelector('mr-change-columns').open();
+  }
+
+  /** Handles successfully saved Hotlist changes. */
+  async _handleHotlistSaveSuccess() {}
+
+  /** Removes items from the hotlist, dispatching an action to Redux. */
+  async _removeItems() {}
+
+  /** Opens a dialog to update attached Hotlists for selected Issues. */
+  _openUpdateIssuesHotlistsDialog() {
+    this.shadowRoot.querySelector('mr-update-issue-hotlists-dialog').open();
+  }
+
+  /** Opens a dialog to move selected Issues to desired Hotlist. */
+  _openMoveToHotlistDialog() {
+    this.shadowRoot.querySelector('mr-move-issue-hotlists-dialog').open();
+  }
+  /**
+   * Reranks items in the hotlist, dispatching an action to Redux.
+   * @param {Array<String>} items The names of the HotlistItems to move.
+   * @param {number} index The index to insert the moved items.
+   * @return {Promise<void>}
+   */
+  async _rerankItems(items, index) {}
+};
+
+/** Redux-connected version of _MrHotlistIssuesPage. */
+export class MrHotlistIssuesPage extends connectStore(_MrHotlistIssuesPage) {
+  /** @override */
+  stateChanged(state) {
+    this._hotlist = hotlists.viewedHotlist(state);
+    this._permissions = hotlists.viewedHotlistPermissions(state);
+    this._items = hotlists.viewedHotlistIssues(state);
+    this._columns = hotlists.viewedHotlistColumns(state);
+    this._fetchError = hotlists.requests(state).fetch.error;
+    this._extractFieldValuesFromIssue =
+      projectV0.extractFieldValuesFromIssue(state);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('_hotlist') && this._hotlist) {
+      const pageTitle = `Issues - ${this._hotlist.displayName}`;
+      store.dispatch(sitewide.setPageTitle(pageTitle));
+      const headerTitle = `Hotlist ${this._hotlist.displayName}`;
+      store.dispatch(sitewide.setHeaderTitle(headerTitle));
+    }
+  }
+
+  /** @override */
+  async _handleHotlistSaveSuccess() {
+    const action = hotlists.fetchItems(this._hotlist.name);
+    await store.dispatch(action);
+    store.dispatch(ui.showSnackbar(ui.snackbarNames.UPDATE_HOTLISTS_SUCCESS,
+        'Hotlists updated.'));
+  }
+
+  /** @override */
+  async _removeItems() {
+    const action = hotlists.removeItems(this._hotlist.name, this._selected);
+    await store.dispatch(action);
+  }
+
+  /** @override */
+  async _rerankItems(items, index) {
+    // The index given from <mr-issue-list> includes only the items shown in
+    // the list and excludes the items that are being moved. So, we need to
+    // count the hidden items.
+    let shownItems = 0;
+    let hiddenItems = 0;
+    for (let i = 0; shownItems < index && i < this._items.length; ++i) {
+      const item = this._items[i];
+      const isShown = _isShown(this._filter, item);
+      if (!isShown) ++hiddenItems;
+      if (isShown && !items.includes(item.name)) ++shownItems;
+    }
+
+    await store.dispatch(hotlists.rerankItems(
+        this._hotlist.name, items, index + hiddenItems));
+  }
+};
+
+const _filterIssues = defaultMemoize(
+    /**
+     * Filters an array of HotlistIssues based on a filter condition. Memoized.
+     * @param {Object<string, boolean>} filter The types of issues to show.
+     * @param {Array<HotlistIssue>} items A HotlistIssue to check.
+     * @return {Array<HotlistIssue>}
+     */
+    (filter, items) => items.filter((item) => _isShown(filter, item)));
+
+/**
+ * Returns true iff the current filter includes the given HotlistIssue.
+ * @param {Object<string, boolean>} filter The types of issues to show.
+ * @param {HotlistIssue} item A HotlistIssue to check.
+ * @return {boolean}
+ */
+function _isShown(filter, item) {
+  return filter.Open && item.statusRef.meansOpen ||
+      filter.Closed && !item.statusRef.meansOpen;
+}
+
+customElements.define('mr-hotlist-issues-page-base', _MrHotlistIssuesPage);
+customElements.define('mr-hotlist-issues-page', MrHotlistIssuesPage);
diff --git a/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.test.js b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.test.js
new file mode 100644
index 0000000..a651578
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-issues-page/mr-hotlist-issues-page.test.js
@@ -0,0 +1,338 @@
+// 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 {store, resetState} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleIssues from 'shared/test/constants-issueV0.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+import {PERMISSION_HOTLIST_EDIT} from 'shared/test/constants-permissions.js';
+
+import {MrHotlistIssuesPage} from './mr-hotlist-issues-page.js';
+
+/** @type {MrHotlistIssuesPage} */
+let element;
+
+describe('mr-hotlist-issues-page (unconnected)', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-issues-page-base');
+    element._extractFieldValuesFromIssue =
+      projectV0.extractFieldValuesFromIssue({});
+    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('shows loading message with null hotlist', async () => {
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'Loading');
+  });
+
+  it('renders hotlist items with one project', async () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.deepEqual(issueList.projectName, 'project-name');
+  });
+
+  it('renders hotlist items with multiple projects', async () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [
+      example.HOTLIST_ISSUE,
+      example.HOTLIST_ISSUE_OTHER_PROJECT,
+    ];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.isNull(issueList.projectName);
+  });
+
+  it('needs permissions to rerank', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.isNull(issueList.rerank);
+
+    element._permissions = [hotlists.EDIT];
+    await element.updateComplete;
+
+    assert.isNotNull(issueList.rerank);
+  });
+
+  it('memoizes issues', async () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    const issues = issueList.issues;
+
+    // Trigger a render without updating the issue list.
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    assert.strictEqual(issues, issueList.issues);
+
+    // Modify the issue list.
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    assert.notStrictEqual(issues, issueList.issues);
+  });
+
+  it('computes strings for HotlistIssue fields', async () => {
+    const clock = sinon.useFakeTimers(24 * 60 * 60 * 1000);
+
+    try {
+      element._hotlist = example.HOTLIST;
+      element._items = [{
+        ...example.HOTLIST_ISSUE,
+        summary: 'Summary',
+        rank: 52,
+        adder: exampleUsers.USER,
+        createTime: new Date(0).toISOString(),
+      }];
+      element._columns = ['Summary', 'Rank', 'Added', 'Adder'];
+      await element.updateComplete;
+
+      const issueList = element.shadowRoot.querySelector('mr-issue-list');
+      assert.include(issueList.shadowRoot.innerHTML, 'Summary');
+      assert.include(issueList.shadowRoot.innerHTML, '53');
+      assert.include(issueList.shadowRoot.innerHTML, 'a day ago');
+      assert.include(issueList.shadowRoot.innerHTML, exampleUsers.DISPLAY_NAME);
+    } finally {
+      clock.restore();
+    }
+  });
+
+  it('filters and shows closed issues', async () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE_CLOSED];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.equal(issueList.issues.length, 0);
+
+    element.shadowRoot.querySelector('chops-filter-chips').select('Closed');
+    await element.updateComplete;
+
+    assert.isTrue(element._filter.Closed);
+    assert.equal(issueList.issues.length, 1);
+  });
+
+  it('updates button bar on list selection', async () => {
+    element._permissions = PERMISSION_HOTLIST_EDIT;
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    const buttonBar = element.shadowRoot.querySelector('mr-button-bar');
+    assert.include(buttonBar.shadowRoot.innerHTML, 'Change columns');
+    assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Remove');
+    assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Update');
+    assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Move to...');
+    assert.deepEqual(element._selected, []);
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    issueList.shadowRoot.querySelector('input').click();
+    await element.updateComplete;
+
+    assert.notInclude(buttonBar.shadowRoot.innerHTML, 'Change columns');
+    assert.include(buttonBar.shadowRoot.innerHTML, 'Remove');
+    assert.include(buttonBar.shadowRoot.innerHTML, 'Update');
+    assert.include(buttonBar.shadowRoot.innerHTML, 'Move to...');
+    assert.deepEqual(element._selected, [exampleIssues.NAME]);
+  });
+
+  it('hides issues checkboxes if the user cannot edit', async () => {
+    element._permissions = [];
+    element._hotlist = example.HOTLIST;
+    element._items = [example.HOTLIST_ISSUE];
+    await element.updateComplete;
+
+    const issueList = element.shadowRoot.querySelector('mr-issue-list');
+    assert.notInclude(issueList.shadowRoot.innerHTML, 'input');
+  });
+
+  it('opens "Change columns" dialog', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    const dialog = element.shadowRoot.querySelector('mr-change-columns');
+    sinon.stub(dialog, 'open');
+    try {
+      element._openColumnsDialog();
+
+      sinon.assert.calledOnce(dialog.open);
+    } finally {
+      dialog.open.restore();
+    }
+  });
+
+  it('opens "Update" dialog', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    const dialog = element.shadowRoot.querySelector(
+        'mr-update-issue-hotlists-dialog');
+    sinon.stub(dialog, 'open');
+    try {
+      element._openUpdateIssuesHotlistsDialog();
+
+      sinon.assert.calledOnce(dialog.open);
+    } finally {
+      dialog.open.restore();
+    }
+  });
+
+  it('handles successful save from its update dialog', async () => {
+    sinon.stub(element, '_handleHotlistSaveSuccess');
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    try {
+      const dialog =
+          element.shadowRoot.querySelector('mr-update-issue-hotlists-dialog');
+      dialog.dispatchEvent(new Event('saveSuccess'));
+      sinon.assert.calledOnce(element._handleHotlistSaveSuccess);
+    } finally {
+      element._handleHotlistSaveSuccess.restore();
+    }
+  });
+
+  it('opens "Move to..." dialog', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    const dialog = element.shadowRoot.querySelector(
+        'mr-move-issue-hotlists-dialog');
+    sinon.stub(dialog, 'open');
+    try {
+      element._openMoveToHotlistDialog();
+
+      sinon.assert.calledOnce(dialog.open);
+    } finally {
+      dialog.open.restore();
+    }
+  });
+
+  it('handles successful save from its move dialog', async () => {
+    sinon.stub(element, '_handleHotlistSaveSuccess');
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+
+    try {
+      const dialog =
+          element.shadowRoot.querySelector('mr-move-issue-hotlists-dialog');
+      dialog.dispatchEvent(new Event('saveSuccess'));
+      sinon.assert.calledOnce(element._handleHotlistSaveSuccess);
+    } finally {
+      element._handleHotlistSaveSuccess.restore();
+    }
+  });
+});
+
+describe('mr-hotlist-issues-page (connected)', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-issues-page');
+    element._extractFieldValuesFromIssue =
+      projectV0.extractFieldValuesFromIssue({});
+    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', () => {
+    assert.instanceOf(element, MrHotlistIssuesPage);
+  });
+
+  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), 'Issues - Hotlist-Name');
+    assert.deepEqual(sitewide.headerTitle(state), 'Hotlist Hotlist-Name');
+  });
+
+  it('removes items', () => {
+    element._hotlist = example.HOTLIST;
+    element._selected = [exampleIssues.NAME];
+
+    const removeItems = sinon.spy(hotlists, 'removeItems');
+    try {
+      element._removeItems();
+      sinon.assert.calledWith(removeItems, example.NAME, [exampleIssues.NAME]);
+    } finally {
+      removeItems.restore();
+    }
+  });
+
+  it('fetches a hotlist when handling a successful save', () => {
+    element._hotlist = example.HOTLIST;
+
+    const fetchItems = sinon.spy(hotlists, 'fetchItems');
+    try {
+      element._handleHotlistSaveSuccess();
+      sinon.assert.calledWith(fetchItems, example.NAME);
+    } finally {
+      fetchItems.restore();
+    }
+  });
+
+  it('reranks', () => {
+    element._hotlist = example.HOTLIST;
+    element._items = [
+      example.HOTLIST_ISSUE,
+      example.HOTLIST_ISSUE_CLOSED,
+      example.HOTLIST_ISSUE_OTHER_PROJECT,
+    ];
+
+    const rerankItems = sinon.spy(hotlists, 'rerankItems');
+    try {
+      element._rerankItems([example.HOTLIST_ITEM_NAME], 1);
+
+      sinon.assert.calledWith(
+          rerankItems, example.NAME, [example.HOTLIST_ITEM_NAME], 2);
+    } finally {
+      rerankItems.restore();
+    }
+  });
+});
+
+it('mr-hotlist-issues-page (stateChanged)', () => {
+  // @ts-ignore
+  element = document.createElement('mr-hotlist-issues-page');
+  document.body.appendChild(element);
+  assert.instanceOf(element, MrHotlistIssuesPage);
+  document.body.removeChild(element);
+});
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);
+});
diff --git a/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js
new file mode 100644
index 0000000..4f4d90d
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.js
@@ -0,0 +1,310 @@
+// 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 'shared/typedef.js';
+import {store, connectStore} from 'reducers/base.js';
+import {hotlists} from 'reducers/hotlists.js';
+import * as sitewide from 'reducers/sitewide.js';
+import * as ui from 'reducers/ui.js';
+import * as userV0 from 'reducers/userV0.js';
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import 'elements/chops/chops-button/chops-button.js';
+import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
+
+/**
+ * Supported Hotlist privacy options from feature_objects.proto.
+ * @enum {string}
+ */
+const HotlistPrivacy = {
+  PRIVATE: 'PRIVATE',
+  PUBLIC: 'PUBLIC',
+};
+
+/** Hotlist Settings page */
+class _MrHotlistSettingsPage extends LitElement {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          display: block;
+        }
+        h2 {
+          font-weight: normal;
+        }
+        section, dl, form {
+          margin: 16px 24px;
+        }
+        dt {
+          font-weight: bold;
+          text-align: right;
+          word-wrap: break-word;
+        }
+        dd {
+          margin-left: 0;
+        }
+        label {
+          display: flex;
+          flex-direction: column;
+        }
+        form input,
+        form select {
+          /* Match minimum size of header. */
+          min-width: 250px;
+        }
+        /* https://material.io/design/layout/responsive-layout-grid.html#breakpoints */
+        @media (min-width: 1024px) {
+          input,
+          select,
+          p,
+          dd {
+            max-width: 750px;
+          }
+        }
+        #save-hotlist {
+          background: var(--chops-primary-button-bg);
+          color: var(--chops-primary-button-color);
+        }
+     `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <mr-hotlist-header selected=2></mr-hotlist-header>
+      ${this._renderPage()}
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderPage() {
+    if (!this._hotlist) {
+      if (this._fetchError) {
+        return html`<section>${this._fetchError.description}</section>`;
+      } else {
+        return html`<section>Loading...</section>`;
+      }
+    }
+
+    const defaultColumns = this._hotlist.defaultColumns
+        .map((col) => col.column).join(' ');
+    if (this._permissions.includes(hotlists.ADMINISTER)) {
+      return this._renderEditableForm(defaultColumns);
+    }
+    return this._renderViewOnly(defaultColumns);
+  }
+
+  /**
+   * Render the editable form Settings page.
+   * @param {string} defaultColumns The default columns to be shown.
+   * @return {TemplateResult}
+   */
+  _renderEditableForm(defaultColumns) {
+    return html`
+      <form id="settingsForm" class="input-grid"
+        @change=${this._handleFormChange}>
+        <label>Name</label>
+        <input id="displayName" class="path"
+            value="${this._hotlist.displayName}">
+        <label>Summary</label>
+        <input id="summary" class="path" value="${this._hotlist.summary}">
+        <label>Default Issues columns</label>
+        <input id="defaultColumns" class="path" value="${defaultColumns}">
+        <label>Who can view this hotlist</label>
+        <select id="hotlistPrivacy" class="path">
+          <option
+            value="${HotlistPrivacy.PUBLIC}"
+            ?selected="${this._hotlist.hotlistPrivacy ===
+                        HotlistPrivacy.PUBLIC}">
+            Anyone on the Internet
+          </option>
+          <option
+            value="${HotlistPrivacy.PRIVATE}"
+            ?selected="${this._hotlist.hotlistPrivacy ===
+                        HotlistPrivacy.PRIVATE}">
+            Members only
+          </option>
+        </select>
+        <span><!-- grid spacer --></span>
+        <p>
+          Individual issues in the list can only be seen by users who can
+          normally see them. The privacy status of an issue is considered
+          when it is being displayed (or not displayed) in a hotlist.
+        </p>
+        <span><!-- grid spacer --></span>
+        <div>
+          <chops-button @click=${this._save} id="save-hotlist" disabled>
+            Save hotlist
+          </chops-button>
+          <chops-button @click=${this._delete} id="delete-hotlist">
+            Delete hotlist
+          </chops-button>
+        </div>
+      </form>
+    `;
+  }
+
+  /**
+   * Render the view-only Settings page.
+   * @param {string} defaultColumns The default columns to be shown.
+   * @return {TemplateResult}
+   */
+  _renderViewOnly(defaultColumns) {
+    return html`
+      <dl class="input-grid">
+        <dt>Name</dt>
+        <dd>${this._hotlist.displayName}</dd>
+        <dt>Summary</dt>
+        <dd>${this._hotlist.summary}</dd>
+        <dt>Default Issues columns</dt>
+        <dd>${defaultColumns}</dd>
+        <dt>Who can view this hotlist</dt>
+        <dd>
+          ${this._hotlist.hotlistPrivacy &&
+            this._hotlist.hotlistPrivacy === HotlistPrivacy.PUBLIC ?
+            'Anyone on the Internet' : 'Members only'}
+        </dd>
+        <dt></dt>
+        <dd>
+          Individual issues in the list can only be seen by users who can
+          normally see them. The privacy status of an issue is considered
+          when it is being displayed (or not displayed) in a hotlist.
+        </dd>
+      </dl>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      // Populated from Redux.
+      _hotlist: {type: Object},
+      _permissions: {type: Array},
+      _currentUser: {type: Object},
+      _fetchError: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    // Populated from Redux.
+    /** @type {?Hotlist} */ this._hotlist = null;
+    /** @type {Array<Permission>} */ this._permissions = [];
+    /** @type {UserRef} */ this._currentUser = null;
+    /** @type {?Error} */ this._fetchError = null;
+
+    // Expose page.js for test stubbing.
+    this.page = page;
+  }
+
+  /**
+   * Handles changes to the editable form.
+   * @param {Event} e
+   */
+  _handleFormChange() {
+    const saveButton = this.shadowRoot.getElementById('save-hotlist');
+    if (saveButton.disabled) {
+      saveButton.disabled = false;
+    }
+  }
+
+  /** Saves the hotlist, dispatching an action to Redux. */
+  async _save() {}
+
+  /** Deletes the hotlist, dispatching an action to Redux. */
+  async _delete() {}
+};
+
+/** Redux-connected version of _MrHotlistSettingsPage. */
+export class MrHotlistSettingsPage
+  extends connectStore(_MrHotlistSettingsPage) {
+  /** @override */
+  stateChanged(state) {
+    this._hotlist = hotlists.viewedHotlist(state);
+    this._permissions = hotlists.viewedHotlistPermissions(state);
+    this._currentUser = userV0.currentUser(state);
+    this._fetchError = hotlists.requests(state).fetch.error;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    super.updated(changedProperties);
+
+    if (changedProperties.has('_hotlist') && this._hotlist) {
+      const pageTitle = 'Settings - ' + this._hotlist.displayName;
+      store.dispatch(sitewide.setPageTitle(pageTitle));
+      const headerTitle = 'Hotlist ' + this._hotlist.displayName;
+      store.dispatch(sitewide.setHeaderTitle(headerTitle));
+    }
+  }
+
+  /** @override */
+  async _save() {
+    const form = this.shadowRoot.getElementById('settingsForm');
+    if (!form) return;
+
+    // TODO(https://crbug.com/monorail/7475): Consider generalizing this logic.
+    const updatedHotlist = /** @type {Hotlist} */({});
+    // These are is an input or select elements.
+    const pathInputs = form.querySelectorAll('.path');
+    pathInputs.forEach((input) => {
+      const path = input.id;
+      const value = /** @type {HTMLInputElement} */(input).value;
+      switch (path) {
+        case 'defaultColumns':
+          const columnsValue = [];
+          value.trim().split(' ').forEach((column) => {
+            if (column) columnsValue.push({column});
+          });
+          if (JSON.stringify(columnsValue) !==
+              JSON.stringify(this._hotlist.defaultColumns)) {
+            updatedHotlist.defaultColumns = columnsValue;
+          }
+          break;
+        default:
+          if (value !== this._hotlist[path]) updatedHotlist[path] = value;
+          break;
+      };
+    });
+
+    const action = hotlists.update(this._hotlist.name, updatedHotlist);
+    await store.dispatch(action);
+    this._showHotlistSavedSnackbar();
+  }
+
+  /**
+   * Shows a snackbar informing the user about their save request.
+   */
+  async _showHotlistSavedSnackbar() {
+    await store.dispatch(ui.showSnackbar(
+        'SNACKBAR_ID_HOTLIST_SETTINGS_UPDATED', 'Hotlist Updated.'));
+  }
+
+  /** @override */
+  async _delete() {
+    if (confirm(
+        'Are you sure you want to delete this hotlist? This cannot be undone.')
+    ) {
+      const action = hotlists.deleteHotlist(this._hotlist.name);
+      await store.dispatch(action);
+
+      // TODO(crbug/monorail/7430): Handle an error and add <chops-snackbar>.
+      // Note that this will redirect regardless of an error.
+      this.page(`/u/${this._currentUser.displayName}/hotlists`);
+    }
+  }
+}
+
+customElements.define('mr-hotlist-settings-page-base', _MrHotlistSettingsPage);
+customElements.define('mr-hotlist-settings-page', MrHotlistSettingsPage);
diff --git a/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.test.js b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.test.js
new file mode 100644
index 0000000..987fff2
--- /dev/null
+++ b/static_src/elements/hotlist/mr-hotlist-settings-page/mr-hotlist-settings-page.test.js
@@ -0,0 +1,167 @@
+// 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 {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 {MrHotlistSettingsPage} from './mr-hotlist-settings-page.js';
+
+/** @type {MrHotlistSettingsPage} */
+let element;
+
+describe('mr-hotlist-settings-page (unconnected)', () => {
+  beforeEach(() => {
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-settings-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('shows loading message with null hotlist', async () => {
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'Loading');
+  });
+
+  it('renders hotlist', async () => {
+    element._hotlist = example.HOTLIST;
+    await element.updateComplete;
+  });
+
+  it('renders a view only hotlist if no permissions', async () => {
+    element._hotlist = {...example.HOTLIST};
+    await element.updateComplete;
+    assert.notInclude(element.shadowRoot.innerHTML, 'form');
+  });
+
+  it('renders an editable hotlist if permission to administer', async () => {
+    element._hotlist = {...example.HOTLIST};
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'form');
+  });
+
+  it('renders private hotlist', async () => {
+    element._hotlist = {...example.HOTLIST, hotlistPrivacy: 'PRIVATE'};
+    await element.updateComplete;
+    assert.include(element.shadowRoot.innerHTML, 'Members only');
+  });
+});
+
+describe('mr-hotlist-settings-page (connected)', () => {
+  beforeEach(() => {
+    store.dispatch(resetState());
+
+    // @ts-ignore
+    element = document.createElement('mr-hotlist-settings-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('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), 'Settings - Hotlist-Name');
+    assert.deepEqual(sitewide.headerTitle(state), 'Hotlist Hotlist-Name');
+  });
+
+  it('deletes hotlist', async () => {
+    element._hotlist = example.HOTLIST;
+    element._permissions = [hotlists.ADMINISTER];
+    element._currentUser = exampleUsers.USER;
+    await element.updateComplete;
+
+    const deleteButton = element.shadowRoot.getElementById('delete-hotlist');
+    assert.isNotNull(deleteButton);
+
+    // Auto confirm deletion of hotlist.
+    const confirmStub = sinon.stub(window, 'confirm');
+    confirmStub.returns(true);
+
+    const pageStub = sinon.stub(element, 'page');
+
+    const deleteHotlist = sinon.spy(hotlists, 'deleteHotlist');
+
+    try {
+      await element._delete();
+
+      sinon.assert.calledWith(deleteHotlist, example.NAME);
+      sinon.assert.calledWith(
+          element.page, `/u/${exampleUsers.DISPLAY_NAME}/hotlists`);
+    } finally {
+      deleteHotlist.restore();
+      pageStub.restore();
+      confirmStub.restore();
+    }
+  });
+
+  it('updates hotlist when there are changes', async () => {
+    element._hotlist = {...example.HOTLIST};
+    element._permissions = [hotlists.ADMINISTER];
+    await element.updateComplete;
+
+    const saveButton = element.shadowRoot.getElementById('save-hotlist');
+    assert.isNotNull(saveButton);
+    assert.isTrue(saveButton.hasAttribute('disabled'));
+
+    const hlist = {
+      displayName: element._hotlist.displayName + 'foo',
+      summary: element._hotlist.summary + 'abc',
+    };
+
+    const summaryInput = element.shadowRoot.getElementById('summary');
+    /** @type {HTMLInputElement} */ (summaryInput).value += 'abc';
+    const nameInput =
+        element.shadowRoot.getElementById('displayName');
+    /** @type {HTMLInputElement} */ (nameInput).value += 'foo';
+
+    await element.shadowRoot.getElementById('settingsForm').dispatchEvent(
+        new Event('change'));
+    assert.isFalse(saveButton.hasAttribute('disabled'));
+
+    const snackbarStub = sinon.stub(element, '_showHotlistSavedSnackbar');
+    const update = sinon.stub(hotlists, 'update').returns(async () => {});
+    try {
+      await element._save();
+      sinon.assert.calledWith(update, example.HOTLIST.name, hlist);
+      sinon.assert.calledOnce(snackbarStub);
+    } finally {
+      update.restore();
+      snackbarStub.restore();
+    }
+  });
+});
+
+it('mr-hotlist-settings-page (stateChanged)', () => {
+  // @ts-ignore
+  element = document.createElement('mr-hotlist-settings-page');
+  document.body.appendChild(element);
+  assert.instanceOf(element, MrHotlistSettingsPage);
+  document.body.removeChild(element);
+});