Skip to content

النماذج والتحقق

يستخدم 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. هذا مثال لإنشاء مستخدم:

typescript
// 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 يتحكم بأي حقل يعرض رسالة الخطأ.

أنماط المخططات الشائعة

typescript
// 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 عبر النموذج:

typescript
// 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 لربط المخطط بتحقق النموذج:

typescript
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: بناء واجهة النموذج

إدخال نصي

tsx
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

tsx
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

tsx
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)

tsx
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

tsx
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 المُرجع في النموذج:

tsx
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 مُفتّح بكود اللغة:

tsx
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:

tsx
import { ContentTranslationsInput } from "@/components/ui/content-translations-input";

<ContentTranslationsInput
  value={formData.translations || {}}
  onChange={(translations) =>
    setFormData({ ...formData, translations })
  }
/>

تحذير

حقول الترجمة تُخزّن كـ JSONB في قاعدة البيانات. عند إرسال نموذج بترجمات، تأكد من تضمين جميع اللغات -- وليس فقط التي حررها المستخدم. مفاتيح اللغات المفقودة ستمسح الترجمات الموجودة لتلك اللغة.

عرض الأخطاء

أخطاء مستوى الحقل

كل حقل يعرض رسالة خطأه مباشرة أسفل المدخل:

tsx
{errors.fieldName && (
  <p className="text-sm text-destructive">
    {errors.fieldName.message}
  </p>
)}

أخطاء التحقق من جانب الخادم

لأخطاء API (المُرجعة كـ errors في الاستجابة)، عيّنها على حقول النموذج المحددة:

typescript
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:

typescript
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 "حدث خطأ غير متوقع";
}

معالجة الإرسال مع حالات التحميل

كل نموذج يتتبع حالة الإرسال لتعطيل زر الإرسال وإظهار ملاحظات:

tsx
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>

أنماط النماذج

نموذج الإنشاء

tsx
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():

tsx
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>
  );
}

نموذج الإعدادات

نماذج الإعدادات تستخدم تخطيطاً مُجمّعاً حيث كل مجموعة هي بطاقة منفصلة:

tsx
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 مخططات تحقق لكل نموذج في التطبيق:

مخططات المصادقة

المخططالحقوليُستخدم في
loginSchemaemail, passwordصفحة تسجيل الدخول
registerSchemaname, email, password, password_confirmationصفحة التسجيل
forgotPasswordSchemaemailصفحة نسيت كلمة المرور
resetPasswordSchemaemail, token, password, password_confirmationصفحة إعادة تعيين كلمة المرور
otpSendSchemamobileتسجيل دخول OTP (إرسال الكود)
otpVerifySchemamobile, code (6 أرقام)تسجيل دخول OTP (التحقق من الكود)

مخططات الملف الشخصي

المخططالحقوليُستخدم في
profileUpdateSchemanameصفحة تعديل الملف الشخصي
passwordChangeSchemacurrent_password, password, password_confirmationصفحة تغيير كلمة المرور
emailUpdateSchemaemail, passwordتحديث البريد الإلكتروني
mobileUpdateSchemamobileتحديث رقم الجوال

مخططات الإدارة

المخططالحقوليُستخدم في
userCreateSchemaname, email, password, password_confirmation, role_ids, is_activeإنشاء مستخدم
userUpdateSchemaname, email, role_ids, is_activeتعديل مستخدم
roleSchemaname, display_name, description, permission_idsإنشاء/تعديل دور
apiKeySchemaname, permissions, expires_atإنشاء مفتاح API
settingsSchemasettings (سجل مفتاح-قيمة)إعدادات التطبيق

ما التالي

Released under the MIT License.