#103 Site-Wide Announcements (revised)
- Download:
- source codeProject Files in Zip (60.5 KB)
- mp4Full Size H.264 Video (24.2 MB)
- m4vSmaller H.264 Video (13.2 MB)
- webmFull Size VP8 Video (15.5 MB)
- ogvFull Size Theora Video (30.7 MB)
There may be times when we need to make an announcement to all the users of our application by displaying a message at the top of every page. For example we might want to take our site down for scheduled maintenance and warn all users in advance no matter what page they’re currently on. Once a user has read this message they should be able to hide it, and it should then stay hidden even if they move to another page or even if they leave the application and then come back to it.
As this is a relatively simple change we’ll implement it through test-driven development. We already have the testing environment set up for this app; we’re using RSpec and Capybara, like we did in episode 275, and PhantomJS and Poltergeist to test JavaScript which we demonstrated in episode 391.
Our First Specs
We’ll start with a request spec and run the integration test generator to create one.
$ rails g integration_test announcement
We’ll replace the default test in this spec with the following code. In this spec we create an Announcement with a message and start and end times. We then visit the home page and check that the announcement’s message is visible on the page.
require 'spec_helper' describe "Announcements" do it "displays active announcements" do Announcement.create! message: "Hello World", starts_at: 1.hour.ago, ends_at: 1.hour.from_now visit root_path page.should have_content "Hello World" end end
This test fails when we run it as our app doesn’t have an Announcement
model. We’ll generate one now with the fields we use in our test then migrate the database.
$ rails g model announcement message:text starts_at:datetime ends_at:datetime $ rake db:migrate
When we run the spec now it still fails but this time because the text “Hello World” isn’t on the home page. We need to add our announcement to the page and we’ll do this in the application layout file so that it shows on every page. For now we’ll loop through all the announcements and use div_for
to display each one in a div
.
<body> <% Announcement.all.each do |announcement| %> <%= div_for announcement do %> <%= announcement.message %> <% end %> <% end %> <!-- Rest of body omitted --> </body>
Our test now passes. We don’t want announcements whose start time is later or whose end time is earlier than the current time to be displayed so we’ll add a test for this.
require 'spec_helper' describe "Announcements" do it "displays active announcements" do Announcement.create! message: "Hello World", starts_at: 1.hour.ago, ends_at: 1.hour.from_now Announcement.create! message: "Upcoming", starts_at: 10.minutes.from_now, ends_at: 1.hour.from_now visit root_path page.should have_content "Hello World" page.should_not have_content "Upcoming" end end
This test fails as we currently display all of the announcements. We could add some where conditions in the view layer to get only the current announcements but instead we’ll create a scope called current
in the Announcement
model and call that in the view instead.
<% Announcement.current.each do |announcement| %> <%= div_for announcement do %> <%= announcement.message %> <% end %> <% end %>
Before we implement this method we’ll add some lower-level tests for it.
require 'spec_helper' describe Announcement do it "has current scope", focus: true do past = Announcement.create! starts_at: 1.day.ago, ends_at: 1.hour.ago current = Announcement.create! starts_at: 1.hour.ago, ends_at: 1.day.from_now upcoming = Announcement.create! starts_at: 1.hour.from_now, ends_at: 1.day.from_now Announcement.current.should eq([current]) end end
This test creates three announcements, only one of which falls within the current time and only this one should appear in our current scope. Note that we’re using the focus
tag here so that we can focus on getting this test passing before we worry about our failing higher-level tests. The focus
setting is configured in the spec helper file so it will run the focussed tests first. When we run it it fails, as we expect, as we don’t have a current scope so we’ll add one to Announcement
.
class Announcement < ActiveRecord::Base attr_accessible :ends_at, :message, :starts_at scope :current, -> { where("starts_at <= :now and ends_at >= :now", now: Time.zone.now) } end
Here we’re using the Ruby 1.9 lambda syntax to add a where condition to fetch the announcements whose starts_at is less than or equal to the current time and whose ends_at is later than or equal to it. When we run our focussed test now it passes. This means that we can remove our focus tag from the test so that we can run them all. When we do the higher-level tests all pass too.
Hiding an Announcement
We want the user to be able to hide an announcement so we’ll write a test for this. We’ll expand our existing spec to do this but we could consider moving it into a separate one.
it "displays active announcements" do Announcement.create! message: "Hello World", starts_at: 1.hour.ago, ends_at: 1.hour.from_now Announcement.create! message: "Upcoming", starts_at: 10.minutes.from_now, ends_at: 1.hour.from_now visit root_path page.should have_content "Hello World" page.should_not have_content "Upcoming" click_on "hide announcement" page.should_not have_content "Hello World" end
At the end of our existing spec we now click on an “hide announcement” link then check to see that the page no longer has the text “Hello World”. Needless to say this fails as that link doesn’t exist on the page. We’ll add one now.
<% Announcement.current.each do |announcement| %> <%= div_for announcement do %> <%= announcement.message %> <%= link_to "hide announcement", hide_announcement_path(announcement) %> <% end %> <% end %>
We’ve pointed this link to hide_announcement_path
which doesn’t exist yet. We’ll modify our routes file to add this path and point it to a hide action on the AnnouncementsController
which we’ll need to create.
Blog::Application.routes.draw do resources :articles root to: 'articles#index' match 'announcements/:id/hide', to: 'announcements#hide', as: 'hide_announcement' end
Now we’ll generate this new controller.
$ rails g controller announcements
We can now define the hide
action. We need to store the announcements that the user has hidden. While we could do this in the session the values won’t persist if the user closes their browser so we’ll store them in a permanent cookie. We want to store an array of ids and to have the serialization happen automatically so we’ll make it a signed cookie so that we can store an array of values inside it.
class AnnouncementsController < ActionController::Base def hide ids = [params[:id], *cookies.signed[:hidden_announcement_ids]] cookies.permanent.signed[:hidden_announcement_ids] = ids redirect_to :back end end
Here we fetch the id
that’s passed in from the parameters and merge it with the ids that are already stored in the cookie and then write the combined array back. We then redirect back to the page the user was previously on.
When we run our tests now they fail but again that’s what we expect as the announcements selected as hidden are still shown. We’ll need to change the layout file as it’s displaying all the current announcements. What we’ll do is pass in the announcements that we want to skip into the current
scope, although we could make a separate method or create a helper method to clean up the view code here.
<% Announcement.current(cookies.signed[:hidden_announcement_ids]).each do |announcement| %> <%= div_for announcement do %> <%= announcement.message %> <%= link_to "hide announcement", hide_announcement_path(announcement) %> <% end %> <% end %>
Before we change current we’ll write some tests for the new functionality.
it "does not include ids passed in to current" do current1 = Announcement.create! starts_at: 1.hour.ago, ends_at: 1.day.from_now current2 = Announcement.create! starts_at: 1.hour.ago, ends_at: 1.day.from_now Announcement.current([current2.id]).should eq([current1]) end it "includes current when nil is passed in" do current = Announcement.create! starts_at: 1.hour.ago, ends_at: 1.day.from_now Announcement.current(nil).should eq([current]) end
The first test here checks that a current announcement passed to current is excluded from the output while the second tests that if no announcements are passed to it then all the current announcements are returned. In the Announcement
model we’ll replace the current
scope with a class method with the same name.
class Announcement < ActiveRecord::Base attr_accessible :ends_at, :message, :starts_at def self.current(hidden_ids = nil) result = where("starts_at <= :now and ends_at >= :now", now: Time.zone.now) result = result.where("id not in (?)", hidden_ids) if hidden_ids.present? result end end
This fetches the same announcements as before and also excludes those with the ids that we pass in. When we run our tests now they all pass and our feature is pretty much complete so let’s try it out in the browser. First we’ll need to create an Announcement
record, which we’ll do in the console. Obviously in a production application we’d want to create an admin interface to handle announcements.
>> Announcement.create! message: "The site will be down for maintenance tonight between 10:00 and 11:00 PM GMT", starts_at: Time.zone.now, ends_at: 1.day.from_now
When we reload the page in the browser now the message appears. We’ve already added some CSS and so it looks good at the top of the page.
Hiding Announcements With JavaScript
We can now visit any page in our application and the current messagees will be shown until we click “hide announcement” for one of them at which point it will disappear and isn’t shown again for us. Clicking the link reloads the page but it would be better if the announcement was hidden with JavaScript instead. We’ll add this functionality, starting by creating a request spec for it.
it "displays active announcements", js: true do Announcement.create! message: "Hello World", starts_at: 1.hour.ago, ends_at: 1.hour.from_now visit root_path page.should have_content "Hello World" click_on "hide announcement" page.should_not have_content "Hello World" end
This spec is similar to our other request spec. Note that we have the js: true
option set so that it uses PhantomJS with Poltergeist which we already have set up in our application. When we run this spec it passes as we’ve already implemented this functionality, although it currently works without using JavaScript. While we want our application to degrade gracefully but how do we write a test that checks that the JavaScript is working correctly? We need to start with a failing test and we can do that by changing our spec to look like this:
it "displays active announcements with JavaScript", js: true do Announcement.create! message: "Hello World", starts_at: 1.hour.ago, ends_at: 1.hour.from_now visit root_path page.should have_content "Hello World" expect { click_on "hide announcement" }.to_not change { page.response_headers } page.should_not have_content "Hello World" end
This time we get a failure because the page’s response headers change as we visit a new page when we click the link. We now have a failing test so let’s work on getting it fixed. All we need to do is add the remote option to the link and set it to true
.
<%= link_to "hide announcement", hide_announcement_path(announcement), remote: true %>
In the AnnouncementController
’s hide
action we’ll change the redirect behaviour so that it only happens on an HTML request.
def hide ids = [params[:id], *cookies.signed[:hidden_announcement_ids]] cookies.permanent.signed[:hidden_announcement_ids] = ids respond_to do |format| format.html { redirect_to :back } format.js end end
For JavaScript requests to this action we’ll create a view that will return the JavaScript necessary to hide the announcement.
$('#announcement_<%= j params[:id] %>').remove();
Our JavaScript test now passes and if we try hiding an announcement in the browser it’s hidden without the page reloading.