This application is a workgroup tool that's appropriate for small teams, as shown in Figure C-1. It provides a lot of features for office communication and collaboration: facilities for managing projects, attaching comments and documents to projects, and so on. It's typical of many real-world applications: not flashy like Google Maps, but useful and necessary. The Ajax is also relatively low-key: it makes the application more powerful and usable, but doesn't call attention to itself. This application also shows where not to use Ajax. Figure 14-1. Intranet home pageTo download the source to this application, rails intranet, visit http://www.oreilly.com/catalog/9780596527440. Where files aren't listed, 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 intranet expects a MySQL database named intranet_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 application uses three tables: users, posts, and attachments. The users table is for managing users, as you'd expect. Attachments are binary file uploads (e.g., photos, spreadsheets, documents). Most of the application centers on posts; a post can be a document (contained in an attachment), a project plan, a message, a comment, or a contact. ActiveRecord::Schema.define( ) do create_table "users", :force => true do |t| t.column "email", :string, :limit => 100, :default => "", :null => false t.column "password", :string, :limit => 100, :default => "", :null => false t.column "name", :string, :limit => 40, :default => "", :null => false t.column "phone", :string, :limit => 50, :default => "", :null => false t.column "address", :string, :limit => 50, :default => "", :null => false t.column "city", :string, :limit => 50, :default => "", :null => false t.column "state", :string, :limit => 50, :default => "", :null => false t.column "zip", :string, :limit => 50, :default => "", :null => false t.column "picture_id", :integer t.column "created_at", :datetime t.column "updated_at", :datetime t.column "status", :string, :limit => 50, :default => "", :null => false t.column "last_active", :datetime t.column "admin", :boolean, :default => false, :null => false end add_index "users", ["email"], :name => "email", :unique => true add_index "users", ["password"], :name => "password" create_table "posts", :force => true do |t| t.column "type", :string, :limit => 20 t.column "post_id", :integer t.column "created_at", :datetime t.column "updated_at", :datetime t.column "created_by", :integer t.column "updated_by", :integer t.column "name", :string, :limit => 128, :default => "Untitled", :null => false t.column "body", :text, :default => "", :null => false t.column "email", :string, :limit => 50, :default => "", :null => false t.column "phone", :string, :limit => 50, :default => "", :null => false t.column "start_date", :date t.column "end_date", :date t.column "attachment_id", :integer t.column "attachment_filename", :string t.column "attachment_content_type", :string, :limit => 128 t.column "attachment_size", :integer end add_index "posts", ["type"], :name => "type" add_index "posts", ["created_at"], :name => "created_at" add_index "posts", ["updated_at"], :name => "updated_at" add_index "posts", ["post_id"], :name => "post_id" create_table "attachments", :force => true do |t| t.column "content", :binary t.column "updated_at", :datetime end end The User model is used to record the system's users. Each user is associated to the posts he created, and every user can have a user picture, stored in an Attachment model. The inactive? method tells whether the user is currently online (more precisely, has been active within the last minute). class User < ActiveRecord::Base has_many :posts, :foreign_key => "created_by", :dependent => :destroy belongs_to :picture, :class_name =>'Attachment', :foreign_key =>'picture_id', :dependent => :destroy validates_length_of :name, :password, :email, :within => 4..100 validates_uniqueness_of :email validates_format_of :email, :with => /^(([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,}))?$/ def self.authenticate(email, password) find_by_email_and_password(email, password) end def first_name; name.split.first; end def last_name; name.split.last; end def short_name name.blank? ? "" : "#{first_name} #{last_name[0,1]}." end # Makes an attachment from a thumbnail upload def file= file unless file.size == 0 picture = Attachment.new :content => file.read picture.save write_attribute'picture_id', picture.id end end # n.b, the status heartbeat updates last_active every 30 seconds def inactive? last_active < 1.minute.ago rescue true end end Post is the superclass for Plan, Message, Document, and Comment. A post has a creator, which must be a user. A post can have attachment and comments. The file= method allows an attachment to be added to a post. class Post < ActiveRecord::Base has_many :comments, :order =>'id', :dependent => :destroy belongs_to :creator, :class_name =>'User', :foreign_key => "created_by" belongs_to :attachment, :dependent => :destroy validates_presence_of :name # Creates an attachment from a file upload def file=(file) unless file.size == 0 attachment=Attachment.new :content => file.read attachment.save write_attribute('attachment_id', attachment.id) write_attribute('attachment_filename', file.original_filename) write_attribute('attachment_content_type', file.content_type) write_attribute('attachment_size', file.size) end end end Contact (not to be confused with User) is a type of Post that stores information about a personfor example, a sales representative, a publicist, or a customer. class Contact < Post validates_format_of :name, :with => /^.+ .+$/, :message => "must include full name" validates_format_of :email, :with => /^(([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,}))?$/ # Find by first letter of last name def self.letter letter Contact.find :all, :conditions => [ "name like ?", '% '+letter+'%' ] end # Turns "Scott Douglas Raymond" into "Raymond, Scott Douglas" def reversed_name names = name.split "#{names.pop}, #{names.join ' '}" end end Document is a subclass of Post that can represent almost any kind of content: a spreadsheet, PDF, Word document, etc. class Document < Post end Message is yet another subclass of Post that represents almost any kind of simple text message. class Message < Post end Plan is a post that represents a particular kind of event. The Plan model provides methods to get plans by certain date ranges. class Plan < Post def self.this_week Plan.find :all, :conditions => "start_date >= now( ) and start_date < '#{Date.today + 7}'", :order => "start_date asc" end def self.next_three_weeks Plan.find :all, :conditions => "start_date >= '#{Date.today + 7}' and start_date < '#{Date.today + 28}'", :order => "start_date asc" end def self.beyond Plan.find :all, :conditions => "start_date >= '#{Date.today + 28}'", :order => "start_date asc" end end Project is yet another kind of Post. class Project < Post end Comment is a simple kind of Post that can be attached to another post. class Comment < Post belongs_to :post validates_presence_of :body end For efficiency reasons, binary files aren't stored directly in the posts table. Instead, Attachment manages them. Attachments are used to represent the binary data associated with a document, and for images attached to the system's users. class Attachment < ActiveRecord::Base end The routing for this application is fairly simple. The calls to map.resources set up RESTful access to the application. ActionController::Routing::Routes.draw do |map| # A resource for each post type map.resources :messages, :plans, :documents, :projects, :contacts, :member => { :download => :get } # A comments resource under every post type; e.g., # /messages/comments and /documents/comments map.resources :comments, :path_prefix => "/:post_type/:post_id" # User and session resources map.resources :sessions map.resources :users, :collection => { :statuses => :get }, :member => { :status => :any } # Home and default routes map.home '', :controller =>'messages', :action =>'home' map.connect ':controller/:action/:id' end The environment file requires the application to load lib/labeling_form_helper.rb. RAILS_GEM_VERSION ='1.1.2' require File.join(File.dirname(__FILE_ _),'boot') Rails::Initializer.run do |config| end # Include a customized helper for building forms from the lib/ dir require'labeling_form_helper' authentication.rb provides very simple authentication services. # based on acts_as_authenticated # http://svn.techno-weenie.net/projects/plugins/acts_as_authenticated module Authentication protected def logged_in? return false unless session[:user_id] begin @current_user ||= User.find(session[:user_id]) rescue ActiveRecord::RecordNotFound reset_session end end def current_user @current_user if logged_in? end def require_login username, passwd = get_auth_data if username && passwd self.current_user ||= User.authenticate(username, passwd) || :false end return true if logged_in? respond_to do |format| format.html do session[:return_to] = request.request_uri redirect_to new_session_url end format.xml do headers["Status"] = "Unauthorized" headers["WWW-Authenticate"] = %(Basic realm="Web Password") render :text => "Could't authenticate you", :status =>'401 Unauthorized' end end false end def access_denied redirect_to new_session_url end def store_location session[:return_to] = request.request_uri end def redirect_back_or_default(default) session[:return_to] ? redirect_to_url(session[:return_to]) : redirect_to(default) session[:return_to] = nil end def self.included(base) base.send :helper_method, :current_user, :logged_in? end private def get_auth_data user, pass = nil, nil if request.env.has_key?'X-HTTP_AUTHORIZATION' authdata = request.env['X-HTTP_AUTHORIZATION'].to_s.split elsif request.env.has_key?'HTTP_AUTHORIZATION' authdata = request.env['HTTP_AUTHORIZATION'].to_s.split end if authdata && authdata[0] =='Basic' user, pass = Base64.decode64(authdata[1]).split(':')[0..1] end return [user, pass] end end LabelingFormBuilder overrides some of the methods in form_for and remote_form_for, extending them so that they automatically handle field names. It's an admittedly tricky bit of code, partly because I use a loop to define several methods at once (e.g., text_field and password_field). class LabelingFormBuilder < ActionView::Helpers::FormBuilder # Overrides default field helpers, adding support for automatic # <label> tags with inline validation messages. (%w(text_field password_field text_area date_select file_field)).each do |selector| src = <<-end_src def #{selector}(method, options = {}) text = options.delete(:label) || method.to_s.humanize errors = @object.errors.on(method.to_s) errors = errors.is_a?(Array) ? errors.first : errors.to_s html = '<label for="' + @object_name.to_s +'_' + method.to_s + '">' html << text unless errors.blank? html << ' <span >' + errors + '</span>' end html << '</label> ' #{selector=='date_select' ? "html << '<span id=\"' + @object_name.to_s +'_' + method.to_s + '\"></span>'" : ""} html << super html end end_src class_eval src, __FILE__, __LINE_ _ end end The application controller provides some before_filters (to make sure that the user has logged in, to make sure the user is valid, and to display a message of the day); it also provides helper methods for access control. class ApplicationController < ActionController::Base include Authentication before_filter :require_login before_filter :set_system_announcement before_filter :check_for_valid_user private # Feel free to remove or change this announcement when # customizing the application to your needs def set_system_announcement flash.now[:system_announcement] = "This is the <strong>Ajax on Rails Intranet</strong>, <br/> released as part of <a href=\"http://scottraymond.net/\"> <em>Ajax on Rails</em></a> from O’Reilly Media." end # Helper method to determine whether the current user can # modify +record+ def can_edit? record # admins can edit anything return true if current_user.admin? case record.class.to_s when'User' # regular users can't edit other users record.id == current_user.id when'Message' # messages can only be edited by their creators record.created_by == current_user.id else true # everyone can edit anything else end end helper_method :can_edit? # Helper method to determine whether the current user is # an administrator def admin?; current_user.admin?; end helper_method :admin? # Before filter to limit certain actions to administrators def require_admin unless admin? flash[:warning] = "Sorry, only administrators can do that." redirect_to messages_url end end # Before filter that insists the current user model is # valid generally just used when the first user is created. def check_for_valid_user if logged_in? and !current_user.valid? flash[:warning] = "Please create your administrator account" redirect_to edit_user_url(:id => current_user) return false end end end application_helper.rb defines more helper methods, for returning information about content types. page_title tries to infer a page title if a title isn't given explicitly. standard_form uses the labeling_form_helper; it exists to simplify the view templates. module ApplicationHelper # Returns the name of an icon (in public/images) for the # given content type def icon_for content_type case content_type.to_s.strip when "image/jpeg" "JPG" when "application/vnd.ms-excel" "XLS" when "application/msword" "DOC" when "application/pdf" "PDF" else "Generic" end end # Returns a textual description of the content type def description_of content_type case content_type.to_s.strip when "image/jpeg" "JPEG graphic" when "application/vnd.ms-excel" "Excel worksheet" when "application/msword" "Word document" when "application/pdf" "PDF file" else "" end end # Returns the name of the site (for the title and h1 elements) def site_title 'Intranet' end # If a page title isn't explicitly set with @page_title, it's # inferred from the post or user title def page_title return @page_title if @page_title return @post.name if @post and !@post.new_record? return @user.name if @user and !@user.new_record? '' end # Returns a div for each key passed if there's a flash # with that key def flash_div *keys divs = keys.select { |k| flash[k] }.collect do |k| content_tag :div, flash[k], :class => "flash #{k}" end divs.join end # Returns a div with the user's thumbnail and name def user_thumb user img = tag("img", :src => formatted_user_url(:id => user, :format =>'jpg'), :class =>'user_picture', :alt => user.name) img_link = link_to img, user_url(:id => user) text_link = link_to user.short_name, user_url(:id => user) content_tag :div, "#{img_link}<br/>#{text_link}", :class =>'user' end # Returns a div def clear_div '<div ></div>' end # Renders the form used for all post and user creating/editing. # Yields an instance of LabelingFormBuilder # (see lib/labeling_form_helper.rb). def standard_form name, object, &block url = { :action => object.new_record? ? "index" : "show" } html = { :class => "standard", :style => (@edit_on ? '' : "display: none;"), :multipart => true } concat form_tag(url, html) + "<fieldset>", block.binding unless object.new_record? concat '<input name="_method" type="hidden" value="put" />', block.binding end yield LabelingFormBuilder.new(name, object, self, {}, block) concat "</fieldset>" + end_form_tag, block.binding end # Standard submit button and delete link for posts and users def standard_submit name=nil, object=nil name = post_type unless name object = @post unless object delete_link = link_to("Delete", { :action =>'show' }, :method => :delete, :confirm => "Are you sure?", :class => "delete") submit_tag("Save #{name}") + (object.new_record? ? "" : (" or " + delete_link)) end end application.rhtml is a basic layout that includes links for navigating through the application. It includes links for signing in and out, plus CSS to create some tabbed navigation. The utility DIV is an Ajax sidebar that lists who is and who isn't logged in, shown in Figure C-2. This DIV uses Prototype's PeriodicalExecutor to send a "heartbeat" back to the server every 30 seconds. In response, the server sends of list of users who are logged in; this list is displayed by rendering the users/_statuses.rhtml partial. Figure 14-2. Changing presence statusThe utility DIV also creates a script.aculo.us Ajax InPlaceEditor to allow the user to modify his "away" message inline. <!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> <%= site_title + (page_title.blank? ? '' : " - #{page_title}") %> </title> <%= stylesheet_link_tag "application" %> <%= javascript_include_tag :defaults %> </head> <body > <%= flash_div :system_announcement %> <div > <h1><%= link_to site_title, home_url %></h1> <% if logged_in? and current_user.valid? %> <div > Signed in as <%= link_to current_user.first_name, user_url(:id => current_user), :class =>'strong stealth' %><br/> <%= link_to'Settings', edit_user_url(:id => current_user), :class =>'small subtle' %> <%= link_to'Sign Out', session_url(:id => session.session_id), :method => :delete, :class =>'small delete' %> </div> <ul > <li > <%= link_to "Messages", messages_url %> </li> <li > <%= link_to "Event Plans", plans_url %> </li> <li > <%= link_to "Documents", documents_url %> </li> <li > <%= link_to "Projects", projects_url %> </li> <li > <%= link_to "Contacts", contacts_url %> </li> </ul> <% end %> </div> <div > <%= flash_div :notice %> <% if logged_in? and current_user.valid? %> <div > <ul> <li> <%= link_to current_user.short_name, user_url(:id => current_user) %> <span > <%= current_user.status.blank? ? "(Click to set status)" : current_user.status %> </span> </li> </ul> <%= javascript_tag "new Ajax.InPlaceEditor('my_status', '#{user_url(current_user)}', {loadTextURL:'#{status_user_url(current_user)}', ajaxOptions:{method:'put'}, callback:function(form, value){ return'user[status]='+escape(value); }});" %> <%= render :partial => "users/statuses" %> <%= javascript_tag "new PeriodicalExecuter(function( ){ new Ajax.Updater('statuses', '#{statuses_users_url}', {method:'get'}); }, 30)" %> </div> <% end %> </div> <div > <%= flash_div :warning %> <%= content_tag :h2, page_title %> <%= yield %> </div> </body> </html> PostsController is the superclass for all the other controllers. It implements all the basic CRUD actions for posts. class PostsController < ApplicationController before_filter :find_post, :only => [ :show, :download, :edit, :update, :destroy ] before_filter :check_permissions, :only => [ :update, :destroy ] def index @page_title = post_type.pluralize @post = model.new @posts = model.find :all end def new @page_title = "New #{post_type}" @edit_on = true @post = model.new end def create @post = model.new params[:post] @post.creator = current_user @post.updated_by = @post.created_by if @post.save flash[:notice] ='Post successfully created.' redirect_to :action =>'index' else @page_title = "New #{post_type}" @edit_on = true render :action =>'new' end end def show end def download filename = @post.attachment_filename.split(/\\/).last send_data @post.attachment.content, :filename => filename, :type => @post.attachment_content_type, :disposition =>'attachment' end def edit @edit_on = true render :action =>'show' end def update post = params[:post].merge(:updated_by => current_user) if @post.update_attributes post flash[:notice] ='Your changes were saved.' redirect_to :action =>'show' else @edit_on = true render :action =>'show' end end def destroy @post.destroy flash[:notice] = "The post was deleted." redirect_to :action =>'index' end private # The name of the model associated with the controller. # Expected to be overridden. def model_name;'Post'; end # The'human name' of the model, if different from the actual # model name. def post_type; model_name; end helper_method :post_type # The model class associated with the controller. def model; eval model_name; end def find_post @post = model.find params[:id] end # Before filter to bail unless the user has permission to edit # the post. def check_permissions unless can_edit? @post flash[:warning] = "You can't edit that post." redirect_to :action =>'show' return false end end end The CommentsController is the first of many subclasses of the Posts controller. I'll move through the controllers quickly; they are fairly similar. The create action creates a comment, as shown in Figure C-3; it uses respond_to, which allows the form to work correctly even if the browser has JavaScript disabled. Figure 14-3. Adding a commentclass CommentsController < ApplicationController before_filter :find_post def index end # Handles both Ajax and regular form submissions def create @comment = Comment.new params[:comment] @comment.post_id = @post.id @comment.name = "Re: #{@post.name}" @comment.creator = current_user @comment.save respond_to do |format| format.html { flash[:notice] = "Comment saved." redirect_to :back } format.js { render :update do |page| page[:comments].reload end } end end def show @comment = @post.comments.find params[:id] end private def find_post @post = Post.find params[:post_id] end end The _comment.rhtml partial displays a single comment that already exists. <div > <%= user_thumb comment.creator %> <p ><%= time_ago_in_words comment.created_at %> ago</p> <%= simple_format comment.body %> <%= clear_div %> </div> The _comments.rhtml partial loops through the existing comments, displaying the _comment.rhtml partial for each. After listing all the comments, it provides an Ajax-enabled form for inserting a new comment. A fallback is included in case JavaScript is disabled. <div > <h2> <% if @post.comments.any? %> <%= pluralize @post.comments.size,'Comment' %> so far <% else %> Be the first to post a comment <% end %> </h2> <%= render :partial =>'comments/comment', :collection => @post.comments %> <%# Creates an Ajax-enabled form with a fallback to # regular form submission %> <% remote_form_for :comment, :url => comments_url(:post_type => params[:controller], :post_id => @post), :html => { :action => comments_url(:post_type => params[:controller], :post_id => @post) }, :before => "$('spinner').show( )", :complete => "$('spinner').hide( ); $('comment_body').value=''" do |c| %> <fieldset> <h3><%= current_user.name %> said...</h3> <p><%= c.text_area :body %></p> <p> <%= submit_tag "Post New Comment" %> <%= image_tag "spinner.gif", :style => "display: none;", :id => "spinner" %> </p> </fieldset> <% end %> </div> <%= render :partial =>'comments' %> <%= render :partial => "comment" %> Another subclass of Posts, it is used for managing contact recordssuch as customers, vendors, or partners. ContactsController inherits most of its behavior from PostsController. class ContactsController < PostsController # If params[:letter] is specified, only returns users whose # last names start with it def index @page_title = post_type.pluralize @post = model.new @posts = params[:letter] ? Contact.letter(params[:letter]) : Contact.find(:all) end private def model_name;'Contact'; end end The _form.rhtml partial is a form for entering contact info, as shown in Figure C-4. standard_form wraps form_for, extending it to include field labels automatically. Figure 14-4. Editing a contact<%# See +standard_form+ in application_helper.rb %> <% standard_form :post, @post do |f| %> <%= f.text_field :name %> <%= f.date_select :start_date, :label => "First Call" %> <%= f.date_select :end_date, :label => "Last Call" %> <%= f.text_field :phone %> <%= f.text_field :email %> <%= f.text_area :body, :label => "Notes" %> <%= standard_submit %> <% end %> The _post.rhtml partial renders one contact. <div > <div > <h4><%= link_to post.reversed_name, :action =>'show', :id => post %></h4> <p > <span > <%= link_to pluralize(post.comments_count,'comment'), :action =>'show', :id => post %> </span> <% if post.end_date %> Last called <%= time_ago_in_words post.end_date %> ago<% end %> </p> </div> <%= clear_div %> </div> This view renders all the contacts, as shown in Figure C-5, by using the _post.rhtml partial. Figure 14-5. Contacts list<div > <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <span>+ </span> <%= link_to_function "New #{post_type}", "PostForm.toggle( )", :class =>'create' %> </div> <%= render :partial =>'form' %> </div> <div > <div > <%= link_to "All", { :letter => "" }, :class => (params[:letter].blank? ?'active' : '') %> <% ('A'..'Z').each do |letter| %> <%= link_to letter, { :letter => letter }, :class => (params[:letter]==letter ?'active' : '') %> <% end %> </div> <%= render :partial => "post", :collection => @posts %> </div> A template that holds a form (defined by the _form.rhtml partial) for creating new contacts. <div > <%= render :partial =>'form' %> </div> A detailed view of one contact. The edit link swaps the plain view with the form view so that you can edit a contact. <div <% if @edit_on %><% end %>> <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <% if can_edit? @post %> <span> </span> <%= link_to_function "Edit", "PostForm.toggle( )", :class =>'create' %> <% end %> </div> <div > Posted <%= distance_of_time_in_words_to_now(@post.created_at) %> ago by <%= link_to @post.creator.name, user_url(@post.creator), {'class' =>'grey' } %> </div> <%= render :partial =>'form' %> <div > <p><strong>First call:</strong> <%= @post.start_date %></p> <p><strong>Last call:</strong> <%= @post.end_date %> (<%= time_ago_in_words @post.end_date %> ago)</p> <% unless @post.phone.blank? %> <p><strong>Phone:</strong> <%= @post.phone %></p> <% end %> <% unless @post.email.blank? %> <p><strong>Email:</strong> <%= mail_to @post.email %></p> <% end %> <%= simple_format @post.body %> </div> </div> <%= render :partial => "comments/comments", :comments => @post.comments %> Documents are simpler than contacts, and the controller behavior is essentially the same. Documents allow you to upload files, which are represented with the appropriate icons for their file type, as shown in Figure C-6. Figure 14-6. Documents list with appropriate iconsclass DocumentsController < PostsController private def model_name;'Document'; end end This partial allows you to upload a file. (The attachment is the actual binary.) <% standard_form :post, @post do |f| %> <%= f.text_field :name %> <% label = (@post.new_record? or !@post.attachment_id) ? "File to upload" : "File to upload (overwriting existing file)" %> <%= f.file_field :file, :label => label %> <%= f.text_area :body, :label => "Description" %> <%= standard_submit %> <% end %> <div > <div > <% icon = icon_for(post.attachment_content_type) %> <img src="/books/4/386/1/html/2//images/icon_<%= icon %>_big.gif" /> <h4> <%= link_to post.name, :action => post.attachment_id.nil? ?'show' :'download', :id => post %> </h4> <p > <span > <%= link_to pluralize(post.comments_count,'comment'), :action =>'show', :id => post %> </span> <%= description_of post.attachment_content_type %> <%= link_to'Edit', :action =>'edit', :id => post %> </p> </div> <%= clear_div %> </div> <div > <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <span>+ </span> <%= link_to_function "New #{post_type}", "PostForm.toggle( )", :class =>'create' %> </div> <%= render :partial =>'form' %> </div> <div > <%= render :partial => "post", :collection => @posts %> </div> Simply renders the _form.rhtml partial to allow creating a new document, as shown in Figure C-7. Figure 14-7. Creating a new document<div > <%= render :partial =>'form' %> </div> <div <% if @edit_on %><% end %>> <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <% if can_edit? @post %> <span> </span> <%= link_to_function "Edit", "PostForm.toggle( )", :class =>'create' %> <% end %> </div> <div > Posted <%= distance_of_time_in_words_to_now(@post.created_at) %> ago by <%= link_to @post.creator.name, user_url(:id => @post.creator), {'class' =>'grey' } %> </div> <%= render :partial =>'form' %> <div > <% if @post.attachment_id %> <% icon = icon_for(@post.attachment_content_type) %> <img src="/books/4/386/1/html/2//images/icon_<%= icon %>_big.gif" /> <h4><%= link_to @post.name, :action =>'download' %></h4> <p > <%= description_of @post.attachment_content_type %> </p> <% end %> <%= simple_format(@post.body) if @post.body.any? %> </div> </div> <%= render :partial => "comments/comments", :comments => @post.comments %> Again, messages are similar to contacts and documents. The home action of the messages controller is the default home page for the application, as was shown in Figure C-1. class MessagesController < PostsController # Default action for the app; might be changed to show a # dashboard-like view def home flash.keep redirect_to messages_url end def index super @post_pages, @posts = paginate :messages, :order_by =>'created_at desc', :per_page => 30 end private def model_name;'Message'; end end <% standard_form :post, @post do |f| %> <%= f.text_field :name, :label => "Subject" %> <%= f.text_area :body, :label => "Message body" %> <%= standard_submit %> <% end %> <div > <%= user_thumb post.creator %> <div > <h4> <%= link_to post.name, url_for(:action =>'show', :id => post) %> </h4> <p > <span > <%= link_to pluralize(post.comments_count,'comment'), :action =>'show', :id => post %> </span> <%= time_ago_in_words post.updated_at %> ago </p> <%= simple_format post.body %> </div> <%= clear_div %> </div> <div > <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <span>+ </span> <%= link_to_function "New #{post_type}", "PostForm.toggle( )", :class =>'create' %> </div> <%= render :partial =>'form' %> </div> <div > <%= render :partial => "post", :collection => @posts %> <%= pagination_links @post_pages %> </div> <div > <%= render :partial =>'form' %> </div> <div <% if @edit_on %><% end %>> <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <% if can_edit? @post %> <span> </span> <%= link_to_function "Edit", "PostForm.toggle( )", :class =>'create' %> <% end %> </div> <div > Posted <%= time_ago_in_words @post.created_at %> ago by <%= link_to @post.creator.name, user_url(:id => @post.creator), {'class' =>'grey' } %> </div> <%= render :partial =>'form' %> <div > <%= simple_format(@post.body) if @post.body.any? %> </div> </div> <%= render :partial => "comments/comments", :comments => @post.comments %> Plans are also similar to documents, messages, and contacts. Ajax is used in the form for building a plan, adding comments to a plan, and toggling back and forth between the show view and the edit view. Figure C-8 shows a list of upcoming event plans. Figure 14-8. Plans listclass PlansController < PostsController def index super @page_title = "Upcoming Event Plans" @this_week = Plan.this_week @next_three_weeks = Plan.next_three_weeks @beyond = Plan.beyond end private def model_name;'Plan'; end def post_type;'Event Plan'; end end <% standard_form :post, @post do |f| %> <%= f.text_field :name %> <%= f.date_select :start_date, :label => "Date" %> <%= f.text_area :body, :label => "Details" %> <%= standard_submit %> <% end %> <div > <div > <h4> <%= link_to post.name, :action =>'show', :id => post %> </h4> <p > <span > <%= link_to pluralize(post.comments_count,'comment'), :action =>'show', :id => post %> </span> <%= post.start_date.strftime "%a, %b %d" %> </p> </div> <%= clear_div %> </div> <div > <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <span>+ </span> <%= link_to_function "New #{post_type}", "PostForm.toggle( )", :class =>'create' %> </div> <%= render :partial =>'form' %> </div> <div > <h3>This week</h3> <%= render :partial => "post", :collection => @this_week %> <h3>Next three weeks</h3> <%= render :partial => "post", :collection => @next_three_weeks %> <h3>Beyond</h3> <%= render :partial => "post", :collection => @beyond %> </div> <div > <%= render :partial =>'form' %> </div> <div <% if @edit_on %><% end %>> <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <% if can_edit? @post %> <span> </span> <%= link_to_function "Edit", "PostForm.toggle( )", :class =>'create' %> <% end %> </div> <div > Posted <%= distance_of_time_in_words_to_now(@post.created_at) %> ago by <%= link_to @post.creator.name, user_url(:id => @post.creator), {'class' =>'grey' } %> </div> <%= render :partial =>'form' %> <div > <p> <strong> Date: <%= @post.start_date.strftime "%a, %b %d" %> </strong> </p> <%= simple_format(@post.body) if @post.body.any? %></div> </div> <%= render :partial => "comments/comments", :comments => @post.comments %> Projects is the last of the Posts subclasses. There's nothing happening here that you haven't seen already. Figure C-9 shows the form for adding a new project. Figure 14-9. New project formclass ProjectsController < PostsController private def model_name;'Project'; end end <% standard_form :post, @post do |f| %> <%= f.text_field :name %> <%= f.text_field :phone %> <%= f.text_field :email %> <%= f.text_area :body, :label => "Details" %> <%= standard_submit %> <% end %> <div > <%= user_thumb post.creator %> <div > <h4><%= link_to post.name, url_for(:action =>'show', :id => post) %></h4> <p > <span > <%= link_to pluralize(post.comments_count,'comment'), :action =>'show', :id => post %> </span> <%= time_ago_in_words post.updated_at %> ago </p> <%= simple_format post.body %> </div> <%= clear_div %> </div> <div > <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <span>+ </span> <%= link_to_function "New #{post_type}", "PostForm.toggle( )", :class =>'create' %> </div> <%= render :partial =>'form' %> </div> <div > <%= render :partial => "post", :collection => @posts %> </div> <div > <%= render :partial =>'form' %> </div> <div <% if @edit_on %><% end %>> <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <% if can_edit? @post %> <span> </span> <%= link_to_function "Edit", "PostForm.toggle( )", :class =>'create' %> <% end %> </div> <div > Posted <%= time_ago_in_words(@post.created_at) %> ago by <%= link_to @post.creator.name, user_url(:id => @post.creator), {'class' =>'grey' } %> </div> <%= render :partial =>'form' %> <div > <%= simple_format(@post.body) if @post.body.any? %> </div> </div> <%= render :partial => "comments/comments", :comments => @post.comments %> Sessions handle login and logout. The new action displays the sign in form; the create action processes the form. class SessionsController < ApplicationController before_filter :create_first_user, :only => :new skip_before_filter :require_login filter_parameter_logging :password def new redirect_to home_url if logged_in? @user = User.new end def create if user = User.authenticate(params[:session][:email], params[:session][:password]) reset_session session[:user_id] = user.id redirect_back_or_default home_url flash[:notice] = "Signed in successfully" else flash[:warning] = "There was a problem signing you in. Please try again." @user = User.new render :action =>'new' end end def destroy reset_session flash[:notice] = "You have been signed out." redirect_to new_session_url end private # Before filter that automatically creates a recordand signs # in for the first user of the system def create_first_user return true unless User.count == 0 user = User.new :admin => 1 user.save_with_validation false session[:user_id] = user.id redirect_to home_url end end Figure C-10 shows the sign-in form generated by new.rhtml. It posts to the create action, which does the processing. Figure 14-10. Sign-in form<% @page_title = "Sign In" %> <% form_for :session, @user, :url => sessions_url, :html => { :class => "standard", :style => "width: 250px" }, :builder => LabelingFormBuilder do |f| %> <fieldset> <%= f.text_field :email %> <%= f.password_field :password %> <%= submit_tag'Sign in' %> </fieldset> <% end %> UsersController supports sign up (i.e., creation of a new user) and editing a user profile. The statuses action is key to the application's presence indicator. This action is invoked repeatedly by the application.rhtml layout. Whenever the action is invoked, we record that the user is online and render a partial that lists everyone else's status. class UsersController < ApplicationController before_filter :require_admin, :only => [ :new, :create ] before_filter :find_user, :only => [ :show, :status, :edit, :update, :destroy ] before_filter :check_permissions, :only => [ :edit, :update, :destroy ] skip_before_filter :check_for_valid_user, :only => [ :edit, :update ] filter_parameter_logging :password def index @users = User.find :all @page_title = "Users" @user = User.new end def new @page_title = "New User" @user = User.new @edit_on = true end def statuses current_user.update_attributes :last_active => Time.now render :partial =>'statuses' end def create if @user = User.create(params[:user]) flash[:notice] ='User was successfully saved.' redirect_to user_url(:id => @user) else render :action =>'index' end end def show if params[:format]=='jpg' if @user.has_picture? send_data @user.picture.content, :filename => "#{@user.id}.jpg", :type =>'image/jpeg', :disposition =>'inline' else send_file RAILS_ROOT+'/public/images/default_user.jpg', :filename => "#{@user.id}.jpg", :type =>'image/jpeg', :disposition =>'inline' end return end end def status render :text => @user.status end def edit @edit_on = true render :action =>'show' end def update success = @user.update_attributes params[:user] respond_to do |format| format.html { if success flash[:notice] ='User was successfully updated.' redirect_to user_url else @edit_on = true render :action =>'show' end } format.js { render :text => @user.status.blank? ? "(none)" : @user.status } end end def destroy @user.destroy flash[:notice] = "User deleted." redirect_to users_url end private def post_type; "User"; end helper_method :post_type def find_user @user = User.find params[:id] end def check_permissions return false unless can_edit? @user end end The _form.rhtml partial lets you edit a profile, as shown in Figure C-11. Figure 14-11. Editing a user's profile<% standard_form :user, @user do |f| %> <%= f.text_field :name %> <%= f.password_field :password %> <%= f.text_field :email %> <%= f.text_field :phone %> <%= f.text_field :address %> <%= f.text_field :city %> <%= f.text_field :state %> <%= f.text_field :zip %> <%= standard_submit "User", @user %> <% end %> The _statuses.rhtml partial is rendered to application.rhtml's sidebar to show which users are online at any given time. <%# This query is put here so that the partial # can easily be included in any view %> <% users = User.find(:all, :conditions => ["id!=?", current_user.id]) %> <% if users.any? %> <ul > <% users.each do |user| %> <li <% if user.inactive? %><% end %>> <%= link_to user.short_name, user_url(:id => user.id) %> <span><%=h user.status %></span> </li> <% end %> </ul> <% end %> The index.rhtml view renders a list of users, shown in Figure C-12, and allows an administrator to add new users. Figure 14-12. Users list<div > <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <% if admin? %><div > <span>+ </span> <%= link_to_function "New #{post_type}", "PostForm.toggle( )", :class =>'create' %> </div><% end %> <%= render :partial =>'form' %> </div> <table style="margin-top: 20px;"> <% for user in @users %> <tr> <td><strong> <%= link_to user.name, user_url(:id => user) %> </strong></td> <td><%= user.email %></td> <td><%= user.phone %></td> </tr> <% end %> </table> This template renders the form for creating a new user, with the _form.rhtml partial. <div > <%= render :partial =>'form' %> </div> The show.rhtml template is used both to display a user's data, and to edit that data. If you're allowed to edit the user, you can switch to the form view and change the user's profile. <div <% if @edit_on %><% end %>> <div > <%= link_to_function "Cancel", "PostForm.toggle( )", :class =>'delete small' %> </div> <div > <% if can_edit? @user %> <span> </span> <%= link_to_function "Edit", "PostForm.toggle( )", :class =>'create' %> <% end %> </div> <%= render :partial =>'form' %> <div > <div > <%= image_tag formatted_user_url(:id => @user, :format =>'jpg'), :class =>'user_picture', :alt => @user.name %> <p><%= link_to_function'Change Picture', "$('picture_form').toggle( )" %></p> <form style="display: none;" method="post" enctype="multipart/form-data" action="<%= url_for(:action =>'show') %>"> <fieldset> <input type="hidden" name="_method" value="put" /> <input type="file" name="user[file]" /> <input type="submit" value="Upload" /> </fieldset> </form> </div> <p><strong>Email:</strong> <%= @user.email %></p> <% unless @user.phone.blank? %> <p><strong>Phone:</strong> <%= @user.phone %></p> <% end %> <p> <%= @user.address %><br/> <%= @user.city %> <% unless @user.city.blank? %>, <% end %> <%= @user.state %> <%= @user.zip %> </p> </div> </div> The PostForm class provides a visual effect when the user clicks an edit link: the form slides in over the content. var PostForm = { toggle: function( ) { var container = $('form_container'); var form = $$('#form_container form').first( ); if(container.hasClassName('active')) { form.visualEffect('blind_up', { duration: 0.25, afterFinish: function( ){ container.removeClassName('active'); } }); } else { form.visualEffect('blind_down', { duration: 0.5, beforeStart: function( ){ container.addClassName('active'); } }); } } } The stylesheet is included for completeness. /* Basics */ /* --------------------------------------------------------- */ */* */ { color: inherit; font: inherit; margin: 0; list-style: none; padding: 0; text-decoration: none; } body { background-color: #fff; background-repeat: repeat-y; color: #333; } body, p, ol, ul, td { font-family: verdana, arial, helvetica, sans-serif; font-size: 11px; line-height: 14px; } p { margin-bottom: 8px; } ul li { list-style-type: disc; } ul, ol { margin: .5em 0 .5em 2em; } ol li { list-style-type: decimal; } fieldset { border: none; } strong, b { font-weight: bold; } em { font-style: italic; } .strong { font-weight: bold; } .small { font-size: 10px; } #main { float: left; position: relative; left: -2px; top: 24px; padding-right: 30px; width: 575px; padding-bottom: 50px; } #utility { width: 170px; padding: 45px 10px 20px 18px; float: left; height: 100%; } div.clear { clear: both; margin-top: 1px; display: block; } /* Links */ /* ------------------------------------------------------------ */ a { color: #264764; text-decoration: underline; } a:visited { color: #264764; } a:hover { color: #fff; background-color: #264764; text-decoration: none; } a.stealth { color: #000; text-decoration: none; } a:hover.stealth { background-color: #000; color: #fff; } a.subtle { color: #666; text-decoration: underline; } a:hover.subtle { background-color: #666; color: #fff; } a.delete { color: #c00; text-decoration: underline; } a:hover.delete { background-color: #c00; color: #fff; } a.create { color: #009900; text-decoration: underline; } a:hover.create { background-color: #009900; color: #fff; } /* Headers */ /* ---------------------------------------------------------- */ #header { height: 92px; background-color: #E0E6EF; border-bottom: 1px solid #888; } #header h1 { font-family: futura; font-size: 30px; float: left; height: 92px; width: 181px; xbackground-image: url('/images/logo.gif'); xtext-indent: -1000px; /* or */ height: 37px; padding-top: 55px; width: 136px; padding-left: 45px; } #header h1 a { text-decoration: none; } #header #account { float: right; text-align: right; font-family: verdana; font-size: 11px; color: #333; margin-right: 8px; margin-top: 15px; line-height: 14px; } #main h2 { font-family: trebuchet ms; font-size: 18px; font-weight: normal; color: #264764; margin-bottom: 0px; border-bottom: 1px solid #B8B8B8; width: 569px; padding-bottom: 8px; clear: both; } h3 { font-size: 12px; font-weight: bold; margin-top: 10px; margin-bottom: 0; background-color: #eee; padding: 3px 0 3px 5px; border-bottom: 1px solid #ddd; } h4 { font-size: 11px; font-weight: bold; margin-top: 10px; margin-bottom: 2px; } /* Warnings and notices */ /* ----------------------------------------------------------- */ .flash.notice { background-color: #ffc; padding: .5em; border-top: 1px solid #dda; border-bottom: 1px solid #dda; margin: 0 30px 1.5em 0; } .flash.warning { background-color: #c22; padding: .5em; border-top: 1px solid #600; border-bottom: 1px solid #600; margin: 0em 0 2em 0em; color: #fff; font-weight: bold; } .flash.warning a { color: #fff; } .flash.system_announcement { padding: 5px; background-color: #EFF3AB; border-bottom: 1px solid #898989; color: #444; text-align: center; height: 30px; } /* Navigation */ /* ------------------------------------------------------------- */ ul#nav { margin: 0; position: relative; left: 15px; top: 67px; } html>body ul#nav { top: 68px; } /* non-iewin */ ul#nav li { display: inline; height: 30px; font-size: 12px; line-height: 26px; font-family: helvetica, arial; margin-right: 5px; padding: 3px 4px 5px 7px; } html>body ul#nav li { padding: 3px 7px 4px 7px; } /* non-iewin */ body.messages li#messages, body.plans li#plans, body.documents li#documents, body.projects li#projects, body.contacts li#contacts { background-color: #fff; border: 1px solid #888; border-bottom: 1px solid #fff; } ul#nav li a { text-decoration: none; color: #555; } ul#nav li a:hover { background-color: transparent; text-decoration: none; } ul#nav li:hover a { text-decoration: none; color: #000; } ul#nav li:hover { text-decoration: underline; } /* Statuses */ /* ------------------------------------------------------------ */ #status ul li { list-style-type: none; margin-bottom: 5px; font-weight: bold; } #status ul li span { font-weight: normal; display: block; font-style: italic; } #status ul { margin-left: 0; } #status ul li a { text-decoration: none; } #status ul li.inactive, #status ul li.inactive a { color: #777; } /* Post container */ /* ------------------------------------------------------------- */ #form_container { padding: 6px 10px 15px 12px; width: 545px; margin-bottom: 0px; } #form_container.active { background: #EEF8ED; border-left: 1px solid #89B989; border-right: 1px solid #89B989; border-bottom: 1px solid #89B989; } #form_container #new_link { float: left; color: #009900; font-weight: bold; margin-left: -12px; } #form_container.active #new_link span { visibility: hidden; } #form_container #cancel_link { visibility: hidden; float: right; } #form_container.active #cancel_link { visibility: visible; } #form_container.active #new_link a { text-decoration: none; } #form_container #meta { float: left; margin-left: 10px; } #form_container #detail { clear: left; padding-top: 20px; } #form_container.active #detail { display: none; } /* Standard form */ /* ------------------------------------------------------------- */ form.standard { clear: left; margin-top: 10px; margin-left: 15px; padding-top: 10px; width: 510px; } form.standard label { font-weight: bold; display: block; margin-bottom: 3px; font-size: 12px; font-family: verdana; } form.standard input, form.standard textarea { width: 100%; display: block; margin-bottom: 10px; } form.standard input#post_name, form.standard input#user_name { font-size: 18px; font-weight: bold; } form.standard .fieldWithErrors { border-left: 4px solid #c00; padding-left: 3px; } form.standard label span.error { color: #c00; } form.standard input[type='submit'] { width: 100px; display: inline; } form.standard textarea { height: 150px; } form.standard select { margin-bottom: 10px; } /* Body details */ /* ------------------------------------------------------------ */ .post_detail { background-color: #eee; border: 1px solid #ccc; padding: 12px; width: 508px; } #main div.post { margin-top: 11px; margin-bottom: 25px; margin-left: 1px; } #main div.post div.user { width: 60px; float: left; text-align: center; margin-right: 10px; margin-top: 4px; font-size: 10px; } img.user_picture { text-decoration: none; background-color: #fff; margin-bottom: -2px; width: 60px; height: 60px; border: 1px solid #666; padding: 2px; } #main div.post h3 { font-weight: bold; font-size: 11px; padding-top: 2px; padding-left: 1px; } #main div.post h3 span { color: #666; font-weight: normal; margin-left: 8px; } #main div.post p.meta { color: #666; font-size: 10px; margin-bottom: 5px; } #main img.icon { float: left; width: 32px; height: 32px; padding-right: 8px; } #main div.post h3 span a { color: #666; font-weight: normal; } #main div.post h3 span a:hover { background-color: #666; color: #fff; } #main div.post div.body { margin-left: 70px; } #main div.post div.no_user { margin-left: 0px; } #main div.post p.meta span.comments { float: right; font-size: 11px; } #main p.meta { color: #666; margin-bottom: 10px; } #letter_links { margin-top: 20px; margin-bottom: 20px; } #letter_links a { background-color: #ffa; padding: 3px 4px; margin: 0px 1px; border: 1px solid #dd9; text-decoration: none; } #letter_links a:hover, #letter_links a.active { color: #000; background-color: #dd9; border: 1px solid #cc7; } /* Comments */ /* ------------------------------------------------------------- */ #comments { background-color: #eee; width: 500px; margin: 3em 0 1em 0; padding: 0 0 1em 0; width: 100%; } #comments h2 { background-color: #777; width: 100%; color: #fff; font-size: 1em; font-weight: bold; padding: 3px 0px 3px 3px; line-height: 1em; border-bottom: 1px solid #555; } #comments form, #comments div.post { margin: 1em 0 1em 1em; background-color: #eee; } #comments form textarea { width: 90%; height: 80px; } #comments h3 { font-size: 1em; } #comments input { float: left; } #comments p img { margin-top: 1px; margin-left: 10px; } /* User list */ /* ----------------------------------------------------------- */ table#posts { width: 100%; } table#posts td { margin: 0; padding: 4px; border-bottom: 1px solid #ccc; } /* User#show */ /* ----------------------------------------------------------- */ body.user_show #main img { float: left; margin-right: 20px; border: 1px solid #ccc; padding: 3px; width: 80px; height: 80px; margin-bottom: 100px; } #change_picture { float: right; width: 120px; padding: 15px; text-align: center; border: 1px solid #ccc } body.user_show #main { width: 600px; } body.user_show #main h3 { clear: left; margin-bottom: 10px; } body.user_show #main ul#lookuplinks li { display: inline; margin-right: 10px; } #change_picture img { border: 1px solid black; } |