| 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 { 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');
const [formData, setFormData] = useState<EntityCreateData>({
name: '',
description: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name?.trim()) {
newErrors.name = t('validation.nameRequired');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
setErrors({});
setIsSaving?.(true);
try {
const requestData = {
first_name: formData.firstName,
last_name: formData.lastName,
description: formData.description || null,
birth_date: formData.birthDate || null,
photo_url: formData.photoUrl || null,
email: formData.email || 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'));
} 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 }));
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');
const [formData, setFormData] = useState<EntityUpdateData>({
name: item.name,
description: item.description || '',
});
const handleSubmit = async () => {
const requestData = { };
const response = await fetch(`/api/entity-names/${item.id}`, {
method: 'PUT',
headers: { },
body: JSON.stringify(requestData),
});
if (response.ok) {
onSuccess(t('toast.updated'));
}
};
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'));
onSuccess(t('toast.updated'));
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
handleSubmit for 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's ctx.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-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.).
const requestData = {
birth_date: formData.birthDate,
email: formData.email,
};
const requestData = {
birth_date: formData.birthDate || null,
email: formData.email || null,
status: formData.status,
};
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:
price: book.price,
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;
}
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(() => {});
}, []);
{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:
npx tsc --noEmit
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.