inertia-form

star 15

Create create and edit form components for a Goravel entity. Uses forwardRef pattern with useTranslation hook for i18n labels, placeholders, validation messages, and toast notifications.

liwoo By liwoo schedule Updated 2/7/2026

name: inertia-form description: Create create and edit form components for a Goravel entity. Uses forwardRef pattern with useTranslation hook for i18n labels, placeholders, validation messages, and toast notifications. argument-hint: "[EntityName]" allowed-tools: Read, Write, Edit, Grep, Glob

Inertia Form Components (i18n-aware)

Create form components for $ARGUMENTS.

File 1: Create Form

resources/js/pages/<EntityName>/sections/<EntityName>CreateForm.tsx

Complete Template

import React, { useState, forwardRef, useImperativeHandle } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Separator } from '@/components/ui/separator';
import {
    Select, SelectContent, SelectItem,
    SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { CrudFormProps } from '@/types/crud';
import { EntityCreateData } from '@/types/entity';
// Import enum options if needed:
// import { StatusType, STATUS_OPTIONS } from '@/types/status_type';
import { User, FileText, Calendar, Tag } from 'lucide-react';

interface EntityCreateFormProps extends CrudFormProps {
    setIsSaving?: (saving: boolean) => void;
}

export const EntityCreateForm = forwardRef<any, EntityCreateFormProps>(({
    onSuccess,
    onError,
    onCancel,
    isLoading = false,
    setIsSaving,
}, ref) => {
    const { t } = useTranslation('entities');  // <-- entity namespace

    const [formData, setFormData] = useState<EntityCreateData>({
        name: '',
        description: '',
        // status: 'ACTIVE',
    });

    const [errors, setErrors] = useState<Record<string, string>>({});

    // ========================================
    // Validation (i18n error messages)
    // ========================================
    const validate = (): boolean => {
        const newErrors: Record<string, string> = {};

        if (!formData.name?.trim()) {
            newErrors.name = t('validation.nameRequired');
        }
        // if (!formData.email?.includes('@')) newErrors.email = t('validation.emailInvalid');
        // if (formData.price < 0) newErrors.price = t('validation.pricePositive');

        setErrors(newErrors);
        return Object.keys(newErrors).length === 0;
    };

    // ========================================
    // Submit Handler
    // ========================================
    const handleSubmit = async () => {
        if (!validate()) return;

        setErrors({});
        setIsSaving?.(true);

        try {
            // CRITICAL: Transform camelCase form state → snake_case for API
            // Goravel's ctx.Request().Bind() requires snake_case keys
            //
            // CRITICAL: Nullable fields MUST use `|| null` (NOT empty string "")
            // PostgreSQL rejects "" for date, numeric, and other typed columns.
            // Required fields keep their value as-is; optional fields use `|| null`.
            const requestData = {
                first_name: formData.firstName,       // required: keep value
                last_name: formData.lastName,         // required: keep value
                description: formData.description || null,  // optional: || null
                birth_date: formData.birthDate || null,     // optional date: || null (CRITICAL)
                photo_url: formData.photoUrl || null,       // optional: || null
                email: formData.email || null,              // optional: || null
            };

            const response = await fetch('/api/entity-names', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Accept': 'application/json',
                    'X-Requested-With': 'XMLHttpRequest',
                },
                body: JSON.stringify(requestData),
            });

            if (response.ok) {
                onSuccess(t('toast.created'));  // <-- i18n toast
            } else {
                const errorData = await response.json().catch(() => ({}));
                if (errorData.errors) {
                    setErrors(errorData.errors);
                }
                onError?.(errorData);
            }
        } catch (error) {
            onError?.(error);
        } finally {
            setIsSaving?.(false);
        }
    };

    useImperativeHandle(ref, () => ({ handleSubmit }));

    // ========================================
    // Render
    // ========================================
    return (
        <form onSubmit={(e) => e.preventDefault()} className="space-y-6">
            <div className="space-y-6">
                <div>
                    <h3 className="text-lg font-semibold mb-4 text-foreground">
                        {t('form.entityInfo')}
                    </h3>
                    <div className="space-y-4">

                        {/* Text Input Field */}
                        <div className="flex items-start gap-3">
                            <div className="p-2 rounded-lg bg-muted">
                                <User className="h-4 w-4 text-muted-foreground" />
                            </div>
                            <div className="flex-1 space-y-2">
                                <Label htmlFor="name">{t('form.name')}</Label>
                                <Input
                                    id="name"
                                    value={formData.name}
                                    onChange={(e) => setFormData({ ...formData, name: e.target.value })}
                                    placeholder={t('form.enterName')}
                                    className={errors.name ? 'border-destructive' : ''}
                                />
                                {errors.name && (
                                    <p className="text-sm text-destructive">{errors.name}</p>
                                )}
                            </div>
                        </div>

                        {/* Textarea Field */}
                        <div className="flex items-start gap-3">
                            <div className="p-2 rounded-lg bg-muted">
                                <FileText className="h-4 w-4 text-muted-foreground" />
                            </div>
                            <div className="flex-1 space-y-2">
                                <Label htmlFor="description">{t('form.description')}</Label>
                                <Textarea
                                    id="description"
                                    value={formData.description}
                                    onChange={(e) => setFormData({ ...formData, description: e.target.value })}
                                    placeholder={t('form.enterDescription')}
                                    rows={4}
                                    className="resize-none"
                                />
                            </div>
                        </div>

                        {/* SELECT/DROPDOWN TEMPLATE (for enums) */}
                        {/*
                        <div className="flex items-start gap-3">
                            <div className="p-2 rounded-lg bg-muted">
                                <Tag className="h-4 w-4 text-muted-foreground" />
                            </div>
                            <div className="flex-1 space-y-2">
                                <Label htmlFor="status">{t('form.status')}</Label>
                                <Select
                                    value={formData.status}
                                    onValueChange={(value) =>
                                        setFormData({ ...formData, status: value as StatusType })
                                    }
                                >
                                    <SelectTrigger className={errors.status ? 'border-destructive' : ''}>
                                        <SelectValue placeholder={t('form.selectStatus')} />
                                    </SelectTrigger>
                                    <SelectContent>
                                        <SelectItem value="ACTIVE">{t('status.active')}</SelectItem>
                                        <SelectItem value="INACTIVE">{t('status.inactive')}</SelectItem>
                                    </SelectContent>
                                </Select>
                                {errors.status && (
                                    <p className="text-sm text-destructive">{errors.status}</p>
                                )}
                            </div>
                        </div>
                        */}

                    </div>
                </div>
            </div>
        </form>
    );
});

