blob: 42a93fc4232d3fd1c2b2dc15f25b03d23a07c922 [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
5import {combineReducers} from 'redux';
6import {createSelector} from 'reselect';
7import {createReducer, createRequestReducer} from './redux-helpers.js';
8import {prpcClient} from 'prpc-client-instance.js';
9import {objectToMap} from 'shared/helpers.js';
10import {userRefToId, userToUserRef} from 'shared/convertersV0.js';
11import loadGapi, {fetchGapiEmail} from 'shared/gapi-loader.js';
12import {DEFAULT_MD_PROJECTS} from 'shared/md-helper.js';
13import {viewedProjectName} from 'reducers/projectV0.js';
14
15// Actions
16const FETCH_START = 'userV0/FETCH_START';
17const FETCH_SUCCESS = 'userV0/FETCH_SUCCESS';
18const FETCH_FAILURE = 'userV0/FETCH_FAILURE';
19
20export const FETCH_PROJECTS_START = 'userV0/FETCH_PROJECTS_START';
21export const FETCH_PROJECTS_SUCCESS = 'userV0/FETCH_PROJECTS_SUCCESS';
22export const FETCH_PROJECTS_FAILURE = 'userV0/FETCH_PROJECTS_FAILURE';
23
24const FETCH_HOTLISTS_START = 'userV0/FETCH_HOTLISTS_START';
25const FETCH_HOTLISTS_SUCCESS = 'userV0/FETCH_HOTLISTS_SUCCESS';
26const FETCH_HOTLISTS_FAILURE = 'userV0/FETCH_HOTLISTS_FAILURE';
27
28const FETCH_PREFS_START = 'userV0/FETCH_PREFS_START';
29const FETCH_PREFS_SUCCESS = 'userV0/FETCH_PREFS_SUCCESS';
30const FETCH_PREFS_FAILURE = 'userV0/FETCH_PREFS_FAILURE';
31
32export const SET_PREFS_START = 'userV0/SET_PREFS_START';
33export const SET_PREFS_SUCCESS = 'userV0/SET_PREFS_SUCCESS';
34export const SET_PREFS_FAILURE = 'userV0/SET_PREFS_FAILURE';
35
36const GAPI_LOGIN_START = 'GAPI_LOGIN_START';
37export const GAPI_LOGIN_SUCCESS = 'GAPI_LOGIN_SUCCESS';
38const GAPI_LOGIN_FAILURE = 'GAPI_LOGIN_FAILURE';
39
40const GAPI_LOGOUT_START = 'GAPI_LOGOUT_START';
41export const GAPI_LOGOUT_SUCCESS = 'GAPI_LOGOUT_SUCCESS';
42const GAPI_LOGOUT_FAILURE = 'GAPI_LOGOUT_FAILURE';
43
44
45// Monorial UserPrefs are stored as plain strings in Monorail's backend.
46// We want boolean preferences to be converted into booleans for convenience.
47// Currently, there are no user prefs in Monorail that are NOT booleans, so
48// we default to converting all user prefs to booleans unless otherwise
49// specified.
50// See: https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/framework/framework_bizobj.py;l=409
51const NON_BOOLEAN_PREFS = new Set(['test_non_bool']);
52
53
54/* State Shape
55{
56 currentUser: {
57 ...user: Object,
58 groups: Array,
59 hotlists: Array,
60 prefs: Object,
61 gapiEmail: String,
62 },
63 requests: {
64 fetch: Object,
65 fetchHotlists: Object,
66 fetchPrefs: Object,
67 },
68}
69*/
70
71// Reducers
72const USER_DEFAULT = {
73 groups: [],
74 hotlists: [],
75 projects: {},
76 prefs: {},
77 prefsLoaded: false,
78};
79
80const gapiEmailReducer = (user, action) => {
81 return {
82 ...user,
83 gapiEmail: action.email || '',
84 };
85};
86
87export const currentUserReducer = createReducer(USER_DEFAULT, {
88 [FETCH_SUCCESS]: (_user, action) => {
89 return {
90 ...USER_DEFAULT,
91 ...action.user,
92 groups: action.groups,
93 };
94 },
95 [FETCH_HOTLISTS_SUCCESS]: (user, action) => {
96 return {...user, hotlists: action.hotlists};
97 },
98 [FETCH_PREFS_SUCCESS]: (user, action) => {
99 return {
100 ...user,
101 prefs: action.prefs,
102 prefsLoaded: true,
103 };
104 },
105 [SET_PREFS_SUCCESS]: (user, action) => {
106 const newPrefs = action.newPrefs;
107 const prefs = Object.assign({}, user.prefs);
108 newPrefs.forEach(({name, value}) => {
109 prefs[name] = value;
110 });
111 return {
112 ...user,
113 prefs,
114 };
115 },
116 [GAPI_LOGIN_SUCCESS]: gapiEmailReducer,
117 [GAPI_LOGOUT_SUCCESS]: gapiEmailReducer,
118});
119
120export const usersByIdReducer = createReducer({}, {
121 [FETCH_PROJECTS_SUCCESS]: (state, action) => {
122 const newState = {...state};
123
124 action.usersProjects.forEach((userProjects) => {
125 const {userRef, ownerOf = [], memberOf = [], contributorTo = [],
126 starredProjects = []} = userProjects;
127
128 const userId = userRefToId(userRef);
129
130 newState[userId] = {
131 ...newState[userId],
132 projects: {
133 ownerOf,
134 memberOf,
135 contributorTo,
136 starredProjects,
137 },
138 };
139 });
140
141 return newState;
142 },
143});
144
145const requestsReducer = combineReducers({
146 // Request for getting backend metadata related to a user, such as
147 // which groups they belong to and whether they're a site admin.
148 fetch: createRequestReducer(FETCH_START, FETCH_SUCCESS, FETCH_FAILURE),
149 // Requests for fetching projects a user is related to.
150 fetchProjects: createRequestReducer(
151 FETCH_PROJECTS_START, FETCH_PROJECTS_SUCCESS, FETCH_PROJECTS_FAILURE),
152 // Request for getting a user's hotlists.
153 fetchHotlists: createRequestReducer(
154 FETCH_HOTLISTS_START, FETCH_HOTLISTS_SUCCESS, FETCH_HOTLISTS_FAILURE),
155 // Request for getting a user's prefs.
156 fetchPrefs: createRequestReducer(
157 FETCH_PREFS_START, FETCH_PREFS_SUCCESS, FETCH_PREFS_FAILURE),
158 // Request for setting a user's prefs.
159 setPrefs: createRequestReducer(
160 SET_PREFS_START, SET_PREFS_SUCCESS, SET_PREFS_FAILURE),
161});
162
163export const reducer = combineReducers({
164 currentUser: currentUserReducer,
165 usersById: usersByIdReducer,
166 requests: requestsReducer,
167});
168
169// Selectors
170export const requests = (state) => state.userV0.requests;
171export const currentUser = (state) => state.userV0.currentUser || {};
172// TODO(zhangtiff): Replace custom logic to check if the user is logged in
173// across the frontend.
174export const isLoggedIn = createSelector(
175 currentUser, (user) => user && user.userId);
176export const userRef = createSelector(
177 currentUser, (user) => userToUserRef(user));
178export const prefs = createSelector(
179 currentUser, viewedProjectName, (user, projectName = '') => {
180 const prefs = {
181 // Make Markdown default to true for projects who have opted in.
182 render_markdown: String(DEFAULT_MD_PROJECTS.has(projectName)),
183 ...user.prefs
184 };
185 for (let prefName of Object.keys(prefs)) {
186 if (!NON_BOOLEAN_PREFS.has(prefName)) {
187 // Monorail user preferences are stored as strings.
188 prefs[prefName] = prefs[prefName] === 'true';
189 }
190 }
191 return objectToMap(prefs);
192 });
193
194const _usersById = (state) => state.userV0.usersById || {};
195export const usersById = createSelector(_usersById,
196 (usersById) => objectToMap(usersById));
197
198export const projectsPerUser = createSelector(usersById, (usersById) => {
199 const map = new Map();
200 for (const [key, value] of usersById.entries()) {
201 if (value.projects) {
202 map.set(key, value.projects);
203 }
204 }
205 return map;
206});
207
208// Projects for just the current user.
209export const projects = createSelector(projectsPerUser, userRef,
210 (projectsMap, userRef) => projectsMap.get(userRefToId(userRef)) || {});
211
212// Action Creators
213/**
214 * Fetches the data required to view the logged in user.
215 * @param {string} displayName The display name of the logged in user. Note that
216 * while usernames may be hidden for users in Monorail, the logged in user
217 * will always be able to view their own name.
218 * @return {function(function): Promise<void>}
219 */
220export const fetch = (displayName) => async (dispatch) => {
221 dispatch({type: FETCH_START});
222
223 const message = {
224 userRef: {displayName},
225 };
226
227 try {
228 const resp = await Promise.all([
229 prpcClient.call(
230 'monorail.Users', 'GetUser', message),
231 prpcClient.call(
232 'monorail.Users', 'GetMemberships', message),
233 ]);
234
235 const user = resp[0];
236
237 dispatch({
238 type: FETCH_SUCCESS,
239 user,
240 groups: resp[1].groupRefs || [],
241 });
242
243 const userRef = userToUserRef(user);
244
245 dispatch(fetchProjects([userRef]));
246 const hotlistsPromise = dispatch(fetchHotlists(userRef));
247 dispatch(fetchPrefs());
248
249 hotlistsPromise.then((hotlists) => {
250 // TODO(crbug.com/monorail/5828): Remove
251 // window.TKR_populateHotlistAutocomplete once the old
252 // autocomplete code is deprecated.
253 window.TKR_populateHotlistAutocomplete(hotlists);
254 });
255 } catch (error) {
256 dispatch({type: FETCH_FAILURE, error});
257 };
258};
259
260export const fetchProjects = (userRefs) => async (dispatch) => {
261 dispatch({type: FETCH_PROJECTS_START});
262 try {
263 const resp = await prpcClient.call(
264 'monorail.Users', 'GetUsersProjects', {userRefs});
265 dispatch({type: FETCH_PROJECTS_SUCCESS, usersProjects: resp.usersProjects});
266 } catch (error) {
267 dispatch({type: FETCH_PROJECTS_FAILURE, error});
268 }
269};
270
271/**
272 * Fetches all of a given user's hotlists.
273 * @param {UserRef} userRef The user to fetch hotlists for.
274 * @return {function(function): Promise<Array<HotlistV0>>}
275 */
276export const fetchHotlists = (userRef) => async (dispatch) => {
277 dispatch({type: FETCH_HOTLISTS_START});
278
279 try {
280 const resp = await prpcClient.call(
281 'monorail.Features', 'ListHotlistsByUser', {user: userRef});
282
283 const hotlists = resp.hotlists || [];
284 hotlists.sort((hotlistA, hotlistB) => {
285 return hotlistA.name.localeCompare(hotlistB.name);
286 });
287 dispatch({type: FETCH_HOTLISTS_SUCCESS, hotlists});
288
289 return hotlists;
290 } catch (error) {
291 dispatch({type: FETCH_HOTLISTS_FAILURE, error});
292 };
293};
294
295/**
296 * Fetches user preferences for the logged in user.
297 * @return {function(function): Promise<void>}
298 */
299export const fetchPrefs = () => async (dispatch) => {
300 dispatch({type: FETCH_PREFS_START});
301
302 try {
303 const resp = await prpcClient.call(
304 'monorail.Users', 'GetUserPrefs', {});
305 const prefs = {};
306 (resp.prefs || []).forEach(({name, value}) => {
307 prefs[name] = value;
308 });
309 dispatch({type: FETCH_PREFS_SUCCESS, prefs});
310 } catch (error) {
311 dispatch({type: FETCH_PREFS_FAILURE, error});
312 };
313};
314
315/**
316 * Action creator for setting a user's preferences.
317 *
318 * @param {Object} newPrefs
319 * @param {boolean} saveChanges
320 * @return {function(function): Promise<void>}
321 */
322export const setPrefs = (newPrefs, saveChanges = true) => async (dispatch) => {
323 if (!saveChanges) {
324 dispatch({type: SET_PREFS_SUCCESS, newPrefs});
325 return;
326 }
327
328 dispatch({type: SET_PREFS_START});
329
330 try {
331 const message = {prefs: newPrefs};
332 await prpcClient.call(
333 'monorail.Users', 'SetUserPrefs', message);
334 dispatch({type: SET_PREFS_SUCCESS, newPrefs});
335
336 // Re-fetch the user's prefs after saving to prevent prefs from
337 // getting out of sync.
338 dispatch(fetchPrefs());
339 } catch (error) {
340 dispatch({type: SET_PREFS_FAILURE, error});
341 }
342};
343
344/**
345 * Action creator to initiate the gapi.js login flow.
346 *
347 * @return {Promise} Resolved only when gapi.js login succeeds.
348 */
349export const initGapiLogin = () => (dispatch) => {
350 dispatch({type: GAPI_LOGIN_START});
351
352 return new Promise(async (resolve) => {
353 try {
354 await loadGapi();
355 gapi.auth2.getAuthInstance().signIn().then(async () => {
356 const email = await fetchGapiEmail();
357 dispatch({type: GAPI_LOGIN_SUCCESS, email: email});
358 resolve();
359 });
360 } catch (error) {
361 // TODO(jeffcarp): Pop up a message that signIn failed.
362 dispatch({type: GAPI_LOGIN_FAILURE, error});
363 }
364 });
365};
366
367/**
368 * Action creator to log the user out of gapi.js
369 *
370 * @return {undefined}
371 */
372export const initGapiLogout = () => async (dispatch) => {
373 dispatch({type: GAPI_LOGOUT_START});
374
375 try {
376 await loadGapi();
377 gapi.auth2.getAuthInstance().signOut().then(() => {
378 dispatch({type: GAPI_LOGOUT_SUCCESS, email: ''});
379 });
380 } catch (error) {
381 // TODO(jeffcarp): Pop up a message that signOut failed.
382 dispatch({type: GAPI_LOGOUT_FAILURE, error});
383 }
384};