Last active
April 21, 2025 02:59
-
-
Save merthanmerter/beaafd1602873aac5aef457f0796a808 to your computer and use it in GitHub Desktop.
Next Safe Actions & React Hook Form - Reusable form component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// actions.ts | |
export const addUpdateProject = privateActionClient | |
.metadata({ actionName: 'addUpdateProject', description: 'Add or update a project.' }) | |
.schema(projectsInsertSchema) | |
.action( | |
async ({ parsedInput, ctx }) => { | |
const { id, ...rest } = parsedInput | |
const [{ insertId }] = await ctx.db | |
.insert(projects) | |
.values(parsedInput) | |
.onDuplicateKeyUpdate({ set: rest }) | |
.execute() | |
return { action: id ? 'update' : 'insert', id: id ?? insertId } | |
}, | |
{ | |
onSuccess: (args) => redirect(`/projects/${args.data?.id}`), | |
} | |
) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// definitions.ts | |
export const projectsInsertSchema = createInsertSchema(projects) | |
.extend({ | |
id: z.number().optional(), | |
name: z.string().min(1, MESSAGES.name_is_required).max(50, MESSAGES.name_max_length), | |
description: z.string().min(1, MESSAGES.description_is_required).max(100, MESSAGES.description_max_length), | |
}) | |
.pick({ | |
id: true, | |
name: true, | |
description: true, | |
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// custom form component | |
'use client' | |
import { | |
Form as Form_, | |
FormControl as FormControl_, | |
FormDescription as FormDescription_, | |
FormField as FormField_, | |
FormItem as FormItem_, | |
FormLabel as FormLabel_, | |
FormMessage as FormMessage_, | |
} from '@/components/ui/form' | |
import { FormHTMLAttributes } from 'react' | |
import { ControllerProps, FieldPath, FieldValues, UseFormReturn } from 'react-hook-form' | |
import { Button } from '@/components/ui/button' | |
import { ChevronLeft } from 'lucide-react' | |
import { usePathname, useRouter } from 'next/navigation' | |
import React from 'react' | |
type FormRootProps<T extends FieldValues> = Omit<FormHTMLAttributes<HTMLFormElement>, 'action'> & { | |
form: UseFormReturn<T> | |
action?: (data: T) => void | undefined | |
} | |
function FormRoot<T extends FieldValues>(props: FormRootProps<T>) { | |
const { form, action, children, className, ...rest } = props | |
const handleSubmitAction: () => void = form.handleSubmit((data: T) => { | |
if (action) { | |
action(data) | |
} | |
}) | |
return ( | |
<Form_<T> {...form}> | |
<form action={handleSubmitAction} className={className} {...rest}> | |
{children} | |
</form> | |
</Form_> | |
) | |
} | |
type CustomFormFieldProps< | |
TFieldValues extends FieldValues = FieldValues, | |
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> | |
> = ControllerProps<TFieldValues, TName> & { | |
label: string | |
description?: string | |
} | |
const FormField = < | |
TFieldValues extends FieldValues = FieldValues, | |
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> | |
>( | |
props: CustomFormFieldProps<TFieldValues, TName> | |
) => { | |
const { label, description, render, ...rest } = props | |
return ( | |
<FormField_ | |
{...rest} | |
render={(field) => ( | |
<FormItem_> | |
<FormLabel_>{label}</FormLabel_> | |
<FormControl_>{render(field)}</FormControl_> | |
{description && <FormDescription_>{description}</FormDescription_>} | |
<FormMessage_ /> | |
</FormItem_> | |
)} | |
/> | |
) | |
} | |
function FormHeader({ | |
title, | |
removeBackButton = false, | |
children, | |
}: { | |
title?: string | |
removeBackButton?: boolean | |
children?: React.ReactNode | |
}) { | |
const router = useRouter() | |
const pathname = usePathname() | |
return ( | |
<div className='inline-flex gap-2 items-center'> | |
{!removeBackButton && ( | |
<Button | |
onClick={() => router.push(pathname?.split('/').slice(0, -1).join('/'))} | |
type='button' | |
aria-label='Back' | |
variant='outline' | |
size='icon' | |
className='h-7 w-7'> | |
<ChevronLeft className='h-4 w-4' /> | |
<span className='sr-only'>Back</span> | |
</Button> | |
)} | |
{title && <legend className='font-bold text-lg'>{title}</legend>} | |
{children} | |
</div> | |
) | |
} | |
export const Form = { | |
Root: FormRoot, | |
Header: FormHeader, | |
Field: FormField, | |
} | |
export default Form |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// usage example | |
'use client' | |
import Form from '@/components/composables/form' | |
import { Button } from '@/components/ui/button' | |
import { Input } from '@/components/ui/input' | |
import { MESSAGES } from '@/constants' | |
import { zodResolver } from '@hookform/resolvers/zod' | |
import { useHookFormAction } from '@next-safe-action/adapter-react-hook-form/hooks' | |
import { InferSafeActionFnResult } from 'next-safe-action/.' | |
import { toast } from 'sonner' | |
import { addUpdateProject, getProject } from '../actions' | |
import { projectsInsertSchema } from '../definitions' | |
export default function ProjectForm({ | |
initialValues, | |
}: { | |
initialValues?: InferSafeActionFnResult<typeof getProject>['data'] | |
}) { | |
const { | |
form, | |
action: { execute, status }, | |
} = useHookFormAction(addUpdateProject, zodResolver(projectsInsertSchema), { | |
actionProps: { | |
onSuccess: ({ data }) => { | |
toast.success(data?.action === 'update' ? MESSAGES.record_updated : MESSAGES.record_created) | |
}, | |
onError: ({ error }) => { | |
toast.error(error?.serverError) | |
}, | |
}, | |
formProps: { | |
values: { id: initialValues?.id, name: initialValues?.name ?? '', description: initialValues?.description ?? '' }, | |
}, | |
}) | |
return ( | |
<Form.Root | |
form={form} | |
action={execute} | |
className='flex gap-4 flex-col border p-4 rounded-sm shadow-sm bg-background'> | |
<Form.Header title='Project'> | |
<Button className='ml-auto' disabled={status === 'executing'} type='submit' size='sm'> | |
{initialValues?.id ? 'Update' : 'Create'} | |
</Button> | |
</Form.Header> | |
<Form.Field | |
control={form.control} | |
name='name' | |
label='Name' | |
description='Project name is a unique identifier.' | |
render={({ field }) => <Input {...field} />} | |
/> | |
<Form.Field | |
control={form.control} | |
name='description' | |
label='Description' | |
description='Provide a brief description of the project.' | |
render={({ field }) => <Input {...field} />} | |
/> | |
</Form.Root> | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment