Skip to content
Open
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
205 changes: 86 additions & 119 deletions src/hooks/useInfiniteScroller.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,6 @@
import {
type ApiEndpointQuery,
type BaseQueryFn,
type EndpointDefinitions,
type FetchArgs,
type FetchBaseQueryError,
type QueryDefinition,
QueryStatus,
skipToken,
} from '@reduxjs/toolkit/query'
import {useEffect, useState} from 'react'
import type {ApiSlug} from '@/environment'
import type {Paginated, PaginationQueryArgs} from '@/types/api'
import type {SerializedError} from '@reduxjs/toolkit'
import {useSelector} from '@/hooks/redux/useSelector'
import {baseApi} from '@/services/baseApi'
import {skipToken} from '@reduxjs/toolkit/query'
import {useEffect} from 'react'
import type {ApiEndpointInfinite, PaginationQueryArgs} from '@/types/api'

const getEmptyItems = <DummyItem>(
length: number,
Expand All @@ -22,11 +9,12 @@ const getEmptyItems = <DummyItem>(
keyName: keyof DummyItem,
) =>
length > 0
? Array<DummyItem>(length)
? new Array<DummyItem>(Math.max(0, length))
.fill(defaultEmptyItem)
.map((el, index) => ({
...el,
[keyName]: `dummy-${index + baseIndex}`,
dummy: true,
}))
: []

Expand All @@ -36,127 +24,106 @@ const config = {
pageSize: 10,
}

type QueryDef<Item, QueryArgs extends PaginationQueryArgs> = QueryDefinition<
QueryArgs,
BaseQueryFn<FetchArgs & {slug: ApiSlug}, unknown, FetchBaseQueryError>,
string,
Paginated<Item>
>

type UseQueryHook<QueryArgs, Result> = (arg: QueryArgs | typeof skipToken) => {
data?: Result
error?: FetchBaseQueryError | SerializedError
isError: boolean
isLoading: boolean
}

/**
* Builds a paged list for infinite scrolling by combining fetched items with
* dummy placeholder items up to the reported total result size.
*
* The hook automatically fetches the next page once the requested `page`
* reaches the last loaded page.
*
* @param defaultEmptyItem Base item shape used to generate placeholder rows.
* @param endpoint RTK Query infinite endpoint that returns paginated results.
* @param keyName Unique item key used to assign stable ids to dummy rows.
* @param page Current page that should be available in the local result.
* @param pageSize Number of items expected per page.
* @param queryParams Query parameters for the endpoint, or `skipToken` to disable fetching.
*
* @example
* const result = useInfiniteScroller(
* dummyBoatChargingHistoryItem,
* boatChargingApi.endpoints[BoatChargingEndpointName.boatChargingSessions],
* 'id',
* page,
* PAGE_SIZE,
* isLoggedIn
* ? {
* page_size: PAGE_SIZE,
* status: SessionStatus.COMPLETED,
* }
* : skipToken,
* )
*/
export const useInfiniteScroller = <

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.

Voor mij zou het heel fijn zijn om iets meer documentatie te zien bij deze hook. Momenteel krijg ik dit terug als ik hover:

 dummy?: boolean;
}, endpoint: ApiEndpointInfiniteQuery<InfiniteQueryDefinition<NewsArticlesQueryArgs, number, BaseQueryFn<FetchArgs & {
 slug: ApiSlug;
}, unknown, FetchBaseQueryError>, string, Paginated<NewsArticleBase>>, EndpointDefinitions> & {
 ...;
}, keyName: "dummy" | keyof NewsArticleBase, page?: number, pageSize?: number, queryParams?: typeof skipToken | NewsArticlesQueryArgs): {
 ...;
}
import useInfiniteScroller

