##// END OF EJS Templates
Merged nested projects branch. Removes limit on subproject nesting (#594)....
Jean-Philippe Lang -
r2302:c9906480d3f2
parent child
Show More
@@ -0,0 +1,11
1 class AddProjectsLftAndRgt < ActiveRecord::Migration
2 def self.up
3 add_column :projects, :lft, :integer
4 add_column :projects, :rgt, :integer
5 end
6
7 def self.down
8 remove_column :projects, :lft
9 remove_column :projects, :rgt
10 end
11 end
@@ -0,0 +1,8
1 class BuildProjectsTree < ActiveRecord::Migration
2 def self.up
3 Project.rebuild!
4 end
5
6 def self.down
7 end
8 end
@@ -0,0 +1,20
1 Copyright (c) 2007 [name of plugin creator]
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.
@@ -0,0 +1,64
1 = AwesomeNestedSet
2
3 Awesome Nested Set is an implementation of the nested set pattern for ActiveRecord models. It is replacement for acts_as_nested_set and BetterNestedSet, but awesomer.
4
5 == What makes this so awesome?
6
7 This is a new implementation of nested set based off of BetterNestedSet that fixes some bugs, removes tons of duplication, adds a few useful methods, and adds STI support.
8
9 == Installation
10
11 If you are on Rails 2.1 or later:
12
13 script/plugin install git://github.com/collectiveidea/awesome_nested_set.git
14
15 == Usage
16
17 To make use of awesome_nested_set, your model needs to have 3 fields: lft, rgt, and parent_id:
18
19 class CreateCategories < ActiveRecord::Migration
20 def self.up
21 create_table :categories do |t|
22 t.string :name
23 t.integer :parent_id
24 t.integer :lft
25 t.integer :rgt
26 end
27 end
28
29 def self.down
30 drop_table :categories
31 end
32 end
33
34 Enable the nested set functionality by declaring acts_as_nested_set on your model
35
36 class Category < ActiveRecord::Base
37 acts_as_nested_set
38 end
39
40 Run `rake rdoc` to generate the API docs and see CollectiveIdea::Acts::NestedSet::SingletonMethods for more info.
41
42 == View Helper
43
44 The view helper is called #nested_set_options.
45
46 Example usage:
47
48 <%= f.select :parent_id, nested_set_options(Category, @category) {|i| "#{'-' * i.level} #{i.name}" } %>
49
50 <%= select_tag 'parent_id', options_for_select(nested_set_options(Category) {|i| "#{'-' * i.level} #{i.name}" } ) %>
51
52 See CollectiveIdea::Acts::NestedSet::Helper for more information about the helpers.
53
54 == References
55
56 You can learn more about nested sets at:
57
58 http://www.dbmsmag.com/9603d06.html
59 http://threebit.net/tutorials/nestedset/tutorial1.html
60 http://api.rubyonrails.com/classes/ActiveRecord/Acts/NestedSet/ClassMethods.html
61 http://opensource.symetrie.com/trac/better_nested_set/
62
63
64 Copyright (c) 2008 Collective Idea, released under the MIT license No newline at end of file
@@ -0,0 +1,46
1 require 'rake'
2 require 'rake/testtask'
3 require 'rake/rdoctask'
4 require 'rake/gempackagetask'
5 require 'rcov/rcovtask'
6 require "load_multi_rails_rake_tasks"
7
8 spec = eval(File.read("#{File.dirname(__FILE__)}/awesome_nested_set.gemspec"))
9 PKG_NAME = spec.name
10 PKG_VERSION = spec.version
11
12 Rake::GemPackageTask.new(spec) do |pkg|
13 pkg.need_zip = true
14 pkg.need_tar = true
15 end
16
17
18 desc 'Default: run unit tests.'
19 task :default => :test
20
21 desc 'Test the awesome_nested_set plugin.'
22 Rake::TestTask.new(:test) do |t|
23 t.libs << 'lib'
24 t.pattern = 'test/**/*_test.rb'
25 t.verbose = true
26 end
27
28 desc 'Generate documentation for the awesome_nested_set plugin.'
29 Rake::RDocTask.new(:rdoc) do |rdoc|
30 rdoc.rdoc_dir = 'rdoc'
31 rdoc.title = 'AwesomeNestedSet'
32 rdoc.options << '--line-numbers' << '--inline-source'
33 rdoc.rdoc_files.include('README.rdoc')
34 rdoc.rdoc_files.include('lib/**/*.rb')
35 end
36
37 namespace :test do
38 desc "just rcov minus html output"
39 Rcov::RcovTask.new(:coverage) do |t|
40 # t.libs << 'test'
41 t.test_files = FileList['test/**/*_test.rb']
42 t.output_dir = 'coverage'
43 t.verbose = true
44 t.rcov_opts = %w(--exclude test,/usr/lib/ruby,/Library/Ruby,lib/awesome_nested_set/named_scope.rb --sort coverage)
45 end
46 end No newline at end of file
@@ -0,0 +1,20
1 Gem::Specification.new do |s|
2 s.name = "awesome_nested_set"
3 s.version = "1.1.1"
4 s.summary = "An awesome replacement for acts_as_nested_set and better_nested_set."
5 s.description = s.summary
6
7 s.files = %w(init.rb MIT-LICENSE Rakefile README.rdoc lib/awesome_nested_set.rb lib/awesome_nested_set/compatability.rb lib/awesome_nested_set/helper.rb lib/awesome_nested_set/named_scope.rb rails/init.rb test/awesome_nested_set_test.rb test/test_helper.rb test/awesome_nested_set/helper_test.rb test/db/database.yml test/db/schema.rb test/fixtures/categories.yml test/fixtures/category.rb test/fixtures/departments.yml test/fixtures/notes.yml)
8
9 s.add_dependency "activerecord", ['>= 1.1']
10
11 s.has_rdoc = true
12 s.extra_rdoc_files = [ "README.rdoc"]
13 s.rdoc_options = ["--main", "README.rdoc", "--inline-source", "--line-numbers"]
14
15 s.test_files = %w(test/awesome_nested_set_test.rb test/test_helper.rb test/awesome_nested_set/helper_test.rb test/db/database.yml test/db/schema.rb test/fixtures/categories.yml test/fixtures/category.rb test/fixtures/departments.yml test/fixtures/notes.yml)
16 s.require_path = 'lib'
17 s.author = "Collective Idea"
18 s.email = "info@collectiveidea.com"
19 s.homepage = "http://collectiveidea.com"
20 end
@@ -0,0 +1,1
1 require File.dirname(__FILE__) + "/rails/init"
This diff has been collapsed as it changes many lines, (547 lines changed) Show them Hide them
@@ -0,0 +1,547
1 module CollectiveIdea #:nodoc:
2 module Acts #:nodoc:
3 module NestedSet #:nodoc:
4 def self.included(base)
5 base.extend(SingletonMethods)
6 end
7
8 # This acts provides Nested Set functionality. Nested Set is a smart way to implement
9 # an _ordered_ tree, with the added feature that you can select the children and all of their
10 # descendants with a single query. The drawback is that insertion or move need some complex
11 # sql queries. But everything is done here by this module!
12 #
13 # Nested sets are appropriate each time you want either an orderd tree (menus,
14 # commercial categories) or an efficient way of querying big trees (threaded posts).
15 #
16 # == API
17 #
18 # Methods names are aligned with acts_as_tree as much as possible, to make replacment from one
19 # by another easier, except for the creation:
20 #
21 # in acts_as_tree:
22 # item.children.create(:name => "child1")
23 #
24 # in acts_as_nested_set:
25 # # adds a new item at the "end" of the tree, i.e. with child.left = max(tree.right)+1
26 # child = MyClass.new(:name => "child1")
27 # child.save
28 # # now move the item to its right place
29 # child.move_to_child_of my_item
30 #
31 # You can pass an id or an object to:
32 # * <tt>#move_to_child_of</tt>
33 # * <tt>#move_to_right_of</tt>
34 # * <tt>#move_to_left_of</tt>
35 #
36 module SingletonMethods
37 # Configuration options are:
38 #
39 # * +:parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id)
40 # * +:left_column+ - column name for left boundry data, default "lft"
41 # * +:right_column+ - column name for right boundry data, default "rgt"
42 # * +:scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id"
43 # (if it hasn't been already) and use that as the foreign key restriction. You
44 # can also pass an array to scope by multiple attributes.
45 # Example: <tt>acts_as_nested_set :scope => [:notable_id, :notable_type]</tt>
46 # * +:dependent+ - behavior for cascading destroy. If set to :destroy, all the
47 # child objects are destroyed alongside this object by calling their destroy
48 # method. If set to :delete_all (default), all the child objects are deleted
49 # without calling their destroy method.
50 #
51 # See CollectiveIdea::Acts::NestedSet::ClassMethods for a list of class methods and
52 # CollectiveIdea::Acts::NestedSet::InstanceMethods for a list of instance methods added
53 # to acts_as_nested_set models
54 def acts_as_nested_set(options = {})
55 options = {
56 :parent_column => 'parent_id',
57 :left_column => 'lft',
58 :right_column => 'rgt',
59 :order => 'id',
60 :dependent => :delete_all, # or :destroy
61 }.merge(options)
62
63 if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/
64 options[:scope] = "#{options[:scope]}_id".intern
65 end
66
67 write_inheritable_attribute :acts_as_nested_set_options, options
68 class_inheritable_reader :acts_as_nested_set_options
69
70 include Comparable
71 include Columns
72 include InstanceMethods
73 extend Columns
74 extend ClassMethods
75
76 # no bulk assignment
77 attr_protected left_column_name.intern,
78 right_column_name.intern,
79 parent_column_name.intern
80
81 before_create :set_default_left_and_right
82 before_destroy :prune_from_tree
83
84 # no assignment to structure fields
85 [left_column_name, right_column_name, parent_column_name].each do |column|
86 module_eval <<-"end_eval", __FILE__, __LINE__
87 def #{column}=(x)
88 raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead."
89 end
90 end_eval
91 end
92
93 named_scope :roots, :conditions => {parent_column_name => nil}, :order => quoted_left_column_name
94 named_scope :leaves, :conditions => "#{quoted_right_column_name} - #{quoted_left_column_name} = 1", :order => quoted_left_column_name
95 if self.respond_to?(:define_callbacks)
96 define_callbacks("before_move", "after_move")
97 end
98
99
100 end
101
102 end
103
104 module ClassMethods
105
106 # Returns the first root
107 def root
108 roots.find(:first)
109 end
110
111 def valid?
112 left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid?
113 end
114
115 def left_and_rights_valid?
116 count(
117 :joins => "LEFT OUTER JOIN #{quoted_table_name} AS parent ON " +
118 "#{quoted_table_name}.#{quoted_parent_column_name} = parent.#{primary_key}",
119 :conditions =>
120 "#{quoted_table_name}.#{quoted_left_column_name} IS NULL OR " +
121 "#{quoted_table_name}.#{quoted_right_column_name} IS NULL OR " +
122 "#{quoted_table_name}.#{quoted_left_column_name} >= " +
123 "#{quoted_table_name}.#{quoted_right_column_name} OR " +
124 "(#{quoted_table_name}.#{quoted_parent_column_name} IS NOT NULL AND " +
125 "(#{quoted_table_name}.#{quoted_left_column_name} <= parent.#{quoted_left_column_name} OR " +
126 "#{quoted_table_name}.#{quoted_right_column_name} >= parent.#{quoted_right_column_name}))"
127 ) == 0
128 end
129
130 def no_duplicates_for_columns?
131 scope_string = Array(acts_as_nested_set_options[:scope]).map do |c|
132 connection.quote_column_name(c)
133 end.push(nil).join(", ")
134 [quoted_left_column_name, quoted_right_column_name].all? do |column|
135 # No duplicates
136 find(:first,
137 :select => "#{scope_string}#{column}, COUNT(#{column})",
138 :group => "#{scope_string}#{column}
139 HAVING COUNT(#{column}) > 1").nil?
140 end
141 end
142
143 # Wrapper for each_root_valid? that can deal with scope.
144 def all_roots_valid?
145 if acts_as_nested_set_options[:scope]
146 roots(:group => scope_column_names).group_by{|record| scope_column_names.collect{|col| record.send(col.to_sym)}}.all? do |scope, grouped_roots|
147 each_root_valid?(grouped_roots)
148 end
149 else
150 each_root_valid?(roots)
151 end
152 end
153
154 def each_root_valid?(roots_to_validate)
155 left = right = 0
156 roots_to_validate.all? do |root|
157 returning(root.left > left && root.right > right) do
158 left = root.left
159 right = root.right
160 end
161 end
162 end
163
164 # Rebuilds the left & rights if unset or invalid. Also very useful for converting from acts_as_tree.
165 def rebuild!
166 # Don't rebuild a valid tree.
167 return true if valid?
168
169 scope = lambda{}
170 if acts_as_nested_set_options[:scope]
171 scope = lambda{|node|
172 scope_column_names.inject(""){|str, column_name|
173 str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name.to_sym))} "
174 }
175 }
176 end
177 indices = {}
178
179 set_left_and_rights = lambda do |node|
180 # set left
181 node[left_column_name] = indices[scope.call(node)] += 1
182 # find
183 find(:all, :conditions => ["#{quoted_parent_column_name} = ? #{scope.call(node)}", node], :order => "#{quoted_left_column_name}, #{quoted_right_column_name}, #{acts_as_nested_set_options[:order]}").each{|n| set_left_and_rights.call(n) }
184 # set right
185 node[right_column_name] = indices[scope.call(node)] += 1
186 node.save!
187 end
188
189 # Find root node(s)
190 root_nodes = find(:all, :conditions => "#{quoted_parent_column_name} IS NULL", :order => "#{quoted_left_column_name}, #{quoted_right_column_name}, #{acts_as_nested_set_options[:order]}").each do |root_node|
191 # setup index for this scope
192 indices[scope.call(root_node)] ||= 0
193 set_left_and_rights.call(root_node)
194 end
195 end
196 end
197
198 # Mixed into both classes and instances to provide easy access to the column names
199 module Columns
200 def left_column_name
201 acts_as_nested_set_options[:left_column]
202 end
203
204 def right_column_name
205 acts_as_nested_set_options[:right_column]
206 end
207
208 def parent_column_name
209 acts_as_nested_set_options[:parent_column]
210 end
211
212 def scope_column_names
213 Array(acts_as_nested_set_options[:scope])
214 end
215
216 def quoted_left_column_name
217 connection.quote_column_name(left_column_name)
218 end
219
220 def quoted_right_column_name
221 connection.quote_column_name(right_column_name)
222 end
223
224 def quoted_parent_column_name
225 connection.quote_column_name(parent_column_name)
226 end
227
228 def quoted_scope_column_names
229 scope_column_names.collect {|column_name| connection.quote_column_name(column_name) }
230 end
231 end
232
233 # Any instance method that returns a collection makes use of Rails 2.1's named_scope (which is bundled for Rails 2.0), so it can be treated as a finder.
234 #
235 # category.self_and_descendants.count
236 # category.ancestors.find(:all, :conditions => "name like '%foo%'")
237 module InstanceMethods
238 # Value of the parent column
239 def parent_id
240 self[parent_column_name]
241 end
242
243 # Value of the left column
244 def left
245 self[left_column_name]
246 end
247
248 # Value of the right column
249 def right
250 self[right_column_name]
251 end
252
253 # Returns true if this is a root node.
254 def root?
255 parent_id.nil?
256 end
257
258 def leaf?
259 right - left == 1
260 end
261
262 # Returns true is this is a child node
263 def child?
264 !parent_id.nil?
265 end
266
267 # order by left column
268 def <=>(x)
269 left <=> x.left
270 end
271
272 # Redefine to act like active record
273 def ==(comparison_object)
274 comparison_object.equal?(self) ||
275 (comparison_object.instance_of?(self.class) &&
276 comparison_object.id == id &&
277 !comparison_object.new_record?)
278 end
279
280 # Returns root
281 def root
282 self_and_ancestors.find(:first)
283 end
284
285 # Returns the immediate parent
286 def parent
287 nested_set_scope.find_by_id(parent_id) if parent_id
288 end
289
290 # Returns the array of all parents and self
291 def self_and_ancestors
292 nested_set_scope.scoped :conditions => [
293 "#{self.class.table_name}.#{quoted_left_column_name} <= ? AND #{self.class.table_name}.#{quoted_right_column_name} >= ?", left, right
294 ]
295 end
296
297 # Returns an array of all parents
298 def ancestors
299 without_self self_and_ancestors
300 end
301
302 # Returns the array of all children of the parent, including self
303 def self_and_siblings
304 nested_set_scope.scoped :conditions => {parent_column_name => parent_id}
305 end
306
307 # Returns the array of all children of the parent, except self
308 def siblings
309 without_self self_and_siblings
310 end
311
312 # Returns a set of all of its nested children which do not have children
313 def leaves
314 descendants.scoped :conditions => "#{self.class.table_name}.#{quoted_right_column_name} - #{self.class.table_name}.#{quoted_left_column_name} = 1"
315 end
316
317 # Returns the level of this object in the tree
318 # root level is 0
319 def level
320 parent_id.nil? ? 0 : ancestors.count
321 end
322
323 # Returns a set of itself and all of its nested children
324 def self_and_descendants
325 nested_set_scope.scoped :conditions => [
326 "#{self.class.table_name}.#{quoted_left_column_name} >= ? AND #{self.class.table_name}.#{quoted_right_column_name} <= ?", left, right
327 ]
328 end
329
330 # Returns a set of all of its children and nested children
331 def descendants
332 without_self self_and_descendants
333 end
334
335 # Returns a set of only this entry's immediate children
336 def children
337 nested_set_scope.scoped :conditions => {parent_column_name => self}
338 end
339
340 def is_descendant_of?(other)
341 other.left < self.left && self.left < other.right && same_scope?(other)
342 end
343
344 def is_or_is_descendant_of?(other)
345 other.left <= self.left && self.left < other.right && same_scope?(other)
346 end
347
348 def is_ancestor_of?(other)
349 self.left < other.left && other.left < self.right && same_scope?(other)
350 end
351
352 def is_or_is_ancestor_of?(other)
353 self.left <= other.left && other.left < self.right && same_scope?(other)
354 end
355
356 # Check if other model is in the same scope
357 def same_scope?(other)
358 Array(acts_as_nested_set_options[:scope]).all? do |attr|
359 self.send(attr) == other.send(attr)
360 end
361 end
362
363 # Find the first sibling to the left
364 def left_sibling
365 siblings.find(:first, :conditions => ["#{self.class.table_name}.#{quoted_left_column_name} < ?", left],
366 :order => "#{self.class.table_name}.#{quoted_left_column_name} DESC")
367 end
368
369 # Find the first sibling to the right
370 def right_sibling
371 siblings.find(:first, :conditions => ["#{self.class.table_name}.#{quoted_left_column_name} > ?", left])
372 end
373
374 # Shorthand method for finding the left sibling and moving to the left of it.
375 def move_left
376 move_to_left_of left_sibling
377 end
378
379 # Shorthand method for finding the right sibling and moving to the right of it.
380 def move_right
381 move_to_right_of right_sibling
382 end
383
384 # Move the node to the left of another node (you can pass id only)
385 def move_to_left_of(node)
386 move_to node, :left
387 end
388
389 # Move the node to the left of another node (you can pass id only)
390 def move_to_right_of(node)
391 move_to node, :right
392 end
393
394 # Move the node to the child of another node (you can pass id only)
395 def move_to_child_of(node)
396 move_to node, :child
397 end
398
399 # Move the node to root nodes
400 def move_to_root
401 move_to nil, :root
402 end
403
404 def move_possible?(target)
405 self != target && # Can't target self
406 same_scope?(target) && # can't be in different scopes
407 # !(left..right).include?(target.left..target.right) # this needs tested more
408 # detect impossible move
409 !((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right))
410 end
411
412 def to_text
413 self_and_descendants.map do |node|
414 "#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})"
415 end.join("\n")
416 end
417
418 protected
419
420 def without_self(scope)
421 scope.scoped :conditions => ["#{self.class.table_name}.#{self.class.primary_key} != ?", self]
422 end
423
424 # All nested set queries should use this nested_set_scope, which performs finds on
425 # the base ActiveRecord class, using the :scope declared in the acts_as_nested_set
426 # declaration.
427 def nested_set_scope
428 options = {:order => quoted_left_column_name}
429 scopes = Array(acts_as_nested_set_options[:scope])
430 options[:conditions] = scopes.inject({}) do |conditions,attr|
431 conditions.merge attr => self[attr]
432 end unless scopes.empty?
433 self.class.base_class.scoped options
434 end
435
436 # on creation, set automatically lft and rgt to the end of the tree
437 def set_default_left_and_right
438 maxright = nested_set_scope.maximum(right_column_name) || 0
439 # adds the new node to the right of all existing nodes
440 self[left_column_name] = maxright + 1
441 self[right_column_name] = maxright + 2
442 end
443
444 # Prunes a branch off of the tree, shifting all of the elements on the right
445 # back to the left so the counts still work.
446 def prune_from_tree
447 return if right.nil? || left.nil?
448 diff = right - left + 1
449
450 delete_method = acts_as_nested_set_options[:dependent] == :destroy ?
451 :destroy_all : :delete_all
452
453 self.class.base_class.transaction do
454 nested_set_scope.send(delete_method,
455 ["#{quoted_left_column_name} > ? AND #{quoted_right_column_name} < ?",
456 left, right]
457 )
458 nested_set_scope.update_all(
459 ["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff],
460 ["#{quoted_left_column_name} >= ?", right]
461 )
462 nested_set_scope.update_all(
463 ["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff],
464 ["#{quoted_right_column_name} >= ?", right]
465 )
466 end
467 end
468
469 # reload left, right, and parent
470 def reload_nested_set
471 reload(:select => "#{quoted_left_column_name}, " +
472 "#{quoted_right_column_name}, #{quoted_parent_column_name}")
473 end
474
475 def move_to(target, position)
476 raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record?
477 return if callback(:before_move) == false
478 transaction do
479 if target.is_a? self.class.base_class
480 target.reload_nested_set
481 elsif position != :root
482 # load object if node is not an object
483 target = nested_set_scope.find(target)
484 end
485 self.reload_nested_set
486
487 unless position == :root || move_possible?(target)
488 raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree."
489 end
490
491 bound = case position
492 when :child; target[right_column_name]
493 when :left; target[left_column_name]
494 when :right; target[right_column_name] + 1
495 when :root; 1
496 else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)."
497 end
498
499 if bound > self[right_column_name]
500 bound = bound - 1
501 other_bound = self[right_column_name] + 1
502 else
503 other_bound = self[left_column_name] - 1
504 end
505
506 # there would be no change
507 return if bound == self[right_column_name] || bound == self[left_column_name]
508
509 # we have defined the boundaries of two non-overlapping intervals,
510 # so sorting puts both the intervals and their boundaries in order
511 a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort
512
513 new_parent = case position
514 when :child; target.id
515 when :root; nil
516 else target[parent_column_name]
517 end
518
519 self.class.base_class.update_all([
520 "#{quoted_left_column_name} = CASE " +
521 "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " +
522 "THEN #{quoted_left_column_name} + :d - :b " +
523 "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " +
524 "THEN #{quoted_left_column_name} + :a - :c " +
525 "ELSE #{quoted_left_column_name} END, " +
526 "#{quoted_right_column_name} = CASE " +
527 "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " +
528 "THEN #{quoted_right_column_name} + :d - :b " +
529 "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " +
530 "THEN #{quoted_right_column_name} + :a - :c " +
531 "ELSE #{quoted_right_column_name} END, " +
532 "#{quoted_parent_column_name} = CASE " +
533 "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " +
534 "ELSE #{quoted_parent_column_name} END",
535 {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent}
536 ], nested_set_scope.proxy_options[:conditions])
537 end
538 target.reload_nested_set if target
539 self.reload_nested_set
540 callback(:after_move)
541 end
542
543 end
544
545 end
546 end
547 end
@@ -0,0 +1,29
1 # Rails <2.x doesn't define #except
2 class Hash #:nodoc:
3 # Returns a new hash without the given keys.
4 def except(*keys)
5 clone.except!(*keys)
6 end unless method_defined?(:except)
7
8 # Replaces the hash without the given keys.
9 def except!(*keys)
10 keys.map! { |key| convert_key(key) } if respond_to?(:convert_key)
11 keys.each { |key| delete(key) }
12 self
13 end unless method_defined?(:except!)
14 end
15
16 # NamedScope is new to Rails 2.1
17 unless defined? ActiveRecord::NamedScope
18 require 'awesome_nested_set/named_scope'
19 ActiveRecord::Base.class_eval do
20 include CollectiveIdea::NamedScope
21 end
22 end
23
24 # Rails 1.2.x doesn't define #quoted_table_name
25 class ActiveRecord::Base #:nodoc:
26 def self.quoted_table_name
27 self.connection.quote_column_name(self.table_name)
28 end unless methods.include?('quoted_table_name')
29 end No newline at end of file
@@ -0,0 +1,40
1 module CollectiveIdea #:nodoc:
2 module Acts #:nodoc:
3 module NestedSet #:nodoc:
4 # This module provides some helpers for the model classes using acts_as_nested_set.
5 # It is included by default in all views.
6 #
7 module Helper
8 # Returns options for select.
9 # You can exclude some items from the tree.
10 # You can pass a block receiving an item and returning the string displayed in the select.
11 #
12 # == Params
13 # * +class_or_item+ - Class name or top level times
14 # * +mover+ - The item that is being move, used to exlude impossible moves
15 # * +&block+ - a block that will be used to display: {Β |item| ... item.name }
16 #
17 # == Usage
18 #
19 # <%= f.select :parent_id, nested_set_options(Category, @category) {|i|
20 # "#{'–' * i.level} #{i.name}"
21 # }) %>
22 #
23 def nested_set_options(class_or_item, mover = nil)
24 class_or_item = class_or_item.roots if class_or_item.is_a?(Class)
25 items = Array(class_or_item)
26 result = []
27 items.each do |root|
28 result += root.self_and_descendants.map do |i|
29 if mover.nil? || mover.new_record? || mover.move_possible?(i)
30 [yield(i), i.id]
31 end
32 end.compact
33 end
34 result
35 end
36
37 end
38 end
39 end
40 end No newline at end of file
@@ -0,0 +1,140
1 # Taken from Rails 2.1
2 module CollectiveIdea #:nodoc:
3 module NamedScope #:nodoc:
4 # All subclasses of ActiveRecord::Base have two named_scopes:
5 # * <tt>all</tt>, which is similar to a <tt>find(:all)</tt> query, and
6 # * <tt>scoped</tt>, which allows for the creation of anonymous scopes, on the fly:
7 #
8 # Shirt.scoped(:conditions => {:color => 'red'}).scoped(:include => :washing_instructions)
9 #
10 # These anonymous scopes tend to be useful when procedurally generating complex queries, where passing
11 # intermediate values (scopes) around as first-class objects is convenient.
12 def self.included(base)
13 base.class_eval do
14 extend ClassMethods
15 named_scope :scoped, lambda { |scope| scope }
16 end
17 end
18
19 module ClassMethods #:nodoc:
20 def scopes
21 read_inheritable_attribute(:scopes) || write_inheritable_attribute(:scopes, {})
22 end
23
24 # Adds a class method for retrieving and querying objects. A scope represents a narrowing of a database query,
25 # such as <tt>:conditions => {:color => :red}, :select => 'shirts.*', :include => :washing_instructions</tt>.
26 #
27 # class Shirt < ActiveRecord::Base
28 # named_scope :red, :conditions => {:color => 'red'}
29 # named_scope :dry_clean_only, :joins => :washing_instructions, :conditions => ['washing_instructions.dry_clean_only = ?', true]
30 # end
31 #
32 # The above calls to <tt>named_scope</tt> define class methods <tt>Shirt.red</tt> and <tt>Shirt.dry_clean_only</tt>. <tt>Shirt.red</tt>,
33 # in effect, represents the query <tt>Shirt.find(:all, :conditions => {:color => 'red'})</tt>.
34 #
35 # Unlike Shirt.find(...), however, the object returned by <tt>Shirt.red</tt> is not an Array; it resembles the association object
36 # constructed by a <tt>has_many</tt> declaration. For instance, you can invoke <tt>Shirt.red.find(:first)</tt>, <tt>Shirt.red.count</tt>,
37 # <tt>Shirt.red.find(:all, :conditions => {:size => 'small'})</tt>. Also, just
38 # as with the association objects, name scopes acts like an Array, implementing Enumerable; <tt>Shirt.red.each(&block)</tt>,
39 # <tt>Shirt.red.first</tt>, and <tt>Shirt.red.inject(memo, &block)</tt> all behave as if Shirt.red really were an Array.
40 #
41 # These named scopes are composable. For instance, <tt>Shirt.red.dry_clean_only</tt> will produce all shirts that are both red and dry clean only.
42 # Nested finds and calculations also work with these compositions: <tt>Shirt.red.dry_clean_only.count</tt> returns the number of garments
43 # for which these criteria obtain. Similarly with <tt>Shirt.red.dry_clean_only.average(:thread_count)</tt>.
44 #
45 # All scopes are available as class methods on the ActiveRecord descendent upon which the scopes were defined. But they are also available to
46 # <tt>has_many</tt> associations. If,
47 #
48 # class Person < ActiveRecord::Base
49 # has_many :shirts
50 # end
51 #
52 # then <tt>elton.shirts.red.dry_clean_only</tt> will return all of Elton's red, dry clean
53 # only shirts.
54 #
55 # Named scopes can also be procedural.
56 #
57 # class Shirt < ActiveRecord::Base
58 # named_scope :colored, lambda { |color|
59 # { :conditions => { :color => color } }
60 # }
61 # end
62 #
63 # In this example, <tt>Shirt.colored('puce')</tt> finds all puce shirts.
64 #
65 # Named scopes can also have extensions, just as with <tt>has_many</tt> declarations:
66 #
67 # class Shirt < ActiveRecord::Base
68 # named_scope :red, :conditions => {:color => 'red'} do
69 # def dom_id
70 # 'red_shirts'
71 # end
72 # end
73 # end
74 #
75 #
76 # For testing complex named scopes, you can examine the scoping options using the
77 # <tt>proxy_options</tt> method on the proxy itself.
78 #
79 # class Shirt < ActiveRecord::Base
80 # named_scope :colored, lambda { |color|
81 # { :conditions => { :color => color } }
82 # }
83 # end
84 #
85 # expected_options = { :conditions => { :colored => 'red' } }
86 # assert_equal expected_options, Shirt.colored('red').proxy_options
87 def named_scope(name, options = {}, &block)
88 scopes[name] = lambda do |parent_scope, *args|
89 Scope.new(parent_scope, case options
90 when Hash
91 options
92 when Proc
93 options.call(*args)
94 end, &block)
95 end
96 (class << self; self end).instance_eval do
97 define_method name do |*args|
98 scopes[name].call(self, *args)
99 end
100 end
101 end
102 end
103
104 class Scope #:nodoc:
105 attr_reader :proxy_scope, :proxy_options
106 [].methods.each { |m| delegate m, :to => :proxy_found unless m =~ /(^__|^nil\?|^send|class|extend|find|count|sum|average|maximum|minimum|paginate)/ }
107 delegate :scopes, :with_scope, :to => :proxy_scope
108
109 def initialize(proxy_scope, options, &block)
110 [options[:extend]].flatten.each { |extension| extend extension } if options[:extend]
111 extend Module.new(&block) if block_given?
112 @proxy_scope, @proxy_options = proxy_scope, options.except(:extend)
113 end
114
115 def reload
116 load_found; self
117 end
118
119 protected
120 def proxy_found
121 @found || load_found
122 end
123
124 private
125 def method_missing(method, *args, &block)
126 if scopes.include?(method)
127 scopes[method].call(self, *args)
128 else
129 with_scope :find => proxy_options do
130 proxy_scope.send(method, *args, &block)
131 end
132 end
133 end
134
135 def load_found
136 @found = find(:all)
137 end
138 end
139 end
140 end No newline at end of file
@@ -0,0 +1,13
1 require 'awesome_nested_set/compatability'
2 require 'awesome_nested_set'
3
4 ActiveRecord::Base.class_eval do
5 include CollectiveIdea::Acts::NestedSet
6 end
7
8 if defined?(ActionView)
9 require 'awesome_nested_set/helper'
10 ActionView::Base.class_eval do
11 include CollectiveIdea::Acts::NestedSet::Helper
12 end
13 end No newline at end of file
@@ -0,0 +1,41
1 require File.dirname(__FILE__) + '/../test_helper'
2
3 module CollectiveIdea
4 module Acts #:nodoc:
5 module NestedSet #:nodoc:
6 class AwesomeNestedSetTest < Test::Unit::TestCase
7 include Helper
8 fixtures :categories
9
10 def test_nested_set_options
11 expected = [
12 [" Top Level", 1],
13 ["- Child 1", 2],
14 ['- Child 2', 3],
15 ['-- Child 2.1', 4],
16 ['- Child 3', 5],
17 [" Top Level 2", 6]
18 ]
19 actual = nested_set_options(Category) do |c|
20 "#{'-' * c.level} #{c.name}"
21 end
22 assert_equal expected, actual
23 end
24
25 def test_nested_set_options_with_mover
26 expected = [
27 [" Top Level", 1],
28 ["- Child 1", 2],
29 ['- Child 3', 5],
30 [" Top Level 2", 6]
31 ]
32 actual = nested_set_options(Category, categories(:child_2)) do |c|
33 "#{'-' * c.level} #{c.name}"
34 end
35 assert_equal expected, actual
36 end
37
38 end
39 end
40 end
41 end No newline at end of file
This diff has been collapsed as it changes many lines, (603 lines changed) Show them Hide them
@@ -0,0 +1,603
1 require File.dirname(__FILE__) + '/test_helper'
2
3 class Note < ActiveRecord::Base
4 acts_as_nested_set :scope => [:notable_id, :notable_type]
5 end
6
7 class AwesomeNestedSetTest < Test::Unit::TestCase
8
9 class Default < ActiveRecord::Base
10 acts_as_nested_set
11 set_table_name 'categories'
12 end
13 class Scoped < ActiveRecord::Base
14 acts_as_nested_set :scope => :organization
15 set_table_name 'categories'
16 end
17
18 def test_left_column_default
19 assert_equal 'lft', Default.acts_as_nested_set_options[:left_column]
20 end
21
22 def test_right_column_default
23 assert_equal 'rgt', Default.acts_as_nested_set_options[:right_column]
24 end
25
26 def test_parent_column_default
27 assert_equal 'parent_id', Default.acts_as_nested_set_options[:parent_column]
28 end
29
30 def test_scope_default
31 assert_nil Default.acts_as_nested_set_options[:scope]
32 end
33
34 def test_left_column_name
35 assert_equal 'lft', Default.left_column_name
36 assert_equal 'lft', Default.new.left_column_name
37 end
38
39 def test_right_column_name
40 assert_equal 'rgt', Default.right_column_name
41 assert_equal 'rgt', Default.new.right_column_name
42 end
43
44 def test_parent_column_name
45 assert_equal 'parent_id', Default.parent_column_name
46 assert_equal 'parent_id', Default.new.parent_column_name
47 end
48
49 def test_quoted_left_column_name
50 quoted = Default.connection.quote_column_name('lft')
51 assert_equal quoted, Default.quoted_left_column_name
52 assert_equal quoted, Default.new.quoted_left_column_name
53 end
54
55 def test_quoted_right_column_name
56 quoted = Default.connection.quote_column_name('rgt')
57 assert_equal quoted, Default.quoted_right_column_name
58 assert_equal quoted, Default.new.quoted_right_column_name
59 end
60
61 def test_left_column_protected_from_assignment
62 assert_raises(ActiveRecord::ActiveRecordError) { Category.new.lft = 1 }
63 end
64
65 def test_right_column_protected_from_assignment
66 assert_raises(ActiveRecord::ActiveRecordError) { Category.new.rgt = 1 }
67 end
68
69 def test_parent_column_protected_from_assignment
70 assert_raises(ActiveRecord::ActiveRecordError) { Category.new.parent_id = 1 }
71 end
72
73 def test_colums_protected_on_initialize
74 c = Category.new(:lft => 1, :rgt => 2, :parent_id => 3)
75 assert_nil c.lft
76 assert_nil c.rgt
77 assert_nil c.parent_id
78 end
79
80 def test_scoped_appends_id
81 assert_equal :organization_id, Scoped.acts_as_nested_set_options[:scope]
82 end
83
84 def test_roots_class_method
85 assert_equal Category.find_all_by_parent_id(nil), Category.roots
86 end
87
88 def test_root_class_method
89 assert_equal categories(:top_level), Category.root
90 end
91
92 def test_root
93 assert_equal categories(:top_level), categories(:child_3).root
94 end
95
96 def test_root?
97 assert categories(:top_level).root?
98 assert categories(:top_level_2).root?
99 end
100
101 def test_leaves_class_method
102 assert_equal Category.find(:all, :conditions => "#{Category.right_column_name} - #{Category.left_column_name} = 1"), Category.leaves
103 assert_equal Category.leaves.count, 4
104 assert (Category.leaves.include? categories(:child_1))
105 assert (Category.leaves.include? categories(:child_2_1))
106 assert (Category.leaves.include? categories(:child_3))
107 assert (Category.leaves.include? categories(:top_level_2))
108 end
109
110 def test_leaf
111 assert categories(:child_1).leaf?
112 assert categories(:child_2_1).leaf?
113 assert categories(:child_3).leaf?
114 assert categories(:top_level_2).leaf?
115
116 assert !categories(:top_level).leaf?
117 assert !categories(:child_2).leaf?
118 end
119
120 def test_parent
121 assert_equal categories(:child_2), categories(:child_2_1).parent
122 end
123
124 def test_self_and_ancestors
125 child = categories(:child_2_1)
126 self_and_ancestors = [categories(:top_level), categories(:child_2), child]
127 assert_equal self_and_ancestors, child.self_and_ancestors
128 end
129
130 def test_ancestors
131 child = categories(:child_2_1)
132 ancestors = [categories(:top_level), categories(:child_2)]
133 assert_equal ancestors, child.ancestors
134 end
135
136 def test_self_and_siblings
137 child = categories(:child_2)
138 self_and_siblings = [categories(:child_1), child, categories(:child_3)]
139 assert_equal self_and_siblings, child.self_and_siblings
140 assert_nothing_raised do
141 tops = [categories(:top_level), categories(:top_level_2)]
142 assert_equal tops, categories(:top_level).self_and_siblings
143 end
144 end
145
146 def test_siblings
147 child = categories(:child_2)
148 siblings = [categories(:child_1), categories(:child_3)]
149 assert_equal siblings, child.siblings
150 end
151
152 def test_leaves
153 leaves = [categories(:child_1), categories(:child_2_1), categories(:child_3), categories(:top_level_2)]
154 assert categories(:top_level).leaves, leaves
155 end
156
157 def test_level
158 assert_equal 0, categories(:top_level).level
159 assert_equal 1, categories(:child_1).level
160 assert_equal 2, categories(:child_2_1).level
161 end
162
163 def test_has_children?
164 assert categories(:child_2_1).children.empty?
165 assert !categories(:child_2).children.empty?
166 assert !categories(:top_level).children.empty?
167 end
168
169 def test_self_and_descendents
170 parent = categories(:top_level)
171 self_and_descendants = [parent, categories(:child_1), categories(:child_2),
172 categories(:child_2_1), categories(:child_3)]
173 assert_equal self_and_descendants, parent.self_and_descendants
174 assert_equal self_and_descendants, parent.self_and_descendants.count
175 end
176
177 def test_descendents
178 lawyers = Category.create!(:name => "lawyers")
179 us = Category.create!(:name => "United States")
180 us.move_to_child_of(lawyers)
181 patent = Category.create!(:name => "Patent Law")
182 patent.move_to_child_of(us)
183 lawyers.reload
184
185 assert_equal 1, lawyers.children.size
186 assert_equal 1, us.children.size
187 assert_equal 2, lawyers.descendants.size
188 end
189
190 def test_self_and_descendents
191 parent = categories(:top_level)
192 descendants = [categories(:child_1), categories(:child_2),
193 categories(:child_2_1), categories(:child_3)]
194 assert_equal descendants, parent.descendants
195 end
196
197 def test_children
198 category = categories(:top_level)
199 category.children.each {|c| assert_equal category.id, c.parent_id }
200 end
201
202 def test_is_or_is_ancestor_of?
203 assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_1))
204 assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_2_1))
205 assert categories(:child_2).is_or_is_ancestor_of?(categories(:child_2_1))
206 assert !categories(:child_2_1).is_or_is_ancestor_of?(categories(:child_2))
207 assert !categories(:child_1).is_or_is_ancestor_of?(categories(:child_2))
208 assert categories(:child_1).is_or_is_ancestor_of?(categories(:child_1))
209 end
210
211 def test_is_ancestor_of?
212 assert categories(:top_level).is_ancestor_of?(categories(:child_1))
213 assert categories(:top_level).is_ancestor_of?(categories(:child_2_1))
214 assert categories(:child_2).is_ancestor_of?(categories(:child_2_1))
215 assert !categories(:child_2_1).is_ancestor_of?(categories(:child_2))
216 assert !categories(:child_1).is_ancestor_of?(categories(:child_2))
217 assert !categories(:child_1).is_ancestor_of?(categories(:child_1))
218 end
219
220 def test_is_or_is_ancestor_of_with_scope
221 root = Scoped.root
222 child = root.children.first
223 assert root.is_or_is_ancestor_of?(child)
224 child.update_attribute :organization_id, 'different'
225 assert !root.is_or_is_ancestor_of?(child)
226 end
227
228 def test_is_or_is_descendant_of?
229 assert categories(:child_1).is_or_is_descendant_of?(categories(:top_level))
230 assert categories(:child_2_1).is_or_is_descendant_of?(categories(:top_level))
231 assert categories(:child_2_1).is_or_is_descendant_of?(categories(:child_2))
232 assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_2_1))
233 assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_1))
234 assert categories(:child_1).is_or_is_descendant_of?(categories(:child_1))
235 end
236
237 def test_is_descendant_of?
238 assert categories(:child_1).is_descendant_of?(categories(:top_level))
239 assert categories(:child_2_1).is_descendant_of?(categories(:top_level))
240 assert categories(:child_2_1).is_descendant_of?(categories(:child_2))
241 assert !categories(:child_2).is_descendant_of?(categories(:child_2_1))
242 assert !categories(:child_2).is_descendant_of?(categories(:child_1))
243 assert !categories(:child_1).is_descendant_of?(categories(:child_1))
244 end
245
246 def test_is_or_is_descendant_of_with_scope
247 root = Scoped.root
248 child = root.children.first
249 assert child.is_or_is_descendant_of?(root)
250 child.update_attribute :organization_id, 'different'
251 assert !child.is_or_is_descendant_of?(root)
252 end
253
254 def test_same_scope?
255 root = Scoped.root
256 child = root.children.first
257 assert child.same_scope?(root)
258 child.update_attribute :organization_id, 'different'
259 assert !child.same_scope?(root)
260 end
261
262 def test_left_sibling
263 assert_equal categories(:child_1), categories(:child_2).left_sibling
264 assert_equal categories(:child_2), categories(:child_3).left_sibling
265 end
266
267 def test_left_sibling_of_root
268 assert_nil categories(:top_level).left_sibling
269 end
270
271 def test_left_sibling_without_siblings
272 assert_nil categories(:child_2_1).left_sibling
273 end
274
275 def test_left_sibling_of_leftmost_node
276 assert_nil categories(:child_1).left_sibling
277 end
278
279 def test_right_sibling
280 assert_equal categories(:child_3), categories(:child_2).right_sibling
281 assert_equal categories(:child_2), categories(:child_1).right_sibling
282 end
283
284 def test_right_sibling_of_root
285 assert_equal categories(:top_level_2), categories(:top_level).right_sibling
286 assert_nil categories(:top_level_2).right_sibling
287 end
288
289 def test_right_sibling_without_siblings
290 assert_nil categories(:child_2_1).right_sibling
291 end
292
293 def test_right_sibling_of_rightmost_node
294 assert_nil categories(:child_3).right_sibling
295 end
296
297 def test_move_left
298 categories(:child_2).move_left
299 assert_nil categories(:child_2).left_sibling
300 assert_equal categories(:child_1), categories(:child_2).right_sibling
301 assert Category.valid?
302 end
303
304 def test_move_right
305 categories(:child_2).move_right
306 assert_nil categories(:child_2).right_sibling
307 assert_equal categories(:child_3), categories(:child_2).left_sibling
308 assert Category.valid?
309 end
310
311 def test_move_to_left_of
312 categories(:child_3).move_to_left_of(categories(:child_1))
313 assert_nil categories(:child_3).left_sibling
314 assert_equal categories(:child_1), categories(:child_3).right_sibling
315 assert Category.valid?
316 end
317
318 def test_move_to_right_of
319 categories(:child_1).move_to_right_of(categories(:child_3))
320 assert_nil categories(:child_1).right_sibling
321 assert_equal categories(:child_3), categories(:child_1).left_sibling
322 assert Category.valid?
323 end
324
325 def test_move_to_root
326 categories(:child_2).move_to_root
327 assert_nil categories(:child_2).parent
328 assert_equal 0, categories(:child_2).level
329 assert_equal 1, categories(:child_2_1).level
330 assert_equal 1, categories(:child_2).left
331 assert_equal 4, categories(:child_2).right
332 assert Category.valid?
333 end
334
335 def test_move_to_child_of
336 categories(:child_1).move_to_child_of(categories(:child_3))
337 assert_equal categories(:child_3).id, categories(:child_1).parent_id
338 assert Category.valid?
339 end
340
341 def test_move_to_child_of_appends_to_end
342 child = Category.create! :name => 'New Child'
343 child.move_to_child_of categories(:top_level)
344 assert_equal child, categories(:top_level).children.last
345 end
346
347 def test_subtree_move_to_child_of
348 assert_equal 4, categories(:child_2).left
349 assert_equal 7, categories(:child_2).right
350
351 assert_equal 2, categories(:child_1).left
352 assert_equal 3, categories(:child_1).right
353
354 categories(:child_2).move_to_child_of(categories(:child_1))
355 assert Category.valid?
356 assert_equal categories(:child_1).id, categories(:child_2).parent_id
357
358 assert_equal 3, categories(:child_2).left
359 assert_equal 6, categories(:child_2).right
360 assert_equal 2, categories(:child_1).left
361 assert_equal 7, categories(:child_1).right
362 end
363
364 def test_slightly_difficult_move_to_child_of
365 assert_equal 11, categories(:top_level_2).left
366 assert_equal 12, categories(:top_level_2).right
367
368 # create a new top-level node and move single-node top-level tree inside it.
369 new_top = Category.create(:name => 'New Top')
370 assert_equal 13, new_top.left
371 assert_equal 14, new_top.right
372
373 categories(:top_level_2).move_to_child_of(new_top)
374
375 assert Category.valid?
376 assert_equal new_top.id, categories(:top_level_2).parent_id
377
378 assert_equal 12, categories(:top_level_2).left
379 assert_equal 13, categories(:top_level_2).right
380 assert_equal 11, new_top.left
381 assert_equal 14, new_top.right
382 end
383
384 def test_difficult_move_to_child_of
385 assert_equal 1, categories(:top_level).left
386 assert_equal 10, categories(:top_level).right
387 assert_equal 5, categories(:child_2_1).left
388 assert_equal 6, categories(:child_2_1).right
389
390 # create a new top-level node and move an entire top-level tree inside it.
391 new_top = Category.create(:name => 'New Top')
392 categories(:top_level).move_to_child_of(new_top)
393 categories(:child_2_1).reload
394 assert Category.valid?
395 assert_equal new_top.id, categories(:top_level).parent_id
396
397 assert_equal 4, categories(:top_level).left
398 assert_equal 13, categories(:top_level).right
399 assert_equal 8, categories(:child_2_1).left
400 assert_equal 9, categories(:child_2_1).right
401 end
402
403 #rebuild swaps the position of the 2 children when added using move_to_child twice onto same parent
404 def test_move_to_child_more_than_once_per_parent_rebuild
405 root1 = Category.create(:name => 'Root1')
406 root2 = Category.create(:name => 'Root2')
407 root3 = Category.create(:name => 'Root3')
408
409 root2.move_to_child_of root1
410 root3.move_to_child_of root1
411
412 output = Category.roots.last.to_text
413 Category.update_all('lft = null, rgt = null')
414 Category.rebuild!
415
416 assert_equal Category.roots.last.to_text, output
417 end
418
419 # doing move_to_child twice onto same parent from the furthest right first
420 def test_move_to_child_more_than_once_per_parent_outside_in
421 node1 = Category.create(:name => 'Node-1')
422 node2 = Category.create(:name => 'Node-2')
423 node3 = Category.create(:name => 'Node-3')
424
425 node2.move_to_child_of node1
426 node3.move_to_child_of node1
427
428 output = Category.roots.last.to_text
429 Category.update_all('lft = null, rgt = null')
430 Category.rebuild!
431
432 assert_equal Category.roots.last.to_text, output
433 end
434
435
436 def test_valid_with_null_lefts
437 assert Category.valid?
438 Category.update_all('lft = null')
439 assert !Category.valid?
440 end
441
442 def test_valid_with_null_rights
443 assert Category.valid?
444 Category.update_all('rgt = null')
445 assert !Category.valid?
446 end
447
448 def test_valid_with_missing_intermediate_node
449 # Even though child_2_1 will still exist, it is a sign of a sloppy delete, not an invalid tree.
450 assert Category.valid?
451 Category.delete(categories(:child_2).id)
452 assert Category.valid?
453 end
454
455 def test_valid_with_overlapping_and_rights
456 assert Category.valid?
457 categories(:top_level_2)['lft'] = 0
458 categories(:top_level_2).save
459 assert !Category.valid?
460 end
461
462 def test_rebuild
463 assert Category.valid?
464 before_text = Category.root.to_text
465 Category.update_all('lft = null, rgt = null')
466 Category.rebuild!
467 assert Category.valid?
468 assert_equal before_text, Category.root.to_text
469 end
470
471 def test_move_possible_for_sibling
472 assert categories(:child_2).move_possible?(categories(:child_1))
473 end
474
475 def test_move_not_possible_to_self
476 assert !categories(:top_level).move_possible?(categories(:top_level))
477 end
478
479 def test_move_not_possible_to_parent
480 categories(:top_level).descendants.each do |descendant|
481 assert !categories(:top_level).move_possible?(descendant)
482 assert descendant.move_possible?(categories(:top_level))
483 end
484 end
485
486 def test_is_or_is_ancestor_of?
487 [:child_1, :child_2, :child_2_1, :child_3].each do |c|
488 assert categories(:top_level).is_or_is_ancestor_of?(categories(c))
489 end
490 assert !categories(:top_level).is_or_is_ancestor_of?(categories(:top_level_2))
491 end
492
493 def test_left_and_rights_valid_with_blank_left
494 assert Category.left_and_rights_valid?
495 categories(:child_2)[:lft] = nil
496 categories(:child_2).save(false)
497 assert !Category.left_and_rights_valid?
498 end
499
500 def test_left_and_rights_valid_with_blank_right
501 assert Category.left_and_rights_valid?
502 categories(:child_2)[:rgt] = nil
503 categories(:child_2).save(false)
504 assert !Category.left_and_rights_valid?
505 end
506
507 def test_left_and_rights_valid_with_equal
508 assert Category.left_and_rights_valid?
509 categories(:top_level_2)[:lft] = categories(:top_level_2)[:rgt]
510 categories(:top_level_2).save(false)
511 assert !Category.left_and_rights_valid?
512 end
513
514 def test_left_and_rights_valid_with_left_equal_to_parent
515 assert Category.left_and_rights_valid?
516 categories(:child_2)[:lft] = categories(:top_level)[:lft]
517 categories(:child_2).save(false)
518 assert !Category.left_and_rights_valid?
519 end
520
521 def test_left_and_rights_valid_with_right_equal_to_parent
522 assert Category.left_and_rights_valid?
523 categories(:child_2)[:rgt] = categories(:top_level)[:rgt]
524 categories(:child_2).save(false)
525 assert !Category.left_and_rights_valid?
526 end
527
528 def test_moving_dirty_objects_doesnt_invalidate_tree
529 r1 = Category.create
530 r2 = Category.create
531 r3 = Category.create
532 r4 = Category.create
533 nodes = [r1, r2, r3, r4]
534
535 r2.move_to_child_of(r1)
536 assert Category.valid?
537
538 r3.move_to_child_of(r1)
539 assert Category.valid?
540
541 r4.move_to_child_of(r2)
542 assert Category.valid?
543 end
544
545 def test_multi_scoped_no_duplicates_for_columns?
546 assert_nothing_raised do
547 Note.no_duplicates_for_columns?
548 end
549 end
550
551 def test_multi_scoped_all_roots_valid?
552 assert_nothing_raised do
553 Note.all_roots_valid?
554 end
555 end
556
557 def test_multi_scoped
558 note1 = Note.create!(:body => "A", :notable_id => 2, :notable_type => 'Category')
559 note2 = Note.create!(:body => "B", :notable_id => 2, :notable_type => 'Category')
560 note3 = Note.create!(:body => "C", :notable_id => 2, :notable_type => 'Default')
561
562 assert_equal [note1, note2], note1.self_and_siblings
563 assert_equal [note3], note3.self_and_siblings
564 end
565
566 def test_multi_scoped_rebuild
567 root = Note.create!(:body => "A", :notable_id => 3, :notable_type => 'Category')
568 child1 = Note.create!(:body => "B", :notable_id => 3, :notable_type => 'Category')
569 child2 = Note.create!(:body => "C", :notable_id => 3, :notable_type => 'Category')
570
571 child1.move_to_child_of root
572 child2.move_to_child_of root
573
574 Note.update_all('lft = null, rgt = null')
575 Note.rebuild!
576
577 assert_equal Note.roots.find_by_body('A'), root
578 assert_equal [child1, child2], Note.roots.find_by_body('A').children
579 end
580
581 def test_same_scope_with_multi_scopes
582 assert_nothing_raised do
583 notes(:scope1).same_scope?(notes(:child_1))
584 end
585 assert notes(:scope1).same_scope?(notes(:child_1))
586 assert notes(:child_1).same_scope?(notes(:scope1))
587 assert !notes(:scope1).same_scope?(notes(:scope2))
588 end
589
590 def test_quoting_of_multi_scope_column_names
591 assert_equal ["\"notable_id\"", "\"notable_type\""], Note.quoted_scope_column_names
592 end
593
594 def test_equal_in_same_scope
595 assert_equal notes(:scope1), notes(:scope1)
596 assert_not_equal notes(:scope1), notes(:child_1)
597 end
598
599 def test_equal_in_different_scopes
600 assert_not_equal notes(:scope1), notes(:scope2)
601 end
602
603 end
@@ -0,0 +1,18
1 sqlite3:
2 adapter: sqlite3
3 dbfile: awesome_nested_set.sqlite3.db
4 sqlite3mem:
5 :adapter: sqlite3
6 :dbfile: ":memory:"
7 postgresql:
8 :adapter: postgresql
9 :username: postgres
10 :password: postgres
11 :database: awesome_nested_set_plugin_test
12 :min_messages: ERROR
13 mysql:
14 :adapter: mysql
15 :host: localhost
16 :username: root
17 :password:
18 :database: awesome_nested_set_plugin_test No newline at end of file
@@ -0,0 +1,23
1 ActiveRecord::Schema.define(:version => 0) do
2
3 create_table :categories, :force => true do |t|
4 t.column :name, :string
5 t.column :parent_id, :integer
6 t.column :lft, :integer
7 t.column :rgt, :integer
8 t.column :organization_id, :integer
9 end
10
11 create_table :departments, :force => true do |t|
12 t.column :name, :string
13 end
14
15 create_table :notes, :force => true do |t|
16 t.column :body, :text
17 t.column :parent_id, :integer
18 t.column :lft, :integer
19 t.column :rgt, :integer
20 t.column :notable_id, :integer
21 t.column :notable_type, :string
22 end
23 end
@@ -0,0 +1,34
1 top_level:
2 id: 1
3 name: Top Level
4 lft: 1
5 rgt: 10
6 child_1:
7 id: 2
8 name: Child 1
9 parent_id: 1
10 lft: 2
11 rgt: 3
12 child_2:
13 id: 3
14 name: Child 2
15 parent_id: 1
16 lft: 4
17 rgt: 7
18 child_2_1:
19 id: 4
20 name: Child 2.1
21 parent_id: 3
22 lft: 5
23 rgt: 6
24 child_3:
25 id: 5
26 name: Child 3
27 parent_id: 1
28 lft: 8
29 rgt: 9
30 top_level_2:
31 id: 6
32 name: Top Level 2
33 lft: 11
34 rgt: 12
@@ -0,0 +1,15
1 class Category < ActiveRecord::Base
2 acts_as_nested_set
3
4 def to_s
5 name
6 end
7
8 def recurse &block
9 block.call self, lambda{
10 self.children.each do |child|
11 child.recurse &block
12 end
13 }
14 end
15 end No newline at end of file
@@ -0,0 +1,3
1 top:
2 id: 1
3 name: Top No newline at end of file
@@ -0,0 +1,38
1 scope1:
2 id: 1
3 body: Top Level
4 lft: 1
5 rgt: 10
6 notable_id: 1
7 notable_type: Category
8 child_1:
9 id: 2
10 body: Child 1
11 parent_id: 1
12 lft: 2
13 rgt: 3
14 notable_id: 1
15 notable_type: Category
16 child_2:
17 id: 3
18 body: Child 2
19 parent_id: 1
20 lft: 4
21 rgt: 7
22 notable_id: 1
23 notable_type: Category
24 child_3:
25 id: 4
26 body: Child 3
27 parent_id: 1
28 lft: 8
29 rgt: 9
30 notable_id: 1
31 notable_type: Category
32 scope2:
33 id: 5
34 body: Top Level 2
35 lft: 1
36 rgt: 2
37 notable_id: 1
38 notable_type: Departments
@@ -0,0 +1,31
1 $:.unshift(File.dirname(__FILE__) + '/../lib')
2 plugin_test_dir = File.dirname(__FILE__)
3
4 require 'rubygems'
5 require 'test/unit'
6 require 'multi_rails_init'
7 # gem 'activerecord', '>= 2.0'
8 require 'active_record'
9 require 'action_controller'
10 require 'action_view'
11 require 'active_record/fixtures'
12
13 require plugin_test_dir + '/../init.rb'
14
15 ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/debug.log")
16
17 ActiveRecord::Base.configurations = YAML::load(IO.read(plugin_test_dir + "/db/database.yml"))
18 ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3mem")
19 ActiveRecord::Migration.verbose = false
20 load(File.join(plugin_test_dir, "db", "schema.rb"))
21
22 Dir["#{plugin_test_dir}/fixtures/*.rb"].each {|file| require file }
23
24
25 class Test::Unit::TestCase #:nodoc:
26 self.fixture_path = File.dirname(__FILE__) + "/fixtures/"
27 self.use_transactional_fixtures = true
28 self.use_instantiated_fixtures = false
29
30 fixtures :categories, :notes, :departments
31 end No newline at end of file
@@ -26,9 +26,6 class AdminController < ApplicationController
26 end
26 end
27
27
28 def projects
28 def projects
29 sort_init 'name', 'asc'
30 sort_update %w(name is_public created_on)
31
32 @status = params[:status] ? params[:status].to_i : 1
29 @status = params[:status] ? params[:status].to_i : 1
33 c = ARCondition.new(@status == 0 ? "status <> 0" : ["status = ?", @status])
30 c = ARCondition.new(@status == 0 ? "status <> 0" : ["status = ?", @status])
34
31
@@ -37,14 +34,8 class AdminController < ApplicationController
37 c << ["LOWER(identifier) LIKE ? OR LOWER(name) LIKE ?", name, name]
34 c << ["LOWER(identifier) LIKE ? OR LOWER(name) LIKE ?", name, name]
38 end
35 end
39
36
40 @project_count = Project.count(:conditions => c.conditions)
37 @projects = Project.find :all, :order => 'lft',
41 @project_pages = Paginator.new self, @project_count,
38 :conditions => c.conditions
42 per_page_option,
43 params['page']
44 @projects = Project.find :all, :order => sort_clause,
45 :conditions => c.conditions,
46 :limit => @project_pages.items_per_page,
47 :offset => @project_pages.current.offset
48
39
49 render :action => "projects", :layout => false if request.xhr?
40 render :action => "projects", :layout => false if request.xhr?
50 end
41 end
@@ -43,17 +43,14 class ProjectsController < ApplicationController
43
43
44 # Lists visible projects
44 # Lists visible projects
45 def index
45 def index
46 projects = Project.find :all,
47 :conditions => Project.visible_by(User.current),
48 :include => :parent
49 respond_to do |format|
46 respond_to do |format|
50 format.html {
47 format.html {
51 @project_tree = projects.group_by {|p| p.parent || p}
48 @projects = Project.visible.find(:all, :order => 'lft')
52 @project_tree.keys.each {|p| @project_tree[p] -= [p]}
53 }
49 }
54 format.atom {
50 format.atom {
55 render_feed(projects.sort_by(&:created_on).reverse.slice(0, Setting.feeds_limit.to_i),
51 projects = Project.visible.find(:all, :order => 'created_on DESC',
56 :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
52 :limit => Setting.feeds_limit.to_i)
53 render_feed(projects, :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
57 }
54 }
58 end
55 end
59 end
56 end
@@ -62,9 +59,6 class ProjectsController < ApplicationController
62 def add
59 def add
63 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
60 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
64 @trackers = Tracker.all
61 @trackers = Tracker.all
65 @root_projects = Project.find(:all,
66 :conditions => "parent_id IS NULL AND status = #{Project::STATUS_ACTIVE}",
67 :order => 'name')
68 @project = Project.new(params[:project])
62 @project = Project.new(params[:project])
69 if request.get?
63 if request.get?
70 @project.identifier = Project.next_identifier if Setting.sequential_project_identifiers?
64 @project.identifier = Project.next_identifier if Setting.sequential_project_identifiers?
@@ -74,6 +68,7 class ProjectsController < ApplicationController
74 else
68 else
75 @project.enabled_module_names = params[:enabled_modules]
69 @project.enabled_module_names = params[:enabled_modules]
76 if @project.save
70 if @project.save
71 @project.set_parent!(params[:project]['parent_id']) if User.current.admin? && params[:project].has_key?('parent_id')
77 flash[:notice] = l(:notice_successful_create)
72 flash[:notice] = l(:notice_successful_create)
78 redirect_to :controller => 'admin', :action => 'projects'
73 redirect_to :controller => 'admin', :action => 'projects'
79 end
74 end
@@ -88,7 +83,8 class ProjectsController < ApplicationController
88 end
83 end
89
84
90 @members_by_role = @project.members.find(:all, :include => [:user, :role], :order => 'position').group_by {|m| m.role}
85 @members_by_role = @project.members.find(:all, :include => [:user, :role], :order => 'position').group_by {|m| m.role}
91 @subprojects = @project.children.find(:all, :conditions => Project.visible_by(User.current))
86 @subprojects = @project.children.visible
87 @ancestors = @project.ancestors.visible
92 @news = @project.news.find(:all, :limit => 5, :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
88 @news = @project.news.find(:all, :limit => 5, :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
93 @trackers = @project.rolled_up_trackers
89 @trackers = @project.rolled_up_trackers
94
90
@@ -110,9 +106,6 class ProjectsController < ApplicationController
110 end
106 end
111
107
112 def settings
108 def settings
113 @root_projects = Project.find(:all,
114 :conditions => ["parent_id IS NULL AND status = #{Project::STATUS_ACTIVE} AND id <> ?", @project.id],
115 :order => 'name')
116 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
109 @issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
117 @issue_category ||= IssueCategory.new
110 @issue_category ||= IssueCategory.new
118 @member ||= @project.members.new
111 @member ||= @project.members.new
@@ -126,6 +119,7 class ProjectsController < ApplicationController
126 if request.post?
119 if request.post?
127 @project.attributes = params[:project]
120 @project.attributes = params[:project]
128 if @project.save
121 if @project.save
122 @project.set_parent!(params[:project]['parent_id']) if User.current.admin? && params[:project].has_key?('parent_id')
129 flash[:notice] = l(:notice_successful_update)
123 flash[:notice] = l(:notice_successful_update)
130 redirect_to :action => 'settings', :id => @project
124 redirect_to :action => 'settings', :id => @project
131 else
125 else
@@ -61,7 +61,7 class ReportsController < ApplicationController
61 render :template => "reports/issue_report_details"
61 render :template => "reports/issue_report_details"
62 when "subproject"
62 when "subproject"
63 @field = "project_id"
63 @field = "project_id"
64 @rows = @project.active_children
64 @rows = @project.descendants.active
65 @data = issues_by_subproject
65 @data = issues_by_subproject
66 @report_title = l(:field_subproject)
66 @report_title = l(:field_subproject)
67 render :template => "reports/issue_report_details"
67 render :template => "reports/issue_report_details"
@@ -72,7 +72,7 class ReportsController < ApplicationController
72 @categories = @project.issue_categories
72 @categories = @project.issue_categories
73 @assignees = @project.members.collect { |m| m.user }
73 @assignees = @project.members.collect { |m| m.user }
74 @authors = @project.members.collect { |m| m.user }
74 @authors = @project.members.collect { |m| m.user }
75 @subprojects = @project.active_children
75 @subprojects = @project.descendants.active
76 issues_by_tracker
76 issues_by_tracker
77 issues_by_version
77 issues_by_version
78 issues_by_priority
78 issues_by_priority
@@ -229,8 +229,8 private
229 #{Issue.table_name} i, #{IssueStatus.table_name} s
229 #{Issue.table_name} i, #{IssueStatus.table_name} s
230 where
230 where
231 i.status_id=s.id
231 i.status_id=s.id
232 and i.project_id IN (#{@project.active_children.collect{|p| p.id}.join(',')})
232 and i.project_id IN (#{@project.descendants.active.collect{|p| p.id}.join(',')})
233 group by s.id, s.is_closed, i.project_id") if @project.active_children.any?
233 group by s.id, s.is_closed, i.project_id") if @project.descendants.active.any?
234 @issues_by_subproject ||= []
234 @issues_by_subproject ||= []
235 end
235 end
236 end
236 end
@@ -34,7 +34,7 class SearchController < ApplicationController
34 when 'my_projects'
34 when 'my_projects'
35 User.current.memberships.collect(&:project)
35 User.current.memberships.collect(&:project)
36 when 'subprojects'
36 when 'subprojects'
37 @project ? ([ @project ] + @project.active_children) : nil
37 @project ? (@project.self_and_descendants.active) : nil
38 else
38 else
39 @project
39 @project
40 end
40 end
@@ -83,7 +83,7 class UsersController < ApplicationController
83 end
83 end
84 @auth_sources = AuthSource.find(:all)
84 @auth_sources = AuthSource.find(:all)
85 @roles = Role.find_all_givable
85 @roles = Role.find_all_givable
86 @projects = Project.find(:all, :order => 'name', :conditions => "status=#{Project::STATUS_ACTIVE}") - @user.projects
86 @projects = Project.active.find(:all, :order => 'lft')
87 @membership ||= Member.new
87 @membership ||= Member.new
88 @memberships = @user.memberships
88 @memberships = @user.memberships
89 end
89 end
@@ -20,4 +20,12 module AdminHelper
20 options_for_select([[l(:label_all), ''],
20 options_for_select([[l(:label_all), ''],
21 [l(:status_active), 1]], selected)
21 [l(:status_active), 1]], selected)
22 end
22 end
23
24 def css_project_classes(project)
25 s = 'project'
26 s << ' root' if project.root?
27 s << ' child' if project.child?
28 s << (project.leaf? ? ' leaf' : ' parent')
29 s
30 end
23 end
31 end
@@ -156,6 +156,45 module ApplicationHelper
156 end
156 end
157 s
157 s
158 end
158 end
159
160 # Renders the project quick-jump box
161 def render_project_jump_box
162 # Retrieve them now to avoid a COUNT query
163 projects = User.current.projects.all
164 if projects.any?
165 s = '<select onchange="if (this.value != \'\') { window.location = this.value; }">' +
166 "<option selected='selected'>#{ l(:label_jump_to_a_project) }</option>" +
167 '<option disabled="disabled">---</option>'
168 s << project_tree_options_for_select(projects) do |p|
169 { :value => url_for(:controller => 'projects', :action => 'show', :id => p) }
170 end
171 s << '</select>'
172 s
173 end
174 end
175
176 def project_tree_options_for_select(projects, options = {})
177 s = ''
178 project_tree(projects) do |project, level|
179 name_prefix = (level > 0 ? ('&nbsp;' * 2 * level + '&#187; ') : '')
180 tag_options = {:value => project.id, :selected => ((project == options[:selected]) ? 'selected' : nil)}
181 tag_options.merge!(yield(project)) if block_given?
182 s << content_tag('option', name_prefix + h(project), tag_options)
183 end
184 s
185 end
186
187 # Yields the given block for each project with its level in the tree
188 def project_tree(projects, &block)
189 ancestors = []
190 projects.sort_by(&:lft).each do |project|
191 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
192 ancestors.pop
193 end
194 yield project, ancestors.size
195 ancestors << project
196 end
197 end
159
198
160 # Truncates and returns the string as a single line
199 # Truncates and returns the string as a single line
161 def truncate_single_line(string, *args)
200 def truncate_single_line(string, *args)
@@ -33,4 +33,39 module ProjectsHelper
33 ]
33 ]
34 tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
34 tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
35 end
35 end
36
37 def parent_project_select_tag(project)
38 options = '<option></option>' + project_tree_options_for_select(project.possible_parents, :selected => project.parent)
39 content_tag('select', options, :name => 'project[parent_id]')
40 end
41
42 # Renders a tree of projects as a nested set of unordered lists
43 # The given collection may be a subset of the whole project tree
44 # (eg. some intermediate nodes are private and can not be seen)
45 def render_project_hierarchy(projects)
46 s = ''
47 if projects.any?
48 ancestors = []
49 projects.each do |project|
50 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
51 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
52 else
53 ancestors.pop
54 s << "</li>"
55 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
56 ancestors.pop
57 s << "</ul></li>\n"
58 end
59 end
60 classes = (ancestors.empty? ? 'root' : 'child')
61 s << "<li class='#{classes}'><div class='#{classes}'>" +
62 link_to(h(project), {:controller => 'projects', :action => 'show', :id => project}, :class => "project #{User.current.member_of?(project) ? 'my-project' : nil}")
63 s << "<div class='wiki description'>#{textilizable(project.short_description, :project => project)}</div>" unless project.description.blank?
64 s << "</div>\n"
65 ancestors << project
66 end
67 s << ("</li></ul>\n" * ancestors.size)
68 end
69 s
70 end
36 end
71 end
@@ -44,7 +44,7 module SearchHelper
44 def project_select_tag
44 def project_select_tag
45 options = [[l(:label_project_all), 'all']]
45 options = [[l(:label_project_all), 'all']]
46 options << [l(:label_my_projects), 'my_projects'] unless User.current.memberships.empty?
46 options << [l(:label_my_projects), 'my_projects'] unless User.current.memberships.empty?
47 options << [l(:label_and_its_subprojects, @project.name), 'subprojects'] unless @project.nil? || @project.active_children.empty?
47 options << [l(:label_and_its_subprojects, @project.name), 'subprojects'] unless @project.nil? || @project.descendants.active.empty?
48 options << [@project.name, ''] unless @project.nil?
48 options << [@project.name, ''] unless @project.nil?
49 select_tag('scope', options_for_select(options, params[:scope].to_s)) if options.size > 1
49 select_tag('scope', options_for_select(options, params[:scope].to_s)) if options.size > 1
50 end
50 end
@@ -25,15 +25,10 module UsersHelper
25 end
25 end
26
26
27 # Options for the new membership projects combo-box
27 # Options for the new membership projects combo-box
28 def projects_options_for_select(projects)
28 def options_for_membership_project_select(user, projects)
29 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
29 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
30 projects_by_root = projects.group_by(&:root)
30 options << project_tree_options_for_select(projects) do |p|
31 projects_by_root.keys.sort.each do |root|
31 {:disabled => (user.projects.include?(p))}
32 options << content_tag('option', h(root.name), :value => root.id, :disabled => (!projects.include?(root)))
33 projects_by_root[root].sort.each do |project|
34 next if project == root
35 options << content_tag('option', '&#187; ' + h(project.name), :value => project.id)
36 end
37 end
32 end
38 options
33 options
39 end
34 end
@@ -43,7 +43,7 class Project < ActiveRecord::Base
43 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
43 :join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
44 :association_foreign_key => 'custom_field_id'
44 :association_foreign_key => 'custom_field_id'
45
45
46 acts_as_tree :order => "name", :counter_cache => true
46 acts_as_nested_set :order => 'name', :dependent => :destroy
47 acts_as_attachable :view_permission => :view_files,
47 acts_as_attachable :view_permission => :view_files,
48 :delete_permission => :manage_files
48 :delete_permission => :manage_files
49
49
@@ -66,6 +66,8 class Project < ActiveRecord::Base
66 before_destroy :delete_all_members
66 before_destroy :delete_all_members
67
67
68 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
68 named_scope :has_module, lambda { |mod| { :conditions => ["#{Project.table_name}.id IN (SELECT em.project_id FROM #{EnabledModule.table_name} em WHERE em.name=?)", mod.to_s] } }
69 named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
70 named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
69
71
70 def identifier=(identifier)
72 def identifier=(identifier)
71 super unless identifier_frozen?
73 super unless identifier_frozen?
@@ -78,7 +80,7 class Project < ActiveRecord::Base
78 def issues_with_subprojects(include_subprojects=false)
80 def issues_with_subprojects(include_subprojects=false)
79 conditions = nil
81 conditions = nil
80 if include_subprojects
82 if include_subprojects
81 ids = [id] + child_ids
83 ids = [id] + descendants.collect(&:id)
82 conditions = ["#{Project.table_name}.id IN (#{ids.join(',')}) AND #{Project.visible_by}"]
84 conditions = ["#{Project.table_name}.id IN (#{ids.join(',')}) AND #{Project.visible_by}"]
83 end
85 end
84 conditions ||= ["#{Project.table_name}.id = ?", id]
86 conditions ||= ["#{Project.table_name}.id = ?", id]
@@ -118,7 +120,7 class Project < ActiveRecord::Base
118 end
120 end
119 if options[:project]
121 if options[:project]
120 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
122 project_statement = "#{Project.table_name}.id = #{options[:project].id}"
121 project_statement << " OR #{Project.table_name}.parent_id = #{options[:project].id}" if options[:with_subprojects]
123 project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
122 base_statement = "(#{project_statement}) AND (#{base_statement})"
124 base_statement = "(#{project_statement}) AND (#{base_statement})"
123 end
125 end
124 if user.admin?
126 if user.admin?
@@ -141,7 +143,7 class Project < ActiveRecord::Base
141
143
142 def project_condition(with_subprojects)
144 def project_condition(with_subprojects)
143 cond = "#{Project.table_name}.id = #{id}"
145 cond = "#{Project.table_name}.id = #{id}"
144 cond = "(#{cond} OR #{Project.table_name}.parent_id = #{id})" if with_subprojects
146 cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
145 cond
147 cond
146 end
148 end
147
149
@@ -164,6 +166,7 class Project < ActiveRecord::Base
164 self.status == STATUS_ACTIVE
166 self.status == STATUS_ACTIVE
165 end
167 end
166
168
169 # Archives the project and its descendants recursively
167 def archive
170 def archive
168 # Archive subprojects if any
171 # Archive subprojects if any
169 children.each do |subproject|
172 children.each do |subproject|
@@ -172,13 +175,54 class Project < ActiveRecord::Base
172 update_attribute :status, STATUS_ARCHIVED
175 update_attribute :status, STATUS_ARCHIVED
173 end
176 end
174
177
178 # Unarchives the project
179 # All its ancestors must be active
175 def unarchive
180 def unarchive
176 return false if parent && !parent.active?
181 return false if ancestors.detect {|a| !a.active?}
177 update_attribute :status, STATUS_ACTIVE
182 update_attribute :status, STATUS_ACTIVE
178 end
183 end
179
184
180 def active_children
185 # Returns an array of projects the project can be moved to
181 children.select {|child| child.active?}
186 def possible_parents
187 @possible_parents ||= (Project.active.find(:all) - self_and_descendants)
188 end
189
190 # Sets the parent of the project
191 # Argument can be either a Project, a String, a Fixnum or nil
192 def set_parent!(p)
193 unless p.nil? || p.is_a?(Project)
194 if p.to_s.blank?
195 p = nil
196 else
197 p = Project.find_by_id(p)
198 return false unless p
199 end
200 end
201 if p == parent && !p.nil?
202 # Nothing to do
203 true
204 elsif p.nil? || (p.active? && move_possible?(p))
205 # Insert the project so that target's children or root projects stay alphabetically sorted
206 sibs = (p.nil? ? self.class.roots : p.children)
207 to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
208 if to_be_inserted_before
209 move_to_left_of(to_be_inserted_before)
210 elsif p.nil?
211 if sibs.empty?
212 # move_to_root adds the project in first (ie. left) position
213 move_to_root
214 else
215 move_to_right_of(sibs.last) unless self == sibs.last
216 end
217 else
218 # move_to_child_of adds the project in last (ie.right) position
219 move_to_child_of(p)
220 end
221 true
222 else
223 # Can not move to the given target
224 false
225 end
182 end
226 end
183
227
184 # Returns an array of the trackers used by the project and its sub projects
228 # Returns an array of the trackers used by the project and its sub projects
@@ -186,7 +230,7 class Project < ActiveRecord::Base
186 @rolled_up_trackers ||=
230 @rolled_up_trackers ||=
187 Tracker.find(:all, :include => :projects,
231 Tracker.find(:all, :include => :projects,
188 :select => "DISTINCT #{Tracker.table_name}.*",
232 :select => "DISTINCT #{Tracker.table_name}.*",
189 :conditions => ["#{Project.table_name}.id = ? OR #{Project.table_name}.parent_id = ?", id, id],
233 :conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ?", lft, rgt],
190 :order => "#{Tracker.table_name}.position")
234 :order => "#{Tracker.table_name}.position")
191 end
235 end
192
236
@@ -225,7 +269,7 class Project < ActiveRecord::Base
225
269
226 # Returns a short description of the projects (first lines)
270 # Returns a short description of the projects (first lines)
227 def short_description(length = 255)
271 def short_description(length = 255)
228 description.gsub(/^(.{#{length}}[^\n]*).*$/m, '\1').strip if description
272 description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
229 end
273 end
230
274
231 def allows_to?(action)
275 def allows_to?(action)
@@ -257,8 +301,6 class Project < ActiveRecord::Base
257
301
258 protected
302 protected
259 def validate
303 def validate
260 errors.add(parent_id, " must be a root project") if parent and parent.parent
261 errors.add_to_base("A project with subprojects can't be a subproject") if parent and children.size > 0
262 errors.add(:identifier, :activerecord_error_invalid) if !identifier.blank? && identifier.match(/^\d*$/)
304 errors.add(:identifier, :activerecord_error_invalid) if !identifier.blank? && identifier.match(/^\d*$/)
263 end
305 end
264
306
@@ -174,8 +174,8 class Query < ActiveRecord::Base
174 unless @project.versions.empty?
174 unless @project.versions.empty?
175 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.versions.sort.collect{|s| [s.name, s.id.to_s] } }
175 @available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.versions.sort.collect{|s| [s.name, s.id.to_s] } }
176 end
176 end
177 unless @project.active_children.empty?
177 unless @project.descendants.active.empty?
178 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.active_children.collect{|s| [s.name, s.id.to_s] } }
178 @available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.descendants.visible.collect{|s| [s.name, s.id.to_s] } }
179 end
179 end
180 add_custom_fields_filters(@project.all_issue_custom_fields)
180 add_custom_fields_filters(@project.all_issue_custom_fields)
181 else
181 else
@@ -257,7 +257,7 class Query < ActiveRecord::Base
257
257
258 def project_statement
258 def project_statement
259 project_clauses = []
259 project_clauses = []
260 if project && !@project.active_children.empty?
260 if project && !@project.descendants.active.empty?
261 ids = [project.id]
261 ids = [project.id]
262 if has_filter?("subproject_id")
262 if has_filter?("subproject_id")
263 case operator_for("subproject_id")
263 case operator_for("subproject_id")
@@ -268,10 +268,10 class Query < ActiveRecord::Base
268 # main project only
268 # main project only
269 else
269 else
270 # all subprojects
270 # all subprojects
271 ids += project.child_ids
271 ids += project.descendants.collect(&:id)
272 end
272 end
273 elsif Setting.display_subprojects_issues?
273 elsif Setting.display_subprojects_issues?
274 ids += project.child_ids
274 ids += project.descendants.collect(&:id)
275 end
275 end
276 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
276 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
277 elsif project
277 elsif project
@@ -17,22 +17,20
17
17
18 <table class="list">
18 <table class="list">
19 <thead><tr>
19 <thead><tr>
20 <%= sort_header_tag('name', :caption => l(:label_project)) %>
20 <th><%=l(:label_project)%></th>
21 <th><%=l(:field_description)%></th>
21 <th><%=l(:field_description)%></th>
22 <th><%=l(:label_subproject_plural)%></th>
22 <th><%=l(:field_is_public)%></th>
23 <%= sort_header_tag('is_public', :caption => l(:field_is_public), :default_order => 'desc') %>
23 <th><%=l(:field_created_on)%></th>
24 <%= sort_header_tag('created_on', :caption => l(:field_created_on), :default_order => 'desc') %>
25 <th></th>
24 <th></th>
26 <th></th>
25 <th></th>
27 </tr></thead>
26 </tr></thead>
28 <tbody>
27 <tbody>
29 <% for project in @projects %>
28 <% for project in @projects %>
30 <tr class="<%= cycle("odd", "even") %>">
29 <tr class="<%= cycle("odd", "even") %> <%= css_project_classes(project) %>">
31 <td><%= project.active? ? link_to(h(project.name), :controller => 'projects', :action => 'settings', :id => project) : h(project.name) %>
30 <td class="name" style="padding-left: <%= project.level %>em;"><%= project.active? ? link_to(h(project.name), :controller => 'projects', :action => 'settings', :id => project) : h(project.name) %></td>
32 <td><%= textilizable project.short_description, :project => project %>
31 <td><%= textilizable project.short_description, :project => project %></td>
33 <td align="center"><%= project.children.size %>
32 <td align="center"><%= image_tag 'true.png' if project.is_public? %></td>
34 <td align="center"><%= image_tag 'true.png' if project.is_public? %>
33 <td align="center"><%= format_date(project.created_on) %></td>
35 <td align="center"><%= format_date(project.created_on) %>
36 <td align="center" style="width:10%">
34 <td align="center" style="width:10%">
37 <small>
35 <small>
38 <%= link_to(l(:button_archive), { :controller => 'projects', :action => 'archive', :id => project }, :confirm => l(:text_are_you_sure), :method => :post, :class => 'icon icon-lock') if project.active? %>
36 <%= link_to(l(:button_archive), { :controller => 'projects', :action => 'archive', :id => project }, :confirm => l(:text_are_you_sure), :method => :post, :class => 'icon icon-lock') if project.active? %>
@@ -47,6 +45,4
47 </tbody>
45 </tbody>
48 </table>
46 </table>
49
47
50 <p class="pagination"><%= pagination_links_full @project_pages, @project_count %></p>
51
52 <% html_title(l(:label_project_plural)) -%>
48 <% html_title(l(:label_project_plural)) -%>
@@ -34,7 +34,7
34 <%= link_to l(:label_search), {:controller => 'search', :action => 'index', :id => @project}, :accesskey => accesskey(:search) %>:
34 <%= link_to l(:label_search), {:controller => 'search', :action => 'index', :id => @project}, :accesskey => accesskey(:search) %>:
35 <%= text_field_tag 'q', @question, :size => 20, :class => 'small', :accesskey => accesskey(:quick_search) %>
35 <%= text_field_tag 'q', @question, :size => 20, :class => 'small', :accesskey => accesskey(:quick_search) %>
36 <% end %>
36 <% end %>
37 <%= render :partial => 'layouts/project_selector' if User.current.memberships.any? %>
37 <%= render_project_jump_box %>
38 </div>
38 </div>
39
39
40 <h1><%= h(@project && !@project.new_record? ? @project.name : Setting.app_title) %></h1>
40 <h1><%= h(@project && !@project.new_record? ? @project.name : Setting.app_title) %></h1>
@@ -4,8 +4,8
4 <!--[form:project]-->
4 <!--[form:project]-->
5 <p><%= f.text_field :name, :required => true %><br /><em><%= l(:text_caracters_maximum, 30) %></em></p>
5 <p><%= f.text_field :name, :required => true %><br /><em><%= l(:text_caracters_maximum, 30) %></em></p>
6
6
7 <% if User.current.admin? and !@root_projects.empty? %>
7 <% if User.current.admin? && !@project.possible_parents.empty? %>
8 <p><%= f.select :parent_id, (@root_projects.collect {|p| [p.name, p.id]}), { :include_blank => true } %></p>
8 <p><label><%= l(:field_parent) %></label><%= parent_project_select_tag(@project) %></p>
9 <% end %>
9 <% end %>
10
10
11 <p><%= f.text_area :description, :rows => 5, :class => 'wiki-edit' %></p>
11 <p><%= f.text_area :description, :rows => 5, :class => 'wiki-edit' %></p>
@@ -48,7 +48,7
48 <p><% @activity.event_types.each do |t| %>
48 <p><% @activity.event_types.each do |t| %>
49 <label><%= check_box_tag "show_#{t}", 1, @activity.scope.include?(t) %> <%= l("label_#{t.singularize}_plural")%></label><br />
49 <label><%= check_box_tag "show_#{t}", 1, @activity.scope.include?(t) %> <%= l("label_#{t.singularize}_plural")%></label><br />
50 <% end %></p>
50 <% end %></p>
51 <% if @project && @project.active_children.any? %>
51 <% if @project && @project.descendants.active.any? %>
52 <p><label><%= check_box_tag 'with_subprojects', 1, @with_subprojects %> <%=l(:label_subproject_plural)%></label></p>
52 <p><label><%= check_box_tag 'with_subprojects', 1, @with_subprojects %> <%=l(:label_subproject_plural)%></label></p>
53 <%= hidden_field_tag 'with_subprojects', 0 %>
53 <%= hidden_field_tag 'with_subprojects', 0 %>
54 <% end %>
54 <% end %>
@@ -3,8 +3,8
3 <p><strong><%=h @project_to_destroy %></strong><br />
3 <p><strong><%=h @project_to_destroy %></strong><br />
4 <%=l(:text_project_destroy_confirmation)%>
4 <%=l(:text_project_destroy_confirmation)%>
5
5
6 <% if @project_to_destroy.children.any? %>
6 <% if @project_to_destroy.descendants.any? %>
7 <br /><%= l(:text_subprojects_destroy_warning, content_tag('strong', h(@project_to_destroy.children.sort.collect{|p| p.to_s}.join(', ')))) %>
7 <br /><%= l(:text_subprojects_destroy_warning, content_tag('strong', h(@project_to_destroy.descendants.collect{|p| p.to_s}.join(', ')))) %>
8 <% end %>
8 <% end %>
9 </p>
9 </p>
10 <p>
10 <p>
@@ -6,20 +6,11
6
6
7 <h2><%=l(:label_project_plural)%></h2>
7 <h2><%=l(:label_project_plural)%></h2>
8
8
9 <% @project_tree.keys.sort.each do |project| %>
9 <%= render_project_hierarchy(@projects)%>
10 <h3><%= link_to h(project.name), {:action => 'show', :id => project}, :class => (User.current.member_of?(project) ? "icon icon-fav" : "") %></h3>
11 <%= textilizable(project.short_description, :project => project) %>
12
13 <% if @project_tree[project].any? %>
14 <p><%= l(:label_subproject_plural) %>:
15 <%= @project_tree[project].sort.collect {|subproject|
16 link_to(h(subproject.name), {:action => 'show', :id => subproject}, :class => (User.current.member_of?(subproject) ? "icon icon-fav" : ""))}.join(', ') %></p>
17 <% end %>
18 <% end %>
19
10
20 <% if User.current.logged? %>
11 <% if User.current.logged? %>
21 <p style="text-align:right;">
12 <p style="text-align:right;">
22 <span class="icon icon-fav"><%= l(:label_my_projects) %></span>
13 <span class="my-project"><%= l(:label_my_projects) %></span>
23 </p>
14 </p>
24 <% end %>
15 <% end %>
25
16
@@ -4,11 +4,13
4 <%= textilizable @project.description %>
4 <%= textilizable @project.description %>
5 <ul>
5 <ul>
6 <% unless @project.homepage.blank? %><li><%=l(:field_homepage)%>: <%= link_to(h(@project.homepage), @project.homepage) %></li><% end %>
6 <% unless @project.homepage.blank? %><li><%=l(:field_homepage)%>: <%= link_to(h(@project.homepage), @project.homepage) %></li><% end %>
7 <% if @subprojects.any? %>
7 <% if @subprojects.any? %>
8 <li><%=l(:label_subproject_plural)%>: <%= @subprojects.collect{|p| link_to(h(p.name), :action => 'show', :id => p)}.join(", ") %></li>
8 <li><%=l(:label_subproject_plural)%>:
9 <% end %>
9 <%= @subprojects.collect{|p| link_to(h(p), :action => 'show', :id => p)}.join(", ") %></li>
10 <% if @project.parent %>
10 <% end %>
11 <li><%=l(:field_parent)%>: <%= link_to h(@project.parent.name), :controller => 'projects', :action => 'show', :id => @project.parent %></li>
11 <% if @ancestors.any? %>
12 <li><%=l(:field_parent)%>:
13 <%= @ancestors.collect {|p| link_to(h(p), :action => 'show', :id => p)}.join(" &#187; ") %></li>
12 <% end %>
14 <% end %>
13 <% @project.custom_values.each do |custom_value| %>
15 <% @project.custom_values.each do |custom_value| %>
14 <% if !custom_value.value.empty? %>
16 <% if !custom_value.value.empty? %>
@@ -31,7 +31,7
31 <p>
31 <p>
32 <label><%=l(:label_project_new)%></label><br/>
32 <label><%=l(:label_project_new)%></label><br/>
33 <% form_tag({ :action => 'edit_membership', :id => @user }) do %>
33 <% form_tag({ :action => 'edit_membership', :id => @user }) do %>
34 <%= select_tag 'membership[project_id]', projects_options_for_select(@projects) %>
34 <%= select_tag 'membership[project_id]', options_for_membership_project_select(@user, @projects) %>
35 <%= l(:label_role) %>:
35 <%= l(:label_role) %>:
36 <%= select_tag 'membership[role_id]', options_from_collection_for_select(@roles, "id", "name") %>
36 <%= select_tag 'membership[role_id]', options_from_collection_for_select(@roles, "id", "name") %>
37 <%= submit_tag l(:button_add) %>
37 <%= submit_tag l(:button_add) %>
@@ -85,6 +85,9 table.list td { vertical-align: top; }
85 table.list td.id { width: 2%; text-align: center;}
85 table.list td.id { width: 2%; text-align: center;}
86 table.list td.checkbox { width: 15px; padding: 0px;}
86 table.list td.checkbox { width: 15px; padding: 0px;}
87
87
88 tr.project td.name a { padding-left: 16px; white-space:nowrap; }
89 tr.project.parent td.name a { background: url('../images/bullet_toggle_minus.png') no-repeat; }
90
88 tr.issue { text-align: center; white-space: nowrap; }
91 tr.issue { text-align: center; white-space: nowrap; }
89 tr.issue td.subject, tr.issue td.category, td.assigned_to { white-space: normal; }
92 tr.issue td.subject, tr.issue td.category, td.assigned_to { white-space: normal; }
90 tr.issue td.subject { text-align: left; }
93 tr.issue td.subject { text-align: left; }
@@ -235,6 +238,15 form#issue-form .attributes { margin-bottom: 8px; }
235 form#issue-form .attributes p { padding-top: 1px; padding-bottom: 2px; }
238 form#issue-form .attributes p { padding-top: 1px; padding-bottom: 2px; }
236 form#issue-form .attributes select { min-width: 30%; }
239 form#issue-form .attributes select { min-width: 30%; }
237
240
241 ul.projects { margin: 0; padding-left: 1em; }
242 ul.projects.root { margin: 0; padding: 0; }
243 ul.projects ul { border-left: 3px solid #e0e0e0; }
244 ul.projects li { list-style-type:none; }
245 ul.projects li.root { margin-bottom: 1em; }
246 ul.projects li.child { margin-top: 1em;}
247 ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
248 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
249
238 ul.properties {padding:0; font-size: 0.9em; color: #777;}
250 ul.properties {padding:0; font-size: 0.9em; color: #777;}
239 ul.properties li {list-style-type:none;}
251 ul.properties li {list-style-type:none;}
240 ul.properties li span {font-style:italic;}
252 ul.properties li span {font-style:italic;}
@@ -10,6 +10,8 projects_001:
10 is_public: true
10 is_public: true
11 identifier: ecookbook
11 identifier: ecookbook
12 parent_id:
12 parent_id:
13 lft: 1
14 rgt: 10
13 projects_002:
15 projects_002:
14 created_on: 2006-07-19 19:14:19 +02:00
16 created_on: 2006-07-19 19:14:19 +02:00
15 name: OnlineStore
17 name: OnlineStore
@@ -21,6 +23,8 projects_002:
21 is_public: false
23 is_public: false
22 identifier: onlinestore
24 identifier: onlinestore
23 parent_id:
25 parent_id:
26 lft: 11
27 rgt: 12
24 projects_003:
28 projects_003:
25 created_on: 2006-07-19 19:15:21 +02:00
29 created_on: 2006-07-19 19:15:21 +02:00
26 name: eCookbook Subproject 1
30 name: eCookbook Subproject 1
@@ -32,6 +36,8 projects_003:
32 is_public: true
36 is_public: true
33 identifier: subproject1
37 identifier: subproject1
34 parent_id: 1
38 parent_id: 1
39 lft: 6
40 rgt: 7
35 projects_004:
41 projects_004:
36 created_on: 2006-07-19 19:15:51 +02:00
42 created_on: 2006-07-19 19:15:51 +02:00
37 name: eCookbook Subproject 2
43 name: eCookbook Subproject 2
@@ -43,6 +49,8 projects_004:
43 is_public: true
49 is_public: true
44 identifier: subproject2
50 identifier: subproject2
45 parent_id: 1
51 parent_id: 1
52 lft: 8
53 rgt: 9
46 projects_005:
54 projects_005:
47 created_on: 2006-07-19 19:15:51 +02:00
55 created_on: 2006-07-19 19:15:51 +02:00
48 name: Private child of eCookbook
56 name: Private child of eCookbook
@@ -52,6 +60,21 projects_005:
52 description: This is a private subproject of a public project
60 description: This is a private subproject of a public project
53 homepage: ""
61 homepage: ""
54 is_public: false
62 is_public: false
55 identifier: private_child
63 identifier: private-child
56 parent_id: 1
64 parent_id: 1
65 lft: 2
66 rgt: 5
67 projects_006:
68 created_on: 2006-07-19 19:15:51 +02:00
69 name: Child of private child
70 updated_on: 2006-07-19 19:17:07 +02:00
71 projects_count: 0
72 id: 6
73 description: This is a public subproject of a private project
74 homepage: ""
75 is_public: true
76 identifier: project6
77 parent_id: 5
78 lft: 3
79 rgt: 4
57 No newline at end of file
80
@@ -38,11 +38,18 class ProjectsControllerTest < Test::Unit::TestCase
38 get :index
38 get :index
39 assert_response :success
39 assert_response :success
40 assert_template 'index'
40 assert_template 'index'
41 assert_not_nil assigns(:project_tree)
41 assert_not_nil assigns(:projects)
42 # Root project as hash key
42
43 assert assigns(:project_tree).keys.include?(Project.find(1))
43 assert_tag :ul, :child => {:tag => 'li',
44 # Subproject in corresponding value
44 :descendant => {:tag => 'a', :content => 'eCookbook'},
45 assert assigns(:project_tree)[Project.find(1)].include?(Project.find(3))
45 :child => { :tag => 'ul',
46 :descendant => { :tag => 'a',
47 :content => 'Child of private child'
48 }
49 }
50 }
51
52 assert_no_tag :a, :content => /Private child of eCookbook/
46 end
53 end
47
54
48 def test_index_atom
55 def test_index_atom
@@ -45,12 +45,6 class ProjectTest < Test::Unit::TestCase
45 assert_equal "activerecord_error_blank", @ecookbook.errors.on(:name)
45 assert_equal "activerecord_error_blank", @ecookbook.errors.on(:name)
46 end
46 end
47
47
48 def test_public_projects
49 public_projects = Project.find(:all, :conditions => ["is_public=?", true])
50 assert_equal 3, public_projects.length
51 assert_equal true, public_projects[0].is_public?
52 end
53
54 def test_archive
48 def test_archive
55 user = @ecookbook.members.first.user
49 user = @ecookbook.members.first.user
56 @ecookbook.archive
50 @ecookbook.archive
@@ -60,7 +54,7 class ProjectTest < Test::Unit::TestCase
60 assert !user.projects.include?(@ecookbook)
54 assert !user.projects.include?(@ecookbook)
61 # Subproject are also archived
55 # Subproject are also archived
62 assert !@ecookbook.children.empty?
56 assert !@ecookbook.children.empty?
63 assert @ecookbook.active_children.empty?
57 assert @ecookbook.descendants.active.empty?
64 end
58 end
65
59
66 def test_unarchive
60 def test_unarchive
@@ -95,25 +89,98 class ProjectTest < Test::Unit::TestCase
95 assert Board.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).empty?
89 assert Board.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).empty?
96 end
90 end
97
91
98 def test_subproject_ok
92 def test_move_an_orphan_project_to_a_root_project
99 sub = Project.find(2)
93 sub = Project.find(2)
100 sub.parent = @ecookbook
94 sub.set_parent! @ecookbook
101 assert sub.save
102 assert_equal @ecookbook.id, sub.parent.id
95 assert_equal @ecookbook.id, sub.parent.id
103 @ecookbook.reload
96 @ecookbook.reload
104 assert_equal 4, @ecookbook.children.size
97 assert_equal 4, @ecookbook.children.size
105 end
98 end
106
99
107 def test_subproject_invalid
100 def test_move_an_orphan_project_to_a_subproject
108 sub = Project.find(2)
101 sub = Project.find(2)
109 sub.parent = @ecookbook_sub1
102 assert sub.set_parent!(@ecookbook_sub1)
110 assert !sub.save
103 end
104
105 def test_move_a_root_project_to_a_project
106 sub = @ecookbook
107 assert sub.set_parent!(Project.find(2))
111 end
108 end
112
109
113 def test_subproject_invalid_2
110 def test_should_not_move_a_project_to_its_children
114 sub = @ecookbook
111 sub = @ecookbook
115 sub.parent = Project.find(2)
112 assert !(sub.set_parent!(Project.find(3)))
116 assert !sub.save
113 end
114
115 def test_set_parent_should_add_roots_in_alphabetical_order
116 ProjectCustomField.delete_all
117 Project.delete_all
118 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
119 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
120 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
121 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
122
123 assert_equal 4, Project.count
124 assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
125 end
126
127 def test_set_parent_should_add_children_in_alphabetical_order
128 ProjectCustomField.delete_all
129 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
130 Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
131 Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
132 Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
133 Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
134
135 parent.reload
136 assert_equal 4, parent.children.size
137 assert_equal parent.children.sort_by(&:name), parent.children
138 end
139
140 def test_rebuild_should_sort_children_alphabetically
141 ProjectCustomField.delete_all
142 parent = Project.create!(:name => 'Parent', :identifier => 'parent')
143 Project.create!(:name => 'Project C', :identifier => 'project-c').move_to_child_of(parent)
144 Project.create!(:name => 'Project B', :identifier => 'project-b').move_to_child_of(parent)
145 Project.create!(:name => 'Project D', :identifier => 'project-d').move_to_child_of(parent)
146 Project.create!(:name => 'Project A', :identifier => 'project-a').move_to_child_of(parent)
147
148 Project.update_all("lft = NULL, rgt = NULL")
149 Project.rebuild!
150
151 parent.reload
152 assert_equal 4, parent.children.size
153 assert_equal parent.children.sort_by(&:name), parent.children
154 end
155
156 def test_parent
157 p = Project.find(6).parent
158 assert p.is_a?(Project)
159 assert_equal 5, p.id
160 end
161
162 def test_ancestors
163 a = Project.find(6).ancestors
164 assert a.first.is_a?(Project)
165 assert_equal [1, 5], a.collect(&:id)
166 end
167
168 def test_root
169 r = Project.find(6).root
170 assert r.is_a?(Project)
171 assert_equal 1, r.id
172 end
173
174 def test_children
175 c = Project.find(1).children
176 assert c.first.is_a?(Project)
177 assert_equal [5, 3, 4], c.collect(&:id)
178 end
179
180 def test_descendants
181 d = Project.find(1).descendants
182 assert d.first.is_a?(Project)
183 assert_equal [5, 6, 3, 4], d.collect(&:id)
117 end
184 end
118
185
119 def test_rolled_up_trackers
186 def test_rolled_up_trackers
1 NO CONTENT: file was removed
NO CONTENT: file was removed
General Comments 0
You need to be logged in to leave comments. Login now