Nested Habtm Forms For Associations In Rails
Contents |
HTML Form Input
Start from basic docs for html input and forms https://html.spec.whatwg.org/multipage/forms.html The most important attribute is
name
identify the input in data submitted with the form. If not provided, this input will not we included.type
to identify a input type that input element representsvalue
when specified, it is initial value. For unselectedcheckbox
orradio
it is not submitted. They have default valueon
There is a lot of input types (like color
which will popup
color picker) but here we will focus on:
text
single line text field (line breaks are automatically removed). In railsf.text_field :name, size: 2
orf.number_field :num, min: 1, max: 9
to use shorter width,f.time_field :t
will generatetype='time'
butf.time_select :t
will generate select options insteadcheckbox
allowing single value to be selected/deselected.f.check_box :name
will submit'0'
in case it is not selected, butcheck_box_tag :name
will not.hidden
it is not displayed, but value is submittedradio
allowing a single value to be selected out of multiple choices,f.radio_button :overnight_rate, 1, label: 'Yes', inline: true, checked: f.object.overnight_rate
submit
ie<input type='submit'
acts like a button that submits the form. You can use<button>OK</button>
(default istype='submit'
) instead (button that does not submit the form is<button type='button'>
.
Now start with what Rails provides https://api.rubyonrails.org/v5.2.1/classes/ActionView/Helpers/FormHelper.html#method-i-fields_for
Similar to nested forms, you can have select tag with multiple options (habtm relation) so we can update without ajax (it is created in one request). Since in html forms we can only send value or array
- single value (html
name='name'
) In railstext_field_tag :name
and we gotparams[:name] # => 'Duke'
- array (html
name='ids[]'
) In railstext_field_tag 'ids[]'
orf.text_field :ids, name: 'ids[]'
and we gotparams[:ids] #=> [1,2]
Note that you need to permit arrayparams.permit(ids: [])
. When you remove all elements from DOM than nothing is send to server, so you need to add empty and reject empty valuesbefore_save :clear_unchecked_values def clear_unchecked_values self.custom_sign_up_labels = custom_sign_up_labels.reject(&:blank?) true end
-
hash is when you nest inside brackets and define specific key, for example
name='user[name]
, in railsf.text_field :name
(when f.object.class == User). Note that you need to permit each key.Hash values can also be string, array or another hash. For array (html
name='user[ids][]
) in rails you can usef.text_field :ids, name: 'user[ids][]
(f.text_field :ids
does not make sense since there are multiple input fields, so better is to usetext_field_tag :name
, but if you are using strong params, than you need to put inside model name, for example"#{f.object.class.name.underscore}[ids][]"
) we gotparams[:user][:ids] #=> [1,2]
. Hash with hash values rails use it forfields_for & accepts_nested_attributes_for
htmlname='user[posts_attributes][0][id]
and we gotparams[:user][:posts_attributes]["0"][:id]
. Note that you need to permit each keyparams.require(:user).permit(posts_attributes: [:id,:name])
So for habtm or has_many through, we need to mark for destruction and than add params
# modal
class Book < ApplicationRecord
has_many :book_topics
has_many :topics, through: :book_topics
accepts_nested_attributes_for :book_topics
end
class BookTopic < ApplicationRecord
belongs_to :book
belongs_to :topic
end
# html
<%= f.select :topic_ids, options_from_collection_for_select(Topic.all, :id, :topic_name, -> (topic) { @book.book_topics.map(&:topic).include? topic }), {}, multiple: true %>
# controller
@book.book_topics.each &:mark_for_destruction
book_topics_params = {
book_topics_attributes: params[:book][:topic_ids].map {|id| { topic_id: id } }
}
if @book.update _book_params.merge book_topics_params
Nested forms
http://api.rubyonrails.org/classes/ActiveRecord/NestedAttributes/ClassMethods.html
If you want to use nested forms like question and answers, good approach is to
create!
on new action and redirect to edit. That way you have question_id
.
For new questions or delete questions, you can simply use ajax. So start with
rails g scaffold questions title;rails g model answers question:references
.
# models/question.rb
class Question < ActiveRecord::Base
has_many :answers, dependent: :destroy
accepts_nested_attributes_for :answers, allow_destroy: true
end
# questions/_form.html.erb
<div id="answers">
<h2>Answers</h2>
<% @question.answers.each do |answer| %>
<%= render partial: 'answer', locals: { question_form: f, answer: answer } %>
<% end %>
</div>
<%= link_to "Create new answers", create_answer_question_path, remote: true,
method: :post %>
# questions/_answer.html.erb
<%#
question_form - we need this because we don't want to generate <form> tags
- we need just fields
answer - target answer
we hard code "answers_attributes[]" because
when we use fields_for :answer, than when we use ajax `new` twice we got same
name for different records
question[answers_attributes][0][id] (value 111)
question[answers_attributes][0][id] (value 222)
and only latest will be considered
it is because uniq number is reset for each fields_for
this sequential "0", "1" is used so you can show `fields_for :answers` for
existing and new answers (which does not have id) so they are all separated
with hard coded `answers_attributes[]` it is
question[answers_attributes][111][id] (value 111)
question[answers_attributes][222][id] (value 222)
but for unsaved objects it will be
question[answers_attributes][][id] (value nil)
so there are two solutions:
* always create objects and than render form
* add fake id (used for key), but not provide a hidden input field 'id'
%>
<%= question_form.fields_for "answers_attributes[]", answer do |ff| %>
<div class="field">
<%= ff.hidden_field :id %>
<%= ff.text_field :content, placeholder: "Answer" %>
<%= ff.number_field :score, placeholder: 'Score' %>
<%= ff.number_field :position, placeholder: 'Position' %>
<%= link_to "Destroy", destroy_answer_question_path(answer.question, answer_id: answer.id), remote: true, method: :delete %>
</div>
<% end %>
# questions/create_answer.js.erb
<% output = nil %>
<% form_for(@question) do |f| %>
<% output = j render partial: 'answer', locals: { question_form: f, answer:
@answer } %>
<% end %>
$('#answers').append('<%= output %>');
# config/routes.rb
resources :questions do
member do
post :create_answer
delete :destroy_answer
end
end
# controllers/questions_controller.js
def create_answer
@answer = @question.answers.create!
end
def destroy_answer
@answer = @question.answers.find(params[:answer_id])
@answer.destroy!
end
def question_params
params.require(:question).permit(
:title, :time_limit,
answers_attributes: [:id, :score, :content, :_destroy]
)
end
You can try
cocoon gem and use
link_to_add_association
tutorial
post
has_many :ip_addresses, inverse_of: :subscriber
is needed when you have
validation errors for accepts_nested_attributes_for
https://robots.thoughtbot.com/accepts-nested-attributes-for-with-has-many-through
Validation of uniqueness does not work for bulk update with _attributes
since
it check only what is in db (not in params), so one solution is to add
inverse_of
and to add validation
# this works when we create one record (not for batch update with
# ip_address_attributes)
validates :fix_ip_address, presence: true, uniqueness: { scope: :parent_location_id }
# so we need to validate that also
validate :uniqueness_for_batch_update
def uniqueness_for_batch_update
ips = subscriber.ip_addresses.map(&:fix_ip_address)
errors.add(:fix_ip_address, 'already exists') if ips.size != ips.uniq.size
end
Note that callback in nested model, like
class Answer
before_save :check_something_on_answer
end
will not be called when answers are updated as answers_attributes
Multiple form submit buttons for different actions
you can use rails builder to show two buttons
<%= f.submit "Some label" %>
<%= f.submit "Some other label" %>
will generate
<input type="submit" name="commit" value="Some label">
<input type="submit" name="commit" value="Some other label">
so you can check on server
if params[:commit] == "Some label"
When you are not using f.submit
but plain <button>Some label</button>
than
you need to add hidden_field_tag :commit, "Some label"
Sometimes there is a problem when automatic translator on the page change
button labels and inputs so commit param is different…
There are two solutions for that:
Instead of commit
you can use
formaction
So you do not need to parse commits but you need different action methods to
handle.
<%= form_for('url') do |f| %>
<%= f.submit 'Create' %>
<%= f.submit 'Special Action', formaction: special_action_path %>
<% end %>
Another solution to translations I18 of input submit is to use buttons with value as the same as the text inside button tag.
<%= form_for('url') do |f| %>
<%# instead of <input> we use <button> with value (which could be the same as inner text) so automatic page translators do not change that value %>
<%= f.button 'Create', value: 'Create' %>
<%= f.button 'Special Action', value: 'Special Action' %>
<% end %>
this will generate
<form action="url">
<button name="button" type="submit" value="Create">Create</button>
<button name="button" type="submit" value="Special Action">Special Action</button>
</form>
so you can grab params[:button] == 'Create'
If you want to disable utf-8 and authenticity hidden fields, remove hidden
input name='commit'
for submit buttons button_tag 'OK', name: nil
and use
plain param name instead of in brackets f.hidden_field :name
generate
name='[name]'
(but f.text_field :name
generate name='name'
), than use
hidden_field_tag :name
<%= bootstrap_form_tag url: @atom_payment.atom_server_link, layout: :horizontal, enforce_utf8: false, authenticity_token: false do |f| %>
<% @atom_payment.atom_params.each do |atom_param| %>
<%= hidden_field_tag atom_param[:name], atom_param[:value] %>
<% end %>
<%= button_tag "Pay Now", name: nil, class: 'btn btn-primary btn-block', 'data-disable-with': 'Processing...' %>
<% end %>
- if you want to access hash keys by symbol or string you can instantiate with
params = HashWithIndifferentAccess.new name: 'Duke'
so you can useparams[:name]
orparams["name"]
.
Here is a rails app, and here is a gist
Rails and Forms
https://api.rubyonrails.org/v5.2.1/classes/ActionView/Helpers/FormHelper.html
Input outside of a form
<input>
can be outside of a <form>
, all it needs is form='id_of_a_form'
Dialog element
There is native html tag for modals <dialog>
. When you call
dialogEl.showModal()
there will be backgrop and autofocus is triggered (if
there is autofocus
attribute).
https://alligator.io/html/dialog-element/
Fieldset & Legend
Use <fieldset>
to group several input fields into one section and set caption
label on this part with <legend>
.
<fieldset>
<legend>
My section
</legend>
My content
</fieldset>
When it is disabled, all nested input fields can not be used, as they were disabled. In Rails 6 I submitted a bug when using remote: true, all nested input fields are submitted so in this case you need to disable manually each input field. But it is fixed now https://github.com/rails/rails/issues/36728
Another problem with f.fields_for :venue
is that if model persists, this will
add some hidden venue_attributes[:id] = id
https://github.com/rails/rails/blob/fc5dd0b85189811062c85520fd70de8389b55aeb/actionview/lib/action_view/helpers/form_helper.rb#L1928
You can set caption also on figure
<figure>
<img src="/wp-content/uploads/flamingo.jpg" alt="flamingo">
<figcaption><i>fig. 1</i> A pink flamingo.</figcaption>
</figure>
Input Attributes
Autosuggestion
Pure html autoselect suggestions (but not required from list) can be done using
datalist
and <input list='id_of_datalist'>
attribute.
Autocomplete
Autocomplete can be enabled with specific value https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete or disabled because off security (even disabled, browser can ask for auto save password, it will populate them)
Firefox has soft refresh which persist input values and disabled attribute on
refresh the page https://stackoverflow.com/questions/5985839/bug-with-firefox-disabled-attribute-of-input-not-resetting-when-refreshing
This can be disabled by hard refresh or autocomplete off
But it is advisable to enable autocomplete so user get suggestions on phone https://developer.apple.com/documentation/security/password_autofill/enabling_password_autofill_on_an_html_input_element
<input id="user-text-field" type="email" autocomplete="username"/>
<input id="password-text-field" type="password" autocomplete="current-password"/>
<%= f.text_field :login, autofocus: true, autocomplete: "email", placeholder: 'Enter Mobile No. / Email ID', skip_label: true, autocapitalize: "off" %>
<%= form.label :mobile, 'What is your Mobile?', class: 'labeltext', autocomplete: "tel" %>
<%= form.text_field :otp_attempt, class: 'form-control', placeholder: 'Enter OTP', autofocus: true, required: true, autocomplete: "one-time-code" %>
<%= form.select :year, options_for_select(year_options, selected: form.object.year), {}, class: 'form-control', id: :year, autocomplete: "bday-year" %>
<%= form.text_field :zip, class: 'form-control', placeholder: 'Enter Your Zipcode', autofocus: true, required: true, autocomplete: "postal-code" %>
<%= form.password_field :password, class: 'form-control', placeholder: "Enter Password (min #{User.password_length.min} chars)", autocomplete: "new-password" %>
<%= form.password_field :password_confirmation, class: 'form-control', placeholder: 'Confirm Password', autocomplete: "new-password" %>
Autofocus
Auto focus input fields on page load (or dialog show). If you need to show focus
on input with existing value than you can use callback onfocus
https://stackoverflow.com/questions/511088/use-javascript-to-place-cursor-at-end-of-text-in-text-input-element/2345915#2345915
<%= f.search_field :s, autofocus: true, onfocus: 'this.selectionStart = this.selectionEnd = this.value.length;' %>
or another solution https://stackoverflow.com/a/24891345/287166
<%= form.text_field :mobile, class: 'form-control', placeholder: 'Enter Mobile', value: form.object.mobile || '+1', autofocus: true, onfocus: 'var temp = this.value; this.value = ""; this.value = temp' %>
Disabled
Disabled inputs do not receive click
event, and they are not submitted with
the form.
This can be used to preventDefault on click event for other data-
event
listeners.
Required
required
is boolean attribute, when is present, user myst specify a value. On
all except (color, hidden, range, submit, image, reset, button). When it is on
checkbox
than user have to select it before procceeding.
Required inputs has pseudoclass :required
.
Tabindex
tabindex
should be 0
so it is reachable by sequential keyboard navigation.
Placeholder
There are three ways of providing more info about form field, but the best way
is using label
and to avoid placeholders.
label
element is outside of a inputplaceholder
text that is shown when field is empty. So when is not empty, no show. Also browsers translators does not work on placeholders since it is attribute (not a value or a text object).- adjacent elements (google sign in use this)
Select
form select can accept as 2th param (choices) two variant:
- flat collection
[['name', 123],...]
- if you need manual tags
<select><option></option></select>
than you can use<%= select tag "statuses[]", options_for_select([[]], selected: 1) %>
-
nested collection
grouped_options_for_select()
(also existsf.grouped_collection_select
)Preselected value can be defined as second param in
options_for_select([], selected: f.object.user_id
, or as 4th param inoptions_from_collection_for_select(User.all, :id, :email, selected: f.object.user_id)
. Note that it can bevalue
or hashselected: value
.
as 3th param (options):
Sometimes when options do not include value that you want to set and you
use prompt to be shown, please preform check { prompt: 'Select package'
}.merge( options.present? ? { selected: @customer.some_other_package.id
} : {} )
. If target selected value is not in options, that first option will be
used, or prompt is shown when options is empty.
disabled: [values]
to disable some optionslabel: 'My label'
prompt: 'Please select'
this is shown only if not already have some valueinclude_blank: 'Please select'
this is shown always (even already have value) I found usefull only with select2 where we use custom placeholder and blank option is not selectable<%= f.select :customer_name_and_username, options_from_collection_for_select(current_location.customers, 'id', 'name_and_username'), { include_blank: true, label: 'Customer' }, 'data-select2': true, placeholder: 'Search by Customer Name or Username' %>
as 4th params (html options)
-
multiple: true
so it is multi_select (instead of dropdown). Multi select will be shown with its own scrollbar (usesize: 5
to limit the size). In controller you need to allow arraysdef event_params params.require(:event).permit( :title, skills: [] ) end
-
Form array can be set on any input, use square brackets
name='user_ids[]'
. In view you can set array of hidden fields<% @isp_push_packages.isp_package_ids.each do |isp_package_id| %> <%= f.hidden_field 'isp_package_ids[]', value: isp_package_id %> <% end %>
or using select
multiple: true
(it will automatically add[]
to the name)<%= f.select isp_package_ids, options, {}, multiple: true %>
Note that you can replace dropdown select input with checkboxes, for example
<% ApplicationRecord.split_and_convert_to_hash(LANGUAGES).values.each do |language| %> <%= f.check_box 'languages_spoken_at_home', { multiple: true, label: language}, language, nil %> <% end %>
You can also nest in two dimensions ie it will be a hash with array values
name='user_ids[#{company.id}][]
. In this case you need to permit hash
(Rails 5.2) or whitelist in before Rails 5.2
params.require(:post).permit(reseller_location_ids: {}) # Rails 5.2
permit(files: params[:post][:files].key # before Rails 5.2
<%= select_tag 'reseller_location_ids[#{operator.id}]', options, multiple: true %>
# do not know how to use f.select since can not have method with a name
`name[name]`
Note that if it is not required and nothing is selected than nothing will be
sent, you should use hidden field or default value {}
<%= hidden_field_tag 'reseller_location_ids[0][]' %>
# or
location_ids = (params[:reseller_location_ids] || {})[params[:reseller_operator_id]] || []
- And in model you need to clean empty strings
[""]
when nothing is selectedbefore_validation :clean_empty_skills def clean_empty_skills self.skills = skills.select &:present? end
examples TODO https://www.driftingruby.com/episodes/nested-forms-from-scratch-with-stimulusjs
instead of polymorhic, we could use separate columns isp_id
, operator_id
,
location_id
. it is hard to search by that colum
for specific form inputs to ask, you can use fieldset and disable those which are not necessary.
Single line oneliner form in one line is using button_to with params: label, url, form class
<%= button_to 'Email bounce', superadmin_user_path(@user, user: { email_bounce: true }), method: :patch, class: 'btn btn-danger', form_class: 'd-inline' %>
<%= link_to "Fair usage policy", expire_subscriber_path(@subscriber, button: 'fair_usage_policy'), method: :patch, class: 'btn btn-primary' %>
Default is using POST, but you can change and add params
- in rails
<%= form_with ..., class: 'my-class'
you can use class attribute directly, but other attributes are not passed, so you need to use html like<%= form_with ..., html: { class: 'my-class' } %>
- optional permit params that allows empty
def _my_form_params params[:my_from] = {name: ""} unless params.keys.include? "my_from" params.require(:my_from).permit(:name) end
todo https://web.dev/learn/forms/