Multistep Form (Wizard) — Ricette Rails
Anatomia di una forma multistep
Un modulo multistep è una variante di una procedura guidata. Una procedura guidata è uno strumento di usabilità. Rappresenta un'attività come un insieme di fasi disposte in un ordine prescritto . Gli utenti devono completare ogni passaggio nell'ordine indicato per raggiungere il proprio obiettivo.
I moduli multifase suddividono i moduli complessi in parti più piccole, raggruppando i relativi campi in base a criteri diversi . Ciò si traduce in un'esperienza di compilazione dei moduli meno scoraggiante per gli utenti finali. Se combinati con le istruzioni, i moduli multistep aumentano notevolmente la facilità d'uso delle nostre applicazioni .
Il loro principale svantaggio è l'efficienza . La compilazione di un modulo multifase di solito richiede più tempo rispetto alla compilazione di un modulo singolo (se si ha familiarità con il processo). Ciò è dovuto alla navigazione tra i passaggi, che non esiste nei moduli standard.
Il modo in cui implementi questa navigazione può avere un impatto significativo sull'esperienza dell'utente : consentirai agli utenti di spostarsi avanti e indietro a qualsiasi passaggio della procedura guidata o solo ai passaggi successivi e precedenti? le informazioni sul passaggio precedente sono visibili nel passaggio corrente? Scelte errate di progettazione possono portare a esperienze di compilazione dei moduli lente e frustranti che scoraggiano gli utenti dal compilare i nostri moduli e persino dall'usare il nostro sistema.
Questa ricetta ti insegnerà a creare moduli multistep riutilizzabili e personalizzabili in Rails.
La sfida
Nella nostra applicazione gestiamo Projects. Un progetto è definito da a namee a description. A Projectpuò avere più Booksassociati ad esso. A Bookè definito da a titlee a description. Vogliamo implementare un modulo multistep per creare file Projects. Il primo passaggio richiederà le informazioni di base del progetto e il secondo, facoltativo, le informazioni su un Bookassociato al file Project.
La ricetta
Prerequisiti
- (Obbligatorio) Rails
ViewComponentgem installato :ViewComponentavvolge Railsviewsin oggetti. Ciò semplifica il test delle nostre visualizzazioni e fornisce metodi aggiuntivi per una migliore esperienza di lavoro degli sviluppatori con esse. - (Opzionale) plugin installato : questo framework elimina la necessità di scrivere nel nostro codice fornendo un potente set di classi.
TailwindCSSPostCSSCSSCSSCSS
In Rails, utilizziamo i moduli per creare e aggiornare i record. Queste operazioni vengono eseguite tramite POST, PUTe PATCH HTTPrichieste inoltrate da un unico modulo. Le richieste vengono evase seguendo lo stesso iter:
- Il livello Rails
Controlleranalizza i dati della richiesta del modulo e chiede alModellivello di eseguire l'operazione richiesta con le informazioni estratte. - Il livello Rails
Modelelabora i dati di richiesta, li convalida ed esegue l'operazione richiesta se è valida. Il risultato dell'operazione viene rispedito dalModelalController. - Il livello Rails
Controllerrichiede alViewlivello di eseguire il rendering di una risposta in base al risultato dell'operazione. Le operazioni riuscite forniscono una vista dei risultati dell'operazione. Le operazioni non riuscite visualizzano il modulo inviato con i dati della richiesta del modulo e i messaggi di errore.
Il nostro primo compito è implementare la logica per convalidare se tutti i passaggi in un modulo multistep sono stati completati.
ActiveRecord::Validations L'API Rails , inclusa nelle ApplicationRecord Modelclassi, definisce il valid?metodo. Questo metodo viene eseguito a ogni operazione di creazione e aggiornamento . Valuta tutte Modelle condizioni di convalida, restituendo truequando tutte le convalide vengono superate. Solo valid?le operazioni possono rendere persistenti le informazioni nel database.
La convalida per il nostro modulo multipasso deve verificare che l'indice del passo corrente nel modulo di richiesta corrisponda al suo ultimo passo . Abbiamo bisogno di due attributi per tenere traccia di queste variabili: current_stepper memorizzare l'indice del passo corrente e total_stepsper memorizzare il numero di passi in una forma multipasso. Poiché sia la convalida che gli attributi sono gli stessi per tutti i modelli, possiamo incapsularli in un file concernche può essere riutilizzato nelle nostre Modelclassi:
# app/models/concerns/multistep_form_model.rb
module MultistepFormModel
ERROR_ATTRIBUTE = :current_step
ERROR_DETAIL = :incompleted_multistep_form
extend ActiveSupport::Concern
included do
# Defines two new attributes in including Model class
attr_accessor :current_step, :total_steps
# Adds new validation rule in including Model class
validate :all_multistep_form_steps_completed
end
private
def all_multistep_form_steps_completed? = current_step == total_steps
def all_multistep_form_steps_completed
errors.add(ERROR_ATTRIBUTE, ERROR_DETAIL) unless all_multistep_form_steps_completed?
end
end
# app/models/concerns/multistep_form_model.rb
module MultistepFormModel
...
included do
# Adds new validation rule in including Model class
validate :all_multistep_form_steps_completed,
if: -> { current_step.present? && total_steps.present? }
end
...
end
Infine, possiamo aggiornare i nostri modelli includendo il nostro modulo multistep concernin modo da poterli utilizzare con il nostro modulo multistep:
# app/models/project.rb
class Project < ApplicationRecord
...
# Now our Project class has current_step and total_steps attributes, and the multistep validation rule
include MultistepFormModel
...
end
Controllerssono responsabili dell'analisi dei dati della richiesta del modulo e del loro trasferimento alla Modelclasse. Per lavorare con i moduli multistep, dobbiamo aggiornare i parametri forti del controller per garantire che i parametri curret_stepe total_stepsvengano analizzati e passati alla Modelclasse.
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
...
private
...
def project_params
params
.require(:project)
.permit(:name, :description, :total_steps, :current_step, books_attributes: [:title, :description])
end
...
end
Scomponiamo un form multistep in ViewComponents. Un modulo multistep è un modulo suddiviso in più parti (passaggi). In termini di componenti, questo può essere modellato come un file MultistepFormComponentil cui contenuto è composto da più file StepComponents.
Ogni passaggio contiene una serie di campi del modulo. Un passaggio è completato quando tutti i campi obbligatori sono riempiti con dati validi. A StepComponentesegue il rendering dei campi corrispondenti a un passaggio del modulo multistep e fornisce una semplice interfaccia per sapere, dati i dati del modulo, se il passaggio che rappresenta è completato .
In precedenza abbiamo parlato del valid?metodo e di come determina se i dati del modulo sono validi. Oltre a questo comportamento, aggiorna anche l' oggetto con un dizionario contenente errori di validazione . Vi si può accedere tramite , che restituisce un oggetto. valid?Modelmodel.errorsActiveModel::Errors
Per determinare se a StepComponentè completato, possiamo usare l' form.object.errorsoggetto. Dato un passaggio con un set di campi di input, possiamo confermare che il passaggio è completato se l' form.object.errorsoggetto non include alcun errore associato ai campi del passaggio.
# How to check whether a step is completed.
def completed?(form_object)
input_attributes.none? { |attr| form_object.errors.key?(attr) }
end
- Un
ActionView::Helpers::FormBuilderoggetto per eseguire il rendering dei campi del passaggio e recuperare i dati del modulo tramite il.objectmetodo. - Un elenco dei
input_attributescampi di cui esegue il rendering. - Non rivelato dal metodo ma ovvio, occorre conoscere anche l'indice del passo nella forma multipasso.
# app/components/common/multistep_form_component/step_component.rb
module Common
class MultistepFormComponent::StepComponent < ApplicationComponent
class << self
def title = raise "StepComponent #{name} does not define title method"
def input_attributes = raise "StepComponent #{name} does not define list of input attributes"
def completed?(form_object)
input_attributes.none? { |attr| form_object.errors.key?(attr) }
end
end
attr_reader :multistep_component, :index, :form
def initialize(multistep_component:, index:, html_attributes: {})
@index = index
@form = multistep_component.form
@multistep_component = multistep_component
super(html_attributes:)
end
def call
raise "
#{self.class.name} is a StepComponent. StepComponents are abstract by default.
Components inheriting from it must define its own template or call method.
"
end
def current_step? = index == multistep_component.current_step
def completed? = self.class.completed?(multistep_component.form.object)
end
end
Il StepComponentè un componente astratto . Non è progettato per essere visualizzato ma esteso da altre StepComponentclassi. Per i nostri Projectpassi del form, definiremo due nuovi componenti, ognuno per ogni passo che vogliamo renderizzare:
# app/components/common/projects/form_component/project_details_component.rb
module Common
class Projects::FormComponent::ProjectDetailsComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Project Details"
def input_attributes = %w[name]
end
...
end
end
# app/components/common/projects/form_component/project_deliverables_component.rb
module Common
class Projects::FormComponent::ProjectDeliverablesComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Project Deliverables"
# Example of a step component with nested attributes / nested form fields
def input_attributes = %w[books.title]
end
...
end
end
- Dove
URLverranno inviati i dati del modulo. - E i dati del modulo stesso. Questi dati rappresentano il record che vogliamo creare o aggiornare. In ogni iterazione del ciclo del modulo multifase, i dati rappresenteranno le informazioni fornite dall'utente nel precedente invio del modulo.
# app/components/common/multistep_form_component.rb
module Common
class MultistepFormComponent < ApplicationComponent
class << self
def steps = raise "#{name} MultistepFormComponent does not define steps class model"
end
delegate :steps, to: :class
attr_reader :form_url, :back_url, :model, :form
def initialize(form_url:, back_url:, model:, html_attributes: {})
@form_url = form_url
@back_url = back_url
@model = model
super(html_attributes:)
end
def call
form_with(url:, model: model) do |form|
@form = form
concat(form.hidden_field(:total_steps, value: steps.count))
concat(form.hidden_field(:current_step, value: current_step_index))
steps.each_with_index do |step, index|
concat(render step.new(multistep_form: self, index:))
end
end
end
end
end
Possiamo ora definire il nostro progetto FormComponentestendendo la classe e specificando il passo delle StepComponentclassi che ne definiscono la forma:
# app/components/common/projects/form_component.rb
module Common
class Projects::FormComponent < Common::MultistepFormComponent
class << self
def steps = [ProjectDetailsComponent, ProjectDeliverablesComponent]
end
end
end
Esistono diversi modi per navigare tra i passaggi: alcuni consentono solo la navigazione in avanti fino al completamento di tutti i passaggi; in altri, è possibile navigare avanti e indietro di un passaggio e di un tempo, e altri consentono persino di visualizzare più passaggi precedenti contemporaneamente. Il nostro modulo multistep dovrebbe essere abbastanza flessibile da implementare tutte le possibili strategie di navigazione . Tuttavia, non dovrebbe implementare alcuna strategia di navigazione. Lo delegheremo ai componenti che estendono la MultistepFormComponentclasse.
Possiamo nascondere e mostrare i passaggi usando input semplici checkboxe radio HTML. A checkboxè una semplice scatola che può essere checked. A radioè un gruppo di checkboxesin cui solo uno può essere checked. I CSS possono scegliere come target lo stato controllato di questi elementi tramite il :checkedselettore. Ciò consente HTMLelementi di stile basati sullo stato di a checkboxo radio.
Possiamo assegnare un radioe checkboxinput a ogni passo nel nostro form e dare uno stile al passo in base al suo stato in modo che sia visibile solo quando l'input corrispondente è selezionato. Definiremo un nuovo metodo per racchiudere ogni istanza di passaggio all'interno di un tag contenente una radio e una casella di controllo per raggiungerlo.
# app/components/common/multistep_form_component.rb
...
def call
...
steps.each_with_index do |step, index|
concat(step_wrapper(step, index) { render step.new(multistep_form: self, index:) })
end
...
end
def radio_id(step_index) = "#{object_id}_radio_#{step_index}"
def checkbox_id(step_index) = "#{object_id}_radio_#{step_index}"
def step_wrapper(step, index, html_attributes: {}, &content)
tag.div(**html_attributes) do
concat(
radio_button_tag(
radio_id(nil),
1,
current_step_index == index,
id: radio_id(index),
class: "hidden peer/radio"
)
)
concat(
check_box_tag(
checkbox_id(index),
1,
current_step_index == index,
id: checkbox_id(index),
class: "hidden peer/checkbox"
)
)
concat(capture(&content))
end
end
...
Il componente che estende il MultistepFormComponentpuò aggiornare questo metodo definendo gli elementi comuni presenti in tutti i passaggi e utilizzando il peersper mostrare e nascondere i passaggi in base allo checkedstato.
# app/components/common/projects/form_component.rb
...
def step_wrapper(step, index, html_attributes: {}, &content)
selected = index == current_step_index
options = (selected ? { "aria-current": "step" } : {})
super(step, index, html_attributes: options) do
concat(
tag.label(
for: index <= current_step_index ? radio_id(index) : "",
class: "
w-full inline-flex items-center px-4 py-2 gap-4 border-2 text-zinc-500 border-zinc-500
peer-checked/radio:bg-zinc-500 peer-checked/radio:text-white
"
) do
concat(tag.p(index + 1, class: "h-6 w-6 text-center rounded-full text-white bg-zinc-500"))
concat(tag.p(step.title))
end
)
concat(
tag.div(
class: "
hidden px-4 py-6
peer-checked/radio:block
"
) do
concat(capture(&content))
concat(
tag.div(class: "mt-5 w-full inline-flex gap-2 items-center justify-end") do
concat(link_to("cancel", back_url, class: "px-4 py-2 bg-zinc-500 text-white")) if index == 0
concat(tag.label("previous", for: radio_id(index - 1), class: "px-4 py-2 bg-zinc-500 text-white")) if index > 0
concat(form.submit("next", class: "cursor-pointer px-4 py-2 bg-zinc-500 text-white")) if index < (steps.count - 1)
concat(form.submit("create project", class: "cursor-pointer px-4 py-2 bg-zinc-500 text-white")) if index == (steps.count - 1)
end
)
end
)
end
end
...
Nella riga 31 c'è la logica per tornare indietro nel modulo. Usiamo il MultistepFormComponent radio_idmetodo per mappare l'etichetta all'input radiodel passaggio precedente. Quando clicchiamo sull'etichetta, il radiopulsante verrà selezionato. Questo nasconderà il passaggio corrente e rivelerà quello precedente.
Per navigare in avanti, dobbiamo sempre inviare il modulo. Se, dopo l'invio del modulo, tutti i passaggi precedenti sono stati completati, il multistep eseguirà il rendering del passaggio successivo. In caso contrario, il modulo tornerà al passaggio incompleto.
# app/components/common/multistep_form_component.rb
...
def first_incompleted_step_index
steps.find_index { |s| !s.completed?(model) }
end
def current_step_index
previous_step = model.current_step&.to_i
@current_step_index =
if model.current_step.nil?
0
else
next_step = previous_step + (steps[previous_step].completed?(model) ? 1 : 0)
[next_step, first_incompleted_step_index].compact.min
end
end
...
# app/components/common/multistep_form_component.rb
...
def step_wrapper(step, index, html_attributes: {}, &content)
tag.div(**html_attributes) do
concat(
radio_button_tag(
...
# If true radio is checked
current_step_index == index,
...
)
)
concat(
check_box_tag(
...
# If true checkbox is checked
current_step_index == index,
...
)
)
concat(capture(&content))
end
end
...
Aggiungeremo un nuovo metodo responsabile della rimozione di tutti i messaggi di errore relativi ai passaggi che l'utente non ha visitato e un altro per eliminare l' current_steperrore. Per raggiungere questo obiettivo, aggiungeremo un terzo attributo responsabile del monitoraggio dell'ultimo passaggio nel modulo compilato dall'utente in modo da poter determinare quali passaggi rimangono non visitati.
# app/components/common/multistep_form_component.rb
...
def call
...
concat(form.hidden_field(:total_steps, value: steps.count))
concat(form.hidden_field(:latest_step, value: latest_step_index))
concat(form.hidden_field(:current_step, value: current_step_index))
...
end
end
...
def latest_step_index
@latest_step_index =
if model.latest_step.nil?
0
else
[current_step_index, model.latest_step].max
end
end
...
Ci sono due elementi chiave da testare per il nostro modulo multistep: la nostra Modelpreoccupazione e il file MultistepFormComponent. Sono inoltre essenziali ulteriori unit test e test di sistema per i controller, i componenti del progetto e l'intero flusso utente di creazione dei progetti. Tuttavia, non comportano alcuna configurazione speciale come fanno questi altri.
Per testare la MultistepFormModelpreoccupazione, potremmo essere tentati di testare le current_stepconvalide tramite il Projectmodello. Questa non è una buona pratica poiché potrebbe portare a test instabili: non dobbiamo testare problemi Modelsche potrebbero non includerli o estenderli in futuro.
Invece, possiamo definire una Modelclasse ausiliaria con il solo scopo di testare il problema e quindi eseguire i test con essa. Questo approccio ha due vantaggi: testare il problema senza fare affidamento sulle Modelclassi della nostra logica di business e documentare come il problema può essere utilizzato nelle nostre Modelclassi.
class MultistepFormModelTest < ActiveSupport::TestCase
class ValidMultistepFormMode
include ActiveModel::Model
include MultistepFormModel
end
def setup
@model = ValidMultistepFormMode.new
end
# Validations
# -----------
test "MultistepFormModel does not apply step validation if total and current attibutes are not specified" do
assert_nil @model.current_step
assert_nil @model.total_steps
assert @model.valid?
end
test "MultistepFormModel is valid only when total and current steps are the same" do
@model.current_step = 1
@model.total_steps = 2
refute @model.valid?
@model.current_step = 3
@model.total_steps = 3
assert @model.valid?
end
test "MultistepFormModel sets custom error attribute and detail when total and current step attributes are invalid" do
@model.current_step = 1
@model.total_steps = 2
refute @model.valid?
assert_equal @model.errors.first.attribute, MultistepFormModel::ERROR_ATTRIBUTE
assert_equal @model.errors.first.detail, MultistepFormModel::ERROR_DETAIL
end
end
class Common::MultistepFormComponentTest < ViewComponent::TestCase
class SampleModel
include ActiveModel::Model
include MultistepFormModel
attr_accessor :attribute_1, :attribute_2, :attribute_3
validates_presence_of :attribute_1
validates_presence_of :attribute_2
validates_presence_of :attribute_3
end
class TestMultistepFormComponent < Common::MultistepFormComponent
class << self
def steps = [FirstTestStepComponent, SecondTestStepComponent, ThirdTestStepComponent]
end
end
class FirstTestStepComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "First Step"
def input_attributes = %i[attribute_1]
end
def call
tag.div(**wrapper_attributes)
end
end
class SecondTestStepComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Second Step"
def input_attributes = %i[attribute_2]
end
def call
tag.div(**wrapper_attributes)
end
end
class ThirdTestStepComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Third Step"
def input_attributes = %i[attribute_3]
end
def call
tag.div(**wrapper_attributes)
end
end
def setup
@component = TestMultistepFormComponent
end
test "Renders multistep form" do
render_inline(@component.new(form_url: "test", back_url: "test", model: SampleModel.new))
assert_selector(".#{@component.component_class}")
end
test "Renders multistep form steps" do
render_inline(@component.new(form_url: "test", back_url: "test", model: SampleModel.new))
assert_selector(".#{FirstTestStepComponent.component_class}", visible: :all)
assert_selector(".#{SecondTestStepComponent.component_class}", visible: :all)
assert_selector(".#{ThirdTestStepComponent.component_class}", visible: :all)
end
test "Selects first step as current step if current_step value is nil" do
model = SampleModel.new
model.valid?
result = @component.new(form_url: "test", back_url: "test", model:).current_step_index
assert_equal 0, result
end
...
end
I multistep in Rails sono strettamente accoppiati ai livelli del controller e del modello. Per mantenere la nostra logica aziendale il più separata possibile da questo componente, ci affidiamo a concernscomponenti astratti. Questi strumenti funzionano come una logica plug & play che rende estremamente facile l'utilizzo dei multistep in qualsiasi parte della nostra applicazione.
La natura astratta del MultistepFormComponentsemplifica il compito di implementare forme con diversi disegni e logiche di navigazione, delegando l'implementazione di questa logica ai componenti che la estendono.
Infine, scrivendo alcuni test generici che si basano su classi Modele Componenttest agnostici, assicuriamo il corretto funzionamento dei multistep in tutte le nostre applicazioni e forniamo un esempio dettagliato e ben documentato di come gli sviluppatori possono utilizzare la logica multistep in futuro.
Codice in Github
- 140 modulo multistep di AlbertoHdezCerezo · Pull Request #150 · Albert-Markdown-Editor/Albert
- Come fare: creare moduli accessibili: il progetto A11Y
- Nei moduli con più passaggi, identifica il passaggio corrente utilizzando aria-current
Se ti ritrovi perso in qualsiasi punto dell'articolo o provi a replicare la mia soluzione senza risultati, puoi contattarmi nella sezione dei commenti. Sarò lieto di fornire ulteriori informazioni e cercare di chiarire le questioni aperte. Presto implementerò ulteriori canali per la condivisione di feedback e domande.
Se sei arrivato a questo punto, grazie per il tuo tempo e la tua pazienza. Spero che tu abbia trovato utile il mio tutorial!

![Che cos'è un elenco collegato, comunque? [Parte 1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)



































