Implementing Dynamic Forms in Rails
In this lesson we will integrate a form and the new and create controller actions for posts, along with associating posts with users and topics.
Guide Tasks
  • Read Tutorial

Our application is coming along nicely, and you should be very happy that we're building it the right way, with a test first approach. In this lesson we're going to implement the ability to create posts, up until this point we've had to create posts in the rails console, but now we'll give users the ability to do this from a form in the browser.

Before we get started, let's think about what kind of behavior this feature will need to have:

  • There should be a form page to create a new post

  • A post will need to have a reference to its parent topic

  • A post will need to know what user created the post

  • A post needs to have a title and content attribute

  • A post should not be valid without a title and content

Do you notice what we just did there by thinking through the features? We used plain english, but those sentences look suspiciously like test expectations (that's on purpose). Whenever I'm planning out a new feature I find it very helpful to walk through the basic requirements instead of simply diving into the code. We'll add a few more items, but we can start with these for now.

Before we start writing our specs though, I'm seeing an issue with the user experience that I don't like. If we follow the pattern of having all of the post pages nested under a specific topic it will mean that a user will need to navigate to a topic page and then click a button that will direct them to a form page. Ugh, that's a really bad user experience choice, users should be able to create a post from anywhere on the site and then simply select what topic they want it nested underneath. So do we start from scratch and throw out all of the nesting work that we're already implemented? Well we could, but let's not do that since we're better developers than that. Instead let's create some cool functionality that will give our post form some flexibility. At times like this I like to draw out some basic diagrams because visuals help me to have a goal for what needs to be built. Below are two different action diagrams that show the type of behavior we're going to implement.

large

FYI, this is not formal UML at all, this is simply what I'd draw on a white board to visualize the feature

Now that we know the basic functionality we're going to build, inside of the features/post_spec.rb file let's add a new describe block and translate each of these requirements to specs.

# spec/features/post_spec.rb

  describe 'creation' do
    before do
      visit new_post_path
    end

    it 'should allow a post to be created from the form page and redirect to the show page' do
      fill_in 'post[title]', with: "Houston Astros"
      fill_in 'post[content]', with: "Are going all the way"
      find_field("post[topic_id]").find("option[value='#{@topic.id}']").click

      click_on "Save"

      expect(page).to have_css("h1", text: "Houston Astros")
    end

    xit 'should automatically have a topic selected if clicked on new post from the topic page' do
    end

    xit 'should have a user associated with the post' do
    end
  end

You've seen most of this before, here were navigating to new_post_path for each spec and the first expectation is going to fill out the form. Running this spec will fail because the route hasn't been setup, let's refactor our route and pull out the new action:

# config/routes.rb

  resources :topics do
    scope module: :topics do
      resources :posts, except: [:new]
    end
  end
  get 'posts/new', to: 'topics/posts#new', as: 'new_post'

If you run rake routes | grep post you'll see that our new action has been pulled out and even though it's working with the same nested controller it can be reached by navigating to /posts/new and isn't required to be nested inside of a Topic. Running the specs will now result in them complaining that it can't find out new action in the controller, let's update that:

# app/controllers/topics/posts_controller.rb

class Topics::PostsController < ApplicationController
  before_action :set_topic, except: [:new]

  def index
    @posts = @topic.posts
  end

  def new
  end

  private

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

In addition to creating the empty new method I also added an except list for the set_topic before action, since we don't want that method to run for our new method since that would result in an error. Running the specs now will give us a missing template error. And this brings up a good question to think about: where do we place the template? It seems logical that we'd need to create a new posts/ directory at the root of the views directory, right? Not quite, Rails conventions for nested controllers follow the pattern of looking for a template inside the parent directory that the controller is nested inside of. The RSpec error actually hints at this, notice what it says: ActionView::MissingTemplate: Missing template topics/posts/new. So let's add this template by running:

touch app/views/topics/posts/new.html.erb

Running the specs will show that we're making good progress, it now has moved onto Capybara not being able to find the field post[title], let's setup the form using the Rails form_tag helper method. We're not going to use the form_for method here because form_for binds itself to the model, and in this case it would require us to pass in the @topic value, which we won't always have. Below is the basic implementation:

<!-- app/views/topics/posts/new.html.erb -->

<%= form_tag create_post_path, method: 'post' do %>

  <%= text_field_tag :title %>
  <%= text_field_tag :content %>
  <%= collection_select(:post, :topic_id, Topic.all, :id, :title) %>

  <%= submit_tag 'Save' %>

<% end %>

If we startup the rails server and navigate to localhost:3000/posts/new it's going to throw an error undefined local variable or methodcreate_post_path', which make sense because we haven't created that custom route yet, stop the rails server and update theroutes.rb` file like so:

# config/routes.rb

  resources :topics do
    scope module: :topics do
        resources :posts, except: [:new, :create]
    end
  end
  get 'posts/new', to: 'topics/posts#new', as: 'new_post'
  post 'posts', to: 'topics/posts#create', as: 'create_post'

As you can see we removed the create action from the nested resource and added a custom post route for our create action. Now if you startup the Rails server you'll see our form page is now loading properly and allowing us to enter a title, content, and select a topic. Let's continue traversing the feature with our specs, running the specs now will give us the error that it can't find the field "post[title]" field. This is because form_tag uses a different naming structure than form_for by default, so we need to update our form. But before we do that, let's see how to find the naming structure that the form is using. Navigate to the form in the browser, and use the inspector by right clicking on one of the form attributes.

large

As you can see form_tag uses a more simplistic naming structure than form_for, which uses a hash. On a side note, this is the pattern I follow whenever I have a Capybara matcher that isn't working. At the end of the day Capybara is looking through the rendered HTML to find an element, so if you follow the same pattern you'll be able to find the right name to add to the spec.

Let's update our form:

# app/views/topics/posts/new.html.erb

<%= form_tag create_post_path, method: 'post' do %>

  <%= text_field_tag "post[title]" %>
  <%= text_field_tag "post[content]" %>
  <%= collection_select(:post, :topic_id, Topic.all, :id, :title) %>

  <%= submit_tag 'Save' %>

<% end %>

Now if you run the specs you'll see that the test is now finding the elements and it's asking for a create action, let's implement the ability for posts to be created, along with some of the elements we know need to be integrated, such as strong parameters.

# app/controllers/topics/posts_controller.rb

  # update the before_action so it doesn't look for a topic for the create method
  before_action :set_topic, except: [:new, :create]

  def create
    post = Post.new(post_params)
    post.save
  end

If you run RSpec it will now complain that it can't find a create view template. Let's refactor the the create method so it redirects to a show action which is what the spec is looking for anyways. Let's also add in some conditional logic to handle errors:

# app/controllers/topics/posts_controller.rb

  # update the before_action so it doesn't look for a topic for the create method
  before_action :set_topic, except: [:new, :create]

  def create
    post = Post.new(post_params)

    if post.save
      redirect_to topic_post_path(topic_id: post.topic_id, id: post), notice: 'Your post was successfully published.'
    else
      render :new
    end
  end

Notice how we navigate between the nested and non-nested resource? We are able to call the show action by simply grabbing the topic_id from the post that was just created. We're almost done with the initial implementation, let's create the show action and view:

touch app/views/topics/posts/show.html.erb
# app/controllers/topics/posts_controller.rb

  def show
    @post = Post.find(params[:id])
  end

The spec will now complain that it couldn't find the content we told it to look for. Let's update the view template to get the test passing:

<!-- app/views/topics/posts/show.html.erb -->

<h1><%= @post.title %></h1>

Yay! It's working, nice work. Let's implement the next expectation now:

# spec/features/post_spec.rb

    it 'should automatically have a topic selected if clicked on new post from the topic page' do
      visit new_post_path(topic_id: @topic.id)

      fill_in 'post[title]', with: "Houston Astros"
      fill_in 'post[content]', with: "Are going all the way"

      click_on "Save"

      expect(page).to have_content("Sports")
    end

Ok, this looks a little weird, before we even get into the implementation let's walk through what this spec is doing:

  1. It's navigating to the new_post_path, however now it's passing in the topic_id as a parameter, this is what we'll do from pages where we want the Topic value pre-populated.

  2. Then it fills in the title and content values, however it skips selecting a topic since for cases like this we want the topic pre-selected

  3. Then it looks for the topic name on the post show page

Now that we know what we want to do, let's implement it. let's start by giving our form some more advanced behavior.

<%= form_tag create_post_path, method: 'post' do %>

  <%= text_field_tag "post[title]" %>
  <%= text_field_tag "post[content]" %>

  <%= collection_select(:post, 
    :topic_id, 
    Topic.all, 
    :id,
    :title,
    { selected: (params[:topic_id] if params[:topic_id])}) 
  %>

  <%= submit_tag 'Save' %>

<% end %>

Don't let the syntax scare you, I moved each of the collection_select arguments to their own line to make it more readable, it wouldn't change anything if you wanted it all on one line. Everything on the form is the same except we're passing in another argument into the collection_select method where we tell it that we want the default value of the topic set if the params hash contains a value for topic_id. Essentially what we're saying here is that if the URL looks something like this:

http://localhost:3000/posts/new?topic_id=2

The Topic value should be set to whatever value topic_id=2 is equal to. By adding in the if params[:topic_id] conditional, we're saying, if params[:topic_id] isn't available, such as when they navigate to http://localhost:3000/posts/new this default value will be blank.

Let's add our button to the posts index page:

<!-- app/views/topics/posts/index.html.erb -->

<%= link_to "New Post", new_post_path(topic_id: @topic.id) %>

Starting up the rails server, let's navigate to the posts index page, such as http://localhost:3000/topics/baseball/posts and you'll see our shiny new button, if you click it you'll see that we're taken to the new post page, however now the topic value is pre-populated based on the topic page you came from. From my personal machine, if I go to http://localhost:3000/posts/new?topic_id=1 it shows the topic as Baseball, if I change the ID in the URL bar to http://localhost:3000/posts/new?topic_id=3 it changes to Star Wars. Filling out the form and clicking save will take you to the show page like before. To get the rspec test passing, we need to have the topic title printed out on the screen, let's implement this in the show template:

<!-- app/views/topics/posts/show.html.erb -->

<h1><%= @post.title %></h1>
<p><%= @post.topic.title %></p>

Running rspec will now show that the tests are all passing, nice! I know this has been a long lesson, but let's finish strong and implement the last feature. Integrating the expectation:

# spec/features/post_spec.rb

    it 'should have a user associated with the post' do
      user = FactoryGirl.create(:user)
      login_as(user, :scope => :user)

      visit new_post_path

      fill_in 'post[title]', with: "Houston Astros"
      fill_in 'post[content]', with: "Are going all the way"
      find_field("post[topic_id]").find("option[value='#{@topic.id}']").click

      click_on "Save"

      expect(page).to have_content("Jon Snow")
    end

Ok, what are we doing now... What is this login_as call? Can I go get a snack? Let's answer those questions one at a time:

  1. We're testing to see if a user is associated with a post that they create.

  2. The login_as call is a method provided by the devise gem that lets us mock a user signing into the application, which is required in order for us to tie them to a post that they create.

  3. Only as long a you bring me one too.

If you try to run this spec now you'll see that RSpec doesn't know what the login_as method is:

Failures:

  1) post creation should have a user associated with the post
     Failure/Error: login_as(user, :scope => :user)

     NoMethodError:
       undefined method `login_as' for #<RSpec::ExampleGroups::Post::Creation:0x007ff5b8d4ea80>
     # ./spec/features/post_spec.rb:66:in `block (3 levels) in <top (required)>'

