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
Using Poltergeist with a Proxy
Rails: Chartkick Cummulative User Graph
How to Handle Combination []+= for Auto-Vivifying Hash in Ruby
How to Dry Up Method with Multiple { 'Not Found' }
Defined' and 'Unless' Not Working as Expected
Why Doesn't Array Override the Triple Equal Sign Method in Ruby
Why Does My Recursive Method from Helper Not Return Every Value
How to Get the Real File from S3 Using Carrierwave
Access Local Variables from a Different Binding in Ruby
Ruby/Rails Actionmailer Not Working with Ntlm
How to Download File from Google Drive API with Service Account
Why Do Numeric String Comparisons Give Unexpected Results
Bundler How to Uninstall Conflicting Dependency
Ruby Popen3 -- How to Repeatedly Write to Stdin & Read Stdout Without Re-Opening Process
Rmagick How to Convert Image in Memory