blob: 5d6a97b2ab4b1e4ecfbafe1051d284dc3b9bc7bd [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.
4import {css} from 'lit-element';
5import {MrDropdown} from 'elements/framework/mr-dropdown/mr-dropdown.js';
6import page from 'page';
7import qs from 'qs';
8import {connectStore} from 'reducers/base.js';
9import * as projectV0 from 'reducers/projectV0.js';
10import * as sitewide from 'reducers/sitewide.js';
11import {fieldTypes, fieldsForIssue} from 'shared/issue-fields.js';
12
13
14/**
15 * `<mr-show-columns-dropdown>`
16 *
17 * Issue list column options dropdown.
18 *
19 */
20export class MrShowColumnsDropdown extends connectStore(MrDropdown) {
21 /** @override */
22 static get styles() {
23 return [
24 ...MrDropdown.styles,
25 css`
26 :host {
27 font-weight: normal;
28 color: var(--chops-link-color);
29 --mr-dropdown-icon-color: var(--chops-link-color);
30 --mr-dropdown-anchor-padding: 3px 8px;
31 --mr-dropdown-anchor-font-weight: bold;
32 --mr-dropdown-menu-min-width: 150px;
33 --mr-dropdown-menu-font-size: var(--chops-main-font-size);
34 --mr-dropdown-menu-icon-size: var(--chops-main-font-size);
35 /* Because we're using a sticky header, we need to make sure the
36 * dropdown cannot be taller than the screen. */
37 --mr-dropdown-menu-max-height: 80vh;
38 --mr-dropdown-menu-overflow: auto;
39 }
40 `,
41 ];
42 }
43 /** @override */
44 static get properties() {
45 return {
46 ...MrDropdown.properties,
47 /**
48 * Array of displayed columns.
49 */
50 columns: {type: Array},
51 /**
52 * Array of displayed issues.
53 */
54 issues: {type: Array},
55 /**
56 * Array of unique phase names to prepend to phase field columns.
57 */
58 // TODO(dtu): Delete after removing EZT hotlist issue list.
59 phaseNames: {type: Array},
60 /**
61 * Array of built in fields that are available outside of project
62 * configuration.
63 */
64 defaultFields: {type: Array},
65 _fieldDefs: {type: Array},
66 _labelPrefixFields: {type: Array},
67 // TODO(zhangtiff): Delete this legacy integration after removing
68 // the EZT issue list view.
69 onHideColumn: {type: Object},
70 onShowColumn: {type: Object},
71 };
72 }
73
74 /** @override */
75 constructor() {
76 super();
77
78 // Inherited from MrDropdown.
79 this.label = 'Show columns';
80 this.icon = 'more_horiz';
81
82 this.columns = [];
83 /** @type {Array<Issue>} */
84 this.issues = [];
85 this.phaseNames = [];
86 this.defaultFields = [];
87
88 // TODO(dtu): Delete after removing EZT hotlist issue list.
89 this._fieldDefs = [];
90 this._labelPrefixFields = [];
91
92 this._queryParams = {};
93 this._page = page;
94
95 // TODO(zhangtiff): Delete this legacy integration after removing
96 // the EZT issue list view.
97 this.onHideColumn = null;
98 this.onShowColumn = null;
99 }
100
101 /** @override */
102 stateChanged(state) {
103 this._fieldDefs = projectV0.fieldDefs(state) || [];
104 this._labelPrefixFields = projectV0.labelPrefixFields(state) || [];
105 this._queryParams = sitewide.queryParams(state);
106 }
107
108 /** @override */
109 update(changedProperties) {
110 if (this.issues.length) {
111 this.items = this.columnOptions();
112 } else {
113 // TODO(dtu): Delete after removing EZT hotlist issue list.
114 this.items = this.columnOptionsEzt(
115 this.defaultFields, this._fieldDefs, this._labelPrefixFields,
116 this.columns, this.phaseNames);
117 }
118
119 super.update(changedProperties);
120 }
121
122 /**
123 * Computes the column options available in the list view based on Issues.
124 * @return {Array<MenuItem>}
125 */
126 columnOptions() {
127 const availableFields = new Set(this.defaultFields);
128 this.issues.forEach((issue) => {
129 fieldsForIssue(issue).forEach((field) => {
130 availableFields.add(field);
131 });
132 });
133
134 // Remove selected columns from available fields.
135 this.columns.forEach((field) => availableFields.delete(field));
136 const sortedFields = [...availableFields].sort();
137
138 return [
139 // Show selected options first.
140 ...this.columns.map((field, i) => ({
141 icon: 'check',
142 text: field,
143 handler: () => this._removeColumn(i),
144 })),
145 // Unselected options come next.
146 ...sortedFields.map((field) => ({
147 icon: '',
148 text: field,
149 handler: () => this._addColumn(field),
150 })),
151 ];
152 }
153
154 // TODO(dtu): Delete after removing EZT hotlist issue list.
155 /**
156 * Computes the column options available in the list view based on project
157 * config data.
158 * @param {Array<string>} defaultFields List of built in columns.
159 * @param {Array<FieldDef>} fieldDefs List of custom fields configured in the
160 * viewed project.
161 * @param {Array<string>} labelPrefixes List of available label prefixes for
162 * the current project config..
163 * @param {Array<string>} selectedColumns List of columns the user is
164 * currently viewing.
165 * @param {Array<string>} phaseNames All phase namws present in the currently
166 * viewed issue list.
167 * @return {Array<MenuItem>}
168 */
169 columnOptionsEzt(defaultFields, fieldDefs, labelPrefixes, selectedColumns,
170 phaseNames) {
171 const selectedOptions = new Set(
172 selectedColumns.map((col) => col.toLowerCase()));
173
174 const availableFields = new Set();
175
176 // Built-in, hard-coded fields like Owner, Status, and Labels.
177 defaultFields.forEach((field) => this._addUnselectedField(
178 availableFields, field, selectedOptions));
179
180 // Custom fields.
181 fieldDefs.forEach((fd) => {
182 const {fieldRef, isPhaseField} = fd;
183 const {fieldName, type} = fieldRef;
184 if (isPhaseField) {
185 // If the custom field belongs to phases, prefix the phase name for
186 // each phase.
187 phaseNames.forEach((phaseName) => {
188 this._addUnselectedField(
189 availableFields, `${phaseName}.${fieldName}`, selectedOptions);
190 });
191 return;
192 }
193
194 // TODO(zhangtiff): Prefix custom fields with "approvalName" defined by
195 // the approval name after deprecating the old issue list page.
196
197 // Most custom fields can be directly added to the list with no
198 // modifications.
199 this._addUnselectedField(
200 availableFields, fieldName, selectedOptions);
201
202 // If the custom field is type approval, then it also has a built in
203 // "Approver" field.
204 if (type === fieldTypes.APPROVAL_TYPE) {
205 this._addUnselectedField(
206 availableFields, `${fieldName}-Approver`, selectedOptions);
207 }
208 });
209
210 // Fields inferred from label prefixes.
211 labelPrefixes.forEach((field) => this._addUnselectedField(
212 availableFields, field, selectedOptions));
213
214 const sortedFields = [...availableFields];
215 sortedFields.sort();
216
217 return [
218 ...selectedColumns.map((field, i) => ({
219 icon: 'check',
220 text: field,
221 handler: () => this._removeColumn(i),
222 })),
223 ...sortedFields.map((field) => ({
224 icon: '',
225 text: field,
226 handler: () => this._addColumn(field),
227 })),
228 ];
229 }
230
231 /**
232 * Helper that mutates a Set of column names in place, adding a given
233 * field only if it doesn't already show up in the list of selected
234 * fields.
235 * @param {Set<string>} availableFields Set of column names to mutate.
236 * @param {string} field Name of the field being added to the options.
237 * @param {Set<string>} selectedOptions Set of fieldNames that the user
238 * is viewing.
239 * @private
240 */
241 _addUnselectedField(availableFields, field, selectedOptions) {
242 if (!selectedOptions.has(field.toLowerCase())) {
243 availableFields.add(field);
244 }
245 }
246
247 /**
248 * Removes the column at a particular index.
249 *
250 * @param {number} i the issue column to be removed.
251 */
252 _removeColumn(i) {
253 if (this.onHideColumn) {
254 if (!this.onHideColumn(this.columns[i])) {
255 return;
256 }
257 }
258 const columns = [...this.columns];
259 columns.splice(i, 1);
260 this._reloadColspec(columns);
261 }
262
263 /**
264 * Adds a new column to a particular index.
265 *
266 * @param {string} name of the new column added.
267 */
268 _addColumn(name) {
269 if (this.onShowColumn) {
270 if (!this.onShowColumn(name)) {
271 return;
272 }
273 }
274 this._reloadColspec([...this.columns, name]);
275 }
276
277 /**
278 * Reflects changes to the columns of an issue list to the URL, through
279 * frontend routing.
280 *
281 * @param {Array} newColumns the new colspec to set in the URL.
282 */
283 _reloadColspec(newColumns) {
284 this._updateQueryParams({colspec: newColumns.join(' ')});
285 }
286
287 /**
288 * Navigates to the same URL as the current page, but with query
289 * params updated.
290 *
291 * @param {Object} newParams keys and values of the queryParams
292 * Object to be updated.
293 */
294 _updateQueryParams(newParams) {
295 const params = {...this._queryParams, ...newParams};
296 this._page(`${this._baseUrl()}?${qs.stringify(params)}`);
297 }
298
299 /**
300 * Get the current URL of the page, without query params. Useful for
301 * test stubbing.
302 *
303 * @return {string} the URL of the list page, without params.
304 */
305 _baseUrl() {
306 return window.location.pathname;
307 }
308}
309
310customElements.define('mr-show-columns-dropdown', MrShowColumnsDropdown);