From 21d0c026874f3dc38fb618190ede151242d4b4e9 Mon Sep 17 00:00:00 2001 From: Sebastien Castiel Date: Sat, 19 Oct 2024 22:59:47 -0400 Subject: [PATCH] Use tRPC for expense form (#251) --- .../expenses/[expenseId]/edit/page.tsx | 48 ++------------ .../expenses/create-expense-form.tsx | 45 +++++++++++++ .../groups/[groupId]/expenses/create/page.tsx | 32 ++------- .../[groupId]/expenses/edit-expense-form.tsx | 65 +++++++++++++++++++ .../[groupId]/expenses}/expense-form.tsx | 35 +++++----- src/components/delete-popup.tsx | 2 - src/lib/hooks.ts | 8 ++- .../groups/expenses/create.procedure.ts | 23 +++++++ .../groups/expenses/delete.procedure.ts | 16 +++++ .../routers/groups/expenses/get.procedure.ts | 17 +++++ src/trpc/routers/groups/expenses/index.ts | 8 +++ .../groups/expenses/update.procedure.ts | 27 ++++++++ 12 files changed, 237 insertions(+), 89 deletions(-) create mode 100644 src/app/groups/[groupId]/expenses/create-expense-form.tsx create mode 100644 src/app/groups/[groupId]/expenses/edit-expense-form.tsx rename src/{components => app/groups/[groupId]/expenses}/expense-form.tsx (97%) create mode 100644 src/trpc/routers/groups/expenses/create.procedure.ts create mode 100644 src/trpc/routers/groups/expenses/delete.procedure.ts create mode 100644 src/trpc/routers/groups/expenses/get.procedure.ts create mode 100644 src/trpc/routers/groups/expenses/update.procedure.ts diff --git a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx index a91de1f..f3e2e1c 100644 --- a/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx +++ b/src/app/groups/[groupId]/expenses/[expenseId]/edit/page.tsx @@ -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 ( - - - + ) } diff --git a/src/app/groups/[groupId]/expenses/create-expense-form.tsx b/src/app/groups/[groupId]/expenses/create-expense-form.tsx new file mode 100644 index 0000000..d6cfbed --- /dev/null +++ b/src/app/groups/[groupId]/expenses/create-expense-form.tsx @@ -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 ( + { + await createExpenseMutateAsync({ + groupId, + expenseFormValues, + participantId, + }) + utils.groups.expenses.invalidate() + router.push(`/groups/${group.id}`) + }} + runtimeFeatureFlags={runtimeFeatureFlags} + /> + ) +} diff --git a/src/app/groups/[groupId]/expenses/create/page.tsx b/src/app/groups/[groupId]/expenses/create/page.tsx index 3ca3b36..daa32dd 100644 --- a/src/app/groups/[groupId]/expenses/create/page.tsx +++ b/src/app/groups/[groupId]/expenses/create/page.tsx @@ -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 ( - - - + ) } diff --git a/src/app/groups/[groupId]/expenses/edit-expense-form.tsx b/src/app/groups/[groupId]/expenses/edit-expense-form.tsx new file mode 100644 index 0000000..d762ec4 --- /dev/null +++ b/src/app/groups/[groupId]/expenses/edit-expense-form.tsx @@ -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 ( + { + 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} + /> + ) +} diff --git a/src/components/expense-form.tsx b/src/app/groups/[groupId]/expenses/expense-form.tsx similarity index 97% rename from src/components/expense-form.tsx rename to src/app/groups/[groupId]/expenses/expense-form.tsx index 043a160..08f4436 100644 --- a/src/components/expense-form.tsx +++ b/src/app/groups/[groupId]/expenses/expense-form.tsx @@ -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>> - expense?: NonNullable>> - categories: NonNullable>> - onSubmit: (values: ExpenseFormValues, participantId?: string) => Promise - onDelete?: (participantId?: string) => Promise - 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 + onDelete?: (participantId?: string) => Promise + 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`) diff --git a/src/components/delete-popup.tsx b/src/components/delete-popup.tsx index 6e7d75a..fc66503 100644 --- a/src/components/delete-popup.tsx +++ b/src/components/delete-popup.tsx @@ -1,5 +1,3 @@ -'use client' - import { Trash2 } from 'lucide-react' import { useTranslations } from 'next-intl' import { AsyncButton } from './async-button' diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts index 490eccf..56e7744 100644 --- a/src/lib/hooks.ts +++ b/src/lib/hooks.ts @@ -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(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 diff --git a/src/trpc/routers/groups/expenses/create.procedure.ts b/src/trpc/routers/groups/expenses/create.procedure.ts new file mode 100644 index 0000000..1b84ad4 --- /dev/null +++ b/src/trpc/routers/groups/expenses/create.procedure.ts @@ -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 } + }, + ) diff --git a/src/trpc/routers/groups/expenses/delete.procedure.ts b/src/trpc/routers/groups/expenses/delete.procedure.ts new file mode 100644 index 0000000..9969cc5 --- /dev/null +++ b/src/trpc/routers/groups/expenses/delete.procedure.ts @@ -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 {} + }) diff --git a/src/trpc/routers/groups/expenses/get.procedure.ts b/src/trpc/routers/groups/expenses/get.procedure.ts new file mode 100644 index 0000000..4dc3b53 --- /dev/null +++ b/src/trpc/routers/groups/expenses/get.procedure.ts @@ -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 } + }) diff --git a/src/trpc/routers/groups/expenses/index.ts b/src/trpc/routers/groups/expenses/index.ts index d457ade..7f7cb54 100644 --- a/src/trpc/routers/groups/expenses/index.ts +++ b/src/trpc/routers/groups/expenses/index.ts @@ -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, }) diff --git a/src/trpc/routers/groups/expenses/update.procedure.ts b/src/trpc/routers/groups/expenses/update.procedure.ts new file mode 100644 index 0000000..b7f9cc2 --- /dev/null +++ b/src/trpc/routers/groups/expenses/update.procedure.ts @@ -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 } + }, + )