Finished in 0.93205 seconds (files took 2.59 seconds to load)
31 examples, 1 failure, 2 pending

Failed examples:

rspec ./spec/features/post_spec.rb:64 # post creation should have a user associated with the post

This makes sense because we need to bring in the devise test helper, open up the rails_helper file and add in these two lines:

include Warden::Test::Helpers
Warden.test_mode!

I placed them right below the call to capybara/rails.

Now if you run rspec you'll get a more normal error saying that the test couldn't find "Jon Snow" if you're wondering where I got the name, it's located in the user factory. So what do we do now? We can't simply add the call to the view, we need to build in the functionality where users are associated with posts automatically when the post is created. It sounds like the post create action would be a good place for this behavior, let's update that method:

# app/controllers/topics/posts/posts_controller.rb

  def create
    post = Post.new(post_params)
    post.user_id = current_user.id

    if post.save
      redirect_to topic_post_path(topic_id: post.topic_id, id: post), notice: 'Your post was successfully published.'
    else
      render :new
    end
  end

All we had to do was add in the line post.user_id = current_user.id, the current_user method is a method from devise that pulls in the session data for the user and makes it available to the application. In this line we're simply saying assign the value of the current user's id as the user associated with the post.

Lastly, let's update the view:

<!-- app/views/topics/posts/show.html.erb -->