EntityCreateForm.displayName = 'EntityCreateForm';

File 2: Edit Form

resources/js/pages/<EntityName>/sections/<EntityName>EditForm.tsx

Same pattern as create form with these differences:

import { useTranslation } from 'react-i18next';
import { CrudEditFormProps } from '@/types/crud';
import { Entity, EntityUpdateData } from '@/types/entity';

interface EntityEditFormProps extends CrudEditFormProps<Entity> {
    setIsSaving?: (saving: boolean) => void;
}

export const EntityEditForm = forwardRef<any, EntityEditFormProps>(({
    item,
    onSuccess,
    onError,
    onCancel,
    isLoading = false,
    setIsSaving,
}, ref) => {
    const { t } = useTranslation('entities');  // <-- same namespace

    // Initialize from existing item (handle dual-case)
    const [formData, setFormData] = useState<EntityUpdateData>({
        name: item.name,
        description: item.description || '',
        // status: item.status || item.status,
    });

    const handleSubmit = async () => {
        // ... validation with t('validation.*') ...

        const requestData = { /* snake_case fields */ };

        const response = await fetch(`/api/entity-names/${item.id}`, {
            method: 'PUT',  // PUT for updates
            headers: { /* same headers */ },
            body: JSON.stringify(requestData),
        });

        if (response.ok) {
            onSuccess(t('toast.updated'));  // <-- i18n toast
        }
    };

    return (
        <form onSubmit={(e) => e.preventDefault()} className="space-y-6">
            {/* ... same field layout as create, using t() for all labels ... */}

            <Separator />

            {/* Metadata Section */}
            <div>
                <h3 className="text-lg font-semibold mb-4 text-foreground">
                    {t('form.metadata')}
                </h3>
                <div className="grid grid-cols-2 gap-4 text-sm">
                    <div>
                        <p className="text-muted-foreground">{t('form.entityId')}</p>
                        <p className="font-medium text-foreground">#{item.id}</p>
                    </div>
                    <div>
                        <p className="text-muted-foreground">{t('form.created')}</p>
                        <p className="font-medium text-foreground">
                            {(item.createdAt || item.created_at)
                                ? new Date(item.createdAt || item.created_at || '').toLocaleDateString()
                                : '-'}
                        </p>
                    </div>
                    <div>
                        <p className="text-muted-foreground">{t('form.lastUpdated')}</p>
                        <p className="font-medium text-foreground">
                            {(item.updatedAt || item.updated_at)
                                ? new Date(item.updatedAt || item.updated_at || '').toLocaleDateString()
                                : '-'}
                        </p>
                    </div>
                </div>
            </div>
        </form>
    );
});

EntityEditForm.displayName = 'EntityEditForm';

i18n Patterns in Forms

Hook (inside component, NOT TFunction parameter)

Forms are React components, so they use useTranslation directly:

const { t } = useTranslation('entities');

Labels & Placeholders

<Label htmlFor="name">{t('form.name')}</Label>
<Input placeholder={t('form.enterName')} />

Validation Errors

newErrors.name = t('validation.nameRequired');
newErrors.price = t('validation.pricePositive');

Toast Messages

onSuccess(t('toast.created'));  // Create form
onSuccess(t('toast.updated'));  // Edit form

Select Dropdown Options (enum values)

<SelectItem value="ACTIVE">{t('status.active')}</SelectItem>
<SelectItem value="INACTIVE">{t('status.inactive')}</SelectItem>

Section Headers

