Hotwire et ViewComponent sont dans un bateau… Épisode 2 : Stimulus
La SPA tombe à l’eau, encore ! Dans la continuité du précédent article sur Turbo Stream, nous allons poursuivre avec Stimulus. Découvrez comment nous associons Hotwire et ViewComponent pour une écriture épurée de composants dynamiques, avec conventions et sans fautes de frappe.
Si vous ne l’avez pas encore fait, nous vous invitons à lire a minima l’introduction de l’article précédent, sus-mentionné. Cela vous apportera le contexte et la structure du code que nous utiliserons dans la suite de cet article.
Stimulus, ses signaux sans le bruit
Pour vous mettre l’eau à la bouche, voici le niveau de concision que nous avons obtenu dans la vue de notre composant-exemple :
div id=dom_id(self) data-controller=stimulus_controller *stimulus_value("submittable", submittable)
= simple_form_for @attendance do |f|
= f.input :status, input_html: stimulus_target("status").deep_merge(stimulus_action("statusDidChange"))
= f.input :comment, input_html: stimulus_target("commentInput"), label_html: stimulus_target("commentLabel")
= f.submit **stimulus_target("submit")
Pour obtenir ce résultat, nous profitons encore une fois des conventions de ViewComponent pour calculer automagiquement l’identifiant Stimulus du contrôleur JavaScript et le réutiliser pour les values, targets et actions de Stimulus. Mais avant, allons indiquer à importmap-rails où trouver ces contrôleurs générés en --sidecar (merci Paul Sadauskas) :
# config/initializers/assets.rb
# […]
Rails.application.config.assets.paths << "app/components"
Rails.application.config.importmap.cache_sweepers << Rails.root.join("app/components")
# config/importmap.rb
# […]
pin_all_from "app/components", under: "controllers", to: ""
La hiérarchie de fichiers imposée par le générateur ViewComponent va, soyez prévenu·es, produire des identifiants de contrôleur à rallonge, du style occurrences--call-lists--attendances--form-component--form-component. Vous pouvez trouver un custom loader pour limiter les répétitions, mais comme nous n’écrirons jamais cela à la main, nous nous en passons. C’est l’ApplicationComponent qui va se charger de calculer cet identifiant Stimulus et le réutiliser pour les autres attributs.
Le contrôleur
class ApplicationComponent < ViewComponent::Base
# […]
def stimulus_controller
lineage = self.class.name.underscore.dasherize.split('/')
(lineage << lineage.last).join("--")
end
end
Ce qui nous permet de brancher notre vue avec notre contrôleur Stimulus aussi simplement que :
/ app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim
div data-controller=stimulus_controller
Ce qui produira ce HTML :
<div data-controller="occurrences--call-lists--attendances--form-component--form-component">
</div>
Les values
class ApplicationComponent < ViewComponent::Base
#[…]
def stimulus_value(name, value)
{
data: {
stimulus_controller => {
name => { value: }
}
}
}
end
end
/ app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim
div *stimulus_value("submittable", submittable)
<div data-occurrences--call-lists--attendances--form-component--form-component-submittable-value='["absent", "present"]'>
</div>
Les targets
class ApplicationComponent < ViewComponent::Base
# […]
def stimulus_target(name)
{
data: {
"#{stimulus_controller}-target" => name
}
}
end
end
/ app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim
= f.input :status, input_html: stimulus_target("status")
<input data-occurrences--call-lists--attendances--form-component--form-component-target="status" />
Les actions
class ApplicationComponent < ViewComponent::Base
# […]
def stimulus_action(function_name, trigger: nil)
{
data: {
action: [
trigger,
[
stimulus_controller,
function_name
].join("#")
].compact.join("->")
}
}
end
end
/ app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim
= f.input :status, input_html: stimulus_action("statusDidChange")
<input data-action="occurrences--call-lists--attendances--form-component--form-component#statusDidChange" />
Avec cette méthode, vous avez droit également à une petite subtilité : le keyword argument trigger optionnel. Dans l’exemple ci-dessus, on ne le spécifie pas, la syntaxe est donc celle utilisant le trigger par défaut de Stimulus. Vous pouvez néanmoins préciser l’événement scruté :
/ app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim
= f.input :comment, input_html: stimulus_action("commentWillChange", trigger: "keypress")
<input data-action="keypress->occurrences--call-lists--attendances--form-component--form-component#commentWillChange" />
Les points de vigilance
Les quelques méthodes présentées ci-dessus ne couvrent pas toutes les possibilités de Stimulus, loin de là. Il n’y a par exemple pas les actions parameters ou les outlets. Mais ces premiers usages pavent la voie vers des vues à la fois plus faciles à lire et moins exposées aux fautes de frappe et erreurs de syntaxe. Et ce n’est pas une mince affaire dans ce monde très « stringly-typed » des attributs Stimulus.
Prêtez également une attention toute particulière aux traitements faits par vos gems aux arguments qui leur sont passés. Autant Slim gère très bien les Hash imbriqués pour la génération des noms des data-attributes (cf #stimulus_value), autant SimpleForm nous a obligé à utiliser l’interpolation de chaîne de caractère, ne supportant pas les Hash de plus de deux niveaux (cf. #stimulus_target).
Et pour terminer, soyez à l’aise avec vos splat operators pour satisfaire aux exigences de votre moteur de templating (Slim ici pour nous) et aux signatures de méthode de vos bibliothèques. Vous allez retrouver des syntaxes somme toute particulières mais le jeu en vaut la chandelle1. Voyez par vous même :
/ app/components/occurrences/call_lists/attendances/form_component/form_component.html.slim
= f.submit **stimulus_target("submit")
<input type="submit" data-occurrences--call-lists--attendances--form-component--form-component-target="submit" />
Quelle différence ?
-div.attendance data-controller="occurrences--call-lists--attendances--form-component--form-component" data-occurrences--call-lists--attendances--form-component--form-component-submittable-value=submittable
+div.attendance data-controller=stimulus_controller *stimulus_value("submittable", submittable)
= simple_form_for @attendance do |f|
- = f.input :status, input_html: { data: { "occurrences--call-lists--attendances--form-component--form-component-target": "status", action: "occurrences--call-lists--attendances--form-component--form-component#statusDidChange" } }
- = f.input :comment, input_html: { data: { "occurrences--call-lists--attendances--form-component--form-component-target": "commentInput" } }, label_html: { data: { "occurrences--call-lists--attendances--form-component--form-component-target": "commentLabel" } }
- = f.submit data: { "occurrences--call-lists--attendances--form-component--form-component-target": "submit" }
+ = f.input :status, input_html: stimulus_target("status").deep_merge(stimulus_action("statusDidChange"))
+ = f.input :comment, input_html: stimulus_target("commentInput"), label_html: stimulus_target("commentLabel")
+ = f.submit **stimulus_target("submit")
Bruit versus signal 😌
-
En plus de gagner en expertise sur les subtilités de votre langage de programmation. ↩
infoPiiaf