blob: a46bbfaefb38cd8022b3ce712d3e65081a3141ae [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// 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,
};