Add tRPC, use it for group expenses, balances and information page (#246)

* Add tRPC, use it for group expense list

* Use tRPC for balances

* Use tRPC in group information + better loading states
This commit is contained in:
Sebastien Castiel
2024-10-19 17:42:11 -04:00
committed by GitHub
parent 727803ea5c
commit 66e15e419e
24 changed files with 671 additions and 239 deletions

View File

@@ -4,21 +4,22 @@ import { getGroupExpensesAction } from '@/app/groups/[groupId]/expenses/expense-
import { Button } from '@/components/ui/button'
import { SearchBar } from '@/components/ui/search-bar'
import { Skeleton } from '@/components/ui/skeleton'
import { normalizeString } from '@/lib/utils'
import { trpc } from '@/trpc/client'
import { Participant } from '@prisma/client'
import dayjs, { type Dayjs } from 'dayjs'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import { forwardRef, useEffect, useMemo, useState } from 'react'
import { useInView } from 'react-intersection-observer'
import { useDebounce } from 'use-debounce'
const PAGE_SIZE = 200
type ExpensesType = NonNullable<
Awaited<ReturnType<typeof getGroupExpensesAction>>
>
type Props = {
expensesFirstPage: ExpensesType
expenseCount: number
participants: Participant[]
currency: string
groupId: string
@@ -62,22 +63,9 @@ function getGroupedExpensesByDate(expenses: ExpensesType) {
}, {})
}
export function ExpenseList({
expensesFirstPage,
expenseCount,
currency,
participants,
groupId,
}: Props) {
const firstLen = expensesFirstPage.length
export function ExpenseList({ currency, participants, groupId }: Props) {
const [searchText, setSearchText] = useState('')
const [dataIndex, setDataIndex] = useState(firstLen)
const [dataLen, setDataLen] = useState(firstLen)
const [hasMoreData, setHasMoreData] = useState(expenseCount > firstLen)
const [isFetching, setIsFetching] = useState(false)
const [expenses, setExpenses] = useState(expensesFirstPage)
const { ref, inView } = useInView()
const t = useTranslations('Expenses')
const [debouncedSearchText] = useDebounce(searchText, 300)
useEffect(() => {
const activeUser = localStorage.getItem('newGroup-activeUser')
@@ -98,57 +86,74 @@ export function ExpenseList({
}
}, [groupId, participants])
return (
<>
<SearchBar onValueChange={(value) => setSearchText(value)} />
<ExpenseListForSearch
groupId={groupId}
currency={currency}
searchText={debouncedSearchText}
/>
</>
)
}
const ExpenseListForSearch = ({
currency,
groupId,
searchText,
}: {
currency: string
groupId: string
searchText: string
}) => {
const utils = trpc.useUtils()
useEffect(() => {
const fetchNextPage = async () => {
setIsFetching(true)
// Until we use tRPC more widely and can invalidate the cache on expense
// update, it's easier and safer to invalidate the cache on page load.
utils.groups.expenses.invalidate()
}, [utils])
const newExpenses = await getGroupExpensesAction(groupId, {
offset: dataIndex,
length: dataLen,
})
const t = useTranslations('Expenses')
const { ref: loadingRef, inView } = useInView()
if (newExpenses !== null) {
const exp = expenses.concat(newExpenses)
setExpenses(exp)
setHasMoreData(exp.length < expenseCount)
setDataIndex(dataIndex + dataLen)
setDataLen(Math.ceil(1.5 * dataLen))
}
const { data, isLoading, isError, fetchNextPage } =
trpc.groups.expenses.list.useInfiniteQuery(
{ groupId, limit: PAGE_SIZE, filter: searchText },
{ getNextPageParam: ({ nextCursor }) => nextCursor },
)
const expenses = data?.pages.flatMap((page) => page.expenses)
const hasMore = data?.pages.at(-1)?.hasMore ?? false
setTimeout(() => setIsFetching(false), 500)
}
if (inView && hasMoreData && !isFetching) fetchNextPage()
}, [
dataIndex,
dataLen,
expenseCount,
expenses,
groupId,
hasMoreData,
inView,
isFetching,
])
useEffect(() => {
if (inView && hasMore && !isLoading) fetchNextPage()
}, [fetchNextPage, hasMore, inView, isLoading])
const groupedExpensesByDate = useMemo(
() => getGroupedExpensesByDate(expenses),
() => (expenses ? getGroupedExpensesByDate(expenses) : {}),
[expenses],
)
return expenses.length > 0 ? (
if (isLoading) return <ExpensesLoading />
if (!expenses || expenses?.length === 0)
return (
<p className="px-6 text-sm py-6">
{t('noExpenses')}{' '}
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/${groupId}/expenses/create`}>
{t('createFirst')}
</Link>
</Button>
</p>
)
return (
<>
<SearchBar
onValueChange={(value) => setSearchText(normalizeString(value))}
/>
{Object.values(EXPENSE_GROUPS).map((expenseGroup: string) => {
let groupExpenses = groupedExpensesByDate[expenseGroup]
if (!groupExpenses) return null
groupExpenses = groupExpenses.filter(({ title }) =>
normalizeString(title).includes(searchText),
)
if (groupExpenses.length === 0) return null
if (!groupExpenses || groupExpenses.length === 0) return null
return (
<div key={expenseGroup}>
@@ -170,31 +175,34 @@ export function ExpenseList({
</div>
)
})}
{expenses.length < expenseCount &&
[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
ref={i === 0 ? ref : undefined}
>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div>
<Skeleton className="h-4 w-16 rounded-full" />
</div>
</div>
))}
{hasMore && <ExpensesLoading ref={loadingRef} />}
</>
) : (
<p className="px-6 text-sm py-6">
{t('noExpenses')}{' '}
<Button variant="link" asChild className="-m-4">
<Link href={`/groups/${groupId}/expenses/create`}>
{t('createFirst')}
</Link>
</Button>
</p>
)
}
const ExpensesLoading = forwardRef<HTMLDivElement>((_, ref) => {
return (
<div ref={ref}>
<Skeleton className="mx-4 sm:mx-6 mb-2 h-4 w-32 rounded-full" />
{[0, 1, 2].map((i) => (
<div
key={i}
className="flex justify-between items-start px-2 sm:px-6 py-4 text-sm gap-2"
>
<div className="flex-0 pl-2 pr-1">
<Skeleton className="h-4 w-4 rounded-full" />
</div>
<div className="flex-1 flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div className="flex-0 flex flex-col gap-2 items-end mr-2 sm:mr-12">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-20 rounded-full" />
</div>
</div>
))}
</div>
)
})
ExpensesLoading.displayName = 'ExpensesLoading'

View File

@@ -10,19 +10,13 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
getCategories,
getGroupExpenseCount,
getGroupExpenses,
} from '@/lib/api'
import { getCategories } from '@/lib/api'
import { env } from '@/lib/env'
import { Download, Plus } from 'lucide-react'
import { Metadata } from 'next'
import { getTranslations } from 'next-intl/server'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Suspense } from 'react'
export const revalidate = 3600
@@ -79,24 +73,11 @@ export default async function GroupExpensesPage({
</div>
<CardContent className="p-0 pt-2 pb-4 sm:pb-6 flex flex-col gap-4 relative">
<Suspense
fallback={[0, 1, 2].map((i) => (
<div
key={i}
className="border-t flex justify-between items-center px-6 py-4 text-sm"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-16 rounded-full" />
<Skeleton className="h-4 w-32 rounded-full" />
</div>
<div>
<Skeleton className="h-4 w-16 rounded-full" />
</div>
</div>
))}
>
<Expenses group={group} />
</Suspense>
<ExpenseList
groupId={group.id}
currency={group.currency}
participants={group.participants}
/>
</CardContent>
</Card>
@@ -104,26 +85,3 @@ export default async function GroupExpensesPage({
</>
)
}
type Props = {
group: NonNullable<Awaited<ReturnType<typeof cached.getGroup>>>
}
async function Expenses({ group }: Props) {
const expenseCount = await getGroupExpenseCount(group.id)
const expenses = await getGroupExpenses(group.id, {
offset: 0,
length: 200,
})
return (
<ExpenseList
expensesFirstPage={expenses}
expenseCount={expenseCount}
groupId={group.id}
currency={group.currency}
participants={group.participants}
/>
)
}