user-email-context.tsx 7.7 KB

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