Support for tagging of actions!

Made a start on tagging support. You can add tags via the action forms (just single word tags, separated by a space so far), update tags via the edit form (same limitations), and also search for all actions with a particular tag:

/todo/tag/[tag_name]

Tests for tagging are a bit rudimentary at the moment, and you can't as yet use tags consisting of multiple words, or search for conjunctions of tags (e.g. foo+bar), but I'm hoping to add these. Also no validation, so don't try anything funny!

I'm also planning on letting the user create custom links to /todo/tag pages, so that you can use a tag for inbox, someday/maybe, today, a meta-project, priority, or whatever you like.

git-svn-id: http://www.rousette.org.uk/svn/tracks-repos/trunk@400 a4c988fc-2ded-0310-b66e-134b36920a42
This commit is contained in:
bsag 2007-01-14 19:29:01 +00:00
parent 3fb15f81a5
commit 36fcfe5c59
19 changed files with 317 additions and 20 deletions

View file

@ -33,6 +33,7 @@ class TodoController < ApplicationController
def create
@item = @user.todos.build
p = params['request'] || params
# @item.tag_with(params[:tag_list])
@item.attributes = p['todo']
if p['todo']['project_id'].blank? && !p['project_name'].blank? && p['project_name'] != 'None'
@ -69,6 +70,7 @@ class TodoController < ApplicationController
@item.show_from = parse_date_per_user_prefs(p['todo']['show_from'])
end
@item.tag_with(params[:tag_list], @user)
@saved = @item.save
respond_to do |wants|
@ -124,6 +126,7 @@ class TodoController < ApplicationController
def update
@item = check_user_return_item
@item.tag_with(params[:tag_list], @user)
@original_item_context_id = @item.context_id
@original_item_project_id = @item.project_id
@original_item_was_deferred = @item.deferred?
@ -159,6 +162,8 @@ class TodoController < ApplicationController
params['item']['show_from'] = parse_date_per_user_prefs(params['item']['show_from'])
end
@saved = @item.update_attributes params["item"]
@context_changed = @original_item_context_id != @item.context_id
@item_was_activated_from_deferred_state = @original_item_was_deferred && @item.active?
@ -234,6 +239,19 @@ class TodoController < ApplicationController
end
end
# /todo/tag/[tag_name] shows all the actions tagged with tag_name
#
def tag
@tag = tag_name = params[:id]
if Tag.find_by_name(tag_name)
@todos = Todo.find_tagged_with(tag_name, @user)
else
@todos = []
end
@count = @todos.size unless @todos.empty?
end
private
def check_user_return_item

View file

@ -5,6 +5,7 @@ class Todo < ActiveRecord::Base
belongs_to :project
belongs_to :user
acts_as_taggable
acts_as_state_machine :initial => :active, :column => 'state'
state :active, :enter => Proc.new { |t| t[:show_from] = nil }

View file

@ -33,6 +33,8 @@ class User < ActiveRecord::Base
end
has_many :notes, :order => "created_at DESC", :dependent => :delete_all
has_one :preference, :dependent => :destroy
has_many :taggings
has_many :tags, :through => :taggings, :select => "DISTINCT tags.*"
attr_protected :is_admin

View file

