Form validation

This commit is contained in:
Sebastien Castiel
2023-12-16 16:54:42 -05:00
parent 2b712cd69c
commit f4b31c805d
2 changed files with 242 additions and 151 deletions

View File

@@ -29,6 +29,7 @@ import {
} from '@/components/ui/select' } from '@/components/ui/select'
import { getExpense, getGroup } from '@/lib/api' import { getExpense, getGroup } from '@/lib/api'
import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas' import { ExpenseFormValues, expenseFormSchema } from '@/lib/schemas'
import { cn } from '@/lib/utils'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
@@ -84,12 +85,12 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
{isCreate ? <>Create expense</> : <>Edit expense</>} {isCreate ? <>Create expense</> : <>Edit expense</>}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="grid grid-cols-1 sm:grid-cols-2 gap-6"> <CardContent className="grid sm:grid-cols-2 gap-6">
<FormField <FormField
control={form.control} control={form.control}
name="title" name="title"
render={({ field }) => ( render={({ field }) => (
<FormItem className="order-1"> <FormItem className="">
<FormLabel>Expense title</FormLabel> <FormLabel>Expense title</FormLabel>
<FormControl> <FormControl>
<Input <Input
@@ -106,40 +107,11 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
)} )}
/> />
<FormField
control={form.control}
name="paidBy"
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>Paid by</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a participant" />
</SelectTrigger>
<SelectContent>
{group.participants.map(({ id, name }) => (
<SelectItem key={id} value={id}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the participant who paid the expense.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="amount" name="amount"
render={({ field }) => ( render={({ field }) => (
<FormItem className="order-3 sm:order-4"> <FormItem className="sm:order-3">
<FormLabel>Amount</FormLabel> <FormLabel>Amount</FormLabel>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span>{group.currency}</span> <span>{group.currency}</span>
@@ -179,18 +151,48 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
<FormField <FormField
control={form.control} control={form.control}
name="paidFor" name="paidBy"
render={() => ( render={({ field }) => (
<FormItem className="order-5"> <FormItem className="sm:order-5">
<FormLabel>Paid by</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a participant" />
</SelectTrigger>
<SelectContent>
{group.participants.map(({ id, name }) => (
<SelectItem key={id} value={id}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
Select the participant who paid the expense.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="splitMode" name="splitMode"
render={({ field }) => ( render={({ field }) => (
<FormItem className="order-2 sm:order-3 mb-4"> <FormItem className="sm:order-2">
<FormLabel>Split mode</FormLabel> <FormLabel>Split mode</FormLabel>
<FormControl> <FormControl>
<Select <Select
onValueChange={field.onChange} onValueChange={(value) => {
form.setValue('splitMode', value as any, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}}
defaultValue={field.value} defaultValue={field.value}
> >
<SelectTrigger> <SelectTrigger>
@@ -204,9 +206,9 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
<SelectItem value="BY_PERCENTAGE"> <SelectItem value="BY_PERCENTAGE">
Unevenly By percentage Unevenly By percentage
</SelectItem> </SelectItem>
<SelectItem value="BY_AMOUNT"> {/* <SelectItem value="BY_AMOUNT">
Unevenly By amount Unevenly By amount
</SelectItem> </SelectItem> */}
</SelectContent> </SelectContent>
</Select> </Select>
</FormControl> </FormControl>
@@ -217,7 +219,12 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
)} )}
/> />
<div className="mb-4"> <FormField
control={form.control}
name="paidFor"
render={() => (
<FormItem className="sm:order-4 row-span-2">
<div className="">
<FormLabel> <FormLabel>
Paid for Paid for
<Button <Button
@@ -260,11 +267,13 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
name="paidFor" name="paidFor"
render={({ field }) => { render={({ field }) => {
return ( return (
<div className="flex items-center"> <div
<FormItem data-id={`${id}/${form.getValues().splitMode}/${
key={id} group.currency
className="flex-1 flex flex-row items-start space-x-3 space-y-0" }`}
className="flex items-center"
> >
<FormItem className="flex-1 flex flex-row items-start space-x-3 space-y-0">
<FormControl> <FormControl>
<Checkbox <Checkbox
checked={field.value?.some( checked={field.value?.some(
@@ -288,15 +297,30 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
{name} {name}
</FormLabel> </FormLabel>
</FormItem> </FormItem>
{field.value?.some( {form.getValues().splitMode !== 'EVENLY' && (
<FormField
name={`paidFor[${field.value.findIndex(
({ participant }) => participant === id, ({ participant }) => participant === id,
) && )}].shares`}
form.getValues().splitMode !== 'EVENLY' && ( render={() => (
<div>
<div className="flex gap-1 items-baseline"> <div className="flex gap-1 items-baseline">
<FormControl> <FormControl>
<Input <Input
className="w-[80px]" key={String(
!field.value?.some(
({ participant }) =>
participant === id,
),
)}
className="text-base w-[80px]"
type="number" type="number"
disabled={
!field.value?.some(
({ participant }) =>
participant === id,
)
}
value={ value={
field.value?.find( field.value?.find(
({ participant }) => ({ participant }) =>
@@ -321,10 +345,19 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
step={1} step={1}
/> />
</FormControl> </FormControl>
<span className="text-sm"> <span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
),
})}
>
{match(form.getValues().splitMode) {match(form.getValues().splitMode)
.with('EVENLY', () => <></>) .with('EVENLY', () => <></>)
.with('BY_SHARES', () => <>share(s)</>) .with('BY_SHARES', () => (
<>share(s)</>
))
.with('BY_PERCENTAGE', () => <>%</>) .with('BY_PERCENTAGE', () => <>%</>)
.with('BY_AMOUNT', () => ( .with('BY_AMOUNT', () => (
<>{group.currency}</> <>{group.currency}</>
@@ -332,6 +365,10 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
.exhaustive()} .exhaustive()}
</span> </span>
</div> </div>
<FormMessage className="float-right" />
</div>
)}
/>
)} )}
</div> </div>
) )

View File

@@ -39,7 +39,8 @@ export const groupFormSchema = z
export type GroupFormValues = z.infer<typeof groupFormSchema> export type GroupFormValues = z.infer<typeof groupFormSchema>
export const expenseFormSchema = z.object({ export const expenseFormSchema = z
.object({
title: z title: z
.string({ required_error: 'Please enter a title.' }) .string({ required_error: 'Please enter a title.' })
.min(2, 'Enter at least two characters.'), .min(2, 'Enter at least two characters.'),
@@ -66,8 +67,25 @@ export const expenseFormSchema = z.object({
), ),
paidBy: z.string({ required_error: 'You must select a participant.' }), paidBy: z.string({ required_error: 'You must select a participant.' }),
paidFor: z paidFor: z
.array(z.object({ participant: z.string(), shares: z.number().int() })) .array(
.min(1, 'The expense must be paid for at least one participant.'), z.object({
participant: z.string(),
shares: z.number().int(),
}),
)
.min(1, 'The expense must be paid for at least one participant.')
.superRefine((paidFor, ctx) => {
let sum = 0
for (const { participant, shares } of paidFor) {
sum += shares
if (shares < 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'All shares must be higher than 0.',
})
}
}
}),
splitMode: z splitMode: z
.enum<SplitMode, [SplitMode, ...SplitMode[]]>( .enum<SplitMode, [SplitMode, ...SplitMode[]]>(
Object.values(SplitMode) as any, Object.values(SplitMode) as any,
@@ -75,5 +93,41 @@ export const expenseFormSchema = z.object({
.default('EVENLY'), .default('EVENLY'),
isReimbursement: z.boolean(), isReimbursement: z.boolean(),
}) })
.superRefine((expense, ctx) => {
let sum = 0
for (const { participant, shares } of expense.paidFor) {
sum += shares
}
switch (expense.splitMode) {
case 'EVENLY':
break // noop
case 'BY_SHARES':
break // noop
case 'BY_AMOUNT':
if (sum !== expense.amount) {
const detail =
sum < expense.amount
? `${expense.amount - sum} missing`
: `${sum - expense.amount} surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sum of amounts must equal the expense amount (${detail}).`,
path: ['paidFor'],
})
}
break
case 'BY_PERCENTAGE':
if (sum !== 100) {
const detail =
sum < 100 ? `${100 - sum}% missing` : `${sum - 100}% surplus`
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Sum of percentages must equal 100 (${detail})`,
path: ['paidFor'],
})
}
break
}
})
export type ExpenseFormValues = z.infer<typeof expenseFormSchema> export type ExpenseFormValues = z.infer<typeof expenseFormSchema>