Multistep Form (Wizard) — Resep Rel

Feb 12 2023
Anatomi Bentuk Multistep Bentuk multistep adalah varian dari wizard. Wizard adalah alat kegunaan.

Anatomi Bentuk Multistep

Bentuk multistep adalah varian dari wizard. Wizard adalah alat kegunaan. Ini merupakan tugas sebagai satu set langkah diatur dalam urutan yang ditentukan . Pengguna harus menyelesaikan setiap langkah dalam urutan yang diberikan untuk mencapai tujuannya.

Formulir bertingkat membagi formulir kompleks menjadi bagian-bagian yang lebih kecil, mengelompokkan bidangnya menurut kriteria yang berbeda . Ini menghasilkan pengalaman pengisian formulir yang tidak terlalu menakutkan bagi pengguna akhir. Saat digabungkan dengan instruksi, formulir multilangkah secara signifikan meningkatkan kemudahan penggunaan aplikasi kami .

Perbandingan bentuk biasa dengan bentuk multistep

Kelemahan utama mereka adalah efisiensi . Mengisi formulir multi-langkah biasanya membutuhkan waktu lebih lama daripada mengisi formulir tunggal (jika Anda sudah terbiasa dengan prosesnya). Ini karena navigasi antar langkah, yang tidak ada dalam bentuk standar.

Cara Anda menerapkan navigasi ini dapat memengaruhi pengalaman pengguna secara signifikan : apakah Anda akan mengizinkan pengguna menavigasi bolak-balik ke langkah mana pun dalam wisaya atau hanya ke langkah berikutnya dan sebelumnya? apakah informasi langkah sebelumnya terlihat di langkah saat ini? Pilihan desain yang buruk dapat menyebabkan pengalaman pengisian formulir yang lambat dan membuat frustrasi sehingga pengguna enggan mengisi formulir kami dan bahkan menggunakan sistem kami.

Resep ini akan mengajari Anda cara membuat formulir multilangkah yang dapat digunakan kembali dan disesuaikan di Rails.

Tantangan

Dalam aplikasi kami, kami mengelola Projects. Sebuah proyek didefinisikan oleh a namedan a description. A Projectdapat memiliki banyak Booksyang terkait dengannya. A Bookdidefinisikan oleh a titledan a description. Kami ingin menerapkan formulir multilangkah untuk membuat file Projects. Langkah pertama akan memerlukan informasi dasar proyek, dan yang kedua, opsional, informasi tentang yang Bookterkait dengan Project.

Resep

Prasyarat

  • (Wajib) Rails ViewComponentgem diinstal : ViewComponentmembungkus Rails viewsmenjadi objek. Hal ini memudahkan untuk menguji pandangan kami dan memberikan metode tambahan untuk pengalaman pengembang yang lebih baik saat bekerja dengannya.
  • Plugin (Opsional) diinstal : kerangka kerja ini menghilangkan kebutuhan untuk menulis dalam kode kami dengan menyediakan satu set kelas yang kuat.TailwindCSS PostCSSCSSCSSCSS

Di Rails, kami menggunakan formulir untuk membuat dan memperbarui catatan. Operasi ini dilakukan melalui POST, PUT, dan PATCH HTTPpermintaan dikirimkan dari satu formulir. Permintaan diproses mengikuti proses yang sama:

  1. Lapisan Rails Controllermem-parsing data permintaan formulir dan meminta Modellapisan untuk melakukan operasi yang diminta dengan informasi yang diekstraksi.
  2. Lapisan Rails Modelmemproses data permintaan, memvalidasinya, dan melakukan operasi yang diminta jika valid. Hasil operasi dikirim kembali dari Modelke Controller.
  3. Layer Rails Controllermeminta Viewlayer untuk memberikan respons berdasarkan hasil operasi. Operasi yang berhasil membuat tampilan hasil operasi. Operasi yang gagal merender formulir yang dikirimkan dengan data permintaan formulir dan pesan kesalahan.
Logika bisnis dari bentuk multistep

Tugas pertama kita adalah mengimplementasikan logika untuk memvalidasi apakah semua langkah dalam bentuk multilangkah sudah selesai.

Rails ActiveRecord::Validations API , termasuk dalam ApplicationRecord Modelkelas, mendefinisikan valid?metode. Metode ini dijalankan pada setiap operasi pembuatan dan pembaruan . Itu mengevaluasi semua Modelkondisi validasi, kembali trueketika semua validasi lulus. Hanya valid?operasi yang dapat mempertahankan informasi dalam database.

