diff --git a/src/app/redux/store.ts b/src/app/redux/store.ts index 68e77c820..5ba6285fb 100644 --- a/src/app/redux/store.ts +++ b/src/app/redux/store.ts @@ -1,5 +1,6 @@ import { configureStore, ConfigureStoreOptions } from '@reduxjs/toolkit'; import { farmApi } from 'common/api/farmApi'; +import { setupListeners } from '@reduxjs/toolkit/query'; import { authApi } from 'common/api/authApi'; import { notificationApi } from 'common/api/notificationApi'; import { userApi } from 'common/api/userApi'; @@ -31,6 +32,8 @@ export const createAppStore = (options?: ConfigureStoreOptions['preloadedState'] export const store = createAppStore(); +setupListeners(store.dispatch); + export type RootState = ReturnType; export type AppDispatch = typeof store.dispatch; diff --git a/src/common/api/notificationApi.ts b/src/common/api/notificationApi.ts index 86fce5e21..6021733db 100644 --- a/src/common/api/notificationApi.ts +++ b/src/common/api/notificationApi.ts @@ -5,7 +5,14 @@ import { customBaseQuery } from './customBaseQuery'; export const notificationApi = createApi({ reducerPath: 'notificationApi', + baseQuery: customBaseQuery, + + // Always refetch data, don't used cache. + keepUnusedDataFor: 0, + refetchOnMountOrArgChange: true, + refetchOnReconnect: true, + tagTypes: ['AppNotification'], endpoints: builder => ({ diff --git a/src/common/hooks/useInfiniteLoading.ts b/src/common/hooks/useInfiniteLoading.ts index 6549fa77a..458312227 100644 --- a/src/common/hooks/useInfiniteLoading.ts +++ b/src/common/hooks/useInfiniteLoading.ts @@ -1,58 +1,135 @@ +import { ActionCreatorWithoutPayload } from '@reduxjs/toolkit'; import { PaginatedResult } from 'common/models'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useReducer } from 'react'; +import { useDispatch } from 'react-redux'; import { UseQuery, UseQueryOptions } from 'rtk-query-config'; -export const useInfiniteLoading = >( - initialUrl: string, +export interface WithIdentifier { + id?: number | string; +} + +interface State { + items: T[]; + nextItemUrl: string | null; + count: number; + isGettingMore: boolean; +} + +const initialState = { + items: [], + nextItemUrl: null, + count: 0, + isGettingMore: false, +}; + +type Action = + | { type: 'addOneToFront'; item: T } + | { type: 'addMultipleToBack'; items: T[]; totalCount: number } + | { type: 'set-next-item-url'; nextItemUrl: string | null } + | { type: 'reset-get-more' } + | { type: 'remove'; item: T } + | { type: 'reset'; nextItemUrl: string | null }; + +const reducer = (state: State, action: Action) => { + switch (action.type) { + case 'addOneToFront': + return { ...state, items: [action.item, ...state.items], count: state.count + 1 }; + case 'addMultipleToBack': + return { ...state, items: [...state.items, ...action.items], count: action.totalCount }; + case 'remove': + return { + ...state, + items: state.items.filter(i => i.id !== action.item.id), + count: state.count - 1, + }; + case 'reset': + return { ...initialState, nextItemUrl: action.nextItemUrl }; + case 'set-next-item-url': + return { ...state, nextItemUrl: action.nextItemUrl, isGettingMore: true, allItemsRemoved: false }; + default: + return { ...initialState }; + } +}; + +export const useInfiniteLoading = >( + initialUrl: string | null, useQuery: UseQuery, + resetApiStateFunction?: ActionCreatorWithoutPayload, options?: UseQueryOptions, ) => { - const [url, setUrl] = useState(initialUrl); - const [loadedData, setLoadedData] = useState([]); - const rerenderingType = useRef('clear'); + const [{ items, nextItemUrl, count, isGettingMore }, itemDispatch] = useReducer(reducer, { + ...initialState, + nextItemUrl: initialUrl, + }); + const dispatch = useDispatch(); - const { data, error, isLoading, isFetching } = useQuery(url, options); + const { data: fetchedItems, isFetching, isLoading, refetch, error } = useQuery(nextItemUrl, options); - useEffect(() => { - const clear = () => { - rerenderingType.current = 'clear'; - setLoadedData([]); - setUrl(initialUrl); - }; + const addOneToFront = useCallback( + (newItem: T) => { + itemDispatch({ type: 'addOneToFront', item: newItem }); + }, + [itemDispatch], + ); - if (data && !isLoading) { - setLoadedData(n => [...n, ...data.results]); + const clear = useCallback(() => { + itemDispatch({ type: 'reset', nextItemUrl: initialUrl }); + if (resetApiStateFunction) { + dispatch(resetApiStateFunction()); } + }, [itemDispatch, initialUrl, dispatch, resetApiStateFunction]); - return () => { - if (rerenderingType.current === 'clear') { - clear(); - } - if (rerenderingType.current === 'fetchMore') { - rerenderingType.current = 'clear'; - } - }; - }, [data, isLoading, initialUrl]); + const remove = useCallback( + (itemToRemove: T) => { + itemDispatch({ type: 'remove', item: itemToRemove }); + }, + [itemDispatch], + ); const hasMore = useMemo(() => { if (isLoading || isFetching) return false; - return !!data?.links.next; - }, [data, isLoading, isFetching]); + return !!fetchedItems?.links.next; + }, [fetchedItems, isLoading, isFetching]); - const fetchMore = () => { - if (hasMore && data) { - rerenderingType.current = 'fetchMore'; - setUrl(data.links.next); + const getMore = useCallback(() => { + if (fetchedItems?.links.next && !isFetching) { + itemDispatch({ type: 'set-next-item-url', nextItemUrl: fetchedItems.links.next }); } - }; - - return { - loadedData, - error, - isLoading, - isFetching, - totalCount: data?.meta.count, - hasMore, - fetchMore, - }; + }, [itemDispatch, isFetching, fetchedItems]); + + // Clear the items when the user's internet connection is restored + useEffect(() => { + if (!isLoading && isFetching && !isGettingMore) { + clear(); + } + }, [isLoading, isFetching, isGettingMore, clear]); + + // Append new items that we got from the API to + // the items list + useEffect(() => { + itemDispatch({ + type: 'addMultipleToBack', + items: fetchedItems?.results || [], + totalCount: fetchedItems?.meta.count || 0, + }); + }, [fetchedItems]); + + const itemProviderValue = useMemo(() => { + const result = { + items: items as T[], + count, + hasMore, + isFetching, + isLoading, + remove, + clear, + getMore, + refetch, + addOneToFront, + error, + }; + return result; + }, [clear, remove, getMore, hasMore, items, count, isFetching, isLoading, addOneToFront, refetch, error]); + + return itemProviderValue; }; diff --git a/src/features/farm-dashboard/pages/UpdateFarmView.tsx b/src/features/farm-dashboard/pages/UpdateFarmView.tsx index 246bbb697..e7a51b6b6 100644 --- a/src/features/farm-dashboard/pages/UpdateFarmView.tsx +++ b/src/features/farm-dashboard/pages/UpdateFarmView.tsx @@ -12,13 +12,13 @@ import { Link, useNavigate, useParams } from 'react-router-dom'; import { FarmDetailForm, FormData } from '../components/FarmDetailForm'; import { useAuth } from 'features/auth/hooks'; import { ChangeLog } from 'common/components/ChangeLog/ChangeLog'; -import { useInfiniteLoading } from 'common/hooks/useInfiniteLoading'; import { QueryParamsBuilder } from 'common/api/queryParamsBuilder'; import { HistoricalRecord } from 'common/models/historicalRecord'; import { ChangeListGroup } from 'common/components/ChangeLog/ChangeListGroup'; import { LoadingButton } from 'common/components/LoadingButton'; import { useModal } from 'react-modal-hook'; import { DimmableContent } from 'common/styles/utilities'; +import { WithIdentifier, useInfiniteLoading } from 'common/hooks/useInfiniteLoading'; export type RouteParams = { id: string; @@ -35,15 +35,18 @@ export const UpdateFarmView: FC = () => { const queryParams = new QueryParamsBuilder().setPaginationParams(1, pageSize).build(); const url = `/farms/${id}/history/?${queryParams}`; const { - loadedData: farmHistory, + items: farmHistory, error: farmHistoryError, isFetching: isFetchingHistory, - totalCount, + count: totalCount, hasMore, - fetchMore, - } = useInfiniteLoading, PaginatedResult>>(url, useGetFarmHistoryQuery, { - skip: user?.role !== 'ADMIN', - }); + getMore, + } = useInfiniteLoading & WithIdentifier, PaginatedResult>>( + url, + useGetFarmHistoryQuery, + undefined, + { skip: user?.role !== 'ADMIN' }, + ); const [formValidationErrors, setFormValidationErrors] = useState | null>(null); @@ -65,7 +68,7 @@ export const UpdateFarmView: FC = () => { className='action-shadow' loading={isFetchingHistory} variant='primary' - onClick={() => fetchMore()} + onClick={() => getMore()} > Load More diff --git a/src/features/network-detector/components/NetworkDetector.tsx b/src/features/network-detector/components/NetworkDetector.tsx index d97fd1318..b3cd4fdd4 100644 --- a/src/features/network-detector/components/NetworkDetector.tsx +++ b/src/features/network-detector/components/NetworkDetector.tsx @@ -1,35 +1,8 @@ -import { FC, PropsWithChildren, useEffect, useRef, useState } from 'react'; -import * as notificationService from 'common/services/notification'; +import { FC, PropsWithChildren } from 'react'; +import { useNetworkDetection } from '../hooks/useNetworkConnection'; export const NetworkDetector: FC> = ({ children }) => { - const [isDisconnected, setDisconnectedStatus] = useState(false); - const prevDisconnectionStatus = useRef(false); - - const handleConnectionChange = () => { - setDisconnectedStatus(!navigator.onLine); - }; - - const getRandomNumber = () => { - return new Date().valueOf().toString(); - }; - - useEffect(() => { - window.addEventListener('online', handleConnectionChange); - window.addEventListener('offline', handleConnectionChange); - - if (isDisconnected) { - notificationService.showErrorMessage('Internet Connection Lost', getRandomNumber()); - } else if (prevDisconnectionStatus.current) { - notificationService.showSuccessMessage('Internet Connection Restored', getRandomNumber()); - } - - prevDisconnectionStatus.current = isDisconnected; - - return () => { - window.removeEventListener('online', handleConnectionChange); - window.removeEventListener('offline', handleConnectionChange); - }; - }, [isDisconnected]); + useNetworkDetection(); return <>{children}; }; diff --git a/src/features/network-detector/hooks/useNetworkConnection.ts b/src/features/network-detector/hooks/useNetworkConnection.ts new file mode 100644 index 000000000..899faffd2 --- /dev/null +++ b/src/features/network-detector/hooks/useNetworkConnection.ts @@ -0,0 +1,35 @@ +import { useState, useRef, useEffect } from 'react'; +import * as notificationService from 'common/services/notification'; + +export const getRandomNumber = () => { + return new Date().valueOf().toString(); +}; + +export const useNetworkDetection = () => { + const [isDisconnected, setDisconnectedStatus] = useState(false); + const prevDisconnectionStatus = useRef(false); + + const handleConnectionChange = () => { + setDisconnectedStatus(!navigator.onLine); + }; + + useEffect(() => { + window.addEventListener('online', handleConnectionChange); + window.addEventListener('offline', handleConnectionChange); + + if (isDisconnected) { + notificationService.showErrorMessage('Internet Connection Lost', getRandomNumber()); + } else if (prevDisconnectionStatus.current) { + notificationService.showSuccessMessage('Internet Connection Restored', getRandomNumber()); + } + + prevDisconnectionStatus.current = isDisconnected; + + return () => { + window.removeEventListener('online', handleConnectionChange); + window.removeEventListener('offline', handleConnectionChange); + }; + }, [isDisconnected]); + + return { isDisconnected }; +}; diff --git a/src/features/notifications/components/NotificationDropdown.tsx b/src/features/notifications/components/NotificationDropdown.tsx index 598463e58..21e9c46a9 100644 --- a/src/features/notifications/components/NotificationDropdown.tsx +++ b/src/features/notifications/components/NotificationDropdown.tsx @@ -2,7 +2,7 @@ import { faBell, faEnvelope, faEnvelopeOpen } from '@fortawesome/free-solid-svg- import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useGetReadNotificationsQuery, useMarkAllReadMutation } from 'common/api/notificationApi'; import { LoadingButton } from 'common/components/LoadingButton'; -import { useInfiniteLoading } from 'common/hooks/useInfiniteLoading'; +import { WithIdentifier, useInfiniteLoading } from 'common/hooks/useInfiniteLoading'; import { PaginatedResult } from 'common/models'; import { AppNotification } from 'common/models/notifications'; import { NoContent } from 'common/styles/utilities'; @@ -12,6 +12,7 @@ import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { NotificationContext } from '../context'; import { renderNotification } from './renderNotification'; +import { notificationApi } from 'common/api/notificationApi'; const StyledContainer = styled.div` min-width: 420px; @@ -72,10 +73,15 @@ export const NotificationDropdown: FC = () => { count: unreadNotificationsCount, clear: clearUnreadNotifications, } = useContext(NotificationContext); - const { loadedData: readNotifications, isLoading: isLoadingReadNotifications } = useInfiniteLoading< - AppNotification, - PaginatedResult - >('', useGetReadNotificationsQuery); + const { + items: readNotifications, + isLoading: isLoadingReadNotifications, + hasMore: hasMoreReadNotifications, + } = useInfiniteLoading>( + '', + useGetReadNotificationsQuery, + notificationApi.util.resetApiState, + ); const [markAllRead, { isLoading: isLoadingMarkAllRead }] = useMarkAllReadMutation(); const handleMarkAllRead = async () => { diff --git a/src/features/notifications/components/ReadNotifications.tsx b/src/features/notifications/components/ReadNotifications.tsx index 936b8e4f9..e1934fc48 100644 --- a/src/features/notifications/components/ReadNotifications.tsx +++ b/src/features/notifications/components/ReadNotifications.tsx @@ -1,5 +1,5 @@ import { faBell } from '@fortawesome/free-solid-svg-icons'; -import { useGetReadNotificationsQuery } from 'common/api/notificationApi'; +import { notificationApi, useGetReadNotificationsQuery } from 'common/api/notificationApi'; import { useInfiniteLoading } from 'common/hooks/useInfiniteLoading'; import { PaginatedResult } from 'common/models'; import { AppNotification } from 'common/models/notifications'; @@ -10,12 +10,16 @@ import { renderNotification } from './renderNotification'; export const ReadNotifications: FC = () => { const { - loadedData: notifications, + items: notifications, isLoading, isFetching, hasMore, - fetchMore, - } = useInfiniteLoading>('', useGetReadNotificationsQuery); + getMore, + } = useInfiniteLoading>( + '', + useGetReadNotificationsQuery, + notificationApi.util.resetApiState, + ); return ( <> @@ -34,7 +38,7 @@ export const ReadNotifications: FC = () => { {hasMore && (
-
diff --git a/src/features/notifications/hooks/useLiveNotifications.tsx b/src/features/notifications/hooks/useLiveNotifications.tsx index 0dc28eeed..1b26f42ee 100644 --- a/src/features/notifications/hooks/useLiveNotifications.tsx +++ b/src/features/notifications/hooks/useLiveNotifications.tsx @@ -1,77 +1,22 @@ import { notificationApi, useGetEventTokenQuery, useGetUnreadNotificationsQuery } from 'common/api/notificationApi'; +import { useInfiniteLoading } from 'common/hooks/useInfiniteLoading'; +import { PaginatedResult } from 'common/models'; import { AppNotification } from 'common/models/notifications'; import { environment } from 'environment'; import { useAuth } from 'features/auth/hooks'; import * as NotificationComponents from 'features/notifications/components'; -import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import { toast } from 'react-toastify'; -type State = { - notifications: AppNotification[]; - oldNotifications: AppNotification[]; - nextNotificationUrl: string | null; - count: number; -}; - -const initialState: State = { - notifications: [], - oldNotifications: [], - nextNotificationUrl: null, - count: 0, -}; - -type Action = - | { type: 'add'; notification: AppNotification } - | { type: 'add-old'; notifications: AppNotification[]; count: number } - | { type: 'set-next-notification-url'; nextNotificationUrl: string | null } - | { type: 'remove'; notification: AppNotification } - | { type: 'reset' }; - -const reducer = (state: State, action: Action) => { - switch (action.type) { - case 'add': - return { ...state, notifications: [action.notification, ...state.notifications] }; - case 'add-old': - return { ...state, oldNotifications: [...state.oldNotifications, ...action.notifications], count: action.count }; - case 'remove': { - const notifications = state.notifications.filter(n => n.id !== action.notification.id); - const oldNotifications = state.oldNotifications.filter(n => n.id !== action.notification.id); - let numRemoved = state.notifications.length - notifications.length; - numRemoved += state.oldNotifications.length - oldNotifications.length; - - return { - ...state, - notifications, - oldNotifications, - count: state.count - numRemoved, - }; - } - case 'reset': - return { ...initialState }; - case 'set-next-notification-url': - return { ...state, nextNotificationUrl: action.nextNotificationUrl }; - default: - return { ...initialState }; - } -}; - export const useLiveNotifications = () => { const { user } = useAuth(); const dispatch = useDispatch(); - const [{ notifications, oldNotifications, nextNotificationUrl, count }, notificationDispatch] = useReducer( - reducer, - initialState, - ); - const { - data: unreadNotifications, - isFetching, - isLoading, - refetch, - } = useGetUnreadNotificationsQuery(nextNotificationUrl, { - skip: !user, - }); + const { items, count, hasMore, getMore, isFetching, isLoading, refetch, clear, addOneToFront, remove } = useInfiniteLoading< + AppNotification, + PaginatedResult + >(null, useGetUnreadNotificationsQuery, notificationApi.util.resetApiState, { skip: !user }); const [enablePolling, setEnablePolling] = useState(true); const { data: eventToken } = useGetEventTokenQuery(undefined, { @@ -79,25 +24,14 @@ export const useLiveNotifications = () => { pollingInterval: enablePolling ? 3000 : 0, }); - // Append new notifications that we got from the API to - // oldNotifications list - useEffect(() => { - notificationDispatch({ - type: 'add-old', - notifications: unreadNotifications?.results || [], - count: unreadNotifications?.meta.count || 0, - }); - }, [unreadNotifications]); - useEffect(() => { if (user) { refetch(); } else { // User no longer logged in. Clean up state. - dispatch(notificationApi.util.resetApiState()); - notificationDispatch({ type: 'reset' }); + clear(); } - }, [user, refetch, notificationDispatch, dispatch]); + }, [user, refetch, clear, dispatch]); // Set up receiving SSE events from the server. useEffect(() => { @@ -108,7 +42,7 @@ export const useLiveNotifications = () => { eventSource.onmessage = message => { const payload = JSON.parse(message.data); - notificationDispatch({ type: 'add', notification: payload }); + addOneToFront(payload); // Grab the component for the notification type // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -137,31 +71,13 @@ export const useLiveNotifications = () => { return () => { eventSource?.close(); }; - }, [user, eventToken, setEnablePolling]); - - const clear = useCallback(() => { - notificationDispatch({ type: 'reset' }); - dispatch(notificationApi.util.resetApiState()); - }, [notificationDispatch, dispatch]); - - const remove = useCallback( - (notification: AppNotification) => { - notificationDispatch({ type: 'remove', notification }); - }, - [notificationDispatch], - ); - - const getMore = useCallback(() => { - if (unreadNotifications?.links.next && !isFetching) { - notificationDispatch({ type: 'set-next-notification-url', nextNotificationUrl: unreadNotifications.links.next }); - } - }, [notificationDispatch, isFetching, unreadNotifications]); + }, [user, eventToken, setEnablePolling, addOneToFront]); const notificationProviderValue = useMemo(() => { const result = { - notifications: [...notifications, ...oldNotifications], - count: notifications.length + count, // I'm not so sure this is right. - hasMore: !!unreadNotifications?.links.next, + notifications: items, + count, + hasMore, isFetching, isLoading, remove, @@ -169,7 +85,7 @@ export const useLiveNotifications = () => { getMore, }; return result; - }, [clear, remove, getMore, notifications, unreadNotifications, count, oldNotifications, isFetching, isLoading]); + }, [clear, remove, getMore, hasMore, items, count, isFetching, isLoading]); return notificationProviderValue; }; diff --git a/src/features/user-dashboard/pages/UpdateUserView.tsx b/src/features/user-dashboard/pages/UpdateUserView.tsx index c88060cde..52e45d958 100644 --- a/src/features/user-dashboard/pages/UpdateUserView.tsx +++ b/src/features/user-dashboard/pages/UpdateUserView.tsx @@ -15,7 +15,7 @@ import { Trans } from 'react-i18next'; import { ChangeLog } from 'common/components/ChangeLog/ChangeLog'; import { useAuth } from 'features/auth/hooks'; import { LoadingButton } from 'common/components/LoadingButton'; -import { useInfiniteLoading } from 'common/hooks/useInfiniteLoading'; +import { useInfiniteLoading, WithIdentifier } from 'common/hooks/useInfiniteLoading'; import { HistoricalRecord } from 'common/models/historicalRecord'; import { QueryParamsBuilder } from 'common/api/queryParamsBuilder'; import { ChangeListGroup } from 'common/components/ChangeLog/ChangeListGroup'; @@ -38,15 +38,20 @@ export const UpdateUserView: FC = () => { const queryParams = new QueryParamsBuilder().setPaginationParams(1, pageSize).build(); const url = `/users/${id}/history/?${queryParams}`; const { - loadedData: userHistory, + items: userHistory, error: userHistoryError, isFetching: isFetchingHistory, - totalCount, + count: totalCount, hasMore, - fetchMore, - } = useInfiniteLoading, PaginatedResult>>(url, useGetUserHistoryQuery, { - skip: loggedInUser?.role !== 'ADMIN', - }); + getMore, + } = useInfiniteLoading & WithIdentifier, PaginatedResult>>( + url, + useGetUserHistoryQuery, + undefined, + { + skip: loggedInUser?.role !== 'ADMIN', + }, + ); const roles = Object.values(Role); @@ -71,7 +76,7 @@ export const UpdateUserView: FC = () => { className='action-shadow' loading={isFetchingHistory} variant='primary' - onClick={() => fetchMore()} + onClick={() => getMore()} > Load More