Formulario de varios pasos (Asistente) — Recetas de Rails
Anatomía de un formulario de varios pasos
Un formulario de varios pasos es una variante de un asistente. Un asistente es una herramienta de usabilidad. Representa una tarea como un conjunto de pasos organizados en un orden prescrito . Los usuarios deben completar cada paso en el orden dado para lograr su objetivo.
Los formularios de varios pasos dividen formularios complejos en partes más pequeñas, agrupando sus campos por diferentes criterios . Esto da como resultado una experiencia de llenado de formularios menos abrumadora para los usuarios finales. Cuando se combinan con instrucciones, los formularios de varios pasos aumentan significativamente la facilidad de uso de nuestras aplicaciones .
Su principal inconveniente es la eficiencia . Por lo general, completar un formulario de varios pasos lleva más tiempo que completar un solo formulario (si está familiarizado con el proceso). Esto se debe a la navegación entre pasos, que no existe en los formularios estándar.
La forma en que implemente esta navegación puede afectar significativamente la experiencia del usuario : ¿permitirá a los usuarios navegar hacia adelante y hacia atrás a cualquier paso del asistente o solo al paso siguiente y al anterior? ¿La información del paso anterior está visible en el paso actual? Las malas elecciones de diseño pueden dar lugar a experiencias de llenado de formularios lentas y frustrantes que desaniman a los usuarios a completar nuestro formulario e incluso a utilizar nuestro sistema.
Esta receta le enseñará a crear formularios de varios pasos reutilizables y personalizables en Rails.
El reto
En nuestra aplicación gestionamos Projects. Un proyecto está definido por a namey a description. A Projectpuede tener múltiples Booksasociados. A Bookestá definida por a titley a description. Queremos implementar un formulario de varios pasos para crear Projects. El primer paso requerirá la información básica del proyecto, y el segundo, opcional, información sobre un Bookasociado al Project.
La receta
requisitos previos
- (Requerido)
ViewComponentGema Rails instalada :ViewComponentenvuelve Railsviewsen objetos. Esto facilita la prueba de nuestras vistas y proporciona métodos adicionales para una mejor experiencia de desarrollador al trabajar con ellas. - Complemento (opcional) instalado : este marco elimina la necesidad de escribir en nuestro código al proporcionar un poderoso conjunto de clases.
TailwindCSSPostCSSCSSCSSCSS
En Rails, usamos formularios para crear y actualizar registros. Estas operaciones se realizan a través de POST, PUTy PATCH HTTPlas solicitudes se envían desde un solo formulario. Las solicitudes se procesan siguiendo el mismo proceso:
- La capa de Rails
Controlleranaliza los datos de solicitud del formulario y le pide a laModelcapa que realice la operación solicitada con la información extraída. - La capa Rails
Modelprocesa los datos solicitados, los valida y realiza la operación solicitada si es válida. El resultado de la operación se devuelve delModelalController. - La capa Rails
Controllersolicita a laViewcapa que proporcione una respuesta basada en el resultado de la operación. Las operaciones exitosas muestran una vista de los resultados de la operación. Las operaciones fallidas representan el formulario enviado con los datos de solicitud del formulario y los mensajes de error.
Nuestra primera tarea es implementar la lógica para validar si se completaron todos los pasos en un formulario de varios pasos.
ActiveRecord::Validations La API de Rails , incluida en ApplicationRecord Modellas clases, define el valid?método. Este método se ejecuta en cada operación de creación y actualización . Evalúa todas Modellas condiciones de validación y regresa truecuando pasan todas las validaciones. Solo valid?las operaciones pueden conservar la información en la base de datos.
La validación de nuestro formulario de varios pasos debe comprobar que el índice del paso actual en la solicitud del formulario corresponde a su último paso . Necesitamos dos atributos para rastrear estas variables: current_stepalmacenar el índice de pasos actual y total_stepsalmacenar el número de pasos en un formulario de varios pasos. Dado que tanto la validación como los atributos son los mismos para todos los modelos, podemos encapsularlos en un archivo concernque se puede reutilizar en nuestras Modelclases:
# 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
Finalmente, podemos actualizar nuestros modelos incluyendo nuestro formulario de varios pasos concernpara que podamos usarlos con nuestro formulario de varios pasos:
# 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
Controllersson responsables de analizar los datos de solicitud del formulario y pasarlos a la Modelclase. Para trabajar con formularios de varios pasos, necesitamos actualizar los parámetros fuertes del controlador para garantizar que los parámetros curret_stepy total_stepsse analicen y pasen a la Modelclase.
# 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
Descompongamos un formulario de varios pasos en ViewComponents. Un formulario de varios pasos es un formulario dividido en varias partes (pasos). En términos de componentes, esto se puede modelar como un MultistepFormComponentcuyo contenido se compone de varios archivos StepComponents.
Cada paso contiene un conjunto de campos de formulario. Un paso se completa cuando todos los campos requeridos por el paso se completan con datos válidos. A StepComponentrepresenta los campos correspondientes a un paso de formulario de varios pasos y proporciona una interfaz simple para saber, dados los datos del formulario, si se completó el paso que representa .
Anteriormente hablamos sobre el valid?método y cómo determina si los datos del formulario son válidos. Además de este comportamiento, también actualiza el objeto con un diccionario que contiene errores de validación . Se puede acceder a través de , que devuelve un objeto. valid?Modelmodel.errorsActiveModel::Errors
Para determinar si a StepComponentestá completo, podemos usar el form.object.errorsobjeto. Dado un paso con un conjunto de campos de entrada, podemos confirmar que el paso se completó si el form.object.errorsobjeto no incluye ningún error asociado con los campos del paso.
# 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::FormBuilderobjeto para representar los campos de paso y obtener los datos del formulario a través del.objectmétodo. - Una lista de los
input_attributescampos que representa. - No revelado por el método pero obvio, también necesitamos saber el índice del paso en la forma de varios pasos.
# 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
El StepComponentes un componente abstracto . No está diseñado para ser renderizado sino extendido por otras StepComponentclases. Para nuestros Projectpasos de formulario, definiremos dos nuevos componentes, cada uno para cada paso que queremos 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
- El
URLlugar donde se enviarán los datos del formulario. - Y los datos del formulario en sí. Estos datos representan el registro que queremos crear o actualizar. En cada iteración del ciclo del formulario de varios pasos, los datos representarán la información proporcionada por el usuario en el envío del formulario 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
Ahora podemos definir nuestro proyecto FormComponentextendiendo la clase y especificando el paso de StepComponentclases que definen su forma:
# app/components/common/projects/form_component.rb
module Common
class Projects::FormComponent < Common::MultistepFormComponent
class << self
def steps = [ProjectDetailsComponent, ProjectDeliverablesComponent]
end
end
end
Hay varias formas de navegar entre los pasos: algunas solo permiten la navegación hacia adelante hasta que se completen todos los pasos; en otros, es posible navegar hacia adelante y hacia atrás un paso y una vez, y otros incluso permiten mostrar varios pasos anteriores a la vez. Nuestro formulario de varios pasos debe ser lo suficientemente flexible para implementar todas las estrategias de navegación posibles . Sin embargo, no debe implementar ninguna estrategia de navegación. Lo delegaremos a los componentes extendiendo la MultistepFormComponentclase.
Podemos ocultar y mostrar pasos usando entradas simples checkboxy . radio HTMLA checkboxes una caja simple que puede ser checked. A radioes un grupo de checkboxesen el que sólo puede estar uno checked. CSS puede apuntar al estado verificado de estos elementos a través del :checkedselector. Esto permite diseñar HTMLelementos según el estado de a checkboxo radio.
Podemos asignar una entrada radioy checkboxa cada paso en nuestro formulario y diseñar el paso según su estado para que solo sea visible cuando se marca la entrada correspondiente. Definiremos un nuevo método para envolver cada instancia de paso dentro de una etiqueta que contenga una radio y una casilla de verificación para lograrlo.
# 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
...
El componente que extiende MultistepFormComponentpuede actualizar este método definiendo elementos comunes presentes en todos los pasos y usando peerspara mostrar y ocultar pasos según el checkedestado.
# 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
...
En la línea 31 está la lógica para navegar un paso atrás en el formulario. Usamos el MultistepFormComponent radio_idmétodo para asignar la etiqueta a la radioentrada del paso anterior. Cuando hagamos clic en la etiqueta, el radiobotón se marcará. Esto ocultará el paso actual y revelará el anterior.
Para navegar hacia adelante, siempre necesitamos enviar el formulario. Si, después del envío del formulario, se completan todos los pasos anteriores, el paso múltiple representará el paso siguiente. De lo contrario, el formulario volverá al paso 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
...
Agregaremos un nuevo método responsable de eliminar todos los mensajes de error relacionados con los pasos que el usuario no visitó y otro para eliminar el current_steperror. Para lograr esto, agregaremos un tercer atributo responsable de rastrear el último paso en el formulario que el usuario llenó para que podamos determinar qué pasos quedan sin visitar.
# 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
...
Hay dos elementos clave para probar nuestro formulario de varios pasos: nuestra Modelpreocupación y el MultistepFormComponent. También son esenciales las pruebas adicionales de unidades y sistemas para los controladores, los componentes del proyecto y todo el flujo de usuarios para la creación de proyectos. Aún así, no implican ninguna configuración especial como estos otros.
Para probar la MultistepFormModelpreocupación, podríamos tener la tentación de probar las current_stepvalidaciones a través del Projectmodelo. Esta no es una buena práctica, ya que podría dar lugar a pruebas inestables: no debemos probar las preocupaciones Modelsque podrían no incluirse o extenderse en el futuro.
En su lugar, podemos definir una Modelclase auxiliar con el único propósito de probar la inquietud y luego ejecutar las pruebas con ella. Este enfoque tiene dos beneficios: probar la inquietud sin depender de Modellas clases de nuestra lógica comercial y documentar cómo se puede usar la inquietud en nuestras Modelclases.
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
Los pasos múltiples en Rails están estrechamente acoplados a las capas del controlador y del modelo. Para mantener nuestra lógica empresarial lo más separada posible de este componente, nos basamos en concernscomponentes abstractos. Estas herramientas funcionan como una lógica plug & play que hace que sea extremadamente fácil usar varios pasos en cualquier parte de nuestra aplicación.
El carácter abstracto del MultistepFormComponentsimplifica la tarea de implementar formularios con diferentes diseños y lógicas de navegación, al delegar la implementación de esta lógica a los componentes que la amplían.
Finalmente, al escribir algunas pruebas genéricas que se basan en pruebas Modely Componentclases agnósticas, aseguramos el correcto funcionamiento de los pasos múltiples en todas nuestras aplicaciones y brindamos un ejemplo detallado y bien documentado de cómo los desarrolladores pueden hacer uso de la lógica de pasos múltiples en el futuro.
Código en Github
- 140 formulario de varios pasos por AlbertoHdezCerezo · Solicitud de extracción #150 · Albert-Markdown-Editor/Albert
- Cómo hacerlo: Crear formularios accesibles — The A11Y Project
- En formularios con múltiples pasos, identifique el paso actual usando aria-current
Si se encuentra perdido en algún punto del artículo o intenta replicar mi solución sin resultado, puede comunicarse conmigo en la sección de comentarios. Estaré encantado de proporcionar información adicional y tratar de aclarar las preguntas abiertas. Pronto implementaré canales adicionales para compartir comentarios y preguntas.
Si ha llegado a este punto, gracias por su tiempo y paciencia. ¡Espero que hayas encontrado útil mi tutorial!

![¿Qué es una lista vinculada, de todos modos? [Parte 1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)



































