Wolfmans Howlings

A programmers Blog about Ruby, Rails and a few other issues

Paginating acts_as_taggable with will_paginate

Posted by Jim Morris Mon, 30 Jul 2007 22:04:50 GMT

A question I see asked a lot is how do I paginate acts_as_taggable (on steroids)?

I haven't seen any answers I liked, so I created my own, which I'm sure a few people won't like either ;) But it works for me (tm).

I use will_paginate, but this does not work with custom finds that plugins define themselves, as is the case with acts_as_taggable.

If you do use a custom find_by_sql you have to hit the database twice, once to find the total number of items and then the paginated find.

I have a situation where I generate a tag cloud with every page, and part of that tag cloud has already calculated the number of tags for each classification I use.

I combined this with the paginator gem to get myself pages without too many hits to the database.

The first thing I do is pass the total count I get from the tag cloud to the action that renders the index for all those items matching the cloud... If you look at the article cited above I make this modification, this is dumbed down a bit for the sake of simplicity...

  tags= Post.tag_counts(:order => 'tags.name')
  tags.each do |t|
    link_to(h(t.name), tagged_post_path(:tag => t.name, :cnt => t.count))
  end

This passes the count I have already calculated to the action that will list the paginated results.

In my controller I do this to get the paginated results using will_paginate and the Pagination gem...

    def tagged
    ...
      # page if we can
      size= params[:cnt]
      if size
        per_page= 10

        # use Paginator gem to do the actual paging
        pager = ::Paginator.new(size, per_page) do |offset, per_page|
          Post.find_tagged_with(params[:tag], :limit => per_page, :offset => offset)
        end

        # default to page 1 if not specified
        page= params[:page] || 1

        # gets a paged array of posts
        @posts= returning WillPaginate::Collection.new(page, per_page, size) do |p|
          p.replace pager.page(page).items
        end
      else
        # fall back if we don't know the size
        @posts= Post.find_tagged_with(params[:tag])
      end

      render :action => 'index'
    end    

This fits in nicely with the tag cloud I need to calculate, and it uses will_paginate just like the regular index action does.

UPDATE

I refactored this to be more generally useful, I added the following as a protected method in application.rb...

  # paginate a call to find_tagged_with
  # klass is the tagged class
  # tag is the tag to find
  # count is the total number of items with that tag, if nil count_tags is called
  # per_page is numbe rof items per page
  # page is the page we are on
  # order is the order to return the items in
  def tag_paginator(klass, tag, count=nil, per_page=10, page=1, order='updated_at DESC')
    count ||= klass.count_tags(tag)
    pager = ::Paginator.new(count, per_page) do |offset, per_page|
      klass.find_tagged_with(tag, :order => order, :limit => per_page, :offset => offset)
    end

    page ||= 1

    returning WillPaginate::Collection.new(page, per_page, count) do |p|
      p.replace pager.page(page).items
    end
  end

I call it from one of my other actions like this...

@faqs= tag_paginator(Post, 'FAQ', nil, per_page, params[:page], 'updated_at DESC')

Passing in nil as the third parameter causes the tag_paginator method to call Post.count_tags which is not part of the acts_as_taggable methods, I added it to the SingletonMethods module myself...

module ActiveRecord
  module Acts #:nodoc:
    module Taggable #:nodoc:
      module SingletonMethods

      ...

        # Return the count of tag tags in this class
       def count_tags(tag)
         count_by_sql("select count(*) FROM tags, taggings WHERE " + sanitize_sql(['name = ? AND tags.id = taggings.tag_id AND taggable_type = ?', tag, name]))
       end

      ....

If you don't want to hack acts_as_taggable then simply leave that part out and call the count_by_sql yourself.

My refactored tagged action from above now looks like this...

def tagged
  ...
  tag= params[:tag]
  per_page= 10
  size= params[:cnt]
  @posts= tag_paginator(Post, tag, size, 10, params[:page])

Posted in  | Tags ,  | 10 comments | no trackbacks

Developing a social networking site part 3 - tag cloud

Posted by Jim Morris Sat, 23 Jun 2007 21:02:49 GMT

This is a simple one.

I use the excellent acts_as_taggable plugin, and I wanted to have a tag cloud like everyone does.

Of course I have different Models that can be tagged, and I want to keep everything DRY, so I created a helper and put the following in the application_helper.rb file

  # display a tag cloud for the given model
  def tag_cloud(model, title= nil)
    m= model.to_s.camelcase.constantize
    plural= model.to_s.capitalize.pluralize
    title ||= plural
    tags= m.tag_counts(:order => 'tags.name')
    gen= ""
    unless tags.empty?
      urlmeth= "tagged_#{model.to_s.pluralize}_path".to_sym
      gen= "<div class=\"tagcloud\">"
      gen += "<h3>#{title}</h3>"
      tags.each do |t|
        gen += "<span style=\"font-size:#{calc_size(t.count)}%\">"
        gen += link_to(h(t.name), self.send(urlmeth, :tag => t.name))
        gen += "</span> "
      end
      gen += "</div>"
      gen += "<hr/>"
    end
    gen
  end

This allows me to have multiple tag clouds for the different models, see snowdogsr.us on the left bar at the bottom.

The font is bigger for the more popular tags, and you can click on any tag to get a listing of all matching articles.

I use RESTful routing so I added this to routes.rb

  map.resources :places, :collection => { :tagged => :get }

which creates a named route tagged_places_path which returns an index with the places tagged with the tag that was clicked.

The places_controller method looks like this...

  def tagged
    @places= Place.find_tagged_with(params[:tag], :order => "updated_at DESC")
    @filter= "Tagged with #{params[:tag]}"
    respond_to do |format|
      format.html { render :action => 'index' }
      format.xml  { render :xml => @places.to_xml }
    end
  end

Posted in  | Tags , ,  | no comments | no trackbacks