En wellicht is deze hook ook een mooie kandidaat voor tests? (met als argument dat deze hook op meerdere plekken wordt gebruikt, verspreid over meerdere modules, en het kernlogica bevat.

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.

Misschien dat GH copilot beide dingen makkelijk kan opzetten (jsDoc en eventuele tests)

Item,
ItemOrDummyItem,
QueryArgs extends PaginationQueryArgs,
>(
defaultEmptyItem: ItemOrDummyItem,
endpoint: ApiEndpointQuery<QueryDef<Item, QueryArgs>, EndpointDefinitions>,
keyName: keyof ItemOrDummyItem,
useQueryHook: UseQueryHook<QueryArgs, Paginated<Item>>,
defaultEmptyItem: Item & {dummy?: boolean},
endpoint: ApiEndpointInfinite<Item, QueryArgs>,
keyName: keyof (Item & {dummy?: boolean}),
page = config.page,
pageSize = config.pageSize,
queryParams: QueryArgs | typeof skipToken = {} as QueryArgs,
) => {
const reduxApiState = useSelector(state => state[baseApi.reducerPath])
const [totalPages, setTotalPages] = useState<number>(config.totalPages)

const {
data: previousData,
isError: isErrorPreviousPage,
isLoading: isLoadingPreviousPage,
error: errorPreviousPage,
} = useQueryHook(
page > 1 && queryParams !== skipToken
? {
...queryParams,
page: page - 1,
}
: skipToken,
)

const {
data: currentData,
isError: isErrorCurrentPage,
isLoading: isLoadingCurrentPage,
error: errorCurrentPage,
} = useQueryHook(
page <= totalPages && queryParams !== skipToken
? {
...queryParams,
page,
}
: skipToken,
)

const {
data: nextData,
isError: isErrorNextPage,
isLoading: isLoadingNextPage,
error: errorNextPage,
} = useQueryHook(
page < totalPages && queryParams !== skipToken
? {
...queryParams,
page: page + 1,
}
: skipToken,
)
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = endpoint.useInfiniteQuery(queryParams, {initialPageParam: 1})

useEffect(() => {
if (currentData?.page.totalPages) {
setTotalPages(currentData?.page.totalPages)
if (
hasNextPage &&
page >= (currentData?.pageParams.at(-1) ?? 0) &&
!isFetchingNextPage
) {
void fetchNextPage()
}
}, [currentData?.page.totalPages])
}, [
currentData?.pageParams,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
page,
])

const totalElements =
previousData?.page.totalElements ??
currentData?.page.totalElements ??
nextData?.page.totalElements ??
0
const totalElements = currentData?.pages[0]?.page.totalElements ?? 0

const endpointSelectorState = {
[baseApi.reducerPath]: reduxApiState,
} as Parameters<ReturnType<typeof endpoint.select>>[0]
const fetchedData =
queryParams === skipToken
? []
: (currentData?.pages.flatMap(({result}, index) =>
result.map(item => ({...item, page: index + 1})),
) ?? [])

return {
// create an array of pages with data
data:
queryParams === skipToken
? []
: (Array(totalPages)
// fill the array with empty values
.fill({})
// map over the array and fill it with data
.reduce<unknown[]>((acc, _s, index) => {
const {data, status} = endpoint.select({
...queryParams,
page: index + 1,
})(endpointSelectorState)
// if there is no data, fill the page with empty items
const pageData =
data?.result && status === QueryStatus.fulfilled
? data?.result
: getEmptyItems<ItemOrDummyItem>(
Math.min(pageSize, totalElements - index * pageSize),
index * pageSize,
defaultEmptyItem,
keyName,
)
const numberOfDummyItems = totalElements - fetchedData.length
Comment thread
RikSchefferAmsterdam marked this conversation as resolved.

return [
...acc,
...pageData.map(item => ({...item, page: index + 1})),
]
}, []) as Array<ItemOrDummyItem & {page: number}>),
error: errorPreviousPage || errorCurrentPage || errorNextPage,
isError: isErrorPreviousPage || isErrorCurrentPage || isErrorNextPage,
isLoading:
isLoadingPreviousPage || isLoadingCurrentPage || isLoadingNextPage,
const data: Array<Item & {dummy?: boolean; page: number}> =
queryParams === skipToken
? []
: [
...fetchedData,
...getEmptyItems(
numberOfDummyItems,
fetchedData.length,
defaultEmptyItem,
keyName,
).map((item, index) => ({
...item,
page:
Math.floor(
(totalElements - index - fetchedData.length) / pageSize,
) + 1,
})),
]

return {
data,
error: errorCurrentPage,
isError: isErrorCurrentPage,
isLoading: isLoadingCurrentPage,
}
}
8 changes: 3 additions & 5 deletions src/modules/boat-charging/components/BoatChargingDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {Column} from '@/components/ui/layout/Column'
import {Paragraph} from '@/components/ui/text/Paragraph'
import {Phrase} from '@/components/ui/text/Phrase'
import {Title} from '@/components/ui/text/Title'
import {useInterval} from '@/hooks/useInterval'
import {getAddressLine1} from '@/modules/address/utils/addDerivedAddressFields'
import {BoatChargingDetailsInfoRows} from '@/modules/boat-charging/components/BoatChargingDetailsInfoRows'
import {BoatChargingDetailsSocketRadioGroup} from '@/modules/boat-charging/components/BoatChargingDetailsSocketRadioGroup'
Expand All @@ -31,18 +30,17 @@ export const BoatChargingDetails = ({id}: {id: BoatChargingLocation['id']}) => {
data: location,
isLoading: isLoadingLocation,
isError: isErrorLocation,
refetch: refetchLocationDetails,
fulfilledTimeStamp,
} = useBoatChargingLocationDetailsQuery(id ?? skipToken)
} = useBoatChargingLocationDetailsQuery(id ?? skipToken, {
pollingInterval: REFETCH_INTERVAL,
Comment thread
RikSchefferAmsterdam marked this conversation as resolved.
})

