Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 1 | // 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 | import {combineReducers} from 'redux'; |
| 6 | import {createSelector} from 'reselect'; |
| 7 | import {createReducer, createRequestReducer} from './redux-helpers.js'; |
| 8 | import {prpcClient} from 'prpc-client-instance.js'; |
| 9 | import {objectToMap} from 'shared/helpers.js'; |
| 10 | import {userRefToId, userToUserRef} from 'shared/convertersV0.js'; |
| 11 | import loadGapi, {fetchGapiEmail} from 'shared/gapi-loader.js'; |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 12 | import {viewedProjectName} from 'reducers/projectV0.js'; |
| 13 | |
| 14 | // Actions |
| 15 | const FETCH_START = 'userV0/FETCH_START'; |
| 16 | const FETCH_SUCCESS = 'userV0/FETCH_SUCCESS'; |
| 17 | const FETCH_FAILURE = 'userV0/FETCH_FAILURE'; |
| 18 | |
| 19 | export const FETCH_PROJECTS_START = 'userV0/FETCH_PROJECTS_START'; |
| 20 | export const FETCH_PROJECTS_SUCCESS = 'userV0/FETCH_PROJECTS_SUCCESS'; |
| 21 | export const FETCH_PROJECTS_FAILURE = 'userV0/FETCH_PROJECTS_FAILURE'; |
| 22 | |
| 23 | const FETCH_HOTLISTS_START = 'userV0/FETCH_HOTLISTS_START'; |
| 24 | const FETCH_HOTLISTS_SUCCESS = 'userV0/FETCH_HOTLISTS_SUCCESS'; |
| 25 | const FETCH_HOTLISTS_FAILURE = 'userV0/FETCH_HOTLISTS_FAILURE'; |
| 26 | |
| 27 | const FETCH_PREFS_START = 'userV0/FETCH_PREFS_START'; |
| 28 | const FETCH_PREFS_SUCCESS = 'userV0/FETCH_PREFS_SUCCESS'; |
| 29 | const FETCH_PREFS_FAILURE = 'userV0/FETCH_PREFS_FAILURE'; |
| 30 | |
| 31 | export const SET_PREFS_START = 'userV0/SET_PREFS_START'; |
| 32 | export const SET_PREFS_SUCCESS = 'userV0/SET_PREFS_SUCCESS'; |
| 33 | export const SET_PREFS_FAILURE = 'userV0/SET_PREFS_FAILURE'; |
| 34 | |
| 35 | const GAPI_LOGIN_START = 'GAPI_LOGIN_START'; |
| 36 | export const GAPI_LOGIN_SUCCESS = 'GAPI_LOGIN_SUCCESS'; |
| 37 | const GAPI_LOGIN_FAILURE = 'GAPI_LOGIN_FAILURE'; |
| 38 | |
| 39 | const GAPI_LOGOUT_START = 'GAPI_LOGOUT_START'; |
| 40 | export const GAPI_LOGOUT_SUCCESS = 'GAPI_LOGOUT_SUCCESS'; |
| 41 | const 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 |
| 50 | const 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 |
| 71 | const USER_DEFAULT = { |
| 72 | groups: [], |
| 73 | hotlists: [], |
| 74 | projects: {}, |
| 75 | prefs: {}, |
| 76 | prefsLoaded: false, |
| 77 | }; |
| 78 | |
| 79 | const gapiEmailReducer = (user, action) => { |
| 80 | return { |
| 81 | ...user, |
| 82 | gapiEmail: action.email || '', |
| 83 | }; |
| 84 | }; |
| 85 | |
| 86 | export 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 | |
| 119 | export 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 | |
| 144 | const 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 | |
| 162 | export const reducer = combineReducers({ |
| 163 | currentUser: currentUserReducer, |
| 164 | usersById: usersByIdReducer, |
| 165 | requests: requestsReducer, |
| 166 | }); |
| 167 | |
| 168 | // Selectors |
| 169 | export const requests = (state) => state.userV0.requests; |
| 170 | export const currentUser = (state) => state.userV0.currentUser || {}; |
| 171 | // TODO(zhangtiff): Replace custom logic to check if the user is logged in |
| 172 | // across the frontend. |
| 173 | export const isLoggedIn = createSelector( |
| 174 | currentUser, (user) => user && user.userId); |
| 175 | export const userRef = createSelector( |
| 176 | currentUser, (user) => userToUserRef(user)); |
| 177 | export 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Ãnez | d5550d4 | 2022-01-13 13:34:38 +0100 | [diff] [blame] | 181 | render_markdown: 'true', |
Copybara | 854996b | 2021-09-07 19:36:02 +0000 | [diff] [blame] | 182 | ...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 | |
| 193 | const _usersById = (state) => state.userV0.usersById || {}; |
| 194 | export const usersById = createSelector(_usersById, |
| 195 | (usersById) => objectToMap(usersById)); |
| 196 | |
| 197 | export 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. |
| 208 | export 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 | */ |
| 219 | export 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 | |
| 259 | export 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 | */ |
| 275 | export 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 | */ |
| 298 | export 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 | */ |
| 321 | export 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 | */ |
| 348 | export 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 | */ |
| 371 | export 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 | }; |