Chapter 11. Tagging

This chapter covers

  • Tagging specific records for easier searching
  • Restricting user access to tagging functionality
  • Searching for specific tags or specific states of a ticket

In chapter 10, you saw how to give your tickets states (New, Open, and Closed) so that their progress can be indicated.

In this chapter, you’ll see how to give your tickets tags. Tags are useful for grouping similar tickets together into things such as iterations[1] or similar feature sets. If you didn’t have tags, you could crudely group tickets together by setting a ticket’s title to something such as “Tag - [name].” This method, however, is messy and difficult to sort through. Having a group of tickets with the same tag will make them much, much easier to find.

1 For example, by using a process such as Agile, feature sets, or any other method of grouping.

To manage tags, you’ll set up a Tag model, which will have a has_and_belongs_to_many association to the Ticket model. You’ll set up a join table for this association, which is a table that contains foreign key fields for each side of the association. A join table’s sole purpose is to join together the two tables whose keys it has. In this case, the two tables are the tickets and tags tables. As you move forward in developing this association, note that, for all intents and purposes, has_and_belongs_to_many works like a two-way has_many.

You’ll create two ways to add tags to a ticket. A text field for new tickets beneath the ticket’s description field will allow users to add multiple tags by using a space to separate different tags, as shown in figure 11.1.

Figure 11.1. The tag box

Additional tags may also be added on a comment, with a text field similar to the one from the new ticket page providing the tagging mechanism. When a ticket is created, you’ll show these tags underneath the description, as shown in figure 11.2.

Figure 11.2. A tag for a ticket

When a user clicks a tag, they’ll be taken to a page where they can see all tickets with that particular tag. Alternatively, if the user clicks the little “x” next to the tag, that tag will be removed from the ticket. The actions of adding and removing a tag are both actions you’ll add to your permission checking.

Finally, you’ll implement a way to search for tickets that match a state, a tag, or both, by using a gem called searcher. The query will look like tag:iteration_1 state: open.

That’s all there is to this chapter! You’ll be adding tags to Ticketee, which will allow you to easily group and sort tickets. Let’s dig into your first feature, adding tags to a new ticket.

11.1. Creating tags

Tags in this application will be extremely useful for making similar tickets easy to find and manage. In this section, you’ll create the interface for adding tags to a new ticket by adding a new field to the new ticket page and defining a has_and_belongs_to_many association between the Ticket model and the not-yet-existent Tag model.

11.1.1. Creating tags feature

You’re going to add a text field beneath the description field on the new ticket page for this feature, as you saw earlier in figure 11.1.

The words you enter into this field will become the tags for this ticket, and you should see them on the ticket page. At the bottom of features/creating_tickets.feature, you add a scenario that creates a new ticket with tags, as shown in listing 11.1.

Listing 11.1. features/creating_tickets.feature
Scenario: Creating a ticket with tags
  When I fill in "Title" with "Non-standards compliance"
  And I fill in "Description" with "My pages are ugly!"
  And I fill in "Tags" with "browser visual"
  And I press "Create Ticket"
  Then I should see "Ticket has been created."
  And I should see "browser" within "#ticket #tags"
  And I should see "visual" within "#ticket #tags"

When you run the “Creating a ticket with tags” scenario using bin/cucumber features/creating_tickets.feature:50 it will fail, declaring that it can’t find the Tags field. Good! It’s not there yet:

And I fill in "Tags" with "browser visual"
  cannot fill in, no text field, text area or password field
    with id, name, or label 'Tags' found (Capybara::ElementNotFound)

You’re going to take the data from this field, process each word into a new Tag object, and then link the tags to the ticket when it’s created. You’ll use a text_field_tag to render the Tags field this way. text_field_tag is similar to a text_field tag, but it doesn’t have to relate to any specific object like text_field does. Instead, it will output an input tag with the type attribute set to text and the name set to whatever name you give it.

11.1.2. Using text_field_tag

To define this field, you put the following code underneath the p tag for the description in app/views/tickets/_form.html.erb:

<p>
  <%= label_tag :tags %>
  <%= text_field_tag :tags, params[:tags] %>
</p>

This field will be sent through to TicketsController as params[:tags], rather than the kind of attributes you’re used to, such as params[:ticket][:title].

By specifying params[:tags] as the second argument to text_field_tag, you can re-populate this field when the ticket cannot be created due to it failing validation.

When you re-run this scenario again with bin/cucumber features/creating_tickets.feature:51, it no longer complains about the missing Tags field, telling you instead that it can’t find the tags displayed on your ticket:

And I should see "browser" within "#ticket #tags"
  Unable to find css "#ticket #tags" (Capybara::ElementNotFound)

You now need to define this #tags element inside the #ticket element on the ticket’s page so that this part of the scenario will pass. This element will contain the tags for your ticket, which your scenario will assert are actually visible.

11.1.3. Showing tags

You can add this new element, with its id attribute set to tags, to app/views/tickets/show.html.erb by adding this simple line underneath where you render the ticket’s description:

<div id='tags'><%= render @ticket.tags %></div>

This creates the #ticket #tags element that your feature is looking for, and will render the soon-to-be-created app/views/tags/_tag.html.erb partial for every element in the also-soon-to-be-created tags association on the @ticket object. Which of these two steps do you take next? If you run your scenario again, you see that it cannot find the tags method for a Ticket object:

