Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/reducers/projectV0.test.js b/static_src/reducers/projectV0.test.js
new file mode 100644
index 0000000..fb1f051
--- /dev/null
+++ b/static_src/reducers/projectV0.test.js
@@ -0,0 +1,944 @@
+// 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 {prpcClient} from 'prpc-client-instance.js';
+import * as projectV0 from './projectV0.js';
+import {store} from './base.js';
+import * as example from 'shared/test/constants-projectV0.js';
+import {restrictionLabelsForPermissions} from 'shared/convertersV0.js';
+import {fieldTypes, SITEWIDE_DEFAULT_COLUMNS} from 'shared/issue-fields.js';
+
+describe('project reducers', () => {
+  it('root reducer initial state', () => {
+    const actual = projectV0.reducer(undefined, {type: null});
+    const expected = {
+      name: null,
+      configs: {},
+      presentationConfigs: {},
+      customPermissions: {},
+      visibleMembers: {},
+      templates: {},
+      requests: {
+        fetchConfig: {
+          error: null,
+          requesting: false,
+        },
+        fetchCustomPermissions: {
+          error: null,
+          requesting: false,
+        },
+        fetchMembers: {
+          error: null,
+          requesting: false,
+        },
+        fetchPresentationConfig: {
+          error: null,
+          requesting: false,
+        },
+        fetchTemplates: {
+          error: null,
+          requesting: false,
+        },
+      },
+    };
+    assert.deepEqual(actual, expected);
+  });
+
+  it('name', () => {
+    const action = {type: projectV0.SELECT, projectName: example.PROJECT_NAME};
+    assert.deepEqual(projectV0.nameReducer(null, action), example.PROJECT_NAME);
+  });
+
+  it('configs updates when fetching Config', () => {
+    const action = {
+      type: projectV0.FETCH_CONFIG_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      config: example.CONFIG,
+    };
+    const expected = {[example.PROJECT_NAME]: example.CONFIG};
+    assert.deepEqual(projectV0.configsReducer({}, action), expected);
+  });
+
+  it('customPermissions', () => {
+    const action = {
+      type: projectV0.FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      permissions: example.CUSTOM_PERMISSIONS,
+    };
+    const expected = {[example.PROJECT_NAME]: example.CUSTOM_PERMISSIONS};
+    assert.deepEqual(projectV0.customPermissionsReducer({}, action), expected);
+  });
+
+  it('presentationConfigs', () => {
+    const action = {
+      type: projectV0.FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      presentationConfig: example.PRESENTATION_CONFIG,
+    };
+    const expected = {[example.PROJECT_NAME]: example.PRESENTATION_CONFIG};
+    assert.deepEqual(projectV0.presentationConfigsReducer({}, action),
+      expected);
+  });
+
+  it('visibleMembers', () => {
+    const action = {
+      type: projectV0.FETCH_VISIBLE_MEMBERS_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      visibleMembers: example.VISIBLE_MEMBERS,
+    };
+    const expected = {[example.PROJECT_NAME]: example.VISIBLE_MEMBERS};
+    assert.deepEqual(projectV0.visibleMembersReducer({}, action), expected);
+  });
+
+  it('templates', () => {
+    const action = {
+      type: projectV0.FETCH_TEMPLATES_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      templates: [example.TEMPLATE_DEF],
+    };
+    const expected = {[example.PROJECT_NAME]: [example.TEMPLATE_DEF]};
+    assert.deepEqual(projectV0.templatesReducer({}, action), expected);
+  });
+});
+
+describe('project selectors', () => {
+  it('viewedProjectName', () => {
+    const actual = projectV0.viewedProjectName(example.STATE);
+    assert.deepEqual(actual, example.PROJECT_NAME);
+  });
+
+  it('viewedVisibleMembers', () => {
+    assert.deepEqual(projectV0.viewedVisibleMembers({}), {});
+    assert.deepEqual(projectV0.viewedVisibleMembers({projectV0: {}}), {});
+    assert.deepEqual(projectV0.viewedVisibleMembers(
+        {projectV0: {visibleMembers: {}}}), {});
+    const actual = projectV0.viewedVisibleMembers(example.STATE);
+    assert.deepEqual(actual, example.VISIBLE_MEMBERS);
+  });
+
+  it('viewedCustomPermissions', () => {
+    assert.deepEqual(projectV0.viewedCustomPermissions({}), []);
+    assert.deepEqual(projectV0.viewedCustomPermissions({projectV0: {}}), []);
+    assert.deepEqual(projectV0.viewedCustomPermissions(
+        {projectV0: {customPermissions: {}}}), []);
+    const actual = projectV0.viewedCustomPermissions(example.STATE);
+    assert.deepEqual(actual, example.CUSTOM_PERMISSIONS);
+  });
+
+  it('viewedPresentationConfig', () => {
+    assert.deepEqual(projectV0.viewedPresentationConfig({}), {});
+    assert.deepEqual(projectV0.viewedPresentationConfig({projectV0: {}}), {});
+    const actual = projectV0.viewedPresentationConfig(example.STATE);
+    assert.deepEqual(actual, example.PRESENTATION_CONFIG);
+  });
+
+  it('defaultColumns', () => {
+    assert.deepEqual(projectV0.defaultColumns({}), SITEWIDE_DEFAULT_COLUMNS);
+    assert.deepEqual(
+        projectV0.defaultColumns({projectV0: {}}), SITEWIDE_DEFAULT_COLUMNS);
+    assert.deepEqual(
+        projectV0.defaultColumns({projectV0: {presentationConfig: {}}}),
+        SITEWIDE_DEFAULT_COLUMNS);
+    const expected = ['ID', 'Summary', 'AllLabels'];
+    assert.deepEqual(projectV0.defaultColumns(example.STATE), expected);
+  });
+
+  it('defaultQuery', () => {
+    assert.deepEqual(projectV0.defaultQuery({}), '');
+    assert.deepEqual(projectV0.defaultQuery({projectV0: {}}), '');
+    const actual = projectV0.defaultQuery(example.STATE);
+    assert.deepEqual(actual, example.DEFAULT_QUERY);
+  });
+
+  it('fieldDefs', () => {
+    assert.deepEqual(projectV0.fieldDefs({projectV0: {}}), []);
+    assert.deepEqual(projectV0.fieldDefs({projectV0: {config: {}}}), []);
+    const actual = projectV0.fieldDefs(example.STATE);
+    assert.deepEqual(actual, example.FIELD_DEFS);
+  });
+
+  it('labelDefMap', () => {
+    const labelDefs = (permissions) =>
+        restrictionLabelsForPermissions(permissions).map((labelDef) =>
+            [labelDef.label.toLowerCase(), labelDef]);
+
+    assert.deepEqual(
+      projectV0.labelDefMap({projectV0: {}}), new Map(labelDefs([])));
+    assert.deepEqual(
+      projectV0.labelDefMap({projectV0: {config: {}}}), new Map(labelDefs([])));
+    const expected = new Map([
+      ['one', {label: 'One'}],
+      ['enum', {label: 'EnUm'}],
+      ['enum-options', {label: 'eNuM-Options'}],
+      ['hello-world', {label: 'hello-world', docstring: 'hmmm'}],
+      ['hello-me', {label: 'hello-me', docstring: 'hmmm'}],
+      ...labelDefs(example.CUSTOM_PERMISSIONS),
+    ]);
+    assert.deepEqual(projectV0.labelDefMap(example.STATE), expected);
+  });
+
+  it('labelPrefixValueMap', () => {
+    const builtInLabelPrefixes = [
+      ['Restrict', new Set(['View-EditIssue', 'AddIssueComment-EditIssue'])],
+      ['Restrict-View', new Set(['EditIssue'])],
+      ['Restrict-AddIssueComment', new Set(['EditIssue'])],
+    ];
+    assert.deepEqual(projectV0.labelPrefixValueMap({projectV0: {}}),
+        new Map(builtInLabelPrefixes));
+
+    assert.deepEqual(projectV0.labelPrefixValueMap(
+        {projectV0: {config: {}}}), new Map(builtInLabelPrefixes));
+
+    const expected = new Map([
+      ['Restrict', new Set(['View-Google', 'View-Security', 'EditIssue-Google',
+          'EditIssue-Security', 'AddIssueComment-Google',
+          'AddIssueComment-Security', 'DeleteIssue-Google',
+          'DeleteIssue-Security', 'FlagSpam-Google', 'FlagSpam-Security',
+          'View-EditIssue', 'AddIssueComment-EditIssue'])],
+      ['Restrict-View', new Set(['Google', 'Security', 'EditIssue'])],
+      ['Restrict-EditIssue', new Set(['Google', 'Security'])],
+      ['Restrict-AddIssueComment', new Set(['Google', 'Security', 'EditIssue'])],
+      ['Restrict-DeleteIssue', new Set(['Google', 'Security'])],
+      ['Restrict-FlagSpam', new Set(['Google', 'Security'])],
+      ['eNuM', new Set(['Options'])],
+      ['hello', new Set(['world', 'me'])],
+    ]);
+    assert.deepEqual(projectV0.labelPrefixValueMap(example.STATE), expected);
+  });
+
+  it('labelPrefixFields', () => {
+    const fields1 = projectV0.labelPrefixFields({projectV0: {}});
+    assert.deepEqual(fields1, ['Restrict']);
+    const fields2 = projectV0.labelPrefixFields({projectV0: {config: {}}});
+    assert.deepEqual(fields2, ['Restrict']);
+    const expected = [
+      'hello', 'Restrict', 'Restrict-View', 'Restrict-EditIssue',
+      'Restrict-AddIssueComment', 'Restrict-DeleteIssue', 'Restrict-FlagSpam'
+    ];
+    assert.deepEqual(projectV0.labelPrefixFields(example.STATE), expected);
+  });
+
+  it('enumFieldDefs', () => {
+    assert.deepEqual(projectV0.enumFieldDefs({projectV0: {}}), []);
+    assert.deepEqual(projectV0.enumFieldDefs({projectV0: {config: {}}}), []);
+    const expected = [example.FIELD_DEF_ENUM];
+    assert.deepEqual(projectV0.enumFieldDefs(example.STATE), expected);
+  });
+
+  it('optionsPerEnumField', () => {
+    assert.deepEqual(projectV0.optionsPerEnumField({projectV0: {}}), new Map());
+    const expected = new Map([
+      ['enum', [
+        {label: 'eNuM-Options', optionName: 'Options'},
+      ]],
+    ]);
+    assert.deepEqual(projectV0.optionsPerEnumField(example.STATE), expected);
+  });
+
+  it('viewedPresentationConfigLoaded', () => {
+    const loadConfigAction = {
+      type: projectV0.FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName: example.PROJECT_NAME,
+      presentationConfig: example.PRESENTATION_CONFIG,
+    };
+    const selectProjectAction = {
+      type: projectV0.SELECT,
+      projectName: example.PROJECT_NAME,
+    };
+    let projectState = {};
+
+    assert.equal(false, projectV0.viewedPresentationConfigLoaded(
+        {projectV0: projectState}));
+
+    projectState = projectV0.reducer(projectState, selectProjectAction);
+    projectState = projectV0.reducer(projectState, loadConfigAction);
+
+    assert.equal(true, projectV0.viewedPresentationConfigLoaded(
+        {projectV0: projectState}));
+  });
+
+  it('fetchingPresentationConfig', () => {
+    const projectState = projectV0.reducer(undefined, {type: null});
+    assert.equal(false,
+        projectState.requests.fetchPresentationConfig.requesting);
+  });
+
+  describe('extractTypeForFieldName', () => {
+    let typeExtractor;
+
+    describe('built-in fields', () => {
+      beforeEach(() => {
+        typeExtractor = projectV0.extractTypeForFieldName({});
+      });
+
+      it('not case sensitive', () => {
+        assert.deepEqual(typeExtractor('id'), fieldTypes.ISSUE_TYPE);
+        assert.deepEqual(typeExtractor('iD'), fieldTypes.ISSUE_TYPE);
+        assert.deepEqual(typeExtractor('Id'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for ID', () => {
+        assert.deepEqual(typeExtractor('ID'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for Project', () => {
+        assert.deepEqual(typeExtractor('Project'), fieldTypes.PROJECT_TYPE);
+      });
+
+      it('gets type for Attachments', () => {
+        assert.deepEqual(typeExtractor('Attachments'), fieldTypes.INT_TYPE);
+      });
+
+      it('gets type for AllLabels', () => {
+        assert.deepEqual(typeExtractor('AllLabels'), fieldTypes.LABEL_TYPE);
+      });
+
+      it('gets type for AllLabels', () => {
+        assert.deepEqual(typeExtractor('AllLabels'), fieldTypes.LABEL_TYPE);
+      });
+
+      it('gets type for Blocked', () => {
+        assert.deepEqual(typeExtractor('Blocked'), fieldTypes.STR_TYPE);
+      });
+
+      it('gets type for BlockedOn', () => {
+        assert.deepEqual(typeExtractor('BlockedOn'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for Blocking', () => {
+        assert.deepEqual(typeExtractor('Blocking'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for CC', () => {
+        assert.deepEqual(typeExtractor('CC'), fieldTypes.USER_TYPE);
+      });
+
+      it('gets type for Closed', () => {
+        assert.deepEqual(typeExtractor('Closed'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Component', () => {
+        assert.deepEqual(typeExtractor('Component'), fieldTypes.COMPONENT_TYPE);
+      });
+
+      it('gets type for ComponentModified', () => {
+        assert.deepEqual(typeExtractor('ComponentModified'),
+            fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for MergedInto', () => {
+        assert.deepEqual(typeExtractor('MergedInto'), fieldTypes.ISSUE_TYPE);
+      });
+
+      it('gets type for Modified', () => {
+        assert.deepEqual(typeExtractor('Modified'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Reporter', () => {
+        assert.deepEqual(typeExtractor('Reporter'), fieldTypes.USER_TYPE);
+      });
+
+      it('gets type for Stars', () => {
+        assert.deepEqual(typeExtractor('Stars'), fieldTypes.INT_TYPE);
+      });
+
+      it('gets type for Status', () => {
+        assert.deepEqual(typeExtractor('Status'), fieldTypes.STATUS_TYPE);
+      });
+
+      it('gets type for StatusModified', () => {
+        assert.deepEqual(typeExtractor('StatusModified'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Summary', () => {
+        assert.deepEqual(typeExtractor('Summary'), fieldTypes.STR_TYPE);
+      });
+
+      it('gets type for Type', () => {
+        assert.deepEqual(typeExtractor('Type'), fieldTypes.ENUM_TYPE);
+      });
+
+      it('gets type for Owner', () => {
+        assert.deepEqual(typeExtractor('Owner'), fieldTypes.USER_TYPE);
+      });
+
+      it('gets type for OwnerModified', () => {
+        assert.deepEqual(typeExtractor('OwnerModified'), fieldTypes.TIME_TYPE);
+      });
+
+      it('gets type for Opened', () => {
+        assert.deepEqual(typeExtractor('Opened'), fieldTypes.TIME_TYPE);
+      });
+    });
+
+    it('gets types for custom fields', () => {
+      typeExtractor = projectV0.extractTypeForFieldName({projectV0: {
+        name: example.PROJECT_NAME,
+        configs: {[example.PROJECT_NAME]: {fieldDefs: [
+          {fieldRef: {fieldName: 'CustomIntField', type: 'INT_TYPE'}},
+          {fieldRef: {fieldName: 'CustomStrField', type: 'STR_TYPE'}},
+          {fieldRef: {fieldName: 'CustomUserField', type: 'USER_TYPE'}},
+          {fieldRef: {fieldName: 'CustomEnumField', type: 'ENUM_TYPE'}},
+          {fieldRef: {fieldName: 'CustomApprovalField',
+            type: 'APPROVAL_TYPE'}},
+        ]}},
+      }});
+
+      assert.deepEqual(typeExtractor('CustomIntField'), fieldTypes.INT_TYPE);
+      assert.deepEqual(typeExtractor('CustomStrField'), fieldTypes.STR_TYPE);
+      assert.deepEqual(typeExtractor('CustomUserField'), fieldTypes.USER_TYPE);
+      assert.deepEqual(typeExtractor('CustomEnumField'), fieldTypes.ENUM_TYPE);
+      assert.deepEqual(typeExtractor('CustomApprovalField'),
+          fieldTypes.APPROVAL_TYPE);
+    });
+
+    it('defaults to string type for other fields', () => {
+      typeExtractor = projectV0.extractTypeForFieldName({projectV0: {
+        name: example.PROJECT_NAME,
+        configs: {[example.PROJECT_NAME]: {fieldDefs: [
+          {fieldRef: {fieldName: 'CustomIntField', type: 'INT_TYPE'}},
+          {fieldRef: {fieldName: 'CustomUserField', type: 'USER_TYPE'}},
+        ]}},
+      }});
+
+      assert.deepEqual(typeExtractor('FakeUserField'), fieldTypes.STR_TYPE);
+      assert.deepEqual(typeExtractor('NotOwner'), fieldTypes.STR_TYPE);
+    });
+  });
+
+  describe('extractFieldValuesFromIssue', () => {
+    let clock;
+    let issue;
+    let fieldExtractor;
+
+    describe('built-in fields', () => {
+      beforeEach(() => {
+        // Built-in fields will always act the same, regardless of
+        // project config.
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({});
+
+        // Set clock to some specified date for relative time.
+        const initialTime = 365 * 24 * 60 * 60;
+
+        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'},
+        };
+
+        clock = sinon.useFakeTimers({
+          now: new Date(initialTime * 1000),
+          shouldAdvanceTime: false,
+        });
+      });
+
+      afterEach(() => {
+        clock.restore();
+      });
+
+      it('computes strings for ID', () => {
+        const fieldName = 'ID';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:33']);
+      });
+
+      it('computes strings for Project', () => {
+        const fieldName = 'Project';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium']);
+      });
+
+      it('computes strings for Attachments', () => {
+        const fieldName = 'Attachments';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['22']);
+      });
+
+      it('computes strings for AllLabels', () => {
+        const fieldName = 'AllLabels';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Restrict-View-Google', 'Type-Defect']);
+      });
+
+      it('computes strings for Blocked when issue is blocked', () => {
+        const fieldName = 'Blocked';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Yes']);
+      });
+
+      it('computes strings for Blocked when issue is not blocked', () => {
+        const fieldName = 'Blocked';
+        issue.blockedOnIssueRefs = [];
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['No']);
+      });
+
+      it('computes strings for BlockedOn', () => {
+        const fieldName = 'BlockedOn';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:30']);
+      });
+
+      it('computes strings for Blocking', () => {
+        const fieldName = 'Blocking';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:60']);
+      });
+
+      it('computes strings for CC', () => {
+        const fieldName = 'CC';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['test@example.com']);
+      });
+
+      it('computes strings for Closed', () => {
+        const fieldName = 'Closed';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['2 minutes ago']);
+      });
+
+      it('computes strings for Component', () => {
+        const fieldName = 'Component';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Infra', 'Monorail>UI']);
+      });
+
+      it('computes strings for ComponentModified', () => {
+        const fieldName = 'ComponentModified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for MergedInto', () => {
+        const fieldName = 'MergedInto';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['chromium:31']);
+      });
+
+      it('computes strings for Modified', () => {
+        const fieldName = 'Modified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for Reporter', () => {
+        const fieldName = 'Reporter';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['test@example.com']);
+      });
+
+      it('computes strings for Stars', () => {
+        const fieldName = 'Stars';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['2']);
+      });
+
+      it('computes strings for Status', () => {
+        const fieldName = 'Status';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Duplicate']);
+      });
+
+      it('computes strings for StatusModified', () => {
+        const fieldName = 'StatusModified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for Summary', () => {
+        const fieldName = 'Summary';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Test summary']);
+      });
+
+      it('computes strings for Type', () => {
+        const fieldName = 'Type';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['Defect']);
+      });
+
+      it('computes strings for Owner', () => {
+        const fieldName = 'Owner';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['owner@example.com']);
+      });
+
+      it('computes strings for OwnerModified', () => {
+        const fieldName = 'OwnerModified';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a minute ago']);
+      });
+
+      it('computes strings for Opened', () => {
+        const fieldName = 'Opened';
+
+        assert.deepEqual(fieldExtractor(issue, fieldName),
+            ['a day ago']);
+      });
+    });
+
+    describe('custom approval fields', () => {
+      beforeEach(() => {
+        const fieldDefs = [
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Goose-Approval'}},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Chicken-Approval'}},
+          {fieldRef: {type: 'APPROVAL_TYPE', fieldName: 'Dodo-Approval'}},
+        ];
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({
+          projectV0: {
+            name: example.PROJECT_NAME,
+            configs: {
+              [example.PROJECT_NAME]: {
+                projectName: 'chromium',
+                fieldDefs,
+              },
+            },
+          },
+        });
+
+        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', () => {
+        assert.deepEqual(fieldExtractor(issue, 'goose-approval-approver'), []);
+        assert.deepEqual(fieldExtractor(issue, 'chicken-approval-approver'),
+            []);
+        assert.deepEqual(fieldExtractor(issue, 'dodo-approval-approver'),
+            ['kiwi@bird.test', 'mini-dino@bird.test']);
+      });
+
+      it('handles approval value columns', () => {
+        assert.deepEqual(fieldExtractor(issue, 'goose-approval'), ['NotSet']);
+        assert.deepEqual(fieldExtractor(issue, 'chicken-approval'),
+            ['Approved']);
+        assert.deepEqual(fieldExtractor(issue, 'dodo-approval'),
+            ['NeedInfo']);
+      });
+    });
+
+    describe('custom fields', () => {
+      beforeEach(() => {
+        const fieldDefs = [
+          {fieldRef: {type: 'STR_TYPE', fieldName: 'aString'}},
+          {fieldRef: {type: 'ENUM_TYPE', fieldName: 'ENUM'}},
+          {fieldRef: {type: 'INT_TYPE', fieldName: 'Cow-Number'},
+            bool_is_phase_field: true, is_multivalued: true},
+        ];
+        // As a label prefix, aString conflicts with the custom field named
+        // "aString". In this case, Monorail gives precedence to the
+        // custom field.
+        const labelDefs = [
+          {label: 'aString-ignore'},
+          {label: 'aString-two'},
+        ];
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({
+          projectV0: {
+            name: example.PROJECT_NAME,
+            configs: {
+              [example.PROJECT_NAME]: {
+                projectName: 'chromium',
+                fieldDefs,
+                labelDefs,
+              },
+            },
+          },
+        });
+
+        const 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'},
+        ];
+
+        issue = {
+          localId: 33,
+          projectName: 'chromium',
+          fieldValues,
+        };
+      });
+
+      it('gets values for custom fields', () => {
+        assert.deepEqual(fieldExtractor(issue, 'aString'), ['test', 'test2']);
+        assert.deepEqual(fieldExtractor(issue, 'enum'), ['a-value']);
+        assert.deepEqual(fieldExtractor(issue, 'cow-phase.cow-number'),
+            ['55', '54']);
+        assert.deepEqual(fieldExtractor(issue, 'milkcow-phase.cow-number'),
+            ['56']);
+      });
+
+      it('custom fields get precedence over label fields', () => {
+        issue.labelRefs = [{label: 'aString-ignore'}];
+        assert.deepEqual(fieldExtractor(issue, 'aString'),
+            ['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'},
+          ],
+        };
+
+        fieldExtractor = projectV0.extractFieldValuesFromIssue({
+          projectV0: {
+            name: example.PROJECT_NAME,
+            configs: {
+              [example.PROJECT_NAME]: {
+                projectName: 'chromium',
+                labelDefs: [
+                  {label: 'test-1'},
+                  {label: 'test-2'},
+                  {label: 'milestone-1'},
+                  {label: 'milestone-2'},
+                ],
+              },
+            },
+          },
+        });
+      });
+
+      it('gets values for label prefixes', () => {
+        assert.deepEqual(fieldExtractor(issue, 'test'), ['label', 'label-2']);
+        assert.deepEqual(fieldExtractor(issue, 'Milestone'), ['UI', 'Goodies']);
+      });
+    });
+  });
+
+  it('fieldDefsByApprovalName', () => {
+    assert.deepEqual(projectV0.fieldDefsByApprovalName({projectV0: {}}),
+        new Map());
+
+    assert.deepEqual(projectV0.fieldDefsByApprovalName({projectV0: {
+      name: example.PROJECT_NAME,
+      configs: {[example.PROJECT_NAME]: {
+        fieldDefs: [
+          {fieldRef: {fieldName: 'test', type: fieldTypes.INT_TYPE}},
+          {fieldRef: {fieldName: 'ignoreMe', type: fieldTypes.APPROVAL_TYPE}},
+          {fieldRef: {fieldName: 'yay', approvalName: 'ThisIsAnApproval'}},
+          {fieldRef: {fieldName: 'ImAField', approvalName: 'ThisIsAnApproval'}},
+          {fieldRef: {fieldName: 'TalkToALawyer', approvalName: 'Legal'}},
+        ],
+      }},
+    }}), new Map([
+      ['ThisIsAnApproval', [
+        {fieldRef: {fieldName: 'yay', approvalName: 'ThisIsAnApproval'}},
+        {fieldRef: {fieldName: 'ImAField', approvalName: 'ThisIsAnApproval'}},
+      ]],
+      ['Legal', [
+        {fieldRef: {fieldName: 'TalkToALawyer', approvalName: 'Legal'}},
+      ]],
+    ]));
+  });
+});
+
+let dispatch;
+
+describe('project action creators', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+
+    dispatch = sinon.stub();
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  it('select', () => {
+    projectV0.select('project-name')(dispatch);
+    const action = {type: projectV0.SELECT, projectName: 'project-name'};
+    sinon.assert.calledWith(dispatch, action);
+  });
+
+  it('fetchCustomPermissions', async () => {
+    const action = projectV0.fetchCustomPermissions('chromium');
+
+    prpcClient.call.returns(Promise.resolve({permissions: ['google']}));
+
+    await action(dispatch);
+
+    sinon.assert.calledWith(dispatch,
+        {type: projectV0.FETCH_CUSTOM_PERMISSIONS_START});
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Projects',
+        'GetCustomPermissions',
+        {projectName: 'chromium'});
+
+    sinon.assert.calledWith(dispatch, {
+      type: projectV0.FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+      projectName: 'chromium',
+      permissions: ['google'],
+    });
+  });
+
+  it('fetchPresentationConfig', async () => {
+    const action = projectV0.fetchPresentationConfig('chromium');
+
+    prpcClient.call.returns(Promise.resolve({projectThumbnailUrl: 'test'}));
+
+    await action(dispatch);
+
+    sinon.assert.calledWith(dispatch,
+        {type: projectV0.FETCH_PRESENTATION_CONFIG_START});
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Projects',
+        'GetPresentationConfig',
+        {projectName: 'chromium'});
+
+    sinon.assert.calledWith(dispatch, {
+      type: projectV0.FETCH_PRESENTATION_CONFIG_SUCCESS,
+      projectName: 'chromium',
+      presentationConfig: {projectThumbnailUrl: 'test'},
+    });
+  });
+
+  it('fetchVisibleMembers', async () => {
+    const action = projectV0.fetchVisibleMembers('chromium');
+
+    prpcClient.call.returns(Promise.resolve({userRefs: [{userId: '123'}]}));
+
+    await action(dispatch);
+
+    sinon.assert.calledWith(dispatch,
+        {type: projectV0.FETCH_VISIBLE_MEMBERS_START});
+
+    sinon.assert.calledWith(
+        prpcClient.call,
+        'monorail.Projects',
+        'GetVisibleMembers',
+        {projectName: 'chromium'});
+
+    sinon.assert.calledWith(dispatch, {
+      type: projectV0.FETCH_VISIBLE_MEMBERS_SUCCESS,
+      projectName: 'chromium',
+      visibleMembers: {userRefs: [{userId: '123'}]},
+    });
+  });
+});
+
+describe('helpers', () => {
+  beforeEach(() => {
+    sinon.stub(prpcClient, 'call');
+  });
+
+  afterEach(() => {
+    prpcClient.call.restore();
+  });
+
+  describe('fetchFieldPerms', () => {
+    it('fetch field permissions', async () => {
+      const projectName = 'proj';
+      const fieldDefs = [
+        {
+          fieldRef: {
+            fieldName: 'testField',
+            fieldId: 1,
+            type: 'ENUM_TYPE',
+          },
+        },
+      ];
+      const response = {};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      await store.dispatch(projectV0.fetchFieldPerms(projectName, fieldDefs));
+
+      const args = {names: ['projects/proj/fieldDefs/1']};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Permissions',
+          'BatchGetPermissionSets', args);
+    });
+
+    it('fetch with no fieldDefs', async () => {
+      const config = {projectName: 'proj'};
+      const response = {};
+      prpcClient.call.returns(Promise.resolve(response));
+
+      // fieldDefs will be undefined.
+      await store.dispatch(projectV0.fetchFieldPerms(
+          config.projectName, config.fieldDefs));
+
+      const args = {names: []};
+      sinon.assert.calledWith(
+          prpcClient.call, 'monorail.v3.Permissions',
+          'BatchGetPermissionSets', args);
+    });
+  });
+});