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>
@@ -177,47 +149,82 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
)} )}
/> />
<FormField
control={form.control}
name="paidBy"
render={({ field }) => (
<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
control={form.control}
name="splitMode"
render={({ field }) => (
<FormItem className="sm:order-2">
<FormLabel>Split mode</FormLabel>
<FormControl>
<Select
onValueChange={(value) => {
form.setValue('splitMode', value as any, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVENLY">Evenly</SelectItem>
<SelectItem value="BY_SHARES">
Unevenly By shares
</SelectItem>
<SelectItem value="BY_PERCENTAGE">
Unevenly By percentage
</SelectItem>
{/* <SelectItem value="BY_AMOUNT">
Unevenly By amount
</SelectItem> */}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Select how to split the expense.
</FormDescription>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="paidFor" name="paidFor"
render={() => ( render={() => (
<FormItem className="order-5"> <FormItem className="sm:order-4 row-span-2">
<FormField <div className="">
control={form.control}
name="splitMode"
render={({ field }) => (
<FormItem className="order-2 sm:order-3 mb-4">
<FormLabel>Split mode</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVENLY">Evenly</SelectItem>
<SelectItem value="BY_SHARES">
Unevenly By shares
</SelectItem>
<SelectItem value="BY_PERCENTAGE">
Unevenly By percentage
</SelectItem>
<SelectItem value="BY_AMOUNT">
Unevenly By amount
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormDescription>
Select how to split the expense.
</FormDescription>
</FormItem>
)}
/>
<div className="mb-4">
<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,51 +297,79 @@ export function ExpenseForm({ group, expense, onSubmit, onDelete }: Props) {
{name} {name}
</FormLabel> </FormLabel>
</FormItem> </FormItem>
{field.value?.some( {form.getValues().splitMode !== 'EVENLY' && (
({ participant }) => participant === id, <FormField
) && name={`paidFor[${field.value.findIndex(
form.getValues().splitMode !== 'EVENLY' && ( ({ participant }) => participant === id,
<div className="flex gap-1 items-baseline"> )}].shares`}
<FormControl> render={() => (
<Input <div>
className="w-[80px]" <div className="flex gap-1 items-baseline">
type="number" <FormControl>
value={ <Input
field.value?.find( key={String(
({ participant }) => !field.value?.some(
participant === id, ({ participant }) =>
)?.shares participant === id,
} ),
onChange={(event) => )}
field.onChange( className="text-base w-[80px]"
field.value.map((p) => type="number"
p.participant === id disabled={
? { !field.value?.some(
participant: id, ({ participant }) =>
shares: Number( participant === id,
event.target.value, )
), }
} value={
: p, field.value?.find(
({ participant }) =>
participant === id,
)?.shares
}
onChange={(event) =>
field.onChange(
field.value.map((p) =>
p.participant === id
? {
participant: id,
shares: Number(
event.target.value,
),
}
: p,
),
)
}
inputMode="numeric"
step={1}
/>
</FormControl>
<span
className={cn('text-sm', {
'text-muted': !field.value?.some(
({ participant }) =>
participant === id,
), ),
) })}
} >
inputMode="numeric" {match(form.getValues().splitMode)
step={1} .with('EVENLY', () => <></>)
/> .with('BY_SHARES', () => (
</FormControl> <>share(s)</>
<span className="text-sm"> ))
{match(form.getValues().splitMode) .with('BY_PERCENTAGE', () => <>%</>)
.with('EVENLY', () => <></>) .with('BY_AMOUNT', () => (
.with('BY_SHARES', () => <>share(s)</>) <>{group.currency}</>
.with('BY_PERCENTAGE', () => <>%</>) ))
.with('BY_AMOUNT', () => ( .exhaustive()}
<>{group.currency}</> </span>
)) </div>
.exhaustive()} <FormMessage className="float-right" />
</span> </div>
</div> )}
)} />
)}
</div> </div>
) )
}} }}

View File

@@ -39,41 +39,95 @@ 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
title: z .object({
.string({ required_error: 'Please enter a title.' }) title: z
.min(2, 'Enter at least two characters.'), .string({ required_error: 'Please enter a title.' })
amount: z .min(2, 'Enter at least two characters.'),
.union( amount: z
[ .union(
z.number(), [
z.string().transform((value, ctx) => { z.number(),
const valueAsNumber = Number(value) z.string().transform((value, ctx) => {
if (Number.isNaN(valueAsNumber)) const valueAsNumber = Number(value)
if (Number.isNaN(valueAsNumber))
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid number.',
})
return Math.round(valueAsNumber * 100)
}),
],
{ required_error: 'You must enter an amount.' },
)
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.')
.refine(
(amount) => amount <= 10_000_000_00,
'The amount must be lower than 10,000,000.',
),
paidBy: z.string({ required_error: 'You must select a participant.' }),
paidFor: z
.array(
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({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Invalid number.', message: 'All shares must be higher than 0.',
}) })
return Math.round(valueAsNumber * 100) }
}), }
], }),
{ required_error: 'You must enter an amount.' }, splitMode: z
) .enum<SplitMode, [SplitMode, ...SplitMode[]]>(
.refine((amount) => amount >= 1, 'The amount must be higher than 0.01.') Object.values(SplitMode) as any,
.refine( )
(amount) => amount <= 10_000_000_00, .default('EVENLY'),
'The amount must be lower than 10,000,000.', isReimbursement: z.boolean(),
), })
paidBy: z.string({ required_error: 'You must select a participant.' }), .superRefine((expense, ctx) => {
paidFor: z let sum = 0
.array(z.object({ participant: z.string(), shares: z.number().int() })) for (const { participant, shares } of expense.paidFor) {
.min(1, 'The expense must be paid for at least one participant.'), sum += shares
splitMode: z }
.enum<SplitMode, [SplitMode, ...SplitMode[]]>( switch (expense.splitMode) {
Object.values(SplitMode) as any, case 'EVENLY':
) break // noop
.default('EVENLY'), case 'BY_SHARES':
isReimbursement: z.boolean(), 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>