user-email-context.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  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. linkedInstitutionIds: NonNullable<UserEmailData['samlProviderId']>[]
  60. emailAffiliationBeingEdited: Nullable<UserEmailData['email']>
  61. }
  62. }
  63. type Action =
  64. | ActionSetData
  65. | ActionSetLoading
  66. | ActionMakePrimary
  67. | ActionDeleteEmail
  68. | ActionSetEmailAffiliationBeingEdited
  69. | ActionUpdateAffiliation
  70. const setData = (state: State, action: ActionSetData) => {
  71. const normalized = normalize<UserEmailData>(action.payload, {
  72. idAttribute: 'email',
  73. })
  74. const byId = normalized || {}
  75. const linkedInstitutionIds = action.payload
  76. .filter(email => Boolean(email.samlProviderId))
  77. .map(email => email.samlProviderId) as NonNullable<
  78. UserEmailData['samlProviderId']
  79. >[]
  80. return {
  81. ...state,
  82. data: {
  83. ...initialState.data,
  84. byId,
  85. linkedInstitutionIds,
  86. },
  87. }
  88. }
  89. const setLoadingAction = (state: State, action: ActionSetLoading) => ({
  90. ...state,
  91. isLoading: action.payload,
  92. })
  93. const makePrimaryAction = (state: State, action: ActionMakePrimary) => {
  94. const byId: State['data']['byId'] = {}
  95. for (const id of Object.keys(state.data.byId)) {
  96. byId[id] = {
  97. ...state.data.byId[id],
  98. default: state.data.byId[id].email === action.payload,
  99. }
  100. }
  101. return {
  102. ...state,
  103. data: {
  104. ...state.data,
  105. byId,
  106. },
  107. }
  108. }
  109. const deleteEmailAction = (state: State, action: ActionDeleteEmail) => {
  110. const { [action.payload]: _, ...byId } = state.data.byId
  111. return {
  112. ...state,
  113. data: {
  114. ...state.data,
  115. byId,
  116. },
  117. }
  118. }
  119. const setEmailAffiliationBeingEditedAction = (
  120. state: State,
  121. action: ActionSetEmailAffiliationBeingEdited
  122. ) => ({
  123. ...state,
  124. data: {
  125. ...state.data,
  126. emailAffiliationBeingEdited: action.payload,
  127. },
  128. })
  129. const updateAffiliationAction = (
  130. state: State,
  131. action: ActionUpdateAffiliation
  132. ) => {
  133. const { email, role, department } = action.payload
  134. const affiliation = state.data.byId[email].affiliation
  135. return {
  136. ...state,
  137. data: {
  138. ...state.data,
  139. byId: {
  140. ...state.data.byId,
  141. [email]: {
  142. ...state.data.byId[email],
  143. ...(affiliation && {
  144. affiliation: {
  145. ...affiliation,
  146. role,
  147. department,
  148. },
  149. }),
  150. },
  151. },
  152. emailAffiliationBeingEdited: null,
  153. },
  154. }
  155. }
  156. const initialState: State = {
  157. isLoading: false,
  158. data: {
  159. byId: {},
  160. linkedInstitutionIds: [],
  161. emailAffiliationBeingEdited: null,
  162. },
  163. }
  164. const reducer = (state: State, action: Action) => {
  165. switch (action.type) {
  166. case Actions.SET_DATA:
  167. return setData(state, action)
  168. case Actions.SET_LOADING_STATE:
  169. return setLoadingAction(state, action)
  170. case Actions.MAKE_PRIMARY:
  171. return makePrimaryAction(state, action)
  172. case Actions.DELETE_EMAIL:
  173. return deleteEmailAction(state, action)
  174. case Actions.SET_EMAIL_AFFILIATION_BEING_EDITED:
  175. return setEmailAffiliationBeingEditedAction(state, action)
  176. case Actions.UPDATE_AFFILIATION:
  177. return updateAffiliationAction(state, action)
  178. default:
  179. return state
  180. }
  181. }
  182. function useUserEmails() {
  183. const [, setExpirationDate] = usePersistedState(
  184. 'showInstitutionalLeaversSurveyUntil',
  185. 0
  186. )
  187. const [state, unsafeDispatch] = useReducer(reducer, initialState)
  188. const dispatch = useSafeDispatch(unsafeDispatch)
  189. const { data, isLoading, isError, isSuccess, runAsync } =
  190. useAsync<UserEmailData[]>()
  191. const getEmails = useCallback(() => {
  192. runAsync(getJSON('/user/emails?ensureAffiliation=true'))
  193. .then(data => {
  194. dispatch(ActionCreators.setData(data))
  195. })
  196. .catch(() => {})
  197. }, [runAsync, dispatch])
  198. // Get emails on page load
  199. useEffect(() => {
  200. getEmails()
  201. }, [getEmails])
  202. const resetLeaversSurveyExpiration = useCallback(
  203. (deletedEmail: UserEmailData) => {
  204. if (
  205. deletedEmail.emailHasInstitutionLicence ||
  206. deletedEmail.affiliation?.pastReconfirmDate
  207. ) {
  208. const stillHasLicenseAccess = Object.values(state.data.byId).some(
  209. userEmail =>
  210. userEmail.email !== deletedEmail.email &&
  211. userEmail.emailHasInstitutionLicence
  212. )
  213. if (!stillHasLicenseAccess) {
  214. setExpirationDate(Date.now() + ONE_WEEK_IN_MS)
  215. }
  216. }
  217. },
  218. [state, setExpirationDate]
  219. )
  220. return {
  221. state,
  222. isInitializing: isLoading && !data,
  223. isInitializingSuccess: isSuccess,
  224. isInitializingError: isError,
  225. getEmails,
  226. resetLeaversSurveyExpiration,
  227. setLoading: useCallback(
  228. (flag: boolean) => dispatch(ActionCreators.setLoading(flag)),
  229. [dispatch]
  230. ),
  231. makePrimary: useCallback(
  232. (email: UserEmailData['email']) =>
  233. dispatch(ActionCreators.makePrimary(email)),
  234. [dispatch]
  235. ),
  236. deleteEmail: useCallback(
  237. (email: UserEmailData['email']) =>
  238. dispatch(ActionCreators.deleteEmail(email)),
  239. [dispatch]
  240. ),
  241. setEmailAffiliationBeingEdited: useCallback(
  242. (email: Nullable<UserEmailData['email']>) =>
  243. dispatch(ActionCreators.setEmailAffiliationBeingEdited(email)),
  244. [dispatch]
  245. ),
  246. updateAffiliation: useCallback(
  247. (
  248. email: UserEmailData['email'],
  249. role: Affiliation['role'],
  250. department: Affiliation['department']
  251. ) => dispatch(ActionCreators.updateAffiliation(email, role, department)),
  252. [dispatch]
  253. ),
  254. }
  255. }
  256. const UserEmailsContext = createContext<
  257. ReturnType<typeof useUserEmails> | undefined
  258. >(undefined)
  259. UserEmailsContext.displayName = 'UserEmailsContext'
  260. type UserEmailsProviderProps = {
  261. children: React.ReactNode
  262. } & Record<string, unknown>
  263. function UserEmailsProvider(props: UserEmailsProviderProps) {
  264. const value = useUserEmails()
  265. return <UserEmailsContext.Provider value={value} {...props} />
  266. }
  267. const useUserEmailsContext = () => {
  268. const context = useContext(UserEmailsContext)
  269. if (context === undefined) {
  270. throw new Error('useUserEmailsContext must be used in a UserEmailsProvider')
  271. }
  272. return context
  273. }
  274. export { UserEmailsProvider, useUserEmailsContext }