Multistep Form (Wizard) — Receitas Rails

Feb 12 2023
Anatomia de um formulário de várias etapas Um formulário de várias etapas é uma variante de um assistente. Um assistente é uma ferramenta de usabilidade.

Anatomia de um formulário de várias etapas

Um formulário de várias etapas é uma variante de um assistente. Um assistente é uma ferramenta de usabilidade. Ele representa uma tarefa como um conjunto de etapas organizadas em uma ordem prescrita . Os usuários devem concluir cada etapa na ordem especificada para atingir seu objetivo.

Formulários de várias etapas dividem formulários complexos em partes menores, agrupando seus campos por diferentes critérios . Isso resulta em uma experiência de preenchimento de formulário menos assustadora para os usuários finais. Quando combinados com instruções, os formulários de várias etapas aumentam significativamente a facilidade de uso de nossos aplicativos .

Comparação de um formulário regular com um formulário de várias etapas

Sua principal desvantagem é a eficiência . Preencher um formulário de várias etapas geralmente leva mais tempo do que preencher um único formulário (se você estiver familiarizado com o processo). Isso se deve à navegação entre as etapas, que não existe nos formulários padrão.

A forma como você implementa essa navegação pode afetar significativamente a experiência do usuário : você permitirá que os usuários naveguem para frente e para trás em qualquer etapa do assistente ou apenas nas etapas seguintes e anteriores? as informações da etapa anterior estão visíveis na etapa atual? Escolhas ruins de design podem levar a experiências de preenchimento de formulário lentas e frustrantes que desencorajam os usuários a preencher nosso formulário e até mesmo a usar nosso sistema.

Esta receita ensinará você a criar formulários de várias etapas reutilizáveis ​​e personalizáveis ​​no Rails.

O desafio

Em nosso aplicativo, gerenciamos arquivos Projects. Um projeto é definido por a namee a description. A Projectpode ter múltiplos Booksassociados a ele. A Booké definido por a titlee a description. Queremos implementar um formulário de várias etapas para criar arquivos Projects. A primeira etapa exigirá as informações básicas do projeto, e a segunda, opcional, informações sobre um Bookassociado ao Project.

A receita

Pré-requisitos

  • (Obrigatório) Rails ViewComponentgem instalado : ViewComponentenvolve Rails viewsem objetos. Isso facilita o teste de nossas exibições e fornece métodos adicionais para uma melhor experiência do desenvolvedor ao trabalhar com elas.
  • (Opcional) plug-in instalado : esta estrutura elimina a necessidade de escrever em nosso código, fornecendo um poderoso conjunto de classes.TailwindCSS PostCSSCSSCSSCSS

No Rails, usamos formulários para criar e atualizar registros. Essas operações são realizadas por meio de POST, PUTe PATCH HTTPsolicitações enviadas de um único formulário. As solicitações são processadas seguindo o mesmo processo:

  1. A camada Rails Controlleranalisa os dados da solicitação do formulário e solicita à Modelcamada que execute a operação solicitada com as informações extraídas.
  2. A camada Rails Modelprocessa os dados solicitados, os valida e executa a operação solicitada, se for válida. O resultado da operação é enviado de volta do Modelpara o Controller.
  3. ControllerA camada Rails solicita que a Viewcamada renderize uma resposta com base no resultado da operação. As operações bem-sucedidas fornecem uma visão dos resultados da operação. Operações malsucedidas processam o formulário enviado com os dados de solicitação de formulário e mensagens de erro.
Lógica de negócios de um formulário de várias etapas

Nossa primeira tarefa é implementar a lógica para validar se todas as etapas em um formulário de várias etapas foram concluídas.

ActiveRecord::Validations A API Rails , incluída nas ApplicationRecord Modelclasses, define o valid?método. Este método é executado em cada operação de criação e atualização . Ele avalia todas Modelas condições de validação, retornando truequando todas as validações passam. Somente valid?as operações podem persistir informações no banco de dados.