const {
activeSessions,
isLoading: isLoadingSessions,
isError: isErrorSessions,
} = useBoatChargingSessions()

useInterval(refetchLocationDetails, REFETCH_INTERVAL)

const infoRows = useMemo(
() =>
Object.entries({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@ import {BoatChargingHistoryEmpty} from '@/modules/boat-charging/components/histo
import {BoatChargingHistoryItem} from '@/modules/boat-charging/components/history/BoatChargingHistoryItem'
import {BoatChargingHistoryLogin} from '@/modules/boat-charging/components/history/BoatChargingHistoryLogin'
import {useIsLoggedIn} from '@/modules/boat-charging/hooks/useIsLoggedIn'
import {
boatChargingApi,
useBoatChargingSessionsQuery,
} from '@/modules/boat-charging/service'
import {boatChargingApi} from '@/modules/boat-charging/service'
import {
BoatChargingEndpointName,
NRGStatus,
type BoatChargingSession,
SessionStatus,
type BoatChargingLocation,
} from '@/modules/boat-charging/types'
import {layoutStyles} from '@/styles/layoutStyles'
import {getCurrentPage} from '@/utils/pagination/getCurrentPage'
Expand All @@ -30,24 +28,11 @@ import {

const PAGE_SIZE = 20

type BoatChargingHistoryInfiniteSession = BoatChargingSession & {
dummy?: never
export type BoatChargingHistoryInfiniteItem = BoatChargingSession & {
dummy?: boolean
page: number
}

type BoatChargingHistoryInfiniteDummySession = Omit<
BoatChargingSession,
'location' | 'email'
> & {
dummy: true
location: {name: string}
page: number
}

type BoatChargingHistoryInfiniteItem =
| BoatChargingHistoryInfiniteSession
| BoatChargingHistoryInfiniteDummySession

type BoatChargingHistoryInfiniteSection = {
data: BoatChargingHistoryInfiniteItem[]
title: string
Expand All @@ -66,18 +51,18 @@ const isCompletedOrDummySession = (
const dummyBoatChargingHistoryItem: BoatChargingHistoryInfiniteItem = {
created_date_time: '1970-01-01T00:00:00Z',
currency: 'EUR',
dummy: true,
end_date_time: '1970-01-01T00:00:00Z',
id: '',
Comment thread
RikSchefferAmsterdam marked this conversation as resolved.
kwh: 0,
location: {name: ''},
location: {name: ''} as BoatChargingLocation,
nrg_status: NRGStatus.Created,
page: 0,
socket_number: '',
start_date_time: '1970-01-01T00:00:00Z',
station_id: '',
status: SessionStatus.COMPLETED,
total_cost: 0,
email: '',
}

export const BoatChargingHistory = () => {
Expand All @@ -89,7 +74,6 @@ export const BoatChargingHistory = () => {
dummyBoatChargingHistoryItem,
boatChargingApi.endpoints[BoatChargingEndpointName.boatChargingSessions],
'id',
useBoatChargingSessionsQuery,
page,
PAGE_SIZE,
isLoggedIn
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import {memo} from 'react'
import type {BoatChargingSession} from '@/modules/boat-charging/types'
import type {BoatChargingHistoryInfiniteItem} from '@/modules/boat-charging/components/history/BoatChargingHistory'
import {NavigationButton} from '@/components/ui/buttons/NavigationButton'
import {Box} from '@/components/ui/containers/Box'
import {Skeleton} from '@/components/ui/feedback/Skeleton'
import {useNavigation} from '@/hooks/navigation/useNavigation'
import {BoatChargingRouteName} from '@/modules/boat-charging/routes'
import {useBoatChargingSettingsQuery} from '@/modules/boat-charging/service'
import {formatKWH} from '@/modules/boat-charging/utils/formatKWH'
import {formatNumber} from '@/utils/formatNumber'

export type BoatChargingSessionOrDummy =
| (BoatChargingSession & {dummy?: never})
| {dummy: true; start_date_time: string}

type Props = {
session: BoatChargingSessionOrDummy
session: BoatChargingHistoryInfiniteItem
}

export const BoatChargingHistoryItem = memo(({session}: Props) => {
const {navigate} = useNavigation()

const {data: settingsServerData} = useBoatChargingSettingsQuery()

if ('dummy' in session && session.dummy === true) {
return (
<Box insetTop="md">
Expand All @@ -38,7 +37,10 @@ export const BoatChargingHistoryItem = memo(({session}: Props) => {

const elements = [
typeof session.total_cost === 'number'
? formatNumber(session.total_cost * 1.21, session.currency)
? formatNumber(
session.total_cost * (settingsServerData?.vat_fraction ?? 1.21),
session.currency,
)
: null,
typeof session.kwh === 'number' ? formatKWH(session.kwh) : null,
].filter(Boolean)
Expand Down
Loading