Smart Form

PreviousNext

A smart form component

Docs
rigiduicomponent

Preview

Loading preview…
r/new-york/smart-form/smart-form.tsx
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client"

import React from 'react'
import { useForm, UseFormReturn, FieldPath, FieldValues } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Card, CardContent } from '@/components/ui/card'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'

export interface FormFieldOption {
  value: string
  label: string
}

export interface SmartFormProps<T extends FieldValues = FieldValues> {
  schema: z.ZodSchema<T>
  mutationFn: (data: T) => Promise<any>
  queryKey?: string[]
  mode?: 'create' | 'edit'
  defaultValues?: Partial<T>
  onSuccess?: (data: any) => void
  onError?: (error: Error) => void
  submitText?: string
  className?: string
  children: (form: UseFormReturn<T>) => React.ReactNode
}

export interface SmartFormFieldProps<T extends FieldValues = FieldValues> {
  form: UseFormReturn<T>
  name: FieldPath<T>
  type: 'text' | 'email' | 'password' | 'number' | 'textarea' | 'select' | 'checkbox' | 'radio' | 'color'
  label?: string
  placeholder?: string
  description?: string
  options?: FormFieldOption[]
  disabled?: boolean
  className?: string
}

export interface FormSectionProps {
  title: string
  description?: string
  children: React.ReactNode
  className?: string
}

export function SmartForm<T extends FieldValues>({
  schema,
  mutationFn,
  queryKey = [],
  mode = 'create',
  defaultValues,
  onSuccess,
  onError,
  submitText,
  className,
  children
}: SmartFormProps<T>) {
  const queryClient = useQueryClient()

  const form = useForm<T>({
    // @ts-ignore // TODO: fix this
    resolver: zodResolver(schema),
    defaultValues: (defaultValues || {}) as any
  })

  const mutation = useMutation({
    mutationFn,
    onSuccess: (data) => {
      if (queryKey.length > 0) {
        queryClient.invalidateQueries({ queryKey })
      }
      form.reset()
      onSuccess?.(data)
    },
    onError: (error) => {
      onError?.(error instanceof Error ? error : new Error('Unknown error'))
    }
  })

  const onSubmit = (data: T) => {
    mutation.mutate(data)
  }

  return (
    <Card className={cn("w-full", className)}>
      <Form {...form}>
        <form onSubmit={form.handleSubmit(onSubmit)}>
          <CardContent className="p-6">
            <div className="space-y-6">
              {children(form)}
            </div>

            <div className="flex items-center justify-end pt-6 mt-6 border-t">
              <Button
                type="submit"
                disabled={mutation.isPending}
                className="min-w-32"
              >
                {mutation.isPending ? (
                  <>
                    <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                    {mode === 'create' ? 'Creating...' : 'Updating...'}
                  </>
                ) : (
                  <>
                    {mutation.isSuccess ? (
                      <CheckCircle className="mr-2 h-4 w-4" />
                    ) : mutation.isError ? (
                      <AlertCircle className="mr-2 h-4 w-4" />
                    ) : null}
                    {submitText || (mode === 'create' ? 'Create' : 'Update')}
                  </>
                )}
              </Button>
            </div>
          </CardContent>
        </form>
      </Form>
    </Card>
  )
}

export function SmartFormField<T extends FieldValues>({
  form,
  name,
  type,
  label,
  placeholder,
  description,
  options = [],
  disabled,
  className
}: SmartFormFieldProps<T>) {
  const renderField = (field: any) => {
    switch (type) {
      case 'text':
      case 'email':
      case 'password':
        return (
          <Input
            type={type}
            placeholder={placeholder}
            disabled={disabled}
            {...field}
            value={field.value || ''}
          />
        )

      case 'number':
        return (
          <Input
            type="number"
            placeholder={placeholder}
            disabled={disabled}
            {...field}
            value={field.value || ''}
            onChange={(e) => {
              const value = e.target.value
              field.onChange(value === '' ? undefined : Number(value))
            }}
          />
        )

      case 'textarea':
        return (
          <Textarea
            placeholder={placeholder}
            disabled={disabled}
            rows={3}
            {...field}
            value={field.value || ''}
          />
        )

      case 'select':
        return (
          <Select onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
            <SelectTrigger>
              <SelectValue placeholder={placeholder || `Select ${label}`} />
            </SelectTrigger>
            <SelectContent>
              {options.map((option) => (
                <SelectItem key={option.value} value={option.value}>
                  {option.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        )

      case 'checkbox':
        return (
          <div className="flex items-center space-x-2">
            <Checkbox
              checked={field.value || false}
              onCheckedChange={field.onChange}
              disabled={disabled}
            />
            <label className="text-sm font-normal leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
              {label}
            </label>
          </div>
        )

      case 'radio':
        return (
          <RadioGroup onValueChange={field.onChange} defaultValue={field.value} disabled={disabled}>
            {options.map((option) => (
              <div key={option.value} className="flex items-center space-x-2">
                <RadioGroupItem value={option.value} id={`${name}-${option.value}`} />
                <label
                  htmlFor={`${name}-${option.value}`}
                  className="text-sm font-normal cursor-pointer"
                >
                  {option.label}
                </label>
              </div>
            ))}
          </RadioGroup>
        )

      case 'color':
        return (
          <div className="flex items-center space-x-2">
            <Input
              type="color"
              disabled={disabled}
              className="w-12 h-10 p-1 border rounded"
              value={field.value || '#000000'}
              onChange={(e) => field.onChange(e.target.value)}
            />
            <Input
              type="text"
              placeholder="#000000"
              disabled={disabled}
              className="flex-1"
              value={field.value || ''}
              onChange={(e) => field.onChange(e.target.value)}
            />
          </div>
        )

      default:
        return null
    }
  }

  if (type === 'checkbox') {
    return (
      <FormField
        control={form.control}
        name={name}
        render={({ field }) => (
          <FormItem className={cn("space-y-2", className)}>
            <FormControl>
              {renderField(field)}
            </FormControl>
            {description && <FormDescription>{description}</FormDescription>}
            <FormMessage />
          </FormItem>
        )}
      />
    )
  }

  return (
    <FormField
      control={form.control}
      name={name}
      render={({ field }) => (
        <FormItem className={className}>
          {label && <FormLabel>{label}</FormLabel>}
          <FormControl>
            {renderField(field)}
          </FormControl>
          {description && <FormDescription>{description}</FormDescription>}
          <FormMessage />
        </FormItem>
      )}
    />
  )
}

export function FormSection({ title, description, children, className }: FormSectionProps) {
  return (
    <div className={cn("space-y-4", className)}>
      <div className="space-y-1">
        <h3 className="text-lg font-medium">{title}</h3>
        {description && (
          <p className="text-sm text-muted-foreground">{description}</p>
        )}
      </div>
      <div className="space-y-4">
        {children}
      </div>
    </div>
  )
}

export function ConditionalField<T extends FieldValues>({
  form,
  when,
  equals,
  children
}: {
  form: UseFormReturn<T>
  when: FieldPath<T>
  equals: any
  children: React.ReactNode
}) {
  const watchedValue = form.watch(when)

  if (watchedValue === equals) {
    return <>{children}</>
  }

  return null
}

Installation

npx shadcn@latest add @rigidui/smart-form

Usage

import { SmartForm } from "@/components/smart-form"
<SmartForm />