Skip to content

Commit

Permalink
Use tRPC for expense form (#251)
Browse files Browse the repository at this point in the history
  • Loading branch information
scastiel authored Oct 20, 2024
1 parent 2281316 commit 21d0c02
Show file tree
Hide file tree
Showing 12 changed files with 237 additions and 89 deletions.
48 changes: 7 additions & 41 deletions src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,21 @@
import { cached } from '@/app/cached-functions'
import { ExpenseForm } from '@/components/expense-form'
import {
deleteExpense,
getCategories,
getExpense,
updateExpense,
} from '@/lib/api'
import { EditExpenseForm } from '@/app/groups/[groupId]/expenses/edit-expense-form'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { Suspense } from 'react'

export const metadata: Metadata = {
title: 'Edit expense',
title: 'Edit Expense',
}

export default async function EditExpensePage({
params: { groupId, expenseId },
}: {
params: { groupId: string; expenseId: string }
}) {
const categories = await getCategories()
const group = await cached.getGroup(groupId)
if (!group) notFound()
const expense = await getExpense(groupId, expenseId)
if (!expense) notFound()

async function updateExpenseAction(values: unknown, participantId?: string) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await updateExpense(groupId, expenseId, expenseFormValues, participantId)
redirect(`/groups/${groupId}`)
}

async function deleteExpenseAction(participantId?: string) {
'use server'
await deleteExpense(groupId, expenseId, participantId)
redirect(`/groups/${groupId}`)
}

return (
<Suspense>
<ExpenseForm
group={group}
expense={expense}
categories={categories}
onSubmit={updateExpenseAction}
onDelete={deleteExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
</Suspense>
<EditExpenseForm
groupId={groupId}
expenseId={expenseId}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
)
}
45 changes: 45 additions & 0 deletions src/app/groups/[groupId]/expenses/create-expense-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
'use client'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { trpc } from '@/trpc/client'
import { useRouter } from 'next/navigation'
import { ExpenseForm } from './expense-form'

export function CreateExpenseForm({
groupId,
runtimeFeatureFlags,
}: {
groupId: string
expenseId?: string
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const group = groupData?.group

const { data: categoriesData } = trpc.categories.list.useQuery()
const categories = categoriesData?.categories

const { mutateAsync: createExpenseMutateAsync } =
trpc.groups.expenses.create.useMutation()

const utils = trpc.useUtils()
const router = useRouter()

if (!group || !categories) return null

return (
<ExpenseForm
group={group}
categories={categories}
onSubmit={async (expenseFormValues, participantId) => {
await createExpenseMutateAsync({
groupId,
expenseFormValues,
participantId,
})
utils.groups.expenses.invalidate()
router.push(`/groups/${group.id}`)
}}
runtimeFeatureFlags={runtimeFeatureFlags}
/>
)
}
32 changes: 6 additions & 26 deletions src/app/groups/[groupId]/expenses/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,20 @@
import { cached } from '@/app/cached-functions'
import { ExpenseForm } from '@/components/expense-form'
import { createExpense, getCategories } from '@/lib/api'
import { CreateExpenseForm } from '@/app/groups/[groupId]/expenses/create-expense-form'
import { getRuntimeFeatureFlags } from '@/lib/featureFlags'
import { expenseFormSchema } from '@/lib/schemas'
import { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import { Suspense } from 'react'

export const metadata: Metadata = {
title: 'Create expense',
title: 'Create Expense',
}

export default async function ExpensePage({
params: { groupId },
}: {
params: { groupId: string }
}) {
const categories = await getCategories()
const group = await cached.getGroup(groupId)
if (!group) notFound()

async function createExpenseAction(values: unknown, participantId?: string) {
'use server'
const expenseFormValues = expenseFormSchema.parse(values)
await createExpense(expenseFormValues, groupId, participantId)
redirect(`/groups/${groupId}`)
}

return (
<Suspense>
<ExpenseForm
group={group}
categories={categories}
onSubmit={createExpenseAction}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
</Suspense>
<CreateExpenseForm
groupId={groupId}
runtimeFeatureFlags={await getRuntimeFeatureFlags()}
/>
)
}
65 changes: 65 additions & 0 deletions src/app/groups/[groupId]/expenses/edit-expense-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { trpc } from '@/trpc/client'
import { useRouter } from 'next/navigation'
import { ExpenseForm } from './expense-form'

export function EditExpenseForm({
groupId,
expenseId,
runtimeFeatureFlags,
}: {
groupId: string
expenseId: string
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const { data: groupData } = trpc.groups.get.useQuery({ groupId })
const group = groupData?.group

const { data: categoriesData } = trpc.categories.list.useQuery()
const categories = categoriesData?.categories

const { data: expenseData } = trpc.groups.expenses.get.useQuery({
groupId,
expenseId,
})
const expense = expenseData?.expense

const { mutateAsync: updateExpenseMutateAsync } =
trpc.groups.expenses.update.useMutation()
const { mutateAsync: deleteExpenseMutateAsync } =
trpc.groups.expenses.delete.useMutation()

const utils = trpc.useUtils()
const router = useRouter()

if (!group || !categories || !expense) return null

return (
<ExpenseForm
group={group}
expense={expense}
categories={categories}
onSubmit={async (expenseFormValues, participantId) => {
await updateExpenseMutateAsync({
expenseId,
groupId,
expenseFormValues,
participantId,
})
utils.groups.expenses.invalidate()
router.push(`/groups/${group.id}`)
}}
onDelete={async (participantId) => {
await deleteExpenseMutateAsync({
expenseId,
groupId,
participantId,
})
utils.groups.expenses.invalidate()
router.push(`/groups/${group.id}`)
}}
runtimeFeatureFlags={runtimeFeatureFlags}
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
'use client'
import { CategorySelector } from '@/components/category-selector'
import { ExpenseDocumentsInput } from '@/components/expense-documents-input'
import { SubmitButton } from '@/components/submit-button'
Expand Down Expand Up @@ -33,7 +32,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { getCategories, getExpense, getGroup, randomId } from '@/lib/api'
import { randomId } from '@/lib/api'
import { RuntimeFeatureFlags } from '@/lib/featureFlags'
import { useActiveUser } from '@/lib/hooks'
import {
Expand All @@ -42,6 +41,7 @@ import {
expenseFormSchema,
} from '@/lib/schemas'
import { cn } from '@/lib/utils'
import { AppRouterOutput } from '@/trpc/routers/_app'
import { zodResolver } from '@hookform/resolvers/zod'
import { Save } from 'lucide-react'
import { useTranslations } from 'next-intl'
Expand All @@ -50,18 +50,9 @@ import { useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { match } from 'ts-pattern'
import { DeletePopup } from './delete-popup'
import { extractCategoryFromTitle } from './expense-form-actions'
import { Textarea } from './ui/textarea'

export type Props = {
group: NonNullable<Awaited<ReturnType<typeof getGroup>>>
expense?: NonNullable<Awaited<ReturnType<typeof getExpense>>>
categories: NonNullable<Awaited<ReturnType<typeof getCategories>>>
onSubmit: (values: ExpenseFormValues, participantId?: string) => Promise<void>
onDelete?: (participantId?: string) => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags
}
import { DeletePopup } from '../../../../components/delete-popup'
import { extractCategoryFromTitle } from '../../../../components/expense-form-actions'
import { Textarea } from '../../../../components/ui/textarea'

const enforceCurrencyPattern = (value: string) =>
value
Expand All @@ -72,7 +63,9 @@ const enforceCurrencyPattern = (value: string) =>
.replace(/#/, '.') // change back # to dot
.replace(/[^-\d.]/g, '') // remove all non-numeric characters

const getDefaultSplittingOptions = (group: Props['group']) => {
const getDefaultSplittingOptions = (
group: AppRouterOutput['groups']['get']['group'],
) => {
const defaultValue = {
splitMode: 'EVENLY' as const,
paidFor: group.participants.map(({ id }) => ({
Expand Down Expand Up @@ -146,15 +139,23 @@ async function persistDefaultSplittingOptions(

export function ExpenseForm({
group,
expense,
categories,
expense,
onSubmit,
onDelete,
runtimeFeatureFlags,
}: Props) {
}: {
group: AppRouterOutput['groups']['get']['group']
categories: AppRouterOutput['categories']['list']['categories']
expense?: AppRouterOutput['groups']['expenses']['get']['expense']
onSubmit: (value: ExpenseFormValues, participantId?: string) => Promise<void>
onDelete?: (participantId?: string) => Promise<void>
runtimeFeatureFlags: RuntimeFeatureFlags
}) {
const t = useTranslations('ExpenseForm')
const isCreate = expense === undefined
const searchParams = useSearchParams()

const getSelectedPayer = (field?: { value: string }) => {
if (isCreate && typeof window !== 'undefined') {
const activeUser = localStorage.getItem(`${group.id}-activeUser`)
Expand Down
2 changes: 0 additions & 2 deletions src/components/delete-popup.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
'use client'

import { Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { AsyncButton } from './async-button'
Expand Down
8 changes: 5 additions & 3 deletions src/lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ export function useBaseUrl() {
/**
* @returns The active user, or `null` until it is fetched from local storage
*/
export function useActiveUser(groupId: string) {
export function useActiveUser(groupId?: string) {
const [activeUser, setActiveUser] = useState<string | null>(null)

useEffect(() => {
const activeUser = localStorage.getItem(`${groupId}-activeUser`)
if (activeUser) setActiveUser(activeUser)
if (groupId) {
const activeUser = localStorage.getItem(`${groupId}-activeUser`)
if (activeUser) setActiveUser(activeUser)
}
}, [groupId])

return activeUser
Expand Down
23 changes: 23 additions & 0 deletions src/trpc/routers/groups/expenses/create.procedure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createExpense } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'

export const createGroupExpenseProcedure = baseProcedure
.input(
z.object({
groupId: z.string().min(1),
expenseFormValues: expenseFormSchema,
participantId: z.string().optional(),
}),
)
.mutation(
async ({ input: { groupId, expenseFormValues, participantId } }) => {
const expense = await createExpense(
expenseFormValues,
groupId,
participantId,
)
return { expenseId: expense.id }
},
)
16 changes: 16 additions & 0 deletions src/trpc/routers/groups/expenses/delete.procedure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { deleteExpense } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'

export const deleteGroupExpenseProcedure = baseProcedure
.input(
z.object({
expenseId: z.string().min(1),
groupId: z.string().min(1),
participantId: z.string().optional(),
}),
)
.mutation(async ({ input: { expenseId, groupId, participantId } }) => {
await deleteExpense(groupId, expenseId, participantId)
return {}
})
17 changes: 17 additions & 0 deletions src/trpc/routers/groups/expenses/get.procedure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getExpense } from '@/lib/api'
import { baseProcedure } from '@/trpc/init'
import { TRPCError } from '@trpc/server'
import { z } from 'zod'

export const getGroupExpenseProcedure = baseProcedure
.input(z.object({ groupId: z.string().min(1), expenseId: z.string().min(1) }))
.query(async ({ input: { groupId, expenseId } }) => {
const expense = await getExpense(groupId, expenseId)
if (!expense) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Expense not found',
})
}
return { expense }
})
Loading

0 comments on commit 21d0c02

Please sign in to comment.