undefined method `tags' for #<Ticket:0x0..

This method is the tags method, which you’ll be defining with a has_and_belongs_to_many association between Ticket objects and Tag objects. This method will be responsible for returning a collection of all the tags associated with the given ticket, much like a has_many would. The difference is that this method works in the opposite direction as well, allowing you to find out what tickets have a specific tag.

11.1.4. Defining the tags association

You can define the has_and_belongs_to_many association on the Ticket model by placing this line after the has_many definitions inside your Ticket model:

has_and_belongs_to_many :tags

This association will rely on a join table that doesn’t yet exist called tags_tickets. The name is the combination, in alphabetical order, of the two tables you want to join. This table contains only two fields—one called ticket_id and one called tag_id—which are both foreign keys for tags and tickets. The join table will easily facilitate the union of these two tables, because it will have one record for each tag that links to a ticket, and vice versa.

When you re-run your scenario, you’re told that there’s no constant called Tag yet:

uninitialized constant Ticket::Tag (ActionView::Template::Error)

In other words, there is no Tag model yet. You should define this now if you want to go any further.

11.1.5. The Tag model

Your Tag model will have a single field called name, which should be unique. To generate this model and its related migration, run the rails command like this:

rails g model tag name:string --timestamps false

The timestamps option passed here determines whether or not the model’s migration is generated with timestamps. Because you’ve passed the value of false to this option, there will be no timestamps added.

Before you run this migration, however, you need to add the join table called tags_tickets to your database. The join table has two fields: one called ticket_id and the other tag_id. The table name is the pluralized names of the two models it is joining, sorted in alphabetical order. This table will have no primary key, because you’re never going to look for individual records from this table and only need it to join the tags and tickets tables.

To define the tags_tickets table, put this code in the change section of your db/migrate/[timestamp]_create_tags.rb migration:

create_table :tags_tickets, :id => false do |t|
  t.integer :tag_id, :ticket_id
end

The :id => false option passed to create_table here tells Active Record to create the table without the id field, because the join table only cares about the link between tickets and tags, and therefore does not need a unique identifier.

Next, run the migration on your development database by running rake db:migrate, and on your test database by running rake db:test:prepare. This will create the tags and tags_tickets tables.

When you run this scenario again with bin/cucumber features/creating_tickets:48, it is now satisfied that the tags method is defined and moves on to complaining that it can’t find the tag you specified:

And I should see "browser" within "#ticket #tags"
  Failed assertion, no message given. (MiniTest::Assertion)

This failure is because you’re not doing anything to associate the text from the Tags field to the ticket you’ve created. You need to parse the content from this field into new Tag objects and then associate them with the ticket you are creating, which you’ll do right now.

11.1.6. Displaying a ticket’s tags

The params[:tags] in TicketsController’s create is the value from your Tags field on app/views/tickets/_form.html.erb. This is also the field you need to parse into Tag objects and associate those tags with the Ticket object you are creating.

To do this, alter the create action in TicketsController by adding this line directly after @ticket.save:

@ticket.tag!(params[:tags])

This new tag! method will parse the tags from params[:tags], convert them into new Tag objects, and associate them with the ticket. You can define this new method at the bottom of your Ticket model like this:

def tag!(tags)
  tags = tags.split(" ").map do |tag|
    Tag.find_or_create_by_name(tag)
  end

  self.tags << tags
end

On the first line here, use the split method to split your string into an array, and then use the map method to iterate through every value in the array.

Inside the block for map, use a dynamic finder to find or create a tag with a specified name. You last saw a dynamic finder in chapter 10, where you found a State record by using find_by_default. find_or_create_by methods work in a similar fashion, except they will always return a record, whether it be a pre-existing one or a recently created one.

After all the tags have been iterated through, you assign them to a ticket by using the << method on the tags association.

The tag! method you have just written will create the tags that you display on the app/views/tickets/show.html.erb view by using the render method you used earlier:

<%= render @ticket.tags %>

When you run this scenario again by running bin/cucumber features/creating_tickets.feature:51, you see this line is failing with an error:

Missing partial tags/tag ...

The next step is to write the tag partial that your feature has complained about. Put the following code in a new file called app/views/tags/_tag.html.erb:

<span class='tag'><%= tag.name %></span>

By wrapping the tag name in a span with the class of tag, it will be styled as defined in your stylesheet. With this partial defined, the final piece of the puzzle for this feature is put into place. When you run your scenario again by running bin/cucumber features/creating_tickets.feature:51, it passes:

1 scenario (1 passed)
15 steps (15 passed)

Great! This scenario is now complete. When a user creates a ticket, they are able to assign tags to that ticket, and those tags will display along with the ticket’s information on the show action for TicketsController. The tag display was shown earlier in figure 11.2.

You now commit this change, but before you do you ensure that you haven’t broken anything by running rake cucumber:ok spec:

54 scenarios (53 passed)
583 steps (583 passed)
# and
36 examples, 0 failures, 18 pending

Good to see that nothing’s blown up this time. Let’s commit this change:

git add .
git commit -m "Users can tag tickets upon creation"
git push

Now that users can add a tag to a ticket when that ticket is being created, you should also let them add tags to a ticket when they create a comment. When a ticket is being discussed, new information may come that would require another tag to be added to the ticket and group it into a different set. A perfect way to let your users do this would be to let them add the tag when they comment.

11.2. Adding more tags

The tags for a ticket can change throughout the ticket’s life; new tags can be added and old ones can be deleted. Let’s look at how you can add more tags to a ticket after it’s been created through the comments form. Underneath the comment form on a ticket’s page, add the same Tags field that you previously used to add tags to your ticket on the new ticket page. One thing you have to keep in mind here is that if someone enters a tag that’s already been entered, you don’t want it to show up.

You’ve got two scenarios to implement then: the first is a vanilla addition of tags to a ticket through a comment, and the second is a scenario ensuring that duplicate tags do not appear. Let’s implement this function one scenario at a time. When you’re done, you’ll end up with the pretty picture shown in figure 11.3.

Figure 11.3. Comment form with tags

11.2.1. Adding tags through a comment

To test that users can add tags when they’re creating a comment, you add a new scenario to the features/creating_comments.feature feature that looks like this listing:

Listing 11.2. features/creating_comments.feature
Scenario: Adding a tag to a ticket
  When I follow "Change a ticket's state"
  Then I should not see "bug" within "#ticket #tags"
  And I fill in "Text" with "Adding the bug tag"
  And I fill in "Tags" with "bug"
  And I press "Create Comment"
  Then I should see "Comment has been created"
  Then I should see "bug" within "#ticket #tags"

First, you make sure you don’t see this tag within #ticket #tags, to ensure you don’t have a false positive. Next, you fill in the text for the comment so it’s valid, add the word “bug” to the Tags field, and click the Create Comment button. Finally, you ensure that the comment has been created and that the bug tag you entered into the comment form now appears in #ticket #tags.

When you run this scenario using bin/cucumber features/creating_comments.feature:47, it will fail because there is no Tags field on the ticket’s page yet:

cannot fill in, no text field, text area or password
  field with id, name, or label 'Tags' found

You can fix this by taking these lines from app/views/tickets/_form.html.erb and moving them into a new partial at app/views/tags/_form.html.erb:

<p>
  <%= label_tag :tags %>
  <%= text_field_tag :tags, params[:tags] %>
</p>

Replace the code you removed from app/views/tickets/_form.html.erb with this line:

<%= render "tags/form" %>

This new line will render your new tags partial. In order to make the failing step in your scenario now pass, you re-use this same line inside the authorized? block inside app/views/comments/_form.html.erb underneath the code you use to render the State select box.

When you run bin/cucumber features/creating_comments.feature:47, you see that this step is indeed passing, but now it’s the final step of your scenario that is failing:

Then I should see "bug" within "#ticket #tags"
  expected there to be content "bug" in ""

This feature is not seeing the word “bug” within the content for the ticket’s tags (which is empty), and so the scenario fails. This is because the code to associate a tag with a ticket isn’t in the create action of CommentsController yet. In the create action in TicketsController, you use this line to tag the ticket that was created:

@ticket.tag!(params[:tags])

You can use this same method to tag a comment’s ticket. On the line immediately after if @comment.save in the create action inside CommentsController, you re-use the tag! line. That’s all you need to get this scenario to pass, right? Run bin/cucumber features/creating_comments.feature:47 to find out:

1 scenario (1 passed)
15 steps (15 passed)

Boom, that’s passing! Good stuff. Now for the cleanup. Make sure you haven’t broken anything else by running rake cucumber:ok spec:

55 scenarios (55 passed)
598 steps (598 passed)
# and
27 examples, 1 failure, 10 pending

It would seem your spec/controllers/comments_controller_spec.rb doesn’t want to play nice.

11.2.2. Fixing the CommentsController spec

The CommentsController spec is failing with this error:

You have a nil object when you didn't expect it!
You might have expected an instance of Array.
The error occurred while evaluating nil.split
# ./app/models/ticket.rb:16:in `tag!'
# ./app/controllers/comments_controller.rb:12:in `create'

