Form Objects in Rails

Form Objects in Rails

Complete Answer

Models:

#app/models/user.rb
class User < ApplicationRecord
has_many :emails
end

#app/models/email.rb
class Email < ApplicationRecord
belongs_to :user
end

Controller:

#app/controllers/users_controller.rb
class UsersController < ApplicationController

def index
@users = User.all
end

def new
@user_form = UserForm.new
@user_form.emails = [EmailForm.new, EmailForm.new, EmailForm.new]
end

def create
@user_form = UserForm.new(user_form_params)
if @user_form.save
redirect_to users_path, notice: 'User was successfully created.'
else
render :new
end
end

private

def user_form_params
params.require(:user_form).permit(:name, {emails_attributes: [:email_text]})
end
end

Form Objects:

#app/forms/user_form.rb
class UserForm
include ActiveModel::Model

attr_accessor :name, :emails

validates :name, presence: true
validate :all_emails_valid

def emails_attributes=(attributes)
@emails ||= []
attributes.each do |_int, email_params|
email = EmailForm.new(email_params)
@emails.push(email)
end
end

def save
if valid?
persist!
true
else
false
end
end

private

def persist!
user = User.new(name: name)
new_emails = emails.map do |email_form|
Email.new(email_text: email_form.email_text)
end
user.emails = new_emails
user.save!
end

def all_emails_valid
emails.each do |email_form|
errors.add(:base, "Email Must Be Present") unless email_form.valid?
end
throw(:abort) if errors.any?
end
end

app/forms/email_form.rb
class EmailForm
include ActiveModel::Model

attr_accessor :email_text, :user_id
validates :email_text, presence: true
end

Views:

app/views/users/new.html.erb
<h1>New User</h1>

<%= render 'form', user_form: @user_form %>
<%= link_to 'Back', users_path %>

#app/views/users/_form.html.erb
<%= form_for(user_form, url: users_path) do |f| %>

<% if user_form.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(user_form.errors.count, "error") %> prohibited this User from being saved:</h2>

<ul>
<% user_form.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<div class="field">
<%= f.label :name %>
<%= f.text_field :name %>
</div>

<%= f.fields_for :emails do |email_form| %>
<div class="field">
<%= email_form.label :email_text %>
<%= email_form.text_field :email_text %>
</div>
<% end %>

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

Rails Form Object with Virtus: has_many association

I would just set the emails_attributes from user_form_params in the user_form.rb as a setter method. That way you don't have to customize the form fields.

Complete Answer:

Models:

#app/modeles/user.rb
class User < ApplicationRecord
has_many :user_emails
end

#app/modeles/user_email.rb
class UserEmail < ApplicationRecord
# contains the attribute: #email
belongs_to :user
end

Form Objects:

# app/forms/user_form.rb
class UserForm
include ActiveModel::Model
include Virtus.model

attribute :name, String

validates :name, presence: true
validate :all_emails_valid

attr_accessor :emails

def emails_attributes=(attributes)
@emails ||= []
attributes.each do |_int, email_params|
email = EmailForm.new(email_params)
@emails.push(email)
end
end

def save
if valid?
persist!
true
else
false
end
end

private

def persist!
user = User.new(name: name)
new_emails = emails.map do |email|
UserEmail.new(email: email.email_text)
end
user.user_emails = new_emails
user.save!
end

def all_emails_valid
emails.each do |email_form|
errors.add(:base, "Email Must Be Present") unless email_form.valid?
end
throw(:abort) if errors.any?
end
end

# app/forms/email_form.rb
# "Embedded Value" Form Object. Utilized within the user_form object.
class EmailForm
include ActiveModel::Model
include Virtus.model

attribute :email_text, String

validates :email_text, presence: true
end

Controller:

# app/users_controller.rb
class UsersController < ApplicationController

def index
@users = User.all
end

def new
@user_form = UserForm.new
@user_form.emails = [EmailForm.new, EmailForm.new, EmailForm.new]
end

def create
@user_form = UserForm.new(user_form_params)
if @user_form.save
redirect_to users_path, notice: 'User was successfully created.'
else
render :new
end
end

private
def user_form_params
params.require(:user_form).permit(:name, {emails_attributes: [:email_text]})
end
end

Views:

#app/views/users/new.html.erb
<h1>New User</h1>
<%= render 'form', user_form: @user_form %>

#app/views/users/_form.html.erb
<%= form_for(user_form, url: users_path) do |f| %>

<% if user_form.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(user_form.errors.count, "error") %> prohibited this User from being saved:</h2>

<ul>
<% user_form.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<div class="field">
<%= f.label :name %>
<%= f.text_field :name %>
</div>

<%= f.fields_for :emails do |email_form| %>
<div class="field">
<%= email_form.label :email_text %>
<%= email_form.text_field :email_text %>
</div>
<% end %>

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

How do you use form objects to edit another class instance?

I would use the delegation pattern so that your form object wraps the underlying user instead of duplicating the attributes:

class UserForm
include ActiveModel::Model

