Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[SF Ruby #7] Forms on Rails

[SF Ruby #7] Forms on Rails

Let's explore the idea of introducing form objects into Rails and demonstrate how form objects could be useful in implementing multi-step wizards.

Vladimir Dementyev

October 10, 2024
Tweet

More Decks by Vladimir Dementyev

Other Decks in Programming

Transcript

  1. <%= form_for @cable do |f| %> <%= f.text_field :name, required:

    true %> <%= f.text_field :region, required: true %> <%= f.radio_button :framework, "rails" %> <%= f.radio_button :framework, "js" %> <%= f.radio_button :framework, "hotwire" %> <%= f.radio_button :framework, "default" %> <%= f.text_field :rpc_host %> <%= f.text_field :rpc_secret %> <%= f.text_field :secret %> <%= f.text_field :turbo_secret %> <%= f.text_field :jwt_secret %> <%= f.submit "Create" %> <% end %>
  2. Form Object: concerns — Context-specific validations — User input transformation

    — User feedback — Custom UI-driven logic (like wizards)
  3. Reform class AlbumForm < Reform::Form property :title validates :title, presence:

    true property :artist do property :name validates :name, presence: true end end
  4. class Cable class CreateForm < ApplicationForm self.model_name = "Cable" attribute

    :name attribute :region, default: -> { "sea" } attribute :framework, default: -> { "rails" } attributes :secret, :rpc_host, :rpc_secret, :turbo_secret, :jwt_secret attr_reader :cable def initialize(...) super @cable = Cable.new( name:, region:, metadata: {framework:}, configuration: { secret:, rpc_host:, rpc_secret:, turbo_secret:, jwt_secret: } ) end def submit! = cable.save! end end
  5. class Cable class CreateForm < ApplicationForm self.model_name = "Cable" attribute

    :name attribute :region, default: -> { "sea" } attribute :framework, default: -> { "rails" } attributes :secret, :rpc_host, :rpc_secret, :turbo_secret, :jwt_secret attr_reader :cable def initialize(...) super @cable = Cable.new( name:, region:, metadata: {framework:}, configuration: { secret:, rpc_host:, rpc_secret:, turbo_secret:, jwt_secret: } ) end def submit! = cable.save! end end UI / schema decoupling
  6. class Cable class CreateForm < ApplicationForm self.model_name = "Cable" attribute

    :name attribute :region, default: -> { "sea" } attribute :framework, default: -> { "rails" } attributes :secret, :rpc_host, :rpc_secret, :turbo_secret, :jwt_secret validates :name, :region, presence: true validates :rpc_host, format: %r{\Ahttps?://}, allow_blank: true attr_reader :cable def initialize(...) # ... end def submit! = cable.save! end end Validations?
  7. class Cable class CreateForm < ApplicationForm self.model_name = "Cable" attribute

    :name attribute :region, default: -> { "sea" } attribute :framework, default: -> { "rails" } attributes :secret, :rpc_host, :rpc_secret, :turbo_secret, :jwt_secret validates :cable_is_valid validates :rpc_host, format: %r{\Ahttps?://}, allow_blank: true attr_reader :cable def initialize(...) # ... end def submit! = cable.save! private def cable_is_valid return if cable.valid? merge_errors!(cable) end end end Validations: a) model delegation b) context-specific
  8. class Cable class CreateForm < ApplicationForm self.model_name = "Cable" attribute

    :name attribute :region, default: -> { "sea" } attribute :framework, default: -> { "rails" } attributes :secret, :rpc_host, :rpc_secret, :turbo_secret, :jwt_secret validates :cable_is_valid validates :rpc_host, format: %r{\Ahttps?://}, allow_blank: true after_commit :enqueue_provisioning attr_reader :cable def initialize(...) # ... end def submit! = cable.save! private def enqueue_provisioning = cable.provision_later end end Trigger business operations
  9. class Cable class CreateForm < ApplicationForm self.model_name = "Cable" attribute

    :name attribute :region, default: -> { "sea" } attribute :framework, default: -> { "rails" } attributes :secret, :rpc_host, :rpc_secret, :turbo_secret, :jwt_secret validates :cable_is_valid validates :rpc_host, format: %r{\Ahttps?://}, allow_blank: true after_commit :enqueue_provisioning attr_reader :cable def initialize(...) # ... end def submit! = cable.save! private def enqueue_provisioning = cable.provision_later end end What is this?
  10. — Learn Rails building blocks and re-use them — Agree

    to the overachieving principle of conventions — Make your code play nicely with other Rails components
  11. class ApplicationForm include ActiveModel::API include ActiveModel::Attributes define_callbacks :save, only: :after

    define_callbacks :commit, only: :after def save return false unless valid? with_transaction do AfterCommitEverywhere.after_commit { run_callbacks(:commit) } run_callbacks(:save) { submit! } end end def model_name ActiveModel::Name.new(nil, nil, self.class.name.sub(/Form$/, "")) end private def with_transaction(&) = ApplicationRecord.transaction(&) def submit! raise NotImplementedError end end Validations / Types Callbacks Transactions awareness Interface Action View compat
  12. class CablesController < ApplicationController def new authorize! @cable = Cable.new

    end def create authorize! @cable = Cable.new(cable_params) if @cable.save redirect_to cable_path(@cable), notice: "Success!" else render :new, status: :unprocessable_entity end end end
  13. class CablesController < ApplicationController def new authorize! @form = Cable::CreateForm.new

    end def create authorize! @form = Cable::CreateForm.from(params.require(:cable)) if @form.save redirect_to cable_path(@form.cable), notice: "Success!" else render :new, status: :unprocessable_entity end end end
  14. <%= form_for @cable do |f| %> <%= f.text_field :name, required:

    true %> <%= f.text_field :region, required: true %> # ... <%= f.submit "Create" %> <% end %> <form action="/cables" method="post"> <input type="text" name="cable[name]" required> <input type="text" name="cable[region]" required> <!-- ... --> <input type="submit" value="Create"> </form>
  15. <%= form_for form do |f| %> <%= f.text_field :name, required:

    true %> <%= f.text_field :region, required: true %> # ... <%= f.submit "Create" %> <% end %> <form action="/cables" method="post"> <input type="text" name="cable[name]" required> <input type="text" name="cable[region]" required> <!-- ... --> <input type="submit" value="Create"> </form> self.model_name = "Cable"
  16. — Now the form logic has its own home in

    the application — We can localize changes related to this feature and iterate faster
  17. class Cable class CreateForm < ApplicationForm class Wizard < ApplicationWorkflow

    workflow do state :name do event :submit, transitions_to: :framework end state :framework do event :submit, transitions_to: :rpc, if: :needs_rpc? event :submit, transitions_to: :secrets event :back, transitions_to: :name end state :rpc do event :submit, transitions_to: :secrets event :back, transitions_to: :framework end state :secrets do event :submit, transitions_to: :region event :back, transitions_to: :rpc, if: :needs_rpc? event :back, transitions_to: :framework end state :complete end end end end
  18. class Cable class CreateForm < ApplicationForm class Wizard < ApplicationWorkflow

    # ... end attribute :wizard_state, default: -> { "name" } attribute :wizard_action def submit! if wizard_action == "back" wizard.back! else wizard.submit! end return false unless wizard.complete? cable.save! end def wizard = @wizard ||= Wizard.new(self) end end
  19. class CablesController < ApplicationController def new authorize! @form = Cable::CreateForm.new

    end def create authorize! @form = Cable::CreateForm.from(params.require(:cable)) if @form.save redirect_to cable_path(@form.cable), notice: "Success!" else status = @form.valid? ? :created : :unprocessable_entity render :new, status: end end end
  20. <%= form_for form do |f| %> <%= f.hidden_field :wizard_state %>

    <% if form.wizard.name? %> <%= f.text_field :name %> <% else %> <%= f.hidden_field :name %> <% end %>
  21. <%= form_for form do |f| %> <%= f.hidden_field :wizard_state %>

    # ... <% if form.wizard.framework? %> <%= f.radio_button :framework, "rails" %> <%= f.radio_button :framework, "js" %> <%= f.radio_button :framework, "hotwire" %> <%= f.radio_button :framework, "default" %> <% else %> <%= f.hidden_field :framework %> <% end %>
  22. <% if form.wizard.prerequisites? %> # ... <% end %> <%=

    form_for form do |f| %> <%= f.hidden_field :wizard_state %> # ...
  23. <% if form.wizard.rpc? %> <%= f.text_field :rpc_host %> <% else

    %> <%= f.hidden_field :rpc_host %> <% end %> <%= form_for form do |f| %> <%= f.hidden_field :wizard_state %> # ...
  24. <% if form.wizard.secrets? %> <%= f.text_field :secret %> <% else

    %> <%= f.hidden_field :secret %> <% end %> <%= form_for form do |f| %> <%= f.hidden_field :wizard_state %> # ...
  25. <% if form.wizard.can_complete? %> <%= f.submit "Create" %> <% else

    %> <%= f.submit "Next", formaction: new_cable_path %> <% end %> <% if form.wizard.can_back? %> <%= f.submit "Back", formaction: new_cable_path, value: "Back", name: "cable[wizard_action]" %> <% end %> <% end %> <%= form_for form do |f| %> <%= f.hidden_field :wizard_state %> # ...