Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/reducers/base.js b/static_src/reducers/base.js
new file mode 100644
index 0000000..f4603b7
--- /dev/null
+++ b/static_src/reducers/base.js
@@ -0,0 +1,109 @@
+// 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 {connect} from 'pwa-helpers/connect-mixin.js';
+import {applyMiddleware, combineReducers, compose, createStore} from 'redux';
+import thunk from 'redux-thunk';
+import {hotlists} from './hotlists.js';
+import * as issueV0 from './issueV0.js';
+import * as permissions from './permissions.js';
+import * as projects from './projects.js';
+import * as projectV0 from './projectV0.js';
+import * as sitewide from './sitewide.js';
+import {stars} from './stars.js';
+import * as users from './users.js';
+import * as userV0 from './userV0.js';
+import * as ui from './ui.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+const RESET_STATE = 'RESET_STATE';
+
+/* State Shape
+{
+ hotlists: Object,
+ permissions: Object,
+ projects: Object,
+ sitewide: Object,
+ users: Object,
+
+ ui: Object,
+
+ // To be deprecated
+ issue: Object,
+ projectV0: Object,
+ userV0: Object,
+}
+*/
+
+// Reducers
+const reducer = combineReducers({
+ hotlists: hotlists.reducer,
+ issue: issueV0.reducer,
+ permissions: permissions.reducer,
+ projects: projects.reducer,
+ projectV0: projectV0.reducer,
+ users: users.reducer,
+ userV0: userV0.reducer,
+ sitewide: sitewide.reducer,
+ stars: stars.reducer,
+
+ ui: ui.reducer,
+});
+
+/**
+ * The top level reducer function that all actions pass through.
+ * @param {any} state
+ * @param {AnyAction} action
+ * @return {any}
+ */
+export function rootReducer(state, action) {
+ if (action.type === RESET_STATE) {
+ state = undefined;
+ }
+ return reducer(state, action);
+}
+
+// Selectors
+
+// Action Creators
+
+/**
+ * Changes Redux state back to its default initial state. Primarily
+ * used in testing.
+ * @return {AnyAction} An action to reset Redux state to default.
+ */
+export const resetState = () => ({type: RESET_STATE});
+
+// Store
+
+// For debugging with the Redux Devtools extension:
+// https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd/
+const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
+export const store = createStore(rootReducer, composeEnhancers(
+ applyMiddleware(thunk),
+));
+
+/**
+ * Class mixin function that connects a given HTMLElement class to our
+ * store instance.
+ * @link https://pwa-starter-kit.polymer-project.org/redux-and-state-management#connecting-an-element-to-the-store
+ * @param {typeof HTMLElement} class
+ * @return {function} New class type with connected features.
+ */
+export const connectStore = connect(store);
+
+/**
+ * Promise to allow waiting for a state update. Useful in testing.
+ * @example
+ * store.dispatch(updateState());
+ * await stateUpdated;
+ * doThingWithUpdatedState();
+ *
+ * @type {Promise}
+ */
+export const stateUpdated = new Promise((resolve) => {
+ store.subscribe(resolve);
+});
diff --git a/static_src/reducers/hotlists.js b/static_src/reducers/hotlists.js
new file mode 100644
index 0000000..95989cc
--- /dev/null
+++ b/static_src/reducers/hotlists.js
@@ -0,0 +1,517 @@
+// 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 Hotlist actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving hotlist state
+ * on the frontend.
+ *
+ * The Hotlist data is stored in a normalized format.
+ * `name` is a reference to the currently viewed Hotlist.
+ * `hotlists` stores all Hotlist data indexed by Hotlist name.
+ * `hotlistItems` stores all Hotlist items indexed by Hotlist name.
+ * `hotlist` is a selector that gets the currently viewed Hotlist data.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import {userIdOrDisplayNameToUserRef, issueNameToRef}
+ from 'shared/convertersV0.js';
+import {pathsToFieldMask} from 'shared/converters.js';
+
+import * as issueV0 from './issueV0.js';
+import * as permissions from './permissions.js';
+import * as sitewide from './sitewide.js';
+import * as ui from './ui.js';
+import * as users from './users.js';
+
+import 'shared/typedef.js';
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+/** @type {Array<string>} */
+export const DEFAULT_COLUMNS = [
+ 'Rank', 'ID', 'Status', 'Owner', 'Summary', 'Modified',
+];
+
+// Permissions
+// TODO(crbug.com/monorail/7879): Move these to a permissions constants file.
+export const EDIT = 'HOTLIST_EDIT';
+export const ADMINISTER = 'HOTLIST_ADMINISTER';
+
+// Actions
+export const SELECT = 'hotlist/SELECT';
+export const RECEIVE_HOTLIST = 'hotlist/RECEIVE_HOTLIST';
+
+export const DELETE_START = 'hotlist/DELETE_START';
+export const DELETE_SUCCESS = 'hotlist/DELETE_SUCCESS';
+export const DELETE_FAILURE = 'hotlist/DELETE_FAILURE';
+
+export const FETCH_START = 'hotlist/FETCH_START';
+export const FETCH_SUCCESS = 'hotlist/FETCH_SUCCESS';
+export const FETCH_FAILURE = 'hotlist/FETCH_FAILURE';
+
+export const FETCH_ITEMS_START = 'hotlist/FETCH_ITEMS_START';
+export const FETCH_ITEMS_SUCCESS = 'hotlist/FETCH_ITEMS_SUCCESS';
+export const FETCH_ITEMS_FAILURE = 'hotlist/FETCH_ITEMS_FAILURE';
+
+export const REMOVE_EDITORS_START = 'hotlist/REMOVE_EDITORS_START';
+export const REMOVE_EDITORS_SUCCESS = 'hotlist/REMOVE_EDITORS_SUCCESS';
+export const REMOVE_EDITORS_FAILURE = 'hotlist/REMOVE_EDITORS_FAILURE';
+
+export const REMOVE_ITEMS_START = 'hotlist/REMOVE_ITEMS_START';
+export const REMOVE_ITEMS_SUCCESS = 'hotlist/REMOVE_ITEMS_SUCCESS';
+export const REMOVE_ITEMS_FAILURE = 'hotlist/REMOVE_ITEMS_FAILURE';
+
+export const RERANK_ITEMS_START = 'hotlist/RERANK_ITEMS_START';
+export const RERANK_ITEMS_SUCCESS = 'hotlist/RERANK_ITEMS_SUCCESS';
+export const RERANK_ITEMS_FAILURE = 'hotlist/RERANK_ITEMS_FAILURE';
+
+export const UPDATE_START = 'hotlist/UPDATE_START';
+export const UPDATE_SUCCESS = 'hotlist/UPDATE_SUCCESS';
+export const UPDATE_FAILURE = 'hotlist/UPDATE_FAILURE';
+
+/* State Shape
+{
+ name: string,
+
+ byName: Object<string, Hotlist>,
+ hotlistItems: Object<string, Array<HotlistItem>>,
+
+ requests: {
+ fetch: ReduxRequestState,
+ fetchItems: ReduxRequestState,
+ update: ReduxRequestState,
+ },
+}
+*/
+
+// Reducers
+
+/**
+ * A reference to the currently viewed Hotlist.
+ * @param {?string} state The existing Hotlist resource name.
+ * @param {AnyAction} action
+ * @return {?string}
+ */
+export const nameReducer = createReducer(null, {
+ [SELECT]: (_state, {name}) => name,
+});
+
+/**
+ * All Hotlist data indexed by Hotlist resource name.
+ * @param {Object<string, Hotlist>} state The existing Hotlist data.
+ * @param {AnyAction} action
+ * @param {Hotlist} action.hotlist The Hotlist that was fetched.
+ * @return {Object<string, Hotlist>}
+ */
+export const byNameReducer = createReducer({}, {
+ [RECEIVE_HOTLIST]: (state, {hotlist}) => {
+ if (!hotlist.defaultColumns) hotlist.defaultColumns = [];
+ if (!hotlist.editors) hotlist.editors = [];
+ return {...state, [hotlist.name]: hotlist};
+ },
+});
+
+/**
+ * All Hotlist items indexed by Hotlist resource name.
+ * @param {Object<string, Array<HotlistItem>>} state The existing items.
+ * @param {AnyAction} action
+ * @param {name} action.name The Hotlist resource name.
+ * @param {Array<HotlistItem>} action.items The Hotlist items fetched.
+ * @return {Object<string, Array<HotlistItem>>}
+ */
+export const hotlistItemsReducer = createReducer({}, {
+ [FETCH_ITEMS_SUCCESS]: (state, {name, items}) => ({...state, [name]: items}),
+});
+
+export const requestsReducer = combineReducers({
+ deleteHotlist: createRequestReducer(
+ DELETE_START, DELETE_SUCCESS, DELETE_FAILURE),
+ fetch: createRequestReducer(
+ FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+ fetchItems: createRequestReducer(
+ FETCH_ITEMS_START, FETCH_ITEMS_SUCCESS, FETCH_ITEMS_FAILURE),
+ removeEditors: createRequestReducer(
+ REMOVE_EDITORS_START, REMOVE_EDITORS_SUCCESS, REMOVE_EDITORS_FAILURE),
+ removeItems: createRequestReducer(
+ REMOVE_ITEMS_START, REMOVE_ITEMS_SUCCESS, REMOVE_ITEMS_FAILURE),
+ rerankItems: createRequestReducer(
+ RERANK_ITEMS_START, RERANK_ITEMS_SUCCESS, RERANK_ITEMS_FAILURE),
+ update: createRequestReducer(
+ UPDATE_START, UPDATE_SUCCESS, UPDATE_FAILURE),
+});
+
+export const reducer = combineReducers({
+ name: nameReducer,
+
+ byName: byNameReducer,
+ hotlistItems: hotlistItemsReducer,
+
+ requests: requestsReducer,
+});
+
+// Selectors
+
+/**
+ * Returns the currently viewed Hotlist resource name, or null if there is none.
+ * @param {any} state
+ * @return {?string}
+ */
+export const name = (state) => state.hotlists.name;
+
+/**
+ * Returns all the Hotlist data in the store as a mapping from name to Hotlist.
+ * @param {any} state
+ * @return {Object<string, Hotlist>}
+ */
+export const byName = (state) => state.hotlists.byName;
+
+/**
+ * Returns all the Hotlist items in the store as a mapping from a
+ * Hotlist resource name to its respective array of HotlistItems.
+ * @param {any} state
+ * @return {Object<string, Array<HotlistItem>>}
+ */
+export const hotlistItems = (state) => state.hotlists.hotlistItems;
+
+/**
+ * Returns the currently viewed Hotlist, or null if there is none.
+ * @param {any} state
+ * @return {?Hotlist}
+ */
+export const viewedHotlist = createSelector(
+ [byName, name],
+ (byName, name) => name && byName[name] || null);
+
+/**
+ * Returns the owner of the currently viewed Hotlist, or null if there is none.
+ * @param {any} state
+ * @return {?User}
+ */
+export const viewedHotlistOwner = createSelector(
+ [viewedHotlist, users.byName],
+ (hotlist, usersByName) => {
+ return hotlist && usersByName[hotlist.owner] || null;
+ });
+
+/**
+ * Returns the editors of the currently viewed Hotlist. Returns null if there
+ * is no hotlist data. Includes a null in the array for each editor whose User
+ * data is not in the store.
+ * @param {any} state
+ * @return {Array<User>}
+ */
+export const viewedHotlistEditors = createSelector(
+ [viewedHotlist, users.byName],
+ (hotlist, usersByName) => {
+ if (!hotlist) return null;
+ return hotlist.editors.map((editor) => usersByName[editor] || null);
+ });
+
+/**
+ * Returns an Array containing the items in the currently viewed Hotlist,
+ * or [] if there is no current Hotlist or no Hotlist data.
+ * @param {any} state
+ * @return {Array<HotlistItem>}
+ */
+export const viewedHotlistItems = createSelector(
+ [hotlistItems, name],
+ (hotlistItems, name) => name && hotlistItems[name] || []);
+
+/**
+ * Returns an Array containing the HotlistIssues in the currently viewed
+ * Hotlist, or [] if there is no current Hotlist or no Hotlist data.
+ * A HotlistIssue merges the HotlistItem and Issue into one flat object.
+ * @param {any} state
+ * @return {Array<HotlistIssue>}
+ */
+export const viewedHotlistIssues = createSelector(
+ [viewedHotlistItems, issueV0.issue, users.byName],
+ (items, getIssue, usersByName) => {
+ // Filter out issues that haven't been fetched yet or failed to fetch.
+ // Example: if the user doesn't have permissions to view the issue.
+ // <mr-issue-list> assumes that every Issue is populated.
+ const itemsWithData = items.filter((item) => getIssue(item.issue));
+ return itemsWithData.map((item) => ({
+ ...getIssue(item.issue),
+ ...item,
+ adder: usersByName[item.adder],
+ }));
+ });
+
+/**
+ * Returns the currently viewed Hotlist columns.
+ * @param {any} state
+ * @return {Array<string>}
+ */
+export const viewedHotlistColumns = createSelector(
+ [viewedHotlist, sitewide.currentColumns],
+ (hotlist, sitewideCurrentColumns) => {
+ if (sitewideCurrentColumns) return sitewideCurrentColumns;
+ if (!hotlist) return DEFAULT_COLUMNS;
+ if (!hotlist.defaultColumns.length) return DEFAULT_COLUMNS;
+ return hotlist.defaultColumns.map((col) => col.column);
+ });
+
+/**
+ * Returns the currently viewed Hotlist permissions, or [] if there is none.
+ * @param {any} state
+ * @return {Array<Permission>}
+ */
+export const viewedHotlistPermissions = createSelector(
+ [viewedHotlist, permissions.byName],
+ (hotlist, permissionsByName) => {
+ if (!hotlist) return [];
+ const permissionSet = permissionsByName[hotlist.name];
+ if (!permissionSet) return [];
+ return permissionSet.permissions;
+ });
+
+/**
+ * Returns the Hotlist requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.hotlists.requests;
+
+// Action Creators
+
+/**
+ * Action creator to delete the Hotlist. We would have liked to have named this
+ * `delete` but it's a reserved word in JS.
+ * @param {string} name The resource name of the Hotlist to delete.
+ * @return {function(function): Promise<void>}
+ */
+export const deleteHotlist = (name) => async (dispatch) => {
+ dispatch({type: DELETE_START});
+
+ try {
+ const args = {name};
+ await prpcClient.call('monorail.v3.Hotlists', 'DeleteHotlist', args);
+
+ dispatch({type: DELETE_SUCCESS});
+ } catch (error) {
+ dispatch({type: DELETE_FAILURE, error});
+ };
+};
+
+/**
+ * Action creator to fetch a Hotlist object.
+ * @param {string} name The resource name of the Hotlist to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (name) => async (dispatch) => {
+ dispatch({type: FETCH_START});
+
+ try {
+ /** @type {Hotlist} */
+ const hotlist = await prpcClient.call(
+ 'monorail.v3.Hotlists', 'GetHotlist', {name});
+ dispatch({type: FETCH_SUCCESS});
+ dispatch({type: RECEIVE_HOTLIST, hotlist});
+
+ const editors = hotlist.editors.map((editor) => editor);
+ editors.push(hotlist.owner);
+ await dispatch(users.batchGet(editors));
+ } catch (error) {
+ dispatch({type: FETCH_FAILURE, error});
+ };
+};
+
+/**
+ * Action creator to fetch the items in a Hotlist.
+ * @param {string} name The resource name of the Hotlist to fetch.
+ * @return {function(function): Promise<Array<HotlistItem>>}
+ */
+export const fetchItems = (name) => async (dispatch) => {
+ dispatch({type: FETCH_ITEMS_START});
+
+ try {
+ const args = {parent: name, orderBy: 'rank'};
+ /** @type {{items: Array<HotlistItem>}} */
+ const {items} = await prpcClient.call(
+ 'monorail.v3.Hotlists', 'ListHotlistItems', args);
+ if (!items) {
+ dispatch({type: FETCH_ITEMS_SUCCESS, name, items: []});
+ }
+ const itemsWithRank =
+ items.map((item) => item.rank ? item : {...item, rank: 0});
+
+ const issueRefs = items.map((item) => issueNameToRef(item.issue));
+ await dispatch(issueV0.fetchIssues(issueRefs));
+
+ const adderNames = [...new Set(items.map((item) => item.adder))];
+ await dispatch(users.batchGet(adderNames));
+
+ dispatch({type: FETCH_ITEMS_SUCCESS, name, items: itemsWithRank});
+ return itemsWithRank;
+ } catch (error) {
+ dispatch({type: FETCH_ITEMS_FAILURE, error});
+ };
+};
+
+/**
+ * Action creator to remove editors from a Hotlist.
+ * @param {string} name The resource name of the Hotlist.
+ * @param {Array<string>} editors The resource names of the Users to remove.
+ * @return {function(function): Promise<void>}
+ */
+export const removeEditors = (name, editors) => async (dispatch) => {
+ dispatch({type: REMOVE_EDITORS_START});
+
+ try {
+ const args = {name, editors};
+ await prpcClient.call('monorail.v3.Hotlists', 'RemoveHotlistEditors', args);
+
+ dispatch({type: REMOVE_EDITORS_SUCCESS});
+
+ await dispatch(fetch(name));
+ } catch (error) {
+ dispatch({type: REMOVE_EDITORS_FAILURE, error});
+ };
+};
+
+/**
+ * Action creator to remove items from a Hotlist.
+ * @param {string} name The resource name of the Hotlist.
+ * @param {Array<string>} issues The resource names of the Issues to remove.
+ * @return {function(function): Promise<void>}
+ */
+export const removeItems = (name, issues) => async (dispatch) => {
+ dispatch({type: REMOVE_ITEMS_START});
+
+ try {
+ const args = {parent: name, issues};
+ await prpcClient.call('monorail.v3.Hotlists', 'RemoveHotlistItems', args);
+
+ dispatch({type: REMOVE_ITEMS_SUCCESS});
+
+ await dispatch(fetchItems(name));
+ } catch (error) {
+ dispatch({type: REMOVE_ITEMS_FAILURE, error});
+ };
+};
+
+/**
+ * Action creator to rerank the items in a Hotlist.
+ * @param {string} name The resource name of the Hotlist.
+ * @param {Array<string>} items The resource names of the HotlistItems to move.
+ * @param {number} index The index to insert the moved items.
+ * @return {function(function): Promise<void>}
+ */
+export const rerankItems = (name, items, index) => async (dispatch) => {
+ dispatch({type: RERANK_ITEMS_START});
+
+ try {
+ const args = {name, hotlistItems: items, targetPosition: index};
+ await prpcClient.call('monorail.v3.Hotlists', 'RerankHotlistItems', args);
+
+ dispatch({type: RERANK_ITEMS_SUCCESS});
+
+ await dispatch(fetchItems(name));
+ } catch (error) {
+ dispatch({type: RERANK_ITEMS_FAILURE, error});
+ };
+};
+
+/**
+ * Action creator to set the currently viewed Hotlist.
+ * @param {string} name The resource name of the Hotlist to select.
+ * @return {AnyAction}
+ */
+export const select = (name) => ({type: SELECT, name});
+
+/**
+ * Action creator to update the Hotlist metadata.
+ * @param {string} name The resource name of the Hotlist to delete.
+ * @param {Hotlist} hotlist This represents the updated version of the Hotlist
+ * with only the fields that need to be updated.
+ * @return {function(function): Promise<void>}
+ */
+export const update = (name, hotlist) => async (dispatch) => {
+ dispatch({type: UPDATE_START});
+ try {
+ const paths = pathsToFieldMask(Object.keys(hotlist));
+ const hotlistArg = {...hotlist, name};
+ const args = {hotlist: hotlistArg, updateMask: paths};
+
+ /** @type {Hotlist} */
+ const updatedHotlist = await prpcClient.call(
+ 'monorail.v3.Hotlists', 'UpdateHotlist', args);
+ dispatch({type: UPDATE_SUCCESS});
+ dispatch({type: RECEIVE_HOTLIST, hotlist: updatedHotlist});
+
+ const editors = updatedHotlist.editors.map((editor) => editor);
+ editors.push(updatedHotlist.owner);
+ await dispatch(users.batchGet(editors));
+ } catch (error) {
+ dispatch({type: UPDATE_FAILURE, error});
+ dispatch(ui.showSnackbar(UPDATE_FAILURE, error.description));
+ throw error;
+ }
+};
+
+// Helpers
+
+/**
+ * Helper to fetch a Hotlist ID given its owner and display name.
+ * @param {string} owner The Hotlist owner's user id or display name.
+ * @param {string} hotlist The Hotlist's id or display name.
+ * @return {Promise<?string>}
+ */
+export const getHotlistName = async (owner, hotlist) => {
+ const hotlistRef = {
+ owner: userIdOrDisplayNameToUserRef(owner),
+ name: hotlist,
+ };
+
+ try {
+ /** @type {{hotlistId: number}} */
+ const {hotlistId} = await prpcClient.call(
+ 'monorail.Features', 'GetHotlistID', {hotlistRef});
+ return 'hotlists/' + hotlistId;
+ } catch (error) {
+ return null;
+ };
+};
+
+export const hotlists = {
+ // Permissions
+ EDIT,
+ ADMINISTER,
+
+ // Reducer
+ reducer,
+
+ // Selectors
+ name,
+ byName,
+ hotlistItems,
+ viewedHotlist,
+ viewedHotlistOwner,
+ viewedHotlistEditors,
+ viewedHotlistItems,
+ viewedHotlistIssues,
+ viewedHotlistColumns,
+ viewedHotlistPermissions,
+ requests,
+
+ // Action creators
+ deleteHotlist,
+ fetch,
+ fetchItems,
+ removeEditors,
+ removeItems,
+ rerankItems,
+ select,
+ update,
+
+ // Helpers
+ getHotlistName,
+};
diff --git a/static_src/reducers/hotlists.test.js b/static_src/reducers/hotlists.test.js
new file mode 100644
index 0000000..4aa42a2
--- /dev/null
+++ b/static_src/reducers/hotlists.test.js
@@ -0,0 +1,568 @@
+// 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 * as hotlists from './hotlists.js';
+import * as example from 'shared/test/constants-hotlists.js';
+import * as exampleIssues from 'shared/test/constants-issueV0.js';
+import * as exampleUsers from 'shared/test/constants-users.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+describe('hotlist reducers', () => {
+ it('root reducer initial state', () => {
+ const actual = hotlists.reducer(undefined, {type: null});
+ const expected = {
+ name: null,
+ byName: {},
+ hotlistItems: {},
+ requests: {
+ deleteHotlist: {error: null, requesting: false},
+ fetch: {error: null, requesting: false},
+ fetchItems: {error: null, requesting: false},
+ removeEditors: {error: null, requesting: false},
+ removeItems: {error: null, requesting: false},
+ rerankItems: {error: null, requesting: false},
+ update: {error: null, requesting: false},
+ },
+ };
+ assert.deepEqual(actual, expected);
+ });
+
+ it('name updates on SELECT', () => {
+ const action = {type: hotlists.SELECT, name: example.NAME};
+ const actual = hotlists.nameReducer(null, action);
+ assert.deepEqual(actual, example.NAME);
+ });
+
+ it('byName updates on RECEIVE_HOTLIST', () => {
+ const action = {type: hotlists.RECEIVE_HOTLIST, hotlist: example.HOTLIST};
+ const actual = hotlists.byNameReducer({}, action);
+ assert.deepEqual(actual, example.BY_NAME);
+ });
+
+ it('byName fills in missing fields on RECEIVE_HOTLIST', () => {
+ const action = {
+ type: hotlists.RECEIVE_HOTLIST,
+ hotlist: {name: example.NAME},
+ };
+ const actual = hotlists.byNameReducer({}, action);
+
+ const hotlist = {name: example.NAME, defaultColumns: [], editors: []};
+ assert.deepEqual(actual, {[example.NAME]: hotlist});
+ });
+
+ it('hotlistItems updates on FETCH_ITEMS_SUCCESS', () => {
+ const action = {
+ type: hotlists.FETCH_ITEMS_SUCCESS,
+ name: example.NAME,
+ items: [example.HOTLIST_ITEM],
+ };
+ const actual = hotlists.hotlistItemsReducer({}, action);
+ assert.deepEqual(actual, example.HOTLIST_ITEMS);
+ });
+});
+
+describe('hotlist selectors', () => {
+ it('name', () => {
+ const state = {hotlists: {name: example.NAME}};
+ assert.deepEqual(hotlists.name(state), example.NAME);
+ });
+
+ it('byName', () => {
+ const state = {hotlists: {byName: example.BY_NAME}};
+ assert.deepEqual(hotlists.byName(state), example.BY_NAME);
+ });
+
+ it('hotlistItems', () => {
+ const state = {hotlists: {hotlistItems: example.HOTLIST_ITEMS}};
+ assert.deepEqual(hotlists.hotlistItems(state), example.HOTLIST_ITEMS);
+ });
+
+ describe('viewedHotlist', () => {
+ it('normal case', () => {
+ const state = {hotlists: {name: example.NAME, byName: example.BY_NAME}};
+ assert.deepEqual(hotlists.viewedHotlist(state), example.HOTLIST);
+ });
+
+ it('no name', () => {
+ const state = {hotlists: {name: null, byName: example.BY_NAME}};
+ assert.deepEqual(hotlists.viewedHotlist(state), null);
+ });
+
+ it('hotlist not found', () => {
+ const state = {hotlists: {name: example.NAME, byName: {}}};
+ assert.deepEqual(hotlists.viewedHotlist(state), null);
+ });
+ });
+
+ describe('viewedHotlistOwner', () => {
+ it('normal case', () => {
+ const state = {
+ hotlists: {name: example.NAME, byName: example.BY_NAME},
+ users: {byName: exampleUsers.BY_NAME},
+ };
+ assert.deepEqual(hotlists.viewedHotlistOwner(state), exampleUsers.USER);
+ });
+
+ it('no hotlist', () => {
+ const state = {hotlists: {}, users: {}};
+ assert.deepEqual(hotlists.viewedHotlistOwner(state), null);
+ });
+ });
+
+ describe('viewedHotlistEditors', () => {
+ it('normal case', () => {
+ const state = {
+ hotlists: {
+ name: example.NAME,
+ byName: {[example.NAME]: {
+ ...example.HOTLIST,
+ editors: [exampleUsers.NAME, exampleUsers.NAME_2],
+ }},
+ },
+ users: {byName: exampleUsers.BY_NAME},
+ };
+
+ const editors = [exampleUsers.USER, exampleUsers.USER_2];
+ assert.deepEqual(hotlists.viewedHotlistEditors(state), editors);
+ });
+
+ it('no user data', () => {
+ const editors = [exampleUsers.NAME, exampleUsers.NAME_2];
+ const state = {
+ hotlists: {
+ name: example.NAME,
+ byName: {[example.NAME]: {...example.HOTLIST, editors}},
+ },
+ users: {byName: {}},
+ };
+ assert.deepEqual(hotlists.viewedHotlistEditors(state), [null, null]);
+ });
+
+ it('no hotlist', () => {
+ const state = {hotlists: {}, users: {}};
+ assert.deepEqual(hotlists.viewedHotlistEditors(state), null);
+ });
+ });
+
+ describe('viewedHotlistItems', () => {
+ it('normal case', () => {
+ const state = {hotlists: {
+ name: example.NAME,
+ hotlistItems: example.HOTLIST_ITEMS,
+ }};
+ const actual = hotlists.viewedHotlistItems(state);
+ assert.deepEqual(actual, [example.HOTLIST_ITEM]);
+ });
+
+ it('no name', () => {
+ const state = {hotlists: {
+ name: null,
+ hotlistItems: example.HOTLIST_ITEMS,
+ }};
+ assert.deepEqual(hotlists.viewedHotlistItems(state), []);
+ });
+
+ it('hotlist not found', () => {
+ const state = {hotlists: {name: example.NAME, hotlistItems: {}}};
+ assert.deepEqual(hotlists.viewedHotlistItems(state), []);
+ });
+ });
+
+ describe('viewedHotlistIssues', () => {
+ it('normal case', () => {
+ const state = {
+ hotlists: {
+ name: example.NAME,
+ hotlistItems: example.HOTLIST_ITEMS,
+ },
+ issue: {
+ issuesByRefString: {
+ [exampleIssues.ISSUE_REF_STRING]: exampleIssues.ISSUE,
+ },
+ },
+ users: {byName: {[exampleUsers.NAME]: exampleUsers.USER}},
+ };
+ const actual = hotlists.viewedHotlistIssues(state);
+ assert.deepEqual(actual, [example.HOTLIST_ISSUE]);
+ });
+
+ it('no issue', () => {
+ const state = {
+ hotlists: {
+ name: example.NAME,
+ hotlistItems: example.HOTLIST_ITEMS,
+ },
+ issue: {
+ issuesByRefString: {
+ [exampleIssues.ISSUE_OTHER_PROJECT_REF_STRING]: exampleIssues.ISSUE,
+ },
+ },
+ users: {byName: {}},
+ };
+ assert.deepEqual(hotlists.viewedHotlistIssues(state), []);
+ });
+ });
+
+ describe('viewedHotlistColumns', () => {
+ it('sitewide currentColumns overrides hotlist defaultColumns', () => {
+ const state = {
+ sitewide: {queryParams: {colspec: 'Summary+ColumnName'}},
+ hotlists: {},
+ };
+ const actual = hotlists.viewedHotlistColumns(state);
+ assert.deepEqual(actual, ['Summary', 'ColumnName']);
+ });
+
+ it('uses DEFAULT_COLUMNS when no hotlist', () => {
+ const actual = hotlists.viewedHotlistColumns({hotlists: {}});
+ assert.deepEqual(actual, hotlists.DEFAULT_COLUMNS);
+ });
+
+ it('uses DEFAULT_COLUMNS when hotlist has empty defaultColumns', () => {
+ const state = {hotlists: {
+ name: example.HOTLIST.name,
+ byName: {
+ [example.HOTLIST.name]: {...example.HOTLIST, defaultColumns: []},
+ },
+ }};
+ const actual = hotlists.viewedHotlistColumns(state);
+ assert.deepEqual(actual, hotlists.DEFAULT_COLUMNS);
+ });
+
+ it('uses hotlist defaultColumns', () => {
+ const state = {hotlists: {
+ name: example.HOTLIST.name,
+ byName: {[example.HOTLIST.name]: {
+ ...example.HOTLIST,
+ defaultColumns: [{column: 'ID'}, {column: 'ColumnName'}],
+ }},
+ }};
+ const actual = hotlists.viewedHotlistColumns(state);
+ assert.deepEqual(actual, ['ID', 'ColumnName']);
+ });
+ });
+
+ describe('viewedHotlistPermissions', () => {
+ it('normal case', () => {
+ const permissions = [hotlists.ADMINISTER, hotlists.EDIT];
+ const state = {
+ hotlists: {name: example.NAME, byName: example.BY_NAME},
+ permissions: {byName: {[example.NAME]: {permissions}}},
+ };
+ assert.deepEqual(hotlists.viewedHotlistPermissions(state), permissions);
+ });
+
+ it('no issue', () => {
+ const state = {hotlists: {}, permissions: {}};
+ assert.deepEqual(hotlists.viewedHotlistPermissions(state), []);
+ });
+ });
+});
+
+describe('hotlist action creators', () => {
+ beforeEach(() => {
+ sinon.stub(prpcClient, 'call');
+ dispatch = sinon.stub();
+ });
+
+ afterEach(() => {
+ prpcClient.call.restore();
+ });
+
+ it('select', () => {
+ const actual = hotlists.select(example.NAME);
+ const expected = {type: hotlists.SELECT, name: example.NAME};
+ assert.deepEqual(actual, expected);
+ });
+
+ describe('deleteHotlist', () => {
+ it('success', async () => {
+ prpcClient.call.returns(Promise.resolve({}));
+
+ await hotlists.deleteHotlist(example.NAME)(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: hotlists.DELETE_START});
+
+ const args = {name: example.NAME};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Hotlists', 'DeleteHotlist', args);
+
+ sinon.assert.calledWith(dispatch, {type: hotlists.DELETE_SUCCESS});
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await hotlists.deleteHotlist(example.NAME)(dispatch);
+
+ const action = {
+ type: hotlists.DELETE_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('fetch', () => {
+ it('success', async () => {
+ const hotlist = example.HOTLIST;
+ prpcClient.call.returns(Promise.resolve(hotlist));
+
+ await hotlists.fetch(example.NAME)(dispatch);
+
+ const args = {name: example.NAME};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Hotlists', 'GetHotlist', args);
+
+ sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_START});
+ sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_SUCCESS});
+ sinon.assert.calledWith(
+ dispatch, {type: hotlists.RECEIVE_HOTLIST, hotlist});
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await hotlists.fetch(example.NAME)(dispatch);
+
+ const action = {type: hotlists.FETCH_FAILURE, error: sinon.match.any};
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('fetchItems', () => {
+ it('success', async () => {
+ const response = {items: [example.HOTLIST_ITEM]};
+ prpcClient.call.returns(Promise.resolve(response));
+
+ const returnValue = await hotlists.fetchItems(example.NAME)(dispatch);
+ assert.deepEqual(returnValue, [{...example.HOTLIST_ITEM, rank: 0}]);
+
+ const args = {parent: example.NAME, orderBy: 'rank'};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Hotlists', 'ListHotlistItems', args);
+
+ sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_ITEMS_START});
+ const action = {
+ type: hotlists.FETCH_ITEMS_SUCCESS,
+ name: example.NAME,
+ items: [{...example.HOTLIST_ITEM, rank: 0}],
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await hotlists.fetchItems(example.NAME)(dispatch);
+
+ const action = {
+ type: hotlists.FETCH_ITEMS_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+
+ it('success with empty hotlist', async () => {
+ const response = {items: []};
+ prpcClient.call.returns(Promise.resolve(response));
+
+ const returnValue = await hotlists.fetchItems(example.NAME)(dispatch);
+ assert.deepEqual(returnValue, []);
+
+ sinon.assert.calledWith(dispatch, {type: hotlists.FETCH_ITEMS_START});
+
+ const args = {parent: example.NAME, orderBy: 'rank'};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Hotlists', 'ListHotlistItems', args);
+
+ const action = {
+ type: hotlists.FETCH_ITEMS_SUCCESS,
+ name: example.NAME,
+ items: [],
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('removeEditors', () => {
+ it('success', async () => {
+ prpcClient.call.returns(Promise.resolve({}));
+
+ const editors = [exampleUsers.NAME];
+ await hotlists.removeEditors(example.NAME, editors)(dispatch);
+
+ const args = {name: example.NAME, editors};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Hotlists',
+ 'RemoveHotlistEditors', args);
+
+ sinon.assert.calledWith(dispatch, {type: hotlists.REMOVE_EDITORS_START});
+ const action = {type: hotlists.REMOVE_EDITORS_SUCCESS};
+ sinon.assert.calledWith(dispatch, action);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await hotlists.removeEditors(example.NAME, [])(dispatch);
+
+ const action = {
+ type: hotlists.REMOVE_EDITORS_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('removeItems', () => {
+ it('success', async () => {
+ prpcClient.call.returns(Promise.resolve({}));
+
+ const issues = [exampleIssues.NAME];
+ await hotlists.removeItems(example.NAME, issues)(dispatch);
+
+ const args = {parent: example.NAME, issues};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Hotlists',
+ 'RemoveHotlistItems', args);
+
+ sinon.assert.calledWith(dispatch, {type: hotlists.REMOVE_ITEMS_START});
+ sinon.assert.calledWith(dispatch, {type: hotlists.REMOVE_ITEMS_SUCCESS});
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await hotlists.removeItems(example.NAME, [])(dispatch);
+
+ const action = {
+ type: hotlists.REMOVE_ITEMS_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('rerankItems', () => {
+ it('success', async () => {
+ prpcClient.call.returns(Promise.resolve({}));
+
+ const items = [example.HOTLIST_ITEM_NAME];
+ await hotlists.rerankItems(example.NAME, items, 0)(dispatch);
+
+ const args = {
+ name: example.NAME,
+ hotlistItems: items,
+ targetPosition: 0,
+ };
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Hotlists',
+ 'RerankHotlistItems', args);
+
+ sinon.assert.calledWith(dispatch, {type: hotlists.RERANK_ITEMS_START});
+ sinon.assert.calledWith(dispatch, {type: hotlists.RERANK_ITEMS_SUCCESS});
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await hotlists.rerankItems(example.NAME, [], 0)(dispatch);
+
+ const action = {
+ type: hotlists.RERANK_ITEMS_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('update', () => {
+ it('success', async () => {
+ const hotlistOnlyWithUpdates = {
+ displayName: example.HOTLIST.displayName + 'foo',
+ summary: example.HOTLIST.summary + 'abc',
+ };
+ const hotlist = {...example.HOTLIST, ...hotlistOnlyWithUpdates};
+ prpcClient.call.returns(Promise.resolve(hotlist));
+
+ await hotlists.update(
+ example.HOTLIST.name, hotlistOnlyWithUpdates)(dispatch);
+
+ const hotlistArg = {
+ ...hotlistOnlyWithUpdates,
+ name: example.HOTLIST.name,
+ };
+ const fieldMask = 'displayName,summary';
+ const args = {hotlist: hotlistArg, updateMask: fieldMask};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Hotlists', 'UpdateHotlist', args);
+
+ sinon.assert.calledWith(dispatch, {type: hotlists.UPDATE_START});
+ sinon.assert.calledWith(dispatch, {type: hotlists.UPDATE_SUCCESS});
+ sinon.assert.calledWith(
+ dispatch, {type: hotlists.RECEIVE_HOTLIST, hotlist});
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+ const hotlistOnlyWithUpdates = {
+ displayName: example.HOTLIST.displayName + 'foo',
+ summary: example.HOTLIST.summary + 'abc',
+ };
+ try {
+ // TODO(crbug.com/monorail/7883): Use Chai Promises plugin
+ // to assert promise rejected.
+ await hotlists.update(
+ example.HOTLIST.name, hotlistOnlyWithUpdates)(dispatch);
+ } catch (e) {}
+
+ const action = {
+ type: hotlists.UPDATE_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+});
+
+describe('helpers', () => {
+ beforeEach(() => {
+ sinon.stub(prpcClient, 'call');
+ dispatch = sinon.stub();
+ });
+
+ afterEach(() => {
+ prpcClient.call.restore();
+ });
+
+ describe('getHotlistName', () => {
+ it('success', async () => {
+ const response = {hotlistId: '1234'};
+ prpcClient.call.returns(Promise.resolve(response));
+
+ const name = await hotlists.getHotlistName('foo@bar.com', 'hotlist');
+ assert.deepEqual(name, 'hotlists/1234');
+
+ const args = {hotlistRef: {
+ owner: {displayName: 'foo@bar.com'},
+ name: 'hotlist',
+ }};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.Features', 'GetHotlistID', args);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ assert.isNull(await hotlists.getHotlistName('foo@bar.com', 'hotlist'));
+ });
+ });
+});
diff --git a/static_src/reducers/issueV0.js b/static_src/reducers/issueV0.js
new file mode 100644
index 0000000..36c446d
--- /dev/null
+++ b/static_src/reducers/issueV0.js
@@ -0,0 +1,1411 @@
+// 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 Issue actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving issue state
+ * on the frontend.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {autolink} from 'autolink.js';
+import {fieldTypes, extractTypeForIssue,
+ fieldValuesToMap} from 'shared/issue-fields.js';
+import {removePrefix, objectToMap} from 'shared/helpers.js';
+import {issueRefToString, issueToIssueRefString,
+ issueStringToRef, issueNameToRefString} from 'shared/convertersV0.js';
+import {fromShortlink} from 'shared/federated.js';
+import {createReducer, createRequestReducer,
+ createKeyedRequestReducer} from './redux-helpers.js';
+import * as projectV0 from './projectV0.js';
+import * as userV0 from './userV0.js';
+import {fieldValueMapKey} from 'shared/metadata-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import loadGapi, {fetchGapiEmail} from 'shared/gapi-loader.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const VIEW_ISSUE = 'VIEW_ISSUE';
+
+export const FETCH_START = 'issueV0/FETCH_START';
+export const FETCH_SUCCESS = 'issueV0/FETCH_SUCCESS';
+export const FETCH_FAILURE = 'issueV0/FETCH_FAILURE';
+
+export const FETCH_ISSUES_START = 'issueV0/FETCH_ISSUES_START';
+export const FETCH_ISSUES_SUCCESS = 'issueV0/FETCH_ISSUES_SUCCESS';
+export const FETCH_ISSUES_FAILURE = 'issueV0/FETCH_ISSUES_FAILURE';
+
+const FETCH_HOTLISTS_START = 'FETCH_HOTLISTS_START';
+export const FETCH_HOTLISTS_SUCCESS = 'FETCH_HOTLISTS_SUCCESS';
+const FETCH_HOTLISTS_FAILURE = 'FETCH_HOTLISTS_FAILURE';
+
+const FETCH_ISSUE_LIST_START = 'FETCH_ISSUE_LIST_START';
+export const FETCH_ISSUE_LIST_UPDATE = 'FETCH_ISSUE_LIST_UPDATE';
+const FETCH_ISSUE_LIST_SUCCESS = 'FETCH_ISSUE_LIST_SUCCESS';
+const FETCH_ISSUE_LIST_FAILURE = 'FETCH_ISSUE_LIST_FAILURE';
+
+const FETCH_PERMISSIONS_START = 'FETCH_PERMISSIONS_START';
+const FETCH_PERMISSIONS_SUCCESS = 'FETCH_PERMISSIONS_SUCCESS';
+const FETCH_PERMISSIONS_FAILURE = 'FETCH_PERMISSIONS_FAILURE';
+
+export const STAR_START = 'STAR_START';
+export const STAR_SUCCESS = 'STAR_SUCCESS';
+const STAR_FAILURE = 'STAR_FAILURE';
+
+const PRESUBMIT_START = 'PRESUBMIT_START';
+const PRESUBMIT_SUCCESS = 'PRESUBMIT_SUCCESS';
+const PRESUBMIT_FAILURE = 'PRESUBMIT_FAILURE';
+
+export const FETCH_IS_STARRED_START = 'FETCH_IS_STARRED_START';
+export const FETCH_IS_STARRED_SUCCESS = 'FETCH_IS_STARRED_SUCCESS';
+const FETCH_IS_STARRED_FAILURE = 'FETCH_IS_STARRED_FAILURE';
+
+const FETCH_ISSUES_STARRED_START = 'FETCH_ISSUES_STARRED_START';
+export const FETCH_ISSUES_STARRED_SUCCESS = 'FETCH_ISSUES_STARRED_SUCCESS';
+const FETCH_ISSUES_STARRED_FAILURE = 'FETCH_ISSUES_STARRED_FAILURE';
+
+const FETCH_COMMENTS_START = 'FETCH_COMMENTS_START';
+export const FETCH_COMMENTS_SUCCESS = 'FETCH_COMMENTS_SUCCESS';
+const FETCH_COMMENTS_FAILURE = 'FETCH_COMMENTS_FAILURE';
+
+const FETCH_COMMENT_REFERENCES_START = 'FETCH_COMMENT_REFERENCES_START';
+const FETCH_COMMENT_REFERENCES_SUCCESS = 'FETCH_COMMENT_REFERENCES_SUCCESS';
+const FETCH_COMMENT_REFERENCES_FAILURE = 'FETCH_COMMENT_REFERENCES_FAILURE';
+
+const FETCH_REFERENCED_USERS_START = 'FETCH_REFERENCED_USERS_START';
+const FETCH_REFERENCED_USERS_SUCCESS = 'FETCH_REFERENCED_USERS_SUCCESS';
+const FETCH_REFERENCED_USERS_FAILURE = 'FETCH_REFERENCED_USERS_FAILURE';
+
+const FETCH_RELATED_ISSUES_START = 'FETCH_RELATED_ISSUES_START';
+const FETCH_RELATED_ISSUES_SUCCESS = 'FETCH_RELATED_ISSUES_SUCCESS';
+const FETCH_RELATED_ISSUES_FAILURE = 'FETCH_RELATED_ISSUES_FAILURE';
+
+const FETCH_FEDERATED_REFERENCES_START = 'FETCH_FEDERATED_REFERENCES_START';
+const FETCH_FEDERATED_REFERENCES_SUCCESS = 'FETCH_FEDERATED_REFERENCES_SUCCESS';
+const FETCH_FEDERATED_REFERENCES_FAILURE = 'FETCH_FEDERATED_REFERENCES_FAILURE';
+
+const CONVERT_START = 'CONVERT_START';
+const CONVERT_SUCCESS = 'CONVERT_SUCCESS';
+const CONVERT_FAILURE = 'CONVERT_FAILURE';
+
+const UPDATE_START = 'UPDATE_START';
+const UPDATE_SUCCESS = 'UPDATE_SUCCESS';
+const UPDATE_FAILURE = 'UPDATE_FAILURE';
+
+const UPDATE_APPROVAL_START = 'UPDATE_APPROVAL_START';
+const UPDATE_APPROVAL_SUCCESS = 'UPDATE_APPROVAL_SUCCESS';
+const UPDATE_APPROVAL_FAILURE = 'UPDATE_APPROVAL_FAILURE';
+
+/* State Shape
+{
+ issuesByRefString: Object<IssueRefString, Issue>,
+
+ viewedIssueRef: IssueRefString,
+
+ hotlists: Array<HotlistV0>,
+ issueList: {
+ issueRefs: Array<IssueRefString>,
+ progress: number,
+ totalResults: number,
+ }
+ comments: Array<IssueComment>,
+ commentReferences: Object,
+ relatedIssues: Object,
+ referencedUsers: Array<UserV0>,
+ starredIssues: Object<IssueRefString, Boolean>,
+ permissions: Array<string>,
+ presubmitResponse: Object,
+
+ requests: {
+ fetch: ReduxRequestState,
+ fetchHotlists: ReduxRequestState,
+ fetchIssueList: ReduxRequestState,
+ fetchPermissions: ReduxRequestState,
+ starringIssues: Object<string, ReduxRequestState>,
+ presubmit: ReduxRequestState,
+ fetchComments: ReduxRequestState,
+ fetchCommentReferences: ReduxRequestState,
+ fetchFederatedReferences: ReduxRequestState,
+ fetchRelatedIssues: ReduxRequestState,
+ fetchStarredIssues: ReduxRequestState,
+ fetchStarredIssues: ReduxRequestState,
+ convert: ReduxRequestState,
+ update: ReduxRequestState,
+ updateApproval: ReduxRequestState,
+ },
+}
+*/
+
+// Helpers for the reducers.
+
+/**
+ * Overrides local data for single approval on an Issue object with fresh data.
+ * Note that while an Issue can have multiple approvals, this function only
+ * refreshes data for a single approval.
+ * @param {Issue} issue Issue Object being updated.
+ * @param {ApprovalDef} approval A single approval to override in the issue.
+ * @return {Issue} Issue with updated approval data.
+ */
+const updateApprovalValues = (issue, approval) => {
+ if (!issue.approvalValues) return issue;
+ const newApprovals = issue.approvalValues.map((item) => {
+ if (item.fieldRef.fieldName === approval.fieldRef.fieldName) {
+ // PhaseRef isn't populated on the response so we want to make sure
+ // it doesn't overwrite the original phaseRef with {}.
+ return {...approval, phaseRef: item.phaseRef};
+ }
+ return item;
+ });
+ return {...issue, approvalValues: newApprovals};
+};
+
+// Reducers
+
+/**
+ * Creates a new issuesByRefString Object with a single issue's data
+ * edited.
+ * @param {Object<IssueRefString, Issue>} issuesByRefString
+ * @param {Issue} issue The new issue data to add to the state.
+ * @return {Object<IssueRefString, Issue>}
+ */
+const updateSingleIssueInState = (issuesByRefString, issue) => {
+ return {
+ ...issuesByRefString,
+ [issueToIssueRefString(issue)]: issue,
+ };
+};
+
+// TODO(crbug.com/monorail/6882): Finish converting all other issue
+// actions to use this format.
+/**
+ * Adds issues fetched by a ListIssues request to the Redux store in a
+ * normalized format.
+ * @param {Object<IssueRefString, Issue>} state Redux state.
+ * @param {AnyAction} action
+ * @param {Array<Issue>} action.issues The list of issues that was fetched.
+ * @param {Issue=} action.issue The issue being updated.
+ * @param {number=} action.starCount Number of stars the issue has. This changes
+ * when a user stars an issue and needs to be updated.
+ * @param {ApprovalDef=} action.approval A new approval to update the issue
+ * with.
+ * @param {IssueRef=} action.issueRef A specific IssueRef to update.
+ */
+export const issuesByRefStringReducer = createReducer({}, {
+ [FETCH_ISSUE_LIST_UPDATE]: (state, {issues}) => {
+ const newState = {...state};
+
+ issues.forEach((issue) => {
+ const refString = issueToIssueRefString(issue);
+ newState[refString] = {...newState[refString], ...issue};
+ });
+
+ return newState;
+ },
+ [FETCH_SUCCESS]: (state, {issue}) => updateSingleIssueInState(state, issue),
+ [FETCH_ISSUES_SUCCESS]: (state, {issues}) => {
+ const newState = {...state};
+
+ issues.forEach((issue) => {
+ const refString = issueToIssueRefString(issue);
+ newState[refString] = {...newState[refString], ...issue};
+ });
+
+ return newState;
+ },
+ [CONVERT_SUCCESS]: (state, {issue}) => updateSingleIssueInState(state, issue),
+ [UPDATE_SUCCESS]: (state, {issue}) => updateSingleIssueInState(state, issue),
+ [UPDATE_APPROVAL_SUCCESS]: (state, {issueRef, approval}) => {
+ const issueRefString = issueToIssueRefString(issueRef);
+ const originalIssue = state[issueRefString] || {};
+ const newIssue = updateApprovalValues(originalIssue, approval);
+ return {
+ ...state,
+ [issueRefString]: {
+ ...newIssue,
+ },
+ };
+ },
+ [STAR_SUCCESS]: (state, {issueRef, starCount}) => {
+ const issueRefString = issueToIssueRefString(issueRef);
+ const originalIssue = state[issueRefString] || {};
+ return {
+ ...state,
+ [issueRefString]: {
+ ...originalIssue,
+ starCount,
+ },
+ };
+ },
+});
+
+/**
+ * Sets a reference for the issue that the user is currently viewing.
+ * @param {IssueRefString} state Currently viewed issue.
+ * @param {AnyAction} action
+ * @param {IssueRef} action.issueRef The updated localId to view.
+ * @return {IssueRefString}
+ */
+const viewedIssueRefReducer = createReducer('', {
+ [VIEW_ISSUE]: (state, {issueRef}) => issueRefToString(issueRef) || state,
+});
+
+/**
+ * Reducer to manage updating the list of hotlists attached to an Issue.
+ * @param {Array<HotlistV0>} state List of issue hotlists.
+ * @param {AnyAction} action
+ * @param {Array<HotlistV0>} action.hotlists New list of hotlists.
+ * @return {Array<HotlistV0>}
+ */
+const hotlistsReducer = createReducer([], {
+ [FETCH_HOTLISTS_SUCCESS]: (_, {hotlists}) => hotlists,
+});
+
+/**
+ * @typedef {Object} IssueListState
+ * @property {Array<IssueRefString>} issues The list of issues being viewed,
+ * in a normalized form.
+ * @property {number} progress The percentage of issues loaded. Used for
+ * incremental loading of issues in the grid view.
+ * @property {number} totalResults The total number of issues matching the
+ * query.
+ */
+
+/**
+ * Handles the state of the currently viewed issue list. This reducer
+ * stores this data in normalized form.
+ * @param {IssueListState} state
+ * @param {AnyAction} action
+ * @param {Array<Issue>} action.issues Issues that were fetched.
+ * @param {number} state.progress New percentage of issues have been loaded.
+ * @param {number} state.totalResults The total number of issues matching the
+ * query.
+ * @return {IssueListState}
+ */
+export const issueListReducer = createReducer({}, {
+ [FETCH_ISSUE_LIST_UPDATE]: (_state, {issues, progress, totalResults}) => ({
+ issueRefs: issues.map(issueToIssueRefString), progress, totalResults,
+ }),
+});
+
+/**
+ * Updates the comments attached to the currently viewed issue.
+ * @param {Array<IssueComment>} state The list of comments in an issue.
+ * @param {AnyAction} action
+ * @param {Array<IssueComment>} action.comments Fetched comments.
+ * @return {Array<IssueComment>}
+ */
+const commentsReducer = createReducer([], {
+ [FETCH_COMMENTS_SUCCESS]: (_state, {comments}) => comments,
+});
+
+// TODO(crbug.com/monorail/5953): Come up with some way to refactor
+// autolink.js's reference code to allow avoiding duplicate lookups
+// with data already in Redux state.
+/**
+ * For autolinking, this reducer stores the dereferenced data for bits
+ * of data that were referenced in comments. For example, comments might
+ * include user emails or IDs for other issues, and this state slice would
+ * store the full Objects for that data.
+ * @param {Array<CommentReference>} state
+ * @param {AnyAction} action
+ * @param {Array<CommentReference>} action.commentReferences New references
+ * to store.
+ * @return {Array<CommentReference>}
+ */
+const commentReferencesReducer = createReducer({}, {
+ [FETCH_COMMENTS_START]: (_state, _action) => ({}),
+ [FETCH_COMMENT_REFERENCES_SUCCESS]: (_state, {commentReferences}) => {
+ return commentReferences;
+ },
+});
+
+/**
+ * Handles state for related issues such as blocking and blocked on issues,
+ * including federated references that could reference external issues outside
+ * Monorail.
+ * @param {Object<IssueRefString, Issue>} state
+ * @param {AnyAction} action
+ * @param {Object<IssueRefString, Issue>=} action.relatedIssues New related
+ * issues.
+ * @param {Array<IssueRef>=} action.fedRefIssueRefs List of fetched federated
+ * issue references.
+ * @return {Object<IssueRefString, Issue>}
+ */
+export const relatedIssuesReducer = createReducer({}, {
+ [FETCH_RELATED_ISSUES_SUCCESS]: (_state, {relatedIssues}) => relatedIssues,
+ [FETCH_FEDERATED_REFERENCES_SUCCESS]: (state, {fedRefIssueRefs}) => {
+ if (!fedRefIssueRefs) {
+ return state;
+ }
+
+ const fedRefStates = {};
+ fedRefIssueRefs.forEach((ref) => {
+ fedRefStates[ref.extIdentifier] = ref;
+ });
+
+ // Return a new object, in Redux fashion.
+ return Object.assign(fedRefStates, state);
+ },
+});
+
+/**
+ * Stores data for users referenced by issue. ie: Owner, CC, etc.
+ * @param {Object<string, UserV0>} state
+ * @param {AnyAction} action
+ * @param {Object<string, UserV0>} action.referencedUsers
+ * @return {Object<string, UserV0>}
+ */
+const referencedUsersReducer = createReducer({}, {
+ [FETCH_REFERENCED_USERS_SUCCESS]: (_state, {referencedUsers}) =>
+ referencedUsers,
+});
+
+/**
+ * Handles updating state of all starred issues.
+ * @param {Object<IssueRefString, boolean>} state Set of starred issues,
+ * stored in a serializeable Object form.
+ * @param {AnyAction} action
+ * @param {IssueRef=} action.issueRef An issue with a star state being updated.
+ * @param {boolean=} action.starred Whether the issue is starred or unstarred.
+ * @param {Array<IssueRef>=} action.starredIssueRefs A list of starred issues.
+ * @return {Object<IssueRefString, boolean>}
+ */
+export const starredIssuesReducer = createReducer({}, {
+ [STAR_SUCCESS]: (state, {issueRef, starred}) => {
+ return {...state, [issueRefToString(issueRef)]: starred};
+ },
+ [FETCH_ISSUES_STARRED_SUCCESS]: (_state, {starredIssueRefs}) => {
+ const normalizedStars = {};
+ starredIssueRefs.forEach((issueRef) => {
+ normalizedStars[issueRefToString(issueRef)] = true;
+ });
+ return normalizedStars;
+ },
+ [FETCH_IS_STARRED_SUCCESS]: (state, {issueRef, starred}) => {
+ const refString = issueRefToString(issueRef);
+ return {...state, [refString]: starred};
+ },
+});
+
+/**
+ * Adds the result of an IssuePresubmit response to the Redux store.
+ * @param {Object} state Initial Redux state.
+ * @param {AnyAction} action
+ * @param {Object} action.presubmitResponse The issue
+ * presubmit response Object.
+ * @return {Object}
+ */
+const presubmitResponseReducer = createReducer({}, {
+ [PRESUBMIT_SUCCESS]: (_state, {presubmitResponse}) => presubmitResponse,
+});
+
+/**
+ * Stores the user's permissions for a given issue.
+ * @param {Array<string>} state Permission list. Each permission is a string
+ * with the name of the permission.
+ * @param {AnyAction} action
+ * @param {Array<string>} action.permissions The fetched permission data.
+ * @return {Array<string>}
+ */
+const permissionsReducer = createReducer([], {
+ [FETCH_PERMISSIONS_SUCCESS]: (_state, {permissions}) => permissions,
+});
+
+const requestsReducer = combineReducers({
+ fetch: createRequestReducer(
+ FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+ fetchIssues: createRequestReducer(
+ FETCH_ISSUES_START, FETCH_ISSUES_SUCCESS, FETCH_ISSUES_FAILURE),
+ fetchHotlists: createRequestReducer(
+ FETCH_HOTLISTS_START, FETCH_HOTLISTS_SUCCESS, FETCH_HOTLISTS_FAILURE),
+ fetchIssueList: createRequestReducer(
+ FETCH_ISSUE_LIST_START,
+ FETCH_ISSUE_LIST_SUCCESS,
+ FETCH_ISSUE_LIST_FAILURE),
+ fetchPermissions: createRequestReducer(
+ FETCH_PERMISSIONS_START,
+ FETCH_PERMISSIONS_SUCCESS,
+ FETCH_PERMISSIONS_FAILURE),
+ starringIssues: createKeyedRequestReducer(
+ STAR_START, STAR_SUCCESS, STAR_FAILURE),
+ presubmit: createRequestReducer(
+ PRESUBMIT_START, PRESUBMIT_SUCCESS, PRESUBMIT_FAILURE),
+ fetchComments: createRequestReducer(
+ FETCH_COMMENTS_START, FETCH_COMMENTS_SUCCESS, FETCH_COMMENTS_FAILURE),
+ fetchCommentReferences: createRequestReducer(
+ FETCH_COMMENT_REFERENCES_START,
+ FETCH_COMMENT_REFERENCES_SUCCESS,
+ FETCH_COMMENT_REFERENCES_FAILURE),
+ fetchFederatedReferences: createRequestReducer(
+ FETCH_FEDERATED_REFERENCES_START,
+ FETCH_FEDERATED_REFERENCES_SUCCESS,
+ FETCH_FEDERATED_REFERENCES_FAILURE),
+ fetchRelatedIssues: createRequestReducer(
+ FETCH_RELATED_ISSUES_START,
+ FETCH_RELATED_ISSUES_SUCCESS,
+ FETCH_RELATED_ISSUES_FAILURE),
+ fetchReferencedUsers: createRequestReducer(
+ FETCH_REFERENCED_USERS_START,
+ FETCH_REFERENCED_USERS_SUCCESS,
+ FETCH_REFERENCED_USERS_FAILURE),
+ fetchIsStarred: createRequestReducer(
+ FETCH_IS_STARRED_START, FETCH_IS_STARRED_SUCCESS,
+ FETCH_IS_STARRED_FAILURE),
+ fetchStarredIssues: createRequestReducer(
+ FETCH_ISSUES_STARRED_START, FETCH_ISSUES_STARRED_SUCCESS,
+ FETCH_ISSUES_STARRED_FAILURE,
+ ),
+ convert: createRequestReducer(
+ CONVERT_START, CONVERT_SUCCESS, CONVERT_FAILURE),
+ update: createRequestReducer(
+ UPDATE_START, UPDATE_SUCCESS, UPDATE_FAILURE),
+ // TODO(zhangtiff): Update this to use createKeyedRequestReducer() instead, so
+ // users can update multiple approvals at once.
+ updateApproval: createRequestReducer(
+ UPDATE_APPROVAL_START, UPDATE_APPROVAL_SUCCESS, UPDATE_APPROVAL_FAILURE),
+});
+
+export const reducer = combineReducers({
+ viewedIssueRef: viewedIssueRefReducer,
+
+ issuesByRefString: issuesByRefStringReducer,
+
+ hotlists: hotlistsReducer,
+ issueList: issueListReducer,
+ comments: commentsReducer,
+ commentReferences: commentReferencesReducer,
+ relatedIssues: relatedIssuesReducer,
+ referencedUsers: referencedUsersReducer,
+ starredIssues: starredIssuesReducer,
+ permissions: permissionsReducer,
+ presubmitResponse: presubmitResponseReducer,
+
+ requests: requestsReducer,
+});
+
+// Selectors
+const RESTRICT_VIEW_PREFIX = 'restrict-view-';
+const RESTRICT_EDIT_PREFIX = 'restrict-editissue-';
+const RESTRICT_COMMENT_PREFIX = 'restrict-addissuecomment-';
+
+/**
+ * Selector to retrieve all normalized Issue data in the Redux store,
+ * keyed by IssueRefString.
+ * @param {any} state
+ * @return {Object<IssueRefString, Issue>}
+ */
+const issuesByRefString = (state) => state.issue.issuesByRefString;
+
+/**
+ * Selector to return a function to retrieve an Issue from the Redux store.
+ * @param {any} state
+ * @return {function(string): ?Issue}
+ */
+export const issue = createSelector(issuesByRefString, (issuesByRefString) =>
+ (name) => issuesByRefString[issueNameToRefString(name)]);
+
+/**
+ * Selector to return a function to retrieve a given Issue Object from
+ * the Redux store.
+ * @param {any} state
+ * @return {function(IssueRefString, string): Issue}
+ */
+export const issueForRefString = createSelector(issuesByRefString,
+ (issuesByRefString) => (issueRefString, projectName = undefined) => {
+ // In some contexts, an issue ref string will omit a project name,
+ // assuming the default project to be the project name. We never
+ // omit the project name in strings used as keys, so we have to
+ // make sure issue ref strings contain the project name.
+ const ref = issueStringToRef(issueRefString, projectName);
+ const refString = issueRefToString(ref);
+ if (issuesByRefString.hasOwnProperty(refString)) {
+ return issuesByRefString[refString];
+ }
+ return issueStringToRef(refString, projectName);
+ });
+
+/**
+ * Selector to get a reference to the currently viewed issue, in string form.
+ * @param {any} state
+ * @return {IssueRefString}
+ */
+const viewedIssueRefString = (state) => state.issue.viewedIssueRef;
+
+/**
+ * Selector to get a reference to the currently viewed issue.
+ * @param {any} state
+ * @return {IssueRef}
+ */
+export const viewedIssueRef = createSelector(viewedIssueRefString,
+ (viewedIssueRefString) => issueStringToRef(viewedIssueRefString));
+
+/**
+ * Selector to get the full Issue data for the currently viewed issue.
+ * @param {any} state
+ * @return {Issue}
+ */
+export const viewedIssue = createSelector(issuesByRefString,
+ viewedIssueRefString,
+ (issuesByRefString, viewedIssueRefString) =>
+ issuesByRefString[viewedIssueRefString] || {});
+
+export const comments = (state) => state.issue.comments;
+export const commentsLoaded = (state) => state.issue.commentsLoaded;
+
+const _commentReferences = (state) => state.issue.commentReferences;
+export const commentReferences = createSelector(_commentReferences,
+ (commentReferences) => objectToMap(commentReferences));
+
+export const hotlists = (state) => state.issue.hotlists;
+
+const stateIssueList = (state) => state.issue.issueList;
+export const issueList = createSelector(
+ issuesByRefString,
+ stateIssueList,
+ (issuesByRefString, stateIssueList) => {
+ return (stateIssueList.issueRefs || []).map((issueRef) => {
+ return issuesByRefString[issueRef];
+ });
+ },
+);
+export const totalIssues = (state) => state.issue.issueList.totalResults;
+export const issueListProgress = (state) => state.issue.issueList.progress;
+export const issueListPhaseNames = createSelector(issueList, (issueList) => {
+ const phaseNamesSet = new Set();
+ if (issueList) {
+ issueList.forEach(({phases}) => {
+ if (phases) {
+ phases.forEach(({phaseRef: {phaseName}}) => {
+ phaseNamesSet.add(phaseName.toLowerCase());
+ });
+ }
+ });
+ }
+ return Array.from(phaseNamesSet);
+});
+
+/**
+ * @param {any} state
+ * @return {boolean} Whether the currently viewed issue list
+ * has loaded.
+ */
+export const issueListLoaded = createSelector(
+ stateIssueList,
+ (stateIssueList) => stateIssueList.issueRefs !== undefined);
+
+export const permissions = (state) => state.issue.permissions;
+export const presubmitResponse = (state) => state.issue.presubmitResponse;
+
+const _relatedIssues = (state) => state.issue.relatedIssues || {};
+export const relatedIssues = createSelector(_relatedIssues,
+ (relatedIssues) => objectToMap(relatedIssues));
+
+const _referencedUsers = (state) => state.issue.referencedUsers || {};
+export const referencedUsers = createSelector(_referencedUsers,
+ (referencedUsers) => objectToMap(referencedUsers));
+
+export const isStarred = (state) => state.issue.isStarred;
+export const _starredIssues = (state) => state.issue.starredIssues;
+
+export const requests = (state) => state.issue.requests;
+
+// Returns a Map of in flight StarIssues requests, keyed by issueRef.
+export const starringIssues = createSelector(requests, (requests) =>
+ objectToMap(requests.starringIssues));
+
+export const starredIssues = createSelector(
+ _starredIssues,
+ (starredIssues) => {
+ const stars = new Set();
+ for (const [ref, starred] of Object.entries(starredIssues)) {
+ if (starred) stars.add(ref);
+ }
+ return stars;
+ },
+);
+
+// TODO(zhangtiff): Split up either comments or approvals into their own "duck".
+export const commentsByApprovalName = createSelector(
+ comments,
+ (comments) => {
+ const map = new Map();
+ comments.forEach((comment) => {
+ const key = (comment.approvalRef && comment.approvalRef.fieldName) ||
+ '';
+ if (map.has(key)) {
+ map.get(key).push(comment);
+ } else {
+ map.set(key, [comment]);
+ }
+ });
+ return map;
+ },
+);
+
+export const fieldValues = createSelector(
+ viewedIssue,
+ (issue) => issue && issue.fieldValues,
+);
+
+export const labelRefs = createSelector(
+ viewedIssue,
+ (issue) => issue && issue.labelRefs,
+);
+
+export const type = createSelector(
+ fieldValues,
+ labelRefs,
+ (fieldValues, labelRefs) => extractTypeForIssue(fieldValues, labelRefs),
+);
+
+export const restrictions = createSelector(
+ labelRefs,
+ (labelRefs) => {
+ if (!labelRefs) return {};
+
+ const restrictions = {};
+
+ labelRefs.forEach((labelRef) => {
+ const label = labelRef.label;
+ const lowerCaseLabel = label.toLowerCase();
+
+ if (lowerCaseLabel.startsWith(RESTRICT_VIEW_PREFIX)) {
+ const permissionType = removePrefix(label, RESTRICT_VIEW_PREFIX);
+ if (!('view' in restrictions)) {
+ restrictions['view'] = [permissionType];
+ } else {
+ restrictions['view'].push(permissionType);
+ }
+ } else if (lowerCaseLabel.startsWith(RESTRICT_EDIT_PREFIX)) {
+ const permissionType = removePrefix(label, RESTRICT_EDIT_PREFIX);
+ if (!('edit' in restrictions)) {
+ restrictions['edit'] = [permissionType];
+ } else {
+ restrictions['edit'].push(permissionType);
+ }
+ } else if (lowerCaseLabel.startsWith(RESTRICT_COMMENT_PREFIX)) {
+ const permissionType = removePrefix(label, RESTRICT_COMMENT_PREFIX);
+ if (!('comment' in restrictions)) {
+ restrictions['comment'] = [permissionType];
+ } else {
+ restrictions['comment'].push(permissionType);
+ }
+ }
+ });
+
+ return restrictions;
+ },
+);
+
+export const isOpen = createSelector(
+ viewedIssue,
+ (issue) => issue && issue.statusRef && issue.statusRef.meansOpen || false);
+
+// Returns a function that, given an issue and its related issues,
+// returns a combined list of issue ref strings including related issues,
+// blocking or blocked on issues, and federated references.
+const mapRefsWithRelated = (blocking) => {
+ return (issue, relatedIssues) => {
+ let refs = [];
+ if (blocking) {
+ if (issue.blockingIssueRefs) {
+ refs = refs.concat(issue.blockingIssueRefs);
+ }
+ if (issue.danglingBlockingRefs) {
+ refs = refs.concat(issue.danglingBlockingRefs);
+ }
+ } else {
+ if (issue.blockedOnIssueRefs) {
+ refs = refs.concat(issue.blockedOnIssueRefs);
+ }
+ if (issue.danglingBlockedOnRefs) {
+ refs = refs.concat(issue.danglingBlockedOnRefs);
+ }
+ }
+
+ // Note: relatedIssues is a Redux generated key for issues, not part of the
+ // pRPC Issue object.
+ if (issue.relatedIssues) {
+ refs = refs.concat(issue.relatedIssues);
+ }
+ return refs.map((ref) => {
+ const key = issueRefToString(ref);
+ if (relatedIssues.has(key)) {
+ return relatedIssues.get(key);
+ }
+ return ref;
+ });
+ };
+};
+
+export const blockingIssues = createSelector(
+ viewedIssue, relatedIssues,
+ mapRefsWithRelated(true),
+);
+
+export const blockedOnIssues = createSelector(
+ viewedIssue, relatedIssues,
+ mapRefsWithRelated(false),
+);
+
+export const mergedInto = createSelector(
+ viewedIssue, relatedIssues,
+ (issue, relatedIssues) => {
+ if (!issue || !issue.mergedIntoIssueRef) return {};
+ const key = issueRefToString(issue.mergedIntoIssueRef);
+ if (relatedIssues && relatedIssues.has(key)) {
+ return relatedIssues.get(key);
+ }
+ return issue.mergedIntoIssueRef;
+ },
+);
+
+export const sortedBlockedOn = createSelector(
+ blockedOnIssues,
+ (blockedOn) => blockedOn.sort((a, b) => {
+ const aIsOpen = a.statusRef && a.statusRef.meansOpen ? 1 : 0;
+ const bIsOpen = b.statusRef && b.statusRef.meansOpen ? 1 : 0;
+ return bIsOpen - aIsOpen;
+ }),
+);
+
+// values (from issue.fieldValues) is an array with one entry per value.
+// We want to turn this into a map of fieldNames -> values.
+export const fieldValueMap = createSelector(
+ fieldValues,
+ (fieldValues) => fieldValuesToMap(fieldValues),
+);
+
+// Get the list of full componentDefs for the viewed issue.
+export const components = createSelector(
+ viewedIssue,
+ projectV0.componentsMap,
+ (issue, components) => {
+ if (!issue || !issue.componentRefs) return [];
+ return issue.componentRefs.map(
+ (comp) => components.get(comp.path) || comp);
+ },
+);
+
+// Get custom fields that apply to a specific issue.
+export const fieldDefs = createSelector(
+ projectV0.fieldDefs,
+ type,
+ fieldValueMap,
+ (fieldDefs, type, fieldValues) => {
+ if (!fieldDefs) return [];
+ type = type || '';
+ return fieldDefs.filter((f) => {
+ const fieldValueKey = fieldValueMapKey(f.fieldRef.fieldName,
+ f.phaseRef && f.phaseRef.phaseName);
+ if (fieldValues && fieldValues.has(fieldValueKey)) {
+ // Regardless of other checks, include a particular field def if the
+ // issue has a value defined.
+ return true;
+ }
+ // Skip approval type and phase fields here.
+ if (f.fieldRef.approvalName ||
+ f.fieldRef.type === fieldTypes.APPROVAL_TYPE ||
+ f.isPhaseField) {
+ return false;
+ }
+
+ // If this fieldDef belongs to only one type, filter out the field if
+ // that type isn't the specified type.
+ if (f.applicableType && type.toLowerCase() !==
+ f.applicableType.toLowerCase()) {
+ return false;
+ }
+
+ return true;
+ });
+ },
+);
+
+// Action Creators
+/**
+ * Tells Redux that the user has navigated to an issue page and is now
+ * viewing a new issue.
+ * @param {IssueRef} issueRef The issue that the user is viewing.
+ * @return {AnyAction}
+ */
+export const viewIssue = (issueRef) => ({type: VIEW_ISSUE, issueRef});
+
+export const fetchCommentReferences = (comments, projectName) => {
+ return async (dispatch) => {
+ dispatch({type: FETCH_COMMENT_REFERENCES_START});
+
+ try {
+ const refs = await autolink.getReferencedArtifacts(comments, projectName);
+ const commentRefs = {};
+ refs.forEach(({componentName, existingRefs}) => {
+ commentRefs[componentName] = existingRefs;
+ });
+ dispatch({
+ type: FETCH_COMMENT_REFERENCES_SUCCESS,
+ commentReferences: commentRefs,
+ });
+ } catch (error) {
+ dispatch({type: FETCH_COMMENT_REFERENCES_FAILURE, error});
+ }
+ };
+};
+
+export const fetchReferencedUsers = (issue) => async (dispatch) => {
+ if (!issue) return;
+ dispatch({type: FETCH_REFERENCED_USERS_START});
+
+ // TODO(zhangtiff): Make this function account for custom fields
+ // of type user.
+ const userRefs = [...(issue.ccRefs || [])];
+ if (issue.ownerRef) {
+ userRefs.push(issue.ownerRef);
+ }
+ (issue.approvalValues || []).forEach((approval) => {
+ userRefs.push(...(approval.approverRefs || []));
+ if (approval.setterRef) {
+ userRefs.push(approval.setterRef);
+ }
+ });
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Users', 'ListReferencedUsers', {userRefs});
+
+ const referencedUsers = {};
+ (resp.users || []).forEach((user) => {
+ referencedUsers[user.displayName] = user;
+ });
+ dispatch({type: FETCH_REFERENCED_USERS_SUCCESS, referencedUsers});
+ } catch (error) {
+ dispatch({type: FETCH_REFERENCED_USERS_FAILURE, error});
+ }
+};
+
+export const fetchFederatedReferences = (issue) => async (dispatch) => {
+ dispatch({type: FETCH_FEDERATED_REFERENCES_START});
+
+ // Concat all potential fedrefs together, convert from shortlink to classes,
+ // then fire off a request to fetch the status of each.
+ const fedRefs = []
+ .concat(issue.danglingBlockingRefs || [])
+ .concat(issue.danglingBlockedOnRefs || [])
+ .concat(issue.mergedIntoIssueRef ? [issue.mergedIntoIssueRef] : [])
+ .filter((ref) => ref && ref.extIdentifier)
+ .map((ref) => fromShortlink(ref.extIdentifier))
+ .filter((fedRef) => fedRef);
+
+ // If no FedRefs, return empty Map.
+ if (fedRefs.length === 0) {
+ return;
+ }
+
+ try {
+ // Load email separately since it might have changed.
+ await loadGapi();
+ const email = await fetchGapiEmail();
+
+ // If already logged in, dispatch login success event.
+ dispatch({
+ type: userV0.GAPI_LOGIN_SUCCESS,
+ email: email,
+ });
+
+ await Promise.all(fedRefs.map((fedRef) => fedRef.getFederatedDetails()));
+ const fedRefIssueRefs = fedRefs.map((fedRef) => fedRef.toIssueRef());
+
+ dispatch({
+ type: FETCH_FEDERATED_REFERENCES_SUCCESS,
+ fedRefIssueRefs: fedRefIssueRefs,
+ });
+ } catch (error) {
+ dispatch({type: FETCH_FEDERATED_REFERENCES_FAILURE, error});
+ }
+};
+
+// TODO(zhangtiff): Figure out if we can reduce request/response sizes by
+// diffing issues to fetch against issues we already know about to avoid
+// fetching duplicate info.
+export const fetchRelatedIssues = (issue) => async (dispatch) => {
+ if (!issue) return;
+ dispatch({type: FETCH_RELATED_ISSUES_START});
+
+ const refsToFetch = (issue.blockedOnIssueRefs || []).concat(
+ issue.blockingIssueRefs || []);
+ // Add mergedinto ref, exclude FedRefs which are fetched separately.
+ if (issue.mergedIntoIssueRef && !issue.mergedIntoIssueRef.extIdentifier) {
+ refsToFetch.push(issue.mergedIntoIssueRef);
+ }
+
+ const message = {
+ issueRefs: refsToFetch,
+ };
+ try {
+ // Fire off call to fetch FedRefs. Since it might take longer it is
+ // handled by a separate reducer.
+ dispatch(fetchFederatedReferences(issue));
+
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'ListReferencedIssues', message);
+
+ const relatedIssues = {};
+
+ const openIssues = resp.openRefs || [];
+ const closedIssues = resp.closedRefs || [];
+ openIssues.forEach((issue) => {
+ issue.statusRef.meansOpen = true;
+ relatedIssues[issueRefToString(issue)] = issue;
+ });
+ closedIssues.forEach((issue) => {
+ issue.statusRef.meansOpen = false;
+ relatedIssues[issueRefToString(issue)] = issue;
+ });
+ dispatch({
+ type: FETCH_RELATED_ISSUES_SUCCESS,
+ relatedIssues: relatedIssues,
+ });
+ } catch (error) {
+ dispatch({type: FETCH_RELATED_ISSUES_FAILURE, error});
+ };
+};
+
+/**
+ * Fetches issue data needed to display a detailed view of a single
+ * issue. This function dispatches many actions to handle the fetching
+ * of issue comments, permissions, star state, and more.
+ * @param {IssueRef} issueRef The issue that the user is viewing.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIssuePageData = (issueRef) => async (dispatch) => {
+ dispatch(fetchComments(issueRef));
+ dispatch(fetch(issueRef));
+ dispatch(fetchPermissions(issueRef));
+ dispatch(fetchIsStarred(issueRef));
+};
+
+/**
+ * @param {IssueRef} issueRef Which issue to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (issueRef) => async (dispatch) => {
+ dispatch({type: FETCH_START});
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'GetIssue', {issueRef},
+ );
+
+ const movedToRef = resp.movedToRef;
+
+ // The API can return deleted issue objects that don't have issueRef data
+ // specified. For this case, we want to make sure a projectName and localId
+ // are still provided to the frontend to ensure that keying issues still
+ // works.
+ const issue = {...issueRef, ...resp.issue};
+ if (movedToRef) {
+ issue.movedToRef = movedToRef;
+ }
+
+ dispatch({type: FETCH_SUCCESS, issue});
+
+ if (!issue.isDeleted && !movedToRef) {
+ dispatch(fetchRelatedIssues(issue));
+ dispatch(fetchHotlists(issueRef));
+ dispatch(fetchReferencedUsers(issue));
+ dispatch(userV0.fetchProjects([issue.reporterRef]));
+ }
+ } catch (error) {
+ dispatch({type: FETCH_FAILURE, error});
+ }
+};
+
+/**
+ * Action creator to fetch multiple Issues.
+ * @param {Array<IssueRef>} issueRefs An Array of Issue references to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIssues = (issueRefs) => async (dispatch) => {
+ dispatch({type: FETCH_ISSUES_START});
+
+ try {
+ const {openRefs, closedRefs} = await prpcClient.call(
+ 'monorail.Issues', 'ListReferencedIssues', {issueRefs});
+ const issues = [...openRefs || [], ...closedRefs || []];
+
+ dispatch({type: FETCH_ISSUES_SUCCESS, issues});
+ } catch (error) {
+ dispatch({type: FETCH_ISSUES_FAILURE, error});
+ }
+};
+
+/**
+ * Gets the hotlists that a given issue is in.
+ * @param {IssueRef} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchHotlists = (issueRef) => async (dispatch) => {
+ dispatch({type: FETCH_HOTLISTS_START});
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Features', 'ListHotlistsByIssue', {issue: issueRef});
+
+ const hotlists = (resp.hotlists || []);
+ hotlists.sort((hotlistA, hotlistB) => {
+ return hotlistA.name.localeCompare(hotlistB.name);
+ });
+ dispatch({type: FETCH_HOTLISTS_SUCCESS, hotlists});
+ } catch (error) {
+ dispatch({type: FETCH_HOTLISTS_FAILURE, error});
+ };
+};
+
+/**
+ * Async action creator to fetch issues in the issue list and grid pages. This
+ * action creator supports batching multiple async requests to support the grid
+ * view's ability to load up to 6,000 issues in one page load.
+ *
+ * @param {string} projectName The project to fetch issues from.
+ * @param {Object} params Options for which issues to fetch.
+ * @param {string=} params.q The query string for the search.
+ * @param {string=} params.can The ID of the canned query for the search.
+ * @param {string=} params.groupby The spec of which fields to group by.
+ * @param {string=} params.sort The spec of which fields to sort by.
+ * @param {number=} params.start What cursor index to start at.
+ * @param {number=} params.maxItems How many items to fetch per page.
+ * @param {number=} params.maxCalls The maximum number of API calls to make.
+ * Combined with pagination.maxItems, this defines the maximum number of
+ * issues this method can fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIssueList =
+ (projectName, {q = undefined, can = undefined, groupby = undefined,
+ sort = undefined, start = undefined, maxItems = undefined,
+ maxCalls = 1,
+ }) => async (dispatch) => {
+ let updateData = {};
+ const promises = [];
+ const issuesByRequest = [];
+ let issueLimit;
+ let totalIssues;
+ let totalCalls;
+ const itemsPerCall = maxItems || 1000;
+
+ const cannedQuery = Number.parseInt(can) || undefined;
+
+ const pagination = {
+ ...(start && {start}),
+ ...(maxItems && {maxItems}),
+ };
+
+ const message = {
+ projectNames: [projectName],
+ query: q,
+ cannedQuery,
+ groupBySpec: groupby,
+ sortSpec: sort,
+ pagination,
+ };
+
+ dispatch({type: FETCH_ISSUE_LIST_START});
+
+ // initial api call made to determine total number of issues matching
+ // the query.
+ try {
+ // TODO(zhangtiff): Refactor this action creator when adding issue
+ // list pagination.
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'ListIssues', message);
+
+ // prpcClient is not actually a protobuf client and therefore not
+ // hydrating default values. See crbug.com/monorail/6641
+ // Until that is fixed, we have to explicitly define it.
+ const defaultFetchListResponse = {totalResults: 0, issues: []};
+
+ updateData =
+ Object.entries(resp).length === 0 ?
+ defaultFetchListResponse :
+ resp;
+ issuesByRequest[0] = updateData.issues;
+ issueLimit = updateData.totalResults;
+
+ // determine correct issues to load and number of calls to be made.
+ if (issueLimit > (itemsPerCall * maxCalls)) {
+ totalIssues = itemsPerCall * maxCalls;
+ totalCalls = maxCalls - 1;
+ } else {
+ totalIssues = issueLimit;
+ totalCalls = Math.ceil(issueLimit / itemsPerCall) - 1;
+ }
+
+ if (totalIssues) {
+ updateData.progress = updateData.issues.length / totalIssues;
+ } else {
+ updateData.progress = 1;
+ }
+
+ dispatch({type: FETCH_ISSUE_LIST_UPDATE, ...updateData});
+
+ // remaining api calls are made.
+ for (let i = 1; i <= totalCalls; i++) {
+ promises[i - 1] = (async () => {
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'ListIssues', {
+ ...message,
+ pagination: {start: i * itemsPerCall, maxItems: itemsPerCall},
+ });
+ issuesByRequest[i] = (resp.issues || []);
+ // sort the issues in the correct order.
+ updateData.issues = [];
+ issuesByRequest.forEach((issue) => {
+ updateData.issues = updateData.issues.concat(issue);
+ });
+ updateData.progress = updateData.issues.length / totalIssues;
+ dispatch({type: FETCH_ISSUE_LIST_UPDATE, ...updateData});
+ })();
+ }
+
+ await Promise.all(promises);
+
+ // TODO(zhangtiff): Try to delete FETCH_ISSUE_LIST_SUCCESS in favor of
+ // just FETCH_ISSUE_LIST_UPDATE.
+ dispatch({type: FETCH_ISSUE_LIST_SUCCESS});
+ } catch (error) {
+ dispatch({type: FETCH_ISSUE_LIST_FAILURE, error});
+ };
+ };
+
+/**
+ * Fetches the currently logged in user's permissions for a given issue.
+ * @param {Issue} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchPermissions = (issueRef) => async (dispatch) => {
+ dispatch({type: FETCH_PERMISSIONS_START});
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'ListIssuePermissions', {issueRef},
+ );
+
+ dispatch({type: FETCH_PERMISSIONS_SUCCESS, permissions: resp.permissions});
+ } catch (error) {
+ dispatch({type: FETCH_PERMISSIONS_FAILURE, error});
+ };
+};
+
+/**
+ * Fetches comments for an issue. Note that issue descriptions are also
+ * comments.
+ * @param {IssueRef} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchComments = (issueRef) => async (dispatch) => {
+ dispatch({type: FETCH_COMMENTS_START});
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'ListComments', {issueRef});
+
+ dispatch({type: FETCH_COMMENTS_SUCCESS, comments: resp.comments});
+ dispatch(fetchCommentReferences(
+ resp.comments, issueRef.projectName));
+
+ const commenterRefs = (resp.comments || []).map(
+ (comment) => comment.commenter);
+ dispatch(userV0.fetchProjects(commenterRefs));
+ } catch (error) {
+ dispatch({type: FETCH_COMMENTS_FAILURE, error});
+ };
+};
+
+/**
+ * Gets whether the logged in user has starred a given issue.
+ * @param {IssueRef} issueRef
+ * @return {function(function): Promise<void>}
+ */
+export const fetchIsStarred = (issueRef) => async (dispatch) => {
+ dispatch({type: FETCH_IS_STARRED_START});
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'IsIssueStarred', {issueRef},
+ );
+
+ dispatch({
+ type: FETCH_IS_STARRED_SUCCESS,
+ starred: resp.isStarred,
+ issueRef: issueRef,
+ });
+ } catch (error) {
+ dispatch({type: FETCH_IS_STARRED_FAILURE, error});
+ };
+};
+
+/**
+ * Fetch all of a logged in user's starred issues.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchStarredIssues = () => async (dispatch) => {
+ dispatch({type: FETCH_ISSUES_STARRED_START});
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'ListStarredIssues', {},
+ );
+ dispatch({type: FETCH_ISSUES_STARRED_SUCCESS,
+ starredIssueRefs: resp.starredIssueRefs});
+ } catch (error) {
+ dispatch({type: FETCH_ISSUES_STARRED_FAILURE, error});
+ };
+};
+
+/**
+ * Stars or unstars an issue.
+ * @param {IssueRef} issueRef The issue to star.
+ * @param {boolean} starred Whether to star or unstar.
+ * @return {function(function): Promise<void>}
+ */
+export const star = (issueRef, starred) => async (dispatch) => {
+ const requestKey = issueRefToString(issueRef);
+
+ dispatch({type: STAR_START, requestKey});
+ const message = {issueRef, starred};
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'StarIssue', message,
+ );
+
+ dispatch({
+ type: STAR_SUCCESS,
+ starCount: resp.starCount,
+ issueRef,
+ starred,
+ requestKey,
+ });
+ } catch (error) {
+ dispatch({type: STAR_FAILURE, error, requestKey});
+ }
+};
+
+/**
+ * Runs a presubmit request to find warnings to show the user before an issue
+ * edit is saved.
+ * @param {IssueRef} issueRef The issue being edited.
+ * @param {IssueDelta} issueDelta The user's in flight changes to the issue.
+ * @return {function(function): Promise<void>}
+ */
+export const presubmit = (issueRef, issueDelta) => async (dispatch) => {
+ dispatch({type: PRESUBMIT_START});
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'PresubmitIssue', {issueRef, issueDelta});
+
+ dispatch({type: PRESUBMIT_SUCCESS, presubmitResponse: resp});
+ } catch (error) {
+ dispatch({type: PRESUBMIT_FAILURE, error: error});
+ }
+};
+
+/**
+ * Async action creator to update an issue's approval.
+ *
+ * @param {Object} params Options for the approval update.
+ * @param {IssueRef} params.issueRef
+ * @param {FieldRef} params.fieldRef
+ * @param {ApprovalDelta=} params.approvalDelta
+ * @param {string=} params.commentContent
+ * @param {boolean=} params.sendEmail
+ * @param {boolean=} params.isDescription
+ * @param {AttachmentUpload=} params.uploads
+ * @return {function(function): Promise<void>}
+ */
+export const updateApproval = ({issueRef, fieldRef, approvalDelta,
+ commentContent, sendEmail, isDescription, uploads}) => async (dispatch) => {
+ dispatch({type: UPDATE_APPROVAL_START});
+ try {
+ const {approval} = await prpcClient.call(
+ 'monorail.Issues', 'UpdateApproval', {
+ ...(issueRef && {issueRef}),
+ ...(fieldRef && {fieldRef}),
+ ...(approvalDelta && {approvalDelta}),
+ ...(commentContent && {commentContent}),
+ ...(sendEmail && {sendEmail}),
+ ...(isDescription && {isDescription}),
+ ...(uploads && {uploads}),
+ });
+
+ dispatch({type: UPDATE_APPROVAL_SUCCESS, approval, issueRef});
+ dispatch(fetch(issueRef));
+ dispatch(fetchComments(issueRef));
+ } catch (error) {
+ dispatch({type: UPDATE_APPROVAL_FAILURE, error: error});
+ };
+};
+
+/**
+ * Async action creator to update an issue.
+ *
+ * @param {Object} params Options for the issue update.
+ * @param {IssueRef} params.issueRef
+ * @param {IssueDelta=} params.delta
+ * @param {string=} params.commentContent
+ * @param {boolean=} params.sendEmail
+ * @param {boolean=} params.isDescription
+ * @param {AttachmentUpload=} params.uploads
+ * @param {Array<number>=} params.keptAttachments
+ * @return {function(function): Promise<void>}
+ */
+export const update = ({issueRef, delta, commentContent, sendEmail,
+ isDescription, uploads, keptAttachments}) => async (dispatch) => {
+ dispatch({type: UPDATE_START});
+
+ try {
+ const {issue} = await prpcClient.call(
+ 'monorail.Issues', 'UpdateIssue', {issueRef, delta,
+ commentContent, sendEmail, isDescription, uploads,
+ keptAttachments});
+
+ dispatch({type: UPDATE_SUCCESS, issue});
+ dispatch(fetchComments(issueRef));
+ dispatch(fetchRelatedIssues(issue));
+ dispatch(fetchReferencedUsers(issue));
+ } catch (error) {
+ dispatch({type: UPDATE_FAILURE, error: error});
+ };
+};
+
+/**
+ * Converts an issue from one template to another. This is used for changing
+ * launch issues.
+ * @param {IssueRef} issueRef
+ * @param {Object} options
+ * @param {string=} options.templateName
+ * @param {string=} options.commentContent
+ * @param {boolean=} options.sendEmail
+ * @return {function(function): Promise<void>}
+ */
+export const convert = (issueRef, {templateName = '',
+ commentContent = '', sendEmail = true},
+) => async (dispatch) => {
+ dispatch({type: CONVERT_START});
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Issues', 'ConvertIssueApprovalsTemplate',
+ {issueRef, templateName, commentContent, sendEmail});
+
+ dispatch({type: CONVERT_SUCCESS, issue: resp.issue});
+ const fetchCommentsMessage = {issueRef};
+ dispatch(fetchComments(fetchCommentsMessage));
+ } catch (error) {
+ dispatch({type: CONVERT_FAILURE, error: error});
+ };
+};
diff --git a/static_src/reducers/issueV0.test.js b/static_src/reducers/issueV0.test.js
new file mode 100644
index 0000000..0c7a0f5
--- /dev/null
+++ b/static_src/reducers/issueV0.test.js
@@ -0,0 +1,1409 @@
+// 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 {createSelector} from 'reselect';
+import {store, resetState} from './base.js';
+import * as issueV0 from './issueV0.js';
+import * as example from 'shared/test/constants-issueV0.js';
+import {fieldTypes} from 'shared/issue-fields.js';
+import {issueToIssueRef, issueRefToString} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {getSigninInstance} from 'shared/gapi-loader.js';
+
+let prpcCall;
+let dispatch;
+
+describe('issue', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+ });
+
+ describe('reducers', () => {
+ describe('issueByRefReducer', () => {
+ it('no-op on unmatching action', () => {
+ const action = {
+ type: 'FAKE_ACTION',
+ issues: [example.ISSUE_OTHER_PROJECT],
+ };
+ assert.deepEqual(issueV0.issuesByRefStringReducer({}, action), {});
+
+ const state = {[example.ISSUE_REF_STRING]: example.ISSUE};
+ assert.deepEqual(issueV0.issuesByRefStringReducer(state, action),
+ state);
+ });
+
+ it('handles FETCH_ISSUE_LIST_UPDATE', () => {
+ const newState = issueV0.issuesByRefStringReducer({}, {
+ type: issueV0.FETCH_ISSUE_LIST_UPDATE,
+ issues: [example.ISSUE, example.ISSUE_OTHER_PROJECT],
+ totalResults: 2,
+ progress: 1,
+ });
+ assert.deepEqual(newState, {
+ [example.ISSUE_REF_STRING]: example.ISSUE,
+ [example.ISSUE_OTHER_PROJECT_REF_STRING]: example.ISSUE_OTHER_PROJECT,
+ });
+ });
+
+ it('handles FETCH_ISSUES_SUCCESS', () => {
+ const newState = issueV0.issuesByRefStringReducer({}, {
+ type: issueV0.FETCH_ISSUES_SUCCESS,
+ issues: [example.ISSUE, example.ISSUE_OTHER_PROJECT],
+ });
+ assert.deepEqual(newState, {
+ [example.ISSUE_REF_STRING]: example.ISSUE,
+ [example.ISSUE_OTHER_PROJECT_REF_STRING]: example.ISSUE_OTHER_PROJECT,
+ });
+ });
+ });
+
+ describe('issueListReducer', () => {
+ it('no-op on unmatching action', () => {
+ const action = {
+ type: 'FETCH_ISSUE_LIST_FAKE_ACTION',
+ issues: [
+ {localId: 1, projectName: 'chromium', summary: 'hello-world'},
+ ],
+ };
+ assert.deepEqual(issueV0.issueListReducer({}, action), {});
+
+ assert.deepEqual(issueV0.issueListReducer({
+ issueRefs: ['chromium:1'],
+ totalResults: 1,
+ progress: 1,
+ }, action), {
+ issueRefs: ['chromium:1'],
+ totalResults: 1,
+ progress: 1,
+ });
+ });
+
+ it('handles FETCH_ISSUE_LIST_UPDATE', () => {
+ const newState = issueV0.issueListReducer({}, {
+ type: 'FETCH_ISSUE_LIST_UPDATE',
+ issues: [
+ {localId: 1, projectName: 'chromium', summary: 'hello-world'},
+ {localId: 2, projectName: 'monorail', summary: 'Test'},
+ ],
+ totalResults: 2,
+ progress: 1,
+ });
+ assert.deepEqual(newState, {
+ issueRefs: ['chromium:1', 'monorail:2'],
+ totalResults: 2,
+ progress: 1,
+ });
+ });
+ });
+
+ describe('relatedIssuesReducer', () => {
+ it('handles FETCH_RELATED_ISSUES_SUCCESS', () => {
+ const newState = issueV0.relatedIssuesReducer({}, {
+ type: 'FETCH_RELATED_ISSUES_SUCCESS',
+ relatedIssues: {'rutabaga:1234': {}},
+ });
+ assert.deepEqual(newState, {'rutabaga:1234': {}});
+ });
+
+ describe('FETCH_FEDERATED_REFERENCES_SUCCESS', () => {
+ it('returns early if data is missing', () => {
+ const newState = issueV0.relatedIssuesReducer({'b/123': {}}, {
+ type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+ });
+ assert.deepEqual(newState, {'b/123': {}});
+ });
+
+ it('returns early if data is empty', () => {
+ const newState = issueV0.relatedIssuesReducer({'b/123': {}}, {
+ type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+ fedRefIssueRefs: [],
+ });
+ assert.deepEqual(newState, {'b/123': {}});
+ });
+
+ it('assigns each FedRef to the state', () => {
+ const state = {
+ 'rutabaga:123': {},
+ 'rutabaga:345': {},
+ };
+ const newState = issueV0.relatedIssuesReducer(state, {
+ type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+ fedRefIssueRefs: [
+ {
+ extIdentifier: 'b/987',
+ summary: 'What is up',
+ statusRef: {meansOpen: true},
+ },
+ {
+ extIdentifier: 'b/765',
+ summary: 'Rutabaga',
+ statusRef: {meansOpen: false},
+ },
+ ],
+ });
+ assert.deepEqual(newState, {
+ 'rutabaga:123': {},
+ 'rutabaga:345': {},
+ 'b/987': {
+ extIdentifier: 'b/987',
+ summary: 'What is up',
+ statusRef: {meansOpen: true},
+ },
+ 'b/765': {
+ extIdentifier: 'b/765',
+ summary: 'Rutabaga',
+ statusRef: {meansOpen: false},
+ },
+ });
+ });
+ });
+ });
+ });
+
+ it('viewedIssue', () => {
+ assert.deepEqual(issueV0.viewedIssue(wrapIssue()), {});
+ assert.deepEqual(
+ issueV0.viewedIssue(wrapIssue({projectName: 'proj', localId: 100})),
+ {projectName: 'proj', localId: 100},
+ );
+ });
+
+ describe('issueList', () => {
+ it('issueList', () => {
+ const stateWithEmptyIssueList = {issue: {
+ issueList: {},
+ }};
+ assert.deepEqual(issueV0.issueList(stateWithEmptyIssueList), []);
+
+ const stateWithIssueList = {issue: {
+ issuesByRefString: {
+ 'chromium:1': {localId: 1, projectName: 'chromium', summary: 'test'},
+ 'monorail:2': {localId: 2, projectName: 'monorail',
+ summary: 'hello world'},
+ },
+ issueList: {
+ issueRefs: ['chromium:1', 'monorail:2'],
+ }}};
+ assert.deepEqual(issueV0.issueList(stateWithIssueList),
+ [
+ {localId: 1, projectName: 'chromium', summary: 'test'},
+ {localId: 2, projectName: 'monorail', summary: 'hello world'},
+ ]);
+ });
+
+ it('is a selector', () => {
+ issueV0.issueList.constructor === createSelector;
+ });
+
+ it('memoizes results: returns same reference', () => {
+ const stateWithIssueList = {issue: {
+ issuesByRefString: {
+ 'chromium:1': {localId: 1, projectName: 'chromium', summary: 'test'},
+ 'monorail:2': {localId: 2, projectName: 'monorail',
+ summary: 'hello world'},
+ },
+ issueList: {
+ issueRefs: ['chromium:1', 'monorail:2'],
+ }}};
+ const reference1 = issueV0.issueList(stateWithIssueList);
+ const reference2 = issueV0.issueList(stateWithIssueList);
+
+ assert.equal(typeof reference1, 'object');
+ assert.equal(typeof reference2, 'object');
+ assert.equal(reference1, reference2);
+ });
+ });
+
+ describe('issueListLoaded', () => {
+ const stateWithEmptyIssueList = {issue: {
+ issueList: {},
+ }};
+
+ it('false when no issue list', () => {
+ assert.isFalse(issueV0.issueListLoaded(stateWithEmptyIssueList));
+ });
+
+ it('true after issues loaded, even when empty', () => {
+ const issueList = issueV0.issueListReducer({}, {
+ type: issueV0.FETCH_ISSUE_LIST_UPDATE,
+ issues: [],
+ progress: 1,
+ totalResults: 0,
+ });
+ assert.isTrue(issueV0.issueListLoaded({issue: {issueList}}));
+ });
+ });
+
+ it('fieldValues', () => {
+ assert.isUndefined(issueV0.fieldValues(wrapIssue()));
+ assert.deepEqual(issueV0.fieldValues(wrapIssue({
+ fieldValues: [{value: 'v'}],
+ })), [{value: 'v'}]);
+ });
+
+ it('type computes type from custom field', () => {
+ assert.isUndefined(issueV0.type(wrapIssue()));
+ assert.isUndefined(issueV0.type(wrapIssue({
+ fieldValues: [{value: 'v'}],
+ })));
+ assert.deepEqual(issueV0.type(wrapIssue({
+ fieldValues: [
+ {fieldRef: {fieldName: 'IgnoreMe'}, value: 'v'},
+ {fieldRef: {fieldName: 'Type'}, value: 'Defect'},
+ ],
+ })), 'Defect');
+ });
+
+ it('type computes type from label', () => {
+ assert.deepEqual(issueV0.type(wrapIssue({
+ labelRefs: [
+ {label: 'Test'},
+ {label: 'tYpE-FeatureRequest'},
+ ],
+ })), 'FeatureRequest');
+
+ assert.deepEqual(issueV0.type(wrapIssue({
+ fieldValues: [
+ {fieldRef: {fieldName: 'IgnoreMe'}, value: 'v'},
+ ],
+ labelRefs: [
+ {label: 'Test'},
+ {label: 'Type-Defect'},
+ ],
+ })), 'Defect');
+ });
+
+ it('restrictions', () => {
+ assert.deepEqual(issueV0.restrictions(wrapIssue()), {});
+ assert.deepEqual(issueV0.restrictions(wrapIssue({labelRefs: []})), {});
+
+ assert.deepEqual(issueV0.restrictions(wrapIssue({labelRefs: [
+ {label: 'IgnoreThis'},
+ {label: 'IgnoreThis2'},
+ ]})), {});
+
+ assert.deepEqual(issueV0.restrictions(wrapIssue({labelRefs: [
+ {label: 'IgnoreThis'},
+ {label: 'IgnoreThis2'},
+ {label: 'Restrict-View-Google'},
+ {label: 'Restrict-EditIssue-hello'},
+ {label: 'Restrict-EditIssue-test'},
+ {label: 'Restrict-AddIssueComment-HELLO'},
+ ]})), {
+ 'view': ['Google'],
+ 'edit': ['hello', 'test'],
+ 'comment': ['HELLO'],
+ });
+ });
+
+ it('isOpen', () => {
+ assert.isFalse(issueV0.isOpen(wrapIssue()));
+ assert.isTrue(issueV0.isOpen(wrapIssue({statusRef: {meansOpen: true}})));
+ assert.isFalse(issueV0.isOpen(wrapIssue({statusRef: {meansOpen: false}})));
+ });
+
+ it('issueListPhaseNames', () => {
+ const stateWithEmptyIssueList = {issue: {
+ issueList: [],
+ }};
+ assert.deepEqual(issueV0.issueListPhaseNames(stateWithEmptyIssueList), []);
+ const stateWithIssueList = {issue: {
+ issuesByRefString: {
+ '1': {localId: 1, phases: [{phaseRef: {phaseName: 'chicken-phase'}}]},
+ '2': {localId: 2, phases: [
+ {phaseRef: {phaseName: 'chicken-Phase'}},
+ {phaseRef: {phaseName: 'cow-phase'}}],
+ },
+ '3': {localId: 3, phases: [
+ {phaseRef: {phaseName: 'cow-Phase'}},
+ {phaseRef: {phaseName: 'DOG-phase'}}],
+ },
+ '4': {localId: 4, phases: [
+ {phaseRef: {phaseName: 'dog-phase'}},
+ ]},
+ },
+ issueList: {
+ issueRefs: ['1', '2', '3', '4'],
+ }}};
+ assert.deepEqual(issueV0.issueListPhaseNames(stateWithIssueList),
+ ['chicken-phase', 'cow-phase', 'dog-phase']);
+ });
+
+ describe('blockingIssues', () => {
+ const relatedIssues = {
+ ['proj:1']: {
+ localId: 1,
+ projectName: 'proj',
+ labelRefs: [{label: 'label'}],
+ },
+ ['proj:3']: {
+ localId: 3,
+ projectName: 'proj',
+ labelRefs: [],
+ },
+ ['chromium:332']: {
+ localId: 332,
+ projectName: 'chromium',
+ labelRefs: [],
+ },
+ };
+
+ it('returns references when no issue data', () => {
+ const stateNoReferences = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockingIssueRefs: [{localId: 1, projectName: 'proj'}],
+ },
+ {relatedIssues: {}},
+ );
+ assert.deepEqual(issueV0.blockingIssues(stateNoReferences),
+ [{localId: 1, projectName: 'proj'}],
+ );
+ });
+
+ it('returns empty when no blocking issues', () => {
+ const stateNoIssues = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockingIssueRefs: [],
+ },
+ {relatedIssues},
+ );
+ assert.deepEqual(issueV0.blockingIssues(stateNoIssues), []);
+ });
+
+ it('returns full issues when deferenced data present', () => {
+ const stateIssuesWithReferences = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockingIssueRefs: [
+ {localId: 1, projectName: 'proj'},
+ {localId: 332, projectName: 'chromium'},
+ ],
+ },
+ {relatedIssues},
+ );
+ assert.deepEqual(issueV0.blockingIssues(stateIssuesWithReferences),
+ [
+ {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+ {localId: 332, projectName: 'chromium', labelRefs: []},
+ ]);
+ });
+
+ it('returns federated references', () => {
+ const stateIssuesWithFederatedReferences = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockingIssueRefs: [
+ {localId: 1, projectName: 'proj'},
+ {extIdentifier: 'b/1234'},
+ ],
+ },
+ {relatedIssues},
+ );
+ assert.deepEqual(
+ issueV0.blockingIssues(stateIssuesWithFederatedReferences), [
+ {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+ {extIdentifier: 'b/1234'},
+ ]);
+ });
+ });
+
+ describe('blockedOnIssues', () => {
+ const relatedIssues = {
+ ['proj:1']: {
+ localId: 1,
+ projectName: 'proj',
+ labelRefs: [{label: 'label'}],
+ },
+ ['proj:3']: {
+ localId: 3,
+ projectName: 'proj',
+ labelRefs: [],
+ },
+ ['chromium:332']: {
+ localId: 332,
+ projectName: 'chromium',
+ labelRefs: [],
+ },
+ };
+
+ it('returns references when no issue data', () => {
+ const stateNoReferences = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockedOnIssueRefs: [{localId: 1, projectName: 'proj'}],
+ },
+ {relatedIssues: {}},
+ );
+ assert.deepEqual(issueV0.blockedOnIssues(stateNoReferences),
+ [{localId: 1, projectName: 'proj'}],
+ );
+ });
+
+ it('returns empty when no blocking issues', () => {
+ const stateNoIssues = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockedOnIssueRefs: [],
+ },
+ {relatedIssues},
+ );
+ assert.deepEqual(issueV0.blockedOnIssues(stateNoIssues), []);
+ });
+
+ it('returns full issues when deferenced data present', () => {
+ const stateIssuesWithReferences = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockedOnIssueRefs: [
+ {localId: 1, projectName: 'proj'},
+ {localId: 332, projectName: 'chromium'},
+ ],
+ },
+ {relatedIssues},
+ );
+ assert.deepEqual(issueV0.blockedOnIssues(stateIssuesWithReferences),
+ [
+ {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+ {localId: 332, projectName: 'chromium', labelRefs: []},
+ ]);
+ });
+
+ it('returns federated references', () => {
+ const stateIssuesWithFederatedReferences = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockedOnIssueRefs: [
+ {localId: 1, projectName: 'proj'},
+ {extIdentifier: 'b/1234'},
+ ],
+ },
+ {relatedIssues},
+ );
+ assert.deepEqual(
+ issueV0.blockedOnIssues(stateIssuesWithFederatedReferences),
+ [
+ {localId: 1, projectName: 'proj', labelRefs: [{label: 'label'}]},
+ {extIdentifier: 'b/1234'},
+ ]);
+ });
+ });
+
+ describe('sortedBlockedOn', () => {
+ const relatedIssues = {
+ ['proj:1']: {
+ localId: 1,
+ projectName: 'proj',
+ statusRef: {meansOpen: true},
+ },
+ ['proj:3']: {
+ localId: 3,
+ projectName: 'proj',
+ statusRef: {meansOpen: false},
+ },
+ ['proj:4']: {
+ localId: 4,
+ projectName: 'proj',
+ statusRef: {meansOpen: false},
+ },
+ ['proj:5']: {
+ localId: 5,
+ projectName: 'proj',
+ statusRef: {meansOpen: false},
+ },
+ ['chromium:332']: {
+ localId: 332,
+ projectName: 'chromium',
+ statusRef: {meansOpen: true},
+ },
+ };
+
+ it('does not sort references when no issue data', () => {
+ const stateNoReferences = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockedOnIssueRefs: [
+ {localId: 3, projectName: 'proj'},
+ {localId: 1, projectName: 'proj'},
+ ],
+ },
+ {relatedIssues: {}},
+ );
+ assert.deepEqual(issueV0.sortedBlockedOn(stateNoReferences), [
+ {localId: 3, projectName: 'proj'},
+ {localId: 1, projectName: 'proj'},
+ ]);
+ });
+
+ it('sorts open issues first when issue data available', () => {
+ const stateReferences = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockedOnIssueRefs: [
+ {localId: 3, projectName: 'proj'},
+ {localId: 1, projectName: 'proj'},
+ ],
+ },
+ {relatedIssues},
+ );
+ assert.deepEqual(issueV0.sortedBlockedOn(stateReferences), [
+ {localId: 1, projectName: 'proj', statusRef: {meansOpen: true}},
+ {localId: 3, projectName: 'proj', statusRef: {meansOpen: false}},
+ ]);
+ });
+
+ it('preserves original order on ties', () => {
+ const statePreservesArrayOrder = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ blockedOnIssueRefs: [
+ {localId: 5, projectName: 'proj'}, // Closed
+ {localId: 1, projectName: 'proj'}, // Open
+ {localId: 4, projectName: 'proj'}, // Closed
+ {localId: 3, projectName: 'proj'}, // Closed
+ {localId: 332, projectName: 'chromium'}, // Open
+ ],
+ },
+ {relatedIssues},
+ );
+ assert.deepEqual(issueV0.sortedBlockedOn(statePreservesArrayOrder),
+ [
+ {localId: 1, projectName: 'proj', statusRef: {meansOpen: true}},
+ {localId: 332, projectName: 'chromium',
+ statusRef: {meansOpen: true}},
+ {localId: 5, projectName: 'proj', statusRef: {meansOpen: false}},
+ {localId: 4, projectName: 'proj', statusRef: {meansOpen: false}},
+ {localId: 3, projectName: 'proj', statusRef: {meansOpen: false}},
+ ],
+ );
+ });
+ });
+
+ describe('mergedInto', () => {
+ it('empty', () => {
+ assert.deepEqual(issueV0.mergedInto(wrapIssue()), {});
+ });
+
+ it('gets mergedInto ref for viewed issue', () => {
+ const state = issueV0.mergedInto(wrapIssue({
+ projectName: 'project',
+ localId: 123,
+ mergedIntoIssueRef: {localId: 22, projectName: 'proj'},
+ }));
+ assert.deepEqual(state, {
+ localId: 22,
+ projectName: 'proj',
+ });
+ });
+
+ it('gets full mergedInto issue data when it exists in the store', () => {
+ const state = wrapIssue(
+ {
+ projectName: 'project',
+ localId: 123,
+ mergedIntoIssueRef: {localId: 22, projectName: 'proj'},
+ }, {
+ relatedIssues: {
+ ['proj:22']: {localId: 22, projectName: 'proj', summary: 'test'},
+ },
+ });
+ assert.deepEqual(issueV0.mergedInto(state), {
+ localId: 22,
+ projectName: 'proj',
+ summary: 'test',
+ });
+ });
+ });
+
+ it('fieldValueMap', () => {
+ assert.deepEqual(issueV0.fieldValueMap(wrapIssue()), new Map());
+ assert.deepEqual(issueV0.fieldValueMap(wrapIssue({
+ fieldValues: [],
+ })), new Map());
+ assert.deepEqual(issueV0.fieldValueMap(wrapIssue({
+ fieldValues: [
+ {fieldRef: {fieldName: 'hello'}, value: 'v3'},
+ {fieldRef: {fieldName: 'hello'}, value: 'v2'},
+ {fieldRef: {fieldName: 'world'}, value: 'v3'},
+ ],
+ })), new Map([
+ ['hello', ['v3', 'v2']],
+ ['world', ['v3']],
+ ]));
+ });
+
+ it('fieldDefs filters fields by applicable type', () => {
+ assert.deepEqual(issueV0.fieldDefs({
+ projectV0: {},
+ ...wrapIssue(),
+ }), []);
+
+ assert.deepEqual(issueV0.fieldDefs({
+ projectV0: {
+ name: 'chromium',
+ configs: {
+ chromium: {
+ fieldDefs: [
+ {fieldRef: {fieldName: 'intyInt', type: fieldTypes.INT_TYPE}},
+ {fieldRef: {fieldName: 'enum', type: fieldTypes.ENUM_TYPE}},
+ {
+ fieldRef:
+ {fieldName: 'nonApplicable', type: fieldTypes.STR_TYPE},
+ applicableType: 'None',
+ },
+ {fieldRef: {fieldName: 'defectsOnly', type: fieldTypes.STR_TYPE},
+ applicableType: 'Defect'},
+ ],
+ },
+ },
+ },
+ ...wrapIssue({
+ fieldValues: [
+ {fieldRef: {fieldName: 'Type'}, value: 'Defect'},
+ ],
+ }),
+ }), [
+ {fieldRef: {fieldName: 'intyInt', type: fieldTypes.INT_TYPE}},
+ {fieldRef: {fieldName: 'enum', type: fieldTypes.ENUM_TYPE}},
+ {fieldRef: {fieldName: 'defectsOnly', type: fieldTypes.STR_TYPE},
+ applicableType: 'Defect'},
+ ]);
+ });
+
+ it('fieldDefs skips approval fields for all issues', () => {
+ assert.deepEqual(issueV0.fieldDefs({
+ projectV0: {
+ name: 'chromium',
+ configs: {
+ chromium: {
+ fieldDefs: [
+ {fieldRef: {fieldName: 'test', type: fieldTypes.INT_TYPE}},
+ {fieldRef:
+ {fieldName: 'ignoreMe', type: fieldTypes.APPROVAL_TYPE}},
+ {fieldRef:
+ {fieldName: 'LookAway', approvalName: 'ThisIsAnApproval'}},
+ {fieldRef: {fieldName: 'phaseField'}, isPhaseField: true},
+ ],
+ },
+ },
+ },
+ ...wrapIssue(),
+ }), [
+ {fieldRef: {fieldName: 'test', type: fieldTypes.INT_TYPE}},
+ ]);
+ });
+
+ it('fieldDefs includes non applicable fields when values defined', () => {
+ assert.deepEqual(issueV0.fieldDefs({
+ projectV0: {
+ name: 'chromium',
+ configs: {
+ chromium: {
+ fieldDefs: [
+ {
+ fieldRef:
+ {fieldName: 'nonApplicable', type: fieldTypes.STR_TYPE},
+ applicableType: 'None',
+ },
+ ],
+ },
+ },
+ },
+ ...wrapIssue({
+ fieldValues: [
+ {fieldRef: {fieldName: 'nonApplicable'}, value: 'v3'},
+ ],
+ }),
+ }), [
+ {fieldRef: {fieldName: 'nonApplicable', type: fieldTypes.STR_TYPE},
+ applicableType: 'None'},
+ ]);
+ });
+
+ describe('action creators', () => {
+ beforeEach(() => {
+ prpcCall = sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ prpcCall.restore();
+ });
+
+ it('viewIssue creates action with issueRef', () => {
+ assert.deepEqual(
+ issueV0.viewIssue({projectName: 'proj', localId: 123}),
+ {
+ type: issueV0.VIEW_ISSUE,
+ issueRef: {projectName: 'proj', localId: 123},
+ },
+ );
+ });
+
+
+ describe('updateApproval', async () => {
+ const APPROVAL = {
+ fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+ approverRefs: [{userId: 1234, displayName: 'test@example.com'}],
+ status: 'APPROVED',
+ };
+
+ it('approval update success', async () => {
+ const dispatch = sinon.stub();
+
+ prpcCall.returns({approval: APPROVAL});
+
+ const action = issueV0.updateApproval({
+ issueRef: {projectName: 'chromium', localId: 1234},
+ fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+ approvalDelta: {status: 'APPROVED'},
+ sendEmail: true,
+ });
+
+ await action(dispatch);
+
+ sinon.assert.calledOnce(prpcCall);
+
+ sinon.assert.calledWith(prpcCall, 'monorail.Issues',
+ 'UpdateApproval', {
+ issueRef: {projectName: 'chromium', localId: 1234},
+ fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+ approvalDelta: {status: 'APPROVED'},
+ sendEmail: true,
+ });
+
+ sinon.assert.calledWith(dispatch, {type: 'UPDATE_APPROVAL_START'});
+ sinon.assert.calledWith(dispatch, {
+ type: 'UPDATE_APPROVAL_SUCCESS',
+ approval: APPROVAL,
+ issueRef: {projectName: 'chromium', localId: 1234},
+ });
+ });
+
+ it('approval survey update success', async () => {
+ const dispatch = sinon.stub();
+
+ prpcCall.returns({approval: APPROVAL});
+
+ const action = issueV0.updateApproval({
+ issueRef: {projectName: 'chromium', localId: 1234},
+ fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+ commentContent: 'new survey',
+ sendEmail: false,
+ isDescription: true,
+ });
+
+ await action(dispatch);
+
+ sinon.assert.calledOnce(prpcCall);
+
+ sinon.assert.calledWith(prpcCall, 'monorail.Issues',
+ 'UpdateApproval', {
+ issueRef: {projectName: 'chromium', localId: 1234},
+ fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+ commentContent: 'new survey',
+ isDescription: true,
+ });
+
+ sinon.assert.calledWith(dispatch, {type: 'UPDATE_APPROVAL_START'});
+ sinon.assert.calledWith(dispatch, {
+ type: 'UPDATE_APPROVAL_SUCCESS',
+ approval: APPROVAL,
+ issueRef: {projectName: 'chromium', localId: 1234},
+ });
+ });
+
+ it('attachment upload success', async () => {
+ const dispatch = sinon.stub();
+
+ prpcCall.returns({approval: APPROVAL});
+
+ const action = issueV0.updateApproval({
+ issueRef: {projectName: 'chromium', localId: 1234},
+ fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+ uploads: '78f17a020cbf39e90e344a842cd19911',
+ });
+
+ await action(dispatch);
+
+ sinon.assert.calledOnce(prpcCall);
+
+ sinon.assert.calledWith(prpcCall, 'monorail.Issues',
+ 'UpdateApproval', {
+ issueRef: {projectName: 'chromium', localId: 1234},
+ fieldRef: {fieldName: 'Privacy', type: 'APPROVAL_TYPE'},
+ uploads: '78f17a020cbf39e90e344a842cd19911',
+ });
+
+ sinon.assert.calledWith(dispatch, {type: 'UPDATE_APPROVAL_START'});
+ sinon.assert.calledWith(dispatch, {
+ type: 'UPDATE_APPROVAL_SUCCESS',
+ approval: APPROVAL,
+ issueRef: {projectName: 'chromium', localId: 1234},
+ });
+ });
+ });
+
+ describe('fetchIssues', () => {
+ it('success', async () => {
+ const response = {
+ openRefs: [example.ISSUE],
+ closedRefs: [example.ISSUE_OTHER_PROJECT],
+ };
+ prpcClient.call.returns(Promise.resolve(response));
+ const dispatch = sinon.stub();
+
+ await issueV0.fetchIssues([example.ISSUE_REF])(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: issueV0.FETCH_ISSUES_START});
+
+ const args = {issueRefs: [example.ISSUE_REF]};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.Issues', 'ListReferencedIssues', args);
+
+ const action = {
+ type: issueV0.FETCH_ISSUES_SUCCESS,
+ issues: [example.ISSUE, example.ISSUE_OTHER_PROJECT],
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+ const dispatch = sinon.stub();
+
+ await issueV0.fetchIssues([example.ISSUE_REF])(dispatch);
+
+ const action = {
+ type: issueV0.FETCH_ISSUES_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ it('fetchIssueList calls ListIssues', async () => {
+ prpcCall.callsFake(() => {
+ return {
+ issues: [{localId: 1}, {localId: 2}, {localId: 3}],
+ totalResults: 6,
+ };
+ });
+
+ store.dispatch(issueV0.fetchIssueList('chromium',
+ {q: 'owner:me', can: '4'}));
+
+ sinon.assert.calledWith(prpcCall, 'monorail.Issues', 'ListIssues', {
+ query: 'owner:me',
+ cannedQuery: 4,
+ projectNames: ['chromium'],
+ pagination: {},
+ groupBySpec: undefined,
+ sortSpec: undefined,
+ });
+ });
+
+ it('fetchIssueList does not set can when can is NaN', async () => {
+ prpcCall.callsFake(() => ({}));
+
+ store.dispatch(issueV0.fetchIssueList('chromium', {q: 'owner:me',
+ can: 'four-leaf-clover'}));
+
+ sinon.assert.calledWith(prpcCall, 'monorail.Issues', 'ListIssues', {
+ query: 'owner:me',
+ cannedQuery: undefined,
+ projectNames: ['chromium'],
+ pagination: {},
+ groupBySpec: undefined,
+ sortSpec: undefined,
+ });
+ });
+
+ it('fetchIssueList makes several calls to ListIssues', async () => {
+ prpcCall.callsFake(() => {
+ return {
+ issues: [{localId: 1}, {localId: 2}, {localId: 3}],
+ totalResults: 6,
+ };
+ });
+
+ const dispatch = sinon.stub();
+ const action = issueV0.fetchIssueList('chromium',
+ {maxItems: 3, maxCalls: 2});
+ await action(dispatch);
+
+ sinon.assert.calledTwice(prpcCall);
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_ISSUE_LIST_UPDATE',
+ issues:
+ [{localId: 1}, {localId: 2}, {localId: 3},
+ {localId: 1}, {localId: 2}, {localId: 3}],
+ progress: 1,
+ totalResults: 6,
+ });
+ sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+ });
+
+ it('fetchIssueList orders issues correctly', async () => {
+ prpcCall.onFirstCall().returns({issues: [{localId: 1}], totalResults: 6});
+ prpcCall.onSecondCall().returns({
+ issues: [{localId: 2}],
+ totalResults: 6});
+ prpcCall.onThirdCall().returns({issues: [{localId: 3}], totalResults: 6});
+
+ const dispatch = sinon.stub();
+ const action = issueV0.fetchIssueList('chromium',
+ {maxItems: 1, maxCalls: 3});
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_ISSUE_LIST_UPDATE',
+ issues: [{localId: 1}, {localId: 2}, {localId: 3}],
+ progress: 1,
+ totalResults: 6,
+ });
+ sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+ });
+
+ it('returns progress of 1 when no totalIssues', async () => {
+ prpcCall.onFirstCall().returns({issues: [], totalResults: 0});
+
+ const dispatch = sinon.stub();
+ const action = issueV0.fetchIssueList('chromium',
+ {maxItems: 1, maxCalls: 1});
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_ISSUE_LIST_UPDATE',
+ issues: [],
+ progress: 1,
+ totalResults: 0,
+ });
+ sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+ });
+
+ it('returns progress of 1 when totalIssues undefined', async () => {
+ prpcCall.onFirstCall().returns({issues: []});
+
+ const dispatch = sinon.stub();
+ const action = issueV0.fetchIssueList('chromium',
+ {maxItems: 1, maxCalls: 1});
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_ISSUE_LIST_UPDATE',
+ issues: [],
+ progress: 1,
+ });
+ sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+ });
+
+ // TODO(kweng@) remove once crbug.com/monorail/6641 is fixed
+ it('has expected default for empty response', async () => {
+ prpcCall.onFirstCall().returns({});
+
+ const dispatch = sinon.stub();
+ const action = issueV0.fetchIssueList('chromium',
+ {maxItems: 1, maxCalls: 1});
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_ISSUE_LIST_UPDATE',
+ issues: [],
+ progress: 1,
+ totalResults: 0,
+ });
+ sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUE_LIST_SUCCESS'});
+ });
+
+ describe('federated references', () => {
+ beforeEach(() => {
+ // Preload signinImpl with a fake for testing.
+ getSigninInstance({
+ init: sinon.stub(),
+ getUserProfileAsync: () => (
+ Promise.resolve({
+ getEmail: sinon.stub().returns('rutabaga@google.com'),
+ })
+ ),
+ });
+ window.CS_env = {gapi_client_id: 'rutabaga'};
+ const getStub = sinon.stub().returns({
+ execute: (cb) => cb(response),
+ });
+ const response = {
+ result: {
+ resolvedTime: 12345,
+ issueState: {
+ title: 'Rutabaga title',
+ },
+ },
+ };
+ window.gapi = {
+ client: {
+ load: (_url, _version, cb) => cb(),
+ corp_issuetracker: {issues: {get: getStub}},
+ },
+ };
+ });
+
+ afterEach(() => {
+ delete window.CS_env;
+ delete window.gapi;
+ });
+
+ describe('fetchFederatedReferences', () => {
+ it('returns an empty map if no fedrefs found', async () => {
+ const dispatch = sinon.stub();
+ const testIssue = {};
+ const action = issueV0.fetchFederatedReferences(testIssue);
+ const result = await action(dispatch);
+
+ assert.equal(dispatch.getCalls().length, 1);
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_FEDERATED_REFERENCES_START',
+ });
+ assert.isUndefined(result);
+ });
+
+ it('fetches from Buganizer API', async () => {
+ const dispatch = sinon.stub();
+ const testIssue = {
+ danglingBlockingRefs: [
+ {extIdentifier: 'b/123456'},
+ ],
+ danglingBlockedOnRefs: [
+ {extIdentifier: 'b/654321'},
+ ],
+ mergedIntoIssueRef: {
+ extIdentifier: 'b/987654',
+ },
+ };
+ const action = issueV0.fetchFederatedReferences(testIssue);
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_FEDERATED_REFERENCES_START',
+ });
+ sinon.assert.calledWith(dispatch, {
+ type: 'GAPI_LOGIN_SUCCESS',
+ email: 'rutabaga@google.com',
+ });
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_FEDERATED_REFERENCES_SUCCESS',
+ fedRefIssueRefs: [
+ {
+ extIdentifier: 'b/123456',
+ statusRef: {meansOpen: false},
+ summary: 'Rutabaga title',
+ },
+ {
+ extIdentifier: 'b/654321',
+ statusRef: {meansOpen: false},
+ summary: 'Rutabaga title',
+ },
+ {
+ extIdentifier: 'b/987654',
+ statusRef: {meansOpen: false},
+ summary: 'Rutabaga title',
+ },
+ ],
+ });
+ });
+ });
+
+ describe('fetchRelatedIssues', () => {
+ it('calls fetchFederatedReferences for mergedinto', async () => {
+ const dispatch = sinon.stub();
+ prpcCall.returns(Promise.resolve({openRefs: [], closedRefs: []}));
+ const testIssue = {
+ mergedIntoIssueRef: {
+ extIdentifier: 'b/987654',
+ },
+ };
+ const action = issueV0.fetchRelatedIssues(testIssue);
+ await action(dispatch);
+
+ // Important: mergedinto fedref is not passed to ListReferencedIssues.
+ const expectedMessage = {issueRefs: []};
+ sinon.assert.calledWith(prpcClient.call, 'monorail.Issues',
+ 'ListReferencedIssues', expectedMessage);
+
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_RELATED_ISSUES_START',
+ });
+ // No mergedInto refs returned, they're handled by
+ // fetchFederatedReferences.
+ sinon.assert.calledWith(dispatch, {
+ type: 'FETCH_RELATED_ISSUES_SUCCESS',
+ relatedIssues: {},
+ });
+ });
+ });
+ });
+ });
+
+ describe('starring issues', () => {
+ describe('reducers', () => {
+ it('FETCH_IS_STARRED_SUCCESS updates the starredIssues object', () => {
+ const state = {};
+ const newState = issueV0.starredIssuesReducer(state,
+ {
+ type: issueV0.FETCH_IS_STARRED_SUCCESS,
+ starred: false,
+ issueRef: {
+ projectName: 'proj',
+ localId: 1,
+ },
+ },
+ );
+ assert.deepEqual(newState, {'proj:1': false});
+ });
+
+ it('FETCH_ISSUES_STARRED_SUCCESS updates the starredIssues object',
+ () => {
+ const state = {};
+ const starredIssueRefs = [{projectName: 'proj', localId: 1},
+ {projectName: 'proj', localId: 2}];
+ const newState = issueV0.starredIssuesReducer(state,
+ {type: issueV0.FETCH_ISSUES_STARRED_SUCCESS, starredIssueRefs},
+ );
+ assert.deepEqual(newState, {'proj:1': true, 'proj:2': true});
+ });
+
+ it('FETCH_ISSUES_STARRED_SUCCESS does not time out with 10,000 stars',
+ () => {
+ const state = {};
+ const starredIssueRefs = [];
+ const expected = {};
+ for (let i = 1; i <= 10000; i++) {
+ starredIssueRefs.push({projectName: 'proj', localId: i});
+ expected[`proj:${i}`] = true;
+ }
+ const newState = issueV0.starredIssuesReducer(state,
+ {type: issueV0.FETCH_ISSUES_STARRED_SUCCESS, starredIssueRefs},
+ );
+ assert.deepEqual(newState, expected);
+ });
+
+ it('STAR_SUCCESS updates the starredIssues object', () => {
+ const state = {'proj:1': true, 'proj:2': false};
+ const newState = issueV0.starredIssuesReducer(state,
+ {
+ type: issueV0.STAR_SUCCESS,
+ starred: true,
+ issueRef: {projectName: 'proj', localId: 2},
+ });
+ assert.deepEqual(newState, {'proj:1': true, 'proj:2': true});
+ });
+ });
+
+ describe('selectors', () => {
+ describe('issue', () => {
+ const selector = issueV0.issue(wrapIssue(example.ISSUE));
+ assert.deepEqual(selector(example.NAME), example.ISSUE);
+ });
+
+ describe('issueForRefString', () => {
+ const noIssues = issueV0.issueForRefString(wrapIssue({}));
+ const withIssue = issueV0.issueForRefString(wrapIssue({
+ projectName: 'test',
+ localId: 1,
+ summary: 'hello world',
+ }));
+
+ it('returns issue ref when no issue data', () => {
+ assert.deepEqual(noIssues('1', 'chromium'), {
+ localId: 1,
+ projectName: 'chromium',
+ });
+
+ assert.deepEqual(noIssues('chromium:2', 'ignore'), {
+ localId: 2,
+ projectName: 'chromium',
+ });
+
+ assert.deepEqual(noIssues('other:3'), {
+ localId: 3,
+ projectName: 'other',
+ });
+
+ assert.deepEqual(withIssue('other:3'), {
+ localId: 3,
+ projectName: 'other',
+ });
+ });
+
+ it('returns full issue data when available', () => {
+ assert.deepEqual(withIssue('1', 'test'), {
+ projectName: 'test',
+ localId: 1,
+ summary: 'hello world',
+ });
+
+ assert.deepEqual(withIssue('test:1', 'other'), {
+ projectName: 'test',
+ localId: 1,
+ summary: 'hello world',
+ });
+
+ assert.deepEqual(withIssue('test:1'), {
+ projectName: 'test',
+ localId: 1,
+ summary: 'hello world',
+ });
+ });
+ });
+
+ it('starredIssues', () => {
+ const state = {issue:
+ {starredIssues: {'proj:1': true, 'proj:2': false}}};
+ assert.deepEqual(issueV0.starredIssues(state), new Set(['proj:1']));
+ });
+
+ it('starringIssues', () => {
+ const state = {issue: {
+ requests: {
+ starringIssues: {
+ 'proj:1': {requesting: true},
+ 'proj:2': {requestin: false, error: 'unknown error'},
+ },
+ },
+ }};
+ assert.deepEqual(issueV0.starringIssues(state), new Map([
+ ['proj:1', {requesting: true}],
+ ['proj:2', {requestin: false, error: 'unknown error'}],
+ ]));
+ });
+ });
+
+ describe('action creators', () => {
+ beforeEach(() => {
+ prpcCall = sinon.stub(prpcClient, 'call');
+
+ dispatch = sinon.stub();
+ });
+
+ afterEach(() => {
+ prpcCall.restore();
+ });
+
+ it('fetching if an issue is starred', async () => {
+ const issueRef = {projectName: 'proj', localId: 1};
+ const action = issueV0.fetchIsStarred(issueRef);
+
+ prpcCall.returns(Promise.resolve({isStarred: true}));
+
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch,
+ {type: issueV0.FETCH_IS_STARRED_START});
+
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.Issues',
+ 'IsIssueStarred', {issueRef},
+ );
+
+ sinon.assert.calledWith(dispatch, {
+ type: issueV0.FETCH_IS_STARRED_SUCCESS,
+ starred: true,
+ issueRef,
+ });
+ });
+
+ it('fetching starred issues', async () => {
+ const returnedIssueRef = {projectName: 'proj', localId: 1};
+ const starredIssueRefs = [returnedIssueRef];
+ const action = issueV0.fetchStarredIssues();
+
+ prpcCall.returns(Promise.resolve({starredIssueRefs}));
+
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: 'FETCH_ISSUES_STARRED_START'});
+
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.Issues',
+ 'ListStarredIssues', {},
+ );
+
+ sinon.assert.calledWith(dispatch, {
+ type: issueV0.FETCH_ISSUES_STARRED_SUCCESS,
+ starredIssueRefs,
+ });
+ });
+
+ it('star', async () => {
+ const testIssue = {projectName: 'proj', localId: 1, starCount: 1};
+ const issueRef = issueToIssueRef(testIssue);
+ const action = issueV0.star(issueRef, false);
+
+ prpcCall.returns(Promise.resolve(testIssue));
+
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {
+ type: issueV0.STAR_START,
+ requestKey: 'proj:1',
+ });
+
+ sinon.assert.calledWith(
+ prpcClient.call,
+ 'monorail.Issues', 'StarIssue',
+ {issueRef, starred: false},
+ );
+
+ sinon.assert.calledWith(dispatch, {
+ type: issueV0.STAR_SUCCESS,
+ starCount: 1,
+ issueRef,
+ starred: false,
+ requestKey: 'proj:1',
+ });
+ });
+ });
+ });
+});
+
+/**
+ * Return an initial Redux state with a given viewed
+ * @param {Issue=} viewedIssue The viewed issue.
+ * @param {Object=} otherValues Any other state values that need
+ * to be initialized.
+ * @return {Object}
+ */
+function wrapIssue(viewedIssue, otherValues = {}) {
+ if (!viewedIssue) {
+ return {
+ issue: {
+ issuesByRefString: {},
+ ...otherValues,
+ },
+ };
+ }
+
+ const ref = issueRefToString(viewedIssue);
+ return {
+ issue: {
+ viewedIssueRef: ref,
+ issuesByRefString: {
+ [ref]: {...viewedIssue},
+ },
+ ...otherValues,
+ },
+ };
+}
diff --git a/static_src/reducers/permissions.js b/static_src/reducers/permissions.js
new file mode 100644
index 0000000..2f0101b
--- /dev/null
+++ b/static_src/reducers/permissions.js
@@ -0,0 +1,118 @@
+// 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 Permissions actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving permissions state
+ * on the frontend.
+ *
+ * The Permissions data is stored in a normalized format.
+ * `permissions` stores all PermissionSets[] indexed by resource name.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Permissions
+
+// Field Permissions
+export const FIELD_DEF_EDIT = 'FIELD_DEF_EDIT';
+export const FIELD_DEF_VALUE_EDIT = 'FIELD_DEF_VALUE_EDIT';
+
+// Actions
+export const BATCH_GET_START = 'permissions/BATCH_GET_START';
+export const BATCH_GET_SUCCESS = 'permissions/BATCH_GET_SUCCESS';
+export const BATCH_GET_FAILURE = 'permissions/BATCH_GET_FAILURE';
+
+/* State Shape
+{
+ byName: Object<string, PermissionSet>,
+
+ requests: {
+ batchGet: ReduxRequestState,
+ },
+}
+*/
+
+// Reducers
+
+/**
+ * All PermissionSets indexed by resource name.
+ * @param {Object<string, PermissionSet>} state The existing items.
+ * @param {AnyAction} action
+ * @param {Array<PermissionSet>} action.permissionSets
+ * @return {Object<string, PermissionSet>}
+ */
+export const byNameReducer = createReducer({}, {
+ [BATCH_GET_SUCCESS]: (state, {permissionSets}) => {
+ const newState = {...state};
+ for (const permissionSet of permissionSets) {
+ newState[permissionSet.resource] = permissionSet;
+ }
+ return newState;
+ },
+});
+
+const requestsReducer = combineReducers({
+ batchGet: createRequestReducer(
+ BATCH_GET_START, BATCH_GET_SUCCESS, BATCH_GET_FAILURE),
+});
+
+export const reducer = combineReducers({
+ byName: byNameReducer,
+
+ requests: requestsReducer,
+});
+
+// Selectors
+
+/**
+ * Returns all the PermissionSets in the store as a mapping.
+ * @param {any} state
+ * @return {Object<string, PermissionSet>}
+ */
+export const byName = (state) => state.permissions.byName;
+
+/**
+ * Returns the Permissions requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.permissions.requests;
+
+// Action Creators
+
+/**
+ * Action creator to fetch PermissionSets.
+ * @param {Array<string>} names The resource names to get.
+ * @return {function(function): Promise<Array<PermissionSet>>}
+ */
+export const batchGet = (names) => async (dispatch) => {
+ dispatch({type: BATCH_GET_START});
+
+ try {
+ /** @type {{permissionSets: Array<PermissionSet>}} */
+ const {permissionSets} = await prpcClient.call(
+ 'monorail.v3.Permissions', 'BatchGetPermissionSets', {names});
+
+ for (const permissionSet of permissionSets) {
+ if (!permissionSet.permissions) {
+ permissionSet.permissions = [];
+ }
+ }
+ dispatch({type: BATCH_GET_SUCCESS, permissionSets});
+
+ return permissionSets;
+ } catch (error) {
+ dispatch({type: BATCH_GET_FAILURE, error});
+ };
+};
diff --git a/static_src/reducers/permissions.test.js b/static_src/reducers/permissions.test.js
new file mode 100644
index 0000000..3c29076
--- /dev/null
+++ b/static_src/reducers/permissions.test.js
@@ -0,0 +1,105 @@
+// 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 sinon from 'sinon';
+
+import * as permissions from './permissions.js';
+import * as example from 'shared/test/constants-permissions.js';
+import * as exampleIssues from 'shared/test/constants-issueV0.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+describe('permissions reducers', () => {
+ it('root reducer initial state', () => {
+ const actual = permissions.reducer(undefined, {type: null});
+ const expected = {
+ byName: {},
+ requests: {
+ batchGet: {error: null, requesting: false},
+ },
+ };
+ assert.deepEqual(actual, expected);
+ });
+
+ it('byName updates on BATCH_GET_SUCCESS', () => {
+ const action = {
+ type: permissions.BATCH_GET_SUCCESS,
+ permissionSets: [example.PERMISSION_SET_ISSUE],
+ };
+ const actual = permissions.byNameReducer({}, action);
+ const expected = {
+ [example.PERMISSION_SET_ISSUE.resource]: example.PERMISSION_SET_ISSUE,
+ };
+ assert.deepEqual(actual, expected);
+ });
+});
+
+describe('permissions selectors', () => {
+ it('byName', () => {
+ const state = {permissions: {byName: example.BY_NAME}};
+ const actual = permissions.byName(state);
+ assert.deepEqual(actual, example.BY_NAME);
+ });
+});
+
+describe('permissions action creators', () => {
+ beforeEach(() => {
+ sinon.stub(prpcClient, 'call');
+ dispatch = sinon.stub();
+ });
+
+ afterEach(() => {
+ prpcClient.call.restore();
+ });
+
+ describe('batchGet', () => {
+ it('success', async () => {
+ const response = {permissionSets: [example.PERMISSION_SET_ISSUE]};
+ prpcClient.call.returns(Promise.resolve(response));
+
+ await permissions.batchGet([exampleIssues.NAME])(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: permissions.BATCH_GET_START});
+
+ const args = {names: [exampleIssues.NAME]};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Permissions',
+ 'BatchGetPermissionSets', args);
+
+ const action = {
+ type: permissions.BATCH_GET_SUCCESS,
+ permissionSets: [example.PERMISSION_SET_ISSUE],
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await permissions.batchGet([exampleIssues.NAME])(dispatch);
+
+ const action = {
+ type: permissions.BATCH_GET_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+
+ it('fills in permissions field', async () => {
+ const response = {permissionSets: [{resource: exampleIssues.NAME}]};
+ prpcClient.call.returns(Promise.resolve(response));
+
+ await permissions.batchGet([exampleIssues.NAME])(dispatch);
+
+ const action = {
+ type: permissions.BATCH_GET_SUCCESS,
+ permissionSets: [{resource: exampleIssues.NAME, permissions: []}],
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+});
diff --git a/static_src/reducers/projectV0.js b/static_src/reducers/projectV0.js
new file mode 100644
index 0000000..5101ff8
--- /dev/null
+++ b/static_src/reducers/projectV0.js
@@ -0,0 +1,586 @@
+// 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 {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import * as permissions from 'reducers/permissions.js';
+import {fieldTypes, SITEWIDE_DEFAULT_COLUMNS, defaultIssueFieldMap,
+ parseColSpec, stringValuesForIssueField} from 'shared/issue-fields.js';
+import {hasPrefix, removePrefix} from 'shared/helpers.js';
+import {fieldNameToLabelPrefix,
+ labelNameToLabelPrefixes, labelNameToLabelValue, fieldDefToName,
+ restrictionLabelsForPermissions} from 'shared/convertersV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const SELECT = 'projectV0/SELECT';
+
+const FETCH_CONFIG_START = 'projectV0/FETCH_CONFIG_START';
+export const FETCH_CONFIG_SUCCESS = 'projectV0/FETCH_CONFIG_SUCCESS';
+const FETCH_CONFIG_FAILURE = 'projectV0/FETCH_CONFIG_FAILURE';
+
+export const FETCH_PRESENTATION_CONFIG_START =
+ 'projectV0/FETCH_PRESENTATION_CONFIG_START';
+export const FETCH_PRESENTATION_CONFIG_SUCCESS =
+ 'projectV0/FETCH_PRESENTATION_CONFIG_SUCCESS';
+export const FETCH_PRESENTATION_CONFIG_FAILURE =
+ 'projectV0/FETCH_PRESENTATION_CONFIG_FAILURE';
+
+export const FETCH_CUSTOM_PERMISSIONS_START =
+ 'projectV0/FETCH_CUSTOM_PERMISSIONS_START';
+export const FETCH_CUSTOM_PERMISSIONS_SUCCESS =
+ 'projectV0/FETCH_CUSTOM_PERMISSIONS_SUCCESS';
+export const FETCH_CUSTOM_PERMISSIONS_FAILURE =
+ 'projectV0/FETCH_CUSTOM_PERMISSIONS_FAILURE';
+
+
+export const FETCH_VISIBLE_MEMBERS_START =
+ 'projectV0/FETCH_VISIBLE_MEMBERS_START';
+export const FETCH_VISIBLE_MEMBERS_SUCCESS =
+ 'projectV0/FETCH_VISIBLE_MEMBERS_SUCCESS';
+export const FETCH_VISIBLE_MEMBERS_FAILURE =
+ 'projectV0/FETCH_VISIBLE_MEMBERS_FAILURE';
+
+const FETCH_TEMPLATES_START = 'projectV0/FETCH_TEMPLATES_START';
+export const FETCH_TEMPLATES_SUCCESS = 'projectV0/FETCH_TEMPLATES_SUCCESS';
+const FETCH_TEMPLATES_FAILURE = 'projectV0/FETCH_TEMPLATES_FAILURE';
+
+/* State Shape
+{
+ name: string,
+
+ configs: Object<string, Config>,
+ presentationConfigs: Object<string, PresentationConfig>,
+ customPermissions: Object<string, Array<string>>,
+ visibleMembers:
+ Object<string, {userRefs: Array<UserRef>, groupRefs: Array<UserRef>}>,
+ templates: Object<string, Array<TemplateDef>>,
+ presentationConfigsLoaded: Object<string, boolean>,
+
+ requests: {
+ fetchConfig: ReduxRequestState,
+ fetchMembers: ReduxRequestState
+ fetchCustomPermissions: ReduxRequestState,
+ fetchPresentationConfig: ReduxRequestState,
+ fetchTemplates: ReduxRequestState,
+ },
+}
+*/
+
+// Reducers
+export const nameReducer = createReducer(null, {
+ [SELECT]: (_state, {projectName}) => projectName,
+});
+
+export const configsReducer = createReducer({}, {
+ [FETCH_CONFIG_SUCCESS]: (state, {projectName, config}) => ({
+ ...state,
+ [projectName]: config,
+ }),
+});
+
+export const presentationConfigsReducer = createReducer({}, {
+ [FETCH_PRESENTATION_CONFIG_SUCCESS]:
+ (state, {projectName, presentationConfig}) => ({
+ ...state,
+ [projectName]: presentationConfig,
+ }),
+});
+
+/**
+ * Adds custom permissions to Redux in a normalized state.
+ * @param {Object<string, Array<String>>} state Redux state.
+ * @param {AnyAction} Action
+ * @return {Object<string, Array<String>>}
+ */
+export const customPermissionsReducer = createReducer({}, {
+ [FETCH_CUSTOM_PERMISSIONS_SUCCESS]:
+ (state, {projectName, permissions}) => ({
+ ...state,
+ [projectName]: permissions,
+ }),
+});
+
+export const visibleMembersReducer = createReducer({}, {
+ [FETCH_VISIBLE_MEMBERS_SUCCESS]: (state, {projectName, visibleMembers}) => ({
+ ...state,
+ [projectName]: visibleMembers,
+ }),
+});
+
+export const templatesReducer = createReducer({}, {
+ [FETCH_TEMPLATES_SUCCESS]: (state, {projectName, templates}) => ({
+ ...state,
+ [projectName]: templates,
+ }),
+});
+
+const requestsReducer = combineReducers({
+ fetchConfig: createRequestReducer(
+ FETCH_CONFIG_START, FETCH_CONFIG_SUCCESS, FETCH_CONFIG_FAILURE),
+ fetchMembers: createRequestReducer(
+ FETCH_VISIBLE_MEMBERS_START,
+ FETCH_VISIBLE_MEMBERS_SUCCESS,
+ FETCH_VISIBLE_MEMBERS_FAILURE),
+ fetchCustomPermissions: createRequestReducer(
+ FETCH_CUSTOM_PERMISSIONS_START,
+ FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+ FETCH_CUSTOM_PERMISSIONS_FAILURE),
+ fetchPresentationConfig: createRequestReducer(
+ FETCH_PRESENTATION_CONFIG_START,
+ FETCH_PRESENTATION_CONFIG_SUCCESS,
+ FETCH_PRESENTATION_CONFIG_FAILURE),
+ fetchTemplates: createRequestReducer(
+ FETCH_TEMPLATES_START, FETCH_TEMPLATES_SUCCESS, FETCH_TEMPLATES_FAILURE),
+});
+
+export const reducer = combineReducers({
+ name: nameReducer,
+ configs: configsReducer,
+ customPermissions: customPermissionsReducer,
+ presentationConfigs: presentationConfigsReducer,
+ visibleMembers: visibleMembersReducer,
+ templates: templatesReducer,
+ requests: requestsReducer,
+});
+
+// Selectors
+export const project = (state) => state.projectV0 || {};
+
+export const viewedProjectName =
+ createSelector(project, (project) => project.name || null);
+
+export const configs =
+ createSelector(project, (project) => project.configs || {});
+export const presentationConfigs =
+ createSelector(project, (project) => project.presentationConfigs || {});
+export const customPermissions =
+ createSelector(project, (project) => project.customPermissions || {});
+export const visibleMembers =
+ createSelector(project, (project) => project.visibleMembers || {});
+export const templates =
+ createSelector(project, (project) => project.templates || {});
+
+export const viewedConfig = createSelector(
+ [viewedProjectName, configs],
+ (projectName, configs) => configs[projectName] || {});
+export const viewedPresentationConfig = createSelector(
+ [viewedProjectName, presentationConfigs],
+ (projectName, configs) => configs[projectName] || {});
+
+// TODO(crbug.com/monorail/7080): Come up with a more clear and
+// consistent pattern for determining when data is loaded.
+export const viewedPresentationConfigLoaded = createSelector(
+ [viewedProjectName, presentationConfigs],
+ (projectName, configs) => !!configs[projectName]);
+export const viewedCustomPermissions = createSelector(
+ [viewedProjectName, customPermissions],
+ (projectName, permissions) => permissions[projectName] || []);
+export const viewedVisibleMembers = createSelector(
+ [viewedProjectName, visibleMembers],
+ (projectName, visibleMembers) => visibleMembers[projectName] || {});
+export const viewedTemplates = createSelector(
+ [viewedProjectName, templates],
+ (projectName, templates) => templates[projectName] || []);
+
+/**
+ * Get the default columns for the currently viewed project.
+ */
+export const defaultColumns = createSelector(viewedPresentationConfig,
+ ({defaultColSpec}) =>{
+ if (defaultColSpec) {
+ return parseColSpec(defaultColSpec);
+ }
+ return SITEWIDE_DEFAULT_COLUMNS;
+ });
+
+
+/**
+ * Get the default query for the currently viewed project.
+ */
+export const defaultQuery = createSelector(viewedPresentationConfig,
+ (config) => config.defaultQuery || '');
+
+// Look up components by path.
+export const componentsMap = createSelector(
+ viewedConfig,
+ (config) => {
+ if (!config || !config.componentDefs) return new Map();
+ const acc = new Map();
+ for (const v of config.componentDefs) {
+ acc.set(v.path, v);
+ }
+ return acc;
+ },
+);
+
+export const fieldDefs = createSelector(
+ viewedConfig, (config) => ((config && config.fieldDefs) || []),
+);
+
+export const fieldDefMap = createSelector(
+ fieldDefs, (fieldDefs) => {
+ const map = new Map();
+ fieldDefs.forEach((fd) => {
+ map.set(fd.fieldRef.fieldName.toLowerCase(), fd);
+ });
+ return map;
+ },
+);
+
+export const labelDefs = createSelector(
+ [viewedConfig, viewedCustomPermissions],
+ (config, permissions) => [
+ ...((config && config.labelDefs) || []),
+ ...restrictionLabelsForPermissions(permissions),
+ ],
+);
+
+// labelDefs stored in an easily findable format with label names as keys.
+export const labelDefMap = createSelector(
+ labelDefs, (labelDefs) => {
+ const map = new Map();
+ labelDefs.forEach((ld) => {
+ map.set(ld.label.toLowerCase(), ld);
+ });
+ return map;
+ },
+);
+
+/**
+ * A selector that builds a map where keys are label prefixes
+ * and values equal to sets of possible values corresponding to the prefix
+ * @param {Object} state Current Redux state.
+ * @return {Map}
+ */
+export const labelPrefixValueMap = createSelector(labelDefs, (labelDefs) => {
+ const prefixMap = new Map();
+ labelDefs.forEach((ld) => {
+ const prefixes = labelNameToLabelPrefixes(ld.label);
+
+ prefixes.forEach((prefix) => {
+ if (prefixMap.has(prefix)) {
+ prefixMap.get(prefix).add(labelNameToLabelValue(ld.label, prefix));
+ } else {
+ prefixMap.set(prefix, new Set(
+ [labelNameToLabelValue(ld.label, prefix)]));
+ }
+ });
+ });
+
+ return prefixMap;
+});
+
+/**
+ * A selector that builds an array of label prefixes, keeping casing intact
+ * Some labels are implicitly used as custom fields in the grid and list view.
+ * Only labels with more than one option are included, to reduce noise.
+ * @param {Object} state Current Redux state.
+ * @return {Array}
+ */
+export const labelPrefixFields = createSelector(
+ labelPrefixValueMap, (map) => {
+ const prefixes = [];
+
+ map.forEach((options, prefix) => {
+ // Ignore label prefixes with only one value.
+ if (options.size > 1) {
+ prefixes.push(prefix);
+ }
+ });
+
+ return prefixes;
+ },
+);
+
+/**
+ * A selector that wraps labelPrefixFields arrays as set for fast lookup.
+ * @param {Object} state Current Redux state.
+ * @return {Set}
+ */
+export const labelPrefixSet = createSelector(
+ labelPrefixFields, (fields) => new Set(fields.map(
+ (field) => field.toLowerCase())),
+);
+
+export const enumFieldDefs = createSelector(
+ fieldDefs,
+ (fieldDefs) => {
+ return fieldDefs.filter(
+ (fd) => fd.fieldRef.type === fieldTypes.ENUM_TYPE);
+ },
+);
+
+/**
+ * A selector that builds a function that's used to compute the value of
+ * a given field name on a given issue. This function abstracts the difference
+ * between custom fields, built-in fields, and implicit fields created
+ * from labels and considers these values in the context of the current
+ * project configuration.
+ * @param {Object} state Current Redux state.
+ * @return {function(Issue, string): Array<string>} A function that processes a
+ * given issue and field name to find the string value for that field, in
+ * the issue.
+ */
+export const extractFieldValuesFromIssue = createSelector(
+ viewedProjectName,
+ (projectName) => (issue, fieldName) =>
+ stringValuesForIssueField(issue, fieldName, projectName),
+);
+
+/**
+ * A selector that builds a function that's used to compute the type of a given
+ * field name.
+ * @param {Object} state Current Redux state.
+ * @return {function(string): string}
+ */
+export const extractTypeForFieldName = createSelector(fieldDefMap,
+ (fieldDefMap) => {
+ return (fieldName) => {
+ const key = fieldName.toLowerCase();
+
+ // If the field is a built in field. Default fields have precedence
+ // over custom fields.
+ if (defaultIssueFieldMap.hasOwnProperty(key)) {
+ return defaultIssueFieldMap[key].type;
+ }
+
+ // If the field is a custom field. Custom fields have precedence
+ // over label prefixes.
+ if (fieldDefMap.has(key)) {
+ return fieldDefMap.get(key).fieldRef.type;
+ }
+
+ // Default to STR_TYPE, including for label fields.
+ return fieldTypes.STR_TYPE;
+ };
+ },
+);
+
+export const optionsPerEnumField = createSelector(
+ enumFieldDefs,
+ labelDefs,
+ (fieldDefs, labelDefs) => {
+ const map = new Map(fieldDefs.map(
+ (fd) => [fd.fieldRef.fieldName.toLowerCase(), []]));
+ labelDefs.forEach((ld) => {
+ const labelName = ld.label;
+
+ const fd = fieldDefs.find((fd) => hasPrefix(
+ labelName, fieldNameToLabelPrefix(fd.fieldRef.fieldName)));
+ if (fd) {
+ const key = fd.fieldRef.fieldName.toLowerCase();
+ map.get(key).push({
+ ...ld,
+ optionName: removePrefix(labelName,
+ fieldNameToLabelPrefix(fd.fieldRef.fieldName)),
+ });
+ }
+ });
+ return map;
+ },
+);
+
+export const fieldDefsForPhases = createSelector(
+ fieldDefs,
+ (fieldDefs) => {
+ if (!fieldDefs) return [];
+ return fieldDefs.filter((f) => f.isPhaseField);
+ },
+);
+
+export const fieldDefsByApprovalName = createSelector(
+ fieldDefs,
+ (fieldDefs) => {
+ if (!fieldDefs) return new Map();
+ const acc = new Map();
+ for (const fd of fieldDefs) {
+ if (fd.fieldRef && fd.fieldRef.approvalName) {
+ if (acc.has(fd.fieldRef.approvalName)) {
+ acc.get(fd.fieldRef.approvalName).push(fd);
+ } else {
+ acc.set(fd.fieldRef.approvalName, [fd]);
+ }
+ }
+ }
+ return acc;
+ },
+);
+
+export const fetchingConfig = (state) => {
+ return state.projectV0.requests.fetchConfig.requesting;
+};
+
+/**
+ * Shorthand method for detecting whether we are currently
+ * fetching presentationConcifg
+ * @param {Object} state Current Redux state.
+ * @return {boolean}
+ */
+export const fetchingPresentationConfig = (state) => {
+ return state.projectV0.requests.fetchPresentationConfig.requesting;
+};
+
+// Action Creators
+/**
+ * Action creator to set the currently viewed Project.
+ * @param {string} projectName The name of the Project to select.
+ * @return {function(function): Promise<void>}
+ */
+export const select = (projectName) => {
+ return (dispatch) => dispatch({type: SELECT, projectName});
+};
+
+/**
+ * Fetches data required to view project.
+ * @param {string} projectName
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (projectName) => async (dispatch) => {
+ const configPromise = dispatch(fetchConfig(projectName));
+ const visibleMembersPromise = dispatch(fetchVisibleMembers(projectName));
+
+ dispatch(fetchPresentationConfig(projectName));
+ dispatch(fetchTemplates(projectName));
+
+ const customPermissionsPromise = dispatch(
+ fetchCustomPermissions(projectName));
+
+ // TODO(crbug.com/monorail/5828): Remove window.TKR_populateAutocomplete once
+ // the old autocomplete code is deprecated.
+ const [config, visibleMembers, customPermissions] = await Promise.all([
+ configPromise,
+ visibleMembersPromise,
+ customPermissionsPromise]);
+ config.labelDefs = [...config.labelDefs,
+ ...restrictionLabelsForPermissions(customPermissions)];
+ dispatch(fetchFieldPerms(config.projectName, config.fieldDefs));
+ // eslint-disable-next-line new-cap
+ window.TKR_populateAutocomplete(config, visibleMembers, customPermissions);
+};
+
+/**
+ * Fetches project configuration including things like the custom fields in a
+ * project, the statuses, etc.
+ * @param {string} projectName
+ * @return {function(function): Promise<Config>}
+ */
+const fetchConfig = (projectName) => async (dispatch) => {
+ dispatch({type: FETCH_CONFIG_START});
+
+ const getConfig = prpcClient.call(
+ 'monorail.Projects', 'GetConfig', {projectName});
+
+ try {
+ const config = await getConfig;
+ dispatch({type: FETCH_CONFIG_SUCCESS, projectName, config});
+ return config;
+ } catch (error) {
+ dispatch({type: FETCH_CONFIG_FAILURE, error});
+ }
+};
+
+export const fetchPresentationConfig = (projectName) => async (dispatch) => {
+ dispatch({type: FETCH_PRESENTATION_CONFIG_START});
+
+ try {
+ const presentationConfig = await prpcClient.call(
+ 'monorail.Projects', 'GetPresentationConfig', {projectName});
+ dispatch({
+ type: FETCH_PRESENTATION_CONFIG_SUCCESS,
+ projectName,
+ presentationConfig,
+ });
+ } catch (error) {
+ dispatch({type: FETCH_PRESENTATION_CONFIG_FAILURE, error});
+ }
+};
+
+/**
+ * Fetches custom permissions defined for a project.
+ * @param {string} projectName
+ * @return {function(function): Promise<Array<string>>}
+ */
+export const fetchCustomPermissions = (projectName) => async (dispatch) => {
+ dispatch({type: FETCH_CUSTOM_PERMISSIONS_START});
+
+ try {
+ const {permissions} = await prpcClient.call(
+ 'monorail.Projects', 'GetCustomPermissions', {projectName});
+ dispatch({
+ type: FETCH_CUSTOM_PERMISSIONS_SUCCESS,
+ projectName,
+ permissions,
+ });
+ return permissions;
+ } catch (error) {
+ dispatch({type: FETCH_CUSTOM_PERMISSIONS_FAILURE, error});
+ }
+};
+
+/**
+ * Fetches the project members that the user is able to view.
+ * @param {string} projectName
+ * @return {function(function): Promise<GetVisibleMembersResponse>}
+ */
+export const fetchVisibleMembers = (projectName) => async (dispatch) => {
+ dispatch({type: FETCH_VISIBLE_MEMBERS_START});
+
+ try {
+ const visibleMembers = await prpcClient.call(
+ 'monorail.Projects', 'GetVisibleMembers', {projectName});
+ dispatch({
+ type: FETCH_VISIBLE_MEMBERS_SUCCESS,
+ projectName,
+ visibleMembers,
+ });
+ return visibleMembers;
+ } catch (error) {
+ dispatch({type: FETCH_VISIBLE_MEMBERS_FAILURE, error});
+ }
+};
+
+const fetchTemplates = (projectName) => async (dispatch) => {
+ dispatch({type: FETCH_TEMPLATES_START});
+
+ const listTemplates = prpcClient.call(
+ 'monorail.Projects', 'ListProjectTemplates', {projectName});
+
+ // TODO(zhangtiff): Remove (see above TODO).
+ if (!listTemplates) return;
+
+ try {
+ const resp = await listTemplates;
+ dispatch({
+ type: FETCH_TEMPLATES_SUCCESS,
+ projectName,
+ templates: resp.templates,
+ });
+ } catch (error) {
+ dispatch({type: FETCH_TEMPLATES_FAILURE, error});
+ }
+};
+
+// Helpers
+
+/**
+ * Helper to fetch field permissions.
+ * @param {string} projectName The name of the project where the fields are.
+ * @param {Array<FieldDef>} fieldDefs
+ * @return {function(function): Promise<void>}
+ */
+export const fetchFieldPerms = (projectName, fieldDefs) => async (dispatch) => {
+ const fieldDefsNames = [];
+ if (fieldDefs) {
+ fieldDefs.forEach((fd) => {
+ const fieldDefName = fieldDefToName(projectName, fd);
+ fieldDefsNames.push(fieldDefName);
+ });
+ }
+ await dispatch(permissions.batchGet(fieldDefsNames));
+};
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);
+ });
+ });
+});
diff --git a/static_src/reducers/projects.js b/static_src/reducers/projects.js
new file mode 100644
index 0000000..955dfea
--- /dev/null
+++ b/static_src/reducers/projects.js
@@ -0,0 +1,129 @@
+// 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 Project actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving project state
+ * on the frontend.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+export const LIST_START = 'projects/LIST_START';
+export const LIST_SUCCESS = 'projects/LIST_SUCCESS';
+export const LIST_FAILURE = 'projects/LIST_FAILURE';
+
+/* State Shape
+{
+ name: string,
+
+ byName: Object<ProjectName, Project>,
+ allNames: Array<ProjectName>,
+
+ requests: {
+ list: ReduxRequestState,
+ },
+}
+*/
+
+/**
+ * All Project data indexed by Project name.
+ * @param {Object<ProjectName, Project>} state Existing Project data.
+ * @param {AnyAction} action
+ * @param {Array<Project>} action.projects The Projects that were fetched.
+ * @return {Object<ProjectName, Project>}
+ */
+export const byNameReducer = createReducer({}, {
+ [LIST_SUCCESS]: (state, {projects}) => {
+ const newProjects = {};
+ projects.forEach((proj) => {
+ newProjects[proj.name] = proj;
+ });
+ return {...state, ...newProjects};
+ },
+});
+
+/**
+ * Resource names for all Projects in Monorail.
+ * @param {Array<ProjectName>} _state Existing Project data.
+ * @param {AnyAction} action
+ * @param {Array<Project>} action.projects The Projects that were fetched.
+ * @return {Array<ProjectName>}
+ */
+export const allNamesReducer = createReducer([], {
+ [LIST_SUCCESS]: (_state, {projects}) => {
+ return projects.map((proj) => proj.name);
+ },
+});
+
+const requestsReducer = combineReducers({
+ list: createRequestReducer(
+ LIST_START, LIST_SUCCESS, LIST_FAILURE),
+});
+
+export const reducer = combineReducers({
+ byName: byNameReducer,
+ allNames: allNamesReducer,
+
+ requests: requestsReducer,
+});
+
+
+/**
+ * Returns normalized Project data by name.
+ * @param {any} state
+ * @return {Object<ProjectName, Project>}
+ * @private
+ */
+export const byName = (state) => state.projects.byName;
+
+/**
+ * Base selector for wrapping the allNames state key.
+ * @param {any} state
+ * @return {Array<ProjectName>}
+ * @private
+ */
+export const _allNames = (state) => state.projects.allNames;
+
+/**
+ * Returns all Projects on Monorail, in denormalized form, in
+ * the sort order returned by the API.
+ * @param {any} state
+ * @return {Array<Project>}
+ */
+export const all = createSelector([byName, _allNames],
+ (byName, allNames) => allNames.map((name) => byName[name]));
+
+
+/**
+ * Returns the Project requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.projects.requests;
+
+/**
+ * Gets all projects hosted on Monorail.
+ * @return {function(function): Promise<void>}
+ */
+export const list = () => async (dispatch) => {
+ dispatch({type: LIST_START});
+ try {
+ /** @type {{projects: Array<Project>}} */
+ const {projects} = await prpcClient.call(
+ 'monorail.v3.Projects', 'ListProjects', {});
+
+ dispatch({type: LIST_SUCCESS, projects});
+ } catch (error) {
+ dispatch({type: LIST_FAILURE, error});
+ }
+};
diff --git a/static_src/reducers/projects.test.js b/static_src/reducers/projects.test.js
new file mode 100644
index 0000000..0a9dee4
--- /dev/null
+++ b/static_src/reducers/projects.test.js
@@ -0,0 +1,174 @@
+// 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 * as projects from './projects.js';
+import * as example from 'shared/test/constants-projects.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+
+describe('project reducers', () => {
+ it('root reducer initial state', () => {
+ const actual = projects.reducer(undefined, {type: null});
+ const expected = {
+ byName: {},
+ allNames: [],
+ requests: {
+ list: {
+ error: null,
+ requesting: false,
+ },
+ },
+ };
+ assert.deepEqual(actual, expected);
+ });
+
+ describe('byNameReducer', () => {
+ it('populated on LIST_SUCCESS', () => {
+ const action = {type: projects.LIST_SUCCESS, projects:
+ [example.PROJECT, example.PROJECT_2]};
+ const actual = projects.byNameReducer({}, action);
+
+ assert.deepEqual(actual, {
+ [example.NAME]: example.PROJECT,
+ [example.NAME_2]: example.PROJECT_2,
+ });
+ });
+
+ it('keeps original state on empty LIST_SUCCESS', () => {
+ const originalState = {
+ [example.NAME]: example.PROJECT,
+ [example.NAME_2]: example.PROJECT_2,
+ };
+ const action = {type: projects.LIST_SUCCESS, projects: []};
+ const actual = projects.byNameReducer(originalState, action);
+
+ assert.deepEqual(actual, originalState);
+ });
+
+ it('appends new issues to state on LIST_SUCCESS', () => {
+ const originalState = {
+ [example.NAME]: example.PROJECT,
+ };
+ const action = {type: projects.LIST_SUCCESS,
+ projects: [example.PROJECT_2]};
+ const actual = projects.byNameReducer(originalState, action);
+
+ const expected = {
+ [example.NAME]: example.PROJECT,
+ [example.NAME_2]: example.PROJECT_2,
+ };
+ assert.deepEqual(actual, expected);
+ });
+
+ it('overrides outdated data on LIST_SUCCESS', () => {
+ const originalState = {
+ [example.NAME]: example.PROJECT,
+ [example.NAME_2]: example.PROJECT_2,
+ };
+
+ const newProject2 = {
+ name: example.NAME_2,
+ summary: 'I hacked your project!',
+ };
+ const action = {type: projects.LIST_SUCCESS,
+ projects: [newProject2]};
+ const actual = projects.byNameReducer(originalState, action);
+ const expected = {
+ [example.NAME]: example.PROJECT,
+ [example.NAME_2]: newProject2,
+ };
+ assert.deepEqual(actual, expected);
+ });
+ });
+
+ it('allNames populated on LIST_SUCCESS', () => {
+ const action = {type: projects.LIST_SUCCESS, projects:
+ [example.PROJECT, example.PROJECT_2]};
+ const actual = projects.allNamesReducer([], action);
+
+ assert.deepEqual(actual, [example.NAME, example.NAME_2]);
+ });
+});
+
+describe('project selectors', () => {
+ it('byName', () => {
+ const normalizedProjects = {
+ [example.NAME]: example.PROJECT,
+ };
+ const state = {projects: {
+ byName: normalizedProjects,
+ }};
+ assert.deepEqual(projects.byName(state), normalizedProjects);
+ });
+
+ it('all', () => {
+ const state = {projects: {
+ byName: {
+ [example.NAME]: example.PROJECT,
+ },
+ allNames: [example.NAME],
+ }};
+ assert.deepEqual(projects.all(state), [example.PROJECT]);
+ });
+
+ it('requests', () => {
+ const state = {projects: {
+ requests: {
+ list: {error: null, requesting: false},
+ },
+ }};
+ assert.deepEqual(projects.requests(state), {
+ list: {error: null, requesting: false},
+ });
+ });
+});
+
+describe('project action creators', () => {
+ beforeEach(() => {
+ sinon.stub(prpcClient, 'call');
+ dispatch = sinon.stub();
+ });
+
+ afterEach(() => {
+ prpcClient.call.restore();
+ });
+
+ describe('list', () => {
+ it('success', async () => {
+ const projectsResponse = {projects: [example.PROJECT, example.PROJECT_2]};
+ prpcClient.call.returns(Promise.resolve(projectsResponse));
+
+ await projects.list()(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: projects.LIST_START});
+
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Projects', 'ListProjects', {});
+
+ const successAction = {
+ type: projects.LIST_SUCCESS,
+ projects: projectsResponse.projects,
+ };
+ sinon.assert.calledWith(dispatch, successAction);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await projects.list()(dispatch);
+
+ const action = {
+ type: projects.LIST_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+});
diff --git a/static_src/reducers/redux-helpers.js b/static_src/reducers/redux-helpers.js
new file mode 100644
index 0000000..ce80d60
--- /dev/null
+++ b/static_src/reducers/redux-helpers.js
@@ -0,0 +1,64 @@
+// 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 createReducer = (initialState, handlers) => {
+ return function reducer(state = initialState, action) {
+ if (handlers.hasOwnProperty(action.type)) {
+ return handlers[action.type](state, action);
+ } else {
+ return state;
+ }
+ };
+};
+
+const DEFAULT_REQUEST_KEY = '*';
+
+export const createKeyedRequestReducer = (start, success, failure) => {
+ return createReducer({}, {
+ [start]: (state, {requestKey = DEFAULT_REQUEST_KEY}) => {
+ return {
+ ...state,
+ [requestKey]: {
+ requesting: true,
+ error: null,
+ },
+ };
+ },
+ [success]: (state, {requestKey = DEFAULT_REQUEST_KEY}) =>{
+ return {
+ ...state,
+ [requestKey]: {
+ requesting: false,
+ error: null,
+ },
+ };
+ },
+ [failure]: (state, {requestKey = DEFAULT_REQUEST_KEY, error}) => {
+ return {
+ ...state,
+ [requestKey]: {
+ requesting: false,
+ error,
+ },
+ };
+ },
+ });
+};
+
+export const createRequestReducer = (start, success, failure) => {
+ return createReducer({requesting: false, error: null}, {
+ [start]: (_state, _action) => ({
+ requesting: true,
+ error: null,
+ }),
+ [success]: (_state, _action) =>({
+ requesting: false,
+ error: null,
+ }),
+ [failure]: (_state, {error}) => ({
+ requesting: false,
+ error,
+ }),
+ });
+};
diff --git a/static_src/reducers/redux-helpers.test.js b/static_src/reducers/redux-helpers.test.js
new file mode 100644
index 0000000..93f0e0a
--- /dev/null
+++ b/static_src/reducers/redux-helpers.test.js
@@ -0,0 +1,102 @@
+// 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 {createRequestReducer,
+ createKeyedRequestReducer} from './redux-helpers.js';
+
+let keyedRequestReducer;
+let requestReducer;
+
+describe('redux-helpers', () => {
+ describe('createKeyedRequestReducer', () => {
+ beforeEach(() => {
+ keyedRequestReducer = createKeyedRequestReducer(
+ 'REQUEST_START', 'REQUEST_SUCCESS', 'REQUEST_FAILURE');
+ });
+
+ it('sets requesting to true on start', () => {
+ assert.deepEqual(keyedRequestReducer({}, {type: 'REQUEST_START'}),
+ {['*']: {requesting: true, error: null}});
+ });
+
+ it('sets requesting to false on success', () => {
+ assert.deepEqual(keyedRequestReducer({}, {type: 'REQUEST_SUCCESS'}),
+ {['*']: {requesting: false, error: null}});
+ });
+
+ it('sets error message on failure', () => {
+ assert.deepEqual(keyedRequestReducer({}, {
+ type: 'REQUEST_FAILURE',
+ error: 'hello',
+ }), {['*']: {requesting: false, error: 'hello'}});
+ });
+
+ it('preserves previous request state on start', () => {
+ const initialState = {
+ ['*']: {requesting: false, error: 'hello'},
+ };
+ assert.deepEqual(keyedRequestReducer(initialState, {
+ type: 'REQUEST_START',
+ requestKey: 'chromium:11',
+ }), {
+ ['*']: {requesting: false, error: 'hello'},
+ ['chromium:11']: {requesting: true, error: null},
+ });
+ });
+
+ it('preserves previous request state on success', () => {
+ const initialState = {
+ ['*']: {requesting: false, error: 'hello'},
+ ['chromium:11']: {requesting: true, error: null},
+ };
+ assert.deepEqual(keyedRequestReducer(initialState, {
+ type: 'REQUEST_SUCCESS',
+ requestKey: 'chromium:11',
+ }), {
+ ['*']: {requesting: false, error: 'hello'},
+ ['chromium:11']: {requesting: false, error: null},
+ });
+ });
+
+ it('preserves previous request state on failure', () => {
+ const initialState = {
+ ['*']: {requesting: false, error: 'hello'},
+ ['chromium:11']: {requesting: false, error: null},
+ };
+ assert.deepEqual(keyedRequestReducer(initialState, {
+ type: 'REQUEST_FAILURE',
+ requestKey: 'chromium:11',
+ error: 'something went wrong',
+ }), {
+ ['*']: {requesting: false, error: 'hello'},
+ ['chromium:11']: {requesting: false, error: 'something went wrong'},
+ });
+ });
+ });
+
+ describe('createRequestReducer', () => {
+ beforeEach(() => {
+ requestReducer = createRequestReducer(
+ 'REQUEST_START', 'REQUEST_SUCCESS', 'REQUEST_FAILURE');
+ });
+
+ it('sets requesting to true on start', () => {
+ assert.deepEqual(requestReducer({}, {type: 'REQUEST_START'}),
+ {requesting: true, error: null});
+ });
+
+ it('sets requesting to false on success', () => {
+ assert.deepEqual(requestReducer({}, {type: 'REQUEST_SUCCESS'}),
+ {requesting: false, error: null});
+ });
+
+ it('sets error message on failure', () => {
+ assert.deepEqual(requestReducer({}, {
+ type: 'REQUEST_FAILURE',
+ error: 'hello',
+ }), {requesting: false, error: 'hello'});
+ });
+ });
+});
diff --git a/static_src/reducers/sitewide.js b/static_src/reducers/sitewide.js
new file mode 100644
index 0000000..f7e20d7
--- /dev/null
+++ b/static_src/reducers/sitewide.js
@@ -0,0 +1,167 @@
+// 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 projectV0 from 'reducers/projectV0.js';
+import {combineReducers} from 'redux';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import {createSelector} from 'reselect';
+import {prpcClient} from 'prpc-client-instance.js';
+import {SITEWIDE_DEFAULT_CAN, parseColSpec} from 'shared/issue-fields.js';
+
+// Actions
+const SET_PAGE_TITLE = 'SET_PAGE_TITLE';
+const SET_HEADER_TITLE = 'SET_HEADER_TITLE';
+export const SET_QUERY_PARAMS = 'SET_QUERY_PARAMS';
+
+// Async actions
+const GET_SERVER_STATUS_FAILURE = 'GET_SERVER_STATUS_FAILURE';
+const GET_SERVER_STATUS_START = 'GET_SERVER_STATUS_START';
+const GET_SERVER_STATUS_SUCCESS = 'GET_SERVER_STATUS_SUCCESS';
+
+/* State Shape
+{
+ bannerMessage: String,
+ bannerTime: Number,
+ pageTitle: String,
+ headerTitle: String,
+ queryParams: Object,
+ readOnly: Boolean,
+ requests: {
+ serverStatus: Object,
+ },
+}
+*/
+
+// Reducers
+const bannerMessageReducer = createReducer('', {
+ [GET_SERVER_STATUS_SUCCESS]:
+ (_state, action) => action.serverStatus.bannerMessage || '',
+});
+
+const bannerTimeReducer = createReducer(0, {
+ [GET_SERVER_STATUS_SUCCESS]:
+ (_state, action) => action.serverStatus.bannerTime || 0,
+});
+
+/**
+ * Handle state for the current document title.
+ */
+const pageTitleReducer = createReducer('', {
+ [SET_PAGE_TITLE]: (_state, {title}) => title,
+});
+
+const headerTitleReducer = createReducer('', {
+ [SET_HEADER_TITLE]: (_state, {title}) => title,
+});
+
+const queryParamsReducer = createReducer({}, {
+ [SET_QUERY_PARAMS]: (_state, {queryParams}) => queryParams || {},
+});
+
+const readOnlyReducer = createReducer(false, {
+ [GET_SERVER_STATUS_SUCCESS]:
+ (_state, action) => action.serverStatus.readOnly || false,
+});
+
+const requestsReducer = combineReducers({
+ serverStatus: createRequestReducer(
+ GET_SERVER_STATUS_START,
+ GET_SERVER_STATUS_SUCCESS,
+ GET_SERVER_STATUS_FAILURE),
+});
+
+export const reducer = combineReducers({
+ bannerMessage: bannerMessageReducer,
+ bannerTime: bannerTimeReducer,
+ readOnly: readOnlyReducer,
+ queryParams: queryParamsReducer,
+ pageTitle: pageTitleReducer,
+ headerTitle: headerTitleReducer,
+
+ requests: requestsReducer,
+});
+
+// Selectors
+export const sitewide = (state) => state.sitewide || {};
+export const bannerMessage =
+ createSelector(sitewide, (sitewide) => sitewide.bannerMessage);
+export const bannerTime =
+ createSelector(sitewide, (sitewide) => sitewide.bannerTime);
+export const queryParams =
+ createSelector(sitewide, (sitewide) => sitewide.queryParams || {});
+export const pageTitle = createSelector(
+ sitewide, projectV0.viewedConfig,
+ (sitewide, projectConfig) => {
+ const titlePieces = [];
+
+ // If a specific page specifies its own page title, add that
+ // to the beginning of the title.
+ if (sitewide.pageTitle) {
+ titlePieces.push(sitewide.pageTitle);
+ }
+
+ // If the user is viewing a project, add the project data.
+ if (projectConfig && projectConfig.projectName) {
+ titlePieces.push(projectConfig.projectName);
+ }
+
+ return titlePieces.join(' - ') || 'Monorail';
+ });
+export const headerTitle =
+ createSelector(sitewide, (sitewide) => sitewide.headerTitle);
+export const readOnly =
+ createSelector(sitewide, (sitewide) => sitewide.readOnly);
+
+/**
+ * Computes the issue list columns from the URL parameters.
+ */
+export const currentColumns = createSelector(
+ queryParams,
+ (params = {}) => params.colspec ? parseColSpec(params.colspec) : null);
+
+/**
+* Get the default canned query for the currently viewed project.
+* Note: Projects cannot configure a per-project default canned query,
+* so there is only a sitewide default.
+*/
+export const currentCan = createSelector(queryParams,
+ (params) => params.can || SITEWIDE_DEFAULT_CAN);
+
+/**
+ * Compute the current issue search query that the user has
+ * entered for a project, based on queryParams and the default
+ * project search.
+ */
+export const currentQuery = createSelector(
+ projectV0.defaultQuery,
+ queryParams,
+ (defaultQuery, params = {}) => {
+ // Make sure entering an empty search still works.
+ if (params.q === '') return params.q;
+ return params.q || defaultQuery;
+ });
+
+export const requests = createSelector(sitewide,
+ (sitewide) => sitewide.requests || {});
+
+// Action Creators
+export const setQueryParams =
+ (queryParams) => ({type: SET_QUERY_PARAMS, queryParams});
+
+export const setPageTitle = (title) => ({type: SET_PAGE_TITLE, title});
+
+export const setHeaderTitle = (title) => ({type: SET_HEADER_TITLE, title});
+
+export const getServerStatus = () => async (dispatch) => {
+ dispatch({type: GET_SERVER_STATUS_START});
+
+ try {
+ const serverStatus = await prpcClient.call(
+ 'monorail.Sitewide', 'GetServerStatus', {});
+
+ dispatch({type: GET_SERVER_STATUS_SUCCESS, serverStatus});
+ } catch (error) {
+ dispatch({type: GET_SERVER_STATUS_FAILURE, error});
+ }
+};
diff --git a/static_src/reducers/sitewide.test.js b/static_src/reducers/sitewide.test.js
new file mode 100644
index 0000000..114ecaf
--- /dev/null
+++ b/static_src/reducers/sitewide.test.js
@@ -0,0 +1,235 @@
+// 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 {store, stateUpdated, resetState} from 'reducers/base.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import * as sitewide from './sitewide.js';
+
+let prpcCall;
+
+describe('sitewide selectors', () => {
+ beforeEach(() => {
+ store.dispatch(resetState());
+ });
+ it('queryParams', () => {
+ assert.deepEqual(sitewide.queryParams({}), {});
+ assert.deepEqual(sitewide.queryParams({sitewide: {}}), {});
+ assert.deepEqual(sitewide.queryParams({sitewide: {queryParams:
+ {q: 'owner:me'}}}), {q: 'owner:me'});
+ });
+
+ describe('pageTitle', () => {
+ it('defaults to Monorail when no data', () => {
+ assert.equal(sitewide.pageTitle({}), 'Monorail');
+ assert.equal(sitewide.pageTitle({sitewide: {}}), 'Monorail');
+ });
+
+ it('uses local page title when one exists', () => {
+ assert.equal(sitewide.pageTitle(
+ {sitewide: {pageTitle: 'Issue Detail'}}), 'Issue Detail');
+ });
+
+ it('shows name of viewed project', () => {
+ assert.equal(sitewide.pageTitle({
+ sitewide: {pageTitle: 'Page'},
+ projectV0: {
+ name: 'chromium',
+ configs: {chromium: {projectName: 'chromium'}},
+ },
+ }), 'Page - chromium');
+ });
+ });
+
+ describe('currentColumns', () => {
+ it('returns null no configuration', () => {
+ assert.deepEqual(sitewide.currentColumns({}), null);
+ assert.deepEqual(sitewide.currentColumns({projectV0: {}}), null);
+ const state = {projectV0: {presentationConfig: {}}};
+ assert.deepEqual(sitewide.currentColumns(state), null);
+ });
+
+ it('gets columns from URL query params', () => {
+ const state = {sitewide: {
+ queryParams: {colspec: 'ID+Summary+ColumnName+Priority'},
+ }};
+ const expected = ['ID', 'Summary', 'ColumnName', 'Priority'];
+ assert.deepEqual(sitewide.currentColumns(state), expected);
+ });
+ });
+
+ describe('currentCan', () => {
+ it('uses sitewide default can by default', () => {
+ assert.deepEqual(sitewide.currentCan({}), '2');
+ });
+
+ it('URL params override default can', () => {
+ assert.deepEqual(sitewide.currentCan({
+ sitewide: {
+ queryParams: {can: '3'},
+ },
+ }), '3');
+ });
+
+ it('undefined query param does not override default can', () => {
+ assert.deepEqual(sitewide.currentCan({
+ sitewide: {
+ queryParams: {can: undefined},
+ },
+ }), '2');
+ });
+ });
+
+ describe('currentQuery', () => {
+ it('defaults to empty', () => {
+ assert.deepEqual(sitewide.currentQuery({}), '');
+ assert.deepEqual(sitewide.currentQuery({projectV0: {}}), '');
+ });
+
+ it('uses project default when no params', () => {
+ assert.deepEqual(sitewide.currentQuery({projectV0: {
+ name: 'chromium',
+ presentationConfigs: {
+ chromium: {defaultQuery: 'owner:me'},
+ },
+ }}), 'owner:me');
+ });
+
+ it('URL query params override default query', () => {
+ assert.deepEqual(sitewide.currentQuery({
+ projectV0: {
+ name: 'chromium',
+ presentationConfigs: {
+ chromium: {defaultQuery: 'owner:me'},
+ },
+ },
+ sitewide: {
+ queryParams: {q: 'component:Infra'},
+ },
+ }), 'component:Infra');
+ });
+
+ it('empty string in param overrides default project query', () => {
+ assert.deepEqual(sitewide.currentQuery({
+ projectV0: {
+ name: 'chromium',
+ presentationConfigs: {
+ chromium: {defaultQuery: 'owner:me'},
+ },
+ },
+ sitewide: {
+ queryParams: {q: ''},
+ },
+ }), '');
+ });
+
+ it('undefined query param does not override default search', () => {
+ assert.deepEqual(sitewide.currentQuery({
+ projectV0: {
+ name: 'chromium',
+ presentationConfigs: {
+ chromium: {defaultQuery: 'owner:me'},
+ },
+ },
+ sitewide: {
+ queryParams: {q: undefined},
+ },
+ }), 'owner:me');
+ });
+ });
+});
+
+
+describe('sitewide action creators', () => {
+ beforeEach(() => {
+ prpcCall = sinon.stub(prpcClient, 'call');
+ });
+
+ afterEach(() => {
+ prpcClient.call.restore();
+ });
+
+ it('setQueryParams updates queryParams', async () => {
+ store.dispatch(sitewide.setQueryParams({test: 'param'}));
+
+ await stateUpdated;
+
+ assert.deepEqual(sitewide.queryParams(store.getState()), {test: 'param'});
+ });
+
+ describe('getServerStatus', () => {
+ it('gets server status', async () => {
+ prpcCall.callsFake(() => {
+ return {
+ bannerMessage: 'Message',
+ bannerTime: 1234,
+ readOnly: true,
+ };
+ });
+
+ store.dispatch(sitewide.getServerStatus());
+
+ await stateUpdated;
+ const state = store.getState();
+
+ assert.deepEqual(sitewide.bannerMessage(state), 'Message');
+ assert.deepEqual(sitewide.bannerTime(state), 1234);
+ assert.isTrue(sitewide.readOnly(state));
+
+ assert.deepEqual(sitewide.requests(state), {
+ serverStatus: {
+ error: null,
+ requesting: false,
+ },
+ });
+ });
+
+ it('gets empty status', async () => {
+ prpcCall.callsFake(() => {
+ return {};
+ });
+
+ store.dispatch(sitewide.getServerStatus());
+
+ await stateUpdated;
+ const state = store.getState();
+
+ assert.deepEqual(sitewide.bannerMessage(state), '');
+ assert.deepEqual(sitewide.bannerTime(state), 0);
+ assert.isFalse(sitewide.readOnly(state));
+
+ assert.deepEqual(sitewide.requests(state), {
+ serverStatus: {
+ error: null,
+ requesting: false,
+ },
+ });
+ });
+
+ it('fails', async () => {
+ const error = new Error('error');
+ prpcCall.callsFake(() => {
+ throw error;
+ });
+
+ store.dispatch(sitewide.getServerStatus());
+
+ await stateUpdated;
+ const state = store.getState();
+
+ assert.deepEqual(sitewide.bannerMessage(state), '');
+ assert.deepEqual(sitewide.bannerTime(state), 0);
+ assert.isFalse(sitewide.readOnly(state));
+
+ assert.deepEqual(sitewide.requests(state), {
+ serverStatus: {
+ error: error,
+ requesting: false,
+ },
+ });
+ });
+ });
+});
diff --git a/static_src/reducers/stars.js b/static_src/reducers/stars.js
new file mode 100644
index 0000000..b67ff9d
--- /dev/null
+++ b/static_src/reducers/stars.js
@@ -0,0 +1,172 @@
+// 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 Star actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving star state
+ * on the frontend.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createReducer, createRequestReducer,
+ createKeyedRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {projectAndUserToStarName} from 'shared/converters.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const LIST_PROJECTS_START = 'stars/LIST_PROJECTS_START';
+export const LIST_PROJECTS_SUCCESS = 'stars/LIST_PROJECTS_SUCCESS';
+export const LIST_PROJECTS_FAILURE = 'stars/LIST_PROJECTS_FAILURE';
+
+export const STAR_PROJECT_START = 'stars/STAR_PROJECT_START';
+export const STAR_PROJECT_SUCCESS = 'stars/STAR_PROJECT_SUCCESS';
+export const STAR_PROJECT_FAILURE = 'stars/STAR_PROJECT_FAILURE';
+
+export const UNSTAR_PROJECT_START = 'stars/UNSTAR_PROJECT_START';
+export const UNSTAR_PROJECT_SUCCESS = 'stars/UNSTAR_PROJECT_SUCCESS';
+export const UNSTAR_PROJECT_FAILURE = 'stars/UNSTAR_PROJECT_FAILURE';
+
+/* State Shape
+{
+ byName: Object<StarName, Star>,
+
+ requests: {
+ listProjects: ReduxRequestState,
+ },
+}
+*/
+
+/**
+ * All star data indexed by resource name.
+ * @param {Object<ProjectName, Star>} state Existing Project data.
+ * @param {AnyAction} action
+ * @param {Array<Star>} action.star The Stars that were fetched.
+ * @param {ProjectStar} action.projectStar A single ProjectStar that was
+ * created.
+ * @param {StarName} action.starName The StarName that was mutated.
+ * @return {Object<ProjectName, Star>}
+ */
+export const byNameReducer = createReducer({}, {
+ [LIST_PROJECTS_SUCCESS]: (state, {stars}) => {
+ const newStars = {};
+ stars.forEach((star) => {
+ newStars[star.name] = star;
+ });
+ return {...state, ...newStars};
+ },
+ [STAR_PROJECT_SUCCESS]: (state, {projectStar}) => {
+ return {...state, [projectStar.name]: projectStar};
+ },
+ [UNSTAR_PROJECT_SUCCESS]: (state, {starName}) => {
+ const newState = {...state};
+ delete newState[starName];
+ return newState;
+ },
+});
+
+
+const requestsReducer = combineReducers({
+ listProjects: createRequestReducer(LIST_PROJECTS_START,
+ LIST_PROJECTS_SUCCESS, LIST_PROJECTS_FAILURE),
+ starProject: createKeyedRequestReducer(STAR_PROJECT_START,
+ STAR_PROJECT_SUCCESS, STAR_PROJECT_FAILURE),
+ unstarProject: createKeyedRequestReducer(UNSTAR_PROJECT_START,
+ UNSTAR_PROJECT_SUCCESS, UNSTAR_PROJECT_FAILURE),
+});
+
+
+export const reducer = combineReducers({
+ byName: byNameReducer,
+ requests: requestsReducer,
+});
+
+
+/**
+ * Returns normalized star data by name.
+ * @param {any} state
+ * @return {Object<StarName, Star>}
+ * @private
+ */
+export const byName = (state) => state.stars.byName;
+
+/**
+ * Returns star requests.
+ * @param {any} state
+ * @return {Object<string, ReduxRequestState>}
+ */
+export const requests = (state) => state.stars.requests;
+
+/**
+ * Retrieves the starred projects for a given user.
+ * @param {UserName} user The resource name of the user to fetch
+ * starred projects for.
+ * @return {function(function): Promise<void>}
+ */
+export const listProjects = (user) => async (dispatch) => {
+ dispatch({type: LIST_PROJECTS_START});
+
+ try {
+ const {projectStars} = await prpcClient.call(
+ 'monorail.v3.Users', 'ListProjectStars', {parent: user});
+ dispatch({type: LIST_PROJECTS_SUCCESS, stars: projectStars});
+ } catch (error) {
+ dispatch({type: LIST_PROJECTS_FAILURE, error});
+ };
+};
+
+/**
+ * Stars a given project.
+ * @param {ProjectName} project The resource name of the project to star.
+ * @param {UserName} user The resource name of the user who is starring
+ * the issue. This will always be the currently logged in user.
+ * @return {function(function): Promise<void>}
+ */
+export const starProject = (project, user) => async (dispatch) => {
+ const requestKey = projectAndUserToStarName(project, user);
+ dispatch({type: STAR_PROJECT_START, requestKey});
+ try {
+ const projectStar = await prpcClient.call(
+ 'monorail.v3.Users', 'StarProject', {project});
+ dispatch({type: STAR_PROJECT_SUCCESS, requestKey, projectStar});
+ } catch (error) {
+ dispatch({type: STAR_PROJECT_FAILURE, requestKey, error});
+ };
+};
+
+/**
+ * Unstars a given project.
+ * @param {ProjectName} project The resource name of the project to unstar.
+ * @param {UserName} user The resource name of the user who is unstarring
+ * the issue. This will always be the currently logged in user, but
+ * passing in the user's resource name is necessary to make it possible to
+ * generate the resource name of the removed star.
+ * @return {function(function): Promise<void>}
+ */
+export const unstarProject = (project, user) => async (dispatch) => {
+ const starName = projectAndUserToStarName(project, user);
+ const requestKey = starName;
+ dispatch({type: UNSTAR_PROJECT_START, requestKey});
+
+ try {
+ await prpcClient.call(
+ 'monorail.v3.Users', 'UnStarProject', {project});
+ dispatch({type: UNSTAR_PROJECT_SUCCESS, requestKey, starName});
+ } catch (error) {
+ dispatch({type: UNSTAR_PROJECT_FAILURE, requestKey, error});
+ };
+};
+
+export const stars = {
+ reducer,
+ byName,
+ requests,
+ listProjects,
+ starProject,
+ unstarProject,
+};
diff --git a/static_src/reducers/stars.test.js b/static_src/reducers/stars.test.js
new file mode 100644
index 0000000..3437723
--- /dev/null
+++ b/static_src/reducers/stars.test.js
@@ -0,0 +1,247 @@
+// 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 * as stars from './stars.js';
+import * as example from 'shared/test/constants-stars.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+
+describe('star reducers', () => {
+ it('root reducer initial state', () => {
+ const actual = stars.reducer(undefined, {type: null});
+ const expected = {
+ byName: {},
+ requests: {
+ listProjects: {error: null, requesting: false},
+ starProject: {},
+ unstarProject: {},
+ },
+ };
+ assert.deepEqual(actual, expected);
+ });
+
+ describe('byNameReducer', () => {
+ it('populated on LIST_PROJECTS_SUCCESS', () => {
+ const action = {type: stars.LIST_PROJECTS_SUCCESS, stars:
+ [example.PROJECT_STAR, example.PROJECT_STAR_2]};
+ const actual = stars.byNameReducer({}, action);
+
+ assert.deepEqual(actual, {
+ [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+ [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+ });
+ });
+
+ it('keeps original state on empty LIST_PROJECTS_SUCCESS', () => {
+ const originalState = {
+ [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+ [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+ };
+ const action = {type: stars.LIST_PROJECTS_SUCCESS, stars: []};
+ const actual = stars.byNameReducer(originalState, action);
+
+ assert.deepEqual(actual, originalState);
+ });
+
+ it('appends new stars to state on LIST_PROJECTS_SUCCESS', () => {
+ const originalState = {
+ [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+ };
+ const action = {type: stars.LIST_PROJECTS_SUCCESS,
+ stars: [example.PROJECT_STAR_2]};
+ const actual = stars.byNameReducer(originalState, action);
+
+ const expected = {
+ [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+ [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+ };
+ assert.deepEqual(actual, expected);
+ });
+
+ it('adds star on STAR_PROJECT_SUCCESS', () => {
+ const originalState = {
+ [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+ };
+ const action = {type: stars.STAR_PROJECT_SUCCESS,
+ projectStar: example.PROJECT_STAR_2};
+ const actual = stars.byNameReducer(originalState, action);
+
+ const expected = {
+ [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+ [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+ };
+ assert.deepEqual(actual, expected);
+ });
+
+ it('removes star on UNSTAR_PROJECT_SUCCESS', () => {
+ const originalState = {
+ [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+ [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+ };
+ const action = {type: stars.UNSTAR_PROJECT_SUCCESS,
+ starName: example.PROJECT_STAR_NAME};
+ const actual = stars.byNameReducer(originalState, action);
+
+ const expected = {
+ [example.PROJECT_STAR_NAME_2]: example.PROJECT_STAR_2,
+ };
+ assert.deepEqual(actual, expected);
+ });
+ });
+});
+
+describe('project selectors', () => {
+ it('byName', () => {
+ const normalizedStars = {
+ [example.PROJECT_STAR_NAME]: example.PROJECT_STAR,
+ };
+ const state = {stars: {
+ byName: normalizedStars,
+ }};
+ assert.deepEqual(stars.byName(state), normalizedStars);
+ });
+
+ it('requests', () => {
+ const state = {stars: {
+ requests: {
+ listProjects: {error: null, requesting: false},
+ starProject: {},
+ unstarProject: {},
+ },
+ }};
+ assert.deepEqual(stars.requests(state), {
+ listProjects: {error: null, requesting: false},
+ starProject: {},
+ unstarProject: {},
+ });
+ });
+});
+
+describe('star action creators', () => {
+ beforeEach(() => {
+ sinon.stub(prpcClient, 'call');
+ dispatch = sinon.stub();
+ });
+
+ afterEach(() => {
+ prpcClient.call.restore();
+ });
+
+ describe('listProjects', () => {
+ it('success', async () => {
+ const starsResponse = {
+ projectStars: [example.PROJECT_STAR, example.PROJECT_STAR_2],
+ };
+ prpcClient.call.returns(Promise.resolve(starsResponse));
+
+ await stars.listProjects('users/1234')(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: stars.LIST_PROJECTS_START});
+
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Users', 'ListProjectStars',
+ {parent: 'users/1234'});
+
+ const successAction = {
+ type: stars.LIST_PROJECTS_SUCCESS,
+ stars: [example.PROJECT_STAR, example.PROJECT_STAR_2],
+ };
+ sinon.assert.calledWith(dispatch, successAction);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await stars.listProjects('users/1234')(dispatch);
+
+ const action = {
+ type: stars.LIST_PROJECTS_FAILURE,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('starProject', () => {
+ it('success', async () => {
+ const starResponse = example.PROJECT_STAR;
+ prpcClient.call.returns(Promise.resolve(starResponse));
+
+ await stars.starProject('projects/monorail', 'users/1234')(dispatch);
+
+ sinon.assert.calledWith(dispatch, {
+ type: stars.STAR_PROJECT_START,
+ requestKey: example.PROJECT_STAR_NAME,
+ });
+
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Users', 'StarProject',
+ {project: 'projects/monorail'});
+
+ const successAction = {
+ type: stars.STAR_PROJECT_SUCCESS,
+ requestKey: example.PROJECT_STAR_NAME,
+ projectStar: example.PROJECT_STAR,
+ };
+ sinon.assert.calledWith(dispatch, successAction);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await stars.starProject('projects/monorail', 'users/1234')(dispatch);
+
+ const action = {
+ type: stars.STAR_PROJECT_FAILURE,
+ requestKey: example.PROJECT_STAR_NAME,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('unstarProject', () => {
+ it('success', async () => {
+ const starResponse = {};
+ prpcClient.call.returns(Promise.resolve(starResponse));
+
+ await stars.unstarProject('projects/monorail', 'users/1234')(dispatch);
+
+ sinon.assert.calledWith(dispatch, {
+ type: stars.UNSTAR_PROJECT_START,
+ requestKey: example.PROJECT_STAR_NAME,
+ });
+
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Users', 'UnStarProject',
+ {project: 'projects/monorail'});
+
+ const successAction = {
+ type: stars.UNSTAR_PROJECT_SUCCESS,
+ requestKey: example.PROJECT_STAR_NAME,
+ starName: example.PROJECT_STAR_NAME,
+ };
+ sinon.assert.calledWith(dispatch, successAction);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await stars.unstarProject('projects/monorail', 'users/1234')(dispatch);
+
+ const action = {
+ type: stars.UNSTAR_PROJECT_FAILURE,
+ requestKey: example.PROJECT_STAR_NAME,
+ error: sinon.match.any,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+});
diff --git a/static_src/reducers/ui.js b/static_src/reducers/ui.js
new file mode 100644
index 0000000..871cf87
--- /dev/null
+++ b/static_src/reducers/ui.js
@@ -0,0 +1,170 @@
+// 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 {combineReducers} from 'redux';
+import {createReducer} from './redux-helpers.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+const DEFAULT_SNACKBAR_TIMEOUT_MS = 10 * 1000;
+
+
+/**
+ * Object of various constant strings used to uniquely identify
+ * snackbar instances used in the app.
+ * TODO(https://crbug.com/monorail/7491): Avoid using this Object.
+ * @type {Object<string, string>}
+ */
+export const snackbarNames = Object.freeze({
+ // Issue list page snackbars.
+ FETCH_ISSUE_LIST_ERROR: 'FETCH_ISSUE_LIST_ERROR',
+ FETCH_ISSUE_LIST: 'FETCH_ISSUE_LIST',
+ UPDATE_HOTLISTS_SUCCESS: 'UPDATE_HOTLISTS_SUCCESS',
+
+ // Issue detail page snackbars.
+ ISSUE_COMMENT_ADDED: 'ISSUE_COMMENT_ADDED',
+});
+
+// Actions
+const INCREMENT_NAVIGATION_COUNT = 'INCREMENT_NAVIGATION_COUNT';
+const REPORT_DIRTY_FORM = 'REPORT_DIRTY_FORM';
+const CLEAR_DIRTY_FORMS = 'CLEAR_DIRTY_FORMS';
+const SET_FOCUS_ID = 'SET_FOCUS_ID';
+export const SHOW_SNACKBAR = 'SHOW_SNACKBAR';
+const HIDE_SNACKBAR = 'HIDE_SNACKBAR';
+
+/**
+ * @typedef {Object} Snackbar
+ * @param {string} id Unique string identifying the snackbar.
+ * @param {string} text The text to show in the snackbar.
+ */
+
+/* State Shape
+{
+ navigationCount: number,
+ dirtyForms: Array,
+ focusId: String,
+ snackbars: Array<Snackbar>,
+}
+*/
+
+// Reducers
+
+
+const navigationCountReducer = createReducer(0, {
+ [INCREMENT_NAVIGATION_COUNT]: (state) => state + 1,
+});
+
+/**
+ * Saves state on which forms have been edited, to warn the user
+ * about possible data loss when they navigate away from a page.
+ * @param {Array<string>} state Dirty form names.
+ * @param {AnyAction} action
+ * @param {string} action.name The name of the form being updated.
+ * @param {boolean} action.isDirty Whether the form is dirty or not dirty.
+ * @return {Array<string>}
+ */
+const dirtyFormsReducer = createReducer([], {
+ [REPORT_DIRTY_FORM]: (state, {name, isDirty}) => {
+ const newState = [...state];
+ const index = state.indexOf(name);
+ if (isDirty && index === -1) {
+ newState.push(name);
+ } else if (!isDirty && index !== -1) {
+ newState.splice(index, 1);
+ }
+ return newState;
+ },
+ [CLEAR_DIRTY_FORMS]: () => [],
+});
+
+const focusIdReducer = createReducer(null, {
+ [SET_FOCUS_ID]: (_state, action) => action.focusId,
+});
+
+/**
+ * Updates snackbar state.
+ * @param {Array<Snackbar>} state A snackbar-shaped slice of Redux state.
+ * @param {AnyAction} action
+ * @param {string} action.text The text to display in the snackbar.
+ * @param {string} action.id A unique global ID for the snackbar.
+ * @return {Array<Snackbar>} New snackbar state.
+ */
+export const snackbarsReducer = createReducer([], {
+ [SHOW_SNACKBAR]: (state, {text, id}) => {
+ return [...state, {text, id}];
+ },
+ [HIDE_SNACKBAR]: (state, {id}) => {
+ return state.filter((snackbar) => snackbar.id !== id);
+ },
+});
+
+export const reducer = combineReducers({
+ // Count of "page" navigations.
+ navigationCount: navigationCountReducer,
+ // Forms to be checked for user changes before leaving the page.
+ dirtyForms: dirtyFormsReducer,
+ // The ID of the element to be focused, as given by the hash part of the URL.
+ focusId: focusIdReducer,
+ // Array of snackbars to render on the page.
+ snackbars: snackbarsReducer,
+});
+
+// Selectors
+export const navigationCount = (state) => state.ui.navigationCount;
+export const dirtyForms = (state) => state.ui.dirtyForms;
+export const focusId = (state) => state.ui.focusId;
+
+/**
+ * Retrieves snackbar data from the Redux store.
+ * @param {any} state Redux state.
+ * @return {Array<Snackbar>} All the snackbars in the store.
+ */
+export const snackbars = (state) => state.ui.snackbars;
+
+// Action Creators
+export const incrementNavigationCount = () => {
+ return {type: INCREMENT_NAVIGATION_COUNT};
+};
+
+export const reportDirtyForm = (name, isDirty) => {
+ return {type: REPORT_DIRTY_FORM, name, isDirty};
+};
+
+export const clearDirtyForms = () => ({type: CLEAR_DIRTY_FORMS});
+
+export const setFocusId = (focusId) => {
+ return {type: SET_FOCUS_ID, focusId};
+};
+
+/**
+ * Displays a snackbar.
+ * @param {string} id Unique identifier for a given snackbar. We depend on
+ * snackbar users to keep this unique.
+ * @param {string} text The text to be shown in the snackbar.
+ * @param {number} timeout An optional timeout in milliseconds for how
+ * long to wait to dismiss a snackbar.
+ * @return {function(function): Promise<void>}
+ */
+export const showSnackbar = (id, text,
+ timeout = DEFAULT_SNACKBAR_TIMEOUT_MS) => (dispatch) => {
+ dispatch({type: SHOW_SNACKBAR, text, id});
+
+ if (timeout) {
+ window.setTimeout(() => dispatch(hideSnackbar(id)),
+ timeout);
+ }
+};
+
+/**
+ * Hides a snackbar.
+ * @param {string} id The unique name of the snackbar to be hidden.
+ * @return {any} A Redux action.
+ */
+export const hideSnackbar = (id) => {
+ return {
+ type: HIDE_SNACKBAR,
+ id,
+ };
+};
diff --git a/static_src/reducers/ui.test.js b/static_src/reducers/ui.test.js
new file mode 100644
index 0000000..587bc0c
--- /dev/null
+++ b/static_src/reducers/ui.test.js
@@ -0,0 +1,123 @@
+// 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 * as ui from './ui.js';
+
+
+describe('ui', () => {
+ describe('reducers', () => {
+ describe('snackbarsReducer', () => {
+ it('adds snackbar', () => {
+ let state = ui.snackbarsReducer([],
+ {type: 'SHOW_SNACKBAR', id: 'one', text: 'A snackbar'});
+
+ assert.deepEqual(state, [{id: 'one', text: 'A snackbar'}]);
+
+ state = ui.snackbarsReducer(state,
+ {type: 'SHOW_SNACKBAR', id: 'two', text: 'Another snack'});
+
+ assert.deepEqual(state, [
+ {id: 'one', text: 'A snackbar'},
+ {id: 'two', text: 'Another snack'},
+ ]);
+ });
+
+ it('removes snackbar', () => {
+ let state = [
+ {id: 'one', text: 'A snackbar'},
+ {id: 'two', text: 'Another snack'},
+ ];
+
+ state = ui.snackbarsReducer(state,
+ {type: 'HIDE_SNACKBAR', id: 'one'});
+
+ assert.deepEqual(state, [
+ {id: 'two', text: 'Another snack'},
+ ]);
+
+ state = ui.snackbarsReducer(state,
+ {type: 'HIDE_SNACKBAR', id: 'two'});
+
+ assert.deepEqual(state, []);
+ });
+
+ it('does not remove non-existent snackbar', () => {
+ let state = [
+ {id: 'one', text: 'A snackbar'},
+ {id: 'two', text: 'Another snack'},
+ ];
+
+ state = ui.snackbarsReducer(state,
+ {action: 'HIDE_SNACKBAR', id: 'whatever'});
+
+ assert.deepEqual(state, [
+ {id: 'one', text: 'A snackbar'},
+ {id: 'two', text: 'Another snack'},
+ ]);
+ });
+ });
+ });
+
+ describe('selectors', () => {
+ it('snackbars', () => {
+ assert.deepEqual(ui.snackbars({ui: {snackbars: []}}), []);
+ assert.deepEqual(ui.snackbars({ui: {snackbars: [
+ {text: 'Snackbar one', id: 'one'},
+ {text: 'Snackbar two', id: 'two'},
+ ]}}), [
+ {text: 'Snackbar one', id: 'one'},
+ {text: 'Snackbar two', id: 'two'},
+ ]);
+ });
+ });
+
+ describe('action creators', () => {
+ describe('showSnackbar', () => {
+ it('produces action', () => {
+ const action = ui.showSnackbar('id', 'text');
+ const dispatch = sinon.stub();
+
+ action(dispatch);
+
+ sinon.assert.calledWith(dispatch,
+ {type: 'SHOW_SNACKBAR', text: 'text', id: 'id'});
+ });
+
+ it('hides snackbar after timeout', () => {
+ const clock = sinon.useFakeTimers(0);
+
+ const action = ui.showSnackbar('id', 'text', 1000);
+ const dispatch = sinon.stub();
+
+ action(dispatch);
+
+ sinon.assert.neverCalledWith(dispatch,
+ {type: 'HIDE_SNACKBAR', id: 'id'});
+
+ clock.tick(1000);
+
+ sinon.assert.calledWith(dispatch, {type: 'HIDE_SNACKBAR', id: 'id'});
+
+ clock.restore();
+ });
+
+ it('does not setTimeout when no timeout specified', () => {
+ sinon.stub(window, 'setTimeout');
+
+ ui.showSnackbar('id', 'text', 0);
+
+ sinon.assert.notCalled(window.setTimeout);
+
+ window.setTimeout.restore();
+ });
+ });
+
+ it('hideSnackbar produces action', () => {
+ assert.deepEqual(ui.hideSnackbar('one'),
+ {type: 'HIDE_SNACKBAR', id: 'one'});
+ });
+ });
+});
diff --git a/static_src/reducers/userV0.js b/static_src/reducers/userV0.js
new file mode 100644
index 0000000..42a93fc
--- /dev/null
+++ b/static_src/reducers/userV0.js
@@ -0,0 +1,384 @@
+// 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 {combineReducers} from 'redux';
+import {createSelector} from 'reselect';
+import {createReducer, createRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {objectToMap} from 'shared/helpers.js';
+import {userRefToId, userToUserRef} from 'shared/convertersV0.js';
+import loadGapi, {fetchGapiEmail} from 'shared/gapi-loader.js';
+import {DEFAULT_MD_PROJECTS} from 'shared/md-helper.js';
+import {viewedProjectName} from 'reducers/projectV0.js';
+
+// Actions
+const FETCH_START = 'userV0/FETCH_START';
+const FETCH_SUCCESS = 'userV0/FETCH_SUCCESS';
+const FETCH_FAILURE = 'userV0/FETCH_FAILURE';
+
+export const FETCH_PROJECTS_START = 'userV0/FETCH_PROJECTS_START';
+export const FETCH_PROJECTS_SUCCESS = 'userV0/FETCH_PROJECTS_SUCCESS';
+export const FETCH_PROJECTS_FAILURE = 'userV0/FETCH_PROJECTS_FAILURE';
+
+const FETCH_HOTLISTS_START = 'userV0/FETCH_HOTLISTS_START';
+const FETCH_HOTLISTS_SUCCESS = 'userV0/FETCH_HOTLISTS_SUCCESS';
+const FETCH_HOTLISTS_FAILURE = 'userV0/FETCH_HOTLISTS_FAILURE';
+
+const FETCH_PREFS_START = 'userV0/FETCH_PREFS_START';
+const FETCH_PREFS_SUCCESS = 'userV0/FETCH_PREFS_SUCCESS';
+const FETCH_PREFS_FAILURE = 'userV0/FETCH_PREFS_FAILURE';
+
+export const SET_PREFS_START = 'userV0/SET_PREFS_START';
+export const SET_PREFS_SUCCESS = 'userV0/SET_PREFS_SUCCESS';
+export const SET_PREFS_FAILURE = 'userV0/SET_PREFS_FAILURE';
+
+const GAPI_LOGIN_START = 'GAPI_LOGIN_START';
+export const GAPI_LOGIN_SUCCESS = 'GAPI_LOGIN_SUCCESS';
+const GAPI_LOGIN_FAILURE = 'GAPI_LOGIN_FAILURE';
+
+const GAPI_LOGOUT_START = 'GAPI_LOGOUT_START';
+export const GAPI_LOGOUT_SUCCESS = 'GAPI_LOGOUT_SUCCESS';
+const GAPI_LOGOUT_FAILURE = 'GAPI_LOGOUT_FAILURE';
+
+
+// Monorial UserPrefs are stored as plain strings in Monorail's backend.
+// We want boolean preferences to be converted into booleans for convenience.
+// Currently, there are no user prefs in Monorail that are NOT booleans, so
+// we default to converting all user prefs to booleans unless otherwise
+// specified.
+// See: https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/framework/framework_bizobj.py;l=409
+const NON_BOOLEAN_PREFS = new Set(['test_non_bool']);
+
+
+/* State Shape
+{
+ currentUser: {
+ ...user: Object,
+ groups: Array,
+ hotlists: Array,
+ prefs: Object,
+ gapiEmail: String,
+ },
+ requests: {
+ fetch: Object,
+ fetchHotlists: Object,
+ fetchPrefs: Object,
+ },
+}
+*/
+
+// Reducers
+const USER_DEFAULT = {
+ groups: [],
+ hotlists: [],
+ projects: {},
+ prefs: {},
+ prefsLoaded: false,
+};
+
+const gapiEmailReducer = (user, action) => {
+ return {
+ ...user,
+ gapiEmail: action.email || '',
+ };
+};
+
+export const currentUserReducer = createReducer(USER_DEFAULT, {
+ [FETCH_SUCCESS]: (_user, action) => {
+ return {
+ ...USER_DEFAULT,
+ ...action.user,
+ groups: action.groups,
+ };
+ },
+ [FETCH_HOTLISTS_SUCCESS]: (user, action) => {
+ return {...user, hotlists: action.hotlists};
+ },
+ [FETCH_PREFS_SUCCESS]: (user, action) => {
+ return {
+ ...user,
+ prefs: action.prefs,
+ prefsLoaded: true,
+ };
+ },
+ [SET_PREFS_SUCCESS]: (user, action) => {
+ const newPrefs = action.newPrefs;
+ const prefs = Object.assign({}, user.prefs);
+ newPrefs.forEach(({name, value}) => {
+ prefs[name] = value;
+ });
+ return {
+ ...user,
+ prefs,
+ };
+ },
+ [GAPI_LOGIN_SUCCESS]: gapiEmailReducer,
+ [GAPI_LOGOUT_SUCCESS]: gapiEmailReducer,
+});
+
+export const usersByIdReducer = createReducer({}, {
+ [FETCH_PROJECTS_SUCCESS]: (state, action) => {
+ const newState = {...state};
+
+ action.usersProjects.forEach((userProjects) => {
+ const {userRef, ownerOf = [], memberOf = [], contributorTo = [],
+ starredProjects = []} = userProjects;
+
+ const userId = userRefToId(userRef);
+
+ newState[userId] = {
+ ...newState[userId],
+ projects: {
+ ownerOf,
+ memberOf,
+ contributorTo,
+ starredProjects,
+ },
+ };
+ });
+
+ return newState;
+ },
+});
+
+const requestsReducer = combineReducers({
+ // Request for getting backend metadata related to a user, such as
+ // which groups they belong to and whether they're a site admin.
+ fetch: createRequestReducer(FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+ // Requests for fetching projects a user is related to.
+ fetchProjects: createRequestReducer(
+ FETCH_PROJECTS_START, FETCH_PROJECTS_SUCCESS, FETCH_PROJECTS_FAILURE),
+ // Request for getting a user's hotlists.
+ fetchHotlists: createRequestReducer(
+ FETCH_HOTLISTS_START, FETCH_HOTLISTS_SUCCESS, FETCH_HOTLISTS_FAILURE),
+ // Request for getting a user's prefs.
+ fetchPrefs: createRequestReducer(
+ FETCH_PREFS_START, FETCH_PREFS_SUCCESS, FETCH_PREFS_FAILURE),
+ // Request for setting a user's prefs.
+ setPrefs: createRequestReducer(
+ SET_PREFS_START, SET_PREFS_SUCCESS, SET_PREFS_FAILURE),
+});
+
+export const reducer = combineReducers({
+ currentUser: currentUserReducer,
+ usersById: usersByIdReducer,
+ requests: requestsReducer,
+});
+
+// Selectors
+export const requests = (state) => state.userV0.requests;
+export const currentUser = (state) => state.userV0.currentUser || {};
+// TODO(zhangtiff): Replace custom logic to check if the user is logged in
+// across the frontend.
+export const isLoggedIn = createSelector(
+ currentUser, (user) => user && user.userId);
+export const userRef = createSelector(
+ currentUser, (user) => userToUserRef(user));
+export const prefs = createSelector(
+ currentUser, viewedProjectName, (user, projectName = '') => {
+ const prefs = {
+ // Make Markdown default to true for projects who have opted in.
+ render_markdown: String(DEFAULT_MD_PROJECTS.has(projectName)),
+ ...user.prefs
+ };
+ for (let prefName of Object.keys(prefs)) {
+ if (!NON_BOOLEAN_PREFS.has(prefName)) {
+ // Monorail user preferences are stored as strings.
+ prefs[prefName] = prefs[prefName] === 'true';
+ }
+ }
+ return objectToMap(prefs);
+ });
+
+const _usersById = (state) => state.userV0.usersById || {};
+export const usersById = createSelector(_usersById,
+ (usersById) => objectToMap(usersById));
+
+export const projectsPerUser = createSelector(usersById, (usersById) => {
+ const map = new Map();
+ for (const [key, value] of usersById.entries()) {
+ if (value.projects) {
+ map.set(key, value.projects);
+ }
+ }
+ return map;
+});
+
+// Projects for just the current user.
+export const projects = createSelector(projectsPerUser, userRef,
+ (projectsMap, userRef) => projectsMap.get(userRefToId(userRef)) || {});
+
+// Action Creators
+/**
+ * Fetches the data required to view the logged in user.
+ * @param {string} displayName The display name of the logged in user. Note that
+ * while usernames may be hidden for users in Monorail, the logged in user
+ * will always be able to view their own name.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (displayName) => async (dispatch) => {
+ dispatch({type: FETCH_START});
+
+ const message = {
+ userRef: {displayName},
+ };
+
+ try {
+ const resp = await Promise.all([
+ prpcClient.call(
+ 'monorail.Users', 'GetUser', message),
+ prpcClient.call(
+ 'monorail.Users', 'GetMemberships', message),
+ ]);
+
+ const user = resp[0];
+
+ dispatch({
+ type: FETCH_SUCCESS,
+ user,
+ groups: resp[1].groupRefs || [],
+ });
+
+ const userRef = userToUserRef(user);
+
+ dispatch(fetchProjects([userRef]));
+ const hotlistsPromise = dispatch(fetchHotlists(userRef));
+ dispatch(fetchPrefs());
+
+ hotlistsPromise.then((hotlists) => {
+ // TODO(crbug.com/monorail/5828): Remove
+ // window.TKR_populateHotlistAutocomplete once the old
+ // autocomplete code is deprecated.
+ window.TKR_populateHotlistAutocomplete(hotlists);
+ });
+ } catch (error) {
+ dispatch({type: FETCH_FAILURE, error});
+ };
+};
+
+export const fetchProjects = (userRefs) => async (dispatch) => {
+ dispatch({type: FETCH_PROJECTS_START});
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Users', 'GetUsersProjects', {userRefs});
+ dispatch({type: FETCH_PROJECTS_SUCCESS, usersProjects: resp.usersProjects});
+ } catch (error) {
+ dispatch({type: FETCH_PROJECTS_FAILURE, error});
+ }
+};
+
+/**
+ * Fetches all of a given user's hotlists.
+ * @param {UserRef} userRef The user to fetch hotlists for.
+ * @return {function(function): Promise<Array<HotlistV0>>}
+ */
+export const fetchHotlists = (userRef) => async (dispatch) => {
+ dispatch({type: FETCH_HOTLISTS_START});
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Features', 'ListHotlistsByUser', {user: userRef});
+
+ const hotlists = resp.hotlists || [];
+ hotlists.sort((hotlistA, hotlistB) => {
+ return hotlistA.name.localeCompare(hotlistB.name);
+ });
+ dispatch({type: FETCH_HOTLISTS_SUCCESS, hotlists});
+
+ return hotlists;
+ } catch (error) {
+ dispatch({type: FETCH_HOTLISTS_FAILURE, error});
+ };
+};
+
+/**
+ * Fetches user preferences for the logged in user.
+ * @return {function(function): Promise<void>}
+ */
+export const fetchPrefs = () => async (dispatch) => {
+ dispatch({type: FETCH_PREFS_START});
+
+ try {
+ const resp = await prpcClient.call(
+ 'monorail.Users', 'GetUserPrefs', {});
+ const prefs = {};
+ (resp.prefs || []).forEach(({name, value}) => {
+ prefs[name] = value;
+ });
+ dispatch({type: FETCH_PREFS_SUCCESS, prefs});
+ } catch (error) {
+ dispatch({type: FETCH_PREFS_FAILURE, error});
+ };
+};
+
+/**
+ * Action creator for setting a user's preferences.
+ *
+ * @param {Object} newPrefs
+ * @param {boolean} saveChanges
+ * @return {function(function): Promise<void>}
+ */
+export const setPrefs = (newPrefs, saveChanges = true) => async (dispatch) => {
+ if (!saveChanges) {
+ dispatch({type: SET_PREFS_SUCCESS, newPrefs});
+ return;
+ }
+
+ dispatch({type: SET_PREFS_START});
+
+ try {
+ const message = {prefs: newPrefs};
+ await prpcClient.call(
+ 'monorail.Users', 'SetUserPrefs', message);
+ dispatch({type: SET_PREFS_SUCCESS, newPrefs});
+
+ // Re-fetch the user's prefs after saving to prevent prefs from
+ // getting out of sync.
+ dispatch(fetchPrefs());
+ } catch (error) {
+ dispatch({type: SET_PREFS_FAILURE, error});
+ }
+};
+
+/**
+ * Action creator to initiate the gapi.js login flow.
+ *
+ * @return {Promise} Resolved only when gapi.js login succeeds.
+ */
+export const initGapiLogin = () => (dispatch) => {
+ dispatch({type: GAPI_LOGIN_START});
+
+ return new Promise(async (resolve) => {
+ try {
+ await loadGapi();
+ gapi.auth2.getAuthInstance().signIn().then(async () => {
+ const email = await fetchGapiEmail();
+ dispatch({type: GAPI_LOGIN_SUCCESS, email: email});
+ resolve();
+ });
+ } catch (error) {
+ // TODO(jeffcarp): Pop up a message that signIn failed.
+ dispatch({type: GAPI_LOGIN_FAILURE, error});
+ }
+ });
+};
+
+/**
+ * Action creator to log the user out of gapi.js
+ *
+ * @return {undefined}
+ */
+export const initGapiLogout = () => async (dispatch) => {
+ dispatch({type: GAPI_LOGOUT_START});
+
+ try {
+ await loadGapi();
+ gapi.auth2.getAuthInstance().signOut().then(() => {
+ dispatch({type: GAPI_LOGOUT_SUCCESS, email: ''});
+ });
+ } catch (error) {
+ // TODO(jeffcarp): Pop up a message that signOut failed.
+ dispatch({type: GAPI_LOGOUT_FAILURE, error});
+ }
+};
diff --git a/static_src/reducers/userV0.test.js b/static_src/reducers/userV0.test.js
new file mode 100644
index 0000000..aab7989
--- /dev/null
+++ b/static_src/reducers/userV0.test.js
@@ -0,0 +1,372 @@
+// 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 * as userV0 from './userV0.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+
+let dispatch;
+
+describe('userV0', () => {
+ describe('reducers', () => {
+ it('SET_PREFS_SUCCESS updates existing prefs with new prefs', () => {
+ const state = {prefs: {
+ testPref: 'true',
+ anotherPref: 'hello-world',
+ }};
+
+ const newPrefs = [
+ {name: 'anotherPref', value: 'override'},
+ {name: 'newPref', value: 'test-me'},
+ ];
+
+ const newState = userV0.currentUserReducer(state,
+ {type: userV0.SET_PREFS_SUCCESS, newPrefs});
+
+ assert.deepEqual(newState, {prefs: {
+ testPref: 'true',
+ anotherPref: 'override',
+ newPref: 'test-me',
+ }});
+ });
+
+ it('FETCH_PROJECTS_SUCCESS overrides existing entry in usersById', () => {
+ const state = {
+ ['123']: {
+ projects: {
+ ownerOf: [],
+ memberOf: [],
+ contributorTo: [],
+ starredProjects: [],
+ },
+ },
+ };
+
+ const usersProjects = [
+ {
+ userRef: {userId: '123'},
+ ownerOf: ['chromium'],
+ },
+ ];
+
+ const newState = userV0.usersByIdReducer(state,
+ {type: userV0.FETCH_PROJECTS_SUCCESS, usersProjects});
+
+ assert.deepEqual(newState, {
+ ['123']: {
+ projects: {
+ ownerOf: ['chromium'],
+ memberOf: [],
+ contributorTo: [],
+ starredProjects: [],
+ },
+ },
+ });
+ });
+
+ it('FETCH_PROJECTS_SUCCESS adds new entry to usersById', () => {
+ const state = {
+ ['123']: {
+ projects: {
+ ownerOf: [],
+ memberOf: [],
+ contributorTo: [],
+ starredProjects: [],
+ },
+ },
+ };
+
+ const usersProjects = [
+ {
+ userRef: {userId: '543'},
+ ownerOf: ['chromium'],
+ },
+ {
+ userRef: {userId: '789'},
+ memberOf: ['v8'],
+ },
+ ];
+
+ const newState = userV0.usersByIdReducer(state,
+ {type: userV0.FETCH_PROJECTS_SUCCESS, usersProjects});
+
+ assert.deepEqual(newState, {
+ ['123']: {
+ projects: {
+ ownerOf: [],
+ memberOf: [],
+ contributorTo: [],
+ starredProjects: [],
+ },
+ },
+ ['543']: {
+ projects: {
+ ownerOf: ['chromium'],
+ memberOf: [],
+ contributorTo: [],
+ starredProjects: [],
+ },
+ },
+ ['789']: {
+ projects: {
+ ownerOf: [],
+ memberOf: ['v8'],
+ contributorTo: [],
+ starredProjects: [],
+ },
+ },
+ });
+ });
+
+ describe('GAPI_LOGIN_SUCCESS', () => {
+ it('sets currentUser.gapiEmail', () => {
+ const newState = userV0.currentUserReducer({}, {
+ type: userV0.GAPI_LOGIN_SUCCESS,
+ email: 'rutabaga@rutabaga.com',
+ });
+ assert.deepEqual(newState, {
+ gapiEmail: 'rutabaga@rutabaga.com',
+ });
+ });
+
+ it('defaults to an empty string', () => {
+ const newState = userV0.currentUserReducer({}, {
+ type: userV0.GAPI_LOGIN_SUCCESS,
+ });
+ assert.deepEqual(newState, {
+ gapiEmail: '',
+ });
+ });
+ });
+
+ describe('GAPI_LOGOUT_SUCCESS', () => {
+ it('sets currentUser.gapiEmail', () => {
+ const newState = userV0.currentUserReducer({}, {
+ type: userV0.GAPI_LOGOUT_SUCCESS,
+ email: '',
+ });
+ assert.deepEqual(newState, {
+ gapiEmail: '',
+ });
+ });
+
+ it('defaults to an empty string', () => {
+ const state = {};
+ const newState = userV0.currentUserReducer(state, {
+ type: userV0.GAPI_LOGOUT_SUCCESS,
+ });
+ assert.deepEqual(newState, {
+ gapiEmail: '',
+ });
+ });
+ });
+ });
+
+ describe('selectors', () => {
+ it('prefs', () => {
+ const state = wrapCurrentUser({prefs: {
+ testPref: 'true',
+ test_non_bool: 'hello-world',
+ }});
+
+ assert.deepEqual(userV0.prefs(state), new Map([
+ ['render_markdown', false],
+ ['testPref', true],
+ ['test_non_bool', 'hello-world'],
+ ]));
+ });
+
+ it('prefs is set with the correct type', async () =>{
+ // When setting prefs it's important that they are set as their
+ // String value.
+ const state = wrapCurrentUser({prefs: {
+ render_markdown : 'true',
+ }});
+ const markdownPref = userV0.prefs(state).get('render_markdown');
+ assert.isTrue(markdownPref);
+ });
+
+ it('prefs is NOT set with the correct type', async () =>{
+ // Here the value is a boolean so when it gets set it would
+ // appear as false because it's compared with a String.
+ const state = wrapCurrentUser({prefs: {
+ render_markdown : true,
+ }});
+ const markdownPref = userV0.prefs(state).get('render_markdown');
+ // Thus this is false when it was meant to be true.
+ assert.isFalse(markdownPref);
+ });
+
+ it('projects', () => {
+ assert.deepEqual(userV0.projects(wrapUser({})), {});
+
+ const state = wrapUser({
+ currentUser: {userId: '123'},
+ usersById: {
+ ['123']: {
+ projects: {
+ ownerOf: ['chromium'],
+ memberOf: ['v8'],
+ contributorTo: [],
+ starredProjects: [],
+ },
+ },
+ },
+ });
+
+ assert.deepEqual(userV0.projects(state), {
+ ownerOf: ['chromium'],
+ memberOf: ['v8'],
+ contributorTo: [],
+ starredProjects: [],
+ });
+ });
+
+ it('projectPerUser', () => {
+ assert.deepEqual(userV0.projectsPerUser(wrapUser({})), new Map());
+
+ const state = wrapUser({
+ usersById: {
+ ['123']: {
+ projects: {
+ ownerOf: ['chromium'],
+ memberOf: ['v8'],
+ contributorTo: [],
+ starredProjects: [],
+ },
+ },
+ },
+ });
+
+ assert.deepEqual(userV0.projectsPerUser(state), new Map([
+ ['123', {
+ ownerOf: ['chromium'],
+ memberOf: ['v8'],
+ contributorTo: [],
+ starredProjects: [],
+ }],
+ ]));
+ });
+ });
+
+ describe('action creators', () => {
+ beforeEach(() => {
+ sinon.stub(prpcClient, 'call');
+
+ dispatch = sinon.stub();
+ });
+
+ afterEach(() => {
+ prpcClient.call.restore();
+ });
+
+ it('fetchProjects succeeds', async () => {
+ const action = userV0.fetchProjects([{userId: '123'}]);
+
+ prpcClient.call.returns(Promise.resolve({
+ usersProjects: [
+ {
+ userRef: {
+ userId: '123',
+ },
+ ownerOf: ['chromium'],
+ },
+ ],
+ }));
+
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: userV0.FETCH_PROJECTS_START});
+
+ sinon.assert.calledWith(
+ prpcClient.call,
+ 'monorail.Users',
+ 'GetUsersProjects',
+ {userRefs: [{userId: '123'}]});
+
+ sinon.assert.calledWith(dispatch, {
+ type: userV0.FETCH_PROJECTS_SUCCESS,
+ usersProjects: [
+ {
+ userRef: {
+ userId: '123',
+ },
+ ownerOf: ['chromium'],
+ },
+ ],
+ });
+ });
+
+ it('fetchProjects fails', async () => {
+ const action = userV0.fetchProjects([{userId: '123'}]);
+
+ const error = new Error('mistakes were made');
+ prpcClient.call.returns(Promise.reject(error));
+
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: userV0.FETCH_PROJECTS_START});
+
+ sinon.assert.calledWith(
+ prpcClient.call,
+ 'monorail.Users',
+ 'GetUsersProjects',
+ {userRefs: [{userId: '123'}]});
+
+ sinon.assert.calledWith(dispatch, {
+ type: userV0.FETCH_PROJECTS_FAILURE,
+ error,
+ });
+ });
+
+ it('setPrefs', async () => {
+ const action = userV0.setPrefs([{name: 'pref_name', value: 'true'}]);
+
+ prpcClient.call.returns(Promise.resolve({}));
+
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: userV0.SET_PREFS_START});
+
+ sinon.assert.calledWith(
+ prpcClient.call,
+ 'monorail.Users',
+ 'SetUserPrefs',
+ {prefs: [{name: 'pref_name', value: 'true'}]});
+
+ sinon.assert.calledWith(dispatch, {
+ type: userV0.SET_PREFS_SUCCESS,
+ newPrefs: [{name: 'pref_name', value: 'true'}],
+ });
+ });
+
+ it('setPrefs fails', async () => {
+ const action = userV0.setPrefs([{name: 'pref_name', value: 'true'}]);
+
+ const error = new Error('mistakes were made');
+ prpcClient.call.returns(Promise.reject(error));
+
+ await action(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: userV0.SET_PREFS_START});
+
+ sinon.assert.calledWith(
+ prpcClient.call,
+ 'monorail.Users',
+ 'SetUserPrefs',
+ {prefs: [{name: 'pref_name', value: 'true'}]});
+
+ sinon.assert.calledWith(dispatch, {
+ type: userV0.SET_PREFS_FAILURE,
+ error,
+ });
+ });
+ });
+});
+
+
+const wrapCurrentUser = (currentUser = {}) => ({userV0: {currentUser}});
+const wrapUser = (user) => ({userV0: user});
diff --git a/static_src/reducers/users.js b/static_src/reducers/users.js
new file mode 100644
index 0000000..af8609c
--- /dev/null
+++ b/static_src/reducers/users.js
@@ -0,0 +1,222 @@
+// 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 User actions, selectors, and reducers organized into
+ * a single Redux "Duck" that manages updating and retrieving user state
+ * on the frontend.
+ *
+ * The User data is stored in a normalized format.
+ * `byName` stores all User data indexed by User name.
+ * `user` is a selector that gets the currently viewed User data.
+ *
+ * Reference: https://github.com/erikras/ducks-modular-redux
+ */
+
+import {combineReducers} from 'redux';
+import {createReducer, createKeyedRequestReducer} from './redux-helpers.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import 'shared/typedef.js';
+
+/** @typedef {import('redux').AnyAction} AnyAction */
+
+// Actions
+export const LOG_IN = 'user/LOG_IN';
+
+export const BATCH_GET_START = 'user/BATCH_GET_START';
+export const BATCH_GET_SUCCESS = 'user/BATCH_GET_SUCCESS';
+export const BATCH_GET_FAILURE = 'user/BATCH_GET_FAILURE';
+
+export const FETCH_START = 'user/FETCH_START';
+export const FETCH_SUCCESS = 'user/FETCH_SUCCESS';
+export const FETCH_FAILURE = 'user/FETCH_FAILURE';
+
+export const GATHER_PROJECT_MEMBERSHIPS_START =
+ 'user/GATHER_PROJECT_MEMBERSHIPS_START';
+export const GATHER_PROJECT_MEMBERSHIPS_SUCCESS =
+ 'user/GATHER_PROJECT_MEMBERSHIPS_SUCCESS';
+export const GATHER_PROJECT_MEMBERSHIPS_FAILURE =
+ 'user/GATHER_PROJECT_MEMBERSHIPS_FAILURE';
+
+/* State Shape
+{
+ currentUserName: ?string,
+
+ byName: Object<UserName, User>,
+
+ requests: {
+ batchGet: ReduxRequestState,
+ fetch: ReduxRequestState,
+ gatherProjectMemberships: ReduxRequestState,
+ },
+}
+*/
+
+// Reducers
+
+/**
+ * A reference to the currently logged in user.
+ * @param {?string} state The current user name.
+ * @param {AnyAction} action
+ * @param {User} action.user The user that was logged in.
+ * @return {?string}
+ */
+export const currentUserNameReducer = createReducer(null, {
+ [LOG_IN]: (_state, {user}) => user.name,
+});
+
+/**
+ * All User data indexed by User name.
+ * @param {Object<UserName, User>} state The existing User data.
+ * @param {AnyAction} action
+ * @param {User} action.user The user that was fetched.
+ * @return {Object<UserName, User>}
+ */
+export const byNameReducer = createReducer({}, {
+ [BATCH_GET_SUCCESS]: (state, {users}) => {
+ const newState = {...state};
+ for (const user of users) {
+ newState[user.name] = user;
+ }
+ return newState;
+ },
+ [FETCH_SUCCESS]: (state, {user}) => ({...state, [user.name]: user}),
+});
+
+/**
+ * ProjectMember data indexed by User name.
+ *
+ * Pragma: No normalization for ProjectMember objects. There is never a
+ * situation when we will have access to ProjectMember names but not associated
+ * ProjectMember objects so normalizing is unnecessary.
+ * @param {Object<UserName, Array<ProjectMember>>} state The existing User data.
+ * @param {AnyAction} action
+ * @param {UserName} action.userName The resource name of the user that was
+ * fetched.
+ * @param {Array<ProjectMember>=} action.projectMemberships The project
+ * memberships for the fetched user.
+ * @return {Object<UserName, Array<ProjectMember>>}
+ */
+export const projectMembershipsReducer = createReducer({}, {
+ [GATHER_PROJECT_MEMBERSHIPS_SUCCESS]: (state, {userName,
+ projectMemberships}) => {
+ const newState = {...state};
+
+ newState[userName] = projectMemberships || [];
+ return newState;
+ },
+});
+
+const requestsReducer = combineReducers({
+ batchGet: createKeyedRequestReducer(
+ BATCH_GET_START, BATCH_GET_SUCCESS, BATCH_GET_FAILURE),
+ fetch: createKeyedRequestReducer(
+ FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
+ gatherProjectMemberships: createKeyedRequestReducer(
+ GATHER_PROJECT_MEMBERSHIPS_START, GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+ GATHER_PROJECT_MEMBERSHIPS_FAILURE),
+});
+
+export const reducer = combineReducers({
+ currentUserName: currentUserNameReducer,
+ byName: byNameReducer,
+ projectMemberships: projectMembershipsReducer,
+
+ requests: requestsReducer,
+});
+
+// Selectors
+
+/**
+ * Returns the currently logged in user name, or null if there is none.
+ * @param {any} state
+ * @return {?string}
+ */
+export const currentUserName = (state) => state.users.currentUserName;
+
+/**
+ * Returns all the User data in the store as a mapping from name to User.
+ * @param {any} state
+ * @return {Object<UserName, User>}
+ */
+export const byName = (state) => state.users.byName;
+
+/**
+ * Returns all the ProjectMember data in the store, mapped to Users' names.
+ * @param {any} state
+ * @return {Object<UserName, ProjectMember>}
+ */
+export const projectMemberships = (state) => state.users.projectMemberships;
+
+// Action Creators
+
+/**
+ * Action creator to fetch multiple User objects.
+ * @param {Array<UserName>} names The names of the Users to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const batchGet = (names) => async (dispatch) => {
+ dispatch({type: BATCH_GET_START});
+
+ try {
+ /** @type {{users: Array<User>}} */
+ const {users} = await prpcClient.call(
+ 'monorail.v3.Users', 'BatchGetUsers', {names});
+
+ dispatch({type: BATCH_GET_SUCCESS, users});
+ } catch (error) {
+ dispatch({type: BATCH_GET_FAILURE, error});
+ };
+};
+
+/**
+ * Action creator to fetch a single User object.
+ * TODO(https://crbug.com/monorail/7824): Maybe decouple LOG_IN from
+ * FETCH_SUCCESS once we move away from server-side logins.
+ * @param {UserName} name The resource name of the User to fetch.
+ * @return {function(function): Promise<void>}
+ */
+export const fetch = (name) => async (dispatch) => {
+ dispatch({type: FETCH_START});
+
+ try {
+ /** @type {User} */
+ const user = await prpcClient.call('monorail.v3.Users', 'GetUser', {name});
+ dispatch({type: FETCH_SUCCESS, user});
+ dispatch({type: LOG_IN, user});
+ } catch (error) {
+ dispatch({type: FETCH_FAILURE, error});
+ }
+};
+
+/**
+ * Action creator to fetch ProjectMember objects for a given User.
+ * @param {UserName} name The resource name of the User.
+ * @return {function(function): Promise<void>}
+ */
+export const gatherProjectMemberships = (name) => async (dispatch) => {
+ dispatch({type: GATHER_PROJECT_MEMBERSHIPS_START});
+
+ try {
+ /** @type {{projectMemberships: Array<ProjectMember>}} */
+ const {projectMemberships} = await prpcClient.call(
+ 'monorail.v3.Frontend', 'GatherProjectMembershipsForUser',
+ {user: name});
+
+ dispatch({type: GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+ userName: name, projectMemberships});
+ } catch (error) {
+ // TODO(crbug.com/monorail/7627): Catch actual API errors.
+ dispatch({type: GATHER_PROJECT_MEMBERSHIPS_FAILURE, error});
+ };
+};
+
+export const users = {
+ currentUserName,
+ byName,
+ projectMemberships,
+ batchGet,
+ fetch,
+ gatherProjectMemberships,
+};
diff --git a/static_src/reducers/users.test.js b/static_src/reducers/users.test.js
new file mode 100644
index 0000000..ea0ce61
--- /dev/null
+++ b/static_src/reducers/users.test.js
@@ -0,0 +1,178 @@
+// 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 sinon from 'sinon';
+
+import * as users from './users.js';
+import * as example from 'shared/test/constants-users.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+
+let dispatch;
+
+describe('user reducers', () => {
+ it('root reducer initial state', () => {
+ const actual = users.reducer(undefined, {type: null});
+ const expected = {
+ currentUserName: null,
+ byName: {},
+ projectMemberships: {},
+ requests: {
+ batchGet: {},
+ fetch: {},
+ gatherProjectMemberships: {},
+ },
+ };
+ assert.deepEqual(actual, expected);
+ });
+
+ it('currentUserName updates on LOG_IN', () => {
+ const action = {type: users.LOG_IN, user: example.USER};
+ const actual = users.currentUserNameReducer(null, action);
+ assert.deepEqual(actual, example.NAME);
+ });
+
+ it('byName updates on BATCH_GET_SUCCESS', () => {
+ const action = {type: users.BATCH_GET_SUCCESS, users: [example.USER]};
+ const actual = users.byNameReducer({}, action);
+ assert.deepEqual(actual, {[example.NAME]: example.USER});
+ });
+
+ describe('projectMembershipsReducer', () => {
+ it('updates on GATHER_PROJECT_MEMBERSHIPS_SUCCESS', () => {
+ const action = {type: users.GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+ userName: example.NAME, projectMemberships: [example.PROJECT_MEMBER]};
+ const actual = users.projectMembershipsReducer({}, action);
+ assert.deepEqual(actual, {[example.NAME]: [example.PROJECT_MEMBER]});
+ });
+
+ it('sets empty on GATHER_PROJECT_MEMBERSHIPS_SUCCESS', () => {
+ const action = {type: users.GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+ userName: example.NAME, projectMemberships: undefined};
+ const actual = users.projectMembershipsReducer({}, action);
+ assert.deepEqual(actual, {[example.NAME]: []});
+ });
+ });
+});
+
+describe('user selectors', () => {
+ it('currentUserName', () => {
+ const state = {users: {currentUserName: example.NAME}};
+ assert.deepEqual(users.currentUserName(state), example.NAME);
+ });
+
+ it('byName', () => {
+ const state = {users: {byName: example.BY_NAME}};
+ assert.deepEqual(users.byName(state), example.BY_NAME);
+ });
+
+ it('projectMemberships', () => {
+ const membershipsByName = {[example.NAME]: [example.PROJECT_MEMBER]};
+ const state = {users: {projectMemberships: membershipsByName}};
+ assert.deepEqual(users.projectMemberships(state), membershipsByName);
+ });
+});
+
+describe('user action creators', () => {
+ beforeEach(() => {
+ sinon.stub(prpcClient, 'call');
+ dispatch = sinon.stub();
+ });
+
+ afterEach(() => {
+ prpcClient.call.restore();
+ });
+
+ describe('batchGet', () => {
+ it('success', async () => {
+ prpcClient.call.returns(Promise.resolve({users: [example.USER]}));
+
+ await users.batchGet([example.NAME])(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: users.BATCH_GET_START});
+
+ const args = {names: [example.NAME]};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Users', 'BatchGetUsers', args);
+
+ const action = {type: users.BATCH_GET_SUCCESS, users: [example.USER]};
+ sinon.assert.calledWith(dispatch, action);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await users.batchGet([example.NAME])(dispatch);
+
+ const action = {type: users.BATCH_GET_FAILURE, error: sinon.match.any};
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('fetch', () => {
+ it('success', async () => {
+ prpcClient.call.returns(Promise.resolve(example.USER));
+
+ await users.fetch(example.NAME)(dispatch);
+
+ sinon.assert.calledWith(dispatch, {type: users.FETCH_START});
+
+ const args = {name: example.NAME};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Users', 'GetUser', args);
+
+ const fetchAction = {type: users.FETCH_SUCCESS, user: example.USER};
+ sinon.assert.calledWith(dispatch, fetchAction);
+
+ const logInAction = {type: users.LOG_IN, user: example.USER};
+ sinon.assert.calledWith(dispatch, logInAction);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws();
+
+ await users.fetch(example.NAME)(dispatch);
+
+ const action = {type: users.FETCH_FAILURE, error: sinon.match.any};
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+
+ describe('gatherProjectMemberships', () => {
+ it('success', async () => {
+ prpcClient.call.returns(Promise.resolve({projectMemberships: [
+ example.PROJECT_MEMBER,
+ ]}));
+
+ await users.gatherProjectMemberships(
+ example.NAME)(dispatch);
+
+ sinon.assert.calledWith(dispatch,
+ {type: users.GATHER_PROJECT_MEMBERSHIPS_START});
+
+ const args = {user: example.NAME};
+ sinon.assert.calledWith(
+ prpcClient.call, 'monorail.v3.Frontend',
+ 'GatherProjectMembershipsForUser', args);
+
+ const action = {
+ type: users.GATHER_PROJECT_MEMBERSHIPS_SUCCESS,
+ projectMemberships: [example.PROJECT_MEMBER],
+ userName: example.NAME,
+ };
+ sinon.assert.calledWith(dispatch, action);
+ });
+
+ it('failure', async () => {
+ prpcClient.call.throws(new Error());
+
+ await users.batchGet([example.NAME])(dispatch);
+
+ const action = {type: users.BATCH_GET_FAILURE,
+ error: sinon.match.instanceOf(Error)};
+ sinon.assert.calledWith(dispatch, action);
+ });
+ });
+});