diff --git a/.eslintrc.json b/.eslintrc.json index 53067ba4..69b301ef 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -61,7 +61,12 @@ } ], "no-plusplus": ["warn", { "allowForLoopAfterthoughts": true }], - "prettier/prettier": "warn", + "prettier/prettier": [ + "warn", + { + "endOfLine": "auto" + } + ], "react/require-default-props": "off", "no-await-in-loop": "off", "camelcase": "off", diff --git a/package.json b/package.json index d41a197d..283323e0 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@sentry/react": "^6.12.0", "@sentry/tracing": "^6.12.0", + "@types/lodash": "^4.14.192", "@types/react-map-gl": "^6.1.3", "axios": "^0.21.4", "cheerio": "^1.0.0-rc.3", @@ -23,6 +24,7 @@ "html-entities": "^2.3.3", "immer": "^9.0.6", "js-cookie": "^3.0.1", + "lodash": "^4.17.21", "mapbox-gl": "^2.4.1", "node-sass": "^6.0.1", "normalize.css": "^8.0.1", diff --git a/src/components/AppDataLoader/index.tsx b/src/components/AppDataLoader/index.tsx index 302d9ec1..5651a4ef 100644 --- a/src/components/AppDataLoader/index.tsx +++ b/src/components/AppDataLoader/index.tsx @@ -2,9 +2,11 @@ import produce, { Immutable, Draft, original, castDraft } from 'immer'; import React, { useCallback, useMemo } from 'react'; import { - ScheduleContextValue, TermsContext, ScheduleContext, + ScheduleContextValue, + FriendContext, + FriendContextValue, } from '../../contexts'; import { AccountContext, AccountContextValue } from '../../contexts/account'; import { Oscar } from '../../data/beans'; @@ -15,6 +17,9 @@ import { TermScheduleData, ScheduleVersion, ScheduleData, + FriendTermData, + FriendInfo, + FriendScheduleData, } from '../../data/types'; import { lexicographicCompare } from '../../utils/misc'; import { @@ -22,6 +27,7 @@ import { StageLoadTerms, StageEnsureValidTerm, StageLoadAccount, + StageLoadRawFriendData, StageLoadRawScheduleDataHybrid, StageMigrateScheduleData, StageCreateScheduleDataProducer, @@ -29,7 +35,12 @@ import { StageLoadOscarData, StageExtractScheduleVersion, StageSkeletonProps, + StageCreateFriendDataProducer, + StageExtractFriendTermData, + StageLoadRawFriendScheduleDataFromFirebaseFunction, + StageExtractFriendInfo, } from './stages'; +import { softError, ErrorWithFields } from '../../log'; export type DataLoaderProps = { children: React.ReactNode; @@ -99,48 +110,70 @@ export default function DataLoader({ termScheduleData, updateTermScheduleData, }): React.ReactElement => ( - - {({ oscar }): React.ReactElement => ( - ( + - {({ - currentVersion, - scheduleVersion, - updateScheduleVersion, - }): React.ReactElement => ( - ( + - {children} - + {({ + currentVersion, + scheduleVersion, + updateScheduleVersion, + }): React.ReactElement => ( + + {children} + + )} + )} - + )} - + )} )} @@ -203,6 +236,87 @@ function GroupLoadScheduleData({ ); } +type GroupLoadFriendScheduleDataProps = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + currentTerm: string; + children: (props: { + friendScheduleData: Immutable; + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + }) => React.ReactNode; +}; + +function GroupLoadFriendScheduleData({ + skeletonProps, + accountState, + currentTerm, + children, +}: GroupLoadFriendScheduleDataProps): React.ReactElement { + return ( + + {({ rawFriendData, setFriendData }): React.ReactElement => ( + + {({ updateFriendData }): React.ReactElement => ( + + {({ + termFriendData, + updateFriendTermData, + }): React.ReactElement => ( + + {({ rawFriendScheduleData }): React.ReactElement => ( + + {({ + friendScheduleData, + updateFriendInfo, + }): React.ReactElement => ( + <> + {children({ + friendScheduleData, + updateFriendTermData, + updateFriendInfo, + })} + + )} + + )} + + )} + + )} + + )} + + ); +} + type ContextProviderProps = { terms: string[]; currentTerm: string; @@ -223,6 +337,15 @@ type ContextProviderProps = { ) => void | Immutable ) => void; accountState: AccountContextValue; + friendScheduleData: Immutable; + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; children: React.ReactNode; }; @@ -244,6 +367,9 @@ function ContextProvider({ termScheduleData, updateTermScheduleData, accountState, + friendScheduleData, + updateFriendTermData, + updateFriendInfo, children, }: ContextProviderProps): React.ReactElement { // Create a `updateSchedule` function @@ -293,6 +419,38 @@ function ContextProvider({ currentVersion, }); + // Create a rename friend function. + const renameFriend = useCallback( + (id: string, newName: string): void => { + updateFriendInfo((draft) => { + const existingDraft = draft[id]; + if (existingDraft === undefined) { + softError( + new ErrorWithFields({ + message: + "renameFriend called with current friend id that doesn't exist; ignoring", + fields: { + allFriendNames: Object.entries(draft).map( + ([friendId, { name }]) => ({ + id: friendId, + name, + }) + ), + id, + friendCount: Object.keys(draft).length, + newName, + }, + }) + ); + return; + } + + existingDraft.name = newName; + }); + }, + [updateFriendInfo] + ); + // Memoize the context values so that they are stable const scheduleContextValue = useMemo( () => [ @@ -331,11 +489,27 @@ function ContextProvider({ ] ); + const friendContextValue = useMemo( + () => [ + { + friends: friendScheduleData, + }, + { + renameFriend, + updateFriendTermData, + updateFriendInfo, + }, + ], + [friendScheduleData, renameFriend, updateFriendTermData, updateFriendInfo] + ); + return ( - {children} + + {children} + diff --git a/src/components/AppDataLoader/stages.tsx b/src/components/AppDataLoader/stages.tsx index 9fdd11c9..9002efd1 100644 --- a/src/components/AppDataLoader/stages.tsx +++ b/src/components/AppDataLoader/stages.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Immutable, Draft, castDraft } from 'immer'; +import React, { useMemo } from 'react'; +import { Immutable, Draft, castDraft, castImmutable } from 'immer'; import { Oscar } from '../../data/beans'; import useDownloadOscarData from '../../data/hooks/useDownloadOscarData'; @@ -9,9 +9,16 @@ import LoadingDisplay from '../LoadingDisplay'; import { SkeletonContent, AppSkeleton, AppSkeletonProps } from '../App/content'; import { AnyScheduleData, + defaultFriendData, + FriendData, + FriendTermData, + FriendIds, + FriendInfo, ScheduleData, ScheduleVersion, TermScheduleData, + RawFriendScheduleData, + FriendScheduleData, } from '../../data/types'; import useRawScheduleDataFromStorage from '../../data/hooks/useRawScheduleDataFromStorage'; import useExtractSchedule from '../../data/hooks/useExtractScheduleVersion'; @@ -23,6 +30,11 @@ import useUIStateFromStorage from '../../data/hooks/useUIStateFromStorage'; import { AccountContextValue, SignedIn } from '../../contexts/account'; import useFirebaseAuth from '../../data/hooks/useFirebaseAuth'; import useRawScheduleDataFromFirebase from '../../data/hooks/useRawScheduleDataFromFirebase'; +import useRawFriendDataFromFirebase from '../../data/hooks/useRawFriendDataFromFirebase'; +import useFriendDataProducer from '../../data/hooks/useFriendDataProducer'; +import useExtractFriendTermData from '../../data/hooks/useExtractFriendTermData'; +import useRawFriendScheduleDataFromFirebaseFunction from '../../data/hooks/useRawFriendScheduleDataFromFirebaseFunction'; +import useExtractFriendInfo from '../../data/hooks/useExtractFriendInfo'; // Each of the components in this file is a "stage" -- // a component that takes in a render function for its `children` prop @@ -521,3 +533,245 @@ export function StageExtractScheduleVersion({ return <>{children({ ...loadingState.result })}; } + +export type StageLoadRawFriendDataProps = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + currentTerm: string; + children: (props: { + rawFriendData: Immutable; + setFriendData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; + }) => React.ReactNode; +}; + +export function StageLoadRawFriendData({ + skeletonProps, + accountState, + currentTerm, + children, +}: StageLoadRawFriendDataProps): React.ReactElement { + const friendDataSignedOut = useMemo(() => { + const friendData = castDraft({ ...defaultFriendData }); + friendData.terms[currentTerm] = { accessibleSchedules: {} }; + return castImmutable(friendData); + }, [currentTerm]); + + if (accountState.type === 'signedOut') { + return ( + <> + {children({ + rawFriendData: friendDataSignedOut, + setFriendData: () => { + /* empty */ + }, + })} + + ); + } + + return StageLoadRawFriendDataFromFirebase({ + skeletonProps, + accountState, + children, + }); +} + +export type StageLoadRawFriendDataFromFirebaseProps = { + skeletonProps?: StageSkeletonProps; + accountState: SignedIn; + children: (props: { + rawFriendData: Immutable; + setFriendData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; + }) => React.ReactNode; +}; + +export function StageLoadRawFriendDataFromFirebase({ + skeletonProps, + accountState, + children, +}: StageLoadRawFriendDataFromFirebaseProps): React.ReactElement { + const loadingState = useRawFriendDataFromFirebase(accountState); + + if (loadingState.type !== 'loaded') { + return ( + + + + + + ); + } + + return <>{children({ ...loadingState.result })}; +} + +export type StageCreateFriendDataProducerProps = { + setFriendData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; + children: (props: { + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + }) => React.ReactNode; +}; + +export function StageCreateFriendDataProducer({ + setFriendData, + children, +}: StageCreateFriendDataProducerProps): React.ReactElement { + const { updateFriendData } = useFriendDataProducer({ setFriendData }); + return <>{children({ updateFriendData })}; +} + +export type StageExtractFriendTermDataProps = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + currentTerm: string; + rawFriendData: Immutable; + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + children: (props: { + termFriendData: Immutable; + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; + }) => React.ReactNode; +}; + +export function StageExtractFriendTermData({ + skeletonProps, + accountState, + currentTerm, + rawFriendData, + updateFriendData, + children, +}: StageExtractFriendTermDataProps): React.ReactElement { + const loadingState = useExtractFriendTermData({ + currentTerm, + rawFriendData, + updateFriendData, + }); + + if (loadingState.type !== 'loaded') { + return ( + + + + + + ); + } + + return <>{children({ ...loadingState.result })}; +} + +export type StageLoadRawFriendScheduleDataFromFirebaseFunctionProps = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + currentTerm: string; + termFriendData: Immutable; + children: (props: { + rawFriendScheduleData: RawFriendScheduleData; + }) => React.ReactNode; +}; + +export function StageLoadRawFriendScheduleDataFromFirebaseFunction({ + skeletonProps, + accountState, + currentTerm, + termFriendData, + children, +}: // eslint-disable-next-line max-len +StageLoadRawFriendScheduleDataFromFirebaseFunctionProps): React.ReactElement { + const loadingState = useRawFriendScheduleDataFromFirebaseFunction({ + currentTerm, + termFriendData, + }); + + if (loadingState.type !== 'loaded') { + return ( + + + + + + ); + } + + return ( + <> + {children({ + rawFriendScheduleData: { ...loadingState.result.friendScheduleData }, + })} + + ); +} + +export type StageExtractFriendInfo = { + skeletonProps?: StageSkeletonProps; + accountState: AccountContextValue; + rawFriendScheduleData: RawFriendScheduleData; + friendInfo: Immutable; + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + children: (props: { + friendScheduleData: Immutable; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + }) => React.ReactNode; +}; + +export function StageExtractFriendInfo({ + skeletonProps, + accountState, + rawFriendScheduleData, + friendInfo, + updateFriendData, + children, +}: StageExtractFriendInfo): React.ReactElement { + const loadingState = useExtractFriendInfo({ + rawFriendScheduleData, + friendInfo, + updateFriendData, + }); + + if (loadingState.type !== 'loaded') { + return ( + + + + + + ); + } + + return ( + <> + {children({ + ...loadingState.result, + })} + + ); +} diff --git a/src/components/Calendar/index.tsx b/src/components/Calendar/index.tsx index adae1a80..99abb211 100644 --- a/src/components/Calendar/index.tsx +++ b/src/components/Calendar/index.tsx @@ -323,6 +323,7 @@ export default function Calendar({ {events && events.map((event) => ( day !== 'S' && day !== 'U')} diff --git a/src/components/TimeBlocks/index.tsx b/src/components/TimeBlocks/index.tsx index bebc2571..e684e60d 100644 --- a/src/components/TimeBlocks/index.tsx +++ b/src/components/TimeBlocks/index.tsx @@ -252,17 +252,22 @@ function MeetingDayBlock({ {includeContent && (
- {contentHeader.map((content) => { + {contentHeader.map((content, i) => { return ( - + {content.content}  ); })}
- {contentBody.map((content) => { + {contentBody.map((content, i) => { return ( - {content.content} + + {content.content} + ); })}
@@ -316,9 +321,9 @@ function DetailsPopoverContent({ return ( - {popover.map((popoverInfo) => { + {popover.map((popoverInfo, i) => { return popoverInfo.content ? ( - + diff --git a/src/contexts/friend.ts b/src/contexts/friend.ts new file mode 100644 index 00000000..389dbb9b --- /dev/null +++ b/src/contexts/friend.ts @@ -0,0 +1,52 @@ +import { Immutable, Draft } from 'immer'; +import React from 'react'; + +import { + defaultFriendScheduleData, + FriendInfo, + FriendScheduleData, + FriendTermData, +} from '../data/types'; +import { ErrorWithFields } from '../log'; + +export type FriendContextData = Immutable<{ friends: FriendScheduleData }>; +export type FriendContextSetters = { + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; + renameFriend: (id: string, newName: string) => void; +}; + +export type FriendContextValue = [FriendContextData, FriendContextSetters]; + +export const FriendContext = React.createContext([ + { + friends: defaultFriendScheduleData, + }, + { + updateFriendTermData: (): void => { + throw new ErrorWithFields({ + message: 'empty FriendContext.updateFriendTermData value being used', + }); + }, + updateFriendInfo: (): void => { + throw new ErrorWithFields({ + message: 'empty FriendContext.updateFriendInfo value being used', + }); + }, + renameFriend: (id: string, newName: string): void => { + throw new ErrorWithFields({ + message: 'empty FriendContext.renameFriend value being used', + fields: { + id, + newName, + }, + }); + }, + }, +]); diff --git a/src/contexts/index.ts b/src/contexts/index.ts index 942ff79f..7ae78b80 100644 --- a/src/contexts/index.ts +++ b/src/contexts/index.ts @@ -1,3 +1,4 @@ +export * from './friend'; export * from './theme'; export * from './terms'; export * from './schedule'; diff --git a/src/data/hooks/useExtractFriendInfo.ts b/src/data/hooks/useExtractFriendInfo.ts new file mode 100644 index 00000000..b07f2ee5 --- /dev/null +++ b/src/data/hooks/useExtractFriendInfo.ts @@ -0,0 +1,138 @@ +import produce, { Immutable, Draft, castDraft, castImmutable } from 'immer'; +import { useEffect, useCallback, useMemo } from 'react'; + +import { LoadingState } from '../../types'; +import { + FriendData, + defaultFriendInfo, + FriendInfo, + RawFriendScheduleData, + FriendScheduleData, +} from '../types'; +import { ErrorWithFields, softError } from '../../log'; + +/** + * Gets the current term friend info based on the current term. + * If the term friend info for the current term doesn't exist, + * then this hook also initializes it to an empty value. + */ +export default function useExtractFriendInfo({ + rawFriendScheduleData, + friendInfo, + updateFriendData, +}: { + rawFriendScheduleData: RawFriendScheduleData; + friendInfo: Immutable; + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; +}): LoadingState<{ + friendScheduleData: Immutable; + updateFriendInfo: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; +}> { + // Ensure that there is a valid term friend info object for the term + useEffect(() => { + if (friendInfo === undefined) { + updateFriendData((draft) => { + draft.info = castDraft({}); + }); + return; + } + + for (const friendId of Object.keys(rawFriendScheduleData)) { + const currentFriendInfo = friendInfo[friendId]; + const correctedFriendInfo = + currentFriendInfo === undefined || + currentFriendInfo.name === undefined || + currentFriendInfo.email === undefined + ? defaultFriendInfo + : currentFriendInfo; + if (correctedFriendInfo !== currentFriendInfo) { + updateFriendData((draft) => { + draft.info[friendId] = castDraft(correctedFriendInfo); + }); + return; + } + } + }, [rawFriendScheduleData, friendInfo, updateFriendData]); + + // Create a nested update callback for just the friend info. + const updateFriendInfo = useCallback( + ( + applyDraft: (draft: Draft) => void | Immutable + ): void => { + updateFriendData((draft) => { + const currentFriendInfoDraft = draft.info ?? null; + if (currentFriendInfoDraft === null) { + softError( + new ErrorWithFields({ + message: + 'updateFriendInfo called with invalid info field; ignoring', + fields: { + currentFriendInfo: null, + }, + }) + ); + return; + } + + draft.info = produce(currentFriendInfoDraft, (subDraft) => + castDraft(applyDraft(subDraft)) + ); + }); + }, + [updateFriendData] + ); + + const friendScheduleData = + useMemo | null>(() => { + const temp: FriendScheduleData = {}; + if (friendInfo === undefined) return null; + + for (const friendId of Object.keys(rawFriendScheduleData)) { + const currentFriendInfo = friendInfo[friendId]; + if ( + currentFriendInfo === undefined || + currentFriendInfo.email === undefined || + currentFriendInfo.name === undefined + ) + return null; + + const rawFriendScheduleDatum = rawFriendScheduleData[friendId]; + if ( + rawFriendScheduleDatum === undefined || + rawFriendScheduleDatum.versions === undefined + ) { + softError( + new ErrorWithFields({ + message: 'an error occurred when accessing friend schedule data', + fields: { + friendId, + }, + }) + ); + } else { + temp[friendId] = { + ...currentFriendInfo, + ...rawFriendScheduleDatum, + }; + } + } + + return castImmutable(temp); + }, [friendInfo, rawFriendScheduleData]); + + if (friendScheduleData === null) { + return { type: 'loading' }; + } + + return { + type: 'loaded', + result: { + friendScheduleData, + updateFriendInfo, + }, + }; +} diff --git a/src/data/hooks/useExtractFriendTermData.ts b/src/data/hooks/useExtractFriendTermData.ts new file mode 100644 index 00000000..087a46f0 --- /dev/null +++ b/src/data/hooks/useExtractFriendTermData.ts @@ -0,0 +1,110 @@ +import produce, { Immutable, Draft, castDraft } from 'immer'; +import { useEffect, useCallback } from 'react'; + +import { LoadingState } from '../../types'; +import { + FriendData, + defaultFriendTermData, + FriendTermData, + FriendIds, +} from '../types'; +import { ErrorWithFields, softError } from '../../log'; + +/** + * Gets the current term friend data based on the current term. + * If the term friend data for the current term doesn't exist, + * then this hook also initializes it to an empty value. + */ +export default function useExtractFriendTermData({ + currentTerm, + rawFriendData, + updateFriendData, +}: { + currentTerm: string; + rawFriendData: Immutable; + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; +}): LoadingState<{ + termFriendData: Immutable; + updateFriendTermData: ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ) => void; +}> { + // Ensure that there is a valid term friend data object for the term + useEffect(() => { + if (rawFriendData.terms === undefined) { + return updateFriendData((draft) => { + draft.terms = { [currentTerm]: castDraft(defaultFriendTermData) }; + }); + } + const currentFriendTermData = rawFriendData.terms[currentTerm]; + const correctedFriendTermData = + currentFriendTermData === undefined || + currentFriendTermData.accessibleSchedules === undefined + ? defaultFriendTermData + : currentFriendTermData; + + if (correctedFriendTermData !== currentFriendTermData) { + updateFriendData((draft) => { + draft.terms[currentTerm] = castDraft(correctedFriendTermData); + }); + } + }, [currentTerm, rawFriendData.terms, updateFriendData]); + + // Create a nested update callback for just the friend term data. + const updateFriendTermData = useCallback( + ( + applyDraft: ( + draft: Draft + ) => void | Immutable + ): void => { + updateFriendData((draft) => { + const currentFriendTermDataDraft = draft.terms[currentTerm] ?? null; + if ( + currentFriendTermDataDraft === null || + currentFriendTermDataDraft.accessibleSchedules === undefined + ) { + softError( + new ErrorWithFields({ + message: + 'updateFriendTermData called with invalid current term; ignoring', + fields: { + currentTerm, + currentFriendTermData: null, + }, + }) + ); + return; + } + + draft.terms[currentTerm] = produce( + currentFriendTermDataDraft, + (subDraft) => castDraft(applyDraft(subDraft)) + ); + }); + }, + [updateFriendData, currentTerm] + ); + + const currentFriendTermData = rawFriendData.terms + ? rawFriendData.terms[currentTerm] + : undefined; + + if ( + currentFriendTermData === undefined || + currentFriendTermData.accessibleSchedules === undefined + ) { + return { type: 'loading' }; + } + + return { + type: 'loaded', + result: { + termFriendData: currentFriendTermData.accessibleSchedules, + updateFriendTermData, + }, + }; +} diff --git a/src/data/hooks/useExtractTermScheduleData.ts b/src/data/hooks/useExtractTermScheduleData.ts index 13d492db..0864798e 100644 --- a/src/data/hooks/useExtractTermScheduleData.ts +++ b/src/data/hooks/useExtractTermScheduleData.ts @@ -73,7 +73,7 @@ export default function useExtractTermScheduleData({ 'updateTermScheduleData called on term that does not exist', fields: { currentTerm, - currentTermScheduleData, + currentTermScheduleData: currentTermScheduleDataDraft, allTermsInData: Object.keys(draft.terms), }, }) @@ -87,7 +87,7 @@ export default function useExtractTermScheduleData({ ); }); }, - [currentTerm, currentTermScheduleData, updateScheduleData] + [currentTerm, updateScheduleData] ); if (currentTermScheduleData === undefined) { diff --git a/src/data/hooks/useFriendDataProducer.ts b/src/data/hooks/useFriendDataProducer.ts new file mode 100644 index 00000000..0cf255bf --- /dev/null +++ b/src/data/hooks/useFriendDataProducer.ts @@ -0,0 +1,41 @@ +import produce, { Draft, Immutable } from 'immer'; +import { useCallback } from 'react'; + +import { FriendData } from '../types'; + +type HookResult = { + updateFriendData: ( + applyDraft: (draft: Draft) => void | Immutable + ) => void; +}; + +/** + * Constructs the Immer producer + * from the raw schedule data state setter. + * Returns a referentially stable callback function + * that can be used to update the schedule data using an immer draft: + * https://immerjs.github.io/immer/produce/ + */ +export default function useFriendDataProducer({ + setFriendData, +}: { + setFriendData: ( + next: ((current: FriendData | null) => FriendData | null) | FriendData + ) => void; +}): HookResult { + const updateFriendData = useCallback( + (applyDraft: (draft: Draft) => void): void => + // Here, we use the callback API for the setter function + // returned by `useState` so that we don't have to re-generate + // the callback when the state changes + setFriendData((current: FriendData | null) => { + // Use `produce` from Immer to combine the current state + // & caller-supplied callback that modifies the current state + // to produce the next state + return produce(current, applyDraft); + }), + [setFriendData] + ); + + return { updateFriendData }; +} diff --git a/src/data/hooks/useRawFriendDataFromFirebase.ts b/src/data/hooks/useRawFriendDataFromFirebase.ts index ee7dde91..1ccf05fe 100644 --- a/src/data/hooks/useRawFriendDataFromFirebase.ts +++ b/src/data/hooks/useRawFriendDataFromFirebase.ts @@ -12,8 +12,8 @@ import { db, isAuthEnabled, friendsCollection } from '../firebase'; import { FriendData, defaultFriendData } from '../types'; type HookResult = { - rawFriendData: Immutable | null; - setFriendScheduleData: ( + rawFriendData: Immutable; + setFriendData: ( next: ((current: FriendData | null) => FriendData | null) | FriendData ) => void; }; @@ -79,25 +79,28 @@ export default function useRawFriendDataFromFirebase( next: ((current: FriendData | null) => FriendData | null) | FriendData ): void => { let nextFriendData; - if (typeof next === 'function') { - let currentFriendData; - if (friendData.type === 'exists') { - currentFriendData = friendData.data; + setFriendData((state: FriendDataState) => { + if (typeof next === 'function') { + let currentFriendData; + if (state.type === 'exists') { + currentFriendData = state.data; + } else { + currentFriendData = null; + } + nextFriendData = next(currentFriendData); } else { - currentFriendData = null; + nextFriendData = next; } - nextFriendData = next(currentFriendData); - } else { - nextFriendData = next; - } - if (nextFriendData === null) return; - - // Eagerly set the friend data here as well. - // It would be okay to wait until Firebase updates the state for us, - // (which it will do, even before the network calls are made), - // but this allows a window where state can react based on stale state. - setFriendData({ type: 'exists', data: nextFriendData }); + if (nextFriendData === null) return state; + + // Eagerly set the friend data here as well. + // It would be okay to wait until Firebase updates the state for us, + // (which it will do, even before the network calls are made), + // but this allows a window where state can react based on stale state. + return { type: 'exists', data: nextFriendData }; + }); + if (nextFriendData === undefined || nextFriendData === null) return; friendsCollection .doc(account.id) .set(nextFriendData) @@ -113,7 +116,7 @@ export default function useRawFriendDataFromFirebase( ); }); }, - [account.id, friendData] + [account.id] ); // Perform a transaction if the type is non-existent, @@ -185,7 +188,7 @@ export default function useRawFriendDataFromFirebase( type: 'loaded', result: { rawFriendData: castImmutable(friendData.data), - setFriendScheduleData: setFriendDataPersistent, + setFriendData: setFriendDataPersistent, }, }; } diff --git a/src/data/hooks/useRawFriendScheduleDataFromFirebaseFunction.ts b/src/data/hooks/useRawFriendScheduleDataFromFirebaseFunction.ts new file mode 100644 index 00000000..68feb135 --- /dev/null +++ b/src/data/hooks/useRawFriendScheduleDataFromFirebaseFunction.ts @@ -0,0 +1,221 @@ +import axios, { AxiosPromise } from 'axios'; +import { useState, useRef } from 'react'; +import { Immutable } from 'immer'; + +import { auth } from '../firebase'; +import { ErrorWithFields, softError } from '../../log'; +import { LoadingState } from '../../types'; +import { + exponentialBackoff, + isAxiosNetworkError, + sleep, +} from '../../utils/misc'; +import Cancellable from '../../utils/cancellable'; +import { CLOUD_FUNCTION_BASE_URL } from '../../constants'; +import { FriendIds, RawFriendScheduleData } from '../types'; +import useDeepCompareEffect from '../../hooks/useDeepCompareEffect'; + +interface HookResult { + friendScheduleData: RawFriendScheduleData; + term: string; +} + +const url = `${CLOUD_FUNCTION_BASE_URL}/fetchFriendSchedules`; + +// Number of minutes between re-fetches of the friend schedules +const REFRESH_INTERVAL_MIN = 5; + +/** + * Fetches the schedules of friends that have been shared with the user + * for the given term. + * Repeatedly attempts to load in the case of errors, + * and cancels any in-flight downloads if the parent context is unmounted + * or the term is changed. + * Once loaded, this also attempts to update the data every 5 minutes + * in case the friends' schedules have been updated. + */ +export default function useRawFriendScheduleDataFromFirebaseFunction({ + currentTerm, + termFriendData, +}: { + currentTerm: string; + termFriendData: Immutable; +}): LoadingState { + const [state, setState] = useState>({ + type: 'loading', + }); + + // Keep a ref of the latest loaded schedules + // to check if it is any newer than the current one. + const loadedFriendScheduleRef = useRef(null); + + // Fetch the current term's friend schedules information + useDeepCompareEffect(() => { + if (Object.keys(termFriendData).length === 0) { + const res = { + friendScheduleData: {}, + term: currentTerm, + }; + loadedFriendScheduleRef.current = res; + return setState({ + type: 'loaded', + result: res, + }); + } + const loadOperation = new Cancellable(); + + async function loadAndRefresh(): Promise { + let isFirst = true; + while (!loadOperation.isCancelled) { + // Load the friend schedules, showing errors only if this is the + // first time it is being loaded (otherwise, just log errors + // but don't disrupt the user). This is to prevent + // a background refresh from showing an error screen + // in the middle of a session. + // `load` will return early if it is cancelled + await load({ initialLoad: isFirst }); + if (loadOperation.isCancelled) return; + + // Sleep for the refresh interval, + // exiting early if cancelled + const promise = sleep({ amount_ms: REFRESH_INTERVAL_MIN * 60 * 1000 }); + const result = await loadOperation.perform(promise); + if (result.cancelled) { + return; + } + + isFirst = false; + } + } + + async function load({ + initialLoad, + }: { + initialLoad: boolean; + }): Promise { + if (initialLoad) { + setState({ + type: 'loading', + }); + } + + let attemptNumber = 1; + while (!loadOperation.isCancelled) { + try { + const requestData = JSON.stringify({ + IDToken: await auth.currentUser?.getIdToken(), + friends: termFriendData, + term: currentTerm, + }); + /* eslint-disable max-len */ + // This request should be made with content type is application/x-www-form-urlencoded. + // This is done to prevent a pre-flight CORS request made to the firebase function. + // Refer: https://github.com/gt-scheduler/website/pull/187#issuecomment-1496439246 + /* eslint-enable max-len */ + const promise = axios({ + method: 'POST', + url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + data: `data=${requestData}`, + }) as AxiosPromise; + const result = await loadOperation.perform(promise); + if (result.cancelled) { + return; + } + + const json = result.value.data; + + // If the data is the same as the currently loaded data, + // skip loading it + if ( + loadedFriendScheduleRef.current !== null && + loadedFriendScheduleRef.current.friendScheduleData === json && + loadedFriendScheduleRef.current.term === currentTerm + ) { + // Skip this update + return; + } + + const res = { + friendScheduleData: json, + term: currentTerm, + }; + + setState({ + type: 'loaded', + result: res, + }); + loadedFriendScheduleRef.current = res; + + return; + } catch (err) { + // Report the error to Sentry if not a network error + if (!isAxiosNetworkError(err)) { + softError( + new ErrorWithFields({ + message: 'error fetching friend schedules', + source: err, + fields: { + url, + term: currentTerm, + termFriendData, + }, + }) + ); + } + + if (initialLoad) { + // Flag that an error has occurred + setState({ + type: 'error', + error: + err instanceof Error + ? err + : new ErrorWithFields({ + message: + 'an error occurred while fetching friend schedules', + source: err, + }), + stillLoading: true, + overview: String(err), + }); + } + } + + // Sleep for an exponential backoff between each retry + await exponentialBackoff(attemptNumber); + attemptNumber += 1; + } + } + + loadAndRefresh().catch((err) => { + softError( + new ErrorWithFields({ + message: 'error loading and refreshing friend schedules', + source: err, + fields: { + url, + term: currentTerm, + termFriendData, + }, + }) + ); + }); + + // Cancel the background load when this cleans up + return (): void => { + loadOperation.cancel(); + }; + }, [currentTerm, termFriendData, setState]); + + // If we are about to start a new background load + // after the term changed, then don't return the already fetched + // friend schedules + if (state.type === 'loaded' && state.result.term !== currentTerm) { + return { type: 'loading' }; + } + + return state; +} diff --git a/src/data/hooks/useRawScheduleDataFromFirebase.ts b/src/data/hooks/useRawScheduleDataFromFirebase.ts index 21838796..49c3620e 100644 --- a/src/data/hooks/useRawScheduleDataFromFirebase.ts +++ b/src/data/hooks/useRawScheduleDataFromFirebase.ts @@ -80,24 +80,28 @@ export default function useRawScheduleDataFromFirebase( | AnyScheduleData ): void => { let nextScheduleData; - if (typeof next === 'function') { - let currentScheduleData; - if (scheduleData.type === 'exists') { - currentScheduleData = scheduleData.data; + setScheduleData((state: ScheduleDataState) => { + if (typeof next === 'function') { + let currentScheduleData; + if (state.type === 'exists') { + currentScheduleData = state.data; + } else { + currentScheduleData = null; + } + nextScheduleData = next(currentScheduleData); } else { - currentScheduleData = null; + nextScheduleData = next; } - nextScheduleData = next(currentScheduleData); - } else { - nextScheduleData = next; - } - if (nextScheduleData === null) return; + if (nextScheduleData === null) return state; + + // Eagerly set the schedule data here as well. + // It would be okay to wait until Firebase updates the state for us, + // (which it will do, even before the network calls are made), + // but this allows a window where state can react based on stale state. + return { type: 'exists', data: nextScheduleData }; + }); - // Eagerly set the schedule data here as well. - // It would be okay to wait until Firebase updates the state for us, - // (which it will do, even before the network calls are made), - // but this allows a window where state can react based on stale state. - setScheduleData({ type: 'exists', data: nextScheduleData }); + if (nextScheduleData === undefined || nextScheduleData === null) return; schedulesCollection .doc(account.id) @@ -114,7 +118,7 @@ export default function useRawScheduleDataFromFirebase( ); }); }, - [account.id, scheduleData] + [account.id] ); // Perform a transaction if the type is non-existent, diff --git a/src/data/types.ts b/src/data/types.ts index d1af52b1..f6da38c1 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -1,4 +1,4 @@ -import { Immutable } from 'immer'; +import { castImmutable, Immutable } from 'immer'; import { Event } from '../types'; import { generateRandomId } from '../utils/misc'; @@ -41,6 +41,23 @@ export const defaultScheduleData: Immutable = { export const defaultFriendData: Immutable = { terms: {}, + info: {}, +}; + +export const defaultFriendInfo: Immutable<{ + name: string; + email: string; +}> = castImmutable({ + name: '', + email: '', +}); + +export const defaultFriendTermData: Immutable = { + accessibleSchedules: {}, +}; + +export const defaultFriendScheduleData: Immutable = { + // }; export const defaultTermScheduleData: Immutable = { @@ -147,10 +164,49 @@ export interface Version3Schedule { sortingOptionIndex: number; } +export type FriendIds = Record; + +export interface FriendTermData { + accessibleSchedules: FriendIds; +} + +export type FriendInfo = Record< + string, + { + name: string; + email: string; + } +>; + export interface FriendData { terms: Record; + info: FriendInfo; } -export interface FriendTermData { - accessibleSchedules: Record; -} +export type RawFriendScheduleData = Record< + string, + { + versions: Record< + string, + { + name: string; + schedule: Schedule; + } + >; + } +>; + +export type FriendScheduleData = Record< + string, + { + name: string; + email: string; + versions: Record< + string, + { + name: string; + schedule: Schedule; + } + >; + } +>; diff --git a/src/hooks/useDeepCompareEffect.ts b/src/hooks/useDeepCompareEffect.ts new file mode 100644 index 00000000..88d331ad --- /dev/null +++ b/src/hooks/useDeepCompareEffect.ts @@ -0,0 +1,32 @@ +import { useEffect, EffectCallback, DependencyList, useRef } from 'react'; +import lodash from 'lodash'; + +/** + * Inspired by https://github.com/kentcdodds/use-deep-compare-effect + * React useEffect performs a reference equality check for its non-primitive + * dependencies. + * + * If obj is an object that is a state, and useEffect has a dependency + * on obj.field, where obj.field is non-primitive, issues can arise. + * The Effect will re-run on every state change to obj even if the value of + * obj.field remains the same, since the state change changed the reference + * of obj. + * + * This hook keeps track of the previous dependency list and performs a deep + * comparison with the new dependency list. The Effect is re-run only if the + * deep comparison is false. + */ +export default function useDeepCompareEffect( + callback: EffectCallback, + dependencies: DependencyList +): void { + const ref = useRef(dependencies); + dependencies.forEach((dep, i) => { + if (!lodash.isEqual(dep, ref.current[i])) { + ref.current = dependencies; + } + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useEffect(callback, ref.current); +} diff --git a/yarn.lock b/yarn.lock index 06dc9d87..afaf2904 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2571,6 +2571,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/lodash@^4.14.192": + version "4.14.192" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.192.tgz#5790406361a2852d332d41635d927f1600811285" + integrity sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A== + "@types/long@^4.0.1": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.2.tgz#b74129719fc8d11c01868010082d483b7545591a"
{popoverInfo.name}