@ -43,14 +43,17 @@ Event.observe($('todo_project_name'), "focus", projectAutoCompleter.activate.bin
Event.observe($('todo_project_name'), "click", projectAutoCompleter.activate.bind(projectAutoCompleter));
</script>
<label for="tag_list">Tags</label>
<%= text_field_tag "tag_list", nil, :size => 40, :tabindex => 5 %>
<label for="todo_due">Due</label>
<%= text_field("todo", "due", "size" => 10, "class" => "Date", "onfocus" => "Calendar.setup", "tabindex" => 5, "autocomplete" => "off") %>
<%= text_field("todo", "due", "size" => 10, "class" => "Date", "onfocus" => "Calendar.setup", "tabindex" => 6, "autocomplete" => "off") %>
<label for="todo_show_from">Show from</label>
<%= text_field("todo", "show_from", "size" => 10, "class" => "Date", "onfocus" => "Calendar.setup", "tabindex" => 6, "autocomplete" => "off") %>
<%= text_field("todo", "show_from", "size" => 10, "class" => "Date", "onfocus" => "Calendar.setup", "tabindex" => 7, "autocomplete" => "off") %>
<%= source_view_tag( @source_view ) %>
<div class="submit_box"><input type="submit" value="Add item" tabindex="7" /></div>
<div class="submit_box"><input type="submit" value="Add item" tabindex="8" /></div>
<% end -%><!--[eoform:todo]-->

View file

@ -34,6 +34,10 @@
Event.observe($('<%= dom_id(@item, 'project_name') %>'), "click", editFormProjectAutoCompleter.activate.bind(editFormProjectAutoCompleter));
</script>
</tr>
<tr>
<td class="label"><label for="<%= dom_id(@item, 'tag_list') %>">Tags</label></td>
<td><%= text_field_tag "tag_list", @item.tags.collect{|t| t.name}.join(" "), :size => 40 %></td>
</tr>
<tr>
<td class="label"><label for="<%= dom_id(@item, 'due') %>">Due</td>
<td><input name="item[due]" id="<%= dom_id(@item, 'due') %>" type="text" value="<%= format_date(@item.due) %>" tabindex="12" size="10" onfocus="Calendar.setup" autocomplete="off" class="Date" /></td>

View file

@ -18,6 +18,17 @@
<% end -%>
<%= sanitize(item.description) %>
<%= if item.tags.blank?
""
else
tag_string = ""
item.tags.each do |t|
tag_string << "<span class=\"tag\">" + link_to(t.name, :action => "tag", :id => t.name) + "</span>"
end
tag_string
end
%>
<% if item.deferred? && item.due -%>
(action due on <%= format_date(item.due) %>)
@ -32,6 +43,10 @@
<% if (parent_container_type == "context" || parent_container_type == "tickler") && item.project_id -%>
<%= item_link_to_project( item ) %>
<% end -%>
<% if (parent_container_type == "tag") -%>
<%= item_link_to_context( item ) %>
<%= item_link_to_project( item ) if item.project_id %>
<% end -%>
<% end -%>
<% if item.notes? -%>

View file

@ -0,0 +1,22 @@
<div id="display_box">
<div id="t" class="container context">
<h2>
All actions tagged with '<%= @tag %>'
</h2>
<div id="t_items" class="items toggle_target">
<div id="t_empty-nd" style="display:<%= @todos.empty? ? 'block' : 'none'%>;">
<div class="message"><p>Currently there are no actions tagged with <%= @tag %></p></div>
</div>
<%= render :partial => "todo/item", :collection => @todos, :locals => { :parent_container_type => "tag" } %>
</div><!-- [end:items] -->
</div><!-- [end:t-->
</div><!-- End of display_box -->
<div id="input_box">
<%= render :partial => "shared/add_new_item_form" %>
<%= render "sidebar/sidebar" %>
</div><!-- End of input box -->

View file

@ -0,0 +1,23 @@
class AddTagSupport < ActiveRecord::Migration
def self.up
create_table :taggings do |t|
t.column :taggable_id, :integer
t.column :tag_id, :integer
t.column :taggable_type, :string
t.column :user_id, :integer
end
create_table :tags do |t|
t.column :name, :string
t.column :created_at, :datetime
t.column :updated_at, :datetime
end
add_index :tags, :name
add_index :taggings, [:tag_id, :taggable_id, :taggable_type]
end
def self.down
drop_table :taggings
drop_table :tags
end
end

View file

@ -2,13 +2,13 @@
# migrations feature of ActiveRecord to incrementally modify your database, and
# then regenerate this schema definition.
ActiveRecord::Schema.define(:version => 23) do
ActiveRecord::Schema.define(:version => 24) do
create_table "contexts", :force => true do |t|
t.column "name", :string, :default => "", :null => false
t.column "hide", :integer, :limit => 4, :default => 0, :null => false
t.column "position", :integer, :default => 0, :null => false
t.column "user_id", :integer, :default => 0, :null => false
t.column "name", :string, :default => "", :null => false
t.column "position", :integer, :default => 0, :null => false
t.column "hide", :boolean, :default => false
t.column "user_id", :integer, :default => 1
end
add_index "contexts", ["user_id"], :name => "index_contexts_on_user_id"
@ -61,7 +61,7 @@ ActiveRecord::Schema.define(:version => 23) do
create_table "projects", :force => true do |t|
t.column "name", :string, :default => "", :null => false
t.column "position", :integer, :default => 0, :null => false
t.column "user_id", :integer, :default => 0, :null => false
t.column "user_id", :integer, :default => 1
t.column "description", :text
t.column "state", :string, :limit => 20, :default => "active", :null => false
end
@ -76,17 +76,34 @@ ActiveRecord::Schema.define(:version => 23) do
add_index "sessions", ["session_id"], :name => "sessions_session_id_index"
create_table "taggings", :force => true do |t|
t.column "taggable_id", :integer
t.column "tag_id", :integer
t.column "taggable_type", :string
t.column "user_id", :integer
end
add_index "taggings", ["tag_id", "taggable_id", "taggable_type"], :name => "index_taggings_on_tag_id_and_taggable_id_and_taggable_type"
create_table "tags", :force => true do |t|
t.column "name", :string
t.column "created_at", :datetime
t.column "updated_at", :datetime
end
add_index "tags", ["name"], :name => "index_tags_on_name"
create_table "todos", :force => true do |t|
t.column "context_id", :integer, :default => 0, :null => false
t.column "description", :string, :limit => 100, :default => "", :null => false
t.column "context_id", :integer, :default => 0, :null => false
t.column "project_id", :integer
t.column "description", :string, :default => "", :null => false
t.column "notes", :text
t.column "created_at", :datetime
t.column "due", :date
t.column "completed_at", :datetime
t.column "project_id", :integer
t.column "user_id", :integer, :default => 0, :null => false
t.column "user_id", :integer, :default => 1
t.column "show_from", :date
t.column "state", :string, :limit => 20, :default => "immediate", :null => false
t.column "state", :string, :limit => 20, :default => "immediate", :null => false
end
add_index "todos", ["user_id", "state"], :name => "index_todos_on_user_id_and_state"
@ -96,10 +113,10 @@ ActiveRecord::Schema.define(:version => 23) do
add_index "todos", ["user_id", "context_id"], :name => "index_todos_on_user_id_and_context_id"
create_table "users", :force => true do |t|
t.column "login", :string, :limit => 80
t.column "password", :string, :limit => 40
t.column "login", :string, :limit => 80, :default => "", :null => false
t.column "password", :string, :limit => 40, :default => "", :null => false
t.column "word", :string
t.column "is_admin", :integer, :limit => 4, :default => 0, :null => false
t.column "is_admin", :boolean, :default => false, :null => false
t.column "first_name", :string
t.column "last_name", :string
t.column "auth_type", :string, :default => "database", :null => false

View file

@ -206,7 +206,7 @@ div#input_box {
}
div.item-container {
padding: 2px;
padding: 3px;
clear: both;
}
@ -317,6 +317,28 @@ div#project_status > div {
a.footer_link {color: #cc3334; font-style: normal;}
a.footer_link:hover {color: #fff; background-color: #cc3334 !important;}
/* Tag formatting */
span.tag {
font-size: 0.8em;
background-color: #CCE7FF;
color: #000;
padding: 1px;
margin-right: 2px;
}
span.tag a,
span.tag a:link,
span.tag a:active,
span.tag a:visited {
color: #000;
}
span.tag a:hover {
background-color: #99CCFF;
color: #333;
}
/* Flash box styling */
div#message_holder {

22
tracks/test/fixtures/taggings.yml vendored Normal file
View file

@ -0,0 +1,22 @@
# Todo 1 should be tagged with foo and bar
foo_bar1:
id: 1
tag_id: 1
taggable_id: 1 # Call Bill Gates
taggable_type: Todo
user_id: 1
foo_bar2:
id: 2
tag_id: 2
taggable_id: 1 # Call Bill Gates
taggable_type: Todo
user_id: 1
# Todo 2 should be tagged with foo
foo:
id: 3
tag_id: 1
taggable_id: 2 # Call dinosaur exterminator
taggable_type: Todo
user_id: 1

17
tracks/test/fixtures/tags.yml vendored Normal file
View file

@ -0,0 +1,17 @@
foo:
id: 1
name: foo
created_at: <%= Time.now.utc.to_s(:db) %>
updated_at: <%= Time.now.utc.to_s(:db) %>
bar:
id: 2
name: bar
created_at: <%= Time.now.utc.to_s(:db) %>
updated_at: <%= Time.now.utc.to_s(:db) %>
baz:
id: 3
name: baz
created_at: <%= Time.now.utc.to_s(:db) %>
updated_at: <%= Time.now.utc.to_s(:db) %>

View file

@ -5,7 +5,7 @@ require 'todo_controller'
class TodoController; def rescue_action(e) raise e end; end
class TodoControllerTest < Test::Unit::TestCase
fixtures :users, :preferences, :projects, :contexts, :todos
fixtures :users, :preferences, :projects, :contexts, :todos, :tags, :taggings
def setup
@controller = TodoController.new
@ -67,7 +67,7 @@ class TodoControllerTest < Test::Unit::TestCase
def test_update_item
t = Todo.find(1)
@request.session['user_id'] = users(:admin_user).id
xhr :post, :update, :id => 1, :_source_view => 'todo', "item"=>{"context_id"=>"1", "project_id"=>"2", "id"=>"1", "notes"=>"", "description"=>"Call Warren Buffet to find out how much he makes per day", "due"=>"30/11/2006"}
xhr :post, :update, :id => 1, :_source_view => 'todo', "item"=>{"context_id"=>"1", "project_id"=>"2", "id"=>"1", "notes"=>"", "description"=>"Call Warren Buffet to find out how much he makes per day", "due"=>"30/11/2006"}, "tag_list"=>"foo bar"
#assert_rjs :page, "todo_1", :visual_effect, :highlight, :duration => '1'
t = Todo.find(1)
assert_equal "Call Warren Buffet to find out how much he makes per day", t.description
@ -76,5 +76,14 @@ class TodoControllerTest < Test::Unit::TestCase
assert_equal expected, actual, "Expected #{expected.to_s(:db)}, was #{actual.to_s(:db)}"
end
def test_tag
@request.session['user_id'] = users(:admin_user).id
@user = User.find(@request.session['user_id'])
@tagged = Todo.find_tagged_with('foo', @user).size
get :tag, :id => 'foo'
assert_success
assert_equal 2, @tagged
end
end

View file

@ -0,0 +1,5 @@
require 'acts_as_taggable'
ActiveRecord::Base.send(:include, ActiveRecord::Acts::Taggable)
require File.dirname(__FILE__) + '/lib/tagging'
require File.dirname(__FILE__) + '/lib/tag'

View file

@ -0,0 +1,4 @@
Acts As Taggable
=================
Allows for tags to be added to multiple classes.

View file

@ -0,0 +1,59 @@
module ActiveRecord
module Acts #:nodoc:
module Taggable #:nodoc:
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def acts_as_taggable(options = {})
write_inheritable_attribute(:acts_as_taggable_options, {
:taggable_type => ActiveRecord::Base.send(:class_name_of_active_record_descendant, self).to_s,
:from => options[:from]
})
class_inheritable_reader :acts_as_taggable_options
has_many :taggings, :as => :taggable, :dependent => true
has_many :tags, :through => :taggings
include ActiveRecord::Acts::Taggable::InstanceMethods
extend ActiveRecord::Acts::Taggable::SingletonMethods
end
end
module SingletonMethods
def find_tagged_with(list, user)
find_by_sql([
"SELECT #{table_name}.* FROM #{table_name}, tags, taggings " +
"WHERE #{table_name}.#{primary_key} = taggings.taggable_id " +
"AND taggings.user_id = ? " +
"AND taggings.taggable_type = ? " +
"AND taggings.tag_id = tags.id AND tags.name IN (?)",
user.id, acts_as_taggable_options[:taggable_type], list
])
end
end
module InstanceMethods
def tag_with(list, user)
Tag.transaction do
taggings.destroy_all
Tag.parse(list).each do |name|
if acts_as_taggable_options[:from]
send(acts_as_taggable_options[:from]).tags.find_or_create_by_name(name).on(self, user)
else
Tag.find_or_create_by_name(name).on(self, user)
end
end
end
end
def tag_list
tags.collect { |tag| tag.name.include?(" ") ? '"' + tag.name + '"' : tag.name }.join(" ")
end
end
end
end
end

View file

@ -0,0 +1,40 @@
class Tag < ActiveRecord::Base
has_many :taggings
def self.parse(list)
tag_names = []
# first, pull out the quoted tags
list.gsub!(/\"(.*?)\"\s*/ ) { tag_names << $1; "" }
# then, replace all commas with a space
list.gsub!(/,/, " ")
# then, get whatever's left
tag_names.concat list.split(/\s/)
# strip whitespace from the names
tag_names = tag_names.map { |t| t.strip }
# delete any blank tag names
tag_names = tag_names.delete_if { |t| t.empty? }
return tag_names
end
def tagged
@tagged ||= taggings.collect { |tagging| tagging.taggable }
end
def on(taggable, user)
tagging = taggings.create :taggable => taggable, :user => user
end
def ==(comparison_object)
super || name == comparison_object.to_s
end
def to_s
name
end
end

View file

@ -0,0 +1,13 @@
class Tagging < ActiveRecord::Base
belongs_to :tag
belongs_to :taggable, :polymorphic => true
belongs_to :user
def self.tagged_class(taggable)
ActiveRecord::Base.send(:class_name_of_active_record_descendant, taggable.class).to_s
end
def self.find_taggable(tagged_class, tagged_id)
tagged_class.constantize.find(tagged_id)
end
end

View file

@ -0,0 +1 @@
# Testing goes here