Comment nous construisons des composants d'interface utilisateur dans Rails chez Flywheel

Publié: 2019-11-16

Le maintien de la cohérence visuelle dans une grande application Web est un problème partagé par de nombreuses organisations. Chez Flywheel, nous ne sommes pas différents. Notre application Web principale est construite avec Ruby on Rails et nous avons environ 15 développeurs Rails et trois développeurs frontaux qui y valident 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 étroite collaboration avec les développeurs de nos équipes Scrum.

L'un de nos principaux objectifs est de garantir que tout développeur Flywheel puisse créer une page réactive sans aucun obstacle. Les obstacles incluent généralement le fait de ne pas savoir quels composants existants utiliser pour créer une maquette (ce qui conduit à gonfler la base de code avec des composants redondants très similaires) et de ne pas savoir quand discuter de la réutilisation avec les concepteurs. Cela contribue à des expériences client incohérentes, à la frustration des développeurs et à un langage de conception disparate entre les développeurs et les 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 à l'époque. Nous sommes maintenant sur une nouvelle approche (pour nous) qui, j'en suis convaincu, nous permettra de rester en place pour 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.

Dans cet article, je vais plonger dans :

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


Ce que nous résolvons pour

Nous voulions complètement contraindre nos composants d'interface utilisateur et éliminer la possibilité que la même interface utilisateur soit créée de plusieurs manières. Bien qu'un client puisse ne pas être 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 la modification de la conception globale.

La manière traditionnelle dont nous abordions les composants était par le biais de notre guide de style, qui répertoriait tout le balisage requis pour créer un composant donné. Par exemple, voici à quoi ressemblait la page du guide de style pour notre composant lamelles :

