Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/app/redux/store.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -31,6 +32,8 @@ export const createAppStore = (options?: ConfigureStoreOptions['preloadedState']

export const store = createAppStore();

setupListeners(store.dispatch);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job finding this, looks like this enables rtk-query's focus and reconnect tracking.


export type RootState = ReturnType<typeof store.getState>;

export type AppDispatch = typeof store.dispatch;
7 changes: 7 additions & 0 deletions src/common/api/notificationApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down
159 changes: 118 additions & 41 deletions src/common/hooks/useInfiniteLoading.ts
Original file line number Diff line number Diff line change
@@ -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 = <T, ResultType extends PaginatedResult<T>>(
initialUrl: string,
export interface WithIdentifier {
id?: number | string;
}

interface State<T> {
items: T[];
nextItemUrl: string | null;
count: number;
isGettingMore: boolean;
}

const initialState = {
items: [],
nextItemUrl: null,
count: 0,
isGettingMore: false,
};

type Action<T> =
| { 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 = <T extends WithIdentifier>(state: State<T>, action: Action<T>) => {
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 = <T extends WithIdentifier, ResultType extends PaginatedResult<T>>(
initialUrl: string | null,
useQuery: UseQuery<ResultType>,
resetApiStateFunction?: ActionCreatorWithoutPayload,
options?: UseQueryOptions,
) => {
const [url, setUrl] = useState<string | null>(initialUrl);
const [loadedData, setLoadedData] = useState<T[]>([]);
const rerenderingType = useRef<string>('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;
};
19 changes: 11 additions & 8 deletions src/features/farm-dashboard/pages/UpdateFarmView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<HistoricalRecord<User>, PaginatedResult<HistoricalRecord<User>>>(url, useGetFarmHistoryQuery, {
skip: user?.role !== 'ADMIN',
});
getMore,
} = useInfiniteLoading<HistoricalRecord<User> & WithIdentifier, PaginatedResult<HistoricalRecord<User>>>(
url,
useGetFarmHistoryQuery,
undefined,
{ skip: user?.role !== 'ADMIN' },
);

const [formValidationErrors, setFormValidationErrors] = useState<ServerValidationErrors<FormData> | null>(null);

Expand All @@ -65,7 +68,7 @@ export const UpdateFarmView: FC = () => {
className='action-shadow'
loading={isFetchingHistory}
variant='primary'
onClick={() => fetchMore()}
onClick={() => getMore()}
>
Load More
</LoadingButton>
Expand Down
33 changes: 3 additions & 30 deletions src/features/network-detector/components/NetworkDetector.tsx
Original file line number Diff line number Diff line change
@@ -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<PropsWithChildren<unknown>> = ({ 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}</>;
};
35 changes: 35 additions & 0 deletions src/features/network-detector/hooks/useNetworkConnection.ts
Original file line number Diff line number Diff line change
@@ -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 = () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love how you extracted this to it's own hook.

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 };
};
16 changes: 11 additions & 5 deletions src/features/notifications/components/NotificationDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -72,10 +73,15 @@ export const NotificationDropdown: FC = () => {
count: unreadNotificationsCount,
clear: clearUnreadNotifications,
} = useContext(NotificationContext);
const { loadedData: readNotifications, isLoading: isLoadingReadNotifications } = useInfiniteLoading<
AppNotification,
PaginatedResult<AppNotification>
>('', useGetReadNotificationsQuery);
const {
items: readNotifications,
isLoading: isLoadingReadNotifications,
hasMore: hasMoreReadNotifications,
} = useInfiniteLoading<AppNotification & WithIdentifier, PaginatedResult<AppNotification>>(
'',
useGetReadNotificationsQuery,
notificationApi.util.resetApiState,
);
const [markAllRead, { isLoading: isLoadingMarkAllRead }] = useMarkAllReadMutation();

const handleMarkAllRead = async () => {
Expand Down
Loading