1

I've successfully been able to implement this tutorial up to a point.

I can get the nested items to appear by saving the parent form and going back to edit, but I am unable to get my Delete or my Add Option button to actually work.

The Delete button doesn't appear to actually do anything and I can't understand the server stream to figure out what it is trying to do.

The Add Option button somehow is returning "unpermitted parameter" for :title, :description, :question_options_attributes error by attempting to create a parent record instead of a child one as it goes up one level in the controller chain to the surveys/questions_controller#create action.

here's what I have so far:

class SurveyQuestion < ApplicationRecord
  belongs_to :survey_section
  has_many :question_options
  accepts_nested_attributes_for :question_options, reject_if: :all_blank, allow_destroy: true

class Surveys::QuestionsController < SurveysController
    before_action :get_section, only: [:index, :new, :create, :update, :edit]
    before_action :set_question, only: [:edit, :update]

    ...

    def edit
        @option = @question.options.build
        render partial: "surveys/questions/edit", locals: {q: @question, 
            survey: @survey, section: @section, option: @option}
    end

     private
        def get_section
            @survey = Survey.find_by(slug: params[:survey_slug])
            @section = @survey.sections.find_by(section_number: params[:sec_num])
        end

        def create_params
            params.require(:survey_question).permit(:survey_section_id, :position, :question_type)
        end

        def update_params
            params.require(:survey_question).permit(:survey_setion_id, :position, :question_type, 
                :title, :description, question_options_attributes: [:id, :_destroy, :name, 
                    :logic, :sub_logic])
        end
        
        def set_question
            @question = @section.questions.find_by(position: params[:position])
        end

In my survey question edit view:

%turbo-frame{id: "question_#{q.id}", data: {turbo: {action: "advance"}}}
    = form_with(model: q, url: survey_question_path(survey_slug: survey.slug, position: q.position, sec_num: section.section_number)) do |f|

     ...
      %turbo-frame{id: "options"}
        = f.fields_for :question_options, q.options do |of|
            = render "surveys/questions/options/form", form: of, q: q

    .text-start
        = f.submit "Add Option", formaction: survey_question_option_path(survey_slug: q.survey_section.survey.slug, question_position: q.position, index: q.options.size, sec_num: q.survey_section.section_number), formmethod: :post, formnovalidate: true, id: "add_option"

in the surveys/questions/options/form partial:

%turbo-frame{id: "question_#{q.id}_option_#{form.index}"}
    = form.hidden_field :id
    %p This is form index number #{form.index}.
    .form-floating.mb-4
        = form.text_field :name, placeholder: "Option #{form.index + 1}", class: "form-control border border-dark"
        = form.label :name, "Option #{form.index + 1}"
    = form.submit "Delete", formaction: survey_question_option_path(survey_slug: q.survey_section.survey.slug, question_position: q.position, index: form.index, id: form.object.id, sec_num: q.survey_section.section_number), formmethod: :delete, formnovalidate: true, data: {turbo: {frame: "question_#{q.id}_option_#{form.index}"}}

in the surveys/questions/options/create.turbo_stream.haml:

= fields model: @question do |f|
  = f.fields_for :question_options, @options, child_index: params[:index] do |of|
    = turbo_stream.replace "add_option" do
      = f.submit "Add Option", formaction: survey_question_option_path(survey_slug: @survey.slug, question_position: @question.position, index: of.index.to_i + 1, sec_num: @section.section_number), formmethod: :post, formnovalidate: true, id: "add_option"

    = turbo_stream.append "options" do
      = render "surveys/questions/options/form", form: task_form, q: @question

in the surveys/questions/options/destroy.turbo_stream.haml partial:

= fields model: @question do |f|
    = f.fields_for :question_options, @options, child_index: params[:index] do |of|
        %turbo-frame{id: "question_#{@question.id}_option_#{of.index}"}
            = of.hidden_field :id, value: params[:id]
            = of.hidden_field :_destroy, value: true

= turbo_stream.remove(id: "question_#{@question.id}_option_#{params[:index]}")

the options controller:

class Surveys::Questions::OptionsController < Surveys::QuestionsController
    before_action :setup

    def new
    end

    def destroy
    end

    private

        def setup
            @survey = Survey.find_by(slug: params[:survey_slug])
            @channel = @survey.channel
            @section = @survey.sections.find_by(section_number: params[:sec_num])
            @question = @section.questions.find_by(position: params[:question_position])
            @options = @question.options
        end

end

And finally the relevant routes:

   resources :surveys, param: :slug do
      resources :sections, controller: "surveys/sections", param: :sec_num
      resources :questions, controller: "surveys/questions", param: :position do
        resources :options, controller: "surveys/questions/options", only: [], param: :index do
          member do
            delete "(:id)" => "surveys/questions/options#destroy", as: ""
            post "/" => "surveys/questions/options#create"
          end
        end
      end
    end

Did I miss something?

Patrick Vellia
  • 399
  • 2
  • 9
  • Just a side note but you should probally name that partial `_fields.html.erb` or something similiar as its kind of implied that a partial named form actually contains said form. There are some other issues here like the use of `find_by` instead of `find` which is an invitation for nil errors and slightly excessive deep nesting. – max Apr 30 '23 at 14:31