Cela a bien fonctionné et nous a convenu pendant plusieurs années, mais des problèmes ont commencé à s'installer 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 se référer au 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. Et souvent, les concepteurs apportaient de petits ajouts ou ajustements à un composant donné. Étant donné que le guide de style ne supportait pas tout à fait cela, des hacks alternatifs pour que ce réglage s'affiche correctement (comme cannibaliser de manière inappropriée 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-left
    %h2 Backups

  .card__header-right
    = link_to "#" do
      = icon("plus_small")

Une fois codé, imaginez un designer voulant ajouter une icône à gauche du titre. Hors de la boîte, il n'y aura pas de marge entre l'icône et le titre.

...
  .card__header-left
    = icon("arrow_backup", color: "gray25")
    %h2 Backups
...

Idéalement, nous résoudrions cela dans le CSS pour les en-têtes de carte, mais pour cet exemple, disons qu'un autre développeur a pensé « Oh, je sais ! Nous avons quelques aides de marge. Je vais juste gifler une classe d'assistance sur le titre.

...
  .card__header-left
    = icon("arrow_backup", color: "gray25")
    %h2.--ml-10 Backups
...

Eh bien, cela ressemble techniquement à 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 ça a l'air correct, non ? Hors contexte, pour quelqu'un qui n'a pas l'œil pour le design, bien sûr ! Mais regardez-le à côté de l'original. Cette marge gauche sur le titre est toujours là parce qu'ils n'ont pas réalisé que l'assistant de marge gauche devait être supprimé !

Poussons cet exemple un peu plus loin, disons qu'une autre maquette appelle 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 bordure" 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. Disons 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 rembourrage supplémentaire entre le titre et la bordure, ils appliquent donc une classe d'assistance au hr et le tour est joué !

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

.card__header.--borderless
  .card__header-left
    %h2.--ml-10 Backups

  .card__header-right
    = link_to "#" do
      = icon("plus_small")

  %hr.--mt-0.--mb-0

Gardez à l'esprit que l'exemple ci-dessus est juste pour illustrer la façon dont les composants non contraints peuvent devenir désordonnés avec le temps. Si un membre de notre équipe essayait d'expédier une variante d'un en-tête de carte, cela devrait faire l'objet d'une revue de conception ou d'une revue de code. Mais des choses comme celle-ci passent parfois entre les mailles du filet, d'où notre besoin de pare-balles !


Contraindre les composants

Vous pensez peut-être que les problèmes énumérés ci-dessus ont déjà été clairement résolus avec des composants. C'est une hypothèse correcte ! Les frameworks frontaux 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 hic avec eux que nous n'aimons pas toujours - ils nécessitent que votre interface utilisateur soit rendue par JavaScript.

L'application Flywheel est très lourde en arrière-plan 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 pour les 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 trouvés en les utilisant :

  • Il n'y a jamais vraiment de mauvaise façon d'assembler un composant.
  • Le composant fait toute la réflexion de conception pour vous. (Vous venez de passer dans 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 le modifier une fois dans le composant et être sûr qu'il est mis à 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 Flywheel puisse consulter une maquette de conception d'une page et être en mesure de créer immédiatement 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 sans conjecture.

Partials à la rescousse (ou alors nous le pensions)

Un premier essai que nous avons essayé dans le passé était d'utiliser les partiels de Rails. Les partiels sont le seul outil que Rails vous offre pour la réutilisation dans les modèles. Naturellement, ils sont la première chose que tout le monde recherche. Mais il y a des inconvénients importants à s'appuyer sur eux 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 empêchent les erreurs de duplication de copier/coller et ils fonctionnent bien les deux premières fois où vous devez réutiliser quelque chose. Mais d'après notre expérience, les partiels sont rapidement encombrés par la prise en charge de plus en plus de fonctionnalités et de logique. Mais la logique ne devrait pas vivre dans les 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. Il s'appelle Cells, un joyau Ruby développé par Trailblazer. Les cellules existaient bien avant la montée en popularité des frameworks frontaux 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 de 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, Cells nous permet d'abstraire un morceau de balisage comme celui-ci (nous utilisons Haml pour notre langage de template) :

%div
  %h1 Hello, world!

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

= cell("hello_world")

Cela nous aide finalement à contraindre le composant là où les classes d'assistance ou les composants enfants incorrects ne peuvent pas être ajoutés sans modifier la cellule elle-même.

Construire des cellules

Nous mettons toutes nos cellules UI dans un répertoire app/cells/ui. Chaque cellule doit contenir un seul fichier Ruby, suffixé par _cell.rb. Vous pouvez techniquement é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 :

// cells/ui/slat_cell.rb

module UI
  class SlatCell < ViewModel
    def show
    end
  end
end

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”, title: “Title”, subtitle: “Subtitle”, label: “Label”), nous pouvons accéder à ces options via l'objet options.

// cells/ui/slat/show.haml
.slat
  .slat__inner
    .slat__content
      %h4= options[:title]
      %p= options[:subtitle]
      = icon(options[:icon], color: "blue")

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

// cells/ui/slat_cell.rb
def title
  return unless options[:title]
  content_tag :h4, options[:title]
end

def subtitle
  return unless options[:subtitle]
  content_tag :p, options[:subtitle]
end
// cells/ui/slat/show.haml
.slat
  .slat__inner
    .slat__content
      = title
      = subtitle

Emballage de cellules avec un utilitaire d'interface utilisateur

Après avoir prouvé que cela pouvait fonctionner à grande échelle, je voulais m'attaquer au balisage superflu nécessaire pour appeler une cellule. Il ne coule tout simplement pas tout à fait droit et est difficile à retenir. 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", title: "Title", subtitle: "Subtitle", label: "Label"

Passer des options en tant que bloc au lieu d'être en ligne

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

= ui “slat", title: “Title”, subtitle: “Subtitle”, label: “Label”, link: “#”, tertiary_title: “Tertiary”, disabled: true, checklist: [“Item 1”, “Item 2”, “Item 3”]

C'est très lourd, ce qui nous a amené à créer une classe appelée OptionProxy qui intercepte les méthodes de définition des cellules et les traduit en valeurs de hachage, qui sont ensuite fusionnées en options. Si cela 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 seniors, a écrit.

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

module UI
  class SlatCell < ViewModel
    def show
      OptionProxy.new(self).yield!(options, &block)
      super()
    end
  end
end

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

= ui "slat" do |slat|
  - slat.title = "Title"
  - slat.subtitle = "Subtitle"
  - slat.label = "Label"
  - slat.link = "#"
  - slat.tertiary_title = "Tertiary"
  - slat.disabled = true
  - slat.checklist = ["Item 1", "Item 2", "Item 3"]

Présentation de la logique

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

En nous en tenant à notre composant lamelle, nous avons besoin de rendre parfois le tout sous forme de lien et parfois de le rendre sous forme de div, en fonction de la présence ou non d'une option de lien. Je pense que c'est le seul composant que nous avons qui peut être rendu sous forme de div ou de lien, mais c'est un exemple assez net de la puissance de Cells.

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

Remarque : Ceci a été inspiré et créé par Adam Lassek, qui a été extrêmement influent en nous aidant à construire toute cette méthode de développement d'interface utilisateur avec Cells.

def container(&block)
  tag =
    if options[:link]
      [:link_to, options[:link]]
    else
      [:content_tag, :div]
    end

  send(*tag, class: “slat__inner”, &block)
end

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

.slat
  = container do
  ...

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

= ui "slat" do |slat|
  ...
  - slat.disabled = true

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{ class: possible_classes("--disabled": options[:disabled]) }
  .slat__inner
    .slat__content
      %h4{ class: possible_classes("--alt": options[:disabled]) }= options[:title]
      %p{ class: possible_classes("--alt": options[:disabled]) }=
      options[:subtitle]
      = icon(options[:icon], color: "gray")

Traditionnellement, nous aurions dû nous rappeler (ou référencer le guide de style) quels éléments individuels avaient besoin de classes supplémentaires pour que l'ensemble fonctionne correctement dans l'état désactivé. Les cellules nous permettent de déclarer une option, puis de faire le gros du travail pour nous.

Remarque : possible_classes est une méthode que nous avons créée pour permettre l'application conditionnelle de classes dans Haml d'une manière agréable.


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

Bien que l'approche cellulaire soit 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 construisons pas mal d'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 continuer à profiter de l'approche cellulaire.

Cependant, dans les endroits où il est plus logique de contraindre complètement un composant en tant qu'instance de 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 nécessaire pour le construire dans le DOM et nous profitons du framework Vue pour le 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énement.

Au fur et à mesure que nous progressons avec 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 ça ressemble, alors on traversera ce pont quand on y sera !


Notre conclusion

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

Notre équipe de conception est plus convaincue que jamais que les composants et les expériences de notre application sont 1:1 avec ce qu'ils ont conçu dans Adobe XD. Les modifications ou les ajouts aux composants sont désormais gérés uniquement par le biais d'une interaction avec un concepteur et un développeur frontal, 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 itérons constamment notre approche pour limiter 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 chez Flywheel !

Chez Flywheel, chaque département 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 n'importe quoi d'autre, nous travaillons tous ensemble à notre mission de créer une société d'hébergement dont les gens peuvent vraiment tomber amoureux.

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