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