A validação do nosso formulário multistep deve verificar se o índice do passo atual na solicitação do formulário corresponde ao seu último passo . Precisamos de dois atributos para rastrear essas variáveis: current_steppara armazenar o índice de etapas atual e total_stepspara armazenar o número de etapas em um formulário de várias etapas. Como a validação e os atributos são os mesmos para todos os modelos, podemos encapsulá-los em um concernque pode ser reutilizado em nossas Modelclasses:

# 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

Por fim, podemos atualizar nossos modelos incluindo nosso formulário de várias etapas concernpara que possamos usá-los com nosso formulário de várias etapas:

# 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

Controllerssão responsáveis ​​por analisar os dados da solicitação do formulário e transmiti-los à Modelclasse. Para trabalhar com formulários de várias etapas, precisamos atualizar os parâmetros fortes do controlador para garantir que os parâmetros curret_stepe total_stepssejam analisados ​​e passados ​​para a 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

Vamos decompor um formulário de várias etapas em ViewComponents. Um formulário de várias etapas é um formulário dividido em várias partes (etapas). Em termos de componentes, isso pode ser modelado como um MultistepFormComponentcujo conteúdo é composto por vários arquivos StepComponents.

Cada etapa contém um conjunto de campos de formulário. Uma etapa é concluída quando todos os campos obrigatórios da etapa são preenchidos com dados válidos. A StepComponentrenderiza os campos correspondentes a uma etapa de formulário de várias etapas e fornece uma interface simples para saber, dados os dados do formulário, se a etapa que ele representa foi concluída .

Anteriormente, falamos sobre o valid?método e como ele determina se os dados do formulário são válidos. Além desse comportamento, também atualiza o objeto com um dicionário contendo erros de validação . Ele pode ser acessado via , que retorna um objeto. valid?Modelmodel.errorsActiveModel::Errors

Para determinar se a StepComponentfoi concluído, podemos usar o form.object.errorsobjeto. Dada uma etapa com um conjunto de campos de entrada, podemos confirmar que a etapa foi concluída se o form.object.errorsobjeto não incluir nenhum erro associado aos campos da etapa.

# How to check whether a step is completed.
def completed?(form_object)
  input_attributes.none? { |attr| form_object.errors.key?(attr) }
end

  1. Um ActionView::Helpers::FormBuilderobjeto para renderizar os campos da etapa e buscar os dados do formulário por meio do .objectmétodo.
  2. Uma lista dos input_attributescampos que ele renderiza.
  3. Não revelado pelo método, mas óbvio, também precisamos saber o índice da etapa na forma de várias etapas.

# 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

O StepComponenté um componente abstrato . Ele não foi projetado para ser renderizado, mas estendido por outras StepComponentclasses. Para nossas Projectetapas de formulário, definiremos dois novos componentes, cada um para cada etapa que desejamos renderizar:

# 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

  • O URLlocal onde os dados do formulário serão enviados.
  • E os próprios dados do formulário. Esses dados representam o registro que queremos criar ou atualizar. Em cada iteração do ciclo de formulário de várias etapas, os dados representarão as informações fornecidas pelo usuário no envio do formulário anterior.

# 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

Agora podemos definir nosso projeto FormComponentestendendo a classe e especificando a etapa das StepComponentclasses que definem sua forma:

# app/components/common/projects/form_component.rb

module Common
  class Projects::FormComponent < Common::MultistepFormComponent
    class << self
      def steps = [ProjectDetailsComponent, ProjectDeliverablesComponent]
    end
  end
end

Existem várias maneiras de navegar entre as etapas: algumas permitem apenas a navegação para frente até que todas as etapas sejam concluídas; em outros, é possível navegar para frente e para trás uma etapa e outra vez, e outros ainda permitem a exibição de várias etapas anteriores ao mesmo tempo. Nosso formulário de várias etapas deve ser flexível o suficiente para implementar todas as estratégias de navegação possíveis . No entanto, não deve implementar nenhuma estratégia de navegação. Vamos delegá-lo aos componentes que estendem a MultistepFormComponentclasse.

Podemos ocultar e mostrar etapas usando entradas simples checkboxe . radio HTMLA checkboxé uma caixa simples que pode ser checked. A radioé um grupo de checkboxesno qual apenas um pode ser checked. O CSS pode direcionar o status verificado desses elementos por meio do :checkedseletor. Isso permite estilizar HTMLelementos com base no status de um checkboxou radio.