attr_reader :user
delegate :fname, :lname, :email, :account, to: :user

# Just an example of how the validation uses delegation
validates :fname, presence: true

def initialize(object, **attributes)
case object
when User
@user = object
when Account
@user = object.users.new(**attributes)
end
end

def save
# this triggers the validation callbacks
return false unless valid?
@user.save
end

def update(**attributes)
@user.assign_attributes(**attributes)
save
end

def to_model
@user
end
end

Instead of abusing the validation callbacks to perform assignment just pass the attributes to the underlying model.

module Admin
class UsersController < AdminController
before_action :set_user, except: [:new, :index, :create]

def new
@user_form = UserForm.new(current_account)
end

def create
@user_form = UserForm.new(current_account, **user_form_params)
if @user_form.save
flash[:success] = "User created"
redirect_to [:admin, @user_form]
else
render "new"
end
end

def edit
@user_form = UserForm.new(@user)
end

def update
@user_form = UserForm.new(@user)
if @user_form.update(**user_form_params)
flash[:success] = "User updated"
redirect_to [:admin, @user_form]
else
render "edit"
end
end

private

def set_user
@user = current_account.users.find(params[:id])
end
end
end

Some notes:

Avoid params = {} and use real keyword arguments. Do not declare nested classes/modules with the scope resultion operator ::. It leads to autoloading bugs and suprising constant lookups.

Use associations instead of assigning ids directly. This will fire the callbacks on the assocation and avoids leaking the implementation details of your models into the controller layer.

class Account < ApplicationRecord
has_many :users
# ...
end

Your save method should most likely have the same return signature as the object your are wrapping and return a boolean.

Can you make a form object work for new and edit actions if the form itself is never persisted?

class UserForm
# ...

def to_model
@user
end
end
<%= simple_form_for @user_form, url: [:admin, @user_form] do |f| %>
<%= f.input :fname %>
<%= f.input :lname %>
<%= f.input :email %>
<%= f.submit %>
end

When you pass a record to form_for (which SimpleForm wraps), form_with or link_to the polymorphic routing helpers call to_model.model_name.route_key or singular_route_key depending on if the model is persisted?. Passing [:admin, @user_form] will cause the polymorphic route helpers to use admin_users_path instead of just users_path.

On normal models to_model just returns self.

https://api.rubyonrails.org/v6.1.4/classes/ActionDispatch/Routing/PolymorphicRoutes.html

How to retain PORO Form object field inputs?

The problem is most likely that you have overridden the initialize method without calling super. This messes up the whole attibute mapping done by ActiveModel::AttributeAssignment which the form really relies on to be able to fetch the attributes of your model.

class FormObject
include ActiveModel::Model
attr_accessor(:name, :date)

def initialize(params = {})
@params = params
super
end

def save
return if invalid?
no = NestedObject.new(nested_object_params)
no.save
end

def nested_object_params
@params.permit(:name, :date)
end
end

If you use ActiveModel::Attributes instead of Ruby's built in attr_accessor you get type casting just like with ActiveRecord backed attributes.

But this is a straight up disaster as you now have three different representations of the same data:

  • the instance variables on your FormObject
  • the hash stored in @params
  • the attributes stored in NestedObject

Instead you should probally rethink this completely and use delegation:

class FormObject
include ActiveModel::Model
attr_accessor :object
delegate :name, :name=, :date, :date=, :save, to: :object

def intialize(**attributes)
@object = NestedObject.new(attributes)
super
end
end

Rails form object with reform-rails with collections not working or validating

Complete Answer:

Models:

# app/models/user.rb
class User < ApplicationRecord
has_many :user_emails
end

# app/models/user_email.rb
class UserEmail < ApplicationRecord
belongs_to :user
end

Form Object:

# app/forms/user_form.rb
# if using the latest version of reform (2.2.4): you can now call validates on property
class UserForm < Reform::Form
property :name, validates: {presence: true}

collection :user_emails do
property :email_text, validates: {presence: true}
end
end

Controller:

# app/controllers/users_controller.rb
class UsersController < ApplicationController
before_action :user_form, only: [:new, :create]

def new
end

# validate method actually comes from reform this will persist your params to the Class objects
# you added to the UserForm object.
# this will also return a boolean true or false based on if your UserForm is valid.
# you can pass either params[:user][:user_emails] or params[:user][user_email_attributes].
# Reform is smart enough to pick up on both.
# I'm not sure you need to use strong parameters but you can.

def create
if @user_form.validate(user_params)
@user_form.save
redirect_to users_path, notice: 'User was successfully created.'
else
render :new
end
end

private

# call this method in a hook so you don't have to repeat
def user_form
user = User.new(user_emails: [UserEmail.new, UserEmail.new])
@user_form ||= UserForm.new(user)
end

# no need to add :id in user_emails_attributes
def user_params
params.require(:user).permit(:name, user_emails_attributes: [:_destroy, :email_text])
end
end

The Form:

# app/views/users/new.html.erb
<h1>New User</h1>
<%= render 'form', user_form: @user_form %>
<%= link_to 'Back', users_path %>

