Formulaires
Documentation / Formulaires / Exemple complet & pièges courants

Exemple complet & pièges courants

Exemple complet : inscription avec sauvegarde

use runique::prelude::*;

#[derive(Serialize, Debug, Clone)]
#[serde(transparent)]
pub struct RegisterForm {
    pub form: Forms,
}

impl FormAccess for RegisterForm {
    fn register_fields(form: &mut Forms) {
        form.field(
            &TextField::text("username")
                .label("Nom d'utilisateur")
                .required(),
        );

        form.field(
            &TextField::email("email")
                .label("Email")
                .required(),
        );

        form.field(
            &TextField::password("password")
                .label("Mot de passe")
                .required()
                .min_length(8, "Minimum 8 caractères"),
        );
    }

    impl_form_access!();
}

impl RuniqueForm for RegisterForm {}

impl RegisterForm {
    pub async fn save(&self, db: &DatabaseConnection) -> Result<users::Model, DbErr> {
        use sea_orm::Set;
        let model = users::ActiveModel {
            username: Set(self.cleaned::<String>("username").unwrap_or_default()),
            email: Set(self.cleaned::<String>("email").unwrap_or_default()),
            // Mode Auto : après is_valid(), la valeur du champ est déjà le hash.
            // En mode Manual ou Custom, hachez-la ici avec hash().
            password: Set(self.cleaned::<String>("password").unwrap_or_default()),
            ..Default::default()
        };
        model.insert(db).await
    }
}

Handler GET/POST

ValidationForm::try_form(form, &request) remplace le boilerplate if request.is_get() {...} if request.is_post() {...} : il dispatche lui-même sur la méthode HTTP (allow_get/allow_post), valide, et renvoie Ok(ValidationForm<F>) (prouve au niveau du type que le formulaire est validé) ou Err(F) (formulaire avec ses erreurs de champ, à ré-afficher).

pub async fn inscription(mut request: Request) -> AppResult<Response> {
    let form: RegisterForm = request.form("my_form");
    let template = "profile/register_form.html";

    let mut validated = match ValidationForm::try_form(form, &request).await {
        Ok(validated) => validated,
        Err(form) => {
            // Pas envoyé (premier affichage) : formulaire vierge, pas de flash.
            // Envoyé mais invalide : ré-affichage avec le flash d'erreur.
            if !form.is_submitted() {
                context_update!(request => {
                    "title" => "Inscription",
                    "register_form" => &form,
                });
            } else {
                context_update!(request => {
                    "title" => "Erreur",
                    "register_form" => &form,
                    "messages" => flash_now!(error => "Veuillez corriger les erreurs"),
                });
            }
            return request.render(template);
        }
    };

    // `RegisterForm::save` (méthode du formulaire, pas `ValidationForm::save`) :
    // `Ok` est le modèle, `Err` un `DbErr` à reporter avec `database_error`.
    match validated.save(&request.engine.db).await {
        Ok(_user) => {
            success!(request.notices => "Inscription réussie !");
            return Ok(Redirect::to("/").into_response());
        }
        Err(err) => {
            validated.database_error(&err);
        }
    }

    context_update!(request => {
        "title" => "Erreur",
        "register_form" => &*validated,
        "messages" => flash_now!(error => "Veuillez corriger les erreurs"),
    });
    request.render(template)
}

💡 Cette save est celle écrite sur RegisterForm : elle renvoie le modèle, et database_error(&err) reporte le DbErr. Pour un formulaire d'entité, ValidationForm::save renvoie le formulaire (Ok(form) / Err(form)) et l'erreur base est déjà dessus. Deref permet &*validated dans le contexte.


Formulaire d'édition — mode PATCH

Sur une requête PATCH ou PUT, fill() relâche automatiquement le required des champs Password. Cela permet un formulaire d'édition où le mot de passe est optionnel : s'il est laissé vide, l'ancien hash est conservé.

Un formulaire HTML n'envoie que GET ou POST : ce mode concerne une requête envoyée en PATCH / PUT par JavaScript (fetch). Pour un formulaire classique en POST, déclarez le champ sans .required().

pub async fn modifier_profil(mut request: Request) -> AppResult<Response> {
    let form: EditProfileForm = request.form("my_form");
    let template = "profile/edit.html";
    let Some(current) = request.user.clone() else {
        return Ok(Redirect::to("/login").into_response());
    };
    let Some(user) = users::Entity::find_by_id(current.id).one(request.db()).await? else {
        return Ok(Redirect::to("/login").into_response());
    };

    let validated = match ValidationForm::try_form(form, &request).await {
        Ok(validated) => validated,
        Err(form) => {
            context_update!(request => {
                "title" => "Modifier le profil",
                "edit_form" => &form,
            });
            return request.render(template);
        }
    };

    // En PATCH : le champ password n'est plus requis automatiquement
    let new_password = validated.cleaned::<String>("password");

    let mut active: users::ActiveModel = user.into();
    active.username = Set(validated.cleaned::<String>("username").unwrap_or_default());

    // Si le champ password est rempli → nouveau hash ; sinon → inchangé
    if let Some(pwd) = new_password {
        active.password = Set(pwd); // déjà haché par finalize()
    }

    active.update(&request.engine.db).await?;
    success!(request.notices => "Profil mis à jour !");
    Ok(Redirect::to("/profil").into_response())
}

💡 fill() lit la méthode HTTP de la requête : rien d'autre à configurer pour une requête PATCH / PUT.


⚠️ Pièges courants

1. Collision de noms de variables template

Si votre template utilise {% form.user %}, la variable user dans le contexte doit être un formulaire, pas un Model SeaORM :

// ❌ ERREUR — db_user est un Model, pas un formulaire
context_update!(request => { "user" => &db_user });

// ✅ CORRECT — séparer les noms
context_update!(request => {
    "user_form" => &form,
    "found_user" => &db_user,
});

2. Valider sans nommer le formulaire

// À éviter : is_valid() direct saute les contrôles de try_form. Il ne vérifie pas
// que la requête renvoie le nom du formulaire (un add_value suffit à le croire
// envoyé), ignore allow_get / allow_post, et ne donne pas de ValidationForm :
// pas de save() ni de delete() derrière.
let mut form: MyForm = request.form("my_form");
if form.is_valid().await { /* ... */ }

// Correct : request.form("nom") nomme le formulaire, try_form ne valide que celui envoyé
let form: MyForm = request.form("my_form");
match ValidationForm::try_form(form, &request).await { /* ... */ }

3. Comparer des mots de passe après is_valid()

/// main.rs ->
/// avec cette configuration ->
password_init(PasswordConfig::auto_with(Manual::Argon2));

// Après is_valid(), les mots de passe sont hachés !
let mdp = form.cleaned::<String>("password").unwrap_or_default();
// mdp == "$argon2id$v=19$m=..." 😱

// Comparer dans clean(), AVANT la finalisation
async fn clean(&mut self) -> Result<(), StrMap> {
    let mdp1 = self.cleaned::<String>("password").unwrap_or_default();
    let mdp2 = self.cleaned::<String>("password_confirm").unwrap_or_default();
    if mdp1 != mdp2 { /* erreur */ }
    Ok(())
}

← Rendu dans les templates | Formulaires →