scoped-tags 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- data/MIT-LICENSE +20 -0
- data/VERSION.yml +4 -0
- data/generators/scoped_tags_migration/scoped_tags_generator.rb +7 -0
- data/generators/scoped_tags_migration/templates/migration.rb +21 -0
- data/install.rb +1 -0
- data/lib/scoped_tags.rb +13 -0
- data/lib/scoped_tags/active_record_additions.rb +64 -0
- data/lib/scoped_tags/tag.rb +32 -0
- data/lib/scoped_tags/tag_list_collection.rb +76 -0
- data/lib/scoped_tags/tag_list_proxy.rb +98 -0
- data/lib/scoped_tags/tagging.rb +8 -0
- data/readme.md +93 -0
- data/spec/debug.log +1 -0
- data/spec/schema.rb +20 -0
- data/spec/scoped_tags/scoped_tags_spec.rb +145 -0
- data/spec/scoped_tags/tag_and_tagging_spec.rb +63 -0
- data/spec/scoped_tags/tag_list_spec.rb +123 -0
- data/spec/spec_helper.rb +29 -0
- data/spec/test.sqlite3 +0 -0
- data/uninstall.rb +1 -0
- metadata +82 -0
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2009 Josh Kalderimis
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/VERSION.yml
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
class ScopedTagsMigration < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :tags do |t|
|
4
|
+
t.string :name
|
5
|
+
t.string :context
|
6
|
+
end
|
7
|
+
|
8
|
+
create_table :taggings do |t|
|
9
|
+
t.references :tag
|
10
|
+
t.references :taggable, :polymorphic => true
|
11
|
+
end
|
12
|
+
|
13
|
+
add_index "tags", ['context', 'name']
|
14
|
+
add_index "taggings", ['taggable_id', 'taggable_type']
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.down
|
18
|
+
drop_table :taggings
|
19
|
+
drop_table :tags
|
20
|
+
end
|
21
|
+
end
|
data/install.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
# Install hook code here
|
data/lib/scoped_tags.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'scoped_tags/active_record_additions'
|
2
|
+
require 'scoped_tags/tag'
|
3
|
+
require 'scoped_tags/tagging'
|
4
|
+
require 'scoped_tags/tag_list_proxy'
|
5
|
+
require 'scoped_tags/tag_list_collection'
|
6
|
+
|
7
|
+
module ActiveRecord
|
8
|
+
Base.class_eval do
|
9
|
+
include ScopedTags::ActiveRecordAdditions
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
TagListCollection.delimiter = ','
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module ScopedTags
|
2
|
+
|
3
|
+
module ActiveRecordAdditions
|
4
|
+
|
5
|
+
def self.included(base)
|
6
|
+
base.class_eval do
|
7
|
+
def self.scoped_tags(contexts, options = nil)
|
8
|
+
self.class.instance_eval{ attr_accessor :tag_contexts }
|
9
|
+
|
10
|
+
raise ScopedTagsError, 'context is required for scoped-tags setup' if contexts.blank?
|
11
|
+
|
12
|
+
self.tag_contexts = [contexts].flatten
|
13
|
+
|
14
|
+
has_many :taggings, :as => :taggable, :class_name => 'Tagging', :dependent => :delete_all
|
15
|
+
has_many :tags, :through => :taggings, :class_name => 'Tag', :readonly => true
|
16
|
+
|
17
|
+
self.tag_contexts.each do |context|
|
18
|
+
has_many context, :through => :taggings, :class_name => 'Tag',
|
19
|
+
:source => :tag,
|
20
|
+
:conditions => ['context = ?', context.to_s.downcase]
|
21
|
+
|
22
|
+
c = context.to_s.singularize
|
23
|
+
define_method("#{c}_list") { get_tag_list(context) }
|
24
|
+
define_method("#{c}_list=") { |new_list| set_tag_list(context, new_list) }
|
25
|
+
self.class.instance_eval do
|
26
|
+
define_method("tagged_with_#{context}") { |*args| find_tagged_with(args.first, context.to_s, args.extract_options!) }
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
self.send :extend, ClassMethods
|
31
|
+
self.send :include, InstanceMethods
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
module ClassMethods
|
38
|
+
def find_tagged_with(tag_names, context, options = {})
|
39
|
+
tag_names = tag_names.is_a?(Array) ? tag_names : tag_names.split(TagListCollection.delimiter)
|
40
|
+
tag_names = tag_names.collect(&:strip).reject(&:blank?)
|
41
|
+
|
42
|
+
required_options = { :include => [:taggings, :tags],
|
43
|
+
:conditions => ['tags.name IN (?) AND tags.context = ?', tag_names, context] }
|
44
|
+
|
45
|
+
self.all(options.merge(required_options))
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
|
50
|
+
module InstanceMethods
|
51
|
+
protected
|
52
|
+
def get_tag_list(context)
|
53
|
+
@tag_list_collections = { } if not @tag_list_collections
|
54
|
+
@tag_list_collections[context] ||= TagListCollection.new(self, context.to_s.downcase)
|
55
|
+
end
|
56
|
+
|
57
|
+
def set_tag_list(context, new_tags)
|
58
|
+
get_tag_list(context).replace(new_tags)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
class Tag < ActiveRecord::Base
|
2
|
+
has_many :taggings, :dependent => :delete_all
|
3
|
+
|
4
|
+
validates_presence_of :context
|
5
|
+
validates_uniqueness_of :name, :case_sensitive => false, :scope => :context
|
6
|
+
|
7
|
+
attr_accessible :name, :context
|
8
|
+
|
9
|
+
before_validation :trim_spaces, :lowercase_name
|
10
|
+
|
11
|
+
|
12
|
+
def self.find_or_new_by_name_and_context(name, context)
|
13
|
+
tag = self.find(:first, :conditions => ["name = ? and context = ?", name, context])
|
14
|
+
tag || Tag.new(:name => name, :context => context)
|
15
|
+
end
|
16
|
+
|
17
|
+
def ==(object)
|
18
|
+
super || (object.is_a?(Tag) && self.name == object.name && self.context == object.context)
|
19
|
+
end
|
20
|
+
|
21
|
+
|
22
|
+
private
|
23
|
+
def trim_spaces
|
24
|
+
self.name.try(:strip!).try(:squeeze!, ' ')
|
25
|
+
self.context.try(:strip!).try(:squeeze!, ' ')
|
26
|
+
end
|
27
|
+
|
28
|
+
def lowercase_name
|
29
|
+
self.name.try(:downcase!)
|
30
|
+
self.context.try(:downcase!)
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
class TagListCollection < TagListProxy
|
2
|
+
|
3
|
+
self.class.instance_eval do
|
4
|
+
attr_accessor :delimiter
|
5
|
+
end
|
6
|
+
|
7
|
+
|
8
|
+
def <<(tag_names)
|
9
|
+
tag_names = clean_tag_list(tag_names)
|
10
|
+
current_list = self.to_a
|
11
|
+
context_tags = self.proxy_owner.send(self.proxy_context)
|
12
|
+
tag_names.each do |new_tag|
|
13
|
+
unless current_list.include?(new_tag)
|
14
|
+
context_tags << Tag.find_or_new_by_name_and_context(new_tag, self.proxy_context.to_s)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
self
|
18
|
+
end
|
19
|
+
|
20
|
+
alias_method :push, :<<
|
21
|
+
alias_method :concat, :<<
|
22
|
+
|
23
|
+
|
24
|
+
def delete(tag_names)
|
25
|
+
context_tags = self.proxy_owner.send(self.proxy_context)
|
26
|
+
to_delete = []
|
27
|
+
tag_names = clean_tag_list(tag_names)
|
28
|
+
context_tags.each do |tag|
|
29
|
+
to_delete << tag if tag_names.include?(tag.name)
|
30
|
+
end
|
31
|
+
context_tags.delete(to_delete)
|
32
|
+
to_delete.collect(&:name)
|
33
|
+
end
|
34
|
+
|
35
|
+
def delete_at(index)
|
36
|
+
context_tags = self.proxy_owner.send(self.proxy_context)
|
37
|
+
return nil if 0 > index or index > context_tags.size
|
38
|
+
tag_at_index = context_tags[index]
|
39
|
+
context_tags.delete(tag_at_index)
|
40
|
+
tag_at_index
|
41
|
+
end
|
42
|
+
|
43
|
+
def pop
|
44
|
+
self.delete_at(self.size - 1)
|
45
|
+
end
|
46
|
+
|
47
|
+
def replace(tag_names)
|
48
|
+
new_uniq_list = clean_tag_list(tag_names).uniq
|
49
|
+
new_tag_list = new_uniq_list.inject([]) do |list, tag_name|
|
50
|
+
list << Tag.find_or_new_by_name_and_context(tag_name, self.proxy_context)
|
51
|
+
list
|
52
|
+
end
|
53
|
+
context_tags = self.proxy_owner.send(self.proxy_context)
|
54
|
+
context_tags.replace(new_tag_list)
|
55
|
+
self
|
56
|
+
end
|
57
|
+
|
58
|
+
|
59
|
+
def to_s
|
60
|
+
self.join("#{TagListCollection.delimiter} ")
|
61
|
+
end
|
62
|
+
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def find_target
|
67
|
+
association = self.proxy_owner.send proxy_context
|
68
|
+
association.collect(&:name)
|
69
|
+
end
|
70
|
+
|
71
|
+
def clean_tag_list(tags)
|
72
|
+
tags = tags.is_a?(Array) ? tags : tags.split(TagListCollection.delimiter)
|
73
|
+
tags.collect(&:strip).reject(&:blank?)
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
class TagListProxy
|
2
|
+
|
3
|
+
alias_method :proxy_respond_to?, :respond_to?
|
4
|
+
alias_method :proxy_extend, :extend
|
5
|
+
delegate :to_param, :to => :proxy_target
|
6
|
+
instance_methods.each { |m| undef_method m unless m =~ /(^__|^nil\?$|^send$|proxy_|^object_id$)/ }
|
7
|
+
|
8
|
+
def initialize(owner, context)
|
9
|
+
@owner, @context = owner, context
|
10
|
+
@target = nil
|
11
|
+
end
|
12
|
+
|
13
|
+
# Returns the owner of the proxy.
|
14
|
+
def proxy_owner
|
15
|
+
@owner
|
16
|
+
end
|
17
|
+
|
18
|
+
# Returns the \context of the proxy, same as +context+.
|
19
|
+
def proxy_context
|
20
|
+
@context
|
21
|
+
end
|
22
|
+
|
23
|
+
# Returns the \target of the proxy, same as +target+.
|
24
|
+
def proxy_target
|
25
|
+
@target
|
26
|
+
end
|
27
|
+
|
28
|
+
# Does the proxy or its \target respond to +symbol+?
|
29
|
+
def respond_to?(*args)
|
30
|
+
proxy_respond_to?(*args) || (load_target && @target.respond_to?(*args))
|
31
|
+
end
|
32
|
+
|
33
|
+
# Forwards <tt>===</tt> explicitly to the \target because the instance method
|
34
|
+
# removal above doesn't catch it. Loads the \target if needed.
|
35
|
+
def ===(other)
|
36
|
+
reload
|
37
|
+
other === @target
|
38
|
+
end
|
39
|
+
|
40
|
+
# Reloads the \target and returns +self+ on success.
|
41
|
+
def reload
|
42
|
+
@target = nil
|
43
|
+
load_target
|
44
|
+
self unless @target.nil?
|
45
|
+
end
|
46
|
+
|
47
|
+
# Returns the target of this proxy, same as +proxy_target+.
|
48
|
+
def target
|
49
|
+
@target
|
50
|
+
end
|
51
|
+
|
52
|
+
# Sets the target of this proxy to <tt>\target</tt>, and the \loaded flag to +true+.
|
53
|
+
def target=(target)
|
54
|
+
@target = target
|
55
|
+
end
|
56
|
+
|
57
|
+
# Forwards the call to the target. Loads the \target if needed.
|
58
|
+
def inspect
|
59
|
+
reload
|
60
|
+
@target.inspect
|
61
|
+
end
|
62
|
+
|
63
|
+
def send(method, *args)
|
64
|
+
if proxy_respond_to?(method)
|
65
|
+
super
|
66
|
+
else
|
67
|
+
reload
|
68
|
+
@target.send(method, *args)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
|
73
|
+
private
|
74
|
+
# Forwards any missing method call to the \target.
|
75
|
+
def method_missing(method, *args)
|
76
|
+
if reload
|
77
|
+
unless @target.respond_to?(method)
|
78
|
+
message = "undefined method `#{method.to_s}' for \"#{@target}\":#{@target.class.to_s}"
|
79
|
+
raise NoMethodError, message
|
80
|
+
end
|
81
|
+
|
82
|
+
if block_given?
|
83
|
+
@target.send(method, *args) { |*block_args| yield(*block_args) }
|
84
|
+
else
|
85
|
+
@target.send(method, *args)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def load_target
|
91
|
+
@target = find_target
|
92
|
+
@target
|
93
|
+
rescue ActiveRecord::RecordNotFound
|
94
|
+
reset
|
95
|
+
end
|
96
|
+
|
97
|
+
|
98
|
+
end
|
data/readme.md
ADDED
@@ -0,0 +1,93 @@
|
|
1
|
+
Scoped-Tags
|
2
|
+
===========
|
3
|
+
|
4
|
+
When it comes to tagging dsls there are three main players, acts-as-taggable-on, acts-as-taggable-on-steriods and is-taggable.
|
5
|
+
Each seem to have one thing is common and thats using an after save callback to sync the tags list (usually an array) with
|
6
|
+
the tags association.
|
7
|
+
|
8
|
+
The key difference between scoped-tags and the other players on the field is that scoped-tags syncs
|
9
|
+
the array version with the association version upon each request.
|
10
|
+
|
11
|
+
The initial array tag list implementation was half Array half beast. The current version has been updated to use a proxy
|
12
|
+
based implementation for the array list, similar to the ActiveRecord ProxyAssociation which is used for the different
|
13
|
+
relationship associations. This is the heart of the code which syncs the array style listing with the association listing.
|
14
|
+
|
15
|
+
Scoped-tags will, in its next release, allow for tags to be strictly or silently scoped, with the default allowing
|
16
|
+
tag creation if the tag does not exist.
|
17
|
+
|
18
|
+
|
19
|
+
Key Features
|
20
|
+
------------
|
21
|
+
|
22
|
+
- multiple tag contexts per model
|
23
|
+
- works with sphinx and thinking-sphinx
|
24
|
+
- add and remove tags using array like features
|
25
|
+
- association and array list stay in sync
|
26
|
+
- tags are available for use in the validations as they are always kept in sync
|
27
|
+
|
28
|
+
|
29
|
+
|
30
|
+
How can I use it?
|
31
|
+
-----------------
|
32
|
+
|
33
|
+
class Person
|
34
|
+
scoped_tags :genres
|
35
|
+
end
|
36
|
+
|
37
|
+
me = Person.new(:name => 'Josh')
|
38
|
+
|
39
|
+
me.interests << Tag.new(:name => 'scuba', :context => 'interests')
|
40
|
+
# it would be nice to leave the context out, but sadly not just yet
|
41
|
+
# and me.interests.build(:name => 'scuba') does not work at the moment either
|
42
|
+
# (has_many through issue)
|
43
|
+
|
44
|
+
me.interest_list => ['scuba']
|
45
|
+
|
46
|
+
me.interest_list << 'cycling'
|
47
|
+
# comma seperated strings are excepted, as well as arrays of strings
|
48
|
+
|
49
|
+
me.interests => [#<Tag id: nil, name: "scuba", context: 'interests'>,#<Tag id: nil, name: "cycling", context: 'interests'>]
|
50
|
+
|
51
|
+
|
52
|
+
|
53
|
+
Things to watch out for
|
54
|
+
-----------------------
|
55
|
+
|
56
|
+
### _updating a scoped tagged model via a controller update_
|
57
|
+
|
58
|
+
be aware that if no tags are selected then the browser
|
59
|
+
does not pass through a blank array. This means if the model previously had tags saved to it and you remove the tags, the browser
|
60
|
+
will not pass though the changes because the select box is empty meaning the tags will not be removed.
|
61
|
+
|
62
|
+
An example of
|
63
|
+
this is the FCBKcomplete javascript plugin, it uses a select box behind the scenes to store the tags and pass
|
64
|
+
them through to the controller, but if none are selected then the browser passes nothing to the controller,
|
65
|
+
not even a blank array. To fix this, add the following method to the controller and add it as a before\_filter to the update
|
66
|
+
action.
|
67
|
+
|
68
|
+
Add to the controller:
|
69
|
+
|
70
|
+
before_filter :check_blank_tag_ids, :only => :update
|
71
|
+
|
72
|
+
def check_blank_tag_ids
|
73
|
+
params[:person][:interest_ids] = [] if params[:person] && params[:person][:interest_ids].blank?
|
74
|
+
end
|
75
|
+
|
76
|
+
|
77
|
+
### _use a transaction when updating the scoped model_
|
78
|
+
|
79
|
+
otherwise the tags will save even if the model validations fail.
|
80
|
+
This is due to the standard behavior of has\_many :through relationships.
|
81
|
+
|
82
|
+
Example
|
83
|
+
|
84
|
+
Person.transaction { @person.update_attributes!(params[:person]) }
|
85
|
+
|
86
|
+
|
87
|
+
|
88
|
+
|
89
|
+
Future enhancements
|
90
|
+
-------------------
|
91
|
+
|
92
|
+
- add strict and silent scoping on setup (scoped_tags :interests, :strict => true), thus any tag added which is not already in the tags table for that context will be rejected
|
93
|
+
- get the instance.context.build method to work correctly
|
data/spec/debug.log
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
# Logfile created on Sat Oct 17 19:04:03 +0200 2009 by logger.rb/22285
|
data/spec/schema.rb
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
ActiveRecord::Schema.define :version => 0 do
|
2
|
+
|
3
|
+
create_table "scoped_tagged_models", :force => true do |t|
|
4
|
+
t.string :name
|
5
|
+
end
|
6
|
+
|
7
|
+
create_table "tags", :force => true do |t|
|
8
|
+
t.string :name
|
9
|
+
t.string :context
|
10
|
+
end
|
11
|
+
|
12
|
+
create_table "taggings", :force => true do |t|
|
13
|
+
t.references :tag
|
14
|
+
t.references :taggable, :polymorphic => true
|
15
|
+
end
|
16
|
+
|
17
|
+
add_index "tags", ['context', 'name']
|
18
|
+
add_index "taggings", ['taggable_id', 'taggable_type']
|
19
|
+
|
20
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
|
3
|
+
|
4
|
+
describe "ScopedTaggedModel" do
|
5
|
+
context "attaching scoped-tags to a model" do
|
6
|
+
before(:each) do
|
7
|
+
@scoped_model = ScopedTaggedModel.new
|
8
|
+
end
|
9
|
+
|
10
|
+
it "should include the taggings method" do
|
11
|
+
@scoped_model.should respond_to(:taggings)
|
12
|
+
end
|
13
|
+
|
14
|
+
it "should include a tags method" do
|
15
|
+
@scoped_model.should respond_to(:tags)
|
16
|
+
end
|
17
|
+
|
18
|
+
it "should include the genres method" do
|
19
|
+
@scoped_model.should respond_to(:genres)
|
20
|
+
end
|
21
|
+
|
22
|
+
it "should at tag_contexts to the class" do
|
23
|
+
ScopedTaggedModel.should respond_to(:tag_contexts)
|
24
|
+
end
|
25
|
+
|
26
|
+
it "should create a getter and setter context tag list" do
|
27
|
+
@scoped_model.should respond_to(:genre_list, :genre_list=)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
context "using scoped-tags in an every day situation" do
|
32
|
+
before(:each) do
|
33
|
+
@scoped_model = ScopedTaggedModel.new
|
34
|
+
@scoped_model.genres << Tag.new(:name => 'rock', :context => 'genres')
|
35
|
+
end
|
36
|
+
|
37
|
+
it "should return the genres association name list when genre_list is called" do
|
38
|
+
@scoped_model.genre_list.should include('rock')
|
39
|
+
end
|
40
|
+
|
41
|
+
it "should add a tag to the association when genre_list << is used" do
|
42
|
+
@scoped_model.genre_list << 'pop'
|
43
|
+
@scoped_model.genres.size.should == 2
|
44
|
+
@scoped_model.genre_list.should include('pop')
|
45
|
+
end
|
46
|
+
|
47
|
+
it "should change the entire list when using genre_list =" do
|
48
|
+
@scoped_model.genre_list = 'blues'
|
49
|
+
@scoped_model.genres.size.should == 1
|
50
|
+
@scoped_model.genre_list.should include('blues')
|
51
|
+
end
|
52
|
+
|
53
|
+
it "should change the entire list when using genre_list= with a comma seperated list of tags" do
|
54
|
+
@scoped_model.genre_list = 'blues, rock, country'
|
55
|
+
@scoped_model.genres.size.should == 3
|
56
|
+
@scoped_model.genre_list.should == ["rock", "blues", "country"]
|
57
|
+
end
|
58
|
+
|
59
|
+
it "should only add uniq tags to the association" do
|
60
|
+
@scoped_model.genre_list << 'blues'
|
61
|
+
@scoped_model.genres.size.should == 2
|
62
|
+
@scoped_model.genre_list.should include('blues', 'rock')
|
63
|
+
@scoped_model.genre_list << 'blues'
|
64
|
+
@scoped_model.genre_list << 'blues'
|
65
|
+
@scoped_model.genre_list << 'rock'
|
66
|
+
@scoped_model.genres.size.should == 2
|
67
|
+
@scoped_model.genre_list.should include('blues', 'rock')
|
68
|
+
end
|
69
|
+
|
70
|
+
it "should allow tags to be removed from the association and have the tag list act consistently" do
|
71
|
+
@scoped_model.genre_list << 'blues'
|
72
|
+
@scoped_model.genres.size.should == 2
|
73
|
+
@scoped_model.genres.delete(@scoped_model.genres.last)
|
74
|
+
@scoped_model.genres.size.should == 1
|
75
|
+
@scoped_model.genre_list.should include('rock')
|
76
|
+
end
|
77
|
+
|
78
|
+
it "#tagged_with_genres" do
|
79
|
+
tag = 'pop'
|
80
|
+
|
81
|
+
@scoped_model.genre_list << tag
|
82
|
+
@scoped_model.save!
|
83
|
+
|
84
|
+
ar_check = ScopedTaggedModel.all(:conditions => ['tags.name IN (?) AND tags.context = ?', tag, 'genres'], :include => [:taggings, :tags])
|
85
|
+
ar_check.size.should == 1
|
86
|
+
|
87
|
+
ScopedTaggedModel.methods.should include('tagged_with_genres')
|
88
|
+
|
89
|
+
st_check = ScopedTaggedModel.tagged_with_genres(tag)
|
90
|
+
st_check.size.should == 1
|
91
|
+
st_check.first.id.should == @scoped_model.id
|
92
|
+
end
|
93
|
+
|
94
|
+
it "#tagged_with_genres with options" do
|
95
|
+
tag = 'pop'
|
96
|
+
|
97
|
+
ScopedTaggedModel.delete_all
|
98
|
+
options_check1 = ScopedTaggedModel.new
|
99
|
+
options_check1.genre_list << tag
|
100
|
+
options_check1.save!
|
101
|
+
|
102
|
+
options_check2 = ScopedTaggedModel.new
|
103
|
+
options_check2.genre_list << tag
|
104
|
+
options_check2.save!
|
105
|
+
|
106
|
+
st_check = ScopedTaggedModel.tagged_with_genres(tag, :limit => 1)
|
107
|
+
st_check.size.should == 1
|
108
|
+
st_check.first.id.should == options_check1.id
|
109
|
+
end
|
110
|
+
|
111
|
+
it "#tagged_with_genres with options and multiple tags in an array" do
|
112
|
+
tag = ['pop', 'techno', 'dance']
|
113
|
+
|
114
|
+
ScopedTaggedModel.delete_all
|
115
|
+
options_check1 = ScopedTaggedModel.new
|
116
|
+
options_check1.genre_list << tag
|
117
|
+
options_check1.save!
|
118
|
+
|
119
|
+
options_check2 = ScopedTaggedModel.new
|
120
|
+
options_check2.genre_list << tag
|
121
|
+
options_check2.save!
|
122
|
+
|
123
|
+
st_check = ScopedTaggedModel.tagged_with_genres(tag, :limit => 1)
|
124
|
+
st_check.size.should == 1
|
125
|
+
st_check.first.id.should == options_check1.id
|
126
|
+
end
|
127
|
+
|
128
|
+
it "#tagged_with_genres with options and multiple tags in a string" do
|
129
|
+
tag = 'pop, techno, dance'
|
130
|
+
|
131
|
+
ScopedTaggedModel.delete_all
|
132
|
+
options_check1 = ScopedTaggedModel.new
|
133
|
+
options_check1.genre_list << tag
|
134
|
+
options_check1.save!
|
135
|
+
|
136
|
+
options_check2 = ScopedTaggedModel.new
|
137
|
+
options_check2.genre_list << tag
|
138
|
+
options_check2.save!
|
139
|
+
|
140
|
+
st_check = ScopedTaggedModel.tagged_with_genres(tag, :limit => 1)
|
141
|
+
st_check.size.should == 1
|
142
|
+
st_check.first.id.should == options_check1.id
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
|
3
|
+
|
4
|
+
describe Tagging do
|
5
|
+
before(:all) { Tagging.create!(:tag_id => 1, :taggable_id => 1) }
|
6
|
+
|
7
|
+
it { should belong_to(:tag) }
|
8
|
+
it { should belong_to(:taggable) }
|
9
|
+
it { should validate_uniqueness_of(:taggable_id).scoped_to(:tag_id) }
|
10
|
+
|
11
|
+
it { should allow_mass_assignment_of(:taggable_id)
|
12
|
+
should allow_mass_assignment_of(:taggable_type)
|
13
|
+
should allow_mass_assignment_of(:tag_id) }
|
14
|
+
|
15
|
+
it { should_not allow_mass_assignment_of(:created_at)
|
16
|
+
should_not allow_mass_assignment_of(:updated_at) }
|
17
|
+
|
18
|
+
it { should have_db_index([:taggable_id, :taggable_type]) }
|
19
|
+
end
|
20
|
+
|
21
|
+
|
22
|
+
describe Tag do
|
23
|
+
before(:all) { Tag.create!(:name => 'rock', :context => 'genres') }
|
24
|
+
|
25
|
+
it { should have_many(:taggings).dependent(:delete_all) }
|
26
|
+
|
27
|
+
it { should validate_uniqueness_of(:name).scoped_to(:context).case_insensitive }
|
28
|
+
it { should validate_presence_of(:context) }
|
29
|
+
|
30
|
+
it { should allow_mass_assignment_of(:name)
|
31
|
+
should allow_mass_assignment_of(:context) }
|
32
|
+
|
33
|
+
it { should_not allow_mass_assignment_of(:created_at)
|
34
|
+
should_not allow_mass_assignment_of(:updated_at) }
|
35
|
+
|
36
|
+
it { should have_db_index([:context, :name]) }
|
37
|
+
|
38
|
+
it "should trim and squeeze spaces from name and context before validation" do
|
39
|
+
t = Tag.new(:name => ' rock and roll', :context => ' genres ')
|
40
|
+
t.valid?
|
41
|
+
t.name.should == 'rock and roll'
|
42
|
+
t.context.should == 'genres'
|
43
|
+
end
|
44
|
+
|
45
|
+
it "should lowercase the name and context before validation" do
|
46
|
+
t = Tag.new(:name => 'POP', :context => 'GENRES')
|
47
|
+
t.valid?
|
48
|
+
t.name.should == 'pop'
|
49
|
+
t.context.should == 'genres'
|
50
|
+
end
|
51
|
+
|
52
|
+
it "should recognize that it is equal to another tag with the same name and context" do
|
53
|
+
t = Tag.new(:name => 'pop', :context => 'genres')
|
54
|
+
s = Tag.new(:name => 'pop', :context => 'genres')
|
55
|
+
t.should == s
|
56
|
+
end
|
57
|
+
|
58
|
+
it "#find_or_new_by_name_ane_context" do
|
59
|
+
Tag.create!(:name => 'pop', :context => 'genres')
|
60
|
+
s = Tag.find_or_new_by_name_and_context('pop', 'genres')
|
61
|
+
s.new_record?.should be_false
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../spec_helper'
|
2
|
+
|
3
|
+
|
4
|
+
describe TagListCollection do
|
5
|
+
|
6
|
+
before(:each) do
|
7
|
+
@sm = ScopedTaggedModel.new
|
8
|
+
@tlc = TagListCollection.new(@sm, :genres)
|
9
|
+
end
|
10
|
+
|
11
|
+
it "#delimiter" do
|
12
|
+
TagListCollection.delimiter.should == ','
|
13
|
+
TagListCollection.delimiter = '-'
|
14
|
+
TagListCollection.delimiter.should == '-'
|
15
|
+
TagListCollection.delimiter = ','
|
16
|
+
TagListCollection.delimiter.should == ','
|
17
|
+
end
|
18
|
+
|
19
|
+
it ".proxy_owner" do
|
20
|
+
@sm.genre_list.proxy_owner.should == @sm
|
21
|
+
end
|
22
|
+
|
23
|
+
it ".===" do
|
24
|
+
@sm.genre_list << "disco"
|
25
|
+
@sm.genre_list.should === ['disco']
|
26
|
+
end
|
27
|
+
|
28
|
+
it "should return an array with the class method is called (think its an array)" do
|
29
|
+
@tlc.class.should == Array
|
30
|
+
end
|
31
|
+
|
32
|
+
it "should return the tag names in the genre list" do
|
33
|
+
@sm.genres << Tag.new(:name => 'pop', :context => 'genres')
|
34
|
+
@sm.genres << Tag.new(:name => 'rock', :context => 'genres')
|
35
|
+
@sm.genre_list.should == ['pop', 'rock']
|
36
|
+
end
|
37
|
+
|
38
|
+
it "should add a tag to the tag list when the association << method is used" do
|
39
|
+
@sm.genre_list.should == []
|
40
|
+
@sm.genres << Tag.new(:name => 'rock', :context => 'genres')
|
41
|
+
@sm.genre_list.should == ['rock']
|
42
|
+
end
|
43
|
+
|
44
|
+
it "should be able to be add a new tag word using <<" do
|
45
|
+
@sm.genre_list << "disco"
|
46
|
+
@sm.genre_list.should include('disco')
|
47
|
+
end
|
48
|
+
|
49
|
+
it "should be able to be add a new tag word using push" do
|
50
|
+
@tlc.push('techno')
|
51
|
+
@tlc.should include('techno')
|
52
|
+
end
|
53
|
+
|
54
|
+
it "should be able to be add a new tag word using concat" do
|
55
|
+
@tlc.concat('dance')
|
56
|
+
@tlc.should include('dance')
|
57
|
+
end
|
58
|
+
|
59
|
+
it "should be able to add delimited lists of tags" do
|
60
|
+
@tlc << "techno, house"
|
61
|
+
@tlc.should include('techno')
|
62
|
+
@tlc.should include('house')
|
63
|
+
@tlc.size.should == 2
|
64
|
+
end
|
65
|
+
|
66
|
+
it "should be able to delete tags using delete" do
|
67
|
+
@tlc << 'rock'
|
68
|
+
@sm.genres.size.should == 1
|
69
|
+
@tlc.delete('rock')
|
70
|
+
@tlc.should_not include('rock')
|
71
|
+
@sm.genres.size.should == 0
|
72
|
+
end
|
73
|
+
|
74
|
+
it "should be able to delete tags using delete_at" do
|
75
|
+
@tlc << 'rock' << 'pop' << 'disco'
|
76
|
+
@sm.genres.size.should == 3
|
77
|
+
@sm.save!
|
78
|
+
@tlc.delete_at(0)
|
79
|
+
@tlc.should_not include('rock')
|
80
|
+
@sm.genres.size.should == 2
|
81
|
+
@sm.save!
|
82
|
+
@sm.reload
|
83
|
+
@sm.genres.size.should == 2
|
84
|
+
end
|
85
|
+
|
86
|
+
it "should be able to delete tags using pop" do
|
87
|
+
@tlc << 'rock' << 'pop' << 'disco'
|
88
|
+
@sm.genres.size.should == 3
|
89
|
+
@sm.genre_list.last.should == 'disco'
|
90
|
+
@tlc.pop
|
91
|
+
@tlc.should_not include('disco')
|
92
|
+
@sm.save!
|
93
|
+
@sm.reload
|
94
|
+
@sm.genres.size.should == 2
|
95
|
+
end
|
96
|
+
|
97
|
+
it "should give a delimited list of words when converted to string" do
|
98
|
+
@tlc << 'rock' << 'pop'
|
99
|
+
@tlc.to_s.should == "rock, pop"
|
100
|
+
end
|
101
|
+
|
102
|
+
it "should not add duplicate tags" do
|
103
|
+
@tlc << 'rock' << 'pop' << 'disco'
|
104
|
+
@sm.genres.size.should == 3
|
105
|
+
@tlc.should == ['rock', 'pop', 'disco']
|
106
|
+
@tlc << "rock"
|
107
|
+
@tlc.should == ['rock', 'pop', 'disco']
|
108
|
+
@sm.genres.size.should == 3
|
109
|
+
end
|
110
|
+
|
111
|
+
it ".replace" do
|
112
|
+
@tlc << 'rock' << 'pop' << 'disco'
|
113
|
+
@sm.genre_list.should == ['rock', 'pop', 'disco']
|
114
|
+
@sm.genre_list = ['funk', 'house']
|
115
|
+
@sm.genre_list.should == ['funk', 'house']
|
116
|
+
end
|
117
|
+
|
118
|
+
it ".to_param" do
|
119
|
+
@sm.genre_list = ['funk', 'house']
|
120
|
+
@sm.genre_list.to_param.should == 'funk/house'
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'spec'
|
2
|
+
require 'shoulda'
|
3
|
+
|
4
|
+
require 'active_support'
|
5
|
+
require 'active_record'
|
6
|
+
|
7
|
+
require 'scoped_tags'
|
8
|
+
|
9
|
+
Spec::Runner.configure do |config|
|
10
|
+
config.include(Shoulda::ActiveRecord::Matchers, :type => :model)
|
11
|
+
end
|
12
|
+
|
13
|
+
TEST_DATABASE_FILE = 'spec/test.sqlite3'
|
14
|
+
|
15
|
+
File.unlink(TEST_DATABASE_FILE) if File.exist?(TEST_DATABASE_FILE)
|
16
|
+
ActiveRecord::Base.establish_connection(
|
17
|
+
"adapter" => "sqlite3", "database" => TEST_DATABASE_FILE
|
18
|
+
)
|
19
|
+
|
20
|
+
load('schema.rb')
|
21
|
+
|
22
|
+
RAILS_DEFAULT_LOGGER = Logger.new("spec/debug.log")
|
23
|
+
|
24
|
+
class ScopedTaggedModel < ActiveRecord::Base
|
25
|
+
scoped_tags :genres
|
26
|
+
end
|
27
|
+
|
28
|
+
class UnScopedTaggedModel < ActiveRecord::Base
|
29
|
+
end
|
data/spec/test.sqlite3
ADDED
Binary file
|
data/uninstall.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
# Uninstall hook code here
|
metadata
ADDED
@@ -0,0 +1,82 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: scoped-tags
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.3.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Josh Kalderimis
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-10-18 00:00:00 +02:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: activerecord
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ">="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 2.3.3
|
24
|
+
version:
|
25
|
+
description:
|
26
|
+
email: josh.kalderimis@gmail.com
|
27
|
+
executables: []
|
28
|
+
|
29
|
+
extensions: []
|
30
|
+
|
31
|
+
extra_rdoc_files: []
|
32
|
+
|
33
|
+
files:
|
34
|
+
- MIT-LICENSE
|
35
|
+
- VERSION.yml
|
36
|
+
- generators/scoped_tags_migration/scoped_tags_generator.rb
|
37
|
+
- generators/scoped_tags_migration/templates/migration.rb
|
38
|
+
- install.rb
|
39
|
+
- lib/scoped_tags.rb
|
40
|
+
- lib/scoped_tags/active_record_additions.rb
|
41
|
+
- lib/scoped_tags/tag.rb
|
42
|
+
- lib/scoped_tags/tag_list_collection.rb
|
43
|
+
- lib/scoped_tags/tag_list_proxy.rb
|
44
|
+
- lib/scoped_tags/tagging.rb
|
45
|
+
- readme.md
|
46
|
+
- uninstall.rb
|
47
|
+
has_rdoc: true
|
48
|
+
homepage: http://github.com/joshk/scoped-tags
|
49
|
+
licenses: []
|
50
|
+
|
51
|
+
post_install_message:
|
52
|
+
rdoc_options:
|
53
|
+
- --charset=UTF-8
|
54
|
+
require_paths:
|
55
|
+
- lib
|
56
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
version: "0"
|
61
|
+
version:
|
62
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
63
|
+
requirements:
|
64
|
+
- - ">="
|
65
|
+
- !ruby/object:Gem::Version
|
66
|
+
version: "0"
|
67
|
+
version:
|
68
|
+
requirements: []
|
69
|
+
|
70
|
+
rubyforge_project:
|
71
|
+
rubygems_version: 1.3.5
|
72
|
+
signing_key:
|
73
|
+
specification_version: 3
|
74
|
+
summary: Scoped tagging plugin for your rails models which keeps your associations in sync with your tag arrays
|
75
|
+
test_files:
|
76
|
+
- spec/debug.log
|
77
|
+
- spec/schema.rb
|
78
|
+
- spec/scoped_tags/scoped_tags_spec.rb
|
79
|
+
- spec/scoped_tags/tag_and_tagging_spec.rb
|
80
|
+
- spec/scoped_tags/tag_list_spec.rb
|
81
|
+
- spec/spec_helper.rb
|
82
|
+
- spec/test.sqlite3
|