Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
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,
+};