Merged nested projects branch. Removes limit on subproject nesting (#594).

git-svn-id: svn+ssh://rubyforge.org/var/svn/redmine/trunk@2304 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Jean-Philippe Lang 2009-01-24 11:31:15 +00:00
parent 51b745470c
commit c9906480d3
46 changed files with 2060 additions and 125 deletions

View File

@ -26,9 +26,6 @@ class AdminController < ApplicationController
end
def projects
sort_init 'name', 'asc'
sort_update %w(name is_public created_on)
@status = params[:status] ? params[:status].to_i : 1
c = ARCondition.new(@status == 0 ? "status <> 0" : ["status = ?", @status])
@ -37,14 +34,8 @@ class AdminController < ApplicationController
c << ["LOWER(identifier) LIKE ? OR LOWER(name) LIKE ?", name, name]
end
@project_count = Project.count(:conditions => c.conditions)
@project_pages = Paginator.new self, @project_count,
per_page_option,
params['page']
@projects = Project.find :all, :order => sort_clause,
:conditions => c.conditions,
:limit => @project_pages.items_per_page,
:offset => @project_pages.current.offset
@projects = Project.find :all, :order => 'lft',
:conditions => c.conditions
render :action => "projects", :layout => false if request.xhr?
end

View File

@ -43,17 +43,14 @@ class ProjectsController < ApplicationController
# Lists visible projects
def index
projects = Project.find :all,
:conditions => Project.visible_by(User.current),
:include => :parent
respond_to do |format|
format.html {
@project_tree = projects.group_by {|p| p.parent || p}
@project_tree.keys.each {|p| @project_tree[p] -= [p]}
@projects = Project.visible.find(:all, :order => 'lft')
}
format.atom {
render_feed(projects.sort_by(&:created_on).reverse.slice(0, Setting.feeds_limit.to_i),
:title => "#{Setting.app_title}: #{l(:label_project_latest)}")
projects = Project.visible.find(:all, :order => 'created_on DESC',
:limit => Setting.feeds_limit.to_i)
render_feed(projects, :title => "#{Setting.app_title}: #{l(:label_project_latest)}")
}
end
end
@ -62,9 +59,6 @@ class ProjectsController < ApplicationController
def add
@issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
@trackers = Tracker.all
@root_projects = Project.find(:all,
:conditions => "parent_id IS NULL AND status = #{Project::STATUS_ACTIVE}",
:order => 'name')
@project = Project.new(params[:project])
if request.get?
@project.identifier = Project.next_identifier if Setting.sequential_project_identifiers?
@ -74,6 +68,7 @@ class ProjectsController < ApplicationController
else
@project.enabled_module_names = params[:enabled_modules]
if @project.save
@project.set_parent!(params[:project]['parent_id']) if User.current.admin? && params[:project].has_key?('parent_id')
flash[:notice] = l(:notice_successful_create)
redirect_to :controller => 'admin', :action => 'projects'
end
@ -88,7 +83,8 @@ class ProjectsController < ApplicationController
end
@members_by_role = @project.members.find(:all, :include => [:user, :role], :order => 'position').group_by {|m| m.role}
@subprojects = @project.children.find(:all, :conditions => Project.visible_by(User.current))
@subprojects = @project.children.visible
@ancestors = @project.ancestors.visible
@news = @project.news.find(:all, :limit => 5, :include => [ :author, :project ], :order => "#{News.table_name}.created_on DESC")
@trackers = @project.rolled_up_trackers
@ -110,9 +106,6 @@ class ProjectsController < ApplicationController
end
def settings
@root_projects = Project.find(:all,
:conditions => ["parent_id IS NULL AND status = #{Project::STATUS_ACTIVE} AND id <> ?", @project.id],
:order => 'name')
@issue_custom_fields = IssueCustomField.find(:all, :order => "#{CustomField.table_name}.position")
@issue_category ||= IssueCategory.new
@member ||= @project.members.new
@ -126,6 +119,7 @@ class ProjectsController < ApplicationController
if request.post?
@project.attributes = params[:project]
if @project.save
@project.set_parent!(params[:project]['parent_id']) if User.current.admin? && params[:project].has_key?('parent_id')
flash[:notice] = l(:notice_successful_update)
redirect_to :action => 'settings', :id => @project
else

View File

@ -61,7 +61,7 @@ class ReportsController < ApplicationController
render :template => "reports/issue_report_details"
when "subproject"
@field = "project_id"
@rows = @project.active_children
@rows = @project.descendants.active
@data = issues_by_subproject
@report_title = l(:field_subproject)
render :template => "reports/issue_report_details"
@ -72,7 +72,7 @@ class ReportsController < ApplicationController
@categories = @project.issue_categories
@assignees = @project.members.collect { |m| m.user }
@authors = @project.members.collect { |m| m.user }
@subprojects = @project.active_children
@subprojects = @project.descendants.active
issues_by_tracker
issues_by_version
issues_by_priority
@ -229,8 +229,8 @@ private
#{Issue.table_name} i, #{IssueStatus.table_name} s
where
i.status_id=s.id
and i.project_id IN (#{@project.active_children.collect{|p| p.id}.join(',')})
group by s.id, s.is_closed, i.project_id") if @project.active_children.any?
and i.project_id IN (#{@project.descendants.active.collect{|p| p.id}.join(',')})
group by s.id, s.is_closed, i.project_id") if @project.descendants.active.any?
@issues_by_subproject ||= []
end
end

View File

@ -34,7 +34,7 @@ class SearchController < ApplicationController
when 'my_projects'
User.current.memberships.collect(&:project)
when 'subprojects'
@project ? ([ @project ] + @project.active_children) : nil
@project ? (@project.self_and_descendants.active) : nil
else
@project
end

View File

@ -83,7 +83,7 @@ class UsersController < ApplicationController
end
@auth_sources = AuthSource.find(:all)
@roles = Role.find_all_givable
@projects = Project.find(:all, :order => 'name', :conditions => "status=#{Project::STATUS_ACTIVE}") - @user.projects
@projects = Project.active.find(:all, :order => 'lft')
@membership ||= Member.new
@memberships = @user.memberships
end

View File

@ -20,4 +20,12 @@ module AdminHelper
options_for_select([[l(:label_all), ''],
[l(:status_active), 1]], selected)
end
def css_project_classes(project)
s = 'project'
s << ' root' if project.root?
s << ' child' if project.child?
s << (project.leaf? ? ' leaf' : ' parent')
s
end
end

View File

@ -157,6 +157,45 @@ module ApplicationHelper
s
end
# Renders the project quick-jump box
def render_project_jump_box
# Retrieve them now to avoid a COUNT query
projects = User.current.projects.all
if projects.any?
s = '<select onchange="if (this.value != \'\') { window.location = this.value; }">' +
"<option selected='selected'>#{ l(:label_jump_to_a_project) }</option>" +
'<option disabled="disabled">---</option>'
s << project_tree_options_for_select(projects) do |p|
{ :value => url_for(:controller => 'projects', :action => 'show', :id => p) }
end
s << '</select>'
s
end
end
def project_tree_options_for_select(projects, options = {})
s = ''
project_tree(projects) do |project, level|
name_prefix = (level > 0 ? ('&nbsp;' * 2 * level + '&#187; ') : '')
tag_options = {:value => project.id, :selected => ((project == options[:selected]) ? 'selected' : nil)}
tag_options.merge!(yield(project)) if block_given?
s << content_tag('option', name_prefix + h(project), tag_options)
end
s
end
# Yields the given block for each project with its level in the tree
def project_tree(projects, &block)
ancestors = []
projects.sort_by(&:lft).each do |project|
while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
ancestors.pop
end
yield project, ancestors.size
ancestors << project
end
end
# Truncates and returns the string as a single line
def truncate_single_line(string, *args)
truncate(string, *args).gsub(%r{[\r\n]+}m, ' ')

View File

@ -33,4 +33,39 @@ module ProjectsHelper
]
tabs.select {|tab| User.current.allowed_to?(tab[:action], @project)}
end
def parent_project_select_tag(project)
options = '<option></option>' + project_tree_options_for_select(project.possible_parents, :selected => project.parent)
content_tag('select', options, :name => 'project[parent_id]')
end
# Renders a tree of projects as a nested set of unordered lists
# The given collection may be a subset of the whole project tree
# (eg. some intermediate nodes are private and can not be seen)
def render_project_hierarchy(projects)
s = ''
if projects.any?
ancestors = []
projects.each do |project|
if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
else
ancestors.pop
s << "</li>"
while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
ancestors.pop
s << "</ul></li>\n"
end
end
classes = (ancestors.empty? ? 'root' : 'child')
s << "<li class='#{classes}'><div class='#{classes}'>" +
link_to(h(project), {:controller => 'projects', :action => 'show', :id => project}, :class => "project #{User.current.member_of?(project) ? 'my-project' : nil}")
s << "<div class='wiki description'>#{textilizable(project.short_description, :project => project)}</div>" unless project.description.blank?
s << "</div>\n"
ancestors << project
end
s << ("</li></ul>\n" * ancestors.size)
end
s
end
end

View File

@ -44,7 +44,7 @@ module SearchHelper
def project_select_tag
options = [[l(:label_project_all), 'all']]
options << [l(:label_my_projects), 'my_projects'] unless User.current.memberships.empty?
options << [l(:label_and_its_subprojects, @project.name), 'subprojects'] unless @project.nil? || @project.active_children.empty?
options << [l(:label_and_its_subprojects, @project.name), 'subprojects'] unless @project.nil? || @project.descendants.active.empty?
options << [@project.name, ''] unless @project.nil?
select_tag('scope', options_for_select(options, params[:scope].to_s)) if options.size > 1
end

View File

@ -25,15 +25,10 @@ module UsersHelper
end
# Options for the new membership projects combo-box
def projects_options_for_select(projects)
def options_for_membership_project_select(user, projects)
options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
projects_by_root = projects.group_by(&:root)
projects_by_root.keys.sort.each do |root|
options << content_tag('option', h(root.name), :value => root.id, :disabled => (!projects.include?(root)))
projects_by_root[root].sort.each do |project|
next if project == root
options << content_tag('option', '&#187; ' + h(project.name), :value => project.id)
end
options << project_tree_options_for_select(projects) do |p|
{:disabled => (user.projects.include?(p))}
end
options
end

View File

@ -43,7 +43,7 @@ class Project < ActiveRecord::Base
:join_table => "#{table_name_prefix}custom_fields_projects#{table_name_suffix}",
:association_foreign_key => 'custom_field_id'
acts_as_tree :order => "name", :counter_cache => true
acts_as_nested_set :order => 'name', :dependent => :destroy
acts_as_attachable :view_permission => :view_files,
:delete_permission => :manage_files
@ -66,6 +66,8 @@ class Project < ActiveRecord::Base
before_destroy :delete_all_members
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] } }
named_scope :active, { :conditions => "#{Project.table_name}.status = #{STATUS_ACTIVE}"}
named_scope :visible, lambda { { :conditions => Project.visible_by(User.current) } }
def identifier=(identifier)
super unless identifier_frozen?
@ -78,7 +80,7 @@ class Project < ActiveRecord::Base
def issues_with_subprojects(include_subprojects=false)
conditions = nil
if include_subprojects
ids = [id] + child_ids
ids = [id] + descendants.collect(&:id)
conditions = ["#{Project.table_name}.id IN (#{ids.join(',')}) AND #{Project.visible_by}"]
end
conditions ||= ["#{Project.table_name}.id = ?", id]
@ -118,7 +120,7 @@ class Project < ActiveRecord::Base
end
if options[:project]
project_statement = "#{Project.table_name}.id = #{options[:project].id}"
project_statement << " OR #{Project.table_name}.parent_id = #{options[:project].id}" if options[:with_subprojects]
project_statement << " OR (#{Project.table_name}.lft > #{options[:project].lft} AND #{Project.table_name}.rgt < #{options[:project].rgt})" if options[:with_subprojects]
base_statement = "(#{project_statement}) AND (#{base_statement})"
end
if user.admin?
@ -141,7 +143,7 @@ class Project < ActiveRecord::Base
def project_condition(with_subprojects)
cond = "#{Project.table_name}.id = #{id}"
cond = "(#{cond} OR #{Project.table_name}.parent_id = #{id})" if with_subprojects
cond = "(#{cond} OR (#{Project.table_name}.lft > #{lft} AND #{Project.table_name}.rgt < #{rgt}))" if with_subprojects
cond
end
@ -164,6 +166,7 @@ class Project < ActiveRecord::Base
self.status == STATUS_ACTIVE
end
# Archives the project and its descendants recursively
def archive
# Archive subprojects if any
children.each do |subproject|
@ -172,13 +175,54 @@ class Project < ActiveRecord::Base
update_attribute :status, STATUS_ARCHIVED
end
# Unarchives the project
# All its ancestors must be active
def unarchive
return false if parent && !parent.active?
return false if ancestors.detect {|a| !a.active?}
update_attribute :status, STATUS_ACTIVE
end
def active_children
children.select {|child| child.active?}
# Returns an array of projects the project can be moved to
def possible_parents
@possible_parents ||= (Project.active.find(:all) - self_and_descendants)
end
# Sets the parent of the project
# Argument can be either a Project, a String, a Fixnum or nil
def set_parent!(p)
unless p.nil? || p.is_a?(Project)
if p.to_s.blank?
p = nil
else
p = Project.find_by_id(p)
return false unless p
end
end
if p == parent && !p.nil?
# Nothing to do
true
elsif p.nil? || (p.active? && move_possible?(p))
# Insert the project so that target's children or root projects stay alphabetically sorted
sibs = (p.nil? ? self.class.roots : p.children)
to_be_inserted_before = sibs.detect {|c| c.name.to_s.downcase > name.to_s.downcase }
if to_be_inserted_before
move_to_left_of(to_be_inserted_before)
elsif p.nil?
if sibs.empty?
# move_to_root adds the project in first (ie. left) position
move_to_root
else
move_to_right_of(sibs.last) unless self == sibs.last
end
else
# move_to_child_of adds the project in last (ie.right) position
move_to_child_of(p)
end
true
else
# Can not move to the given target
false
end
end
# Returns an array of the trackers used by the project and its sub projects
@ -186,7 +230,7 @@ class Project < ActiveRecord::Base
@rolled_up_trackers ||=
Tracker.find(:all, :include => :projects,
:select => "DISTINCT #{Tracker.table_name}.*",
:conditions => ["#{Project.table_name}.id = ? OR #{Project.table_name}.parent_id = ?", id, id],
:conditions => ["#{Project.table_name}.lft >= ? AND #{Project.table_name}.rgt <= ?", lft, rgt],
:order => "#{Tracker.table_name}.position")
end
@ -225,7 +269,7 @@ class Project < ActiveRecord::Base
# Returns a short description of the projects (first lines)
def short_description(length = 255)
description.gsub(/^(.{#{length}}[^\n]*).*$/m, '\1').strip if description
description.gsub(/^(.{#{length}}[^\n\r]*).*$/m, '\1...').strip if description
end
def allows_to?(action)
@ -257,8 +301,6 @@ class Project < ActiveRecord::Base
protected
def validate
errors.add(parent_id, " must be a root project") if parent and parent.parent
errors.add_to_base("A project with subprojects can't be a subproject") if parent and children.size > 0
errors.add(:identifier, :activerecord_error_invalid) if !identifier.blank? && identifier.match(/^\d*$/)
end

View File

@ -174,8 +174,8 @@ class Query < ActiveRecord::Base
unless @project.versions.empty?
@available_filters["fixed_version_id"] = { :type => :list_optional, :order => 7, :values => @project.versions.sort.collect{|s| [s.name, s.id.to_s] } }
end
unless @project.active_children.empty?
@available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.active_children.collect{|s| [s.name, s.id.to_s] } }
unless @project.descendants.active.empty?
@available_filters["subproject_id"] = { :type => :list_subprojects, :order => 13, :values => @project.descendants.visible.collect{|s| [s.name, s.id.to_s] } }
end
add_custom_fields_filters(@project.all_issue_custom_fields)
else
@ -257,7 +257,7 @@ class Query < ActiveRecord::Base
def project_statement
project_clauses = []
if project && !@project.active_children.empty?
if project && !@project.descendants.active.empty?
ids = [project.id]
if has_filter?("subproject_id")
case operator_for("subproject_id")
@ -268,10 +268,10 @@ class Query < ActiveRecord::Base
# main project only
else
# all subprojects
ids += project.child_ids
ids += project.descendants.collect(&:id)
end
elsif Setting.display_subprojects_issues?
ids += project.child_ids
ids += project.descendants.collect(&:id)
end
project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
elsif project

View File

@ -17,22 +17,20 @@
<table class="list">
<thead><tr>
<%= sort_header_tag('name', :caption => l(:label_project)) %>
<th><%=l(:label_project)%></th>
<th><%=l(:field_description)%></th>
<th><%=l(:label_subproject_plural)%></th>
<%= sort_header_tag('is_public', :caption => l(:field_is_public), :default_order => 'desc') %>
<%= sort_header_tag('created_on', :caption => l(:field_created_on), :default_order => 'desc') %>
<th><%=l(:field_is_public)%></th>
<th><%=l(:field_created_on)%></th>
<th></th>
<th></th>
</tr></thead>
<tbody>
<% for project in @projects %>
<tr class="<%= cycle("odd", "even") %>">
<td><%= project.active? ? link_to(h(project.name), :controller => 'projects', :action => 'settings', :id => project) : h(project.name) %>
<td><%= textilizable project.short_description, :project => project %>
<td align="center"><%= project.children.size %>
<td align="center"><%= image_tag 'true.png' if project.is_public? %>
<td align="center"><%= format_date(project.created_on) %>
<tr class="<%= cycle("odd", "even") %> <%= css_project_classes(project) %>">
<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>
<td><%= textilizable project.short_description, :project => project %></td>
<td align="center"><%= image_tag 'true.png' if project.is_public? %></td>
<td align="center"><%= format_date(project.created_on) %></td>
<td align="center" style="width:10%">
<small>
<%= 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 @@
</tbody>
</table>
<p class="pagination"><%= pagination_links_full @project_pages, @project_count %></p>
<% html_title(l(:label_project_plural)) -%>

View File

@ -1,12 +0,0 @@
<% user_projects_by_root = User.current.projects.find(:all, :include => :parent).group_by(&:root) %>
<select onchange="if (this.value != '') { window.location = this.value; }">
<option selected="selected" value=""><%= l(:label_jump_to_a_project) %></option>
<option disabled="disabled" value="">---</option>
<% user_projects_by_root.keys.sort.each do |root| %>
<%= content_tag('option', h(root.name), :value => url_for(:controller => 'projects', :action => 'show', :id => root, :jump => current_menu_item)) %>
<% user_projects_by_root[root].sort.each do |project| %>
<% next if project == root %>
<%= content_tag('option', ('&#187; ' + h(project.name)), :value => url_for(:controller => 'projects', :action => 'show', :id => project, :jump => current_menu_item)) %>
<% end %>
<% end %>
</select>

View File

@ -34,7 +34,7 @@
<%= link_to l(:label_search), {:controller => 'search', :action => 'index', :id => @project}, :accesskey => accesskey(:search) %>:
<%= text_field_tag 'q', @question, :size => 20, :class => 'small', :accesskey => accesskey(:quick_search) %>
<% end %>
<%= render :partial => 'layouts/project_selector' if User.current.memberships.any? %>
<%= render_project_jump_box %>
</div>
<h1><%= h(@project && !@project.new_record? ? @project.name : Setting.app_title) %></h1>

View File

@ -4,8 +4,8 @@
<!--[form:project]-->
<p><%= f.text_field :name, :required => true %><br /><em><%= l(:text_caracters_maximum, 30) %></em></p>
<% if User.current.admin? and !@root_projects.empty? %>
<p><%= f.select :parent_id, (@root_projects.collect {|p| [p.name, p.id]}), { :include_blank => true } %></p>
<% if User.current.admin? && !@project.possible_parents.empty? %>
<p><label><%= l(:field_parent) %></label><%= parent_project_select_tag(@project) %></p>
<% end %>
<p><%= f.text_area :description, :rows => 5, :class => 'wiki-edit' %></p>

View File

@ -48,7 +48,7 @@
<p><% @activity.event_types.each do |t| %>
<label><%= check_box_tag "show_#{t}", 1, @activity.scope.include?(t) %> <%= l("label_#{t.singularize}_plural")%></label><br />
<% end %></p>
<% if @project && @project.active_children.any? %>
<% if @project && @project.descendants.active.any? %>
<p><label><%= check_box_tag 'with_subprojects', 1, @with_subprojects %> <%=l(:label_subproject_plural)%></label></p>
<%= hidden_field_tag 'with_subprojects', 0 %>
<% end %>

View File

@ -3,8 +3,8 @@
<p><strong><%=h @project_to_destroy %></strong><br />
<%=l(:text_project_destroy_confirmation)%>
<% if @project_to_destroy.children.any? %>
<br /><%= l(:text_subprojects_destroy_warning, content_tag('strong', h(@project_to_destroy.children.sort.collect{|p| p.to_s}.join(', ')))) %>
<% if @project_to_destroy.descendants.any? %>
<br /><%= l(:text_subprojects_destroy_warning, content_tag('strong', h(@project_to_destroy.descendants.collect{|p| p.to_s}.join(', ')))) %>
<% end %>
</p>
<p>

View File

@ -6,20 +6,11 @@
<h2><%=l(:label_project_plural)%></h2>
<% @project_tree.keys.sort.each do |project| %>
<h3><%= link_to h(project.name), {:action => 'show', :id => project}, :class => (User.current.member_of?(project) ? "icon icon-fav" : "") %></h3>
<%= textilizable(project.short_description, :project => project) %>
<% if @project_tree[project].any? %>
<p><%= l(:label_subproject_plural) %>:
<%= @project_tree[project].sort.collect {|subproject|
link_to(h(subproject.name), {:action => 'show', :id => subproject}, :class => (User.current.member_of?(subproject) ? "icon icon-fav" : ""))}.join(', ') %></p>
<% end %>
<% end %>
<%= render_project_hierarchy(@projects)%>
<% if User.current.logged? %>
<p style="text-align:right;">
<span class="icon icon-fav"><%= l(:label_my_projects) %></span>
<span class="my-project"><%= l(:label_my_projects) %></span>
</p>
<% end %>

View File

@ -5,10 +5,12 @@
<ul>
<% unless @project.homepage.blank? %><li><%=l(:field_homepage)%>: <%= link_to(h(@project.homepage), @project.homepage) %></li><% end %>
<% if @subprojects.any? %>
<li><%=l(:label_subproject_plural)%>: <%= @subprojects.collect{|p| link_to(h(p.name), :action => 'show', :id => p)}.join(", ") %></li>
<li><%=l(:label_subproject_plural)%>:
<%= @subprojects.collect{|p| link_to(h(p), :action => 'show', :id => p)}.join(", ") %></li>
<% end %>
<% if @project.parent %>
<li><%=l(:field_parent)%>: <%= link_to h(@project.parent.name), :controller => 'projects', :action => 'show', :id => @project.parent %></li>
<% if @ancestors.any? %>
<li><%=l(:field_parent)%>:
<%= @ancestors.collect {|p| link_to(h(p), :action => 'show', :id => p)}.join(" &#187; ") %></li>
<% end %>
<% @project.custom_values.each do |custom_value| %>
<% if !custom_value.value.empty? %>

View File

@ -31,7 +31,7 @@
<p>
<label><%=l(:label_project_new)%></label><br/>
<% form_tag({ :action => 'edit_membership', :id => @user }) do %>
<%= select_tag 'membership[project_id]', projects_options_for_select(@projects) %>
<%= select_tag 'membership[project_id]', options_for_membership_project_select(@user, @projects) %>
<%= l(:label_role) %>:
<%= select_tag 'membership[role_id]', options_from_collection_for_select(@roles, "id", "name") %>
<%= submit_tag l(:button_add) %>

View File

@ -0,0 +1,11 @@
class AddProjectsLftAndRgt < ActiveRecord::Migration
def self.up
add_column :projects, :lft, :integer
add_column :projects, :rgt, :integer
end
def self.down
remove_column :projects, :lft
remove_column :projects, :rgt
end
end

View File

@ -0,0 +1,8 @@
class BuildProjectsTree < ActiveRecord::Migration
def self.up
Project.rebuild!
end
def self.down
end
end

View File

@ -85,6 +85,9 @@ table.list td { vertical-align: top; }
table.list td.id { width: 2%; text-align: center;}
table.list td.checkbox { width: 15px; padding: 0px;}
tr.project td.name a { padding-left: 16px; white-space:nowrap; }
tr.project.parent td.name a { background: url('../images/bullet_toggle_minus.png') no-repeat; }
tr.issue { text-align: center; white-space: nowrap; }
tr.issue td.subject, tr.issue td.category, td.assigned_to { white-space: normal; }
tr.issue td.subject { text-align: left; }
@ -235,6 +238,15 @@ form#issue-form .attributes { margin-bottom: 8px; }
form#issue-form .attributes p { padding-top: 1px; padding-bottom: 2px; }
form#issue-form .attributes select { min-width: 30%; }
ul.projects { margin: 0; padding-left: 1em; }
ul.projects.root { margin: 0; padding: 0; }
ul.projects ul { border-left: 3px solid #e0e0e0; }
ul.projects li { list-style-type:none; }
ul.projects li.root { margin-bottom: 1em; }
ul.projects li.child { margin-top: 1em;}
ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
.my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
ul.properties {padding:0; font-size: 0.9em; color: #777;}
ul.properties li {list-style-type:none;}
ul.properties li span {font-style:italic;}

View File

@ -10,6 +10,8 @@ projects_001:
is_public: true
identifier: ecookbook
parent_id:
lft: 1
rgt: 10
projects_002:
created_on: 2006-07-19 19:14:19 +02:00
name: OnlineStore
@ -21,6 +23,8 @@ projects_002:
is_public: false
identifier: onlinestore
parent_id:
lft: 11
rgt: 12
projects_003:
created_on: 2006-07-19 19:15:21 +02:00
name: eCookbook Subproject 1
@ -32,6 +36,8 @@ projects_003:
is_public: true
identifier: subproject1
parent_id: 1
lft: 6
rgt: 7
projects_004:
created_on: 2006-07-19 19:15:51 +02:00
name: eCookbook Subproject 2
@ -43,6 +49,8 @@ projects_004:
is_public: true
identifier: subproject2
parent_id: 1
lft: 8
rgt: 9
projects_005:
created_on: 2006-07-19 19:15:51 +02:00
name: Private child of eCookbook
@ -52,6 +60,21 @@ projects_005:
description: This is a private subproject of a public project
homepage: ""
is_public: false
identifier: private_child
identifier: private-child
parent_id: 1
lft: 2
rgt: 5
projects_006:
created_on: 2006-07-19 19:15:51 +02:00
name: Child of private child
updated_on: 2006-07-19 19:17:07 +02:00
projects_count: 0
id: 6
description: This is a public subproject of a private project
homepage: ""
is_public: true
identifier: project6
parent_id: 5
lft: 3
rgt: 4

View File

@ -38,11 +38,18 @@ class ProjectsControllerTest < Test::Unit::TestCase
get :index
assert_response :success
assert_template 'index'
assert_not_nil assigns(:project_tree)
# Root project as hash key
assert assigns(:project_tree).keys.include?(Project.find(1))
# Subproject in corresponding value
assert assigns(:project_tree)[Project.find(1)].include?(Project.find(3))
assert_not_nil assigns(:projects)
assert_tag :ul, :child => {:tag => 'li',
:descendant => {:tag => 'a', :content => 'eCookbook'},
:child => { :tag => 'ul',
:descendant => { :tag => 'a',
:content => 'Child of private child'
}
}
}
assert_no_tag :a, :content => /Private child of eCookbook/
end
def test_index_atom

View File

@ -45,12 +45,6 @@ class ProjectTest < Test::Unit::TestCase
assert_equal "activerecord_error_blank", @ecookbook.errors.on(:name)
end
def test_public_projects
public_projects = Project.find(:all, :conditions => ["is_public=?", true])
assert_equal 3, public_projects.length
assert_equal true, public_projects[0].is_public?
end
def test_archive
user = @ecookbook.members.first.user
@ecookbook.archive
@ -60,7 +54,7 @@ class ProjectTest < Test::Unit::TestCase
assert !user.projects.include?(@ecookbook)
# Subproject are also archived
assert !@ecookbook.children.empty?
assert @ecookbook.active_children.empty?
assert @ecookbook.descendants.active.empty?
end
def test_unarchive
@ -95,25 +89,98 @@ class ProjectTest < Test::Unit::TestCase
assert Board.find(:all, :conditions => ['project_id = ?', @ecookbook.id]).empty?
end
def test_subproject_ok
def test_move_an_orphan_project_to_a_root_project
sub = Project.find(2)
sub.parent = @ecookbook
assert sub.save
sub.set_parent! @ecookbook
assert_equal @ecookbook.id, sub.parent.id
@ecookbook.reload
assert_equal 4, @ecookbook.children.size
end
def test_subproject_invalid
def test_move_an_orphan_project_to_a_subproject
sub = Project.find(2)
sub.parent = @ecookbook_sub1
assert !sub.save
assert sub.set_parent!(@ecookbook_sub1)
end
def test_subproject_invalid_2
def test_move_a_root_project_to_a_project
sub = @ecookbook
sub.parent = Project.find(2)
assert !sub.save
assert sub.set_parent!(Project.find(2))
end
def test_should_not_move_a_project_to_its_children
sub = @ecookbook
assert !(sub.set_parent!(Project.find(3)))
end
def test_set_parent_should_add_roots_in_alphabetical_order
ProjectCustomField.delete_all
Project.delete_all
Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(nil)
Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(nil)
Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(nil)
Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(nil)
assert_equal 4, Project.count
assert_equal Project.all.sort_by(&:name), Project.all.sort_by(&:lft)
end
def test_set_parent_should_add_children_in_alphabetical_order
ProjectCustomField.delete_all
parent = Project.create!(:name => 'Parent', :identifier => 'parent')
Project.create!(:name => 'Project C', :identifier => 'project-c').set_parent!(parent)
Project.create!(:name => 'Project B', :identifier => 'project-b').set_parent!(parent)
Project.create!(:name => 'Project D', :identifier => 'project-d').set_parent!(parent)
Project.create!(:name => 'Project A', :identifier => 'project-a').set_parent!(parent)
parent.reload
assert_equal 4, parent.children.size
assert_equal parent.children.sort_by(&:name), parent.children
end
def test_rebuild_should_sort_children_alphabetically
ProjectCustomField.delete_all
parent = Project.create!(:name => 'Parent', :identifier => 'parent')
Project.create!(:name => 'Project C', :identifier => 'project-c').move_to_child_of(parent)
Project.create!(:name => 'Project B', :identifier => 'project-b').move_to_child_of(parent)
Project.create!(:name => 'Project D', :identifier => 'project-d').move_to_child_of(parent)
Project.create!(:name => 'Project A', :identifier => 'project-a').move_to_child_of(parent)
Project.update_all("lft = NULL, rgt = NULL")
Project.rebuild!
parent.reload
assert_equal 4, parent.children.size
assert_equal parent.children.sort_by(&:name), parent.children
end
def test_parent
p = Project.find(6).parent
assert p.is_a?(Project)
assert_equal 5, p.id
end
def test_ancestors
a = Project.find(6).ancestors
assert a.first.is_a?(Project)
assert_equal [1, 5], a.collect(&:id)
end
def test_root
r = Project.find(6).root
assert r.is_a?(Project)
assert_equal 1, r.id
end
def test_children
c = Project.find(1).children
assert c.first.is_a?(Project)
assert_equal [5, 3, 4], c.collect(&:id)
end
def test_descendants
d = Project.find(1).descendants
assert d.first.is_a?(Project)
assert_equal [5, 6, 3, 4], d.collect(&:id)
end
def test_rolled_up_trackers

View File

@ -0,0 +1,20 @@
Copyright (c) 2007 [name of plugin creator]
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,64 @@
= AwesomeNestedSet
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.
== What makes this so awesome?
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.
== Installation
If you are on Rails 2.1 or later:
script/plugin install git://github.com/collectiveidea/awesome_nested_set.git
== Usage
To make use of awesome_nested_set, your model needs to have 3 fields: lft, rgt, and parent_id:
class CreateCategories < ActiveRecord::Migration
def self.up
create_table :categories do |t|
t.string :name
t.integer :parent_id
t.integer :lft
t.integer :rgt
end
end
def self.down
drop_table :categories
end
end
Enable the nested set functionality by declaring acts_as_nested_set on your model
class Category < ActiveRecord::Base
acts_as_nested_set
end
Run `rake rdoc` to generate the API docs and see CollectiveIdea::Acts::NestedSet::SingletonMethods for more info.
== View Helper
The view helper is called #nested_set_options.
Example usage:
<%= f.select :parent_id, nested_set_options(Category, @category) {|i| "#{'-' * i.level} #{i.name}" } %>
<%= select_tag 'parent_id', options_for_select(nested_set_options(Category) {|i| "#{'-' * i.level} #{i.name}" } ) %>
See CollectiveIdea::Acts::NestedSet::Helper for more information about the helpers.
== References
You can learn more about nested sets at:
http://www.dbmsmag.com/9603d06.html
http://threebit.net/tutorials/nestedset/tutorial1.html
http://api.rubyonrails.com/classes/ActiveRecord/Acts/NestedSet/ClassMethods.html
http://opensource.symetrie.com/trac/better_nested_set/
Copyright (c) 2008 Collective Idea, released under the MIT license

View File

@ -0,0 +1,46 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'
require 'rake/gempackagetask'
require 'rcov/rcovtask'
require "load_multi_rails_rake_tasks"
spec = eval(File.read("#{File.dirname(__FILE__)}/awesome_nested_set.gemspec"))
PKG_NAME = spec.name
PKG_VERSION = spec.version
Rake::GemPackageTask.new(spec) do |pkg|
pkg.need_zip = true
pkg.need_tar = true
end
desc 'Default: run unit tests.'
task :default => :test
desc 'Test the awesome_nested_set plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end
desc 'Generate documentation for the awesome_nested_set plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'AwesomeNestedSet'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README.rdoc')
rdoc.rdoc_files.include('lib/**/*.rb')
end
namespace :test do
desc "just rcov minus html output"
Rcov::RcovTask.new(:coverage) do |t|
# t.libs << 'test'
t.test_files = FileList['test/**/*_test.rb']
t.output_dir = 'coverage'
t.verbose = true
t.rcov_opts = %w(--exclude test,/usr/lib/ruby,/Library/Ruby,lib/awesome_nested_set/named_scope.rb --sort coverage)
end
end

View File

@ -0,0 +1,20 @@
Gem::Specification.new do |s|
s.name = "awesome_nested_set"
s.version = "1.1.1"
s.summary = "An awesome replacement for acts_as_nested_set and better_nested_set."
s.description = s.summary
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)
s.add_dependency "activerecord", ['>= 1.1']
s.has_rdoc = true
s.extra_rdoc_files = [ "README.rdoc"]
s.rdoc_options = ["--main", "README.rdoc", "--inline-source", "--line-numbers"]
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)
s.require_path = 'lib'
s.author = "Collective Idea"
s.email = "info@collectiveidea.com"
s.homepage = "http://collectiveidea.com"
end

View File

@ -0,0 +1 @@
require File.dirname(__FILE__) + "/rails/init"

View File

@ -0,0 +1,547 @@
module CollectiveIdea #:nodoc:
module Acts #:nodoc:
module NestedSet #:nodoc:
def self.included(base)
base.extend(SingletonMethods)
end
# This acts provides Nested Set functionality. Nested Set is a smart way to implement
# an _ordered_ tree, with the added feature that you can select the children and all of their
# descendants with a single query. The drawback is that insertion or move need some complex
# sql queries. But everything is done here by this module!
#
# Nested sets are appropriate each time you want either an orderd tree (menus,
# commercial categories) or an efficient way of querying big trees (threaded posts).
#
# == API
#
# Methods names are aligned with acts_as_tree as much as possible, to make replacment from one
# by another easier, except for the creation:
#
# in acts_as_tree:
# item.children.create(:name => "child1")
#
# in acts_as_nested_set:
# # adds a new item at the "end" of the tree, i.e. with child.left = max(tree.right)+1
# child = MyClass.new(:name => "child1")
# child.save
# # now move the item to its right place
# child.move_to_child_of my_item
#
# You can pass an id or an object to:
# * <tt>#move_to_child_of</tt>
# * <tt>#move_to_right_of</tt>
# * <tt>#move_to_left_of</tt>
#
module SingletonMethods
# Configuration options are:
#
# * +:parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id)
# * +:left_column+ - column name for left boundry data, default "lft"
# * +:right_column+ - column name for right boundry data, default "rgt"
# * +:scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id"
# (if it hasn't been already) and use that as the foreign key restriction. You
# can also pass an array to scope by multiple attributes.
# Example: <tt>acts_as_nested_set :scope => [:notable_id, :notable_type]</tt>
# * +:dependent+ - behavior for cascading destroy. If set to :destroy, all the
# child objects are destroyed alongside this object by calling their destroy
# method. If set to :delete_all (default), all the child objects are deleted
# without calling their destroy method.
#
# See CollectiveIdea::Acts::NestedSet::ClassMethods for a list of class methods and
# CollectiveIdea::Acts::NestedSet::InstanceMethods for a list of instance methods added
# to acts_as_nested_set models
def acts_as_nested_set(options = {})
options = {
:parent_column => 'parent_id',
:left_column => 'lft',
:right_column => 'rgt',
:order => 'id',
:dependent => :delete_all, # or :destroy
}.merge(options)
if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/
options[:scope] = "#{options[:scope]}_id".intern
end
write_inheritable_attribute :acts_as_nested_set_options, options
class_inheritable_reader :acts_as_nested_set_options
include Comparable
include Columns
include InstanceMethods
extend Columns
extend ClassMethods
# no bulk assignment
attr_protected left_column_name.intern,
right_column_name.intern,
parent_column_name.intern
before_create :set_default_left_and_right
before_destroy :prune_from_tree
# no assignment to structure fields
[left_column_name, right_column_name, parent_column_name].each do |column|
module_eval <<-"end_eval", __FILE__, __LINE__
def #{column}=(x)
raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead."
end
end_eval
end
named_scope :roots, :conditions => {parent_column_name => nil}, :order => quoted_left_column_name
named_scope :leaves, :conditions => "#{quoted_right_column_name} - #{quoted_left_column_name} = 1", :order => quoted_left_column_name
if self.respond_to?(:define_callbacks)
define_callbacks("before_move", "after_move")
end
end
end
module ClassMethods
# Returns the first root
def root
roots.find(:first)
end
def valid?
left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid?
end
def left_and_rights_valid?
count(
:joins => "LEFT OUTER JOIN #{quoted_table_name} AS parent ON " +
"#{quoted_table_name}.#{quoted_parent_column_name} = parent.#{primary_key}",
:conditions =>
"#{quoted_table_name}.#{quoted_left_column_name} IS NULL OR " +
"#{quoted_table_name}.#{quoted_right_column_name} IS NULL OR " +
"#{quoted_table_name}.#{quoted_left_column_name} >= " +
"#{quoted_table_name}.#{quoted_right_column_name} OR " +
"(#{quoted_table_name}.#{quoted_parent_column_name} IS NOT NULL AND " +
"(#{quoted_table_name}.#{quoted_left_column_name} <= parent.#{quoted_left_column_name} OR " +
"#{quoted_table_name}.#{quoted_right_column_name} >= parent.#{quoted_right_column_name}))"
) == 0
end
def no_duplicates_for_columns?
scope_string = Array(acts_as_nested_set_options[:scope]).map do |c|
connection.quote_column_name(c)
end.push(nil).join(", ")
[quoted_left_column_name, quoted_right_column_name].all? do |column|
# No duplicates
find(:first,
:select => "#{scope_string}#{column}, COUNT(#{column})",
:group => "#{scope_string}#{column}
HAVING COUNT(#{column}) > 1").nil?
end
end
# Wrapper for each_root_valid? that can deal with scope.
def all_roots_valid?
if acts_as_nested_set_options[:scope]
roots(:group => scope_column_names).group_by{|record| scope_column_names.collect{|col| record.send(col.to_sym)}}.all? do |scope, grouped_roots|
each_root_valid?(grouped_roots)
end
else
each_root_valid?(roots)
end
end
def each_root_valid?(roots_to_validate)
left = right = 0
roots_to_validate.all? do |root|
returning(root.left > left && root.right > right) do
left = root.left
right = root.right
end
end
end
# Rebuilds the left & rights if unset or invalid. Also very useful for converting from acts_as_tree.
def rebuild!
# Don't rebuild a valid tree.
return true if valid?
scope = lambda{}
if acts_as_nested_set_options[:scope]
scope = lambda{|node|
scope_column_names.inject(""){|str, column_name|
str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name.to_sym))} "
}
}
end
indices = {}
set_left_and_rights = lambda do |node|
# set left
node[left_column_name] = indices[scope.call(node)] += 1
# find
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) }
# set right
node[right_column_name] = indices[scope.call(node)] += 1
node.save!
end
# Find root node(s)
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|
# setup index for this scope
indices[scope.call(root_node)] ||= 0
set_left_and_rights.call(root_node)
end
end
end
# Mixed into both classes and instances to provide easy access to the column names
module Columns
def left_column_name
acts_as_nested_set_options[:left_column]
end
def right_column_name
acts_as_nested_set_options[:right_column]
end
def parent_column_name
acts_as_nested_set_options[:parent_column]
end
def scope_column_names
Array(acts_as_nested_set_options[:scope])
end
def quoted_left_column_name
connection.quote_column_name(left_column_name)
end
def quoted_right_column_name
connection.quote_column_name(right_column_name)
end
def quoted_parent_column_name
connection.quote_column_name(parent_column_name)
end
def quoted_scope_column_names
scope_column_names.collect {|column_name| connection.quote_column_name(column_name) }
end
end
# 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.
#
# category.self_and_descendants.count
# category.ancestors.find(:all, :conditions => "name like '%foo%'")
module InstanceMethods
# Value of the parent column
def parent_id
self[parent_column_name]
end
# Value of the left column
def left
self[left_column_name]
end
# Value of the right column
def right
self[right_column_name]
end
# Returns true if this is a root node.
def root?
parent_id.nil?
end
def leaf?
right - left == 1
end
# Returns true is this is a child node
def child?
!parent_id.nil?
end
# order by left column
def <=>(x)
left <=> x.left
end
# Redefine to act like active record
def ==(comparison_object)
comparison_object.equal?(self) ||
(comparison_object.instance_of?(self.class) &&
comparison_object.id == id &&
!comparison_object.new_record?)
end
# Returns root
def root
self_and_ancestors.find(:first)
end
# Returns the immediate parent
def parent
nested_set_scope.find_by_id(parent_id) if parent_id
end
# Returns the array of all parents and self
def self_and_ancestors
nested_set_scope.scoped :conditions => [
"#{self.class.table_name}.#{quoted_left_column_name} <= ? AND #{self.class.table_name}.#{quoted_right_column_name} >= ?", left, right
]
end
# Returns an array of all parents
def ancestors
without_self self_and_ancestors
end
# Returns the array of all children of the parent, including self
def self_and_siblings
nested_set_scope.scoped :conditions => {parent_column_name => parent_id}
end
# Returns the array of all children of the parent, except self
def siblings
without_self self_and_siblings
end
# Returns a set of all of its nested children which do not have children
def leaves
descendants.scoped :conditions => "#{self.class.table_name}.#{quoted_right_column_name} - #{self.class.table_name}.#{quoted_left_column_name} = 1"
end
# Returns the level of this object in the tree
# root level is 0
def level
parent_id.nil? ? 0 : ancestors.count
end
# Returns a set of itself and all of its nested children
def self_and_descendants
nested_set_scope.scoped :conditions => [
"#{self.class.table_name}.#{quoted_left_column_name} >= ? AND #{self.class.table_name}.#{quoted_right_column_name} <= ?", left, right
]
end
# Returns a set of all of its children and nested children
def descendants
without_self self_and_descendants
end
# Returns a set of only this entry's immediate children
def children
nested_set_scope.scoped :conditions => {parent_column_name => self}
end
def is_descendant_of?(other)
other.left < self.left && self.left < other.right && same_scope?(other)
end
def is_or_is_descendant_of?(other)
other.left <= self.left && self.left < other.right && same_scope?(other)
end
def is_ancestor_of?(other)
self.left < other.left && other.left < self.right && same_scope?(other)
end
def is_or_is_ancestor_of?(other)
self.left <= other.left && other.left < self.right && same_scope?(other)
end
# Check if other model is in the same scope
def same_scope?(other)
Array(acts_as_nested_set_options[:scope]).all? do |attr|
self.send(attr) == other.send(attr)
end
end
# Find the first sibling to the left
def left_sibling
siblings.find(:first, :conditions => ["#{self.class.table_name}.#{quoted_left_column_name} < ?", left],
:order => "#{self.class.table_name}.#{quoted_left_column_name} DESC")
end
# Find the first sibling to the right
def right_sibling
siblings.find(:first, :conditions => ["#{self.class.table_name}.#{quoted_left_column_name} > ?", left])
end
# Shorthand method for finding the left sibling and moving to the left of it.
def move_left
move_to_left_of left_sibling
end
# Shorthand method for finding the right sibling and moving to the right of it.
def move_right
move_to_right_of right_sibling
end
# Move the node to the left of another node (you can pass id only)
def move_to_left_of(node)
move_to node, :left
end
# Move the node to the left of another node (you can pass id only)
def move_to_right_of(node)
move_to node, :right
end
# Move the node to the child of another node (you can pass id only)
def move_to_child_of(node)
move_to node, :child
end
# Move the node to root nodes
def move_to_root
move_to nil, :root
end
def move_possible?(target)
self != target && # Can't target self
same_scope?(target) && # can't be in different scopes
# !(left..right).include?(target.left..target.right) # this needs tested more
# detect impossible move
!((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right))
end
def to_text
self_and_descendants.map do |node|
"#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})"
end.join("\n")
end
protected
def without_self(scope)
scope.scoped :conditions => ["#{self.class.table_name}.#{self.class.primary_key} != ?", self]
end
# All nested set queries should use this nested_set_scope, which performs finds on
# the base ActiveRecord class, using the :scope declared in the acts_as_nested_set
# declaration.
def nested_set_scope
options = {:order => quoted_left_column_name}
scopes = Array(acts_as_nested_set_options[:scope])
options[:conditions] = scopes.inject({}) do |conditions,attr|
conditions.merge attr => self[attr]
end unless scopes.empty?
self.class.base_class.scoped options
end
# on creation, set automatically lft and rgt to the end of the tree
def set_default_left_and_right
maxright = nested_set_scope.maximum(right_column_name) || 0
# adds the new node to the right of all existing nodes
self[left_column_name] = maxright + 1
self[right_column_name] = maxright + 2
end
# Prunes a branch off of the tree, shifting all of the elements on the right
# back to the left so the counts still work.
def prune_from_tree
return if right.nil? || left.nil?
diff = right - left + 1
delete_method = acts_as_nested_set_options[:dependent] == :destroy ?
:destroy_all : :delete_all
self.class.base_class.transaction do
nested_set_scope.send(delete_method,
["#{quoted_left_column_name} > ? AND #{quoted_right_column_name} < ?",
left, right]
)
nested_set_scope.update_all(
["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff],
["#{quoted_left_column_name} >= ?", right]
)
nested_set_scope.update_all(
["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff],
["#{quoted_right_column_name} >= ?", right]
)
end
end
# reload left, right, and parent
def reload_nested_set
reload(:select => "#{quoted_left_column_name}, " +
"#{quoted_right_column_name}, #{quoted_parent_column_name}")
end
def move_to(target, position)
raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record?
return if callback(:before_move) == false
transaction do
if target.is_a? self.class.base_class
target.reload_nested_set
elsif position != :root
# load object if node is not an object
target = nested_set_scope.find(target)
end
self.reload_nested_set
unless position == :root || move_possible?(target)
raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree."
end
bound = case position
when :child; target[right_column_name]
when :left; target[left_column_name]
when :right; target[right_column_name] + 1
when :root; 1
else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)."
end
if bound > self[right_column_name]
bound = bound - 1
other_bound = self[right_column_name] + 1
else
other_bound = self[left_column_name] - 1
end
# there would be no change
return if bound == self[right_column_name] || bound == self[left_column_name]
# we have defined the boundaries of two non-overlapping intervals,
# so sorting puts both the intervals and their boundaries in order
a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort
new_parent = case position
when :child; target.id
when :root; nil
else target[parent_column_name]
end
self.class.base_class.update_all([
"#{quoted_left_column_name} = CASE " +
"WHEN #{quoted_left_column_name} BETWEEN :a AND :b " +
"THEN #{quoted_left_column_name} + :d - :b " +
"WHEN #{quoted_left_column_name} BETWEEN :c AND :d " +
"THEN #{quoted_left_column_name} + :a - :c " +
"ELSE #{quoted_left_column_name} END, " +
"#{quoted_right_column_name} = CASE " +
"WHEN #{quoted_right_column_name} BETWEEN :a AND :b " +
"THEN #{quoted_right_column_name} + :d - :b " +
"WHEN #{quoted_right_column_name} BETWEEN :c AND :d " +
"THEN #{quoted_right_column_name} + :a - :c " +
"ELSE #{quoted_right_column_name} END, " +
"#{quoted_parent_column_name} = CASE " +
"WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " +
"ELSE #{quoted_parent_column_name} END",
{:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent}
], nested_set_scope.proxy_options[:conditions])
end
target.reload_nested_set if target
self.reload_nested_set
callback(:after_move)
end
end
end
end
end

View File

@ -0,0 +1,29 @@
# Rails <2.x doesn't define #except
class Hash #:nodoc:
# Returns a new hash without the given keys.
def except(*keys)
clone.except!(*keys)
end unless method_defined?(:except)
# Replaces the hash without the given keys.
def except!(*keys)
keys.map! { |key| convert_key(key) } if respond_to?(:convert_key)
keys.each { |key| delete(key) }
self
end unless method_defined?(:except!)
end
# NamedScope is new to Rails 2.1
unless defined? ActiveRecord::NamedScope
require 'awesome_nested_set/named_scope'
ActiveRecord::Base.class_eval do
include CollectiveIdea::NamedScope
end
end
# Rails 1.2.x doesn't define #quoted_table_name
class ActiveRecord::Base #:nodoc:
def self.quoted_table_name
self.connection.quote_column_name(self.table_name)
end unless methods.include?('quoted_table_name')
end

View File

@ -0,0 +1,40 @@
module CollectiveIdea #:nodoc:
module Acts #:nodoc:
module NestedSet #:nodoc:
# This module provides some helpers for the model classes using acts_as_nested_set.
# It is included by default in all views.
#
module Helper
# Returns options for select.
# You can exclude some items from the tree.
# You can pass a block receiving an item and returning the string displayed in the select.
#
# == Params
# * +class_or_item+ - Class name or top level times
# * +mover+ - The item that is being move, used to exlude impossible moves
# * +&block+ - a block that will be used to display: { |item| ... item.name }
#
# == Usage
#
# <%= f.select :parent_id, nested_set_options(Category, @category) {|i|
# "#{'' * i.level} #{i.name}"
# }) %>
#
def nested_set_options(class_or_item, mover = nil)
class_or_item = class_or_item.roots if class_or_item.is_a?(Class)
items = Array(class_or_item)
result = []
items.each do |root|
result += root.self_and_descendants.map do |i|
if mover.nil? || mover.new_record? || mover.move_possible?(i)
[yield(i), i.id]
end
end.compact
end
result
end
end
end
end
end

View File

@ -0,0 +1,140 @@
# Taken from Rails 2.1
module CollectiveIdea #:nodoc:
module NamedScope #:nodoc:
# All subclasses of ActiveRecord::Base have two named_scopes:
# * <tt>all</tt>, which is similar to a <tt>find(:all)</tt> query, and
# * <tt>scoped</tt>, which allows for the creation of anonymous scopes, on the fly:
#
# Shirt.scoped(:conditions => {:color => 'red'}).scoped(:include => :washing_instructions)
#
# These anonymous scopes tend to be useful when procedurally generating complex queries, where passing
# intermediate values (scopes) around as first-class objects is convenient.
def self.included(base)
base.class_eval do
extend ClassMethods
named_scope :scoped, lambda { |scope| scope }
end
end
module ClassMethods #:nodoc:
def scopes
read_inheritable_attribute(:scopes) || write_inheritable_attribute(:scopes, {})
end
# Adds a class method for retrieving and querying objects. A scope represents a narrowing of a database query,
# such as <tt>:conditions => {:color => :red}, :select => 'shirts.*', :include => :washing_instructions</tt>.
#
# class Shirt < ActiveRecord::Base
# named_scope :red, :conditions => {:color => 'red'}
# named_scope :dry_clean_only, :joins => :washing_instructions, :conditions => ['washing_instructions.dry_clean_only = ?', true]
# end
#
# 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>,
# in effect, represents the query <tt>Shirt.find(:all, :conditions => {:color => 'red'})</tt>.
#
# Unlike Shirt.find(...), however, the object returned by <tt>Shirt.red</tt> is not an Array; it resembles the association object
# constructed by a <tt>has_many</tt> declaration. For instance, you can invoke <tt>Shirt.red.find(:first)</tt>, <tt>Shirt.red.count</tt>,
# <tt>Shirt.red.find(:all, :conditions => {:size => 'small'})</tt>. Also, just
# as with the association objects, name scopes acts like an Array, implementing Enumerable; <tt>Shirt.red.each(&block)</tt>,
# <tt>Shirt.red.first</tt>, and <tt>Shirt.red.inject(memo, &block)</tt> all behave as if Shirt.red really were an Array.
#
# 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.
# Nested finds and calculations also work with these compositions: <tt>Shirt.red.dry_clean_only.count</tt> returns the number of garments
# for which these criteria obtain. Similarly with <tt>Shirt.red.dry_clean_only.average(:thread_count)</tt>.
#
# All scopes are available as class methods on the ActiveRecord descendent upon which the scopes were defined. But they are also available to
# <tt>has_many</tt> associations. If,
#
# class Person < ActiveRecord::Base
# has_many :shirts
# end
#
# then <tt>elton.shirts.red.dry_clean_only</tt> will return all of Elton's red, dry clean
# only shirts.
#
# Named scopes can also be procedural.
#
# class Shirt < ActiveRecord::Base
# named_scope :colored, lambda { |color|
# { :conditions => { :color => color } }
# }
# end
#
# In this example, <tt>Shirt.colored('puce')</tt> finds all puce shirts.
#
# Named scopes can also have extensions, just as with <tt>has_many</tt> declarations:
#
# class Shirt < ActiveRecord::Base
# named_scope :red, :conditions => {:color => 'red'} do
# def dom_id
# 'red_shirts'
# end
# end
# end
#
#
# For testing complex named scopes, you can examine the scoping options using the
# <tt>proxy_options</tt> method on the proxy itself.
#
# class Shirt < ActiveRecord::Base
# named_scope :colored, lambda { |color|
# { :conditions => { :color => color } }
# }
# end
#
# expected_options = { :conditions => { :colored => 'red' } }
# assert_equal expected_options, Shirt.colored('red').proxy_options
def named_scope(name, options = {}, &block)
scopes[name] = lambda do |parent_scope, *args|
Scope.new(parent_scope, case options
when Hash
options
when Proc
options.call(*args)
end, &block)
end
(class << self; self end).instance_eval do
define_method name do |*args|
scopes[name].call(self, *args)
end
end
end
end
class Scope #:nodoc:
attr_reader :proxy_scope, :proxy_options
[].methods.each { |m| delegate m, :to => :proxy_found unless m =~ /(^__|^nil\?|^send|class|extend|find|count|sum|average|maximum|minimum|paginate)/ }
delegate :scopes, :with_scope, :to => :proxy_scope
def initialize(proxy_scope, options, &block)
[options[:extend]].flatten.each { |extension| extend extension } if options[:extend]
extend Module.new(&block) if block_given?
@proxy_scope, @proxy_options = proxy_scope, options.except(:extend)
end
def reload
load_found; self
end
protected
def proxy_found
@found || load_found
end
private
def method_missing(method, *args, &block)
if scopes.include?(method)
scopes[method].call(self, *args)
else
with_scope :find => proxy_options do
proxy_scope.send(method, *args, &block)
end
end
end
def load_found
@found = find(:all)
end
end
end
end

View File

@ -0,0 +1,13 @@
require 'awesome_nested_set/compatability'
require 'awesome_nested_set'
ActiveRecord::Base.class_eval do
include CollectiveIdea::Acts::NestedSet
end
if defined?(ActionView)
require 'awesome_nested_set/helper'
ActionView::Base.class_eval do
include CollectiveIdea::Acts::NestedSet::Helper
end
end

View File

@ -0,0 +1,41 @@
require File.dirname(__FILE__) + '/../test_helper'
module CollectiveIdea
module Acts #:nodoc:
module NestedSet #:nodoc:
class AwesomeNestedSetTest < Test::Unit::TestCase
include Helper
fixtures :categories
def test_nested_set_options
expected = [
[" Top Level", 1],
["- Child 1", 2],
['- Child 2', 3],
['-- Child 2.1', 4],
['- Child 3', 5],
[" Top Level 2", 6]
]
actual = nested_set_options(Category) do |c|
"#{'-' * c.level} #{c.name}"
end
assert_equal expected, actual
end
def test_nested_set_options_with_mover
expected = [
[" Top Level", 1],
["- Child 1", 2],
['- Child 3', 5],
[" Top Level 2", 6]
]
actual = nested_set_options(Category, categories(:child_2)) do |c|
"#{'-' * c.level} #{c.name}"
end
assert_equal expected, actual
end
end
end
end
end

View File

@ -0,0 +1,603 @@
require File.dirname(__FILE__) + '/test_helper'
class Note < ActiveRecord::Base
acts_as_nested_set :scope => [:notable_id, :notable_type]
end
class AwesomeNestedSetTest < Test::Unit::TestCase
class Default < ActiveRecord::Base
acts_as_nested_set
set_table_name 'categories'
end
class Scoped < ActiveRecord::Base
acts_as_nested_set :scope => :organization
set_table_name 'categories'
end
def test_left_column_default
assert_equal 'lft', Default.acts_as_nested_set_options[:left_column]
end
def test_right_column_default
assert_equal 'rgt', Default.acts_as_nested_set_options[:right_column]
end
def test_parent_column_default
assert_equal 'parent_id', Default.acts_as_nested_set_options[:parent_column]
end
def test_scope_default
assert_nil Default.acts_as_nested_set_options[:scope]
end
def test_left_column_name
assert_equal 'lft', Default.left_column_name
assert_equal 'lft', Default.new.left_column_name
end
def test_right_column_name
assert_equal 'rgt', Default.right_column_name
assert_equal 'rgt', Default.new.right_column_name
end
def test_parent_column_name
assert_equal 'parent_id', Default.parent_column_name
assert_equal 'parent_id', Default.new.parent_column_name
end
def test_quoted_left_column_name
quoted = Default.connection.quote_column_name('lft')
assert_equal quoted, Default.quoted_left_column_name
assert_equal quoted, Default.new.quoted_left_column_name
end
def test_quoted_right_column_name
quoted = Default.connection.quote_column_name('rgt')
assert_equal quoted, Default.quoted_right_column_name
assert_equal quoted, Default.new.quoted_right_column_name
end
def test_left_column_protected_from_assignment
assert_raises(ActiveRecord::ActiveRecordError) { Category.new.lft = 1 }
end
def test_right_column_protected_from_assignment
assert_raises(ActiveRecord::ActiveRecordError) { Category.new.rgt = 1 }
end
def test_parent_column_protected_from_assignment
assert_raises(ActiveRecord::ActiveRecordError) { Category.new.parent_id = 1 }
end
def test_colums_protected_on_initialize
c = Category.new(:lft => 1, :rgt => 2, :parent_id => 3)
assert_nil c.lft
assert_nil c.rgt
assert_nil c.parent_id
end
def test_scoped_appends_id
assert_equal :organization_id, Scoped.acts_as_nested_set_options[:scope]
end
def test_roots_class_method
assert_equal Category.find_all_by_parent_id(nil), Category.roots
end
def test_root_class_method
assert_equal categories(:top_level), Category.root
end
def test_root
assert_equal categories(:top_level), categories(:child_3).root
end
def test_root?
assert categories(:top_level).root?
assert categories(:top_level_2).root?
end
def test_leaves_class_method
assert_equal Category.find(:all, :conditions => "#{Category.right_column_name} - #{Category.left_column_name} = 1"), Category.leaves
assert_equal Category.leaves.count, 4
assert (Category.leaves.include? categories(:child_1))
assert (Category.leaves.include? categories(:child_2_1))
assert (Category.leaves.include? categories(:child_3))
assert (Category.leaves.include? categories(:top_level_2))
end
def test_leaf
assert categories(:child_1).leaf?
assert categories(:child_2_1).leaf?
assert categories(:child_3).leaf?
assert categories(:top_level_2).leaf?
assert !categories(:top_level).leaf?
assert !categories(:child_2).leaf?
end
def test_parent
assert_equal categories(:child_2), categories(:child_2_1).parent
end
def test_self_and_ancestors
child = categories(:child_2_1)
self_and_ancestors = [categories(:top_level), categories(:child_2), child]
assert_equal self_and_ancestors, child.self_and_ancestors
end
def test_ancestors
child = categories(:child_2_1)
ancestors = [categories(:top_level), categories(:child_2)]
assert_equal ancestors, child.ancestors
end
def test_self_and_siblings
child = categories(:child_2)
self_and_siblings = [categories(:child_1), child, categories(:child_3)]
assert_equal self_and_siblings, child.self_and_siblings
assert_nothing_raised do
tops = [categories(:top_level), categories(:top_level_2)]
assert_equal tops, categories(:top_level).self_and_siblings
end
end
def test_siblings
child = categories(:child_2)
siblings = [categories(:child_1), categories(:child_3)]
assert_equal siblings, child.siblings
end
def test_leaves
leaves = [categories(:child_1), categories(:child_2_1), categories(:child_3), categories(:top_level_2)]
assert categories(:top_level).leaves, leaves
end
def test_level
assert_equal 0, categories(:top_level).level
assert_equal 1, categories(:child_1).level
assert_equal 2, categories(:child_2_1).level
end
def test_has_children?
assert categories(:child_2_1).children.empty?
assert !categories(:child_2).children.empty?
assert !categories(:top_level).children.empty?
end
def test_self_and_descendents
parent = categories(:top_level)
self_and_descendants = [parent, categories(:child_1), categories(:child_2),
categories(:child_2_1), categories(:child_3)]
assert_equal self_and_descendants, parent.self_and_descendants
assert_equal self_and_descendants, parent.self_and_descendants.count
end
def test_descendents
lawyers = Category.create!(:name => "lawyers")
us = Category.create!(:name => "United States")
us.move_to_child_of(lawyers)
patent = Category.create!(:name => "Patent Law")
patent.move_to_child_of(us)
lawyers.reload
assert_equal 1, lawyers.children.size
assert_equal 1, us.children.size
assert_equal 2, lawyers.descendants.size
end
def test_self_and_descendents
parent = categories(:top_level)
descendants = [categories(:child_1), categories(:child_2),
categories(:child_2_1), categories(:child_3)]
assert_equal descendants, parent.descendants
end
def test_children
category = categories(:top_level)
category.children.each {|c| assert_equal category.id, c.parent_id }
end
def test_is_or_is_ancestor_of?
assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_1))
assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_2_1))
assert categories(:child_2).is_or_is_ancestor_of?(categories(:child_2_1))
assert !categories(:child_2_1).is_or_is_ancestor_of?(categories(:child_2))
assert !categories(:child_1).is_or_is_ancestor_of?(categories(:child_2))
assert categories(:child_1).is_or_is_ancestor_of?(categories(:child_1))
end
def test_is_ancestor_of?
assert categories(:top_level).is_ancestor_of?(categories(:child_1))
assert categories(:top_level).is_ancestor_of?(categories(:child_2_1))
assert categories(:child_2).is_ancestor_of?(categories(:child_2_1))
assert !categories(:child_2_1).is_ancestor_of?(categories(:child_2))
assert !categories(:child_1).is_ancestor_of?(categories(:child_2))
assert !categories(:child_1).is_ancestor_of?(categories(:child_1))
end
def test_is_or_is_ancestor_of_with_scope
root = Scoped.root
child = root.children.first
assert root.is_or_is_ancestor_of?(child)
child.update_attribute :organization_id, 'different'
assert !root.is_or_is_ancestor_of?(child)
end
def test_is_or_is_descendant_of?
assert categories(:child_1).is_or_is_descendant_of?(categories(:top_level))
assert categories(:child_2_1).is_or_is_descendant_of?(categories(:top_level))
assert categories(:child_2_1).is_or_is_descendant_of?(categories(:child_2))
assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_2_1))
assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_1))
assert categories(:child_1).is_or_is_descendant_of?(categories(:child_1))
end
def test_is_descendant_of?
assert categories(:child_1).is_descendant_of?(categories(:top_level))
assert categories(:child_2_1).is_descendant_of?(categories(:top_level))
assert categories(:child_2_1).is_descendant_of?(categories(:child_2))
assert !categories(:child_2).is_descendant_of?(categories(:child_2_1))
assert !categories(:child_2).is_descendant_of?(categories(:child_1))
assert !categories(:child_1).is_descendant_of?(categories(:child_1))
end
def test_is_or_is_descendant_of_with_scope
root = Scoped.root
child = root.children.first
assert child.is_or_is_descendant_of?(root)
child.update_attribute :organization_id, 'different'
assert !child.is_or_is_descendant_of?(root)
end
def test_same_scope?
root = Scoped.root
child = root.children.first
assert child.same_scope?(root)
child.update_attribute :organization_id, 'different'
assert !child.same_scope?(root)
end
def test_left_sibling
assert_equal categories(:child_1), categories(:child_2).left_sibling
assert_equal categories(:child_2), categories(:child_3).left_sibling
end
def test_left_sibling_of_root
assert_nil categories(:top_level).left_sibling
end
def test_left_sibling_without_siblings
assert_nil categories(:child_2_1).left_sibling
end
def test_left_sibling_of_leftmost_node
assert_nil categories(:child_1).left_sibling
end
def test_right_sibling
assert_equal categories(:child_3), categories(:child_2).right_sibling
assert_equal categories(:child_2), categories(:child_1).right_sibling
end
def test_right_sibling_of_root
assert_equal categories(:top_level_2), categories(:top_level).right_sibling
assert_nil categories(:top_level_2).right_sibling
end
def test_right_sibling_without_siblings
assert_nil categories(:child_2_1).right_sibling
end
def test_right_sibling_of_rightmost_node
assert_nil categories(:child_3).right_sibling
end
def test_move_left
categories(:child_2).move_left
assert_nil categories(:child_2).left_sibling
assert_equal categories(:child_1), categories(:child_2).right_sibling
assert Category.valid?
end
def test_move_right
categories(:child_2).move_right
assert_nil categories(:child_2).right_sibling
assert_equal categories(:child_3), categories(:child_2).left_sibling
assert Category.valid?
end
def test_move_to_left_of
categories(:child_3).move_to_left_of(categories(:child_1))
assert_nil categories(:child_3).left_sibling
assert_equal categories(:child_1), categories(:child_3).right_sibling
assert Category.valid?
end
def test_move_to_right_of
categories(:child_1).move_to_right_of(categories(:child_3))
assert_nil categories(:child_1).right_sibling
assert_equal categories(:child_3), categories(:child_1).left_sibling
assert Category.valid?
end
def test_move_to_root
categories(:child_2).move_to_root
assert_nil categories(:child_2).parent
assert_equal 0, categories(:child_2).level
assert_equal 1, categories(:child_2_1).level
assert_equal 1, categories(:child_2).left
assert_equal 4, categories(:child_2).right
assert Category.valid?
end
def test_move_to_child_of
categories(:child_1).move_to_child_of(categories(:child_3))
assert_equal categories(:child_3).id, categories(:child_1).parent_id
assert Category.valid?
end
def test_move_to_child_of_appends_to_end
child = Category.create! :name => 'New Child'
child.move_to_child_of categories(:top_level)
assert_equal child, categories(:top_level).children.last
end
def test_subtree_move_to_child_of
assert_equal 4, categories(:child_2).left
assert_equal 7, categories(:child_2).right
assert_equal 2, categories(:child_1).left
assert_equal 3, categories(:child_1).right
categories(:child_2).move_to_child_of(categories(:child_1))
assert Category.valid?
assert_equal categories(:child_1).id, categories(:child_2).parent_id
assert_equal 3, categories(:child_2).left
assert_equal 6, categories(:child_2).right
assert_equal 2, categories(:child_1).left
assert_equal 7, categories(:child_1).right
end
def test_slightly_difficult_move_to_child_of
assert_equal 11, categories(:top_level_2).left
assert_equal 12, categories(:top_level_2).right
# create a new top-level node and move single-node top-level tree inside it.
new_top = Category.create(:name => 'New Top')
assert_equal 13, new_top.left
assert_equal 14, new_top.right
categories(:top_level_2).move_to_child_of(new_top)
assert Category.valid?
assert_equal new_top.id, categories(:top_level_2).parent_id
assert_equal 12, categories(:top_level_2).left
assert_equal 13, categories(:top_level_2).right
assert_equal 11, new_top.left
assert_equal 14, new_top.right
end
def test_difficult_move_to_child_of
assert_equal 1, categories(:top_level).left
assert_equal 10, categories(:top_level).right
assert_equal 5, categories(:child_2_1).left
assert_equal 6, categories(:child_2_1).right
# create a new top-level node and move an entire top-level tree inside it.
new_top = Category.create(:name => 'New Top')
categories(:top_level).move_to_child_of(new_top)
categories(:child_2_1).reload
assert Category.valid?
assert_equal new_top.id, categories(:top_level).parent_id
assert_equal 4, categories(:top_level).left
assert_equal 13, categories(:top_level).right
assert_equal 8, categories(:child_2_1).left
assert_equal 9, categories(:child_2_1).right
end
#rebuild swaps the position of the 2 children when added using move_to_child twice onto same parent
def test_move_to_child_more_than_once_per_parent_rebuild
root1 = Category.create(:name => 'Root1')
root2 = Category.create(:name => 'Root2')
root3 = Category.create(:name => 'Root3')
root2.move_to_child_of root1
root3.move_to_child_of root1
output = Category.roots.last.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!
assert_equal Category.roots.last.to_text, output
end
# doing move_to_child twice onto same parent from the furthest right first
def test_move_to_child_more_than_once_per_parent_outside_in
node1 = Category.create(:name => 'Node-1')
node2 = Category.create(:name => 'Node-2')
node3 = Category.create(:name => 'Node-3')
node2.move_to_child_of node1
node3.move_to_child_of node1
output = Category.roots.last.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!
assert_equal Category.roots.last.to_text, output
end
def test_valid_with_null_lefts
assert Category.valid?
Category.update_all('lft = null')
assert !Category.valid?
end
def test_valid_with_null_rights
assert Category.valid?
Category.update_all('rgt = null')
assert !Category.valid?
end
def test_valid_with_missing_intermediate_node
# Even though child_2_1 will still exist, it is a sign of a sloppy delete, not an invalid tree.
assert Category.valid?
Category.delete(categories(:child_2).id)
assert Category.valid?
end
def test_valid_with_overlapping_and_rights
assert Category.valid?
categories(:top_level_2)['lft'] = 0
categories(:top_level_2).save
assert !Category.valid?
end
def test_rebuild
assert Category.valid?
before_text = Category.root.to_text
Category.update_all('lft = null, rgt = null')
Category.rebuild!
assert Category.valid?
assert_equal before_text, Category.root.to_text
end
def test_move_possible_for_sibling
assert categories(:child_2).move_possible?(categories(:child_1))
end
def test_move_not_possible_to_self
assert !categories(:top_level).move_possible?(categories(:top_level))
end
def test_move_not_possible_to_parent
categories(:top_level).descendants.each do |descendant|
assert !categories(:top_level).move_possible?(descendant)
assert descendant.move_possible?(categories(:top_level))
end
end
def test_is_or_is_ancestor_of?
[:child_1, :child_2, :child_2_1, :child_3].each do |c|
assert categories(:top_level).is_or_is_ancestor_of?(categories(c))
end
assert !categories(:top_level).is_or_is_ancestor_of?(categories(:top_level_2))
end
def test_left_and_rights_valid_with_blank_left
assert Category.left_and_rights_valid?
categories(:child_2)[:lft] = nil
categories(:child_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_left_and_rights_valid_with_blank_right
assert Category.left_and_rights_valid?
categories(:child_2)[:rgt] = nil
categories(:child_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_left_and_rights_valid_with_equal
assert Category.left_and_rights_valid?
categories(:top_level_2)[:lft] = categories(:top_level_2)[:rgt]
categories(:top_level_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_left_and_rights_valid_with_left_equal_to_parent
assert Category.left_and_rights_valid?
categories(:child_2)[:lft] = categories(:top_level)[:lft]
categories(:child_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_left_and_rights_valid_with_right_equal_to_parent
assert Category.left_and_rights_valid?
categories(:child_2)[:rgt] = categories(:top_level)[:rgt]
categories(:child_2).save(false)
assert !Category.left_and_rights_valid?
end
def test_moving_dirty_objects_doesnt_invalidate_tree
r1 = Category.create
r2 = Category.create
r3 = Category.create
r4 = Category.create
nodes = [r1, r2, r3, r4]
r2.move_to_child_of(r1)
assert Category.valid?
r3.move_to_child_of(r1)
assert Category.valid?
r4.move_to_child_of(r2)
assert Category.valid?
end
def test_multi_scoped_no_duplicates_for_columns?
assert_nothing_raised do
Note.no_duplicates_for_columns?
end
end
def test_multi_scoped_all_roots_valid?
assert_nothing_raised do
Note.all_roots_valid?
end
end
def test_multi_scoped
note1 = Note.create!(:body => "A", :notable_id => 2, :notable_type => 'Category')
note2 = Note.create!(:body => "B", :notable_id => 2, :notable_type => 'Category')
note3 = Note.create!(:body => "C", :notable_id => 2, :notable_type => 'Default')
assert_equal [note1, note2], note1.self_and_siblings
assert_equal [note3], note3.self_and_siblings
end
def test_multi_scoped_rebuild
root = Note.create!(:body => "A", :notable_id => 3, :notable_type => 'Category')
child1 = Note.create!(:body => "B", :notable_id => 3, :notable_type => 'Category')
child2 = Note.create!(:body => "C", :notable_id => 3, :notable_type => 'Category')
child1.move_to_child_of root
child2.move_to_child_of root
Note.update_all('lft = null, rgt = null')
Note.rebuild!
assert_equal Note.roots.find_by_body('A'), root
assert_equal [child1, child2], Note.roots.find_by_body('A').children
end
def test_same_scope_with_multi_scopes
assert_nothing_raised do
notes(:scope1).same_scope?(notes(:child_1))
end
assert notes(:scope1).same_scope?(notes(:child_1))
assert notes(:child_1).same_scope?(notes(:scope1))
assert !notes(:scope1).same_scope?(notes(:scope2))
end
def test_quoting_of_multi_scope_column_names
assert_equal ["\"notable_id\"", "\"notable_type\""], Note.quoted_scope_column_names
end
def test_equal_in_same_scope
assert_equal notes(:scope1), notes(:scope1)
assert_not_equal notes(:scope1), notes(:child_1)
end
def test_equal_in_different_scopes
assert_not_equal notes(:scope1), notes(:scope2)
end
end

View File

@ -0,0 +1,18 @@
sqlite3:
adapter: sqlite3
dbfile: awesome_nested_set.sqlite3.db
sqlite3mem:
:adapter: sqlite3
:dbfile: ":memory:"
postgresql:
:adapter: postgresql
:username: postgres
:password: postgres
:database: awesome_nested_set_plugin_test
:min_messages: ERROR
mysql:
:adapter: mysql
:host: localhost
:username: root
:password:
:database: awesome_nested_set_plugin_test

View File

@ -0,0 +1,23 @@
ActiveRecord::Schema.define(:version => 0) do
create_table :categories, :force => true do |t|
t.column :name, :string
t.column :parent_id, :integer
t.column :lft, :integer
t.column :rgt, :integer
t.column :organization_id, :integer
end
create_table :departments, :force => true do |t|
t.column :name, :string
end
create_table :notes, :force => true do |t|
t.column :body, :text
t.column :parent_id, :integer
t.column :lft, :integer
t.column :rgt, :integer
t.column :notable_id, :integer
t.column :notable_type, :string
end
end

View File

@ -0,0 +1,34 @@
top_level:
id: 1
name: Top Level
lft: 1
rgt: 10
child_1:
id: 2
name: Child 1
parent_id: 1
lft: 2
rgt: 3
child_2:
id: 3
name: Child 2
parent_id: 1
lft: 4
rgt: 7
child_2_1:
id: 4
name: Child 2.1
parent_id: 3
lft: 5
rgt: 6
child_3:
id: 5
name: Child 3
parent_id: 1
lft: 8
rgt: 9
top_level_2:
id: 6
name: Top Level 2
lft: 11
rgt: 12

View File

@ -0,0 +1,15 @@
class Category < ActiveRecord::Base
acts_as_nested_set
def to_s
name
end
def recurse &block
block.call self, lambda{
self.children.each do |child|
child.recurse &block
end
}
end
end

View File

@ -0,0 +1,3 @@
top:
id: 1
name: Top

View File

@ -0,0 +1,38 @@
scope1:
id: 1
body: Top Level
lft: 1
rgt: 10
notable_id: 1
notable_type: Category
child_1:
id: 2
body: Child 1
parent_id: 1
lft: 2
rgt: 3
notable_id: 1
notable_type: Category
child_2:
id: 3
body: Child 2
parent_id: 1
lft: 4
rgt: 7
notable_id: 1
notable_type: Category
child_3:
id: 4
body: Child 3
parent_id: 1
lft: 8
rgt: 9
notable_id: 1
notable_type: Category
scope2:
id: 5
body: Top Level 2
lft: 1
rgt: 2
notable_id: 1
notable_type: Departments

View File

@ -0,0 +1,31 @@
$:.unshift(File.dirname(__FILE__) + '/../lib')
plugin_test_dir = File.dirname(__FILE__)
require 'rubygems'
require 'test/unit'
require 'multi_rails_init'
# gem 'activerecord', '>= 2.0'
require 'active_record'
require 'action_controller'
require 'action_view'
require 'active_record/fixtures'
require plugin_test_dir + '/../init.rb'
ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/debug.log")
ActiveRecord::Base.configurations = YAML::load(IO.read(plugin_test_dir + "/db/database.yml"))
ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3mem")
ActiveRecord::Migration.verbose = false
load(File.join(plugin_test_dir, "db", "schema.rb"))
Dir["#{plugin_test_dir}/fixtures/*.rb"].each {|file| require file }
class Test::Unit::TestCase #:nodoc:
self.fixture_path = File.dirname(__FILE__) + "/fixtures/"
self.use_transactional_fixtures = true
self.use_instantiated_fixtures = false
fixtures :categories, :notes, :departments
end