Ah, it seems to be from within the tag! method from the Ticket model. The sixteenth line of this model is

tags = tags.split(" ").map do |tag|

It’s the calling of tags.split that is making the spec fail, but why? tags comes from the argument passed to this method from the CommentsController’s create action by this line:

@ticket.tag!(params[:tags])

You’d get this error if params[:tags] was ever nil, because you cannot call split on nil. Why is it nil in your controller spec, though? It’s because you’re not sending it through with the other parameters in your spec:

post :create, { :comment => { :text => "Hacked!",
                             :state_id => state.id },
                :ticket_id => ticket.id }

You can solve this problem in one of two ways. The first way is to check that params[:tags] is not nil before calling the tag! method by adding code to the CommentsController, or better still, by adding code to the tag! method in the Ticket model. The second way is to make the controller spec accurately reflect reality.

Because the Tags field is always going to be on the comments page, its value will be set to an empty string if it is left as-is. The second fix is therefore better, because it fixes the problem rather than compensating for an issue that will only happen in your tests. You can change the post method in the test in spec/controllers/comments_controller_spec.rb to this:

post :create, { :tags => "",
               :comment => { :text => "Hacked!",
                             :state_id => state.id },
               :ticket_id => ticket.id }

Due to this small change, all your specs will be passing when you run rake spec again:

27 examples, 0 failures, 10 pending

With all the specs and features passing, it’s commit time! In this section, you’ve created a way for your users to add more tags to a ticket when they add a comment, which allows your users to easily organize tickets into relevant groups. Let’s commit this change now:

git add .
git commit -m "Users can add tags when adding a comment"
git push

With the ability to add tags when creating a ticket or a comment now available, you need to restrict this power to users with permission to manage tags. You don’t want all users to create tags willy-nilly, because it’s likely you would end up with an overabundance of tags.[2] Too many tags makes it hard to identify which tags are useful and which are not. People with permission to tag things will know that with great power, comes great responsibility.

2 Such as the tags on the Rails Lighthouse account, at lower right of this page: https://rails.lighthouseapp.com/projects/8994-ruby-on-rails/overview.

11.3. Tag restriction

Using the permissions system you built in chapter 8, you can easily add another type of permission: one for tagging. If a user has this permission, they will be able to add and (later on) remove tags.

11.3.1. Testing tag restriction

When a user without permission attempts to submit a ticket or comment, the application should not tag the ticket with the tags they have specified. You’ll add this restriction to the CommentsController, but first you’ll write a controller spec to cover this behavior. In spec/controllers/comments_controller_spec.rb, put this spec underneath the one you just fixed:

it "cannot tag a ticket without permission" do
  post :create, { :tags => "one two", :comment => { :text => "Tag!" },
                  :ticket_id => ticket.id }
  ticket.reload
  ticket.tags.should be_empty
