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);
+});