Spec design (Figma) pour une étape d’upload de pièces justificatives, trois messages d’erreur exigés au mot près :
- « Type de fichier non autorisé. Seuls PDF, JPG ou PNG sont acceptés. »
- « Le fichier est trop volumineux. Taille maximale : 5 Mo. »
- « Veuillez sélectionner un fichier à uploader. »
En cours d’implémentation, on avait dérivé — sans mauvaise intention, juste par « à peu près ». Et l’« à peu près » sur du wording UX, c’est une dette qui se voit.
Symptôme
Deux écarts, repérés à la relecture face à la maquette :
- Le message #2 s’affichait
Le fichier est trop volumineux. Taille maximale : 5 Mo (après compression).Ce « (après compression) » était un détail d’implémentation interne (on compresse les images côté serveur) qui n’a rien à faire face utilisateur. La spec ne le mentionnait pas. On avait « enrichi » le message de notre propre chef. - Le message #3 n’était tout simplement pas câblé au submit. Quand un champ obligatoire manquait, le serveur renvoyait un flash global « pièces manquantes » en haut de page — au lieu d’une erreur inline, sous le champ concerné, comme l’exigeait la maquette.
Le second écart est le plus pernicieux : fonctionnellement « ça marche » (l’utilisateur est bien bloqué), mais l’UX est cassée. Un message global « il manque des pièces » sur un formulaire qui en demande huit, c’est une chasse au trésor.
Cause
Deux causes distinctes :
-
La paraphrase au feeling. Le message de taille avait été recopié « dans l’esprit », pas à la lettre. Personne n’avait décidé d’ajouter « (après compression) » ; ça avait l’air utile sur le moment. Sauf que le wording d’une maquette est validé par le client/designer — le modifier, même pour « mieux », c’est sortir du contrat.
-
Le composant d’upload n’avait pas de canal d’erreur par champ. Le
UploadField(composant Twig) ne connaissait que son état « rempli / vide ». Aucune properror. Donc le serveur n’avait nulle part où poser une erreur sur un champ précis — d’où le repli sur le flash global, faute de mieux.
Solution
D’abord, aligner le message mot-pour-mot avec la spec — côté client comme côté serveur, et au même endroit pour les deux. Le message vit à un seul endroit, on supprime la variante artisanale :
final class UploadValidator
{
public const ERROR_MESSAGE = 'Veuillez sélectionner un fichier à uploader.';
// ...
}
// Contrôleur d'upload — messages strictement conformes à la maquette
return $this->json(
['error' => 'Le fichier est trop volumineux. Taille maximale : 5 Mo.'],
Response::HTTP_BAD_REQUEST,
);
// ...
return $this->json(
['error' => 'Type de fichier non autorisé. Seuls PDF, JPG ou PNG sont acceptés.'],
Response::HTTP_BAD_REQUEST,
);
Le « (après compression) » saute. Le détail d’implémentation reste dans le code (et dans les logs), pas dans l’UI.
Ensuite, donner au composant un canal d’erreur par champ. On ajoute une prop error au UploadField :
#[AsTwigComponent]
final class UploadField
{
public string $sectionId = '';
public string $title = '';
public bool $required = true;
public array $uploaded = [];
public ?string $error = null; // ← erreur inline, rendue sous le champ
}
Côté validation serveur, le UploadValidator ne renvoie plus un booléen « tout est bon / il manque des trucs », mais une map sectionId → message : chaque champ en défaut sait exactement quoi afficher.
/** @return array<string, string> sectionId => message d'erreur (vide si tout OK) */
public function validate(array $uploads, bool $contratActuel, bool $hasConjoint, array $enfants): array
{
$errors = [];
foreach ($this->computeRequiredSections($contratActuel, $hasConjoint, $enfants) as $section) {
if (empty($uploads[$section])) {
$errors[$section] = self::ERROR_MESSAGE;
}
}
return $errors; // ex: ['principal_rib' => 'Veuillez sélectionner un fichier à uploader.']
}
Le contrôleur passe cette map au template, qui distribue chaque message au UploadField correspondant via sa prop error. Résultat : l’erreur s’affiche sous le bon champ, exactement comme la maquette. Le flash global disparaît au profit d’erreurs contextuelles.
Enfin, validation client et serveur affichent le même libellé : la validation JS (taille, type, présence) utilise les mêmes chaînes que le serveur. Si le JS dit « 5 Mo max », le serveur dit pareil, mot pour mot — pas de divergence selon que la validation tombe avant ou après l’envoi.
Lessons learned
- Le wording UX fait partie du contrat, au même titre que les couleurs et les espacements. Un message validé sur la maquette se reproduit mot pour mot. Pas de paraphrase « dans l’esprit », pas d’ajout « utile » de son propre chef — même bien intentionné, c’est une dérive du contrat.
- Une erreur globale (
flash) ne remplace pas une erreur inline sur le champ concerné. « Il manque des pièces » sur un formulaire à huit champs oblige l’utilisateur à deviner lesquelles. L’erreur doit pointer le champ exact. Si votre composant n’a pas de canal d’erreur par champ, il vous force à la solution paresseuse. - Concevez vos composants de formulaire avec une prop
errordès le départ. Sans elle, le serveur n’a nulle part où poser un message contextuel et se rabat sur le flash global. La structure du composant détermine la qualité de l’UX possible. - Faites renvoyer aux validateurs une map
champ → message, pas un booléen. Le booléen « valide / invalide » jette l’information dont l’UI a besoin pour être précise. - Validation client et serveur doivent afficher le même message. Quand les deux cohabitent, centralisez les libellés (constantes partagées) pour qu’ils ne divergent jamais selon le moment où la validation se déclenche.
#ux #forms #symfony #twig #design-system #figma