user-email-context.tsx 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import {
  2. createContext,
  3. useEffect,
  4. useContext,
  5. useReducer,
  6. useCallback,
  7. } from 'react'
  8. import useSafeDispatch from '../../../shared/hooks/use-safe-dispatch'
  9. import * as ActionCreators from '../utils/action-creators'
  10. import { UserEmailData } from '../../../../../types/user-email'
  11. import { Nullable } from '../../../../../types/utils'
  12. import { Affiliation } from '../../../../../types/affiliation'
  13. import { normalize, NormalizedObject } from '../../../utils/normalize'
  14. import { getJSON } from '../../../infrastructure/fetch-json'
  15. import useAsync from '../../../shared/hooks/use-async'
  16. import usePersistedState from '../../../shared/hooks/use-persisted-state'
  17. const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000
  18. // eslint-disable-next-line no-unused-vars
  19. export enum Actions {
  20. SET_DATA = 'SET_DATA', // eslint-disable-line no-unused-vars
  21. SET_LOADING_STATE = 'SET_LOADING_STATE', // eslint-disable-line no-unused-vars
  22. MAKE_PRIMARY = 'MAKE_PRIMARY', // eslint-disable-line no-unused-vars
  23. DELETE_EMAIL = 'DELETE_EMAIL', // eslint-disable-line no-unused-vars
  24. SET_EMAIL_AFFILIATION_BEING_EDITED = 'SET_EMAIL_AFFILIATION_BEING_EDITED', // eslint-disable-line no-unused-vars
  25. UPDATE_AFFILIATION = 'UPDATE_AFFILIATION', // eslint-disable-line no-unused-vars
  26. }
  27. export type ActionSetData = {
  28. type: Actions.SET_DATA
  29. payload: UserEmailData[]
  30. }
  31. export type ActionSetLoading = {
  32. type: Actions.SET_LOADING_STATE
  33. payload: boolean
  34. }
  35. export type ActionMakePrimary = {
  36. type: Actions.MAKE_PRIMARY
  37. payload: UserEmailData['email']
  38. }
  39. export type ActionDeleteEmail = {
  40. type: Actions.DELETE_EMAIL
  41. payload: UserEmailData['email']
  42. }
  43. export type ActionSetEmailAffiliationBeingEdited = {
  44. type: Actions.SET_EMAIL_AFFILIATION_BEING_EDITED
  45. payload: Nullable<UserEmailData['email']>
  46. }
  47. export type ActionUpdateAffiliation = {
  48. type: Actions.UPDATE_AFFILIATION
  49. payload: {
  50. email: UserEmailData['email']
  51. role: Affiliation['role']
  52. department: Affiliation['department']
  53. }
  54. }
  55. export type State = {
  56. isLoading: boolean
  57. data: {
  58. byId: NormalizedObject<UserEmailData>
  59. emailCount: number
  60. linkedInstitutionIds: NonNullable<UserEmailData['samlProviderId']>[]
  61. emailAffiliationBeingEdited: Nullable<UserEmailData['email']>
  62. }
  63. }
  64. type Action =
  65. | ActionSetData
  66. | ActionSetLoading
  67. | ActionMakePrimary
  68. | ActionDeleteEmail
  69. | ActionSetEmailAffiliationBeingEdited
  70. | ActionUpdateAffiliation
  71. const setData = (state: State, action: ActionSetData) => {
  72. const normalized = normalize<UserEmailData>(action.payload, {
  73. idAttribute: 'email',
  74. })
  75. const emailCount = action.payload.length
  76. const byId = normalized || {}
  77. const linkedInstitutionIds = action.payload
  78. .filter(email => Boolean(email.samlProviderId))
  79. .map(email => email.samlProviderId) as NonNullable<
  80. UserEmailData['samlProviderId']
  81. >[]
  82. return {
  83. ...state,
  84. data: {
  85. ...initialState.data,
  86. byId,
  87. emailCount,
  88. linkedInstitutionIds,
  89. },
  90. }
  91. }
  92. const setLoadingAction = (state: State, action: ActionSetLoading) => ({
  93. ...state,
  94. isLoading: action.payload,
  95. })
  96. const makePrimaryAction = (state: State, action: ActionMakePrimary) => {
  97. if (!state.data.byId[action.payload]) {
  98. return state
  99. }
  100. const byId: State['data']['byId'] = {}
  101. for (const id of Object.keys(state.data.byId)) {
  102. byId[id] = {
  103. ...state.data.byId[id],
  104. default: state.data.byId[id].email === action.payload,
  105. }
  106. }
  107. return {
  108. ...state,
  109. data: {
  110. ...state.data,
  111. byId,
  112. },
  113. }
  114. }
  115. const deleteEmailAction = (state: State, action: ActionDeleteEmail) => {
  116. const { [action.payload]: _, ...byId } = state.data.byId
  117. return {
  118. ...state,
  119. data: {
  120. ...state.data,
  121. emailCount: state.data.emailCount - 1,
  122. byId,
  123. },
  124. }
  125. }
  126. const setEmailAffiliationBeingEditedAction = (
  127. state: State,
  128. action: ActionSetEmailAffiliationBeingEdited
  129. ) => {
  130. if (action.payload && !state.data.byId[action.payload]) {
  131. return state
  132. }
  133. return {
  134. ...state,
  135. data: {
  136. ...state.data,
  137. emailAffiliationBeingEdited: action.payload,
  138. },
  139. }
  140. }
  141. const updateAffiliationAction = (
  142. state: State,
  143. action: ActionUpdateAffiliation
  144. ) => {
  145. const { email, role, department } = action.payload
  146. if (action.payload && !state.data.byId[email]) {
  147. return state
  148. }
  149. const affiliation = state.data.byId[email].affiliation
  150. return {
  151. ...state,
  152. data: {
  153. ...state.data,
  154. byId: {
  155. ...state.data.byId,
  156. [email]: {
  157. ...state.data.byId[email],
  158. ...(affiliation && {
  159. affiliation: {
  160. ...affiliation,
  161. role,
  162. department,
  163. },
  164. }),
  165. },
  166. },
  167. emailAffiliationBeingEdited: null,
  168. },
  169. }
  170. }
  171. const initialState: State = {
  172. isLoading: false,
  173. data: {
  174. byId: {},
  175. emailCount: 0,
  176. linkedInstitutionIds: [],
  177. emailAffiliationBeingEdited: null,
  178. },
  179. }
  180. const reducer = (state: State, action: Action) => {
  181. switch (action.type) {
  182. case Actions.SET_DATA:
  183. return setData(state, action)
  184. case Actions.SET_LOADING_STATE:
  185. return setLoadingAction(state, action)
  186. case Actions.MAKE_PRIMARY:
  187. return makePrimaryAction(state, action)
  188. case Actions.DELETE_EMAIL:
  189. return deleteEmailAction(state, action)
  190. case Actions.SET_EMAIL_AFFILIATION_BEING_EDITED:
  191. return setEmailAffiliationBeingEditedAction(state, action)
  192. case Actions.UPDATE_AFFILIATION:
  193. return updateAffiliationAction(state, action)
  194. default:
  195. return state
  196. }
  197. }
  198. function useUserEmails() {
  199. const [
  200. showInstitutionalLeaversSurveyUntil,
  201. setShowInstitutionalLeaversSurveyUntil,
  202. ] = usePersistedState('showInstitutionalLeaversSurveyUntil', 0, true)
  203. const [state, unsafeDispatch] = useReducer(reducer, initialState)
  204. const dispatch = useSafeDispatch(unsafeDispatch)
  205. const { data, isLoading, isError, isSuccess, runAsync } =
  206. useAsync<UserEmailData[]>()
  207. const getEmails = useCallback(() => {
  208. dispatch(ActionCreators.setLoading(true))
  209. runAsync(getJSON('/user/emails?ensureAffiliation=true'))
  210. .then(data => {
  211. dispatch(ActionCreators.setData(data))
  212. })
  213. .catch(() => {})
  214. .finally(() => dispatch(ActionCreators.setLoading(false)))
  215. }, [runAsync, dispatch])
  216. // Get emails on page load
  217. useEffect(() => {
  218. getEmails()
  219. }, [getEmails])
  220. const resetLeaversSurveyExpiration = useCallback(
  221. (deletedEmail: UserEmailData) => {
  222. if (
  223. deletedEmail.emailHasInstitutionLicence ||
  224. deletedEmail.affiliation?.pastReconfirmDate
  225. ) {
  226. const stillHasLicenseAccess = Object.values(state.data.byId).some(
  227. userEmail =>
  228. userEmail.email !== deletedEmail.email &&
  229. userEmail.emailHasInstitutionLicence
  230. )
  231. if (!stillHasLicenseAccess) {
  232. setShowInstitutionalLeaversSurveyUntil(Date.now() + ONE_WEEK_IN_MS)
  233. }
  234. }
  235. },
  236. [state, setShowInstitutionalLeaversSurveyUntil]
  237. )
  238. return {
  239. state,
  240. isInitializing: isLoading && !data,
  241. isInitializingSuccess: isSuccess,
  242. isInitializingError: isError,
  243. getEmails,
  244. showInstitutionalLeaversSurveyUntil,
  245. setShowInstitutionalLeaversSurveyUntil,
  246. resetLeaversSurveyExpiration,
  247. setLoading: useCallback(
  248. (flag: boolean) => dispatch(ActionCreators.setLoading(flag)),
  249. [dispatch]
  250. ),
  251. makePrimary: useCallback(
  252. (email: UserEmailData['email']) =>
  253. dispatch(ActionCreators.makePrimary(email)),
  254. [dispatch]
  255. ),
  256. deleteEmail: useCallback(
  257. (email: UserEmailData['email']) =>
  258. dispatch(ActionCreators.deleteEmail(email)),
  259. [dispatch]
  260. ),
  261. setEmailAffiliationBeingEdited: useCallback(
  262. (email: Nullable<UserEmailData['email']>) =>
  263. dispatch(ActionCreators.setEmailAffiliationBeingEdited(email)),
  264. [dispatch]
  265. ),
  266. updateAffiliation: useCallback(
  267. (
  268. email: UserEmailData['email'],
  269. role: Affiliation['role'],
  270. department: Affiliation['department']
  271. ) => dispatch(ActionCreators.updateAffiliation(email, role, department)),
  272. [dispatch]
  273. ),
  274. }
  275. }
  276. const UserEmailsContext = createContext<
  277. ReturnType<typeof useUserEmails> | undefined
  278. >(undefined)
  279. UserEmailsContext.displayName = 'UserEmailsContext'
  280. type UserEmailsProviderProps = {
  281. children: React.ReactNode
  282. } & Record<string, unknown>
  283. function UserEmailsProvider(props: UserEmailsProviderProps) {
  284. const value = useUserEmails()
  285. return <UserEmailsContext.Provider value={value} {...props} />
  286. }
  287. const useUserEmailsContext = () => {
  288. const context = useContext(UserEmailsContext)
  289. if (context === undefined) {
  290. throw new Error('useUserEmailsContext must be used in a UserEmailsProvider')
  291. }
  292. return context
  293. }
  294. type EmailContextType = ReturnType<typeof useUserEmailsContext>
  295. export { UserEmailsProvider, useUserEmailsContext, EmailContextType }