<h3>{t('form.entityInfo')}</h3>
<h3>{t('form.metadata')}</h3>

Required Translation Keys

{
  "form": {
    "entityInfo": "Entity Information",
    "metadata": "Metadata",
    "name": "Name *",
    "description": "Description",
    "enterName": "Enter name",
    "enterDescription": "Enter description",
    "selectStatus": "Select status",
    "entityId": "Entity ID",
    "created": "Created",
    "lastUpdated": "Last Updated",
    "notSpecified": "Not specified"
  },
  "validation": {
    "nameRequired": "Name is required"
  },
  "toast": {
    "created": "Entity created successfully",
    "updated": "Entity updated successfully"
  },
  "status": {
    "active": "Active",
    "inactive": "Inactive"
  }
}

Critical Requirements

  1. forwardRef + useImperativeHandle: MUST expose handleSubmit for CrudPage drawer
  2. displayName: MUST set ComponentName.displayName = 'ComponentName'
  3. useTranslation: MUST use entity namespace for all user-visible strings
  4. camelCase -> snake_case in requestData: Multi-word fields MUST be converted before API call (firstName -> first_name, birthDate -> birth_date). Goravel's ctx.Request().Bind() only works with snake_case JSON keys.
  5. X-Requested-With header: Required for CSRF/auth
  6. Icon layout: Every field wrapped in flex items-start gap-3 with icon box

Nullable Field Pattern (CRITICAL)

All optional/nullable fields MUST be converted to null before sending to the API. Sending empty string "" causes PostgreSQL errors for typed columns (date, numeric, etc.).

// WRONG — causes "invalid input syntax for type date" error
const requestData = {
    birth_date: formData.birthDate,       // sends "" when empty
    email: formData.email,                // sends "" when empty
};

// CORRECT — converts empty strings to null
const requestData = {
    birth_date: formData.birthDate || null,   // sends null when empty
    email: formData.email || null,            // sends null when empty
    status: formData.status,                  // required field: keep as-is
};

Rule: Required fields keep their value. Optional/nullable fields use || null.

Decimal/Price Field Formatting

When initializing edit form state for decimal fields, round to avoid floating-point display issues:

// WRONG — displays "23.989999771118164" in the input
price: book.price,

// CORRECT — displays "23.99"
price: book.price ? parseFloat(book.price.toFixed(2)) : 0,

Foreign Key Dropdown Pattern

When an entity has a foreign key to another entity (e.g., Book → Author), use a dropdown instead of free-text input:

interface RelatedOption {
  id: number;
  name: string; // or firstName + lastName, etc.
}

// Fetch related records on mount
const [relatedItems, setRelatedItems] = useState<RelatedOption[]>([]);

useEffect(() => {
    fetch('/api/related-entities?pageSize=100&sort=name&direction=ASC', {
        headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
    })
        .then(res => res.json())
        .then(res => {
            const items = res?.data?.data || res?.data || [];
            setRelatedItems(items.map((a: any) => ({ id: a.id, name: a.name })));
        })
        .catch(() => {});
}, []);

// In the render — dropdown with fallback to text input
{relatedItems.length > 0 ? (
    <Select
        value={formData.relatedId?.toString() || ''}
        onValueChange={(value) => {
            const selected = relatedItems.find(a => a.id === Number(value));
            setFormData({
                ...formData,
                relatedId: Number(value),
                relatedName: selected?.name || formData.relatedName,
            });
        }}
    >
        <SelectTrigger className={errors.relatedName ? 'border-destructive' : ''}>
            <SelectValue placeholder={t('form.selectRelated')} />
        </SelectTrigger>
        <SelectContent>
            {relatedItems.map((item) => (
                <SelectItem key={item.id} value={item.id.toString()}>
                    {item.name}
                </SelectItem>
            ))}
        </SelectContent>
    </Select>
) : (
    <Input
        value={formData.relatedName}
        onChange={(e) => setFormData({ ...formData, relatedName: e.target.value })}
        placeholder={t('form.enterRelatedName')}
        className={errors.relatedName ? 'border-destructive' : ''}
    />
)}

Key points:

  • Fetch related records in useEffect with pageSize=100 for reasonable dropdown size
  • Use res?.data?.data || res?.data || [] to handle nested paginated response
  • Set BOTH the FK ID (relatedId) and display name field on selection
  • Fallback to <Input> if API returns no results (graceful degradation)
  • Add selectRelated translation key to the i18n namespace

Verify

After creating both forms:

# TypeScript compiles (catches wrong prop types, missing imports)
npx tsc --noEmit

# Lint both forms
npx eslint "resources/js/pages/<EntityName>/sections/<EntityName>CreateForm.tsx" "resources/js/pages/<EntityName>/sections/<EntityName>EditForm.tsx" --max-warnings=0

Reference

See resources/js/pages/Books/sections/BookCreateForm.tsx and BookEditForm.tsx.

Install via CLI
npx skills add https://github.com/liwoo/goravel-inertia-tw-starter --skill inertia-form
Repository Details
star Stars 15
call_split Forks 10
navigation Branch main
article Path SKILL.md
More from Creator