ruby-on-railsrubyhotwire-rails

How to create a dynamic user form with Devise and Hotwire


I have devise installed as authentication system in my Rails 7 app. I want to be able to create dynamic forms for organization and individual user types using hotwire. That is, when I click on individual option, a number of form fields should be displayed while others are hidden. I should be able to do this both on new and edit registration pages.

Since I am attempting to use hotwire to make this work, I have the a navigate stimulus controller

navigate_controller.js

import { Controller } from "@hotwired/stimulus";

/*
 * Usage
 * =====
 *
 * add data-controller="navigate" to the turbo frame you want to navigate
 *
 * Action (add to radio input):
 * data-action="change->navigate#to"
 * data-url="/new?input=yes"
 *
 */
export default class extends Controller {
  to(e) {
    e.preventDefault();

    const { url } = e.target.dataset;

    this.element.src = url;
  }
}

In my signup page, I have a conditional statement that wraps the organization fields and also turbo frame tag which points to the navigate controller.

<h2>Sign up</h2>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
  <%= render "devise/shared/error_messages", resource: resource %>

  <%= turbo_frame_tag "account_types", data: { controller: "navigate" } do %>

    <div class="form-check form-check-inline">
      <%= f.label :account_type, value: 'individual', class: 'form-check form-check-inline' do %>
        <%= f.radio_button :account_type, 'individual', class: 'form-check-input', data: { action: "change->navigate#to", url: new_account_registration_path({ account: { account_type: "individual" } }) } %>
        <label class="form-check-label">Individual</label>
      <% end %>

      <%= f.label :account_type, value: 'organization', class: 'form-check form-check-inline' do %>
        <%= f.radio_button :account_type, 'organization', class: 'form-check-input', data: { action: "change->navigate#to", url: new_account_registration_path({ account: { account_type: "company" } }) } %>
        <label class="form-check-label">Organization</label>
      <% end %>
    </div>

    <% if @account.organization? %>

      <div class="field">
        <%= f.label :organization_name %><br />
        <%= f.text_field :organization_name, autocomplete: "Organization name" %>
      </div>

      <div class="form-check form-check-inline">
        Organization Type
        <%= f.label :organization_type, value: 'base', class: 'form-check form-check-inline' do %>
          <%= f.radio_button :organization_type, 'base', class: 'form-check-input' %>
          <label class="form-check-label">Base</label>
        <% end %>

        <%= f.label :organization_type, value: 'ministry', class: 'form-check form-check-inline' do %>
          <%= f.radio_button :organization_type, 'ministry', class: 'form-check-input' %>
          <label class="form-check-label">Ministry</label>
        <% end %>
      </div>

    <% else %>
      <div class="field">
        <%= f.label :firstname %><br />
        <%= f.text_field :firstname, autocomplete: "firstname" %>
      </div>

      <div class="field">
        <%= f.label :surname %><br />
        <%= f.text_field :surname, autocomplete: "surname" %>
      </div>
    <% end %>
  <% end %>

  <div class="field">
    <%= f.label :email %><br />
    <%= f.email_field :email, autocomplete: "email" %>
  </div>

  <div class="field">
    <%= f.label :username %><br />
    <%= f.text_field :username, autocomplete: "username" %>
  </div>

  <div class="field">
    <%= f.label :state %><br />
    <%= f.text_field :state, autocomplete: "state" %>
  </div>

  <div class="field">
    <%= f.label :country %><br />
    <%= f.text_field :country, autocomplete: "country" %>
  </div>

  <div class="field">
    <%= f.label :bio %><br />
    <%= f.text_area :bio, autocomplete: "bio" %>
  </div>

  <div class="field">
    <%= f.label :password %>
    <% if @minimum_password_length %>
    <em>(<%= @minimum_password_length %> characters minimum)</em>
    <% end %><br />
    <%= f.password_field :password, autocomplete: "new-password" %>
  </div>

  <div class="field">
    <%= f.label :password_confirmation %><br />
    <%= f.password_field :password_confirmation, autocomplete: "new-password" %>
  </div>

  <br>

  <div class="actions">
    <%= f.submit "Sign up" %>
  </div>