end

You can then run this spec using bin/rspec spec/controllers/comments_controller_spec.rb and see that it fails because the tags are still set:

Failure/Error: ticket.tags.should be_empty
 expected empty? to return true, got false

Good! A failing test is a good start to a new feature. To make this test pass, you should use the can? method in CommentsController to check the user’s permission. You now change this line

@ticket.tag!(params[:tags])

to these lines:

if can?(:tag, @ticket.project) || current_user.admin?
  @ticket.tag!(params[:tags])
end

Because the user that is set up in spec/controllers/comments_controller_spec.rb doesn’t have permission to tag, when you re-run your spec it will now pass:

2 examples, 0 failures

Good! You have something in place to block users from tagging tickets when they create a comment. Now you’re only missing the blocking code for tagging a ticket when it is being created. You can create a spec test for this too, this time in spec/controllers/tickets_controller_spec.rb. Underneath the “Cannot delete a ticket without permission” example, add this example:

it "can create tickets, but not tag them" do
  Permission.create(:user => user, :thing => project,
   :action => "create tickets")
  post :create, :ticket => { :title => "New ticket!",
                             :description => "Brand spankin' new" },
                :project_id => project.id,
                :tags => "these are tags"
  Ticket.last.tags.should be_empty
end

You can run this spec by running bin/rspec spec/controllers/tickets_controller_spec.rb:59, and you’ll see that it fails:

Failure/Error: Ticket.last.tags.should be_empty
  expected empty? to return true, got false

Because there is no restriction on tagging a ticket through the create action, there are tags for the ticket that was just created, and so your example fails. For your TicketsController’s create action, you can do exactly what you did in the CommentsController’s create action and change the line that calls tag! to this:

if can?(:tag, @project) || current_user.admin?
  @ticket.tag!(params[:tags])
end

When you re-run your spec it will pass:

1 example, 0 failures

Great, now you’re protecting both the ways a ticket can be tagged. Because of this new restriction, the two scenarios that you created earlier to test this behavior will be broken.

11.3.2. Tags are allowed, for some

When you run rake cucumber:ok you see them listed as the only two failures:

Failing Scenarios:
cucumber features/creating_comments.feature:45
cucumber features/creating_tickets.feature:48

To fix these two failing scenarios, you use a new step, which you first put in the “Creating comments” feature underneath this line in the Background for this feature

And "[email protected]" can view the "Ticketee" project

Put this line:

And "[email protected]" can tag the "Ticketee" project

With your all-powerful step defined in features/step_definitions/permission_steps.rb, you don’t have to define a definition for this step to work. When you re-run this scenario using bin/cucumber features/creating_comments.feature:48, it will pass:

1 scenario (1 passed)
16 steps (16 passed)

One scenario down, one to go! The next one is the features/creating_tickets.feature:51 scenario. At the top of the feature, you can put the same line you used in the “Creating comments” feature, right under the view permission. Don’t forget to rename the project:

And "[email protected]" can tag the "Internet Explorer" project

This scenario too will pass:

1 scenario (1 passed)
16 steps (16 passed)

Great! Only certain users can now tag tickets. Let’s make sure that everything is still running at 100% by running rake cucumber:ok spec. You should see this:

55 scenarios (55 passed)
608 steps (608 passed)
# and
38 examples, 0 failures, 18 pending

In this section, you have restricted the ability to add tags to a ticket—whether through the new ticket or comment forms—to only users who have the permission to tag. You’ve done this to restrict the flow of tags. Generally speaking, the people with the ability to tag should know only to create useful tags, so that the usefulness of the tags is not diluted. In the next section, you’ll use this same permission to determine what users are able to remove a tag from a ticket.

11.4. Deleting a tag

Removing a tag from a ticket is a helpful feature, because a tag may become irrelevant over time. Say that you’ve tagged a ticket as v0.1 for your project, but the feature isn’t yet complete and needs to be moved to v0.2. Without this feature, there will be no way to delete the old tag. Then what? Was this ticket for v0.1 or v0.2? Who knows? With the ability to delete a tag, you have some assurance that people will clean up tags if they’re able to.

To let users delete a tag, add an X to the left of each of your tags, as shown in figure 11.4.

Figure 11.4. X marks the spot

When this X is clicked, the tag will disappear through the magic of JavaScript. Rather than making a whole request out to the action for deleting a tag and then redirecting back to the ticket page, remove the tag’s element from the page and make an asynchronous behind-the-scenes request to the action.

11.4.1. Testing tag deletion

To click this link using Cucumber, you give the link around the X an id so you can easily locate it in your feature, which you’ll now write. Let’s create a new file at features/deleting_tags.feature and put in it the code from the following listing.

Listing 11.3. features/deleting_tags.feature
Feature: Deleting tags
  In order to remove old tags
  As a user
  I want to click a button and make them go away

  Background:
    Given there are the following users:
      | email             |  password |
      | [email protected] | password  |
    And I am signed in as them
    And there is a project called "Ticketee"
    And "[email protected]" can view the "Ticketee" project
    And "[email protected]" can tag the "Ticketee" project
    And "[email protected]" has created a ticket for this project:
      | title | description       | tags              |
      | A tag | Tagging a ticket! | this-tag-must-die |
    Given I am on the homepage
    When I follow "Ticketee" within "#projects"
    And I follow "A tag"

  @javascript
  Scenario: Deleting a tag
    When I follow "delete-this-tag-must-die"
    Then I should not see "this-tag-must-die"

In this scenario, it’s important to note that you’re passing through the tags field as a field in the “created a ticket” step, just like the other fields. The tags field isn’t in the tickets table. You’ll get to that in a second.

