Comment nous construisons des composants d'interface utilisateur dans Rails

Publié: 2024-06-28

Maintenir la cohérence visuelle dans une grande application Web est un problème partagé par de nombreuses organisations. La principale application Web derrière notre produit Flywheel est construite avec Ruby on Rails, et nous avons environ plusieurs développeurs Rails et trois développeurs front-end qui y consacrent du code chaque jour. Nous sommes également très attachés au design (c'est l'une de nos valeurs fondamentales en tant qu'entreprise) et avons trois designers qui travaillent en très étroite collaboration avec les développeurs dans nos équipes Scrum.

deux personnes collaborent sur la conception d'un site Web

L’un de nos objectifs majeurs est de garantir que tout développeur puisse créer une page réactive sans aucun obstacle. Les obstacles consistaient généralement à ne pas savoir quels composants existants utiliser pour créer une maquette (ce qui conduisait à gonfler la base de code avec des composants très similaires et redondants) et à ne pas savoir quand discuter de la réutilisabilité avec les concepteurs. Cela contribue à des expériences client incohérentes, à la frustration des développeurs et à un langage de conception disparate entre développeurs et concepteurs.

Nous avons parcouru plusieurs itérations de guides de style et de méthodes de création/maintenance de modèles et de composants d'interface utilisateur, et chaque itération a aidé à résoudre les problèmes auxquels nous étions confrontés à cette époque. Nous sommes convaincus que notre nouvelle approche nous permettra de durer longtemps. Si vous rencontrez des problèmes similaires dans votre application Rails et que vous souhaitez aborder les composants côté serveur, j'espère que cet article pourra vous donner quelques idées.

un homme barbu sourit à la caméra alors qu'il est assis devant un écran d'ordinateur qui affiche des lignes de code

Dans cet article, je vais plonger dans :

  • Ce que nous résolvons
  • Contraindre des composants
  • Composants de rendu côté serveur
  • Où nous ne pouvons pas utiliser de composants côté serveur

Ce que nous résolvons

Nous voulions limiter complètement nos composants d'interface utilisateur et éliminer la possibilité que la même interface utilisateur soit créée de plusieurs manières. Même si un client n'est peut-être pas en mesure de le dire (au début), le fait de ne pas avoir de contraintes sur les composants conduit à une expérience de développement déroutante, rend les choses très difficiles à maintenir et rend difficile les modifications de conception globales.

La manière traditionnelle dont nous abordions les composants consistait à utiliser notre guide de style, qui répertoriait l'ensemble du balisage requis pour créer un composant donné. Par exemple, voici à quoi ressemblait la page du guide de style pour notre composant de lattes :

page de guide de style pour le composant de latte

Cela a bien fonctionné pendant plusieurs années, mais des problèmes ont commencé à apparaître lorsque nous avons ajouté des variantes, des états ou d'autres façons d'utiliser le composant. Avec une interface utilisateur complexe, il devenait fastidieux de référencer le guide de style pour savoir quelles classes utiliser et lesquelles éviter, et dans quel ordre le balisage devait être pour produire la variation souhaitée.

Souvent, les concepteurs apportaient de petits ajouts ou modifications à un composant donné. Étant donné que le guide de style ne prenait pas tout à fait en charge cela, des hacks alternatifs permettant d'afficher correctement ce réglage (comme la cannibalisation inappropriée d'une partie d'un autre composant) sont devenus extrêmement courants.

Exemple de composant sans contrainte

Pour illustrer comment les incohérences apparaissent au fil du temps, j'utiliserai un exemple simple (et artificiel) mais très courant de l'un de nos composants dans l'application Flywheel : les en-têtes de carte.

En partant d'une maquette de conception, voici à quoi ressemblait un en-tête de carte. C'était assez simple avec un titre, un bouton et une bordure inférieure.

 .card__header
  .card__header-gauche
    %h2 Sauvegardes
  .card__header-right
    = link_to "#" faire
      = icône("plus_small")

Une fois le code codé, imaginez un concepteur souhaitant ajouter une icône à gauche du titre. Dès la sortie de la boîte, il n'y aura aucune marge entre l'icône et le titre.

 ...
  .card__header-gauche
    = icône("arrow_backup", couleur : "gris25")
    %h2 Sauvegardes
...

Idéalement, nous résoudrions ce problème dans le CSS pour les en-têtes de cartes, mais pour cet exemple, disons qu'un autre développeur pensait « Oh, je sais ! Nous avons quelques aides à la marge. Je vais juste mettre une classe assistante sur le titre.

 ...
  .card__header-gauche
    = icône("arrow_backup", couleur : "gris25")
    %h2.--ml-10 Sauvegardes
...

Eh bien, techniquement, cela ressemble à la maquette, n'est-ce pas ?! Bien sûr, mais disons qu'un mois plus tard, un autre développeur a besoin d'un en-tête de carte, mais sans l'icône. Ils trouvent le dernier exemple, le copient/collent et suppriment simplement l'icône.

Encore une fois, cela semble correct, non ? Hors contexte, pour quelqu'un qui n'a pas le sens du design, bien sûr ! Mais regardez-le à côté de l'original. Cette marge gauche sur le titre est toujours là car ils n'ont pas réalisé que l'assistant de marge gauche devait être supprimé !

En poussant cet exemple un peu plus loin, disons qu'une autre maquette réclame un en-tête de carte sans bordure inférieure. On pourrait trouver un état que nous avons dans le guide de style appelé « sans frontières » et l'appliquer. Parfait!

Un autre développeur pourrait alors essayer de réutiliser ce code, mais dans ce cas, il a en fait besoin d'une bordure. Supposons hypothétiquement qu'ils ignorent l'utilisation appropriée documentée dans le guide de style et ne réalisent pas que la suppression de la classe sans bordure leur donnera leur bordure. Au lieu de cela, ils ajoutent une règle horizontale. Il finit par y avoir un remplissage supplémentaire entre le titre et la bordure, ils appliquent donc une classe d'assistance aux heures et le tour est joué !

Avec toutes ces modifications apportées à l'en-tête de la carte d'origine, nous avons maintenant un désordre dans le code.

 .card__header.--sans bordure
  .card__header-gauche
    %h2.--ml-10 Sauvegardes
  .card__header-right
    = link_to "#" faire
      = icône("plus_small")
  %hr.--mt-0.--mb-0

Gardez à l’esprit que l’exemple ci-dessus sert simplement à illustrer la façon dont les composants sans contraintes peuvent devenir compliqués au fil du temps. Si un membre de notre équipe tente de proposer une variante d'un en-tête de carte, cela doit être détecté par une révision de conception ou une révision de code. Mais des choses comme celle-ci passent parfois entre les mailles du filet, d’où notre besoin de protéger les choses !


Contraindre des composants

Vous pensez peut-être que les problèmes répertoriés ci-dessus ont déjà été clairement résolus grâce aux composants. C'est une hypothèse correcte ! Les frameworks front-end comme React et Vue sont très populaires dans ce but précis ; ce sont des outils incroyables pour encapsuler l’interface utilisateur. Cependant, il y a un problème avec eux que nous n'aimons pas toujours : ils nécessitent que votre interface utilisateur soit rendue par JavaScript.

Notre application Flywheel est très lourde en back-end avec principalement du HTML rendu par le serveur, mais heureusement pour nous, les composants peuvent se présenter sous de nombreuses formes. En fin de compte, un composant d’interface utilisateur est une encapsulation de styles et de règles de conception qui génère un balisage vers un navigateur. Avec cette réalisation, nous pouvons adopter la même approche des composants, mais sans la surcharge d'un framework JavaScript.

Nous verrons ci-dessous comment nous construisons des composants contraints, mais voici quelques-uns des avantages que nous avons découverts en les utilisant :

  • Il n’y a jamais vraiment de mauvaise façon d’assembler un composant.
  • Le composant réfléchit entièrement à la conception pour vous. (Vous transmettez simplement les options !)
  • La syntaxe de création d'un composant est très cohérente et facile à raisonner.
  • Si une modification de conception est nécessaire sur un composant, nous pouvons la modifier une fois dans le composant et être sûr qu'elle est mise à jour partout.

Composants de rendu côté serveur

Alors de quoi parle-t-on en contraignant les composants ? Allons creuser !

Comme mentionné précédemment, nous souhaitons que tout développeur travaillant dans l'application puisse consulter une maquette de conception d'une page et pouvoir immédiatement créer cette page sans obstacles. Cela signifie que la méthode de création de l'interface utilisateur doit être A) très bien documentée et B) très déclarative et exempte de conjectures.

Partiels à la rescousse (du moins c'est ce que nous pensions)

Une première tentative que nous avons essayée dans le passé a été d'utiliser des partiels Rails. Les partiels sont le seul outil que Rails vous offre pour la réutilisation dans les modèles. Naturellement, c’est la première chose à laquelle tout le monde s’adresse. Mais s'appuyer sur eux présente des inconvénients importants, car si vous devez combiner la logique avec un modèle réutilisable, vous avez deux choix : dupliquer la logique sur chaque contrôleur qui utilise le partiel ou intégrer la logique dans le partiel lui-même.

Les partiels évitent les erreurs de duplication copier/coller et fonctionnent correctement les deux premières fois où vous devez réutiliser quelque chose. Mais d'après notre expérience, les partiels sont vite encombrés par la prise en charge de plus en plus de fonctionnalités et de logiques. Mais la logique ne devrait pas vivre dans des modèles !

Introduction aux cellules

Heureusement, il existe une meilleure alternative aux partiels qui nous permet à la fois de réutiliser le code et de garder la logique hors de vue. Cela s'appelle Cells, un joyau Ruby développé par Trailblazer. Les cellules existaient bien avant la popularité croissante des frameworks front-end comme React et Vue et elles vous permettent d'écrire des modèles de vue encapsulés qui gèrent à la fois la logique et les modèles. Ils fournissent une abstraction du modèle de vue, que Rails n'a tout simplement pas vraiment prête à l'emploi. En fait, nous utilisons Cells dans l'application Flywheel depuis un certain temps maintenant, mais pas à une échelle mondiale et super réutilisable.

Au niveau le plus simple, les cellules nous permettent d'abstraire une partie du balisage comme ceci (nous utilisons Haml pour notre langage de création de modèles) :

 %div
  %h1 Bonjour tout le monde !

Dans un modèle de vue réutilisable (très similaire aux partiels à ce stade), et transformez-le en ceci :

 = cellule("bonjour_monde")

Cela nous aide en fin de compte à contraindre le composant là où des classes auxiliaires ou des composants enfants incorrects ne peuvent pas être ajoutés sans modifier la cellule elle-même.

Construire des cellules

Nous mettons toutes nos cellules d'interface utilisateur dans un répertoire app/cells/ui. Chaque cellule doit contenir un seul fichier Ruby, suffixé par _cell.rb. Techniquement, vous pouvez écrire les modèles directement dans Ruby avec l'assistant content_tag, mais la plupart de nos cellules contiennent également un modèle Haml correspondant qui se trouve dans un dossier nommé par le composant.

Une cellule super basique sans logique ressemble à ceci :

 // cellules/ui/slat_cell.rb
interface utilisateur du module
  classe SlatCell < ViewModel
    définitivement montrer
    fin
  fin
fin

La méthode show est ce qui est rendu lorsque vous instanciez la cellule et elle recherchera automatiquement un fichier show.haml correspondant dans le dossier portant le même nom que la cellule. Dans ce cas, il s'agit de app/cells/ui/slat (nous étendons toutes nos cellules d'interface utilisateur au module d'interface utilisateur).

Dans le modèle, vous pouvez accéder aux options transmises à la cellule. Par exemple, si la cellule est instanciée dans une vue comme = cell("ui/slat", titre : "Titre", sous-titre : "Sous-titre", label : "Label"), nous pouvons accéder à ces options via l'objet options.

 // cellules/ui/slat/show.haml
.latte
  .slat__intérieur
    .slat__content
      %h4=options[:titre]
      %p= options[:sous-titre]
      = icône(options[:icône], couleur : "bleu")

Souvent, nous déplacerons des éléments simples et leurs valeurs dans une méthode dans la cellule pour empêcher le rendu des éléments vides si une option n'est pas présente.

 // cellules/ui/slat_cell.rb
titre définitif
  retourner sauf si options[:title]
  content_tag :h4, options[:titre]
fin
sous-titre déf
  retourner sauf si options[:subtitle]
  content_tag :p, options[:subtitle]
fin
 // cellules/ui/slat/show.haml
.latte
  .slat__intérieur
    .slat__content
      = titre
      = sous-titre

Encapsuler des cellules avec un utilitaire d'interface utilisateur

Après avoir prouvé que cela pouvait fonctionner à grande échelle, j'ai voulu m'attaquer au balisage superflu requis pour appeler une cellule. Cela ne se déroule tout simplement pas correctement et il est difficile de s'en souvenir. Nous avons donc créé une petite aide pour cela ! Maintenant, nous pouvons simplement appeler = ui « name_of_component » et transmettre les options en ligne.

 = ui "slat", titre : "Titre", sous-titre : "Sous-titre", label : "Label"

Passer des options en bloc plutôt qu'en ligne

En poussant l'utilitaire d'interface utilisateur un peu plus loin, il est rapidement devenu évident qu'une cellule avec un tas d'options sur une seule ligne serait très difficile à suivre et tout simplement moche. Voici un exemple de cellule avec de nombreuses options définies en ligne :

 = ui « slat », titre : « Titre », sous-titre : « Sous-titre », étiquette : « Étiquette », lien : « # », tertiary_title : « Tertiaire », désactivé : vrai, liste de contrôle : [« Item 1 », « Item 2 », « Article 3 »]

C'est très fastidieux, ce qui nous a amené à créer une classe appelée OptionProxy qui intercepte les méthodes de définition de Cells et les traduit en valeurs de hachage, qui sont ensuite fusionnées en options. Si cela vous semble compliqué, ne vous inquiétez pas, c'est compliqué pour moi aussi. Voici un aperçu de la classe OptionProxy qu'Adam, l'un de nos ingénieurs logiciels senior, a écrit.

Voici un exemple d'utilisation de la classe OptionProxy dans notre cellule :

 interface utilisateur du module
  classe SlatCell < ViewModel
    définitivement montrer
      OptionProxy.new(self).yield!(options, &bloc)
      super()
    fin
  fin
fin

Maintenant que cela est en place, nous pouvons transformer nos options en ligne encombrantes en un bloc plus agréable !

 = ui "latte" faire |slat|
  - slat.title = "Titre"
  - slat.subtitle = "Sous-titre"
  - slat.label = "Étiquette"
  - latte.link = "#"
  - slat.tertiary_title = "Tertiaire"
  - slat.disabled = vrai
  - slat.checklist = ["Article 1", "Article 2", "Article 3"]

Présentation de la logique

Jusqu'à présent, les exemples n'incluaient aucune logique autour de ce que la vue affiche. C'est l'une des meilleures choses qu'offre Cells, alors parlons-en !

En nous en tenant à notre composant slat, nous avons besoin parfois de restituer le tout sous forme de lien et parfois de le restituer sous forme de div, selon qu'une option de lien est présente ou non. Je pense que c'est le seul composant dont nous disposons qui peut être rendu sous forme de div ou de lien, mais c'est un exemple assez intéressant de la puissance des cellules.

La méthode ci-dessous appelle un assistant link_to ou content_tag en fonction de la présence d'options [:link] .

 conteneur def (&bloc)
  étiquette =
    si options[:link]
      [:link_to, options[:link]]
    autre
      [:content_tag, :div]
    fin
  envoyer(*tag, classe : "slat__inner", &block)
fin

Cela nous permet de remplacer l'élément .slat__inner dans le modèle par un bloc conteneur :

 .latte
  = conteneur faire
  ...

Un autre exemple de logique dans Cells que nous utilisons beaucoup est celui des classes à sortie conditionnelle. Disons que nous ajoutons une option désactivée à la cellule. Rien d'autre dans l'invocation de la cellule ne change, à part que vous pouvez maintenant passer une option désactivée : true et regarder le tout se transformer en un état désactivé (grisé avec des liens non cliquables).

 = ui "latte" faire |slat|
  ...
  - slat.disabled = vrai

Lorsque l'option désactivée est vraie, nous pouvons définir des classes sur les éléments du modèle qui sont nécessaires pour obtenir l'apparence désactivée souhaitée.

 .slat{classe : possible_classes("--disabled": options[:disabled]) }
  .slat__intérieur
    .slat__content
      %h4{ classe : possible_classes("--alt": options[:disabled]) }= options[:title]
      %p{ classe : possible_classes("--alt": options[:disabled]) }=
      options[:sous-titre]
      = icône(options[:icône], couleur : "gris")

Traditionnellement, nous aurions dû nous rappeler (ou référencer le guide de style) quels éléments individuels nécessitaient des classes supplémentaires pour que le tout fonctionne correctement à l'état désactivé. Les cellules nous permettent de déclarer une option et de faire ensuite le gros du travail à notre place.

Remarque : possible_classes est une méthode que nous avons créée pour permettre d'appliquer conditionnellement des classes dans Haml d'une manière agréable.


Où nous ne pouvons pas utiliser les composants côté serveur

Même si l'approche cellulaire est extrêmement utile pour notre application particulière et notre façon de travailler, je m'en voudrais de dire qu'elle a résolu 100 % de nos problèmes. Nous écrivons toujours du JavaScript (en grande partie) et créons de nombreuses expériences dans Vue tout au long de notre application. 75% du temps, notre modèle Vue vit toujours dans Haml et nous lions nos instances Vue à l'élément conteneur, ce qui nous permet de toujours profiter de l'approche cellulaire.

Cependant, dans les endroits où il est plus logique de contraindre complètement un composant en tant qu'instance Vue à fichier unique, nous ne pouvons pas utiliser Cells. Nos listes de sélection, par exemple, sont toutes Vue. Mais je pense que ça va ! Nous n'avons pas vraiment eu besoin d'avoir des versions en double des composants dans les composants Cells et Vue, il est donc normal que certains composants soient construits à 100 % avec Vue et d'autres avec Cells.

Si un composant est construit avec Vue, cela signifie que JavaScript est requis pour le construire dans le DOM et nous profitons du framework Vue pour ce faire. Cependant, pour la plupart de nos autres composants, ils ne nécessitent pas JavaScript et s'ils le font, ils nécessitent que le DOM soit déjà construit et nous nous connectons simplement et ajoutons des écouteurs d'événements.

Au fur et à mesure que nous progressons dans l'approche cellulaire, nous allons certainement expérimenter la combinaison de composants cellulaires et de composants Vue afin d'avoir une et une seule façon de créer et d'utiliser des composants. Je ne sais pas encore à quoi cela ressemble, alors nous traverserons ce pont quand nous y arriverons !


Notre conclusion

Jusqu'à présent, nous avons converti une trentaine de nos composants visuels les plus utilisés en cellules. Cela nous a donné un énorme gain de productivité et donne aux développeurs le sentiment de valider que les expériences qu'ils créent sont correctes et non piratées ensemble.

Notre équipe de conception est plus que jamais convaincue que les composants et les expériences de notre application correspondent à ce qu'ils ont conçu dans Adobe XD. Les modifications ou les ajouts de composants sont désormais gérés uniquement via une interaction avec un concepteur et un développeur front-end, ce qui permet au reste de l'équipe de rester concentré et sans souci de savoir comment modifier un composant pour qu'il corresponde à une maquette de conception.

Nous réitérons constamment notre approche pour contraindre les composants de l'interface utilisateur, mais j'espère que les techniques illustrées dans cet article vous donneront un aperçu de ce qui fonctionne bien pour nous !


Venez travailler avec nous !

Chaque département qui travaille sur nos produits a un impact significatif sur nos clients et nos résultats. Qu'il s'agisse de support client, de développement de logiciels, de marketing ou de tout ce qui se situe entre les deux, nous travaillons tous ensemble pour réaliser notre mission : créer une société d'hébergement dont les gens peuvent vraiment tomber amoureux.

Prêt à rejoindre notre équipe ? Nous embauchons! Appliquer ici.