##// END OF EJS Templates
Adds an option for displaying the issue description on the issue list (#3447)....
Jean-Philippe Lang -
r10721:8201761e7732
parent child
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,407 +1,410
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 module IssuesHelper
21 21 include ApplicationHelper
22 22
23 23 def issue_list(issues, &block)
24 24 ancestors = []
25 25 issues.each do |issue|
26 26 while (ancestors.any? && !issue.is_descendant_of?(ancestors.last))
27 27 ancestors.pop
28 28 end
29 29 yield issue, ancestors.size
30 30 ancestors << issue unless issue.leaf?
31 31 end
32 32 end
33 33
34 34 # Renders a HTML/CSS tooltip
35 35 #
36 36 # To use, a trigger div is needed. This is a div with the class of "tooltip"
37 37 # that contains this method wrapped in a span with the class of "tip"
38 38 #
39 39 # <div class="tooltip"><%= link_to_issue(issue) %>
40 40 # <span class="tip"><%= render_issue_tooltip(issue) %></span>
41 41 # </div>
42 42 #
43 43 def render_issue_tooltip(issue)
44 44 @cached_label_status ||= l(:field_status)
45 45 @cached_label_start_date ||= l(:field_start_date)
46 46 @cached_label_due_date ||= l(:field_due_date)
47 47 @cached_label_assigned_to ||= l(:field_assigned_to)
48 48 @cached_label_priority ||= l(:field_priority)
49 49 @cached_label_project ||= l(:field_project)
50 50
51 51 link_to_issue(issue) + "<br /><br />".html_safe +
52 52 "<strong>#{@cached_label_project}</strong>: #{link_to_project(issue.project)}<br />".html_safe +
53 53 "<strong>#{@cached_label_status}</strong>: #{h(issue.status.name)}<br />".html_safe +
54 54 "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />".html_safe +
55 55 "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />".html_safe +
56 56 "<strong>#{@cached_label_assigned_to}</strong>: #{h(issue.assigned_to)}<br />".html_safe +
57 57 "<strong>#{@cached_label_priority}</strong>: #{h(issue.priority.name)}".html_safe
58 58 end
59 59
60 60 def issue_heading(issue)
61 61 h("#{issue.tracker} ##{issue.id}")
62 62 end
63 63
64 64 def render_issue_subject_with_tree(issue)
65 65 s = ''
66 66 ancestors = issue.root? ? [] : issue.ancestors.visible.all
67 67 ancestors.each do |ancestor|
68 68 s << '<div>' + content_tag('p', link_to_issue(ancestor, :project => (issue.project_id != ancestor.project_id)))
69 69 end
70 70 s << '<div>'
71 71 subject = h(issue.subject)
72 72 if issue.is_private?
73 73 subject = content_tag('span', l(:field_is_private), :class => 'private') + ' ' + subject
74 74 end
75 75 s << content_tag('h3', subject)
76 76 s << '</div>' * (ancestors.size + 1)
77 77 s.html_safe
78 78 end
79 79
80 80 def render_descendants_tree(issue)
81 81 s = '<form><table class="list issues">'
82 82 issue_list(issue.descendants.visible.sort_by(&:lft)) do |child, level|
83 83 css = "issue issue-#{child.id} hascontextmenu"
84 84 css << " idnt idnt-#{level}" if level > 0
85 85 s << content_tag('tr',
86 86 content_tag('td', check_box_tag("ids[]", child.id, false, :id => nil), :class => 'checkbox') +
87 87 content_tag('td', link_to_issue(child, :truncate => 60, :project => (issue.project_id != child.project_id)), :class => 'subject') +
88 88 content_tag('td', h(child.status)) +
89 89 content_tag('td', link_to_user(child.assigned_to)) +
90 90 content_tag('td', progress_bar(child.done_ratio, :width => '80px')),
91 91 :class => css)
92 92 end
93 93 s << '</table></form>'
94 94 s.html_safe
95 95 end
96 96
97 97 # Returns a link for adding a new subtask to the given issue
98 98 def link_to_new_subtask(issue)
99 99 attrs = {
100 100 :tracker_id => issue.tracker,
101 101 :parent_issue_id => issue
102 102 }
103 103 link_to(l(:button_add), new_project_issue_path(issue.project, :issue => attrs))
104 104 end
105 105
106 106 class IssueFieldsRows
107 107 include ActionView::Helpers::TagHelper
108 108
109 109 def initialize
110 110 @left = []
111 111 @right = []
112 112 end
113 113
114 114 def left(*args)
115 115 args.any? ? @left << cells(*args) : @left
116 116 end
117 117
118 118 def right(*args)
119 119 args.any? ? @right << cells(*args) : @right
120 120 end
121 121
122 122 def size
123 123 @left.size > @right.size ? @left.size : @right.size
124 124 end
125 125
126 126 def to_html
127 127 html = ''.html_safe
128 128 blank = content_tag('th', '') + content_tag('td', '')
129 129 size.times do |i|
130 130 left = @left[i] || blank
131 131 right = @right[i] || blank
132 132 html << content_tag('tr', left + right)
133 133 end
134 134 html
135 135 end
136 136
137 137 def cells(label, text, options={})
138 138 content_tag('th', "#{label}:", options) + content_tag('td', text, options)
139 139 end
140 140 end
141 141
142 142 def issue_fields_rows
143 143 r = IssueFieldsRows.new
144 144 yield r
145 145 r.to_html
146 146 end
147 147
148 148 def render_custom_fields_rows(issue)
149 149 return if issue.custom_field_values.empty?
150 150 ordered_values = []
151 151 half = (issue.custom_field_values.size / 2.0).ceil
152 152 half.times do |i|
153 153 ordered_values << issue.custom_field_values[i]
154 154 ordered_values << issue.custom_field_values[i + half]
155 155 end
156 156 s = "<tr>\n"
157 157 n = 0
158 158 ordered_values.compact.each do |value|
159 159 s << "</tr>\n<tr>\n" if n > 0 && (n % 2) == 0
160 160 s << "\t<th>#{ h(value.custom_field.name) }:</th><td>#{ simple_format_without_paragraph(h(show_value(value))) }</td>\n"
161 161 n += 1
162 162 end
163 163 s << "</tr>\n"
164 164 s.html_safe
165 165 end
166 166
167 167 def issues_destroy_confirmation_message(issues)
168 168 issues = [issues] unless issues.is_a?(Array)
169 169 message = l(:text_issues_destroy_confirmation)
170 170 descendant_count = issues.inject(0) {|memo, i| memo += (i.right - i.left - 1)/2}
171 171 if descendant_count > 0
172 172 issues.each do |issue|
173 173 next if issue.root?
174 174 issues.each do |other_issue|
175 175 descendant_count -= 1 if issue.is_descendant_of?(other_issue)
176 176 end
177 177 end
178 178 if descendant_count > 0
179 179 message << "\n" + l(:text_issues_destroy_descendants_confirmation, :count => descendant_count)
180 180 end
181 181 end
182 182 message
183 183 end
184 184
185 185 def sidebar_queries
186 186 unless @sidebar_queries
187 187 @sidebar_queries = Query.visible.all(
188 188 :order => "#{Query.table_name}.name ASC",
189 189 # Project specific queries and global queries
190 190 :conditions => (@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id])
191 191 )
192 192 end
193 193 @sidebar_queries
194 194 end
195 195
196 196 def query_links(title, queries)
197 197 # links to #index on issues/show
198 198 url_params = controller_name == 'issues' ? {:controller => 'issues', :action => 'index', :project_id => @project} : params
199 199
200 200 content_tag('h3', h(title)) +
201 201 queries.collect {|query|
202 202 css = 'query'
203 203 css << ' selected' if query == @query
204 204 link_to(h(query.name), url_params.merge(:query_id => query), :class => css)
205 205 }.join('<br />').html_safe
206 206 end
207 207
208 208 def render_sidebar_queries
209 209 out = ''.html_safe
210 210 queries = sidebar_queries.select {|q| !q.is_public?}
211 211 out << query_links(l(:label_my_queries), queries) if queries.any?
212 212 queries = sidebar_queries.select {|q| q.is_public?}
213 213 out << query_links(l(:label_query_plural), queries) if queries.any?
214 214 out
215 215 end
216 216
217 217 # Returns the textual representation of a journal details
218 218 # as an array of strings
219 219 def details_to_strings(details, no_html=false, options={})
220 220 options[:only_path] = (options[:only_path] == false ? false : true)
221 221 strings = []
222 222 values_by_field = {}
223 223 details.each do |detail|
224 224 if detail.property == 'cf'
225 225 field_id = detail.prop_key
226 226 field = CustomField.find_by_id(field_id)
227 227 if field && field.multiple?
228 228 values_by_field[field_id] ||= {:added => [], :deleted => []}
229 229 if detail.old_value
230 230 values_by_field[field_id][:deleted] << detail.old_value
231 231 end
232 232 if detail.value
233 233 values_by_field[field_id][:added] << detail.value
234 234 end
235 235 next
236 236 end
237 237 end
238 238 strings << show_detail(detail, no_html, options)
239 239 end
240 240 values_by_field.each do |field_id, changes|
241 241 detail = JournalDetail.new(:property => 'cf', :prop_key => field_id)
242 242 if changes[:added].any?
243 243 detail.value = changes[:added]
244 244 strings << show_detail(detail, no_html, options)
245 245 elsif changes[:deleted].any?
246 246 detail.old_value = changes[:deleted]
247 247 strings << show_detail(detail, no_html, options)
248 248 end
249 249 end
250 250 strings
251 251 end
252 252
253 253 # Returns the textual representation of a single journal detail
254 254 def show_detail(detail, no_html=false, options={})
255 255 multiple = false
256 256 case detail.property
257 257 when 'attr'
258 258 field = detail.prop_key.to_s.gsub(/\_id$/, "")
259 259 label = l(("field_" + field).to_sym)
260 260 case detail.prop_key
261 261 when 'due_date', 'start_date'
262 262 value = format_date(detail.value.to_date) if detail.value
263 263 old_value = format_date(detail.old_value.to_date) if detail.old_value
264 264
265 265 when 'project_id', 'status_id', 'tracker_id', 'assigned_to_id',
266 266 'priority_id', 'category_id', 'fixed_version_id'
267 267 value = find_name_by_reflection(field, detail.value)
268 268 old_value = find_name_by_reflection(field, detail.old_value)
269 269
270 270 when 'estimated_hours'
271 271 value = "%0.02f" % detail.value.to_f unless detail.value.blank?
272 272 old_value = "%0.02f" % detail.old_value.to_f unless detail.old_value.blank?
273 273
274 274 when 'parent_id'
275 275 label = l(:field_parent_issue)
276 276 value = "##{detail.value}" unless detail.value.blank?
277 277 old_value = "##{detail.old_value}" unless detail.old_value.blank?
278 278
279 279 when 'is_private'
280 280 value = l(detail.value == "0" ? :general_text_No : :general_text_Yes) unless detail.value.blank?
281 281 old_value = l(detail.old_value == "0" ? :general_text_No : :general_text_Yes) unless detail.old_value.blank?
282 282 end
283 283 when 'cf'
284 284 custom_field = CustomField.find_by_id(detail.prop_key)
285 285 if custom_field
286 286 multiple = custom_field.multiple?
287 287 label = custom_field.name
288 288 value = format_value(detail.value, custom_field.field_format) if detail.value
289 289 old_value = format_value(detail.old_value, custom_field.field_format) if detail.old_value
290 290 end
291 291 when 'attachment'
292 292 label = l(:label_attachment)
293 293 end
294 294 call_hook(:helper_issues_show_detail_after_setting,
295 295 {:detail => detail, :label => label, :value => value, :old_value => old_value })
296 296
297 297 label ||= detail.prop_key
298 298 value ||= detail.value
299 299 old_value ||= detail.old_value
300 300
301 301 unless no_html
302 302 label = content_tag('strong', label)
303 303 old_value = content_tag("i", h(old_value)) if detail.old_value
304 304 old_value = content_tag("del", old_value) if detail.old_value and detail.value.blank?
305 305 if detail.property == 'attachment' && !value.blank? && atta = Attachment.find_by_id(detail.prop_key)
306 306 # Link to the attachment if it has not been removed
307 307 value = link_to_attachment(atta, :download => true, :only_path => options[:only_path])
308 308 if options[:only_path] != false && atta.is_text?
309 309 value += link_to(
310 310 image_tag('magnifier.png'),
311 311 :controller => 'attachments', :action => 'show',
312 312 :id => atta, :filename => atta.filename
313 313 )
314 314 end
315 315 else
316 316 value = content_tag("i", h(value)) if value
317 317 end
318 318 end
319 319
320 320 if detail.property == 'attr' && detail.prop_key == 'description'
321 321 s = l(:text_journal_changed_no_detail, :label => label)
322 322 unless no_html
323 323 diff_link = link_to 'diff',
324 324 {:controller => 'journals', :action => 'diff', :id => detail.journal_id,
325 325 :detail_id => detail.id, :only_path => options[:only_path]},
326 326 :title => l(:label_view_diff)
327 327 s << " (#{ diff_link })"
328 328 end
329 329 s.html_safe
330 330 elsif detail.value.present?
331 331 case detail.property
332 332 when 'attr', 'cf'
333 333 if detail.old_value.present?
334 334 l(:text_journal_changed, :label => label, :old => old_value, :new => value).html_safe
335 335 elsif multiple
336 336 l(:text_journal_added, :label => label, :value => value).html_safe
337 337 else
338 338 l(:text_journal_set_to, :label => label, :value => value).html_safe
339 339 end
340 340 when 'attachment'
341 341 l(:text_journal_added, :label => label, :value => value).html_safe
342 342 end
343 343 else
344 344 l(:text_journal_deleted, :label => label, :old => old_value).html_safe
345 345 end
346 346 end
347 347
348 348 # Find the name of an associated record stored in the field attribute
349 349 def find_name_by_reflection(field, id)
350 350 association = Issue.reflect_on_association(field.to_sym)
351 351 if association
352 352 record = association.class_name.constantize.find_by_id(id)
353 353 return record.name if record
354 354 end
355 355 end
356 356
357 357 # Renders issue children recursively
358 358 def render_api_issue_children(issue, api)
359 359 return if issue.leaf?
360 360 api.array :children do
361 361 issue.children.each do |child|
362 362 api.issue(:id => child.id) do
363 363 api.tracker(:id => child.tracker_id, :name => child.tracker.name) unless child.tracker.nil?
364 364 api.subject child.subject
365 365 render_api_issue_children(child, api)
366 366 end
367 367 end
368 368 end
369 369 end
370 370
371 371 def issues_to_csv(issues, project, query, options={})
372 372 decimal_separator = l(:general_csv_decimal_separator)
373 373 encoding = l(:general_csv_encoding)
374 columns = (options[:columns] == 'all' ? query.available_columns : query.columns)
374 columns = (options[:columns] == 'all' ? query.available_inline_columns : query.inline_columns)
375 if options[:description]
376 if description = query.available_columns.detect {|q| q.name == :description}
377 columns << description
378 end
379 end
375 380
376 381 export = FCSV.generate(:col_sep => l(:general_csv_separator)) do |csv|
377 382 # csv header fields
378 csv << [ "#" ] + columns.collect {|c| Redmine::CodesetUtil.from_utf8(c.caption.to_s, encoding) } +
379 (options[:description] ? [Redmine::CodesetUtil.from_utf8(l(:field_description), encoding)] : [])
383 csv << [ "#" ] + columns.collect {|c| Redmine::CodesetUtil.from_utf8(c.caption.to_s, encoding) }
380 384
381 385 # csv lines
382 386 issues.each do |issue|
383 387 col_values = columns.collect do |column|
384 388 s = if column.is_a?(QueryCustomFieldColumn)
385 389 cv = issue.custom_field_values.detect {|v| v.custom_field_id == column.custom_field.id}
386 390 show_value(cv)
387 391 else
388 392 value = column.value(issue)
389 393 if value.is_a?(Date)
390 394 format_date(value)
391 395 elsif value.is_a?(Time)
392 396 format_time(value)
393 397 elsif value.is_a?(Float)
394 398 ("%.2f" % value).gsub('.', decimal_separator)
395 399 else
396 400 value
397 401 end
398 402 end
399 403 s.to_s
400 404 end
401 csv << [ issue.id.to_s ] + col_values.collect {|c| Redmine::CodesetUtil.from_utf8(c.to_s, encoding) } +
402 (options[:description] ? [Redmine::CodesetUtil.from_utf8(issue.description, encoding)] : [])
405 csv << [ issue.id.to_s ] + col_values.collect {|c| Redmine::CodesetUtil.from_utf8(c.to_s, encoding) }
403 406 end
404 407 end
405 408 export
406 409 end
407 410 end
@@ -1,163 +1,173
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 module QueriesHelper
21 21 def filters_options_for_select(query)
22 22 options_for_select(filters_options(query))
23 23 end
24 24
25 25 def filters_options(query)
26 26 options = [[]]
27 27 sorted_options = query.available_filters.sort do |a, b|
28 28 ord = 0
29 29 if !(a[1][:order] == 20 && b[1][:order] == 20)
30 30 ord = a[1][:order] <=> b[1][:order]
31 31 else
32 32 cn = (CustomField::CUSTOM_FIELDS_NAMES.index(a[1][:field].class.name) <=>
33 33 CustomField::CUSTOM_FIELDS_NAMES.index(b[1][:field].class.name))
34 34 if cn != 0
35 35 ord = cn
36 36 else
37 37 f = (a[1][:field] <=> b[1][:field])
38 38 if f != 0
39 39 ord = f
40 40 else
41 41 # assigned_to or author
42 42 ord = (a[0] <=> b[0])
43 43 end
44 44 end
45 45 end
46 46 ord
47 47 end
48 48 options += sorted_options.map do |field, field_options|
49 49 [field_options[:name], field]
50 50 end
51 51 end
52 52
53 def available_block_columns_tags(query)
54 tags = ''.html_safe
55 query.available_block_columns.each do |column|
56 tags << content_tag('label', check_box_tag('c[]', column.name.to_s, query.has_column?(column)) + " #{column.caption}", :class => 'inline')
57 end
58 tags
59 end
60
53 61 def column_header(column)
54 62 column.sortable ? sort_header_tag(column.name.to_s, :caption => column.caption,
55 63 :default_order => column.default_order) :
56 64 content_tag('th', h(column.caption))
57 65 end
58 66
59 67 def column_content(column, issue)
60 68 value = column.value(issue)
61 69 if value.is_a?(Array)
62 70 value.collect {|v| column_value(column, issue, v)}.compact.join(', ').html_safe
63 71 else
64 72 column_value(column, issue, value)
65 73 end
66 74 end
67 75
68 76 def column_value(column, issue, value)
69 77 case value.class.name
70 78 when 'String'
71 79 if column.name == :subject
72 80 link_to(h(value), :controller => 'issues', :action => 'show', :id => issue)
81 elsif column.name == :description
82 issue.description? ? content_tag('div', textilizable(issue, :description), :class => "wiki") : ''
73 83 else
74 84 h(value)
75 85 end
76 86 when 'Time'
77 87 format_time(value)
78 88 when 'Date'
79 89 format_date(value)
80 90 when 'Fixnum', 'Float'
81 91 if column.name == :done_ratio
82 92 progress_bar(value, :width => '80px')
83 93 elsif column.name == :spent_hours
84 94 sprintf "%.2f", value
85 95 else
86 96 h(value.to_s)
87 97 end
88 98 when 'User'
89 99 link_to_user value
90 100 when 'Project'
91 101 link_to_project value
92 102 when 'Version'
93 103 link_to(h(value), :controller => 'versions', :action => 'show', :id => value)
94 104 when 'TrueClass'
95 105 l(:general_text_Yes)
96 106 when 'FalseClass'
97 107 l(:general_text_No)
98 108 when 'Issue'
99 109 link_to_issue(value, :subject => false)
100 110 when 'IssueRelation'
101 111 other = value.other_issue(issue)
102 112 content_tag('span',
103 113 (l(value.label_for(issue)) + " " + link_to_issue(other, :subject => false, :tracker => false)).html_safe,
104 114 :class => value.css_classes_for(issue))
105 115 else
106 116 h(value)
107 117 end
108 118 end
109 119
110 120 # Retrieve query from session or build a new query
111 121 def retrieve_query
112 122 if !params[:query_id].blank?
113 123 cond = "project_id IS NULL"
114 124 cond << " OR project_id = #{@project.id}" if @project
115 125 @query = Query.find(params[:query_id], :conditions => cond)
116 126 raise ::Unauthorized unless @query.visible?
117 127 @query.project = @project
118 128 session[:query] = {:id => @query.id, :project_id => @query.project_id}
119 129 sort_clear
120 130 elsif api_request? || params[:set_filter] || session[:query].nil? || session[:query][:project_id] != (@project ? @project.id : nil)
121 131 # Give it a name, required to be valid
122 132 @query = Query.new(:name => "_")
123 133 @query.project = @project
124 134 build_query_from_params
125 135 session[:query] = {:project_id => @query.project_id, :filters => @query.filters, :group_by => @query.group_by, :column_names => @query.column_names}
126 136 else
127 137 # retrieve from session
128 138 @query = Query.find_by_id(session[:query][:id]) if session[:query][:id]
129 139 @query ||= Query.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
130 140 @query.project = @project
131 141 end
132 142 end
133 143
134 144 def retrieve_query_from_session
135 145 if session[:query]
136 146 if session[:query][:id]
137 147 @query = Query.find_by_id(session[:query][:id])
138 148 return unless @query
139 149 else
140 150 @query = Query.new(:name => "_", :filters => session[:query][:filters], :group_by => session[:query][:group_by], :column_names => session[:query][:column_names])
141 151 end
142 152 if session[:query].has_key?(:project_id)
143 153 @query.project_id = session[:query][:project_id]
144 154 else
145 155 @query.project = @project
146 156 end
147 157 @query
148 158 end
149 159 end
150 160
151 161 def build_query_from_params
152 162 if params[:fields] || params[:f]
153 163 @query.filters = {}
154 164 @query.add_filters(params[:fields] || params[:f], params[:operators] || params[:op], params[:values] || params[:v])
155 165 else
156 166 @query.available_filters.keys.each do |field|
157 167 @query.add_short_filter(field, params[field]) if params[field]
158 168 end
159 169 end
160 170 @query.group_by = params[:group_by] || (params[:query] && params[:query][:group_by])
161 171 @query.column_names = params[:c] || (params[:query] && params[:query][:column_names])
162 172 end
163 173 end
@@ -1,1086 +1,1109
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 QueryColumn
19 19 attr_accessor :name, :sortable, :groupable, :default_order
20 20 include Redmine::I18n
21 21
22 22 def initialize(name, options={})
23 23 self.name = name
24 24 self.sortable = options[:sortable]
25 25 self.groupable = options[:groupable] || false
26 26 if groupable == true
27 27 self.groupable = name.to_s
28 28 end
29 29 self.default_order = options[:default_order]
30 @inline = options.key?(:inline) ? options[:inline] : true
30 31 @caption_key = options[:caption] || "field_#{name}"
31 32 end
32 33
33 34 def caption
34 35 l(@caption_key)
35 36 end
36 37
37 38 # Returns true if the column is sortable, otherwise false
38 39 def sortable?
39 40 !@sortable.nil?
40 41 end
41 42
42 43 def sortable
43 44 @sortable.is_a?(Proc) ? @sortable.call : @sortable
44 45 end
45 46
47 def inline?
48 @inline
49 end
50
46 51 def value(issue)
47 52 issue.send name
48 53 end
49 54
50 55 def css_classes
51 56 name
52 57 end
53 58 end
54 59
55 60 class QueryCustomFieldColumn < QueryColumn
56 61
57 62 def initialize(custom_field)
58 63 self.name = "cf_#{custom_field.id}".to_sym
59 64 self.sortable = custom_field.order_statement || false
60 65 self.groupable = custom_field.group_statement || false
66 @inline = true
61 67 @cf = custom_field
62 68 end
63 69
64 70 def caption
65 71 @cf.name
66 72 end
67 73
68 74 def custom_field
69 75 @cf
70 76 end
71 77
72 78 def value(issue)
73 79 cv = issue.custom_values.select {|v| v.custom_field_id == @cf.id}.collect {|v| @cf.cast_value(v.value)}
74 80 cv.size > 1 ? cv.sort {|a,b| a.to_s <=> b.to_s} : cv.first
75 81 end
76 82
77 83 def css_classes
78 84 @css_classes ||= "#{name} #{@cf.field_format}"
79 85 end
80 86 end
81 87
82 88 class Query < ActiveRecord::Base
83 89 class StatementInvalid < ::ActiveRecord::StatementInvalid
84 90 end
85 91
86 92 belongs_to :project
87 93 belongs_to :user
88 94 serialize :filters
89 95 serialize :column_names
90 96 serialize :sort_criteria, Array
91 97
92 98 attr_protected :project_id, :user_id
93 99
94 100 validates_presence_of :name
95 101 validates_length_of :name, :maximum => 255
96 102 validate :validate_query_filters
97 103
98 104 @@operators = { "=" => :label_equals,
99 105 "!" => :label_not_equals,
100 106 "o" => :label_open_issues,
101 107 "c" => :label_closed_issues,
102 108 "!*" => :label_none,
103 109 "*" => :label_any,
104 110 ">=" => :label_greater_or_equal,
105 111 "<=" => :label_less_or_equal,
106 112 "><" => :label_between,
107 113 "<t+" => :label_in_less_than,
108 114 ">t+" => :label_in_more_than,
109 115 "><t+"=> :label_in_the_next_days,
110 116 "t+" => :label_in,
111 117 "t" => :label_today,
112 118 "w" => :label_this_week,
113 119 ">t-" => :label_less_than_ago,
114 120 "<t-" => :label_more_than_ago,
115 121 "><t-"=> :label_in_the_past_days,
116 122 "t-" => :label_ago,
117 123 "~" => :label_contains,
118 124 "!~" => :label_not_contains,
119 125 "=p" => :label_any_issues_in_project,
120 126 "=!p" => :label_any_issues_not_in_project,
121 127 "!p" => :label_no_issues_in_project}
122 128
123 129 cattr_reader :operators
124 130
125 131 @@operators_by_filter_type = { :list => [ "=", "!" ],
126 132 :list_status => [ "o", "=", "!", "c", "*" ],
127 133 :list_optional => [ "=", "!", "!*", "*" ],
128 134 :list_subprojects => [ "*", "!*", "=" ],
129 135 :date => [ "=", ">=", "<=", "><", "<t+", ">t+", "><t+", "t+", "t", "w", ">t-", "<t-", "><t-", "t-", "!*", "*" ],
130 136 :date_past => [ "=", ">=", "<=", "><", ">t-", "<t-", "><t-", "t-", "t", "w", "!*", "*" ],
131 137 :string => [ "=", "~", "!", "!~", "!*", "*" ],
132 138 :text => [ "~", "!~", "!*", "*" ],
133 139 :integer => [ "=", ">=", "<=", "><", "!*", "*" ],
134 140 :float => [ "=", ">=", "<=", "><", "!*", "*" ],
135 141 :relation => ["=", "=p", "=!p", "!p", "!*", "*"]}
136 142
137 143 cattr_reader :operators_by_filter_type
138 144
139 145 @@available_columns = [
140 146 QueryColumn.new(:project, :sortable => "#{Project.table_name}.name", :groupable => true),
141 147 QueryColumn.new(:tracker, :sortable => "#{Tracker.table_name}.position", :groupable => true),
142 148 QueryColumn.new(:parent, :sortable => ["#{Issue.table_name}.root_id", "#{Issue.table_name}.lft ASC"], :default_order => 'desc', :caption => :field_parent_issue),
143 149 QueryColumn.new(:status, :sortable => "#{IssueStatus.table_name}.position", :groupable => true),
144 150 QueryColumn.new(:priority, :sortable => "#{IssuePriority.table_name}.position", :default_order => 'desc', :groupable => true),
145 151 QueryColumn.new(:subject, :sortable => "#{Issue.table_name}.subject"),
146 152 QueryColumn.new(:author, :sortable => lambda {User.fields_for_order_statement("authors")}, :groupable => true),
147 153 QueryColumn.new(:assigned_to, :sortable => lambda {User.fields_for_order_statement}, :groupable => true),
148 154 QueryColumn.new(:updated_on, :sortable => "#{Issue.table_name}.updated_on", :default_order => 'desc'),
149 155 QueryColumn.new(:category, :sortable => "#{IssueCategory.table_name}.name", :groupable => true),
150 156 QueryColumn.new(:fixed_version, :sortable => lambda {Version.fields_for_order_statement}, :groupable => true),
151 157 QueryColumn.new(:start_date, :sortable => "#{Issue.table_name}.start_date"),
152 158 QueryColumn.new(:due_date, :sortable => "#{Issue.table_name}.due_date"),
153 159 QueryColumn.new(:estimated_hours, :sortable => "#{Issue.table_name}.estimated_hours"),
154 160 QueryColumn.new(:done_ratio, :sortable => "#{Issue.table_name}.done_ratio", :groupable => true),
155 161 QueryColumn.new(:created_on, :sortable => "#{Issue.table_name}.created_on", :default_order => 'desc'),
156 QueryColumn.new(:relations, :caption => :label_related_issues)
162 QueryColumn.new(:relations, :caption => :label_related_issues),
163 QueryColumn.new(:description, :inline => false)
157 164 ]
158 165 cattr_reader :available_columns
159 166
160 167 scope :visible, lambda {|*args|
161 168 user = args.shift || User.current
162 169 base = Project.allowed_to_condition(user, :view_issues, *args)
163 170 user_id = user.logged? ? user.id : 0
164 171 {
165 172 :conditions => ["(#{table_name}.project_id IS NULL OR (#{base})) AND (#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id],
166 173 :include => :project
167 174 }
168 175 }
169 176
170 177 def initialize(attributes=nil, *args)
171 178 super attributes
172 179 self.filters ||= { 'status_id' => {:operator => "o", :values => [""]} }
173 180 @is_for_all = project.nil?
174 181 end
175 182
176 183 def validate_query_filters
177 184 filters.each_key do |field|
178 185 if values_for(field)
179 186 case type_for(field)
180 187 when :integer
181 188 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+$/) }
182 189 when :float
183 190 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^[+-]?\d+(\.\d*)?$/) }
184 191 when :date, :date_past
185 192 case operator_for(field)
186 193 when "=", ">=", "<=", "><"
187 194 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && (!v.match(/^\d{4}-\d{2}-\d{2}$/) || (Date.parse(v) rescue nil).nil?) }
188 195 when ">t-", "<t-", "t-", ">t+", "<t+", "t+", "><t+", "><t-"
189 196 add_filter_error(field, :invalid) if values_for(field).detect {|v| v.present? && !v.match(/^\d+$/) }
190 197 end
191 198 end
192 199 end
193 200
194 201 add_filter_error(field, :blank) unless
195 202 # filter requires one or more values
196 203 (values_for(field) and !values_for(field).first.blank?) or
197 204 # filter doesn't require any value
198 205 ["o", "c", "!*", "*", "t", "w"].include? operator_for(field)
199 206 end if filters
200 207 end
201 208
202 209 def add_filter_error(field, message)
203 210 m = label_for(field) + " " + l(message, :scope => 'activerecord.errors.messages')
204 211 errors.add(:base, m)
205 212 end
206 213
207 214 # Returns true if the query is visible to +user+ or the current user.
208 215 def visible?(user=User.current)
209 216 (project.nil? || user.allowed_to?(:view_issues, project)) && (self.is_public? || self.user_id == user.id)
210 217 end
211 218
212 219 def editable_by?(user)
213 220 return false unless user
214 221 # Admin can edit them all and regular users can edit their private queries
215 222 return true if user.admin? || (!is_public && self.user_id == user.id)
216 223 # Members can not edit public queries that are for all project (only admin is allowed to)
217 224 is_public && !@is_for_all && user.allowed_to?(:manage_public_queries, project)
218 225 end
219 226
220 227 def trackers
221 228 @trackers ||= project.nil? ? Tracker.sorted.all : project.rolled_up_trackers
222 229 end
223 230
224 231 # Returns a hash of localized labels for all filter operators
225 232 def self.operators_labels
226 233 operators.inject({}) {|h, operator| h[operator.first] = l(operator.last); h}
227 234 end
228 235
229 236 def available_filters
230 237 return @available_filters if @available_filters
231 238 @available_filters = {
232 239 "status_id" => {
233 240 :type => :list_status, :order => 0,
234 241 :values => IssueStatus.sorted.all.collect{|s| [s.name, s.id.to_s] }
235 242 },
236 243 "tracker_id" => {
237 244 :type => :list, :order => 2, :values => trackers.collect{|s| [s.name, s.id.to_s] }
238 245 },
239 246 "priority_id" => {
240 247 :type => :list, :order => 3, :values => IssuePriority.all.collect{|s| [s.name, s.id.to_s] }
241 248 },
242 249 "subject" => { :type => :text, :order => 8 },
243 250 "created_on" => { :type => :date_past, :order => 9 },
244 251 "updated_on" => { :type => :date_past, :order => 10 },
245 252 "start_date" => { :type => :date, :order => 11 },
246 253 "due_date" => { :type => :date, :order => 12 },
247 254 "estimated_hours" => { :type => :float, :order => 13 },
248 255 "done_ratio" => { :type => :integer, :order => 14 }
249 256 }
250 257 IssueRelation::TYPES.each do |relation_type, options|
251 258 @available_filters[relation_type] = {
252 259 :type => :relation, :order => @available_filters.size + 100,
253 260 :label => options[:name]
254 261 }
255 262 end
256 263 principals = []
257 264 if project
258 265 principals += project.principals.sort
259 266 unless project.leaf?
260 267 subprojects = project.descendants.visible.all
261 268 if subprojects.any?
262 269 @available_filters["subproject_id"] = {
263 270 :type => :list_subprojects, :order => 13,
264 271 :values => subprojects.collect{|s| [s.name, s.id.to_s] }
265 272 }
266 273 principals += Principal.member_of(subprojects)
267 274 end
268 275 end
269 276 else
270 277 if all_projects.any?
271 278 # members of visible projects
272 279 principals += Principal.member_of(all_projects)
273 280 # project filter
274 281 project_values = []
275 282 if User.current.logged? && User.current.memberships.any?
276 283 project_values << ["<< #{l(:label_my_projects).downcase} >>", "mine"]
277 284 end
278 285 project_values += all_projects_values
279 286 @available_filters["project_id"] = {
280 287 :type => :list, :order => 1, :values => project_values
281 288 } unless project_values.empty?
282 289 end
283 290 end
284 291 principals.uniq!
285 292 principals.sort!
286 293 users = principals.select {|p| p.is_a?(User)}
287 294
288 295 assigned_to_values = []
289 296 assigned_to_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
290 297 assigned_to_values += (Setting.issue_group_assignment? ?
291 298 principals : users).collect{|s| [s.name, s.id.to_s] }
292 299 @available_filters["assigned_to_id"] = {
293 300 :type => :list_optional, :order => 4, :values => assigned_to_values
294 301 } unless assigned_to_values.empty?
295 302
296 303 author_values = []
297 304 author_values << ["<< #{l(:label_me)} >>", "me"] if User.current.logged?
298 305 author_values += users.collect{|s| [s.name, s.id.to_s] }
299 306 @available_filters["author_id"] = {
300 307 :type => :list, :order => 5, :values => author_values
301 308 } unless author_values.empty?
302 309
303 310 group_values = Group.all.collect {|g| [g.name, g.id.to_s] }
304 311 @available_filters["member_of_group"] = {
305 312 :type => :list_optional, :order => 6, :values => group_values
306 313 } unless group_values.empty?
307 314
308 315 role_values = Role.givable.collect {|r| [r.name, r.id.to_s] }
309 316 @available_filters["assigned_to_role"] = {
310 317 :type => :list_optional, :order => 7, :values => role_values
311 318 } unless role_values.empty?
312 319
313 320 if User.current.logged?
314 321 @available_filters["watcher_id"] = {
315 322 :type => :list, :order => 15, :values => [["<< #{l(:label_me)} >>", "me"]]
316 323 }
317 324 end
318 325
319 326 if project
320 327 # project specific filters
321 328 categories = project.issue_categories.all
322 329 unless categories.empty?
323 330 @available_filters["category_id"] = {
324 331 :type => :list_optional, :order => 6,
325 332 :values => categories.collect{|s| [s.name, s.id.to_s] }
326 333 }
327 334 end
328 335 versions = project.shared_versions.all
329 336 unless versions.empty?
330 337 @available_filters["fixed_version_id"] = {
331 338 :type => :list_optional, :order => 7,
332 339 :values => versions.sort.collect{|s| ["#{s.project.name} - #{s.name}", s.id.to_s] }
333 340 }
334 341 end
335 342 add_custom_fields_filters(project.all_issue_custom_fields)
336 343 else
337 344 # global filters for cross project issue list
338 345 system_shared_versions = Version.visible.find_all_by_sharing('system')
339 346 unless system_shared_versions.empty?
340 347 @available_filters["fixed_version_id"] = {
341 348 :type => :list_optional, :order => 7,
342 349 :values => system_shared_versions.sort.collect{|s|
343 350 ["#{s.project.name} - #{s.name}", s.id.to_s]
344 351 }
345 352 }
346 353 end
347 354 add_custom_fields_filters(IssueCustomField.where(:is_filter => true, :is_for_all => true).all)
348 355 end
349 356 add_associations_custom_fields_filters :project, :author, :assigned_to, :fixed_version
350 357 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
351 358 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
352 359 @available_filters["is_private"] = {
353 360 :type => :list, :order => 16,
354 361 :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]]
355 362 }
356 363 end
357 364 Tracker.disabled_core_fields(trackers).each {|field|
358 365 @available_filters.delete field
359 366 }
360 367 @available_filters.each do |field, options|
361 368 options[:name] ||= l(options[:label] || "field_#{field}".gsub(/_id$/, ''))
362 369 end
363 370 @available_filters
364 371 end
365 372
366 373 # Returns a representation of the available filters for JSON serialization
367 374 def available_filters_as_json
368 375 json = {}
369 376 available_filters.each do |field, options|
370 377 json[field] = options.slice(:type, :name, :values).stringify_keys
371 378 end
372 379 json
373 380 end
374 381
375 382 def all_projects
376 383 @all_projects ||= Project.visible.all
377 384 end
378 385
379 386 def all_projects_values
380 387 return @all_projects_values if @all_projects_values
381 388
382 389 values = []
383 390 Project.project_tree(all_projects) do |p, level|
384 391 prefix = (level > 0 ? ('--' * level + ' ') : '')
385 392 values << ["#{prefix}#{p.name}", p.id.to_s]
386 393 end
387 394 @all_projects_values = values
388 395 end
389 396
390 397 def add_filter(field, operator, values)
391 398 # values must be an array
392 399 return unless values.nil? || values.is_a?(Array)
393 400 # check if field is defined as an available filter
394 401 if available_filters.has_key? field
395 402 filter_options = available_filters[field]
396 403 # check if operator is allowed for that filter
397 404 #if @@operators_by_filter_type[filter_options[:type]].include? operator
398 405 # allowed_values = values & ([""] + (filter_options[:values] || []).collect {|val| val[1]})
399 406 # filters[field] = {:operator => operator, :values => allowed_values } if (allowed_values.first and !allowed_values.first.empty?) or ["o", "c", "!*", "*", "t"].include? operator
400 407 #end
401 408 filters[field] = {:operator => operator, :values => (values || [''])}
402 409 end
403 410 end
404 411
405 412 def add_short_filter(field, expression)
406 413 return unless expression && available_filters.has_key?(field)
407 414 field_type = available_filters[field][:type]
408 415 @@operators_by_filter_type[field_type].sort.reverse.detect do |operator|
409 416 next unless expression =~ /^#{Regexp.escape(operator)}(.*)$/
410 417 add_filter field, operator, $1.present? ? $1.split('|') : ['']
411 418 end || add_filter(field, '=', expression.split('|'))
412 419 end
413 420
414 421 # Add multiple filters using +add_filter+
415 422 def add_filters(fields, operators, values)
416 423 if fields.is_a?(Array) && operators.is_a?(Hash) && (values.nil? || values.is_a?(Hash))
417 424 fields.each do |field|
418 425 add_filter(field, operators[field], values && values[field])
419 426 end
420 427 end
421 428 end
422 429
423 430 def has_filter?(field)
424 431 filters and filters[field]
425 432 end
426 433
427 434 def type_for(field)
428 435 available_filters[field][:type] if available_filters.has_key?(field)
429 436 end
430 437
431 438 def operator_for(field)
432 439 has_filter?(field) ? filters[field][:operator] : nil
433 440 end
434 441
435 442 def values_for(field)
436 443 has_filter?(field) ? filters[field][:values] : nil
437 444 end
438 445
439 446 def value_for(field, index=0)
440 447 (values_for(field) || [])[index]
441 448 end
442 449
443 450 def label_for(field)
444 451 label = available_filters[field][:name] if available_filters.has_key?(field)
445 452 label ||= l("field_#{field.to_s.gsub(/_id$/, '')}", :default => field)
446 453 end
447 454
448 455 def available_columns
449 456 return @available_columns if @available_columns
450 457 @available_columns = ::Query.available_columns.dup
451 458 @available_columns += (project ?
452 459 project.all_issue_custom_fields :
453 460 IssueCustomField.all
454 461 ).collect {|cf| QueryCustomFieldColumn.new(cf) }
455 462
456 463 if User.current.allowed_to?(:view_time_entries, project, :global => true)
457 464 index = nil
458 465 @available_columns.each_with_index {|column, i| index = i if column.name == :estimated_hours}
459 466 index = (index ? index + 1 : -1)
460 467 # insert the column after estimated_hours or at the end
461 468 @available_columns.insert index, QueryColumn.new(:spent_hours,
462 469 :sortable => "(SELECT COALESCE(SUM(hours), 0) FROM #{TimeEntry.table_name} WHERE #{TimeEntry.table_name}.issue_id = #{Issue.table_name}.id)",
463 470 :default_order => 'desc',
464 471 :caption => :label_spent_time
465 472 )
466 473 end
467 474
468 475 if User.current.allowed_to?(:set_issues_private, nil, :global => true) ||
469 476 User.current.allowed_to?(:set_own_issues_private, nil, :global => true)
470 477 @available_columns << QueryColumn.new(:is_private, :sortable => "#{Issue.table_name}.is_private")
471 478 end
472 479
473 480 disabled_fields = Tracker.disabled_core_fields(trackers).map {|field| field.sub(/_id$/, '')}
474 481 @available_columns.reject! {|column|
475 482 disabled_fields.include?(column.name.to_s)
476 483 }
477 484
478 485 @available_columns
479 486 end
480 487
481 488 def self.available_columns=(v)
482 489 self.available_columns = (v)
483 490 end
484 491
485 492 def self.add_available_column(column)
486 493 self.available_columns << (column) if column.is_a?(QueryColumn)
487 494 end
488 495
489 496 # Returns an array of columns that can be used to group the results
490 497 def groupable_columns
491 498 available_columns.select {|c| c.groupable}
492 499 end
493 500
494 501 # Returns a Hash of columns and the key for sorting
495 502 def sortable_columns
496 503 {'id' => "#{Issue.table_name}.id"}.merge(available_columns.inject({}) {|h, column|
497 504 h[column.name.to_s] = column.sortable
498 505 h
499 506 })
500 507 end
501 508
502 509 def columns
503 510 # preserve the column_names order
504 511 (has_default_columns? ? default_columns_names : column_names).collect do |name|
505 512 available_columns.find { |col| col.name == name }
506 513 end.compact
507 514 end
508 515
516 def inline_columns
517 columns.select(&:inline?)
518 end
519
520 def block_columns
521 columns.reject(&:inline?)
522 end
523
524 def available_inline_columns
525 available_columns.select(&:inline?)
526 end
527
528 def available_block_columns
529 available_columns.reject(&:inline?)
530 end
531
509 532 def default_columns_names
510 533 @default_columns_names ||= begin
511 534 default_columns = Setting.issue_list_default_columns.map(&:to_sym)
512 535
513 536 project.present? ? default_columns : [:project] | default_columns
514 537 end
515 538 end
516 539
517 540 def column_names=(names)
518 541 if names
519 542 names = names.select {|n| n.is_a?(Symbol) || !n.blank? }
520 543 names = names.collect {|n| n.is_a?(Symbol) ? n : n.to_sym }
521 544 # Set column_names to nil if default columns
522 545 if names == default_columns_names
523 546 names = nil
524 547 end
525 548 end
526 549 write_attribute(:column_names, names)
527 550 end
528 551
529 552 def has_column?(column)
530 553 column_names && column_names.include?(column.is_a?(QueryColumn) ? column.name : column)
531 554 end
532 555
533 556 def has_default_columns?
534 557 column_names.nil? || column_names.empty?
535 558 end
536 559
537 560 def sort_criteria=(arg)
538 561 c = []
539 562 if arg.is_a?(Hash)
540 563 arg = arg.keys.sort.collect {|k| arg[k]}
541 564 end
542 565 c = arg.select {|k,o| !k.to_s.blank?}.slice(0,3).collect {|k,o| [k.to_s, (o == 'desc' || o == false) ? 'desc' : 'asc']}
543 566 write_attribute(:sort_criteria, c)
544 567 end
545 568
546 569 def sort_criteria
547 570 read_attribute(:sort_criteria) || []
548 571 end
549 572
550 573 def sort_criteria_key(arg)
551 574 sort_criteria && sort_criteria[arg] && sort_criteria[arg].first
552 575 end
553 576
554 577 def sort_criteria_order(arg)
555 578 sort_criteria && sort_criteria[arg] && sort_criteria[arg].last
556 579 end
557 580
558 581 def sort_criteria_order_for(key)
559 582 sort_criteria.detect {|k, order| key.to_s == k}.try(:last)
560 583 end
561 584
562 585 # Returns the SQL sort order that should be prepended for grouping
563 586 def group_by_sort_order
564 587 if grouped? && (column = group_by_column)
565 588 order = sort_criteria_order_for(column.name) || column.default_order
566 589 column.sortable.is_a?(Array) ?
567 590 column.sortable.collect {|s| "#{s} #{order}"}.join(',') :
568 591 "#{column.sortable} #{order}"
569 592 end
570 593 end
571 594
572 595 # Returns true if the query is a grouped query
573 596 def grouped?
574 597 !group_by_column.nil?
575 598 end
576 599
577 600 def group_by_column
578 601 groupable_columns.detect {|c| c.groupable && c.name.to_s == group_by}
579 602 end
580 603
581 604 def group_by_statement
582 605 group_by_column.try(:groupable)
583 606 end
584 607
585 608 def project_statement
586 609 project_clauses = []
587 610 if project && !project.descendants.active.empty?
588 611 ids = [project.id]
589 612 if has_filter?("subproject_id")
590 613 case operator_for("subproject_id")
591 614 when '='
592 615 # include the selected subprojects
593 616 ids += values_for("subproject_id").each(&:to_i)
594 617 when '!*'
595 618 # main project only
596 619 else
597 620 # all subprojects
598 621 ids += project.descendants.collect(&:id)
599 622 end
600 623 elsif Setting.display_subprojects_issues?
601 624 ids += project.descendants.collect(&:id)
602 625 end
603 626 project_clauses << "#{Project.table_name}.id IN (%s)" % ids.join(',')
604 627 elsif project
605 628 project_clauses << "#{Project.table_name}.id = %d" % project.id
606 629 end
607 630 project_clauses.any? ? project_clauses.join(' AND ') : nil
608 631 end
609 632
610 633 def statement
611 634 # filters clauses
612 635 filters_clauses = []
613 636 filters.each_key do |field|
614 637 next if field == "subproject_id"
615 638 v = values_for(field).clone
616 639 next unless v and !v.empty?
617 640 operator = operator_for(field)
618 641
619 642 # "me" value subsitution
620 643 if %w(assigned_to_id author_id watcher_id).include?(field)
621 644 if v.delete("me")
622 645 if User.current.logged?
623 646 v.push(User.current.id.to_s)
624 647 v += User.current.group_ids.map(&:to_s) if field == 'assigned_to_id'
625 648 else
626 649 v.push("0")
627 650 end
628 651 end
629 652 end
630 653
631 654 if field == 'project_id'
632 655 if v.delete('mine')
633 656 v += User.current.memberships.map(&:project_id).map(&:to_s)
634 657 end
635 658 end
636 659
637 660 if field =~ /cf_(\d+)$/
638 661 # custom field
639 662 filters_clauses << sql_for_custom_field(field, operator, v, $1)
640 663 elsif respond_to?("sql_for_#{field}_field")
641 664 # specific statement
642 665 filters_clauses << send("sql_for_#{field}_field", field, operator, v)
643 666 else
644 667 # regular field
645 668 filters_clauses << '(' + sql_for_field(field, operator, v, Issue.table_name, field) + ')'
646 669 end
647 670 end if filters and valid?
648 671
649 672 filters_clauses << project_statement
650 673 filters_clauses.reject!(&:blank?)
651 674
652 675 filters_clauses.any? ? filters_clauses.join(' AND ') : nil
653 676 end
654 677
655 678 # Returns the issue count
656 679 def issue_count
657 680 Issue.visible.count(:include => [:status, :project], :conditions => statement)
658 681 rescue ::ActiveRecord::StatementInvalid => e
659 682 raise StatementInvalid.new(e.message)
660 683 end
661 684
662 685 # Returns the issue count by group or nil if query is not grouped
663 686 def issue_count_by_group
664 687 r = nil
665 688 if grouped?
666 689 begin
667 690 # Rails3 will raise an (unexpected) RecordNotFound if there's only a nil group value
668 691 r = Issue.visible.count(:group => group_by_statement, :include => [:status, :project], :conditions => statement)
669 692 rescue ActiveRecord::RecordNotFound
670 693 r = {nil => issue_count}
671 694 end
672 695 c = group_by_column
673 696 if c.is_a?(QueryCustomFieldColumn)
674 697 r = r.keys.inject({}) {|h, k| h[c.custom_field.cast_value(k)] = r[k]; h}
675 698 end
676 699 end
677 700 r
678 701 rescue ::ActiveRecord::StatementInvalid => e
679 702 raise StatementInvalid.new(e.message)
680 703 end
681 704
682 705 # Returns the issues
683 706 # Valid options are :order, :offset, :limit, :include, :conditions
684 707 def issues(options={})
685 708 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
686 709 order_option = nil if order_option.blank?
687 710
688 711 issues = Issue.visible.where(options[:conditions]).all(
689 712 :include => ([:status, :project] + (options[:include] || [])).uniq,
690 713 :conditions => statement,
691 714 :order => order_option,
692 715 :joins => joins_for_order_statement(order_option),
693 716 :limit => options[:limit],
694 717 :offset => options[:offset]
695 718 )
696 719
697 720 if has_column?(:spent_hours)
698 721 Issue.load_visible_spent_hours(issues)
699 722 end
700 723 if has_column?(:relations)
701 724 Issue.load_visible_relations(issues)
702 725 end
703 726 issues
704 727 rescue ::ActiveRecord::StatementInvalid => e
705 728 raise StatementInvalid.new(e.message)
706 729 end
707 730
708 731 # Returns the issues ids
709 732 def issue_ids(options={})
710 733 order_option = [group_by_sort_order, options[:order]].reject {|s| s.blank?}.join(',')
711 734 order_option = nil if order_option.blank?
712 735
713 736 Issue.visible.scoped(:conditions => options[:conditions]).scoped(:include => ([:status, :project] + (options[:include] || [])).uniq,
714 737 :conditions => statement,
715 738 :order => order_option,
716 739 :joins => joins_for_order_statement(order_option),
717 740 :limit => options[:limit],
718 741 :offset => options[:offset]).find_ids
719 742 rescue ::ActiveRecord::StatementInvalid => e
720 743 raise StatementInvalid.new(e.message)
721 744 end
722 745
723 746 # Returns the journals
724 747 # Valid options are :order, :offset, :limit
725 748 def journals(options={})
726 749 Journal.visible.all(
727 750 :include => [:details, :user, {:issue => [:project, :author, :tracker, :status]}],
728 751 :conditions => statement,
729 752 :order => options[:order],
730 753 :limit => options[:limit],
731 754 :offset => options[:offset]
732 755 )
733 756 rescue ::ActiveRecord::StatementInvalid => e
734 757 raise StatementInvalid.new(e.message)
735 758 end
736 759
737 760 # Returns the versions
738 761 # Valid options are :conditions
739 762 def versions(options={})
740 763 Version.visible.where(options[:conditions]).all(
741 764 :include => :project,
742 765 :conditions => project_statement
743 766 )
744 767 rescue ::ActiveRecord::StatementInvalid => e
745 768 raise StatementInvalid.new(e.message)
746 769 end
747 770
748 771 def sql_for_watcher_id_field(field, operator, value)
749 772 db_table = Watcher.table_name
750 773 "#{Issue.table_name}.id #{ operator == '=' ? 'IN' : 'NOT IN' } (SELECT #{db_table}.watchable_id FROM #{db_table} WHERE #{db_table}.watchable_type='Issue' AND " +
751 774 sql_for_field(field, '=', value, db_table, 'user_id') + ')'
752 775 end
753 776
754 777 def sql_for_member_of_group_field(field, operator, value)
755 778 if operator == '*' # Any group
756 779 groups = Group.all
757 780 operator = '=' # Override the operator since we want to find by assigned_to
758 781 elsif operator == "!*"
759 782 groups = Group.all
760 783 operator = '!' # Override the operator since we want to find by assigned_to
761 784 else
762 785 groups = Group.find_all_by_id(value)
763 786 end
764 787 groups ||= []
765 788
766 789 members_of_groups = groups.inject([]) {|user_ids, group|
767 790 if group && group.user_ids.present?
768 791 user_ids << group.user_ids
769 792 end
770 793 user_ids.flatten.uniq.compact
771 794 }.sort.collect(&:to_s)
772 795
773 796 '(' + sql_for_field("assigned_to_id", operator, members_of_groups, Issue.table_name, "assigned_to_id", false) + ')'
774 797 end
775 798
776 799 def sql_for_assigned_to_role_field(field, operator, value)
777 800 case operator
778 801 when "*", "!*" # Member / Not member
779 802 sw = operator == "!*" ? 'NOT' : ''
780 803 nl = operator == "!*" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
781 804 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}" +
782 805 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id))"
783 806 when "=", "!"
784 807 role_cond = value.any? ?
785 808 "#{MemberRole.table_name}.role_id IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")" :
786 809 "1=0"
787 810
788 811 sw = operator == "!" ? 'NOT' : ''
789 812 nl = operator == "!" ? "#{Issue.table_name}.assigned_to_id IS NULL OR" : ''
790 813 "(#{nl} #{Issue.table_name}.assigned_to_id #{sw} IN (SELECT DISTINCT #{Member.table_name}.user_id FROM #{Member.table_name}, #{MemberRole.table_name}" +
791 814 " WHERE #{Member.table_name}.project_id = #{Issue.table_name}.project_id AND #{Member.table_name}.id = #{MemberRole.table_name}.member_id AND #{role_cond}))"
792 815 end
793 816 end
794 817
795 818 def sql_for_is_private_field(field, operator, value)
796 819 op = (operator == "=" ? 'IN' : 'NOT IN')
797 820 va = value.map {|v| v == '0' ? connection.quoted_false : connection.quoted_true}.uniq.join(',')
798 821
799 822 "#{Issue.table_name}.is_private #{op} (#{va})"
800 823 end
801 824
802 825 def sql_for_relations(field, operator, value, options={})
803 826 relation_options = IssueRelation::TYPES[field]
804 827 return relation_options unless relation_options
805 828
806 829 relation_type = field
807 830 join_column, target_join_column = "issue_from_id", "issue_to_id"
808 831 if relation_options[:reverse] || options[:reverse]
809 832 relation_type = relation_options[:reverse] || relation_type
810 833 join_column, target_join_column = target_join_column, join_column
811 834 end
812 835
813 836 sql = case operator
814 837 when "*", "!*"
815 838 op = (operator == "*" ? 'IN' : 'NOT IN')
816 839 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}')"
817 840 when "=", "!"
818 841 op = (operator == "=" ? 'IN' : 'NOT IN')
819 842 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name} WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = #{value.first.to_i})"
820 843 when "=p", "=!p", "!p"
821 844 op = (operator == "!p" ? 'NOT IN' : 'IN')
822 845 comp = (operator == "=!p" ? '<>' : '=')
823 846 "#{Issue.table_name}.id #{op} (SELECT DISTINCT #{IssueRelation.table_name}.#{join_column} FROM #{IssueRelation.table_name}, #{Issue.table_name} relissues WHERE #{IssueRelation.table_name}.relation_type = '#{connection.quote_string(relation_type)}' AND #{IssueRelation.table_name}.#{target_join_column} = relissues.id AND relissues.project_id #{comp} #{value.first.to_i})"
824 847 end
825 848
826 849 if relation_options[:sym] == field && !options[:reverse]
827 850 sqls = [sql, sql_for_relations(field, operator, value, :reverse => true)]
828 851 sqls.join(["!", "!*", "!p"].include?(operator) ? " AND " : " OR ")
829 852 else
830 853 sql
831 854 end
832 855 end
833 856
834 857 IssueRelation::TYPES.keys.each do |relation_type|
835 858 alias_method "sql_for_#{relation_type}_field".to_sym, :sql_for_relations
836 859 end
837 860
838 861 private
839 862
840 863 def sql_for_custom_field(field, operator, value, custom_field_id)
841 864 db_table = CustomValue.table_name
842 865 db_field = 'value'
843 866 filter = @available_filters[field]
844 867 return nil unless filter
845 868 if filter[:format] == 'user'
846 869 if value.delete('me')
847 870 value.push User.current.id.to_s
848 871 end
849 872 end
850 873 not_in = nil
851 874 if operator == '!'
852 875 # Makes ! operator work for custom fields with multiple values
853 876 operator = '='
854 877 not_in = 'NOT'
855 878 end
856 879 customized_key = "id"
857 880 customized_class = Issue
858 881 if field =~ /^(.+)\.cf_/
859 882 assoc = $1
860 883 customized_key = "#{assoc}_id"
861 884 customized_class = Issue.reflect_on_association(assoc.to_sym).klass.base_class rescue nil
862 885 raise "Unknown Issue association #{assoc}" unless customized_class
863 886 end
864 887 "#{Issue.table_name}.#{customized_key} #{not_in} IN (SELECT #{customized_class.table_name}.id FROM #{customized_class.table_name} LEFT OUTER JOIN #{db_table} ON #{db_table}.customized_type='#{customized_class}' AND #{db_table}.customized_id=#{customized_class.table_name}.id AND #{db_table}.custom_field_id=#{custom_field_id} WHERE " +
865 888 sql_for_field(field, operator, value, db_table, db_field, true) + ')'
866 889 end
867 890
868 891 # Helper method to generate the WHERE sql for a +field+, +operator+ and a +value+
869 892 def sql_for_field(field, operator, value, db_table, db_field, is_custom_filter=false)
870 893 sql = ''
871 894 case operator
872 895 when "="
873 896 if value.any?
874 897 case type_for(field)
875 898 when :date, :date_past
876 899 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), (Date.parse(value.first) rescue nil))
877 900 when :integer
878 901 if is_custom_filter
879 902 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) = #{value.first.to_i})"
880 903 else
881 904 sql = "#{db_table}.#{db_field} = #{value.first.to_i}"
882 905 end
883 906 when :float
884 907 if is_custom_filter
885 908 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5})"
886 909 else
887 910 sql = "#{db_table}.#{db_field} BETWEEN #{value.first.to_f - 1e-5} AND #{value.first.to_f + 1e-5}"
888 911 end
889 912 else
890 913 sql = "#{db_table}.#{db_field} IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + ")"
891 914 end
892 915 else
893 916 # IN an empty set
894 917 sql = "1=0"
895 918 end
896 919 when "!"
897 920 if value.any?
898 921 sql = "(#{db_table}.#{db_field} IS NULL OR #{db_table}.#{db_field} NOT IN (" + value.collect{|val| "'#{connection.quote_string(val)}'"}.join(",") + "))"
899 922 else
900 923 # NOT IN an empty set
901 924 sql = "1=1"
902 925 end
903 926 when "!*"
904 927 sql = "#{db_table}.#{db_field} IS NULL"
905 928 sql << " OR #{db_table}.#{db_field} = ''" if is_custom_filter
906 929 when "*"
907 930 sql = "#{db_table}.#{db_field} IS NOT NULL"
908 931 sql << " AND #{db_table}.#{db_field} <> ''" if is_custom_filter
909 932 when ">="
910 933 if [:date, :date_past].include?(type_for(field))
911 934 sql = date_clause(db_table, db_field, (Date.parse(value.first) rescue nil), nil)
912 935 else
913 936 if is_custom_filter
914 937 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) >= #{value.first.to_f})"
915 938 else
916 939 sql = "#{db_table}.#{db_field} >= #{value.first.to_f}"
917 940 end
918 941 end
919 942 when "<="
920 943 if [:date, :date_past].include?(type_for(field))
921 944 sql = date_clause(db_table, db_field, nil, (Date.parse(value.first) rescue nil))
922 945 else
923 946 if is_custom_filter
924 947 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) <= #{value.first.to_f})"
925 948 else
926 949 sql = "#{db_table}.#{db_field} <= #{value.first.to_f}"
927 950 end
928 951 end
929 952 when "><"
930 953 if [:date, :date_past].include?(type_for(field))
931 954 sql = date_clause(db_table, db_field, (Date.parse(value[0]) rescue nil), (Date.parse(value[1]) rescue nil))
932 955 else
933 956 if is_custom_filter
934 957 sql = "(#{db_table}.#{db_field} <> '' AND CAST(#{db_table}.#{db_field} AS decimal(60,3)) BETWEEN #{value[0].to_f} AND #{value[1].to_f})"
935 958 else
936 959 sql = "#{db_table}.#{db_field} BETWEEN #{value[0].to_f} AND #{value[1].to_f}"
937 960 end
938 961 end
939 962 when "o"
940 963 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_false})" if field == "status_id"
941 964 when "c"
942 965 sql = "#{Issue.table_name}.status_id IN (SELECT id FROM #{IssueStatus.table_name} WHERE is_closed=#{connection.quoted_true})" if field == "status_id"
943 966 when "><t-"
944 967 # between today - n days and today
945 968 sql = relative_date_clause(db_table, db_field, - value.first.to_i, 0)
946 969 when ">t-"
947 970 # >= today - n days
948 971 sql = relative_date_clause(db_table, db_field, - value.first.to_i, nil)
949 972 when "<t-"
950 973 # <= today - n days
951 974 sql = relative_date_clause(db_table, db_field, nil, - value.first.to_i)
952 975 when "t-"
953 976 # = n days in past
954 977 sql = relative_date_clause(db_table, db_field, - value.first.to_i, - value.first.to_i)
955 978 when "><t+"
956 979 # between today and today + n days
957 980 sql = relative_date_clause(db_table, db_field, 0, value.first.to_i)
958 981 when ">t+"
959 982 # >= today + n days
960 983 sql = relative_date_clause(db_table, db_field, value.first.to_i, nil)
961 984 when "<t+"
962 985 # <= today + n days
963 986 sql = relative_date_clause(db_table, db_field, nil, value.first.to_i)
964 987 when "t+"
965 988 # = today + n days
966 989 sql = relative_date_clause(db_table, db_field, value.first.to_i, value.first.to_i)
967 990 when "t"
968 991 # = today
969 992 sql = relative_date_clause(db_table, db_field, 0, 0)
970 993 when "w"
971 994 # = this week
972 995 first_day_of_week = l(:general_first_day_of_week).to_i
973 996 day_of_week = Date.today.cwday
974 997 days_ago = (day_of_week >= first_day_of_week ? day_of_week - first_day_of_week : day_of_week + 7 - first_day_of_week)
975 998 sql = relative_date_clause(db_table, db_field, - days_ago, - days_ago + 6)
976 999 when "~"
977 1000 sql = "LOWER(#{db_table}.#{db_field}) LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
978 1001 when "!~"
979 1002 sql = "LOWER(#{db_table}.#{db_field}) NOT LIKE '%#{connection.quote_string(value.first.to_s.downcase)}%'"
980 1003 else
981 1004 raise "Unknown query operator #{operator}"
982 1005 end
983 1006
984 1007 return sql
985 1008 end
986 1009
987 1010 def add_custom_fields_filters(custom_fields, assoc=nil)
988 1011 return unless custom_fields.present?
989 1012 @available_filters ||= {}
990 1013
991 1014 custom_fields.select(&:is_filter?).each do |field|
992 1015 case field.field_format
993 1016 when "text"
994 1017 options = { :type => :text, :order => 20 }
995 1018 when "list"
996 1019 options = { :type => :list_optional, :values => field.possible_values, :order => 20}
997 1020 when "date"
998 1021 options = { :type => :date, :order => 20 }
999 1022 when "bool"
1000 1023 options = { :type => :list, :values => [[l(:general_text_yes), "1"], [l(:general_text_no), "0"]], :order => 20 }
1001 1024 when "int"
1002 1025 options = { :type => :integer, :order => 20 }
1003 1026 when "float"
1004 1027 options = { :type => :float, :order => 20 }
1005 1028 when "user", "version"
1006 1029 next unless project
1007 1030 values = field.possible_values_options(project)
1008 1031 if User.current.logged? && field.field_format == 'user'
1009 1032 values.unshift ["<< #{l(:label_me)} >>", "me"]
1010 1033 end
1011 1034 options = { :type => :list_optional, :values => values, :order => 20}
1012 1035 else
1013 1036 options = { :type => :string, :order => 20 }
1014 1037 end
1015 1038 filter_id = "cf_#{field.id}"
1016 1039 filter_name = field.name
1017 1040 if assoc.present?
1018 1041 filter_id = "#{assoc}.#{filter_id}"
1019 1042 filter_name = l("label_attribute_of_#{assoc}", :name => filter_name)
1020 1043 end
1021 1044 @available_filters[filter_id] = options.merge({
1022 1045 :name => filter_name,
1023 1046 :format => field.field_format,
1024 1047 :field => field
1025 1048 })
1026 1049 end
1027 1050 end
1028 1051
1029 1052 def add_associations_custom_fields_filters(*associations)
1030 1053 fields_by_class = CustomField.where(:is_filter => true).group_by(&:class)
1031 1054 associations.each do |assoc|
1032 1055 association_klass = Issue.reflect_on_association(assoc).klass
1033 1056 fields_by_class.each do |field_class, fields|
1034 1057 if field_class.customized_class <= association_klass
1035 1058 add_custom_fields_filters(fields, assoc)
1036 1059 end
1037 1060 end
1038 1061 end
1039 1062 end
1040 1063
1041 1064 # Returns a SQL clause for a date or datetime field.
1042 1065 def date_clause(table, field, from, to)
1043 1066 s = []
1044 1067 if from
1045 1068 from_yesterday = from - 1
1046 1069 from_yesterday_time = Time.local(from_yesterday.year, from_yesterday.month, from_yesterday.day)
1047 1070 if self.class.default_timezone == :utc
1048 1071 from_yesterday_time = from_yesterday_time.utc
1049 1072 end
1050 1073 s << ("#{table}.#{field} > '%s'" % [connection.quoted_date(from_yesterday_time.end_of_day)])
1051 1074 end
1052 1075 if to
1053 1076 to_time = Time.local(to.year, to.month, to.day)
1054 1077 if self.class.default_timezone == :utc
1055 1078 to_time = to_time.utc
1056 1079 end
1057 1080 s << ("#{table}.#{field} <= '%s'" % [connection.quoted_date(to_time.end_of_day)])
1058 1081 end
1059 1082 s.join(' AND ')
1060 1083 end
1061 1084
1062 1085 # Returns a SQL clause for a date or datetime field using relative dates.
1063 1086 def relative_date_clause(table, field, days_from, days_to)
1064 1087 date_clause(table, field, (days_from ? Date.today + days_from : nil), (days_to ? Date.today + days_to : nil))
1065 1088 end
1066 1089
1067 1090 # Additional joins required for the given sort options
1068 1091 def joins_for_order_statement(order_options)
1069 1092 joins = []
1070 1093
1071 1094 if order_options
1072 1095 if order_options.include?('authors')
1073 1096 joins << "LEFT OUTER JOIN #{User.table_name} authors ON authors.id = #{Issue.table_name}.author_id"
1074 1097 end
1075 1098 order_options.scan(/cf_\d+/).uniq.each do |name|
1076 1099 column = available_columns.detect {|c| c.name.to_s == name}
1077 1100 join = column && column.custom_field.join_for_order_statement
1078 1101 if join
1079 1102 joins << join
1080 1103 end
1081 1104 end
1082 1105 end
1083 1106
1084 1107 joins.any? ? joins.join(' ') : nil
1085 1108 end
1086 1109 end
@@ -1,42 +1,49
1 1 <%= form_tag({}) do -%>
2 2 <%= hidden_field_tag 'back_url', url_for(params), :id => nil %>
3 3 <div class="autoscroll">
4 4 <table class="list issues">
5 5 <thead>
6 6 <tr>
7 7 <th class="checkbox hide-when-print">
8 8 <%= link_to image_tag('toggle_check.png'), {},
9 9 :onclick => 'toggleIssuesSelection(this); return false;',
10 10 :title => "#{l(:button_check_all)}/#{l(:button_uncheck_all)}" %>
11 11 </th>
12 12 <%= sort_header_tag('id', :caption => '#', :default_order => 'desc') %>
13 <% query.columns.each do |column| %>
13 <% query.inline_columns.each do |column| %>
14 14 <%= column_header(column) %>
15 15 <% end %>
16 16 </tr>
17 17 </thead>
18 18 <% previous_group = false %>
19 19 <tbody>
20 20 <% issue_list(issues) do |issue, level| -%>
21 21 <% if @query.grouped? && (group = @query.group_by_column.value(issue)) != previous_group %>
22 22 <% reset_cycle %>
23 23 <tr class="group open">
24 <td colspan="<%= query.columns.size + 2 %>">
24 <td colspan="<%= query.inline_columns.size + 2 %>">
25 25 <span class="expander" onclick="toggleRowGroup(this);">&nbsp;</span>
26 26 <%= group.blank? ? l(:label_none) : column_content(@query.group_by_column, issue) %> <span class="count"><%= @issue_count_by_group[group] %></span>
27 27 <%= link_to_function("#{l(:button_collapse_all)}/#{l(:button_expand_all)}",
28 28 "toggleAllRowGroups(this)", :class => 'toggle-all') %>
29 29 </td>
30 30 </tr>
31 31 <% previous_group = group %>
32 32 <% end %>
33 33 <tr id="issue-<%= issue.id %>" class="hascontextmenu <%= cycle('odd', 'even') %> <%= issue.css_classes %> <%= level > 0 ? "idnt idnt-#{level}" : nil %>">
34 34 <td class="checkbox hide-when-print"><%= check_box_tag("ids[]", issue.id, false, :id => nil) %></td>
35 35 <td class="id"><%= link_to issue.id, issue_path(issue) %></td>
36 <%= raw query.columns.map {|column| "<td class=\"#{column.css_classes}\">#{column_content(column, issue)}</td>"}.join %>
36 <%= raw query.inline_columns.map {|column| "<td class=\"#{column.css_classes}\">#{column_content(column, issue)}</td>"}.join %>
37 37 </tr>
38 <% @query.block_columns.each do |column|
39 if (text = column_content(column, issue)) && text.present? -%>
40 <tr class="<%= current_cycle %>">
41 <td colspan="<%= @query.inline_columns.size + 2 %>" class="<%= column.css_classes %>"><%= text %></td>
42 </tr>
43 <% end -%>
44 <% end -%>
38 45 <% end -%>
39 46 </tbody>
40 47 </table>
41 48 </div>
42 49 <% end -%>
@@ -1,104 +1,108
1 1 <div class="contextual">
2 2 <% if !@query.new_record? && @query.editable_by?(User.current) %>
3 3 <%= link_to l(:button_edit), edit_query_path(@query), :class => 'icon icon-edit' %>
4 4 <%= delete_link query_path(@query) %>
5 5 <% end %>
6 6 </div>
7 7
8 8 <h2><%= @query.new_record? ? l(:label_issue_plural) : h(@query.name) %></h2>
9 9 <% html_title(@query.new_record? ? l(:label_issue_plural) : @query.name) %>
10 10
11 11 <%= form_tag({ :controller => 'issues', :action => 'index', :project_id => @project },
12 12 :method => :get, :id => 'query_form') do %>
13 13 <%= hidden_field_tag 'set_filter', '1' %>
14 14 <div id="query_form_content" class="hide-when-print">
15 15 <fieldset id="filters" class="collapsible <%= @query.new_record? ? "" : "collapsed" %>">
16 16 <legend onclick="toggleFieldset(this);"><%= l(:label_filter_plural) %></legend>
17 17 <div style="<%= @query.new_record? ? "" : "display: none;" %>">
18 18 <%= render :partial => 'queries/filters', :locals => {:query => @query} %>
19 19 </div>
20 20 </fieldset>
21 21 <fieldset class="collapsible collapsed">
22 22 <legend onclick="toggleFieldset(this);"><%= l(:label_options) %></legend>
23 23 <div style="display: none;">
24 24 <table>
25 25 <tr>
26 26 <td><%= l(:field_column_names) %></td>
27 27 <td><%= render :partial => 'queries/columns', :locals => {:query => @query} %></td>
28 28 </tr>
29 29 <tr>
30 30 <td><label for='group_by'><%= l(:field_group_by) %></label></td>
31 31 <td><%= select_tag('group_by',
32 32 options_for_select(
33 33 [[]] + @query.groupable_columns.collect {|c| [c.caption, c.name.to_s]},
34 34 @query.group_by)
35 35 ) %></td>
36 36 </tr>
37 <tr>
38 <td><%= l(:button_show) %></td>
39 <td><%= available_block_columns_tags(@query) %></td>
40 </tr>
37 41 </table>
38 42 </div>
39 43 </fieldset>
40 44 </div>
41 45 <p class="buttons hide-when-print">
42 46
43 47 <%= link_to_function l(:button_apply), 'submit_query_form("query_form")', :class => 'icon icon-checked' %>
44 48 <%= link_to l(:button_clear), { :set_filter => 1, :project_id => @project }, :class => 'icon icon-reload' %>
45 49 <% if @query.new_record? && User.current.allowed_to?(:save_queries, @project, :global => true) %>
46 50 <%= link_to_function l(:button_save),
47 51 "$('#query_form').attr('action', '#{ @project ? new_project_query_path(@project) : new_query_path }'); submit_query_form('query_form')",
48 52 :class => 'icon icon-save' %>
49 53 <% end %>
50 54 </p>
51 55 <% end %>
52 56
53 57 <%= error_messages_for 'query' %>
54 58 <% if @query.valid? %>
55 59 <% if @issues.empty? %>
56 60 <p class="nodata"><%= l(:label_no_data) %></p>
57 61 <% else %>
58 62 <%= render :partial => 'issues/list', :locals => {:issues => @issues, :query => @query} %>
59 63 <p class="pagination"><%= pagination_links_full @issue_pages, @issue_count %></p>
60 64 <% end %>
61 65
62 66 <% other_formats_links do |f| %>
63 67 <%= f.link_to 'Atom', :url => params.merge(:key => User.current.rss_key) %>
64 68 <%= f.link_to 'CSV', :url => params, :onclick => "showModal('csv-export-options', '330px'); return false;" %>
65 69 <%= f.link_to 'PDF', :url => params %>
66 70 <% end %>
67 71
68 72 <div id="csv-export-options" style="display:none;">
69 73 <h3 class="title"><%= l(:label_export_options, :export_format => 'CSV') %></h3>
70 74 <%= form_tag(params.merge({:format => 'csv',:page=>nil}), :method => :get, :id => 'csv-export-form') do %>
71 75 <p>
72 76 <label><%= radio_button_tag 'columns', '', true %> <%= l(:description_selected_columns) %></label><br />
73 77 <label><%= radio_button_tag 'columns', 'all' %> <%= l(:description_all_columns) %></label>
74 78 </p>
75 79 <p>
76 <label><%= check_box_tag 'description', '1' %> <%= l(:field_description) %></label>
80 <label><%= check_box_tag 'description', '1', @query.has_column?(:description) %> <%= l(:field_description) %></label>
77 81 </p>
78 82 <p class="buttons">
79 83 <%= submit_tag l(:button_export), :name => nil, :onclick => "hideModal(this);" %>
80 84 <%= submit_tag l(:button_cancel), :name => nil, :onclick => "hideModal(this);", :type => 'button' %>
81 85 </p>
82 86 <% end %>
83 87 </div>
84 88
85 89 <% end %>
86 90 <%= call_hook(:view_issues_index_bottom, { :issues => @issues, :project => @project, :query => @query }) %>
87 91
88 92 <% content_for :sidebar do %>
89 93 <%= render :partial => 'issues/sidebar' %>
90 94 <% end %>
91 95
92 96 <% content_for :header_tags do %>
93 97 <%= auto_discovery_link_tag(:atom,
94 98 {:query_id => @query, :format => 'atom',
95 99 :page => nil, :key => User.current.rss_key},
96 100 :title => l(:label_issue_plural)) %>
97 101 <%= auto_discovery_link_tag(:atom,
98 102 {:controller => 'journals', :action => 'index',
99 103 :query_id => @query, :format => 'atom',
100 104 :page => nil, :key => User.current.rss_key},
101 105 :title => l(:label_changes_details)) %>
102 106 <% end %>
103 107
104 108 <%= context_menu issues_context_menu_path %>
@@ -1,34 +1,34
1 1 <table class="query-columns">
2 2 <tr>
3 3 <td style="padding-left:0">
4 4 <%= label_tag "available_columns", l(:description_available_columns) %>
5 5 <br />
6 6 <%= select_tag 'available_columns',
7 options_for_select((query.available_columns - query.columns).collect {|column| [column.caption, column.name]}),
7 options_for_select((query.available_inline_columns - query.columns).collect {|column| [column.caption, column.name]}),
8 8 :multiple => true, :size => 10, :style => "width:150px",
9 9 :ondblclick => "moveOptions(this.form.available_columns, this.form.selected_columns);" %>
10 10 </td>
11 11 <td class="buttons">
12 12 <input type="button" value="&#8594;"
13 13 onclick="moveOptions(this.form.available_columns, this.form.selected_columns);" /><br />
14 14 <input type="button" value="&#8592;"
15 15 onclick="moveOptions(this.form.selected_columns, this.form.available_columns);" />
16 16 </td>
17 17 <td>
18 18 <%= label_tag "selected_columns", l(:description_selected_columns) %>
19 19 <br />
20 20 <%= select_tag((defined?(tag_name) ? tag_name : 'c[]'),
21 options_for_select(query.columns.collect {|column| [column.caption, column.name]}),
21 options_for_select(query.inline_columns.collect {|column| [column.caption, column.name]}),
22 22 :id => 'selected_columns', :multiple => true, :size => 10, :style => "width:150px",
23 23 :ondblclick => "moveOptions(this.form.selected_columns, this.form.available_columns);") %>
24 24 </td>
25 25 <td class="buttons">
26 26 <input type="button" value="&#8593;" onclick="moveOptionUp(this.form.selected_columns);" /><br />
27 27 <input type="button" value="&#8595;" onclick="moveOptionDown(this.form.selected_columns);" />
28 28 </td>
29 29 </tr>
30 30 </table>
31 31
32 32 <% content_for :header_tags do %>
33 33 <%= javascript_include_tag 'select_list_move' %>
34 34 <% end %>
@@ -1,52 +1,55
1 1 <%= error_messages_for 'query' %>
2 2
3 3 <div class="box">
4 4 <div class="tabular">
5 5 <p><label for="query_name"><%=l(:field_name)%></label>
6 6 <%= text_field 'query', 'name', :size => 80 %></p>
7 7
8 8 <% if User.current.admin? || User.current.allowed_to?(:manage_public_queries, @project) %>
9 9 <p><label for="query_is_public"><%=l(:field_is_public)%></label>
10 10 <%= check_box 'query', 'is_public',
11 11 :onchange => (User.current.admin? ? nil : 'if (this.checked) {$("#query_is_for_all").removeAttr("checked"); $("#query_is_for_all").attr("disabled", true);} else {$("#query_is_for_all").removeAttr("disabled");}') %></p>
12 12 <% end %>
13 13
14 14 <p><label for="query_is_for_all"><%=l(:field_is_for_all)%></label>
15 15 <%= check_box_tag 'query_is_for_all', 1, @query.project.nil?,
16 16 :disabled => (!@query.new_record? && (@query.project.nil? || (@query.is_public? && !User.current.admin?))) %></p>
17 17
18 18 <p><label for="query_default_columns"><%=l(:label_default_columns)%></label>
19 19 <%= check_box_tag 'default_columns', 1, @query.has_default_columns?, :id => 'query_default_columns',
20 20 :onclick => 'if (this.checked) {$("#columns").hide();} else {$("#columns").show();}' %></p>
21 21
22 22 <p><label for="query_group_by"><%= l(:field_group_by) %></label>
23 23 <%= select 'query', 'group_by', @query.groupable_columns.collect {|c| [c.caption, c.name.to_s]}, :include_blank => true %></p>
24
25 <p><label><%= l(:button_show) %></label>
26 <%= available_block_columns_tags(@query) %></p>
24 27 </div>
25 28
26 29 <fieldset id="filters"><legend><%= l(:label_filter_plural) %></legend>
27 30 <%= render :partial => 'queries/filters', :locals => {:query => query}%>
28 31 </fieldset>
29 32
30 33 <fieldset><legend><%= l(:label_sort) %></legend>
31 34 <% 3.times do |i| %>
32 35 <%= i+1 %>:
33 36 <%= label_tag "query_sort_criteria_attribute_" + i.to_s,
34 37 l(:description_query_sort_criteria_attribute), :class => "hidden-for-sighted" %>
35 38 <%= select_tag("query[sort_criteria][#{i}][]",
36 39 options_for_select([[]] + query.available_columns.select(&:sortable?).collect {|column| [column.caption, column.name.to_s]}, @query.sort_criteria_key(i)),
37 40 :id => "query_sort_criteria_attribute_" + i.to_s)%>
38 41 <%= label_tag "query_sort_criteria_direction_" + i.to_s,
39 42 l(:description_query_sort_criteria_direction), :class => "hidden-for-sighted" %>
40 43 <%= select_tag("query[sort_criteria][#{i}][]",
41 44 options_for_select([[], [l(:label_ascending), 'asc'], [l(:label_descending), 'desc']], @query.sort_criteria_order(i)),
42 45 :id => "query_sort_criteria_direction_" + i.to_s) %>
43 46 <br />
44 47 <% end %>
45 48 </fieldset>
46 49
47 50 <%= content_tag 'fieldset', :id => 'columns', :style => (query.has_default_columns? ? 'display:none;' : nil) do %>
48 51 <legend><%= l(:field_column_names) %></legend>
49 52 <%= render :partial => 'queries/columns', :locals => {:query => query}%>
50 53 <% end %>
51 54
52 55 </div>
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,779 +1,792
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require 'iconv'
21 21 require 'tcpdf'
22 22 require 'fpdf/chinese'
23 23 require 'fpdf/japanese'
24 24 require 'fpdf/korean'
25 25
26 26 module Redmine
27 27 module Export
28 28 module PDF
29 29 include ActionView::Helpers::TextHelper
30 30 include ActionView::Helpers::NumberHelper
31 31 include IssuesHelper
32 32
33 33 class ITCPDF < TCPDF
34 34 include Redmine::I18n
35 35 attr_accessor :footer_date
36 36
37 def initialize(lang)
37 def initialize(lang, orientation='P')
38 38 @@k_path_cache = Rails.root.join('tmp', 'pdf')
39 39 FileUtils.mkdir_p @@k_path_cache unless File::exist?(@@k_path_cache)
40 40 set_language_if_valid lang
41 41 pdf_encoding = l(:general_pdf_encoding).upcase
42 super('P', 'mm', 'A4', (pdf_encoding == 'UTF-8'), pdf_encoding)
42 super(orientation, 'mm', 'A4', (pdf_encoding == 'UTF-8'), pdf_encoding)
43 43 case current_language.to_s.downcase
44 44 when 'vi'
45 45 @font_for_content = 'DejaVuSans'
46 46 @font_for_footer = 'DejaVuSans'
47 47 else
48 48 case pdf_encoding
49 49 when 'UTF-8'
50 50 @font_for_content = 'FreeSans'
51 51 @font_for_footer = 'FreeSans'
52 52 when 'CP949'
53 53 extend(PDF_Korean)
54 54 AddUHCFont()
55 55 @font_for_content = 'UHC'
56 56 @font_for_footer = 'UHC'
57 57 when 'CP932', 'SJIS', 'SHIFT_JIS'
58 58 extend(PDF_Japanese)
59 59 AddSJISFont()
60 60 @font_for_content = 'SJIS'
61 61 @font_for_footer = 'SJIS'
62 62 when 'GB18030'
63 63 extend(PDF_Chinese)
64 64 AddGBFont()
65 65 @font_for_content = 'GB'
66 66 @font_for_footer = 'GB'
67 67 when 'BIG5'
68 68 extend(PDF_Chinese)
69 69 AddBig5Font()
70 70 @font_for_content = 'Big5'
71 71 @font_for_footer = 'Big5'
72 72 else
73 73 @font_for_content = 'Arial'
74 74 @font_for_footer = 'Helvetica'
75 75 end
76 76 end
77 77 SetCreator(Redmine::Info.app_name)
78 78 SetFont(@font_for_content)
79 79 @outlines = []
80 80 @outlineRoot = nil
81 81 end
82 82
83 83 def SetFontStyle(style, size)
84 84 SetFont(@font_for_content, style, size)
85 85 end
86 86
87 87 def SetTitle(txt)
88 88 txt = begin
89 89 utf16txt = Iconv.conv('UTF-16BE', 'UTF-8', txt)
90 90 hextxt = "<FEFF" # FEFF is BOM
91 91 hextxt << utf16txt.unpack("C*").map {|x| sprintf("%02X",x) }.join
92 92 hextxt << ">"
93 93 rescue
94 94 txt
95 95 end || ''
96 96 super(txt)
97 97 end
98 98
99 99 def textstring(s)
100 100 # Format a text string
101 101 if s =~ /^</ # This means the string is hex-dumped.
102 102 return s
103 103 else
104 104 return '('+escape(s)+')'
105 105 end
106 106 end
107 107
108 108 def fix_text_encoding(txt)
109 109 RDMPdfEncoding::rdm_from_utf8(txt, l(:general_pdf_encoding))
110 110 end
111 111
112 112 def RDMCell(w ,h=0, txt='', border=0, ln=0, align='', fill=0, link='')
113 113 Cell(w, h, fix_text_encoding(txt), border, ln, align, fill, link)
114 114 end
115 115
116 116 def RDMMultiCell(w, h=0, txt='', border=0, align='', fill=0, ln=1)
117 117 MultiCell(w, h, fix_text_encoding(txt), border, align, fill, ln)
118 118 end
119 119
120 120 def RDMwriteHTMLCell(w, h, x, y, txt='', attachments=[], border=0, ln=1, fill=0)
121 121 @attachments = attachments
122 122 writeHTMLCell(w, h, x, y,
123 123 fix_text_encoding(
124 124 Redmine::WikiFormatting.to_html(Setting.text_formatting, txt)),
125 125 border, ln, fill)
126 126 end
127 127
128 128 def getImageFilename(attrname)
129 129 # attrname: general_pdf_encoding string file/uri name
130 130 atta = RDMPdfEncoding.attach(@attachments, attrname, l(:general_pdf_encoding))
131 131 if atta
132 132 return atta.diskfile
133 133 else
134 134 return nil
135 135 end
136 136 end
137 137
138 138 def Footer
139 139 SetFont(@font_for_footer, 'I', 8)
140 140 SetY(-15)
141 141 SetX(15)
142 142 RDMCell(0, 5, @footer_date, 0, 0, 'L')
143 143 SetY(-15)
144 144 SetX(-30)
145 145 RDMCell(0, 5, PageNo().to_s + '/{nb}', 0, 0, 'C')
146 146 end
147 147
148 148 def Bookmark(txt, level=0, y=0)
149 149 if (y == -1)
150 150 y = GetY()
151 151 end
152 152 @outlines << {:t => txt, :l => level, :p => PageNo(), :y => (@h - y)*@k}
153 153 end
154 154
155 155 def bookmark_title(txt)
156 156 txt = begin
157 157 utf16txt = Iconv.conv('UTF-16BE', 'UTF-8', txt)
158 158 hextxt = "<FEFF" # FEFF is BOM
159 159 hextxt << utf16txt.unpack("C*").map {|x| sprintf("%02X",x) }.join
160 160 hextxt << ">"
161 161 rescue
162 162 txt
163 163 end || ''
164 164 end
165 165
166 166 def putbookmarks
167 167 nb=@outlines.size
168 168 return if (nb==0)
169 169 lru=[]
170 170 level=0
171 171 @outlines.each_with_index do |o, i|
172 172 if(o[:l]>0)
173 173 parent=lru[o[:l]-1]
174 174 #Set parent and last pointers
175 175 @outlines[i][:parent]=parent
176 176 @outlines[parent][:last]=i
177 177 if (o[:l]>level)
178 178 #Level increasing: set first pointer
179 179 @outlines[parent][:first]=i
180 180 end
181 181 else
182 182 @outlines[i][:parent]=nb
183 183 end
184 184 if (o[:l]<=level && i>0)
185 185 #Set prev and next pointers
186 186 prev=lru[o[:l]]
187 187 @outlines[prev][:next]=i
188 188 @outlines[i][:prev]=prev
189 189 end
190 190 lru[o[:l]]=i
191 191 level=o[:l]
192 192 end
193 193 #Outline items
194 194 n=self.n+1
195 195 @outlines.each_with_index do |o, i|
196 196 newobj()
197 197 out('<</Title '+bookmark_title(o[:t]))
198 198 out("/Parent #{n+o[:parent]} 0 R")
199 199 if (o[:prev])
200 200 out("/Prev #{n+o[:prev]} 0 R")
201 201 end
202 202 if (o[:next])
203 203 out("/Next #{n+o[:next]} 0 R")
204 204 end
205 205 if (o[:first])
206 206 out("/First #{n+o[:first]} 0 R")
207 207 end
208 208 if (o[:last])
209 209 out("/Last #{n+o[:last]} 0 R")
210 210 end
211 211 out("/Dest [%d 0 R /XYZ 0 %.2f null]" % [1+2*o[:p], o[:y]])
212 212 out('/Count 0>>')
213 213 out('endobj')
214 214 end
215 215 #Outline root
216 216 newobj()
217 217 @outlineRoot=self.n
218 218 out("<</Type /Outlines /First #{n} 0 R");
219 219 out("/Last #{n+lru[0]} 0 R>>");
220 220 out('endobj');
221 221 end
222 222
223 223 def putresources()
224 224 super
225 225 putbookmarks()
226 226 end
227 227
228 228 def putcatalog()
229 229 super
230 230 if(@outlines.size > 0)
231 231 out("/Outlines #{@outlineRoot} 0 R");
232 232 out('/PageMode /UseOutlines');
233 233 end
234 234 end
235 235 end
236 236
237 237 # fetch row values
238 238 def fetch_row_values(issue, query, level)
239 query.columns.collect do |column|
239 query.inline_columns.collect do |column|
240 240 s = if column.is_a?(QueryCustomFieldColumn)
241 241 cv = issue.custom_field_values.detect {|v| v.custom_field_id == column.custom_field.id}
242 242 show_value(cv)
243 243 else
244 244 value = issue.send(column.name)
245 245 if column.name == :subject
246 246 value = " " * level + value
247 247 end
248 248 if value.is_a?(Date)
249 249 format_date(value)
250 250 elsif value.is_a?(Time)
251 251 format_time(value)
252 252 else
253 253 value
254 254 end
255 255 end
256 256 s.to_s
257 257 end
258 258 end
259 259
260 260 # calculate columns width
261 261 def calc_col_width(issues, query, table_width, pdf)
262 262 # calculate statistics
263 263 # by captions
264 264 pdf.SetFontStyle('B',8)
265 265 col_padding = pdf.GetStringWidth('OO')
266 col_width_min = query.columns.map {|v| pdf.GetStringWidth(v.caption) + col_padding}
266 col_width_min = query.inline_columns.map {|v| pdf.GetStringWidth(v.caption) + col_padding}
267 267 col_width_max = Array.new(col_width_min)
268 268 col_width_avg = Array.new(col_width_min)
269 word_width_max = query.columns.map {|c|
269 word_width_max = query.inline_columns.map {|c|
270 270 n = 10
271 271 c.caption.split.each {|w|
272 272 x = pdf.GetStringWidth(w) + col_padding
273 273 n = x if n < x
274 274 }
275 275 n
276 276 }
277 277
278 278 # by properties of issues
279 279 pdf.SetFontStyle('',8)
280 280 col_padding = pdf.GetStringWidth('OO')
281 281 k = 1
282 282 issue_list(issues) {|issue, level|
283 283 k += 1
284 284 values = fetch_row_values(issue, query, level)
285 285 values.each_with_index {|v,i|
286 286 n = pdf.GetStringWidth(v) + col_padding
287 287 col_width_max[i] = n if col_width_max[i] < n
288 288 col_width_min[i] = n if col_width_min[i] > n
289 289 col_width_avg[i] += n
290 290 v.split.each {|w|
291 291 x = pdf.GetStringWidth(w) + col_padding
292 292 word_width_max[i] = x if word_width_max[i] < x
293 293 }
294 294 }
295 295 }
296 296 col_width_avg.map! {|x| x / k}
297 297
298 298 # calculate columns width
299 299 ratio = table_width / col_width_avg.inject(0) {|s,w| s += w}
300 300 col_width = col_width_avg.map {|w| w * ratio}
301 301
302 302 # correct max word width if too many columns
303 303 ratio = table_width / word_width_max.inject(0) {|s,w| s += w}
304 304 word_width_max.map! {|v| v * ratio} if ratio < 1
305 305
306 306 # correct and lock width of some columns
307 307 done = 1
308 308 col_fix = []
309 309 col_width.each_with_index do |w,i|
310 310 if w > col_width_max[i]
311 311 col_width[i] = col_width_max[i]
312 312 col_fix[i] = 1
313 313 done = 0
314 314 elsif w < word_width_max[i]
315 315 col_width[i] = word_width_max[i]
316 316 col_fix[i] = 1
317 317 done = 0
318 318 else
319 319 col_fix[i] = 0
320 320 end
321 321 end
322 322
323 323 # iterate while need to correct and lock coluns width
324 324 while done == 0
325 325 # calculate free & locked columns width
326 326 done = 1
327 327 fix_col_width = 0
328 328 free_col_width = 0
329 329 col_width.each_with_index do |w,i|
330 330 if col_fix[i] == 1
331 331 fix_col_width += w
332 332 else
333 333 free_col_width += w
334 334 end
335 335 end
336 336
337 337 # calculate column normalizing ratio
338 338 if free_col_width == 0
339 339 ratio = table_width / col_width.inject(0) {|s,w| s += w}
340 340 else
341 341 ratio = (table_width - fix_col_width) / free_col_width
342 342 end
343 343
344 344 # correct columns width
345 345 col_width.each_with_index do |w,i|
346 346 if col_fix[i] == 0
347 347 col_width[i] = w * ratio
348 348
349 349 # check if column width less then max word width
350 350 if col_width[i] < word_width_max[i]
351 351 col_width[i] = word_width_max[i]
352 352 col_fix[i] = 1
353 353 done = 0
354 354 elsif col_width[i] > col_width_max[i]
355 355 col_width[i] = col_width_max[i]
356 356 col_fix[i] = 1
357 357 done = 0
358 358 end
359 359 end
360 360 end
361 361 end
362 362 col_width
363 363 end
364 364
365 365 def render_table_header(pdf, query, col_width, row_height, col_id_width, table_width)
366 366 # headers
367 367 pdf.SetFontStyle('B',8)
368 368 pdf.SetFillColor(230, 230, 230)
369 369
370 370 # render it background to find the max height used
371 371 base_x = pdf.GetX
372 372 base_y = pdf.GetY
373 max_height = issues_to_pdf_write_cells(pdf, query.columns, col_width, row_height, true)
373 max_height = issues_to_pdf_write_cells(pdf, query.inline_columns, col_width, row_height, true)
374 374 pdf.Rect(base_x, base_y, table_width + col_id_width, max_height, 'FD');
375 375 pdf.SetXY(base_x, base_y);
376 376
377 377 # write the cells on page
378 378 pdf.RDMCell(col_id_width, row_height, "#", "T", 0, 'C', 1)
379 issues_to_pdf_write_cells(pdf, query.columns, col_width, row_height, true)
379 issues_to_pdf_write_cells(pdf, query.inline_columns, col_width, row_height, true)
380 380 issues_to_pdf_draw_borders(pdf, base_x, base_y, base_y + max_height, col_id_width, col_width)
381 381 pdf.SetY(base_y + max_height);
382 382
383 383 # rows
384 384 pdf.SetFontStyle('',8)
385 385 pdf.SetFillColor(255, 255, 255)
386 386 end
387 387
388 388 # Returns a PDF string of a list of issues
389 389 def issues_to_pdf(issues, project, query)
390 pdf = ITCPDF.new(current_language)
390 pdf = ITCPDF.new(current_language, "L")
391 391 title = query.new_record? ? l(:label_issue_plural) : query.name
392 392 title = "#{project} - #{title}" if project
393 393 pdf.SetTitle(title)
394 394 pdf.alias_nb_pages
395 395 pdf.footer_date = format_date(Date.today)
396 396 pdf.SetAutoPageBreak(false)
397 397 pdf.AddPage("L")
398 398
399 399 # Landscape A4 = 210 x 297 mm
400 400 page_height = 210
401 401 page_width = 297
402 402 right_margin = 10
403 403 bottom_margin = 20
404 404 col_id_width = 10
405 405 row_height = 4
406 406
407 407 # column widths
408 408 table_width = page_width - right_margin - 10 # fixed left margin
409 409 col_width = []
410 unless query.columns.empty?
410 unless query.inline_columns.empty?
411 411 col_width = calc_col_width(issues, query, table_width - col_id_width, pdf)
412 412 table_width = col_width.inject(0) {|s,v| s += v}
413 413 end
414 414
415 # use full width if the description is displayed
416 if table_width > 0 && query.has_column?(:description)
417 col_width = col_width.map {|w| w = w * (page_width - right_margin - 10 - col_id_width) / table_width}
418 table_width = col_width.inject(0) {|s,v| s += v}
419 end
420
415 421 # title
416 422 pdf.SetFontStyle('B',11)
417 423 pdf.RDMCell(190,10, title)
418 424 pdf.Ln
419 425 render_table_header(pdf, query, col_width, row_height, col_id_width, table_width)
420 426 previous_group = false
421 427 issue_list(issues) do |issue, level|
422 428 if query.grouped? &&
423 429 (group = query.group_by_column.value(issue)) != previous_group
424 430 pdf.SetFontStyle('B',10)
425 431 group_label = group.blank? ? 'None' : group.to_s
426 432 group_label << " (#{query.issue_count_by_group[group]})"
427 433 pdf.Bookmark group_label, 0, -1
428 434 pdf.RDMCell(table_width + col_id_width, row_height * 2, group_label, 1, 1, 'L')
429 435 pdf.SetFontStyle('',8)
430 436 previous_group = group
431 437 end
432 438
433 439 # fetch row values
434 440 col_values = fetch_row_values(issue, query, level)
435 441
436 442 # render it off-page to find the max height used
437 443 base_x = pdf.GetX
438 444 base_y = pdf.GetY
439 445 pdf.SetY(2 * page_height)
440 446 max_height = issues_to_pdf_write_cells(pdf, col_values, col_width, row_height)
441 447 pdf.SetXY(base_x, base_y)
442 448
443 449 # make new page if it doesn't fit on the current one
444 450 space_left = page_height - base_y - bottom_margin
445 451 if max_height > space_left
446 452 pdf.AddPage("L")
447 453 render_table_header(pdf, query, col_width, row_height, col_id_width, table_width)
448 454 base_x = pdf.GetX
449 455 base_y = pdf.GetY
450 456 end
451 457
452 458 # write the cells on page
453 459 pdf.RDMCell(col_id_width, row_height, issue.id.to_s, "T", 0, 'C', 1)
454 460 issues_to_pdf_write_cells(pdf, col_values, col_width, row_height)
455 461 issues_to_pdf_draw_borders(pdf, base_x, base_y, base_y + max_height, col_id_width, col_width)
456 462 pdf.SetY(base_y + max_height);
463
464 if query.has_column?(:description) && issue.description?
465 pdf.SetX(10)
466 pdf.SetAutoPageBreak(true, 20)
467 pdf.RDMwriteHTMLCell(0, 5, 10, 0, issue.description.to_s, issue.attachments, "LRBT")
468 pdf.SetAutoPageBreak(false)
469 end
457 470 end
458 471
459 472 if issues.size == Setting.issues_export_limit.to_i
460 473 pdf.SetFontStyle('B',10)
461 474 pdf.RDMCell(0, row_height, '...')
462 475 end
463 476 pdf.Output
464 477 end
465 478
466 479 # Renders MultiCells and returns the maximum height used
467 480 def issues_to_pdf_write_cells(pdf, col_values, col_widths,
468 481 row_height, head=false)
469 482 base_y = pdf.GetY
470 483 max_height = row_height
471 484 col_values.each_with_index do |column, i|
472 485 col_x = pdf.GetX
473 486 if head == true
474 487 pdf.RDMMultiCell(col_widths[i], row_height, column.caption, "T", 'L', 1)
475 488 else
476 489 pdf.RDMMultiCell(col_widths[i], row_height, column, "T", 'L', 1)
477 490 end
478 491 max_height = (pdf.GetY - base_y) if (pdf.GetY - base_y) > max_height
479 492 pdf.SetXY(col_x + col_widths[i], base_y);
480 493 end
481 494 return max_height
482 495 end
483 496
484 497 # Draw lines to close the row (MultiCell border drawing in not uniform)
485 498 def issues_to_pdf_draw_borders(pdf, top_x, top_y, lower_y,
486 499 id_width, col_widths)
487 500 col_x = top_x + id_width
488 501 pdf.Line(col_x, top_y, col_x, lower_y) # id right border
489 502 col_widths.each do |width|
490 503 col_x += width
491 504 pdf.Line(col_x, top_y, col_x, lower_y) # columns right border
492 505 end
493 506 pdf.Line(top_x, top_y, top_x, lower_y) # left border
494 507 pdf.Line(top_x, lower_y, col_x, lower_y) # bottom border
495 508 end
496 509
497 510 # Returns a PDF string of a single issue
498 511 def issue_to_pdf(issue, assoc={})
499 512 pdf = ITCPDF.new(current_language)
500 513 pdf.SetTitle("#{issue.project} - #{issue.tracker} ##{issue.id}")
501 514 pdf.alias_nb_pages
502 515 pdf.footer_date = format_date(Date.today)
503 516 pdf.AddPage
504 517 pdf.SetFontStyle('B',11)
505 518 buf = "#{issue.project} - #{issue.tracker} ##{issue.id}"
506 519 pdf.RDMMultiCell(190, 5, buf)
507 520 pdf.SetFontStyle('',8)
508 521 base_x = pdf.GetX
509 522 i = 1
510 523 issue.ancestors.visible.each do |ancestor|
511 524 pdf.SetX(base_x + i)
512 525 buf = "#{ancestor.tracker} # #{ancestor.id} (#{ancestor.status.to_s}): #{ancestor.subject}"
513 526 pdf.RDMMultiCell(190 - i, 5, buf)
514 527 i += 1 if i < 35
515 528 end
516 529 pdf.SetFontStyle('B',11)
517 530 pdf.RDMMultiCell(190 - i, 5, issue.subject.to_s)
518 531 pdf.SetFontStyle('',8)
519 532 pdf.RDMMultiCell(190, 5, "#{format_time(issue.created_on)} - #{issue.author}")
520 533 pdf.Ln
521 534
522 535 left = []
523 536 left << [l(:field_status), issue.status]
524 537 left << [l(:field_priority), issue.priority]
525 538 left << [l(:field_assigned_to), issue.assigned_to] unless issue.disabled_core_fields.include?('assigned_to_id')
526 539 left << [l(:field_category), issue.category] unless issue.disabled_core_fields.include?('category_id')
527 540 left << [l(:field_fixed_version), issue.fixed_version] unless issue.disabled_core_fields.include?('fixed_version_id')
528 541
529 542 right = []
530 543 right << [l(:field_start_date), format_date(issue.start_date)] unless issue.disabled_core_fields.include?('start_date')
531 544 right << [l(:field_due_date), format_date(issue.due_date)] unless issue.disabled_core_fields.include?('due_date')
532 545 right << [l(:field_done_ratio), "#{issue.done_ratio}%"] unless issue.disabled_core_fields.include?('done_ratio')
533 546 right << [l(:field_estimated_hours), l_hours(issue.estimated_hours)] unless issue.disabled_core_fields.include?('estimated_hours')
534 547 right << [l(:label_spent_time), l_hours(issue.total_spent_hours)] if User.current.allowed_to?(:view_time_entries, issue.project)
535 548
536 549 rows = left.size > right.size ? left.size : right.size
537 550 while left.size < rows
538 551 left << nil
539 552 end
540 553 while right.size < rows
541 554 right << nil
542 555 end
543 556
544 557 half = (issue.custom_field_values.size / 2.0).ceil
545 558 issue.custom_field_values.each_with_index do |custom_value, i|
546 559 (i < half ? left : right) << [custom_value.custom_field.name, show_value(custom_value)]
547 560 end
548 561
549 562 rows = left.size > right.size ? left.size : right.size
550 563 rows.times do |i|
551 564 item = left[i]
552 565 pdf.SetFontStyle('B',9)
553 566 pdf.RDMCell(35,5, item ? "#{item.first}:" : "", i == 0 ? "LT" : "L")
554 567 pdf.SetFontStyle('',9)
555 568 pdf.RDMCell(60,5, item ? item.last.to_s : "", i == 0 ? "RT" : "R")
556 569
557 570 item = right[i]
558 571 pdf.SetFontStyle('B',9)
559 572 pdf.RDMCell(35,5, item ? "#{item.first}:" : "", i == 0 ? "LT" : "L")
560 573 pdf.SetFontStyle('',9)
561 574 pdf.RDMCell(60,5, item ? item.last.to_s : "", i == 0 ? "RT" : "R")
562 575 pdf.Ln
563 576 end
564 577
565 578 pdf.SetFontStyle('B',9)
566 579 pdf.RDMCell(35+155, 5, l(:field_description), "LRT", 1)
567 580 pdf.SetFontStyle('',9)
568 581
569 582 # Set resize image scale
570 583 pdf.SetImageScale(1.6)
571 584 pdf.RDMwriteHTMLCell(35+155, 5, 0, 0,
572 585 issue.description.to_s, issue.attachments, "LRB")
573 586
574 587 unless issue.leaf?
575 588 # for CJK
576 589 truncate_length = ( l(:general_pdf_encoding).upcase == "UTF-8" ? 90 : 65 )
577 590
578 591 pdf.SetFontStyle('B',9)
579 592 pdf.RDMCell(35+155,5, l(:label_subtask_plural) + ":", "LTR")
580 593 pdf.Ln
581 594 issue_list(issue.descendants.visible.sort_by(&:lft)) do |child, level|
582 595 buf = truncate("#{child.tracker} # #{child.id}: #{child.subject}",
583 596 :length => truncate_length)
584 597 level = 10 if level >= 10
585 598 pdf.SetFontStyle('',8)
586 599 pdf.RDMCell(35+135,5, (level >=1 ? " " * level : "") + buf, "L")
587 600 pdf.SetFontStyle('B',8)
588 601 pdf.RDMCell(20,5, child.status.to_s, "R")
589 602 pdf.Ln
590 603 end
591 604 end
592 605
593 606 relations = issue.relations.select { |r| r.other_issue(issue).visible? }
594 607 unless relations.empty?
595 608 # for CJK
596 609 truncate_length = ( l(:general_pdf_encoding).upcase == "UTF-8" ? 80 : 60 )
597 610
598 611 pdf.SetFontStyle('B',9)
599 612 pdf.RDMCell(35+155,5, l(:label_related_issues) + ":", "LTR")
600 613 pdf.Ln
601 614 relations.each do |relation|
602 615 buf = ""
603 616 buf += "#{l(relation.label_for(issue))} "
604 617 if relation.delay && relation.delay != 0
605 618 buf += "(#{l('datetime.distance_in_words.x_days', :count => relation.delay)}) "
606 619 end
607 620 if Setting.cross_project_issue_relations?
608 621 buf += "#{relation.other_issue(issue).project} - "
609 622 end
610 623 buf += "#{relation.other_issue(issue).tracker}" +
611 624 " # #{relation.other_issue(issue).id}: #{relation.other_issue(issue).subject}"
612 625 buf = truncate(buf, :length => truncate_length)
613 626 pdf.SetFontStyle('', 8)
614 627 pdf.RDMCell(35+155-60, 5, buf, "L")
615 628 pdf.SetFontStyle('B',8)
616 629 pdf.RDMCell(20,5, relation.other_issue(issue).status.to_s, "")
617 630 pdf.RDMCell(20,5, format_date(relation.other_issue(issue).start_date), "")
618 631 pdf.RDMCell(20,5, format_date(relation.other_issue(issue).due_date), "R")
619 632 pdf.Ln
620 633 end
621 634 end
622 635 pdf.RDMCell(190,5, "", "T")
623 636 pdf.Ln
624 637
625 638 if issue.changesets.any? &&
626 639 User.current.allowed_to?(:view_changesets, issue.project)
627 640 pdf.SetFontStyle('B',9)
628 641 pdf.RDMCell(190,5, l(:label_associated_revisions), "B")
629 642 pdf.Ln
630 643 for changeset in issue.changesets
631 644 pdf.SetFontStyle('B',8)
632 645 csstr = "#{l(:label_revision)} #{changeset.format_identifier} - "
633 646 csstr += format_time(changeset.committed_on) + " - " + changeset.author.to_s
634 647 pdf.RDMCell(190, 5, csstr)
635 648 pdf.Ln
636 649 unless changeset.comments.blank?
637 650 pdf.SetFontStyle('',8)
638 651 pdf.RDMwriteHTMLCell(190,5,0,0,
639 652 changeset.comments.to_s, issue.attachments, "")
640 653 end
641 654 pdf.Ln
642 655 end
643 656 end
644 657
645 658 if assoc[:journals].present?
646 659 pdf.SetFontStyle('B',9)
647 660 pdf.RDMCell(190,5, l(:label_history), "B")
648 661 pdf.Ln
649 662 assoc[:journals].each do |journal|
650 663 pdf.SetFontStyle('B',8)
651 664 title = "##{journal.indice} - #{format_time(journal.created_on)} - #{journal.user}"
652 665 title << " (#{l(:field_private_notes)})" if journal.private_notes?
653 666 pdf.RDMCell(190,5, title)
654 667 pdf.Ln
655 668 pdf.SetFontStyle('I',8)
656 669 details_to_strings(journal.details, true).each do |string|
657 670 pdf.RDMMultiCell(190,5, "- " + string)
658 671 end
659 672 if journal.notes?
660 673 pdf.Ln unless journal.details.empty?
661 674 pdf.SetFontStyle('',8)
662 675 pdf.RDMwriteHTMLCell(190,5,0,0,
663 676 journal.notes.to_s, issue.attachments, "")
664 677 end
665 678 pdf.Ln
666 679 end
667 680 end
668 681
669 682 if issue.attachments.any?
670 683 pdf.SetFontStyle('B',9)
671 684 pdf.RDMCell(190,5, l(:label_attachment_plural), "B")
672 685 pdf.Ln
673 686 for attachment in issue.attachments
674 687 pdf.SetFontStyle('',8)
675 688 pdf.RDMCell(80,5, attachment.filename)
676 689 pdf.RDMCell(20,5, number_to_human_size(attachment.filesize),0,0,"R")
677 690 pdf.RDMCell(25,5, format_date(attachment.created_on),0,0,"R")
678 691 pdf.RDMCell(65,5, attachment.author.name,0,0,"R")
679 692 pdf.Ln
680 693 end
681 694 end
682 695 pdf.Output
683 696 end
684 697
685 698 # Returns a PDF string of a set of wiki pages
686 699 def wiki_pages_to_pdf(pages, project)
687 700 pdf = ITCPDF.new(current_language)
688 701 pdf.SetTitle(project.name)
689 702 pdf.alias_nb_pages
690 703 pdf.footer_date = format_date(Date.today)
691 704 pdf.AddPage
692 705 pdf.SetFontStyle('B',11)
693 706 pdf.RDMMultiCell(190,5, project.name)
694 707 pdf.Ln
695 708 # Set resize image scale
696 709 pdf.SetImageScale(1.6)
697 710 pdf.SetFontStyle('',9)
698 711 write_page_hierarchy(pdf, pages.group_by(&:parent_id))
699 712 pdf.Output
700 713 end
701 714
702 715 # Returns a PDF string of a single wiki page
703 716 def wiki_page_to_pdf(page, project)
704 717 pdf = ITCPDF.new(current_language)
705 718 pdf.SetTitle("#{project} - #{page.title}")
706 719 pdf.alias_nb_pages
707 720 pdf.footer_date = format_date(Date.today)
708 721 pdf.AddPage
709 722 pdf.SetFontStyle('B',11)
710 723 pdf.RDMMultiCell(190,5,
711 724 "#{project} - #{page.title} - # #{page.content.version}")
712 725 pdf.Ln
713 726 # Set resize image scale
714 727 pdf.SetImageScale(1.6)
715 728 pdf.SetFontStyle('',9)
716 729 write_wiki_page(pdf, page)
717 730 pdf.Output
718 731 end
719 732
720 733 def write_page_hierarchy(pdf, pages, node=nil, level=0)
721 734 if pages[node]
722 735 pages[node].each do |page|
723 736 if @new_page
724 737 pdf.AddPage
725 738 else
726 739 @new_page = true
727 740 end
728 741 pdf.Bookmark page.title, level
729 742 write_wiki_page(pdf, page)
730 743 write_page_hierarchy(pdf, pages, page.id, level + 1) if pages[page.id]
731 744 end
732 745 end
733 746 end
734 747
735 748 def write_wiki_page(pdf, page)
736 749 pdf.RDMwriteHTMLCell(190,5,0,0,
737 750 page.content.text.to_s, page.attachments, 0)
738 751 if page.attachments.any?
739 752 pdf.Ln
740 753 pdf.SetFontStyle('B',9)
741 754 pdf.RDMCell(190,5, l(:label_attachment_plural), "B")
742 755 pdf.Ln
743 756 for attachment in page.attachments
744 757 pdf.SetFontStyle('',8)
745 758 pdf.RDMCell(80,5, attachment.filename)
746 759 pdf.RDMCell(20,5, number_to_human_size(attachment.filesize),0,0,"R")
747 760 pdf.RDMCell(25,5, format_date(attachment.created_on),0,0,"R")
748 761 pdf.RDMCell(65,5, attachment.author.name,0,0,"R")
749 762 pdf.Ln
750 763 end
751 764 end
752 765 end
753 766
754 767 class RDMPdfEncoding
755 768 def self.rdm_from_utf8(txt, encoding)
756 769 txt ||= ''
757 770 txt = Redmine::CodesetUtil.from_utf8(txt, encoding)
758 771 if txt.respond_to?(:force_encoding)
759 772 txt.force_encoding('ASCII-8BIT')
760 773 end
761 774 txt
762 775 end
763 776
764 777 def self.attach(attachments, filename, encoding)
765 778 filename_utf8 = Redmine::CodesetUtil.to_utf8(filename, encoding)
766 779 atta = nil
767 780 if filename_utf8 =~ /^[^\/"]+\.(gif|jpg|jpe|jpeg|png)$/i
768 781 atta = Attachment.latest_attach(attachments, filename_utf8)
769 782 end
770 783 if atta && atta.readable? && atta.visible?
771 784 return atta
772 785 else
773 786 return nil
774 787 end
775 788 end
776 789 end
777 790 end
778 791 end
779 792 end
@@ -1,1136 +1,1138
1 1 html {overflow-y:scroll;}
2 2 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
3 3
4 4 h1, h2, h3, h4 {font-family: "Trebuchet MS", Verdana, sans-serif;padding: 2px 10px 1px 0px;margin: 0 0 10px 0;}
5 5 #content h1, h2, h3, h4 {color: #555;}
6 6 h2, .wiki h1 {font-size: 20px;}
7 7 h3, .wiki h2 {font-size: 16px;}
8 8 h4, .wiki h3 {font-size: 13px;}
9 9 h4 {border-bottom: 1px dotted #bbb;}
10 10
11 11 /***** Layout *****/
12 12 #wrapper {background: white;}
13 13
14 14 #top-menu {background: #3E5B76; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
15 15 #top-menu ul {margin: 0; padding: 0;}
16 16 #top-menu li {
17 17 float:left;
18 18 list-style-type:none;
19 19 margin: 0px 0px 0px 0px;
20 20 padding: 0px 0px 0px 0px;
21 21 white-space:nowrap;
22 22 }
23 23 #top-menu a {color: #fff; margin-right: 8px; font-weight: bold;}
24 24 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
25 25
26 26 #account {float:right;}
27 27
28 28 #header {height:5.3em;margin:0;background-color:#628DB6;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
29 29 #header a {color:#f8f8f8;}
30 30 #header h1 a.ancestor { font-size: 80%; }
31 31 #quick-search {float:right;}
32 32
33 33 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
34 34 #main-menu ul {margin: 0; padding: 0;}
35 35 #main-menu li {
36 36 float:left;
37 37 list-style-type:none;
38 38 margin: 0px 2px 0px 0px;
39 39 padding: 0px 0px 0px 0px;
40 40 white-space:nowrap;
41 41 }
42 42 #main-menu li a {
43 43 display: block;
44 44 color: #fff;
45 45 text-decoration: none;
46 46 font-weight: bold;
47 47 margin: 0;
48 48 padding: 4px 10px 4px 10px;
49 49 }
50 50 #main-menu li a:hover {background:#759FCF; color:#fff;}
51 51 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
52 52
53 53 #admin-menu ul {margin: 0; padding: 0;}
54 54 #admin-menu li {margin: 0; padding: 0 0 6px 0; list-style-type:none;}
55 55
56 56 #admin-menu a { background-position: 0% 40%; background-repeat: no-repeat; padding-left: 20px; padding-top: 2px; padding-bottom: 3px;}
57 57 #admin-menu a.projects { background-image: url(../images/projects.png); }
58 58 #admin-menu a.users { background-image: url(../images/user.png); }
59 59 #admin-menu a.groups { background-image: url(../images/group.png); }
60 60 #admin-menu a.roles { background-image: url(../images/database_key.png); }
61 61 #admin-menu a.trackers { background-image: url(../images/ticket.png); }
62 62 #admin-menu a.issue_statuses { background-image: url(../images/ticket_edit.png); }
63 63 #admin-menu a.workflows { background-image: url(../images/ticket_go.png); }
64 64 #admin-menu a.custom_fields { background-image: url(../images/textfield.png); }
65 65 #admin-menu a.enumerations { background-image: url(../images/text_list_bullets.png); }
66 66 #admin-menu a.settings { background-image: url(../images/changeset.png); }
67 67 #admin-menu a.plugins { background-image: url(../images/plugin.png); }
68 68 #admin-menu a.info { background-image: url(../images/help.png); }
69 69 #admin-menu a.server_authentication { background-image: url(../images/server_key.png); }
70 70
71 71 #main {background-color:#EEEEEE;}
72 72
73 73 #sidebar{ float: right; width: 22%; position: relative; z-index: 9; padding: 0; margin: 0;}
74 74 * html #sidebar{ width: 22%; }
75 75 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
76 76 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
77 77 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
78 78 #sidebar .contextual { margin-right: 1em; }
79 79
80 80 #content { width: 75%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; }
81 81 * html #content{ width: 75%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
82 82 html>body #content { min-height: 600px; }
83 83 * html body #content { height: 600px; } /* IE */
84 84
85 85 #main.nosidebar #sidebar{ display: none; }
86 86 #main.nosidebar #content{ width: auto; border-right: 0; }
87 87
88 88 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
89 89
90 90 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
91 91 #login-form table td {padding: 6px;}
92 92 #login-form label {font-weight: bold;}
93 93 #login-form input#username, #login-form input#password { width: 300px; }
94 94
95 95 div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
96 96 div.modal h3.title {display:none;}
97 97 div.modal p.buttons {text-align:right; margin-bottom:0;}
98 98
99 99 input#openid_url { background: url(../images/openid-bg.gif) no-repeat; background-color: #fff; background-position: 0 50%; padding-left: 18px; }
100 100
101 101 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
102 102
103 103 /***** Links *****/
104 104 a, a:link, a:visited{ color: #169; text-decoration: none; }
105 105 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
106 106 a img{ border: 0; }
107 107
108 108 a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: #999; text-decoration: line-through; }
109 109 a.project.closed, a.project.closed:link, a.project.closed:visited { color: #999; }
110 110 a.user.locked, a.user.locked:link, a.user.locked:visited {color: #999;}
111 111
112 112 #sidebar a.selected {line-height:1.7em; padding:1px 3px 2px 2px; margin-left:-2px; background-color:#9DB9D5; color:#fff; border-radius:2px;}
113 113 #sidebar a.selected:hover {text-decoration:none;}
114 114 #admin-menu a {line-height:1.7em;}
115 115 #admin-menu a.selected {padding-left: 20px !important; background-position: 2px 40%;}
116 116
117 117 a.collapsible {padding-left: 12px; background: url(../images/arrow_expanded.png) no-repeat -3px 40%;}
118 118 a.collapsible.collapsed {background: url(../images/arrow_collapsed.png) no-repeat -5px 40%;}
119 119
120 120 a#toggle-completed-versions {color:#999;}
121 121 /***** Tables *****/
122 122 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
123 123 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
124 124 table.list td { vertical-align: top; padding-right:10px; }
125 125 table.list td.id { width: 2%; text-align: center;}
126 126 table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
127 127 table.list td.checkbox input {padding:0px;}
128 128 table.list td.buttons { width: 15%; white-space:nowrap; text-align: right; }
129 129 table.list td.buttons a { padding-right: 0.6em; }
130 130 table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
131 131
132 132 tr.project td.name a { white-space:nowrap; }
133 133 tr.project.closed, tr.project.archived { color: #aaa; }
134 134 tr.project.closed a, tr.project.archived a { color: #aaa; }
135 135
136 136 tr.project.idnt td.name span {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
137 137 tr.project.idnt-1 td.name {padding-left: 0.5em;}
138 138 tr.project.idnt-2 td.name {padding-left: 2em;}
139 139 tr.project.idnt-3 td.name {padding-left: 3.5em;}
140 140 tr.project.idnt-4 td.name {padding-left: 5em;}
141 141 tr.project.idnt-5 td.name {padding-left: 6.5em;}
142 142 tr.project.idnt-6 td.name {padding-left: 8em;}
143 143 tr.project.idnt-7 td.name {padding-left: 9.5em;}
144 144 tr.project.idnt-8 td.name {padding-left: 11em;}
145 145 tr.project.idnt-9 td.name {padding-left: 12.5em;}
146 146
147 147 tr.issue { text-align: center; white-space: nowrap; }
148 148 tr.issue td.subject, tr.issue td.category, td.assigned_to, tr.issue td.string, tr.issue td.text, tr.issue td.relations { white-space: normal; }
149 149 tr.issue td.subject, tr.issue td.relations { text-align: left; }
150 150 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
151 151 tr.issue td.relations span {white-space: nowrap;}
152 table.issues td.description {color:#777; font-size:90%; padding:4px 4px 4px 24px; text-align:left; white-space:normal;}
153 table.issues td.description pre {white-space:normal;}
152 154
153 155 tr.issue.idnt td.subject a {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
154 156 tr.issue.idnt-1 td.subject {padding-left: 0.5em;}
155 157 tr.issue.idnt-2 td.subject {padding-left: 2em;}
156 158 tr.issue.idnt-3 td.subject {padding-left: 3.5em;}
157 159 tr.issue.idnt-4 td.subject {padding-left: 5em;}
158 160 tr.issue.idnt-5 td.subject {padding-left: 6.5em;}
159 161 tr.issue.idnt-6 td.subject {padding-left: 8em;}
160 162 tr.issue.idnt-7 td.subject {padding-left: 9.5em;}
161 163 tr.issue.idnt-8 td.subject {padding-left: 11em;}
162 164 tr.issue.idnt-9 td.subject {padding-left: 12.5em;}
163 165
164 166 tr.entry { border: 1px solid #f8f8f8; }
165 167 tr.entry td { white-space: nowrap; }
166 168 tr.entry td.filename { width: 30%; }
167 169 tr.entry td.filename_no_report { width: 70%; }
168 170 tr.entry td.size { text-align: right; font-size: 90%; }
169 171 tr.entry td.revision, tr.entry td.author { text-align: center; }
170 172 tr.entry td.age { text-align: right; }
171 173 tr.entry.file td.filename a { margin-left: 16px; }
172 174 tr.entry.file td.filename_no_report a { margin-left: 16px; }
173 175
174 176 tr span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
175 177 tr.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
176 178
177 179 tr.changeset { height: 20px }
178 180 tr.changeset ul, ol { margin-top: 0px; margin-bottom: 0px; }
179 181 tr.changeset td.revision_graph { width: 15%; background-color: #fffffb; }
180 182 tr.changeset td.author { text-align: center; width: 15%; white-space:nowrap;}
181 183 tr.changeset td.committed_on { text-align: center; width: 15%; white-space:nowrap;}
182 184
183 185 table.files tr.file td { text-align: center; }
184 186 table.files tr.file td.filename { text-align: left; padding-left: 24px; }
185 187 table.files tr.file td.digest { font-size: 80%; }
186 188
187 189 table.members td.roles, table.memberships td.roles { width: 45%; }
188 190
189 191 tr.message { height: 2.6em; }
190 192 tr.message td.subject { padding-left: 20px; }
191 193 tr.message td.created_on { white-space: nowrap; }
192 194 tr.message td.last_message { font-size: 80%; white-space: nowrap; }
193 195 tr.message.locked td.subject { background: url(../images/locked.png) no-repeat 0 1px; }
194 196 tr.message.sticky td.subject { background: url(../images/bullet_go.png) no-repeat 0 1px; font-weight: bold; }
195 197
196 198 tr.version.closed, tr.version.closed a { color: #999; }
197 199 tr.version td.name { padding-left: 20px; }
198 200 tr.version.shared td.name { background: url(../images/link.png) no-repeat 0% 70%; }
199 201 tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
200 202
201 203 tr.user td { width:13%; }
202 204 tr.user td.email { width:18%; }
203 205 tr.user td { white-space: nowrap; }
204 206 tr.user.locked, tr.user.registered { color: #aaa; }
205 207 tr.user.locked a, tr.user.registered a { color: #aaa; }
206 208
207 209 table.permissions td.role {color:#999;font-size:90%;font-weight:normal !important;text-align:center;vertical-align:bottom;}
208 210
209 211 tr.wiki-page-version td.updated_on, tr.wiki-page-version td.author {text-align:center;}
210 212
211 213 tr.time-entry { text-align: center; white-space: nowrap; }
212 214 tr.time-entry td.subject, tr.time-entry td.comments { text-align: left; white-space: normal; }
213 215 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
214 216 td.hours .hours-dec { font-size: 0.9em; }
215 217
216 218 table.plugins td { vertical-align: middle; }
217 219 table.plugins td.configure { text-align: right; padding-right: 1em; }
218 220 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px; }
219 221 table.plugins span.description { display: block; font-size: 0.9em; }
220 222 table.plugins span.url { display: block; font-size: 0.9em; }
221 223
222 224 table.list tbody tr.group td { padding: 0.8em 0 0.5em 0.3em; font-weight: bold; border-bottom: 1px solid #ccc; }
223 225 table.list tbody tr.group span.count {position:relative; top:-1px; color:#fff; font-size:10px; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
224 226 tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
225 227 tr.group:hover a.toggle-all { display:inline;}
226 228 a.toggle-all:hover {text-decoration:none;}
227 229
228 230 table.list tbody tr:hover { background-color:#ffffdd; }
229 231 table.list tbody tr.group:hover { background-color:inherit; }
230 232 table td {padding:2px;}
231 233 table p {margin:0;}
232 234 .odd {background-color:#f6f7f8;}
233 235 .even {background-color: #fff;}
234 236
235 237 a.sort { padding-right: 16px; background-position: 100% 50%; background-repeat: no-repeat; }
236 238 a.sort.asc { background-image: url(../images/sort_asc.png); }
237 239 a.sort.desc { background-image: url(../images/sort_desc.png); }
238 240
239 241 table.attributes { width: 100% }
240 242 table.attributes th { vertical-align: top; text-align: left; }
241 243 table.attributes td { vertical-align: top; }
242 244
243 245 table.boards a.board, h3.comments { background: url(../images/comment.png) no-repeat 0% 50%; padding-left: 20px; }
244 246 table.boards td.topic-count, table.boards td.message-count {text-align:center;}
245 247 table.boards td.last-message {font-size:80%;}
246 248
247 249 table.messages td.author, table.messages td.created_on, table.messages td.reply-count {text-align:center;}
248 250
249 251 table.query-columns {
250 252 border-collapse: collapse;
251 253 border: 0;
252 254 }
253 255
254 256 table.query-columns td.buttons {
255 257 vertical-align: middle;
256 258 text-align: center;
257 259 }
258 260
259 261 td.center {text-align:center;}
260 262
261 263 h3.version { background: url(../images/package.png) no-repeat 0% 50%; padding-left: 20px; }
262 264
263 265 div.issues h3 { background: url(../images/ticket.png) no-repeat 0% 50%; padding-left: 20px; }
264 266 div.members h3 { background: url(../images/group.png) no-repeat 0% 50%; padding-left: 20px; }
265 267 div.news h3 { background: url(../images/news.png) no-repeat 0% 50%; padding-left: 20px; }
266 268 div.projects h3 { background: url(../images/projects.png) no-repeat 0% 50%; padding-left: 20px; }
267 269
268 270 #watchers ul {margin: 0; padding: 0;}
269 271 #watchers li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;}
270 272 #watchers select {width: 95%; display: block;}
271 273 #watchers a.delete {opacity: 0.4;}
272 274 #watchers a.delete:hover {opacity: 1;}
273 275 #watchers img.gravatar {margin: 0 4px 2px 0;}
274 276
275 277 span#watchers_inputs {overflow:auto; display:block;}
276 278 span.search_for_watchers {display:block;}
277 279 span.search_for_watchers, span.add_attachment {font-size:80%; line-height:2.5em;}
278 280 span.search_for_watchers a, span.add_attachment a {padding-left:16px; background: url(../images/bullet_add.png) no-repeat 0 50%; }
279 281
280 282
281 283 .highlight { background-color: #FCFD8D;}
282 284 .highlight.token-1 { background-color: #faa;}
283 285 .highlight.token-2 { background-color: #afa;}
284 286 .highlight.token-3 { background-color: #aaf;}
285 287
286 288 .box{
287 289 padding:6px;
288 290 margin-bottom: 10px;
289 291 background-color:#f6f6f6;
290 292 color:#505050;
291 293 line-height:1.5em;
292 294 border: 1px solid #e4e4e4;
293 295 }
294 296
295 297 div.square {
296 298 border: 1px solid #999;
297 299 float: left;
298 300 margin: .3em .4em 0 .4em;
299 301 overflow: hidden;
300 302 width: .6em; height: .6em;
301 303 }
302 304 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
303 305 .contextual input, .contextual select {font-size:0.9em;}
304 306 .message .contextual { margin-top: 0; }
305 307
306 308 .splitcontent {overflow:auto;}
307 309 .splitcontentleft{float:left; width:49%;}
308 310 .splitcontentright{float:right; width:49%;}
309 311 form {display: inline;}
310 312 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
311 313 fieldset {border: 1px solid #e4e4e4; margin:0;}
312 314 legend {color: #484848;}
313 315 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
314 316 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
315 317 blockquote blockquote { margin-left: 0;}
316 318 acronym { border-bottom: 1px dotted; cursor: help; }
317 319 textarea.wiki-edit {width:99%; resize:vertical;}
318 320 li p {margin-top: 0;}
319 321 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
320 322 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
321 323 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
322 324 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
323 325
324 326 div.issue div.subject div div { padding-left: 16px; }
325 327 div.issue div.subject p {margin: 0; margin-bottom: 0.1em; font-size: 90%; color: #999;}
326 328 div.issue div.subject>div>p { margin-top: 0.5em; }
327 329 div.issue div.subject h3 {margin: 0; margin-bottom: 0.1em;}
328 330 div.issue span.private { position:relative; bottom: 2px; text-transform: uppercase; background: #d22; color: #fff; font-weight:bold; padding: 0px 2px 0px 2px; font-size: 60%; margin-right: 2px; border-radius: 2px;}
329 331 div.issue .next-prev-links {color:#999;}
330 332 div.issue table.attributes th {width:22%;}
331 333 div.issue table.attributes td {width:28%;}
332 334
333 335 #issue_tree table.issues, #relations table.issues { border: 0; }
334 336 #issue_tree td.checkbox, #relations td.checkbox {display:none;}
335 337 #relations td.buttons {padding:0;}
336 338
337 339 fieldset.collapsible { border-width: 1px 0 0 0; font-size: 0.9em; }
338 340 fieldset.collapsible legend { padding-left: 16px; background: url(../images/arrow_expanded.png) no-repeat 0% 40%; cursor:pointer; }
339 341 fieldset.collapsible.collapsed legend { background-image: url(../images/arrow_collapsed.png); }
340 342
341 343 fieldset#date-range p { margin: 2px 0 2px 0; }
342 344 fieldset#filters table { border-collapse: collapse; }
343 345 fieldset#filters table td { padding: 0; vertical-align: middle; }
344 346 fieldset#filters tr.filter { height: 2.1em; }
345 347 fieldset#filters td.field { width:230px; }
346 348 fieldset#filters td.operator { width:180px; }
347 349 fieldset#filters td.operator select {max-width:170px;}
348 350 fieldset#filters td.values { white-space:nowrap; }
349 351 fieldset#filters td.values select {min-width:130px;}
350 352 fieldset#filters td.values input {height:1em;}
351 353 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
352 354
353 355 .toggle-multiselect {background: url(../images/bullet_toggle_plus.png) no-repeat 0% 40%; padding-left:8px; margin-left:0; cursor:pointer;}
354 356 .buttons { font-size: 0.9em; margin-bottom: 1.4em; margin-top: 1em; }
355 357
356 358 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
357 359 div#issue-changesets div.changeset { padding: 4px;}
358 360 div#issue-changesets div.changeset { border-bottom: 1px solid #ddd; }
359 361 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
360 362
361 363 .journal ul.details img {margin:0 0 -3px 4px;}
362 364 div.journal {overflow:auto;}
363 365 div.journal.private-notes {border-left:2px solid #d22; padding-left:4px; margin-left:-6px;}
364 366
365 367 div#activity dl, #search-results { margin-left: 2em; }
366 368 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
367 369 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
368 370 div#activity dt.me .time { border-bottom: 1px solid #999; }
369 371 div#activity dt .time { color: #777; font-size: 80%; }
370 372 div#activity dd .description, #search-results dd .description { font-style: italic; }
371 373 div#activity span.project:after, #search-results span.project:after { content: " -"; }
372 374 div#activity dd span.description, #search-results dd span.description { display:block; color: #808080; }
373 375
374 376 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
375 377
376 378 div#search-results-counts {float:right;}
377 379 div#search-results-counts ul { margin-top: 0.5em; }
378 380 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
379 381
380 382 dt.issue { background-image: url(../images/ticket.png); }
381 383 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
382 384 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
383 385 dt.issue-note { background-image: url(../images/ticket_note.png); }
384 386 dt.changeset { background-image: url(../images/changeset.png); }
385 387 dt.news { background-image: url(../images/news.png); }
386 388 dt.message { background-image: url(../images/message.png); }
387 389 dt.reply { background-image: url(../images/comments.png); }
388 390 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
389 391 dt.attachment { background-image: url(../images/attachment.png); }
390 392 dt.document { background-image: url(../images/document.png); }
391 393 dt.project { background-image: url(../images/projects.png); }
392 394 dt.time-entry { background-image: url(../images/time.png); }
393 395
394 396 #search-results dt.issue.closed { background-image: url(../images/ticket_checked.png); }
395 397
396 398 div#roadmap .related-issues { margin-bottom: 1em; }
397 399 div#roadmap .related-issues td.checkbox { display: none; }
398 400 div#roadmap .wiki h1:first-child { display: none; }
399 401 div#roadmap .wiki h1 { font-size: 120%; }
400 402 div#roadmap .wiki h2 { font-size: 110%; }
401 403 body.controller-versions.action-show div#roadmap .related-issues {width:70%;}
402 404
403 405 div#version-summary { float:right; width:28%; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
404 406 div#version-summary fieldset { margin-bottom: 1em; }
405 407 div#version-summary fieldset.time-tracking table { width:100%; }
406 408 div#version-summary th, div#version-summary td.total-hours { text-align: right; }
407 409
408 410 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
409 411 table#time-report tbody tr.subtotal { font-style: italic; color:#777;}
410 412 table#time-report tbody tr.subtotal td.hours { color:#b0b0b0; }
411 413 table#time-report tbody tr.total { font-weight: bold; background-color:#EEEEEE; border-top:1px solid #e4e4e4;}
412 414 table#time-report .hours-dec { font-size: 0.9em; }
413 415
414 416 div.wiki-page .contextual a {opacity: 0.4}
415 417 div.wiki-page .contextual a:hover {opacity: 1}
416 418
417 419 form .attributes select { width: 60%; }
418 420 input#issue_subject { width: 99%; }
419 421 select#issue_done_ratio { width: 95px; }
420 422
421 423 ul.projects {margin:0; padding-left:1em;}
422 424 ul.projects ul {padding-left:1.6em;}
423 425 ul.projects.root {margin:0; padding:0;}
424 426 ul.projects li {list-style-type:none;}
425 427
426 428 #projects-index ul.projects ul.projects { border-left: 3px solid #e0e0e0; padding-left:1em;}
427 429 #projects-index ul.projects li.root {margin-bottom: 1em;}
428 430 #projects-index ul.projects li.child {margin-top: 1em;}
429 431 #projects-index ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
430 432 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
431 433
432 434 #notified-projects ul, #tracker_project_ids ul {max-height:250px; overflow-y:auto;}
433 435
434 436 #related-issues li img {vertical-align:middle;}
435 437
436 438 ul.properties {padding:0; font-size: 0.9em; color: #777;}
437 439 ul.properties li {list-style-type:none;}
438 440 ul.properties li span {font-style:italic;}
439 441
440 442 .total-hours { font-size: 110%; font-weight: bold; }
441 443 .total-hours span.hours-int { font-size: 120%; }
442 444
443 445 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
444 446 #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select, #user_identity_url { width: 90%; }
445 447
446 448 #workflow_copy_form select { width: 200px; }
447 449 table.transitions td.enabled {background: #bfb;}
448 450 table.fields_permissions select {font-size:90%}
449 451 table.fields_permissions td.readonly {background:#ddd;}
450 452 table.fields_permissions td.required {background:#d88;}
451 453
452 454 textarea#custom_field_possible_values {width: 99%}
453 455 input#content_comments {width: 99%}
454 456
455 457 .pagination {font-size: 90%}
456 458 p.pagination {margin-top:8px;}
457 459
458 460 /***** Tabular forms ******/
459 461 .tabular p{
460 462 margin: 0;
461 463 padding: 3px 0 3px 0;
462 464 padding-left: 180px; /* width of left column containing the label elements */
463 465 min-height: 1.8em;
464 466 clear:left;
465 467 }
466 468
467 469 html>body .tabular p {overflow:hidden;}
468 470
469 471 .tabular label{
470 472 font-weight: bold;
471 473 float: left;
472 474 text-align: right;
473 475 /* width of left column */
474 476 margin-left: -180px;
475 477 /* width of labels. Should be smaller than left column to create some right margin */
476 478 width: 175px;
477 479 }
478 480
479 481 .tabular label.floating{
480 482 font-weight: normal;
481 483 margin-left: 0px;
482 484 text-align: left;
483 485 width: 270px;
484 486 }
485 487
486 488 .tabular label.block{
487 489 font-weight: normal;
488 490 margin-left: 0px !important;
489 491 text-align: left;
490 492 float: none;
491 493 display: block;
492 494 width: auto;
493 495 }
494 496
495 497 .tabular label.inline{
496 498 font-weight: normal;
497 499 float:none;
498 500 margin-left: 5px !important;
499 501 width: auto;
500 502 }
501 503
502 504 label.no-css {
503 505 font-weight: inherit;
504 506 float:none;
505 507 text-align:left;
506 508 margin-left:0px;
507 509 width:auto;
508 510 }
509 511 input#time_entry_comments { width: 90%;}
510 512
511 513 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
512 514
513 515 .tabular.settings p{ padding-left: 300px; }
514 516 .tabular.settings label{ margin-left: -300px; width: 295px; }
515 517 .tabular.settings textarea { width: 99%; }
516 518
517 519 .settings.enabled_scm table {width:100%}
518 520 .settings.enabled_scm td.scm_name{ font-weight: bold; }
519 521
520 522 fieldset.settings label { display: block; }
521 523 fieldset#notified_events .parent { padding-left: 20px; }
522 524
523 525 span.required {color: #bb0000;}
524 526 .summary {font-style: italic;}
525 527
526 528 #attachments_fields input.description {margin-left: 8px; width:340px;}
527 529 #attachments_fields span {display:block; white-space:nowrap;}
528 530 #attachments_fields img {vertical-align: middle;}
529 531
530 532 div.attachments { margin-top: 12px; }
531 533 div.attachments p { margin:4px 0 2px 0; }
532 534 div.attachments img { vertical-align: middle; }
533 535 div.attachments span.author { font-size: 0.9em; color: #888; }
534 536
535 537 div.thumbnails {margin-top:0.6em;}
536 538 div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
537 539 div.thumbnails img {margin: 3px;}
538 540
539 541 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
540 542 .other-formats span + span:before { content: "| "; }
541 543
542 544 a.atom { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
543 545
544 546 em.info {font-style:normal;font-size:90%;color:#888;display:block;}
545 547 em.info.error {padding-left:20px; background:url(../images/exclamation.png) no-repeat 0 50%;}
546 548
547 549 textarea.text_cf {width:90%;}
548 550
549 551 /* Project members tab */
550 552 div#tab-content-members .splitcontentleft, div#tab-content-memberships .splitcontentleft, div#tab-content-users .splitcontentleft { width: 64% }
551 553 div#tab-content-members .splitcontentright, div#tab-content-memberships .splitcontentright, div#tab-content-users .splitcontentright { width: 34% }
552 554 div#tab-content-members fieldset, div#tab-content-memberships fieldset, div#tab-content-users fieldset { padding:1em; margin-bottom: 1em; }
553 555 div#tab-content-members fieldset legend, div#tab-content-memberships fieldset legend, div#tab-content-users fieldset legend { font-weight: bold; }
554 556 div#tab-content-members fieldset label, div#tab-content-memberships fieldset label, div#tab-content-users fieldset label { display: block; }
555 557 div#tab-content-members fieldset div, div#tab-content-users fieldset div { max-height: 400px; overflow:auto; }
556 558
557 559 #users_for_watcher {height: 200px; overflow:auto;}
558 560 #users_for_watcher label {display: block;}
559 561
560 562 table.members td.group { padding-left: 20px; background: url(../images/group.png) no-repeat 0% 50%; }
561 563
562 564 input#principal_search, input#user_search {width:100%}
563 565 input#principal_search, input#user_search {
564 566 background: url(../images/magnifier.png) no-repeat 2px 50%; padding-left:20px;
565 567 border:1px solid #9EB1C2; border-radius:3px; height:1.5em; width:95%;
566 568 }
567 569 input#principal_search.ajax-loading, input#user_search.ajax-loading {
568 570 background-image: url(../images/loading.gif);
569 571 }
570 572
571 573 * html div#tab-content-members fieldset div { height: 450px; }
572 574
573 575 /***** Flash & error messages ****/
574 576 #errorExplanation, div.flash, .nodata, .warning, .conflict {
575 577 padding: 4px 4px 4px 30px;
576 578 margin-bottom: 12px;
577 579 font-size: 1.1em;
578 580 border: 2px solid;
579 581 }
580 582
581 583 div.flash {margin-top: 8px;}
582 584
583 585 div.flash.error, #errorExplanation {
584 586 background: url(../images/exclamation.png) 8px 50% no-repeat;
585 587 background-color: #ffe3e3;
586 588 border-color: #dd0000;
587 589 color: #880000;
588 590 }
589 591
590 592 div.flash.notice {
591 593 background: url(../images/true.png) 8px 5px no-repeat;
592 594 background-color: #dfffdf;
593 595 border-color: #9fcf9f;
594 596 color: #005f00;
595 597 }
596 598
597 599 div.flash.warning, .conflict {
598 600 background: url(../images/warning.png) 8px 5px no-repeat;
599 601 background-color: #FFEBC1;
600 602 border-color: #FDBF3B;
601 603 color: #A6750C;
602 604 text-align: left;
603 605 }
604 606
605 607 .nodata, .warning {
606 608 text-align: center;
607 609 background-color: #FFEBC1;
608 610 border-color: #FDBF3B;
609 611 color: #A6750C;
610 612 }
611 613
612 614 #errorExplanation ul { font-size: 0.9em;}
613 615 #errorExplanation h2, #errorExplanation p { display: none; }
614 616
615 617 .conflict-details {font-size:80%;}
616 618
617 619 /***** Ajax indicator ******/
618 620 #ajax-indicator {
619 621 position: absolute; /* fixed not supported by IE */
620 622 background-color:#eee;
621 623 border: 1px solid #bbb;
622 624 top:35%;
623 625 left:40%;
624 626 width:20%;
625 627 font-weight:bold;
626 628 text-align:center;
627 629 padding:0.6em;
628 630 z-index:100;
629 631 opacity: 0.5;
630 632 }
631 633
632 634 html>body #ajax-indicator { position: fixed; }
633 635
634 636 #ajax-indicator span {
635 637 background-position: 0% 40%;
636 638 background-repeat: no-repeat;
637 639 background-image: url(../images/loading.gif);
638 640 padding-left: 26px;
639 641 vertical-align: bottom;
640 642 }
641 643
642 644 /***** Calendar *****/
643 645 table.cal {border-collapse: collapse; width: 100%; margin: 0px 0 6px 0;border: 1px solid #d7d7d7;}
644 646 table.cal thead th {width: 14%; background-color:#EEEEEE; padding: 4px; }
645 647 table.cal thead th.week-number {width: auto;}
646 648 table.cal tbody tr {height: 100px;}
647 649 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
648 650 table.cal td.week-number { background-color:#EEEEEE; padding: 4px; border:none; font-size: 1em;}
649 651 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
650 652 table.cal td.odd p.day-num {color: #bbb;}
651 653 table.cal td.today {background:#ffffdd;}
652 654 table.cal td.today p.day-num {font-weight: bold;}
653 655 table.cal .starting a, p.cal.legend .starting {background: url(../images/bullet_go.png) no-repeat -1px -2px; padding-left:16px;}
654 656 table.cal .ending a, p.cal.legend .ending {background: url(../images/bullet_end.png) no-repeat -1px -2px; padding-left:16px;}
655 657 table.cal .starting.ending a, p.cal.legend .starting.ending {background: url(../images/bullet_diamond.png) no-repeat -1px -2px; padding-left:16px;}
656 658 p.cal.legend span {display:block;}
657 659
658 660 /***** Tooltips ******/
659 661 .tooltip{position:relative;z-index:24;}
660 662 .tooltip:hover{z-index:25;color:#000;}
661 663 .tooltip span.tip{display: none; text-align:left;}
662 664
663 665 div.tooltip:hover span.tip{
664 666 display:block;
665 667 position:absolute;
666 668 top:12px; left:24px; width:270px;
667 669 border:1px solid #555;
668 670 background-color:#fff;
669 671 padding: 4px;
670 672 font-size: 0.8em;
671 673 color:#505050;
672 674 }
673 675
674 676 img.ui-datepicker-trigger {
675 677 cursor: pointer;
676 678 vertical-align: middle;
677 679 margin-left: 4px;
678 680 }
679 681
680 682 /***** Progress bar *****/
681 683 table.progress {
682 684 border-collapse: collapse;
683 685 border-spacing: 0pt;
684 686 empty-cells: show;
685 687 text-align: center;
686 688 float:left;
687 689 margin: 1px 6px 1px 0px;
688 690 }
689 691
690 692 table.progress td { height: 1em; }
691 693 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
692 694 table.progress td.done { background: #D3EDD3 none repeat scroll 0%; }
693 695 table.progress td.todo { background: #eee none repeat scroll 0%; }
694 696 p.pourcent {font-size: 80%;}
695 697 p.progress-info {clear: left; font-size: 80%; margin-top:-4px; color:#777;}
696 698
697 699 #roadmap table.progress td { height: 1.2em; }
698 700 /***** Tabs *****/
699 701 #content .tabs {height: 2.6em; margin-bottom:1.2em; position:relative; overflow:hidden;}
700 702 #content .tabs ul {margin:0; position:absolute; bottom:0; padding-left:0.5em; width: 2000px; border-bottom: 1px solid #bbbbbb;}
701 703 #content .tabs ul li {
702 704 float:left;
703 705 list-style-type:none;
704 706 white-space:nowrap;
705 707 margin-right:4px;
706 708 background:#fff;
707 709 position:relative;
708 710 margin-bottom:-1px;
709 711 }
710 712 #content .tabs ul li a{
711 713 display:block;
712 714 font-size: 0.9em;
713 715 text-decoration:none;
714 716 line-height:1.3em;
715 717 padding:4px 6px 4px 6px;
716 718 border: 1px solid #ccc;
717 719 border-bottom: 1px solid #bbbbbb;
718 720 background-color: #f6f6f6;
719 721 color:#999;
720 722 font-weight:bold;
721 723 border-top-left-radius:3px;
722 724 border-top-right-radius:3px;
723 725 }
724 726
725 727 #content .tabs ul li a:hover {
726 728 background-color: #ffffdd;
727 729 text-decoration:none;
728 730 }
729 731
730 732 #content .tabs ul li a.selected {
731 733 background-color: #fff;
732 734 border: 1px solid #bbbbbb;
733 735 border-bottom: 1px solid #fff;
734 736 color:#444;
735 737 }
736 738
737 739 #content .tabs ul li a.selected:hover {background-color: #fff;}
738 740
739 741 div.tabs-buttons { position:absolute; right: 0; width: 48px; height: 24px; background: white; bottom: 0; border-bottom: 1px solid #bbbbbb; }
740 742
741 743 button.tab-left, button.tab-right {
742 744 font-size: 0.9em;
743 745 cursor: pointer;
744 746 height:24px;
745 747 border: 1px solid #ccc;
746 748 border-bottom: 1px solid #bbbbbb;
747 749 position:absolute;
748 750 padding:4px;
749 751 width: 20px;
750 752 bottom: -1px;
751 753 }
752 754
753 755 button.tab-left {
754 756 right: 20px;
755 757 background: #eeeeee url(../images/bullet_arrow_left.png) no-repeat 50% 50%;
756 758 border-top-left-radius:3px;
757 759 }
758 760
759 761 button.tab-right {
760 762 right: 0;
761 763 background: #eeeeee url(../images/bullet_arrow_right.png) no-repeat 50% 50%;
762 764 border-top-right-radius:3px;
763 765 }
764 766
765 767 /***** Diff *****/
766 768 .diff_out { background: #fcc; }
767 769 .diff_out span { background: #faa; }
768 770 .diff_in { background: #cfc; }
769 771 .diff_in span { background: #afa; }
770 772
771 773 .text-diff {
772 774 padding: 1em;
773 775 background-color:#f6f6f6;
774 776 color:#505050;
775 777 border: 1px solid #e4e4e4;
776 778 }
777 779
778 780 /***** Wiki *****/
779 781 div.wiki table {
780 782 border-collapse: collapse;
781 783 margin-bottom: 1em;
782 784 }
783 785
784 786 div.wiki table, div.wiki td, div.wiki th {
785 787 border: 1px solid #bbb;
786 788 padding: 4px;
787 789 }
788 790
789 791 div.wiki .noborder, div.wiki .noborder td, div.wiki .noborder th {border:0;}
790 792
791 793 div.wiki .external {
792 794 background-position: 0% 60%;
793 795 background-repeat: no-repeat;
794 796 padding-left: 12px;
795 797 background-image: url(../images/external.png);
796 798 }
797 799
798 800 div.wiki a.new {color: #b73535;}
799 801
800 802 div.wiki ul, div.wiki ol {margin-bottom:1em;}
801 803
802 804 div.wiki pre {
803 805 margin: 1em 1em 1em 1.6em;
804 806 padding: 8px;
805 807 background-color: #fafafa;
806 808 border: 1px solid #e2e2e2;
807 809 width:auto;
808 810 overflow-x: auto;
809 811 overflow-y: hidden;
810 812 }
811 813
812 814 div.wiki ul.toc {
813 815 background-color: #ffffdd;
814 816 border: 1px solid #e4e4e4;
815 817 padding: 4px;
816 818 line-height: 1.2em;
817 819 margin-bottom: 12px;
818 820 margin-right: 12px;
819 821 margin-left: 0;
820 822 display: table
821 823 }
822 824 * html div.wiki ul.toc { width: 50%; } /* IE6 doesn't autosize div */
823 825
824 826 div.wiki ul.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
825 827 div.wiki ul.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
826 828 div.wiki ul.toc ul { margin: 0; padding: 0; }
827 829 div.wiki ul.toc li {list-style-type:none; margin: 0; font-size:12px;}
828 830 div.wiki ul.toc li li {margin-left: 1.5em; font-size:10px;}
829 831 div.wiki ul.toc a {
830 832 font-size: 0.9em;
831 833 font-weight: normal;
832 834 text-decoration: none;
833 835 color: #606060;
834 836 }
835 837 div.wiki ul.toc a:hover { color: #c61a1a; text-decoration: underline;}
836 838
837 839 a.wiki-anchor { display: none; margin-left: 6px; text-decoration: none; }
838 840 a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
839 841 h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: inline; color: #ddd; }
840 842
841 843 div.wiki img { vertical-align: middle; }
842 844
843 845 /***** My page layout *****/
844 846 .block-receiver {
845 847 border:1px dashed #c0c0c0;
846 848 margin-bottom: 20px;
847 849 padding: 15px 0 15px 0;
848 850 }
849 851
850 852 .mypage-box {
851 853 margin:0 0 20px 0;
852 854 color:#505050;
853 855 line-height:1.5em;
854 856 }
855 857
856 858 .handle {cursor: move;}
857 859
858 860 a.close-icon {
859 861 display:block;
860 862 margin-top:3px;
861 863 overflow:hidden;
862 864 width:12px;
863 865 height:12px;
864 866 background-repeat: no-repeat;
865 867 cursor:pointer;
866 868 background-image:url('../images/close.png');
867 869 }
868 870 a.close-icon:hover {background-image:url('../images/close_hl.png');}
869 871
870 872 /***** Gantt chart *****/
871 873 .gantt_hdr {
872 874 position:absolute;
873 875 top:0;
874 876 height:16px;
875 877 border-top: 1px solid #c0c0c0;
876 878 border-bottom: 1px solid #c0c0c0;
877 879 border-right: 1px solid #c0c0c0;
878 880 text-align: center;
879 881 overflow: hidden;
880 882 }
881 883
882 884 .gantt_hdr.nwday {background-color:#f1f1f1;}
883 885
884 886 .gantt_subjects { font-size: 0.8em; }
885 887 .gantt_subjects div { line-height:16px;height:16px;overflow:hidden;white-space:nowrap;text-overflow: ellipsis; }
886 888
887 889 .task {
888 890 position: absolute;
889 891 height:8px;
890 892 font-size:0.8em;
891 893 color:#888;
892 894 padding:0;
893 895 margin:0;
894 896 line-height:16px;
895 897 white-space:nowrap;
896 898 }
897 899
898 900 .task.label {width:100%;}
899 901 .task.label.project, .task.label.version { font-weight: bold; }
900 902
901 903 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
902 904 .task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
903 905 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
904 906
905 907 .task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
906 908 .task_late.parent, .task_done.parent { height: 3px;}
907 909 .task.parent.marker.starting { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; left: 0px; top: -1px;}
908 910 .task.parent.marker.ending { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; right: 0px; top: -1px;}
909 911
910 912 .version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
911 913 .version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
912 914 .version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
913 915 .version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
914 916
915 917 .project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
916 918 .project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
917 919 .project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
918 920 .project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
919 921
920 922 .version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
921 923 .version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
922 924
923 925 /***** Icons *****/
924 926 .icon {
925 927 background-position: 0% 50%;
926 928 background-repeat: no-repeat;
927 929 padding-left: 20px;
928 930 padding-top: 2px;
929 931 padding-bottom: 3px;
930 932 }
931 933
932 934 .icon-add { background-image: url(../images/add.png); }
933 935 .icon-edit { background-image: url(../images/edit.png); }
934 936 .icon-copy { background-image: url(../images/copy.png); }
935 937 .icon-duplicate { background-image: url(../images/duplicate.png); }
936 938 .icon-del { background-image: url(../images/delete.png); }
937 939 .icon-move { background-image: url(../images/move.png); }
938 940 .icon-save { background-image: url(../images/save.png); }
939 941 .icon-cancel { background-image: url(../images/cancel.png); }
940 942 .icon-multiple { background-image: url(../images/table_multiple.png); }
941 943 .icon-folder { background-image: url(../images/folder.png); }
942 944 .open .icon-folder { background-image: url(../images/folder_open.png); }
943 945 .icon-package { background-image: url(../images/package.png); }
944 946 .icon-user { background-image: url(../images/user.png); }
945 947 .icon-projects { background-image: url(../images/projects.png); }
946 948 .icon-help { background-image: url(../images/help.png); }
947 949 .icon-attachment { background-image: url(../images/attachment.png); }
948 950 .icon-history { background-image: url(../images/history.png); }
949 951 .icon-time { background-image: url(../images/time.png); }
950 952 .icon-time-add { background-image: url(../images/time_add.png); }
951 953 .icon-stats { background-image: url(../images/stats.png); }
952 954 .icon-warning { background-image: url(../images/warning.png); }
953 955 .icon-fav { background-image: url(../images/fav.png); }
954 956 .icon-fav-off { background-image: url(../images/fav_off.png); }
955 957 .icon-reload { background-image: url(../images/reload.png); }
956 958 .icon-lock { background-image: url(../images/locked.png); }
957 959 .icon-unlock { background-image: url(../images/unlock.png); }
958 960 .icon-checked { background-image: url(../images/true.png); }
959 961 .icon-details { background-image: url(../images/zoom_in.png); }
960 962 .icon-report { background-image: url(../images/report.png); }
961 963 .icon-comment { background-image: url(../images/comment.png); }
962 964 .icon-summary { background-image: url(../images/lightning.png); }
963 965 .icon-server-authentication { background-image: url(../images/server_key.png); }
964 966 .icon-issue { background-image: url(../images/ticket.png); }
965 967 .icon-zoom-in { background-image: url(../images/zoom_in.png); }
966 968 .icon-zoom-out { background-image: url(../images/zoom_out.png); }
967 969 .icon-passwd { background-image: url(../images/textfield_key.png); }
968 970 .icon-test { background-image: url(../images/bullet_go.png); }
969 971
970 972 .icon-file { background-image: url(../images/files/default.png); }
971 973 .icon-file.text-plain { background-image: url(../images/files/text.png); }
972 974 .icon-file.text-x-c { background-image: url(../images/files/c.png); }
973 975 .icon-file.text-x-csharp { background-image: url(../images/files/csharp.png); }
974 976 .icon-file.text-x-java { background-image: url(../images/files/java.png); }
975 977 .icon-file.text-x-javascript { background-image: url(../images/files/js.png); }
976 978 .icon-file.text-x-php { background-image: url(../images/files/php.png); }
977 979 .icon-file.text-x-ruby { background-image: url(../images/files/ruby.png); }
978 980 .icon-file.text-xml { background-image: url(../images/files/xml.png); }
979 981 .icon-file.text-css { background-image: url(../images/files/css.png); }
980 982 .icon-file.text-html { background-image: url(../images/files/html.png); }
981 983 .icon-file.image-gif { background-image: url(../images/files/image.png); }
982 984 .icon-file.image-jpeg { background-image: url(../images/files/image.png); }
983 985 .icon-file.image-png { background-image: url(../images/files/image.png); }
984 986 .icon-file.image-tiff { background-image: url(../images/files/image.png); }
985 987 .icon-file.application-pdf { background-image: url(../images/files/pdf.png); }
986 988 .icon-file.application-zip { background-image: url(../images/files/zip.png); }
987 989 .icon-file.application-x-gzip { background-image: url(../images/files/zip.png); }
988 990
989 991 img.gravatar {
990 992 padding: 2px;
991 993 border: solid 1px #d5d5d5;
992 994 background: #fff;
993 995 vertical-align: middle;
994 996 }
995 997
996 998 div.issue img.gravatar {
997 999 float: left;
998 1000 margin: 0 6px 0 0;
999 1001 padding: 5px;
1000 1002 }
1001 1003
1002 1004 div.issue table img.gravatar {
1003 1005 height: 14px;
1004 1006 width: 14px;
1005 1007 padding: 2px;
1006 1008 float: left;
1007 1009 margin: 0 0.5em 0 0;
1008 1010 }
1009 1011
1010 1012 h2 img.gravatar {margin: -2px 4px -4px 0;}
1011 1013 h3 img.gravatar {margin: -4px 4px -4px 0;}
1012 1014 h4 img.gravatar {margin: -6px 4px -4px 0;}
1013 1015 td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
1014 1016 #activity dt img.gravatar {float: left; margin: 0 1em 1em 0;}
1015 1017 /* Used on 12px Gravatar img tags without the icon background */
1016 1018 .icon-gravatar {float: left; margin-right: 4px;}
1017 1019
1018 1020 #activity dt, .journal {clear: left;}
1019 1021
1020 1022 .journal-link {float: right;}
1021 1023
1022 1024 h2 img { vertical-align:middle; }
1023 1025
1024 1026 .hascontextmenu { cursor: context-menu; }
1025 1027
1026 1028 /************* CodeRay styles *************/
1027 1029 .syntaxhl div {display: inline;}
1028 1030 .syntaxhl .line-numbers {padding: 2px 4px 2px 4px; background-color: #eee; margin:0px 5px 0px 0px;}
1029 1031 .syntaxhl .code pre { overflow: auto }
1030 1032 .syntaxhl .debug { color: white !important; background: blue !important; }
1031 1033
1032 1034 .syntaxhl .annotation { color:#007 }
1033 1035 .syntaxhl .attribute-name { color:#b48 }
1034 1036 .syntaxhl .attribute-value { color:#700 }
1035 1037 .syntaxhl .binary { color:#509 }
1036 1038 .syntaxhl .char .content { color:#D20 }
1037 1039 .syntaxhl .char .delimiter { color:#710 }
1038 1040 .syntaxhl .char { color:#D20 }
1039 1041 .syntaxhl .class { color:#258; font-weight:bold }
1040 1042 .syntaxhl .class-variable { color:#369 }
1041 1043 .syntaxhl .color { color:#0A0 }
1042 1044 .syntaxhl .comment { color:#385 }
1043 1045 .syntaxhl .comment .char { color:#385 }
1044 1046 .syntaxhl .comment .delimiter { color:#385 }
1045 1047 .syntaxhl .complex { color:#A08 }
1046 1048 .syntaxhl .constant { color:#258; font-weight:bold }
1047 1049 .syntaxhl .decorator { color:#B0B }
1048 1050 .syntaxhl .definition { color:#099; font-weight:bold }
1049 1051 .syntaxhl .delimiter { color:black }
1050 1052 .syntaxhl .directive { color:#088; font-weight:bold }
1051 1053 .syntaxhl .doc { color:#970 }
1052 1054 .syntaxhl .doc-string { color:#D42; font-weight:bold }
1053 1055 .syntaxhl .doctype { color:#34b }
1054 1056 .syntaxhl .entity { color:#800; font-weight:bold }
1055 1057 .syntaxhl .error { color:#F00; background-color:#FAA }
1056 1058 .syntaxhl .escape { color:#666 }
1057 1059 .syntaxhl .exception { color:#C00; font-weight:bold }
1058 1060 .syntaxhl .float { color:#06D }
1059 1061 .syntaxhl .function { color:#06B; font-weight:bold }
1060 1062 .syntaxhl .global-variable { color:#d70 }
1061 1063 .syntaxhl .hex { color:#02b }
1062 1064 .syntaxhl .imaginary { color:#f00 }
1063 1065 .syntaxhl .include { color:#B44; font-weight:bold }
1064 1066 .syntaxhl .inline { background-color: hsla(0,0%,0%,0.07); color: black }
1065 1067 .syntaxhl .inline-delimiter { font-weight: bold; color: #666 }
1066 1068 .syntaxhl .instance-variable { color:#33B }
1067 1069 .syntaxhl .integer { color:#06D }
1068 1070 .syntaxhl .key .char { color: #60f }
1069 1071 .syntaxhl .key .delimiter { color: #404 }
1070 1072 .syntaxhl .key { color: #606 }
1071 1073 .syntaxhl .keyword { color:#939; font-weight:bold }
1072 1074 .syntaxhl .label { color:#970; font-weight:bold }
1073 1075 .syntaxhl .local-variable { color:#963 }
1074 1076 .syntaxhl .namespace { color:#707; font-weight:bold }
1075 1077 .syntaxhl .octal { color:#40E }
1076 1078 .syntaxhl .operator { }
1077 1079 .syntaxhl .predefined { color:#369; font-weight:bold }
1078 1080 .syntaxhl .predefined-constant { color:#069 }
1079 1081 .syntaxhl .predefined-type { color:#0a5; font-weight:bold }
1080 1082 .syntaxhl .preprocessor { color:#579 }
1081 1083 .syntaxhl .pseudo-class { color:#00C; font-weight:bold }
1082 1084 .syntaxhl .regexp .content { color:#808 }
1083 1085 .syntaxhl .regexp .delimiter { color:#404 }
1084 1086 .syntaxhl .regexp .modifier { color:#C2C }
1085 1087 .syntaxhl .regexp { background-color:hsla(300,100%,50%,0.06); }
1086 1088 .syntaxhl .reserved { color:#080; font-weight:bold }
1087 1089 .syntaxhl .shell .content { color:#2B2 }
1088 1090 .syntaxhl .shell .delimiter { color:#161 }
1089 1091 .syntaxhl .shell { background-color:hsla(120,100%,50%,0.06); }
1090 1092 .syntaxhl .string .char { color: #46a }
1091 1093 .syntaxhl .string .content { color: #46a }
1092 1094 .syntaxhl .string .delimiter { color: #46a }
1093 1095 .syntaxhl .string .modifier { color: #46a }
1094 1096 .syntaxhl .symbol .content { color:#d33 }
1095 1097 .syntaxhl .symbol .delimiter { color:#d33 }
1096 1098 .syntaxhl .symbol { color:#d33 }
1097 1099 .syntaxhl .tag { color:#070 }
1098 1100 .syntaxhl .type { color:#339; font-weight:bold }
1099 1101 .syntaxhl .value { color: #088; }
1100 1102 .syntaxhl .variable { color:#037 }
1101 1103
1102 1104 .syntaxhl .insert { background: hsla(120,100%,50%,0.12) }
1103 1105 .syntaxhl .delete { background: hsla(0,100%,50%,0.12) }
1104 1106 .syntaxhl .change { color: #bbf; background: #007; }
1105 1107 .syntaxhl .head { color: #f8f; background: #505 }
1106 1108 .syntaxhl .head .filename { color: white; }
1107 1109
1108 1110 .syntaxhl .delete .eyecatcher { background-color: hsla(0,100%,50%,0.2); border: 1px solid hsla(0,100%,45%,0.5); margin: -1px; border-bottom: none; border-top-left-radius: 5px; border-top-right-radius: 5px; }
1109 1111 .syntaxhl .insert .eyecatcher { background-color: hsla(120,100%,50%,0.2); border: 1px solid hsla(120,100%,25%,0.5); margin: -1px; border-top: none; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; }
1110 1112
1111 1113 .syntaxhl .insert .insert { color: #0c0; background:transparent; font-weight:bold }
1112 1114 .syntaxhl .delete .delete { color: #c00; background:transparent; font-weight:bold }
1113 1115 .syntaxhl .change .change { color: #88f }
1114 1116 .syntaxhl .head .head { color: #f4f }
1115 1117
1116 1118 /***** Media print specific styles *****/
1117 1119 @media print {
1118 1120 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
1119 1121 #main { background: #fff; }
1120 1122 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;}
1121 1123 #wiki_add_attachment { display:none; }
1122 1124 .hide-when-print { display: none; }
1123 1125 .autoscroll {overflow-x: visible;}
1124 1126 table.list {margin-top:0.5em;}
1125 1127 table.list th, table.list td {border: 1px solid #aaa;}
1126 1128 }
1127 1129
1128 1130 /* Accessibility specific styles */
1129 1131 .hidden-for-sighted {
1130 1132 position:absolute;
1131 1133 left:-10000px;
1132 1134 top:auto;
1133 1135 width:1px;
1134 1136 height:1px;
1135 1137 overflow:hidden;
1136 1138 }
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,1232 +1,1249
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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.expand_path('../../test_helper', __FILE__)
19 19
20 20 class QueryTest < ActiveSupport::TestCase
21 21 include Redmine::I18n
22 22
23 23 fixtures :projects, :enabled_modules, :users, :members,
24 24 :member_roles, :roles, :trackers, :issue_statuses,
25 25 :issue_categories, :enumerations, :issues,
26 26 :watchers, :custom_fields, :custom_values, :versions,
27 27 :queries,
28 28 :projects_trackers,
29 29 :custom_fields_trackers
30 30
31 31 def test_custom_fields_for_all_projects_should_be_available_in_global_queries
32 32 query = Query.new(:project => nil, :name => '_')
33 33 assert query.available_filters.has_key?('cf_1')
34 34 assert !query.available_filters.has_key?('cf_3')
35 35 end
36 36
37 37 def test_system_shared_versions_should_be_available_in_global_queries
38 38 Version.find(2).update_attribute :sharing, 'system'
39 39 query = Query.new(:project => nil, :name => '_')
40 40 assert query.available_filters.has_key?('fixed_version_id')
41 41 assert query.available_filters['fixed_version_id'][:values].detect {|v| v.last == '2'}
42 42 end
43 43
44 44 def test_project_filter_in_global_queries
45 45 query = Query.new(:project => nil, :name => '_')
46 46 project_filter = query.available_filters["project_id"]
47 47 assert_not_nil project_filter
48 48 project_ids = project_filter[:values].map{|p| p[1]}
49 49 assert project_ids.include?("1") #public project
50 50 assert !project_ids.include?("2") #private project user cannot see
51 51 end
52 52
53 53 def find_issues_with_query(query)
54 54 Issue.includes([:assigned_to, :status, :tracker, :project, :priority]).where(
55 55 query.statement
56 56 ).all
57 57 end
58 58
59 59 def assert_find_issues_with_query_is_successful(query)
60 60 assert_nothing_raised do
61 61 find_issues_with_query(query)
62 62 end
63 63 end
64 64
65 65 def assert_query_statement_includes(query, condition)
66 66 assert query.statement.include?(condition), "Query statement condition not found in: #{query.statement}"
67 67 end
68 68
69 69 def assert_query_result(expected, query)
70 70 assert_nothing_raised do
71 71 assert_equal expected.map(&:id).sort, query.issues.map(&:id).sort
72 72 assert_equal expected.size, query.issue_count
73 73 end
74 74 end
75 75
76 76 def test_query_should_allow_shared_versions_for_a_project_query
77 77 subproject_version = Version.find(4)
78 78 query = Query.new(:project => Project.find(1), :name => '_')
79 79 query.add_filter('fixed_version_id', '=', [subproject_version.id.to_s])
80 80
81 81 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IN ('4')")
82 82 end
83 83
84 84 def test_query_with_multiple_custom_fields
85 85 query = Query.find(1)
86 86 assert query.valid?
87 87 assert query.statement.include?("#{CustomValue.table_name}.value IN ('MySQL')")
88 88 issues = find_issues_with_query(query)
89 89 assert_equal 1, issues.length
90 90 assert_equal Issue.find(3), issues.first
91 91 end
92 92
93 93 def test_operator_none
94 94 query = Query.new(:project => Project.find(1), :name => '_')
95 95 query.add_filter('fixed_version_id', '!*', [''])
96 96 query.add_filter('cf_1', '!*', [''])
97 97 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NULL")
98 98 assert query.statement.include?("#{CustomValue.table_name}.value IS NULL OR #{CustomValue.table_name}.value = ''")
99 99 find_issues_with_query(query)
100 100 end
101 101
102 102 def test_operator_none_for_integer
103 103 query = Query.new(:project => Project.find(1), :name => '_')
104 104 query.add_filter('estimated_hours', '!*', [''])
105 105 issues = find_issues_with_query(query)
106 106 assert !issues.empty?
107 107 assert issues.all? {|i| !i.estimated_hours}
108 108 end
109 109
110 110 def test_operator_none_for_date
111 111 query = Query.new(:project => Project.find(1), :name => '_')
112 112 query.add_filter('start_date', '!*', [''])
113 113 issues = find_issues_with_query(query)
114 114 assert !issues.empty?
115 115 assert issues.all? {|i| i.start_date.nil?}
116 116 end
117 117
118 118 def test_operator_none_for_string_custom_field
119 119 query = Query.new(:project => Project.find(1), :name => '_')
120 120 query.add_filter('cf_2', '!*', [''])
121 121 assert query.has_filter?('cf_2')
122 122 issues = find_issues_with_query(query)
123 123 assert !issues.empty?
124 124 assert issues.all? {|i| i.custom_field_value(2).blank?}
125 125 end
126 126
127 127 def test_operator_all
128 128 query = Query.new(:project => Project.find(1), :name => '_')
129 129 query.add_filter('fixed_version_id', '*', [''])
130 130 query.add_filter('cf_1', '*', [''])
131 131 assert query.statement.include?("#{Issue.table_name}.fixed_version_id IS NOT NULL")
132 132 assert query.statement.include?("#{CustomValue.table_name}.value IS NOT NULL AND #{CustomValue.table_name}.value <> ''")
133 133 find_issues_with_query(query)
134 134 end
135 135
136 136 def test_operator_all_for_date
137 137 query = Query.new(:project => Project.find(1), :name => '_')
138 138 query.add_filter('start_date', '*', [''])
139 139 issues = find_issues_with_query(query)
140 140 assert !issues.empty?
141 141 assert issues.all? {|i| i.start_date.present?}
142 142 end
143 143
144 144 def test_operator_all_for_string_custom_field
145 145 query = Query.new(:project => Project.find(1), :name => '_')
146 146 query.add_filter('cf_2', '*', [''])
147 147 assert query.has_filter?('cf_2')
148 148 issues = find_issues_with_query(query)
149 149 assert !issues.empty?
150 150 assert issues.all? {|i| i.custom_field_value(2).present?}
151 151 end
152 152
153 153 def test_numeric_filter_should_not_accept_non_numeric_values
154 154 query = Query.new(:name => '_')
155 155 query.add_filter('estimated_hours', '=', ['a'])
156 156
157 157 assert query.has_filter?('estimated_hours')
158 158 assert !query.valid?
159 159 end
160 160
161 161 def test_operator_is_on_float
162 162 Issue.update_all("estimated_hours = 171.2", "id=2")
163 163
164 164 query = Query.new(:name => '_')
165 165 query.add_filter('estimated_hours', '=', ['171.20'])
166 166 issues = find_issues_with_query(query)
167 167 assert_equal 1, issues.size
168 168 assert_equal 2, issues.first.id
169 169 end
170 170
171 171 def test_operator_is_on_integer_custom_field
172 172 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_for_all => true, :is_filter => true)
173 173 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7')
174 174 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '12')
175 175 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
176 176
177 177 query = Query.new(:name => '_')
178 178 query.add_filter("cf_#{f.id}", '=', ['12'])
179 179 issues = find_issues_with_query(query)
180 180 assert_equal 1, issues.size
181 181 assert_equal 2, issues.first.id
182 182 end
183 183
184 184 def test_operator_is_on_integer_custom_field_should_accept_negative_value
185 185 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_for_all => true, :is_filter => true)
186 186 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7')
187 187 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '-12')
188 188 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
189 189
190 190 query = Query.new(:name => '_')
191 191 query.add_filter("cf_#{f.id}", '=', ['-12'])
192 192 assert query.valid?
193 193 issues = find_issues_with_query(query)
194 194 assert_equal 1, issues.size
195 195 assert_equal 2, issues.first.id
196 196 end
197 197
198 198 def test_operator_is_on_float_custom_field
199 199 f = IssueCustomField.create!(:name => 'filter', :field_format => 'float', :is_filter => true, :is_for_all => true)
200 200 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7.3')
201 201 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '12.7')
202 202 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
203 203
204 204 query = Query.new(:name => '_')
205 205 query.add_filter("cf_#{f.id}", '=', ['12.7'])
206 206 issues = find_issues_with_query(query)
207 207 assert_equal 1, issues.size
208 208 assert_equal 2, issues.first.id
209 209 end
210 210
211 211 def test_operator_is_on_float_custom_field_should_accept_negative_value
212 212 f = IssueCustomField.create!(:name => 'filter', :field_format => 'float', :is_filter => true, :is_for_all => true)
213 213 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7.3')
214 214 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '-12.7')
215 215 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
216 216
217 217 query = Query.new(:name => '_')
218 218 query.add_filter("cf_#{f.id}", '=', ['-12.7'])
219 219 assert query.valid?
220 220 issues = find_issues_with_query(query)
221 221 assert_equal 1, issues.size
222 222 assert_equal 2, issues.first.id
223 223 end
224 224
225 225 def test_operator_is_on_multi_list_custom_field
226 226 f = IssueCustomField.create!(:name => 'filter', :field_format => 'list', :is_filter => true, :is_for_all => true,
227 227 :possible_values => ['value1', 'value2', 'value3'], :multiple => true)
228 228 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value1')
229 229 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value2')
230 230 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => 'value1')
231 231
232 232 query = Query.new(:name => '_')
233 233 query.add_filter("cf_#{f.id}", '=', ['value1'])
234 234 issues = find_issues_with_query(query)
235 235 assert_equal [1, 3], issues.map(&:id).sort
236 236
237 237 query = Query.new(:name => '_')
238 238 query.add_filter("cf_#{f.id}", '=', ['value2'])
239 239 issues = find_issues_with_query(query)
240 240 assert_equal [1], issues.map(&:id).sort
241 241 end
242 242
243 243 def test_operator_is_not_on_multi_list_custom_field
244 244 f = IssueCustomField.create!(:name => 'filter', :field_format => 'list', :is_filter => true, :is_for_all => true,
245 245 :possible_values => ['value1', 'value2', 'value3'], :multiple => true)
246 246 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value1')
247 247 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => 'value2')
248 248 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => 'value1')
249 249
250 250 query = Query.new(:name => '_')
251 251 query.add_filter("cf_#{f.id}", '!', ['value1'])
252 252 issues = find_issues_with_query(query)
253 253 assert !issues.map(&:id).include?(1)
254 254 assert !issues.map(&:id).include?(3)
255 255
256 256 query = Query.new(:name => '_')
257 257 query.add_filter("cf_#{f.id}", '!', ['value2'])
258 258 issues = find_issues_with_query(query)
259 259 assert !issues.map(&:id).include?(1)
260 260 assert issues.map(&:id).include?(3)
261 261 end
262 262
263 263 def test_operator_is_on_is_private_field
264 264 # is_private filter only available for those who can set issues private
265 265 User.current = User.find(2)
266 266
267 267 query = Query.new(:name => '_')
268 268 assert query.available_filters.key?('is_private')
269 269
270 270 query.add_filter("is_private", '=', ['1'])
271 271 issues = find_issues_with_query(query)
272 272 assert issues.any?
273 273 assert_nil issues.detect {|issue| !issue.is_private?}
274 274 ensure
275 275 User.current = nil
276 276 end
277 277
278 278 def test_operator_is_not_on_is_private_field
279 279 # is_private filter only available for those who can set issues private
280 280 User.current = User.find(2)
281 281
282 282 query = Query.new(:name => '_')
283 283 assert query.available_filters.key?('is_private')
284 284
285 285 query.add_filter("is_private", '!', ['1'])
286 286 issues = find_issues_with_query(query)
287 287 assert issues.any?
288 288 assert_nil issues.detect {|issue| issue.is_private?}
289 289 ensure
290 290 User.current = nil
291 291 end
292 292
293 293 def test_operator_greater_than
294 294 query = Query.new(:project => Project.find(1), :name => '_')
295 295 query.add_filter('done_ratio', '>=', ['40'])
296 296 assert query.statement.include?("#{Issue.table_name}.done_ratio >= 40.0")
297 297 find_issues_with_query(query)
298 298 end
299 299
300 300 def test_operator_greater_than_a_float
301 301 query = Query.new(:project => Project.find(1), :name => '_')
302 302 query.add_filter('estimated_hours', '>=', ['40.5'])
303 303 assert query.statement.include?("#{Issue.table_name}.estimated_hours >= 40.5")
304 304 find_issues_with_query(query)
305 305 end
306 306
307 307 def test_operator_greater_than_on_int_custom_field
308 308 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_filter => true, :is_for_all => true)
309 309 CustomValue.create!(:custom_field => f, :customized => Issue.find(1), :value => '7')
310 310 CustomValue.create!(:custom_field => f, :customized => Issue.find(2), :value => '12')
311 311 CustomValue.create!(:custom_field => f, :customized => Issue.find(3), :value => '')
312 312
313 313 query = Query.new(:project => Project.find(1), :name => '_')
314 314 query.add_filter("cf_#{f.id}", '>=', ['8'])
315 315 issues = find_issues_with_query(query)
316 316 assert_equal 1, issues.size
317 317 assert_equal 2, issues.first.id
318 318 end
319 319
320 320 def test_operator_lesser_than
321 321 query = Query.new(:project => Project.find(1), :name => '_')
322 322 query.add_filter('done_ratio', '<=', ['30'])
323 323 assert query.statement.include?("#{Issue.table_name}.done_ratio <= 30.0")
324 324 find_issues_with_query(query)
325 325 end
326 326
327 327 def test_operator_lesser_than_on_custom_field
328 328 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_filter => true, :is_for_all => true)
329 329 query = Query.new(:project => Project.find(1), :name => '_')
330 330 query.add_filter("cf_#{f.id}", '<=', ['30'])
331 331 assert query.statement.include?("CAST(custom_values.value AS decimal(60,3)) <= 30.0")
332 332 find_issues_with_query(query)
333 333 end
334 334
335 335 def test_operator_between
336 336 query = Query.new(:project => Project.find(1), :name => '_')
337 337 query.add_filter('done_ratio', '><', ['30', '40'])
338 338 assert_include "#{Issue.table_name}.done_ratio BETWEEN 30.0 AND 40.0", query.statement
339 339 find_issues_with_query(query)
340 340 end
341 341
342 342 def test_operator_between_on_custom_field
343 343 f = IssueCustomField.create!(:name => 'filter', :field_format => 'int', :is_filter => true, :is_for_all => true)
344 344 query = Query.new(:project => Project.find(1), :name => '_')
345 345 query.add_filter("cf_#{f.id}", '><', ['30', '40'])
346 346 assert_include "CAST(custom_values.value AS decimal(60,3)) BETWEEN 30.0 AND 40.0", query.statement
347 347 find_issues_with_query(query)
348 348 end
349 349
350 350 def test_date_filter_should_not_accept_non_date_values
351 351 query = Query.new(:name => '_')
352 352 query.add_filter('created_on', '=', ['a'])
353 353
354 354 assert query.has_filter?('created_on')
355 355 assert !query.valid?
356 356 end
357 357
358 358 def test_date_filter_should_not_accept_invalid_date_values
359 359 query = Query.new(:name => '_')
360 360 query.add_filter('created_on', '=', ['2011-01-34'])
361 361
362 362 assert query.has_filter?('created_on')
363 363 assert !query.valid?
364 364 end
365 365
366 366 def test_relative_date_filter_should_not_accept_non_integer_values
367 367 query = Query.new(:name => '_')
368 368 query.add_filter('created_on', '>t-', ['a'])
369 369
370 370 assert query.has_filter?('created_on')
371 371 assert !query.valid?
372 372 end
373 373
374 374 def test_operator_date_equals
375 375 query = Query.new(:name => '_')
376 376 query.add_filter('due_date', '=', ['2011-07-10'])
377 377 assert_match /issues\.due_date > '2011-07-09 23:59:59(\.9+)?' AND issues\.due_date <= '2011-07-10 23:59:59(\.9+)?/, query.statement
378 378 find_issues_with_query(query)
379 379 end
380 380
381 381 def test_operator_date_lesser_than
382 382 query = Query.new(:name => '_')
383 383 query.add_filter('due_date', '<=', ['2011-07-10'])
384 384 assert_match /issues\.due_date <= '2011-07-10 23:59:59(\.9+)?/, query.statement
385 385 find_issues_with_query(query)
386 386 end
387 387
388 388 def test_operator_date_greater_than
389 389 query = Query.new(:name => '_')
390 390 query.add_filter('due_date', '>=', ['2011-07-10'])
391 391 assert_match /issues\.due_date > '2011-07-09 23:59:59(\.9+)?'/, query.statement
392 392 find_issues_with_query(query)
393 393 end
394 394
395 395 def test_operator_date_between
396 396 query = Query.new(:name => '_')
397 397 query.add_filter('due_date', '><', ['2011-06-23', '2011-07-10'])
398 398 assert_match /issues\.due_date > '2011-06-22 23:59:59(\.9+)?' AND issues\.due_date <= '2011-07-10 23:59:59(\.9+)?/, query.statement
399 399 find_issues_with_query(query)
400 400 end
401 401
402 402 def test_operator_in_more_than
403 403 Issue.find(7).update_attribute(:due_date, (Date.today + 15))
404 404 query = Query.new(:project => Project.find(1), :name => '_')
405 405 query.add_filter('due_date', '>t+', ['15'])
406 406 issues = find_issues_with_query(query)
407 407 assert !issues.empty?
408 408 issues.each {|issue| assert(issue.due_date >= (Date.today + 15))}
409 409 end
410 410
411 411 def test_operator_in_less_than
412 412 query = Query.new(:project => Project.find(1), :name => '_')
413 413 query.add_filter('due_date', '<t+', ['15'])
414 414 issues = find_issues_with_query(query)
415 415 assert !issues.empty?
416 416 issues.each {|issue| assert(issue.due_date <= (Date.today + 15))}
417 417 end
418 418
419 419 def test_operator_in_the_next_days
420 420 query = Query.new(:project => Project.find(1), :name => '_')
421 421 query.add_filter('due_date', '><t+', ['15'])
422 422 issues = find_issues_with_query(query)
423 423 assert !issues.empty?
424 424 issues.each {|issue| assert(issue.due_date >= Date.today && issue.due_date <= (Date.today + 15))}
425 425 end
426 426
427 427 def test_operator_less_than_ago
428 428 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
429 429 query = Query.new(:project => Project.find(1), :name => '_')
430 430 query.add_filter('due_date', '>t-', ['3'])
431 431 issues = find_issues_with_query(query)
432 432 assert !issues.empty?
433 433 issues.each {|issue| assert(issue.due_date >= (Date.today - 3))}
434 434 end
435 435
436 436 def test_operator_in_the_past_days
437 437 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
438 438 query = Query.new(:project => Project.find(1), :name => '_')
439 439 query.add_filter('due_date', '><t-', ['3'])
440 440 issues = find_issues_with_query(query)
441 441 assert !issues.empty?
442 442 issues.each {|issue| assert(issue.due_date >= (Date.today - 3) && issue.due_date <= Date.today)}
443 443 end
444 444
445 445 def test_operator_more_than_ago
446 446 Issue.find(7).update_attribute(:due_date, (Date.today - 10))
447 447 query = Query.new(:project => Project.find(1), :name => '_')
448 448 query.add_filter('due_date', '<t-', ['10'])
449 449 assert query.statement.include?("#{Issue.table_name}.due_date <=")
450 450 issues = find_issues_with_query(query)
451 451 assert !issues.empty?
452 452 issues.each {|issue| assert(issue.due_date <= (Date.today - 10))}
453 453 end
454 454
455 455 def test_operator_in
456 456 Issue.find(7).update_attribute(:due_date, (Date.today + 2))
457 457 query = Query.new(:project => Project.find(1), :name => '_')
458 458 query.add_filter('due_date', 't+', ['2'])
459 459 issues = find_issues_with_query(query)
460 460 assert !issues.empty?
461 461 issues.each {|issue| assert_equal((Date.today + 2), issue.due_date)}
462 462 end
463 463
464 464 def test_operator_ago
465 465 Issue.find(7).update_attribute(:due_date, (Date.today - 3))
466 466 query = Query.new(:project => Project.find(1), :name => '_')
467 467 query.add_filter('due_date', 't-', ['3'])
468 468 issues = find_issues_with_query(query)
469 469 assert !issues.empty?
470 470 issues.each {|issue| assert_equal((Date.today - 3), issue.due_date)}
471 471 end
472 472
473 473 def test_operator_today
474 474 query = Query.new(:project => Project.find(1), :name => '_')
475 475 query.add_filter('due_date', 't', [''])
476 476 issues = find_issues_with_query(query)
477 477 assert !issues.empty?
478 478 issues.each {|issue| assert_equal Date.today, issue.due_date}
479 479 end
480 480
481 481 def test_operator_this_week_on_date
482 482 query = Query.new(:project => Project.find(1), :name => '_')
483 483 query.add_filter('due_date', 'w', [''])
484 484 find_issues_with_query(query)
485 485 end
486 486
487 487 def test_operator_this_week_on_datetime
488 488 query = Query.new(:project => Project.find(1), :name => '_')
489 489 query.add_filter('created_on', 'w', [''])
490 490 find_issues_with_query(query)
491 491 end
492 492
493 493 def test_operator_contains
494 494 query = Query.new(:project => Project.find(1), :name => '_')
495 495 query.add_filter('subject', '~', ['uNable'])
496 496 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) LIKE '%unable%'")
497 497 result = find_issues_with_query(query)
498 498 assert result.empty?
499 499 result.each {|issue| assert issue.subject.downcase.include?('unable') }
500 500 end
501 501
502 502 def test_range_for_this_week_with_week_starting_on_monday
503 503 I18n.locale = :fr
504 504 assert_equal '1', I18n.t(:general_first_day_of_week)
505 505
506 506 Date.stubs(:today).returns(Date.parse('2011-04-29'))
507 507
508 508 query = Query.new(:project => Project.find(1), :name => '_')
509 509 query.add_filter('due_date', 'w', [''])
510 510 assert query.statement.match(/issues\.due_date > '2011-04-24 23:59:59(\.9+)?' AND issues\.due_date <= '2011-05-01 23:59:59(\.9+)?/), "range not found in #{query.statement}"
511 511 I18n.locale = :en
512 512 end
513 513
514 514 def test_range_for_this_week_with_week_starting_on_sunday
515 515 I18n.locale = :en
516 516 assert_equal '7', I18n.t(:general_first_day_of_week)
517 517
518 518 Date.stubs(:today).returns(Date.parse('2011-04-29'))
519 519
520 520 query = Query.new(:project => Project.find(1), :name => '_')
521 521 query.add_filter('due_date', 'w', [''])
522 522 assert query.statement.match(/issues\.due_date > '2011-04-23 23:59:59(\.9+)?' AND issues\.due_date <= '2011-04-30 23:59:59(\.9+)?/), "range not found in #{query.statement}"
523 523 end
524 524
525 525 def test_operator_does_not_contains
526 526 query = Query.new(:project => Project.find(1), :name => '_')
527 527 query.add_filter('subject', '!~', ['uNable'])
528 528 assert query.statement.include?("LOWER(#{Issue.table_name}.subject) NOT LIKE '%unable%'")
529 529 find_issues_with_query(query)
530 530 end
531 531
532 532 def test_filter_assigned_to_me
533 533 user = User.find(2)
534 534 group = Group.find(10)
535 535 User.current = user
536 536 i1 = Issue.generate!(:project_id => 1, :tracker_id => 1, :assigned_to => user)
537 537 i2 = Issue.generate!(:project_id => 1, :tracker_id => 1, :assigned_to => group)
538 538 i3 = Issue.generate!(:project_id => 1, :tracker_id => 1, :assigned_to => Group.find(11))
539 539 group.users << user
540 540
541 541 query = Query.new(:name => '_', :filters => { 'assigned_to_id' => {:operator => '=', :values => ['me']}})
542 542 result = query.issues
543 543 assert_equal Issue.visible.all(:conditions => {:assigned_to_id => ([2] + user.reload.group_ids)}).sort_by(&:id), result.sort_by(&:id)
544 544
545 545 assert result.include?(i1)
546 546 assert result.include?(i2)
547 547 assert !result.include?(i3)
548 548 end
549 549
550 550 def test_user_custom_field_filtered_on_me
551 551 User.current = User.find(2)
552 552 cf = IssueCustomField.create!(:field_format => 'user', :is_for_all => true, :is_filter => true, :name => 'User custom field', :tracker_ids => [1])
553 553 issue1 = Issue.create!(:project_id => 1, :tracker_id => 1, :custom_field_values => {cf.id.to_s => '2'}, :subject => 'Test', :author_id => 1)
554 554 issue2 = Issue.generate!(:project_id => 1, :tracker_id => 1, :custom_field_values => {cf.id.to_s => '3'})
555 555
556 556 query = Query.new(:name => '_', :project => Project.find(1))
557 557 filter = query.available_filters["cf_#{cf.id}"]
558 558 assert_not_nil filter
559 559 assert_include 'me', filter[:values].map{|v| v[1]}
560 560
561 561 query.filters = { "cf_#{cf.id}" => {:operator => '=', :values => ['me']}}
562 562 result = query.issues
563 563 assert_equal 1, result.size
564 564 assert_equal issue1, result.first
565 565 end
566 566
567 567 def test_filter_my_projects
568 568 User.current = User.find(2)
569 569 query = Query.new(:name => '_')
570 570 filter = query.available_filters['project_id']
571 571 assert_not_nil filter
572 572 assert_include 'mine', filter[:values].map{|v| v[1]}
573 573
574 574 query.filters = { 'project_id' => {:operator => '=', :values => ['mine']}}
575 575 result = query.issues
576 576 assert_nil result.detect {|issue| !User.current.member_of?(issue.project)}
577 577 end
578 578
579 579 def test_filter_watched_issues
580 580 User.current = User.find(1)
581 581 query = Query.new(:name => '_', :filters => { 'watcher_id' => {:operator => '=', :values => ['me']}})
582 582 result = find_issues_with_query(query)
583 583 assert_not_nil result
584 584 assert !result.empty?
585 585 assert_equal Issue.visible.watched_by(User.current).sort_by(&:id), result.sort_by(&:id)
586 586 User.current = nil
587 587 end
588 588
589 589 def test_filter_unwatched_issues
590 590 User.current = User.find(1)
591 591 query = Query.new(:name => '_', :filters => { 'watcher_id' => {:operator => '!', :values => ['me']}})
592 592 result = find_issues_with_query(query)
593 593 assert_not_nil result
594 594 assert !result.empty?
595 595 assert_equal((Issue.visible - Issue.watched_by(User.current)).sort_by(&:id).size, result.sort_by(&:id).size)
596 596 User.current = nil
597 597 end
598 598
599 599 def test_filter_on_project_custom_field
600 600 field = ProjectCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
601 601 CustomValue.create!(:custom_field => field, :customized => Project.find(3), :value => 'Foo')
602 602 CustomValue.create!(:custom_field => field, :customized => Project.find(5), :value => 'Foo')
603 603
604 604 query = Query.new(:name => '_')
605 605 filter_name = "project.cf_#{field.id}"
606 606 assert_include filter_name, query.available_filters.keys
607 607 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
608 608 assert_equal [3, 5], find_issues_with_query(query).map(&:project_id).uniq.sort
609 609 end
610 610
611 611 def test_filter_on_author_custom_field
612 612 field = UserCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
613 613 CustomValue.create!(:custom_field => field, :customized => User.find(3), :value => 'Foo')
614 614
615 615 query = Query.new(:name => '_')
616 616 filter_name = "author.cf_#{field.id}"
617 617 assert_include filter_name, query.available_filters.keys
618 618 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
619 619 assert_equal [3], find_issues_with_query(query).map(&:author_id).uniq.sort
620 620 end
621 621
622 622 def test_filter_on_assigned_to_custom_field
623 623 field = UserCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
624 624 CustomValue.create!(:custom_field => field, :customized => User.find(3), :value => 'Foo')
625 625
626 626 query = Query.new(:name => '_')
627 627 filter_name = "assigned_to.cf_#{field.id}"
628 628 assert_include filter_name, query.available_filters.keys
629 629 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
630 630 assert_equal [3], find_issues_with_query(query).map(&:assigned_to_id).uniq.sort
631 631 end
632 632
633 633 def test_filter_on_fixed_version_custom_field
634 634 field = VersionCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
635 635 CustomValue.create!(:custom_field => field, :customized => Version.find(2), :value => 'Foo')
636 636
637 637 query = Query.new(:name => '_')
638 638 filter_name = "fixed_version.cf_#{field.id}"
639 639 assert_include filter_name, query.available_filters.keys
640 640 query.filters = {filter_name => {:operator => '=', :values => ['Foo']}}
641 641 assert_equal [2], find_issues_with_query(query).map(&:fixed_version_id).uniq.sort
642 642 end
643 643
644 644 def test_filter_on_relations_with_a_specific_issue
645 645 IssueRelation.delete_all
646 646 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2))
647 647 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1))
648 648
649 649 query = Query.new(:name => '_')
650 650 query.filters = {"relates" => {:operator => '=', :values => ['1']}}
651 651 assert_equal [2, 3], find_issues_with_query(query).map(&:id).sort
652 652
653 653 query = Query.new(:name => '_')
654 654 query.filters = {"relates" => {:operator => '=', :values => ['2']}}
655 655 assert_equal [1], find_issues_with_query(query).map(&:id).sort
656 656 end
657 657
658 658 def test_filter_on_relations_with_any_issues_in_a_project
659 659 IssueRelation.delete_all
660 660 with_settings :cross_project_issue_relations => '1' do
661 661 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first)
662 662 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(2).issues.first)
663 663 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(3).issues.first)
664 664 end
665 665
666 666 query = Query.new(:name => '_')
667 667 query.filters = {"relates" => {:operator => '=p', :values => ['2']}}
668 668 assert_equal [1, 2], find_issues_with_query(query).map(&:id).sort
669 669
670 670 query = Query.new(:name => '_')
671 671 query.filters = {"relates" => {:operator => '=p', :values => ['3']}}
672 672 assert_equal [1], find_issues_with_query(query).map(&:id).sort
673 673
674 674 query = Query.new(:name => '_')
675 675 query.filters = {"relates" => {:operator => '=p', :values => ['4']}}
676 676 assert_equal [], find_issues_with_query(query).map(&:id).sort
677 677 end
678 678
679 679 def test_filter_on_relations_with_any_issues_not_in_a_project
680 680 IssueRelation.delete_all
681 681 with_settings :cross_project_issue_relations => '1' do
682 682 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first)
683 683 #IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(1).issues.first)
684 684 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(3).issues.first)
685 685 end
686 686
687 687 query = Query.new(:name => '_')
688 688 query.filters = {"relates" => {:operator => '=!p', :values => ['1']}}
689 689 assert_equal [1], find_issues_with_query(query).map(&:id).sort
690 690 end
691 691
692 692 def test_filter_on_relations_with_no_issues_in_a_project
693 693 IssueRelation.delete_all
694 694 with_settings :cross_project_issue_relations => '1' do
695 695 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Project.find(2).issues.first)
696 696 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(2), :issue_to => Project.find(3).issues.first)
697 697 IssueRelation.create!(:relation_type => "relates", :issue_to => Project.find(2).issues.first, :issue_from => Issue.find(3))
698 698 end
699 699
700 700 query = Query.new(:name => '_')
701 701 query.filters = {"relates" => {:operator => '!p', :values => ['2']}}
702 702 ids = find_issues_with_query(query).map(&:id).sort
703 703 assert_include 2, ids
704 704 assert_not_include 1, ids
705 705 assert_not_include 3, ids
706 706 end
707 707
708 708 def test_filter_on_relations_with_no_issues
709 709 IssueRelation.delete_all
710 710 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2))
711 711 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1))
712 712
713 713 query = Query.new(:name => '_')
714 714 query.filters = {"relates" => {:operator => '!*', :values => ['']}}
715 715 ids = find_issues_with_query(query).map(&:id)
716 716 assert_equal [], ids & [1, 2, 3]
717 717 assert_include 4, ids
718 718 end
719 719
720 720 def test_filter_on_relations_with_any_issues
721 721 IssueRelation.delete_all
722 722 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(2))
723 723 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(3), :issue_to => Issue.find(1))
724 724
725 725 query = Query.new(:name => '_')
726 726 query.filters = {"relates" => {:operator => '*', :values => ['']}}
727 727 assert_equal [1, 2, 3], find_issues_with_query(query).map(&:id).sort
728 728 end
729 729
730 730 def test_statement_should_be_nil_with_no_filters
731 731 q = Query.new(:name => '_')
732 732 q.filters = {}
733 733
734 734 assert q.valid?
735 735 assert_nil q.statement
736 736 end
737 737
738 738 def test_default_columns
739 739 q = Query.new
740 assert !q.columns.empty?
740 assert q.columns.any?
741 assert q.inline_columns.any?
742 assert q.block_columns.empty?
741 743 end
742 744
743 745 def test_set_column_names
744 746 q = Query.new
745 747 q.column_names = ['tracker', :subject, '', 'unknonw_column']
746 748 assert_equal [:tracker, :subject], q.columns.collect {|c| c.name}
747 749 c = q.columns.first
748 750 assert q.has_column?(c)
749 751 end
750 752
753 def test_inline_and_block_columns
754 q = Query.new
755 q.column_names = ['subject', 'description', 'tracker']
756
757 assert_equal [:subject, :tracker], q.inline_columns.map(&:name)
758 assert_equal [:description], q.block_columns.map(&:name)
759 end
760
761 def test_custom_field_columns_should_be_inline
762 q = Query.new
763 columns = q.available_columns.select {|column| column.is_a? QueryCustomFieldColumn}
764 assert columns.any?
765 assert_nil columns.detect {|column| !column.inline?}
766 end
767
751 768 def test_query_should_preload_spent_hours
752 769 q = Query.new(:name => '_', :column_names => [:subject, :spent_hours])
753 770 assert q.has_column?(:spent_hours)
754 771 issues = q.issues
755 772 assert_not_nil issues.first.instance_variable_get("@spent_hours")
756 773 end
757 774
758 775 def test_groupable_columns_should_include_custom_fields
759 776 q = Query.new
760 777 column = q.groupable_columns.detect {|c| c.name == :cf_1}
761 778 assert_not_nil column
762 779 assert_kind_of QueryCustomFieldColumn, column
763 780 end
764 781
765 782 def test_groupable_columns_should_not_include_multi_custom_fields
766 783 field = CustomField.find(1)
767 784 field.update_attribute :multiple, true
768 785
769 786 q = Query.new
770 787 column = q.groupable_columns.detect {|c| c.name == :cf_1}
771 788 assert_nil column
772 789 end
773 790
774 791 def test_groupable_columns_should_include_user_custom_fields
775 792 cf = IssueCustomField.create!(:name => 'User', :is_for_all => true, :tracker_ids => [1], :field_format => 'user')
776 793
777 794 q = Query.new
778 795 assert q.groupable_columns.detect {|c| c.name == "cf_#{cf.id}".to_sym}
779 796 end
780 797
781 798 def test_groupable_columns_should_include_version_custom_fields
782 799 cf = IssueCustomField.create!(:name => 'User', :is_for_all => true, :tracker_ids => [1], :field_format => 'version')
783 800
784 801 q = Query.new
785 802 assert q.groupable_columns.detect {|c| c.name == "cf_#{cf.id}".to_sym}
786 803 end
787 804
788 805 def test_grouped_with_valid_column
789 806 q = Query.new(:group_by => 'status')
790 807 assert q.grouped?
791 808 assert_not_nil q.group_by_column
792 809 assert_equal :status, q.group_by_column.name
793 810 assert_not_nil q.group_by_statement
794 811 assert_equal 'status', q.group_by_statement
795 812 end
796 813
797 814 def test_grouped_with_invalid_column
798 815 q = Query.new(:group_by => 'foo')
799 816 assert !q.grouped?
800 817 assert_nil q.group_by_column
801 818 assert_nil q.group_by_statement
802 819 end
803 820
804 821 def test_sortable_columns_should_sort_assignees_according_to_user_format_setting
805 822 with_settings :user_format => 'lastname_coma_firstname' do
806 823 q = Query.new
807 824 assert q.sortable_columns.has_key?('assigned_to')
808 825 assert_equal %w(users.lastname users.firstname users.id), q.sortable_columns['assigned_to']
809 826 end
810 827 end
811 828
812 829 def test_sortable_columns_should_sort_authors_according_to_user_format_setting
813 830 with_settings :user_format => 'lastname_coma_firstname' do
814 831 q = Query.new
815 832 assert q.sortable_columns.has_key?('author')
816 833 assert_equal %w(authors.lastname authors.firstname authors.id), q.sortable_columns['author']
817 834 end
818 835 end
819 836
820 837 def test_sortable_columns_should_include_custom_field
821 838 q = Query.new
822 839 assert q.sortable_columns['cf_1']
823 840 end
824 841
825 842 def test_sortable_columns_should_not_include_multi_custom_field
826 843 field = CustomField.find(1)
827 844 field.update_attribute :multiple, true
828 845
829 846 q = Query.new
830 847 assert !q.sortable_columns['cf_1']
831 848 end
832 849
833 850 def test_default_sort
834 851 q = Query.new
835 852 assert_equal [], q.sort_criteria
836 853 end
837 854
838 855 def test_set_sort_criteria_with_hash
839 856 q = Query.new
840 857 q.sort_criteria = {'0' => ['priority', 'desc'], '2' => ['tracker']}
841 858 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
842 859 end
843 860
844 861 def test_set_sort_criteria_with_array
845 862 q = Query.new
846 863 q.sort_criteria = [['priority', 'desc'], 'tracker']
847 864 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
848 865 end
849 866
850 867 def test_create_query_with_sort
851 868 q = Query.new(:name => 'Sorted')
852 869 q.sort_criteria = [['priority', 'desc'], 'tracker']
853 870 assert q.save
854 871 q.reload
855 872 assert_equal [['priority', 'desc'], ['tracker', 'asc']], q.sort_criteria
856 873 end
857 874
858 875 def test_sort_by_string_custom_field_asc
859 876 q = Query.new
860 877 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
861 878 assert c
862 879 assert c.sortable
863 880 issues = Issue.includes([:assigned_to, :status, :tracker, :project, :priority]).where(
864 881 q.statement
865 882 ).order("#{c.sortable} ASC").all
866 883 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
867 884 assert !values.empty?
868 885 assert_equal values.sort, values
869 886 end
870 887
871 888 def test_sort_by_string_custom_field_desc
872 889 q = Query.new
873 890 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'string' }
874 891 assert c
875 892 assert c.sortable
876 893 issues = Issue.includes([:assigned_to, :status, :tracker, :project, :priority]).where(
877 894 q.statement
878 895 ).order("#{c.sortable} DESC").all
879 896 values = issues.collect {|i| i.custom_value_for(c.custom_field).to_s}
880 897 assert !values.empty?
881 898 assert_equal values.sort.reverse, values
882 899 end
883 900
884 901 def test_sort_by_float_custom_field_asc
885 902 q = Query.new
886 903 c = q.available_columns.find {|col| col.is_a?(QueryCustomFieldColumn) && col.custom_field.field_format == 'float' }
887 904 assert c
888 905 assert c.sortable
889 906 issues = Issue.includes([:assigned_to, :status, :tracker, :project, :priority]).where(
890 907 q.statement
891 908 ).order("#{c.sortable} ASC").all
892 909 values = issues.collect {|i| begin; Kernel.Float(i.custom_value_for(c.custom_field).to_s); rescue; nil; end}.compact
893 910 assert !values.empty?
894 911 assert_equal values.sort, values
895 912 end
896 913
897 914 def test_invalid_query_should_raise_query_statement_invalid_error
898 915 q = Query.new
899 916 assert_raise Query::StatementInvalid do
900 917 q.issues(:conditions => "foo = 1")
901 918 end
902 919 end
903 920
904 921 def test_issue_count
905 922 q = Query.new(:name => '_')
906 923 issue_count = q.issue_count
907 924 assert_equal q.issues.size, issue_count
908 925 end
909 926
910 927 def test_issue_count_with_archived_issues
911 928 p = Project.generate! do |project|
912 929 project.status = Project::STATUS_ARCHIVED
913 930 end
914 931 i = Issue.generate!( :project => p, :tracker => p.trackers.first )
915 932 assert !i.visible?
916 933
917 934 test_issue_count
918 935 end
919 936
920 937 def test_issue_count_by_association_group
921 938 q = Query.new(:name => '_', :group_by => 'assigned_to')
922 939 count_by_group = q.issue_count_by_group
923 940 assert_kind_of Hash, count_by_group
924 941 assert_equal %w(NilClass User), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
925 942 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
926 943 assert count_by_group.has_key?(User.find(3))
927 944 end
928 945
929 946 def test_issue_count_by_list_custom_field_group
930 947 q = Query.new(:name => '_', :group_by => 'cf_1')
931 948 count_by_group = q.issue_count_by_group
932 949 assert_kind_of Hash, count_by_group
933 950 assert_equal %w(NilClass String), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
934 951 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
935 952 assert count_by_group.has_key?('MySQL')
936 953 end
937 954
938 955 def test_issue_count_by_date_custom_field_group
939 956 q = Query.new(:name => '_', :group_by => 'cf_8')
940 957 count_by_group = q.issue_count_by_group
941 958 assert_kind_of Hash, count_by_group
942 959 assert_equal %w(Date NilClass), count_by_group.keys.collect {|k| k.class.name}.uniq.sort
943 960 assert_equal %w(Fixnum), count_by_group.values.collect {|k| k.class.name}.uniq
944 961 end
945 962
946 963 def test_issue_count_with_nil_group_only
947 964 Issue.update_all("assigned_to_id = NULL")
948 965
949 966 q = Query.new(:name => '_', :group_by => 'assigned_to')
950 967 count_by_group = q.issue_count_by_group
951 968 assert_kind_of Hash, count_by_group
952 969 assert_equal 1, count_by_group.keys.size
953 970 assert_nil count_by_group.keys.first
954 971 end
955 972
956 973 def test_issue_ids
957 974 q = Query.new(:name => '_')
958 975 order = "issues.subject, issues.id"
959 976 issues = q.issues(:order => order)
960 977 assert_equal issues.map(&:id), q.issue_ids(:order => order)
961 978 end
962 979
963 980 def test_label_for
964 981 set_language_if_valid 'en'
965 982 q = Query.new
966 983 assert_equal 'Assignee', q.label_for('assigned_to_id')
967 984 end
968 985
969 986 def test_label_for_fr
970 987 set_language_if_valid 'fr'
971 988 q = Query.new
972 989 s = "Assign\xc3\xa9 \xc3\xa0"
973 990 s.force_encoding('UTF-8') if s.respond_to?(:force_encoding)
974 991 assert_equal s, q.label_for('assigned_to_id')
975 992 end
976 993
977 994 def test_editable_by
978 995 admin = User.find(1)
979 996 manager = User.find(2)
980 997 developer = User.find(3)
981 998
982 999 # Public query on project 1
983 1000 q = Query.find(1)
984 1001 assert q.editable_by?(admin)
985 1002 assert q.editable_by?(manager)
986 1003 assert !q.editable_by?(developer)
987 1004
988 1005 # Private query on project 1
989 1006 q = Query.find(2)
990 1007 assert q.editable_by?(admin)
991 1008 assert !q.editable_by?(manager)
992 1009 assert q.editable_by?(developer)
993 1010
994 1011 # Private query for all projects
995 1012 q = Query.find(3)
996 1013 assert q.editable_by?(admin)
997 1014 assert !q.editable_by?(manager)
998 1015 assert q.editable_by?(developer)
999 1016
1000 1017 # Public query for all projects
1001 1018 q = Query.find(4)
1002 1019 assert q.editable_by?(admin)
1003 1020 assert !q.editable_by?(manager)
1004 1021 assert !q.editable_by?(developer)
1005 1022 end
1006 1023
1007 1024 def test_visible_scope
1008 1025 query_ids = Query.visible(User.anonymous).map(&:id)
1009 1026
1010 1027 assert query_ids.include?(1), 'public query on public project was not visible'
1011 1028 assert query_ids.include?(4), 'public query for all projects was not visible'
1012 1029 assert !query_ids.include?(2), 'private query on public project was visible'
1013 1030 assert !query_ids.include?(3), 'private query for all projects was visible'
1014 1031 assert !query_ids.include?(7), 'public query on private project was visible'
1015 1032 end
1016 1033
1017 1034 context "#available_filters" do
1018 1035 setup do
1019 1036 @query = Query.new(:name => "_")
1020 1037 end
1021 1038
1022 1039 should "include users of visible projects in cross-project view" do
1023 1040 users = @query.available_filters["assigned_to_id"]
1024 1041 assert_not_nil users
1025 1042 assert users[:values].map{|u|u[1]}.include?("3")
1026 1043 end
1027 1044
1028 1045 should "include users of subprojects" do
1029 1046 user1 = User.generate!
1030 1047 user2 = User.generate!
1031 1048 project = Project.find(1)
1032 1049 Member.create!(:principal => user1, :project => project.children.visible.first, :role_ids => [1])
1033 1050 @query.project = project
1034 1051
1035 1052 users = @query.available_filters["assigned_to_id"]
1036 1053 assert_not_nil users
1037 1054 assert users[:values].map{|u|u[1]}.include?(user1.id.to_s)
1038 1055 assert !users[:values].map{|u|u[1]}.include?(user2.id.to_s)
1039 1056 end
1040 1057
1041 1058 should "include visible projects in cross-project view" do
1042 1059 projects = @query.available_filters["project_id"]
1043 1060 assert_not_nil projects
1044 1061 assert projects[:values].map{|u|u[1]}.include?("1")
1045 1062 end
1046 1063
1047 1064 context "'member_of_group' filter" do
1048 1065 should "be present" do
1049 1066 assert @query.available_filters.keys.include?("member_of_group")
1050 1067 end
1051 1068
1052 1069 should "be an optional list" do
1053 1070 assert_equal :list_optional, @query.available_filters["member_of_group"][:type]
1054 1071 end
1055 1072
1056 1073 should "have a list of the groups as values" do
1057 1074 Group.destroy_all # No fixtures
1058 1075 group1 = Group.generate!.reload
1059 1076 group2 = Group.generate!.reload
1060 1077
1061 1078 expected_group_list = [
1062 1079 [group1.name, group1.id.to_s],
1063 1080 [group2.name, group2.id.to_s]
1064 1081 ]
1065 1082 assert_equal expected_group_list.sort, @query.available_filters["member_of_group"][:values].sort
1066 1083 end
1067 1084
1068 1085 end
1069 1086
1070 1087 context "'assigned_to_role' filter" do
1071 1088 should "be present" do
1072 1089 assert @query.available_filters.keys.include?("assigned_to_role")
1073 1090 end
1074 1091
1075 1092 should "be an optional list" do
1076 1093 assert_equal :list_optional, @query.available_filters["assigned_to_role"][:type]
1077 1094 end
1078 1095
1079 1096 should "have a list of the Roles as values" do
1080 1097 assert @query.available_filters["assigned_to_role"][:values].include?(['Manager','1'])
1081 1098 assert @query.available_filters["assigned_to_role"][:values].include?(['Developer','2'])
1082 1099 assert @query.available_filters["assigned_to_role"][:values].include?(['Reporter','3'])
1083 1100 end
1084 1101
1085 1102 should "not include the built in Roles as values" do
1086 1103 assert ! @query.available_filters["assigned_to_role"][:values].include?(['Non member','4'])
1087 1104 assert ! @query.available_filters["assigned_to_role"][:values].include?(['Anonymous','5'])
1088 1105 end
1089 1106
1090 1107 end
1091 1108
1092 1109 end
1093 1110
1094 1111 context "#statement" do
1095 1112 context "with 'member_of_group' filter" do
1096 1113 setup do
1097 1114 Group.destroy_all # No fixtures
1098 1115 @user_in_group = User.generate!
1099 1116 @second_user_in_group = User.generate!
1100 1117 @user_in_group2 = User.generate!
1101 1118 @user_not_in_group = User.generate!
1102 1119
1103 1120 @group = Group.generate!.reload
1104 1121 @group.users << @user_in_group
1105 1122 @group.users << @second_user_in_group
1106 1123
1107 1124 @group2 = Group.generate!.reload
1108 1125 @group2.users << @user_in_group2
1109 1126
1110 1127 end
1111 1128
1112 1129 should "search assigned to for users in the group" do
1113 1130 @query = Query.new(:name => '_')
1114 1131 @query.add_filter('member_of_group', '=', [@group.id.to_s])
1115 1132
1116 1133 assert_query_statement_includes @query, "#{Issue.table_name}.assigned_to_id IN ('#{@user_in_group.id}','#{@second_user_in_group.id}')"
1117 1134 assert_find_issues_with_query_is_successful @query
1118 1135 end
1119 1136
1120 1137 should "search not assigned to any group member (none)" do
1121 1138 @query = Query.new(:name => '_')
1122 1139 @query.add_filter('member_of_group', '!*', [''])
1123 1140
1124 1141 # Users not in a group
1125 1142 assert_query_statement_includes @query, "#{Issue.table_name}.assigned_to_id IS NULL OR #{Issue.table_name}.assigned_to_id NOT IN ('#{@user_in_group.id}','#{@second_user_in_group.id}','#{@user_in_group2.id}')"
1126 1143 assert_find_issues_with_query_is_successful @query
1127 1144 end
1128 1145
1129 1146 should "search assigned to any group member (all)" do
1130 1147 @query = Query.new(:name => '_')
1131 1148 @query.add_filter('member_of_group', '*', [''])
1132 1149
1133 1150 # Only users in a group
1134 1151 assert_query_statement_includes @query, "#{Issue.table_name}.assigned_to_id IN ('#{@user_in_group.id}','#{@second_user_in_group.id}','#{@user_in_group2.id}')"
1135 1152 assert_find_issues_with_query_is_successful @query
1136 1153 end
1137 1154
1138 1155 should "return an empty set with = empty group" do
1139 1156 @empty_group = Group.generate!
1140 1157 @query = Query.new(:name => '_')
1141 1158 @query.add_filter('member_of_group', '=', [@empty_group.id.to_s])
1142 1159
1143 1160 assert_equal [], find_issues_with_query(@query)
1144 1161 end
1145 1162
1146 1163 should "return issues with ! empty group" do
1147 1164 @empty_group = Group.generate!
1148 1165 @query = Query.new(:name => '_')
1149 1166 @query.add_filter('member_of_group', '!', [@empty_group.id.to_s])
1150 1167
1151 1168 assert_find_issues_with_query_is_successful @query
1152 1169 end
1153 1170 end
1154 1171
1155 1172 context "with 'assigned_to_role' filter" do
1156 1173 setup do
1157 1174 @manager_role = Role.find_by_name('Manager')
1158 1175 @developer_role = Role.find_by_name('Developer')
1159 1176
1160 1177 @project = Project.generate!
1161 1178 @manager = User.generate!
1162 1179 @developer = User.generate!
1163 1180 @boss = User.generate!
1164 1181 @guest = User.generate!
1165 1182 User.add_to_project(@manager, @project, @manager_role)
1166 1183 User.add_to_project(@developer, @project, @developer_role)
1167 1184 User.add_to_project(@boss, @project, [@manager_role, @developer_role])
1168 1185
1169 1186 @issue1 = Issue.generate!(:project => @project, :assigned_to_id => @manager.id)
1170 1187 @issue2 = Issue.generate!(:project => @project, :assigned_to_id => @developer.id)
1171 1188 @issue3 = Issue.generate!(:project => @project, :assigned_to_id => @boss.id)
1172 1189 @issue4 = Issue.generate!(:project => @project, :assigned_to_id => @guest.id)
1173 1190 @issue5 = Issue.generate!(:project => @project)
1174 1191 end
1175 1192
1176 1193 should "search assigned to for users with the Role" do
1177 1194 @query = Query.new(:name => '_', :project => @project)
1178 1195 @query.add_filter('assigned_to_role', '=', [@manager_role.id.to_s])
1179 1196
1180 1197 assert_query_result [@issue1, @issue3], @query
1181 1198 end
1182 1199
1183 1200 should "search assigned to for users with the Role on the issue project" do
1184 1201 other_project = Project.generate!
1185 1202 User.add_to_project(@developer, other_project, @manager_role)
1186 1203
1187 1204 @query = Query.new(:name => '_', :project => @project)
1188 1205 @query.add_filter('assigned_to_role', '=', [@manager_role.id.to_s])
1189 1206
1190 1207 assert_query_result [@issue1, @issue3], @query
1191 1208 end
1192 1209
1193 1210 should "return an empty set with empty role" do
1194 1211 @empty_role = Role.generate!
1195 1212 @query = Query.new(:name => '_', :project => @project)
1196 1213 @query.add_filter('assigned_to_role', '=', [@empty_role.id.to_s])
1197 1214
1198 1215 assert_query_result [], @query
1199 1216 end
1200 1217
1201 1218 should "search assigned to for users without the Role" do
1202 1219 @query = Query.new(:name => '_', :project => @project)
1203 1220 @query.add_filter('assigned_to_role', '!', [@manager_role.id.to_s])
1204 1221
1205 1222 assert_query_result [@issue2, @issue4, @issue5], @query
1206 1223 end
1207 1224
1208 1225 should "search assigned to for users not assigned to any Role (none)" do
1209 1226 @query = Query.new(:name => '_', :project => @project)
1210 1227 @query.add_filter('assigned_to_role', '!*', [''])
1211 1228
1212 1229 assert_query_result [@issue4, @issue5], @query
1213 1230 end
1214 1231
1215 1232 should "search assigned to for users assigned to any Role (all)" do
1216 1233 @query = Query.new(:name => '_', :project => @project)
1217 1234 @query.add_filter('assigned_to_role', '*', [''])
1218 1235
1219 1236 assert_query_result [@issue1, @issue2, @issue3], @query
1220 1237 end
1221 1238
1222 1239 should "return issues with ! empty role" do
1223 1240 @empty_role = Role.generate!
1224 1241 @query = Query.new(:name => '_', :project => @project)
1225 1242 @query.add_filter('assigned_to_role', '!', [@empty_role.id.to_s])
1226 1243
1227 1244 assert_query_result [@issue1, @issue2, @issue3, @issue4, @issue5], @query
1228 1245 end
1229 1246 end
1230 1247 end
1231 1248
1232 1249 end
General Comments 0
You need to be logged in to leave comments. Login now