scoped-tags 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|