blob: fa76477e8d52870d5faa255ba1167f2847551827 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6import {defaultMemoize} from 'reselect';
7
8import {relativeTime}
9 from 'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
10import {issueNameToRef, issueToName, userNameToId}
11 from 'shared/convertersV0.js';
12import {DEFAULT_ISSUE_FIELD_LIST} from 'shared/issue-fields.js';
13
14import {store, connectStore} from 'reducers/base.js';
15import {hotlists} from 'reducers/hotlists.js';
16import * as projectV0 from 'reducers/projectV0.js';
17import * as sitewide from 'reducers/sitewide.js';
18import * as ui from 'reducers/ui.js';
19
20import 'elements/chops/chops-filter-chips/chops-filter-chips.js';
21import 'elements/framework/dialogs/mr-change-columns/mr-change-columns.js';
22// eslint-disable-next-line max-len
23import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-move-issue-hotlists-dialog.js';
24// eslint-disable-next-line max-len
25import 'elements/framework/dialogs/mr-issue-hotlists-action/mr-update-issue-hotlists-dialog.js';
26import 'elements/framework/mr-button-bar/mr-button-bar.js';
27import 'elements/framework/mr-issue-list/mr-issue-list.js';
28import 'elements/hotlist/mr-hotlist-header/mr-hotlist-header.js';
29
30const DEFAULT_HOTLIST_FIELDS = Object.freeze([
31 ...DEFAULT_ISSUE_FIELD_LIST,
32 'Added',
33 'Adder',
34 'Rank',
35]);
36
37/** Hotlist Issues page */
38export class _MrHotlistIssuesPage extends LitElement {
39 /** @override */
40 static get styles() {
41 return css`
42 :host {
43 display: block;
44 }
45 section, p, div {
46 margin: 16px 24px;
47 }
48 div {
49 align-items: center;
50 display: flex;
51 }
52 chops-filter-chips {
53 margin-left: 6px;
54 }
55 mr-button-bar {
56 margin: 16px 24px 8px 24px;
57 }
58 `;
59 }
60
61 /** @override */
62 render() {
63 return html`
64 <mr-hotlist-header selected=0></mr-hotlist-header>
65 ${this._renderPage()}
66 `;
67 }
68
69 /**
70 * @return {TemplateResult}
71 */
72 _renderPage() {
73 if (!this._hotlist) {
74 if (this._fetchError) {
75 return html`<section>${this._fetchError.description}</section>`;
76 } else {
77 return html`<section>Loading...</section>`;
78 }
79 }
80
81 // Memoize the issues passed to <mr-issue-list> so that
82 // out property updates don't cause it to re-render.
83 const items = _filterIssues(this._filter, this._items);
84
85 const allProjectNamesEqual = items.length && items.every(
86 (issue) => issue.projectName === items[0].projectName);
87 const projectName = allProjectNamesEqual ? items[0].projectName : null;
88
89 /** @type {HotlistV0} */
90 // Populates <mr-update-issue-hotlists-dialog>' issueHotlists property.
91 const hotlistV0 = {
92 ownerRef: {userId: userNameToId(this._hotlist.owner)},
93 name: this._hotlist.displayName,
94 };
95
96 const mayEdit = this._permissions.includes(hotlists.ADMINISTER) ||
97 this._permissions.includes(hotlists.EDIT);
98 // TODO(https://crbug.com/monorail/7776): The UI to allow reranking of
99 // Issues should reflect user permissions.
100
101 return html`
102 <p>${this._hotlist.summary}</p>
103
104 <div>
105 Filter by Status
106 <chops-filter-chips
107 .options=${['Open', 'Closed']}
108 .selected=${this._filter}
109 @change=${this._onFilterChange}
110 ></chops-filter-chips>
111 </div>
112
113 <mr-button-bar .items=${this._buttonBarItems()}></mr-button-bar>
114
115 <mr-issue-list
116 .issues=${items}
117 .projectName=${projectName}
118 .columns=${this._columns}
119 .defaultFields=${DEFAULT_HOTLIST_FIELDS}
120 .extractFieldValues=${this._extractFieldValues.bind(this)}
121 .rerank=${mayEdit ? this._rerankItems.bind(this) : null}
122 ?selectionEnabled=${mayEdit}
123 @selectionChange=${this._onSelectionChange}
124 ></mr-issue-list>
125
126 <mr-change-columns .columns=${this._columns}></mr-change-columns>
127 <mr-update-issue-hotlists-dialog
128 .issueRefs=${this._selected.map(issueNameToRef)}
129 .issueHotlists=${[hotlistV0]}
130 @saveSuccess=${this._handleHotlistSaveSuccess}
131 ></mr-update-issue-hotlists-dialog>
132 <mr-move-issue-hotlists-dialog
133 .issueRefs=${this._selected.map(issueNameToRef)}
134 @saveSuccess=${this._handleHotlistSaveSuccess}
135 ><mr-move-issue-hotlists-dialog>
136 `;
137 }
138
139 /**
140 * @return {Array<MenuItem>}
141 */
142 _buttonBarItems() {
143 if (this._selected.length) {
144 return [
145 {
146 icon: 'remove_circle_outline',
147 text: 'Remove',
148 handler: this._removeItems.bind(this)},
149 {
150 icon: 'edit',
151 text: 'Update',
152 handler: this._openUpdateIssuesHotlistsDialog.bind(this),
153 },
154 {
155 icon: 'forward',
156 text: 'Move to...',
157 handler: this._openMoveToHotlistDialog.bind(this),
158 },
159 ];
160 } else {
161 return [
162 // TODO(dtu): Implement this action.
163 // {icon: 'add', text: 'Add issues'},
164 {
165 icon: 'table_chart',
166 text: 'Change columns',
167 handler: this._openColumnsDialog.bind(this),
168 },
169 ];
170 }
171 }
172
173 /** @override */
174 static get properties() {
175 return {
176 // Populated from Redux.
177 _hotlist: {type: Object},
178 _permissions: {type: Array},
179 _items: {type: Array},
180 _columns: {type: Array},
181 _fetchError: {type: Object},
182 _extractFieldValuesFromIssue: {type: Object},
183
184 // Populated from events.
185 _filter: {type: Object},
186 _selected: {type: Array},
187 };
188 };
189
190 /** @override */
191 constructor() {
192 super();
193
194 // Populated from Redux.
195 /** @type {?Hotlist} */
196 this._hotlist = null;
197 /** @type {Array<Permission>} */
198 this._permissions = [];
199 /** @type {Array<HotlistIssue>} */
200 this._items = [];
201 /** @type {Array<string>} */
202 this._columns = [];
203 /** @type {?Error} */
204 this._fetchError = null;
205 /**
206 * @param {Issue} _issue
207 * @param {string} _fieldName
208 * @return {Array<string>}
209 */
210 this._extractFieldValuesFromIssue = (_issue, _fieldName) => [];
211
212 // Populated from events.
213 /** @type {Object<string, boolean>} */
214 this._filter = {Open: true};
215 /**
216 * An array of selected Issue Names.
217 * TODO(https://crbug.com/monorail/7440): Update typedef.
218 * @type {Array<string>}
219 */
220 this._selected = [];
221 }
222
223 /**
224 * @param {HotlistIssue} hotlistIssue
225 * @param {string} fieldName
226 * @return {Array<string>}
227 */
228 _extractFieldValues(hotlistIssue, fieldName) {
229 switch (fieldName) {
230 case 'Added':
231 return [relativeTime(new Date(hotlistIssue.createTime))];
232 case 'Adder':
233 return [hotlistIssue.adder.displayName];
234 case 'Rank':
235 return [String(hotlistIssue.rank + 1)];
236 default:
237 return this._extractFieldValuesFromIssue(hotlistIssue, fieldName);
238 }
239 }
240
241 /**
242 * @param {Event} e A change event fired by <chops-filter-chips>.
243 */
244 _onFilterChange(e) {
245 this._filter = e.target.selected;
246 }
247
248 /**
249 * @param {CustomEvent} e A selectionChange event fired by <mr-issue-list>.
250 */
251 _onSelectionChange(e) {
252 this._selected = e.target.selectedIssues.map(issueToName);
253 }
254
255 /** Opens a dialog to change the columns shown in the issue list. */
256 _openColumnsDialog() {
257 this.shadowRoot.querySelector('mr-change-columns').open();
258 }
259
260 /** Handles successfully saved Hotlist changes. */
261 async _handleHotlistSaveSuccess() {}
262
263 /** Removes items from the hotlist, dispatching an action to Redux. */
264 async _removeItems() {}
265
266 /** Opens a dialog to update attached Hotlists for selected Issues. */
267 _openUpdateIssuesHotlistsDialog() {
268 this.shadowRoot.querySelector('mr-update-issue-hotlists-dialog').open();
269 }
270
271 /** Opens a dialog to move selected Issues to desired Hotlist. */
272 _openMoveToHotlistDialog() {
273 this.shadowRoot.querySelector('mr-move-issue-hotlists-dialog').open();
274 }
275 /**
276 * Reranks items in the hotlist, dispatching an action to Redux.
277 * @param {Array<String>} items The names of the HotlistItems to move.
278 * @param {number} index The index to insert the moved items.
279 * @return {Promise<void>}
280 */
281 async _rerankItems(items, index) {}
282};
283
284/** Redux-connected version of _MrHotlistIssuesPage. */
285export class MrHotlistIssuesPage extends connectStore(_MrHotlistIssuesPage) {
286 /** @override */
287 stateChanged(state) {
288 this._hotlist = hotlists.viewedHotlist(state);
289 this._permissions = hotlists.viewedHotlistPermissions(state);
290 this._items = hotlists.viewedHotlistIssues(state);
291 this._columns = hotlists.viewedHotlistColumns(state);
292 this._fetchError = hotlists.requests(state).fetch.error;
293 this._extractFieldValuesFromIssue =
294 projectV0.extractFieldValuesFromIssue(state);
295 }
296
297 /** @override */
298 updated(changedProperties) {
299 if (changedProperties.has('_hotlist') && this._hotlist) {
300 const pageTitle = `Issues - ${this._hotlist.displayName}`;
301 store.dispatch(sitewide.setPageTitle(pageTitle));
302 const headerTitle = `Hotlist ${this._hotlist.displayName}`;
303 store.dispatch(sitewide.setHeaderTitle(headerTitle));
304 }
305 }
306
307 /** @override */
308 async _handleHotlistSaveSuccess() {
309 const action = hotlists.fetchItems(this._hotlist.name);
310 await store.dispatch(action);
311 store.dispatch(ui.showSnackbar(ui.snackbarNames.UPDATE_HOTLISTS_SUCCESS,
312 'Hotlists updated.'));
313 }
314
315 /** @override */
316 async _removeItems() {
317 const action = hotlists.removeItems(this._hotlist.name, this._selected);
318 await store.dispatch(action);
319 }
320
321 /** @override */
322 async _rerankItems(items, index) {
323 // The index given from <mr-issue-list> includes only the items shown in
324 // the list and excludes the items that are being moved. So, we need to
325 // count the hidden items.
326 let shownItems = 0;
327 let hiddenItems = 0;
328 for (let i = 0; shownItems < index && i < this._items.length; ++i) {
329 const item = this._items[i];
330 const isShown = _isShown(this._filter, item);
331 if (!isShown) ++hiddenItems;
332 if (isShown && !items.includes(item.name)) ++shownItems;
333 }
334
335 await store.dispatch(hotlists.rerankItems(
336 this._hotlist.name, items, index + hiddenItems));
337 }
338};
339
340const _filterIssues = defaultMemoize(
341 /**
342 * Filters an array of HotlistIssues based on a filter condition. Memoized.
343 * @param {Object<string, boolean>} filter The types of issues to show.
344 * @param {Array<HotlistIssue>} items A HotlistIssue to check.
345 * @return {Array<HotlistIssue>}
346 */
347 (filter, items) => items.filter((item) => _isShown(filter, item)));
348
349/**
350 * Returns true iff the current filter includes the given HotlistIssue.
351 * @param {Object<string, boolean>} filter The types of issues to show.
352 * @param {HotlistIssue} item A HotlistIssue to check.
353 * @return {boolean}
354 */
355function _isShown(filter, item) {
356 return filter.Open && item.statusRef.meansOpen ||
357 filter.Closed && !item.statusRef.meansOpen;
358}
359
360customElements.define('mr-hotlist-issues-page-base', _MrHotlistIssuesPage);
361customElements.define('mr-hotlist-issues-page', MrHotlistIssuesPage);