Formulario de varios pasos (Asistente) — Recetas de Rails

Feb 12 2023
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.

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 .

Comparación de un formulario regular con un formulario de varios pasos

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

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:

  1. La capa de Rails Controlleranaliza los datos de solicitud del formulario y le pide a la Modelcapa que realice la operación solicitada con la información extraída.
  2. 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 del Modelal Controller.
  3. La capa Rails Controllersolicita a la Viewcapa 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.
Lógica empresarial de un formulario de varios pasos

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

  1. Un ActionView::Helpers::FormBuilderobjeto para representar los campos de paso y obtener los datos del formulario a través del .objectmétodo.
  2. Una lista de los input_attributescampos que representa.
  3. 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!