#app/views/users/_form.html.erb
<%= form_for(user_form, url: users_path) do |f| %>
<% if user_form.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(user_form.errors.count, "error") %> prohibited this user from being saved:</h2>

<ul>
<% user_form.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<div class="field">
<%= f.label :name %>
<%= f.text_field :name %>
</div>
<%= f.fields_for :user_emails do |email_form| %>
<div class="field">
<%= email_form.label :email_text %>
<%= email_form.text_field :email_text %>
</div>
<% end %>

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

Using Form Objects with FactoryGirl

So after this got tumbleweeds, I did my own research. The solution: FactoryGirl has a function called initialize_with, which can be used for calling initialize functions that require arguments. The trick is that initialize_with understands attributes, which is a hash of all the attributes you've defined in your factory. So for a form, you can call your new(attributes), run whatever else you need to, and then finally return the model or models the form is creating.

For instance:

initialize_with do |result|
form = ProjectForm.new(attributes)
if form.save!
result = form.project
else
result = form.errors
end
end

simple form and form object - one form for new and edit

You can check if your object is a new record, so you can specify different url's for a new or edit form, like this:

= simple_form_for @article_form, 
url: (@article_form.new_record? ? your_path_for_new_record : your_path_for_edit)
do |f|

Hope this helps. Good luck!

(How) Can I use a form object for the edit/update routines?

You will have to use "initialize" method in StationForm object to use it for editing. if you pass an id, it will assume, that the object already exist and from there we can treat it as an persisted object.
Also Add "update" method to update attributes of object.

class StationForm
include Virtus.model

include ActiveModel::Model
# I think this is unnecessary
# extend ActiveModel::Naming
# include ActiveModel::Conversion
# include ActiveModel::Validations

attr_reader :station

attribute :station_name, String
attribute :station_description, String

attr_reader :address

attribute :address_url, String
attribute :address_priority, Integer
attribute :address_is_active, Boolean

def initialize(attr = {})
if !attr["id"].nil?
@station = Station.find(attr["id"])
@address = @station.addresses.first
self[:station_name] = attr[:station_name].nil? ? @station.name : attr[:station_name]
self[:station_description] = attr[:station_description].nil? ? @station.description : attr[:station_description]
self[:address_url] = attr[:address_url].nil? ? @address.url : attr[:address_url]
self[:address_priority] = attr[:address_priority].nil? ? @address.priority : attr[:address_priority]
self[:address_is_active] = attr[:address_is_active].nil? ? @address.is_active : attr[:address_is_active]
else
super(attr)
end
end

def persisted?
@station.nil? ? false : @station.persisted?
end

def id
@station.nil? ? nil : @station.id
end

def save
if valid?
persist
true
else
false
end
end

def update
if valid?
update_form
true
else
false
end
end

private

def persist
@station = Station.create(name: station_name, description: station_description)
@address = @station.addresses.create(url: address_url, priority: address_priority, is_active: address_is_active)
end

def update_form
@station.update_attributes(
:name => self[:station_name],
:description => self[:station_description]
)
@address.update_attributes(
:url => self[:address_url],
:priority => self[:address_priority],
:is_active=> self[:address_is_active]
)
end
end

And Controller will be like

def new
@station = StationForm.new
end

def edit
@station = StationForm.new("id" => params[:id])
end

def create
@station = StationForm.new(station_params)

respond_to do |format|
if @station.save
format.html { redirect_to stations_path, notice: 'Station was successfully created.' }
format.json { render :show, status: :created, location: @station }
else
format.html { render :new }
format.json { render json: @station.errors, status: :unprocessable_entity }
end
end
end

def update
@station = StationForm.new(station_params.merge("id" => params[:id]))
respond_to do |format|
if @station.update
format.html { redirect_to stations_path, notice: 'Station was successfully updated.' }
format.json { render :show, status: :ok, location: @station }
else
format.html { render :edit }
format.json { render json: @station.errors, status: :unprocessable_entity }
end
end
end

use the "persisted" and "id" method from StationForm in the _form.html.erb

    <%= form_for(@station,
:url => @station.persisted? ? station_path(@station.id) : stations_path,
:method => @station.persisted? ? "put": "post") do |f| %>
<% if @station.errors.any? %>
<div id="error_explanation">
<h2><%= pluralize(@station.errors.count, "error") %> prohibited this station from being saved:</h2>

<ul>
<% @station.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>

<div class="field">
<%= f.label :station_name %><br>
<%= f.text_field :station_name %>
</div>
<div class="field">
<%= f.label :station_description %><br>
<%= f.text_field :station_description %>
</div>
<div class="field">
<%= f.label :address_url%><br>
<%= f.text_field :address_url %>
</div>
<div class="field">
<%= f.label :address_priority%><br>
<%= f.text_field :address_priority%>
</div>
<div class="field">
<%= f.label :address_is_active %><br>
<%= f.text_field :address_is_active %>
</div>

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


Related Topics



Leave a reply



Submit