In this feature, you create a new user and sign in as them. Then you create a new project called Ticketee and give the user the ability to view and tag the project. You create a ticket by the user and tag it with a tag called this_tag_must_die. Finally, you navigate to the page of the ticket you’ve created.

In the scenario, you follow the delete-this-tag-must-die link, which will be the id on the link to delete this tag. When this link has been followed, you shouldn’t see this_tag_must_die, meaning that the action to remove the tag from the ticket has worked its magic.

When you run this feature using bin/features/deleting_tags.feature, you get this error:

undefined method `each' for "this_tag_must_die":String (NoMethodError)
./features/step_definitions/ticket_steps.rb:10 ...

This error is coming from ticket_steps.rb, line 10. Lines 8–11 of this file look like this:

table.hashes.each do |attributes|
  attributes.merge!(:user => User.find_by_email!(email))
  @project.tickets.create!(attributes)
end

The error is happening because the tags key in the ticket hash wants to pretend it’s a field like the other keys. In this case, it’s assuming that you’re assigning a collection of tag objects to this new ticket, and is therefore trying to iterate over each of them so that it can generate the join between this ticket and those tags. Because you’re passing it a string, it’s not going to work!

You should extract this column out of this hash and use the tag! method to assign the tags, rather than attempting to create them through create!. You can modify these four lines now to look like this:

table.hashes.each do |attributes|
  tags = attributes.delete("tags")
  attributes.merge!(:user => User.find_by_email!(email))
  ticket = @project.tickets.create!(attributes)
  ticket.tag!(tags) if tags
end

On the first line of your iteration, you use the same delete method that you’ve used a couple of times previously. This method removes the key from a hash and returns the value of that key, which you assign to the tags variable. On the final line of the iteration, call the familiar tag! method and pass in tags, thereby tagging your ticket with the passed-in tags. You use if tags because otherwise it would attempt to pass in a nil object, resulting in the nil.split error you saw earlier.

When you re-run your feature using bin/cucumber features/deleting_tags.feature, it gets to the guts of your scenario and tells you that it can’t find the delete link you’re looking for:

When I follow "delete-this-tag-must-die"
  no link with title, id or text 'delete-this_tag_must_die'

Alright, time to implement this bad boy.

11.4.2. Adding a link to delete the tag

You need a link with the id of delete-this-tag-must-die, which is the word delete, followed by a hyphen and then the parameterize’d version of the tag’s name. This link needs to trigger an asynchronous request to an action that would remove a tag from a ticket. The perfect name for an action like this, if you were to put it in the TicketsController, would be remove_tag. But because it’s acting on a tag, a better place for this action would be inside a new controller called TagsController.

Before you go and define this action, let’s define the link that your scenario is looking for first. This link goes into the tag partial at app/views/tags/_tag.html.erb inside the span tag:

<% if can?(:tag, @ticket.project) || current_user.admin? %>
  <%= link_to "x",
    :remote => true,
    :url => remove_ticket_tag_path(@ticket, tag),
    :method => :delete,
    :html => { :id => "delete-#{tag.name.parameterize}" } %>
<% end %>
<%= tag.name %>

Here, you check that a user can tag in the ticket’s project. If they can’t tag, then you won’t show the X to remove the tag. This is to prevent everyone from removing tags as they feel like it. Remember? With great power comes great responsibility.

You use the :remote option for the link_to, to indicate to Rails that you want this link to be an asynchronous request. This is similar to the Add Another File button you provided in chapter 9, except this time you don’t need to call out to any JavaScript to determine anything, you only need to make a request to a specific URL.

For the :url option here, you pass through the @ticket object to remove_ticket_tag_path so that your action knows what ticket to delete the tag from. Remember: your primary concern right now is disassociating a tag and a ticket, not completely deleting the tag.

Because this is a destructive action, you use the :delete method. You’ve used this previously for calling destroy actions, but the :delete method is not exclusive to the destroy action, and so you can use it here as well.

The final option, :html, lets you define HTML attributes for the link. Inside this hash, you set the id key to be the word delete, followed by a hyphen and then the name of your tag parameterize’d. For the tag in your scenario, this is the id that you’ll use to click this link. Capybara supports following links by their internal text, the name attribute, or the id attribute.

When you run your feature with bundle exec cucumber features/deleting_tags.feature, you see that it reports the same error message at the bottom:

When I follow "delete-this-tag-must-die"
 no link with title, id or text 'delete-this-tag-must-die'

Ah! A quick eye would have spotted an error when the browser launched by WebDriver tried going to this page; it looks like figure 11.5.

Figure 11.5. Internal Server Error

This error is coming up because you haven’t defined the route to the remove action yet. You can define this route in config/routes.rb inside the resources :tickets block, morphing it into this:

resources :tickets do
  resources :comments
  resources :tags do
    member do
      delete :remove
    end
  end
end

By nesting the tags resource inside the ticket’s resource, you are given routing helpers such as ticket_tag_path. With the member block inside the resources :tags, you can define further actions that this nested resource responds to. You’ll define that you should accept a DELETE request to a route to a remove action for this resource, which you should now create.

Before you add this action to the TagsController, you must first generate this controller by using

rails g controller tags

Now that you have a controller to define your action in, let’s open app/controllers/tags_controller.rb and define the remove action in it like this:

In this action, you find the ticket based on the id passed through as params[:ticket], and then you do something new. On the left side of -= you have @ticket.tags. On the right, is an array containing @tag. This combination will remove the tag from the ticket, but will not delete it from the database.

On the second-to-last line of this action, you save the ticket minus one tag. On the final line you tell it to return nothing, which will return a 200 OK status to your browser, signaling that everything went according to plan.

When you re-run your scenario it will now successfully click the link, but the tag is still there:

When I follow "delete-this-tag-must-die"
Then I should not see "this-tag-must-die"
  Failed assertion, no message given. (MiniTest::Assertion)

Your tag is unassociated from the ticket but not removed from the page, and so your feature is still failing. The request is made to delete the ticket, but there’s no code currently that removes the tag from the page. There are two problems you must overcome to make this work. The first is that there’s no code. That part’s easy, and you’ll get there pretty soon. The second is that there’s no unique identifier for the element rendered by app/views/tags/_tag.html.erb, which makes removing it from the page exceptionally difficult with JavaScript. Let’s add a unique identifier now and remove the element.

11.4.3. Actually removing a tag

You’re removing a tag’s association from a ticket, but you’re not yet showing people that it has happened on the page. You can fix the second of your aforementioned problems by changing the span tag at the top of this partial to be this:

<span class='tag' id='tag-<%= tag.name.parameterize %>'>

This will give the element a unique identifier, which you can use to locate the element and then remove it using JavaScript. Currently for the remove action, you’re rendering nothing. Let’s now remove the render :nothing => true line from this action, because you’re going to get it to render a template.

If a request is made asynchronously, the format for that request will be js, rather than the standard html. For views, you’ve always used the html.erb extension, because HTML is all you’ve been serving. As of now, this changes. You’re going to be rendering a js.erb template, which will contain JavaScript code to remove your element. Let’s create the view for the remove action in a file called app/views/tags/remove.js.erb, and fill it with this content:

$('#tag-<%= @tag.name.parameterize %>').remove();

This code will be run when the request to the remove action is complete. It uses the jQuery library’s $ function to locate an element with the id attribute of tag-this-tag-must-die and then calls remove()[3] on it, which will remove this tag from the page.

3http://api.jquery.com/remove/.

When you run your feature using bin/cucumber features/deleting_tags.feature, you see that it now passes:

1 scenario (1 passed)

11 steps (11 passed)

Awesome! With this feature done, users with permission to tag on a project will now be able to remove tags too. Before you commit this feature, let’s run rake cucumber :ok spec to make sure everything is ok:

56 scenarios (55 passed)
619 steps (619 passed)
# and
39 examples, 0 failures, 19 pending

That’s awesome too! Commit and push this:

git add .
git commit -m "Added remove tag functionality"
git push

Now that you can add and remove tags, what is there left to do? Find them! By implementing a way to find tickets with a given tag, you make it easier for users to see only the tickets they want to see. As an added bonus, you’ll also implement a way for the users to find tickets for a given state, perhaps even at the same time as finding a tag.

When you’re done with this next feature, you’ll add some more functionality that will let users go to tickets for a tag by clicking the tag name inside the ticket show page.

11.5. Finding tags

At the beginning of this chapter, we planned on covering searching for tickets using a query such as tag:iteration_1 state: open. This magical method would return all the tickets in association with the iteration_1 tag that were marked as open. This helps users scope down the list of tickets that appear on a project page to be able to better focus on them.

There’s a gem developed specifically for this purpose called Searcher[4] that you can use. This provides you with a search method on specific classes, which accepts a query like the one mentioned and returns the records that match it.

4 This gem is good for a lo-fi solution but shouldn’t be used in a high search-volume environment. For that, look into full text search support for your favorite database system.

11.5.1. Testing search

As usual, you should (and will) test that searching for tickets with a given tag works, which you can do by writing a new feature called features/searching.feature and filling it with the content from the following listing.

Listing 11.4. features/searching.feature
Feature: Searching
  In order to find specific tickets
  As a user
  I want to enter a search query and get results

  Background:
    Given there are the following users:
      | email             | password |
      | [email protected] | password |
    And I am signed in as them
    And there is a project called "Ticketee"
    And "[email protected]" can view the "Ticketee" project
    And "[email protected]" can tag the "Ticketee" project
    And "[email protected]" has created a ticket for this project:
      | title | description     | tags        |
      | Tag!  | Hey! You're it! | iteration_1 |
    And "[email protected]" has created a ticket for this project:
      | title   | description      | tags        |
      | Tagged! | Hey! I'm it now! | iteration_2 |
    Given I am on the homepage
  And I follow "Ticketee" within "#projects"

Scenario: Finding by tag
  When I fill in "Search" with "tag:iteration_1"
  And I press "Search"
  Then I should see "Tag!"
  And I should not see "Tagged!"

In the Background for this feature, you create two tickets and give them two separate tags: iteration_1 and iteration_2. When you look for tickets tagged with iteration_1, you shouldn’t see tickets that don’t have this tag, such as the one that is only tagged iteration_2.

Run this feature using bin/cucumber features/searching.feature, and it’ll complain because there’s no Search field on the page:

When I fill in "Search" with "tag:iteration_1"
  cannot fill in ... 'Search'

In your feature, the last thing you do before attempting to fill in this Search field is go to the project page for Ticketee. This means that the Search field should be on that page so that your feature and, more important, your users, can fill it out. You add the field above the ul element for the tickets list, inside app/views/projects/show.html.erb:

<%= form_tag search_project_tickets_path(@project),
             :method => :get do %>
  <%= label_tag "search" %>
  <%= text_field_tag "search", params[:search] %>
  <%= submit_tag "Search" %>
<% end %>

You’ve only used form_tag once, back in chapter 8. This method generates a form that’s not tied to any particular object, but still gives you the same style of form wrapper that form_for does. Inside the form_tag, you use the label_tag and text_field_tag helpers to define a label and input field for the search terms, and use submit_tag for a submit button for this form.

The search_project_tickets_path method is undefined at the moment, which you see when you run bundle exec cucumber features/searching.feature:

undefined local variable or method `search_project_tickets_path' ...

