Review Quiz is the first of three complete example applications in this book, each designed to demonstrate different techniques for building rich Ajax applications with Rails. The purpose of this application is simply to provide shared quizzes for self-studylike flash cards. The quizzes are self-administered and self-judged, as shown in Figure A-1. Typical use cases: A quiz is created and used by just one person, such as a college student drilling for an exam A quiz is created by one person and then shared with a group, such as a high school teacher helping students review course material A general-interest quiz is created for fun and discovered by other users on the site Figure 12-1. Review Quiz homeTo keep things simple, the application has no user accounts or mechanism for logging on or off. It does, however, have session-based authentication. When a user creates a new quiz, her session ID is stored, and changes can only be made with the same session ID. That means the barrier to entry for new users is extremely low; but it also means that a user can't reliably return to a quiz to change it after creating it. For most applications, this trade-off wouldn't be worthwhile, but in this case, an argument can be made that each quiz is sufficiently disposable for this approach. For an example of a user accounts system, see the Intranet Workgroup Collaboration application described in Example C. To download the source to this application, rails quiz, visit http://www.oreilly.com/catalog/9780596527440. Where files aren't listed (e.g., config/environment.rb), they are the same as the Rails default skeleton. Once the files are in the correct place, you'll need to configure a database by editing config/database.yml. The default configuration as generated by rails quiz expects a MySQL database named quiz_development, accessible at localhost with the username root and no password. To get started, create a database for the application and change database.yml as needed, then run rake db:schema:load to create the application's database structure, as specified by schema.rb. The database and model for the application is very simple: just two tables and two models, for quizzes and questions. ActiveRecord::Schema.define(:version => 1) do create_table "questions", :force => true do |t| t.column "quiz_id", :integer t.column "position", :integer t.column "question", :text, :default => "", :null => false t.column "answer", :text, :default => "", :null => false end add_index "questions", ["quiz_id"], :name => "questions_quiz_id_index" add_index "questions", ["position"], :name => "position" create_table "quizzes", :force => true do |t| t.column "name", :string, :default => "New Quiz", :null => false t.column "session_id", :string, :limit => 50, :default => "", :null => false t.column "created_at", :datetime, :null => false end add_index "quizzes", ["created_at"], :name => "created_at" end The Question model is essentially simple: beyond basic ActiveRecord stuff, it defines a method for returning the next question. class Question < ActiveRecord::Base belongs_to :quiz acts_as_list :scope => :quiz # Returns the next question in the quiz after # this one, excluding those keys passed in +right_keys+ def next right_keys quiz.questions.find :first, :conditions => "position > #{position}" + (right_keys.blank? ? "" : " and id not in (#{right_keys})") end end Likewise, the Quiz model is simple. We add a method to the association between a Quiz and its questions, which allows us to easily find all questions that haven't yet been missed. class Quiz < ActiveRecord::Base # Methods added to the association, e.g quiz.questions.unmissed # to retrieve questions that have not been missed module AssociationExtension def unmissed right_keys cond = "id not in (#{right_keys})" unless right_keys.blank? find :all, :conditions => (cond || nil), :limit => 5 end end has_many :questions, :order =>'position', :dependent => :destroy, :extend => AssociationExtension # Finds the last 20 quizzes created def self.recent find :all, :limit => 20, :order => "created_at desc" end end The application is implemented with just one controller, QuizzesController. The routing map includes the usual Rails default route, one route for the home page, and one resource that defines a collection of named routes for the quizzes controller. ActionController::Routing::Routes.draw do |map| map.resources :quizzes, :member => { :create_q => :post, :destroy_q => :post, :reorder => :post, :answer => :post, :reset => :post } map.home '', :controller =>'quizzes' map.connect ':controller/:action/:id' end The layout view, application.rhtml, is the quiz's top-level layout, as shown in Figure A-2. It contains a simple Ajax form for adding a new quiz, and a DIV where other parts of the application can display the questions. The yield at the end of the template allows the edit.rhtml template to insert a form for adding questions to the quiz. Figure 12-2. Editing a quiz<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"> <head> <title>Review Quiz</title> <%= stylesheet_link_tag "application" %> <%= javascript_include_tag :defaults %> </head> <body> <h1><%= link_to "Review Quiz", home_url %></h1> <% form_for :new_quiz, Quiz.new, :url => quizzes_url, :html => { :id => "new_quiz" } do |f| %> <label for="new_quiz_name">Create a quiz:</label> <%= f.text_field :name %> <%= submit_tag "Create" %> <% end %> <div ></div> <%= yield %> </body> </html> The Quizzes controller starts with a couple of before_filters to make sure there's a current quiz (if one is needed) and, if the action requires permission, makes sure that the user is allowed to edit the quiz. The first few actions are simple, but things gets a little more complex with create_q, which lets the user add a new question. It uses respond_to to handle an Ajax form submission or a traditional submission, all in one action. The RJS template create_q.rjs handles the Ajax side. Drag-and-drop reordering is, of course, handled through Ajax. And it's simple: it's just a matter of assigning the new positions to each question and saving the quiz. Other ways of manipulating the quiz (deleting a question, showing whether the user's answer was right or wrong) are also handled with Ajax. But the controller has little to do with manipulating the page itself: it just manages the data, and renders (if a render is needed). That's how we want it! class QuizzesController < ApplicationController before_filter :find_quiz, :except => [ :index, :create ] before_filter :check_permissions, :only => [ :edit, :reorder, :questions, :destroy_question ] # Lists recent quizzes def index @quizzes = Quiz.recent end # Creates a new quiz and saves the user's session id in it def create quiz = Quiz.new params[:new_quiz] quiz.session_id = session.session_id quiz.save redirect_to edit_quiz_url(:id => quiz) end # Presents a view to edit quiz def edit end # Creates a new question, via either Ajax or traditional form def create_q @question = @quiz.questions.create params[:question] respond_to do |format| format.html { redirect_to edit_quiz_url } format.js end end # Handles drag-and-drop reordering questions via Ajax def reorder params[:quiz].each_with_index do |id, position| q = @quiz.questions.find id q.position = position + 1 q.save end render :nothing => true end # Handles deleting a question via Ajax def destroy_q question = @quiz.questions.find params[:question_id] question.destroy render :nothing => true end # Shows the first five questions that have not been missed def show @questions = @quiz.questions.unmissed right_keys end # Returns a response to a question via Ajax def answer score @quiz.id, params[:question_id], params[:right]=='true' last = @quiz.questions.find params[:last] @next = last.next right_keys end # Resets the user's scoreboard for the quiz def reset reset_scoreboard params[:id] redirect_to quiz_url end private # Before filter to lookup a quiz by id def find_quiz( ) @quiz = Quiz.find params[:id] end # Before filter to ensure only a quiz's creator can edit it def check_permissions redirect_to home_url and return false unless mine? end # Whether @quiz was created by the user def mine? @quiz.session_id == session.session_id end helper_method :mine? # Wraps session to track user's quiz results def scoreboard id=nil return (session[:quizzes] ||= {}) unless id return (scoreboard[id.to_i] ||= {}) end # Wipes the user's scoreboard for a given quiz def reset_scoreboard id scoreboard[id.to_i] = {} end # A response (+right+) for question +q+ of quiz +quiz+ def score id, q, right scoreboard(id)[q.to_i] = right end # An array of hashes representing right answers for quiz +id+ def right(id) scoreboard(id).reject{ |q, v| !v } end helper_method :right # An array of hashes representing wrong answers for quiz +id+ def wrong(id) scoreboard(id).reject{ |q, v| v } end helper_method :wrong # A comma-delimited string of ids to the right responses # for the current quiz. def right_keys questions = right(@quiz.id) questions.keys.join ',' end end Editing a quiz is fairly simple: you can add questions and you can delete questions. This partial displays a question and its answer and provides a link that lets you delete it. <li > <%=h question.question %> <em>(<%=h question.answer %>)</em> <%= link_to_function "x", remote_function( :url => destroy_q_quiz_url(:question_id => question), :complete => "$('question_#{question.id}').hide( )") %> </li> This partial displays a question, along with its answer and "Got It"/"Missed It" links (hidden by default, thanks to the "display: none"). Both links defer to the JavaScript function Quiz.answer( ), which is defined in application.js. <div > <div > <%=h question.question %> <%= link_to_function "Reveal", "Quiz.reveal(#{question.id})", :class => "yellow" %> </div> <div style="display: none"> <%=h question.answer %> <%= link_to_function "Got It", "Quiz.answer('#{question.quiz_id}', #{question.id}, true)", :class => "green" %> <%= link_to_function "Missed It", "Quiz.answer('#{question.quiz_id}', #{question.id}, false)", :class => "red" %> </div> </div> The scoreboard partial just tallies the right and wrong answers. <div > <div > <%= pluralize @quiz.questions_count,'question' %> </div> <div > <span ><%= right(@quiz.id).size %> right</span> / <span ><%= wrong(@quiz.id).size %> wrong</span> </div> <div > <%= @quiz.questions_count right(@quiz.id).size wrong(@quiz.id).size %> remaining </div> </div> This RJS template starts by rendering the question partial, loading it with the next question, and inserting the result at the bottom of the page. It also updates the page's scoreboard. if @next content = render :partial => "question", :locals => { :question => @next } page.insert_html :bottom, :questions, content end page[:scoreboard].reload This RJS template appends a just-created question to the bottom of the page, fires a visual effect to alert the user that the page has changed, and resets the form fields to empty strings. The last line calls Quiz.update_hints( ) as defined in application.js. page.insert_html :bottom, :quiz, render(:partial => "edit_question", :locals => { :question => @question }) page["question_#{@question.id}"].visual_effect :highlight page.sortable :quiz, :url => reorder_quiz_url page[:question_question].value = '' page[:question_answer].value = '' page[:question_question].focus page.quiz.update_hints The edit template displays the quiz and allows the user to add new questions, delete existing ones, and reorder questions via drag and drop, as shown in Figure A-3. The most important part of this template is the remote_form_for, which allows the user to add a new question. Figure 12-3. Reordering questions with drag and drop<h2>Edit: <%= @quiz.name %></h2> <ul > <li <% unless @quiz.questions.any? %> style="display: none"<% end %>> <%= link_to "Take the quiz", quiz_url %> </li> </ul> <ul > <% @quiz.questions.each do |question| %> <%= render :partial => "edit_question", :locals => { :question => question } %> <% end %> </ul> <%= sortable_element :quiz, :url => reorder_quiz_url %> <% remote_form_for :question, Question.new, :url => create_q_quiz_url, :html => { :id => "new_question", :onKeyPress => "return Quiz.captureKeypress(event);" } do |f| %> <div <% if @quiz.questions.any? %> style="display: none"<% end %>> <strong>Add the first question</strong> to your new quiz. </div> <h3>Add a Question</h3> <label for="question_question">Question</label> <%= f.text_area :question %> <label for="question_answer">Answer</label> <%= f.text_area :answer %> <%= submit_tag "Save" %> <%= javascript_tag "$('question_question').focus( )" %> <% end %> This template provides a list of links to the recently created quizzes. It's displayed when the application first starts up, as was shown in Figure A-1. <h2>Recent Quizzes</h2> <% if @quizzes.any? %> <ul> <% @quizzes.each do |quiz| %> <li><%= link_to h(quiz.name), quiz_url(:id => quiz) %></li> <% end %> </ul> <% else %> <p><em>There are no quizzes yet.</em></p> <% end %> The show template is responsible for rendering a given quiz, including the scoreboard and the list of questions for the user to answer. Figure A-4 shows a quiz in progress. Figure 12-4. Taking a quiz<%= render :partial =>'scoreboard' %> <h2><%= h(@quiz.name) %></h2> <ul > <% if mine? %> <li><%= link_to "Edit this quiz", edit_quiz_url %></li> <% end %> <li style="display: none" > <%= link_to "Start Over", reset_quiz_url, :method => :post %> </li> </ul> <div > <%= render :partial => "question", :collection => @questions %> </div> <div style="display: none"> <strong>You're done!</strong> Now you can <%= link_to "start over", reset_quiz_url, :method => :post %>, or just <%= link_to "review what you missed.", quiz_url %> </div> The application-specific JavaScript is defined in application.js. var Quiz = { /* Handles returns within the create-question form */ captureKeypress: function(evt) { var keyCode = evt.keyCode ? evt.keyCode : evt.charCode ? evt.charCode : evt.which; if (keyCode == Event.KEY_RETURN) { if(Event.element(evt).id=='question_question') $('question_answer').focus( ); if(Event.element(evt).id=='question_answer') $('new_question').onsubmit( ); return false; } return true; }, /* Hides and shows help messages while editing a quiz */ updateHints: function( ) { $('quiz').cleanWhitespace( ); if($A($('quiz').childNodes).any( )) { $('done').show( ); $('starting').hide( ); } }, /* Reveals the answer node for a question */ reveal: function(questionId) { $(questionId+'_a').visualEffect('blind_down', {duration:0.25}) }, /* Handles submitting an answer */ answer: function(quizId, questionId, right) { var url = '/quizzes/' + quizId + ';answer'; var params ='question_id=' + questionId + '&right=' + (right ?'true' : false) + '&last=' + this.questions().last( ).id; new Ajax.Request(url, {parameters:params}); $(questionId.toString( )).visualEffect('fade_up', {duration:0.5}); if(this.showingQuestions() && !$('finished').visible( )) $('finished').visualEffect('appear_down'); $('startover').show( ); }, /* Returns all question DOM nodes */ questions: function( ) { var questions = $('questions'); questions.cleanWhitespace( ); return $A(questions.childNodes); }, /* Returns whether there are any showing question nodes */ showingQuestions: function( ) { return this.questions( ).select(function(e){ return e.visible( ); }).length==1; } } // Custom effect combining BlindUp and Fade Effect.FadeUp = function(element) { element = $(element); element.makeClipping( ); return new Effect.Parallel( [ new Effect.Opacity(element, {from:1,to:0}), new Effect.Scale(element, 0, {scaleX:false,scaleContent:false,restoreAfterFinish: true}) ], Object.extend({ to: 1.0, from: 0.0, transition: Effect.Transitions.linear, afterFinishInternal: function(effect) { effect.effects[0].element.hide( ); effect.effects[0].element.undoClipping( ); }}, arguments[1] || {}) ); } // Custom effect combining BlindDown and Appear Effect.AppearDown = function(element) { element = $(element); var elementDimensions = element.getDimensions( ); return new Effect.Parallel( [ new Effect.Opacity(element, {from:0,to:1}), new Effect.Scale(element, 100, {from:0,to:1,scaleX:false, scaleContent:false,restoreAfterFinish:true, scaleMode:{originalHeight:elementDimensions.height, originalWidth:elementDimensions.width}}) ], Object.extend({ transition: Effect.Transitions.linear, afterSetup: function(effect) { effect.effects[0].element.makeClipping( ); effect.effects[0].element.setStyle({height:'0px'}); effect.effects[0].element.show( ); }, afterFinishInternal: function(effect) { effect.effects[0].element.undoClipping( ); }}, arguments[1] || {}) ); } There's nothing really significant in the application's stylesheet. It's here for completeness and to show that we aren't playing any tricks in it. /* Basics */ /* ------------------------- */ html { background-color: #ddd; padding: 20px; border-top: 8px solid #494; height: 100%; } body { width: 80%; margin: 0 auto 0 auto; padding: 0 20px 0 20px; border-top: 1px solid #bbb; border-right: 1px solid #999; border-bottom: 1px solid #999; border-left: 1px solid #bbb; background-color: #fff; font-family: helvetica, arial, sans-serif; min-height: 100%; } h1 { float: left; } h2 a { font-size: 0.5em; } .clear { clear: both; } #links { margin-top: -1.7em; padding-left: 15px; list-style-type: square; font-size: 0.7em; } /* Links */ /* ------------------------- */ a { color: #a44; text-decoration: none; } a:hover { text-decoration: underline; color: #464; } a.green, a.red, a.yellow { text-transform: uppercase; font-size: 0.7em; padding: 1px 2px; } a.green { color: #363; background-color: #cfc; border: 1px solid #696; } a.red { color: #633; background-color: #fcc; border: 1px solid #966; } a.yellow { color: #663; background-color: #ffc; border: 1px solid #996; } /* Create Quiz */ /* ------------------------- */ #new_quiz { font-size: .7em; text-transform: uppercase; float: right; margin-top: 20px; background-color: #bdb; padding: 5px 10px; border: 1px solid #9b9; } #new_quiz input[type='text'] { width: 100px; font-weight: bold; background-color: #cfc; } /* Edit Quiz */ /* ------------------------- */ #new_question { clear: right; background-color: #bdb; padding: 5px 10px; border: 1px solid #9b9; width: 55%; padding-right: 80px; padding-top: 10px; margin-top: 50px; } #new_question h3 { margin-top: 0; margin-bottom: 8px; font-size: 0.7em; letter-spacing: 0.1em; text-transform: uppercase; font-weight: bold; } #new_question label { font-size: 0.7em; text-transform: uppercase; font-weight: normal; float: left; width: 65px; margin-top: 5px; } #new_question textarea { width: 100%; display: block; height: 40px; vertical-align: top; margin-bottom: 10px; } #new_question input { margin-left: 65px; } #starting { color: #331; background-color: #ffc; border: 1px solid #cca; padding: 5px; margin-bottom: 10px; } #finished { color: #331; background-color: #ffc; border: 1px solid #cca; padding: 10px; width: 270px; } /* Take Quiz */ /* ------------------------- */ #questions { padding-top: 20px; } .question .q { margin-bottom: 10px; } .question .a { margin-bottom: 30px; margin-left: 30px; } /* Scoreboard */ /* ------------------------- */ #scoreboard { padding: 6px; float: right; width: 150px; color: #331; background-color: #ffc; border: 1px solid #cca; text-align: center; margin-left: 20px; margin-bottom: 10px; } #scoreboard #total, #scoreboard #remaining { text-transform: uppercase; font-size: 0.7em; color: #888; letter-spacing: 0.1em; } #scoreboard #score { font-weight: bold; margin: 2px 0 3px 0; } #scoreboard #right { color: #090; } #scoreboard #wrong { color: #900; } |