Multistep Form (Wizard) — Ricette Rails

Feb 12 2023
Anatomia di un modulo multistep Un modulo multistep è una variante di una procedura guidata. Una procedura guidata è uno strumento di usabilità.

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 .

Confronto di una forma regolare con una forma multistep

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 Rails viewsin 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.TailwindCSS PostCSSCSSCSSCSS

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:

  1. Il livello Rails Controlleranalizza i dati della richiesta del modulo e chiede al Modellivello di eseguire l'operazione richiesta con le informazioni estratte.
  2. Il livello Rails Modelelabora i dati di richiesta, li convalida ed esegue l'operazione richiesta se è valida. Il risultato dell'operazione viene rispedito dal Modelal Controller.
  3. Il livello Rails Controllerrichiede al Viewlivello 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.
Logica di business di un modulo multistep

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

  1. Un ActionView::Helpers::FormBuilderoggetto per eseguire il rendering dei campi del passaggio e recuperare i dati del modulo tramite il .objectmetodo.
  2. Un elenco dei input_attributescampi di cui esegue il rendering.
  3. 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!