Question: Params hash from a SimpleForm with multiple records has a different structure for one record than for two

Question

Params hash from a SimpleForm with multiple records has a different structure for one record than for two

Answers 3
Added at 2017-11-07 18:11
Tags
Question

I struggled to digest this into a title.

I'm using SimpleForm to construct a bulk-edit page with one or more fieldsets - one for each record in a collection of ActiveRecord models that have been built but not yet saved.

My form looks like this:

= simple_form_for :courses, method: :patch do |f|
  - @courses.each do |course|
    = field_set_tag do
      = f.simple_fields_for 'courses[]', course do |c|
        = c.input :title
        .row
          .medium-6.columns
            = c.input :start_date, as: :string, input_html: { class: 'input-datepicker' }
          .medium-6.columns
            = c.input :end_date, as: :string, input_html: { class: 'input-datepicker' }
  = f.submit 'Save', class: 'primary button'

The params hash for one record looks like this:

"courses"=>{"courses"=>[{"title"=>"Course Y", "start_date"=>"2017-09-26", "end_date"=>"2017-07-31"}]}

with an array, while for two records it looks like this:

"courses"=>{"courses"=>{"1"=>{"title"=>"Course X", "start_date"=>"2018-01-16", "end_date"=>"2018-07-30"}, "2"=>{"title"=>"Course Y", "start_date"=>"2017-09-26", "end_date"=>"2018-07-30"}}}

with a stringy-integer-keyed hash.

This becomes a problem when I try and use strong parameters. After much hacking, I ended up with this piece of code, which works for multiple records but fails when only one is submitted:

ActionController::Parameters
  .new(courses: params[:courses][:courses].values)
  .permit(courses: [:title, :start_date, :end_date])
  .require(:courses)

It fails with param is missing or the value is empty: courses highlighting the .require(:courses) line above.

The problem is "solved" by harmonising the single-record case with the multiple-record case:

if params[:courses][:courses].is_a?(Array)
  params[:courses][:courses] = { '1': params[:courses][:courses][0] }
end

but it feels like there should be a simpler way of doing it.

Is there a better way to write the form for this use-case? Am I missing a trick with strong parameters?

I'm using rails 5.0.5 and simple_form 3.5.0.

Answers to

Params hash from a SimpleForm with multiple records has a different structure for one record than for two

nr: #1 dodano: 2017-11-07 19:11

"but it feels like there should be a simpler way of doing it."

Yes, use ajax to send individual create/update requests. This can be done transparently to the user and provides simpler code and a far better user experience.

Rails has fields_for and accepts_nested_attributes that can be used to create/update multiple child records and the parent record in a single request. But it really requires a association that groups the records together and even at this can get really hacky and convoluted when it comes to validations.

You want to set it up so that you have a seperate form for each record:

- courses.each do |c|
  = render partial: 'courses/_form', course: c

There is really nothing to the form:

# courses/_form.haml.erb
= simple_form_for course, remote: true, html: { 'data-type' => 'json', class: 'course_form'} do |f|
  = c.input :title
  .row
    .medium-6.columns
      = c.input :start_date, as: :string, input_html: { class: 'input-datepicker' }
    .medium-6.columns
      = c.input :end_date, as: :string, input_html: { class: 'input-datepicker' }
  = f.submit 'Save', class: 'primary button'

Instead of using a js.erb template we use 'data-type' => 'json' and write our own handler as its easier to target the correct form:

$(document).on('ajax:success', '.course_form', function(event, xhr, status){
  var $form = $(this);
  alert('Course created');
  if (this.method.post) {
    // changes form to update instead.
    this.method = 'patch';
    this.action = xhr.getResponseHeader('Location');
  } 
});

$(document).on('ajax:error', '.course_form', function(event, xhr, status){
  var $form = $(this);
  // @todo display errors
});

Creating the controller is very straight forward:

class CoursesController
  def create
    @course = Course.new(course_params)
    respond_to do |format|
      if @course.save(course_params)
        format.json { head :created, location: @course }
      else
        format.json do
          render json: {
            errors: @course.errors.full_messages
          }
        end
      end
    end
  end

  def update
    @course = Course.find(params[:id])
    respond_to do |format|
      if @course.update(course_params)
        format.json { head :ok }
      else
        render json: {
          errors: @course.errors.full_messages
        }
      end
    end
  end
end
nr: #2 dodano: 2017-11-07 22:11

Keep your form, change strong params to this:

params.require(:courses).permit(
  courses: [
    :id,
    :title,
    :start_date, 
    :end_date
  ]
)

With this code params should be without index key, @courses is just an array:

# CoursesController
def new
  @courses = []
  # creating 3 items for example
  3.times do
    @courses << Course.new
  end
end

def create
  errors = false
  @courses= []
  # keep courses in the array for showing errors
  courses_params[:courses].each do |params|
    course = Course.new(params)
    @courses << course 
    unless course.valid?
      errors = true
    end
  end
  if errors
    render :new
  else
    # if no errors save and redirect
    @courses.each(&:save)
    redirect_to courses_path, notice: 'courses created'
  end
end
nr: #3 dodano: 2017-11-08 16:11

It turns out that the f.simple_fields_for 'courses[]' ... method only gives that fieldset an ID if the form is populated by an existing record, and the params structure of a string ID mapping to a course hash is only used in this case. For "fresh" records, there is no ID and the course hashes are placed in a plain array.

This bit of code was running in the context of "rolling over" courses from one year to another - copying a previous course and changing the dates. This meant that each fieldset had the ID of the original course.

When the form was submitted, a new record was created and validated with the new attributes, and it was this fresh record with no ID that repopulated the form. The "it only happens when one course is submitted" thing was a red herring - a product of the test scenario.

So worth noting: f.simple_fields_for 'courses[]' ... creates an array for new records and a hash mapping IDs to attributes for existing records.

Source Show
◀ Wstecz