| // 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, |
| }; |