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
- forwardRef + useImperativeHandle: MUST expose
handleSubmitfor CrudPage drawer - displayName: MUST set
ComponentName.displayName = 'ComponentName' - useTranslation: MUST use entity namespace for all user-visible strings
- camelCase -> snake_case in requestData: Multi-word fields MUST be converted before API call (
firstName->first_name,birthDate->birth_date). Goravel'sctx.Request().Bind()only works with snake_case JSON keys. - X-Requested-With header: Required for CSRF/auth
- Icon layout: Every field wrapped in
flex items-start gap-3with 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
useEffectwithpageSize=100for 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
selectRelatedtranslation 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.