Multistep Form (Wizard) — Receitas Rails
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 .
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 Railsviewsem 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.
TailwindCSSPostCSSCSSCSSCSS
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:
- 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. - 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 doModelpara oController. ControllerA camada Rails solicita que aViewcamada 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.
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
- Um
ActionView::Helpers::FormBuilderobjeto para renderizar os campos da etapa e buscar os dados do formulário por meio do.objectmétodo. - Uma lista dos
input_attributescampos que ele renderiza. - 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!





































![O que é uma lista vinculada, afinal? [Parte 1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)