| // Copyright 2019 The Chromium Authors |
| // 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); |