##// END OF EJS Templates
Merged r12415 (#15677)....
Jean-Philippe Lang -
r12157:fa2b39bf7317
parent child
Show More
@@ -1,1269 +1,1270
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2013 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 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27 include Redmine::Pagination::Helper
28 28
29 29 extend Forwardable
30 30 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
31 31
32 32 # Return true if user is authorized for controller/action, otherwise false
33 33 def authorize_for(controller, action)
34 34 User.current.allowed_to?({:controller => controller, :action => action}, @project)
35 35 end
36 36
37 37 # Display a link if user is authorized
38 38 #
39 39 # @param [String] name Anchor text (passed to link_to)
40 40 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
41 41 # @param [optional, Hash] html_options Options passed to link_to
42 42 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
43 43 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
44 44 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
45 45 end
46 46
47 47 # Displays a link to user's account page if active
48 48 def link_to_user(user, options={})
49 49 if user.is_a?(User)
50 50 name = h(user.name(options[:format]))
51 51 if user.active? || (User.current.admin? && user.logged?)
52 52 link_to name, user_path(user), :class => user.css_classes
53 53 else
54 54 name
55 55 end
56 56 else
57 57 h(user.to_s)
58 58 end
59 59 end
60 60
61 61 # Displays a link to +issue+ with its subject.
62 62 # Examples:
63 63 #
64 64 # link_to_issue(issue) # => Defect #6: This is the subject
65 65 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
66 66 # link_to_issue(issue, :subject => false) # => Defect #6
67 67 # link_to_issue(issue, :project => true) # => Foo - Defect #6
68 68 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
69 69 #
70 70 def link_to_issue(issue, options={})
71 71 title = nil
72 72 subject = nil
73 73 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
74 74 if options[:subject] == false
75 75 title = truncate(issue.subject, :length => 60)
76 76 else
77 77 subject = issue.subject
78 78 if options[:truncate]
79 79 subject = truncate(subject, :length => options[:truncate])
80 80 end
81 81 end
82 s = link_to text, issue_path(issue), :class => issue.css_classes, :title => title
82 only_path = options[:only_path].nil? ? true : options[:only_path]
83 s = link_to text, issue_path(issue, :only_path => only_path), :class => issue.css_classes, :title => title
83 84 s << h(": #{subject}") if subject
84 85 s = h("#{issue.project} - ") + s if options[:project]
85 86 s
86 87 end
87 88
88 89 # Generates a link to an attachment.
89 90 # Options:
90 91 # * :text - Link text (default to attachment filename)
91 92 # * :download - Force download (default: false)
92 93 def link_to_attachment(attachment, options={})
93 94 text = options.delete(:text) || attachment.filename
94 95 route_method = options.delete(:download) ? :download_named_attachment_path : :named_attachment_path
95 96 html_options = options.slice!(:only_path)
96 97 url = send(route_method, attachment, attachment.filename, options)
97 98 link_to text, url, html_options
98 99 end
99 100
100 101 # Generates a link to a SCM revision
101 102 # Options:
102 103 # * :text - Link text (default to the formatted revision)
103 104 def link_to_revision(revision, repository, options={})
104 105 if repository.is_a?(Project)
105 106 repository = repository.repository
106 107 end
107 108 text = options.delete(:text) || format_revision(revision)
108 109 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
109 110 link_to(
110 111 h(text),
111 112 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
112 113 :title => l(:label_revision_id, format_revision(revision))
113 114 )
114 115 end
115 116
116 117 # Generates a link to a message
117 118 def link_to_message(message, options={}, html_options = nil)
118 119 link_to(
119 120 truncate(message.subject, :length => 60),
120 121 board_message_path(message.board_id, message.parent_id || message.id, {
121 122 :r => (message.parent_id && message.id),
122 123 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
123 124 }.merge(options)),
124 125 html_options
125 126 )
126 127 end
127 128
128 129 # Generates a link to a project if active
129 130 # Examples:
130 131 #
131 132 # link_to_project(project) # => link to the specified project overview
132 133 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
133 134 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
134 135 #
135 136 def link_to_project(project, options={}, html_options = nil)
136 137 if project.archived?
137 138 h(project.name)
138 139 elsif options.key?(:action)
139 140 ActiveSupport::Deprecation.warn "#link_to_project with :action option is deprecated and will be removed in Redmine 3.0."
140 141 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
141 142 link_to project.name, url, html_options
142 143 else
143 144 link_to project.name, project_path(project, options), html_options
144 145 end
145 146 end
146 147
147 148 # Generates a link to a project settings if active
148 149 def link_to_project_settings(project, options={}, html_options=nil)
149 150 if project.active?
150 151 link_to project.name, settings_project_path(project, options), html_options
151 152 elsif project.archived?
152 153 h(project.name)
153 154 else
154 155 link_to project.name, project_path(project, options), html_options
155 156 end
156 157 end
157 158
158 159 def wiki_page_path(page, options={})
159 160 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
160 161 end
161 162
162 163 def thumbnail_tag(attachment)
163 164 link_to image_tag(thumbnail_path(attachment)),
164 165 named_attachment_path(attachment, attachment.filename),
165 166 :title => attachment.filename
166 167 end
167 168
168 169 def toggle_link(name, id, options={})
169 170 onclick = "$('##{id}').toggle(); "
170 171 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
171 172 onclick << "return false;"
172 173 link_to(name, "#", :onclick => onclick)
173 174 end
174 175
175 176 def image_to_function(name, function, html_options = {})
176 177 html_options.symbolize_keys!
177 178 tag(:input, html_options.merge({
178 179 :type => "image", :src => image_path(name),
179 180 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
180 181 }))
181 182 end
182 183
183 184 def format_activity_title(text)
184 185 h(truncate_single_line(text, :length => 100))
185 186 end
186 187
187 188 def format_activity_day(date)
188 189 date == User.current.today ? l(:label_today).titleize : format_date(date)
189 190 end
190 191
191 192 def format_activity_description(text)
192 193 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
193 194 ).gsub(/[\r\n]+/, "<br />").html_safe
194 195 end
195 196
196 197 def format_version_name(version)
197 198 if version.project == @project
198 199 h(version)
199 200 else
200 201 h("#{version.project} - #{version}")
201 202 end
202 203 end
203 204
204 205 def due_date_distance_in_words(date)
205 206 if date
206 207 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
207 208 end
208 209 end
209 210
210 211 # Renders a tree of projects as a nested set of unordered lists
211 212 # The given collection may be a subset of the whole project tree
212 213 # (eg. some intermediate nodes are private and can not be seen)
213 214 def render_project_nested_lists(projects)
214 215 s = ''
215 216 if projects.any?
216 217 ancestors = []
217 218 original_project = @project
218 219 projects.sort_by(&:lft).each do |project|
219 220 # set the project environment to please macros.
220 221 @project = project
221 222 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
222 223 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
223 224 else
224 225 ancestors.pop
225 226 s << "</li>"
226 227 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
227 228 ancestors.pop
228 229 s << "</ul></li>\n"
229 230 end
230 231 end
231 232 classes = (ancestors.empty? ? 'root' : 'child')
232 233 s << "<li class='#{classes}'><div class='#{classes}'>"
233 234 s << h(block_given? ? yield(project) : project.name)
234 235 s << "</div>\n"
235 236 ancestors << project
236 237 end
237 238 s << ("</li></ul>\n" * ancestors.size)
238 239 @project = original_project
239 240 end
240 241 s.html_safe
241 242 end
242 243
243 244 def render_page_hierarchy(pages, node=nil, options={})
244 245 content = ''
245 246 if pages[node]
246 247 content << "<ul class=\"pages-hierarchy\">\n"
247 248 pages[node].each do |page|
248 249 content << "<li>"
249 250 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
250 251 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
251 252 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
252 253 content << "</li>\n"
253 254 end
254 255 content << "</ul>\n"
255 256 end
256 257 content.html_safe
257 258 end
258 259
259 260 # Renders flash messages
260 261 def render_flash_messages
261 262 s = ''
262 263 flash.each do |k,v|
263 264 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
264 265 end
265 266 s.html_safe
266 267 end
267 268
268 269 # Renders tabs and their content
269 270 def render_tabs(tabs)
270 271 if tabs.any?
271 272 render :partial => 'common/tabs', :locals => {:tabs => tabs}
272 273 else
273 274 content_tag 'p', l(:label_no_data), :class => "nodata"
274 275 end
275 276 end
276 277
277 278 # Renders the project quick-jump box
278 279 def render_project_jump_box
279 280 return unless User.current.logged?
280 281 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
281 282 if projects.any?
282 283 options =
283 284 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
284 285 '<option value="" disabled="disabled">---</option>').html_safe
285 286
286 287 options << project_tree_options_for_select(projects, :selected => @project) do |p|
287 288 { :value => project_path(:id => p, :jump => current_menu_item) }
288 289 end
289 290
290 291 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
291 292 end
292 293 end
293 294
294 295 def project_tree_options_for_select(projects, options = {})
295 296 s = ''
296 297 project_tree(projects) do |project, level|
297 298 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
298 299 tag_options = {:value => project.id}
299 300 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
300 301 tag_options[:selected] = 'selected'
301 302 else
302 303 tag_options[:selected] = nil
303 304 end
304 305 tag_options.merge!(yield(project)) if block_given?
305 306 s << content_tag('option', name_prefix + h(project), tag_options)
306 307 end
307 308 s.html_safe
308 309 end
309 310
310 311 # Yields the given block for each project with its level in the tree
311 312 #
312 313 # Wrapper for Project#project_tree
313 314 def project_tree(projects, &block)
314 315 Project.project_tree(projects, &block)
315 316 end
316 317
317 318 def principals_check_box_tags(name, principals)
318 319 s = ''
319 320 principals.each do |principal|
320 321 s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
321 322 end
322 323 s.html_safe
323 324 end
324 325
325 326 # Returns a string for users/groups option tags
326 327 def principals_options_for_select(collection, selected=nil)
327 328 s = ''
328 329 if collection.include?(User.current)
329 330 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
330 331 end
331 332 groups = ''
332 333 collection.sort.each do |element|
333 334 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
334 335 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
335 336 end
336 337 unless groups.empty?
337 338 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
338 339 end
339 340 s.html_safe
340 341 end
341 342
342 343 # Options for the new membership projects combo-box
343 344 def options_for_membership_project_select(principal, projects)
344 345 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
345 346 options << project_tree_options_for_select(projects) do |p|
346 347 {:disabled => principal.projects.to_a.include?(p)}
347 348 end
348 349 options
349 350 end
350 351
351 352 def option_tag(name, text, value, selected=nil, options={})
352 353 content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
353 354 end
354 355
355 356 # Truncates and returns the string as a single line
356 357 def truncate_single_line(string, *args)
357 358 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
358 359 end
359 360
360 361 # Truncates at line break after 250 characters or options[:length]
361 362 def truncate_lines(string, options={})
362 363 length = options[:length] || 250
363 364 if string.to_s =~ /\A(.{#{length}}.*?)$/m
364 365 "#{$1}..."
365 366 else
366 367 string
367 368 end
368 369 end
369 370
370 371 def anchor(text)
371 372 text.to_s.gsub(' ', '_')
372 373 end
373 374
374 375 def html_hours(text)
375 376 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
376 377 end
377 378
378 379 def authoring(created, author, options={})
379 380 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
380 381 end
381 382
382 383 def time_tag(time)
383 384 text = distance_of_time_in_words(Time.now, time)
384 385 if @project
385 386 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
386 387 else
387 388 content_tag('abbr', text, :title => format_time(time))
388 389 end
389 390 end
390 391
391 392 def syntax_highlight_lines(name, content)
392 393 lines = []
393 394 syntax_highlight(name, content).each_line { |line| lines << line }
394 395 lines
395 396 end
396 397
397 398 def syntax_highlight(name, content)
398 399 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
399 400 end
400 401
401 402 def to_path_param(path)
402 403 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
403 404 str.blank? ? nil : str
404 405 end
405 406
406 407 def reorder_links(name, url, method = :post)
407 408 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
408 409 url.merge({"#{name}[move_to]" => 'highest'}),
409 410 :method => method, :title => l(:label_sort_highest)) +
410 411 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
411 412 url.merge({"#{name}[move_to]" => 'higher'}),
412 413 :method => method, :title => l(:label_sort_higher)) +
413 414 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
414 415 url.merge({"#{name}[move_to]" => 'lower'}),
415 416 :method => method, :title => l(:label_sort_lower)) +
416 417 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
417 418 url.merge({"#{name}[move_to]" => 'lowest'}),
418 419 :method => method, :title => l(:label_sort_lowest))
419 420 end
420 421
421 422 def breadcrumb(*args)
422 423 elements = args.flatten
423 424 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
424 425 end
425 426
426 427 def other_formats_links(&block)
427 428 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
428 429 yield Redmine::Views::OtherFormatsBuilder.new(self)
429 430 concat('</p>'.html_safe)
430 431 end
431 432
432 433 def page_header_title
433 434 if @project.nil? || @project.new_record?
434 435 h(Setting.app_title)
435 436 else
436 437 b = []
437 438 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
438 439 if ancestors.any?
439 440 root = ancestors.shift
440 441 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
441 442 if ancestors.size > 2
442 443 b << "\xe2\x80\xa6"
443 444 ancestors = ancestors[-2, 2]
444 445 end
445 446 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
446 447 end
447 448 b << h(@project)
448 449 b.join(" \xc2\xbb ").html_safe
449 450 end
450 451 end
451 452
452 453 # Returns a h2 tag and sets the html title with the given arguments
453 454 def title(*args)
454 455 strings = args.map do |arg|
455 456 if arg.is_a?(Array) && arg.size >= 2
456 457 link_to(*arg)
457 458 else
458 459 h(arg.to_s)
459 460 end
460 461 end
461 462 html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
462 463 content_tag('h2', strings.join(' &#187; ').html_safe)
463 464 end
464 465
465 466 # Sets the html title
466 467 # Returns the html title when called without arguments
467 468 # Current project name and app_title and automatically appended
468 469 # Exemples:
469 470 # html_title 'Foo', 'Bar'
470 471 # html_title # => 'Foo - Bar - My Project - Redmine'
471 472 def html_title(*args)
472 473 if args.empty?
473 474 title = @html_title || []
474 475 title << @project.name if @project
475 476 title << Setting.app_title unless Setting.app_title == title.last
476 477 title.reject(&:blank?).join(' - ')
477 478 else
478 479 @html_title ||= []
479 480 @html_title += args
480 481 end
481 482 end
482 483
483 484 # Returns the theme, controller name, and action as css classes for the
484 485 # HTML body.
485 486 def body_css_classes
486 487 css = []
487 488 if theme = Redmine::Themes.theme(Setting.ui_theme)
488 489 css << 'theme-' + theme.name
489 490 end
490 491
491 492 css << 'project-' + @project.identifier if @project && @project.identifier.present?
492 493 css << 'controller-' + controller_name
493 494 css << 'action-' + action_name
494 495 css.join(' ')
495 496 end
496 497
497 498 def accesskey(s)
498 499 @used_accesskeys ||= []
499 500 key = Redmine::AccessKeys.key_for(s)
500 501 return nil if @used_accesskeys.include?(key)
501 502 @used_accesskeys << key
502 503 key
503 504 end
504 505
505 506 # Formats text according to system settings.
506 507 # 2 ways to call this method:
507 508 # * with a String: textilizable(text, options)
508 509 # * with an object and one of its attribute: textilizable(issue, :description, options)
509 510 def textilizable(*args)
510 511 options = args.last.is_a?(Hash) ? args.pop : {}
511 512 case args.size
512 513 when 1
513 514 obj = options[:object]
514 515 text = args.shift
515 516 when 2
516 517 obj = args.shift
517 518 attr = args.shift
518 519 text = obj.send(attr).to_s
519 520 else
520 521 raise ArgumentError, 'invalid arguments to textilizable'
521 522 end
522 523 return '' if text.blank?
523 524 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
524 525 only_path = options.delete(:only_path) == false ? false : true
525 526
526 527 text = text.dup
527 528 macros = catch_macros(text)
528 529 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
529 530
530 531 @parsed_headings = []
531 532 @heading_anchors = {}
532 533 @current_section = 0 if options[:edit_section_links]
533 534
534 535 parse_sections(text, project, obj, attr, only_path, options)
535 536 text = parse_non_pre_blocks(text, obj, macros) do |text|
536 537 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
537 538 send method_name, text, project, obj, attr, only_path, options
538 539 end
539 540 end
540 541 parse_headings(text, project, obj, attr, only_path, options)
541 542
542 543 if @parsed_headings.any?
543 544 replace_toc(text, @parsed_headings)
544 545 end
545 546
546 547 text.html_safe
547 548 end
548 549
549 550 def parse_non_pre_blocks(text, obj, macros)
550 551 s = StringScanner.new(text)
551 552 tags = []
552 553 parsed = ''
553 554 while !s.eos?
554 555 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
555 556 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
556 557 if tags.empty?
557 558 yield text
558 559 inject_macros(text, obj, macros) if macros.any?
559 560 else
560 561 inject_macros(text, obj, macros, false) if macros.any?
561 562 end
562 563 parsed << text
563 564 if tag
564 565 if closing
565 566 if tags.last == tag.downcase
566 567 tags.pop
567 568 end
568 569 else
569 570 tags << tag.downcase
570 571 end
571 572 parsed << full_tag
572 573 end
573 574 end
574 575 # Close any non closing tags
575 576 while tag = tags.pop
576 577 parsed << "</#{tag}>"
577 578 end
578 579 parsed
579 580 end
580 581
581 582 def parse_inline_attachments(text, project, obj, attr, only_path, options)
582 583 # when using an image link, try to use an attachment, if possible
583 584 attachments = options[:attachments] || []
584 585 attachments += obj.attachments if obj.respond_to?(:attachments)
585 586 if attachments.present?
586 587 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
587 588 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
588 589 # search for the picture in attachments
589 590 if found = Attachment.latest_attach(attachments, filename)
590 591 image_url = download_named_attachment_path(found, found.filename, :only_path => only_path)
591 592 desc = found.description.to_s.gsub('"', '')
592 593 if !desc.blank? && alttext.blank?
593 594 alt = " title=\"#{desc}\" alt=\"#{desc}\""
594 595 end
595 596 "src=\"#{image_url}\"#{alt}"
596 597 else
597 598 m
598 599 end
599 600 end
600 601 end
601 602 end
602 603
603 604 # Wiki links
604 605 #
605 606 # Examples:
606 607 # [[mypage]]
607 608 # [[mypage|mytext]]
608 609 # wiki links can refer other project wikis, using project name or identifier:
609 610 # [[project:]] -> wiki starting page
610 611 # [[project:|mytext]]
611 612 # [[project:mypage]]
612 613 # [[project:mypage|mytext]]
613 614 def parse_wiki_links(text, project, obj, attr, only_path, options)
614 615 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
615 616 link_project = project
616 617 esc, all, page, title = $1, $2, $3, $5
617 618 if esc.nil?
618 619 if page =~ /^([^\:]+)\:(.*)$/
619 620 identifier, page = $1, $2
620 621 link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
621 622 title ||= identifier if page.blank?
622 623 end
623 624
624 625 if link_project && link_project.wiki
625 626 # extract anchor
626 627 anchor = nil
627 628 if page =~ /^(.+?)\#(.+)$/
628 629 page, anchor = $1, $2
629 630 end
630 631 anchor = sanitize_anchor_name(anchor) if anchor.present?
631 632 # check if page exists
632 633 wiki_page = link_project.wiki.find_page(page)
633 634 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
634 635 "##{anchor}"
635 636 else
636 637 case options[:wiki_links]
637 638 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
638 639 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
639 640 else
640 641 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
641 642 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
642 643 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
643 644 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
644 645 end
645 646 end
646 647 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
647 648 else
648 649 # project or wiki doesn't exist
649 650 all
650 651 end
651 652 else
652 653 all
653 654 end
654 655 end
655 656 end
656 657
657 658 # Redmine links
658 659 #
659 660 # Examples:
660 661 # Issues:
661 662 # #52 -> Link to issue #52
662 663 # Changesets:
663 664 # r52 -> Link to revision 52
664 665 # commit:a85130f -> Link to scmid starting with a85130f
665 666 # Documents:
666 667 # document#17 -> Link to document with id 17
667 668 # document:Greetings -> Link to the document with title "Greetings"
668 669 # document:"Some document" -> Link to the document with title "Some document"
669 670 # Versions:
670 671 # version#3 -> Link to version with id 3
671 672 # version:1.0.0 -> Link to version named "1.0.0"
672 673 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
673 674 # Attachments:
674 675 # attachment:file.zip -> Link to the attachment of the current object named file.zip
675 676 # Source files:
676 677 # source:some/file -> Link to the file located at /some/file in the project's repository
677 678 # source:some/file@52 -> Link to the file's revision 52
678 679 # source:some/file#L120 -> Link to line 120 of the file
679 680 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
680 681 # export:some/file -> Force the download of the file
681 682 # Forum messages:
682 683 # message#1218 -> Link to message with id 1218
683 684 # Projects:
684 685 # project:someproject -> Link to project named "someproject"
685 686 # project#3 -> Link to project with id 3
686 687 #
687 688 # Links can refer other objects from other projects, using project identifier:
688 689 # identifier:r52
689 690 # identifier:document:"Some document"
690 691 # identifier:version:1.0.0
691 692 # identifier:source:some/file
692 693 def parse_redmine_links(text, default_project, obj, attr, only_path, options)
693 694 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-_]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
694 695 leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $1, $2, $3, $4, $5, $10, $11, $8 || $12 || $18, $14 || $19, $15, $17
695 696 link = nil
696 697 project = default_project
697 698 if project_identifier
698 699 project = Project.visible.find_by_identifier(project_identifier)
699 700 end
700 701 if esc.nil?
701 702 if prefix.nil? && sep == 'r'
702 703 if project
703 704 repository = nil
704 705 if repo_identifier
705 706 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
706 707 else
707 708 repository = project.repository
708 709 end
709 710 # project.changesets.visible raises an SQL error because of a double join on repositories
710 711 if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier))
711 712 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.revision},
712 713 :class => 'changeset',
713 714 :title => truncate_single_line(changeset.comments, :length => 100))
714 715 end
715 716 end
716 717 elsif sep == '#'
717 718 oid = identifier.to_i
718 719 case prefix
719 720 when nil
720 721 if oid.to_s == identifier && issue = Issue.visible.find_by_id(oid, :include => :status)
721 722 anchor = comment_id ? "note-#{comment_id}" : nil
722 723 link = link_to(h("##{oid}#{comment_suffix}"), {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid, :anchor => anchor},
723 724 :class => issue.css_classes,
724 725 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
725 726 end
726 727 when 'document'
727 728 if document = Document.visible.find_by_id(oid)
728 729 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
729 730 :class => 'document'
730 731 end
731 732 when 'version'
732 733 if version = Version.visible.find_by_id(oid)
733 734 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
734 735 :class => 'version'
735 736 end
736 737 when 'message'
737 738 if message = Message.visible.find_by_id(oid, :include => :parent)
738 739 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
739 740 end
740 741 when 'forum'
741 742 if board = Board.visible.find_by_id(oid)
742 743 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
743 744 :class => 'board'
744 745 end
745 746 when 'news'
746 747 if news = News.visible.find_by_id(oid)
747 748 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
748 749 :class => 'news'
749 750 end
750 751 when 'project'
751 752 if p = Project.visible.find_by_id(oid)
752 753 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
753 754 end
754 755 end
755 756 elsif sep == ':'
756 757 # removes the double quotes if any
757 758 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
758 759 case prefix
759 760 when 'document'
760 761 if project && document = project.documents.visible.find_by_title(name)
761 762 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
762 763 :class => 'document'
763 764 end
764 765 when 'version'
765 766 if project && version = project.versions.visible.find_by_name(name)
766 767 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
767 768 :class => 'version'
768 769 end
769 770 when 'forum'
770 771 if project && board = project.boards.visible.find_by_name(name)
771 772 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
772 773 :class => 'board'
773 774 end
774 775 when 'news'
775 776 if project && news = project.news.visible.find_by_title(name)
776 777 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
777 778 :class => 'news'
778 779 end
779 780 when 'commit', 'source', 'export'
780 781 if project
781 782 repository = nil
782 783 if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
783 784 repo_prefix, repo_identifier, name = $1, $2, $3
784 785 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
785 786 else
786 787 repository = project.repository
787 788 end
788 789 if prefix == 'commit'
789 790 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
790 791 link = link_to h("#{project_prefix}#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.identifier},
791 792 :class => 'changeset',
792 793 :title => truncate_single_line(changeset.comments, :length => 100)
793 794 end
794 795 else
795 796 if repository && User.current.allowed_to?(:browse_repository, project)
796 797 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
797 798 path, rev, anchor = $1, $3, $5
798 799 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
799 800 :path => to_path_param(path),
800 801 :rev => rev,
801 802 :anchor => anchor},
802 803 :class => (prefix == 'export' ? 'source download' : 'source')
803 804 end
804 805 end
805 806 repo_prefix = nil
806 807 end
807 808 when 'attachment'
808 809 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
809 810 if attachments && attachment = Attachment.latest_attach(attachments, name)
810 811 link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
811 812 end
812 813 when 'project'
813 814 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
814 815 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
815 816 end
816 817 end
817 818 end
818 819 end
819 820 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
820 821 end
821 822 end
822 823
823 824 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
824 825
825 826 def parse_sections(text, project, obj, attr, only_path, options)
826 827 return unless options[:edit_section_links]
827 828 text.gsub!(HEADING_RE) do
828 829 heading = $1
829 830 @current_section += 1
830 831 if @current_section > 1
831 832 content_tag('div',
832 833 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
833 834 :class => 'contextual',
834 835 :title => l(:button_edit_section),
835 836 :id => "section-#{@current_section}") + heading.html_safe
836 837 else
837 838 heading
838 839 end
839 840 end
840 841 end
841 842
842 843 # Headings and TOC
843 844 # Adds ids and links to headings unless options[:headings] is set to false
844 845 def parse_headings(text, project, obj, attr, only_path, options)
845 846 return if options[:headings] == false
846 847
847 848 text.gsub!(HEADING_RE) do
848 849 level, attrs, content = $2.to_i, $3, $4
849 850 item = strip_tags(content).strip
850 851 anchor = sanitize_anchor_name(item)
851 852 # used for single-file wiki export
852 853 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
853 854 @heading_anchors[anchor] ||= 0
854 855 idx = (@heading_anchors[anchor] += 1)
855 856 if idx > 1
856 857 anchor = "#{anchor}-#{idx}"
857 858 end
858 859 @parsed_headings << [level, anchor, item]
859 860 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
860 861 end
861 862 end
862 863
863 864 MACROS_RE = /(
864 865 (!)? # escaping
865 866 (
866 867 \{\{ # opening tag
867 868 ([\w]+) # macro name
868 869 (\(([^\n\r]*?)\))? # optional arguments
869 870 ([\n\r].*?[\n\r])? # optional block of text
870 871 \}\} # closing tag
871 872 )
872 873 )/mx unless const_defined?(:MACROS_RE)
873 874
874 875 MACRO_SUB_RE = /(
875 876 \{\{
876 877 macro\((\d+)\)
877 878 \}\}
878 879 )/x unless const_defined?(:MACRO_SUB_RE)
879 880
880 881 # Extracts macros from text
881 882 def catch_macros(text)
882 883 macros = {}
883 884 text.gsub!(MACROS_RE) do
884 885 all, macro = $1, $4.downcase
885 886 if macro_exists?(macro) || all =~ MACRO_SUB_RE
886 887 index = macros.size
887 888 macros[index] = all
888 889 "{{macro(#{index})}}"
889 890 else
890 891 all
891 892 end
892 893 end
893 894 macros
894 895 end
895 896
896 897 # Executes and replaces macros in text
897 898 def inject_macros(text, obj, macros, execute=true)
898 899 text.gsub!(MACRO_SUB_RE) do
899 900 all, index = $1, $2.to_i
900 901 orig = macros.delete(index)
901 902 if execute && orig && orig =~ MACROS_RE
902 903 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
903 904 if esc.nil?
904 905 h(exec_macro(macro, obj, args, block) || all)
905 906 else
906 907 h(all)
907 908 end
908 909 elsif orig
909 910 h(orig)
910 911 else
911 912 h(all)
912 913 end
913 914 end
914 915 end
915 916
916 917 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
917 918
918 919 # Renders the TOC with given headings
919 920 def replace_toc(text, headings)
920 921 text.gsub!(TOC_RE) do
921 922 # Keep only the 4 first levels
922 923 headings = headings.select{|level, anchor, item| level <= 4}
923 924 if headings.empty?
924 925 ''
925 926 else
926 927 div_class = 'toc'
927 928 div_class << ' right' if $1 == '>'
928 929 div_class << ' left' if $1 == '<'
929 930 out = "<ul class=\"#{div_class}\"><li>"
930 931 root = headings.map(&:first).min
931 932 current = root
932 933 started = false
933 934 headings.each do |level, anchor, item|
934 935 if level > current
935 936 out << '<ul><li>' * (level - current)
936 937 elsif level < current
937 938 out << "</li></ul>\n" * (current - level) + "</li><li>"
938 939 elsif started
939 940 out << '</li><li>'
940 941 end
941 942 out << "<a href=\"##{anchor}\">#{item}</a>"
942 943 current = level
943 944 started = true
944 945 end
945 946 out << '</li></ul>' * (current - root)
946 947 out << '</li></ul>'
947 948 end
948 949 end
949 950 end
950 951
951 952 # Same as Rails' simple_format helper without using paragraphs
952 953 def simple_format_without_paragraph(text)
953 954 text.to_s.
954 955 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
955 956 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
956 957 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
957 958 html_safe
958 959 end
959 960
960 961 def lang_options_for_select(blank=true)
961 962 (blank ? [["(auto)", ""]] : []) + languages_options
962 963 end
963 964
964 965 def label_tag_for(name, option_tags = nil, options = {})
965 966 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
966 967 content_tag("label", label_text)
967 968 end
968 969
969 970 def labelled_form_for(*args, &proc)
970 971 args << {} unless args.last.is_a?(Hash)
971 972 options = args.last
972 973 if args.first.is_a?(Symbol)
973 974 options.merge!(:as => args.shift)
974 975 end
975 976 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
976 977 form_for(*args, &proc)
977 978 end
978 979
979 980 def labelled_fields_for(*args, &proc)
980 981 args << {} unless args.last.is_a?(Hash)
981 982 options = args.last
982 983 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
983 984 fields_for(*args, &proc)
984 985 end
985 986
986 987 def labelled_remote_form_for(*args, &proc)
987 988 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_remote_form_for is deprecated and will be removed in Redmine 2.2."
988 989 args << {} unless args.last.is_a?(Hash)
989 990 options = args.last
990 991 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
991 992 form_for(*args, &proc)
992 993 end
993 994
994 995 def error_messages_for(*objects)
995 996 html = ""
996 997 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
997 998 errors = objects.map {|o| o.errors.full_messages}.flatten
998 999 if errors.any?
999 1000 html << "<div id='errorExplanation'><ul>\n"
1000 1001 errors.each do |error|
1001 1002 html << "<li>#{h error}</li>\n"
1002 1003 end
1003 1004 html << "</ul></div>\n"
1004 1005 end
1005 1006 html.html_safe
1006 1007 end
1007 1008
1008 1009 def delete_link(url, options={})
1009 1010 options = {
1010 1011 :method => :delete,
1011 1012 :data => {:confirm => l(:text_are_you_sure)},
1012 1013 :class => 'icon icon-del'
1013 1014 }.merge(options)
1014 1015
1015 1016 link_to l(:button_delete), url, options
1016 1017 end
1017 1018
1018 1019 def preview_link(url, form, target='preview', options={})
1019 1020 content_tag 'a', l(:label_preview), {
1020 1021 :href => "#",
1021 1022 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1022 1023 :accesskey => accesskey(:preview)
1023 1024 }.merge(options)
1024 1025 end
1025 1026
1026 1027 def link_to_function(name, function, html_options={})
1027 1028 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1028 1029 end
1029 1030
1030 1031 # Helper to render JSON in views
1031 1032 def raw_json(arg)
1032 1033 arg.to_json.to_s.gsub('/', '\/').html_safe
1033 1034 end
1034 1035
1035 1036 def back_url
1036 1037 url = params[:back_url]
1037 1038 if url.nil? && referer = request.env['HTTP_REFERER']
1038 1039 url = CGI.unescape(referer.to_s)
1039 1040 end
1040 1041 url
1041 1042 end
1042 1043
1043 1044 def back_url_hidden_field_tag
1044 1045 url = back_url
1045 1046 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1046 1047 end
1047 1048
1048 1049 def check_all_links(form_name)
1049 1050 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1050 1051 " | ".html_safe +
1051 1052 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1052 1053 end
1053 1054
1054 1055 def progress_bar(pcts, options={})
1055 1056 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1056 1057 pcts = pcts.collect(&:round)
1057 1058 pcts[1] = pcts[1] - pcts[0]
1058 1059 pcts << (100 - pcts[1] - pcts[0])
1059 1060 width = options[:width] || '100px;'
1060 1061 legend = options[:legend] || ''
1061 1062 content_tag('table',
1062 1063 content_tag('tr',
1063 1064 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1064 1065 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1065 1066 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1066 1067 ), :class => "progress progress-#{pcts[0]}", :style => "width: #{width};").html_safe +
1067 1068 content_tag('p', legend, :class => 'percent').html_safe
1068 1069 end
1069 1070
1070 1071 def checked_image(checked=true)
1071 1072 if checked
1072 1073 image_tag 'toggle_check.png'
1073 1074 end
1074 1075 end
1075 1076
1076 1077 def context_menu(url)
1077 1078 unless @context_menu_included
1078 1079 content_for :header_tags do
1079 1080 javascript_include_tag('context_menu') +
1080 1081 stylesheet_link_tag('context_menu')
1081 1082 end
1082 1083 if l(:direction) == 'rtl'
1083 1084 content_for :header_tags do
1084 1085 stylesheet_link_tag('context_menu_rtl')
1085 1086 end
1086 1087 end
1087 1088 @context_menu_included = true
1088 1089 end
1089 1090 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1090 1091 end
1091 1092
1092 1093 def calendar_for(field_id)
1093 1094 include_calendar_headers_tags
1094 1095 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1095 1096 end
1096 1097
1097 1098 def include_calendar_headers_tags
1098 1099 unless @calendar_headers_tags_included
1099 1100 tags = javascript_include_tag("datepicker")
1100 1101 @calendar_headers_tags_included = true
1101 1102 content_for :header_tags do
1102 1103 start_of_week = Setting.start_of_week
1103 1104 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1104 1105 # Redmine uses 1..7 (monday..sunday) in settings and locales
1105 1106 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1106 1107 start_of_week = start_of_week.to_i % 7
1107 1108 tags << javascript_tag(
1108 1109 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1109 1110 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1110 1111 path_to_image('/images/calendar.png') +
1111 1112 "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " +
1112 1113 "selectOtherMonths: true, changeMonth: true, changeYear: true, " +
1113 1114 "beforeShow: beforeShowDatePicker};")
1114 1115 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1115 1116 unless jquery_locale == 'en'
1116 1117 tags << javascript_include_tag("i18n/jquery.ui.datepicker-#{jquery_locale}.js")
1117 1118 end
1118 1119 tags
1119 1120 end
1120 1121 end
1121 1122 end
1122 1123
1123 1124 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1124 1125 # Examples:
1125 1126 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1126 1127 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1127 1128 #
1128 1129 def stylesheet_link_tag(*sources)
1129 1130 options = sources.last.is_a?(Hash) ? sources.pop : {}
1130 1131 plugin = options.delete(:plugin)
1131 1132 sources = sources.map do |source|
1132 1133 if plugin
1133 1134 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1134 1135 elsif current_theme && current_theme.stylesheets.include?(source)
1135 1136 current_theme.stylesheet_path(source)
1136 1137 else
1137 1138 source
1138 1139 end
1139 1140 end
1140 1141 super sources, options
1141 1142 end
1142 1143
1143 1144 # Overrides Rails' image_tag with themes and plugins support.
1144 1145 # Examples:
1145 1146 # image_tag('image.png') # => picks image.png from the current theme or defaults
1146 1147 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1147 1148 #
1148 1149 def image_tag(source, options={})
1149 1150 if plugin = options.delete(:plugin)
1150 1151 source = "/plugin_assets/#{plugin}/images/#{source}"
1151 1152 elsif current_theme && current_theme.images.include?(source)
1152 1153 source = current_theme.image_path(source)
1153 1154 end
1154 1155 super source, options
1155 1156 end
1156 1157
1157 1158 # Overrides Rails' javascript_include_tag with plugins support
1158 1159 # Examples:
1159 1160 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1160 1161 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1161 1162 #
1162 1163 def javascript_include_tag(*sources)
1163 1164 options = sources.last.is_a?(Hash) ? sources.pop : {}
1164 1165 if plugin = options.delete(:plugin)
1165 1166 sources = sources.map do |source|
1166 1167 if plugin
1167 1168 "/plugin_assets/#{plugin}/javascripts/#{source}"
1168 1169 else
1169 1170 source
1170 1171 end
1171 1172 end
1172 1173 end
1173 1174 super sources, options
1174 1175 end
1175 1176
1176 1177 # TODO: remove this in 2.5.0
1177 1178 def has_content?(name)
1178 1179 content_for?(name)
1179 1180 end
1180 1181
1181 1182 def sidebar_content?
1182 1183 content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1183 1184 end
1184 1185
1185 1186 def view_layouts_base_sidebar_hook_response
1186 1187 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1187 1188 end
1188 1189
1189 1190 def email_delivery_enabled?
1190 1191 !!ActionMailer::Base.perform_deliveries
1191 1192 end
1192 1193
1193 1194 # Returns the avatar image tag for the given +user+ if avatars are enabled
1194 1195 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1195 1196 def avatar(user, options = { })
1196 1197 if Setting.gravatar_enabled?
1197 1198 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1198 1199 email = nil
1199 1200 if user.respond_to?(:mail)
1200 1201 email = user.mail
1201 1202 elsif user.to_s =~ %r{<(.+?)>}
1202 1203 email = $1
1203 1204 end
1204 1205 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1205 1206 else
1206 1207 ''
1207 1208 end
1208 1209 end
1209 1210
1210 1211 def sanitize_anchor_name(anchor)
1211 1212 if ''.respond_to?(:encoding) || RUBY_PLATFORM == 'java'
1212 1213 anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1213 1214 else
1214 1215 # TODO: remove when ruby1.8 is no longer supported
1215 1216 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1216 1217 end
1217 1218 end
1218 1219
1219 1220 # Returns the javascript tags that are included in the html layout head
1220 1221 def javascript_heads
1221 1222 tags = javascript_include_tag('jquery-1.8.3-ui-1.9.2-ujs-2.0.3', 'application')
1222 1223 unless User.current.pref.warn_on_leaving_unsaved == '0'
1223 1224 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1224 1225 end
1225 1226 tags
1226 1227 end
1227 1228
1228 1229 def favicon
1229 1230 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />".html_safe
1230 1231 end
1231 1232
1232 1233 def robot_exclusion_tag
1233 1234 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1234 1235 end
1235 1236
1236 1237 # Returns true if arg is expected in the API response
1237 1238 def include_in_api_response?(arg)
1238 1239 unless @included_in_api_response
1239 1240 param = params[:include]
1240 1241 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1241 1242 @included_in_api_response.collect!(&:strip)
1242 1243 end
1243 1244 @included_in_api_response.include?(arg.to_s)
1244 1245 end
1245 1246
1246 1247 # Returns options or nil if nometa param or X-Redmine-Nometa header
1247 1248 # was set in the request
1248 1249 def api_meta(options)
1249 1250 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1250 1251 # compatibility mode for activeresource clients that raise
1251 1252 # an error when unserializing an array with attributes
1252 1253 nil
1253 1254 else
1254 1255 options
1255 1256 end
1256 1257 end
1257 1258
1258 1259 private
1259 1260
1260 1261 def wiki_helper
1261 1262 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1262 1263 extend helper
1263 1264 return self
1264 1265 end
1265 1266
1266 1267 def link_to_content_update(text, url_params = {}, html_options = {})
1267 1268 link_to(text, url_params, html_options)
1268 1269 end
1269 1270 end
@@ -1,428 +1,428
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2013 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 an array of error messages for bulk edited issues
98 98 def bulk_edit_error_messages(issues)
99 99 messages = {}
100 100 issues.each do |issue|
101 101 issue.errors.full_messages.each do |message|
102 102 messages[message] ||= []
103 103 messages[message] << issue
104 104 end
105 105 end
106 106 messages.map { |message, issues|
107 107 "#{message}: " + issues.map {|i| "##{i.id}"}.join(', ')
108 108 }
109 109 end
110 110
111 111 # Returns a link for adding a new subtask to the given issue
112 112 def link_to_new_subtask(issue)
113 113 attrs = {
114 114 :tracker_id => issue.tracker,
115 115 :parent_issue_id => issue
116 116 }
117 117 link_to(l(:button_add), new_project_issue_path(issue.project, :issue => attrs))
118 118 end
119 119
120 120 class IssueFieldsRows
121 121 include ActionView::Helpers::TagHelper
122 122
123 123 def initialize
124 124 @left = []
125 125 @right = []
126 126 end
127 127
128 128 def left(*args)
129 129 args.any? ? @left << cells(*args) : @left
130 130 end
131 131
132 132 def right(*args)
133 133 args.any? ? @right << cells(*args) : @right
134 134 end
135 135
136 136 def size
137 137 @left.size > @right.size ? @left.size : @right.size
138 138 end
139 139
140 140 def to_html
141 141 html = ''.html_safe
142 142 blank = content_tag('th', '') + content_tag('td', '')
143 143 size.times do |i|
144 144 left = @left[i] || blank
145 145 right = @right[i] || blank
146 146 html << content_tag('tr', left + right)
147 147 end
148 148 html
149 149 end
150 150
151 151 def cells(label, text, options={})
152 152 content_tag('th', "#{label}:", options) + content_tag('td', text, options)
153 153 end
154 154 end
155 155
156 156 def issue_fields_rows
157 157 r = IssueFieldsRows.new
158 158 yield r
159 159 r.to_html
160 160 end
161 161
162 162 def render_custom_fields_rows(issue)
163 163 values = issue.visible_custom_field_values
164 164 return if values.empty?
165 165 ordered_values = []
166 166 half = (values.size / 2.0).ceil
167 167 half.times do |i|
168 168 ordered_values << values[i]
169 169 ordered_values << values[i + half]
170 170 end
171 171 s = "<tr>\n"
172 172 n = 0
173 173 ordered_values.compact.each do |value|
174 174 s << "</tr>\n<tr>\n" if n > 0 && (n % 2) == 0
175 175 s << "\t<th>#{ h(value.custom_field.name) }:</th><td>#{ simple_format_without_paragraph(h(show_value(value))) }</td>\n"
176 176 n += 1
177 177 end
178 178 s << "</tr>\n"
179 179 s.html_safe
180 180 end
181 181
182 182 def issues_destroy_confirmation_message(issues)
183 183 issues = [issues] unless issues.is_a?(Array)
184 184 message = l(:text_issues_destroy_confirmation)
185 185 descendant_count = issues.inject(0) {|memo, i| memo += (i.right - i.left - 1)/2}
186 186 if descendant_count > 0
187 187 issues.each do |issue|
188 188 next if issue.root?
189 189 issues.each do |other_issue|
190 190 descendant_count -= 1 if issue.is_descendant_of?(other_issue)
191 191 end
192 192 end
193 193 if descendant_count > 0
194 194 message << "\n" + l(:text_issues_destroy_descendants_confirmation, :count => descendant_count)
195 195 end
196 196 end
197 197 message
198 198 end
199 199
200 200 def sidebar_queries
201 201 unless @sidebar_queries
202 202 @sidebar_queries = IssueQuery.visible.
203 203 order("#{Query.table_name}.name ASC").
204 204 # Project specific queries and global queries
205 205 where(@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id]).
206 206 all
207 207 end
208 208 @sidebar_queries
209 209 end
210 210
211 211 def query_links(title, queries)
212 212 return '' if queries.empty?
213 213 # links to #index on issues/show
214 214 url_params = controller_name == 'issues' ? {:controller => 'issues', :action => 'index', :project_id => @project} : params
215 215
216 216 content_tag('h3', title) + "\n" +
217 217 content_tag('ul',
218 218 queries.collect {|query|
219 219 css = 'query'
220 220 css << ' selected' if query == @query
221 221 content_tag('li', link_to(query.name, url_params.merge(:query_id => query), :class => css))
222 222 }.join("\n").html_safe,
223 223 :class => 'queries'
224 224 ) + "\n"
225 225 end
226 226
227 227 def render_sidebar_queries
228 228 out = ''.html_safe
229 229 out << query_links(l(:label_my_queries), sidebar_queries.select(&:is_private?))
230 230 out << query_links(l(:label_query_plural), sidebar_queries.reject(&:is_private?))
231 231 out
232 232 end
233 233
234 234 def email_issue_attributes(issue, user)
235 235 items = []
236 236 %w(author status priority assigned_to category fixed_version).each do |attribute|
237 237 unless issue.disabled_core_fields.include?(attribute+"_id")
238 238 items << "#{l("field_#{attribute}")}: #{issue.send attribute}"
239 239 end
240 240 end
241 241 issue.visible_custom_field_values(user).each do |value|
242 242 items << "#{value.custom_field.name}: #{show_value(value)}"
243 243 end
244 244 items
245 245 end
246 246
247 247 def render_email_issue_attributes(issue, user, html=false)
248 248 items = email_issue_attributes(issue, user)
249 249 if html
250 250 content_tag('ul', items.map{|s| content_tag('li', s)}.join("\n").html_safe)
251 251 else
252 252 items.map{|s| "* #{s}"}.join("\n")
253 253 end
254 254 end
255 255
256 256 # Returns the textual representation of a journal details
257 257 # as an array of strings
258 258 def details_to_strings(details, no_html=false, options={})
259 259 options[:only_path] = (options[:only_path] == false ? false : true)
260 260 strings = []
261 261 values_by_field = {}
262 262 details.each do |detail|
263 263 if detail.property == 'cf'
264 264 field = detail.custom_field
265 265 if field && field.multiple?
266 266 values_by_field[field] ||= {:added => [], :deleted => []}
267 267 if detail.old_value
268 268 values_by_field[field][:deleted] << detail.old_value
269 269 end
270 270 if detail.value
271 271 values_by_field[field][:added] << detail.value
272 272 end
273 273 next
274 274 end
275 275 end
276 276 strings << show_detail(detail, no_html, options)
277 277 end
278 278 values_by_field.each do |field, changes|
279 279 detail = JournalDetail.new(:property => 'cf', :prop_key => field.id.to_s)
280 280 detail.instance_variable_set "@custom_field", field
281 281 if changes[:added].any?
282 282 detail.value = changes[:added]
283 283 strings << show_detail(detail, no_html, options)
284 284 elsif changes[:deleted].any?
285 285 detail.old_value = changes[:deleted]
286 286 strings << show_detail(detail, no_html, options)
287 287 end
288 288 end
289 289 strings
290 290 end
291 291
292 292 # Returns the textual representation of a single journal detail
293 293 def show_detail(detail, no_html=false, options={})
294 294 multiple = false
295 295 case detail.property
296 296 when 'attr'
297 297 field = detail.prop_key.to_s.gsub(/\_id$/, "")
298 298 label = l(("field_" + field).to_sym)
299 299 case detail.prop_key
300 300 when 'due_date', 'start_date'
301 301 value = format_date(detail.value.to_date) if detail.value
302 302 old_value = format_date(detail.old_value.to_date) if detail.old_value
303 303
304 304 when 'project_id', 'status_id', 'tracker_id', 'assigned_to_id',
305 305 'priority_id', 'category_id', 'fixed_version_id'
306 306 value = find_name_by_reflection(field, detail.value)
307 307 old_value = find_name_by_reflection(field, detail.old_value)
308 308
309 309 when 'estimated_hours'
310 310 value = "%0.02f" % detail.value.to_f unless detail.value.blank?
311 311 old_value = "%0.02f" % detail.old_value.to_f unless detail.old_value.blank?
312 312
313 313 when 'parent_id'
314 314 label = l(:field_parent_issue)
315 315 value = "##{detail.value}" unless detail.value.blank?
316 316 old_value = "##{detail.old_value}" unless detail.old_value.blank?
317 317
318 318 when 'is_private'
319 319 value = l(detail.value == "0" ? :general_text_No : :general_text_Yes) unless detail.value.blank?
320 320 old_value = l(detail.old_value == "0" ? :general_text_No : :general_text_Yes) unless detail.old_value.blank?
321 321 end
322 322 when 'cf'
323 323 custom_field = detail.custom_field
324 324 if custom_field
325 325 multiple = custom_field.multiple?
326 326 label = custom_field.name
327 327 value = format_value(detail.value, custom_field.field_format) if detail.value
328 328 old_value = format_value(detail.old_value, custom_field.field_format) if detail.old_value
329 329 end
330 330 when 'attachment'
331 331 label = l(:label_attachment)
332 332 when 'relation'
333 333 if detail.value && !detail.old_value
334 334 rel_issue = Issue.visible.find_by_id(detail.value)
335 335 value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.value}" :
336 (no_html ? rel_issue : link_to_issue(rel_issue))
336 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
337 337 elsif detail.old_value && !detail.value
338 338 rel_issue = Issue.visible.find_by_id(detail.old_value)
339 339 old_value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.old_value}" :
340 (no_html ? rel_issue : link_to_issue(rel_issue))
340 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
341 341 end
342 342 label = l(detail.prop_key.to_sym)
343 343 end
344 344 call_hook(:helper_issues_show_detail_after_setting,
345 345 {:detail => detail, :label => label, :value => value, :old_value => old_value })
346 346
347 347 label ||= detail.prop_key
348 348 value ||= detail.value
349 349 old_value ||= detail.old_value
350 350
351 351 unless no_html
352 352 label = content_tag('strong', label)
353 353 old_value = content_tag("i", h(old_value)) if detail.old_value
354 354 if detail.old_value && detail.value.blank? && detail.property != 'relation'
355 355 old_value = content_tag("del", old_value)
356 356 end
357 357 if detail.property == 'attachment' && !value.blank? && atta = Attachment.find_by_id(detail.prop_key)
358 358 # Link to the attachment if it has not been removed
359 359 value = link_to_attachment(atta, :download => true, :only_path => options[:only_path])
360 360 if options[:only_path] != false && atta.is_text?
361 361 value += link_to(
362 362 image_tag('magnifier.png'),
363 363 :controller => 'attachments', :action => 'show',
364 364 :id => atta, :filename => atta.filename
365 365 )
366 366 end
367 367 else
368 368 value = content_tag("i", h(value)) if value
369 369 end
370 370 end
371 371
372 372 if detail.property == 'attr' && detail.prop_key == 'description'
373 373 s = l(:text_journal_changed_no_detail, :label => label)
374 374 unless no_html
375 375 diff_link = link_to 'diff',
376 376 {:controller => 'journals', :action => 'diff', :id => detail.journal_id,
377 377 :detail_id => detail.id, :only_path => options[:only_path]},
378 378 :title => l(:label_view_diff)
379 379 s << " (#{ diff_link })"
380 380 end
381 381 s.html_safe
382 382 elsif detail.value.present?
383 383 case detail.property
384 384 when 'attr', 'cf'
385 385 if detail.old_value.present?
386 386 l(:text_journal_changed, :label => label, :old => old_value, :new => value).html_safe
387 387 elsif multiple
388 388 l(:text_journal_added, :label => label, :value => value).html_safe
389 389 else
390 390 l(:text_journal_set_to, :label => label, :value => value).html_safe
391 391 end
392 392 when 'attachment', 'relation'
393 393 l(:text_journal_added, :label => label, :value => value).html_safe
394 394 end
395 395 else
396 396 l(:text_journal_deleted, :label => label, :old => old_value).html_safe
397 397 end
398 398 end
399 399
400 400 # Find the name of an associated record stored in the field attribute
401 401 def find_name_by_reflection(field, id)
402 402 unless id.present?
403 403 return nil
404 404 end
405 405 association = Issue.reflect_on_association(field.to_sym)
406 406 if association
407 407 record = association.class_name.constantize.find_by_id(id)
408 408 if record
409 409 record.name.force_encoding('UTF-8') if record.name.respond_to?(:force_encoding)
410 410 return record.name
411 411 end
412 412 end
413 413 end
414 414
415 415 # Renders issue children recursively
416 416 def render_api_issue_children(issue, api)
417 417 return if issue.leaf?
418 418 api.array :children do
419 419 issue.children.each do |child|
420 420 api.issue(:id => child.id) do
421 421 api.tracker(:id => child.tracker_id, :name => child.tracker.name) unless child.tracker.nil?
422 422 api.subject child.subject
423 423 render_api_issue_children(child, api)
424 424 end
425 425 end
426 426 end
427 427 end
428 428 end
@@ -1,747 +1,757
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2013 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 MailerTest < ActiveSupport::TestCase
21 21 include Redmine::I18n
22 22 include ActionDispatch::Assertions::SelectorAssertions
23 23 fixtures :projects, :enabled_modules, :issues, :users, :members,
24 24 :member_roles, :roles, :documents, :attachments, :news,
25 25 :tokens, :journals, :journal_details, :changesets,
26 26 :trackers, :projects_trackers,
27 27 :issue_statuses, :enumerations, :messages, :boards, :repositories,
28 28 :wikis, :wiki_pages, :wiki_contents, :wiki_content_versions,
29 29 :versions,
30 30 :comments
31 31
32 32 def setup
33 33 ActionMailer::Base.deliveries.clear
34 34 Setting.host_name = 'mydomain.foo'
35 35 Setting.protocol = 'http'
36 36 Setting.plain_text_mail = '0'
37 37 end
38 38
39 39 def test_generated_links_in_emails
40 40 Setting.default_language = 'en'
41 41 Setting.host_name = 'mydomain.foo'
42 42 Setting.protocol = 'https'
43 43
44 44 journal = Journal.find(3)
45 45 assert Mailer.deliver_issue_edit(journal)
46 46
47 47 mail = last_email
48 48 assert_not_nil mail
49 49
50 50 assert_select_email do
51 51 # link to the main ticket
52 52 assert_select 'a[href=?]',
53 53 'https://mydomain.foo/issues/2#change-3',
54 54 :text => 'Feature request #2: Add ingredients categories'
55 55 # link to a referenced ticket
56 56 assert_select 'a[href=?][title=?]',
57 57 'https://mydomain.foo/issues/1',
58 58 'Can&#x27;t print recipes (New)',
59 59 :text => '#1'
60 60 # link to a changeset
61 61 assert_select 'a[href=?][title=?]',
62 62 'https://mydomain.foo/projects/ecookbook/repository/revisions/2',
63 63 'This commit fixes #1, #2 and references #1 &amp; #3',
64 64 :text => 'r2'
65 65 # link to a description diff
66 66 assert_select 'a[href=?][title=?]',
67 67 'https://mydomain.foo/journals/diff/3?detail_id=4',
68 68 'View differences',
69 69 :text => 'diff'
70 70 # link to an attachment
71 71 assert_select 'a[href=?]',
72 72 'https://mydomain.foo/attachments/download/4/source.rb',
73 73 :text => 'source.rb'
74 74 end
75 75 end
76 76
77 77 def test_generated_links_with_prefix
78 78 Setting.default_language = 'en'
79 79 relative_url_root = Redmine::Utils.relative_url_root
80 80 Setting.host_name = 'mydomain.foo/rdm'
81 81 Setting.protocol = 'http'
82 82
83 83 journal = Journal.find(3)
84 84 assert Mailer.deliver_issue_edit(journal)
85 85
86 86 mail = last_email
87 87 assert_not_nil mail
88 88
89 89 assert_select_email do
90 90 # link to the main ticket
91 91 assert_select 'a[href=?]',
92 92 'http://mydomain.foo/rdm/issues/2#change-3',
93 93 :text => 'Feature request #2: Add ingredients categories'
94 94 # link to a referenced ticket
95 95 assert_select 'a[href=?][title=?]',
96 96 'http://mydomain.foo/rdm/issues/1',
97 97 'Can&#x27;t print recipes (New)',
98 98 :text => '#1'
99 99 # link to a changeset
100 100 assert_select 'a[href=?][title=?]',
101 101 'http://mydomain.foo/rdm/projects/ecookbook/repository/revisions/2',
102 102 'This commit fixes #1, #2 and references #1 &amp; #3',
103 103 :text => 'r2'
104 104 # link to a description diff
105 105 assert_select 'a[href=?][title=?]',
106 106 'http://mydomain.foo/rdm/journals/diff/3?detail_id=4',
107 107 'View differences',
108 108 :text => 'diff'
109 109 # link to an attachment
110 110 assert_select 'a[href=?]',
111 111 'http://mydomain.foo/rdm/attachments/download/4/source.rb',
112 112 :text => 'source.rb'
113 113 end
114 114 end
115 115
116 def test_issue_edit_should_generate_url_with_hostname_for_relations
117 journal = Journal.new(:journalized => Issue.find(1), :user => User.find(1), :created_on => Time.now)
118 journal.details << JournalDetail.new(:property => 'relation', :prop_key => 'label_relates_to', :value => 2)
119 Mailer.deliver_issue_edit(journal)
120 assert_not_nil last_email
121 assert_select_email do
122 assert_select 'a[href=?]', 'http://mydomain.foo/issues/2', :text => 'Feature request #2'
123 end
124 end
125
116 126 def test_generated_links_with_prefix_and_no_relative_url_root
117 127 Setting.default_language = 'en'
118 128 relative_url_root = Redmine::Utils.relative_url_root
119 129 Setting.host_name = 'mydomain.foo/rdm'
120 130 Setting.protocol = 'http'
121 131 Redmine::Utils.relative_url_root = nil
122 132
123 133 journal = Journal.find(3)
124 134 assert Mailer.deliver_issue_edit(journal)
125 135
126 136 mail = last_email
127 137 assert_not_nil mail
128 138
129 139 assert_select_email do
130 140 # link to the main ticket
131 141 assert_select 'a[href=?]',
132 142 'http://mydomain.foo/rdm/issues/2#change-3',
133 143 :text => 'Feature request #2: Add ingredients categories'
134 144 # link to a referenced ticket
135 145 assert_select 'a[href=?][title=?]',
136 146 'http://mydomain.foo/rdm/issues/1',
137 147 'Can&#x27;t print recipes (New)',
138 148 :text => '#1'
139 149 # link to a changeset
140 150 assert_select 'a[href=?][title=?]',
141 151 'http://mydomain.foo/rdm/projects/ecookbook/repository/revisions/2',
142 152 'This commit fixes #1, #2 and references #1 &amp; #3',
143 153 :text => 'r2'
144 154 # link to a description diff
145 155 assert_select 'a[href=?][title=?]',
146 156 'http://mydomain.foo/rdm/journals/diff/3?detail_id=4',
147 157 'View differences',
148 158 :text => 'diff'
149 159 # link to an attachment
150 160 assert_select 'a[href=?]',
151 161 'http://mydomain.foo/rdm/attachments/download/4/source.rb',
152 162 :text => 'source.rb'
153 163 end
154 164 ensure
155 165 # restore it
156 166 Redmine::Utils.relative_url_root = relative_url_root
157 167 end
158 168
159 169 def test_email_headers
160 170 issue = Issue.find(1)
161 171 Mailer.deliver_issue_add(issue)
162 172 mail = last_email
163 173 assert_not_nil mail
164 174 assert_equal 'OOF', mail.header['X-Auto-Response-Suppress'].to_s
165 175 assert_equal 'auto-generated', mail.header['Auto-Submitted'].to_s
166 176 assert_equal '<redmine.example.net>', mail.header['List-Id'].to_s
167 177 end
168 178
169 179 def test_email_headers_should_include_sender
170 180 issue = Issue.find(1)
171 181 Mailer.deliver_issue_add(issue)
172 182 mail = last_email
173 183 assert_equal issue.author.login, mail.header['X-Redmine-Sender'].to_s
174 184 end
175 185
176 186 def test_plain_text_mail
177 187 Setting.plain_text_mail = 1
178 188 journal = Journal.find(2)
179 189 Mailer.deliver_issue_edit(journal)
180 190 mail = last_email
181 191 assert_equal "text/plain; charset=UTF-8", mail.content_type
182 192 assert_equal 0, mail.parts.size
183 193 assert !mail.encoded.include?('href')
184 194 end
185 195
186 196 def test_html_mail
187 197 Setting.plain_text_mail = 0
188 198 journal = Journal.find(2)
189 199 Mailer.deliver_issue_edit(journal)
190 200 mail = last_email
191 201 assert_equal 2, mail.parts.size
192 202 assert mail.encoded.include?('href')
193 203 end
194 204
195 205 def test_from_header
196 206 with_settings :mail_from => 'redmine@example.net' do
197 207 Mailer.test_email(User.find(1)).deliver
198 208 end
199 209 mail = last_email
200 210 assert_equal 'redmine@example.net', mail.from_addrs.first
201 211 end
202 212
203 213 def test_from_header_with_phrase
204 214 with_settings :mail_from => 'Redmine app <redmine@example.net>' do
205 215 Mailer.test_email(User.find(1)).deliver
206 216 end
207 217 mail = last_email
208 218 assert_equal 'redmine@example.net', mail.from_addrs.first
209 219 assert_equal 'Redmine app <redmine@example.net>', mail.header['From'].to_s
210 220 end
211 221
212 222 def test_should_not_send_email_without_recipient
213 223 news = News.first
214 224 user = news.author
215 225 # Remove members except news author
216 226 news.project.memberships.each {|m| m.destroy unless m.user == user}
217 227
218 228 user.pref.no_self_notified = false
219 229 user.pref.save
220 230 User.current = user
221 231 Mailer.news_added(news.reload).deliver
222 232 assert_equal 1, last_email.bcc.size
223 233
224 234 # nobody to notify
225 235 user.pref.no_self_notified = true
226 236 user.pref.save
227 237 User.current = user
228 238 ActionMailer::Base.deliveries.clear
229 239 Mailer.news_added(news.reload).deliver
230 240 assert ActionMailer::Base.deliveries.empty?
231 241 end
232 242
233 243 def test_issue_add_message_id
234 244 issue = Issue.find(2)
235 245 Mailer.deliver_issue_add(issue)
236 246 mail = last_email
237 247 assert_match /^redmine\.issue-2\.20060719190421\.[a-f0-9]+@example\.net/, mail.message_id
238 248 assert_include "redmine.issue-2.20060719190421@example.net", mail.references
239 249 end
240 250
241 251 def test_issue_edit_message_id
242 252 journal = Journal.find(3)
243 253 journal.issue = Issue.find(2)
244 254
245 255 Mailer.deliver_issue_edit(journal)
246 256 mail = last_email
247 257 assert_match /^redmine\.journal-3\.\d+\.[a-f0-9]+@example\.net/, mail.message_id
248 258 assert_include "redmine.issue-2.20060719190421@example.net", mail.references
249 259 assert_select_email do
250 260 # link to the update
251 261 assert_select "a[href=?]",
252 262 "http://mydomain.foo/issues/#{journal.journalized_id}#change-#{journal.id}"
253 263 end
254 264 end
255 265
256 266 def test_message_posted_message_id
257 267 message = Message.find(1)
258 268 Mailer.message_posted(message).deliver
259 269 mail = last_email
260 270 assert_match /^redmine\.message-1\.\d+\.[a-f0-9]+@example\.net/, mail.message_id
261 271 assert_include "redmine.message-1.20070512151532@example.net", mail.references
262 272 assert_select_email do
263 273 # link to the message
264 274 assert_select "a[href=?]",
265 275 "http://mydomain.foo/boards/#{message.board.id}/topics/#{message.id}",
266 276 :text => message.subject
267 277 end
268 278 end
269 279
270 280 def test_reply_posted_message_id
271 281 message = Message.find(3)
272 282 Mailer.message_posted(message).deliver
273 283 mail = last_email
274 284 assert_match /^redmine\.message-3\.\d+\.[a-f0-9]+@example\.net/, mail.message_id
275 285 assert_include "redmine.message-1.20070512151532@example.net", mail.references
276 286 assert_select_email do
277 287 # link to the reply
278 288 assert_select "a[href=?]",
279 289 "http://mydomain.foo/boards/#{message.board.id}/topics/#{message.root.id}?r=#{message.id}#message-#{message.id}",
280 290 :text => message.subject
281 291 end
282 292 end
283 293
284 294 test "#issue_add should notify project members" do
285 295 issue = Issue.find(1)
286 296 assert Mailer.deliver_issue_add(issue)
287 297 assert last_email.bcc.include?('dlopper@somenet.foo')
288 298 end
289 299
290 300 test "#issue_add should not notify project members that are not allow to view the issue" do
291 301 issue = Issue.find(1)
292 302 Role.find(2).remove_permission!(:view_issues)
293 303 assert Mailer.deliver_issue_add(issue)
294 304 assert !last_email.bcc.include?('dlopper@somenet.foo')
295 305 end
296 306
297 307 test "#issue_add should notify issue watchers" do
298 308 issue = Issue.find(1)
299 309 user = User.find(9)
300 310 # minimal email notification options
301 311 user.pref.no_self_notified = '1'
302 312 user.pref.save
303 313 user.mail_notification = false
304 314 user.save
305 315
306 316 Watcher.create!(:watchable => issue, :user => user)
307 317 assert Mailer.deliver_issue_add(issue)
308 318 assert last_email.bcc.include?(user.mail)
309 319 end
310 320
311 321 test "#issue_add should not notify watchers not allowed to view the issue" do
312 322 issue = Issue.find(1)
313 323 user = User.find(9)
314 324 Watcher.create!(:watchable => issue, :user => user)
315 325 Role.non_member.remove_permission!(:view_issues)
316 326 assert Mailer.deliver_issue_add(issue)
317 327 assert !last_email.bcc.include?(user.mail)
318 328 end
319 329
320 330 def test_issue_add_should_include_enabled_fields
321 331 Setting.default_language = 'en'
322 332 issue = Issue.find(2)
323 333 assert Mailer.deliver_issue_add(issue)
324 334 assert_mail_body_match '* Target version: 1.0', last_email
325 335 assert_select_email do
326 336 assert_select 'li', :text => 'Target version: 1.0'
327 337 end
328 338 end
329 339
330 340 def test_issue_add_should_not_include_disabled_fields
331 341 Setting.default_language = 'en'
332 342 issue = Issue.find(2)
333 343 tracker = issue.tracker
334 344 tracker.core_fields -= ['fixed_version_id']
335 345 tracker.save!
336 346 assert Mailer.deliver_issue_add(issue)
337 347 assert_mail_body_no_match 'Target version', last_email
338 348 assert_select_email do
339 349 assert_select 'li', :text => /Target version/, :count => 0
340 350 end
341 351 end
342 352
343 353 # test mailer methods for each language
344 354 def test_issue_add
345 355 issue = Issue.find(1)
346 356 valid_languages.each do |lang|
347 357 Setting.default_language = lang.to_s
348 358 assert Mailer.deliver_issue_add(issue)
349 359 end
350 360 end
351 361
352 362 def test_issue_edit
353 363 journal = Journal.find(1)
354 364 valid_languages.each do |lang|
355 365 Setting.default_language = lang.to_s
356 366 assert Mailer.deliver_issue_edit(journal)
357 367 end
358 368 end
359 369
360 370 def test_issue_edit_should_send_private_notes_to_users_with_permission_only
361 371 journal = Journal.find(1)
362 372 journal.private_notes = true
363 373 journal.save!
364 374
365 375 Role.find(2).add_permission! :view_private_notes
366 376 Mailer.deliver_issue_edit(journal)
367 377 assert_equal %w(dlopper@somenet.foo jsmith@somenet.foo), ActionMailer::Base.deliveries.last.bcc.sort
368 378
369 379 Role.find(2).remove_permission! :view_private_notes
370 380 Mailer.deliver_issue_edit(journal)
371 381 assert_equal %w(jsmith@somenet.foo), ActionMailer::Base.deliveries.last.bcc.sort
372 382 end
373 383
374 384 def test_issue_edit_should_send_private_notes_to_watchers_with_permission_only
375 385 Issue.find(1).set_watcher(User.find_by_login('someone'))
376 386 journal = Journal.find(1)
377 387 journal.private_notes = true
378 388 journal.save!
379 389
380 390 Role.non_member.add_permission! :view_private_notes
381 391 Mailer.deliver_issue_edit(journal)
382 392 assert_include 'someone@foo.bar', ActionMailer::Base.deliveries.last.bcc.sort
383 393
384 394 Role.non_member.remove_permission! :view_private_notes
385 395 Mailer.deliver_issue_edit(journal)
386 396 assert_not_include 'someone@foo.bar', ActionMailer::Base.deliveries.last.bcc.sort
387 397 end
388 398
389 399 def test_issue_edit_should_mark_private_notes
390 400 journal = Journal.find(2)
391 401 journal.private_notes = true
392 402 journal.save!
393 403
394 404 with_settings :default_language => 'en' do
395 405 Mailer.deliver_issue_edit(journal)
396 406 end
397 407 assert_mail_body_match '(Private notes)', last_email
398 408 end
399 409
400 410 def test_issue_edit_with_relation_should_notify_users_who_can_see_the_related_issue
401 411 issue = Issue.generate!
402 412 private_issue = Issue.generate!(:is_private => true)
403 413 IssueRelation.create!(:issue_from => issue, :issue_to => private_issue, :relation_type => 'relates')
404 414 issue.reload
405 415 assert_equal 1, issue.journals.size
406 416 journal = issue.journals.first
407 417 ActionMailer::Base.deliveries.clear
408 418
409 419 Mailer.deliver_issue_edit(journal)
410 420 last_email.bcc.each do |email|
411 421 user = User.find_by_mail(email)
412 422 assert private_issue.visible?(user), "Issue was not visible to #{user}"
413 423 end
414 424 end
415 425
416 426 def test_document_added
417 427 document = Document.find(1)
418 428 valid_languages.each do |lang|
419 429 Setting.default_language = lang.to_s
420 430 assert Mailer.document_added(document).deliver
421 431 end
422 432 end
423 433
424 434 def test_attachments_added
425 435 attachements = [ Attachment.find_by_container_type('Document') ]
426 436 valid_languages.each do |lang|
427 437 Setting.default_language = lang.to_s
428 438 assert Mailer.attachments_added(attachements).deliver
429 439 end
430 440 end
431 441
432 442 def test_version_file_added
433 443 attachements = [ Attachment.find_by_container_type('Version') ]
434 444 assert Mailer.attachments_added(attachements).deliver
435 445 assert_not_nil last_email.bcc
436 446 assert last_email.bcc.any?
437 447 assert_select_email do
438 448 assert_select "a[href=?]", "http://mydomain.foo/projects/ecookbook/files"
439 449 end
440 450 end
441 451
442 452 def test_project_file_added
443 453 attachements = [ Attachment.find_by_container_type('Project') ]
444 454 assert Mailer.attachments_added(attachements).deliver
445 455 assert_not_nil last_email.bcc
446 456 assert last_email.bcc.any?
447 457 assert_select_email do
448 458 assert_select "a[href=?]", "http://mydomain.foo/projects/ecookbook/files"
449 459 end
450 460 end
451 461
452 462 def test_news_added
453 463 news = News.first
454 464 valid_languages.each do |lang|
455 465 Setting.default_language = lang.to_s
456 466 assert Mailer.news_added(news).deliver
457 467 end
458 468 end
459 469
460 470 def test_news_comment_added
461 471 comment = Comment.find(2)
462 472 valid_languages.each do |lang|
463 473 Setting.default_language = lang.to_s
464 474 assert Mailer.news_comment_added(comment).deliver
465 475 end
466 476 end
467 477
468 478 def test_message_posted
469 479 message = Message.first
470 480 recipients = ([message.root] + message.root.children).collect {|m| m.author.mail if m.author}
471 481 recipients = recipients.compact.uniq
472 482 valid_languages.each do |lang|
473 483 Setting.default_language = lang.to_s
474 484 assert Mailer.message_posted(message).deliver
475 485 end
476 486 end
477 487
478 488 def test_wiki_content_added
479 489 content = WikiContent.find(1)
480 490 valid_languages.each do |lang|
481 491 Setting.default_language = lang.to_s
482 492 assert_difference 'ActionMailer::Base.deliveries.size' do
483 493 assert Mailer.wiki_content_added(content).deliver
484 494 assert_select_email do
485 495 assert_select 'a[href=?]',
486 496 'http://mydomain.foo/projects/ecookbook/wiki/CookBook_documentation',
487 497 :text => 'CookBook documentation'
488 498 end
489 499 end
490 500 end
491 501 end
492 502
493 503 def test_wiki_content_updated
494 504 content = WikiContent.find(1)
495 505 valid_languages.each do |lang|
496 506 Setting.default_language = lang.to_s
497 507 assert_difference 'ActionMailer::Base.deliveries.size' do
498 508 assert Mailer.wiki_content_updated(content).deliver
499 509 assert_select_email do
500 510 assert_select 'a[href=?]',
501 511 'http://mydomain.foo/projects/ecookbook/wiki/CookBook_documentation',
502 512 :text => 'CookBook documentation'
503 513 end
504 514 end
505 515 end
506 516 end
507 517
508 518 def test_account_information
509 519 user = User.find(2)
510 520 valid_languages.each do |lang|
511 521 user.update_attribute :language, lang.to_s
512 522 user.reload
513 523 assert Mailer.account_information(user, 'pAsswORd').deliver
514 524 end
515 525 end
516 526
517 527 def test_lost_password
518 528 token = Token.find(2)
519 529 valid_languages.each do |lang|
520 530 token.user.update_attribute :language, lang.to_s
521 531 token.reload
522 532 assert Mailer.lost_password(token).deliver
523 533 end
524 534 end
525 535
526 536 def test_register
527 537 token = Token.find(1)
528 538 Setting.host_name = 'redmine.foo'
529 539 Setting.protocol = 'https'
530 540
531 541 valid_languages.each do |lang|
532 542 token.user.update_attribute :language, lang.to_s
533 543 token.reload
534 544 ActionMailer::Base.deliveries.clear
535 545 assert Mailer.register(token).deliver
536 546 mail = last_email
537 547 assert_select_email do
538 548 assert_select "a[href=?]",
539 549 "https://redmine.foo/account/activate?token=#{token.value}",
540 550 :text => "https://redmine.foo/account/activate?token=#{token.value}"
541 551 end
542 552 end
543 553 end
544 554
545 555 def test_test
546 556 user = User.find(1)
547 557 valid_languages.each do |lang|
548 558 user.update_attribute :language, lang.to_s
549 559 assert Mailer.test_email(user).deliver
550 560 end
551 561 end
552 562
553 563 def test_reminders
554 564 Mailer.reminders(:days => 42)
555 565 assert_equal 1, ActionMailer::Base.deliveries.size
556 566 mail = last_email
557 567 assert mail.bcc.include?('dlopper@somenet.foo')
558 568 assert_mail_body_match 'Bug #3: Error 281 when updating a recipe', mail
559 569 assert_equal '1 issue(s) due in the next 42 days', mail.subject
560 570 end
561 571
562 572 def test_reminders_should_not_include_closed_issues
563 573 with_settings :default_language => 'en' do
564 574 Issue.create!(:project_id => 1, :tracker_id => 1, :status_id => 5,
565 575 :subject => 'Closed issue', :assigned_to_id => 3,
566 576 :due_date => 5.days.from_now,
567 577 :author_id => 2)
568 578 ActionMailer::Base.deliveries.clear
569 579
570 580 Mailer.reminders(:days => 42)
571 581 assert_equal 1, ActionMailer::Base.deliveries.size
572 582 mail = last_email
573 583 assert mail.bcc.include?('dlopper@somenet.foo')
574 584 assert_mail_body_no_match 'Closed issue', mail
575 585 end
576 586 end
577 587
578 588 def test_reminders_for_users
579 589 Mailer.reminders(:days => 42, :users => ['5'])
580 590 assert_equal 0, ActionMailer::Base.deliveries.size # No mail for dlopper
581 591 Mailer.reminders(:days => 42, :users => ['3'])
582 592 assert_equal 1, ActionMailer::Base.deliveries.size # No mail for dlopper
583 593 mail = last_email
584 594 assert mail.bcc.include?('dlopper@somenet.foo')
585 595 assert_mail_body_match 'Bug #3: Error 281 when updating a recipe', mail
586 596 end
587 597
588 598 def test_reminder_should_include_issues_assigned_to_groups
589 599 with_settings :default_language => 'en' do
590 600 group = Group.generate!
591 601 group.users << User.find(2)
592 602 group.users << User.find(3)
593 603
594 604 Issue.create!(:project_id => 1, :tracker_id => 1, :status_id => 1,
595 605 :subject => 'Assigned to group', :assigned_to => group,
596 606 :due_date => 5.days.from_now,
597 607 :author_id => 2)
598 608 ActionMailer::Base.deliveries.clear
599 609
600 610 Mailer.reminders(:days => 7)
601 611 assert_equal 2, ActionMailer::Base.deliveries.size
602 612 assert_equal %w(dlopper@somenet.foo jsmith@somenet.foo), ActionMailer::Base.deliveries.map(&:bcc).flatten.sort
603 613 ActionMailer::Base.deliveries.each do |mail|
604 614 assert_mail_body_match 'Assigned to group', mail
605 615 end
606 616 end
607 617 end
608 618
609 619 def test_mailer_should_not_change_locale
610 620 Setting.default_language = 'en'
611 621 # Set current language to italian
612 622 set_language_if_valid 'it'
613 623 # Send an email to a french user
614 624 user = User.find(1)
615 625 user.language = 'fr'
616 626 Mailer.account_activated(user).deliver
617 627 mail = last_email
618 628 assert_mail_body_match 'Votre compte', mail
619 629
620 630 assert_equal :it, current_language
621 631 end
622 632
623 633 def test_with_deliveries_off
624 634 Mailer.with_deliveries false do
625 635 Mailer.test_email(User.find(1)).deliver
626 636 end
627 637 assert ActionMailer::Base.deliveries.empty?
628 638 # should restore perform_deliveries
629 639 assert ActionMailer::Base.perform_deliveries
630 640 end
631 641
632 642 def test_layout_should_include_the_emails_header
633 643 with_settings :emails_header => "*Header content*" do
634 644 with_settings :plain_text_mail => 0 do
635 645 assert Mailer.test_email(User.find(1)).deliver
636 646 assert_select_email do
637 647 assert_select ".header" do
638 648 assert_select "strong", :text => "Header content"
639 649 end
640 650 end
641 651 end
642 652 with_settings :plain_text_mail => 1 do
643 653 assert Mailer.test_email(User.find(1)).deliver
644 654 mail = last_email
645 655 assert_not_nil mail
646 656 assert_include "*Header content*", mail.body.decoded
647 657 end
648 658 end
649 659 end
650 660
651 661 def test_layout_should_not_include_empty_emails_header
652 662 with_settings :emails_header => "", :plain_text_mail => 0 do
653 663 assert Mailer.test_email(User.find(1)).deliver
654 664 assert_select_email do
655 665 assert_select ".header", false
656 666 end
657 667 end
658 668 end
659 669
660 670 def test_layout_should_include_the_emails_footer
661 671 with_settings :emails_footer => "*Footer content*" do
662 672 with_settings :plain_text_mail => 0 do
663 673 assert Mailer.test_email(User.find(1)).deliver
664 674 assert_select_email do
665 675 assert_select ".footer" do
666 676 assert_select "strong", :text => "Footer content"
667 677 end
668 678 end
669 679 end
670 680 with_settings :plain_text_mail => 1 do
671 681 assert Mailer.test_email(User.find(1)).deliver
672 682 mail = last_email
673 683 assert_not_nil mail
674 684 assert_include "\n-- \n", mail.body.decoded
675 685 assert_include "*Footer content*", mail.body.decoded
676 686 end
677 687 end
678 688 end
679 689
680 690 def test_layout_should_not_include_empty_emails_footer
681 691 with_settings :emails_footer => "" do
682 692 with_settings :plain_text_mail => 0 do
683 693 assert Mailer.test_email(User.find(1)).deliver
684 694 assert_select_email do
685 695 assert_select ".footer", false
686 696 end
687 697 end
688 698 with_settings :plain_text_mail => 1 do
689 699 assert Mailer.test_email(User.find(1)).deliver
690 700 mail = last_email
691 701 assert_not_nil mail
692 702 assert_not_include "\n-- \n", mail.body.decoded
693 703 end
694 704 end
695 705 end
696 706
697 707 def test_should_escape_html_templates_only
698 708 Issue.generate!(:project_id => 1, :tracker_id => 1, :subject => 'Subject with a <tag>')
699 709 mail = last_email
700 710 assert_equal 2, mail.parts.size
701 711 assert_include '<tag>', text_part.body.encoded
702 712 assert_include '&lt;tag&gt;', html_part.body.encoded
703 713 end
704 714
705 715 def test_should_raise_delivery_errors_when_raise_delivery_errors_is_true
706 716 mail = Mailer.test_email(User.find(1))
707 717 mail.delivery_method.stubs(:deliver!).raises(Exception.new("delivery error"))
708 718
709 719 ActionMailer::Base.raise_delivery_errors = true
710 720 assert_raise Exception, "delivery error" do
711 721 mail.deliver
712 722 end
713 723 ensure
714 724 ActionMailer::Base.raise_delivery_errors = false
715 725 end
716 726
717 727 def test_should_log_delivery_errors_when_raise_delivery_errors_is_false
718 728 mail = Mailer.test_email(User.find(1))
719 729 mail.delivery_method.stubs(:deliver!).raises(Exception.new("delivery error"))
720 730
721 731 Rails.logger.expects(:error).with("Email delivery error: delivery error")
722 732 ActionMailer::Base.raise_delivery_errors = false
723 733 assert_nothing_raised do
724 734 mail.deliver
725 735 end
726 736 end
727 737
728 738 def test_mail_should_return_a_mail_message
729 739 assert_kind_of ::Mail::Message, Mailer.test_email(User.find(1))
730 740 end
731 741
732 742 private
733 743
734 744 def last_email
735 745 mail = ActionMailer::Base.deliveries.last
736 746 assert_not_nil mail
737 747 mail
738 748 end
739 749
740 750 def text_part
741 751 last_email.parts.detect {|part| part.content_type.include?('text/plain')}
742 752 end
743 753
744 754 def html_part
745 755 last_email.parts.detect {|part| part.content_type.include?('text/html')}
746 756 end
747 757 end
General Comments 0
You need to be logged in to leave comments. Login now