Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/shared/consts/approval.js b/static_src/shared/consts/approval.js
new file mode 100644
index 0000000..772025d
--- /dev/null
+++ b/static_src/shared/consts/approval.js
@@ -0,0 +1,79 @@
+// 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.
+
+/**
+ * @fileoverview A file containing common constants used in the Approvals
+ * feature.
+ */
+
+// Only approvers are allowed to set an approval state to one of these states.
+export const APPROVER_RESTRICTED_STATUSES = new Set(
+ ['NA', 'Approved', 'NotApproved']);
+
+// Map the internal enum names used in Monorail's backend from approval
+// statuses to user friendly names.
+export const STATUS_ENUM_TO_TEXT = {
+ '': 'NotSet',
+ 'NEEDS_REVIEW': 'NeedsReview',
+ 'NA': 'NA',
+ 'REVIEW_REQUESTED': 'ReviewRequested',
+ 'REVIEW_STARTED': 'ReviewStarted',
+ 'NEED_INFO': 'NeedInfo',
+ 'APPROVED': 'Approved',
+ 'NOT_APPROVED': 'NotApproved',
+};
+
+// Reverse mapping of user friendly names to internal enum names.
+// Note that NotSet -> NOT_SET maps differently in reverse because
+// the backend sends an empty message to communicate NOT_SET.
+export const TEXT_TO_STATUS_ENUM = {
+ 'NotSet': 'NOT_SET',
+ 'NeedsReview': 'NEEDS_REVIEW',
+ 'NA': 'NA',
+ 'ReviewRequested': 'REVIEW_REQUESTED',
+ 'ReviewStarted': 'REVIEW_STARTED',
+ 'NeedInfo': 'NEED_INFO',
+ 'Approved': 'APPROVED',
+ 'NotApproved': 'NOT_APPROVED',
+};
+
+// Statuses mapped to CSS classes used to apply custom styles per
+// status like background colors.
+export const STATUS_CLASS_MAP = {
+ 'NotSet': 'status-notset',
+ 'NeedsReview': 'status-notset',
+ 'NA': 'status-na',
+ 'ReviewRequested': 'status-pending',
+ 'ReviewStarted': 'status-pending',
+ 'NeedInfo': 'status-pending',
+ 'Approved': 'status-approved',
+ 'NotApproved': 'status-rejected',
+};
+
+// Hardcoded frontent documentation for each approval status.
+export const STATUS_DOCSTRING_MAP = {
+ 'NotSet': '',
+ 'NeedsReview': 'Review/survey not started',
+ 'NA': 'Approval gate not required',
+ 'ReviewRequested': 'Approval requested',
+ 'ReviewStarted': 'Approval in progress',
+ 'NeedInfo': 'Approval review needs more information',
+ 'Approved': 'Approved for Launch',
+ 'NotApproved': 'Not Approved for Launch',
+};
+
+// The Material Design icon names that are attached to each
+// CSS class.
+export const CLASS_ICON_MAP = {
+ 'status-na': 'remove',
+ 'status-notset': 'warning',
+ 'status-pending': 'autorenew',
+ 'status-approved': 'done',
+ 'status-rejected': 'close',
+};
+
+// Statuses formated as an Array rather than an Object for ease of use
+// by components.
+export const APPROVAL_STATUSES = Object.keys(STATUS_CLASS_MAP).map(
+ (status) => ({status, docstring: STATUS_DOCSTRING_MAP[status], rank: 1}));
diff --git a/static_src/shared/consts/index.js b/static_src/shared/consts/index.js
new file mode 100644
index 0000000..bb196b3
--- /dev/null
+++ b/static_src/shared/consts/index.js
@@ -0,0 +1,4 @@
+// Copyright 2020 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.
+export const SERVER_LIST_ISSUES_LIMIT = 100000;
diff --git a/static_src/shared/consts/permissions.js b/static_src/shared/consts/permissions.js
new file mode 100644
index 0000000..8c8ef1b
--- /dev/null
+++ b/static_src/shared/consts/permissions.js
@@ -0,0 +1,11 @@
+// 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.
+
+export const ISSUE_EDIT_PERMISSION = 'editissue';
+export const ISSUE_EDIT_SUMMARY_PERMISSION = 'editissuesummary';
+export const ISSUE_EDIT_STATUS_PERMISSION = 'editissuestatus';
+export const ISSUE_EDIT_OWNER_PERMISSION = 'editissueowner';
+export const ISSUE_EDIT_CC_PERMISSION = 'editissuecc';
+export const ISSUE_DELETE_PERMISSION = 'deleteissue';
+export const ISSUE_FLAGSPAM_PERMISSION = 'flagspam';
diff --git a/static_src/shared/converters.js b/static_src/shared/converters.js
new file mode 100644
index 0000000..308df2d
--- /dev/null
+++ b/static_src/shared/converters.js
@@ -0,0 +1,102 @@
+// Copyright 2020 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.
+// Based on: https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/project/project_constants.py;l=13
+const PROJECT_NAME_PATTERN = '[a-z0-9][-a-z0-9]*[a-z0-9]';
+const USER_ID_PATTERN = '\\d+';
+
+const PROJECT_MEMBER_NAME_REGEX = new RegExp(
+ `projects/(${PROJECT_NAME_PATTERN})/members/(${USER_ID_PATTERN})`);
+
+const USER_NAME_REGEX = new RegExp(`users/(${USER_ID_PATTERN})`);
+
+const PROJECT_NAME_REGEX = new RegExp(`projects/(${PROJECT_NAME_PATTERN})`);
+
+
+/**
+ * Custom error class for handling invalidly formatted resource names.
+ */
+export class ResourceNameError extends Error {
+ /** @override */
+ constructor(message) {
+ super(message || 'Invalid resource name format');
+ }
+}
+
+/**
+ * Returns a FieldMask given an array of string paths.
+ * https://developers.google.com/protocol-buffers/docs/reference/csharp/class/google/protobuf/well-known-types/field-mask#paths
+ * https://source.chromium.org/chromium/chromium/src/+/main:third_party/protobuf/python/google/protobuf/internal/well_known_types.py;l=425;drc=e10d98917fee771b0947a57468d1cadac446bc42
+ * @param {Array<string>} paths The given paths to turn into a field mask.
+ * These should be a comma separated list of camel case strings.
+ * @return {string}
+ */
+export function pathsToFieldMask(paths) {
+ return paths.join(',');
+}
+
+/**
+ * Extract a User ID from a User resource name.
+ * @param {UserName} user User resource name.
+ * @return {string} User ID.
+ * @throws {Error} if the User resource name is invalid.
+ */
+export function extractUserId(user) {
+ const matches = user.match(USER_NAME_REGEX);
+ if (!matches) {
+ throw new ResourceNameError();
+ }
+ return matches[1];
+}
+
+/**
+ * Extract a project's displayName from a Project resource name.
+ * @param {ProjectName} project Project resource name.
+ * @return {string} The project's displayName.
+ * @throws {Error} if the Project resource name is invalid.
+ */
+export function extractProjectDisplayName(project) {
+ const matches = project.match(PROJECT_NAME_REGEX);
+ if (!matches) {
+ throw new ResourceNameError();
+ }
+ return matches[1];
+}
+
+/**
+ * Gets the displayName of the Project referenced in a ProjectMember
+ * resource name.
+ * @param {ProjectMemberName} projectMember ProjectMember resource name.
+ * @return {string} A display name for a project.
+ */
+export function extractProjectFromProjectMember(projectMember) {
+ const matches = projectMember.match(PROJECT_MEMBER_NAME_REGEX);
+ if (!matches) {
+ throw new ResourceNameError();
+ }
+ return matches[1];
+}
+
+/**
+ * Creates a ProjectStar resource name based on a UserName nad a ProjectName.
+ * @param {ProjectName} project Resource name of the referenced project.
+ * @param {UserName} user Resource name of the referenced user.
+ * @return {ProjectStarName}
+ * @throws {Error} If the project or user resource name is invalid.
+ */
+export function projectAndUserToStarName(project, user) {
+ if (!project || !user) return undefined;
+ const userId = extractUserId(user);
+ const projectName = extractProjectDisplayName(project);
+ return `users/${userId}/projectStars/${projectName}`;
+}
+
+/**
+ * Converts a given ProjectMemberName to just the ProjectName segment present.
+ * @param {ProjectMemberName} projectMember Resource name of a ProjectMember.
+ * @return {ProjectName} Resource name of the referenced project.
+ */
+export function projectMemberToProjectName(projectMember) {
+ const project = extractProjectFromProjectMember(projectMember);
+ return `projects/${project}`;
+}
diff --git a/static_src/shared/converters.test.js b/static_src/shared/converters.test.js
new file mode 100644
index 0000000..428a74d
--- /dev/null
+++ b/static_src/shared/converters.test.js
@@ -0,0 +1,112 @@
+// Copyright 2020 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 {assert} from 'chai';
+import {ResourceNameError, pathsToFieldMask, extractUserId,
+ extractProjectDisplayName, extractProjectFromProjectMember,
+ projectAndUserToStarName, projectMemberToProjectName} from './converters.js';
+
+describe('pathsToFieldMask', () => {
+ it('converts an array of strings to a FieldMask', () => {
+ assert.equal(pathsToFieldMask(['foo', 'barQux', 'qaz']), 'foo,barQux,qaz');
+ });
+});
+
+describe('extractUserId', () => {
+ it('throws error on improperly formatted resource name', () => {
+ assert.throws(() => extractUserId('projects/1234'),
+ ResourceNameError);
+ assert.throws(() => extractUserId('users/notAnId'),
+ ResourceNameError);
+ assert.throws(() => extractUserId('user/1234'),
+ ResourceNameError);
+ });
+
+ it('extracts user ID', () => {
+ assert.equal(extractUserId('users/1234'), '1234');
+ });
+});
+
+describe('extractProjectDisplayName', () => {
+ it('throws error on improperly formatted resource name', () => {
+ assert.throws(() => extractProjectDisplayName('users/1234'),
+ ResourceNameError);
+ assert.throws(() => extractProjectDisplayName('projects/(what)'),
+ ResourceNameError);
+ assert.throws(() => extractProjectDisplayName('project/test'),
+ ResourceNameError);
+ assert.throws(() => extractProjectDisplayName('projects/-test-'),
+ ResourceNameError);
+ });
+
+ it('extracts project display name', () => {
+ assert.equal(extractProjectDisplayName('projects/1234'), '1234');
+ assert.equal(extractProjectDisplayName('projects/monorail'), 'monorail');
+ assert.equal(extractProjectDisplayName('projects/test-project'),
+ 'test-project');
+ assert.equal(extractProjectDisplayName('projects/what-is-love2'),
+ 'what-is-love2');
+ });
+});
+
+describe('extractProjectFromProjectMember', () => {
+ it('throws error on improperly formatted resource name', () => {
+ assert.throws(
+ () => extractProjectFromProjectMember(
+ 'projects/monorail/members/fakeName'),
+ ResourceNameError);
+ assert.throws(
+ () => extractProjectFromProjectMember(
+ 'projects/-invalid-project-/members/1234'),
+ ResourceNameError);
+ assert.throws(
+ () => extractProjectFromProjectMember(
+ 'projects/monorail/member/1234'),
+ ResourceNameError);
+ });
+
+ it('extracts project display name', () => {
+ assert.equal(extractProjectFromProjectMember(
+ 'projects/1234/members/1234'), '1234');
+ assert.equal(extractProjectFromProjectMember(
+ 'projects/monorail/members/1234'), 'monorail');
+ assert.equal(extractProjectFromProjectMember(
+ 'projects/test-project/members/1234'), 'test-project');
+ assert.equal(extractProjectFromProjectMember(
+ 'projects/what-is-love2/members/1234'), 'what-is-love2');
+ });
+});
+
+describe('projectAndUserToStarName', () => {
+ it('throws error on improperly formatted resource name', () => {
+ assert.throws(
+ () => projectAndUserToStarName('users/1234', 'projects/monorail'),
+ ResourceNameError);
+ });
+
+ it('generates project star resource name', () => {
+ assert.equal(projectAndUserToStarName('projects/monorail', 'users/1234'),
+ 'users/1234/projectStars/monorail');
+ });
+});
+
+describe('projectMemberToProjectName', () => {
+ it('throws error on improperly formatted resource name', () => {
+ assert.throws(
+ () => projectMemberToProjectName(
+ 'projects/monorail/members/fakeName'),
+ ResourceNameError);
+ });
+
+ it('creates project resource name', () => {
+ assert.equal(projectMemberToProjectName(
+ 'projects/1234/members/1234'), 'projects/1234');
+ assert.equal(projectMemberToProjectName(
+ 'projects/monorail/members/1234'), 'projects/monorail');
+ assert.equal(projectMemberToProjectName(
+ 'projects/test-project/members/1234'), 'projects/test-project');
+ assert.equal(projectMemberToProjectName(
+ 'projects/what-is-love2/members/1234'), 'projects/what-is-love2');
+ });
+});
diff --git a/static_src/shared/convertersV0.js b/static_src/shared/convertersV0.js
new file mode 100644
index 0000000..ffb8a36
--- /dev/null
+++ b/static_src/shared/convertersV0.js
@@ -0,0 +1,610 @@
+// 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.
+
+/**
+ * @fileoverview This file collects helpers for managing various canonical
+ * formats used within Monorail's frontend. When converting between common
+ * Objects, for example, it's recommended to use the helpers in this file
+ * to ensure consistency across conversions.
+ *
+ * Converters between v0 and v3 object types are included in this file
+ * as well.
+ */
+
+import qs from 'qs';
+
+import {equalsIgnoreCase, capitalizeFirst} from './helpers.js';
+import {fromShortlink} from 'shared/federated.js';
+import {UserInputError} from 'shared/errors.js';
+import './typedef.js';
+
+/**
+ * Common restriction labels to do things users frequently want to do
+ * with restrictions.
+ * This code is a frontend replication of old Python server code that
+ * hardcoded specific restriction labels.
+ * @type {Array<LabelDef>}
+ */
+const FREQUENT_ISSUE_RESTRICTIONS = Object.freeze([
+ {
+ label: 'Restrict-View-EditIssue',
+ docstring: 'Only users who can edit the issue may access it',
+ },
+ {
+ label: 'Restrict-AddIssueComment-EditIssue',
+ docstring: 'Only users who can edit the issue may add comments',
+ },
+]);
+
+/**
+ * The set of actions that permissions on an issue can be applied to.
+ * For example, in the Restrict-View-Google label, "View" is an action.
+ * @type {Array<string>}
+ */
+const STANDARD_ISSUE_ACTIONS = [
+ 'View', 'EditIssue', 'AddIssueComment', 'DeleteIssue', 'FlagSpam'];
+
+// A Regex defining the canonical String format used in Monorail for allowing
+// users to input structured localId and projectName values in free text inputs.
+// Match: projectName:localId format where projectName is optional.
+// ie: "monorail:1234" or "1234".
+const ISSUE_ID_REGEX = /(?:([a-z0-9-]+):)?(\d+)/i;
+
+// RFC 2821-compliant email address regex used by the server when validating
+// email addresses.
+// eslint-disable-next-line max-len
+const RFC_2821_EMAIL_REGEX = /^[-a-zA-Z0-9!#$%&'*+\/=?^_`{|}~]+(?:[.][-a-zA-Z0-9!#$%&'*+\/=?^_`{|}~]+)*@(?:(?:[0-9a-zA-Z](?:[-]*[0-9a-zA-Z]+)*)(?:\.[0-9a-zA-Z](?:[-]*[0-9a-zA-Z]+)*)*)\.(?:[a-zA-Z]{2,9})$/;
+
+/**
+ * Converts a displayName into a canonical UserRef Object format.
+ *
+ * @param {string} displayName The user's email address, used as a display name.
+ * @return {UserRef} UserRef formatted object that contains a
+ * user's displayName.
+ */
+export function displayNameToUserRef(displayName) {
+ if (displayName && !RFC_2821_EMAIL_REGEX.test(displayName)) {
+ throw new UserInputError(`Invalid email address: ${displayName}`);
+ }
+ return {displayName};
+}
+
+/**
+ * Converts a displayName into a canonical UserRef Object format.
+ *
+ * @param {string} user The user's email address, used as a display name,
+ * or their numeric user ID.
+ * @return {UserRef} UserRef formatted object that contains a
+ * user's displayName or userId.
+ */
+export function userIdOrDisplayNameToUserRef(user) {
+ if (RFC_2821_EMAIL_REGEX.test(user)) {
+ return {displayName: user};
+ }
+ const userId = Number.parseInt(user);
+ if (Number.isNaN(userId)) {
+ throw new UserInputError(`Invalid email address or user ID: ${user}`);
+ }
+ return {userId};
+}
+
+/**
+ * Converts an Object into a standard UserRef Object with only a displayName
+ * and userId. Used for cases when we need to use only the data required to
+ * identify a unique user, such as when requesting information related to a user
+ * through the API.
+ *
+ * @param {UserV0} user An Object representing a user, in the JSON format
+ * returned by the pRPC API.
+ * @return {UserRef} UserRef style Object.
+ */
+export function userToUserRef(user) {
+ if (!user) return {};
+ const {userId, displayName} = user;
+ return {userId, displayName};
+}
+
+/**
+ * Converts a User resource name to a numeric user ID.
+ * @param {string} name
+ * @return {number}
+ */
+export function userNameToId(name) {
+ return Number.parseInt(name.split('/')[1]);
+}
+
+/**
+ * Converts a v3 API User object to a v0 API UserRef.
+ * @param {User} user
+ * @return {UserRef}
+ */
+export function userV3ToRef(user) {
+ return {userId: userNameToId(user.name), displayName: user.displayName};
+}
+
+/**
+ * Convert a UserRef style Object to a userId string.
+ *
+ * @param {UserRef} userRef Object expected to contain a userId key.
+ * @return {number} the unique ID of the user.
+ */
+export function userRefToId(userRef) {
+ return userRef && userRef.userId;
+}
+
+/**
+ * Extracts the displayName property from a UserRef Object.
+ *
+ * @param {UserRef} userRef UserRef Object uniquely identifying a user.
+ * @return {string} The user's display name (email address).
+ */
+export function userRefToDisplayName(userRef) {
+ return userRef && userRef.displayName;
+}
+
+/**
+ * Converts an Array of UserRefs to an Array of display name Strings.
+ *
+ * @param {Array<UserRef>} userRefs Array of UserRefs.
+ * @return {Array<string>} Array of display names.
+ */
+export function userRefsToDisplayNames(userRefs) {
+ if (!userRefs) return [];
+ return userRefs.map(userRefToDisplayName);
+}
+
+/**
+ * Takes an Array of UserRefs and keeps only UserRefs where ID
+ * is known.
+ *
+ * @param {Array<UserRef>} userRefs Array of UserRefs.
+ * @return {Array<UserRef>} Filtered Array IDs guaranteed.
+ */
+export function userRefsWithIds(userRefs) {
+ if (!userRefs) return [];
+ return userRefs.filter((u) => u.userId);
+}
+
+/**
+ * Takes an Array of UserRefs and returns displayNames for
+ * only those refs with IDs specified.
+ *
+ * @param {Array<UserRef>} userRefs Array of UserRefs.
+ * @return {Array<string>} Array of user displayNames.
+ */
+export function filteredUserDisplayNames(userRefs) {
+ if (!userRefs) return [];
+ return userRefsToDisplayNames(userRefsWithIds(userRefs));
+}
+
+/**
+ * Takes in the name of a label and turns it into a LabelRef Object.
+ *
+ * @param {string} label The name of a label.
+ * @return {LabelRef}
+ */
+export function labelStringToRef(label) {
+ return {label};
+}
+
+/**
+ * Takes in the name of a label and turns it into a LabelRef Object.
+ *
+ * @param {LabelRef} labelRef
+ * @return {string} The name of the label.
+ */
+export function labelRefToString(labelRef) {
+ if (!labelRef) return;
+ return labelRef.label;
+}
+
+/**
+ * Converts an Array of LabelRef Objects to label name Strings.
+ *
+ * @param {Array<LabelRef>} labelRefs Array of LabelRef Objects.
+ * @return {Array<string>} Array of label names.
+ */
+export function labelRefsToStrings(labelRefs) {
+ if (!labelRefs) return [];
+ return labelRefs.map(labelRefToString);
+}
+
+/**
+ * Filters a list of labels into a list of only labels with one word.
+ *
+ * @param {Array<LabelRef>} labelRefs
+ * @return {Array<LabelRef>} only the LabelRefs that do not have multiple words.
+ */
+export function labelRefsToOneWordLabels(labelRefs) {
+ if (!labelRefs) return [];
+ return labelRefs.filter(({label}) => {
+ return isOneWordLabel(label);
+ });
+}
+
+/**
+ * Checks whether a particular label is one word.
+ *
+ * @param {string} label the name of the label being checked.
+ * @return {boolean} Whether the label is one word or not.
+ */
+export function isOneWordLabel(label = '') {
+ const words = label.split('-');
+ return words.length === 1;
+}
+
+/**
+ * Creates a LabelDef Object for a restriction label given an action
+ * and a permission.
+ * @param {string} action What action a restriction is applied to.
+ * eg. "View", "EditIssue", "AddIssueComment".
+ * @param {string} permission The permission group that has access to
+ * the restricted behavior. eg. "Google".
+ * @return {LabelDef}
+ */
+export function _makeRestrictionLabel(action, permission) {
+ const perm = capitalizeFirst(permission);
+ return {
+ label: `Restrict-${action}-${perm}`,
+ docstring: `Permission ${perm} needed to use ${action}`,
+ };
+}
+
+/**
+ * Given a list of custom permissions defined for a project, this function
+ * generates simulated LabelDef objects for those permissions + default
+ * restriction labels that all projects should have.
+ * @param {Array<string>=} customPermissions
+ * @param {Array<string>=} actions
+ * @param {Array<LabelDef>=} defaultRestrictionLabels Configurable default
+ * restriction labels to include regardless of custom permissions.
+ * @return {Array<LabelDef>}
+ */
+export function restrictionLabelsForPermissions(customPermissions = [],
+ actions = STANDARD_ISSUE_ACTIONS,
+ defaultRestrictionLabels = FREQUENT_ISSUE_RESTRICTIONS) {
+ const labels = [];
+ actions.forEach((action) => {
+ customPermissions.forEach((permission) => {
+ labels.push(_makeRestrictionLabel(action, permission));
+ });
+ });
+ return [...labels, ...defaultRestrictionLabels];
+}
+
+/**
+ * Converts a custom field name in to the prefix format used in
+ * enum type field values. Monorail defines the enum options for
+ * a custom field as labels.
+ *
+ * @param {string} fieldName Name of a custom field.
+ * @return {string} The label prefixes for enum choices
+ * associated with the field.
+ */
+export function fieldNameToLabelPrefix(fieldName) {
+ return `${fieldName.toLowerCase()}-`;
+}
+
+/**
+ * Finds all prefixes in a label's name, delimited by '-'. A given label
+ * can have multiple possible prefixes, one for each instance of '-'.
+ * Labels that share the same prefix are implicitly treated like
+ * enum fields in certain parts of Monorail's UI.
+ *
+ * @param {string} label The name of the label.
+ * @return {Array<string>} All prefixes in the label.
+ */
+export function labelNameToLabelPrefixes(label) {
+ if (!label) return;
+ const prefixes = [];
+ for (let i = 0; i < label.length; i++) {
+ if (label[i] === '-') {
+ prefixes.push(label.substring(0, i));
+ }
+ }
+ return prefixes;
+}
+
+/**
+ * Truncates a label to include only the label's value, delimited
+ * by '-'.
+ *
+ * @param {string} label The name of the label.
+ * @param {string} fieldName The field name that the label is having a
+ * value extracted for.
+ * @return {string} The label's value.
+ */
+export function labelNameToLabelValue(label, fieldName) {
+ if (!label || !fieldName || isOneWordLabel(label)) return null;
+ const prefix = fieldName.toLowerCase() + '-';
+ if (!label.toLowerCase().startsWith(prefix)) return null;
+
+ return label.substring(prefix.length);
+}
+
+/**
+ * Converts a FieldDef to a v3 FieldDef resource name.
+ * @param {string} projectName The name of the project.
+ * @param {FieldDef} fieldDef A FieldDef Object from the pRPC API proto objects.
+ * @return {string} The v3 FieldDef name, e.g. 'projects/proj/fieldDefs/fieldId'
+ */
+export function fieldDefToName(projectName, fieldDef) {
+ return `projects/${projectName}/fieldDefs/${fieldDef.fieldRef.fieldId}`;
+}
+
+/**
+ * Extracts just the name of the status from a StatusRef Object.
+ *
+ * @param {StatusRef} statusRef
+ * @return {string} The name of the status.
+ */
+export function statusRefToString(statusRef) {
+ return statusRef.status;
+}
+
+/**
+ * Extracts the name of multiple statuses from multiple StatusRef Objects.
+ *
+ * @param {Array<StatusRef>} statusRefs
+ * @return {Array<string>} The names of the statuses inputted.
+ */
+export function statusRefsToStrings(statusRefs) {
+ return statusRefs.map(statusRefToString);
+}
+
+/**
+ * Takes the name of a component and converts it into a ComponentRef
+ * Object.
+ *
+ * @param {string} path Name of the component.
+ * @return {ComponentRef}
+ */
+export function componentStringToRef(path) {
+ return {path};
+}
+
+/**
+ * Extracts just the name of a component from a ComponentRef.
+ *
+ * @param {ComponentRef} componentRef
+ * @return {string} The name of the component.
+ */
+export function componentRefToString(componentRef) {
+ return componentRef && componentRef.path;
+}
+
+/**
+ * Extracts the names of multiple components from multiple refs.
+ *
+ * @param {Array<ComponentRef>} componentRefs
+ * @return {Array<string>} Array of component names.
+ */
+export function componentRefsToStrings(componentRefs) {
+ if (!componentRefs) return [];
+ return componentRefs.map(componentRefToString);
+}
+
+/**
+ * Takes a String with a project name and issue ID in Monorail's canonical
+ * IssueRef format and converts it into an IssueRef Object.
+ *
+ * @param {IssueRefString} idStr A String of the format projectName:1234, a
+ * standard issue ID input format used across Monorail.
+ * @param {string=} defaultProjectName The implied projectName if none is
+ * specified.
+ * @return {IssueRef}
+ * @throws {UserInputError} If the IssueRef string is invalidly formatted.
+ */
+export function issueStringToRef(idStr, defaultProjectName) {
+ if (!idStr) return {};
+
+ // If the string includes a slash, it's an external tracker ref.
+ if (idStr.includes('/')) {
+ return {extIdentifier: idStr};
+ }
+
+ const matches = idStr.match(ISSUE_ID_REGEX);
+ if (!matches) {
+ throw new UserInputError(
+ `Invalid issue ref: ${idStr}. Expected [projectName:]issueId.`);
+ }
+ const projectName = matches[1] ? matches[1] : defaultProjectName;
+
+ if (!projectName) {
+ throw new UserInputError(
+ `Issue ref must include a project name or specify a default project.`);
+ }
+
+ const localId = Number.parseInt(matches[2]);
+ return {localId, projectName};
+}
+
+/**
+ * Takes an IssueRefString and converts it into an IssueRef Object, checking
+ * that it's not the same as another specified issueRef. ie: validates that an
+ * inputted blocking issue is not the same as the issue being blocked.
+ *
+ * @param {IssueRef} issueRef The issue that the IssueRefString is being
+ * compared to.
+ * @param {IssueRefString} idStr A String of the format projectName:1234, a
+ * standard issue ID input format used across Monorail.
+ * @return {IssueRef}
+ * @throws {UserInputError} If the IssueRef string is invalidly formatted
+ * or if the issue is equivalent to the linked issue.
+ */
+export function issueStringToBlockingRef(issueRef, idStr) {
+ // TODO(zhangtiff): Consider simplifying this helper function to only validate
+ // that an issue does not block itself rather than also doing string parsing.
+ const result = issueStringToRef(idStr, issueRef.projectName);
+ if (result.projectName === issueRef.projectName &&
+ result.localId === issueRef.localId) {
+ throw new UserInputError(
+ `Invalid issue ref: ${idStr
+ }. Cannot merge or block an issue on itself.`);
+ }
+ return result;
+}
+
+/**
+ * Converts an IssueRef into a canonical String format. ie: "project:1234"
+ *
+ * @param {IssueRef} ref
+ * @param {string=} projectName The current project context. The
+ * generated String excludes the projectName if it matches the
+ * project the user is currently viewing, to create simpler
+ * issue ID links.
+ * @return {IssueRefString} A String representing the pieces of an IssueRef.
+ */
+export function issueRefToString(ref, projectName = undefined) {
+ if (!ref) return '';
+
+ if (ref.hasOwnProperty('extIdentifier')) {
+ return ref.extIdentifier;
+ }
+
+ if (projectName && projectName.length &&
+ equalsIgnoreCase(ref.projectName, projectName)) {
+ return `${ref.localId}`;
+ }
+ return `${ref.projectName}:${ref.localId}`;
+}
+
+/**
+ * Converts a full Issue Object into only the pieces of its data needed
+ * to define an IssueRef. Useful for cases when we don't want to send excess
+ * information to ifentify an Issue.
+ *
+ * @param {Issue} issue A full Issue Object.
+ * @return {IssueRef} Just the ID part of the Issue Object.
+ */
+export function issueToIssueRef(issue) {
+ if (!issue) return {};
+
+ return {localId: issue.localId,
+ projectName: issue.projectName};
+}
+
+/**
+ * Converts a full Issue Object into an IssueRefString
+ *
+ * @param {Issue} issue A full Issue Object.
+ * @param {string=} defaultProjectName The default project the String should
+ * assume.
+ * @return {IssueRefString} A String with all the data needed to
+ * construct an IssueRef.
+ */
+export function issueToIssueRefString(issue, defaultProjectName = undefined) {
+ if (!issue) return '';
+
+ const ref = issueToIssueRef(issue);
+ return issueRefToString(ref, defaultProjectName);
+}
+
+/**
+ * Creates a link to a particular issue specified in an IssueRef.
+ *
+ * @param {IssueRef} ref The issue that the generated URL will point to.
+ * @param {Object} queryParams The URL params for the URL.
+ * @return {string} The URL for the issue's page as a relative path.
+ */
+export function issueRefToUrl(ref, queryParams = {}) {
+ const queryParamsCopy = {...queryParams};
+
+ if (!ref) return '';
+
+ if (ref.extIdentifier) {
+ const extRef = fromShortlink(ref.extIdentifier);
+ if (!extRef) {
+ console.error(`No tracker found for reference: ${ref.extIdentifier}`);
+ return '';
+ }
+ return extRef.toURL();
+ }
+
+ let paramString = '';
+ if (Object.keys(queryParamsCopy).length) {
+ delete queryParamsCopy.id;
+
+ paramString = `&${qs.stringify(queryParamsCopy)}`;
+ }
+
+ return `/p/${ref.projectName}/issues/detail?id=${ref.localId}${paramString}`;
+}
+
+/**
+ * Converts multiple IssueRef Objects into Strings in the canonical IssueRef
+ * String form expeced by Monorail.
+ *
+ * @param {Array<IssueRef>} arr Array of IssueRefs to convert to Strings.
+ * @param {string} projectName The default project name.
+ * @return {Array<IssueRefString>} Array of Strings where each entry is
+ * represents one IssueRef.
+ */
+export function issueRefsToStrings(arr, projectName) {
+ if (!arr || !arr.length) return [];
+ return arr.map((ref) => issueRefToString(ref, projectName));
+}
+
+/**
+ * Converts an issue name in the v3 API to an IssueRef in the v0 API.
+ * @param {string} name The v3 Issue name, e.g. 'projects/proj-name/issues/123'
+ * @return {IssueRef} An IssueRef.
+ */
+export function issueNameToRef(name) {
+ const nameParts = name.split('/');
+ return {
+ projectName: nameParts[1],
+ localId: parseInt(nameParts[3]),
+ };
+}
+
+/**
+ * Converts an issue name in the v3 API to an IssueRefString in the v0 API.
+ * @param {string} name The v3 Issue name, e.g. 'projects/proj-name/issues/123'
+ * @return {IssueRefString} A String with all the data needed to
+ * construct an IssueRef.
+ */
+export function issueNameToRefString(name) {
+ const nameParts = name.split('/');
+ return `${nameParts[1]}:${nameParts[3]}`;
+}
+
+/**
+ * Converts an v0 Issue to a v3 Issue name.
+ * @param {Issue} issue An Issue Object from the pRPC API issue_objects.proto.
+ * @return {string} The v3 Issue name, e.g. 'projects/proj-name/issues/123'
+ */
+export function issueToName(issue) {
+ return `projects/${issue.projectName}/issues/${issue.localId}`;
+}
+
+/**
+ * Since Monorail stores issue descriptions and description updates as comments,
+ * this function exists to filter a list of comments to get only those comments
+ * that are marked as descriptions.
+ *
+ * @param {Array<IssueComment>} comments List of many comments, usually all
+ * comments associated with an issue.
+ * @return {Array<IssueComment>} List of only the comments that are
+ * descriptions.
+ */
+export function commentListToDescriptionList(comments) {
+ if (!comments) return [];
+ // First comment is always a description, even if it doesn't have a
+ // descriptionNum.
+ return comments.filter((c, i) => !i || c.descriptionNum);
+}
+
+/**
+ * Wraps a String value for a field and a FieldRef into a FieldValue
+ * Object.
+ *
+ * @param {FieldRef} fieldRef A reference to the custom field that this
+ * value is tied to.
+ * @param {string} value The value associated with the FieldRef.
+ * @return {FieldValue}
+ */
+export function valueToFieldValue(fieldRef, value) {
+ return {fieldRef, value};
+}
diff --git a/static_src/shared/convertersV0.test.js b/static_src/shared/convertersV0.test.js
new file mode 100644
index 0000000..2e34622
--- /dev/null
+++ b/static_src/shared/convertersV0.test.js
@@ -0,0 +1,427 @@
+// 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 {assert} from 'chai';
+import {UserInputError} from 'shared/errors.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+import {displayNameToUserRef, userIdOrDisplayNameToUserRef,
+ userNameToId, userV3ToRef, labelStringToRef,
+ labelRefToString, labelRefsToStrings, labelRefsToOneWordLabels,
+ isOneWordLabel, _makeRestrictionLabel, restrictionLabelsForPermissions,
+ fieldDefToName, statusRefToString, statusRefsToStrings,
+ componentStringToRef, componentRefToString, componentRefsToStrings,
+ issueStringToRef, issueStringToBlockingRef, issueRefToString,
+ issueRefToUrl, fieldNameToLabelPrefix, labelNameToLabelPrefixes,
+ labelNameToLabelValue, commentListToDescriptionList, valueToFieldValue,
+ issueToIssueRef, issueNameToRef, issueNameToRefString, issueToName,
+} from './convertersV0.js';
+
+describe('displayNameToUserRef', () => {
+ it('converts displayName', () => {
+ assert.deepEqual(
+ displayNameToUserRef('foo@bar.com'),
+ {displayName: 'foo@bar.com'});
+ });
+
+ it('throws on invalid email', () => {
+ assert.throws(() => displayNameToUserRef('foo'), UserInputError);
+ });
+});
+
+describe('userIdOrDisplayNameToUserRef', () => {
+ it('converts userId', () => {
+ assert.throws(() => displayNameToUserRef('foo'));
+ assert.deepEqual(
+ userIdOrDisplayNameToUserRef('12345678'),
+ {userId: 12345678});
+ });
+
+ it('converts displayName', () => {
+ assert.deepEqual(
+ userIdOrDisplayNameToUserRef('foo@bar.com'),
+ {displayName: 'foo@bar.com'});
+ });
+
+ it('throws if not an email or numeric id', () => {
+ assert.throws(() => userIdOrDisplayNameToUserRef('foo'), UserInputError);
+ });
+});
+
+it('userNameToId', () => {
+ assert.deepEqual(userNameToId(exampleUsers.NAME), exampleUsers.ID);
+});
+
+it('userV3ToRef', () => {
+ assert.deepEqual(userV3ToRef(exampleUsers.USER), exampleUsers.USER_REF);
+});
+
+describe('labelStringToRef', () => {
+ it('converts label', () => {
+ assert.deepEqual(labelStringToRef('foo'), {label: 'foo'});
+ });
+});
+
+describe('labelRefToString', () => {
+ it('converts labelRef', () => {
+ assert.deepEqual(labelRefToString({label: 'foo'}), 'foo');
+ });
+});
+
+describe('labelRefsToStrings', () => {
+ it('converts labelRefs', () => {
+ assert.deepEqual(labelRefsToStrings([{label: 'foo'}, {label: 'test'}]),
+ ['foo', 'test']);
+ });
+});
+
+describe('labelRefsToOneWordLabels', () => {
+ it('empty', () => {
+ assert.deepEqual(labelRefsToOneWordLabels(), []);
+ assert.deepEqual(labelRefsToOneWordLabels([]), []);
+ });
+
+ it('filters multi-word labels', () => {
+ assert.deepEqual(labelRefsToOneWordLabels([
+ {label: 'hello'},
+ {label: 'filter-me'},
+ {label: 'hello-world'},
+ {label: 'world'},
+ {label: 'this-label-has-so-many-words'},
+ ]), [
+ {label: 'hello'},
+ {label: 'world'},
+ ]);
+ });
+});
+
+describe('isOneWordLabel', () => {
+ it('true only for one word labels', () => {
+ assert.isTrue(isOneWordLabel('test'));
+ assert.isTrue(isOneWordLabel('LABEL'));
+ assert.isTrue(isOneWordLabel('Security'));
+
+ assert.isFalse(isOneWordLabel('Restrict-View-EditIssue'));
+ assert.isFalse(isOneWordLabel('Type-Feature'));
+ });
+});
+
+describe('_makeRestrictionLabel', () => {
+ it('creates label', () => {
+ assert.deepEqual(_makeRestrictionLabel('View', 'Google'), {
+ label: `Restrict-View-Google`,
+ docstring: `Permission Google needed to use View`,
+ });
+ });
+
+ it('capitalizes permission name', () => {
+ assert.deepEqual(_makeRestrictionLabel('EditIssue', 'security'), {
+ label: `Restrict-EditIssue-Security`,
+ docstring: `Permission Security needed to use EditIssue`,
+ });
+ });
+});
+
+describe('restrictionLabelsForPermissions', () => {
+ it('creates labels for permissions and actions', () => {
+ assert.deepEqual(restrictionLabelsForPermissions(['google', 'security'],
+ ['View', 'EditIssue'], []), [
+ {
+ label: 'Restrict-View-Google',
+ docstring: 'Permission Google needed to use View',
+ }, {
+ label: 'Restrict-View-Security',
+ docstring: 'Permission Security needed to use View',
+ }, {
+ label: 'Restrict-EditIssue-Google',
+ docstring: 'Permission Google needed to use EditIssue',
+ }, {
+ label: 'Restrict-EditIssue-Security',
+ docstring: 'Permission Security needed to use EditIssue',
+ },
+ ]);
+ });
+
+ it('appends default labels when specified', () => {
+ assert.deepEqual(restrictionLabelsForPermissions(['Google'], ['View'], [
+ {label: 'Restrict-Hello-World', docstring: 'description of label'},
+ ]), [
+ {
+ label: 'Restrict-View-Google',
+ docstring: 'Permission Google needed to use View',
+ },
+ {label: 'Restrict-Hello-World', docstring: 'description of label'},
+ ]);
+ });
+});
+
+describe('fieldNameToLabelPrefix', () => {
+ it('converts fieldName', () => {
+ assert.deepEqual(fieldNameToLabelPrefix('test'), 'test-');
+ assert.deepEqual(fieldNameToLabelPrefix('test-hello'), 'test-hello-');
+ assert.deepEqual(fieldNameToLabelPrefix('WHATEVER'), 'whatever-');
+ });
+});
+
+describe('labelNameToLabelPrefixes', () => {
+ it('converts labelName', () => {
+ assert.deepEqual(labelNameToLabelPrefixes('test'), []);
+ assert.deepEqual(labelNameToLabelPrefixes('test-hello'), ['test']);
+ assert.deepEqual(labelNameToLabelPrefixes('WHATEVER-this-label-is'),
+ ['WHATEVER', 'WHATEVER-this', 'WHATEVER-this-label']);
+ });
+});
+
+describe('labelNameToLabelValue', () => {
+ it('returns null when no matching value found in label', () => {
+ assert.isNull(labelNameToLabelValue('test-hello', ''));
+ assert.isNull(labelNameToLabelValue('', 'test'));
+ assert.isNull(labelNameToLabelValue('test-hello', 'hello'));
+ assert.isNull(labelNameToLabelValue('test-hello', 'tes'));
+ assert.isNull(labelNameToLabelValue('test', 'test'));
+ });
+
+ it('converts labelName', () => {
+ assert.deepEqual(labelNameToLabelValue('test-hello', 'test'), 'hello');
+ assert.deepEqual(labelNameToLabelValue('WHATEVER-this-label-is',
+ 'WHATEVER'), 'this-label-is');
+ assert.deepEqual(labelNameToLabelValue('WHATEVER-this-label-is',
+ 'WHATEVER-this'), 'label-is');
+ assert.deepEqual(labelNameToLabelValue('WHATEVER-this-label-is',
+ 'WHATEVER-this-label'), 'is');
+ });
+
+ it('fieldName is case insenstitive', () => {
+ assert.deepEqual(labelNameToLabelValue('test-hello', 'TEST'), 'hello');
+ assert.deepEqual(labelNameToLabelValue('test-hello', 'tEsT'), 'hello');
+ assert.deepEqual(labelNameToLabelValue('TEST-hello', 'test'), 'hello');
+ });
+});
+
+describe('fieldDefToName', () => {
+ it('converts fieldDef', () => {
+ const fieldDef = {fieldRef: {fieldId: '1'}};
+ const actual = fieldDefToName('project-name', fieldDef);
+ assert.equal(actual, 'projects/project-name/fieldDefs/1');
+ });
+});
+
+describe('statusRefToString', () => {
+ it('converts statusRef', () => {
+ assert.deepEqual(statusRefToString({status: 'foo'}), 'foo');
+ });
+});
+
+describe('statusRefsToStrings', () => {
+ it('converts statusRefs', () => {
+ assert.deepEqual(statusRefsToStrings(
+ [{status: 'hello'}, {status: 'world'}]), ['hello', 'world']);
+ });
+});
+
+describe('componentStringToRef', () => {
+ it('converts component', () => {
+ assert.deepEqual(componentStringToRef('foo'), {path: 'foo'});
+ });
+});
+
+describe('componentRefToString', () => {
+ it('converts componentRef', () => {
+ assert.deepEqual(componentRefToString({path: 'Hello>World'}),
+ 'Hello>World');
+ });
+});
+
+describe('componentRefsToStrings', () => {
+ it('converts componentRefs', () => {
+ assert.deepEqual(componentRefsToStrings(
+ [{path: 'Hello>World'}, {path: 'Test'}]), ['Hello>World', 'Test']);
+ });
+});
+
+describe('issueStringToRef', () => {
+ it('converts issue default project', () => {
+ assert.deepEqual(
+ issueStringToRef('1234', 'proj'),
+ {projectName: 'proj', localId: 1234});
+ });
+
+ it('converts issue with project', () => {
+ assert.deepEqual(
+ issueStringToRef('foo:1234', 'proj'),
+ {projectName: 'foo', localId: 1234});
+ });
+
+ it('converts external issue references', () => {
+ assert.deepEqual(
+ issueStringToRef('b/123456', 'proj'),
+ {extIdentifier: 'b/123456'});
+ });
+
+ it('throws on invalid input', () => {
+ assert.throws(() => issueStringToRef('foo', 'proj'));
+ });
+});
+
+describe('issueStringToBlockingRef', () => {
+ it('converts issue default project', () => {
+ assert.deepEqual(
+ issueStringToBlockingRef({projectName: 'proj', localId: 1}, '1234'),
+ {projectName: 'proj', localId: 1234});
+ });
+
+ it('converts issue with project', () => {
+ assert.deepEqual(
+ issueStringToBlockingRef({projectName: 'proj', localId: 1}, 'foo:1234'),
+ {projectName: 'foo', localId: 1234});
+ });
+
+ it('throws on invalid input', () => {
+ assert.throws(() => issueStringToBlockingRef(
+ {projectName: 'proj', localId: 1}, 'foo'));
+ });
+
+ it('throws when blocking an issue on itself', () => {
+ assert.throws(() => issueStringToBlockingRef(
+ {projectName: 'proj', localId: 123}, 'proj:123'));
+ assert.throws(() => issueStringToBlockingRef(
+ {projectName: 'proj', localId: 123}, '123'));
+ });
+});
+
+describe('issueRefToString', () => {
+ it('no ref', () => {
+ assert.equal(issueRefToString(), '');
+ });
+
+ it('ref with no project name', () => {
+ assert.equal(
+ 'other:1234',
+ issueRefToString({projectName: 'other', localId: 1234}),
+ );
+ });
+
+ it('ref with different project name', () => {
+ assert.equal(
+ 'other:1234',
+ issueRefToString({projectName: 'other', localId: 1234}, 'proj'),
+ );
+ });
+
+ it('ref with same project name', () => {
+ assert.equal(
+ '1234',
+ issueRefToString({projectName: 'proj', localId: 1234}, 'proj'),
+ );
+ });
+
+ it('external ref', () => {
+ assert.equal(
+ 'b/123456',
+ issueRefToString({extIdentifier: 'b/123456'}, 'proj'),
+ );
+ });
+});
+
+describe('issueToIssueRef', () => {
+ it('creates ref', () => {
+ const issue = {'localId': 1, 'projectName': 'proj', 'starCount': 1};
+ const expectedRef = {'localId': 1,
+ 'projectName': 'proj'};
+ assert.deepEqual(issueToIssueRef(issue), expectedRef);
+ });
+});
+
+describe('issueRefToUrl', () => {
+ it('no ref', () => {
+ assert.equal(issueRefToUrl(), '');
+ });
+
+ it('issue ref', () => {
+ assert.equal(issueRefToUrl({
+ projectName: 'test',
+ localId: 11,
+ }), '/p/test/issues/detail?id=11');
+ });
+
+ it('issue ref with params', () => {
+ assert.equal(issueRefToUrl({
+ projectName: 'test',
+ localId: 11,
+ }, {
+ q: 'owner:me',
+ id: 44,
+ }), '/p/test/issues/detail?id=11&q=owner%3Ame');
+ });
+
+ it('federated issue ref', () => {
+ assert.equal(issueRefToUrl({
+ extIdentifier: 'b/5678',
+ }), 'https://issuetracker.google.com/issues/5678');
+ });
+
+ it('does not mutate input queryParams', () => {
+ const queryParams = {q: 'owner:me', id: 44};
+ const EXPECTED = JSON.stringify(queryParams);
+ const ref = {projectName: 'test', localId: 11};
+ issueRefToUrl(ref, queryParams);
+ assert.equal(EXPECTED, JSON.stringify(queryParams));
+ });
+});
+
+it('issueNameToRef', () => {
+ const actual = issueNameToRef('projects/project-name/issues/2');
+ assert.deepEqual(actual, {projectName: 'project-name', localId: 2});
+});
+
+it('issueNameToRefString', () => {
+ const actual = issueNameToRefString('projects/project-name/issues/2');
+ assert.equal(actual, 'project-name:2');
+});
+
+it('issueToName', () => {
+ const actual = issueToName({projectName: 'project-name', localId: 2});
+ assert.equal(actual, 'projects/project-name/issues/2');
+});
+
+describe('commentListToDescriptionList', () => {
+ it('empty list', () => {
+ assert.deepEqual(commentListToDescriptionList(), []);
+ assert.deepEqual(commentListToDescriptionList([]), []);
+ });
+
+ it('first comment is description', () => {
+ assert.deepEqual(commentListToDescriptionList([
+ {content: 'test'},
+ {content: 'hello'},
+ {content: 'world'},
+ ]), [{content: 'test'}]);
+ });
+
+ it('some descriptions', () => {
+ assert.deepEqual(commentListToDescriptionList([
+ {content: 'test'},
+ {content: 'hello', descriptionNum: 1},
+ {content: 'world'},
+ {content: 'this'},
+ {content: 'is a'},
+ {content: 'description', descriptionNum: 2},
+ ]), [
+ {content: 'test'},
+ {content: 'hello', descriptionNum: 1},
+ {content: 'description', descriptionNum: 2},
+ ]);
+ });
+});
+
+describe('valueToFieldValue', () => {
+ it('converts field ref and value', () => {
+ assert.deepEqual(valueToFieldValue(
+ {fieldName: 'name', fieldId: 'id'},
+ 'value',
+ ), {
+ fieldRef: {fieldName: 'name', fieldId: 'id'},
+ value: 'value',
+ });
+ });
+});
diff --git a/static_src/shared/cron.js b/static_src/shared/cron.js
new file mode 100644
index 0000000..bd67507
--- /dev/null
+++ b/static_src/shared/cron.js
@@ -0,0 +1,35 @@
+// 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 {store} from 'reducers/base.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+// How long should we wait until asking the server status again.
+const SERVER_STATUS_DELAY_MS = 20 * 60 * 1000; // 20 minutes
+
+// CronTask is a class that supports periodically execution of tasks.
+export class CronTask {
+ constructor(task, delay) {
+ this.task = task;
+ this.delay = delay;
+ this.started = false;
+ }
+
+ start() {
+ if (this.started) return;
+ this.started = true;
+ this._execute();
+ }
+
+ _execute() {
+ this.task();
+ setTimeout(this._execute.bind(this), this.delay);
+ }
+}
+
+// getServerStatusCron requests status information from the server every 20
+// minutes.
+export const getServerStatusCron = new CronTask(
+ () => store.dispatch(sitewide.getServerStatus()),
+ SERVER_STATUS_DELAY_MS);
diff --git a/static_src/shared/cron.test.js b/static_src/shared/cron.test.js
new file mode 100644
index 0000000..e2f9a8e
--- /dev/null
+++ b/static_src/shared/cron.test.js
@@ -0,0 +1,36 @@
+// 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 sinon from 'sinon';
+import {assert} from 'chai';
+import {CronTask} from './cron.js';
+
+let clock;
+
+describe('cron', () => {
+ beforeEach(() => {
+ clock = sinon.useFakeTimers();
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ it('calls task periodically', () => {
+ const task = sinon.spy();
+ const cronTask = new CronTask(task, 1000);
+
+ // Make sure task is not called until the cron task has been started.
+ assert.isFalse(task.called);
+
+ cronTask.start();
+ assert.isTrue(task.calledOnce);
+
+ clock.tick(1000);
+ assert.isTrue(task.calledTwice);
+
+ clock.tick(1000);
+ assert.isTrue(task.calledThrice);
+ });
+});
diff --git a/static_src/shared/dom-helpers.js b/static_src/shared/dom-helpers.js
new file mode 100644
index 0000000..81dec80
--- /dev/null
+++ b/static_src/shared/dom-helpers.js
@@ -0,0 +1,60 @@
+// 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.
+
+
+// Prevent triggering input change handlers on key events that don't
+// edit forms.
+export const NON_EDITING_KEY_EVENTS = new Set(['Enter', 'Tab', 'Escape',
+ 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'ArrowDown']);
+const INPUT_TYPES_WITHOUT_TEXT_INPUT = [
+ 'checkbox',
+ 'radio',
+ 'file',
+ 'submit',
+ 'button',
+ 'image',
+];
+
+// TODO: Add a method to watch for property changes in one of a subset of
+// element properties.
+// Via: https://crrev.com/c/infra/infra/+/1762911/7/appengine/monorail/static_src/elements/help/mr-cue/mr-cue.js
+
+/**
+ * Checks if a keyboard event should be disabled when the user is typing.
+ *
+ * @param {HTMLElement} element is a dom node to run checks against.
+ * @return {boolean} Whether the dom node is an element that accepts key input.
+ */
+export function isTextInput(element) {
+ const tagName = element.tagName && element.tagName.toUpperCase();
+ if (tagName === 'INPUT') {
+ const type = element.type.toLowerCase();
+ if (INPUT_TYPES_WITHOUT_TEXT_INPUT.includes(type)) {
+ return false;
+ }
+ return true;
+ }
+ return tagName === 'SELECT' || tagName === 'TEXTAREA' ||
+ element.isContentEditable;
+}
+
+/**
+ * Helper to find the EventTarget that an Event originated from, even if that
+ * EventTarget is buried until multiple layers of ShadowDOM.
+ *
+ * @param {Event} event
+ * @return {EventTarget} The DOM node that the event came from. For example,
+ * if the input was a keypress, this might be the input element the user was
+ * typing into.
+ */
+export function findDeepEventTarget(event) {
+ /**
+ * Event.target finds the element the event came from, but only
+ * finds events that come from the highest ShadowDOM level. For
+ * example, an Event listener attached to "window" will have all
+ * Events originating from the SPA set to a target of <mr-app>.
+ */
+ const path = event.composedPath();
+ return path ? path[0] : event.target;
+}
diff --git a/static_src/shared/dom-helpers.test.js b/static_src/shared/dom-helpers.test.js
new file mode 100644
index 0000000..78d535a
--- /dev/null
+++ b/static_src/shared/dom-helpers.test.js
@@ -0,0 +1,100 @@
+// 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 {assert} from 'chai';
+import {isTextInput, findDeepEventTarget} from './dom-helpers.js';
+
+describe('isTextInput', () => {
+ it('returns true for select', () => {
+ const element = document.createElement('select');
+ assert.isTrue(isTextInput(element));
+ });
+
+ it('returns true for input tags that take text input', () => {
+ const element = document.createElement('input');
+ assert.isTrue(isTextInput(element));
+
+ element.type = 'text';
+ assert.isTrue(isTextInput(element));
+
+ element.type = 'password';
+ assert.isTrue(isTextInput(element));
+
+ element.type = 'number';
+ assert.isTrue(isTextInput(element));
+
+ element.type = 'date';
+ assert.isTrue(isTextInput(element));
+ });
+
+ it('returns false for input tags without text input', () => {
+ const element = document.createElement('input');
+
+ element.type = 'button';
+ assert.isFalse(isTextInput(element));
+
+ element.type = 'submit';
+ assert.isFalse(isTextInput(element));
+
+ element.type = 'checkbox';
+ assert.isFalse(isTextInput(element));
+
+ element.type = 'radio';
+ assert.isFalse(isTextInput(element));
+ });
+
+ it('returns true for textarea', () => {
+ const element = document.createElement('textarea');
+ assert.isTrue(isTextInput(element));
+ });
+
+ it('returns true for contenteditable', () => {
+ const element = document.createElement('div');
+ element.contentEditable = 'true';
+ assert.isTrue(isTextInput(element));
+
+ element.contentEditable = 'false';
+ assert.isFalse(isTextInput(element));
+ });
+
+ it('returns false for non-input', () => {
+ assert.isFalse(isTextInput(document.createElement('div')));
+ assert.isFalse(isTextInput(document.createElement('table')));
+ assert.isFalse(isTextInput(document.createElement('tr')));
+ assert.isFalse(isTextInput(document.createElement('td')));
+ assert.isFalse(isTextInput(document.createElement('href')));
+ assert.isFalse(isTextInput(document.createElement('random-elment')));
+ assert.isFalse(isTextInput(document.createElement('p')));
+ });
+});
+
+describe('findDeepEventTarget', () => {
+ it('returns empty for event without target', () => {
+ const event = new Event('whatsup');
+ assert.isUndefined(findDeepEventTarget(event));
+ });
+
+ it('returns target for event with target', (done) => {
+ const element = document.createElement('div');
+ element.addEventListener('hello', (e) => {
+ assert.deepEqual(findDeepEventTarget(e), element);
+ done();
+ });
+ element.dispatchEvent(new Event('hello'));
+ });
+
+ it('returns target for event coming from shadowRoot', (done) => {
+ const target = document.createElement('button');
+ const parent = document.createElement('div');
+ parent.appendChild(target);
+ parent.attachShadow({mode: 'open'});
+
+ parent.addEventListener('shadow-root', (e) => {
+ assert.deepEqual(findDeepEventTarget(e), target);
+ done();
+ });
+
+ target.dispatchEvent(new Event('shadow-root', {bubbles: true}));
+ });
+});
diff --git a/static_src/shared/errors.js b/static_src/shared/errors.js
new file mode 100644
index 0000000..81c0035
--- /dev/null
+++ b/static_src/shared/errors.js
@@ -0,0 +1,9 @@
+// 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.
+
+export class UserInputError extends Error {
+ get name() {
+ return 'UserInputError';
+ }
+}
diff --git a/static_src/shared/experiments.js b/static_src/shared/experiments.js
new file mode 100644
index 0000000..1528a00
--- /dev/null
+++ b/static_src/shared/experiments.js
@@ -0,0 +1,91 @@
+// Copyright 2020 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.
+
+/**
+ * @fileoverview Manages the current user's participation in experiments (e.g.
+ * phased rollouts).
+ *
+ * This file is an early prototype serving the needs of go/monorail-slo-v0.
+ *
+ * The more mature design is under discussion:
+ * http://doc/1rtYXq68WSlTNCzVJiSttLWF14CiK5sOlEef2JWAgheg
+ */
+
+/**
+ * An Enum representing known expreriments.
+ *
+ * @typedef {string} Experiment
+ */
+
+/**
+ * @type {Experiment}
+ */
+export const SLO_EXPERIMENT = 'slo';
+
+const EXPERIMENT_QUERY_PARAM = 'e';
+
+const DISABLED_STR = '-';
+
+const _SLO_EXPERIMENT_USER_DISPLAY_NAMES = new Set([
+ 'jessan@google.com',
+]);
+
+/**
+ * Checks whether the current user is in given experiment.
+ *
+ * @param {Experiment} experiment The experiment to check.
+ * @param {UserV0=} user The current user. Although any user can currently
+ * be passed in, we only intend to support checking if the current user is
+ * in the experiment. In the future the user parameter may be removed.
+ * @param {Object} queryParams The current query parameters, parsed by qs.
+ * We support a string like 'e=-exp1,-exp2...' for disabling experiments.
+ *
+ * We allow disabling so that a user in the fishfood group can work around
+ * any bugs or undesired behaviors the experiment may introduce for them.
+ *
+ * As of now, we don't allow enabling experiments by override params.
+ * We may not want access shared beyond the fishfood group (e.g. if it is a
+ * feature we are likely to change dramatically or take away).
+ * @return {boolean} Whether the experiment is enabled for the current user.
+ */
+export const isExperimentEnabled = (experiment, user, queryParams) => {
+ const experimentOverrides = parseExperimentParam(
+ queryParams[EXPERIMENT_QUERY_PARAM]);
+ if (experimentOverrides[experiment] === false) {
+ return false;
+ }
+ switch (experiment) {
+ case SLO_EXPERIMENT:
+ return !!user &&
+ _SLO_EXPERIMENT_USER_DISPLAY_NAMES.has(user.displayName);
+ default:
+ throw Error('Unknown experiment provided');
+ }
+};
+
+/**
+ * Parses a comma separated list of experiments from the query string.
+ * Experiment strings preceded by DISABLED_STR are overrode to be disabled,
+ * otherwise they are to be enabled.
+ *
+ * Does not do any validation of the experiment string provided.
+ *
+ * @param {string?} experimentParam comma separated experiements.
+ * @return {Object} Maps experiment name to whether enabled or
+ * disabled boolean. May include invalid experiment names.
+ */
+const parseExperimentParam = (experimentParam) => {
+ const experimentOverrides = {};
+ if (experimentParam) {
+ for (const experimentOverride of experimentParam.split(',')) {
+ if (experimentOverride.startsWith(DISABLED_STR)) {
+ const experiment = experimentOverride.substr(DISABLED_STR.length);
+ experimentOverrides[experiment] = false;
+ } else {
+ experimentOverrides[experimentOverride] = true;
+ }
+ }
+ }
+ return experimentOverrides;
+};
diff --git a/static_src/shared/experiments.test.js b/static_src/shared/experiments.test.js
new file mode 100644
index 0000000..d5f96b7
--- /dev/null
+++ b/static_src/shared/experiments.test.js
@@ -0,0 +1,62 @@
+// Copyright 2020 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 {assert} from 'chai';
+import {isExperimentEnabled, SLO_EXPERIMENT} from './experiments.js';
+
+
+describe('isExperimentEnabled', () => {
+ it('throws error for unknown experiment', () => {
+ assert.throws(() =>
+ isExperimentEnabled('unknown-exp', {displayName: 'jessan@google.com'}));
+ });
+
+ it('returns false if user not in experiment', () => {
+ const ineligibleUser = {displayName: 'example@example.com'};
+ assert.isFalse(isExperimentEnabled(SLO_EXPERIMENT, ineligibleUser, {}));
+ });
+
+ it('returns false if no user provided', () => {
+ assert.isFalse(isExperimentEnabled(SLO_EXPERIMENT, undefined, {}));
+ });
+
+ it('returns true if user in experiment', () => {
+ const eligibleUser = {displayName: 'jessan@google.com'};
+ assert.isTrue(isExperimentEnabled(SLO_EXPERIMENT, eligibleUser, {}));
+ });
+
+ it('is false if user in experiment has disabled it with URL', () => {
+ const eligibleUser = {displayName: 'jessan@google.com'};
+ assert.isFalse(isExperimentEnabled(
+ SLO_EXPERIMENT, eligibleUser, {'e': '-slo'}));
+ });
+
+ it('ignores enabling experiments with URL', () => {
+ const ineligibleUser = {displayName: 'example@example.com'};
+ assert.isFalse(isExperimentEnabled(
+ SLO_EXPERIMENT, ineligibleUser, {'e': 'slo'}));
+ });
+
+ it('ignores ineligible users disabling experiment with URL', () => {
+ const ineligibleUser = {displayName: 'example@example.com'};
+ assert.isFalse(isExperimentEnabled(
+ SLO_EXPERIMENT, ineligibleUser, {'e': '-slo'}));
+ });
+
+ it('ignores invalid experiments in URL', () => {
+ const eligibleUser = {displayName: 'jessan@google.com'};
+ // Leading comma, unknown experiment str, empty experiment str in
+ // middle, disable_str with no experiment, trailing comma
+ assert.isFalse(isExperimentEnabled(
+ SLO_EXPERIMENT, eligibleUser, {'e': ',unknown,-slo,,-,'}));
+ });
+
+ it('respects last instance when experiment repeated in URL', () => {
+ const eligibleUser = {displayName: 'jessan@google.com'};
+ assert.isFalse(isExperimentEnabled(
+ SLO_EXPERIMENT, eligibleUser, {'e': 'slo,-slo'}));
+ assert.isTrue(isExperimentEnabled(
+ SLO_EXPERIMENT, eligibleUser, {'e': '-slo,slo'}));
+ });
+});
diff --git a/static_src/shared/federated.js b/static_src/shared/federated.js
new file mode 100644
index 0000000..e5b7567
--- /dev/null
+++ b/static_src/shared/federated.js
@@ -0,0 +1,194 @@
+// 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.
+
+/**
+ * Logic for dealing with federated issue references.
+ */
+
+import loadGapi, {fetchGapiEmail} from './gapi-loader.js';
+
+const GOOGLE_ISSUE_TRACKER_REGEX = /^b\/\d+$/;
+
+const GOOGLE_ISSUE_TRACKER_API_ROOT = 'https://issuetracker.corp.googleapis.com';
+const GOOGLE_ISSUE_TRACKER_DISCOVERY_PATH = '/$discovery/rest';
+const GOOGLE_ISSUE_TRACKER_API_VERSION = 'v3';
+
+// Returns if shortlink is valid for any federated tracker.
+export function isShortlinkValid(shortlink) {
+ return FEDERATED_TRACKERS.some((TrackerClass) => {
+ try {
+ return new TrackerClass(shortlink);
+ } catch (e) {
+ if (e instanceof FederatedIssueError) {
+ return false;
+ } else {
+ throw e;
+ }
+ }
+ });
+}
+
+// Returns a issue instance for the first matching tracker.
+export function fromShortlink(shortlink) {
+ for (const key in FEDERATED_TRACKERS) {
+ if (FEDERATED_TRACKERS.hasOwnProperty(key)) {
+ const TrackerClass = FEDERATED_TRACKERS[key];
+ try {
+ return new TrackerClass(shortlink);
+ } catch (e) {
+ if (e instanceof FederatedIssueError) {
+ continue;
+ } else {
+ throw e;
+ }
+ }
+ }
+ }
+ return null;
+}
+
+// FederatedIssue is an abstract class for representing one federated issue.
+// Each supported tracker should subclass this class.
+class FederatedIssue {
+ constructor(shortlink) {
+ if (!this.isShortlinkValid(shortlink)) {
+ throw new FederatedIssueError(`Invalid tracker shortlink: ${shortlink}`);
+ }
+ this.shortlink = shortlink;
+ }
+
+ // isShortlinkValid returns whether a given shortlink is valid.
+ isShortlinkValid(shortlink) {
+ if (!(typeof shortlink === 'string')) {
+ throw new FederatedIssueError('shortlink argument must be a string.');
+ }
+ return Boolean(shortlink.match(this.shortlinkRe()));
+ }
+
+ // shortlinkRe returns the regex used to validate shortlinks.
+ shortlinkRe() {
+ throw new Error('Not implemented.');
+ }
+
+ // toURL returns the URL to this issue.
+ toURL() {
+ throw new Error('Not implemented.');
+ }
+
+ // toIssueRef converts the FedRef's information into an object having the
+ // IssueRef format everywhere on the front-end expects.
+ toIssueRef() {
+ return {
+ extIdentifier: this.shortlink,
+ };
+ }
+
+ // trackerName should return the name of the bug tracker.
+ get trackerName() {
+ throw new Error('Not implemented.');
+ }
+
+ // isOpen returns a Promise that resolves either true or false.
+ async isOpen() {
+ throw new Error('Not implemented.');
+ }
+}
+
+// Class for Google Issue Tracker (Buganizer) logic.
+export class GoogleIssueTrackerIssue extends FederatedIssue {
+ constructor(shortlink) {
+ super(shortlink);
+ this.issueID = Number(shortlink.substr(2));
+ this._federatedDetails = null;
+ }
+
+ shortlinkRe() {
+ return GOOGLE_ISSUE_TRACKER_REGEX;
+ }
+
+ toURL() {
+ return `https://issuetracker.google.com/issues/${this.issueID}`;
+ }
+
+ get trackerName() {
+ return 'Buganizer';
+ }
+
+ async getFederatedDetails() {
+ // Prevent fetching details more than once.
+ if (this._federatedDetails) {
+ return this._federatedDetails;
+ }
+
+ await loadGapi();
+ const email = await fetchGapiEmail();
+ if (!email) {
+ // Fail open.
+ return true;
+ }
+ const res = await this._loadGoogleIssueTrackerIssue(this.issueID);
+ if (!res || !res.result) {
+ // Fail open.
+ return null;
+ }
+
+ this._federatedDetails = res.result;
+ return this._federatedDetails;
+ }
+
+ // isOpen assumes getFederatedDetails has already been called, otherwise
+ // it will fail open (returning that the issue is open).
+ get isOpen() {
+ if (!this._federatedDetails) {
+ // Fail open.
+ return true;
+ }
+
+ // Open issues will not have a `resolvedTime`.
+ return !Boolean(this._federatedDetails.resolvedTime);
+ }
+
+ // summary assumes getFederatedDetails has already been called.
+ get summary() {
+ if (this._federatedDetails &&
+ this._federatedDetails.issueState &&
+ this._federatedDetails.issueState.title) {
+ return this._federatedDetails.issueState.title;
+ }
+ return null;
+ }
+
+ toIssueRef() {
+ return {
+ extIdentifier: this.shortlink,
+ summary: this.summary,
+ statusRef: {meansOpen: this.isOpen},
+ };
+ }
+
+ get _APIURL() {
+ return GOOGLE_ISSUE_TRACKER_API_ROOT + GOOGLE_ISSUE_TRACKER_DISCOVERY_PATH;
+ }
+
+ _loadGoogleIssueTrackerIssue(bugID) {
+ return new Promise((resolve, reject) => {
+ const version = GOOGLE_ISSUE_TRACKER_API_VERSION;
+ gapi.client.load(this._APIURL, version, () => {
+ const request = gapi.client.corp_issuetracker.issues.get({
+ 'issueId': bugID,
+ });
+ request.execute((response) => {
+ resolve(response);
+ });
+ });
+ });
+ }
+}
+
+class FederatedIssueError extends Error {}
+
+// A list of supported tracker classes.
+const FEDERATED_TRACKERS = [
+ GoogleIssueTrackerIssue,
+];
diff --git a/static_src/shared/federated.test.js b/static_src/shared/federated.test.js
new file mode 100644
index 0000000..011b924
--- /dev/null
+++ b/static_src/shared/federated.test.js
@@ -0,0 +1,136 @@
+// 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 {assert} from 'chai';
+import {
+ isShortlinkValid,
+ fromShortlink,
+ GoogleIssueTrackerIssue,
+} from './federated.js';
+import {getSigninInstance} from 'shared/gapi-loader.js';
+
+describe('isShortlinkValid', () => {
+ it('Returns true for valid links', () => {
+ assert.isTrue(isShortlinkValid('b/1'));
+ assert.isTrue(isShortlinkValid('b/12345678'));
+ });
+
+ it('Returns false for invalid links', () => {
+ assert.isFalse(isShortlinkValid('b'));
+ assert.isFalse(isShortlinkValid('b/'));
+ assert.isFalse(isShortlinkValid('b//123456'));
+ assert.isFalse(isShortlinkValid('b/123/123'));
+ assert.isFalse(isShortlinkValid('b123/123'));
+ assert.isFalse(isShortlinkValid('b/123a456'));
+ });
+});
+
+describe('fromShortlink', () => {
+ it('Returns an issue class for valid links', () => {
+ assert.instanceOf(fromShortlink('b/1'), GoogleIssueTrackerIssue);
+ assert.instanceOf(fromShortlink('b/12345678'), GoogleIssueTrackerIssue);
+ });
+
+ it('Returns null for invalid links', () => {
+ assert.isNull(fromShortlink('b'));
+ assert.isNull(fromShortlink('b/'));
+ assert.isNull(fromShortlink('b//123456'));
+ assert.isNull(fromShortlink('b/123/123'));
+ assert.isNull(fromShortlink('b123/123'));
+ assert.isNull(fromShortlink('b/123a456'));
+ });
+});
+
+describe('GoogleIssueTrackerIssue', () => {
+ describe('constructor', () => {
+ it('Sets this.shortlink and this.issueID', () => {
+ const shortlink = 'b/1234';
+ const issue = new GoogleIssueTrackerIssue(shortlink);
+ assert.equal(issue.shortlink, shortlink);
+ assert.equal(issue.issueID, 1234);
+ });
+
+ it('Throws when given an invalid shortlink.', () => {
+ assert.throws(() => {
+ new GoogleIssueTrackerIssue('b/123/123');
+ });
+ });
+ });
+
+ describe('toURL', () => {
+ it('Returns a valid URL.', () => {
+ const issue = new GoogleIssueTrackerIssue('b/1234');
+ assert.equal(issue.toURL(), 'https://issuetracker.google.com/issues/1234');
+ });
+ });
+
+ describe('federated details', () => {
+ let signinImpl;
+ beforeEach(() => {
+ window.CS_env = {gapi_client_id: 'rutabaga'};
+ signinImpl = {
+ init: sinon.stub(),
+ getUserProfileAsync: () => (
+ Promise.resolve({
+ getEmail: sinon.stub().returns('rutabaga@google.com'),
+ })
+ ),
+ };
+ // Preload signinImpl with a fake for testing.
+ getSigninInstance(signinImpl, true);
+ delete window.__gapiLoadPromise;
+ });
+
+ afterEach(() => {
+ delete window.CS_env;
+ });
+
+ describe('isOpen', () => {
+ it('Fails open', () => {
+ const issue = new GoogleIssueTrackerIssue('b/1234');
+ assert.isTrue(issue.isOpen);
+ });
+
+ it('Is based on details.resolvedTime', () => {
+ const issue = new GoogleIssueTrackerIssue('b/1234');
+ issue._federatedDetails = {resolvedTime: 12345};
+ assert.isFalse(issue.isOpen);
+
+ issue._federatedDetails = {};
+ assert.isTrue(issue.isOpen);
+ });
+ });
+
+ describe('summary', () => {
+ it('Returns null if not available', () => {
+ const issue = new GoogleIssueTrackerIssue('b/1234');
+ assert.isNull(issue.summary);
+ });
+
+ it('Returns the summary if available', () => {
+ const issue = new GoogleIssueTrackerIssue('b/1234');
+ issue._federatedDetails = {issueState: {title: 'Rutabaga title'}};
+ assert.equal(issue.summary, 'Rutabaga title');
+ });
+ });
+
+ describe('toIssueRef', () => {
+ it('Returns an issue ref object', () => {
+ const issue = new GoogleIssueTrackerIssue('b/1234');
+ issue._federatedDetails = {
+ resolvedTime: 12345,
+ issueState: {
+ title: 'A fedref issue title',
+ },
+ };
+
+ assert.deepEqual(issue.toIssueRef(), {
+ extIdentifier: 'b/1234',
+ summary: 'A fedref issue title',
+ statusRef: {meansOpen: false},
+ });
+ });
+ });
+ });
+});
diff --git a/static_src/shared/ga-helpers.js b/static_src/shared/ga-helpers.js
new file mode 100644
index 0000000..52d1176
--- /dev/null
+++ b/static_src/shared/ga-helpers.js
@@ -0,0 +1,31 @@
+// 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.
+
+const TITLE = 'title';
+const LOCATION = 'location';
+const DIMENSION1 = 'dimension1';
+const SET = 'set';
+
+/**
+ * Track page-to-page navigation via google analytics. Global window.ga
+ * is set in server rendered HTML.
+ *
+ * @param {string} page
+ * @param {string} userDisplayName
+ */
+export const trackPageChange = (page = '', userDisplayName = '') => {
+ ga(SET, TITLE, `Issue ${page}`);
+ if (page.startsWith('user')) {
+ ga(SET, TITLE, 'A user page');
+ ga(SET, LOCATION, 'A user page URL');
+ }
+
+ if (userDisplayName) {
+ ga(SET, DIMENSION1, 'Logged in');
+ } else {
+ ga(SET, DIMENSION1, 'Not logged in');
+ }
+
+ ga('send', 'pageview');
+};
diff --git a/static_src/shared/ga-helpers.test.js b/static_src/shared/ga-helpers.test.js
new file mode 100644
index 0000000..1876a27
--- /dev/null
+++ b/static_src/shared/ga-helpers.test.js
@@ -0,0 +1,45 @@
+// 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 {trackPageChange} from './ga-helpers.js';
+
+describe('trackPageChange', () => {
+ beforeEach(() => {
+ global.ga = sinon.spy();
+ });
+
+ afterEach(() => {
+ global.ga.resetHistory();
+ });
+
+ it('sets page title', () => {
+ trackPageChange('list');
+ sinon.assert.calledWith(global.ga, 'set', 'title', 'Issue list');
+ });
+
+ it('sets user page title', () => {
+ trackPageChange('user-anything');
+ sinon.assert.calledWith(global.ga, 'set', 'title', 'A user page');
+ });
+
+ it('sets user location', () => {
+ trackPageChange('user-anything');
+ sinon.assert.calledWith(global.ga, 'set', 'location', 'A user page URL');
+ });
+
+ it('defaults dimension1', () => {
+ trackPageChange('list');
+ sinon.assert.calledWith(global.ga, 'set', 'dimension1', 'Not logged in');
+ });
+
+ it('sets dimension1 based on userDisplayName', () => {
+ trackPageChange('list', 'somebody');
+ sinon.assert.calledWith(global.ga, 'set', 'dimension1', 'Logged in');
+ });
+
+ it('sends pageview', () => {
+ trackPageChange('list');
+ sinon.assert.calledWith(global.ga, 'send', 'pageview');
+ });
+});
diff --git a/static_src/shared/gapi-loader.js b/static_src/shared/gapi-loader.js
new file mode 100644
index 0000000..5249d68
--- /dev/null
+++ b/static_src/shared/gapi-loader.js
@@ -0,0 +1,66 @@
+// 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.
+
+/**
+ * gapi-loader.js provides a method for loading gapi.js asynchronously.
+ *
+ * gapi.js docs:
+ * https://developers.google.com/identity/sign-in/web/reference
+ * (we load gapi.js via the chops-signin module)
+ */
+
+import * as signin from '@chopsui/chops-signin';
+
+const BUGANIZER_SCOPE = 'https://www.googleapis.com/auth/buganizer';
+// Only allow google.com profiles through currently.
+const RESTRICT_TO_DOMAIN = '@google.com';
+
+// loadGapi loads window.gapi and returns a logged in user object or null.
+// Allows overriding signinImpl for testing.
+export default function loadGapi() {
+ const signinImpl = getSigninInstance();
+ // Validate client_id exists.
+ if (!CS_env.gapi_client_id) {
+ throw new Error('Cannot find gapi.js client id');
+ }
+
+ // Prevent gapi.js from being loaded multiple times.
+ if (window.__gapiLoadPromise) {
+ return window.__gapiLoadPromise;
+ }
+
+ window.__gapiLoadPromise = new Promise(async (resolve) => {
+ signinImpl.init(CS_env.gapi_client_id, ['client'], [BUGANIZER_SCOPE]);
+ resolve(await fetchGapiEmail(signinImpl));
+ });
+
+ return window.__gapiLoadPromise;
+}
+
+// For fetching current email. May have changed since load.
+export function fetchGapiEmail() {
+ const signinImpl = getSigninInstance();
+ return new Promise((resolve) => {
+ signinImpl.getUserProfileAsync().then((profile) => {
+ resolve(
+ (
+ profile &&
+ profile.getEmail instanceof Function &&
+ profile.getEmail().endsWith(RESTRICT_TO_DOMAIN) &&
+ profile.getEmail()
+ ) || null,
+ );
+ });
+ });
+}
+
+// Provide a singleton chops-signin instance to make testing easier.
+let signinInstance;
+export function getSigninInstance(signinImpl=signin, overwrite=false) {
+ // Assign on first run.
+ if (overwrite || !signinInstance) {
+ signinInstance = signinImpl;
+ }
+ return signinInstance;
+}
diff --git a/static_src/shared/gapi-loader.test.js b/static_src/shared/gapi-loader.test.js
new file mode 100644
index 0000000..fb98fed
--- /dev/null
+++ b/static_src/shared/gapi-loader.test.js
@@ -0,0 +1,73 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+import loadGapi, {fetchGapiEmail, getSigninInstance} from './gapi-loader.js';
+
+describe('gapi-loader', () => {
+ let signinImpl;
+ beforeEach(() => {
+ window.CS_env = {gapi_client_id: 'rutabaga'};
+ signinImpl = {
+ init: sinon.stub(),
+ getUserProfileAsync: () => (
+ Promise.resolve({
+ getEmail: sinon.stub().returns('rutabaga@google.com'),
+ })
+ ),
+ };
+ // Preload signinImpl with a fake for testing.
+ getSigninInstance(signinImpl, true);
+ delete window.__gapiLoadPromise;
+ });
+
+ afterEach(() => {
+ delete window.CS_env;
+ });
+
+ describe('loadGapi()', () => {
+ it('errors out if no client_id', () => {
+ window.CS_env.gapi_client_id = undefined;
+ assert.throws(() => loadGapi());
+ });
+
+ it('returns the same promise when called multiple times', () => {
+ const callOne = loadGapi();
+ const callTwo = loadGapi();
+
+ assert.strictEqual(callOne, callTwo);
+ assert.strictEqual(callOne, window.__gapiLoadPromise);
+ assert.strictEqual(callTwo, window.__gapiLoadPromise);
+ assert.instanceOf(callOne, Promise);
+ });
+
+ it('calls init and returns the current email if any', async () => {
+ const response = await loadGapi();
+ sinon.assert.calledWith(signinImpl.init, window.CS_env.gapi_client_id,
+ ['client'], ['https://www.googleapis.com/auth/buganizer']);
+ assert.equal(response, 'rutabaga@google.com');
+ });
+ });
+
+ describe('fetchGapiEmail()', () => {
+ it('returns a profile for allowed domains', async () => {
+ getSigninInstance({
+ getUserProfileAsync: () => Promise.resolve({
+ getEmail: sinon.stub().returns('rutabaga@google.com'),
+ }),
+ }, true);
+ assert.deepEqual(await fetchGapiEmail(), 'rutabaga@google.com');
+ });
+
+ it('returns nothing for non-allowed domains', async () => {
+ getSigninInstance({
+ getUserProfileAsync: () => Promise.resolve({
+ getEmail: sinon.stub().returns('rutabaga@rutabaga.com'),
+ }),
+ }, true);
+ assert.deepEqual(await fetchGapiEmail(), null);
+ });
+ });
+});
diff --git a/static_src/shared/helpers.js b/static_src/shared/helpers.js
new file mode 100644
index 0000000..362b4ec
--- /dev/null
+++ b/static_src/shared/helpers.js
@@ -0,0 +1,213 @@
+// 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 qs from 'qs';
+
+
+/**
+ * With lists a and b, get the elements that are in a but not in b.
+ * result = a - b
+ * @param {Array} listA
+ * @param {Array} listB
+ * @param {function?} equals
+ * @return {Array}
+ */
+export function arrayDifference(listA, listB, equals = undefined) {
+ if (!equals) {
+ equals = (a, b) => (a === b);
+ }
+ listA = listA || [];
+ listB = listB || [];
+ return listA.filter((a) => {
+ return !listB.find((b) => (equals(a, b)));
+ });
+}
+
+/**
+ * Check to see if a Set contains any of a list of values.
+ *
+ * @param {Set} set the Set to check for values in.
+ * @param {Iterable} values checks if any of these values are included.
+ * @return {boolean} whether the Set has any of the values or not.
+ */
+export function setHasAny(set, values) {
+ for (const value of values) {
+ if (set.has(value)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Capitalize the first letter of a given string.
+ * @param {string} str
+ * @return {string}
+ */
+export function capitalizeFirst(str) {
+ return `${str.charAt(0).toUpperCase()}${str.substring(1)}`;
+}
+
+/**
+ * Check if a string has a prefix, ignoring case.
+ * @param {string} str
+ * @param {string} prefix
+ * @return {boolean}
+ */
+export function hasPrefix(str, prefix) {
+ return str.toLowerCase().startsWith(prefix.toLowerCase());
+}
+
+/**
+ * Returns a string without specified prefix
+ * @param {string} str
+ * @param {string} prefix
+ * @return {string}
+ */
+export function removePrefix(str, prefix) {
+ return str.substr(prefix.length);
+}
+
+// TODO(zhangtiff): Make this more grammatically correct for
+// more than two items.
+export function arrayToEnglish(arr) {
+ if (!arr) return '';
+ return arr.join(' and ');
+}
+
+export function pluralize(count, singular, pluralArg) {
+ const plural = pluralArg || singular + 's';
+ return count === 1 ? singular : plural;
+}
+
+export function objectToMap(obj = {}) {
+ const map = new Map();
+ Object.keys(obj).forEach((key) => {
+ map.set(key, obj[key]);
+ });
+ return map;
+}
+
+/**
+ * Given an Object, extract a list of values from it, based on some
+ * specified keys.
+ *
+ * @param {Object} obj the Object to read values from.
+ * @param {Array} keys the Object keys to fetch values for.
+ * @return {Array} Object values matching the given keys.
+ */
+export function objectValuesForKeys(obj, keys = []) {
+ return keys.map((key) => ((key in obj) ? obj[key] : undefined));
+}
+
+/**
+ * Checks to see if object has no keys
+ * @param {Object} obj
+ * @return {boolean}
+ */
+export function isEmptyObject(obj) {
+ return Object.keys(obj).length === 0;
+}
+
+/**
+ * Checks if two strings are equal, case-insensitive
+ * @param {string} a
+ * @param {string} b
+ * @return {boolean}
+ */
+export function equalsIgnoreCase(a, b) {
+ if (a == b) return true;
+ if (!a || !b) return false;
+ return a.toLowerCase() === b.toLowerCase();
+}
+
+export function immutableSplice(arr, index, count, ...addedItems) {
+ if (!arr) return '';
+
+ return [...arr.slice(0, index), ...addedItems, ...arr.slice(index + count)];
+}
+
+/**
+ * Computes a new URL for a page based on an exiting path and set of query
+ * params.
+ *
+ * @param {string} baseUrl the base URL without query params.
+ * @param {Object} oldParams original query params before changes.
+ * @param {Object} newParams query parameters to override existing ones.
+ * @param {Array} deletedParams list of keys to be cleared.
+ * @return {string} the new URL with the updated params.
+ */
+export function urlWithNewParams(baseUrl = '',
+ oldParams = {}, newParams = {}, deletedParams = []) {
+ const params = {...oldParams, ...newParams};
+ deletedParams.forEach((name) => {
+ delete params[name];
+ });
+
+ const queryString = qs.stringify(params);
+
+ return `${baseUrl}${queryString ? '?' : ''}${queryString}`;
+}
+
+/**
+ * Finds out whether a user is a member of a given project based on
+ * project membership info.
+ *
+ * @param {Object} userRef reference to a given user. Expects an id.
+ * @param {string} projectName name of the project being searched for.
+ * @param {Map} usersProjects all known user project memberships where
+ * keys are userId and values are Objects with expected values
+ * for {ownerOf, memberOf, contributorTo}.
+ * @return {boolean} whether the user is a member of the project or not.
+ */
+export function userIsMember(userRef, projectName, usersProjects = new Map()) {
+ // TODO(crbug.com/monorail/5968): Find a better place to place this function
+ if (!userRef || !userRef.userId || !projectName) return false;
+ const userProjects = usersProjects.get(userRef.userId);
+ if (!userProjects) return false;
+ const {ownerOf = [], memberOf = [], contributorTo = []} = userProjects;
+ return ownerOf.includes(projectName) ||
+ memberOf.includes(projectName) ||
+ contributorTo.includes(projectName);
+}
+
+/**
+ * Creates a function that checks two objects are not equal
+ * based on a set of property keys
+ *
+ * @param {Set<string>} props
+ * @return {function(): boolean}
+ */
+export function createObjectComparisonFunc(props) {
+ /**
+ * Computes whether set of properties have changed
+ * @param {Object<string, string>} newVal
+ * @param {Object<string, string>} oldVal
+ * @return {boolean}
+ */
+ return function(newVal, oldVal) {
+ if (oldVal === undefined && newVal === undefined) {
+ return false;
+ } else if (oldVal === undefined || newVal === undefined) {
+ return true;
+ } else if (oldVal === null && newVal === null) {
+ return false;
+ } else if (oldVal === null || newVal === null) {
+ return true;
+ }
+
+ return Array.from(props)
+ .some((propName) => newVal[propName] !== oldVal[propName]);
+ };
+}
+
+/**
+ * Calculates whether to wait for memberDefaultQuery to exist prior
+ * to fetching IssueList. Logged in users may use a default query.
+ * @param {Object} queryParams
+ * @return {boolean}
+ */
+export const shouldWaitForDefaultQuery = (queryParams) => {
+ return !queryParams.hasOwnProperty('q');
+};
diff --git a/static_src/shared/helpers.test.js b/static_src/shared/helpers.test.js
new file mode 100644
index 0000000..7c40ed5
--- /dev/null
+++ b/static_src/shared/helpers.test.js
@@ -0,0 +1,361 @@
+// 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 {assert} from 'chai';
+import {arrayDifference, setHasAny, capitalizeFirst, hasPrefix, objectToMap,
+ objectValuesForKeys, equalsIgnoreCase, immutableSplice, userIsMember,
+ urlWithNewParams, createObjectComparisonFunc} from './helpers.js';
+
+
+describe('arrayDifference', () => {
+ it('empty array stays empty', () => {
+ assert.deepEqual(arrayDifference([], []), []);
+ assert.deepEqual(arrayDifference([], undefined), []);
+ assert.deepEqual(arrayDifference([], ['a']), []);
+ });
+
+ it('subtracting empty array does nothing', () => {
+ assert.deepEqual(arrayDifference(['a'], []), ['a']);
+ assert.deepEqual(arrayDifference([1, 2, 3], []), [1, 2, 3]);
+ assert.deepEqual(arrayDifference([1, 2, 'test'], []), [1, 2, 'test']);
+ assert.deepEqual(arrayDifference([1, 2, 'test'], undefined),
+ [1, 2, 'test']);
+ });
+
+ it('subtracts elements from array', () => {
+ assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['b', 'c']), ['a']);
+ assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['a']), ['b', 'c']);
+ assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['b']), ['a', 'c']);
+ assert.deepEqual(arrayDifference([1, 2, 3], [2]), [1, 3]);
+ });
+
+ it('does not subtract missing elements from array', () => {
+ assert.deepEqual(arrayDifference(['a', 'b', 'c'], ['d']), ['a', 'b', 'c']);
+ assert.deepEqual(arrayDifference([1, 2, 3], [5]), [1, 2, 3]);
+ assert.deepEqual(arrayDifference([1, 2, 3], [-1, 2]), [1, 3]);
+ });
+
+ it('custom equals function', () => {
+ assert.deepEqual(arrayDifference(['a', 'b'], ['A']), ['a', 'b']);
+ assert.deepEqual(arrayDifference(['a', 'b'], ['A'], equalsIgnoreCase),
+ ['b']);
+ });
+});
+
+describe('setHasAny', () => {
+ it('empty set never has any values', () => {
+ assert.isFalse(setHasAny(new Set(), []));
+ assert.isFalse(setHasAny(new Set(), ['test']));
+ assert.isFalse(setHasAny(new Set(), ['nope', 'yup', 'no']));
+ });
+
+ it('false when no values found', () => {
+ assert.isFalse(setHasAny(new Set(['hello', 'world']), []));
+ assert.isFalse(setHasAny(new Set(['hello', 'world']), ['wor']));
+ assert.isFalse(setHasAny(new Set(['test']), ['other', 'values']));
+ assert.isFalse(setHasAny(new Set([1, 2, 3]), [4, 5, 6]));
+ });
+
+ it('true when values found', () => {
+ assert.isTrue(setHasAny(new Set(['hello', 'world']), ['world']));
+ assert.isTrue(setHasAny(new Set([1, 2, 3]), [3, 4, 5]));
+ assert.isTrue(setHasAny(new Set([1, 2, 3]), [1, 3]));
+ });
+});
+
+describe('capitalizeFirst', () => {
+ it('empty string', () => {
+ assert.equal(capitalizeFirst(''), '');
+ });
+
+ it('ignores non-letters', () => {
+ assert.equal(capitalizeFirst('8fcsdf'), '8fcsdf');
+ });
+
+ it('preserves existing caps', () => {
+ assert.equal(capitalizeFirst('HELLO world'), 'HELLO world');
+ });
+
+ it('capitalizes lowercase', () => {
+ assert.equal(capitalizeFirst('hello world'), 'Hello world');
+ });
+});
+
+describe('hasPrefix', () => {
+ it('only true when has prefix', () => {
+ assert.isFalse(hasPrefix('teststring', 'test-'));
+ assert.isFalse(hasPrefix('stringtest-', 'test-'));
+ assert.isFalse(hasPrefix('^test-$', 'test-'));
+ assert.isTrue(hasPrefix('test-', 'test-'));
+ assert.isTrue(hasPrefix('test-fsdfsdf', 'test-'));
+ });
+
+ it('ignores case when checking prefix', () => {
+ assert.isTrue(hasPrefix('TEST-string', 'test-'));
+ assert.isTrue(hasPrefix('test-string', 'test-'));
+ assert.isTrue(hasPrefix('tEsT-string', 'test-'));
+ });
+});
+
+describe('objectToMap', () => {
+ it('converts Object to Map with the same keys', () => {
+ assert.deepEqual(objectToMap({}), new Map());
+ assert.deepEqual(objectToMap({test: 'value'}),
+ new Map([['test', 'value']]));
+ assert.deepEqual(objectToMap({['weird:key']: 'value',
+ ['what is this key']: 'v2'}), new Map([['weird:key', 'value'],
+ ['what is this key', 'v2']]));
+ });
+});
+
+describe('objectValuesForKeys', () => {
+ it('no values when no matching keys', () => {
+ assert.deepEqual(objectValuesForKeys({}, []), []);
+ assert.deepEqual(objectValuesForKeys({}, []), []);
+ assert.deepEqual(objectValuesForKeys({key: 'value'}, []), []);
+ });
+
+ it('returns values when keys match', () => {
+ assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, ['a', 'b']),
+ [1, 2]);
+ assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, ['b', 'c']),
+ [2, 3]);
+ assert.deepEqual(objectValuesForKeys({['weird:key']: {nested: 'obj'}},
+ ['weird:key']), [{nested: 'obj'}]);
+ });
+
+ it('sets non-matching keys to undefined', () => {
+ assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, ['c', 'd', 'e']),
+ [3, undefined, undefined]);
+ assert.deepEqual(objectValuesForKeys({a: 1, b: 2, c: 3}, [1, 2, 3]),
+ [undefined, undefined, undefined]);
+ });
+});
+
+describe('equalsIgnoreCase', () => {
+ it('matches same case strings', () => {
+ assert.isTrue(equalsIgnoreCase('', ''));
+ assert.isTrue(equalsIgnoreCase('HelloWorld', 'HelloWorld'));
+ assert.isTrue(equalsIgnoreCase('hmm', 'hmm'));
+ assert.isTrue(equalsIgnoreCase('TEST', 'TEST'));
+ });
+
+ it('matches different case strings', () => {
+ assert.isTrue(equalsIgnoreCase('a', 'A'));
+ assert.isTrue(equalsIgnoreCase('HelloWorld', 'helloworld'));
+ assert.isTrue(equalsIgnoreCase('hmm', 'HMM'));
+ assert.isTrue(equalsIgnoreCase('TEST', 'teSt'));
+ });
+
+ it('does not match different strings', () => {
+ assert.isFalse(equalsIgnoreCase('hello', 'hello '));
+ assert.isFalse(equalsIgnoreCase('superstring', 'string'));
+ assert.isFalse(equalsIgnoreCase('aaa', 'aa'));
+ });
+});
+
+describe('immutableSplice', () => {
+ it('does not edit original array', () => {
+ const arr = ['apples', 'pears', 'oranges'];
+
+ assert.deepEqual(immutableSplice(arr, 1, 1),
+ ['apples', 'oranges']);
+
+ assert.deepEqual(arr, ['apples', 'pears', 'oranges']);
+ });
+
+ it('removes multiple items', () => {
+ const arr = [1, 2, 3, 4, 5, 6];
+
+ assert.deepEqual(immutableSplice(arr, 1, 0), [1, 2, 3, 4, 5, 6]);
+ assert.deepEqual(immutableSplice(arr, 1, 4), [1, 6]);
+ assert.deepEqual(immutableSplice(arr, 0, 6), []);
+ });
+
+ it('adds items', () => {
+ const arr = [1, 2, 3];
+
+ assert.deepEqual(immutableSplice(arr, 1, 1, 4, 5, 6), [1, 4, 5, 6, 3]);
+ assert.deepEqual(immutableSplice(arr, 2, 1, 4, 5, 6), [1, 2, 4, 5, 6]);
+ assert.deepEqual(immutableSplice(arr, 0, 0, -3, -2, -1, 0),
+ [-3, -2, -1, 0, 1, 2, 3]);
+ });
+});
+
+describe('urlWithNewParams', () => {
+ it('empty', () => {
+ assert.equal(urlWithNewParams(), '');
+ assert.equal(urlWithNewParams(''), '');
+ assert.equal(urlWithNewParams('', {}), '');
+ assert.equal(urlWithNewParams('', {}, {}), '');
+ assert.equal(urlWithNewParams('', {}, {}, []), '');
+ });
+
+ it('preserves existing URL without changes', () => {
+ assert.equal(urlWithNewParams('/p/chromium/issues/list'),
+ '/p/chromium/issues/list');
+ assert.equal(urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me'}),
+ '/p/chromium/issues/list?q=owner%3Ame');
+ assert.equal(
+ urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me', can: '1'}),
+ '/p/chromium/issues/list?q=owner%3Ame&can=1');
+ });
+
+ it('adds new params', () => {
+ assert.equal(
+ urlWithNewParams('/p/chromium/issues/list', {}, {q: 'owner:me'}),
+ '/p/chromium/issues/list?q=owner%3Ame');
+ assert.equal(
+ urlWithNewParams('/p/chromium/issues/list',
+ {can: '1'}, {q: 'owner:me'}),
+ '/p/chromium/issues/list?can=1&q=owner%3Ame');
+
+ // Override existing params.
+ assert.equal(
+ urlWithNewParams('/p/chromium/issues/list',
+ {can: '1', q: 'owner:me'}, {q: 'test'}),
+ '/p/chromium/issues/list?can=1&q=test');
+ });
+
+ it('clears existing params', () => {
+ assert.equal(
+ urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me'}, {}, ['q']),
+ '/p/chromium/issues/list');
+ assert.equal(
+ urlWithNewParams('/p/chromium/issues/list',
+ {can: '1'}, {q: 'owner:me'}, ['can']),
+ '/p/chromium/issues/list?q=owner%3Ame');
+ assert.equal(
+ urlWithNewParams('/p/chromium/issues/list', {q: 'owner:me'}, {can: '2'},
+ ['q', 'can', 'fakeparam']),
+ '/p/chromium/issues/list');
+ });
+});
+
+describe('userIsMember', () => {
+ it('false when no user', () => {
+ assert.isFalse(userIsMember(undefined));
+ assert.isFalse(userIsMember({}));
+ assert.isFalse(userIsMember({}, 'chromium',
+ new Map([['123', {ownerOf: ['chromium']}]])));
+ });
+
+ it('true when user is member of project', () => {
+ assert.isTrue(userIsMember({userId: '123'}, 'chromium',
+ new Map([['123', {contributorTo: ['chromium']}]])));
+
+ assert.isTrue(userIsMember({userId: '123'}, 'chromium',
+ new Map([['123', {ownerOf: ['chromium']}]])));
+
+ assert.isTrue(userIsMember({userId: '123'}, 'chromium',
+ new Map([['123', {memberOf: ['chromium']}]])));
+ });
+
+ it('true when user is member of multiple projects', () => {
+ assert.isTrue(userIsMember({userId: '123'}, 'chromium', new Map([
+ ['123', {contributorTo: ['test', 'chromium', 'fakeproject']}],
+ ])));
+
+ assert.isTrue(userIsMember({userId: '123'}, 'chromium', new Map([
+ ['123', {ownerOf: ['test', 'chromium', 'fakeproject']}],
+ ])));
+
+ assert.isTrue(userIsMember({userId: '123'}, 'chromium', new Map([
+ ['123', {memberOf: ['test', 'chromium', 'fakeproject']}],
+ ])));
+ });
+
+ it('false when user is member of different project', () => {
+ assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+ ['123', {contributorTo: ['test', 'fakeproject']}],
+ ])));
+
+ assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+ ['123', {ownerOf: ['test', 'fakeproject']}],
+ ])));
+
+ assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+ ['123', {memberOf: ['test', 'fakeproject']}],
+ ])));
+ });
+
+ it('false when no project data for user', () => {
+ assert.isFalse(userIsMember({userId: '123'}, 'chromium'));
+ assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map()));
+ assert.isFalse(userIsMember({userId: '123'}, 'chromium', new Map([
+ ['543', {ownerOf: ['chromium']}],
+ ])));
+ });
+});
+
+describe('createObjectComparisonFunc', () => {
+ it('returns a function', () => {
+ const result = createObjectComparisonFunc(new Set());
+ assert.instanceOf(result, Function);
+ });
+
+ describe('returned function', () => {
+ it('returns false if both inputs are undefined', () => {
+ const comparableProps = new Set(['a', 'b', 'c']);
+ const func = createObjectComparisonFunc(comparableProps);
+ const result = func(undefined, undefined);
+
+ assert.isFalse(result);
+ });
+
+ it('returns true if only one inputs is undefined', () => {
+ const comparableProps = new Set(['a', 'b', 'c']);
+ const func = createObjectComparisonFunc(comparableProps);
+ const result = func({}, undefined);
+
+ assert.isTrue(result);
+ });
+
+ it('returns false if both inputs are null', () => {
+ const comparableProps = new Set(['a', 'b', 'c']);
+ const func = createObjectComparisonFunc(comparableProps);
+ const result = func(null, null);
+
+ assert.isFalse(result);
+ });
+
+ it('returns true if only one inputs is null', () => {
+ const comparableProps = new Set(['a', 'b', 'c']);
+ const func = createObjectComparisonFunc(comparableProps);
+ const result = func({}, null);
+
+ assert.isTrue(result);
+ });
+
+ it('returns true if any comparable property is different', () => {
+ const comparableProps = new Set(['a', 'b', 'c']);
+ const func = createObjectComparisonFunc(comparableProps);
+ const a = {a: 1, b: 2, c: 3};
+ const b = {a: 1, b: 2, c: '3'};
+ const result = func(a, b);
+
+ assert.isTrue(result);
+ });
+
+ it('returns false if all comparable properties are the same', () => {
+ const comparableProps = new Set(['a', 'b', 'c']);
+ const func = createObjectComparisonFunc(comparableProps);
+ const a = {a: 1, b: 2, c: 3};
+ const b = {a: 1, b: 2, c: 3};
+ const result = func(a, b);
+
+ assert.isFalse(result);
+ });
+
+ it('ignores non-comparable properties', () => {
+ const comparableProps = new Set(['a', 'b', 'c']);
+ const func = createObjectComparisonFunc(comparableProps);
+ const a = {a: 1, b: 2, c: 3, d: 4};
+ const b = {a: 1, b: 2, c: 3, d: 'not four', e: 'exists'};
+ const result = func(a, b);
+
+ assert.isFalse(result);
+ });
+ });
+});
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,
+ ];
+}
diff --git a/static_src/shared/issue-fields.test.js b/static_src/shared/issue-fields.test.js
new file mode 100644
index 0000000..c37faa9
--- /dev/null
+++ b/static_src/shared/issue-fields.test.js
@@ -0,0 +1,491 @@
+// 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 {assert} from 'chai';
+import {parseColSpec, fieldsForIssue,
+ stringValuesForIssueField} from './issue-fields.js';
+import sinon from 'sinon';
+
+let issue;
+let clock;
+
+describe('parseColSpec', () => {
+ it('empty spec produces empty list', () => {
+ assert.deepEqual(parseColSpec(),
+ []);
+ assert.deepEqual(parseColSpec(''),
+ []);
+ assert.deepEqual(parseColSpec(' + + + '),
+ []);
+ assert.deepEqual(parseColSpec(' '),
+ []);
+ assert.deepEqual(parseColSpec('+++++'),
+ []);
+ });
+
+ it('parses spec correctly', () => {
+ assert.deepEqual(parseColSpec('ID+Summary+AllLabels+Priority'),
+ ['ID', 'Summary', 'AllLabels', 'Priority']);
+ });
+
+ it('parses spaces correctly', () => {
+ assert.deepEqual(parseColSpec('ID Summary AllLabels Priority'),
+ ['ID', 'Summary', 'AllLabels', 'Priority']);
+ assert.deepEqual(parseColSpec('ID + Summary + AllLabels + Priority'),
+ ['ID', 'Summary', 'AllLabels', 'Priority']);
+ assert.deepEqual(parseColSpec('ID Summary AllLabels Priority'),
+ ['ID', 'Summary', 'AllLabels', 'Priority']);
+ });
+
+ it('spec parsing preserves dashed parameters', () => {
+ assert.deepEqual(parseColSpec('ID+Summary+Test-Label+Another-Label'),
+ ['ID', 'Summary', 'Test-Label', 'Another-Label']);
+ });
+});
+
+describe('fieldsForIssue', () => {
+ const issue = {
+ projectName: 'proj',
+ localId: 1,
+ };
+
+ const issueWithLabels = {
+ projectName: 'proj',
+ localId: 1,
+ labelRefs: [
+ {label: 'test'},
+ {label: 'hello-world'},
+ {label: 'multi-label-field'},
+ ],
+ };
+
+ const issueWithFieldValues = {
+ projectName: 'proj',
+ localId: 1,
+ fieldValues: [
+ {fieldRef: {fieldName: 'number', type: 'INT_TYPE'}},
+ {fieldRef: {fieldName: 'string', type: 'STR_TYPE'}},
+ ],
+ };
+
+ const issueWithPhases = {
+ projectName: 'proj',
+ localId: 1,
+ fieldValues: [
+ {fieldRef: {fieldName: 'phase-number', type: 'INT_TYPE'},
+ phaseRef: {phaseName: 'phase1'}},
+ {fieldRef: {fieldName: 'phase-string', type: 'STR_TYPE'},
+ phaseRef: {phaseName: 'phase2'}},
+ ],
+ };
+
+ const issueWithApprovals = {
+ projectName: 'proj',
+ localId: 1,
+ approvalValues: [
+ {fieldRef: {fieldName: 'approval', type: 'APPROVAL_TYPE'}},
+ ],
+ };
+
+ it('empty issue issue produces no field names', () => {
+ assert.deepEqual(fieldsForIssue(issue), []);
+ assert.deepEqual(fieldsForIssue(issue, true), []);
+ });
+
+ it('includes label prefixes', () => {
+ assert.deepEqual(fieldsForIssue(issueWithLabels), [
+ 'hello',
+ 'multi',
+ 'multi-label',
+ ]);
+ });
+
+ it('includes field values', () => {
+ assert.deepEqual(fieldsForIssue(issueWithFieldValues), [
+ 'number',
+ 'string',
+ ]);
+ });
+
+ it('excludes high cardinality field values', () => {
+ assert.deepEqual(fieldsForIssue(issueWithFieldValues, true), [
+ 'number',
+ ]);
+ });
+
+ it('includes phase fields', () => {
+ assert.deepEqual(fieldsForIssue(issueWithPhases), [
+ 'phase1.phase-number',
+ 'phase2.phase-string',
+ ]);
+ });
+
+ it('excludes high cardinality phase fields', () => {
+ assert.deepEqual(fieldsForIssue(issueWithPhases, true), [
+ 'phase1.phase-number',
+ ]);
+ });
+
+ it('includes approval values', () => {
+ assert.deepEqual(fieldsForIssue(issueWithApprovals), [
+ 'approval',
+ 'approval-Approver',
+ ]);
+ });
+});
+
+describe('stringValuesForIssueField', () => {
+ describe('built-in fields', () => {
+ beforeEach(() => {
+ // Set clock to some specified date for relative time.
+ const initialTime = 365 * 24 * 60 * 60;
+
+ clock = sinon.useFakeTimers({
+ now: new Date(initialTime * 1000),
+ shouldAdvanceTime: false,
+ });
+
+ issue = {
+ localId: 33,
+ projectName: 'chromium',
+ summary: 'Test summary',
+ attachmentCount: 22,
+ starCount: 2,
+ componentRefs: [{path: 'Infra'}, {path: 'Monorail>UI'}],
+ blockedOnIssueRefs: [{localId: 30, projectName: 'chromium'}],
+ blockingIssueRefs: [{localId: 60, projectName: 'chromium'}],
+ labelRefs: [{label: 'Restrict-View-Google'}, {label: 'Type-Defect'}],
+ reporterRef: {displayName: 'test@example.com'},
+ ccRefs: [{displayName: 'test@example.com'}],
+ ownerRef: {displayName: 'owner@example.com'},
+ closedTimestamp: initialTime - 120, // 2 minutes ago
+ modifiedTimestamp: initialTime - 60, // a minute ago
+ openedTimestamp: initialTime - 24 * 60 * 60, // a day ago
+ componentModifiedTimestamp: initialTime - 60, // a minute ago
+ statusModifiedTimestamp: initialTime - 60, // a minute ago
+ ownerModifiedTimestamp: initialTime - 60, // a minute ago
+ statusRef: {status: 'Duplicate'},
+ mergedIntoIssueRef: {localId: 31, projectName: 'chromium'},
+ };
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ it('computes strings for ID', () => {
+ const fieldName = 'ID';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['chromium:33']);
+ });
+
+ it('computes strings for Project', () => {
+ const fieldName = 'Project';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['chromium']);
+ });
+
+ it('computes strings for Attachments', () => {
+ const fieldName = 'Attachments';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['22']);
+ });
+
+ it('computes strings for AllLabels', () => {
+ const fieldName = 'AllLabels';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['Restrict-View-Google', 'Type-Defect']);
+ });
+
+ it('computes strings for Blocked when issue is blocked', () => {
+ const fieldName = 'Blocked';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['Yes']);
+ });
+
+ it('computes strings for Blocked when issue is not blocked', () => {
+ const fieldName = 'Blocked';
+ issue.blockedOnIssueRefs = [];
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['No']);
+ });
+
+ it('computes strings for BlockedOn', () => {
+ const fieldName = 'BlockedOn';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['chromium:30']);
+ });
+
+ it('computes strings for Blocking', () => {
+ const fieldName = 'Blocking';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['chromium:60']);
+ });
+
+ it('computes strings for CC', () => {
+ const fieldName = 'CC';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['test@example.com']);
+ });
+
+ it('computes strings for Closed', () => {
+ const fieldName = 'Closed';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['2 minutes ago']);
+ });
+
+ it('computes strings for Component', () => {
+ const fieldName = 'Component';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['Infra', 'Monorail>UI']);
+ });
+
+ it('computes strings for ComponentModified', () => {
+ const fieldName = 'ComponentModified';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['a minute ago']);
+ });
+
+ it('computes strings for MergedInto', () => {
+ const fieldName = 'MergedInto';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['chromium:31']);
+ });
+
+ it('computes strings for Modified', () => {
+ const fieldName = 'Modified';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['a minute ago']);
+ });
+
+ it('computes strings for Reporter', () => {
+ const fieldName = 'Reporter';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['test@example.com']);
+ });
+
+ it('computes strings for Stars', () => {
+ const fieldName = 'Stars';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['2']);
+ });
+
+ it('computes strings for Status', () => {
+ const fieldName = 'Status';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['Duplicate']);
+ });
+
+ it('computes strings for StatusModified', () => {
+ const fieldName = 'StatusModified';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['a minute ago']);
+ });
+
+ it('computes strings for Summary', () => {
+ const fieldName = 'Summary';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['Test summary']);
+ });
+
+ it('computes strings for Type', () => {
+ const fieldName = 'Type';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['Defect']);
+ });
+
+ it('computes strings for Owner', () => {
+ const fieldName = 'Owner';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['owner@example.com']);
+ });
+
+ it('computes strings for OwnerModified', () => {
+ const fieldName = 'OwnerModified';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['a minute ago']);
+ });
+
+ it('computes strings for Opened', () => {
+ const fieldName = 'Opened';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['a day ago']);
+ });
+ });
+
+ describe('custom approval fields', () => {
+ beforeEach(() => {
+ issue = {
+ localId: 33,
+ projectName: 'bird',
+ approvalValues: [
+ {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Goose-Approval'},
+ approverRefs: []},
+ {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Chicken-Approval'},
+ status: 'APPROVED'},
+ {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Dodo-Approval'},
+ status: 'NEED_INFO', approverRefs: [{displayName: 'kiwi@bird.test'},
+ {displayName: 'mini-dino@bird.test'}],
+ },
+ ],
+ };
+ });
+
+ it('handles approval approver columns', () => {
+ const projectName = 'bird';
+ assert.deepEqual(stringValuesForIssueField(
+ issue, 'goose-approval-approver',
+ projectName), []);
+ assert.deepEqual(stringValuesForIssueField(
+ issue, 'chicken-approval-approver',
+ projectName), []);
+ assert.deepEqual(stringValuesForIssueField(
+ issue, 'dodo-approval-approver',
+ projectName), ['kiwi@bird.test', 'mini-dino@bird.test']);
+ });
+
+ it('handles approval value columns', () => {
+ const projectName = 'bird';
+ assert.deepEqual(stringValuesForIssueField(issue, 'goose-approval',
+ projectName), ['NotSet']);
+ assert.deepEqual(stringValuesForIssueField(issue, 'chicken-approval',
+ projectName), ['Approved']);
+ assert.deepEqual(stringValuesForIssueField(issue, 'dodo-approval',
+ projectName), ['NeedInfo']);
+ });
+ });
+
+ describe('custom fields', () => {
+ beforeEach(() => {
+ issue = {
+ localId: 33,
+ projectName: 'chromium',
+ fieldValues: [
+ {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'}, value: 'test'},
+ {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'}, value: 'test2'},
+ {fieldRef: {type: 'ENUM_TYPE', fieldName: 'ENUM'}, value: 'a-value'},
+ {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+ phaseRef: {phaseName: 'Cow-Phase'},
+ value: '55'},
+ {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+ phaseRef: {phaseName: 'Cow-Phase'},
+ value: '54'},
+ {fieldRef: {type: 'INT_TYPE', fieldId: '6', fieldName: 'Cow-Number'},
+ phaseRef: {phaseName: 'MilkCow-Phase'},
+ value: '56'},
+ ],
+ };
+ });
+
+ it('gets values for custom fields', () => {
+ const projectName = 'chromium';
+ assert.deepEqual(stringValuesForIssueField(issue, 'aString',
+ projectName), ['test', 'test2']);
+ assert.deepEqual(stringValuesForIssueField(issue, 'enum',
+ projectName), ['a-value']);
+ assert.deepEqual(stringValuesForIssueField(issue, 'cow-phase.cow-number',
+ projectName), ['55', '54']);
+ assert.deepEqual(stringValuesForIssueField(issue,
+ 'milkcow-phase.cow-number', projectName), ['56']);
+ });
+
+ it('custom fields get precedence over label fields', () => {
+ const projectName = 'chromium';
+ issue.labelRefs = [{label: 'aString-ignore'}];
+ assert.deepEqual(stringValuesForIssueField(issue, 'aString',
+ projectName), ['test', 'test2']);
+ });
+ });
+
+ describe('label prefix fields', () => {
+ beforeEach(() => {
+ issue = {
+ localId: 33,
+ projectName: 'chromium',
+ labelRefs: [
+ {label: 'test-label'},
+ {label: 'test-label-2'},
+ {label: 'ignore-me'},
+ {label: 'Milestone-UI'},
+ {label: 'Milestone-Goodies'},
+ ],
+ };
+ });
+
+ it('gets values for label prefixes', () => {
+ const projectName = 'chromium';
+ assert.deepEqual(stringValuesForIssueField(issue, 'test',
+ projectName), ['label', 'label-2']);
+ assert.deepEqual(stringValuesForIssueField(issue, 'Milestone',
+ projectName), ['UI', 'Goodies']);
+ assert.deepEqual(stringValuesForIssueField(issue, 'ignore',
+ projectName), ['me']);
+ });
+ });
+
+ describe('composite fields', () => {
+ beforeEach(() => {
+ // Set clock to some specified date for relative time.
+ const initialTime = 365 * 24 * 60 * 60;
+
+ clock = sinon.useFakeTimers({
+ now: new Date(initialTime * 1000),
+ shouldAdvanceTime: false,
+ });
+
+ issue = {
+ localId: 33,
+ projectName: 'chromium',
+ summary: 'Test summary',
+ closedTimestamp: initialTime - 120, // 2 minutes ago
+ modifiedTimestamp: initialTime - 60, // a minute ago
+ openedTimestamp: initialTime - 24 * 60 * 60, // a day ago
+ statusModifiedTimestamp: initialTime - 60, // a minute ago
+ statusRef: {status: 'Duplicate'},
+ };
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ it('computes strings for Status/Closed', () => {
+ const fieldName = 'Status/Closed';
+
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['Duplicate', '2 minutes ago']);
+ });
+
+ it('ignores nonexistant fields', () => {
+ const fieldName = 'Owner/Status';
+
+ assert.isFalse(issue.hasOwnProperty('ownerRef'));
+ assert.deepEqual(stringValuesForIssueField(issue, fieldName),
+ ['Duplicate']);
+ });
+ });
+});
diff --git a/static_src/shared/math.js b/static_src/shared/math.js
new file mode 100644
index 0000000..36e2d75
--- /dev/null
+++ b/static_src/shared/math.js
@@ -0,0 +1,26 @@
+// 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.
+
+// Get parameter of generated line using linear regression formula,
+// using last n data points of values.
+export function linearRegression(values, n) {
+ let sumValues = 0;
+ let indices = 0;
+ let sqIndices = 0;
+ let multiply = 0;
+ let temp;
+ for (let i = 0; i < n; i++) {
+ temp = values[values.length-n+i];
+ sumValues += temp;
+ indices += i;
+ sqIndices += i * i;
+ multiply += i * temp;
+ }
+ // Calculate linear regression formula for values.
+ const slope = (n * multiply - sumValues * indices) /
+ (n * sqIndices - indices * indices);
+ const intercept = (sumValues * sqIndices - indices * multiply) /
+ (n * sqIndices - indices * indices);
+ return [slope, intercept];
+}
diff --git a/static_src/shared/math.test.js b/static_src/shared/math.test.js
new file mode 100644
index 0000000..4b4c153
--- /dev/null
+++ b/static_src/shared/math.test.js
@@ -0,0 +1,22 @@
+// 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 {assert} from 'chai';
+import {linearRegression} from './math.js';
+
+describe('linearRegression', () => {
+ it('calculate slope and intercept using formula', () => {
+ const values = [0, 1, 2, 3, 4, 5, 6];
+ const [slope, intercept] = linearRegression(values, 7);
+ assert.equal(slope, 1);
+ assert.equal(intercept, 0);
+ });
+
+ it('calculate slope and intercept using last n data points', () => {
+ const values = [0, 1, 0, 3, 5, 7, 9];
+ const [slope, intercept] = linearRegression(values, 4);
+ assert.equal(slope, 2);
+ assert.equal(intercept, 3);
+ });
+});
diff --git a/static_src/shared/md-helper.js b/static_src/shared/md-helper.js
new file mode 100644
index 0000000..da5ac3c
--- /dev/null
+++ b/static_src/shared/md-helper.js
@@ -0,0 +1,143 @@
+import marked from 'marked';
+import DOMPurify from 'dompurify';
+
+/** @type {Set} Projects that default Markdown rendering to true. */
+export const DEFAULT_MD_PROJECTS = new Set(['monkeyrail', 'monorail', 'fuchsia']);
+
+/** @type {Set} Projects that allow users to opt into Markdown rendering. */
+export const AVAILABLE_MD_PROJECTS = new Set([...DEFAULT_MD_PROJECTS]);
+
+/** @type {Set} Authors whose comments will not be rendered as Markdown. */
+const BLOCKLIST = new Set(['sheriffbot@sheriffbot-1182.iam.gserviceaccount.com',
+ 'sheriff-o-matic@appspot.gserviceaccount.com',
+ 'sheriff-o-matic-staging@appspot.gserviceaccount.com',
+ 'bugdroid1@chromium.org',
+ 'bugdroid@chops-service-accounts.iam.gserviceaccount.com',
+ 'gitwatcher-staging.google.com@appspot.gserviceaccount.com',
+ 'gitwatcher.google.com@appspot.gserviceaccount.com']);
+
+/**
+ * Determines whether content should be rendered as Markdown.
+ * @param {string} options.project Project this content belongs to.
+ * @param {number} options.author User who authored this content.
+ * @param {boolean} options.enabled Per-issue override to force Markdown.
+ * @param {Array<string>} options.availableProjects List of opted in projects.
+ * @return {boolean} Whether this content should be rendered as Markdown.
+ */
+export const shouldRenderMarkdown = ({
+ project, author, enabled = true, availableProjects = AVAILABLE_MD_PROJECTS
+} = {}) => {
+ if (author in BLOCKLIST) {
+ return false;
+ } else if (!enabled) {
+ return false;
+ } else if (availableProjects.has(project)) {
+ return true;
+ }
+ return false;
+};
+
+/** @const {Object} Options for DOMPurify sanitizer */
+const SANITIZE_OPTIONS = Object.freeze({
+ RETURN_TRUSTED_TYPE: true,
+ FORBID_TAGS: ['style'],
+ FORBID_ATTR: ['style', 'autoplay'],
+});
+
+/**
+ * Replaces bold HTML tags in comment with Markdown equivalent.
+ * @param {string} raw Comment string as stored in database.
+ * @return {string} Comment string after b tags are placed by Markdown bolding.
+ */
+const replaceBoldTag = (raw) => {
+ return raw.replace(/<b>|<\/b>/g, '**');
+};
+
+/** @const {Object} Basic HTML character escape mapping */
+const HTML_ESCAPE_MAP = Object.freeze({
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ '\'': ''',
+ '/': '/',
+ '`': '`',
+ '=': '=',
+});
+
+/**
+ * Escapes HTML characters, used to render HTML blocks in Markdown. This
+ * alleviates security flaws but is not the primary security barrier, that is
+ * handled by DOMPurify.
+ * @param {string} text Content that looks to Marked parser to contain HTML.
+ * @return {string} Same text content after escaping HTML characters.
+ */
+const escapeHtml = (text) => {
+ return text.replace(/[&<>"'`=\/]/g, (s) => {
+ return HTML_ESCAPE_MAP[s];
+ });
+};
+
+/**
+* Checks to see if input string is a valid HTTP link.
+ * @param {string} string
+ * @return {boolean} Whether input string is a valid HTTP(s) link.
+ */
+const isValidHttpUrl = (string) => {
+ let url;
+
+ try {
+ url = new URL(string);
+ } catch (_exception) {
+ return false;
+ }
+
+ return url.protocol === 'http:' || url.protocol === 'https:';
+};
+
+/**
+ * Renderer option for Marked.
+ * See https://marked.js.org/using_pro#renderer on how to use renderer.
+ * @type {Object}
+ */
+const renderer = {
+ html(text) {
+ // Do not render HTML, instead escape HTML and render as plaintext.
+ return escapeHtml(text);
+ },
+ link(href, title, text) {
+ // Overrides default link rendering by adding icon and destination on hover.
+ // TODO(crbug.com/monorail/9316): Add shared-styles/MD_STYLES to all
+ // components that consume the markdown renderer.
+ let linkIcon;
+ let tooltipText;
+ if (isValidHttpUrl(href)) {
+ linkIcon = `<span class="material-icons link">link</span>`;
+ tooltipText = `Link destination: ${href}`;
+ } else {
+ linkIcon = `<span class="material-icons link_off">link_off</span>`;
+ tooltipText = `Link may be malformed: ${href}`;
+ }
+ const tooltip = `<span class="tooltip">${tooltipText}</span>`;
+ return `<span class="annotated-link"><a href=${href} ` +
+ `title=${title ? title : ''}>${linkIcon}${text}</a>${tooltip}</span>`;
+ },
+};
+
+marked.use({renderer, headerIds: false});
+
+/**
+ * Renders Markdown content into HTML.
+ * @param {string} raw Content to be intepretted as Markdown.
+ * @return {string} Rendered content in HTML format.
+ */
+export const renderMarkdown = (raw) => {
+ // TODO(crbug.com/monorail/9310): Add commentReferences, projectName,
+ // and revisionUrlFormat to use in conjunction with Marked's lexer for
+ // autolinking.
+ // TODO(crbug.com/monorail/9310): Integrate autolink
+ const preprocessed = replaceBoldTag(raw);
+ const converted = marked(preprocessed);
+ const sanitized = DOMPurify.sanitize(converted, SANITIZE_OPTIONS);
+ return sanitized.toString();
+};
diff --git a/static_src/shared/md-helper.test.js b/static_src/shared/md-helper.test.js
new file mode 100644
index 0000000..9f7dba1
--- /dev/null
+++ b/static_src/shared/md-helper.test.js
@@ -0,0 +1,90 @@
+import {assert} from 'chai';
+import {renderMarkdown, shouldRenderMarkdown} from './md-helper.js';
+
+describe('shouldRenderMarkdown', () => {
+ it('defaults to false', () => {
+ const actual = shouldRenderMarkdown();
+ assert.isFalse(actual);
+ });
+
+ it('returns true for enabled projects', () => {
+ const actual = shouldRenderMarkdown({project:'astor',
+ availableProjects: new Set(['astor'])});
+ assert.isTrue(actual);
+ });
+
+ it('returns false for disabled projects', () => {
+ const actual = shouldRenderMarkdown({project:'hazelnut',
+ availableProjects: new Set(['astor'])});
+ assert.isFalse(actual);
+ });
+
+ it('user pref can disable markdown', () => {
+ const actual = shouldRenderMarkdown({project:'astor',
+ enabledProjects: new Set(['astor']), enabled: false});
+ assert.isFalse(actual);
+ });
+});
+
+describe('renderMarkdown', () => {
+ it('can render empty string', () => {
+ const actual = renderMarkdown('');
+ assert.equal(actual, '');
+ });
+
+ it('can render basic string', () => {
+ const actual = renderMarkdown('hello world');
+ assert.equal(actual, '<p>hello world</p>\n');
+ });
+
+ it('can render lists', () => {
+ const input = '* First item\n* Second item\n* Third item\n* Fourth item';
+ const actual = renderMarkdown(input);
+ const expected = '<ul>\n<li>First item</li>\n<li>Second item</li>\n' +
+ '<li>Third item</li>\n<li>Fourth item</li>\n</ul>\n';
+ assert.equal(actual, expected);
+ });
+
+ it('can render headings', () => {
+ const actual = renderMarkdown('# Heading level 1\n\n## Heading level 2');
+ assert.equal(actual,
+ '<h1>Heading level 1</h1>\n<h2>Heading level 2</h2>\n');
+ });
+
+ describe('can render links', () => {
+ it('for simple links', () => {
+ const actual = renderMarkdown('[clickme](http://google.com)');
+ const expected = `<p><span class="annotated-link"><a title="" ` +
+ `href="http://google.com"><span class="material-icons link">` +
+ `link</span>clickme</a><span class="tooltip">Link destination: ` +
+ `http://google.com</span></span></p>\n`;
+ assert.equal(actual, expected);
+ });
+
+ it('and indicates malformed link', () => {
+ const actual = renderMarkdown('[clickme](google.com)');
+ const expected = `<p><span class="annotated-link"><a title="" ` +
+ `href="google.com"><span class="material-icons link_off">link_off` +
+ `</span>clickme</a><span class="tooltip">Link may be malformed: ` +
+ `google.com</span></span></p>\n`;
+ assert.equal(actual, expected);
+ });
+ });
+
+ it('preserves bolding from description templates', () => {
+ const input = `<b>What's the problem?</b>\n<b>1.</b> A\n<b>2.</b> B`;
+ const actual = renderMarkdown(input);
+ const expected = `<p><strong>What's the problem?</strong>\n<strong>1.` +
+ `</strong> A\n<strong>2.</strong> B</p>\n`;
+ assert.equal(actual, expected);
+ });
+
+ it('escapes HTML content', () => {
+ let actual = renderMarkdown('<input></input>');
+ assert.equal(actual, '<p><input></input></p>\n');
+
+ actual = renderMarkdown('<a href="https://google.com">clickme</a>');
+ assert.equal(actual,
+ '<p><a href="https://google.com">clickme</a></p>\n');
+ });
+});
diff --git a/static_src/shared/metadata-helpers.js b/static_src/shared/metadata-helpers.js
new file mode 100644
index 0000000..5735557
--- /dev/null
+++ b/static_src/shared/metadata-helpers.js
@@ -0,0 +1,114 @@
+// 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.
+
+// TODO(crbug.com/monorail/4549): Remove this hardcoded data once backend custom
+// field grouping is implemented.
+export const HARDCODED_FIELD_GROUPS = [
+ {
+ groupName: 'Feature Team',
+ fieldNames: ['PM', 'Tech Lead', 'Tech-Lead', 'TechLead', 'TL',
+ 'Team', 'UX', 'TE'],
+ applicableType: 'FLT-Launch',
+ },
+ {
+ groupName: 'Docs',
+ fieldNames: ['PRD', 'DD', 'Design Doc', 'Design-Doc',
+ 'DesignDoc', 'Mocks', 'Test Plan', 'Test-Plan', 'TestPlan',
+ 'Metrics'],
+ applicableType: 'FLT-Launch',
+ },
+];
+
+export const fieldGroupMap = (fieldGroupsArg, issueType) => {
+ const fieldGroups = groupsForType(fieldGroupsArg, issueType);
+ return fieldGroups.reduce((acc, group) => {
+ return group.fieldNames.reduce((acc, fieldName) => {
+ acc[fieldName] = group.groupName;
+ return acc;
+ }, acc);
+ }, {});
+};
+
+/**
+ * Get all values for a field, given an issue's fieldValueMap.
+ * @param {Map.<string, Array<string>>} fieldValueMap Map where keys are
+ * lowercase fieldNames and values are fieldValue strings.
+ * @param {string} fieldName The name of the field to look up.
+ * @param {string=} phaseName Name of the phase the field is attached to,
+ * if applicable.
+ * @return {Array<string>} The values of the field.
+ */
+export const valuesForField = (fieldValueMap, fieldName, phaseName) => {
+ if (!fieldValueMap) return [];
+ return fieldValueMap.get(
+ fieldValueMapKey(fieldName, phaseName)) || [];
+};
+
+/**
+ * Get just one value for a field. Convenient in some cases for
+ * fields that are not multi-valued.
+ * @param {Map.<string, Array<string>>} fieldValueMap Map where keys are
+ * lowercase fieldNames and values are fieldValue strings.
+ * @param {string} fieldName The name of the field to look up.
+ * @param {string=} phaseName Name of the phase the field is attached to,
+ * if applicable.
+ * @return {string} The value of the field.
+ */
+export function valueForField(fieldValueMap, fieldName, phaseName) {
+ const values = valuesForField(fieldValueMap, fieldName, phaseName);
+ return values.length ? values[0] : undefined;
+}
+
+/**
+ * Helper to generate Map keys for FieldValueMaps in a standard format.
+ * @param {string} fieldName Name of the field the value is tied to.
+ * @param {string=} phaseName Name of the phase the value is tied to.
+ * @return {string}
+ */
+export const fieldValueMapKey = (fieldName, phaseName) => {
+ const key = [fieldName];
+ if (phaseName) {
+ key.push(phaseName);
+ }
+ return key.join(' ').toLowerCase();
+};
+
+export const groupsForType = (fieldGroups, issueType) => {
+ return fieldGroups.filter((group) => {
+ if (!group.applicableType) return true;
+ return issueType && group.applicableType.toLowerCase() ===
+ issueType.toLowerCase();
+ });
+};
+
+export const fieldDefsWithGroup = (fieldDefs, fieldGroupsArg, issueType) => {
+ const fieldGroups = groupsForType(fieldGroupsArg, issueType);
+ if (!fieldDefs) return [];
+ const groups = [];
+ fieldGroups.forEach((group) => {
+ const groupFields = [];
+ group.fieldNames.forEach((name) => {
+ const fd = fieldDefs.find(
+ (fd) => (fd.fieldRef.fieldName == name));
+ if (fd) {
+ groupFields.push(fd);
+ }
+ });
+ if (groupFields.length > 0) {
+ groups.push({
+ groupName: group.groupName,
+ fieldDefs: groupFields,
+ });
+ }
+ });
+ return groups;
+};
+
+export const fieldDefsWithoutGroup = (fieldDefs, fieldGroups, issueType) => {
+ if (!fieldDefs) return [];
+ const map = fieldGroupMap(fieldGroups, issueType);
+ return fieldDefs.filter((fd) => {
+ return !(fd.fieldRef.fieldName in map);
+ });
+};
diff --git a/static_src/shared/metadata-helpers.test.js b/static_src/shared/metadata-helpers.test.js
new file mode 100644
index 0000000..fd04806
--- /dev/null
+++ b/static_src/shared/metadata-helpers.test.js
@@ -0,0 +1,93 @@
+// 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 {assert} from 'chai';
+import {valuesForField, valueForField, fieldDefsWithGroup, fieldValueMapKey,
+ fieldDefsWithoutGroup, HARDCODED_FIELD_GROUPS} from './metadata-helpers.js';
+
+const fieldDefs = [
+ {
+ fieldRef: {
+ fieldName: 'Ignore',
+ fieldId: 1,
+ },
+ },
+ {
+ fieldRef: {
+ fieldName: 'DesignDoc',
+ fieldId: 2,
+ },
+ },
+];
+const fieldGroups = HARDCODED_FIELD_GROUPS;
+
+const fieldValueMap = new Map([
+ ['field', ['one', 'two', 'three']],
+ ['field-two phase', ['four']],
+ ['field-three', ['five']],
+]);
+
+describe('metadata-helpers', () => {
+ it('valuesForField', () => {
+ assert.deepEqual(valuesForField(fieldValueMap, 'Field-None'), []);
+ assert.deepEqual(valuesForField(fieldValueMap, 'Field'),
+ ['one', 'two', 'three']);
+ assert.deepEqual(valuesForField(fieldValueMap, 'Field-Two', 'Phase'),
+ ['four']);
+ assert.deepEqual(valuesForField(fieldValueMap, 'Field-Three'), ['five']);
+ });
+
+ it('valueForField', () => {
+ assert.equal(valueForField(fieldValueMap, 'Field-None'),
+ undefined);
+ assert.equal(valueForField(fieldValueMap, 'Field-Two', 'Phase'), 'four');
+ assert.equal(valueForField(fieldValueMap, 'Field-Three'), 'five');
+ });
+
+ it('fieldValueMapKey', () => {
+ assert.equal(fieldValueMapKey('test', 'two'), 'test two');
+
+ assert.equal(fieldValueMapKey('noPhase'), 'nophase');
+ });
+
+ it('fieldDefsWithoutGroup ignores non applicable types', () => {
+ assert.deepEqual(fieldDefsWithoutGroup(
+ fieldDefs, fieldGroups, 'ungrouped-type'), fieldDefs);
+ });
+
+ it('fieldDefsWithoutGroup filters grouped fields', () => {
+ assert.deepEqual(fieldDefsWithoutGroup(
+ fieldDefs, fieldGroups, 'flt-launch'), [
+ {
+ fieldRef: {
+ fieldName: 'Ignore',
+ fieldId: 1,
+ },
+ },
+ ]);
+ });
+
+ it('fieldDefsWithGroup filters by type', () => {
+ const filteredGroupsList = [{
+ groupName: 'Docs',
+ fieldDefs: [
+ {
+ fieldRef: {
+ fieldName: 'DesignDoc',
+ fieldId: 2,
+ },
+ },
+ ],
+ }];
+
+ assert.deepEqual(
+ fieldDefsWithGroup(fieldDefs, fieldGroups, 'Not-FLT-Launch'), []);
+
+ assert.deepEqual(fieldDefsWithGroup(fieldDefs, fieldGroups, 'flt-launch'),
+ filteredGroupsList);
+
+ assert.deepEqual(fieldDefsWithGroup(fieldDefs, fieldGroups, 'FLT-LAUNCH'),
+ filteredGroupsList);
+ });
+});
diff --git a/static_src/shared/settings.js b/static_src/shared/settings.js
new file mode 100644
index 0000000..0b5fc3c
--- /dev/null
+++ b/static_src/shared/settings.js
@@ -0,0 +1,23 @@
+// 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.
+
+// List of content type prefixes that the user will not be warned about when
+// downloading an attachment.
+export const ALLOWED_CONTENT_TYPE_PREFIXES = [
+ 'audio/', 'font/', 'image/', 'text/plain', 'video/',
+];
+
+// List of file extensions that the user will not be warned about when
+// downloading an attachment.
+export const ALLOWED_ATTACHMENT_EXTENSIONS = [
+ '.avi', '.avif', '.bmp', '.csv', '.doc', '.docx', '.email', '.eml', '.gif',
+ '.ico', '.jpeg', '.jpg', '.log', '.m4p', '.m4v', '.mkv', '.mov', '.mp2',
+ '.mp4', '.mpeg', '.mpg', '.mpv', '.odt', '.ogg', '.pdf', '.png', '.sql',
+ '.svg', '.tif', '.tiff', '.txt', '.wav', '.webm', '.wmv',
+];
+
+// The message to show the user when they attempt to download an unrecognized
+// file type.
+export const FILE_DOWNLOAD_WARNING = 'This file type is not recognized. Are' +
+ ' you sure you want to download this attachment?';
diff --git a/static_src/shared/shared-styles.js b/static_src/shared/shared-styles.js
new file mode 100644
index 0000000..c00f639
--- /dev/null
+++ b/static_src/shared/shared-styles.js
@@ -0,0 +1,203 @@
+// 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 {css} from 'lit-element';
+
+export const SHARED_STYLES = css`
+ :host {
+ --mr-edit-field-padding: 0.125em 4px;
+ --mr-edit-field-width: 90%;
+ --mr-input-grid-gap: 6px;
+ font-family: var(--chops-font-family);
+ color: var(--chops-primary-font-color);
+ font-size: var(--chops-main-font-size);
+ }
+ /** Converts a <button> to look like an <a> tag. */
+ .linkify {
+ display: inline;
+ padding: 0;
+ margin: 0;
+ border: 0;
+ background: 0;
+ cursor: pointer;
+ }
+ h1, h2, h3, h4 {
+ background: none;
+ }
+ a, chops-button, a.button, .button, .linkify {
+ color: var(--chops-link-color);
+ text-decoration: none;
+ font-weight: var(--chops-link-font-weight);
+ font-family: var(--chops-font-family);
+ }
+ a:hover, .linkify:hover {
+ text-decoration: underline;
+ }
+ a.button, .button {
+ /* Links that look like buttons. */
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+ transition: filter 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
+ }
+ a.button:hover, .button:hover {
+ filter: brightness(95%);
+ }
+ chops-button, a.button, .button {
+ box-sizing: border-box;
+ font-size: var(--chops-main-font-size);
+ background: var(--chops-white);
+ border-radius: 6px;
+ --chops-button-padding: 0.25em 8px;
+ margin: 0;
+ margin-left: auto;
+ }
+ a.button, .button {
+ padding: var(--chops-button-padding);
+ }
+ chops-button i.material-icons, a.button i.material-icons, .button i.material-icons {
+ display: block;
+ margin-right: 4px;
+ }
+ chops-button.emphasized, a.button.emphasized, .button.emphasized {
+ background: var(--chops-primary-button-bg);
+ color: var(--chops-primary-button-color);
+ text-shadow: 1px 1px 3px hsla(0, 0%, 0%, 0.25);
+ }
+ textarea, select, input {
+ box-sizing: border-box;
+ font-size: var(--chops-main-font-size);
+ }
+ /* Note: decoupling heading levels from styles is useful for
+ * accessibility because styles will not always line up with semantically
+ * appropriate heading levels.
+ */
+ .medium-heading {
+ font-size: var(--chops-large-font-size);
+ font-weight: normal;
+ line-height: 1;
+ padding: 0.25em 0;
+ color: var(--chops-link-color);
+ margin: 0;
+ margin-top: 0.25em;
+ border-bottom: var(--chops-normal-border);
+ }
+ .medium-heading chops-button {
+ line-height: 1.6;
+ }
+ .input-grid {
+ padding: 0.5em 0;
+ display: grid;
+ max-width: 100%;
+ grid-gap: var(--mr-input-grid-gap);
+ grid-template-columns: minmax(120px, max-content) 1fr;
+ align-items: flex-start;
+ }
+ .input-grid label {
+ font-weight: bold;
+ text-align: right;
+ word-wrap: break-word;
+ }
+ @media (max-width: 600px) {
+ .input-grid label {
+ margin-top: var(--mr-input-grid-gap);
+ text-align: left;
+ }
+ .input-grid {
+ grid-gap: var(--mr-input-grid-gap);
+ grid-template-columns: 100%;
+ }
+ }
+`;
+
+/**
+ * Markdown specific styling:
+ * * render link destination on hover as a tooltip
+ * @type {CSSResult}
+ */
+export const MD_STYLES = css`
+ .markdown .annotated-link {
+ position: relative;
+ }
+ .markdown .annotated-link:hover .tooltip {
+ display: block
+ }
+ .markdown .tooltip {
+ display: none;
+ position: absolute;
+ width: auto;
+ white-space: nowrap;
+ box-shadow: rgb(170 170 170) 1px 1px 5px;
+ box-shadow: 0 4px 8px 3px rgb(0 0 0 / 10%);
+ border-radius: 8px;
+ background-color: rgb(255, 255, 255);
+ top: -32px;
+ left: 0px;
+ border: 1px solid #dadce0;
+ padding: 6px 10px;
+ }
+ .markdown .material-icons {
+ font-size: 18px;
+ vertical-align: middle;
+ }
+ .markdown .material-icons.link {
+ color: var(--chops-link-color);
+ }
+ .markdown .material-icons.link_off {
+ color: var(--chops-field-error-color);
+ }
+ .markdown table {
+ -webkit-font-smoothing: antialiased;
+ box-sizing: inherit;
+ border-collapse: collapse;
+ margin: 8px 0 8px 0;
+ box-shadow: 0 2px 2px 0 hsla(315, 3%, 26%, 0.30);
+ border: 1px solid var(--chops-gray-300);
+ line-height: 1.4;
+ }
+ .markdown th {
+ border-bottom: 1px solid var(--chops-gray-300);
+ border-right: 1px solid var(--chops-gray-300);
+ padding: 1px;
+ text-align: left;
+ font-weight: 500;
+ color: var(--chops-gray-900);
+ background-color: var(--chops-gray-50);
+ }
+ .markdown td {
+ border-bottom: 1px solid var(--chops-gray-300);
+ border-right: 1px solid var(--chops-gray-300);
+ padding: 1px;
+ }
+ .markdown pre {
+ -webkit-font-smoothing: antialiased;
+ line-height: 1.6;
+ box-sizing: inherit;
+ background-color: hsla(0, 0%, 0%, 0.05);
+ border: 2px solid hsla(0, 0%, 0%, 0.10);
+ border-radius: 2px;
+ overflow-x: auto;
+ padding: 4px;
+ }
+`;
+
+export const MD_PREVIEW_STYLES = css`
+ ${MD_STYLES}
+ .markdown-preview {
+ padding: 0.25em 1em;
+ color: var(--chops-gray-800);
+ background-color: var(--chops-gray-200);
+ border-radius: 10px;
+ margin: 0px 0px 10px;
+ overflow: auto;
+ }
+ .preview-height-description {
+ max-height: 40vh;
+ }
+ .preview-height-comment {
+ min-height: 5vh;
+ max-height: 15vh;
+ }
+`;
\ No newline at end of file
diff --git a/static_src/shared/test/constants-hotlists.js b/static_src/shared/test/constants-hotlists.js
new file mode 100644
index 0000000..a496905
--- /dev/null
+++ b/static_src/shared/test/constants-hotlists.js
@@ -0,0 +1,76 @@
+// 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 * as issueV0 from './constants-issueV0.js';
+import * as users from './constants-users.js';
+import 'shared/typedef.js';
+
+/** @type {string} */
+export const NAME = 'hotlists/1234';
+
+/** @type {Hotlist} */
+export const HOTLIST = Object.freeze({
+ name: NAME,
+ displayName: 'Hotlist-Name',
+ owner: users.NAME,
+ editors: [users.NAME_2],
+ summary: 'Summary',
+ description: 'Description',
+ defaultColumns: [{column: 'Rank'}, {column: 'ID'}, {column: 'Summary'}],
+ hotlistPrivacy: 'PUBLIC',
+});
+
+export const HOTLIST_ITEM_NAME = NAME + '/items/56';
+
+/** @type {HotlistItem} */
+export const HOTLIST_ITEM = Object.freeze({
+ name: HOTLIST_ITEM_NAME,
+ issue: issueV0.NAME,
+ // rank: The API excludes the rank field if it's 0.
+ adder: users.NAME,
+ createTime: '2020-01-01T12:00:00Z',
+});
+
+/** @type {HotlistIssue} */
+export const HOTLIST_ISSUE = Object.freeze({
+ ...issueV0.ISSUE, ...HOTLIST_ITEM, adder: users.USER,
+});
+
+/** @type {HotlistItem} */
+export const HOTLIST_ITEM_OTHER_PROJECT = Object.freeze({
+ name: NAME + '/items/78',
+ issue: issueV0.NAME_OTHER_PROJECT,
+ rank: 1,
+ adder: users.NAME,
+ createTime: '2020-01-01T12:00:00Z',
+});
+
+/** @type {HotlistIssue} */
+export const HOTLIST_ISSUE_OTHER_PROJECT = Object.freeze({
+ ...issueV0.ISSUE_OTHER_PROJECT,
+ ...HOTLIST_ITEM_OTHER_PROJECT,
+ adder: users.USER,
+});
+
+/** @type {HotlistItem} */
+export const HOTLIST_ITEM_CLOSED = Object.freeze({
+ name: NAME + '/items/90',
+ issue: issueV0.NAME_CLOSED,
+ rank: 2,
+ adder: users.NAME,
+ createTime: '2020-01-01T12:00:00Z',
+});
+
+/** @type {HotlistIssue} */
+export const HOTLIST_ISSUE_CLOSED = Object.freeze({
+ ...issueV0.ISSUE_CLOSED, ...HOTLIST_ITEM_CLOSED, adder: users.USER,
+});
+
+/** @type {Object<string, Hotlist>} */
+export const BY_NAME = Object.freeze({[NAME]: HOTLIST});
+
+/** @type {Object<string, Array<HotlistItem>>} */
+export const HOTLIST_ITEMS = Object.freeze({
+ [NAME]: [HOTLIST_ITEM],
+});
diff --git a/static_src/shared/test/constants-issueV0.js b/static_src/shared/test/constants-issueV0.js
new file mode 100644
index 0000000..4f52aef
--- /dev/null
+++ b/static_src/shared/test/constants-issueV0.js
@@ -0,0 +1,42 @@
+// 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 'shared/typedef.js';
+
+export const NAME = 'projects/project-name/issues/1234';
+
+export const ISSUE_REF_STRING = 'project-name:1234';
+
+/** @type {IssueRef} */
+export const ISSUE_REF = Object.freeze({
+ projectName: 'project-name',
+ localId: 1234,
+});
+
+/** @type {Issue} */
+export const ISSUE = Object.freeze({
+ projectName: 'project-name',
+ localId: 1234,
+ statusRef: {status: 'Available', meansOpen: true},
+});
+
+export const NAME_OTHER_PROJECT = 'projects/other-project-name/issues/1234';
+
+export const ISSUE_OTHER_PROJECT_REF_STRING = 'other-project-name:1234';
+
+/** @type {Issue} */
+export const ISSUE_OTHER_PROJECT = Object.freeze({
+ projectName: 'other-project-name',
+ localId: 1234,
+ statusRef: {status: 'Available', meansOpen: true},
+});
+
+export const NAME_CLOSED = 'projects/project-name/issues/5678';
+
+/** @type {Issue} */
+export const ISSUE_CLOSED = Object.freeze({
+ projectName: 'project-name',
+ localId: 5678,
+ statusRef: {status: 'Fixed', meansOpen: false},
+});
diff --git a/static_src/shared/test/constants-permissions.js b/static_src/shared/test/constants-permissions.js
new file mode 100644
index 0000000..f4b09c0
--- /dev/null
+++ b/static_src/shared/test/constants-permissions.js
@@ -0,0 +1,23 @@
+// Copyright 2020 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 * as issue from './constants-issueV0.js';
+import 'shared/typedef.js';
+
+/** @type {Permission} */
+export const PERMISSION_ISSUE_EDIT = 'ISSUE_EDIT';
+
+/** @type {PermissionSet} */
+export const PERMISSION_SET_ISSUE = {
+ resource: issue.NAME,
+ permissions: [PERMISSION_ISSUE_EDIT],
+};
+
+/** @type {Object<string, PermissionSet>} */
+export const BY_NAME = {
+ [issue.NAME]: PERMISSION_SET_ISSUE,
+};
+
+/** @type {Array<Permission>} */
+export const PERMISSION_HOTLIST_EDIT = ['HOTLIST_EDIT'];
diff --git a/static_src/shared/test/constants-projectV0.js b/static_src/shared/test/constants-projectV0.js
new file mode 100644
index 0000000..4a46af8
--- /dev/null
+++ b/static_src/shared/test/constants-projectV0.js
@@ -0,0 +1,80 @@
+// 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 {fieldTypes} from 'shared/issue-fields.js';
+import {USER_REF} from './constants-users.js';
+import 'shared/typedef.js';
+
+/** @type {string} */
+export const PROJECT_NAME = 'project-name';
+
+/** @type {FieldDef} */
+export const FIELD_DEF_INT = Object.freeze({
+ fieldRef: Object.freeze({
+ fieldId: 123,
+ fieldName: 'field-name',
+ type: fieldTypes.INT_TYPE,
+ }),
+});
+
+/** @type {FieldDef} */
+export const FIELD_DEF_ENUM = Object.freeze({
+ fieldRef: Object.freeze({
+ fieldId: 456,
+ fieldName: 'enum',
+ type: fieldTypes.ENUM_TYPE,
+ }),
+});
+
+/** @type {Array<FieldDef>} */
+export const FIELD_DEFS = [
+ FIELD_DEF_INT,
+ FIELD_DEF_ENUM,
+];
+
+/** @type {Config} */
+export const CONFIG = Object.freeze({
+ projectName: PROJECT_NAME,
+ fieldDefs: FIELD_DEFS,
+ labelDefs: [
+ {label: 'One'},
+ {label: 'EnUm'},
+ {label: 'eNuM-Options'},
+ {label: 'hello-world', docstring: 'hmmm'},
+ {label: 'hello-me', docstring: 'hmmm'},
+ ],
+});
+
+/** @type {string} */
+export const DEFAULT_QUERY = 'owner:me';
+
+/** @type {PresentationConfig} */
+export const PRESENTATION_CONFIG = Object.freeze({
+ projectThumbnailUrl: 'test.png',
+ defaultColSpec: 'ID+Summary+AllLabels',
+ defaultQuery: DEFAULT_QUERY,
+});
+
+/** @type {Array<string>} */
+export const CUSTOM_PERMISSIONS = ['google', 'security'];
+
+/** @type {{userRefs: Array<UserRef>, groupRefs: Array<UserRef>}} */
+export const VISIBLE_MEMBERS = Object.freeze({
+ userRefs: [USER_REF],
+ groupRefs: [],
+});
+
+/** @type {TemplateDef} */
+export const TEMPLATE_DEF = Object.freeze({
+ templateName: 'Template Name',
+});
+
+export const STATE = Object.freeze({projectV0: {
+ name: PROJECT_NAME,
+ configs: {[PROJECT_NAME]: CONFIG},
+ presentationConfigs: {[PROJECT_NAME]: PRESENTATION_CONFIG},
+ customPermissions: {[PROJECT_NAME]: CUSTOM_PERMISSIONS},
+ visibleMembers: {[PROJECT_NAME]: VISIBLE_MEMBERS},
+ templates: {[PROJECT_NAME]: TEMPLATE_DEF},
+}});
diff --git a/static_src/shared/test/constants-projects.js b/static_src/shared/test/constants-projects.js
new file mode 100644
index 0000000..c25f46b
--- /dev/null
+++ b/static_src/shared/test/constants-projects.js
@@ -0,0 +1,27 @@
+// 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 'shared/typedef.js';
+
+/** @type {ProjectName} */
+export const NAME = 'projects/chromium';
+
+/** @type {Project} */
+export const PROJECT = Object.freeze({
+ name: NAME,
+ displayName: 'Chromium',
+ summary: 'A great open source project.',
+ thumbnailUrl: 'chromium.png',
+});
+
+/** @type {ProjectName} */
+export const NAME_2 = 'projects/monorail';
+
+/** @type {Project} */
+export const PROJECT_2 = Object.freeze({
+ name: NAME_2,
+ displayName: 'mOnOrAiL',
+ summary: 'Best issue tracker.',
+ thumbnailUrl: 'dogtrain.gif',
+});
diff --git a/static_src/shared/test/constants-stars.js b/static_src/shared/test/constants-stars.js
new file mode 100644
index 0000000..42e7012
--- /dev/null
+++ b/static_src/shared/test/constants-stars.js
@@ -0,0 +1,18 @@
+// 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 'shared/typedef.js';
+
+export const PROJECT_STAR_NAME = 'users/1234/projectStars/monorail';
+export const PROJECT_STAR_NAME_2 = 'users/1234/projectStars/chromium';
+
+/** @type {ProjectStar} */
+export const PROJECT_STAR = Object.freeze({
+ name: PROJECT_STAR_NAME,
+});
+
+/** @type {ProjectStar} */
+export const PROJECT_STAR_2 = Object.freeze({
+ name: PROJECT_STAR_NAME_2,
+});
diff --git a/static_src/shared/test/constants-users.js b/static_src/shared/test/constants-users.js
new file mode 100644
index 0000000..0a9bbf8
--- /dev/null
+++ b/static_src/shared/test/constants-users.js
@@ -0,0 +1,43 @@
+// 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 'shared/typedef.js';
+
+export const NAME = 'users/1234';
+
+export const DISPLAY_NAME = 'example@example.com';
+
+export const ID = 1234;
+
+/** @type {UserRef} */
+export const USER_REF = Object.freeze({
+ userId: ID,
+ displayName: DISPLAY_NAME,
+});
+
+/** @type {User} */
+export const USER = Object.freeze({
+ name: NAME,
+ displayName: DISPLAY_NAME,
+});
+
+export const NAME_2 = 'users/5678';
+
+export const DISPLAY_NAME_2 = 'other_user@example.com';
+
+/** @type {User} */
+export const USER_2 = Object.freeze({
+ name: NAME_2,
+ displayName: DISPLAY_NAME_2,
+});
+
+/** @type {Object<string, User>} */
+export const BY_NAME = Object.freeze({[NAME]: USER, [NAME_2]: USER_2});
+
+/** @type {ProjectMember} */
+export const PROJECT_MEMBER = Object.freeze({
+ name: 'projects/proj/members/1234',
+ role: 'CONTRIBUTOR',
+});
+
diff --git a/static_src/shared/test/fakes.js b/static_src/shared/test/fakes.js
new file mode 100644
index 0000000..d506f6a
--- /dev/null
+++ b/static_src/shared/test/fakes.js
@@ -0,0 +1,12 @@
+// 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 sinon from 'sinon';
+
+export const clientLoggerFake = () => ({
+ logStart: sinon.stub(),
+ logEnd: sinon.stub(),
+ logPause: sinon.stub(),
+ started: sinon.stub().returns(true),
+});
diff --git a/static_src/shared/test/helpers.js b/static_src/shared/test/helpers.js
new file mode 100644
index 0000000..63a1e12
--- /dev/null
+++ b/static_src/shared/test/helpers.js
@@ -0,0 +1,57 @@
+// 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 axe from 'axe-core';
+import userEvent from '@testing-library/user-event';
+import {fireEvent} from '@testing-library/react';
+
+// TODO(seanmccullough): Move this into crdx/chopsui-npm if we decide this
+// is worth using in other projects.
+
+/**
+ * @param {HTMLElement} element The element to audit accessibility for.
+ */
+export async function auditA11y(element) {
+ // Performance tip: try restricting the analysis using
+ // https://github.com/dequelabs/axe-core/blob/develop/doc/API.md#use-resulttypes
+ const options = {};
+
+ // Adjust this set to make tests more/less permissible.
+ const reportImpact = new Set(['critical', 'serious', 'moderate', 'minor']);
+ const results = await axe.run(element, options);
+
+ if (results.violations.length == 0) {
+ return;
+ }
+
+ const msgs = ['Accessibility violations:'];
+ results.violations.forEach((result) => {
+ if (reportImpact.has(result.impact)) {
+ msgs.push(`\n[${result.impact}] ${result.help}`);
+ for (const node of result.nodes) {
+ if (node.failureSummary) {
+ msgs.push(node.failureSummary);
+ }
+ msgs.push(node.html);
+ }
+ msgs.push('---');
+ }
+ });
+
+ throw new Error(msgs.join('\n'));
+}
+
+/**
+ * Types text into an input field and presses Enter.
+ * @param {HTMLInputElement} input The input field to enter text in.
+ * @param {string} value The text to enter in the input field.
+ */
+export function enterInput(input, value) {
+ userEvent.clear(input);
+
+ userEvent.type(input, value);
+
+ fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'});
+}
+
diff --git a/static_src/shared/typedef.js b/static_src/shared/typedef.js
new file mode 100644
index 0000000..923e1db
--- /dev/null
+++ b/static_src/shared/typedef.js
@@ -0,0 +1,646 @@
+// 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.
+
+/**
+ * @fileoverview Shared file for specifying common types used in type
+ * annotations across Monorail.
+ */
+
+// TODO(zhangtiff): Find out if there's a way we can generate typedef's for
+// API object from .proto files.
+
+
+/**
+ * Types used in the app that don't come from any Proto files.
+ */
+
+/**
+ * A HotlistItem with the Issue flattened into the top-level,
+ * containing the intersection of the fields of HotlistItem and Issue.
+ *
+ * @typedef {Issue & HotlistItem} HotlistIssue
+ * @property {User=} adder
+ */
+
+/**
+ * A String containing the data necessary to identify an IssueRef. An IssueRef
+ * can reference either an issue in Monorail or an external issue in another
+ * tracker.
+ *
+ * Examples of valid IssueRefStrings:
+ * - monorail:1234
+ * - chromium:1
+ * - 1234
+ * - b/123456
+ *
+ * @typedef {string} IssueRefString
+ */
+
+/**
+ * An Object for specifying what to display in a single entry in the
+ * dropdown list.
+ *
+ * @typedef {Object} MenuItem
+ * @property {string=} text The text to display in the menu.
+ * @property {string=} icon A Material Design icon shown left of the text.
+ * @property {Array<MenuItem>=} items A specification for a nested submenu.
+ * @property {function=} handler An optional click handler for an item.
+ * @property {string=} url A link for the menu item to navigate to.
+ */
+
+/**
+ * An Object containing the metadata associated with tracking async requests
+ * through Redux.
+ *
+ * @typedef {Object} ReduxRequestState
+ * @property {boolean=} requesting Whether a request is in flight.
+ * @property {Error=} error An Error Object returned by the request.
+ */
+
+
+/**
+ * Resource names used in our resource-oriented API.
+ * @see https://aip.dev/122
+ */
+
+
+/**
+ * Resource name of an IssueStar.
+ *
+ * Examples of valid IssueStar resource names:
+ * - users/1234/issueStars/monorail.5556
+ * - users/1234/issueStars/test-project.4321
+ *
+ * @typedef {string} IssueStarName
+ */
+
+
+/**
+ * Resource name of a ProjectStar.
+ *
+ * Examples of valid ProjectStar resource names:
+ * - users/1234/projectStars/monorail
+ * - users/1234/projectStars/test-project
+ *
+ * @typedef {string} ProjectStarName
+ */
+
+
+/**
+ * Resource name of a Star.
+ *
+ * @typedef {ProjectStarName|IssueStarName} StarName
+ */
+
+
+/**
+ * Resource name of a Project.
+ *
+ * Examples of valid Project resource names:
+ * - projects/monorail
+ * - projects/test-project-1
+ *
+ * @typedef {string} ProjectName
+ */
+
+
+/**
+ * Resource name of a User.
+ *
+ * Examples of valid User resource names:
+ * - users/test@example.com
+ * - users/1234
+ *
+ * @typedef {string} UserName
+ */
+
+/**
+ * Resource name of a ProjectMember.
+ *
+ * Examples of valid ProjectMember resource names:
+ * - projects/monorail/members/1234
+ * - projects/test-xyz/members/5678
+ *
+ * @typedef {string} ProjectMemberName
+ */
+
+
+/**
+ * Types defined in common.proto.
+ */
+
+
+/**
+ * A ComponentRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} ComponentRef
+ * @property {string} path
+ * @property {boolean=} isDerived
+ */
+
+/**
+ * An Enum representing the type that a custom field uses.
+ *
+ * @typedef {string} FieldType
+ */
+
+/**
+ * A FieldRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} FieldRef
+ * @property {number} fieldId
+ * @property {string} fieldName
+ * @property {FieldType} type
+ * @property {string=} approvalName
+ */
+
+/**
+ * A LabelRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} LabelRef
+ * @property {string} label
+ * @property {boolean=} isDerived
+ */
+
+/**
+ * A StatusRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} StatusRef
+ * @property {string} status
+ * @property {boolean=} meansOpen
+ * @property {boolean=} isDerived
+ */
+
+/**
+ * An IssueRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} IssueRef
+ * @property {string=} projectName
+ * @property {number=} localId
+ * @property {string=} extIdentifier
+ */
+
+/**
+ * A UserRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} UserRef
+ * @property {string=} displayName
+ * @property {number=} userId
+ */
+
+/**
+ * A HotlistRef Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} HotlistRef
+ * @property {string=} name
+ * @property {UserRef=} owner
+ */
+
+/**
+ * A SavedQuery Object returned by the pRPC API common.proto.
+ *
+ * @typedef {Object} SavedQuery
+ * @property {number} queryId
+ * @property {string} name
+ * @property {string} query
+ * @property {Array<string>} projectNames
+ */
+
+
+/**
+ * Types defined in issue_objects.proto.
+ */
+
+/**
+ * An Approval Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Approval
+ * @property {FieldRef} fieldRef
+ * @property {Array<UserRef>} approverRefs
+ * @property {ApprovalStatus} status
+ * @property {number} setOn
+ * @property {UserRef} setterRef
+ * @property {PhaseRef} phaseRef
+ */
+
+/**
+ * An Enum representing the status of an Approval.
+ *
+ * @typedef {string} ApprovalStatus
+ */
+
+/**
+ * An Amendment Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Amendment
+ * @property {string} fieldName
+ * @property {string} newOrDeltaValue
+ * @property {string} oldValue
+ */
+
+/**
+ * An Attachment Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Attachment
+* @property {number} attachmentId
+* @property {string} filename
+* @property {number} size
+* @property {string} contentType
+* @property {boolean} isDeleted
+* @property {string} thumbnailUrl
+* @property {string} viewUrl
+* @property {string} downloadUrl
+*/
+
+/**
+ * A Comment Object returned by the pRPC API issue_objects.proto.
+ *
+ * Note: This Object is called "Comment" in the backend but is named
+ * "IssueComment" here to avoid a collision with an internal JSDoc Intellisense
+ * type.
+ *
+ * @typedef {Object} IssueComment
+ * @property {string} projectName
+ * @property {number} localId
+ * @property {number=} sequenceNum
+ * @property {boolean=} isDeleted
+ * @property {UserRef=} commenter
+ * @property {number=} timestamp
+ * @property {string=} content
+ * @property {string=} inboundMessage
+ * @property {Array<Amendment>=} amendments
+ * @property {Array<Attachment>=} attachments
+ * @property {FieldRef=} approvalRef
+ * @property {number=} descriptionNum
+ * @property {boolean=} isSpam
+ * @property {boolean=} canDelete
+ * @property {boolean=} canFlag
+ */
+
+/**
+ * A FieldValue Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} FieldValue
+ * @property {FieldRef} fieldRef
+ * @property {string} value
+ * @property {boolean=} isDerived
+ * @property {PhaseRef=} phaseRef
+ */
+
+/**
+ * An Issue Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} Issue
+ * @property {string} projectName
+ * @property {number} localId
+ * @property {string=} summary
+ * @property {StatusRef=} statusRef
+ * @property {UserRef=} ownerRef
+ * @property {Array<UserRef>=} ccRefs
+ * @property {Array<LabelRef>=} labelRefs
+ * @property {Array<ComponentRef>=} componentRefs
+ * @property {Array<IssueRef>=} blockedOnIssueRefs
+ * @property {Array<IssueRef>=} blockingIssueRefs
+ * @property {Array<IssueRef>=} danglingBlockedOnRefs
+ * @property {Array<IssueRef>=} danglingBlockingRefs
+ * @property {IssueRef=} mergedIntoIssueRef
+ * @property {Array<FieldValue>=} fieldValues
+ * @property {boolean=} isDeleted
+ * @property {UserRef=} reporterRef
+ * @property {number=} openedTimestamp
+ * @property {number=} closedTimestamp
+ * @property {number=} modifiedTimestamp
+ * @property {number=} componentModifiedTimestamp
+ * @property {number=} statusModifiedTimestamp
+ * @property {number=} ownerModifiedTimestamp
+ * @property {number=} starCount
+ * @property {boolean=} isSpam
+ * @property {number=} attachmentCount
+ * @property {Array<Approval>=} approvalValues
+ * @property {Array<PhaseDef>=} phases
+ */
+
+/**
+ * A IssueDelta Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} IssueDelta
+ * @property {string=} status
+ * @property {UserRef=} ownerRef
+ * @property {Array<UserRef>=} ccRefsAdd
+ * @property {Array<UserRef>=} ccRefsRemove
+ * @property {Array<ComponentRef>=} compRefsAdd
+ * @property {Array<ComponentRef>=} compRefsRemove
+ * @property {Array<LabelRef>=} labelRefsAdd
+ * @property {Array<LabelRef>=} labelRefsRemove
+ * @property {Array<FieldValue>=} fieldValsAdd
+ * @property {Array<FieldValue>=} fieldValsRemove
+ * @property {Array<FieldRef>=} fieldsClear
+ * @property {Array<IssueRef>=} blockedOnRefsAdd
+ * @property {Array<IssueRef>=} blockedOnRefsRemove
+ * @property {Array<IssueRef>=} blockingRefsAdd
+ * @property {Array<IssueRef>=} blockingRefsRemove
+ * @property {IssueRef=} mergedIntoRef
+ * @property {string=} summary
+ */
+
+/**
+ * An PhaseDef Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} PhaseDef
+ * @property {PhaseRef} phaseRef
+ * @property {number} rank
+ */
+
+/**
+ * An PhaseRef Object returned by the pRPC API issue_objects.proto.
+ *
+ * @typedef {Object} PhaseRef
+ * @property {string} phaseName
+ */
+
+/**
+ * An Object returned by the pRPC v3 API from feature_objects.proto.
+ *
+ * @typedef {Object} IssuesListColumn
+ * @property {string} column
+ */
+
+
+/**
+ * Types defined in permission_objects.proto.
+ */
+
+/**
+ * A Permission string returned by the pRPC API permission_objects.proto.
+ *
+ * @typedef {string} Permission
+ */
+
+/**
+ * A PermissionSet Object returned by the pRPC API permission_objects.proto.
+ *
+ * @typedef {Object} PermissionSet
+ * @property {string} resource
+ * @property {Array<Permission>} permissions
+ */
+
+
+/**
+ * Types defined in project_objects.proto.
+ */
+
+/**
+ * An Enum representing the role a ProjectMember has.
+ *
+ * @typedef {string} ProjectRole
+ */
+
+/**
+ * An Enum representing how a ProjectMember shows up in autocomplete.
+ *
+ * @typedef {string} AutocompleteVisibility
+ */
+
+/**
+ * A ProjectMember Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ProjectMember
+ * @property {ProjectMemberName} name
+ * @property {ProjectRole} role
+ * @property {Array<Permission>=} standardPerms
+ * @property {Array<string>=} customPerms
+ * @property {string=} notes
+ * @property {AutocompleteVisibility=} includeInAutocomplete
+ */
+
+/**
+ * A Project Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} Project
+ * @property {string} name
+ * @property {string} summary
+ * @property {string=} description
+ */
+
+/**
+ * A Project Object returned by the v0 pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ProjectV0
+ * @property {string} name
+ * @property {string} summary
+ * @property {string=} description
+ */
+
+/**
+ * A StatusDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} StatusDef
+ * @property {string} status
+ * @property {boolean} meansOpen
+ * @property {number} rank
+ * @property {string} docstring
+ * @property {boolean} deprecated
+ */
+
+/**
+ * A LabelDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} LabelDef
+ * @property {string} label
+ * @property {string=} docstring
+ * @property {boolean=} deprecated
+ */
+
+/**
+ * A ComponentDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ComponentDef
+ * @property {string} path
+ * @property {string} docstring
+ * @property {Array<UserRef>} adminRefs
+ * @property {Array<UserRef>} ccRefs
+ * @property {boolean} deprecated
+ * @property {number} created
+ * @property {UserRef} creatorRef
+ * @property {number} modified
+ * @property {UserRef} modifierRef
+ * @property {Array<LabelRef>} labelRefs
+ */
+
+/**
+ * A FieldDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} FieldDef
+ * @property {FieldRef} fieldRef
+ * @property {string=} applicableType
+ * @property {boolean=} isRequired
+ * @property {boolean=} isNiche
+ * @property {boolean=} isMultivalued
+ * @property {string=} docstring
+ * @property {Array<UserRef>=} adminRefs
+ * @property {boolean=} isPhaseField
+ * @property {Array<UserRef>=} userChoices
+ * @property {Array<LabelDef>=} enumChoices
+ */
+
+/**
+ * A ApprovalDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} ApprovalDef
+ * @property {FieldRef} fieldRef
+ * @property {Array<UserRef>} approverRefs
+ * @property {string} survey
+ */
+
+/**
+ * A Config Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} Config
+ * @property {string} projectName
+ * @property {Array<StatusDef>=} statusDefs
+ * @property {Array<StatusRef>=} statusesOfferMerge
+ * @property {Array<LabelDef>=} labelDefs
+ * @property {Array<string>=} exclusiveLabelPrefixes
+ * @property {Array<ComponentDef>=} componentDefs
+ * @property {Array<FieldDef>=} fieldDefs
+ * @property {Array<ApprovalDef>=} approvalDefs
+ * @property {boolean=} restrictToKnown
+ */
+
+
+/**
+ * A PresentationConfig Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} PresentationConfig
+ * @property {string=} projectThumbnailUrl
+ * @property {string=} projectSummary
+ * @property {string=} customIssueEntryUrl
+ * @property {string=} defaultQuery
+ * @property {Array<SavedQuery>=} savedQueries
+ * @property {string=} revisionUrlFormat
+ * @property {string=} defaultColSpec
+ * @property {string=} defaultSortSpec
+ * @property {string=} defaultXAttr
+ * @property {string=} defaultYAttr
+ */
+
+/**
+ * A TemplateDef Object returned by the pRPC API project_objects.proto.
+ *
+ * @typedef {Object} TemplateDef
+ * @property {string} templateName
+ * @property {string=} content
+ * @property {string=} summary
+ * @property {boolean=} summaryMustBeEdited
+ * @property {UserRef=} ownerRef
+ * @property {StatusRef=} statusRef
+ * @property {Array<LabelRef>=} labelRefs
+ * @property {boolean=} membersOnly
+ * @property {boolean=} ownerDefaultsToMember
+ * @property {Array<UserRef>=} adminRefs
+ * @property {Array<FieldValue>=} fieldValues
+ * @property {Array<ComponentRef>=} componentRefs
+ * @property {boolean=} componentRequired
+ * @property {Array<Approval>=} approvalValues
+ * @property {Array<PhaseDef>=} phases
+ */
+
+
+/**
+ * Types defined in features_objects.proto.
+ */
+
+/**
+ * A Hotlist Object returned by the pRPC API features_objects.proto.
+ *
+ * @typedef {Object} HotlistV0
+ * @property {UserRef=} ownerRef
+ * @property {string=} name
+ * @property {string=} summary
+ * @property {string=} description
+ * @property {string=} defaultColSpec
+ * @property {boolean=} isPrivate
+ */
+
+/**
+ * A Hotlist Object returned by the pRPC v3 API from feature_objects.proto.
+ *
+ * @typedef {Object} Hotlist
+ * @property {string} name
+ * @property {string=} displayName
+ * @property {string=} owner
+ * @property {Array<string>=} editors
+ * @property {string=} summary
+ * @property {string=} description
+ * @property {Array<IssuesListColumn>=} defaultColumns
+ * @property {string=} hotlistPrivacy
+ */
+
+/**
+ * A HotlistItem Object returned by the pRPC API features_objects.proto.
+ *
+ * @typedef {Object} HotlistItemV0
+ * @property {Issue=} issue
+ * @property {number=} rank
+ * @property {UserRef=} adderRef
+ * @property {number=} addedTimestamp
+ * @property {string=} note
+ */
+
+/**
+ * A HotlistItem Object returned by the pRPC v3 API from feature_objects.proto.
+ *
+ * @typedef {Object} HotlistItem
+ * @property {string=} name
+ * @property {string=} issue
+ * @property {number=} rank
+ * @property {string=} adder
+ * @property {string=} createTime
+ * @property {string=} note
+ */
+
+/**
+ * Types defined in user_objects.proto.
+ */
+
+/**
+ * A User Object returned by the pRPC API user_objects.proto.
+ *
+ * @typedef {Object} UserV0
+ * @property {string=} displayName
+ * @property {number=} userId
+ * @property {boolean=} isSiteAdmin
+ * @property {string=} availability
+ * @property {UserRef=} linkedParentRef
+ * @property {Array<UserRef>=} linkedChildRefs
+ */
+
+/**
+ * A User Object returned by the pRPC v3 API from user_objects.proto.
+ *
+ * @typedef {Object} User
+ * @property {string=} name
+ * @property {string=} displayName
+ * @property {string=} availabilityMessage
+ */
+
+/**
+ * A ProjectStar Object returned by the pRPC v3 API from user_objects.proto.
+ *
+ * @typedef {Object} ProjectStar
+ * @property {string=} name
+ */
+
+/**
+ * A IssueStar Object returned by the pRPC v3 API from user_objects.proto.
+ *
+ * @typedef {Object} IssueStar
+ * @property {string=} name
+ */
+
+/**
+ * Type alias for any Star object.
+ *
+ * @typedef {ProjectStar|IssueStar} Star
+ */