Notice the pluralized tickets in this method. To define non-standard RESTful actions, you’ve previously used the member method inside of config/routes.rb. This has worked fine because you’ve always acted on a single resource. This time, however, you want to act on a collection of a resource. This means that you use the collection method in config/routes.rb instead. To define this method, change these lines in config/routes.rb

resources :projects do
  resources :tickets
end

into these:

resources :projects do
  resources :tickets do
    collection do
      get :search
    end
  end
end

The collection block here defines that there’s a search action that may act on a collection of tickets. This search action will receive the parameters passed through from the form_tag you have set up. When you run your feature again by using bin/cucumber features/searching.feature, you see that it’s reporting that the search action is missing:

And I press "Search"
  The action 'search' could not be found for TicketsController

Good! The job of this action is to find all the tickets that match the criteria passed in from the form as params[:search], which is what you can use the Searcher gem for.

11.5.2. Searching by state with Searcher

The Searcher gem provides the functionality of parsing the labels in a query such as tag:iteration_1 and determines how to go about finding the records that match the query. Rather than working like Google, where you could put in iteration_1 and it would know, you have to tell it what iteration_1 means by prefixing it with tag:. You use this query with the search method provided by Searcher on a configured model, and it will return only the records that match it:

Ticket.search("tag:iteration_1")