Podemos atribuir uma entrada radioe checkboxa cada etapa em nosso formulário e estilizar a etapa de acordo com seu estado, para que fique visível apenas quando a entrada correspondente for verificada. Vamos definir um novo método para agrupar cada instância de etapa dentro de uma tag contendo um rádio e uma caixa de seleção para alcançá-lo.

# 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
...

O componente que estende o MultistepFormComponentpode atualizar esse método definindo elementos comuns presentes em todas as etapas e usando o peerspara mostrar e ocultar as etapas com base no checkedstatus.

# 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
...

Na linha 31 está a lógica para navegar um passo atrás no formulário. Usamos o MultistepFormComponent radio_idmétodo para mapear o rótulo para a radioentrada da etapa anterior. Quando clicarmos no rótulo, o radiobotão será verificado. Isso ocultará a etapa atual e revelará a anterior.

Para navegar para a frente, sempre precisamos enviar o formulário. Se, após o envio do formulário, todas as etapas anteriores forem concluídas, o multistep renderizará a próxima etapa. Caso contrário, o formulário voltará para a etapa incompleta.

# 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
...

Vamos adicionar um novo método responsável por remover todas as mensagens de erro relacionadas a etapas que o usuário não visitou e outro para excluir o current_steperro. Para conseguir isso, adicionaremos um terceiro atributo responsável por rastrear a última etapa no formulário que o usuário preencheu para que possamos determinar quais etapas permanecem não visitadas.

# 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
...

Existem dois elementos-chave para testar nosso formulário de várias etapas: nossa Modelpreocupação e o MultistepFormComponent. Testes adicionais de unidade e sistema para os controladores, componentes do projeto e todo o fluxo do usuário na criação de projetos também são essenciais. Ainda assim, eles não envolvem nenhuma configuração especial como esses outros.

Para testar a MultistepFormModelpreocupação, podemos ser tentados a testar as current_stepvalidações por meio do Projectmodelo. Esta não é uma boa prática, pois pode levar a testes incompletos: não devemos testar preocupações Modelsque possam não incluí-las ou ampliá-las no futuro.

Em vez disso, podemos definir uma Modelclasse auxiliar com o único objetivo de testar o interesse e, em seguida, executar os testes com ela. Essa abordagem tem dois benefícios: testar o interesse sem depender de Modelclasses de nossa lógica de negócios e documentar como o interesse pode ser usado em nossas Modelclasses.

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

Os multisteps no Rails são fortemente acoplados às camadas do controlador e do modelo. Para manter nossa lógica de negócios o mais separada possível desse componente, contamos com concernscomponentes abstratos. Essas ferramentas funcionam como uma lógica plug & play que torna extremamente fácil o uso de multietapas em qualquer parte de nosso aplicativo.

A natureza abstrata do MultistepFormComponentsimplifica a tarefa de implementar formulários com diferentes designs e lógicas de navegação, ao delegar a implementação dessa lógica aos componentes que a estendem.

Por fim, ao escrever alguns testes genéricos que dependem de classes Modele Componenttestes agnósticos, garantimos o funcionamento correto das etapas múltiplas em todos os nossos aplicativos e fornecemos um exemplo detalhado e bem documentado de como os desenvolvedores podem usar a lógica de etapas múltiplas no futuro.

Código no Github

  • Formulário multietapas 140 por AlbertoHdezCerezo · Solicitação pull nº 150 · Albert-Markdown-Editor/Albert
  • How-to: Criar formulários acessíveis — The A11Y Project
  • Em formulários com várias etapas, identifique a etapa atual usando aria-current

Se você se perder em algum ponto do artigo ou tentar replicar minha solução sem resultado, entre em contato comigo na seção de comentários. Terei prazer em fornecer informações adicionais e tentar esclarecer questões em aberto. Em breve implementarei canais adicionais para compartilhar feedback e perguntas.

Se você chegou a este ponto, obrigado por seu tempo e paciência. Espero que você tenha achado meu tutorial útil!