##// END OF EJS Templates
Search engine: display total results count (#906) and count by result type....
Jean-Philippe Lang -
r1664:be2b8a62f4d0
parent child
Show More
@@ -1,113 +1,118
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class SearchController < ApplicationController
18 class SearchController < ApplicationController
19 layout 'base'
19 layout 'base'
20
20
21 before_filter :find_optional_project
21 before_filter :find_optional_project
22
22
23 helper :messages
23 helper :messages
24 include MessagesHelper
24 include MessagesHelper
25
25
26 def index
26 def index
27 @question = params[:q] || ""
27 @question = params[:q] || ""
28 @question.strip!
28 @question.strip!
29 @all_words = params[:all_words] || (params[:submit] ? false : true)
29 @all_words = params[:all_words] || (params[:submit] ? false : true)
30 @titles_only = !params[:titles_only].nil?
30 @titles_only = !params[:titles_only].nil?
31
31
32 projects_to_search =
32 projects_to_search =
33 case params[:scope]
33 case params[:scope]
34 when 'all'
34 when 'all'
35 nil
35 nil
36 when 'my_projects'
36 when 'my_projects'
37 User.current.memberships.collect(&:project)
37 User.current.memberships.collect(&:project)
38 when 'subprojects'
38 when 'subprojects'
39 @project ? ([ @project ] + @project.active_children) : nil
39 @project ? ([ @project ] + @project.active_children) : nil
40 else
40 else
41 @project
41 @project
42 end
42 end
43
43
44 offset = nil
44 offset = nil
45 begin; offset = params[:offset].to_time if params[:offset]; rescue; end
45 begin; offset = params[:offset].to_time if params[:offset]; rescue; end
46
46
47 # quick jump to an issue
47 # quick jump to an issue
48 if @question.match(/^#?(\d+)$/) && Issue.find_by_id($1, :include => :project, :conditions => Project.visible_by(User.current))
48 if @question.match(/^#?(\d+)$/) && Issue.find_by_id($1, :include => :project, :conditions => Project.visible_by(User.current))
49 redirect_to :controller => "issues", :action => "show", :id => $1
49 redirect_to :controller => "issues", :action => "show", :id => $1
50 return
50 return
51 end
51 end
52
52
53 @object_types = %w(issues news documents changesets wiki_pages messages projects)
53 @object_types = %w(issues news documents changesets wiki_pages messages projects)
54 if projects_to_search.is_a? Project
54 if projects_to_search.is_a? Project
55 # don't search projects
55 # don't search projects
56 @object_types.delete('projects')
56 @object_types.delete('projects')
57 # only show what the user is allowed to view
57 # only show what the user is allowed to view
58 @object_types = @object_types.select {|o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search)}
58 @object_types = @object_types.select {|o| User.current.allowed_to?("view_#{o}".to_sym, projects_to_search)}
59 end
59 end
60
60
61 @scope = @object_types.select {|t| params[t]}
61 @scope = @object_types.select {|t| params[t]}
62 @scope = @object_types if @scope.empty?
62 @scope = @object_types if @scope.empty?
63
63
64 # extract tokens from the question
64 # extract tokens from the question
65 # eg. hello "bye bye" => ["hello", "bye bye"]
65 # eg. hello "bye bye" => ["hello", "bye bye"]
66 @tokens = @question.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')}
66 @tokens = @question.scan(%r{((\s|^)"[\s\w]+"(\s|$)|\S+)}).collect {|m| m.first.gsub(%r{(^\s*"\s*|\s*"\s*$)}, '')}
67 # tokens must be at least 3 character long
67 # tokens must be at least 3 character long
68 @tokens = @tokens.uniq.select {|w| w.length > 2 }
68 @tokens = @tokens.uniq.select {|w| w.length > 2 }
69
69
70 if !@tokens.empty?
70 if !@tokens.empty?
71 # no more than 5 tokens to search for
71 # no more than 5 tokens to search for
72 @tokens.slice! 5..-1 if @tokens.size > 5
72 @tokens.slice! 5..-1 if @tokens.size > 5
73 # strings used in sql like statement
73 # strings used in sql like statement
74 like_tokens = @tokens.collect {|w| "%#{w.downcase}%"}
74 like_tokens = @tokens.collect {|w| "%#{w.downcase}%"}
75
75 @results = []
76 @results = []
77 @results_by_type = Hash.new {|h,k| h[k] = 0}
78
76 limit = 10
79 limit = 10
77 @scope.each do |s|
80 @scope.each do |s|
78 @results += s.singularize.camelcase.constantize.search(like_tokens, projects_to_search,
81 r, c = s.singularize.camelcase.constantize.search(like_tokens, projects_to_search,
79 :all_words => @all_words,
82 :all_words => @all_words,
80 :titles_only => @titles_only,
83 :titles_only => @titles_only,
81 :limit => (limit+1),
84 :limit => (limit+1),
82 :offset => offset,
85 :offset => offset,
83 :before => params[:previous].nil?)
86 :before => params[:previous].nil?)
87 @results += r
88 @results_by_type[s] += c
84 end
89 end
85 @results = @results.sort {|a,b| b.event_datetime <=> a.event_datetime}
90 @results = @results.sort {|a,b| b.event_datetime <=> a.event_datetime}
86 if params[:previous].nil?
91 if params[:previous].nil?
87 @pagination_previous_date = @results[0].event_datetime if offset && @results[0]
92 @pagination_previous_date = @results[0].event_datetime if offset && @results[0]
88 if @results.size > limit
93 if @results.size > limit
89 @pagination_next_date = @results[limit-1].event_datetime
94 @pagination_next_date = @results[limit-1].event_datetime
90 @results = @results[0, limit]
95 @results = @results[0, limit]
91 end
96 end
92 else
97 else
93 @pagination_next_date = @results[-1].event_datetime if offset && @results[-1]
98 @pagination_next_date = @results[-1].event_datetime if offset && @results[-1]
94 if @results.size > limit
99 if @results.size > limit
95 @pagination_previous_date = @results[-(limit)].event_datetime
100 @pagination_previous_date = @results[-(limit)].event_datetime
96 @results = @results[-(limit), limit]
101 @results = @results[-(limit), limit]
97 end
102 end
98 end
103 end
99 else
104 else
100 @question = ""
105 @question = ""
101 end
106 end
102 render :layout => false if request.xhr?
107 render :layout => false if request.xhr?
103 end
108 end
104
109
105 private
110 private
106 def find_optional_project
111 def find_optional_project
107 return true unless params[:id]
112 return true unless params[:id]
108 @project = Project.find(params[:id])
113 @project = Project.find(params[:id])
109 check_project_privacy
114 check_project_privacy
110 rescue ActiveRecord::RecordNotFound
115 rescue ActiveRecord::RecordNotFound
111 render_404
116 render_404
112 end
117 end
113 end
118 end
@@ -1,46 +1,62
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module SearchHelper
18 module SearchHelper
19 def highlight_tokens(text, tokens)
19 def highlight_tokens(text, tokens)
20 return text unless text && tokens && !tokens.empty?
20 return text unless text && tokens && !tokens.empty?
21 regexp = Regexp.new "(#{tokens.join('|')})", Regexp::IGNORECASE
21 regexp = Regexp.new "(#{tokens.join('|')})", Regexp::IGNORECASE
22 result = ''
22 result = ''
23 text.split(regexp).each_with_index do |words, i|
23 text.split(regexp).each_with_index do |words, i|
24 if result.length > 1200
24 if result.length > 1200
25 # maximum length of the preview reached
25 # maximum length of the preview reached
26 result << '...'
26 result << '...'
27 break
27 break
28 end
28 end
29 if i.even?
29 if i.even?
30 result << h(words.length > 100 ? "#{words[0..44]} ... #{words[-45..-1]}" : words)
30 result << h(words.length > 100 ? "#{words[0..44]} ... #{words[-45..-1]}" : words)
31 else
31 else
32 t = (tokens.index(words.downcase) || 0) % 4
32 t = (tokens.index(words.downcase) || 0) % 4
33 result << content_tag('span', h(words), :class => "highlight token-#{t}")
33 result << content_tag('span', h(words), :class => "highlight token-#{t}")
34 end
34 end
35 end
35 end
36 result
36 result
37 end
37 end
38
38
39 def type_label(t)
40 l("label_#{t.singularize}_plural")
41 end
42
39 def project_select_tag
43 def project_select_tag
40 options = [[l(:label_project_all), 'all']]
44 options = [[l(:label_project_all), 'all']]
41 options << [l(:label_my_projects), 'my_projects'] unless User.current.memberships.empty?
45 options << [l(:label_my_projects), 'my_projects'] unless User.current.memberships.empty?
42 options << [l(:label_and_its_subprojects, @project.name), 'subprojects'] unless @project.nil? || @project.active_children.empty?
46 options << [l(:label_and_its_subprojects, @project.name), 'subprojects'] unless @project.nil? || @project.active_children.empty?
43 options << [@project.name, ''] unless @project.nil?
47 options << [@project.name, ''] unless @project.nil?
44 select_tag('scope', options_for_select(options, params[:scope].to_s)) if options.size > 1
48 select_tag('scope', options_for_select(options, params[:scope].to_s)) if options.size > 1
45 end
49 end
50
51 def render_results_by_type(results_by_type)
52 links = []
53 # Sorts types by results count
54 results_by_type.keys.sort {|a, b| results_by_type[b] <=> results_by_type[a]}.each do |t|
55 c = results_by_type[t]
56 next if c == 0
57 text = "#{type_label(t)} (#{c})"
58 links << link_to(text, :q => params[:q], :titles_only => params[:title_only], :all_words => params[:all_words], :scope => params[:scope], t => 1)
59 end
60 ('<ul>' + links.map {|link| content_tag('li', link)}.join(' ') + '</ul>') unless links.empty?
61 end
46 end
62 end
@@ -1,254 +1,257
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Issue < ActiveRecord::Base
18 class Issue < ActiveRecord::Base
19 belongs_to :project
19 belongs_to :project
20 belongs_to :tracker
20 belongs_to :tracker
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
21 belongs_to :status, :class_name => 'IssueStatus', :foreign_key => 'status_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
22 belongs_to :author, :class_name => 'User', :foreign_key => 'author_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
23 belongs_to :assigned_to, :class_name => 'User', :foreign_key => 'assigned_to_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
24 belongs_to :fixed_version, :class_name => 'Version', :foreign_key => 'fixed_version_id'
25 belongs_to :priority, :class_name => 'Enumeration', :foreign_key => 'priority_id'
25 belongs_to :priority, :class_name => 'Enumeration', :foreign_key => 'priority_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
26 belongs_to :category, :class_name => 'IssueCategory', :foreign_key => 'category_id'
27
27
28 has_many :journals, :as => :journalized, :dependent => :destroy
28 has_many :journals, :as => :journalized, :dependent => :destroy
29 has_many :attachments, :as => :container, :dependent => :destroy
29 has_many :attachments, :as => :container, :dependent => :destroy
30 has_many :time_entries, :dependent => :delete_all
30 has_many :time_entries, :dependent => :delete_all
31 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
31 has_and_belongs_to_many :changesets, :order => "#{Changeset.table_name}.committed_on ASC, #{Changeset.table_name}.id ASC"
32
32
33 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
33 has_many :relations_from, :class_name => 'IssueRelation', :foreign_key => 'issue_from_id', :dependent => :delete_all
34 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
34 has_many :relations_to, :class_name => 'IssueRelation', :foreign_key => 'issue_to_id', :dependent => :delete_all
35
35
36 acts_as_customizable
36 acts_as_customizable
37 acts_as_watchable
37 acts_as_watchable
38 acts_as_searchable :columns => ['subject', "#{table_name}.description"], :include => :project, :with => {:journal => :issue}
38 acts_as_searchable :columns => ['subject', "#{table_name}.description", "#{Journal.table_name}.notes"],
39 :include => [:project, :journals],
40 # sort by id so that limited eager loading doesn't break with postgresql
41 :order_column => "#{table_name}.id"
39 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id}: #{o.subject}"},
42 acts_as_event :title => Proc.new {|o| "#{o.tracker.name} ##{o.id}: #{o.subject}"},
40 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}}
43 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.id}}
41
44
42 validates_presence_of :subject, :description, :priority, :project, :tracker, :author, :status
45 validates_presence_of :subject, :description, :priority, :project, :tracker, :author, :status
43 validates_length_of :subject, :maximum => 255
46 validates_length_of :subject, :maximum => 255
44 validates_inclusion_of :done_ratio, :in => 0..100
47 validates_inclusion_of :done_ratio, :in => 0..100
45 validates_numericality_of :estimated_hours, :allow_nil => true
48 validates_numericality_of :estimated_hours, :allow_nil => true
46
49
47 def after_initialize
50 def after_initialize
48 if new_record?
51 if new_record?
49 # set default values for new records only
52 # set default values for new records only
50 self.status ||= IssueStatus.default
53 self.status ||= IssueStatus.default
51 self.priority ||= Enumeration.default('IPRI')
54 self.priority ||= Enumeration.default('IPRI')
52 end
55 end
53 end
56 end
54
57
55 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
58 # Overrides Redmine::Acts::Customizable::InstanceMethods#available_custom_fields
56 def available_custom_fields
59 def available_custom_fields
57 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
60 (project && tracker) ? project.all_issue_custom_fields.select {|c| tracker.custom_fields.include? c } : []
58 end
61 end
59
62
60 def copy_from(arg)
63 def copy_from(arg)
61 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
64 issue = arg.is_a?(Issue) ? arg : Issue.find(arg)
62 self.attributes = issue.attributes.dup
65 self.attributes = issue.attributes.dup
63 self.custom_values = issue.custom_values.collect {|v| v.clone}
66 self.custom_values = issue.custom_values.collect {|v| v.clone}
64 self
67 self
65 end
68 end
66
69
67 # Move an issue to a new project and tracker
70 # Move an issue to a new project and tracker
68 def move_to(new_project, new_tracker = nil)
71 def move_to(new_project, new_tracker = nil)
69 transaction do
72 transaction do
70 if new_project && project_id != new_project.id
73 if new_project && project_id != new_project.id
71 # delete issue relations
74 # delete issue relations
72 unless Setting.cross_project_issue_relations?
75 unless Setting.cross_project_issue_relations?
73 self.relations_from.clear
76 self.relations_from.clear
74 self.relations_to.clear
77 self.relations_to.clear
75 end
78 end
76 # issue is moved to another project
79 # issue is moved to another project
77 self.category = nil
80 self.category = nil
78 self.fixed_version = nil
81 self.fixed_version = nil
79 self.project = new_project
82 self.project = new_project
80 end
83 end
81 if new_tracker
84 if new_tracker
82 self.tracker = new_tracker
85 self.tracker = new_tracker
83 end
86 end
84 if save
87 if save
85 # Manually update project_id on related time entries
88 # Manually update project_id on related time entries
86 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
89 TimeEntry.update_all("project_id = #{new_project.id}", {:issue_id => id})
87 else
90 else
88 rollback_db_transaction
91 rollback_db_transaction
89 return false
92 return false
90 end
93 end
91 end
94 end
92 return true
95 return true
93 end
96 end
94
97
95 def priority_id=(pid)
98 def priority_id=(pid)
96 self.priority = nil
99 self.priority = nil
97 write_attribute(:priority_id, pid)
100 write_attribute(:priority_id, pid)
98 end
101 end
99
102
100 def estimated_hours=(h)
103 def estimated_hours=(h)
101 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
104 write_attribute :estimated_hours, (h.is_a?(String) ? h.to_hours : h)
102 end
105 end
103
106
104 def validate
107 def validate
105 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
108 if self.due_date.nil? && @attributes['due_date'] && !@attributes['due_date'].empty?
106 errors.add :due_date, :activerecord_error_not_a_date
109 errors.add :due_date, :activerecord_error_not_a_date
107 end
110 end
108
111
109 if self.due_date and self.start_date and self.due_date < self.start_date
112 if self.due_date and self.start_date and self.due_date < self.start_date
110 errors.add :due_date, :activerecord_error_greater_than_start_date
113 errors.add :due_date, :activerecord_error_greater_than_start_date
111 end
114 end
112
115
113 if start_date && soonest_start && start_date < soonest_start
116 if start_date && soonest_start && start_date < soonest_start
114 errors.add :start_date, :activerecord_error_invalid
117 errors.add :start_date, :activerecord_error_invalid
115 end
118 end
116 end
119 end
117
120
118 def validate_on_create
121 def validate_on_create
119 errors.add :tracker_id, :activerecord_error_invalid unless project.trackers.include?(tracker)
122 errors.add :tracker_id, :activerecord_error_invalid unless project.trackers.include?(tracker)
120 end
123 end
121
124
122 def before_create
125 def before_create
123 # default assignment based on category
126 # default assignment based on category
124 if assigned_to.nil? && category && category.assigned_to
127 if assigned_to.nil? && category && category.assigned_to
125 self.assigned_to = category.assigned_to
128 self.assigned_to = category.assigned_to
126 end
129 end
127 end
130 end
128
131
129 def before_save
132 def before_save
130 if @current_journal
133 if @current_journal
131 # attributes changes
134 # attributes changes
132 (Issue.column_names - %w(id description)).each {|c|
135 (Issue.column_names - %w(id description)).each {|c|
133 @current_journal.details << JournalDetail.new(:property => 'attr',
136 @current_journal.details << JournalDetail.new(:property => 'attr',
134 :prop_key => c,
137 :prop_key => c,
135 :old_value => @issue_before_change.send(c),
138 :old_value => @issue_before_change.send(c),
136 :value => send(c)) unless send(c)==@issue_before_change.send(c)
139 :value => send(c)) unless send(c)==@issue_before_change.send(c)
137 }
140 }
138 # custom fields changes
141 # custom fields changes
139 custom_values.each {|c|
142 custom_values.each {|c|
140 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
143 next if (@custom_values_before_change[c.custom_field_id]==c.value ||
141 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
144 (@custom_values_before_change[c.custom_field_id].blank? && c.value.blank?))
142 @current_journal.details << JournalDetail.new(:property => 'cf',
145 @current_journal.details << JournalDetail.new(:property => 'cf',
143 :prop_key => c.custom_field_id,
146 :prop_key => c.custom_field_id,
144 :old_value => @custom_values_before_change[c.custom_field_id],
147 :old_value => @custom_values_before_change[c.custom_field_id],
145 :value => c.value)
148 :value => c.value)
146 }
149 }
147 @current_journal.save
150 @current_journal.save
148 end
151 end
149 # Save the issue even if the journal is not saved (because empty)
152 # Save the issue even if the journal is not saved (because empty)
150 true
153 true
151 end
154 end
152
155
153 def after_save
156 def after_save
154 # Reload is needed in order to get the right status
157 # Reload is needed in order to get the right status
155 reload
158 reload
156
159
157 # Update start/due dates of following issues
160 # Update start/due dates of following issues
158 relations_from.each(&:set_issue_to_dates)
161 relations_from.each(&:set_issue_to_dates)
159
162
160 # Close duplicates if the issue was closed
163 # Close duplicates if the issue was closed
161 if @issue_before_change && !@issue_before_change.closed? && self.closed?
164 if @issue_before_change && !@issue_before_change.closed? && self.closed?
162 duplicates.each do |duplicate|
165 duplicates.each do |duplicate|
163 # Reload is need in case the duplicate was updated by a previous duplicate
166 # Reload is need in case the duplicate was updated by a previous duplicate
164 duplicate.reload
167 duplicate.reload
165 # Don't re-close it if it's already closed
168 # Don't re-close it if it's already closed
166 next if duplicate.closed?
169 next if duplicate.closed?
167 # Same user and notes
170 # Same user and notes
168 duplicate.init_journal(@current_journal.user, @current_journal.notes)
171 duplicate.init_journal(@current_journal.user, @current_journal.notes)
169 duplicate.update_attribute :status, self.status
172 duplicate.update_attribute :status, self.status
170 end
173 end
171 end
174 end
172 end
175 end
173
176
174 def init_journal(user, notes = "")
177 def init_journal(user, notes = "")
175 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
178 @current_journal ||= Journal.new(:journalized => self, :user => user, :notes => notes)
176 @issue_before_change = self.clone
179 @issue_before_change = self.clone
177 @issue_before_change.status = self.status
180 @issue_before_change.status = self.status
178 @custom_values_before_change = {}
181 @custom_values_before_change = {}
179 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
182 self.custom_values.each {|c| @custom_values_before_change.store c.custom_field_id, c.value }
180 @current_journal
183 @current_journal
181 end
184 end
182
185
183 # Return true if the issue is closed, otherwise false
186 # Return true if the issue is closed, otherwise false
184 def closed?
187 def closed?
185 self.status.is_closed?
188 self.status.is_closed?
186 end
189 end
187
190
188 # Users the issue can be assigned to
191 # Users the issue can be assigned to
189 def assignable_users
192 def assignable_users
190 project.assignable_users
193 project.assignable_users
191 end
194 end
192
195
193 # Returns an array of status that user is able to apply
196 # Returns an array of status that user is able to apply
194 def new_statuses_allowed_to(user)
197 def new_statuses_allowed_to(user)
195 statuses = status.find_new_statuses_allowed_to(user.role_for_project(project), tracker)
198 statuses = status.find_new_statuses_allowed_to(user.role_for_project(project), tracker)
196 statuses << status unless statuses.empty?
199 statuses << status unless statuses.empty?
197 statuses.uniq.sort
200 statuses.uniq.sort
198 end
201 end
199
202
200 # Returns the mail adresses of users that should be notified for the issue
203 # Returns the mail adresses of users that should be notified for the issue
201 def recipients
204 def recipients
202 recipients = project.recipients
205 recipients = project.recipients
203 # Author and assignee are always notified unless they have been locked
206 # Author and assignee are always notified unless they have been locked
204 recipients << author.mail if author && author.active?
207 recipients << author.mail if author && author.active?
205 recipients << assigned_to.mail if assigned_to && assigned_to.active?
208 recipients << assigned_to.mail if assigned_to && assigned_to.active?
206 recipients.compact.uniq
209 recipients.compact.uniq
207 end
210 end
208
211
209 def spent_hours
212 def spent_hours
210 @spent_hours ||= time_entries.sum(:hours) || 0
213 @spent_hours ||= time_entries.sum(:hours) || 0
211 end
214 end
212
215
213 def relations
216 def relations
214 (relations_from + relations_to).sort
217 (relations_from + relations_to).sort
215 end
218 end
216
219
217 def all_dependent_issues
220 def all_dependent_issues
218 dependencies = []
221 dependencies = []
219 relations_from.each do |relation|
222 relations_from.each do |relation|
220 dependencies << relation.issue_to
223 dependencies << relation.issue_to
221 dependencies += relation.issue_to.all_dependent_issues
224 dependencies += relation.issue_to.all_dependent_issues
222 end
225 end
223 dependencies
226 dependencies
224 end
227 end
225
228
226 # Returns an array of issues that duplicate this one
229 # Returns an array of issues that duplicate this one
227 def duplicates
230 def duplicates
228 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
231 relations_to.select {|r| r.relation_type == IssueRelation::TYPE_DUPLICATES}.collect {|r| r.issue_from}
229 end
232 end
230
233
231 # Returns the due date or the target due date if any
234 # Returns the due date or the target due date if any
232 # Used on gantt chart
235 # Used on gantt chart
233 def due_before
236 def due_before
234 due_date || (fixed_version ? fixed_version.effective_date : nil)
237 due_date || (fixed_version ? fixed_version.effective_date : nil)
235 end
238 end
236
239
237 def duration
240 def duration
238 (start_date && due_date) ? due_date - start_date : 0
241 (start_date && due_date) ? due_date - start_date : 0
239 end
242 end
240
243
241 def soonest_start
244 def soonest_start
242 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
245 @soonest_start ||= relations_to.collect{|relation| relation.successor_soonest_start}.compact.min
243 end
246 end
244
247
245 def self.visible_by(usr)
248 def self.visible_by(usr)
246 with_scope(:find => { :conditions => Project.visible_by(usr) }) do
249 with_scope(:find => { :conditions => Project.visible_by(usr) }) do
247 yield
250 yield
248 end
251 end
249 end
252 end
250
253
251 def to_s
254 def to_s
252 "#{tracker} ##{id}: #{subject}"
255 "#{tracker} ##{id}: #{subject}"
253 end
256 end
254 end
257 end
@@ -1,67 +1,61
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006 Jean-Philippe Lang
2 # Copyright (C) 2006 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 class Journal < ActiveRecord::Base
18 class Journal < ActiveRecord::Base
19 belongs_to :journalized, :polymorphic => true
19 belongs_to :journalized, :polymorphic => true
20 # added as a quick fix to allow eager loading of the polymorphic association
20 # added as a quick fix to allow eager loading of the polymorphic association
21 # since always associated to an issue, for now
21 # since always associated to an issue, for now
22 belongs_to :issue, :foreign_key => :journalized_id
22 belongs_to :issue, :foreign_key => :journalized_id
23
23
24 belongs_to :user
24 belongs_to :user
25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
25 has_many :details, :class_name => "JournalDetail", :dependent => :delete_all
26 attr_accessor :indice
26 attr_accessor :indice
27
27
28 acts_as_searchable :columns => 'notes',
29 :include => {:issue => :project},
30 :project_key => "#{Issue.table_name}.project_id",
31 :date_column => "#{Issue.table_name}.created_on",
32 :permission => :view_issues
33
34 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
28 acts_as_event :title => Proc.new {|o| status = ((s = o.new_status) ? " (#{s})" : nil); "#{o.issue.tracker} ##{o.issue.id}#{status}: #{o.issue.subject}" },
35 :description => :notes,
29 :description => :notes,
36 :author => :user,
30 :author => :user,
37 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
31 :type => Proc.new {|o| (s = o.new_status) ? (s.is_closed? ? 'issue-closed' : 'issue-edit') : 'issue-note' },
38 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
32 :url => Proc.new {|o| {:controller => 'issues', :action => 'show', :id => o.issue.id, :anchor => "change-#{o.id}"}}
39
33
40 def save
34 def save
41 # Do not save an empty journal
35 # Do not save an empty journal
42 (details.empty? && notes.blank?) ? false : super
36 (details.empty? && notes.blank?) ? false : super
43 end
37 end
44
38
45 # Returns the new status if the journal contains a status change, otherwise nil
39 # Returns the new status if the journal contains a status change, otherwise nil
46 def new_status
40 def new_status
47 c = details.detect {|detail| detail.prop_key == 'status_id'}
41 c = details.detect {|detail| detail.prop_key == 'status_id'}
48 (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
42 (c && c.value) ? IssueStatus.find_by_id(c.value.to_i) : nil
49 end
43 end
50
44
51 def new_value_for(prop)
45 def new_value_for(prop)
52 c = details.detect {|detail| detail.prop_key == prop}
46 c = details.detect {|detail| detail.prop_key == prop}
53 c ? c.value : nil
47 c ? c.value : nil
54 end
48 end
55
49
56 def editable_by?(usr)
50 def editable_by?(usr)
57 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
51 usr && usr.logged? && (usr.allowed_to?(:edit_issue_notes, project) || (self.user == usr && usr.allowed_to?(:edit_own_issue_notes, project)))
58 end
52 end
59
53
60 def project
54 def project
61 journalized.respond_to?(:project) ? journalized.project : nil
55 journalized.respond_to?(:project) ? journalized.project : nil
62 end
56 end
63
57
64 def attachments
58 def attachments
65 journalized.respond_to?(:attachments) ? journalized.attachments : nil
59 journalized.respond_to?(:attachments) ? journalized.attachments : nil
66 end
60 end
67 end
61 end
@@ -1,47 +1,51
1 <h2><%= l(:label_search) %></h2>
1 <h2><%= l(:label_search) %></h2>
2
2
3 <div class="box">
3 <div class="box">
4 <% form_tag({}, :method => :get) do %>
4 <% form_tag({}, :method => :get) do %>
5 <p><%= text_field_tag 'q', @question, :size => 60, :id => 'search-input' %>
5 <p><%= text_field_tag 'q', @question, :size => 60, :id => 'search-input' %>
6 <%= javascript_tag "Field.focus('search-input')" %>
6 <%= javascript_tag "Field.focus('search-input')" %>
7 <%= project_select_tag %>
7 <%= project_select_tag %>
8 <label><%= check_box_tag 'all_words', 1, @all_words %> <%= l(:label_all_words) %></label>
8 <label><%= check_box_tag 'all_words', 1, @all_words %> <%= l(:label_all_words) %></label>
9 <label><%= check_box_tag 'titles_only', 1, @titles_only %> <%= l(:label_search_titles_only) %></label>
9 <label><%= check_box_tag 'titles_only', 1, @titles_only %> <%= l(:label_search_titles_only) %></label>
10 </p>
10 </p>
11 <p>
11 <p>
12 <% @object_types.each do |t| %>
12 <% @object_types.each do |t| %>
13 <label><%= check_box_tag t, 1, @scope.include?(t) %> <%= l("label_#{t.singularize}_plural")%></label>
13 <label><%= check_box_tag t, 1, @scope.include?(t) %> <%= type_label(t) %></label>
14 <% end %>
14 <% end %>
15 </p>
15 </p>
16
16
17 <p><%= submit_tag l(:button_submit), :name => 'submit' %></p>
17 <p><%= submit_tag l(:button_submit), :name => 'submit' %></p>
18 <% end %>
18 <% end %>
19 </div>
19 </div>
20
20
21 <% if @results %>
21 <% if @results %>
22 <h3><%= l(:label_result_plural) %></h3>
22 <div id="search-results-counts">
23 <%= render_results_by_type(@results_by_type) unless @scope.size == 1 %>
24 </div>
25
26 <h3><%= l(:label_result_plural) %> (<%= @results_by_type.values.sum %>)</h3>
23 <dl id="search-results">
27 <dl id="search-results">
24 <% @results.each do |e| %>
28 <% @results.each do |e| %>
25 <dt class="<%= e.event_type %>"><%= content_tag('span', h(e.project), :class => 'project') unless @project == e.project %> <%= link_to highlight_tokens(truncate(e.event_title, 255), @tokens), e.event_url %></dt>
29 <dt class="<%= e.event_type %>"><%= content_tag('span', h(e.project), :class => 'project') unless @project == e.project %> <%= link_to highlight_tokens(truncate(e.event_title, 255), @tokens), e.event_url %></dt>
26 <dd><span class="description"><%= highlight_tokens(e.event_description, @tokens) %></span>
30 <dd><span class="description"><%= highlight_tokens(e.event_description, @tokens) %></span>
27 <span class="author"><%= format_time(e.event_datetime) %></span><dd>
31 <span class="author"><%= format_time(e.event_datetime) %></span></dd>
28 <% end %>
32 <% end %>
29 </dl>
33 </dl>
30 <% end %>
34 <% end %>
31
35
32 <p><center>
36 <p><center>
33 <% if @pagination_previous_date %>
37 <% if @pagination_previous_date %>
34 <%= link_to_remote ('&#171; ' + l(:label_previous)),
38 <%= link_to_remote ('&#171; ' + l(:label_previous)),
35 {:update => :content,
39 {:update => :content,
36 :url => params.merge(:previous => 1, :offset => @pagination_previous_date.strftime("%Y%m%d%H%M%S"))
40 :url => params.merge(:previous => 1, :offset => @pagination_previous_date.strftime("%Y%m%d%H%M%S"))
37 }, :href => url_for(params.merge(:previous => 1, :offset => @pagination_previous_date.strftime("%Y%m%d%H%M%S"))) %>&nbsp;
41 }, :href => url_for(params.merge(:previous => 1, :offset => @pagination_previous_date.strftime("%Y%m%d%H%M%S"))) %>&nbsp;
38 <% end %>
42 <% end %>
39 <% if @pagination_next_date %>
43 <% if @pagination_next_date %>
40 <%= link_to_remote (l(:label_next) + ' &#187;'),
44 <%= link_to_remote (l(:label_next) + ' &#187;'),
41 {:update => :content,
45 {:update => :content,
42 :url => params.merge(:previous => nil, :offset => @pagination_next_date.strftime("%Y%m%d%H%M%S"))
46 :url => params.merge(:previous => nil, :offset => @pagination_next_date.strftime("%Y%m%d%H%M%S"))
43 }, :href => url_for(params.merge(:previous => nil, :offset => @pagination_next_date.strftime("%Y%m%d%H%M%S"))) %>
47 }, :href => url_for(params.merge(:previous => nil, :offset => @pagination_next_date.strftime("%Y%m%d%H%M%S"))) %>
44 <% end %>
48 <% end %>
45 </center></p>
49 </center></p>
46
50
47 <% html_title(l(:label_search)) -%>
51 <% html_title(l(:label_search)) -%>
@@ -1,614 +1,618
1 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
1 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
2
2
3 h1, h2, h3, h4 { font-family: "Trebuchet MS", Verdana, sans-serif;}
3 h1, h2, h3, h4 { font-family: "Trebuchet MS", Verdana, sans-serif;}
4 h1 {margin:0; padding:0; font-size: 24px;}
4 h1 {margin:0; padding:0; font-size: 24px;}
5 h2, .wiki h1 {font-size: 20px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
5 h2, .wiki h1 {font-size: 20px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
6 h3, .wiki h2 {font-size: 16px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
6 h3, .wiki h2 {font-size: 16px;padding: 2px 10px 1px 0px;margin: 0 0 10px 0; border-bottom: 1px solid #bbbbbb; color: #444;}
7 h4, .wiki h3 {font-size: 13px;padding: 2px 10px 1px 0px;margin-bottom: 5px; border-bottom: 1px dotted #bbbbbb; color: #444;}
7 h4, .wiki h3 {font-size: 13px;padding: 2px 10px 1px 0px;margin-bottom: 5px; border-bottom: 1px dotted #bbbbbb; color: #444;}
8
8
9 /***** Layout *****/
9 /***** Layout *****/
10 #wrapper {background: white;}
10 #wrapper {background: white;}
11
11
12 #top-menu {background: #2C4056; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
12 #top-menu {background: #2C4056; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
13 #top-menu ul {margin: 0; padding: 0;}
13 #top-menu ul {margin: 0; padding: 0;}
14 #top-menu li {
14 #top-menu li {
15 float:left;
15 float:left;
16 list-style-type:none;
16 list-style-type:none;
17 margin: 0px 0px 0px 0px;
17 margin: 0px 0px 0px 0px;
18 padding: 0px 0px 0px 0px;
18 padding: 0px 0px 0px 0px;
19 white-space:nowrap;
19 white-space:nowrap;
20 }
20 }
21 #top-menu a {color: #fff; padding-right: 8px; font-weight: bold;}
21 #top-menu a {color: #fff; padding-right: 8px; font-weight: bold;}
22 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
22 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
23
23
24 #account {float:right;}
24 #account {float:right;}
25
25
26 #header {height:5.3em;margin:0;background-color:#507AAA;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
26 #header {height:5.3em;margin:0;background-color:#507AAA;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
27 #header a {color:#f8f8f8;}
27 #header a {color:#f8f8f8;}
28 #quick-search {float:right;}
28 #quick-search {float:right;}
29
29
30 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
30 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
31 #main-menu ul {margin: 0; padding: 0;}
31 #main-menu ul {margin: 0; padding: 0;}
32 #main-menu li {
32 #main-menu li {
33 float:left;
33 float:left;
34 list-style-type:none;
34 list-style-type:none;
35 margin: 0px 2px 0px 0px;
35 margin: 0px 2px 0px 0px;
36 padding: 0px 0px 0px 0px;
36 padding: 0px 0px 0px 0px;
37 white-space:nowrap;
37 white-space:nowrap;
38 }
38 }
39 #main-menu li a {
39 #main-menu li a {
40 display: block;
40 display: block;
41 color: #fff;
41 color: #fff;
42 text-decoration: none;
42 text-decoration: none;
43 font-weight: bold;
43 font-weight: bold;
44 margin: 0;
44 margin: 0;
45 padding: 4px 10px 4px 10px;
45 padding: 4px 10px 4px 10px;
46 }
46 }
47 #main-menu li a:hover {background:#759FCF; color:#fff;}
47 #main-menu li a:hover {background:#759FCF; color:#fff;}
48 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
48 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
49
49
50 #main {background-color:#EEEEEE;}
50 #main {background-color:#EEEEEE;}
51
51
52 #sidebar{ float: right; width: 17%; position: relative; z-index: 9; min-height: 600px; padding: 0; margin: 0;}
52 #sidebar{ float: right; width: 17%; position: relative; z-index: 9; min-height: 600px; padding: 0; margin: 0;}
53 * html #sidebar{ width: 17%; }
53 * html #sidebar{ width: 17%; }
54 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
54 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
55 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
55 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
56 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
56 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
57
57
58 #content { width: 80%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; height:600px; min-height: 600px;}
58 #content { width: 80%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; height:600px; min-height: 600px;}
59 * html #content{ width: 80%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
59 * html #content{ width: 80%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
60 html>body #content { height: auto; min-height: 600px; overflow: auto; }
60 html>body #content { height: auto; min-height: 600px; overflow: auto; }
61
61
62 #main.nosidebar #sidebar{ display: none; }
62 #main.nosidebar #sidebar{ display: none; }
63 #main.nosidebar #content{ width: auto; border-right: 0; }
63 #main.nosidebar #content{ width: auto; border-right: 0; }
64
64
65 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
65 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
66
66
67 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
67 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
68 #login-form table td {padding: 6px;}
68 #login-form table td {padding: 6px;}
69 #login-form label {font-weight: bold;}
69 #login-form label {font-weight: bold;}
70
70
71 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
71 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
72
72
73 /***** Links *****/
73 /***** Links *****/
74 a, a:link, a:visited{ color: #2A5685; text-decoration: none; }
74 a, a:link, a:visited{ color: #2A5685; text-decoration: none; }
75 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
75 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
76 a img{ border: 0; }
76 a img{ border: 0; }
77
77
78 a.issue.closed { text-decoration: line-through; }
78 a.issue.closed { text-decoration: line-through; }
79
79
80 /***** Tables *****/
80 /***** Tables *****/
81 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
81 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
82 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
82 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
83 table.list td { vertical-align: top; }
83 table.list td { vertical-align: top; }
84 table.list td.id { width: 2%; text-align: center;}
84 table.list td.id { width: 2%; text-align: center;}
85 table.list td.checkbox { width: 15px; padding: 0px;}
85 table.list td.checkbox { width: 15px; padding: 0px;}
86
86
87 table.list.issues { margin-top: 10px; }
87 table.list.issues { margin-top: 10px; }
88 tr.issue { text-align: center; white-space: nowrap; }
88 tr.issue { text-align: center; white-space: nowrap; }
89 tr.issue td.subject, tr.issue td.category { white-space: normal; }
89 tr.issue td.subject, tr.issue td.category { white-space: normal; }
90 tr.issue td.subject { text-align: left; }
90 tr.issue td.subject { text-align: left; }
91 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
91 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
92
92
93 tr.entry { border: 1px solid #f8f8f8; }
93 tr.entry { border: 1px solid #f8f8f8; }
94 tr.entry td { white-space: nowrap; }
94 tr.entry td { white-space: nowrap; }
95 tr.entry td.filename { width: 30%; }
95 tr.entry td.filename { width: 30%; }
96 tr.entry td.size { text-align: right; font-size: 90%; }
96 tr.entry td.size { text-align: right; font-size: 90%; }
97 tr.entry td.revision, tr.entry td.author { text-align: center; }
97 tr.entry td.revision, tr.entry td.author { text-align: center; }
98 tr.entry td.age { text-align: right; }
98 tr.entry td.age { text-align: right; }
99
99
100 tr.entry span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
100 tr.entry span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
101 tr.entry.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
101 tr.entry.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
102 tr.entry.file td.filename a { margin-left: 16px; }
102 tr.entry.file td.filename a { margin-left: 16px; }
103
103
104 tr.changeset td.author { text-align: center; width: 15%; }
104 tr.changeset td.author { text-align: center; width: 15%; }
105 tr.changeset td.committed_on { text-align: center; width: 15%; }
105 tr.changeset td.committed_on { text-align: center; width: 15%; }
106
106
107 tr.message { height: 2.6em; }
107 tr.message { height: 2.6em; }
108 tr.message td.last_message { font-size: 80%; }
108 tr.message td.last_message { font-size: 80%; }
109 tr.message.locked td.subject a { background-image: url(../images/locked.png); }
109 tr.message.locked td.subject a { background-image: url(../images/locked.png); }
110 tr.message.sticky td.subject a { background-image: url(../images/sticky.png); font-weight: bold; }
110 tr.message.sticky td.subject a { background-image: url(../images/sticky.png); font-weight: bold; }
111
111
112 tr.user td { width:13%; }
112 tr.user td { width:13%; }
113 tr.user td.email { width:18%; }
113 tr.user td.email { width:18%; }
114 tr.user td { white-space: nowrap; }
114 tr.user td { white-space: nowrap; }
115 tr.user.locked, tr.user.registered { color: #aaa; }
115 tr.user.locked, tr.user.registered { color: #aaa; }
116 tr.user.locked a, tr.user.registered a { color: #aaa; }
116 tr.user.locked a, tr.user.registered a { color: #aaa; }
117
117
118 tr.time-entry { text-align: center; white-space: nowrap; }
118 tr.time-entry { text-align: center; white-space: nowrap; }
119 tr.time-entry td.subject, tr.time-entry td.comments { text-align: left; white-space: normal; }
119 tr.time-entry td.subject, tr.time-entry td.comments { text-align: left; white-space: normal; }
120 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
120 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
121 td.hours .hours-dec { font-size: 0.9em; }
121 td.hours .hours-dec { font-size: 0.9em; }
122
122
123 table.list tbody tr:hover { background-color:#ffffdd; }
123 table.list tbody tr:hover { background-color:#ffffdd; }
124 table td {padding:2px;}
124 table td {padding:2px;}
125 table p {margin:0;}
125 table p {margin:0;}
126 .odd {background-color:#f6f7f8;}
126 .odd {background-color:#f6f7f8;}
127 .even {background-color: #fff;}
127 .even {background-color: #fff;}
128
128
129 .highlight { background-color: #FCFD8D;}
129 .highlight { background-color: #FCFD8D;}
130 .highlight.token-1 { background-color: #faa;}
130 .highlight.token-1 { background-color: #faa;}
131 .highlight.token-2 { background-color: #afa;}
131 .highlight.token-2 { background-color: #afa;}
132 .highlight.token-3 { background-color: #aaf;}
132 .highlight.token-3 { background-color: #aaf;}
133
133
134 .box{
134 .box{
135 padding:6px;
135 padding:6px;
136 margin-bottom: 10px;
136 margin-bottom: 10px;
137 background-color:#f6f6f6;
137 background-color:#f6f6f6;
138 color:#505050;
138 color:#505050;
139 line-height:1.5em;
139 line-height:1.5em;
140 border: 1px solid #e4e4e4;
140 border: 1px solid #e4e4e4;
141 }
141 }
142
142
143 div.square {
143 div.square {
144 border: 1px solid #999;
144 border: 1px solid #999;
145 float: left;
145 float: left;
146 margin: .3em .4em 0 .4em;
146 margin: .3em .4em 0 .4em;
147 overflow: hidden;
147 overflow: hidden;
148 width: .6em; height: .6em;
148 width: .6em; height: .6em;
149 }
149 }
150 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
150 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
151 .contextual input {font-size:0.9em;}
151 .contextual input {font-size:0.9em;}
152
152
153 .splitcontentleft{float:left; width:49%;}
153 .splitcontentleft{float:left; width:49%;}
154 .splitcontentright{float:right; width:49%;}
154 .splitcontentright{float:right; width:49%;}
155 form {display: inline;}
155 form {display: inline;}
156 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
156 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
157 fieldset {border: 1px solid #e4e4e4; margin:0;}
157 fieldset {border: 1px solid #e4e4e4; margin:0;}
158 legend {color: #484848;}
158 legend {color: #484848;}
159 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
159 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
160 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
160 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
161 blockquote blockquote { margin-left: 0;}
161 blockquote blockquote { margin-left: 0;}
162 textarea.wiki-edit { width: 99%; }
162 textarea.wiki-edit { width: 99%; }
163 li p {margin-top: 0;}
163 li p {margin-top: 0;}
164 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
164 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
165 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
165 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
166 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
166 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
167
167
168 fieldset#filters { padding: 0.7em; }
168 fieldset#filters { padding: 0.7em; }
169 fieldset#filters p { margin: 1.2em 0 0.8em 2px; }
169 fieldset#filters p { margin: 1.2em 0 0.8em 2px; }
170 fieldset#filters .buttons { font-size: 0.9em; }
170 fieldset#filters .buttons { font-size: 0.9em; }
171 fieldset#filters table { border-collapse: collapse; }
171 fieldset#filters table { border-collapse: collapse; }
172 fieldset#filters table td { padding: 0; vertical-align: middle; }
172 fieldset#filters table td { padding: 0; vertical-align: middle; }
173 fieldset#filters tr.filter { height: 2em; }
173 fieldset#filters tr.filter { height: 2em; }
174 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
174 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
175
175
176 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
176 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
177 div#issue-changesets .changeset { padding: 4px;}
177 div#issue-changesets .changeset { padding: 4px;}
178 div#issue-changesets .changeset { border-bottom: 1px solid #ddd; }
178 div#issue-changesets .changeset { border-bottom: 1px solid #ddd; }
179 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
179 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
180
180
181 div#activity dl, #search-results { margin-left: 2em; }
181 div#activity dl, #search-results { margin-left: 2em; }
182 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
182 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
183 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
183 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
184 div#activity dt.me .time { border-bottom: 1px solid #999; }
184 div#activity dt.me .time { border-bottom: 1px solid #999; }
185 div#activity dt .time { color: #777; font-size: 80%; }
185 div#activity dt .time { color: #777; font-size: 80%; }
186 div#activity dd .description, #search-results dd .description { font-style: italic; }
186 div#activity dd .description, #search-results dd .description { font-style: italic; }
187 div#activity span.project:after, #search-results span.project:after { content: " -"; }
187 div#activity span.project:after, #search-results span.project:after { content: " -"; }
188 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px;}
189 div#activity dd span.description, #search-results dd span.description { display:block; }
188 div#activity dd span.description, #search-results dd span.description { display:block; }
190
189
190 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
191 div#search-results-counts {float:right;}
192 div#search-results-counts ul { margin-top: 0.5em; }
193 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
194
191 dt.issue { background-image: url(../images/ticket.png); }
195 dt.issue { background-image: url(../images/ticket.png); }
192 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
196 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
193 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
197 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
194 dt.issue-note { background-image: url(../images/ticket_note.png); }
198 dt.issue-note { background-image: url(../images/ticket_note.png); }
195 dt.changeset { background-image: url(../images/changeset.png); }
199 dt.changeset { background-image: url(../images/changeset.png); }
196 dt.news { background-image: url(../images/news.png); }
200 dt.news { background-image: url(../images/news.png); }
197 dt.message { background-image: url(../images/message.png); }
201 dt.message { background-image: url(../images/message.png); }
198 dt.reply { background-image: url(../images/comments.png); }
202 dt.reply { background-image: url(../images/comments.png); }
199 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
203 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
200 dt.attachment { background-image: url(../images/attachment.png); }
204 dt.attachment { background-image: url(../images/attachment.png); }
201 dt.document { background-image: url(../images/document.png); }
205 dt.document { background-image: url(../images/document.png); }
202 dt.project { background-image: url(../images/projects.png); }
206 dt.project { background-image: url(../images/projects.png); }
203
207
204 div#roadmap fieldset.related-issues { margin-bottom: 1em; }
208 div#roadmap fieldset.related-issues { margin-bottom: 1em; }
205 div#roadmap fieldset.related-issues ul { margin-top: 0.3em; margin-bottom: 0.3em; }
209 div#roadmap fieldset.related-issues ul { margin-top: 0.3em; margin-bottom: 0.3em; }
206 div#roadmap .wiki h1:first-child { display: none; }
210 div#roadmap .wiki h1:first-child { display: none; }
207 div#roadmap .wiki h1 { font-size: 120%; }
211 div#roadmap .wiki h1 { font-size: 120%; }
208 div#roadmap .wiki h2 { font-size: 110%; }
212 div#roadmap .wiki h2 { font-size: 110%; }
209
213
210 div#version-summary { float:right; width:380px; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
214 div#version-summary { float:right; width:380px; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
211 div#version-summary fieldset { margin-bottom: 1em; }
215 div#version-summary fieldset { margin-bottom: 1em; }
212 div#version-summary .total-hours { text-align: right; }
216 div#version-summary .total-hours { text-align: right; }
213
217
214 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
218 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
215 table#time-report tbody tr { font-style: italic; color: #777; }
219 table#time-report tbody tr { font-style: italic; color: #777; }
216 table#time-report tbody tr.last-level { font-style: normal; color: #555; }
220 table#time-report tbody tr.last-level { font-style: normal; color: #555; }
217 table#time-report tbody tr.total { font-style: normal; font-weight: bold; color: #555; background-color:#EEEEEE; }
221 table#time-report tbody tr.total { font-style: normal; font-weight: bold; color: #555; background-color:#EEEEEE; }
218 table#time-report .hours-dec { font-size: 0.9em; }
222 table#time-report .hours-dec { font-size: 0.9em; }
219
223
220 ul.properties {padding:0; font-size: 0.9em; color: #777;}
224 ul.properties {padding:0; font-size: 0.9em; color: #777;}
221 ul.properties li {list-style-type:none;}
225 ul.properties li {list-style-type:none;}
222 ul.properties li span {font-style:italic;}
226 ul.properties li span {font-style:italic;}
223
227
224 .total-hours { font-size: 110%; font-weight: bold; }
228 .total-hours { font-size: 110%; font-weight: bold; }
225 .total-hours span.hours-int { font-size: 120%; }
229 .total-hours span.hours-int { font-size: 120%; }
226
230
227 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
231 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
228 #user_firstname, #user_lastname, #user_mail, #my_account_form select { width: 90%; }
232 #user_firstname, #user_lastname, #user_mail, #my_account_form select { width: 90%; }
229
233
230 .pagination {font-size: 90%}
234 .pagination {font-size: 90%}
231 p.pagination {margin-top:8px;}
235 p.pagination {margin-top:8px;}
232
236
233 /***** Tabular forms ******/
237 /***** Tabular forms ******/
234 .tabular p{
238 .tabular p{
235 margin: 0;
239 margin: 0;
236 padding: 5px 0 8px 0;
240 padding: 5px 0 8px 0;
237 padding-left: 180px; /*width of left column containing the label elements*/
241 padding-left: 180px; /*width of left column containing the label elements*/
238 height: 1%;
242 height: 1%;
239 clear:left;
243 clear:left;
240 }
244 }
241
245
242 html>body .tabular p {overflow:hidden;}
246 html>body .tabular p {overflow:hidden;}
243
247
244 .tabular label{
248 .tabular label{
245 font-weight: bold;
249 font-weight: bold;
246 float: left;
250 float: left;
247 text-align: right;
251 text-align: right;
248 margin-left: -180px; /*width of left column*/
252 margin-left: -180px; /*width of left column*/
249 width: 175px; /*width of labels. Should be smaller than left column to create some right
253 width: 175px; /*width of labels. Should be smaller than left column to create some right
250 margin*/
254 margin*/
251 }
255 }
252
256
253 .tabular label.floating{
257 .tabular label.floating{
254 font-weight: normal;
258 font-weight: normal;
255 margin-left: 0px;
259 margin-left: 0px;
256 text-align: left;
260 text-align: left;
257 width: 200px;
261 width: 200px;
258 }
262 }
259
263
260 input#time_entry_comments { width: 90%;}
264 input#time_entry_comments { width: 90%;}
261
265
262 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
266 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
263
267
264 .tabular.settings p{ padding-left: 300px; }
268 .tabular.settings p{ padding-left: 300px; }
265 .tabular.settings label{ margin-left: -300px; width: 295px; }
269 .tabular.settings label{ margin-left: -300px; width: 295px; }
266
270
267 .required {color: #bb0000;}
271 .required {color: #bb0000;}
268 .summary {font-style: italic;}
272 .summary {font-style: italic;}
269
273
270 #attachments_fields input[type=text] {margin-left: 8px; }
274 #attachments_fields input[type=text] {margin-left: 8px; }
271
275
272 div.attachments p { margin:4px 0 2px 0; }
276 div.attachments p { margin:4px 0 2px 0; }
273 div.attachments img { vertical-align: middle; }
277 div.attachments img { vertical-align: middle; }
274 div.attachments span.author { font-size: 0.9em; color: #888; }
278 div.attachments span.author { font-size: 0.9em; color: #888; }
275
279
276 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
280 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
277 .other-formats span + span:before { content: "| "; }
281 .other-formats span + span:before { content: "| "; }
278
282
279 a.feed { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
283 a.feed { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
280
284
281 /***** Flash & error messages ****/
285 /***** Flash & error messages ****/
282 #errorExplanation, div.flash, .nodata, .warning {
286 #errorExplanation, div.flash, .nodata, .warning {
283 padding: 4px 4px 4px 30px;
287 padding: 4px 4px 4px 30px;
284 margin-bottom: 12px;
288 margin-bottom: 12px;
285 font-size: 1.1em;
289 font-size: 1.1em;
286 border: 2px solid;
290 border: 2px solid;
287 }
291 }
288
292
289 div.flash {margin-top: 8px;}
293 div.flash {margin-top: 8px;}
290
294
291 div.flash.error, #errorExplanation {
295 div.flash.error, #errorExplanation {
292 background: url(../images/false.png) 8px 5px no-repeat;
296 background: url(../images/false.png) 8px 5px no-repeat;
293 background-color: #ffe3e3;
297 background-color: #ffe3e3;
294 border-color: #dd0000;
298 border-color: #dd0000;
295 color: #550000;
299 color: #550000;
296 }
300 }
297
301
298 div.flash.notice {
302 div.flash.notice {
299 background: url(../images/true.png) 8px 5px no-repeat;
303 background: url(../images/true.png) 8px 5px no-repeat;
300 background-color: #dfffdf;
304 background-color: #dfffdf;
301 border-color: #9fcf9f;
305 border-color: #9fcf9f;
302 color: #005f00;
306 color: #005f00;
303 }
307 }
304
308
305 .nodata, .warning {
309 .nodata, .warning {
306 text-align: center;
310 text-align: center;
307 background-color: #FFEBC1;
311 background-color: #FFEBC1;
308 border-color: #FDBF3B;
312 border-color: #FDBF3B;
309 color: #A6750C;
313 color: #A6750C;
310 }
314 }
311
315
312 #errorExplanation ul { font-size: 0.9em;}
316 #errorExplanation ul { font-size: 0.9em;}
313
317
314 /***** Ajax indicator ******/
318 /***** Ajax indicator ******/
315 #ajax-indicator {
319 #ajax-indicator {
316 position: absolute; /* fixed not supported by IE */
320 position: absolute; /* fixed not supported by IE */
317 background-color:#eee;
321 background-color:#eee;
318 border: 1px solid #bbb;
322 border: 1px solid #bbb;
319 top:35%;
323 top:35%;
320 left:40%;
324 left:40%;
321 width:20%;
325 width:20%;
322 font-weight:bold;
326 font-weight:bold;
323 text-align:center;
327 text-align:center;
324 padding:0.6em;
328 padding:0.6em;
325 z-index:100;
329 z-index:100;
326 filter:alpha(opacity=50);
330 filter:alpha(opacity=50);
327 opacity: 0.5;
331 opacity: 0.5;
328 }
332 }
329
333
330 html>body #ajax-indicator { position: fixed; }
334 html>body #ajax-indicator { position: fixed; }
331
335
332 #ajax-indicator span {
336 #ajax-indicator span {
333 background-position: 0% 40%;
337 background-position: 0% 40%;
334 background-repeat: no-repeat;
338 background-repeat: no-repeat;
335 background-image: url(../images/loading.gif);
339 background-image: url(../images/loading.gif);
336 padding-left: 26px;
340 padding-left: 26px;
337 vertical-align: bottom;
341 vertical-align: bottom;
338 }
342 }
339
343
340 /***** Calendar *****/
344 /***** Calendar *****/
341 table.cal {border-collapse: collapse; width: 100%; margin: 8px 0 6px 0;border: 1px solid #d7d7d7;}
345 table.cal {border-collapse: collapse; width: 100%; margin: 8px 0 6px 0;border: 1px solid #d7d7d7;}
342 table.cal thead th {width: 14%;}
346 table.cal thead th {width: 14%;}
343 table.cal tbody tr {height: 100px;}
347 table.cal tbody tr {height: 100px;}
344 table.cal th { background-color:#EEEEEE; padding: 4px; }
348 table.cal th { background-color:#EEEEEE; padding: 4px; }
345 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
349 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
346 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
350 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
347 table.cal td.odd p.day-num {color: #bbb;}
351 table.cal td.odd p.day-num {color: #bbb;}
348 table.cal td.today {background:#ffffdd;}
352 table.cal td.today {background:#ffffdd;}
349 table.cal td.today p.day-num {font-weight: bold;}
353 table.cal td.today p.day-num {font-weight: bold;}
350
354
351 /***** Tooltips ******/
355 /***** Tooltips ******/
352 .tooltip{position:relative;z-index:24;}
356 .tooltip{position:relative;z-index:24;}
353 .tooltip:hover{z-index:25;color:#000;}
357 .tooltip:hover{z-index:25;color:#000;}
354 .tooltip span.tip{display: none; text-align:left;}
358 .tooltip span.tip{display: none; text-align:left;}
355
359
356 div.tooltip:hover span.tip{
360 div.tooltip:hover span.tip{
357 display:block;
361 display:block;
358 position:absolute;
362 position:absolute;
359 top:12px; left:24px; width:270px;
363 top:12px; left:24px; width:270px;
360 border:1px solid #555;
364 border:1px solid #555;
361 background-color:#fff;
365 background-color:#fff;
362 padding: 4px;
366 padding: 4px;
363 font-size: 0.8em;
367 font-size: 0.8em;
364 color:#505050;
368 color:#505050;
365 }
369 }
366
370
367 /***** Progress bar *****/
371 /***** Progress bar *****/
368 table.progress {
372 table.progress {
369 border: 1px solid #D7D7D7;
373 border: 1px solid #D7D7D7;
370 border-collapse: collapse;
374 border-collapse: collapse;
371 border-spacing: 0pt;
375 border-spacing: 0pt;
372 empty-cells: show;
376 empty-cells: show;
373 text-align: center;
377 text-align: center;
374 float:left;
378 float:left;
375 margin: 1px 6px 1px 0px;
379 margin: 1px 6px 1px 0px;
376 }
380 }
377
381
378 table.progress td { height: 0.9em; }
382 table.progress td { height: 0.9em; }
379 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
383 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
380 table.progress td.done { background: #DEF0DE none repeat scroll 0%; }
384 table.progress td.done { background: #DEF0DE none repeat scroll 0%; }
381 table.progress td.open { background: #FFF none repeat scroll 0%; }
385 table.progress td.open { background: #FFF none repeat scroll 0%; }
382 p.pourcent {font-size: 80%;}
386 p.pourcent {font-size: 80%;}
383 p.progress-info {clear: left; font-style: italic; font-size: 80%;}
387 p.progress-info {clear: left; font-style: italic; font-size: 80%;}
384
388
385 /***** Tabs *****/
389 /***** Tabs *****/
386 #content .tabs {height: 2.6em; border-bottom: 1px solid #bbbbbb; margin-bottom:1.2em; position:relative;}
390 #content .tabs {height: 2.6em; border-bottom: 1px solid #bbbbbb; margin-bottom:1.2em; position:relative;}
387 #content .tabs ul {margin:0; position:absolute; bottom:-2px; padding-left:1em;}
391 #content .tabs ul {margin:0; position:absolute; bottom:-2px; padding-left:1em;}
388 #content .tabs>ul { bottom:-1px; } /* others */
392 #content .tabs>ul { bottom:-1px; } /* others */
389 #content .tabs ul li {
393 #content .tabs ul li {
390 float:left;
394 float:left;
391 list-style-type:none;
395 list-style-type:none;
392 white-space:nowrap;
396 white-space:nowrap;
393 margin-right:8px;
397 margin-right:8px;
394 background:#fff;
398 background:#fff;
395 }
399 }
396 #content .tabs ul li a{
400 #content .tabs ul li a{
397 display:block;
401 display:block;
398 font-size: 0.9em;
402 font-size: 0.9em;
399 text-decoration:none;
403 text-decoration:none;
400 line-height:1.3em;
404 line-height:1.3em;
401 padding:4px 6px 4px 6px;
405 padding:4px 6px 4px 6px;
402 border: 1px solid #ccc;
406 border: 1px solid #ccc;
403 border-bottom: 1px solid #bbbbbb;
407 border-bottom: 1px solid #bbbbbb;
404 background-color: #eeeeee;
408 background-color: #eeeeee;
405 color:#777;
409 color:#777;
406 font-weight:bold;
410 font-weight:bold;
407 }
411 }
408
412
409 #content .tabs ul li a:hover {
413 #content .tabs ul li a:hover {
410 background-color: #ffffdd;
414 background-color: #ffffdd;
411 text-decoration:none;
415 text-decoration:none;
412 }
416 }
413
417
414 #content .tabs ul li a.selected {
418 #content .tabs ul li a.selected {
415 background-color: #fff;
419 background-color: #fff;
416 border: 1px solid #bbbbbb;
420 border: 1px solid #bbbbbb;
417 border-bottom: 1px solid #fff;
421 border-bottom: 1px solid #fff;
418 }
422 }
419
423
420 #content .tabs ul li a.selected:hover {
424 #content .tabs ul li a.selected:hover {
421 background-color: #fff;
425 background-color: #fff;
422 }
426 }
423
427
424 /***** Diff *****/
428 /***** Diff *****/
425 .diff_out { background: #fcc; }
429 .diff_out { background: #fcc; }
426 .diff_in { background: #cfc; }
430 .diff_in { background: #cfc; }
427
431
428 /***** Wiki *****/
432 /***** Wiki *****/
429 div.wiki table {
433 div.wiki table {
430 border: 1px solid #505050;
434 border: 1px solid #505050;
431 border-collapse: collapse;
435 border-collapse: collapse;
432 margin-bottom: 1em;
436 margin-bottom: 1em;
433 }
437 }
434
438
435 div.wiki table, div.wiki td, div.wiki th {
439 div.wiki table, div.wiki td, div.wiki th {
436 border: 1px solid #bbb;
440 border: 1px solid #bbb;
437 padding: 4px;
441 padding: 4px;
438 }
442 }
439
443
440 div.wiki .external {
444 div.wiki .external {
441 background-position: 0% 60%;
445 background-position: 0% 60%;
442 background-repeat: no-repeat;
446 background-repeat: no-repeat;
443 padding-left: 12px;
447 padding-left: 12px;
444 background-image: url(../images/external.png);
448 background-image: url(../images/external.png);
445 }
449 }
446
450
447 div.wiki a.new {
451 div.wiki a.new {
448 color: #b73535;
452 color: #b73535;
449 }
453 }
450
454
451 div.wiki pre {
455 div.wiki pre {
452 margin: 1em 1em 1em 1.6em;
456 margin: 1em 1em 1em 1.6em;
453 padding: 2px;
457 padding: 2px;
454 background-color: #fafafa;
458 background-color: #fafafa;
455 border: 1px solid #dadada;
459 border: 1px solid #dadada;
456 width:95%;
460 width:95%;
457 overflow-x: auto;
461 overflow-x: auto;
458 }
462 }
459
463
460 div.wiki div.toc {
464 div.wiki div.toc {
461 background-color: #ffffdd;
465 background-color: #ffffdd;
462 border: 1px solid #e4e4e4;
466 border: 1px solid #e4e4e4;
463 padding: 4px;
467 padding: 4px;
464 line-height: 1.2em;
468 line-height: 1.2em;
465 margin-bottom: 12px;
469 margin-bottom: 12px;
466 margin-right: 12px;
470 margin-right: 12px;
467 display: table
471 display: table
468 }
472 }
469 * html div.wiki div.toc { width: 50%; } /* IE6 doesn't autosize div */
473 * html div.wiki div.toc { width: 50%; } /* IE6 doesn't autosize div */
470
474
471 div.wiki div.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
475 div.wiki div.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
472 div.wiki div.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
476 div.wiki div.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
473
477
474 div.wiki div.toc a {
478 div.wiki div.toc a {
475 display: block;
479 display: block;
476 font-size: 0.9em;
480 font-size: 0.9em;
477 font-weight: normal;
481 font-weight: normal;
478 text-decoration: none;
482 text-decoration: none;
479 color: #606060;
483 color: #606060;
480 }
484 }
481 div.wiki div.toc a:hover { color: #c61a1a; text-decoration: underline;}
485 div.wiki div.toc a:hover { color: #c61a1a; text-decoration: underline;}
482
486
483 div.wiki div.toc a.heading2 { margin-left: 6px; }
487 div.wiki div.toc a.heading2 { margin-left: 6px; }
484 div.wiki div.toc a.heading3 { margin-left: 12px; font-size: 0.8em; }
488 div.wiki div.toc a.heading3 { margin-left: 12px; font-size: 0.8em; }
485
489
486 /***** My page layout *****/
490 /***** My page layout *****/
487 .block-receiver {
491 .block-receiver {
488 border:1px dashed #c0c0c0;
492 border:1px dashed #c0c0c0;
489 margin-bottom: 20px;
493 margin-bottom: 20px;
490 padding: 15px 0 15px 0;
494 padding: 15px 0 15px 0;
491 }
495 }
492
496
493 .mypage-box {
497 .mypage-box {
494 margin:0 0 20px 0;
498 margin:0 0 20px 0;
495 color:#505050;
499 color:#505050;
496 line-height:1.5em;
500 line-height:1.5em;
497 }
501 }
498
502
499 .handle {
503 .handle {
500 cursor: move;
504 cursor: move;
501 }
505 }
502
506
503 a.close-icon {
507 a.close-icon {
504 display:block;
508 display:block;
505 margin-top:3px;
509 margin-top:3px;
506 overflow:hidden;
510 overflow:hidden;
507 width:12px;
511 width:12px;
508 height:12px;
512 height:12px;
509 background-repeat: no-repeat;
513 background-repeat: no-repeat;
510 cursor:pointer;
514 cursor:pointer;
511 background-image:url('../images/close.png');
515 background-image:url('../images/close.png');
512 }
516 }
513
517
514 a.close-icon:hover {
518 a.close-icon:hover {
515 background-image:url('../images/close_hl.png');
519 background-image:url('../images/close_hl.png');
516 }
520 }
517
521
518 /***** Gantt chart *****/
522 /***** Gantt chart *****/
519 .gantt_hdr {
523 .gantt_hdr {
520 position:absolute;
524 position:absolute;
521 top:0;
525 top:0;
522 height:16px;
526 height:16px;
523 border-top: 1px solid #c0c0c0;
527 border-top: 1px solid #c0c0c0;
524 border-bottom: 1px solid #c0c0c0;
528 border-bottom: 1px solid #c0c0c0;
525 border-right: 1px solid #c0c0c0;
529 border-right: 1px solid #c0c0c0;
526 text-align: center;
530 text-align: center;
527 overflow: hidden;
531 overflow: hidden;
528 }
532 }
529
533
530 .task {
534 .task {
531 position: absolute;
535 position: absolute;
532 height:8px;
536 height:8px;
533 font-size:0.8em;
537 font-size:0.8em;
534 color:#888;
538 color:#888;
535 padding:0;
539 padding:0;
536 margin:0;
540 margin:0;
537 line-height:0.8em;
541 line-height:0.8em;
538 }
542 }
539
543
540 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
544 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
541 .task_done { background:#66f url(../images/task_done.png); border: 1px solid #66f; }
545 .task_done { background:#66f url(../images/task_done.png); border: 1px solid #66f; }
542 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
546 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
543 .milestone { background-image:url(../images/milestone.png); background-repeat: no-repeat; border: 0; }
547 .milestone { background-image:url(../images/milestone.png); background-repeat: no-repeat; border: 0; }
544
548
545 /***** Icons *****/
549 /***** Icons *****/
546 .icon {
550 .icon {
547 background-position: 0% 40%;
551 background-position: 0% 40%;
548 background-repeat: no-repeat;
552 background-repeat: no-repeat;
549 padding-left: 20px;
553 padding-left: 20px;
550 padding-top: 2px;
554 padding-top: 2px;
551 padding-bottom: 3px;
555 padding-bottom: 3px;
552 }
556 }
553
557
554 .icon22 {
558 .icon22 {
555 background-position: 0% 40%;
559 background-position: 0% 40%;
556 background-repeat: no-repeat;
560 background-repeat: no-repeat;
557 padding-left: 26px;
561 padding-left: 26px;
558 line-height: 22px;
562 line-height: 22px;
559 vertical-align: middle;
563 vertical-align: middle;
560 }
564 }
561
565
562 .icon-add { background-image: url(../images/add.png); }
566 .icon-add { background-image: url(../images/add.png); }
563 .icon-edit { background-image: url(../images/edit.png); }
567 .icon-edit { background-image: url(../images/edit.png); }
564 .icon-copy { background-image: url(../images/copy.png); }
568 .icon-copy { background-image: url(../images/copy.png); }
565 .icon-del { background-image: url(../images/delete.png); }
569 .icon-del { background-image: url(../images/delete.png); }
566 .icon-move { background-image: url(../images/move.png); }
570 .icon-move { background-image: url(../images/move.png); }
567 .icon-save { background-image: url(../images/save.png); }
571 .icon-save { background-image: url(../images/save.png); }
568 .icon-cancel { background-image: url(../images/cancel.png); }
572 .icon-cancel { background-image: url(../images/cancel.png); }
569 .icon-file { background-image: url(../images/file.png); }
573 .icon-file { background-image: url(../images/file.png); }
570 .icon-folder { background-image: url(../images/folder.png); }
574 .icon-folder { background-image: url(../images/folder.png); }
571 .open .icon-folder { background-image: url(../images/folder_open.png); }
575 .open .icon-folder { background-image: url(../images/folder_open.png); }
572 .icon-package { background-image: url(../images/package.png); }
576 .icon-package { background-image: url(../images/package.png); }
573 .icon-home { background-image: url(../images/home.png); }
577 .icon-home { background-image: url(../images/home.png); }
574 .icon-user { background-image: url(../images/user.png); }
578 .icon-user { background-image: url(../images/user.png); }
575 .icon-mypage { background-image: url(../images/user_page.png); }
579 .icon-mypage { background-image: url(../images/user_page.png); }
576 .icon-admin { background-image: url(../images/admin.png); }
580 .icon-admin { background-image: url(../images/admin.png); }
577 .icon-projects { background-image: url(../images/projects.png); }
581 .icon-projects { background-image: url(../images/projects.png); }
578 .icon-logout { background-image: url(../images/logout.png); }
582 .icon-logout { background-image: url(../images/logout.png); }
579 .icon-help { background-image: url(../images/help.png); }
583 .icon-help { background-image: url(../images/help.png); }
580 .icon-attachment { background-image: url(../images/attachment.png); }
584 .icon-attachment { background-image: url(../images/attachment.png); }
581 .icon-index { background-image: url(../images/index.png); }
585 .icon-index { background-image: url(../images/index.png); }
582 .icon-history { background-image: url(../images/history.png); }
586 .icon-history { background-image: url(../images/history.png); }
583 .icon-time { background-image: url(../images/time.png); }
587 .icon-time { background-image: url(../images/time.png); }
584 .icon-stats { background-image: url(../images/stats.png); }
588 .icon-stats { background-image: url(../images/stats.png); }
585 .icon-warning { background-image: url(../images/warning.png); }
589 .icon-warning { background-image: url(../images/warning.png); }
586 .icon-fav { background-image: url(../images/fav.png); }
590 .icon-fav { background-image: url(../images/fav.png); }
587 .icon-fav-off { background-image: url(../images/fav_off.png); }
591 .icon-fav-off { background-image: url(../images/fav_off.png); }
588 .icon-reload { background-image: url(../images/reload.png); }
592 .icon-reload { background-image: url(../images/reload.png); }
589 .icon-lock { background-image: url(../images/locked.png); }
593 .icon-lock { background-image: url(../images/locked.png); }
590 .icon-unlock { background-image: url(../images/unlock.png); }
594 .icon-unlock { background-image: url(../images/unlock.png); }
591 .icon-checked { background-image: url(../images/true.png); }
595 .icon-checked { background-image: url(../images/true.png); }
592 .icon-details { background-image: url(../images/zoom_in.png); }
596 .icon-details { background-image: url(../images/zoom_in.png); }
593 .icon-report { background-image: url(../images/report.png); }
597 .icon-report { background-image: url(../images/report.png); }
594
598
595 .icon22-projects { background-image: url(../images/22x22/projects.png); }
599 .icon22-projects { background-image: url(../images/22x22/projects.png); }
596 .icon22-users { background-image: url(../images/22x22/users.png); }
600 .icon22-users { background-image: url(../images/22x22/users.png); }
597 .icon22-tracker { background-image: url(../images/22x22/tracker.png); }
601 .icon22-tracker { background-image: url(../images/22x22/tracker.png); }
598 .icon22-role { background-image: url(../images/22x22/role.png); }
602 .icon22-role { background-image: url(../images/22x22/role.png); }
599 .icon22-workflow { background-image: url(../images/22x22/workflow.png); }
603 .icon22-workflow { background-image: url(../images/22x22/workflow.png); }
600 .icon22-options { background-image: url(../images/22x22/options.png); }
604 .icon22-options { background-image: url(../images/22x22/options.png); }
601 .icon22-notifications { background-image: url(../images/22x22/notifications.png); }
605 .icon22-notifications { background-image: url(../images/22x22/notifications.png); }
602 .icon22-authent { background-image: url(../images/22x22/authent.png); }
606 .icon22-authent { background-image: url(../images/22x22/authent.png); }
603 .icon22-info { background-image: url(../images/22x22/info.png); }
607 .icon22-info { background-image: url(../images/22x22/info.png); }
604 .icon22-comment { background-image: url(../images/22x22/comment.png); }
608 .icon22-comment { background-image: url(../images/22x22/comment.png); }
605 .icon22-package { background-image: url(../images/22x22/package.png); }
609 .icon22-package { background-image: url(../images/22x22/package.png); }
606 .icon22-settings { background-image: url(../images/22x22/settings.png); }
610 .icon22-settings { background-image: url(../images/22x22/settings.png); }
607 .icon22-plugin { background-image: url(../images/22x22/plugin.png); }
611 .icon22-plugin { background-image: url(../images/22x22/plugin.png); }
608
612
609 /***** Media print specific styles *****/
613 /***** Media print specific styles *****/
610 @media print {
614 @media print {
611 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
615 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
612 #main { background: #fff; }
616 #main { background: #fff; }
613 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; }
617 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; }
614 }
618 }
@@ -1,122 +1,130
1 require File.dirname(__FILE__) + '/../test_helper'
1 require File.dirname(__FILE__) + '/../test_helper'
2 require 'search_controller'
2 require 'search_controller'
3
3
4 # Re-raise errors caught by the controller.
4 # Re-raise errors caught by the controller.
5 class SearchController; def rescue_action(e) raise e end; end
5 class SearchController; def rescue_action(e) raise e end; end
6
6
7 class SearchControllerTest < Test::Unit::TestCase
7 class SearchControllerTest < Test::Unit::TestCase
8 fixtures :projects, :enabled_modules, :roles, :users,
8 fixtures :projects, :enabled_modules, :roles, :users,
9 :issues, :trackers, :issue_statuses,
9 :issues, :trackers, :issue_statuses,
10 :custom_fields, :custom_values,
10 :custom_fields, :custom_values,
11 :repositories, :changesets
11 :repositories, :changesets
12
12
13 def setup
13 def setup
14 @controller = SearchController.new
14 @controller = SearchController.new
15 @request = ActionController::TestRequest.new
15 @request = ActionController::TestRequest.new
16 @response = ActionController::TestResponse.new
16 @response = ActionController::TestResponse.new
17 User.current = nil
17 User.current = nil
18 end
18 end
19
19
20 def test_search_for_projects
20 def test_search_for_projects
21 get :index
21 get :index
22 assert_response :success
22 assert_response :success
23 assert_template 'index'
23 assert_template 'index'
24
24
25 get :index, :q => "cook"
25 get :index, :q => "cook"
26 assert_response :success
26 assert_response :success
27 assert_template 'index'
27 assert_template 'index'
28 assert assigns(:results).include?(Project.find(1))
28 assert assigns(:results).include?(Project.find(1))
29 end
29 end
30
30
31 def test_search_all_projects
31 def test_search_all_projects
32 get :index, :q => 'recipe subproject commit', :submit => 'Search'
32 get :index, :q => 'recipe subproject commit', :submit => 'Search'
33 assert_response :success
33 assert_response :success
34 assert_template 'index'
34 assert_template 'index'
35
35 assert assigns(:results).include?(Issue.find(2))
36 assert assigns(:results).include?(Issue.find(2))
36 assert assigns(:results).include?(Issue.find(5))
37 assert assigns(:results).include?(Issue.find(5))
37 assert assigns(:results).include?(Changeset.find(101))
38 assert assigns(:results).include?(Changeset.find(101))
39 assert_tag :dt, :attributes => { :class => /issue/ },
40 :child => { :tag => 'a', :content => /Add ingredients categories/ },
41 :sibling => { :tag => 'dd', :content => /should be classified by categories/ }
42
43 assert assigns(:results_by_type).is_a?(Hash)
44 assert_equal 4, assigns(:results_by_type)['changesets']
45 assert_tag :a, :content => 'Changesets (4)'
38 end
46 end
39
47
40 def test_search_project_and_subprojects
48 def test_search_project_and_subprojects
41 get :index, :id => 1, :q => 'recipe subproject', :scope => 'subprojects', :submit => 'Search'
49 get :index, :id => 1, :q => 'recipe subproject', :scope => 'subprojects', :submit => 'Search'
42 assert_response :success
50 assert_response :success
43 assert_template 'index'
51 assert_template 'index'
44 assert assigns(:results).include?(Issue.find(1))
52 assert assigns(:results).include?(Issue.find(1))
45 assert assigns(:results).include?(Issue.find(5))
53 assert assigns(:results).include?(Issue.find(5))
46 end
54 end
47
55
48 def test_search_without_searchable_custom_fields
56 def test_search_without_searchable_custom_fields
49 CustomField.update_all "searchable = #{ActiveRecord::Base.connection.quoted_false}"
57 CustomField.update_all "searchable = #{ActiveRecord::Base.connection.quoted_false}"
50
58
51 get :index, :id => 1
59 get :index, :id => 1
52 assert_response :success
60 assert_response :success
53 assert_template 'index'
61 assert_template 'index'
54 assert_not_nil assigns(:project)
62 assert_not_nil assigns(:project)
55
63
56 get :index, :id => 1, :q => "can"
64 get :index, :id => 1, :q => "can"
57 assert_response :success
65 assert_response :success
58 assert_template 'index'
66 assert_template 'index'
59 end
67 end
60
68
61 def test_search_with_searchable_custom_fields
69 def test_search_with_searchable_custom_fields
62 get :index, :id => 1, :q => "stringforcustomfield"
70 get :index, :id => 1, :q => "stringforcustomfield"
63 assert_response :success
71 assert_response :success
64 results = assigns(:results)
72 results = assigns(:results)
65 assert_not_nil results
73 assert_not_nil results
66 assert_equal 1, results.size
74 assert_equal 1, results.size
67 assert results.include?(Issue.find(3))
75 assert results.include?(Issue.find(3))
68 end
76 end
69
77
70 def test_search_all_words
78 def test_search_all_words
71 # 'all words' is on by default
79 # 'all words' is on by default
72 get :index, :id => 1, :q => 'recipe updating saving'
80 get :index, :id => 1, :q => 'recipe updating saving'
73 results = assigns(:results)
81 results = assigns(:results)
74 assert_not_nil results
82 assert_not_nil results
75 assert_equal 1, results.size
83 assert_equal 1, results.size
76 assert results.include?(Issue.find(3))
84 assert results.include?(Issue.find(3))
77 end
85 end
78
86
79 def test_search_one_of_the_words
87 def test_search_one_of_the_words
80 get :index, :id => 1, :q => 'recipe updating saving', :submit => 'Search'
88 get :index, :id => 1, :q => 'recipe updating saving', :submit => 'Search'
81 results = assigns(:results)
89 results = assigns(:results)
82 assert_not_nil results
90 assert_not_nil results
83 assert_equal 3, results.size
91 assert_equal 3, results.size
84 assert results.include?(Issue.find(3))
92 assert results.include?(Issue.find(3))
85 end
93 end
86
94
87 def test_search_titles_only_without_result
95 def test_search_titles_only_without_result
88 get :index, :id => 1, :q => 'recipe updating saving', :all_words => '1', :titles_only => '1', :submit => 'Search'
96 get :index, :id => 1, :q => 'recipe updating saving', :all_words => '1', :titles_only => '1', :submit => 'Search'
89 results = assigns(:results)
97 results = assigns(:results)
90 assert_not_nil results
98 assert_not_nil results
91 assert_equal 0, results.size
99 assert_equal 0, results.size
92 end
100 end
93
101
94 def test_search_titles_only
102 def test_search_titles_only
95 get :index, :id => 1, :q => 'recipe', :titles_only => '1', :submit => 'Search'
103 get :index, :id => 1, :q => 'recipe', :titles_only => '1', :submit => 'Search'
96 results = assigns(:results)
104 results = assigns(:results)
97 assert_not_nil results
105 assert_not_nil results
98 assert_equal 2, results.size
106 assert_equal 2, results.size
99 end
107 end
100
108
101 def test_search_with_invalid_project_id
109 def test_search_with_invalid_project_id
102 get :index, :id => 195, :q => 'recipe'
110 get :index, :id => 195, :q => 'recipe'
103 assert_response 404
111 assert_response 404
104 assert_nil assigns(:results)
112 assert_nil assigns(:results)
105 end
113 end
106
114
107 def test_quick_jump_to_issue
115 def test_quick_jump_to_issue
108 # issue of a public project
116 # issue of a public project
109 get :index, :q => "3"
117 get :index, :q => "3"
110 assert_redirected_to 'issues/show/3'
118 assert_redirected_to 'issues/show/3'
111
119
112 # issue of a private project
120 # issue of a private project
113 get :index, :q => "4"
121 get :index, :q => "4"
114 assert_response :success
122 assert_response :success
115 assert_template 'index'
123 assert_template 'index'
116 end
124 end
117
125
118 def test_tokens_with_quotes
126 def test_tokens_with_quotes
119 get :index, :id => 1, :q => '"good bye" hello "bye bye"'
127 get :index, :id => 1, :q => '"good bye" hello "bye bye"'
120 assert_equal ["good bye", "hello", "bye bye"], assigns(:tokens)
128 assert_equal ["good bye", "hello", "bye bye"], assigns(:tokens)
121 end
129 end
122 end
130 end
@@ -1,134 +1,143
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
2 # Copyright (C) 2006-2008 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 require File.dirname(__FILE__) + '/../test_helper'
18 require File.dirname(__FILE__) + '/../test_helper'
19
19
20 class SearchTest < Test::Unit::TestCase
20 class SearchTest < Test::Unit::TestCase
21 fixtures :users,
21 fixtures :users,
22 :members,
22 :members,
23 :projects,
23 :projects,
24 :roles,
24 :roles,
25 :enabled_modules,
25 :enabled_modules,
26 :issues,
26 :issues,
27 :trackers,
27 :trackers,
28 :journals,
28 :journals,
29 :journal_details,
29 :journal_details,
30 :repositories,
30 :repositories,
31 :changesets
31 :changesets
32
32
33 def setup
33 def setup
34 @project = Project.find(1)
34 @project = Project.find(1)
35 @issue_keyword = '%unable to print recipes%'
35 @issue_keyword = '%unable to print recipes%'
36 @issue = Issue.find(1)
36 @issue = Issue.find(1)
37 @changeset_keyword = '%very first commit%'
37 @changeset_keyword = '%very first commit%'
38 @changeset = Changeset.find(100)
38 @changeset = Changeset.find(100)
39 end
39 end
40
40
41 def test_search_by_anonymous
41 def test_search_by_anonymous
42 User.current = nil
42 User.current = nil
43
43
44 r = Issue.search(@issue_keyword)
44 r = Issue.search(@issue_keyword).first
45 assert r.include?(@issue)
45 assert r.include?(@issue)
46 r = Changeset.search(@changeset_keyword)
46 r = Changeset.search(@changeset_keyword).first
47 assert r.include?(@changeset)
47 assert r.include?(@changeset)
48
48
49 # Removes the :view_changesets permission from Anonymous role
49 # Removes the :view_changesets permission from Anonymous role
50 remove_permission Role.anonymous, :view_changesets
50 remove_permission Role.anonymous, :view_changesets
51
51
52 r = Issue.search(@issue_keyword)
52 r = Issue.search(@issue_keyword).first
53 assert r.include?(@issue)
53 assert r.include?(@issue)
54 r = Changeset.search(@changeset_keyword)
54 r = Changeset.search(@changeset_keyword).first
55 assert !r.include?(@changeset)
55 assert !r.include?(@changeset)
56
56
57 # Make the project private
57 # Make the project private
58 @project.update_attribute :is_public, false
58 @project.update_attribute :is_public, false
59 r = Issue.search(@issue_keyword)
59 r = Issue.search(@issue_keyword).first
60 assert !r.include?(@issue)
60 assert !r.include?(@issue)
61 r = Changeset.search(@changeset_keyword)
61 r = Changeset.search(@changeset_keyword).first
62 assert !r.include?(@changeset)
62 assert !r.include?(@changeset)
63 end
63 end
64
64
65 def test_search_by_user
65 def test_search_by_user
66 User.current = User.find_by_login('rhill')
66 User.current = User.find_by_login('rhill')
67 assert User.current.memberships.empty?
67 assert User.current.memberships.empty?
68
68
69 r = Issue.search(@issue_keyword)
69 r = Issue.search(@issue_keyword).first
70 assert r.include?(@issue)
70 assert r.include?(@issue)
71 r = Changeset.search(@changeset_keyword)
71 r = Changeset.search(@changeset_keyword).first
72 assert r.include?(@changeset)
72 assert r.include?(@changeset)
73
73
74 # Removes the :view_changesets permission from Non member role
74 # Removes the :view_changesets permission from Non member role
75 remove_permission Role.non_member, :view_changesets
75 remove_permission Role.non_member, :view_changesets
76
76
77 r = Issue.search(@issue_keyword)
77 r = Issue.search(@issue_keyword).first
78 assert r.include?(@issue)
78 assert r.include?(@issue)
79 r = Changeset.search(@changeset_keyword)
79 r = Changeset.search(@changeset_keyword).first
80 assert !r.include?(@changeset)
80 assert !r.include?(@changeset)
81
81
82 # Make the project private
82 # Make the project private
83 @project.update_attribute :is_public, false
83 @project.update_attribute :is_public, false
84 r = Issue.search(@issue_keyword)
84 r = Issue.search(@issue_keyword).first
85 assert !r.include?(@issue)
85 assert !r.include?(@issue)
86 r = Changeset.search(@changeset_keyword)
86 r = Changeset.search(@changeset_keyword).first
87 assert !r.include?(@changeset)
87 assert !r.include?(@changeset)
88 end
88 end
89
89
90 def test_search_by_allowed_member
90 def test_search_by_allowed_member
91 User.current = User.find_by_login('jsmith')
91 User.current = User.find_by_login('jsmith')
92 assert User.current.projects.include?(@project)
92 assert User.current.projects.include?(@project)
93
93
94 r = Issue.search(@issue_keyword)
94 r = Issue.search(@issue_keyword).first
95 assert r.include?(@issue)
95 assert r.include?(@issue)
96 r = Changeset.search(@changeset_keyword)
96 r = Changeset.search(@changeset_keyword).first
97 assert r.include?(@changeset)
97 assert r.include?(@changeset)
98
98
99 # Make the project private
99 # Make the project private
100 @project.update_attribute :is_public, false
100 @project.update_attribute :is_public, false
101 r = Issue.search(@issue_keyword)
101 r = Issue.search(@issue_keyword).first
102 assert r.include?(@issue)
102 assert r.include?(@issue)
103 r = Changeset.search(@changeset_keyword)
103 r = Changeset.search(@changeset_keyword).first
104 assert r.include?(@changeset)
104 assert r.include?(@changeset)
105 end
105 end
106
106
107 def test_search_by_unallowed_member
107 def test_search_by_unallowed_member
108 # Removes the :view_changesets permission from user's and non member role
108 # Removes the :view_changesets permission from user's and non member role
109 remove_permission Role.find(1), :view_changesets
109 remove_permission Role.find(1), :view_changesets
110 remove_permission Role.non_member, :view_changesets
110 remove_permission Role.non_member, :view_changesets
111
111
112 User.current = User.find_by_login('jsmith')
112 User.current = User.find_by_login('jsmith')
113 assert User.current.projects.include?(@project)
113 assert User.current.projects.include?(@project)
114
114
115 r = Issue.search(@issue_keyword)
115 r = Issue.search(@issue_keyword).first
116 assert r.include?(@issue)
116 assert r.include?(@issue)
117 r = Changeset.search(@changeset_keyword)
117 r = Changeset.search(@changeset_keyword).first
118 assert !r.include?(@changeset)
118 assert !r.include?(@changeset)
119
119
120 # Make the project private
120 # Make the project private
121 @project.update_attribute :is_public, false
121 @project.update_attribute :is_public, false
122 r = Issue.search(@issue_keyword)
122 r = Issue.search(@issue_keyword).first
123 assert r.include?(@issue)
123 assert r.include?(@issue)
124 r = Changeset.search(@changeset_keyword)
124 r = Changeset.search(@changeset_keyword).first
125 assert !r.include?(@changeset)
125 assert !r.include?(@changeset)
126 end
126 end
127
127
128 def test_search_issue_with_multiple_hits_in_journals
129 i = Issue.find(1)
130 assert_equal 2, i.journals.count(:all, :conditions => "notes LIKE '%notes%'")
131
132 r = Issue.search('%notes%').first
133 assert_equal 1, r.size
134 assert_equal i, r.first
135 end
136
128 private
137 private
129
138
130 def remove_permission(role, permission)
139 def remove_permission(role, permission)
131 role.permissions = role.permissions - [ permission ]
140 role.permissions = role.permissions - [ permission ]
132 role.save
141 role.save
133 end
142 end
134 end
143 end
@@ -1,122 +1,134
1 # redMine - project management software
1 # redMine - project management software
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
2 # Copyright (C) 2006-2007 Jean-Philippe Lang
3 #
3 #
4 # This program is free software; you can redistribute it and/or
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
7 # of the License, or (at your option) any later version.
8 #
8 #
9 # This program is distributed in the hope that it will be useful,
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
12 # GNU General Public License for more details.
13 #
13 #
14 # You should have received a copy of the GNU General Public License
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
17
18 module Redmine
18 module Redmine
19 module Acts
19 module Acts
20 module Searchable
20 module Searchable
21 def self.included(base)
21 def self.included(base)
22 base.extend ClassMethods
22 base.extend ClassMethods
23 end
23 end
24
24
25 module ClassMethods
25 module ClassMethods
26 # Options:
27 # * :columns - a column or an array of columns to search
28 # * :project_key - project foreign key (default to project_id)
29 # * :date_column - name of the datetime column (default to created_on)
30 # * :sort_order - name of the column used to sort results (default to :date_column or created_on)
31 # * :permission - permission required to search the model (default to :view_"objects")
26 def acts_as_searchable(options = {})
32 def acts_as_searchable(options = {})
27 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
33 return if self.included_modules.include?(Redmine::Acts::Searchable::InstanceMethods)
28
34
29 cattr_accessor :searchable_options
35 cattr_accessor :searchable_options
30 self.searchable_options = options
36 self.searchable_options = options
31
37
32 if searchable_options[:columns].nil?
38 if searchable_options[:columns].nil?
33 raise 'No searchable column defined.'
39 raise 'No searchable column defined.'
34 elsif !searchable_options[:columns].is_a?(Array)
40 elsif !searchable_options[:columns].is_a?(Array)
35 searchable_options[:columns] = [] << searchable_options[:columns]
41 searchable_options[:columns] = [] << searchable_options[:columns]
36 end
42 end
37
43
38 if searchable_options[:project_key]
44 if searchable_options[:project_key]
39 elsif column_names.include?('project_id')
45 elsif column_names.include?('project_id')
40 searchable_options[:project_key] = "#{table_name}.project_id"
46 searchable_options[:project_key] = "#{table_name}.project_id"
41 else
47 else
42 raise 'No project key defined.'
48 raise 'No project key defined.'
43 end
49 end
44
50
45 if searchable_options[:date_column]
51 if searchable_options[:date_column]
46 elsif column_names.include?('created_on')
52 elsif column_names.include?('created_on')
47 searchable_options[:date_column] = "#{table_name}.created_on"
53 searchable_options[:date_column] = "#{table_name}.created_on"
48 else
54 else
49 raise 'No date column defined defined.'
55 raise 'No date column defined defined.'
50 end
56 end
51
57
58 searchable_options[:order_column] ||= searchable_options[:date_column]
59
52 # Permission needed to search this model
60 # Permission needed to search this model
53 searchable_options[:permission] = "view_#{self.name.underscore.pluralize}".to_sym unless searchable_options.has_key?(:permission)
61 searchable_options[:permission] = "view_#{self.name.underscore.pluralize}".to_sym unless searchable_options.has_key?(:permission)
54
62
55 # Should we search custom fields on this model ?
63 # Should we search custom fields on this model ?
56 searchable_options[:search_custom_fields] = !reflect_on_association(:custom_values).nil?
64 searchable_options[:search_custom_fields] = !reflect_on_association(:custom_values).nil?
57
65
58 send :include, Redmine::Acts::Searchable::InstanceMethods
66 send :include, Redmine::Acts::Searchable::InstanceMethods
59 end
67 end
60 end
68 end
61
69
62 module InstanceMethods
70 module InstanceMethods
63 def self.included(base)
71 def self.included(base)
64 base.extend ClassMethods
72 base.extend ClassMethods
65 end
73 end
66
74
67 module ClassMethods
75 module ClassMethods
68 # Search the model for the given tokens
76 # Searches the model for the given tokens
69 # projects argument can be either nil (will search all projects), a project or an array of projects
77 # projects argument can be either nil (will search all projects), a project or an array of projects
78 # Returns the results and the results count
70 def search(tokens, projects=nil, options={})
79 def search(tokens, projects=nil, options={})
71 tokens = [] << tokens unless tokens.is_a?(Array)
80 tokens = [] << tokens unless tokens.is_a?(Array)
72 projects = [] << projects unless projects.nil? || projects.is_a?(Array)
81 projects = [] << projects unless projects.nil? || projects.is_a?(Array)
73
82
74 find_options = {:include => searchable_options[:include]}
83 find_options = {:include => searchable_options[:include]}
75 find_options[:limit] = options[:limit] if options[:limit]
84 find_options[:order] = "#{searchable_options[:order_column]} " + (options[:before] ? 'DESC' : 'ASC')
76 find_options[:order] = "#{searchable_options[:date_column]} " + (options[:before] ? 'DESC' : 'ASC')
85
86 limit_options = {}
87 limit_options[:limit] = options[:limit] if options[:limit]
88 if options[:offset]
89 limit_options[:conditions] = "(#{searchable_options[:date_column]} " + (options[:before] ? '<' : '>') + "'#{connection.quoted_date(options[:offset])}')"
90 end
91
77 columns = searchable_options[:columns]
92 columns = searchable_options[:columns]
78 columns.slice!(1..-1) if options[:titles_only]
93 columns.slice!(1..-1) if options[:titles_only]
79
94
80 token_clauses = columns.collect {|column| "(LOWER(#{column}) LIKE ?)"}
95 token_clauses = columns.collect {|column| "(LOWER(#{column}) LIKE ?)"}
81
96
82 if !options[:titles_only] && searchable_options[:search_custom_fields]
97 if !options[:titles_only] && searchable_options[:search_custom_fields]
83 searchable_custom_field_ids = CustomField.find(:all,
98 searchable_custom_field_ids = CustomField.find(:all,
84 :select => 'id',
99 :select => 'id',
85 :conditions => { :type => "#{self.name}CustomField",
100 :conditions => { :type => "#{self.name}CustomField",
86 :searchable => true }).collect(&:id)
101 :searchable => true }).collect(&:id)
87 if searchable_custom_field_ids.any?
102 if searchable_custom_field_ids.any?
88 custom_field_sql = "#{table_name}.id IN (SELECT customized_id FROM #{CustomValue.table_name}" +
103 custom_field_sql = "#{table_name}.id IN (SELECT customized_id FROM #{CustomValue.table_name}" +
89 " WHERE customized_type='#{self.name}' AND customized_id=#{table_name}.id AND LOWER(value) LIKE ?" +
104 " WHERE customized_type='#{self.name}' AND customized_id=#{table_name}.id AND LOWER(value) LIKE ?" +
90 " AND #{CustomValue.table_name}.custom_field_id IN (#{searchable_custom_field_ids.join(',')}))"
105 " AND #{CustomValue.table_name}.custom_field_id IN (#{searchable_custom_field_ids.join(',')}))"
91 token_clauses << custom_field_sql
106 token_clauses << custom_field_sql
92 end
107 end
93 end
108 end
94
109
95 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
110 sql = (['(' + token_clauses.join(' OR ') + ')'] * tokens.size).join(options[:all_words] ? ' AND ' : ' OR ')
96
111
97 if options[:offset]
98 sql = "(#{sql}) AND (#{searchable_options[:date_column]} " + (options[:before] ? '<' : '>') + "'#{connection.quoted_date(options[:offset])}')"
99 end
100 find_options[:conditions] = [sql, * (tokens * token_clauses.size).sort]
112 find_options[:conditions] = [sql, * (tokens * token_clauses.size).sort]
101
113
102 project_conditions = []
114 project_conditions = []
103 project_conditions << (searchable_options[:permission].nil? ? Project.visible_by(User.current) :
115 project_conditions << (searchable_options[:permission].nil? ? Project.visible_by(User.current) :
104 Project.allowed_to_condition(User.current, searchable_options[:permission]))
116 Project.allowed_to_condition(User.current, searchable_options[:permission]))
105 project_conditions << "#{searchable_options[:project_key]} IN (#{projects.collect(&:id).join(',')})" unless projects.nil?
117 project_conditions << "#{searchable_options[:project_key]} IN (#{projects.collect(&:id).join(',')})" unless projects.nil?
106
118
107 results = with_scope(:find => {:conditions => project_conditions.join(' AND ')}) do
119 results = []
108 find(:all, find_options)
120 results_count = 0
109 end
121
110 if searchable_options[:with] && !options[:titles_only]
122 with_scope(:find => {:conditions => project_conditions.join(' AND ')}) do
111 searchable_options[:with].each do |model, assoc|
123 with_scope(:find => find_options) do
112 results += model.to_s.camelcase.constantize.search(tokens, projects, options).collect {|r| r.send assoc}
124 results_count = count(:all)
125 results = find(:all, limit_options)
113 end
126 end
114 results.uniq!
115 end
127 end
116 results
128 [results, results_count]
117 end
129 end
118 end
130 end
119 end
131 end
120 end
132 end
121 end
133 end
122 end
134 end
General Comments 0
You need to be logged in to leave comments. Login now