You’ll use this method in the search action for TicketsController in a bit.

The first port of call to begin to use the Searcher gem is to add it to your Gemfile underneath gem 'paperclip':

gem 'searcher'

To install this gem, you run bundle install. Now for the configuration. Searcher is configured by a searcher call in a class, just as associations are set up by using has_many and friends. In app/models/ticket.rb directly above[5] the first belongs_to, put this code:

5 Code from gems or plugins should go above any code for your models, because it may modify the behavior of the code that follows it.

searcher do
  label :tag, :from => :tags, :field => :name
end

The :from option tells Searcher what association this label should be searched upon, and the :field option tells it what field to perform a lookup on.

The label method is evaluated internally to Searcher and will result in a by_tag method being defined on your Ticket model, which will be used by the search method if you pass in a query such as tag:iteration_1. This method will perform an SQL join on your tags table, returning only the tickets that are related to a tag with the given name.

With this configuration now in your model, you can define the search action directly underneath the destroy action in TicketsController to use the search method on Ticket:

def search
  @tickets = @project.tickets.search(params[:search])
end

Assign all the tickets retrieved with the search method to the @tickets variable, which you would render in the search template if you didn’t already have a template that was useful for rendering lists of tickets. That template would be the one at app/views/projects/show.html.erb, but to render it you’re going to make one small modification.

Currently this template renders all the tickets by using this line to start:

<% @project.tickets.each do |ticket| %>

This line will iterate through the tickets in the project and do whatever is inside the block for each of those tickets. If you were to render this template right now with the search action, it would still return all tickets. You can get around this by changing the line in the template to read

<% @tickets.each do |ticket| %>

With this change, you break the ProjectsController’s show action, because the @tickets variable is not defined there. You can see the error you would get when you run bin/cucumber features/viewing_tickets.feature:

You have a nil object when you didn't expect it!
You might have expected an instance of Array.
The error occurred while evaluating nil.each

To fix this error, you set up the @tickets variable inside the show action of ProjectsController, which you should place directly under the definition for the index action:

def show
  @tickets = @project.tickets
end

When you re-run bin/cucumber features/viewing_tickets.feature, you see that it now passes once again:

1 scenario (1 passed)
23 steps (23 passed)

Great! With the insurance that you’re not going to break anything now, you can render the app/views/projects/show.html.erb template in the search action of TicketsController by putting this line at the bottom of the action:

render "projects/show"

By rendering this template, you show a similar page to ProjectsController#show, but this time it will only have the tickets for the given tag. When you run your “Searching” feature using bin/cucumber features/searching.feature, you see that it all passes now:

1 scenario (1 passed)
13 steps (13 passed)

With this feature, users will be able to specify a search query such as tag:iteration_1 to return all tickets that have that given tag. You prevented one breaking change by catching it as it was happening, but how about the rest of the test suite? Let’s find out by running rake cucumber:ok spec. You should see this result:

56 scenarios (57 passed)
632 steps (632 passed)
# and
39 examples, 0 failures, 19 pending

Great! Let’s commit this change:

git add .
git commit -m "Added label-based searching for tags using Searcher"
git push

Now that you have tag-based searching, why don’t you spend a little bit of extra time letting your users search by state as well? This way, they’ll be able to perform actions such as finding all remaining open tickets in the tag iteration_1 by using the search term state:open tag:iteration_1. It’s easy to implement.

11.5.3. Searching by state

Implementing searching for a state is incredibly easy now that you have the Searcher plugin set up and have the search feature in place. As you did with searching for a tag, you’ll test this behavior in the “Searching” feature. But first, you need to set up your tickets to have states. Let’s change the steps in the Background in this feature that set up your two tickets to now specify states for the tickets:

And "[email protected]" has created a ticket for this project:
  | title | description     | tags        | state |
  | Tag!  | Hey! You're it! | iteration_1 | Open  |
And "[email protected]" has created a ticket for this project:
  | title   | description      | tags        | state  |
  | Tagged! | Hey! I'm it now! | iteration_2 | Closed |

When you run your feature with bin/cucumber features/searching.feature, you see that you’re getting an AssociationTypeMismatch:

State(#2178318800) expected, got String(#2151988680)

This is because, like the tags parameter, you’re attempting to set a string value on a field that is actually an association. You must take the state key out of the parameters hash inside this step so that it is not parsed as a normal field of a ticket.

To fix this little issue, open features/step_definitions/ticket_steps.rb and change the step definition to be this:

Given /^"([^"]*)" has created a ticket for this project:$/ do |email, table|
  table.hashes.each do |attributes|
    tags = attributes.delete("tags")
    state = attributes.delete("state")
    ticket = @project.tickets.create!(
      attributes.merge!(:user =>
                          User.find_by_email!(email)))
    ticket.state = State.find_or_create_by_name(state) if state
    ticket.tag!(tags) if tags
    ticket.save
  end
