user-email-context.tsx 6.8 KB

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