<% end %>

<%= render "devise/shared/links" %>

I have also generated devise registrations controller, added params to the new action

# frozen_string_literal: true

class Accounts::RegistrationsController < Devise::RegistrationsController
  before_action :configure_sign_up_params, only: [:create]
  before_action :configure_account_update_params, only: [:update]

  # GET /resource/sign_up
  def new
    # super
    @account = Account.new configure_sign_up_params
  end

  # POST /resource
  # def create
  #   super
  # end

  # GET /resource/edit
  # def edit
  #   super
  # end

  # PUT /resource
  # def update
  #   super
  # end

  # DELETE /resource
  # def destroy
  #   super
  # end

  # GET /resource/cancel
  # Forces the session data which is usually expired after sign
  # in to be expired now. This is useful if the user wants to
  # cancel oauth signing in/up in the middle of the process,
  # removing all OAuth session data.
  # def cancel
  #   super
  # end

  protected

  # If you have extra params to permit, append them to the sanitizer.
  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up, keys: [:username, :firstname, :surname, :state, :country, :bio, :account_type, :organization_name, :organization_type])
  rescue
    {}
  end

  # If you have extra params to permit, append them to the sanitizer.
  def configure_account_update_params
    devise_parameter_sanitizer.permit(:account_update, keys: [:username, :firstname, :surname, :state, :country, :bio, :account_type, :organization_name, :organization_type])
  end

  # The path used after sign up.
  # def after_sign_up_path_for(resource)
  #   super(resource)
  # end

  # The path used after sign up for inactive accounts.
  # def after_inactive_sign_up_path_for(resource)
  #   super(resource)
  # end
end

I guess I am not customizing the devise registration controller properly for it not to work


Solution

  • configure_sign_up_params only adds extra parameters to permit, which devise then uses inside a corresponding action:

    pp devise_parameter_sanitizer
    
    #=>
    <Devise::ParameterSanitizer:0x00007fd64171a420
     @auth_keys=[:email],
     @params=#<ActionController::Parameters {"controller"=>"accounts/registrations", "action"=>"new"} permitted: false>,
     @permitted=
      {:sign_in=>[:email, :password, :remember_me],
       :sign_up=>[:email, :password, :password_confirmation, :account_type],
       # ^ used in Devise::RegistrationsController#create action
       :account_update=>[:email, :password, :password_confirmation, :current_password]},
     @resource_name=:account>
    

    sign_up_params method is what you want to use to get permitted params when registering:

    class Accounts::RegistrationsController < Devise::RegistrationsController
      before_action :configure_sign_up_params, only: [:create, :new]
    
      def new
        build_resource sign_up_params
      end
    
      protected
    
      def configure_sign_up_params
        devise_parameter_sanitizer.permit(:sign_up, keys: [:account_type])
      end
    end
    

    You really have to look at devise source to understand what is going on. Basic rails also works for this situation:

    class Accounts::RegistrationsController < Devise::RegistrationsController
      def new
        @account = Account.new(params.fetch(:account, {}).permit(:account_type))
      end
    end
    

    Also you're unnecessarily nesting <label> tag:

    <%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
      <%= turbo_frame_tag "account_types", data: {controller: "navigate"} do %>
        <%= f.label :account_type_individual do %>
          <%= f.radio_button :account_type, "individual",
            data: {
              action: "change->navigate#to",
              url: new_account_registration_path(account: {account_type: "individual"})
            }
          %>
          Individual
        <% end %>
    
        <%= f.label :account_type_organization do %>
          <%= f.radio_button :account_type, "organization",
            data: {
              action: "change->navigate#to",
              url: new_account_registration_path(account: {account_type: "organization"})
            }
          %>
          Organization
        <% end %>
    
        <% if resource.account_type == "organization" %>
          # TODO: company fields
        <% end %>
      <% end %>
    
      <%= f.submit "Sign up" %>
    <% end %>