blob: 09ac7d37609d9d82425a3adf545b75e743cc27d4 [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 {relativeTime} from
6 'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
7import {labelRefsToStrings, issueRefsToStrings, componentRefsToStrings,
8 userRefsToDisplayNames, statusRefsToStrings, labelNameToLabelPrefixes,
9} from './convertersV0.js';
10import {removePrefix} from './helpers.js';
11import {STATUS_ENUM_TO_TEXT} from 'shared/consts/approval.js';
12import {fieldValueMapKey} from 'shared/metadata-helpers.js';
13
14// TODO(zhangtiff): Merge this file with metadata-helpers.js.
15
16
17/** @enum {string} */
18export const fieldTypes = Object.freeze({
19 APPROVAL_TYPE: 'APPROVAL_TYPE',
20 DATE_TYPE: 'DATE_TYPE',
21 ENUM_TYPE: 'ENUM_TYPE',
22 INT_TYPE: 'INT_TYPE',
23 STR_TYPE: 'STR_TYPE',
24 USER_TYPE: 'USER_TYPE',
25 URL_TYPE: 'URL_TYPE',
26
27 // Frontend types used to handle built in fields like BlockedOn.
28 // Although these are not configurable custom field types on the
29 // backend, hard-coding these fields types on the frontend allows
30 // us to inter-op custom and baked in fields more seamlessly on
31 // the frontend.
32 ISSUE_TYPE: 'ISSUE_TYPE',
33 TIME_TYPE: 'TIME_TYPE',
34 COMPONENT_TYPE: 'COMPONENT_TYPE',
35 STATUS_TYPE: 'STATUS_TYPE',
36 LABEL_TYPE: 'LABEL_TYPE',
37 PROJECT_TYPE: 'PROJECT_TYPE',
38});
39
40const GROUPABLE_FIELD_TYPES = new Set([
41 fieldTypes.DATE_TYPE,
42 fieldTypes.ENUM_TYPE,
43 fieldTypes.USER_TYPE,
44 fieldTypes.INT_TYPE,
45]);
46
47const SPEC_DELIMITER_REGEX = /[\s\+]+/;
48export const SITEWIDE_DEFAULT_COLUMNS = ['ID', 'Type', 'Status',
49 'Priority', 'Milestone', 'Owner', 'Summary'];
50
51// When no default can is configured, projects use "Open issues".
52export const SITEWIDE_DEFAULT_CAN = '2';
53
54export const PHASE_FIELD_COL_DELIMITER_REGEX = /\./;
55
56export const EMPTY_FIELD_VALUE = '----';
57
58export const APPROVER_COL_SUFFIX_REGEX = /\-approver$/i;
59
60/**
61 * Parses colspec or groupbyspec values from user input such as form fields
62 * or the URL.
63 *
64 * @param {string} spec a delimited string with spec values to parse.
65 * @return {Array} list of spec values represented by the string.
66 */
67export function parseColSpec(spec = '') {
68 return spec.split(SPEC_DELIMITER_REGEX).filter(Boolean);
69}
70
71/**
72 * Finds the type for an issue based on the issue's custom fields
73 * and labels. If there is a custom field named "Type", that field
74 * is used, otherwise labels are used.
75 * @param {!Array<FieldValue>} fieldValues
76 * @param {!Array<LabelRef>} labelRefs
77 * @return {string}
78 */
79export function extractTypeForIssue(fieldValues, labelRefs) {
80 if (fieldValues) {
81 // If there is a custom field for "Type", use that for type.
82 const typeFieldValue = fieldValues.find(
83 (f) => (f.fieldRef && f.fieldRef.fieldName.toLowerCase() === 'type'),
84 );
85 if (typeFieldValue) {
86 return typeFieldValue.value;
87 }
88 }
89
90 // Otherwise, search through labels for a "Type" label.
91 if (labelRefs) {
92 const typeLabel = labelRefs.find(
93 (l) => l.label.toLowerCase().startsWith('type-'));
94 if (typeLabel) {
95 // Strip length of prefix.
96 return typeLabel.label.substr(5);
97 }
98 }
99 return;
100}
101
102// TODO(jojwang): monorail:6397, Refactor these specific map producers into
103// selectors.
104/**
105 * Converts issue.fieldValues into a map where values can be looked up given
106 * a field value key.
107 *
108 * @param {Array} fieldValues List of values with a fieldRef attached.
109 * @return {Map} keys are a string constructed using fieldValueMapKey() and
110 * values are an Array of value strings.
111 */
112export function fieldValuesToMap(fieldValues) {
113 if (!fieldValues) return new Map();
114 const acc = new Map();
115 for (const v of fieldValues) {
116 if (!v || !v.fieldRef || !v.fieldRef.fieldName || !v.value) continue;
117 const key = fieldValueMapKey(v.fieldRef.fieldName,
118 v.phaseRef && v.phaseRef.phaseName);
119 if (acc.has(key)) {
120 acc.get(key).push(v.value);
121 } else {
122 acc.set(key, [v.value]);
123 }
124 }
125 return acc;
126}
127
128/**
129 * Converts issue.approvalValues into a map where values can be looked up given
130 * a field value key.
131 *
132 * @param {Array} approvalValues list of approvals with a fieldRef attached.
133 * @return {Map} keys are a string constructed using approvalValueFieldMapKey()
134 * and values are an Array of value strings.
135 */
136export function approvalValuesToMap(approvalValues) {
137 if (!approvalValues) return new Map();
138 const approvalKeysToValues = new Map();
139 for (const av of approvalValues) {
140 if (!av || !av.fieldRef || !av.fieldRef.fieldName) continue;
141 const key = fieldValueMapKey(av.fieldRef.fieldName);
142 // If there is not status for this approval, the value should show NOT_SET.
143 approvalKeysToValues.set(key, [STATUS_ENUM_TO_TEXT[av.status || '']]);
144 }
145 return approvalKeysToValues;
146}
147
148/**
149 * Converts issue.approvalValues into a map where the approvers can be looked
150 * up given a field value key.
151 *
152 * @param {Array} approvalValues list of approvals with a fieldRef attached.
153 * @return {Map} keys are a string constructed using fieldValueMapKey() and
154 * values are an Array of
155 */
156export function approvalApproversToMap(approvalValues) {
157 if (!approvalValues) return new Map();
158 const approvalKeysToApprovers = new Map();
159 for (const av of approvalValues) {
160 if (!av || !av.fieldRef || !av.fieldRef.fieldName ||
161 !av.approverRefs) continue;
162 const key = fieldValueMapKey(av.fieldRef.fieldName);
163 const approvers = av.approverRefs.map((ref) => ref.displayName);
164 approvalKeysToApprovers.set(key, approvers);
165 }
166 return approvalKeysToApprovers;
167}
168
169
170// Helper function used for fields with only one value that can be unset.
171const wrapValueIfExists = (value) => value ? [value] : [];
172
173
174/**
175 * @typedef DefaultIssueField
176 * @property {string} fieldName
177 * @property {fieldTypes} type
178 * @property {function(*): Array<string>} extractor
179*/
180// TODO(zhangtiff): Merge this functionality with extract-grid-data.js
181// TODO(zhangtiff): Combine this functionality with mr-metadata and
182// mr-edit-metadata to allow more expressive representation of built in fields.
183/**
184 * @const {Array<DefaultIssueField>}
185 */
186const defaultIssueFields = Object.freeze([
187 {
188 fieldName: 'ID',
189 type: fieldTypes.ISSUE_TYPE,
190 extractor: ({localId, projectName}) => [{localId, projectName}],
191 }, {
192 fieldName: 'Project',
193 type: fieldTypes.PROJECT_TYPE,
194 extractor: (issue) => [issue.projectName],
195 }, {
196 fieldName: 'Attachments',
197 type: fieldTypes.INT_TYPE,
198 extractor: (issue) => [issue.attachmentCount || 0],
199 }, {
200 fieldName: 'AllLabels',
201 type: fieldTypes.LABEL_TYPE,
202 extractor: (issue) => issue.labelRefs || [],
203 }, {
204 fieldName: 'Blocked',
205 type: fieldTypes.STR_TYPE,
206 extractor: (issue) => {
207 if (issue.blockedOnIssueRefs && issue.blockedOnIssueRefs.length) {
208 return ['Yes'];
209 }
210 return ['No'];
211 },
212 }, {
213 fieldName: 'BlockedOn',
214 type: fieldTypes.ISSUE_TYPE,
215 extractor: (issue) => issue.blockedOnIssueRefs || [],
216 }, {
217 fieldName: 'Blocking',
218 type: fieldTypes.ISSUE_TYPE,
219 extractor: (issue) => issue.blockingIssueRefs || [],
220 }, {
221 fieldName: 'CC',
222 type: fieldTypes.USER_TYPE,
223 extractor: (issue) => issue.ccRefs || [],
224 }, {
225 fieldName: 'Closed',
226 type: fieldTypes.TIME_TYPE,
227 extractor: (issue) => wrapValueIfExists(issue.closedTimestamp),
228 }, {
229 fieldName: 'Component',
230 type: fieldTypes.COMPONENT_TYPE,
231 extractor: (issue) => issue.componentRefs || [],
232 }, {
233 fieldName: 'ComponentModified',
234 type: fieldTypes.TIME_TYPE,
235 extractor: (issue) => [issue.componentModifiedTimestamp],
236 }, {
237 fieldName: 'MergedInto',
238 type: fieldTypes.ISSUE_TYPE,
239 extractor: (issue) => wrapValueIfExists(issue.mergedIntoIssueRef),
240 }, {
241 fieldName: 'Modified',
242 type: fieldTypes.TIME_TYPE,
243 extractor: (issue) => wrapValueIfExists(issue.modifiedTimestamp),
244 }, {
245 fieldName: 'Reporter',
246 type: fieldTypes.USER_TYPE,
247 extractor: (issue) => [issue.reporterRef],
248 }, {
249 fieldName: 'Stars',
250 type: fieldTypes.INT_TYPE,
251 extractor: (issue) => [issue.starCount || 0],
252 }, {
253 fieldName: 'Status',
254 type: fieldTypes.STATUS_TYPE,
255 extractor: (issue) => wrapValueIfExists(issue.statusRef),
256 }, {
257 fieldName: 'StatusModified',
258 type: fieldTypes.TIME_TYPE,
259 extractor: (issue) => [issue.statusModifiedTimestamp],
260 }, {
261 fieldName: 'Summary',
262 type: fieldTypes.STR_TYPE,
263 extractor: (issue) => [issue.summary],
264 }, {
265 fieldName: 'Type',
266 type: fieldTypes.ENUM_TYPE,
267 extractor: (issue) => wrapValueIfExists(extractTypeForIssue(
268 issue.fieldValues, issue.labelRefs)),
269 }, {
270 fieldName: 'Owner',
271 type: fieldTypes.USER_TYPE,
272 extractor: (issue) => wrapValueIfExists(issue.ownerRef),
273 }, {
274 fieldName: 'OwnerModified',
275 type: fieldTypes.TIME_TYPE,
276 extractor: (issue) => [issue.ownerModifiedTimestamp],
277 }, {
278 fieldName: 'Opened',
279 type: fieldTypes.TIME_TYPE,
280 extractor: (issue) => [issue.openedTimestamp],
281 },
282]);
283
284/**
285 * Lowercase field name -> field object. This uses an Object instead of a Map
286 * so that it can be frozen.
287 * @type {Object<string, DefaultIssueField>}
288 */
289export const defaultIssueFieldMap = Object.freeze(
290 defaultIssueFields.reduce((acc, field) => {
291 acc[field.fieldName.toLowerCase()] = field;
292 return acc;
293 }, {}),
294);
295
296export const DEFAULT_ISSUE_FIELD_LIST = defaultIssueFields.map(
297 (field) => field.fieldName);
298
299/**
300 * Wrapper that extracts potentially composite field values from issue
301 * @param {Issue} issue
302 * @param {string} fieldName
303 * @param {string} projectName
304 * @return {Array<string>}
305 */
306export const stringValuesForIssueField = (issue, fieldName, projectName) => {
307 // Split composite fields into each segment
308 return fieldName.split('/').flatMap((fieldKey) => stringValuesExtractor(
309 issue, fieldKey, projectName));
310};
311
312/**
313 * Extract string values of an issue's field
314 * @param {Issue} issue
315 * @param {string} fieldName
316 * @param {string} projectName
317 * @return {Array<string>}
318 */
319const stringValuesExtractor = (issue, fieldName, projectName) => {
320 const fieldKey = fieldName.toLowerCase();
321
322 // Look at whether the field is a built in field first.
323 if (defaultIssueFieldMap.hasOwnProperty(fieldKey)) {
324 const bakedFieldDef = defaultIssueFieldMap[fieldKey];
325 const values = bakedFieldDef.extractor(issue);
326 switch (bakedFieldDef.type) {
327 case fieldTypes.ISSUE_TYPE:
328 return issueRefsToStrings(values, projectName);
329 case fieldTypes.COMPONENT_TYPE:
330 return componentRefsToStrings(values);
331 case fieldTypes.LABEL_TYPE:
332 return labelRefsToStrings(values);
333 case fieldTypes.USER_TYPE:
334 return userRefsToDisplayNames(values);
335 case fieldTypes.STATUS_TYPE:
336 return statusRefsToStrings(values);
337 case fieldTypes.TIME_TYPE:
338 // TODO(zhangtiff): Find a way to dynamically update displayed
339 // time without page reloads.
340 return values.map((time) => relativeTime(new Date(time * 1000)));
341 }
342 return values.map((value) => `${value}`);
343 }
344
345 // Handle custom approval field approver columns.
346 const found = fieldKey.match(APPROVER_COL_SUFFIX_REGEX);
347 if (found) {
348 const approvalName = fieldKey.slice(0, -found[0].length);
349 const approvalFieldKey = fieldValueMapKey(approvalName);
350 const approvalApproversMap = approvalApproversToMap(issue.approvalValues);
351 if (approvalApproversMap.has(approvalFieldKey)) {
352 return approvalApproversMap.get(approvalFieldKey);
353 }
354 }
355
356 // Handle custom approval field columns.
357 const approvalValuesMap = approvalValuesToMap(issue.approvalValues);
358 if (approvalValuesMap.has(fieldKey)) {
359 return approvalValuesMap.get(fieldKey);
360 }
361
362 // Handle custom fields.
363 let fieldValueKey = fieldKey;
364 let fieldNameKey = fieldKey;
365 if (fieldKey.match(PHASE_FIELD_COL_DELIMITER_REGEX)) {
366 let phaseName;
367 [phaseName, fieldNameKey] = fieldKey.split(
368 PHASE_FIELD_COL_DELIMITER_REGEX);
369 // key for fieldValues Map contain the phaseName, if any.
370 fieldValueKey = fieldValueMapKey(fieldNameKey, phaseName);
371 }
372 const fieldValuesMap = fieldValuesToMap(issue.fieldValues);
373 if (fieldValuesMap.has(fieldValueKey)) {
374 return fieldValuesMap.get(fieldValueKey);
375 }
376
377 // Handle custom labels and ad hoc labels last.
378 const matchingLabels = (issue.labelRefs || []).filter((labelRef) => {
379 const labelPrefixes = labelNameToLabelPrefixes(
380 labelRef.label).map((prefix) => prefix.toLowerCase());
381 return labelPrefixes.includes(fieldKey);
382 });
383 const labelPrefix = fieldKey + '-';
384 return matchingLabels.map(
385 (labelRef) => removePrefix(labelRef.label, labelPrefix));
386};
387
388/**
389 * Computes all custom fields set in a given Issue, including custom
390 * fields derived from label prefixes and approval values.
391 * @param {Issue} issue An Issue object.
392 * @param {boolean=} exclHighCardinality Whether to exclude fields with a high
393 * cardinality, like string custom fields for example. This is useful for
394 * features where issues are grouped by different values because grouping
395 * by high cardinality fields is not meaningful.
396 * @return {Array<string>}
397 */
398export function fieldsForIssue(issue, exclHighCardinality = false) {
399 const approvalValues = issue.approvalValues || [];
400 let fieldValues = issue.fieldValues || [];
401 const labelRefs = issue.labelRefs || [];
402 const labelPrefixes = [];
403 labelRefs.forEach((labelRef) => {
404 labelPrefixes.push(...labelNameToLabelPrefixes(labelRef.label));
405 });
406 if (exclHighCardinality) {
407 fieldValues = fieldValues.filter(({fieldRef}) =>
408 GROUPABLE_FIELD_TYPES.has(fieldRef.type));
409 }
410 return [
411 ...approvalValues.map((approval) => approval.fieldRef.fieldName),
412 ...approvalValues.map(
413 (approval) => approval.fieldRef.fieldName + '-Approver'),
414 ...fieldValues.map((fieldValue) => {
415 if (fieldValue.phaseRef) {
416 return fieldValue.phaseRef.phaseName + '.' +
417 fieldValue.fieldRef.fieldName;
418 } else {
419 return fieldValue.fieldRef.fieldName;
420 }
421 }),
422 ...labelPrefixes,
423 ];
424}