Validasi untuk formulir multilangkah kami harus memeriksa indeks langkah saat ini dalam permintaan formulir sesuai dengan langkah terakhirnya . Kami memerlukan dua atribut untuk melacak variabel ini: current_stepuntuk menyimpan indeks langkah saat ini dan total_stepsuntuk menyimpan jumlah langkah dalam bentuk multilangkah. Karena validasi dan atributnya sama untuk semua model, kita dapat merangkumnya dalam a concernyang dapat digunakan kembali di Modelkelas kita:

# 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

Terakhir, kita dapat memperbarui model kita dengan menyertakan formulir multilangkah kita concernsehingga kita dapat menggunakannya dengan formulir multilangkah kita:

# 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

Controllersbertanggung jawab untuk mem-parsing data permintaan formulir dan meneruskannya ke kelas Model. Untuk bekerja dengan formulir multilangkah, kita perlu memperbarui parameter kuat pengontrol untuk memastikan parameter curret_stepdan total_stepsdiuraikan dan diteruskan ke Modelkelas.

# 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

Mari kita uraikan formulir multi-langkah dalam ViewComponents. Bentuk multistep adalah bentuk yang dibagi menjadi beberapa bagian (langkah). Dalam hal komponen, ini dapat dimodelkan sebagai MultistepFormComponentkonten yang terdiri dari beberapa StepComponents.

Setiap langkah berisi sekumpulan bidang formulir. Sebuah langkah selesai ketika semua bidang yang diperlukan langkah diisi dengan data yang valid. A StepComponentmerender bidang yang sesuai dengan langkah formulir multilangkah dan menyediakan antarmuka sederhana untuk mengetahui, mengingat data formulir, jika langkah yang diwakilinya selesai .

Kami sebelumnya berbicara tentang valid?metode dan bagaimana menentukan apakah data formulir itu valid. Selain perilaku ini, perbarui juga objek dengan kamus yang berisi kesalahan validasi . Itu dapat diakses melalui , yang mengembalikan objek. valid?Modelmodel.errorsActiveModel::Errors

Untuk menentukan apakah a StepComponentselesai, kita dapat menggunakan form.object.errorsobjek. Diberi langkah dengan satu set bidang masukan, kami dapat mengonfirmasi bahwa langkah tersebut selesai jika objek form.object.errorstidak menyertakan kesalahan apa pun yang terkait dengan bidang langkah.

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

  1. Objek ActionView::Helpers::FormBuilderuntuk merender bidang langkah dan mengambil data formulir melalui .objectmetode.
  2. Daftar bidang input_attributesyang dirender.
  3. Tidak terungkap dari metodenya tetapi yang jelas, kita juga perlu mengetahui indeks langkah dalam bentuk multistep.

# 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

Ini StepComponentadalah komponen abstrak . Itu tidak dirancang untuk dirender tetapi diperluas oleh StepComponentkelas lain. Untuk Projectlangkah formulir kami, kami akan menentukan dua komponen baru, masing-masing untuk setiap langkah yang ingin kami render:

# 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

  • Di URLmana formulir data akan diserahkan.
  • Dan bentuk data itu sendiri. Data ini mewakili catatan yang ingin kami buat atau perbarui. Dalam setiap iterasi dari siklus formulir multilangkah, data akan mewakili informasi yang diberikan oleh pengguna dalam pengiriman formulir sebelumnya.

# 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

Kita sekarang dapat mendefinisikan proyek kita FormComponentdengan memperluas kelas dan menentukan langkah StepComponentkelas yang menentukan bentuknya:

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

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

Ada beberapa cara untuk bernavigasi di antara langkah-langkah: beberapa hanya mengizinkan navigasi maju sampai semua langkah selesai; di tempat lain, dimungkinkan untuk menavigasi bolak-balik satu langkah dan satu waktu, dan yang lain bahkan memungkinkan menampilkan beberapa langkah sebelumnya sekaligus. Bentuk multilangkah kita harus cukup fleksibel untuk mengimplementasikan semua kemungkinan strategi navigasi . Namun, seharusnya tidak menerapkan strategi navigasi apa pun. Kami akan mendelegasikannya ke komponen yang memperluas MultistepFormComponentkelas.

Kami dapat menyembunyikan dan menampilkan langkah-langkah menggunakan input checkboxdan sederhana radio HTML. A checkboxadalah kotak sederhana yang dapat checked. A radioadalah grup checkboxesdi mana hanya satu yang bisa checked. CSS dapat menargetkan status yang diperiksa dari elemen ini melalui :checkedpemilih. Ini memungkinkan HTMLelemen gaya berdasarkan status a checkboxatau radio.

