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

60
src/trpc/client.tsx Normal file
View File

@@ -0,0 +1,60 @@
'use client' // <-- to make sure we can mount the Provider from a server component
import type { QueryClient } from '@tanstack/react-query'
import { QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { createTRPCReact } from '@trpc/react-query'
import { useState } from 'react'
import superjson from 'superjson'
import { makeQueryClient } from './query-client'
import type { AppRouter } from './routers/_app'
export const trpc = createTRPCReact<AppRouter>()
let clientQueryClientSingleton: QueryClient
function getQueryClient() {
if (typeof window === 'undefined') {
// Server: always make a new query client
return makeQueryClient()
}
// Browser: use singleton pattern to keep the same query client
return (clientQueryClientSingleton ??= makeQueryClient())
}
function getUrl() {
const base = (() => {
if (typeof window !== 'undefined') return ''
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
return 'http://localhost:3000'
})()
return `${base}/api/trpc`
}
export function TRPCProvider(
props: Readonly<{
children: React.ReactNode
}>,
) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient()
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
transformer: superjson,
url: getUrl(),
}),
],
}),
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</trpc.Provider>
)
}

25
src/trpc/init.ts Normal file
View File

@@ -0,0 +1,25 @@
import { initTRPC } from '@trpc/server'
import { cache } from 'react'
import superjson from 'superjson'
export const createTRPCContext = cache(async () => {
/**
* @see: https://trpc.io/docs/server/context
*/
return {}
})
// Avoid exporting the entire t-object
// since it's not very descriptive.
// For instance, the use of a t variable
// is common in i18n libraries.
const t = initTRPC.create({
/**
* @see https://trpc.io/docs/server/data-transformers
*/
transformer: superjson,
})
// Base router and procedure helpers
export const createTRPCRouter = t.router
export const baseProcedure = t.procedure

21
src/trpc/query-client.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query'
import superjson from 'superjson'
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: superjson.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
hydrate: {
deserializeData: superjson.deserialize,
},
},
})
}

10
src/trpc/routers/_app.ts Normal file
View File

@@ -0,0 +1,10 @@
import { groupsRouter } from '@/trpc/routers/groups'
import { inferRouterOutputs } from '@trpc/server'
import { createTRPCRouter } from '../init'
export const appRouter = createTRPCRouter({
groups: groupsRouter,
})
export type AppRouter = typeof appRouter
export type AppRouterOutput = inferRouterOutputs<AppRouter>

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { listGroupBalancesProcedure } from '@/trpc/routers/groups/balances/list.procedure'
export const groupBalancesRouter = createTRPCRouter({
list: listGroupBalancesProcedure,
})

View File

@@ -0,0 +1,19 @@
import { getGroupExpenses } from '@/lib/api'
import {
getBalances,
getPublicBalances,
getSuggestedReimbursements,
} from '@/lib/balances'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const listGroupBalancesProcedure = baseProcedure
.input(z.object({ groupId: z.string().min(1) }))
.query(async ({ input: { groupId } }) => {
const expenses = await getGroupExpenses(groupId)
const balances = getBalances(expenses)
const reimbursements = getSuggestedReimbursements(balances)
const publicBalances = getPublicBalances(reimbursements)
return { balances: publicBalances, reimbursements }
})

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { listGroupExpensesProcedure } from '@/trpc/routers/groups/expenses/list.procedure'
export const groupExpensesRouter = createTRPCRouter({
list: listGroupExpensesProcedure,
})

View File

@@ -0,0 +1,29 @@
import { getGroupExpenses } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const listGroupExpensesProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
cursor: z.number().optional(),
limit: z.number().optional(),
filter: z.string().optional(),
}),
)
.query(async ({ input: { groupId, cursor = 0, limit = 10, filter } }) => {
const expenses = await getGroupExpenses(groupId, {
offset: cursor,
length: limit + 1,
filter,
})
return {
expenses: expenses.slice(0, limit).map((expense) => ({
...expense,
createdAt: new Date(expense.createdAt),
expenseDate: new Date(expense.expenseDate),
})),
hasMore: !!expenses[limit],
nextCursor: cursor + limit,
}
})

View File

@@ -0,0 +1,10 @@
import { createTRPCRouter } from '@/trpc/init'
import { groupBalancesRouter } from '@/trpc/routers/groups/balances'
import { groupExpensesRouter } from '@/trpc/routers/groups/expenses'
import { groupInformationRouter } from '@/trpc/routers/groups/information'
export const groupsRouter = createTRPCRouter({
expenses: groupExpensesRouter,
balances: groupBalancesRouter,
information: groupInformationRouter,
})

View File

@@ -0,0 +1,21 @@
import { getGroup } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'
export const getGroupInformationProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
}),
)
.query(async ({ input: { groupId } }) => {
const group = await getGroup(groupId)
if (!group) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Group not found.',
})
}
return { information: group.information ?? '' }
})

View File

@@ -0,0 +1,6 @@
import { createTRPCRouter } from '@/trpc/init'
import { getGroupInformationProcedure } from '@/trpc/routers/groups/information/get.procedure'
export const groupInformationRouter = createTRPCRouter({
get: getGroupInformationProcedure,
})