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