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