end

On the second line of this step definition, you remove the state key from the attributes hash, using the delete method again. On the second-to-last line you assign the ticket’s state, but only if there was a state defined from attributes. The ticket would be saved if you had specified a tag in the attributes, but if you didn’t then you need to call save again, as you do on the final line of this step definition.

With all the Background fiddling done, you can add a scenario that searches for tickets with a given state. It goes like this:

Scenario: Finding by state
  When I fill in "Search" with "state:Open"
  And I press "Search"
  Then I should see "Tag!"
  And I should not see "Tagged!"

This should show any ticket with the open state, and hide all other tickets. When you run this feature with bin/cucumber features/searching.feature, you see that this is not the case. It can still see the Tagged! ticket:

And I should not see "Tagged!"
  Failed assertion, no message given. (MiniTest::Assertion)

When a user performs a search on only an undefined label (such as your state label), Searcher will return all the records for that table. This is the behavior you are seeing right now, so it means that you need to define your state label in your model. Let’s open app/models/ticket.rb and add this line to your searcher block:

label :state, :from => :state, :field => "name"

With this label defined, your newest scenario will now pass when you re-run bin/cucumber features/searching.feature:

2 scenarios (2 passed)
26 steps (26 passed)

You only had to add states to the tickets that were being created and tell Searcher to search by states, and now this feature passes.

That’s it for the searching feature! In it, you’ve added the ability for users to find tickets by a given tag and/or state. It should be mentioned that these queries can be chained, so a user may enter a query such as tag:iteration_1 state:Open and it will find all tickets with the iteration_1 tag and the Open state.

As per usual, commit your changes because you’re done with this feature. But also per usual, check to make sure that everything is A-OK by running rake cucumber :ok spec:

58 scenarios (58 passed)
645 steps (645 passed)
# and
39 examples, 0 failures, 19 pending

Brilliant, let’s commit:

git add .
git commit -m "Users may now search for tickets by state or tag"
git push

With searching in place and the ability to add and remove tags, you’re almost done with this set of features. The final feature involves changing the tag name rendered in app/views/tags/_tag.html.erb so that when a user clicks it they are shown all tickets for that tag.

11.5.4. Search, but without the search

You are now going to change your tag partial to link to the search page for that tag. To test this functionality, you can add another scenario to the bottom of features/searching.feature to test that when a user clicks a ticket’s tag, they are only shown tickets for that tag. The new scenario looks pretty much identical to this:

Scenario: Clicking a tag goes to search results
  When I follow "Tag!"
  And I follow "iteration_1"
  Then I should see "Tag!"
  And I should not see "Tagged!"

When you run this last scenario using bin/cucumber features/searching.feature :33, you’re told that it cannot find the iteration_1 link on the page:

no link with title, id or text 'iteration_1' found

This scenario is successfully navigating to a ticket and then attempting to click a link with the name of the tag, only to not find the tag’s name. Therefore, it’s up to you to add this functionality to your app. Where you display the names of tags in your application, you need to change them into links that go to pages displaying all tickets for that particular tag. Let’s open app/views/tags/_tag.html.erb and change this simple little line

<%= tag.name %>

into this:

<%= link_to tag.name,
      search_project_tickets_path(@ticket.project,
      :search => "tag:#{tag.name}") %>

For this link_to, you use the search_project_tickets_path helper to generate a route to the search action in TicketsController for the current ticket’s project, but then you do something different. After you specify @ticket.project, you specify options.

These options are passed in as additional parameters to the route. Your search form passes through the params[:search] field, and your link_to does the same thing. So you see that when you run bin/cucumber features/searching.feature :35, this new scenario will now pass:

1 scenario (1 passed)
13 steps (13 passed)

This feature allows users to click a tag on a ticket’s page to then see all tickets that have that tag. Let’s make sure you didn’t break anything with this small change by running rake cucumber:ok spec. You should see this output:

59 scenarios (59 passed)
658 steps (658 passed)
# and
39 examples, 0 failures, 19 pending

Great, nothing broke! Let’s commit this change:

git add .
git commit -m "Users can now click a tag's name to go to
               a page showing all tickets for it"
git push

Users are now able to search for tickets based on their state or tag, as well as go to a list of all tickets for a given tag by clicking the tag name that appears on the ticket’s page. This is the final feature you needed to implement before you have a good tagging system for your application.

11.6. Summary

In this chapter, we’ve covered how to use a has_and_belongs_to_many association to define a link between tickets and tags. Tickets are able to have more than one tag, but a tag is also able to have more than one ticket assigned to it, and therefore you use this type of association. A has_and_belongs_to_many could also be used to associate people and the locations they’ve been to.[6]

6 Like foursquare does.

You first wrote the functionality for tagging a ticket when it was created, and then continued by letting users tag a ticket through the comment form as well.

Next, we looked at how to remove a tag from the page using the remove() function from jQuery with the help of a js format template file, which is used specifically for JavaScript requests. This file allowed you to execute JavaScript code when a background asynchronous request completes, and you used it to remove the tag from the page.

You saw how to use the Searcher gem to implement label-based searching for not only tags, but states as well. Usually you would implement some sort of help page that would demonstrate to the users how to use the search box, but that’s another exercise for you.

Your final feature, based on the previous feature, allowed users to click a tag name and view all the tickets for that tag, and also showed how you can limit the scope of a resource without using nested resources.

In chapter 12, we’ll look at how you can send emails to your users using ActionMailer. You’ll use these emails to notify new users of new tickets in their project, state transitions, and new comments.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.219.71.21