النماذج والتحقق
يستخدم FORGE React Hook Form لإدارة حالة النماذج و Zod للتحقق من الصحة المبني على المخططات. هذا المزيج يوفر نماذج type-safe حيث تُستنتج أنواع TypeScript مباشرة من مخطط التحقق -- مما يُزيل أي انحراف بين قواعد التحقق وتعريفات الأنواع.
نظرة عامة على النمط
كل نموذج في التطبيقات المُولّدة يتبع نمطاً من أربع خطوات:
┌─────────────────────┐
│ 1. تعريف مخطط │ المخطط يُحدد الشكل، الأنواع،
│ Zod │ وقواعد التحقق
├─────────────────────┤
│ 2. استنتاج نوع │ z.infer<typeof schema>
│ TypeScript │ يُولّد نوع بيانات النموذج
├─────────────────────┤
│ 3. إنشاء النموذج │ useForm + zodResolver
│ بـ useForm │ يربط المخطط بالنموذج
├─────────────────────┤
│ 4. العرض بمكونات │ مكونات shadcn/ui للنماذج
│ النماذج │ تتعامل مع العرض والأخطاء
└─────────────────────┘الخطوة 1: تعريف مخطط Zod
جميع مخططات التحقق مُركزة في lib/validations.ts. هذا مثال لإنشاء مستخدم:
// lib/validations.ts
import { z } from "zod";
export const userCreateSchema = z
.object({
name: z
.string()
.min(1, "الاسم مطلوب")
.min(2, "يجب أن يكون الاسم حرفين على الأقل"),
email: z
.string()
.min(1, "البريد الإلكتروني مطلوب")
.email("يرجى إدخال بريد إلكتروني صالح"),
password: z
.string()
.min(1, "كلمة المرور مطلوبة")
.min(8, "يجب أن تكون كلمة المرور 8 أحرف على الأقل")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"يجب أن تحتوي كلمة المرور على حرف كبير وحرف صغير ورقم على الأقل"
),
password_confirmation: z
.string()
.min(1, "يرجى تأكيد كلمة المرور"),
role_ids: z
.array(z.string())
.min(1, "دور واحد على الأقل مطلوب"),
is_active: z.boolean().default(true),
})
.refine((data) => data.password === data.password_confirmation, {
message: "كلمتا المرور غير متطابقتين",
path: ["password_confirmation"],
});
export type UserCreateFormData = z.infer<typeof userCreateSchema>;نصيحة
طريقة .refine() تُضيف تحققاً عبر الحقول. في هذا المثال، تضمن أن password_confirmation يطابق password. خيار path يتحكم بأي حقل يعرض رسالة الخطأ.
أنماط المخططات الشائعة
// Optional field with default value
bio: z.string().optional().default("")
// Enum field
status: z.enum(["draft", "published", "archived"])
// Nullable field
parent_id: z.string().uuid().nullable()
// URL field (allows empty string)
url: z.string().url("URL غير صالح").or(z.literal(""))
// Number with constraints (coerce from string input)
sort_order: z.coerce.number().int().min(0).default(0)
// Conditional validation
const passwordSchema = z.object({
current_password: z.string().min(1),
new_password: z.string().min(8),
new_password_confirmation: z.string().min(1),
}).refine((data) => data.new_password === data.new_password_confirmation, {
message: "كلمتا المرور غير متطابقتين",
path: ["new_password_confirmation"],
});الخطوة 2: استنتاج نوع TypeScript
أداة z.infer تستخرج نوع TypeScript من المخطط. هذا النوع يُستخدم كمعامل generic لـ useForm، مما يضمن type safety عبر النموذج:
// Type inferred from the schema above:
type UserCreateFormData = {
name: string;
email: string;
password: string;
password_confirmation: string;
role_ids: string[];
is_active: boolean;
};الخطوة 3: ربط المخطط بالنموذج
استخدم useForm من React Hook Form مع zodResolver لربط المخطط بتحقق النموذج:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { userCreateSchema, type UserCreateFormData } from "@/lib/validations";
const form = useForm<UserCreateFormData>({
resolver: zodResolver(userCreateSchema),
defaultValues: {
name: "",
email: "",
password: "",
password_confirmation: "",
role_ids: [],
is_active: true,
},
});الخطوة 4: بناء واجهة النموذج
إدخال نصي
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
<div className="space-y-2">
<Label htmlFor="name">الاسم</Label>
<Input
id="name"
placeholder="الاسم الكامل"
{...form.register("name")}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>قائمة منسدلة Select
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Controller } from "react-hook-form";
<Controller
control={form.control}
name="status"
render={({ field }) => (
<div className="space-y-2">
<Label>الحالة</Label>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="اختر الحالة" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">مسودة</SelectItem>
<SelectItem value="published">منشور</SelectItem>
<SelectItem value="archived">مؤرشف</SelectItem>
</SelectContent>
</Select>
{form.formState.errors.status && (
<p className="text-sm text-destructive">
{form.formState.errors.status.message}
</p>
)}
</div>
)}
/>منطقة نص Textarea
import { Textarea } from "@/components/ui/textarea";
<div className="space-y-2">
<Label htmlFor="description">الوصف</Label>
<Textarea
id="description"
rows={4}
placeholder="أدخل الوصف"
{...form.register("description")}
/>
{form.formState.errors.description && (
<p className="text-sm text-destructive">
{form.formState.errors.description.message}
</p>
)}
</div>مفتاح Switch (Boolean Toggle)
import { Switch } from "@/components/ui/switch";
<div className="flex items-center space-x-2">
<Switch
id="is_active"
checked={form.watch("is_active")}
onCheckedChange={(checked) => form.setValue("is_active", checked)}
/>
<Label htmlFor="is_active">نشط</Label>
</div>مجموعة Checkbox
import { Checkbox } from "@/components/ui/checkbox";
import { Controller } from "react-hook-form";
<Controller
control={form.control}
name="role_ids"
render={({ field }) => (
<div className="space-y-2">
<Label>الأدوار</Label>
{roles.map((role) => (
<div key={role.id} className="flex items-center space-x-2">
<Checkbox
checked={field.value.includes(role.id)}
onCheckedChange={(checked) => {
const updated = checked
? [...field.value, role.id]
: field.value.filter((id) => id !== role.id);
field.onChange(updated);
}}
/>
<Label>{role.display_name}</Label>
</div>
))}
{form.formState.errors.role_ids && (
<p className="text-sm text-destructive">
{form.formState.errors.role_ids.message}
</p>
)}
</div>
)}
/>رفع الملفات في النماذج
الملفات تُرفع لنقطة نهاية media API ويُخزّن الـ URL المُرجع في النموذج:
const [preview, setPreview] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Show preview immediately
setPreview(URL.createObjectURL(file));
// Upload to API
setIsUploading(true);
try {
const response = await api.upload<{
data: { url: string; id: string };
}>("/media", file, "file");
form.setValue("featured_image_url", response.data.url);
toast.success("تم رفع الصورة");
} catch (error) {
toast.error(getErrorMessage(error));
setPreview(null);
} finally {
setIsUploading(false);
}
};
// In JSX
<div className="space-y-2">
<Label>الصورة المميزة</Label>
{preview && (
<img src={preview} alt="معاينة" className="h-40 w-full rounded-md object-cover" />
)}
<Input type="file" accept="image/*" onChange={handleFileChange} disabled={isUploading} />
{isUploading && <p className="text-sm text-muted-foreground">جارٍ الرفع...</p>}
</div>حقول النماذج متعددة اللغات
المحتوى والموارد الأخرى القابلة للترجمة تستخدم واجهة بتبويبات للإدخال متعدد اللغات. كل تبويب لغة يحتوي على نفس مجموعة الحقول، والنموذج يُدير كائن translations مُفتّح بكود اللغة:
import { TranslationsInput } from "@/components/ui/translations-input";
// Translations object shape:
// {
// "en": { display_name: "Admin", description: "Full access" },
// "ar": { display_name: "Admin (AR)", description: "Full access (AR)" }
// }
<TranslationsInput
value={formData.translations || {}}
onChange={(translations) =>
setFormData({ ...formData, translations })
}
fields={["display_name", "description"]}
/>لصفحات المحتوى مع حقول نص غني، استخدم ContentTranslationsInput الذي يوفر تبويبات لكل لغة للعنوان والملخص والتفاصيل (محرر نص غني) ونص الزر وحقول SEO:
import { ContentTranslationsInput } from "@/components/ui/content-translations-input";
<ContentTranslationsInput
value={formData.translations || {}}
onChange={(translations) =>
setFormData({ ...formData, translations })
}
/>تحذير
حقول الترجمة تُخزّن كـ JSONB في قاعدة البيانات. عند إرسال نموذج بترجمات، تأكد من تضمين جميع اللغات -- وليس فقط التي حررها المستخدم. مفاتيح اللغات المفقودة ستمسح الترجمات الموجودة لتلك اللغة.
عرض الأخطاء
أخطاء مستوى الحقل
كل حقل يعرض رسالة خطأه مباشرة أسفل المدخل:
{errors.fieldName && (
<p className="text-sm text-destructive">
{errors.fieldName.message}
</p>
)}أخطاء التحقق من جانب الخادم
لأخطاء API (المُرجعة كـ errors في الاستجابة)، عيّنها على حقول النموذج المحددة:
try {
await api.post("/admin/users", data);
} catch (error) {
if (isApiError(error) && error.errors) {
// Map server validation errors to form fields
Object.entries(error.errors).forEach(([field, messages]) => {
form.setError(field as keyof UserCreateFormData, {
message: messages[0],
});
});
} else {
toast.error(getErrorMessage(error));
}
}أداة getErrorMessage تستخرج رسالة قابلة للقراءة من استجابة خطأ API:
export function getErrorMessage(error: unknown): string {
if (error instanceof AxiosError && error.response?.data?.message) {
return error.response.data.message;
}
if (error instanceof Error) {
return error.message;
}
return "حدث خطأ غير متوقع";
}معالجة الإرسال مع حالات التحميل
كل نموذج يتتبع حالة الإرسال لتعطيل زر الإرسال وإظهار ملاحظات:
const [isSubmitting, setIsSubmitting] = React.useState(false);
const onSubmit = async (data: FormData) => {
setIsSubmitting(true);
try {
await api.post("/admin/users", data);
toast.success("تم إنشاء المستخدم بنجاح");
router.push("/users");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="me-2 h-4 w-4 animate-spin" />
جارٍ الحفظ...
</>
) : (
"حفظ"
)}
</Button>أنماط النماذج
نموذج الإنشاء
export default function CreateUserPage() {
const { t } = useTranslation();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<UserCreateFormData>({
resolver: zodResolver(userCreateSchema),
defaultValues: {
name: "", email: "", password: "",
password_confirmation: "", role_ids: [], is_active: true,
},
});
const onSubmit = async (data: UserCreateFormData) => {
setIsSubmitting(true);
try {
await api.post("/admin/users", data);
toast.success(t("admin.users.User_created_successfully"));
router.push("/users");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>{t("admin.users.Add_User")}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Fields go here */}
<Button type="submit" disabled={isSubmitting}>
{t("common.Create")}
</Button>
</form>
</CardContent>
</Card>
);
}نموذج التعديل
نماذج التعديل تملأ defaultValues مسبقاً من البيانات المجلوبة باستخدام form.reset():
export default function EditUserPage({ params }: { params: { id: string } }) {
const { t } = useTranslation();
const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<UserUpdateFormData>({
resolver: zodResolver(userUpdateSchema),
});
// Fetch existing data and populate form
useEffect(() => {
const fetchUser = async () => {
try {
const response = await api.get<{ data: User }>(
`/admin/users/${params.id}`
);
const user = response.data;
form.reset({
name: user.name,
email: user.email,
is_active: user.is_active,
role_ids: user.roles.map((r) => r.id),
});
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsLoading(false);
}
};
fetchUser();
}, [params.id, form]);
const onSubmit = async (data: UserUpdateFormData) => {
setIsSubmitting(true);
try {
await api.put(`/admin/users/${params.id}`, data);
toast.success(t("admin.users.User_updated_successfully"));
router.push("/users");
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
if (isLoading) return <Skeleton className="h-96 w-full" />;
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Same fields as create, minus password */}
</form>
);
}نموذج الإعدادات
نماذج الإعدادات تستخدم تخطيطاً مُجمّعاً حيث كل مجموعة هي بطاقة منفصلة:
export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsGroup[]>([]);
const handleSave = async (group: string, values: Record<string, string>) => {
try {
await api.put(`/admin/settings/${group}`, { settings: values });
toast.success("تم حفظ الإعدادات");
} catch (error) {
toast.error(getErrorMessage(error));
}
};
return (
<div className="space-y-8">
{settings.map((group) => (
<Card key={group.key}>
<CardHeader>
<CardTitle>{group.label}</CardTitle>
</CardHeader>
<CardContent>
<SettingsForm
settings={group.settings}
onSave={(values) => handleSave(group.key, values)}
/>
</CardContent>
</Card>
))}
</div>
);
}المخططات المتاحة
يُولّد FORGE مخططات تحقق لكل نموذج في التطبيق:
مخططات المصادقة
| المخطط | الحقول | يُستخدم في |
|---|---|---|
loginSchema | email, password | صفحة تسجيل الدخول |
registerSchema | name, email, password, password_confirmation | صفحة التسجيل |
forgotPasswordSchema | صفحة نسيت كلمة المرور | |
resetPasswordSchema | email, token, password, password_confirmation | صفحة إعادة تعيين كلمة المرور |
otpSendSchema | mobile | تسجيل دخول OTP (إرسال الكود) |
otpVerifySchema | mobile, code (6 أرقام) | تسجيل دخول OTP (التحقق من الكود) |
مخططات الملف الشخصي
| المخطط | الحقول | يُستخدم في |
|---|---|---|
profileUpdateSchema | name | صفحة تعديل الملف الشخصي |
passwordChangeSchema | current_password, password, password_confirmation | صفحة تغيير كلمة المرور |
emailUpdateSchema | email, password | تحديث البريد الإلكتروني |
mobileUpdateSchema | mobile | تحديث رقم الجوال |
مخططات الإدارة
| المخطط | الحقول | يُستخدم في |
|---|---|---|
userCreateSchema | name, email, password, password_confirmation, role_ids, is_active | إنشاء مستخدم |
userUpdateSchema | name, email, role_ids, is_active | تعديل مستخدم |
roleSchema | name, display_name, description, permission_ids | إنشاء/تعديل دور |
apiKeySchema | name, permissions, expires_at | إنشاء مفتاح API |
settingsSchema | settings (سجل مفتاح-قيمة) | إعدادات التطبيق |
ما التالي
- جلب البيانات — تحميل بيانات النماذج والإرسال بعميل API
- المكونات — توثيق المكونات المتعلقة بالنماذج
- التدويل — حقول النماذج القابلة للترجمة