Use tRPC for expense form (#251)

This commit is contained in:
Sebastien Castiel
2024-10-19 22:59:47 -04:00
committed by GitHub
parent 2281316d58
commit 21d0c02687
12 changed files with 237 additions and 89 deletions

View File

@@ -1,19 +1,9 @@
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({
@@ -21,35 +11,11 @@ export default async function EditExpensePage({
}: {
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()}
/>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -1,14 +1,9 @@
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({
@@ -16,25 +11,10 @@ export default async function ExpensePage({
}: {
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()}
/>
)
}

View File

@@ -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}
/>
)
}

View File

@@ -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'
@@ -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 {
@@ -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'
@@ -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
@@ -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 }) => ({
@@ -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`)

View File

@@ -1,5 +1,3 @@
'use client'
import { Trash2 } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { AsyncButton } from './async-button'

View File

@@ -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

View File

@@ -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 }
},
)

View File

@@ -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 {}
})

View File

@@ -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 }
})

View File

@@ -1,6 +1,14 @@
import { createTRPCRouter } from '@/trpc/init'
import { createGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/create.procedure'
import { deleteGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/delete.procedure'
import { getGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/get.procedure'
import { listGroupExpensesProcedure } from '@/trpc/routers/groups/expenses/list.procedure'
import { updateGroupExpenseProcedure } from '@/trpc/routers/groups/expenses/update.procedure'
export const groupExpensesRouter = createTRPCRouter({
list: listGroupExpensesProcedure,
get: getGroupExpenseProcedure,
create: createGroupExpenseProcedure,
update: updateGroupExpenseProcedure,
delete: deleteGroupExpenseProcedure,
})

View File

@@ -0,0 +1,27 @@
import { updateExpense } from '@/lib/api'
import { expenseFormSchema } from '@/lib/schemas'
import { baseProcedure } from '@/trpc/init'
import { z } from 'zod'
export const updateGroupExpenseProcedure = baseProcedure
.input(
z.object({
expenseId: z.string().min(1),
groupId: z.string().min(1),
expenseFormValues: expenseFormSchema,
participantId: z.string().optional(),
}),
)
.mutation(
async ({
input: { expenseId, groupId, expenseFormValues, participantId },
}) => {
const expense = await updateExpense(
groupId,
expenseId,
expenseFormValues,
participantId,
)
return { expenseId: expense.id }
},
)