- 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
andcontent
attributeA post should not be valid without a
title
andcontent
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.
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 method
create_post_path', which make sense because we haven't created that custom route yet, stop the rails server and update the
routes.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.
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:
It's navigating to the
new_post_path
, however now it's passing in thetopic_id
as a parameter, this is what we'll do from pages where we want theTopic
value pre-populated.Then it fills in the
title
andcontent
values, however it skips selecting a topic since for cases like this we want the topic pre-selectedThen it looks for the
topic
name on thepost
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:
We're testing to see if a user is associated with a post that they create.
The
login_as
call is a method provided by thedevise
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.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...
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.