Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/shared/issue-fields.js b/static_src/shared/issue-fields.js
new file mode 100644
index 0000000..09ac7d3
--- /dev/null
+++ b/static_src/shared/issue-fields.js
@@ -0,0 +1,424 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {relativeTime} from
+ 'elements/chops/chops-timestamp/chops-timestamp-helpers.js';
+import {labelRefsToStrings, issueRefsToStrings, componentRefsToStrings,
+ userRefsToDisplayNames, statusRefsToStrings, labelNameToLabelPrefixes,
+} from './convertersV0.js';
+import {removePrefix} from './helpers.js';
+import {STATUS_ENUM_TO_TEXT} from 'shared/consts/approval.js';
+import {fieldValueMapKey} from 'shared/metadata-helpers.js';
+
+// TODO(zhangtiff): Merge this file with metadata-helpers.js.
+
+
+/** @enum {string} */
+export const fieldTypes = Object.freeze({
+ APPROVAL_TYPE: 'APPROVAL_TYPE',
+ DATE_TYPE: 'DATE_TYPE',
+ ENUM_TYPE: 'ENUM_TYPE',
+ INT_TYPE: 'INT_TYPE',
+ STR_TYPE: 'STR_TYPE',
+ USER_TYPE: 'USER_TYPE',
+ URL_TYPE: 'URL_TYPE',
+
+ // Frontend types used to handle built in fields like BlockedOn.
+ // Although these are not configurable custom field types on the
+ // backend, hard-coding these fields types on the frontend allows
+ // us to inter-op custom and baked in fields more seamlessly on
+ // the frontend.
+ ISSUE_TYPE: 'ISSUE_TYPE',
+ TIME_TYPE: 'TIME_TYPE',
+ COMPONENT_TYPE: 'COMPONENT_TYPE',
+ STATUS_TYPE: 'STATUS_TYPE',
+ LABEL_TYPE: 'LABEL_TYPE',
+ PROJECT_TYPE: 'PROJECT_TYPE',
+});
+
+const GROUPABLE_FIELD_TYPES = new Set([
+ fieldTypes.DATE_TYPE,
+ fieldTypes.ENUM_TYPE,
+ fieldTypes.USER_TYPE,
+ fieldTypes.INT_TYPE,
+]);
+
+const SPEC_DELIMITER_REGEX = /[\s\+]+/;
+export const SITEWIDE_DEFAULT_COLUMNS = ['ID', 'Type', 'Status',
+ 'Priority', 'Milestone', 'Owner', 'Summary'];
+
+// When no default can is configured, projects use "Open issues".
+export const SITEWIDE_DEFAULT_CAN = '2';
+
+export const PHASE_FIELD_COL_DELIMITER_REGEX = /\./;
+
+export const EMPTY_FIELD_VALUE = '----';
+
+export const APPROVER_COL_SUFFIX_REGEX = /\-approver$/i;
+
+/**
+ * Parses colspec or groupbyspec values from user input such as form fields
+ * or the URL.
+ *
+ * @param {string} spec a delimited string with spec values to parse.
+ * @return {Array} list of spec values represented by the string.
+ */
+export function parseColSpec(spec = '') {
+ return spec.split(SPEC_DELIMITER_REGEX).filter(Boolean);
+}
+
+/**
+ * Finds the type for an issue based on the issue's custom fields
+ * and labels. If there is a custom field named "Type", that field
+ * is used, otherwise labels are used.
+ * @param {!Array<FieldValue>} fieldValues
+ * @param {!Array<LabelRef>} labelRefs
+ * @return {string}
+ */
+export function extractTypeForIssue(fieldValues, labelRefs) {
+ if (fieldValues) {
+ // If there is a custom field for "Type", use that for type.
+ const typeFieldValue = fieldValues.find(
+ (f) => (f.fieldRef && f.fieldRef.fieldName.toLowerCase() === 'type'),
+ );
+ if (typeFieldValue) {
+ return typeFieldValue.value;
+ }
+ }
+
+ // Otherwise, search through labels for a "Type" label.
+ if (labelRefs) {
+ const typeLabel = labelRefs.find(
+ (l) => l.label.toLowerCase().startsWith('type-'));
+ if (typeLabel) {
+ // Strip length of prefix.
+ return typeLabel.label.substr(5);
+ }
+ }
+ return;
+}
+
+// TODO(jojwang): monorail:6397, Refactor these specific map producers into
+// selectors.
+/**
+ * Converts issue.fieldValues into a map where values can be looked up given
+ * a field value key.
+ *
+ * @param {Array} fieldValues List of values with a fieldRef attached.
+ * @return {Map} keys are a string constructed using fieldValueMapKey() and
+ * values are an Array of value strings.
+ */
+export function fieldValuesToMap(fieldValues) {
+ if (!fieldValues) return new Map();
+ const acc = new Map();
+ for (const v of fieldValues) {
+ if (!v || !v.fieldRef || !v.fieldRef.fieldName || !v.value) continue;
+ const key = fieldValueMapKey(v.fieldRef.fieldName,
+ v.phaseRef && v.phaseRef.phaseName);
+ if (acc.has(key)) {
+ acc.get(key).push(v.value);
+ } else {
+ acc.set(key, [v.value]);
+ }
+ }
+ return acc;
+}
+
+/**
+ * Converts issue.approvalValues into a map where values can be looked up given
+ * a field value key.
+ *
+ * @param {Array} approvalValues list of approvals with a fieldRef attached.
+ * @return {Map} keys are a string constructed using approvalValueFieldMapKey()
+ * and values are an Array of value strings.
+ */
+export function approvalValuesToMap(approvalValues) {
+ if (!approvalValues) return new Map();
+ const approvalKeysToValues = new Map();
+ for (const av of approvalValues) {
+ if (!av || !av.fieldRef || !av.fieldRef.fieldName) continue;
+ const key = fieldValueMapKey(av.fieldRef.fieldName);
+ // If there is not status for this approval, the value should show NOT_SET.
+ approvalKeysToValues.set(key, [STATUS_ENUM_TO_TEXT[av.status || '']]);
+ }
+ return approvalKeysToValues;
+}
+
+/**
+ * Converts issue.approvalValues into a map where the approvers can be looked
+ * up given a field value key.
+ *
+ * @param {Array} approvalValues list of approvals with a fieldRef attached.
+ * @return {Map} keys are a string constructed using fieldValueMapKey() and
+ * values are an Array of
+ */
+export function approvalApproversToMap(approvalValues) {
+ if (!approvalValues) return new Map();
+ const approvalKeysToApprovers = new Map();
+ for (const av of approvalValues) {
+ if (!av || !av.fieldRef || !av.fieldRef.fieldName ||
+ !av.approverRefs) continue;
+ const key = fieldValueMapKey(av.fieldRef.fieldName);
+ const approvers = av.approverRefs.map((ref) => ref.displayName);
+ approvalKeysToApprovers.set(key, approvers);
+ }
+ return approvalKeysToApprovers;
+}
+
+
+// Helper function used for fields with only one value that can be unset.
+const wrapValueIfExists = (value) => value ? [value] : [];
+
+
+/**
+ * @typedef DefaultIssueField
+ * @property {string} fieldName
+ * @property {fieldTypes} type
+ * @property {function(*): Array<string>} extractor
+*/
+// TODO(zhangtiff): Merge this functionality with extract-grid-data.js
+// TODO(zhangtiff): Combine this functionality with mr-metadata and
+// mr-edit-metadata to allow more expressive representation of built in fields.
+/**
+ * @const {Array<DefaultIssueField>}
+ */
+const defaultIssueFields = Object.freeze([
+ {
+ fieldName: 'ID',
+ type: fieldTypes.ISSUE_TYPE,
+ extractor: ({localId, projectName}) => [{localId, projectName}],
+ }, {
+ fieldName: 'Project',
+ type: fieldTypes.PROJECT_TYPE,
+ extractor: (issue) => [issue.projectName],
+ }, {
+ fieldName: 'Attachments',
+ type: fieldTypes.INT_TYPE,
+ extractor: (issue) => [issue.attachmentCount || 0],
+ }, {
+ fieldName: 'AllLabels',
+ type: fieldTypes.LABEL_TYPE,
+ extractor: (issue) => issue.labelRefs || [],
+ }, {
+ fieldName: 'Blocked',
+ type: fieldTypes.STR_TYPE,
+ extractor: (issue) => {
+ if (issue.blockedOnIssueRefs && issue.blockedOnIssueRefs.length) {
+ return ['Yes'];
+ }
+ return ['No'];
+ },
+ }, {
+ fieldName: 'BlockedOn',
+ type: fieldTypes.ISSUE_TYPE,
+ extractor: (issue) => issue.blockedOnIssueRefs || [],
+ }, {
+ fieldName: 'Blocking',
+ type: fieldTypes.ISSUE_TYPE,
+ extractor: (issue) => issue.blockingIssueRefs || [],
+ }, {
+ fieldName: 'CC',
+ type: fieldTypes.USER_TYPE,
+ extractor: (issue) => issue.ccRefs || [],
+ }, {
+ fieldName: 'Closed',
+ type: fieldTypes.TIME_TYPE,
+ extractor: (issue) => wrapValueIfExists(issue.closedTimestamp),
+ }, {
+ fieldName: 'Component',
+ type: fieldTypes.COMPONENT_TYPE,
+ extractor: (issue) => issue.componentRefs || [],
+ }, {
+ fieldName: 'ComponentModified',
+ type: fieldTypes.TIME_TYPE,
+ extractor: (issue) => [issue.componentModifiedTimestamp],
+ }, {
+ fieldName: 'MergedInto',
+ type: fieldTypes.ISSUE_TYPE,
+ extractor: (issue) => wrapValueIfExists(issue.mergedIntoIssueRef),
+ }, {
+ fieldName: 'Modified',
+ type: fieldTypes.TIME_TYPE,
+ extractor: (issue) => wrapValueIfExists(issue.modifiedTimestamp),
+ }, {
+ fieldName: 'Reporter',
+ type: fieldTypes.USER_TYPE,
+ extractor: (issue) => [issue.reporterRef],
+ }, {
+ fieldName: 'Stars',
+ type: fieldTypes.INT_TYPE,
+ extractor: (issue) => [issue.starCount || 0],
+ }, {
+ fieldName: 'Status',
+ type: fieldTypes.STATUS_TYPE,
+ extractor: (issue) => wrapValueIfExists(issue.statusRef),
+ }, {
+ fieldName: 'StatusModified',
+ type: fieldTypes.TIME_TYPE,
+ extractor: (issue) => [issue.statusModifiedTimestamp],
+ }, {
+ fieldName: 'Summary',
+ type: fieldTypes.STR_TYPE,
+ extractor: (issue) => [issue.summary],
+ }, {
+ fieldName: 'Type',
+ type: fieldTypes.ENUM_TYPE,
+ extractor: (issue) => wrapValueIfExists(extractTypeForIssue(
+ issue.fieldValues, issue.labelRefs)),
+ }, {
+ fieldName: 'Owner',
+ type: fieldTypes.USER_TYPE,
+ extractor: (issue) => wrapValueIfExists(issue.ownerRef),
+ }, {
+ fieldName: 'OwnerModified',
+ type: fieldTypes.TIME_TYPE,
+ extractor: (issue) => [issue.ownerModifiedTimestamp],
+ }, {
+ fieldName: 'Opened',
+ type: fieldTypes.TIME_TYPE,
+ extractor: (issue) => [issue.openedTimestamp],
+ },
+]);
+
+/**
+ * Lowercase field name -> field object. This uses an Object instead of a Map
+ * so that it can be frozen.
+ * @type {Object<string, DefaultIssueField>}
+ */
+export const defaultIssueFieldMap = Object.freeze(
+ defaultIssueFields.reduce((acc, field) => {
+ acc[field.fieldName.toLowerCase()] = field;
+ return acc;
+ }, {}),
+);
+
+export const DEFAULT_ISSUE_FIELD_LIST = defaultIssueFields.map(
+ (field) => field.fieldName);
+
+/**
+ * Wrapper that extracts potentially composite field values from issue
+ * @param {Issue} issue
+ * @param {string} fieldName
+ * @param {string} projectName
+ * @return {Array<string>}
+ */
+export const stringValuesForIssueField = (issue, fieldName, projectName) => {
+ // Split composite fields into each segment
+ return fieldName.split('/').flatMap((fieldKey) => stringValuesExtractor(
+ issue, fieldKey, projectName));
+};
+
+/**
+ * Extract string values of an issue's field
+ * @param {Issue} issue
+ * @param {string} fieldName
+ * @param {string} projectName
+ * @return {Array<string>}
+ */
+const stringValuesExtractor = (issue, fieldName, projectName) => {
+ const fieldKey = fieldName.toLowerCase();
+
+ // Look at whether the field is a built in field first.
+ if (defaultIssueFieldMap.hasOwnProperty(fieldKey)) {
+ const bakedFieldDef = defaultIssueFieldMap[fieldKey];
+ const values = bakedFieldDef.extractor(issue);
+ switch (bakedFieldDef.type) {
+ case fieldTypes.ISSUE_TYPE:
+ return issueRefsToStrings(values, projectName);
+ case fieldTypes.COMPONENT_TYPE:
+ return componentRefsToStrings(values);
+ case fieldTypes.LABEL_TYPE:
+ return labelRefsToStrings(values);
+ case fieldTypes.USER_TYPE:
+ return userRefsToDisplayNames(values);
+ case fieldTypes.STATUS_TYPE:
+ return statusRefsToStrings(values);
+ case fieldTypes.TIME_TYPE:
+ // TODO(zhangtiff): Find a way to dynamically update displayed
+ // time without page reloads.
+ return values.map((time) => relativeTime(new Date(time * 1000)));
+ }
+ return values.map((value) => `${value}`);
+ }
+
+ // Handle custom approval field approver columns.
+ const found = fieldKey.match(APPROVER_COL_SUFFIX_REGEX);
+ if (found) {
+ const approvalName = fieldKey.slice(0, -found[0].length);
+ const approvalFieldKey = fieldValueMapKey(approvalName);
+ const approvalApproversMap = approvalApproversToMap(issue.approvalValues);
+ if (approvalApproversMap.has(approvalFieldKey)) {
+ return approvalApproversMap.get(approvalFieldKey);
+ }
+ }
+
+ // Handle custom approval field columns.
+ const approvalValuesMap = approvalValuesToMap(issue.approvalValues);
+ if (approvalValuesMap.has(fieldKey)) {
+ return approvalValuesMap.get(fieldKey);
+ }
+
+ // Handle custom fields.
+ let fieldValueKey = fieldKey;
+ let fieldNameKey = fieldKey;
+ if (fieldKey.match(PHASE_FIELD_COL_DELIMITER_REGEX)) {
+ let phaseName;
+ [phaseName, fieldNameKey] = fieldKey.split(
+ PHASE_FIELD_COL_DELIMITER_REGEX);
+ // key for fieldValues Map contain the phaseName, if any.
+ fieldValueKey = fieldValueMapKey(fieldNameKey, phaseName);
+ }
+ const fieldValuesMap = fieldValuesToMap(issue.fieldValues);
+ if (fieldValuesMap.has(fieldValueKey)) {
+ return fieldValuesMap.get(fieldValueKey);
+ }
+
+ // Handle custom labels and ad hoc labels last.
+ const matchingLabels = (issue.labelRefs || []).filter((labelRef) => {
+ const labelPrefixes = labelNameToLabelPrefixes(
+ labelRef.label).map((prefix) => prefix.toLowerCase());
+ return labelPrefixes.includes(fieldKey);
+ });
+ const labelPrefix = fieldKey + '-';
+ return matchingLabels.map(
+ (labelRef) => removePrefix(labelRef.label, labelPrefix));
+};
+
+/**
+ * Computes all custom fields set in a given Issue, including custom
+ * fields derived from label prefixes and approval values.
+ * @param {Issue} issue An Issue object.
+ * @param {boolean=} exclHighCardinality Whether to exclude fields with a high
+ * cardinality, like string custom fields for example. This is useful for
+ * features where issues are grouped by different values because grouping
+ * by high cardinality fields is not meaningful.
+ * @return {Array<string>}
+ */
+export function fieldsForIssue(issue, exclHighCardinality = false) {
+ const approvalValues = issue.approvalValues || [];
+ let fieldValues = issue.fieldValues || [];
+ const labelRefs = issue.labelRefs || [];
+ const labelPrefixes = [];
+ labelRefs.forEach((labelRef) => {
+ labelPrefixes.push(...labelNameToLabelPrefixes(labelRef.label));
+ });
+ if (exclHighCardinality) {
+ fieldValues = fieldValues.filter(({fieldRef}) =>
+ GROUPABLE_FIELD_TYPES.has(fieldRef.type));
+ }
+ return [
+ ...approvalValues.map((approval) => approval.fieldRef.fieldName),
+ ...approvalValues.map(
+ (approval) => approval.fieldRef.fieldName + '-Approver'),
+ ...fieldValues.map((fieldValue) => {
+ if (fieldValue.phaseRef) {
+ return fieldValue.phaseRef.phaseName + '.' +
+ fieldValue.fieldRef.fieldName;
+ } else {
+ return fieldValue.fieldRef.fieldName;
+ }
+ }),
+ ...labelPrefixes,
+ ];
+}