diff --git a/src/components/InfiniteScroll.tsx b/src/components/InfiniteScroll.tsx new file mode 100644 index 00000000..51a7b580 --- /dev/null +++ b/src/components/InfiniteScroll.tsx @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react' + +type Props = { + children: React.ReactNode + batchSize: number + onLoadMore: (offset: number) => Promise +} + +const InfiniteScroll = ({ children, batchSize, onLoadMore }: Props) => { + const [batchOffset, setBatchOffset] = useState(0) + const [hasMore, setHasMore] = useState(true) + + // load more items whenever batchOffset is changed + useEffect(() => { + // don't run on initial render or when there are no more items to load + if (batchOffset !== 0 && hasMore) { + onLoadMore(batchOffset).then(setHasMore) + } + }, [batchOffset]) // eslint-disable-line react-hooks/exhaustive-deps + + const onScroll = () => { + const scrollTop = document.documentElement.scrollTop + const scrollHeight = document.documentElement.scrollHeight + const clientHeight = document.documentElement.clientHeight + + if (scrollTop + clientHeight >= scrollHeight) { + setBatchOffset(batchOffset + batchSize) + } + } + + useEffect(() => { + window.addEventListener('scroll', onScroll) + return () => window.removeEventListener('scroll', onScroll) + }) + + return children +} + +export default InfiniteScroll diff --git a/src/components/TransactionsList.tsx b/src/components/TransactionsList.tsx index e04f0e71..69aa68e8 100644 --- a/src/components/TransactionsList.tsx +++ b/src/components/TransactionsList.tsx @@ -27,6 +27,7 @@ import { FunnelIcon } from '@heroicons/react/24/solid' import TransactionFilters from './TransactionFilters' import { safeName } from '../lib/name-helper' import { XMarkIcon } from '@heroicons/react/20/solid' +import InfiniteScroll from './InfiniteScroll' const transactionApi = api('transactions') const categoryApi = api('categories') @@ -46,11 +47,14 @@ const egg = ` ` +const batchSize = 50 + const TransactionsList = ({ budget }: Props) => { const { t }: Translation = useTranslation() const [searchParams, setSearchParams] = useSearchParams() const [isLoading, setIsLoading] = useState(true) + const [isLoadingMore, setIsLoadingMore] = useState(false) const [showFilters, setShowFilters] = useState(false) const [accounts, setAccounts] = useState([]) const [groupedEnvelopes, setGroupedEnvelopes] = useState([]) @@ -73,13 +77,7 @@ const TransactionsList = ({ budget }: Props) => { } Promise.all([ - transactionApi - .getAll(budget, activeFilters) - .then(transactions => - setGroupedTransactions( - groupBy(transactions, ({ date }: Transaction) => formatDate(date)) - ) - ), + loadTransactionBatch(0), categoryApi.getAll(budget).then((categories: Category[]) => setGroupedEnvelopes( categories.map(category => ({ @@ -94,6 +92,28 @@ const TransactionsList = ({ budget }: Props) => { }) }, [budget, searchParams]) // eslint-disable-line react-hooks/exhaustive-deps + const loadTransactionBatch = ( + offset: number, + appendTransactions: boolean = false + ) => { + return transactionApi + .getBatch(budget, offset, batchSize, activeFilters) + .then(transactions => { + const allTransactions = appendTransactions + ? [ + ...transactions, + ...Object.values(groupedTransactions).flat(), + ].sort((a: Transaction, b: Transaction) => { + return Date.parse(b.date) - Date.parse(a.date) + }) + : transactions + setGroupedTransactions( + groupBy(allTransactions, ({ date }: Transaction) => formatDate(date)) + ) + return transactions + }) + } + const displayValue = ( filter: keyof FilterOptions, value: string | boolean @@ -251,11 +271,25 @@ const TransactionsList = ({ budget }: Props) => { ) : Object.keys(groupedTransactions).length ? (
- + { + setIsLoadingMore(true) + return loadTransactionBatch(offset, true).then( + (transactions: Transaction[]) => { + setIsLoadingMore(false) + return transactions.length > 0 + } + ) + }} + > + + {isLoadingMore && } +
) : ( t('transactions.emptyList') diff --git a/src/lib/api/base.ts b/src/lib/api/base.ts index ca324d50..0ba65336 100644 --- a/src/lib/api/base.ts +++ b/src/lib/api/base.ts @@ -28,11 +28,26 @@ const api = (linkKey: string) => { url.searchParams.set(key, value.toString()) } }) - // Set the limit to -1 to retrieve all resources - if unset the backend defaults to 50, - // but we don't have endless scroll implemented yet + // Set the limit to -1 to retrieve all resources - if unset the backend defaults to 50 url.searchParams.set('limit', '-1') return get(url.href) }, + getBatch: ( + parent: ApiObject, + offset: Number, + limit: Number = 50, + filterOptions: FilterOptions = {} + ) => { + const url = new URL(parent.links[linkKey]) + Object.entries(filterOptions).forEach(([key, value]) => { + if (typeof value !== 'undefined') { + url.searchParams.set(key, value.toString()) + } + }) + url.searchParams.set('limit', limit.toString()) + url.searchParams.set('offset', offset.toString()) + return get(url.href) + }, get: (id: UUID, parent: ApiObject) => { const url = new URL(parent.links[linkKey]) url.pathname += `/${id}`