1 Answers1

1
class Surveys::QuestionsController < SurveysController
...
class Surveys::Questions::OptionsController < Surveys::QuestionsController

I'd say this ^ controller centipede is a bad bad idea. Controllers don't need to inherit from each other to represent your routes. That options controller probably does 3 lookups to set the same @survey from every before_action.

params.require(:survey_question).permit(:survey_setion_id
#                                                ^^

You only have 4 resources and it's already confusing, usually that means you're going against the grain, so to speak. For your routes, just use shallow routes. For example, if I want to edit a question I could care less what section it's in. If I care more there is question.section association. Pretty sure you can make it work with defaults:

# config/routes.rb

# NOTE: if you want to give shallow routes a go:
# shallow do

resources :surveys do
  scope module: :surveys do
    resources :sections
    resources :questions do
      scope module: :questions do
        resources :options, only: [:destroy, :create]
      end
    end
  end
end

# end

I can show you my set up, you can split it into different actions and/or controllers:

# config/routes.rb
resources :surveys do
  post :new, on: :new  # even this is now optional
end

# db/migrate/20230430072140_create_survey.rb
class CreateSurveyQuestions < ActiveRecord::Migration[7.0]
  def change
    create_table :surveys do |t|
      t.string :name
    end
    create_table :survey_sections do |t|
      t.string :name
      t.references :survey
    end
    create_table :survey_questions do |t|
      t.string :title
      t.references :survey_section
    end
    create_table :options do |t|
      t.string :name
      t.references :survey_question
    end
  end
end

# app/models/survey.rb
class Survey < ApplicationRecord
  has_many :survey_sections
  accepts_nested_attributes_for :survey_sections, allow_destroy: true
end

# app/models/survey_section.rb
class SurveySection < ApplicationRecord
  has_many :survey_questions
  accepts_nested_attributes_for :survey_questions, allow_destroy: true
end

# app/models/survey_question.rb
class SurveyQuestion < ApplicationRecord
  belongs_to :survey_section
  has_many :options
  accepts_nested_attributes_for :options, allow_destroy: true
end

# app/models/option.rb
class Option < ApplicationRecord
  belongs_to :survey_question
end

Form and field partials follow the same pattern, you could even make a helper or another partial out of it:

# app/views/surveys/_form.html.erb

<%= form_with model: survey do |f| %>
  <%# === SURVEY FIELDS === %>
  <%= f.text_field :name, placeholder: "Survey name" %>

  <%# === SECTIONS === %>
  <%= tag.div id: f.field_id(:survey_sections, index: nil), class: "p-4 border-l" do %>
    <%= tag.div "Sections", class: "font-bold text-2xl" %>
    <%= f.fields_for :survey_sections do |ff| %>
      <%= render "survey_section_fields", f: ff %>
    <% end %>
  <% end %>
  <%# ADD SECTION %>
  <%= link_to "Add section",
    new_survey_path(field_name: f.field_name(:survey_sections, index: nil)),
    class: "text-blue-500 mb-4", data: {turbo_method: :post} %>

  <%= f.submit class: "block my-4 font-bold" %>
<% end %>
# app/views/surveys/_survey_section_fields.html.erb

<%# === SECTION FIELDS === %>
<%= tag.pre f.object_name %>
<%= f.text_field :name, placeholder: "Section name" %>

<%# === QUESTIONS === %>
<%= tag.div id: f.field_id(:survey_questions, index: nil), class: "p-4 block border-l" do %>
  <%= tag.div "Questions", class: "font-medium text-xl" %>
  <%= f.fields_for :survey_questions do |ff| %>
    <%= render "survey_question_fields", f: ff %>
  <% end %>
<% end %>
<%# ADD QUESTION %>
<%= link_to "Add question",
  new_survey_path(field_name: f.field_name(:survey_questions, index: nil)),
  class: "text-blue-500 mb-4", data: {turbo_method: :post} %>
# app/views/surveys/_survey_question_fields.html.erb

<%# === QUESTION FIELDS === %>
<%= tag.pre f.object_name %>
<%= f.text_field :title, placeholder: "Question title" %>

<%# === OPTIONS === %>
<%= tag.div id: f.field_id(:options, index: nil), class: "p-4 block border-l" do %>
  <%= tag.div "Options", class: "font-medium" %>
  <%= f.fields_for :options do |ff| %>
    <%= render "option_fields", f: ff %>
  <% end %>
<% end %>
<%# ADD OPTION %>
<%= link_to "Add option",
  new_survey_path(field_name: f.field_name(:options, index: nil)),
  class: "text-blue-500 mb-4", data: {turbo_method: :post} %>
# app/views/surveys/_option_fields.html.erb

<%# === OPTION FIELDS === %>
<%= tag.pre f.object_name, class: "text-xs" %>
<%= f.text_field :name, placeholder: "Option name" %>

Controller only gets a single param[:field_name] which has all the information we need to build the requested fields with the appropriate index and append it to the correct place on the page:

# v form model              v associations v           make fields for V
"survey[survey_sections_attributes][4][survey_questions_attributes][1][options]"
#                     section index ^                question index ^ 

To get the target for turbo stream use parameterize on the field_name which will give you the same thing as f.field_id in the templates:

>> "survey[survey_sections_attributes][4][survey_questions_attributes][1][options]".parameterize(separator: "_")
=> "survey_survey_sections_attributes_4_survey_questions_attributes_1_options"
# app/controllers/survey_controller.rb

# GET/POST /surveys/new
def new
  @survey = Survey.new

  if params[:field_name]
    _, *nested_attributes = params[:field_name].split(/\[|\]/).compact_blank
    helpers.fields model: @survey do |form|
      nested_form_builder_for form, nested_attributes do |f|
        render turbo_stream: turbo_stream.append(
          params[:field_name].parameterize(separator: "_"),
          partial: "#{f.object.class.name.underscore}_fields",
          locals: {f:}
        )
      end
    end
  end
end

private

def nested_form_builder_for f, *nested_attributes, &block
  attribute, index = nested_attributes.flatten!.shift(2)
  if attribute.blank?
    yield f
    return
  end
  association = attribute.chomp("_attributes")
  child_index = index || Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
  f.fields_for association, association.classify.constantize.new, child_index: do |ff|
    nested_form_builder_for(ff, nested_attributes, &block)
  end
end

Works forever:

{"authenticity_token"=>"[FILTERED]",
 "survey"=>
  {"name"=>"",
   "survey_sections_attributes"=>
    {"0"=>
      {"name"=>"section one",
       "survey_questions_attributes"=>
        {"0"=>
          {"title"=>"q1",
           "options_attributes"=>
            {"0"=>{"name"=>"opt1", "id"=>"14"},
             "1"=>{"name"=>"opt3", "id"=>"15"},
             "1682897205190"=>{"name"=>"new option"}},  # new option
           "id"=>"9"},
         "1682897209862"=>                              # new question
          {"title"=>"new question",
           "options_attributes"=>
            {"1682897215265"=>{"name"=>"with new option"}}}},
       "id"=>"7"},
     "1"=>{"name"=>"", "id"=>"8"},
     "1682897196889"=>{"name"=>"new section"}}},        # new section
 "commit"=>"Update Survey",
 "id"=>"2"}

Update

@max it can be done up to a point, eventually the number of combinations just becomes unmanageable, but for simple cases this works:

def add_section
  helpers.fields Survey.new do |f|
    f.fields_for :sections, SurveySection.new, child_index: Time.now.to_i do |ff|
      render turbo_stream: turbo_stream.append(params[:target], partial: "survey_section_fields", locals: {f: ff})
    end
  end
end

def add_question
  helpers.fields Survey.new do |f|
    f.fields_for :sections, SurveySection.new, child_index: params[:section_index] do |ff|
      ff.fields_for :questions, SurveyQuestion.new, child_index: Time.now.to_i do |fff|
        render turbo_stream: turbo_stream.append(params[:target], partial: "survey_question_fields", locals: {f: fff})
      end
    end
  end
end

def add_option
  helpers.fields Survey.new do |f|
    f.fields_for :survey_sections, SurveySection.new, child_index: params[:section_index] do |ff|
      ff.fields_for :survey_questions, SurveyQuestion.new, child_index: params[:question_index] do |fff|
        fff.fields_for :options, Option.new, child_index: Time.now.to_i do |ffff|
          render turbo_stream: turbo_stream.append(params[:target], partial: "option_fields", locals: {f: ffff})
        end
      end
    end
  end
end

This also doesn't work if nested fields are recursive and are not limited in depth. Probably much simpler to just do it with javascript then.


For details:
Rails 7 Dynamic Nested Forms with hotwire/turbo frames

Alex
  • 16,409
  • 6
  • 40
  • 56
  • 1
    Nesting your controllers in a module isn't a bad idea in general. It's actually a really good way to differentiate between different nested (and non-nested) respresentations of a resource. Where this question goes wrong is the crazy amount of deep nesting as well as the abuse of the scope resolution operator for namespace definition. – max May 01 '23 at 13:39
  • In general I think its preferable to the "param sniffing" approach where you stuff everything into a single controller action with an ever increasing cyclic complexity. – max May 01 '23 at 13:42
  • @max you're right, i meant that controllers inherit from each other. as for params, it's a cyclic action for a cyclic form, complexity doesn't change(?) with more fields - same method call. the only other way i found is to just use javascript for it. – Alex May 01 '23 at 17:01
  • Ah, yeah I missed the inheritance part. That's sketchy. As for dealing with multiple levels of nested attributes - I tend to think of nested attributes as a dirty hack for moshing a lot of functionality into a single syncronous form. It has it's place but sometimes it's simpler and better to use JS to send individual POST/PATCH/DELETE requests for the nested items. That keeps the logic simpler and you can provide on the fly feedback as the user is creating/updating items. – max May 01 '23 at 17:56