Patterns de formulaires utilisant react-hook-form et Zod pour la validation, avec les composants Form de shadcn/ui.
Les formulaires suivent une hierarchie precise de composants. Chaque champ est encapsule dans un FormField qui gere la connexion avec react-hook-form.
La validation est definie avec un schema Zod. Le type TypeScript est infere automatiquement a partir du schema.
import { z } from "zod";
// Definition du schema de validation
const profileSchema = z.object({
username: z
.string()
.min(3, "Le pseudo doit contenir au moins 3 caracteres")
.max(20, "Le pseudo ne peut pas depasser 20 caracteres")
.regex(
/^[a-zA-Z0-9_-]+$/,
"Seuls les lettres, chiffres, tirets et underscores sont autorises"
),
email: z
.string()
.email("Adresse email invalide"),
bio: z
.string()
.max(160, "La bio ne peut pas depasser 160 caracteres")
.optional(),
wcaId: z
.string()
.regex(/^\d{4}[A-Z]{4}\d{2}$/, "Format WCA ID invalide (ex: 2023DUPO01)")
.optional()
.or(z.literal("")),
mainCube: z.enum(["3x3", "2x2", "4x4", "5x5", "pyraminx", "megaminx", "skewb", "sq1"]),
});
// Type infere automatiquement
type ProfileFormData = z.infer<typeof profileSchema>;Voici le formulaire reel utilise dans src/components/algorithms/create-algorithm-form.tsx pour soumettre un nouvel algorithme a la base de donnees. Il illustre le schema Zod avec validation metier, le layout en grille responsive pour les champs, et la soumission avec feedback via toast.
"use client"
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Form, FormControl, FormDescription,
FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form'
import {
Select, SelectContent, SelectItem,
SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { useValidateAlgorithm } from '@/hooks/use-validate-algorithm'
import { useToast } from '@/hooks/use-toast'
import { Loader2, AlertCircle } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
// Schema Zod avec validations metier speedcubing
const algorithmSchema = z.object({
name: z.string().min(3, 'Le nom doit contenir au moins 3 caracteres'),
notation: z.string().min(1, 'La notation est requise'),
description: z.string().min(50, 'La description doit contenir au moins 50 caracteres'),
puzzle_type: z.string().min(1, 'Le type de puzzle est requis'),
method: z.string().min(1, 'La methode est requise'),
set_name: z.string().min(1, 'Le set est requis'),
difficulty: z.enum(['beginner', 'intermediate', 'advanced', 'expert']),
scramble: z.string().min(1, 'Le scramble est requis'),
solution: z.string().min(1, 'La solution est requise'),
fingertricks: z.string().optional(),
notes: z.string().optional(),
})
type AlgorithmFormData = z.infer<typeof algorithmSchema>
export function CreateAlgorithmForm({ onSuccess }: { onSuccess?: () => void }) {
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
const validateMutation = useValidateAlgorithm()
const form = useForm<AlgorithmFormData>({
resolver: zodResolver(algorithmSchema),
defaultValues: {
puzzle_type: '333',
method: 'cfop',
difficulty: 'intermediate',
},
})
const onSubmit = async (data: AlgorithmFormData) => {
setIsSubmitting(true)
try {
// 1. Valider via Edge Function Supabase
const validationResult = await validateMutation.mutateAsync(data)
if (validationResult.errors.length > 0) {
setIsSubmitting(false)
return
}
// 2. Inserer dans la DB
// ... insertion Supabase ...
toast({
title: 'Algorithme cree',
description: validationResult.data.auto_approved
? 'Votre algorithme a ete publie automatiquement'
: 'Votre algorithme est en attente de moderation',
})
form.reset()
onSuccess?.()
} catch (error) {
toast({
title: 'Erreur',
description: "Impossible de creer l'algorithme",
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Votre algorithme sera valide automatiquement si la notation est
correcte et qu'aucun doublon n'est detecte.
</AlertDescription>
</Alert>
{/* Grille 2 colonnes responsive pour les champs courts */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nom de l'algorithme</FormLabel>
<FormControl>
<Input placeholder="T-Perm" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="set_name"
render={({ field }) => (
<FormItem>
<FormLabel>Set</FormLabel>
<FormControl>
<Input placeholder="PLL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Champ pleine largeur pour la notation */}
<FormField
control={form.control}
name="notation"
render={({ field }) => (
<FormItem>
<FormLabel>Notation</FormLabel>
<FormControl>
<Input
placeholder="R U R' U' R' F R2 U' R' U' R U R' F'"
{...field}
/>
</FormControl>
<FormDescription>
Notation SiGN : R U F D L B, wide (Rw ou r),
tranches M E S, rotations x y z
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Grille 3 colonnes pour les selects */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<FormField
control={form.control}
name="puzzle_type"
render={({ field }) => (
<FormItem>
<FormLabel>Type de puzzle</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="333">3x3x3</SelectItem>
<SelectItem value="222">2x2x2</SelectItem>
<SelectItem value="444">4x4x4</SelectItem>
<SelectItem value="555">5x5x5</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel>Methode</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="cfop">CFOP</SelectItem>
<SelectItem value="roux">Roux</SelectItem>
<SelectItem value="zz">ZZ</SelectItem>
<SelectItem value="ortega">Ortega</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="difficulty"
render={({ field }) => (
<FormItem>
<FormLabel>Difficulte</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="beginner">Debutant</SelectItem>
<SelectItem value="intermediate">Intermediaire</SelectItem>
<SelectItem value="advanced">Avance</SelectItem>
<SelectItem value="expert">Expert</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Textarea avec compteur de caracteres */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Description detaillee... (min 50 caracteres)"
rows={4}
{...field}
/>
</FormControl>
<FormDescription>
{field.value?.length || 0} / 50 caracteres minimum
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{isSubmitting ? 'Validation en cours...' : 'Soumettre l\'algorithme'}
</Button>
</form>
</Form>
)
}Exemple complet d'un formulaire avec validation, gestion des erreurs et soumission.
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
const schema = z.object({
username: z.string().min(3, "Minimum 3 caracteres"),
email: z.string().email("Email invalide"),
bio: z.string().max(160).optional(),
mainCube: z.enum(["3x3", "2x2", "4x4", "5x5"]),
});
type FormData = z.infer<typeof schema>;
export function ProfileForm() {
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
username: "",
email: "",
bio: "",
mainCube: "3x3",
},
});
async function onSubmit(data: FormData) {
try {
await updateProfile(data);
toast.success("Profil mis a jour !");
} catch {
toast.error("Erreur lors de la mise a jour");
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Champ texte */}
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Pseudo</FormLabel>
<FormControl>
<Input
placeholder="speedcuber42"
className="min-h-[44px]"
{...field}
/>
</FormControl>
<FormDescription>
Votre nom affiche publiquement.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Champ email */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="cuber@example.com"
className="min-h-[44px]"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Select */}
<FormField
control={form.control}
name="mainCube"
render={({ field }) => (
<FormItem>
<FormLabel>Puzzle principal</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger className="min-h-[44px]">
<SelectValue placeholder="Choisir un puzzle" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="3x3">3x3</SelectItem>
<SelectItem value="2x2">2x2</SelectItem>
<SelectItem value="4x4">4x4</SelectItem>
<SelectItem value="5x5">5x5</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Textarea */}
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Parlez de vous..."
className="min-h-[100px] resize-none"
{...field}
/>
</FormControl>
<FormDescription>
{field.value?.length || 0}/160 caracteres
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Submit */}
<Button
type="submit"
disabled={form.formState.isSubmitting}
className="w-full sm:w-auto min-h-[44px]"
>
{form.formState.isSubmitting
? "Sauvegarde..."
: "Sauvegarder"}
</Button>
</form>
</Form>
);
}Extrait de src/app/(pages)/room/page.tsx. Ce formulaire n'utilise pas react-hook-form mais un state local avec validation manuelle et feedback via toast. C'est un pattern alternatif valable pour les formulaires simples.
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { useRooms } from "@/hooks/use-rooms";
const puzzleTypes = [
{ value: "333", label: "3x3x3" },
{ value: "222", label: "2x2x2" },
{ value: "444", label: "4x4x4" },
{ value: "skewb", label: "Skewb" },
{ value: "pyram", label: "Pyraminx" },
];
export default function RoomPage() {
const router = useRouter();
const [isCreating, setIsCreating] = useState(false);
const [roomName, setRoomName] = useState("");
const [selectedPuzzle, setSelectedPuzzle] = useState("333");
const [totalRounds, setTotalRounds] = useState(3);
const [isPrivateRoom, setIsPrivateRoom] = useState(false);
const [teamCode, setTeamCode] = useState("");
const { createRoom } = useRooms();
const handleCreateRoom = async () => {
// Validation manuelle
if (!roomName.trim()) {
toast.error("Le nom de la salle est requis");
return;
}
if (isPrivateRoom && (!teamCode.trim() || teamCode.length < 3)) {
toast.error("Le code equipe doit contenir au moins 3 caracteres");
return;
}
setIsCreating(true);
toast.loading("Creation de la salle...", { id: "creating" });
try {
const room = await createRoom(roomName.trim(), selectedPuzzle, totalRounds);
toast.dismiss("creating");
toast.success("Salle creee !");
router.push(`/room/${room.code}`);
} catch {
toast.dismiss("creating");
toast.error("Impossible de creer la salle");
} finally {
setIsCreating(false);
}
};
return (
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="roomName">Nom de la salle</Label>
<Input
id="roomName"
placeholder="Ma salle de speedcubing"
value={roomName}
onChange={(e) => setRoomName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Puzzle</Label>
<Select value={selectedPuzzle} onValueChange={setSelectedPuzzle}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{puzzleTypes.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="private">Salle privee</Label>
<Switch
id="private"
checked={isPrivateRoom}
onCheckedChange={setIsPrivateRoom}
/>
</div>
{isPrivateRoom && (
<div className="space-y-2">
<Label htmlFor="teamCode">Code equipe</Label>
<Input
id="teamCode"
placeholder="Code secret..."
value={teamCode}
onChange={(e) => setTeamCode(e.target.value)}
/>
</div>
)}
<Button onClick={handleCreateRoom} disabled={isCreating} className="w-full">
{isCreating ? "Creation..." : "Creer la salle"}
</Button>
</div>
);
}Les erreurs de validation sont affichees automatiquement par FormMessage. Pour les erreurs serveur, on utilise form.setError().
// Erreur sur un champ specifique (retour serveur)
form.setError("username", {
type: "server",
message: "Ce pseudo est deja pris",
});
// Erreur globale du formulaire
form.setError("root", {
type: "server",
message: "Une erreur inattendue est survenue",
});
// Affichage de l'erreur globale
{form.formState.errors.root && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4">
<p className="text-sm text-destructive">
{form.formState.errors.root.message}
</p>
</div>
)}Patterns de validation Zod frequemment utilises dans l'application.
// Champ requis
z.string().min(1, "Ce champ est requis")
// Email
z.string().email("Email invalide")
// Longueur
z.string().min(3, "Minimum 3 caracteres").max(50, "Maximum 50 caracteres")
// Nombre
z.number().min(0, "Doit etre positif").max(100, "Maximum 100")
// Enum
z.enum(["3x3", "2x2", "4x4"], {
errorMap: () => ({ message: "Selectionnez un puzzle" }),
})
// Regex
z.string().regex(/^[a-zA-Z0-9]+$/, "Caracteres alphanumeriques uniquement")
// Optionnel avec valeur vide autorisee
z.string().optional().or(z.literal(""))
// Confirmation de mot de passe
z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Les mots de passe ne correspondent pas",
path: ["confirmPassword"],
})
// Temps de solve (format mm:ss.xx)
z.string().regex(
/^(\d{1,2}:)?\d{1,2}\.\d{2,3}$/,
"Format invalide (ex: 1:23.45 ou 12.34)"
)Tous les champs de formulaire doivent avoir une hauteur minimale de 44px pour etre facilement utilisables au toucher sur mobile.
// Hauteur minimale pour les inputs
<Input className="min-h-[44px]" />
<SelectTrigger className="min-h-[44px]" />
<Button className="min-h-[44px]" />
// Espacement suffisant entre les champs
<form className="space-y-6">
{/* Les champs ont assez d'espace pour etre touches */}
</form>
// Labels cliquables (FormLabel est automatiquement lie au champ)
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" />
</FormControl>