blob: 95989ccb9b46dfbef4ff8ebcdf7b0e207748d035 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/**
6 * @fileoverview Hotlist actions, selectors, and reducers organized into
7 * a single Redux "Duck" that manages updating and retrieving hotlist state
8 * on the frontend.
9 *
10 * The Hotlist data is stored in a normalized format.
11 * `name` is a reference to the currently viewed Hotlist.
12 * `hotlists` stores all Hotlist data indexed by Hotlist name.
13 * `hotlistItems` stores all Hotlist items indexed by Hotlist name.
14 * `hotlist` is a selector that gets the currently viewed Hotlist data.
15 *
16 * Reference: https://github.com/erikras/ducks-modular-redux
17 */
18
19import {combineReducers} from 'redux';
20import {createSelector} from 'reselect';
21import {createReducer, createRequestReducer} from './redux-helpers.js';
22
23import {prpcClient} from 'prpc-client-instance.js';
24import {userIdOrDisplayNameToUserRef, issueNameToRef}
25 from 'shared/convertersV0.js';
26import {pathsToFieldMask} from 'shared/converters.js';
27
28import * as issueV0 from './issueV0.js';
29import * as permissions from './permissions.js';
30import * as sitewide from './sitewide.js';
31import * as ui from './ui.js';
32import * as users from './users.js';
33
34import 'shared/typedef.js';
35/** @typedef {import('redux').AnyAction} AnyAction */
36
37/** @type {Array<string>} */
38export const DEFAULT_COLUMNS = [
39 'Rank', 'ID', 'Status', 'Owner', 'Summary', 'Modified',
40];
41
42// Permissions
43// TODO(crbug.com/monorail/7879): Move these to a permissions constants file.
44export const EDIT = 'HOTLIST_EDIT';
45export const ADMINISTER = 'HOTLIST_ADMINISTER';
46
47// Actions
48export const SELECT = 'hotlist/SELECT';
49export const RECEIVE_HOTLIST = 'hotlist/RECEIVE_HOTLIST';
50
51export const DELETE_START = 'hotlist/DELETE_START';
52export const DELETE_SUCCESS = 'hotlist/DELETE_SUCCESS';
53export const DELETE_FAILURE = 'hotlist/DELETE_FAILURE';
54
55export const FETCH_START = 'hotlist/FETCH_START';
56export const FETCH_SUCCESS = 'hotlist/FETCH_SUCCESS';
57export const FETCH_FAILURE = 'hotlist/FETCH_FAILURE';
58
59export const FETCH_ITEMS_START = 'hotlist/FETCH_ITEMS_START';
60export const FETCH_ITEMS_SUCCESS = 'hotlist/FETCH_ITEMS_SUCCESS';
61export const FETCH_ITEMS_FAILURE = 'hotlist/FETCH_ITEMS_FAILURE';
62
63export const REMOVE_EDITORS_START = 'hotlist/REMOVE_EDITORS_START';
64export const REMOVE_EDITORS_SUCCESS = 'hotlist/REMOVE_EDITORS_SUCCESS';
65export const REMOVE_EDITORS_FAILURE = 'hotlist/REMOVE_EDITORS_FAILURE';
66
67export const REMOVE_ITEMS_START = 'hotlist/REMOVE_ITEMS_START';
68export const REMOVE_ITEMS_SUCCESS = 'hotlist/REMOVE_ITEMS_SUCCESS';
69export const REMOVE_ITEMS_FAILURE = 'hotlist/REMOVE_ITEMS_FAILURE';
70
71export const RERANK_ITEMS_START = 'hotlist/RERANK_ITEMS_START';
72export const RERANK_ITEMS_SUCCESS = 'hotlist/RERANK_ITEMS_SUCCESS';
73export const RERANK_ITEMS_FAILURE = 'hotlist/RERANK_ITEMS_FAILURE';
74
75export const UPDATE_START = 'hotlist/UPDATE_START';
76export const UPDATE_SUCCESS = 'hotlist/UPDATE_SUCCESS';
77export const UPDATE_FAILURE = 'hotlist/UPDATE_FAILURE';
78
79/* State Shape
80{
81 name: string,
82
83 byName: Object<string, Hotlist>,
84 hotlistItems: Object<string, Array<HotlistItem>>,
85
86 requests: {
87 fetch: ReduxRequestState,
88 fetchItems: ReduxRequestState,
89 update: ReduxRequestState,
90 },
91}
92*/
93
94// Reducers
95
96/**
97 * A reference to the currently viewed Hotlist.
98 * @param {?string} state The existing Hotlist resource name.
99 * @param {AnyAction} action
100 * @return {?string}
101 */
102export const nameReducer = createReducer(null, {
103 [SELECT]: (_state, {name}) => name,
104});
105
106/**
107 * All Hotlist data indexed by Hotlist resource name.
108 * @param {Object<string, Hotlist>} state The existing Hotlist data.
109 * @param {AnyAction} action
110 * @param {Hotlist} action.hotlist The Hotlist that was fetched.
111 * @return {Object<string, Hotlist>}
112 */
113export const byNameReducer = createReducer({}, {
114 [RECEIVE_HOTLIST]: (state, {hotlist}) => {
115 if (!hotlist.defaultColumns) hotlist.defaultColumns = [];
116 if (!hotlist.editors) hotlist.editors = [];
117 return {...state, [hotlist.name]: hotlist};
118 },
119});
120
121/**
122 * All Hotlist items indexed by Hotlist resource name.
123 * @param {Object<string, Array<HotlistItem>>} state The existing items.
124 * @param {AnyAction} action
125 * @param {name} action.name The Hotlist resource name.
126 * @param {Array<HotlistItem>} action.items The Hotlist items fetched.
127 * @return {Object<string, Array<HotlistItem>>}
128 */
129export const hotlistItemsReducer = createReducer({}, {
130 [FETCH_ITEMS_SUCCESS]: (state, {name, items}) => ({...state, [name]: items}),
131});
132
133export const requestsReducer = combineReducers({
134 deleteHotlist: createRequestReducer(
135 DELETE_START, DELETE_SUCCESS, DELETE_FAILURE),
136 fetch: createRequestReducer(
137 FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
138 fetchItems: createRequestReducer(
139 FETCH_ITEMS_START, FETCH_ITEMS_SUCCESS, FETCH_ITEMS_FAILURE),
140 removeEditors: createRequestReducer(
141 REMOVE_EDITORS_START, REMOVE_EDITORS_SUCCESS, REMOVE_EDITORS_FAILURE),
142 removeItems: createRequestReducer(
143 REMOVE_ITEMS_START, REMOVE_ITEMS_SUCCESS, REMOVE_ITEMS_FAILURE),
144 rerankItems: createRequestReducer(
145 RERANK_ITEMS_START, RERANK_ITEMS_SUCCESS, RERANK_ITEMS_FAILURE),
146 update: createRequestReducer(
147 UPDATE_START, UPDATE_SUCCESS, UPDATE_FAILURE),
148});
149
150export const reducer = combineReducers({
151 name: nameReducer,
152
153 byName: byNameReducer,
154 hotlistItems: hotlistItemsReducer,
155
156 requests: requestsReducer,
157});
158
159// Selectors
160
161/**
162 * Returns the currently viewed Hotlist resource name, or null if there is none.
163 * @param {any} state
164 * @return {?string}
165 */
166export const name = (state) => state.hotlists.name;
167
168/**
169 * Returns all the Hotlist data in the store as a mapping from name to Hotlist.
170 * @param {any} state
171 * @return {Object<string, Hotlist>}
172 */
173export const byName = (state) => state.hotlists.byName;
174
175/**
176 * Returns all the Hotlist items in the store as a mapping from a
177 * Hotlist resource name to its respective array of HotlistItems.
178 * @param {any} state
179 * @return {Object<string, Array<HotlistItem>>}
180 */
181export const hotlistItems = (state) => state.hotlists.hotlistItems;
182
183/**
184 * Returns the currently viewed Hotlist, or null if there is none.
185 * @param {any} state
186 * @return {?Hotlist}
187 */
188export const viewedHotlist = createSelector(
189 [byName, name],
190 (byName, name) => name && byName[name] || null);
191
192/**
193 * Returns the owner of the currently viewed Hotlist, or null if there is none.
194 * @param {any} state
195 * @return {?User}
196 */
197export const viewedHotlistOwner = createSelector(
198 [viewedHotlist, users.byName],
199 (hotlist, usersByName) => {
200 return hotlist && usersByName[hotlist.owner] || null;
201 });
202
203/**
204 * Returns the editors of the currently viewed Hotlist. Returns null if there
205 * is no hotlist data. Includes a null in the array for each editor whose User
206 * data is not in the store.
207 * @param {any} state
208 * @return {Array<User>}
209 */
210export const viewedHotlistEditors = createSelector(
211 [viewedHotlist, users.byName],
212 (hotlist, usersByName) => {
213 if (!hotlist) return null;
214 return hotlist.editors.map((editor) => usersByName[editor] || null);
215 });
216
217/**
218 * Returns an Array containing the items in the currently viewed Hotlist,
219 * or [] if there is no current Hotlist or no Hotlist data.
220 * @param {any} state
221 * @return {Array<HotlistItem>}
222 */
223export const viewedHotlistItems = createSelector(
224 [hotlistItems, name],
225 (hotlistItems, name) => name && hotlistItems[name] || []);
226
227/**
228 * Returns an Array containing the HotlistIssues in the currently viewed
229 * Hotlist, or [] if there is no current Hotlist or no Hotlist data.
230 * A HotlistIssue merges the HotlistItem and Issue into one flat object.
231 * @param {any} state
232 * @return {Array<HotlistIssue>}
233 */
234export const viewedHotlistIssues = createSelector(
235 [viewedHotlistItems, issueV0.issue, users.byName],
236 (items, getIssue, usersByName) => {
237 // Filter out issues that haven't been fetched yet or failed to fetch.
238 // Example: if the user doesn't have permissions to view the issue.
239 // <mr-issue-list> assumes that every Issue is populated.
240 const itemsWithData = items.filter((item) => getIssue(item.issue));
241 return itemsWithData.map((item) => ({
242 ...getIssue(item.issue),
243 ...item,
244 adder: usersByName[item.adder],
245 }));
246 });
247
248/**
249 * Returns the currently viewed Hotlist columns.
250 * @param {any} state
251 * @return {Array<string>}
252 */
253export const viewedHotlistColumns = createSelector(
254 [viewedHotlist, sitewide.currentColumns],
255 (hotlist, sitewideCurrentColumns) => {
256 if (sitewideCurrentColumns) return sitewideCurrentColumns;
257 if (!hotlist) return DEFAULT_COLUMNS;
258 if (!hotlist.defaultColumns.length) return DEFAULT_COLUMNS;
259 return hotlist.defaultColumns.map((col) => col.column);
260 });
261
262/**
263 * Returns the currently viewed Hotlist permissions, or [] if there is none.
264 * @param {any} state
265 * @return {Array<Permission>}
266 */
267export const viewedHotlistPermissions = createSelector(
268 [viewedHotlist, permissions.byName],
269 (hotlist, permissionsByName) => {
270 if (!hotlist) return [];
271 const permissionSet = permissionsByName[hotlist.name];
272 if (!permissionSet) return [];
273 return permissionSet.permissions;
274 });
275
276/**
277 * Returns the Hotlist requests.
278 * @param {any} state
279 * @return {Object<string, ReduxRequestState>}
280 */
281export const requests = (state) => state.hotlists.requests;
282
283// Action Creators
284
285/**
286 * Action creator to delete the Hotlist. We would have liked to have named this
287 * `delete` but it's a reserved word in JS.
288 * @param {string} name The resource name of the Hotlist to delete.
289 * @return {function(function): Promise<void>}
290 */
291export const deleteHotlist = (name) => async (dispatch) => {
292 dispatch({type: DELETE_START});
293
294 try {
295 const args = {name};
296 await prpcClient.call('monorail.v3.Hotlists', 'DeleteHotlist', args);
297
298 dispatch({type: DELETE_SUCCESS});
299 } catch (error) {
300 dispatch({type: DELETE_FAILURE, error});
301 };
302};
303
304/**
305 * Action creator to fetch a Hotlist object.
306 * @param {string} name The resource name of the Hotlist to fetch.
307 * @return {function(function): Promise<void>}
308 */
309export const fetch = (name) => async (dispatch) => {
310 dispatch({type: FETCH_START});
311
312 try {
313 /** @type {Hotlist} */
314 const hotlist = await prpcClient.call(
315 'monorail.v3.Hotlists', 'GetHotlist', {name});
316 dispatch({type: FETCH_SUCCESS});
317 dispatch({type: RECEIVE_HOTLIST, hotlist});
318
319 const editors = hotlist.editors.map((editor) => editor);
320 editors.push(hotlist.owner);
321 await dispatch(users.batchGet(editors));
322 } catch (error) {
323 dispatch({type: FETCH_FAILURE, error});
324 };
325};
326
327/**
328 * Action creator to fetch the items in a Hotlist.
329 * @param {string} name The resource name of the Hotlist to fetch.
330 * @return {function(function): Promise<Array<HotlistItem>>}
331 */
332export const fetchItems = (name) => async (dispatch) => {
333 dispatch({type: FETCH_ITEMS_START});
334
335 try {
336 const args = {parent: name, orderBy: 'rank'};
337 /** @type {{items: Array<HotlistItem>}} */
338 const {items} = await prpcClient.call(
339 'monorail.v3.Hotlists', 'ListHotlistItems', args);
340 if (!items) {
341 dispatch({type: FETCH_ITEMS_SUCCESS, name, items: []});
342 }
343 const itemsWithRank =
344 items.map((item) => item.rank ? item : {...item, rank: 0});
345
346 const issueRefs = items.map((item) => issueNameToRef(item.issue));
347 await dispatch(issueV0.fetchIssues(issueRefs));
348
349 const adderNames = [...new Set(items.map((item) => item.adder))];
350 await dispatch(users.batchGet(adderNames));
351
352 dispatch({type: FETCH_ITEMS_SUCCESS, name, items: itemsWithRank});
353 return itemsWithRank;
354 } catch (error) {
355 dispatch({type: FETCH_ITEMS_FAILURE, error});
356 };
357};
358
359/**
360 * Action creator to remove editors from a Hotlist.
361 * @param {string} name The resource name of the Hotlist.
362 * @param {Array<string>} editors The resource names of the Users to remove.
363 * @return {function(function): Promise<void>}
364 */
365export const removeEditors = (name, editors) => async (dispatch) => {
366 dispatch({type: REMOVE_EDITORS_START});
367
368 try {
369 const args = {name, editors};
370 await prpcClient.call('monorail.v3.Hotlists', 'RemoveHotlistEditors', args);
371
372 dispatch({type: REMOVE_EDITORS_SUCCESS});
373
374 await dispatch(fetch(name));
375 } catch (error) {
376 dispatch({type: REMOVE_EDITORS_FAILURE, error});
377 };
378};
379
380/**
381 * Action creator to remove items from a Hotlist.
382 * @param {string} name The resource name of the Hotlist.
383 * @param {Array<string>} issues The resource names of the Issues to remove.
384 * @return {function(function): Promise<void>}
385 */
386export const removeItems = (name, issues) => async (dispatch) => {
387 dispatch({type: REMOVE_ITEMS_START});
388
389 try {
390 const args = {parent: name, issues};
391 await prpcClient.call('monorail.v3.Hotlists', 'RemoveHotlistItems', args);
392
393 dispatch({type: REMOVE_ITEMS_SUCCESS});
394
395 await dispatch(fetchItems(name));
396 } catch (error) {
397 dispatch({type: REMOVE_ITEMS_FAILURE, error});
398 };
399};
400
401/**
402 * Action creator to rerank the items in a Hotlist.
403 * @param {string} name The resource name of the Hotlist.
404 * @param {Array<string>} items The resource names of the HotlistItems to move.
405 * @param {number} index The index to insert the moved items.
406 * @return {function(function): Promise<void>}
407 */
408export const rerankItems = (name, items, index) => async (dispatch) => {
409 dispatch({type: RERANK_ITEMS_START});
410
411 try {
412 const args = {name, hotlistItems: items, targetPosition: index};
413 await prpcClient.call('monorail.v3.Hotlists', 'RerankHotlistItems', args);
414
415 dispatch({type: RERANK_ITEMS_SUCCESS});
416
417 await dispatch(fetchItems(name));
418 } catch (error) {
419 dispatch({type: RERANK_ITEMS_FAILURE, error});
420 };
421};
422
423/**
424 * Action creator to set the currently viewed Hotlist.
425 * @param {string} name The resource name of the Hotlist to select.
426 * @return {AnyAction}
427 */
428export const select = (name) => ({type: SELECT, name});
429
430/**
431 * Action creator to update the Hotlist metadata.
432 * @param {string} name The resource name of the Hotlist to delete.
433 * @param {Hotlist} hotlist This represents the updated version of the Hotlist
434 * with only the fields that need to be updated.
435 * @return {function(function): Promise<void>}
436 */
437export const update = (name, hotlist) => async (dispatch) => {
438 dispatch({type: UPDATE_START});
439 try {
440 const paths = pathsToFieldMask(Object.keys(hotlist));
441 const hotlistArg = {...hotlist, name};
442 const args = {hotlist: hotlistArg, updateMask: paths};
443
444 /** @type {Hotlist} */
445 const updatedHotlist = await prpcClient.call(
446 'monorail.v3.Hotlists', 'UpdateHotlist', args);
447 dispatch({type: UPDATE_SUCCESS});
448 dispatch({type: RECEIVE_HOTLIST, hotlist: updatedHotlist});
449
450 const editors = updatedHotlist.editors.map((editor) => editor);
451 editors.push(updatedHotlist.owner);
452 await dispatch(users.batchGet(editors));
453 } catch (error) {
454 dispatch({type: UPDATE_FAILURE, error});
455 dispatch(ui.showSnackbar(UPDATE_FAILURE, error.description));
456 throw error;
457 }
458};
459
460// Helpers
461
462/**
463 * Helper to fetch a Hotlist ID given its owner and display name.
464 * @param {string} owner The Hotlist owner's user id or display name.
465 * @param {string} hotlist The Hotlist's id or display name.
466 * @return {Promise<?string>}
467 */
468export const getHotlistName = async (owner, hotlist) => {
469 const hotlistRef = {
470 owner: userIdOrDisplayNameToUserRef(owner),
471 name: hotlist,
472 };
473
474 try {
475 /** @type {{hotlistId: number}} */
476 const {hotlistId} = await prpcClient.call(
477 'monorail.Features', 'GetHotlistID', {hotlistRef});
478 return 'hotlists/' + hotlistId;
479 } catch (error) {
480 return null;
481 };
482};
483
484export const hotlists = {
485 // Permissions
486 EDIT,
487 ADMINISTER,
488
489 // Reducer
490 reducer,
491
492 // Selectors
493 name,
494 byName,
495 hotlistItems,
496 viewedHotlist,
497 viewedHotlistOwner,
498 viewedHotlistEditors,
499 viewedHotlistItems,
500 viewedHotlistIssues,
501 viewedHotlistColumns,
502 viewedHotlistPermissions,
503 requests,
504
505 // Action creators
506 deleteHotlist,
507 fetch,
508 fetchItems,
509 removeEditors,
510 removeItems,
511 rerankItems,
512 select,
513 update,
514
515 // Helpers
516 getHotlistName,
517};