Search code examples
ruby-on-railsactiverecordnested-formsactioncontrollerforums

Creating nested models in Rails 4 forum app


Hello I am making a Forum application in Rails 4. It can have numerous forums, each with numerous topics. Each topic can have many posts. When creating a new topic, one must also create the initial post, much like Stack Overflow itself. Therefore, I have a text area in the "New Topic" form that allows this with a fields_for method. The Problem is, when you click the "Create Topic" button after filling out the form (including the "post" field), the transaction is rolled back. The following validation error appears:

3 errors prohibited this topic from being saved:

  • Posts forum must exist
  • Posts topic must exist
  • Posts user must exist

This is my form: app/views/topics/_form.html.erb

<%= form_for([ @forum, topic ]) do |f| %>
  <% if topic.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(topic.errors.count, "error") %> prohibited this topic from being saved:</h2>

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

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

  <div class="field">
    <%= f.fields_for :posts do |builder| %>
      <%= builder.label :content %><br>
      <%= builder.cktext_area :content, class: 'ckeditor' %>
    <% end %>
  </div>

  <div class="actions">   
    <%= f.submit 'Create Topic', class: "btn btn-l btn-success" %>
  </div>
<% end %>

Models: forum.rb

class Forum < ApplicationRecord
    has_many :topics, dependent: :destroy
    has_many :posts, through: :topics

    def most_recent_post
      topic = Topic.last
      return topic
    end
end

topic.rb

class Topic < ApplicationRecord
  belongs_to :forum
  belongs_to :user

  has_many :posts, dependent: :destroy
  accepts_nested_attributes_for :posts

end

post.rb

class Post < ApplicationRecord
  belongs_to :forum
  belongs_to :topic
  belongs_to :user

  validates :content, presence: true
end

The controller for topics, app/controllers/topics_controller.rb

class TopicsController < ApplicationController
  before_action :get_forum
  before_action :set_topic, only: [:show, :edit, :update, :destroy]

  # GET /topics
  # GET /topics.json
  def index
    @topics = @forum.topics
  end

  # GET /topics/1
  # GET /topics/1.json
  def show
  end

  # GET /topics/new
  def new
    @topic = @forum.topics.build
    @topic.posts.build
  end

  # GET /topics/1/edit
  def edit
    # @topic.posts.build
  end

  # POST /topics
  # POST /topics.json
  def create
    @topic = @forum.topics.build(topic_params.merge(user_id: current_user.id))
    @topic.last_poster_id = @topic.user_id

    respond_to do |format|
      if @topic.save
        format.html { redirect_to forum_topic_path(@forum, @topic), notice: 'Topic was successfully created.' }
        format.json { render :show, status: :created, location: @topic }
      else
        format.html { render :new }
        format.json { render json: @topic.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /topics/1
  # PATCH/PUT /topics/1.json
  def update
    respond_to do |format|
      if @topic.update(topic_params)
        format.html { redirect_to forum_topic_path(@forum, @topic), notice: 'Topic was successfully updated.' }
        format.json { render :show, status: :ok, location: @topic }
      else
        format.html { render :edit }
        format.json { render json: @topic.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /topics/1
  # DELETE /topics/1.json
  def destroy
    @topic.destroy
    respond_to do |format|
      format.html { redirect_to forum_path(@forum), notice: 'Topic was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def get_forum
      @forum = Forum.find(params[:forum_id])
    end

    def set_topic
      @topic = Topic.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def topic_params
      params.require(:topic).permit(:title, :last_poster_id, :last_post_at, :tags, :forum_id, :user_id, posts_attributes: [:id, :content])
    end
end

As you see I've added the posts_attributes to the strong parameters for topic. These are the only fields that posts have besides the foreign key references (:forum_id, :topic_id, :user_id). And I've tried putting those attributes in, but I get the same error.

Finally, this is my routes.rb

Rails.application.routes.draw do

  resources :forums do
    resources :topics do
      resources :posts
    end
  end
  resources :sessions
  resources :users
  mount Ckeditor::Engine => '/ckeditor'
end

I should also mention that I have tried adding hidden_fields inside of fields_for, with the id criteria for @forum, @topic, and current_user. That throws the same validation error.

What am I missing? I feel like it's something in the controller. Like I'm not saving it properly. Every tutorial I've seen has it this way. Except for the Rails <=3 versions, which are way different because of no strong_params.

Any ideas? Thanks for the help!

EDIT Here is the log output when I try to submit a topic entitled "I am a title" and the content "I am some content"...

Started POST "/forums/1/topics" for 127.0.0.1 at 2016-01-31 09:03:33 -0500
Processing by TopicsController#create as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"pYt842XQHiOKqNjPHBO8lNP2z92gHF7Lpt24CppbuvHR/cFHky3FVCpBs77p7WFRKmYBHgeZQjx0sE+DI+Q+sQ==", "topic"=>{"title"=>"I am a title", "posts_attributes"=>{"0"=>{"content"=>"<p>I am some content</p>\r\n"}}}, "commit"=>"Create Topic", "forum_id"=>"1"}
  Forum Load (0.6ms)  SELECT  "forums".* FROM "forums" WHERE "forums"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  User Load (0.6ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
   (0.3ms)  BEGIN
  CACHE (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
   (0.4ms)  ROLLBACK

Solution

  • This is not a direct answer; too long for comment.

    One of the issues you have with your routes is that you're nesting too many resources:

    Resources should never be nested more than 1 level deep...

    resources :x do
      resources :y
    end
    

    --

    Although you can do what you're doing, it would perhaps be better to use a scope:

    #config/routes.rb
    scope ':forum' do
       resources :topics do
          resources :posts
       end
    end
    

    The issue you're facing is that things can get very complicated, very quickly. Although the

    This way, you could make the forums CRUD accessible in its own set of functionality:

    #config/routes.rb
    resources :forums #-> only accessible to admins?
    scope ...
    

    Either way, you'd still need to define your routes with the forum present:

    <%= link_to "Test", [@forum, @topic, @post] %>