- Read Tutorial
Since users now have the ability to create posts and we have some clean data, it would make sense to implement the ability for a user to edit a post that they created. Let's build that feature out now.
We're create a new describe
block in our post_spec
and add in the specs that we'll need to get passing to consider this feature ready.
# spec/features/post_spec.rb describe 'editing' do before do user = FactoryGirl.create(:user) second_user = FactoryGirl.create(:second_user) login_as(user, :scope => :user) post = Post.create(title: "starter title", content: "starter content", topic_id: @topic.id, user_id: user.id) visit edit_topic_post_path(topic_id: @topic.id, id: post.id) end it 'allows a user to edit a post they created' do fill_in 'post[title]', with: "Baseball Stats" click_on "Save" expect(page).to have_content("Baseball Stats") end xit 'does not allow a user to edit a post they did not create' do end end
Let's walk through what we're doing here. In the before
block we're creating multiple users, we're doing this because we need to test to ensure that a user is only able to edit posts that they created, so we need multiple users in order to test this functionality. Then we're logging in one of the users.
After this we're creating a new Post
, we're going to use the create
method in this case because I want to control the data and have it right in front of me, in the future I can move this into a factory with the appropriate data relationships, but having a few create
calls in my test suite doesn't bother me. This follows the pattern I like when I'm building out features, where I integrate a working implementation in the easiest way possible and then refactor it afterwards. There's a practical reason for this. If I try to get too fancy with an implementation in the beginning and I run into a bug, I not only have to debug the feature, but I also need to debug the test suite, which is not a fun way to program, and coding should always be fun or we're doing it wrong :)
Lastly we're visiting the edit
path, we want this action nested under a topic since all posts will have a topic that they're associated with, so we can keep the nesting structure at this point.
Inside the spec itself I'm simply filling out the title
attribute with a new value, clicking save
and then checking to see if the new title
value shows up on the next page the user is redirected to.
Nothing too crazy with this feature, so let's get started by running rspec
and see where the tests lead us. This gives us the output we'd hope for AbstractController::ActionNotFound: The action 'edit' could not be found for Topics::PostsController
, this means that our route is working and we need to update the posts
controller by adding in an edit
method.
# app/controllers/topics/posts_controller.rb def edit end
Don't you like these small and easy steps? That's one of my favorite parts of using BDD to build applications. Let's also add in the template as well since we know that is going to be the next thing the test is going to ask for:
touch app/views/topics/posts/edit.html.erb
Running the specs now will tell us that it couldn't find the post[title] field. Let's copy the form from
newtemplate (I know, I know, what happened to being DRY?! Don't worry, we'll take care of the duplication in the **refactor** stage, let's get the spec passing first). We'll only need to make a few modifications to get this working for the
edit` action:
<!-- app/views/topics/posts/edit.html.erb --> <%= form_tag topic_post_path(topic_id: @topic.id, id: @post.id), method: 'put' do %> <%= text_field_tag "post[title]" %> <%= submit_tag 'Save' %> <% end %>
Some of the changes made here are:
- The route changed to the route dictated by the
update
action, you can find this by runningrake routes | grep posts
and looking at the linetopics/posts#update
, notice how it's listed under the methodtopic_post
, that shows use that we'll be using thetopic_post_path
route helper method and passing in thetrail
andpost
id values.
MacBook-Pro-3:dailysmarty admin$ rake routes | grep posts topic_posts GET /topics/:topic_id/posts(.:format) topics/posts#index edit_topic_post GET /topics/:topic_id/posts/:id/edit(.:format) topics/posts#edit topic_post GET /topics/:topic_id/posts/:id(.:format) topics/posts#show PATCH /topics/:topic_id/posts/:id(.:format) topics/posts#update PUT /topics/:topic_id/posts/:id(.:format) topics/posts#update DELETE /topics/:topic_id/posts/:id(.:format) topics/posts#destroy new_post GET /posts/new(.:format) topics/posts#new create_post POST /posts(.:format) topics/posts#create
Changed the
method
frompost
toput
, referencing therake routes
list again, notice how the HTTP verb for theupdate
action isput
andpatch
? That's what tells me that we'll need to use one of those methods (I typically useput
by default, but they're very similar).I removed the other form fields, we'll concern ourselves with those during the refactor step.
Running rspec
now is going to give us a cryptic error ActionView::Template::Error: undefined method
id' for nil:NilClass, so what's going on? Well, we need to let the
editmethod so that it knows what
post` is being edited, that makes sense:
# app/controllers/topics/posts_controller.rb def edit @post = @topic.posts.find(params[:id]) end
Running the specs now will show that we're heading down the right path, saying that it can't find an update
action in the controller. The update
action is needed since it is what will be altering the database record, so let's update the controller, we can also add in some code into the action since we know the method isn't going to do anything magical by itself:
# app/controllers/topics/posts_controller.rb def update post = @topic.posts.find(params[:id]) if post.update(post_params) redirect_to topic_post_path(topic_id: post.topic_id, id: post), notice: 'Your post was successfully updated.' else render :edit, notice: 'There was an error processing your request!' end end
Running rspec
will now show that the tests are passing, nice work! Users can now edit their posts. Let's test this in the browser, starting up the rails server you can navigate to any post show page, such as http://localhost:3000/topics/my-title-99/posts/1
, and even though we could simply append /edit
, let's do it right and add a button to the show view:
<!-- app/views/topics/posts/show.html.erb --> <h1><%= @post.title %></h1> <p><%= @post.topic.title %></p> <p><%= @post.user.full_name %></p> <%= link_to "Edit Post", edit_topic_post_path(topic_id: @topic.id, id: @post.id) %>
Now if you refresh the page you'll see the Edit Post
button. If you click on it you'll be taken to the edit
view page where you can edit the value, so nice work, the feature is working properly. Now let's refactor, starting with the controller, do you see all of the duplicate calls of post = @topic.posts.find(params[:id])
, right now the same code is in the show
, edit
, and update
method. Let's move this into its own method and add it to a before_action
method so these three methods will have access to the @post
value automatically. The controller should now look like this:
# app/controllers/topics/posts_controller.rb class Topics::PostsController < ApplicationController before_action :set_topic, except: [:new, :create] before_action :set_post, only: [:show, :edit, :update] def index @posts = @topic.posts end def new end 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 def show end def edit end def update @post = @topic.posts.find(params[:id]) if @post.update(post_params) redirect_to topic_post_path(topic_id: @post.topic_id, id: @post), notice: 'Your post was successfully updated.' else render :edit, notice: 'There was an error processing your request!' end end private def set_topic @topic = Topic.friendly.find(params[:topic_id]) end def set_post @post = @topic.posts.find(params[:id]) end def post_params params.require(:post).permit(:title, :content, :topic_id) end end
Let's run the tests again and they're all passing, so that refactor worked nicely and our controller looks much better now. The next refactor is a little tricky, mainly because we're using a form_tag
for posts. However form_tag
is pretty flexible, so let's create a new partial for the form that the edit
and new
templates can be share. We'll take it one at a time. First create a partial:
touch app/views/topics/posts/_form.html.erb
Now let's move the code from the posts/new.html.erb
file into the form partial and update the new
template to simply call the partial and let it know that it's coming from the new
action:
<!-- app/views/topics/posts/new.html.erb --> <%= render partial: 'form', locals: { source: 'new' } %>
Running the specs now will show that everything is still working properly, so that's good. However the behavior of the edit
action is a little more complex. Let's go through differences:
The form needs to know the
topic
andpost
that is being edited, so we can't use the same route helper.The form needs to pre-populate values that are already set, so the
title
,content
, andtopic
need to show the current values, right now they'd show up blank.
We can start out by calling the partial from the edit
template, updating the source
local variable:
<!-- app/views/topics/posts/edit.html.erb --> <%= render partial: 'form', locals: { source: 'edit' } %>
Now let's create a super ugly, super hideous implementation in the form
partial that will work for both the edit
and new
actions:
<!-- app/views/topics/posts/_form.html.erb --> <% if source == 'new' %> <%= 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 %> <% else %> <%= form_tag topic_post_path(topic_id: @topic.id, id: @post.id), method: 'put' do %> <%= text_field_tag "post[title]" %> <%= submit_tag 'Save' %> <% end %> <% end %>
Running this will still pass the tests, but... ugh, this is ugly. I put this in place to give us a base case, so we know everything up until this point is working, so now we can focus on the more complex refactor, which is to merge both forms into one, let's do that now:
<!-- app/views/topics/posts/_form.html.erb --> <% if source == 'new' %> <% form_route = create_post_path %> <% form_action = 'post' %> <% else %> <% form_route = topic_post_path(topic_id: @topic.id, id: @post.id) %> <% form_action = 'put' %> <% end %> <%= form_tag form_route, method: form_action do %> <%= text_field_tag "post[title]", (@post.title if @post) || "" %> <%= text_field_tag "post[content]", (@post.content if @post) || "" %> <%= collection_select(:post, :topic_id, Topic.all, :id, :title, { selected: (params[:topic_id] if params[:topic_id])}) %> <%= submit_tag 'Save' %> <% end %>
This works and we can test it by running rspec
and by testing it in the browser for both the new
and edit
features, however we can still clean it up by implementing some view helpers, let's add the following methods to the helper file to get the route and the method:
# app/helpers/posts_helper.rb module PostsHelper def form_route(source) return create_post_path if source == 'new' return topic_post_path(topic_id: @topic.id, id: @post.id) if source == 'edit' end def form_action(source) return 'post' if source == 'new' return 'put' if source == 'edit' end end
Each of these methods take in a single argument of source
and return different values based on if it's coming from the edit
or new
pages. Now we can update the form and simply call these methods, sending in the source
variable:
<!-- app/views/topics/posts/_form.html.erb --> <%= form_tag form_route(source), method: form_action(source) do %> <%= text_field_tag "post[title]", (@post.title if @post) || "" %> <%= text_field_tag "post[content]", (@post.content if @post) || "" %> <%= collection_select(:post, :topic_id, Topic.all, :id, :title, { selected: (params[:topic_id] if params[:topic_id])}) %> <%= submit_tag 'Save' %> <% end %>
Running the specs and everything is still working. If you think that this code still feels a little dirty your code senses are right, later on in the course we will take our refactor even further and implement a meta programming module to clean this up even more. But for right now this is working, even with pretty advanced behavior, using the same form for a nested and non-nested resource is not a trivial feature, however it gives our application much more flexibility, so very nice work.
In the next lesson we'll implement the next expectation that will ensure that only post creators can edit their posts.