<h1><%= @post.title %></h1>
<p><%= @post.topic.title %></p>
<p><%= @post.user.first_name + " " + @post.user.last_name %></p>

Running the specs now and...

large

What did you do? It's all broken. So what happened? Let's see what exactly is failing:

Failed examples:

rspec ./spec/features/post_spec.rb:43 # post creation should allow a post to be created from the form page and redirect to the show page
rspec ./spec/features/post_spec.rb:53 # post creation should automatically have a topic selected if clicked on new post from the topic page

That's interesting, our new spec is passing just fine, it's the other specs that are now broken. This is because we're now setting the current_user id value as the user_id value for posts, but we're only using our login helper in the third spec, let's fix this:

# spec/features/post_spec.rb

  describe 'creation' do
    before do
      user = FactoryGirl.create(:user)
      login_as(user, :scope => :user)

      visit new_post_path
    end

    it 'should allow a post to be created from the form page and redirect to the show page' do
      fill_in 'post[title]', with: "Houston Astros"
      fill_in 'post[content]', with: "Are going all the way"
      find_field("post[topic_id]").find("option[value='#{@topic.id}']").click

      click_on "Save"

      expect(page).to have_css("h1", text: "Houston Astros")
    end

    it 'should automatically have a topic selected if clicked on new post from the topic page' do
      visit new_post_path(topic_id: @topic.id)

      fill_in 'post[title]', with: "Houston Astros"
      fill_in 'post[content]', with: "Are going all the way"

      click_on "Save"

      expect(page).to have_content("Sports")
    end

    it 'should have a user associated with the post' do
      fill_in 'post[title]', with: "Houston Astros"
      fill_in 'post[content]', with: "Are going all the way"
      find_field("post[topic_id]").find("option[value='#{@topic.id}']").click

      click_on "Save"

      expect(page).to have_content("Jon Snow")
    end
  end

Now each of our specs in the create block will have access to a signed in user. Running the specs will show that all of them are passing now, nice work. Let's do a small refactor, this line bothers me:

<p><%= @post.user.first_name + " " + @post.user.last_name %></p>

Let's add a virtual attribute in the User model that combines the first and last name:

# app/models/user.rb

  def full_name
    self.first_name + " " + self.last_name
  end

Now we can update the view:

<!-- app/views/topics/posts/show.html.erb -->

<h1><%= @post.title %></h1>
<p><%= @post.topic.title %></p>
<p><%= @post.user.full_name %></p>

Running the tests again will show that they're all passing. Nice work, this was a long lesson, but you built out a very key portion of the application and implemented some advanced features, all of which are tested.

One thing you may have noticed is that the application's data seems a little fragile. If you look through the database we have nil values all over the place and those are causing errors when navigating the site. That's normal for a new application, however let's start cleaning that up, in the next lesson we'll clean up the database values so we're working with a clean data set by creating a seeds file.

Resources