Kita dapat menetapkan a radiodan checkboxinput ke setiap langkah dalam formulir kita dan memberi gaya pada langkah tersebut sesuai dengan statusnya sehingga hanya terlihat saat input yang sesuai dicentang. Kami akan menentukan metode baru untuk membungkus setiap instance langkah dalam tag yang berisi radio dan kotak centang untuk mencapainya.

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

Komponen yang memperluas MultistepFormComponentdapat memperbarui metode ini mendefinisikan elemen umum yang ada di semua langkah dan menggunakan peersuntuk menampilkan dan menyembunyikan langkah berdasarkan 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
...

Pada baris 31 terdapat logika untuk menavigasi langkah mundur pada form. Kami menggunakan MultistepFormComponent radio_idmetode untuk memetakan label ke radioinput dari langkah sebelumnya. Saat kita mengklik label, radiotombol akan dicentang. Ini akan menyembunyikan langkah saat ini dan mengungkapkan yang sebelumnya.

Untuk menavigasi ke depan, kami selalu perlu mengirimkan formulir. Jika, setelah pengiriman formulir, semua langkah sebelumnya selesai, multistep akan merender langkah berikutnya. Jika tidak, formulir akan mundur ke langkah yang belum selesai.

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

Kami akan menambahkan metode baru yang bertanggung jawab untuk menghapus semua pesan kesalahan yang terkait dengan langkah-langkah yang tidak dikunjungi pengguna dan satu lagi untuk menghapus kesalahan current_step. Untuk mencapainya, kami akan menambahkan atribut ketiga yang bertanggung jawab untuk melacak langkah terbaru dalam formulir yang diisi pengguna sehingga kami dapat menentukan langkah mana yang belum dikunjungi.

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

Ada dua elemen kunci untuk menguji formulir multilangkah kami: Modelperhatian kami dan MultistepFormComponent. Pengujian unit dan sistem tambahan untuk pengontrol, komponen proyek, dan seluruh aliran pengguna dalam pembuatan proyek juga penting. Namun, mereka tidak melibatkan konfigurasi khusus seperti yang lainnya.

Untuk menguji MultistepFormModelperhatian, kita mungkin tergoda untuk menguji current_stepvalidasi melalui Projectmodel. Ini bukan praktik yang baik karena dapat menyebabkan pengujian yang tidak pasti: kami tidak boleh menguji masalah melalui Modelshal yang mungkin tidak menyertakan atau memperluasnya di masa mendatang.

Sebagai gantinya, kita dapat mendefinisikan Modelkelas tambahan dengan satu-satunya tujuan untuk menguji masalah tersebut, dan kemudian menjalankan pengujian dengannya. Pendekatan ini memiliki dua manfaat: menguji kepedulian tanpa mengandalkan Modelkelas dari logika bisnis kami dan mendokumentasikan bagaimana kepedulian dapat digunakan di Modelkelas kami.

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

Multisteps di Rails digabungkan dengan erat ke controller dan layer model. Untuk menjaga logika bisnis kami sedapat mungkin terpisah dari komponen ini, kami mengandalkan concernsdan komponen abstrak. Alat-alat ini berfungsi sebagai logika plug & play yang membuatnya sangat mudah untuk menggunakan beberapa langkah di bagian mana pun dari aplikasi kita.

Sifat abstrak MultistepFormComponentmenyederhanakan tugas mengimplementasikan formulir dengan desain dan logika navigasi yang berbeda, dengan mendelegasikan implementasi logika ini ke komponen yang memperluasnya.

Terakhir, dengan menulis beberapa pengujian umum yang mengandalkan pengujian Modeldan Componentkelas agnostik, kami memastikan fungsi multilangkah yang benar di seluruh aplikasi kami dan memberikan contoh terperinci dan terdokumentasi dengan baik tentang bagaimana pengembang dapat menggunakan logika multilangkah di masa mendatang.

Kode di Github

  • 140 formulir multilangkah oleh AlbertoHdezCerezo · Tarik Permintaan #150 · Albert-Markdown-Editor/Albert
  • How-to: Buat formulir yang dapat diakses — Proyek A11Y
  • Dalam formulir dengan beberapa langkah, identifikasi langkah saat ini menggunakan aria-current

Jika Anda menemukan diri Anda tersesat di titik mana pun dalam artikel atau mencoba meniru solusi saya tanpa hasil, Anda dapat menghubungi saya di bagian komentar. Saya akan dengan senang hati memberikan informasi tambahan dan mencoba mengklarifikasi pertanyaan terbuka. Segera saya akan menerapkan saluran tambahan untuk berbagi umpan balik dan pertanyaan.

Jika Anda telah mencapai titik ini, terima kasih atas waktu dan kesabaran Anda. Saya harap Anda menemukan tutorial saya bermanfaat!