eQuantic.UIeQuantic.UI
Docs
Playground
GitHub
DocsWrite-once
Formulaires
Edit this page
7 min read
Un formulaire dans eQuantic.UI est un modèle, pas un contrôle. Les valeurs, les drapeaux, les règles, les erreurs et l'unique soumission autorisée à être en vol vivent tous dans eQuantic.UI.Primitives, de la logique pure à zéro dépendance et sans pixels, et les composants au-dessus ne savent que lequel des deux possède quoi.
POURQUOI UNE COUCHE DE MODÈLE ? Parce qu'un formulaire est surtout de l'arithmétique sur l'état : si une erreur peut déjà être montrée, s'il y a quelque chose de non enregistré, si un second clic peut soumettre à nouveau. Câblez cela dans un contrôle et il ne peut être testé qu'en cliquant. Gardez-le ici et il se teste par des assertions, et le même C# tourne sur le serveur, dans le navigateur via son jumeau transpilé, et dans une fenêtre native.
Déclarer un formulaire
Depuis 0.2.0-preview.29
Une page tient un FormController, déclare ses champs, et s'abonne une seule fois. Chaque valeur, drapeau et erreur arrive par cet unique événement, donc rien sur la page ne suit l'état une seconde fois.
1
2
3
4
5
6
7
8
9
10
11
12
13
public sealed class SignUpPage : StatefulComponent
{
private readonly FormController _form = new();
public SignUpPage()
{
_form.Add("email", rules: [Rules.Required(), Rules.Email()]);
var password = _form.Add("password", rules: [Rules.Required(), Rules.MinLength(8)]);
_form.Add("confirm", rules: [Rules.Required(), Rules.Matches(password)]);
_form.Changed += () => SetState(() => { });
}
}
Add renvoie le champ, et c'est ce qui rend possible une règle entre champs : Rules.Matches(password) tient l'autre champ et le lit au moment de valider, il juge donc ce qui est à l'écran plutôt que ce qui y était quand le formulaire a été construit.
Silencieux jusqu'à ce que vous partiez
Deux paires de propriétés portent tout le comportement d'un formulaire prévenant, et chaque paire est faite de deux questions différentes :
Question
Sert à
Touched
l'utilisateur a-t-il QUITTÉ ce champ ?
si une erreur peut être montrée
Dirty
la valeur diffère-t-elle de celle avec laquelle le formulaire s'est ouvert ?
"abandonner les modifications ?"
Error
ce qui est faux à cet instant
toujours à jour, même avant que quiconque ait tapé
VisibleError
ce que le champ a le droit de DIRE
ce à quoi un composant se lie
La conséquence est la temporisation que vous pouvez observer dans l'exemple : tapez une adresse cassée et rien ne devient rouge, quittez le champ et ça le devient, corrigez-la et le rouge part à la frappe qui corrige. Un formulaire rendu côté serveur arrive silencieux pour la même raison : chaque règle a déjà tourné, et personne n'a encore rien touché.
1
2
3
4
5
6
var email = form.Field("email")!;
form.Set("email", "ana@");
email.Error; // "Enter a valid email address.", calculé immédiatement
email.VisibleError; // null, le caret est encore dans la boîte
form.Touch("email");
email.VisibleError; // "Enter a valid email address."
Les règles
Required, MinLength, MaxLength, Email, Range, Matches, et Custom pour celle que cette liste n'a pas. Chaque règle sauf Required passe sur une valeur vide : un champ facultatif et vide n'est pas mal formaté, il est absent, et "Entrez un e-mail valide" sous une boîte facultative vide est la fausse alerte la plus courante de la validation de formulaires.
Une règle est un prédicat et son message, et c'est ce qui lui permet de traverser vers le navigateur tout court : System.ComponentModel.DataAnnotations valide par réflexion, et la réflexion est exactement ce que le transpileur ne peut pas porter.
1
2
3
form.Add("age", rules: [Rules.Range(18, 120, "You must be 18 or older.")]);
form.Add("slug", rules: [Rules.Custom("Lowercase letters and dashes only.",
value => value.All(c => char.IsAsciiLetterLower(c) || c == '-'))]);
Un formulaire qui change de forme
Depuis 0.2.0-preview.29
Les vrais formulaires ont plus d'un chemin, et la validation conditionnelle arrive sous forme de deux pièces qui se composent, plutôt que d'un second moteur.
Rules.When rend n'importe quelle règle conditionnelle : elle enveloppe, donc chaque règle ci-dessus est conditionnelle gratuitement. Tant que la condition est fausse, la règle tient vacuement : rien n'a été demandé, donc le champ se tait au lieu de garder la dernière réponse qu'il a donnée.
relevantWhen éteint le CHAMP entier. Une adresse de livraison sur une commande à retirer en magasin n'est pas un champ dont la règle passe, c'est un champ que personne ne demande : il ne tient aucune erreur, il ne peut pas rendre le formulaire invalide, et une page peut l'omettre entièrement. Ce qui a été tapé survit au départ et au retour de la question.
1
2
3
4
5
6
7
8
9
10
11
var kind = _form.Add("kind", "personal");
// La règle va et vient ; le champ reste.
_form.Add("taxNumber", rules: [Rules.When(() => kind.Value == "company", Rules.Required())]);
// Le champ lui-même n'est pas demandé tant que la case est décochée.
_form.Add("phone", relevantWhen: () => _callMe,
rules: [Rules.Required("We need a number to call you on."), Rules.MinLength(9)]);
// …et la page ne le dessine que pendant qu'il s'applique :
if (_form.Field("phone") is { Relevant: true })
card.Add(new FormInput(_form, "phone", "Phone"));
Une condition lit ce qu'elle capture. Quand c'est un autre champ, rien de plus n'est nécessaire : changer n'importe quelle valeur relance les règles de tous les autres champs. Quand c'est un état que le formulaire ne possède pas (une case sur la page, un plan choisi à une étape antérieure), la page le dit avec Revalidate() :
1
2
3
4
5
card.Add(new Checkbox(_callMe, () => SetState(() =>
{
_callMe = !_callMe;
_form.Revalidate(); // la condition vit hors du formulaire, on le lui dit donc
}), "Call me instead of emailing"));
La surface
Depuis 0.2.0-preview.29
Deux composants lient le modèle aux pixels, et ils sont délibérément minces. FormInput possède trois fils et rien d'autre : taper appelle Set, quitter appelle Touch, et ce qu'il dessine est VisibleError. FormSubmit lit le contrôleur pour tout le reste.
1
2
3
4
5
6
7
8
card.Add(new FormInput(_form, "email", "Email", placeholder: "you@example.com",
helper: "We never share it."));
card.Add(new FormInput(_form, "password", "Password", helper: "At least 8 characters")
{ Obscure = true });
var actions = new Row(gap: Space.S2);
actions.Add(new FormSubmit(_form, "Create account", Submit));
actions.Add(new Button("Reset", Variant.Ghost) { OnPressed = () => _form.Reset() });
Le bouton de soumission reste pressable pendant que le formulaire est invalide, et c'est un choix qui mérite d'être défendu : une soumission désactivée est la façon la plus courante de faire qu'un formulaire semble cassé, parce que le champ qui est faux est d'ordinaire un champ que l'utilisatrice n'a jamais visité. Le presser est ce qui révèle la réponse : SubmitAsync touche d'abord chaque champ.
Le modèle l'a déjà dit
Depuis 0.2.0-preview.29
Un modèle annoté avec System.ComponentModel.DataAnnotations décrit déjà l'essentiel d'un formulaire. Marquez-le [FormModel] et le build écrit le contrôleur :
1
2
3
4
5
6
7
8
9
[FormModel]
public sealed class SignUp
{
[Required, EmailAddress] public string Email { get; set; } = "";
[Required, MinLength(8)] public string Password { get; set; } = "";
[Required, Compare(nameof(Password))] public string Confirm { get; set; } = "";
}
private readonly FormController _form = SignUpForm.Create(); // généré
La lecture a lieu au build, et il faut qu'il en soit ainsi : DataAnnotations valide par RÉFLEXION, ce qui est exactement ce que le transpileur ne peut pas porter dans un navigateur. Ce qui est émis, ce sont des appels ORDINAIRES aux mêmes Rules ci-dessus, il n'y a donc pas de second moteur de validation, et le formulaire généré et un formulaire écrit à la main sont donc le même objet. Un modèle peut adopter le pont sans que la page change, et un formulaire peut le dépasser sans réécriture :
1
2
3
4
5
public FormScreen()
{
// …le champ qu'aucune annotation ne peut décrire, ajouté au formulaire que le générateur a bâti.
_form.Add("Phone", relevantWhen: () => _callMe, rules: [Rules.Required(), Rules.MinLength(9)]);
}
Les champs portent le nom de la PROPRIÉTÉ ("Email"), qui est aussi ce que rapporte le model state d'un serveur, donc ApplyServerErrors atterrit sur le bon champ sans rien entre les deux.
Portées : Required, EmailAddress, MinLength, MaxLength, StringLength (les deux bornes), Range, RegularExpression et Compare. ErrorMessage bat le message propre à la règle, parce qu'une application qui en a écrit un l'a écrit pour être montré.
Dit à voix haute plutôt que jeté : un type de propriété qu'aucune boîte de texte ne fait aller-retour (EQ3103), une annotation sans règle à devenir (EQ3104), un [Compare] qui nomme une propriété absente (EQ3105). Tous des avertissements : un formulaire qui porte l'essentiel d'un modèle vaut tout de même la peine, et le serveur fait respecter le reste de toute façon. C'est en opt-in, parce qu'un modèle annoté pour une API n'est pas automatiquement un formulaire.
Soumettre, et le verdict du serveur
SubmitAsync donne à l'appelant trois garanties qu'il réimplémenterait sinon sur chaque page : un formulaire invalide n'atteint jamais le gestionnaire (chaque erreur est révélée à la place), une seconde soumission est refusée pendant que la première est en vol (le double clic qui débite une carte deux fois), et une levée devient SubmitError plutôt qu'une exception non gérée, parce que le réseau qui tombe est une chose que les formulaires font, pas un crash.
Les règles côté client sont une courtoisie. La validation qui compte tourne là où les données vivent, et ApplyServerErrors est la façon dont sa réponse revient sur le champ auquel elle appartient :
1
2
3
4
5
6
7
8
9
10
11
12
[ServerAction]
public async Task<List<FieldError>> Register(string email) =>
await _users.Exists(email)
? [new FieldError("email", "That address is already registered.")]
: [];
private async Task Submit()
{
var verdict = await Register(_form.Field("email")!.Value);
if (verdict.Count > 0) { _form.ApplyServerErrors(verdict); return; }
_form.Accept(); // les valeurs courantes deviennent la nouvelle base : rien de sale, rien qui crie
}
Limites
•
Les dates, les enums et tout ce qu'une boîte de texte ne peut pas faire aller-retour restent hors du pont DataAnnotations : ils sont rapportés (EQ3103) et laissés à un champ écrit à la main, parce qu'une date a besoin d'un sélecteur et d'une culture avant d'avoir besoin d'une règle.
•
Les messages sont des chaînes simples, pas des clés de ressources. Une application qui localise ses messages passe des chaînes déjà localisées, parce que c'est l'application qui possède son resx. Voir Localisation pour le chrome propre au SDK, qui est le seul texte que ce framework traduit en votre nom.
Lié
•
Composants write-once : comment une seule bibliothèque de composants atteint le navigateur et une fenêtre native.
•
Design System : le champ, la case et le bouton dont la surface est faite.
•
Intégration serveur : [ServerAction], qui est la façon dont un formulaire atteint le serveur.