Tag Type-Ahead with Multiple Items
August 6th, 2007
If you've ever implemented type-ahead functionality and wondered how to get it to work when users are entering multiple items, like a list of comma delimited tags, here it is.
You can, of course, roll your own Javascript and Ajax calls but why bother when Rails gives us a nice Javascript helper to handle this. The helper is text_field_with_auto_complete but this defaults to the current controller. For my purposes tags can be entered on different areas of the site for different models so I used text_field_with_auto_complete with one modification:
def text_field_with_auto_complete_with_custom_url(object, method, url_options = {}, tag_options = {}, completion_options = {})
(completion_options[:skip_style] ? "" : auto_complete_stylesheet) +
text_field(object, method, tag_options) +
content_tag("div", "", :id => "#{object}_#{method}_auto_complete", :class => "auto_complete") +
auto_complete_field("#{object}_#{method}", { :url => { :action => "auto_complete_for_#{object}_#{method}" }.update(url_options) }.update(completion_options))
end
Note: text_field_with_auto_complete is deprecated and will be moved to a plugin in Rails 2.0.
The third parameter is now a hash of optional url arguments (action, controller, etc). You can also monkey patch the original method or better use some alias/alias_method_chain trickery in your own app. In your view call the newly created helper with your arguments.
<label for="tag_name">Tags:</label>
<%= text_field_with_auto_complete_with_custom_url :tag, :names,
{:controller => "tagging_demo"}, {}, { :tokens => ','} %>
Include the necessary javascript libraries (prototype and scriptaculous):
<%= javascript_include_tag 'prototype' %>
<%= javascript_include_tag 'scriptaculous' %>
You can set the controller and action to whatever you like but it defaults to a method in the form of auto_complete_for_object_method. Going with that your action could look something like this:
def auto_complete_for_tag_names
@tags = Tag.find(:all,
:conditions => ["name like ?", "%#{params[:tag][:names]}%"],
:order => 'name DESC', :limit => 20)
render :layout => false
end
And your view (auto_complete_for_tag_names.html.erb):
<ul class="tags">
<% for tag in @tags do -%>
<li class="tag"><%= tag.name -%></li>
<% end -%>
</ul>
That's all there is to it. The magic comes from providing a token to Ajax.Autocompleter which we provided in the argument hash to text_field_with_auto_complete_with_custom_url ( { :tokens => ','} ). You can use any delimiter you like. You can also customize the UI in your view and CSS.
Here's a demo http://www.naffis.com/demos/tagging_demo
Tracking Views in Rails
May 22nd, 2007
ActsAsViewable is plugin that allows you to track page and asset views in your Rails application. For example, you can use it to track how many times a page is visited or how many times a particular image is viewed.
Trac: http://trac.intridea.com/trac/public/wiki/ActsAsViewable
Subversion repository: http://svn.intridea.com/svn/public/acts_as_viewable
Installation:script/plugin install http://svn.intridea.com/svn/public/acts_as_viewable
OR
cd vendor/plugins
svn co http://svn.intridea.com/svn/public/acts_as_viewable
Create the tables where views will be tracked:
class CreateViewings < ActiveRecord::Migration
def self.up
create_table :viewings do |t|
t.column :viewable_type, :string
t.column :viewable_id, :integer
t.column :views, :integer, :default => 0
t.column :created_at, :datetime, :null => false
t.column :updated_at, :datetime
end
end
def self.down
drop_table :viewings
end
end
Set the objects you want to track views for:
class SomeAsset < ActiveRecord::Base
acts_as_viewable
end
Now you can increment views for these objects wherever you need to. For example in the show action of our SomeAssetController:
class SomeAssetController < ApplicationController
def show
@some_asset = SomeAsset.find(params[:id])
@some_asset.increment_views
end
end
To get the number of views:
@some_asset.views
Automatically Expiring Sessions in Rails
May 22nd, 2007
SessionExpiration is plugin that allows you to expire sessions after X seconds of inactivity. Useful for when you want to automatically log out users if they’re idle.
Trac: http://trac.intridea.com/trac/public
Subversion repository: http://svn.intridea.com/svn/public/session_expiration/
Installation:script/plugin install http://svn.intridea.com/svn/public/session_expiration
OR
cd vendor/plugins
svn co http://svn.intridea.com/svn/public/session_expiration
Specify when to expire session in your ApplicationController to do it site wide or you can do it for specific controllers:
class ApplicationController
expire_session_in 5.minutes
end
If you want to run a method when the session expires use this:
class ApplicationController
expire_session_in 5.minutes, :after_expiration => :some_method
def some_method
flash[:notice] = "You have been logged out due to inactivity"
end
end
Ajax uploads? Image manipulation & drag-and-drop sorting.
December 11th, 2006
Wouldn’t it be nice to allow uploads in a cool Ajaxy way? Well, because of security restrictions it’s just not possible. There are however ways to create the same effect.
Here’s a quick demo of an ajax-ish image upload as well as some image manipulation functionality, and drag and drop sorting. I’m not sure this will work on all browsers but it’s been tested successfully with most. This was created about 4 months ago and I never had time to polish any of it up so take what you can from it.
http://www.naffis.com/demos/image_demo
First our layout (layouts/image_demo.rhtml):
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/strict.dtd">
<html>
<head>
<title>Image Demo</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<%= javascript_include_tag :defaults %>
<%= stylesheet_link_tag 'image_demo' %>
</head>
<body>
<div id="titlebar">Image Demo</div>
<%= render :partial => "upload_form" %>
<div id="centercontent">
<%= yield %>
</div>
<div id="next">
<%= link_to "Create Animated Gif", :action => "animate" %>
</div>
<div id="bottom">
© naffis.com 2006
</div>
</body>
</html>
We’re going to extend the form_remote_tag to handle file uploads.
Drop this in your lib directory (lib/remote_uploads.rb):
module ActionView
module Helpers
module PrototypeHelper
alias_method :form_remote_tag_old, :form_remote_tag
def form_remote_tag(options = {})
if options[:html] && options[:html][:multipart]
uid = "a#{Time.now.to_f.hash}"
<<-STR
<iframe name="#{uid}" id="#{uid}" src="about:blank" style="position:absolute;left:-100px;width:0px;height:0px;border:0px"></iframe>
<form method="post" action="#{url_for options[:url].update({:iframe_remote => true})}" enctype="multipart/form-data" target="#{uid}" #{%(onsubmit="#{options[:loading]}") if options[:loading]}>
STR
else
form_remote_tag_old(options)
end
end
end
end
end
Add the require in your environment.rb:
require 'remote_uploads.rb'
This will create a custom form for file uploads (multipart => true) that submits to a hidden iframe. If it’s not a file upload then it will revert to the standard form_remote_tag of PrototypeHelper.
Some boring half baked styles for our demo:
body {
background-color: #FFFFFF;
background-image: url(/maps/images/gradient.jpg);
background-repeat: no-repeat;
color: #666666;
font-family: arial, sans;
font-size: 100%;
line-height: 1.7em;
margin: 1em 2em;
}
#titlebar {
font-size: 1.2em;
border-bottom: 2px solid #333333;
margin-bottom: 1em;
padding-bottom: 1em;
}
h2 {
font-size: 1.2em;
}
ul.navigation {
background-color: #333333;
padding: 0em 0.5em;
list-style-type: none;
}
ul.navigation li {
border-right: 1px solid #666666;
display: inline;
}
.navigation a {
color: #FFFFFF;
padding: 0.5em;
}
.description {
font-size: 1.2em;
}
.upload {
font-size: 1.2em;
}
strong {
background-color: #FFFF99;
}
#centercontent {
width: 100%;
text-align: center;
margin-bottom: 1em;
padding-bottom: 1em;
margin-top: 1em;
padding-top: 1em;
}
#bottom {
width: 100%;
float: left;
text-align: center;
border-top: 2px solid #333333;
margin-top: 1em;
padding-top: 1em;
}
div.float {
width: 120px;
padding: 10px;
float: left;
}
div.spacer {
clear: both;
}
div.float img {
margin-left: 5px;
}
div.float p {
font-size: 9px;
text-align: center;
}
#image-list ul {
list-style: none;
}
#image-list ul li {
list-style: none;
display: inline;
float: left;
width: 120px;
height: 120px;
padding: 10px;
border: 1px solid #000;
}
We’re using Sean Treadway’s responds_to_parent plugin (http://sean.treadway.info/svn/plugins/responds_to_parent/) to execute our RJS generated javascript in the parent window instead of the iframe which the file upload is submitted to. There are other ways of doing this that use less code but the plugin is simple so why not use it?
Everything from this point on is pretty self explanitory. I can expand on it later but here’s the rest of the code.
Our index:
<div id="image-list">
<ul id="sortable_list">
<% for @asset in @assets %>
<%= render :partial => "image_container", :locals => { :asset => @asset } %>
<% end %>
</ul>
</div>
<%= sortable_element('sortable_list', :constraint => false, :url => {:action => :update_positions}) %>
Some partials used above:
_image_container.rhtml
<li id="item_<%= @asset.id %>" class="float">
<%= render :partial => "image_thumb", :locals => { :asset => @asset } %>
</li>
_image_thumb.rhtml
<%= image_tag @asset.thumbnail, :border => 2 %>
<br>
<%= link_to_remote(image_tag("arrow_rotate_anticlockwise.png", :border => 0), :url => {:action => "rotate", :id => @asset.id, :direction => "left"} ) %>
<%= link_to_remote(image_tag("cross.png", :border => 0), :url => {:action => "remove", :id => @asset.id} ) %>
<%= link_to_remote(image_tag("arrow_rotate_clockwise.png", :border => 0), :url => {:action => "rotate", :id => @asset.id, :direction => "right"} ) %>
_upload_form.rhtml
<%= form_remote_tag(:url => {
:controller => "image_demo",
:action => "create" },
:html => {:multipart => true}) %>
<b>Picture:</b>
<%= file_field_tag "asset" %>
<%= submit_tag "Upload" %>
<%= end_form_tag %>
Our RJS to handle the create, remove, and rotate.
create.rjs
if @asset.new_record?
page.alert "There was a problem uploading your file:\n" +
@asset.errors.full_messages.join("\n")
else
page.insert_html :top, 'sortable_list', :partial => 'image_container', :locals => { :asset => @asset }
page.visual_effect :highlight, "item_#{@asset.id}"
page.sortable "sortable_list", :constraint => false, :url => { :action => :update_positions }
end
remove.rjs
page.remove "item_#{@asset_id}"
page.sortable "sortable_list", :constraint => false, :url => { :action => :update_positions }
rotate.rjs
page.replace_html "item_#{@asset.id}", :partial => 'image_thumb', :locals => { :asset => @asset }
page.visual_effect :highlight, "item_#{@asset.id}"
page.sortable "sortable_list", :constraint => false, :url => { :action => :update_positions }
Our controller:
class ImageDemoController < ApplicationController
layout 'image_demo'
def index
session[:uid] = Time.now.to_i unless session[:uid]
@assets = Asset.find(:all,
:conditions => ["user_id = ?", session[:uid].to_i],
:order => "position")
end
def create
@asset = Asset.new()
@asset.uploaded_file = params['asset']
@asset.position = 0
@asset.user_id = session[:uid].to_i
@asset.save
responds_to_parent do
render :action => 'create.rjs'
end
return
end
def list
@assets = Asset.find(:all,
:conditions => ["user_id = ?", session[:uid].to_i],
:order => "position")
end
def update_positions
params[:sortable_list].each_with_index do |id, position|
Asset.update(id, :position => position)
end
render :nothing => true
end
def rotate
@asset = Asset.find(params[:id])
degrees = params[:direction] == "left" ? -90 : 90
@asset.rotate(degrees)
end
def remove
@asset_id = params[:id]
Asset.delete(@asset_id)
end
end
Our asset model:
require 'RMagick'
class Asset < ActiveRecord::Base
def uploaded_file=(incoming_file)
content_type = incoming_file.content_type.chomp
if content_type.rindex(/image\/[(jpe?g)||(gif)]/)
self.name = base_part_of(incoming_file.original_filename)
base_dir = "/some/path/you/like"
# save original file
self.original = "image_demo_assets/o_#{Time.now.utc.to_i}#{rand(1000000)}."+self.name
File.open(base_dir+self.original,File::CREAT|File::TRUNC|File::WRONLY,0666){ |f|
f.write(incoming_file.read)
}
self.resized = "image_demo_assets/r_#{Time.now.utc.to_i}#{rand(1000000)}."+self.name
resized = Magick::Image.read(base_dir+self.original).first
resized.change_geometry!('500x500') { |cols, rows, img|
img.resize!(cols, rows)
}
resized.write(base_dir+self.resized)
self.thumbnail = "image_demo_assets/t_#{Time.now.utc.to_i}#{rand(1000000)}."+self.name
thumb = Magick::Image.read(base_dir+self.original).first
thumb.change_geometry!('100x100') { |cols, rows, img|
img.resize!(cols, rows)
}
thumb.write(base_dir+self.thumbnail)
self.save
end
end
def rotate(degrees)
base_dir = "/some/path/you/like"
#main photo
image = Magick::ImageList.new(base_dir+self.original)
image = image.rotate(degrees)
image.write(base_dir+self.original)
# resized
resized = Magick::ImageList.new(base_dir+self.resized)
resized = resized.rotate(degrees)
resized.write(base_dir+self.resized)
# thumb
thumb = Magick::ImageList.new(base_dir+self.thumbnail)
thumb = thumb.rotate(degrees)
thumb.write(base_dir+self.thumbnail)
end
private
def base_part_of(file_name)
name = File.basename(file_name)
name.gsub(/[^W._-]/, '')
sanitize_filename(name)
end
# Fixes a 'feature' of IE where it passes the entire path instead of just the filename
def sanitize_filename(value)
#get only the filename (not the whole path)
just_filename = value.gsub(/^.*(\\|\/)/, '')
just_filename.gsub(/[^\w\.\-]/,'_')
end
end
Some suggestions:
- Use form_for and get rid of some ugliness in the controller by using Asset.new(params[:asset]) instead of setting each value individually.
- Use simply_helpful for generiting your DOM id’s.
- Use acts_as_attachment for handing the storing of files.
- Better validations (aaa will handle that too).
- Rewrite the whole thing.
Again, this is a VERY quick-and-dirty demo written in about 20 minutes with so much room for improvement. If I had the time I would, but alas I hope it helps.
Problem with has_many :through
October 21st, 2006
I recently ran into a problem using has_many :through relationships. The edge code works fine when using standard id’s but for those using legacy databases or non-standard id’s in your join table the code fails when trying to add or delete an association.
Something like this would fail:
create_table :books, :force => true do |t|
t.column :name, :string
end
create_table :citations, :id => false, :force => true do |t|
t.column :book1_id, :integer
t.column :book2_id, :integer
end
class Book < ActiveRecord::Base
has_many :citations, :foreign_key => 'book1_id'
has_many :references, :through => :citations, :source => :reference_of, :uniq => true
end
class Citation < ActiveRecord::Base
belongs_to :reference_of, :class_name => "Book", :foreign_key => :book2_id
belongs_to :book1, :class_name => "Book", :foreign_key => :book1_id
belongs_to :book2, :class_name => "Book", :foreign_key => :book2_id
end
awdr = Book.create!(:name => "Agile Web Development with Rails")
rfr = Book.create!(:name => "Ruby for Rails")
awdr.references << rfr
awdr.delete(rfr)
There’s further information at http://dev.rubyonrails.org/ticket/6466
If you’re running into this problem you can patch your local version of rails. First freeze edge in your tree. Then create the file has_many_through_patch.rb in your lib directory with the following code:
module ActiveRecord
class HasManyThroughCantDisassociateNewRecords < ActiveRecordError #:nodoc:
def initialize(owner, reflection)
super("Cannot disassociate new records through '#{owner.class.name}##{reflection.name}' on '#{reflection.source_reflection.class_name rescue nil}##{reflection.source_reflection.name rescue nil}'. Both records must have an id in order to delete the has_many :through record associating them.")
end
end
module Associations
class HasManyThroughAssociation
# Construct attributes for :through pointing to owner and associate.
def construct_join_attributes(associate)
construct_owner_attributes(@reflection.through_reflection).merge(@reflection.source_reflection.primary_key_name => associate.id)
end
# Remove +records+ from this association. Does not destroy +records+.
def delete(*records)
return if records.empty?
records.each { |associate| raise_on_type_mismatch(associate) }
through = @reflection.through_reflection
raise ActiveRecord::HasManyThroughCantDisassociateNewRecords.new(@owner, through) if @owner.new_record?
load_target
klass = through.klass
klass.transaction do
flatten_deeper(records).each do |associate|
raise_on_type_mismatch(associate)
raise ActiveRecord::HasManyThroughCantDisassociateNewRecords.new(@owner, through) unless associate.respond_to?(:new_record?) && !associate.new_record?
@owner.send(@reflection.through_reflection.name).proxy_target.delete(klass.delete_all(construct_join_attributes(associate)))
@target.delete(associate)
end
end
self
end
end
end
end
Then in your environment.rb add the following:
require 'has_many_through_patch'
You should be able to add and delete now until the patch is committed.