##// END OF EJS Templates
Don't prepend project name if the version is not shared....
Jean-Philippe Lang -
r12972:2e04614e218e
parent child
Show More

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

@@ -1,1360 +1,1360
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2014 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 = issue.subject.truncate(60)
76 76 else
77 77 subject = issue.subject
78 78 if truncate_length = options[:truncate]
79 79 subject = subject.truncate(truncate_length)
80 80 end
81 81 end
82 82 only_path = options[:only_path].nil? ? true : options[:only_path]
83 83 s = link_to(text, issue_path(issue, :only_path => only_path),
84 84 :class => issue.css_classes, :title => title)
85 85 s << h(": #{subject}") if subject
86 86 s = h("#{issue.project} - ") + s if options[:project]
87 87 s
88 88 end
89 89
90 90 # Generates a link to an attachment.
91 91 # Options:
92 92 # * :text - Link text (default to attachment filename)
93 93 # * :download - Force download (default: false)
94 94 def link_to_attachment(attachment, options={})
95 95 text = options.delete(:text) || attachment.filename
96 96 route_method = options.delete(:download) ? :download_named_attachment_path : :named_attachment_path
97 97 html_options = options.slice!(:only_path)
98 98 url = send(route_method, attachment, attachment.filename, options)
99 99 link_to text, url, html_options
100 100 end
101 101
102 102 # Generates a link to a SCM revision
103 103 # Options:
104 104 # * :text - Link text (default to the formatted revision)
105 105 def link_to_revision(revision, repository, options={})
106 106 if repository.is_a?(Project)
107 107 repository = repository.repository
108 108 end
109 109 text = options.delete(:text) || format_revision(revision)
110 110 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
111 111 link_to(
112 112 h(text),
113 113 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
114 114 :title => l(:label_revision_id, format_revision(revision))
115 115 )
116 116 end
117 117
118 118 # Generates a link to a message
119 119 def link_to_message(message, options={}, html_options = nil)
120 120 link_to(
121 121 message.subject.truncate(60),
122 122 board_message_path(message.board_id, message.parent_id || message.id, {
123 123 :r => (message.parent_id && message.id),
124 124 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
125 125 }.merge(options)),
126 126 html_options
127 127 )
128 128 end
129 129
130 130 # Generates a link to a project if active
131 131 # Examples:
132 132 #
133 133 # link_to_project(project) # => link to the specified project overview
134 134 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
135 135 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
136 136 #
137 137 def link_to_project(project, options={}, html_options = nil)
138 138 if project.archived?
139 139 h(project.name)
140 140 elsif options.key?(:action)
141 141 ActiveSupport::Deprecation.warn "#link_to_project with :action option is deprecated and will be removed in Redmine 3.0."
142 142 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
143 143 link_to project.name, url, html_options
144 144 else
145 145 link_to project.name, project_path(project, options), html_options
146 146 end
147 147 end
148 148
149 149 # Generates a link to a project settings if active
150 150 def link_to_project_settings(project, options={}, html_options=nil)
151 151 if project.active?
152 152 link_to project.name, settings_project_path(project, options), html_options
153 153 elsif project.archived?
154 154 h(project.name)
155 155 else
156 156 link_to project.name, project_path(project, options), html_options
157 157 end
158 158 end
159 159
160 160 # Generates a link to a version
161 161 def link_to_version(version, options = {})
162 162 return '' unless version && version.is_a?(Version)
163 163 options = {:title => format_date(version.effective_date)}.merge(options)
164 164 link_to_if version.visible?, format_version_name(version), version_path(version), options
165 165 end
166 166
167 167 # Helper that formats object for html or text rendering
168 168 def format_object(object, html=true, &block)
169 169 if block_given?
170 170 object = yield object
171 171 end
172 172 case object.class.name
173 173 when 'Array'
174 174 object.map {|o| format_object(o, html)}.join(', ').html_safe
175 175 when 'Time'
176 176 format_time(object)
177 177 when 'Date'
178 178 format_date(object)
179 179 when 'Fixnum'
180 180 object.to_s
181 181 when 'Float'
182 182 sprintf "%.2f", object
183 183 when 'User'
184 184 html ? link_to_user(object) : object.to_s
185 185 when 'Project'
186 186 html ? link_to_project(object) : object.to_s
187 187 when 'Version'
188 188 html ? link_to_version(object) : object.to_s
189 189 when 'TrueClass'
190 190 l(:general_text_Yes)
191 191 when 'FalseClass'
192 192 l(:general_text_No)
193 193 when 'Issue'
194 194 object.visible? && html ? link_to_issue(object) : "##{object.id}"
195 195 when 'CustomValue', 'CustomFieldValue'
196 196 if object.custom_field
197 197 f = object.custom_field.format.formatted_custom_value(self, object, html)
198 198 if f.nil? || f.is_a?(String)
199 199 f
200 200 else
201 201 format_object(f, html, &block)
202 202 end
203 203 else
204 204 object.value.to_s
205 205 end
206 206 else
207 207 html ? h(object) : object.to_s
208 208 end
209 209 end
210 210
211 211 def wiki_page_path(page, options={})
212 212 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
213 213 end
214 214
215 215 def thumbnail_tag(attachment)
216 216 link_to image_tag(thumbnail_path(attachment)),
217 217 named_attachment_path(attachment, attachment.filename),
218 218 :title => attachment.filename
219 219 end
220 220
221 221 def toggle_link(name, id, options={})
222 222 onclick = "$('##{id}').toggle(); "
223 223 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
224 224 onclick << "return false;"
225 225 link_to(name, "#", :onclick => onclick)
226 226 end
227 227
228 228 def image_to_function(name, function, html_options = {})
229 229 html_options.symbolize_keys!
230 230 tag(:input, html_options.merge({
231 231 :type => "image", :src => image_path(name),
232 232 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
233 233 }))
234 234 end
235 235
236 236 def format_activity_title(text)
237 237 h(truncate_single_line_raw(text, 100))
238 238 end
239 239
240 240 def format_activity_day(date)
241 241 date == User.current.today ? l(:label_today).titleize : format_date(date)
242 242 end
243 243
244 244 def format_activity_description(text)
245 245 h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
246 246 ).gsub(/[\r\n]+/, "<br />").html_safe
247 247 end
248 248
249 249 def format_version_name(version)
250 if version.project == @project
250 if !version.shared? || version.project == @project
251 251 h(version)
252 252 else
253 253 h("#{version.project} - #{version}")
254 254 end
255 255 end
256 256
257 257 def due_date_distance_in_words(date)
258 258 if date
259 259 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
260 260 end
261 261 end
262 262
263 263 # Renders a tree of projects as a nested set of unordered lists
264 264 # The given collection may be a subset of the whole project tree
265 265 # (eg. some intermediate nodes are private and can not be seen)
266 266 def render_project_nested_lists(projects)
267 267 s = ''
268 268 if projects.any?
269 269 ancestors = []
270 270 original_project = @project
271 271 projects.sort_by(&:lft).each do |project|
272 272 # set the project environment to please macros.
273 273 @project = project
274 274 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
275 275 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
276 276 else
277 277 ancestors.pop
278 278 s << "</li>"
279 279 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
280 280 ancestors.pop
281 281 s << "</ul></li>\n"
282 282 end
283 283 end
284 284 classes = (ancestors.empty? ? 'root' : 'child')
285 285 s << "<li class='#{classes}'><div class='#{classes}'>"
286 286 s << h(block_given? ? yield(project) : project.name)
287 287 s << "</div>\n"
288 288 ancestors << project
289 289 end
290 290 s << ("</li></ul>\n" * ancestors.size)
291 291 @project = original_project
292 292 end
293 293 s.html_safe
294 294 end
295 295
296 296 def render_page_hierarchy(pages, node=nil, options={})
297 297 content = ''
298 298 if pages[node]
299 299 content << "<ul class=\"pages-hierarchy\">\n"
300 300 pages[node].each do |page|
301 301 content << "<li>"
302 302 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
303 303 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
304 304 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
305 305 content << "</li>\n"
306 306 end
307 307 content << "</ul>\n"
308 308 end
309 309 content.html_safe
310 310 end
311 311
312 312 # Renders flash messages
313 313 def render_flash_messages
314 314 s = ''
315 315 flash.each do |k,v|
316 316 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
317 317 end
318 318 s.html_safe
319 319 end
320 320
321 321 # Renders tabs and their content
322 322 def render_tabs(tabs, selected=params[:tab])
323 323 if tabs.any?
324 324 unless tabs.detect {|tab| tab[:name] == selected}
325 325 selected = nil
326 326 end
327 327 selected ||= tabs.first[:name]
328 328 render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
329 329 else
330 330 content_tag 'p', l(:label_no_data), :class => "nodata"
331 331 end
332 332 end
333 333
334 334 # Renders the project quick-jump box
335 335 def render_project_jump_box
336 336 return unless User.current.logged?
337 337 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
338 338 if projects.any?
339 339 options =
340 340 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
341 341 '<option value="" disabled="disabled">---</option>').html_safe
342 342
343 343 options << project_tree_options_for_select(projects, :selected => @project) do |p|
344 344 { :value => project_path(:id => p, :jump => current_menu_item) }
345 345 end
346 346
347 347 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
348 348 end
349 349 end
350 350
351 351 def project_tree_options_for_select(projects, options = {})
352 352 s = ''
353 353 project_tree(projects) do |project, level|
354 354 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
355 355 tag_options = {:value => project.id}
356 356 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
357 357 tag_options[:selected] = 'selected'
358 358 else
359 359 tag_options[:selected] = nil
360 360 end
361 361 tag_options.merge!(yield(project)) if block_given?
362 362 s << content_tag('option', name_prefix + h(project), tag_options)
363 363 end
364 364 s.html_safe
365 365 end
366 366
367 367 # Yields the given block for each project with its level in the tree
368 368 #
369 369 # Wrapper for Project#project_tree
370 370 def project_tree(projects, &block)
371 371 Project.project_tree(projects, &block)
372 372 end
373 373
374 374 def principals_check_box_tags(name, principals)
375 375 s = ''
376 376 principals.each do |principal|
377 377 s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
378 378 end
379 379 s.html_safe
380 380 end
381 381
382 382 # Returns a string for users/groups option tags
383 383 def principals_options_for_select(collection, selected=nil)
384 384 s = ''
385 385 if collection.include?(User.current)
386 386 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
387 387 end
388 388 groups = ''
389 389 collection.sort.each do |element|
390 390 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
391 391 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
392 392 end
393 393 unless groups.empty?
394 394 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
395 395 end
396 396 s.html_safe
397 397 end
398 398
399 399 # Options for the new membership projects combo-box
400 400 def options_for_membership_project_select(principal, projects)
401 401 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
402 402 options << project_tree_options_for_select(projects) do |p|
403 403 {:disabled => principal.projects.to_a.include?(p)}
404 404 end
405 405 options
406 406 end
407 407
408 408 def option_tag(name, text, value, selected=nil, options={})
409 409 content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
410 410 end
411 411
412 412 # Truncates and returns the string as a single line
413 413 def truncate_single_line(string, *args)
414 414 ActiveSupport::Deprecation.warn(
415 415 "ApplicationHelper#truncate_single_line is deprecated and will be removed in Rails 4 poring")
416 416 # Rails 4 ActionView::Helpers::TextHelper#truncate escapes.
417 417 # So, result is broken.
418 418 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
419 419 end
420 420
421 421 def truncate_single_line_raw(string, length)
422 422 string.truncate(length).gsub(%r{[\r\n]+}m, ' ')
423 423 end
424 424
425 425 # Truncates at line break after 250 characters or options[:length]
426 426 def truncate_lines(string, options={})
427 427 length = options[:length] || 250
428 428 if string.to_s =~ /\A(.{#{length}}.*?)$/m
429 429 "#{$1}..."
430 430 else
431 431 string
432 432 end
433 433 end
434 434
435 435 def anchor(text)
436 436 text.to_s.gsub(' ', '_')
437 437 end
438 438
439 439 def html_hours(text)
440 440 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
441 441 end
442 442
443 443 def authoring(created, author, options={})
444 444 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
445 445 end
446 446
447 447 def time_tag(time)
448 448 text = distance_of_time_in_words(Time.now, time)
449 449 if @project
450 450 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
451 451 else
452 452 content_tag('abbr', text, :title => format_time(time))
453 453 end
454 454 end
455 455
456 456 def syntax_highlight_lines(name, content)
457 457 lines = []
458 458 syntax_highlight(name, content).each_line { |line| lines << line }
459 459 lines
460 460 end
461 461
462 462 def syntax_highlight(name, content)
463 463 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
464 464 end
465 465
466 466 def to_path_param(path)
467 467 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
468 468 str.blank? ? nil : str
469 469 end
470 470
471 471 def reorder_links(name, url, method = :post)
472 472 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
473 473 url.merge({"#{name}[move_to]" => 'highest'}),
474 474 :method => method, :title => l(:label_sort_highest)) +
475 475 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
476 476 url.merge({"#{name}[move_to]" => 'higher'}),
477 477 :method => method, :title => l(:label_sort_higher)) +
478 478 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
479 479 url.merge({"#{name}[move_to]" => 'lower'}),
480 480 :method => method, :title => l(:label_sort_lower)) +
481 481 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
482 482 url.merge({"#{name}[move_to]" => 'lowest'}),
483 483 :method => method, :title => l(:label_sort_lowest))
484 484 end
485 485
486 486 def breadcrumb(*args)
487 487 elements = args.flatten
488 488 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
489 489 end
490 490
491 491 def other_formats_links(&block)
492 492 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
493 493 yield Redmine::Views::OtherFormatsBuilder.new(self)
494 494 concat('</p>'.html_safe)
495 495 end
496 496
497 497 def page_header_title
498 498 if @project.nil? || @project.new_record?
499 499 h(Setting.app_title)
500 500 else
501 501 b = []
502 502 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
503 503 if ancestors.any?
504 504 root = ancestors.shift
505 505 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
506 506 if ancestors.size > 2
507 507 b << "\xe2\x80\xa6"
508 508 ancestors = ancestors[-2, 2]
509 509 end
510 510 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
511 511 end
512 512 b << h(@project)
513 513 b.join(" \xc2\xbb ").html_safe
514 514 end
515 515 end
516 516
517 517 # Returns a h2 tag and sets the html title with the given arguments
518 518 def title(*args)
519 519 strings = args.map do |arg|
520 520 if arg.is_a?(Array) && arg.size >= 2
521 521 link_to(*arg)
522 522 else
523 523 h(arg.to_s)
524 524 end
525 525 end
526 526 html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
527 527 content_tag('h2', strings.join(' &#187; ').html_safe)
528 528 end
529 529
530 530 # Sets the html title
531 531 # Returns the html title when called without arguments
532 532 # Current project name and app_title and automatically appended
533 533 # Exemples:
534 534 # html_title 'Foo', 'Bar'
535 535 # html_title # => 'Foo - Bar - My Project - Redmine'
536 536 def html_title(*args)
537 537 if args.empty?
538 538 title = @html_title || []
539 539 title << @project.name if @project
540 540 title << Setting.app_title unless Setting.app_title == title.last
541 541 title.reject(&:blank?).join(' - ')
542 542 else
543 543 @html_title ||= []
544 544 @html_title += args
545 545 end
546 546 end
547 547
548 548 # Returns the theme, controller name, and action as css classes for the
549 549 # HTML body.
550 550 def body_css_classes
551 551 css = []
552 552 if theme = Redmine::Themes.theme(Setting.ui_theme)
553 553 css << 'theme-' + theme.name
554 554 end
555 555
556 556 css << 'project-' + @project.identifier if @project && @project.identifier.present?
557 557 css << 'controller-' + controller_name
558 558 css << 'action-' + action_name
559 559 css.join(' ')
560 560 end
561 561
562 562 def accesskey(s)
563 563 @used_accesskeys ||= []
564 564 key = Redmine::AccessKeys.key_for(s)
565 565 return nil if @used_accesskeys.include?(key)
566 566 @used_accesskeys << key
567 567 key
568 568 end
569 569
570 570 # Formats text according to system settings.
571 571 # 2 ways to call this method:
572 572 # * with a String: textilizable(text, options)
573 573 # * with an object and one of its attribute: textilizable(issue, :description, options)
574 574 def textilizable(*args)
575 575 options = args.last.is_a?(Hash) ? args.pop : {}
576 576 case args.size
577 577 when 1
578 578 obj = options[:object]
579 579 text = args.shift
580 580 when 2
581 581 obj = args.shift
582 582 attr = args.shift
583 583 text = obj.send(attr).to_s
584 584 else
585 585 raise ArgumentError, 'invalid arguments to textilizable'
586 586 end
587 587 return '' if text.blank?
588 588 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
589 589 only_path = options.delete(:only_path) == false ? false : true
590 590
591 591 text = text.dup
592 592 macros = catch_macros(text)
593 593 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
594 594
595 595 @parsed_headings = []
596 596 @heading_anchors = {}
597 597 @current_section = 0 if options[:edit_section_links]
598 598
599 599 parse_sections(text, project, obj, attr, only_path, options)
600 600 text = parse_non_pre_blocks(text, obj, macros) do |text|
601 601 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
602 602 send method_name, text, project, obj, attr, only_path, options
603 603 end
604 604 end
605 605 parse_headings(text, project, obj, attr, only_path, options)
606 606
607 607 if @parsed_headings.any?
608 608 replace_toc(text, @parsed_headings)
609 609 end
610 610
611 611 text.html_safe
612 612 end
613 613
614 614 def parse_non_pre_blocks(text, obj, macros)
615 615 s = StringScanner.new(text)
616 616 tags = []
617 617 parsed = ''
618 618 while !s.eos?
619 619 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
620 620 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
621 621 if tags.empty?
622 622 yield text
623 623 inject_macros(text, obj, macros) if macros.any?
624 624 else
625 625 inject_macros(text, obj, macros, false) if macros.any?
626 626 end
627 627 parsed << text
628 628 if tag
629 629 if closing
630 630 if tags.last == tag.downcase
631 631 tags.pop
632 632 end
633 633 else
634 634 tags << tag.downcase
635 635 end
636 636 parsed << full_tag
637 637 end
638 638 end
639 639 # Close any non closing tags
640 640 while tag = tags.pop
641 641 parsed << "</#{tag}>"
642 642 end
643 643 parsed
644 644 end
645 645
646 646 def parse_inline_attachments(text, project, obj, attr, only_path, options)
647 647 # when using an image link, try to use an attachment, if possible
648 648 attachments = options[:attachments] || []
649 649 attachments += obj.attachments if obj.respond_to?(:attachments)
650 650 if attachments.present?
651 651 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
652 652 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
653 653 # search for the picture in attachments
654 654 if found = Attachment.latest_attach(attachments, filename)
655 655 image_url = download_named_attachment_path(found, found.filename, :only_path => only_path)
656 656 desc = found.description.to_s.gsub('"', '')
657 657 if !desc.blank? && alttext.blank?
658 658 alt = " title=\"#{desc}\" alt=\"#{desc}\""
659 659 end
660 660 "src=\"#{image_url}\"#{alt}"
661 661 else
662 662 m
663 663 end
664 664 end
665 665 end
666 666 end
667 667
668 668 # Wiki links
669 669 #
670 670 # Examples:
671 671 # [[mypage]]
672 672 # [[mypage|mytext]]
673 673 # wiki links can refer other project wikis, using project name or identifier:
674 674 # [[project:]] -> wiki starting page
675 675 # [[project:|mytext]]
676 676 # [[project:mypage]]
677 677 # [[project:mypage|mytext]]
678 678 def parse_wiki_links(text, project, obj, attr, only_path, options)
679 679 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
680 680 link_project = project
681 681 esc, all, page, title = $1, $2, $3, $5
682 682 if esc.nil?
683 683 if page =~ /^([^\:]+)\:(.*)$/
684 684 identifier, page = $1, $2
685 685 link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
686 686 title ||= identifier if page.blank?
687 687 end
688 688
689 689 if link_project && link_project.wiki
690 690 # extract anchor
691 691 anchor = nil
692 692 if page =~ /^(.+?)\#(.+)$/
693 693 page, anchor = $1, $2
694 694 end
695 695 anchor = sanitize_anchor_name(anchor) if anchor.present?
696 696 # check if page exists
697 697 wiki_page = link_project.wiki.find_page(page)
698 698 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
699 699 "##{anchor}"
700 700 else
701 701 case options[:wiki_links]
702 702 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
703 703 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
704 704 else
705 705 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
706 706 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
707 707 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
708 708 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
709 709 end
710 710 end
711 711 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
712 712 else
713 713 # project or wiki doesn't exist
714 714 all
715 715 end
716 716 else
717 717 all
718 718 end
719 719 end
720 720 end
721 721
722 722 # Redmine links
723 723 #
724 724 # Examples:
725 725 # Issues:
726 726 # #52 -> Link to issue #52
727 727 # Changesets:
728 728 # r52 -> Link to revision 52
729 729 # commit:a85130f -> Link to scmid starting with a85130f
730 730 # Documents:
731 731 # document#17 -> Link to document with id 17
732 732 # document:Greetings -> Link to the document with title "Greetings"
733 733 # document:"Some document" -> Link to the document with title "Some document"
734 734 # Versions:
735 735 # version#3 -> Link to version with id 3
736 736 # version:1.0.0 -> Link to version named "1.0.0"
737 737 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
738 738 # Attachments:
739 739 # attachment:file.zip -> Link to the attachment of the current object named file.zip
740 740 # Source files:
741 741 # source:some/file -> Link to the file located at /some/file in the project's repository
742 742 # source:some/file@52 -> Link to the file's revision 52
743 743 # source:some/file#L120 -> Link to line 120 of the file
744 744 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
745 745 # export:some/file -> Force the download of the file
746 746 # Forum messages:
747 747 # message#1218 -> Link to message with id 1218
748 748 # Projects:
749 749 # project:someproject -> Link to project named "someproject"
750 750 # project#3 -> Link to project with id 3
751 751 #
752 752 # Links can refer other objects from other projects, using project identifier:
753 753 # identifier:r52
754 754 # identifier:document:"Some document"
755 755 # identifier:version:1.0.0
756 756 # identifier:source:some/file
757 757 def parse_redmine_links(text, default_project, obj, attr, only_path, options)
758 758 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|
759 759 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
760 760 link = nil
761 761 project = default_project
762 762 if project_identifier
763 763 project = Project.visible.find_by_identifier(project_identifier)
764 764 end
765 765 if esc.nil?
766 766 if prefix.nil? && sep == 'r'
767 767 if project
768 768 repository = nil
769 769 if repo_identifier
770 770 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
771 771 else
772 772 repository = project.repository
773 773 end
774 774 # project.changesets.visible raises an SQL error because of a double join on repositories
775 775 if repository &&
776 776 (changeset = Changeset.visible.
777 777 find_by_repository_id_and_revision(repository.id, identifier))
778 778 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"),
779 779 {:only_path => only_path, :controller => 'repositories',
780 780 :action => 'revision', :id => project,
781 781 :repository_id => repository.identifier_param,
782 782 :rev => changeset.revision},
783 783 :class => 'changeset',
784 784 :title => truncate_single_line_raw(changeset.comments, 100))
785 785 end
786 786 end
787 787 elsif sep == '#'
788 788 oid = identifier.to_i
789 789 case prefix
790 790 when nil
791 791 if oid.to_s == identifier &&
792 792 issue = Issue.visible.includes(:status).find_by_id(oid)
793 793 anchor = comment_id ? "note-#{comment_id}" : nil
794 794 link = link_to(h("##{oid}#{comment_suffix}"),
795 795 {:only_path => only_path, :controller => 'issues',
796 796 :action => 'show', :id => oid, :anchor => anchor},
797 797 :class => issue.css_classes,
798 798 :title => "#{issue.subject.truncate(100)} (#{issue.status.name})")
799 799 end
800 800 when 'document'
801 801 if document = Document.visible.find_by_id(oid)
802 802 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
803 803 :class => 'document'
804 804 end
805 805 when 'version'
806 806 if version = Version.visible.find_by_id(oid)
807 807 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
808 808 :class => 'version'
809 809 end
810 810 when 'message'
811 811 if message = Message.visible.includes(:parent).find_by_id(oid)
812 812 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
813 813 end
814 814 when 'forum'
815 815 if board = Board.visible.find_by_id(oid)
816 816 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
817 817 :class => 'board'
818 818 end
819 819 when 'news'
820 820 if news = News.visible.find_by_id(oid)
821 821 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
822 822 :class => 'news'
823 823 end
824 824 when 'project'
825 825 if p = Project.visible.find_by_id(oid)
826 826 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
827 827 end
828 828 end
829 829 elsif sep == ':'
830 830 # removes the double quotes if any
831 831 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
832 832 name = CGI.unescapeHTML(name)
833 833 case prefix
834 834 when 'document'
835 835 if project && document = project.documents.visible.find_by_title(name)
836 836 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
837 837 :class => 'document'
838 838 end
839 839 when 'version'
840 840 if project && version = project.versions.visible.find_by_name(name)
841 841 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
842 842 :class => 'version'
843 843 end
844 844 when 'forum'
845 845 if project && board = project.boards.visible.find_by_name(name)
846 846 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
847 847 :class => 'board'
848 848 end
849 849 when 'news'
850 850 if project && news = project.news.visible.find_by_title(name)
851 851 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
852 852 :class => 'news'
853 853 end
854 854 when 'commit', 'source', 'export'
855 855 if project
856 856 repository = nil
857 857 if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
858 858 repo_prefix, repo_identifier, name = $1, $2, $3
859 859 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
860 860 else
861 861 repository = project.repository
862 862 end
863 863 if prefix == 'commit'
864 864 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
865 865 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},
866 866 :class => 'changeset',
867 867 :title => truncate_single_line_raw(changeset.comments, 100)
868 868 end
869 869 else
870 870 if repository && User.current.allowed_to?(:browse_repository, project)
871 871 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
872 872 path, rev, anchor = $1, $3, $5
873 873 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
874 874 :path => to_path_param(path),
875 875 :rev => rev,
876 876 :anchor => anchor},
877 877 :class => (prefix == 'export' ? 'source download' : 'source')
878 878 end
879 879 end
880 880 repo_prefix = nil
881 881 end
882 882 when 'attachment'
883 883 attachments = options[:attachments] || []
884 884 attachments += obj.attachments if obj.respond_to?(:attachments)
885 885 if attachments && attachment = Attachment.latest_attach(attachments, name)
886 886 link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
887 887 end
888 888 when 'project'
889 889 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
890 890 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
891 891 end
892 892 end
893 893 end
894 894 end
895 895 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
896 896 end
897 897 end
898 898
899 899 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
900 900
901 901 def parse_sections(text, project, obj, attr, only_path, options)
902 902 return unless options[:edit_section_links]
903 903 text.gsub!(HEADING_RE) do
904 904 heading = $1
905 905 @current_section += 1
906 906 if @current_section > 1
907 907 content_tag('div',
908 908 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
909 909 :class => 'contextual',
910 910 :title => l(:button_edit_section),
911 911 :id => "section-#{@current_section}") + heading.html_safe
912 912 else
913 913 heading
914 914 end
915 915 end
916 916 end
917 917
918 918 # Headings and TOC
919 919 # Adds ids and links to headings unless options[:headings] is set to false
920 920 def parse_headings(text, project, obj, attr, only_path, options)
921 921 return if options[:headings] == false
922 922
923 923 text.gsub!(HEADING_RE) do
924 924 level, attrs, content = $2.to_i, $3, $4
925 925 item = strip_tags(content).strip
926 926 anchor = sanitize_anchor_name(item)
927 927 # used for single-file wiki export
928 928 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
929 929 @heading_anchors[anchor] ||= 0
930 930 idx = (@heading_anchors[anchor] += 1)
931 931 if idx > 1
932 932 anchor = "#{anchor}-#{idx}"
933 933 end
934 934 @parsed_headings << [level, anchor, item]
935 935 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
936 936 end
937 937 end
938 938
939 939 MACROS_RE = /(
940 940 (!)? # escaping
941 941 (
942 942 \{\{ # opening tag
943 943 ([\w]+) # macro name
944 944 (\(([^\n\r]*?)\))? # optional arguments
945 945 ([\n\r].*?[\n\r])? # optional block of text
946 946 \}\} # closing tag
947 947 )
948 948 )/mx unless const_defined?(:MACROS_RE)
949 949
950 950 MACRO_SUB_RE = /(
951 951 \{\{
952 952 macro\((\d+)\)
953 953 \}\}
954 954 )/x unless const_defined?(:MACRO_SUB_RE)
955 955
956 956 # Extracts macros from text
957 957 def catch_macros(text)
958 958 macros = {}
959 959 text.gsub!(MACROS_RE) do
960 960 all, macro = $1, $4.downcase
961 961 if macro_exists?(macro) || all =~ MACRO_SUB_RE
962 962 index = macros.size
963 963 macros[index] = all
964 964 "{{macro(#{index})}}"
965 965 else
966 966 all
967 967 end
968 968 end
969 969 macros
970 970 end
971 971
972 972 # Executes and replaces macros in text
973 973 def inject_macros(text, obj, macros, execute=true)
974 974 text.gsub!(MACRO_SUB_RE) do
975 975 all, index = $1, $2.to_i
976 976 orig = macros.delete(index)
977 977 if execute && orig && orig =~ MACROS_RE
978 978 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
979 979 if esc.nil?
980 980 h(exec_macro(macro, obj, args, block) || all)
981 981 else
982 982 h(all)
983 983 end
984 984 elsif orig
985 985 h(orig)
986 986 else
987 987 h(all)
988 988 end
989 989 end
990 990 end
991 991
992 992 TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
993 993
994 994 # Renders the TOC with given headings
995 995 def replace_toc(text, headings)
996 996 text.gsub!(TOC_RE) do
997 997 left_align, right_align = $2, $3
998 998 # Keep only the 4 first levels
999 999 headings = headings.select{|level, anchor, item| level <= 4}
1000 1000 if headings.empty?
1001 1001 ''
1002 1002 else
1003 1003 div_class = 'toc'
1004 1004 div_class << ' right' if right_align
1005 1005 div_class << ' left' if left_align
1006 1006 out = "<ul class=\"#{div_class}\"><li>"
1007 1007 root = headings.map(&:first).min
1008 1008 current = root
1009 1009 started = false
1010 1010 headings.each do |level, anchor, item|
1011 1011 if level > current
1012 1012 out << '<ul><li>' * (level - current)
1013 1013 elsif level < current
1014 1014 out << "</li></ul>\n" * (current - level) + "</li><li>"
1015 1015 elsif started
1016 1016 out << '</li><li>'
1017 1017 end
1018 1018 out << "<a href=\"##{anchor}\">#{item}</a>"
1019 1019 current = level
1020 1020 started = true
1021 1021 end
1022 1022 out << '</li></ul>' * (current - root)
1023 1023 out << '</li></ul>'
1024 1024 end
1025 1025 end
1026 1026 end
1027 1027
1028 1028 # Same as Rails' simple_format helper without using paragraphs
1029 1029 def simple_format_without_paragraph(text)
1030 1030 text.to_s.
1031 1031 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
1032 1032 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
1033 1033 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
1034 1034 html_safe
1035 1035 end
1036 1036
1037 1037 def lang_options_for_select(blank=true)
1038 1038 (blank ? [["(auto)", ""]] : []) + languages_options
1039 1039 end
1040 1040
1041 1041 def label_tag_for(name, option_tags = nil, options = {})
1042 1042 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
1043 1043 content_tag("label", label_text)
1044 1044 end
1045 1045
1046 1046 def labelled_form_for(*args, &proc)
1047 1047 args << {} unless args.last.is_a?(Hash)
1048 1048 options = args.last
1049 1049 if args.first.is_a?(Symbol)
1050 1050 options.merge!(:as => args.shift)
1051 1051 end
1052 1052 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1053 1053 form_for(*args, &proc)
1054 1054 end
1055 1055
1056 1056 def labelled_fields_for(*args, &proc)
1057 1057 args << {} unless args.last.is_a?(Hash)
1058 1058 options = args.last
1059 1059 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1060 1060 fields_for(*args, &proc)
1061 1061 end
1062 1062
1063 1063 def labelled_remote_form_for(*args, &proc)
1064 1064 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_remote_form_for is deprecated and will be removed in Redmine 2.2."
1065 1065 args << {} unless args.last.is_a?(Hash)
1066 1066 options = args.last
1067 1067 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
1068 1068 form_for(*args, &proc)
1069 1069 end
1070 1070
1071 1071 def error_messages_for(*objects)
1072 1072 html = ""
1073 1073 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1074 1074 errors = objects.map {|o| o.errors.full_messages}.flatten
1075 1075 if errors.any?
1076 1076 html << "<div id='errorExplanation'><ul>\n"
1077 1077 errors.each do |error|
1078 1078 html << "<li>#{h error}</li>\n"
1079 1079 end
1080 1080 html << "</ul></div>\n"
1081 1081 end
1082 1082 html.html_safe
1083 1083 end
1084 1084
1085 1085 def delete_link(url, options={})
1086 1086 options = {
1087 1087 :method => :delete,
1088 1088 :data => {:confirm => l(:text_are_you_sure)},
1089 1089 :class => 'icon icon-del'
1090 1090 }.merge(options)
1091 1091
1092 1092 link_to l(:button_delete), url, options
1093 1093 end
1094 1094
1095 1095 def preview_link(url, form, target='preview', options={})
1096 1096 content_tag 'a', l(:label_preview), {
1097 1097 :href => "#",
1098 1098 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1099 1099 :accesskey => accesskey(:preview)
1100 1100 }.merge(options)
1101 1101 end
1102 1102
1103 1103 def link_to_function(name, function, html_options={})
1104 1104 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1105 1105 end
1106 1106
1107 1107 # Helper to render JSON in views
1108 1108 def raw_json(arg)
1109 1109 arg.to_json.to_s.gsub('/', '\/').html_safe
1110 1110 end
1111 1111
1112 1112 def back_url
1113 1113 url = params[:back_url]
1114 1114 if url.nil? && referer = request.env['HTTP_REFERER']
1115 1115 url = CGI.unescape(referer.to_s)
1116 1116 end
1117 1117 url
1118 1118 end
1119 1119
1120 1120 def back_url_hidden_field_tag
1121 1121 url = back_url
1122 1122 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1123 1123 end
1124 1124
1125 1125 def check_all_links(form_name)
1126 1126 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1127 1127 " | ".html_safe +
1128 1128 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1129 1129 end
1130 1130
1131 1131 def progress_bar(pcts, options={})
1132 1132 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1133 1133 pcts = pcts.collect(&:round)
1134 1134 pcts[1] = pcts[1] - pcts[0]
1135 1135 pcts << (100 - pcts[1] - pcts[0])
1136 1136 width = options[:width] || '100px;'
1137 1137 legend = options[:legend] || ''
1138 1138 content_tag('table',
1139 1139 content_tag('tr',
1140 1140 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1141 1141 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1142 1142 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1143 1143 ), :class => "progress progress-#{pcts[0]}", :style => "width: #{width};").html_safe +
1144 1144 content_tag('p', legend, :class => 'percent').html_safe
1145 1145 end
1146 1146
1147 1147 def checked_image(checked=true)
1148 1148 if checked
1149 1149 image_tag 'toggle_check.png'
1150 1150 end
1151 1151 end
1152 1152
1153 1153 def context_menu(url)
1154 1154 unless @context_menu_included
1155 1155 content_for :header_tags do
1156 1156 javascript_include_tag('context_menu') +
1157 1157 stylesheet_link_tag('context_menu')
1158 1158 end
1159 1159 if l(:direction) == 'rtl'
1160 1160 content_for :header_tags do
1161 1161 stylesheet_link_tag('context_menu_rtl')
1162 1162 end
1163 1163 end
1164 1164 @context_menu_included = true
1165 1165 end
1166 1166 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1167 1167 end
1168 1168
1169 1169 def calendar_for(field_id)
1170 1170 include_calendar_headers_tags
1171 1171 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1172 1172 end
1173 1173
1174 1174 def include_calendar_headers_tags
1175 1175 unless @calendar_headers_tags_included
1176 1176 tags = javascript_include_tag("datepicker")
1177 1177 @calendar_headers_tags_included = true
1178 1178 content_for :header_tags do
1179 1179 start_of_week = Setting.start_of_week
1180 1180 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1181 1181 # Redmine uses 1..7 (monday..sunday) in settings and locales
1182 1182 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1183 1183 start_of_week = start_of_week.to_i % 7
1184 1184 tags << javascript_tag(
1185 1185 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1186 1186 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1187 1187 path_to_image('/images/calendar.png') +
1188 1188 "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " +
1189 1189 "selectOtherMonths: true, changeMonth: true, changeYear: true, " +
1190 1190 "beforeShow: beforeShowDatePicker};")
1191 1191 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1192 1192 unless jquery_locale == 'en'
1193 1193 tags << javascript_include_tag("i18n/jquery.ui.datepicker-#{jquery_locale}.js")
1194 1194 end
1195 1195 tags
1196 1196 end
1197 1197 end
1198 1198 end
1199 1199
1200 1200 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1201 1201 # Examples:
1202 1202 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1203 1203 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1204 1204 #
1205 1205 def stylesheet_link_tag(*sources)
1206 1206 options = sources.last.is_a?(Hash) ? sources.pop : {}
1207 1207 plugin = options.delete(:plugin)
1208 1208 sources = sources.map do |source|
1209 1209 if plugin
1210 1210 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1211 1211 elsif current_theme && current_theme.stylesheets.include?(source)
1212 1212 current_theme.stylesheet_path(source)
1213 1213 else
1214 1214 source
1215 1215 end
1216 1216 end
1217 1217 super sources, options
1218 1218 end
1219 1219
1220 1220 # Overrides Rails' image_tag with themes and plugins support.
1221 1221 # Examples:
1222 1222 # image_tag('image.png') # => picks image.png from the current theme or defaults
1223 1223 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1224 1224 #
1225 1225 def image_tag(source, options={})
1226 1226 if plugin = options.delete(:plugin)
1227 1227 source = "/plugin_assets/#{plugin}/images/#{source}"
1228 1228 elsif current_theme && current_theme.images.include?(source)
1229 1229 source = current_theme.image_path(source)
1230 1230 end
1231 1231 super source, options
1232 1232 end
1233 1233
1234 1234 # Overrides Rails' javascript_include_tag with plugins support
1235 1235 # Examples:
1236 1236 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1237 1237 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1238 1238 #
1239 1239 def javascript_include_tag(*sources)
1240 1240 options = sources.last.is_a?(Hash) ? sources.pop : {}
1241 1241 if plugin = options.delete(:plugin)
1242 1242 sources = sources.map do |source|
1243 1243 if plugin
1244 1244 "/plugin_assets/#{plugin}/javascripts/#{source}"
1245 1245 else
1246 1246 source
1247 1247 end
1248 1248 end
1249 1249 end
1250 1250 super sources, options
1251 1251 end
1252 1252
1253 1253 # TODO: remove this in 2.5.0
1254 1254 def has_content?(name)
1255 1255 content_for?(name)
1256 1256 end
1257 1257
1258 1258 def sidebar_content?
1259 1259 content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1260 1260 end
1261 1261
1262 1262 def view_layouts_base_sidebar_hook_response
1263 1263 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1264 1264 end
1265 1265
1266 1266 def email_delivery_enabled?
1267 1267 !!ActionMailer::Base.perform_deliveries
1268 1268 end
1269 1269
1270 1270 # Returns the avatar image tag for the given +user+ if avatars are enabled
1271 1271 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1272 1272 def avatar(user, options = { })
1273 1273 if Setting.gravatar_enabled?
1274 1274 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1275 1275 email = nil
1276 1276 if user.respond_to?(:mail)
1277 1277 email = user.mail
1278 1278 elsif user.to_s =~ %r{<(.+?)>}
1279 1279 email = $1
1280 1280 end
1281 1281 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1282 1282 else
1283 1283 ''
1284 1284 end
1285 1285 end
1286 1286
1287 1287 def sanitize_anchor_name(anchor)
1288 1288 if ''.respond_to?(:encoding) || RUBY_PLATFORM == 'java'
1289 1289 anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1290 1290 else
1291 1291 # TODO: remove when ruby1.8 is no longer supported
1292 1292 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1293 1293 end
1294 1294 end
1295 1295
1296 1296 # Returns the javascript tags that are included in the html layout head
1297 1297 def javascript_heads
1298 1298 tags = javascript_include_tag('jquery-1.8.3-ui-1.9.2-ujs-2.0.3', 'application')
1299 1299 unless User.current.pref.warn_on_leaving_unsaved == '0'
1300 1300 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1301 1301 end
1302 1302 tags
1303 1303 end
1304 1304
1305 1305 def favicon
1306 1306 "<link rel='shortcut icon' href='#{favicon_path}' />".html_safe
1307 1307 end
1308 1308
1309 1309 # Returns the path to the favicon
1310 1310 def favicon_path
1311 1311 icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : '/favicon.ico'
1312 1312 image_path(icon)
1313 1313 end
1314 1314
1315 1315 # Returns the full URL to the favicon
1316 1316 def favicon_url
1317 1317 # TODO: use #image_url introduced in Rails4
1318 1318 path = favicon_path
1319 1319 base = url_for(:controller => 'welcome', :action => 'index', :only_path => false)
1320 1320 base.sub(%r{/+$},'') + '/' + path.sub(%r{^/+},'')
1321 1321 end
1322 1322
1323 1323 def robot_exclusion_tag
1324 1324 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1325 1325 end
1326 1326
1327 1327 # Returns true if arg is expected in the API response
1328 1328 def include_in_api_response?(arg)
1329 1329 unless @included_in_api_response
1330 1330 param = params[:include]
1331 1331 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1332 1332 @included_in_api_response.collect!(&:strip)
1333 1333 end
1334 1334 @included_in_api_response.include?(arg.to_s)
1335 1335 end
1336 1336
1337 1337 # Returns options or nil if nometa param or X-Redmine-Nometa header
1338 1338 # was set in the request
1339 1339 def api_meta(options)
1340 1340 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1341 1341 # compatibility mode for activeresource clients that raise
1342 1342 # an error when deserializing an array with attributes
1343 1343 nil
1344 1344 else
1345 1345 options
1346 1346 end
1347 1347 end
1348 1348
1349 1349 private
1350 1350
1351 1351 def wiki_helper
1352 1352 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1353 1353 extend helper
1354 1354 return self
1355 1355 end
1356 1356
1357 1357 def link_to_content_update(text, url_params = {}, html_options = {})
1358 1358 link_to(text, url_params, html_options)
1359 1359 end
1360 1360 end
@@ -1,290 +1,295
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class Version < ActiveRecord::Base
19 19 include Redmine::SafeAttributes
20 20 after_update :update_issues_from_sharing_change
21 21 belongs_to :project
22 22 has_many :fixed_issues, :class_name => 'Issue', :foreign_key => 'fixed_version_id', :dependent => :nullify
23 23 acts_as_customizable
24 24 acts_as_attachable :view_permission => :view_files,
25 25 :delete_permission => :manage_files
26 26
27 27 VERSION_STATUSES = %w(open locked closed)
28 28 VERSION_SHARINGS = %w(none descendants hierarchy tree system)
29 29
30 30 validates_presence_of :name
31 31 validates_uniqueness_of :name, :scope => [:project_id]
32 32 validates_length_of :name, :maximum => 60
33 33 validates :effective_date, :date => true
34 34 validates_inclusion_of :status, :in => VERSION_STATUSES
35 35 validates_inclusion_of :sharing, :in => VERSION_SHARINGS
36 36
37 37 scope :named, lambda {|arg| where("LOWER(#{table_name}.name) = LOWER(?)", arg.to_s.strip)}
38 38 scope :open, lambda { where(:status => 'open') }
39 39 scope :visible, lambda {|*args|
40 40 includes(:project).where(Project.allowed_to_condition(args.first || User.current, :view_issues))
41 41 }
42 42
43 43 safe_attributes 'name',
44 44 'description',
45 45 'effective_date',
46 46 'due_date',
47 47 'wiki_page_title',
48 48 'status',
49 49 'sharing',
50 50 'custom_field_values',
51 51 'custom_fields'
52 52
53 53 # Returns true if +user+ or current user is allowed to view the version
54 54 def visible?(user=User.current)
55 55 user.allowed_to?(:view_issues, self.project)
56 56 end
57 57
58 58 # Version files have same visibility as project files
59 59 def attachments_visible?(*args)
60 60 project.present? && project.attachments_visible?(*args)
61 61 end
62 62
63 63 def start_date
64 64 @start_date ||= fixed_issues.minimum('start_date')
65 65 end
66 66
67 67 def due_date
68 68 effective_date
69 69 end
70 70
71 71 def due_date=(arg)
72 72 self.effective_date=(arg)
73 73 end
74 74
75 75 # Returns the total estimated time for this version
76 76 # (sum of leaves estimated_hours)
77 77 def estimated_hours
78 78 @estimated_hours ||= fixed_issues.leaves.sum(:estimated_hours).to_f
79 79 end
80 80
81 81 # Returns the total reported time for this version
82 82 def spent_hours
83 83 @spent_hours ||= TimeEntry.joins(:issue).where("#{Issue.table_name}.fixed_version_id = ?", id).sum(:hours).to_f
84 84 end
85 85
86 86 def closed?
87 87 status == 'closed'
88 88 end
89 89
90 90 def open?
91 91 status == 'open'
92 92 end
93 93
94 94 # Returns true if the version is completed: due date reached and no open issues
95 95 def completed?
96 96 effective_date && (effective_date < Date.today) && (open_issues_count == 0)
97 97 end
98 98
99 99 def behind_schedule?
100 100 if completed_percent == 100
101 101 return false
102 102 elsif due_date && start_date
103 103 done_date = start_date + ((due_date - start_date+1)* completed_percent/100).floor
104 104 return done_date <= Date.today
105 105 else
106 106 false # No issues so it's not late
107 107 end
108 108 end
109 109
110 110 # Returns the completion percentage of this version based on the amount of open/closed issues
111 111 # and the time spent on the open issues.
112 112 def completed_percent
113 113 if issues_count == 0
114 114 0
115 115 elsif open_issues_count == 0
116 116 100
117 117 else
118 118 issues_progress(false) + issues_progress(true)
119 119 end
120 120 end
121 121
122 122 # TODO: remove in Redmine 3.0
123 123 def completed_pourcent
124 124 ActiveSupport::Deprecation.warn "Version#completed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #completed_percent instead."
125 125 completed_percent
126 126 end
127 127
128 128 # Returns the percentage of issues that have been marked as 'closed'.
129 129 def closed_percent
130 130 if issues_count == 0
131 131 0
132 132 else
133 133 issues_progress(false)
134 134 end
135 135 end
136 136
137 137 # TODO: remove in Redmine 3.0
138 138 def closed_pourcent
139 139 ActiveSupport::Deprecation.warn "Version#closed_pourcent is deprecated and will be removed in Redmine 3.0. Please use #closed_percent instead."
140 140 closed_percent
141 141 end
142 142
143 143 # Returns true if the version is overdue: due date reached and some open issues
144 144 def overdue?
145 145 effective_date && (effective_date < Date.today) && (open_issues_count > 0)
146 146 end
147 147
148 148 # Returns assigned issues count
149 149 def issues_count
150 150 load_issue_counts
151 151 @issue_count
152 152 end
153 153
154 154 # Returns the total amount of open issues for this version.
155 155 def open_issues_count
156 156 load_issue_counts
157 157 @open_issues_count
158 158 end
159 159
160 160 # Returns the total amount of closed issues for this version.
161 161 def closed_issues_count
162 162 load_issue_counts
163 163 @closed_issues_count
164 164 end
165 165
166 166 def wiki_page
167 167 if project.wiki && !wiki_page_title.blank?
168 168 @wiki_page ||= project.wiki.find_page(wiki_page_title)
169 169 end
170 170 @wiki_page
171 171 end
172 172
173 173 def to_s; name end
174 174
175 175 def to_s_with_project
176 176 "#{project} - #{name}"
177 177 end
178 178
179 179 # Versions are sorted by effective_date and name
180 180 # Those with no effective_date are at the end, sorted by name
181 181 def <=>(version)
182 182 if self.effective_date
183 183 if version.effective_date
184 184 if self.effective_date == version.effective_date
185 185 name == version.name ? id <=> version.id : name <=> version.name
186 186 else
187 187 self.effective_date <=> version.effective_date
188 188 end
189 189 else
190 190 -1
191 191 end
192 192 else
193 193 if version.effective_date
194 194 1
195 195 else
196 196 name == version.name ? id <=> version.id : name <=> version.name
197 197 end
198 198 end
199 199 end
200 200
201 201 def self.fields_for_order_statement(table=nil)
202 202 table ||= table_name
203 203 ["(CASE WHEN #{table}.effective_date IS NULL THEN 1 ELSE 0 END)", "#{table}.effective_date", "#{table}.name", "#{table}.id"]
204 204 end
205 205
206 206 scope :sorted, lambda { order(fields_for_order_statement) }
207 207
208 208 # Returns the sharings that +user+ can set the version to
209 209 def allowed_sharings(user = User.current)
210 210 VERSION_SHARINGS.select do |s|
211 211 if sharing == s
212 212 true
213 213 else
214 214 case s
215 215 when 'system'
216 216 # Only admin users can set a systemwide sharing
217 217 user.admin?
218 218 when 'hierarchy', 'tree'
219 219 # Only users allowed to manage versions of the root project can
220 220 # set sharing to hierarchy or tree
221 221 project.nil? || user.allowed_to?(:manage_versions, project.root)
222 222 else
223 223 true
224 224 end
225 225 end
226 226 end
227 227 end
228 228
229 # Returns true if the version is shared, otherwise false
230 def shared?
231 sharing != 'none'
232 end
233
229 234 private
230 235
231 236 def load_issue_counts
232 237 unless @issue_count
233 238 @open_issues_count = 0
234 239 @closed_issues_count = 0
235 240 fixed_issues.group(:status).count.each do |status, count|
236 241 if status.is_closed?
237 242 @closed_issues_count += count
238 243 else
239 244 @open_issues_count += count
240 245 end
241 246 end
242 247 @issue_count = @open_issues_count + @closed_issues_count
243 248 end
244 249 end
245 250
246 251 # Update the issue's fixed versions. Used if a version's sharing changes.
247 252 def update_issues_from_sharing_change
248 253 if sharing_changed?
249 254 if VERSION_SHARINGS.index(sharing_was).nil? ||
250 255 VERSION_SHARINGS.index(sharing).nil? ||
251 256 VERSION_SHARINGS.index(sharing_was) > VERSION_SHARINGS.index(sharing)
252 257 Issue.update_versions_from_sharing_change self
253 258 end
254 259 end
255 260 end
256 261
257 262 # Returns the average estimated time of assigned issues
258 263 # or 1 if no issue has an estimated time
259 264 # Used to weight unestimated issues in progress calculation
260 265 def estimated_average
261 266 if @estimated_average.nil?
262 267 average = fixed_issues.average(:estimated_hours).to_f
263 268 if average == 0
264 269 average = 1
265 270 end
266 271 @estimated_average = average
267 272 end
268 273 @estimated_average
269 274 end
270 275
271 276 # Returns the total progress of open or closed issues. The returned percentage takes into account
272 277 # the amount of estimated time set for this version.
273 278 #
274 279 # Examples:
275 280 # issues_progress(true) => returns the progress percentage for open issues.
276 281 # issues_progress(false) => returns the progress percentage for closed issues.
277 282 def issues_progress(open)
278 283 @issues_progress ||= {}
279 284 @issues_progress[open] ||= begin
280 285 progress = 0
281 286 if issues_count > 0
282 287 ratio = open ? 'done_ratio' : 100
283 288
284 289 done = fixed_issues.open(open).sum("COALESCE(estimated_hours, #{estimated_average}) * #{ratio}").to_f
285 290 progress = done / (estimated_average * issues_count)
286 291 end
287 292 progress
288 293 end
289 294 end
290 295 end
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,85 +1,94
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2014 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 ProjectsHelperTest < ActionView::TestCase
21 21 include ApplicationHelper
22 22 include ProjectsHelper
23 23 include Redmine::I18n
24 24 include ERB::Util
25 25 include Rails.application.routes.url_helpers
26 26
27 27 fixtures :projects, :trackers, :issue_statuses, :issues,
28 28 :enumerations, :users, :issue_categories,
29 29 :versions,
30 30 :projects_trackers,
31 31 :member_roles,
32 32 :members,
33 33 :groups_users,
34 34 :enabled_modules
35 35
36 36 def setup
37 37 super
38 38 set_language_if_valid('en')
39 39 User.current = nil
40 40 end
41 41
42 42 def test_link_to_version_within_project
43 43 @project = Project.find(2)
44 44 User.current = User.find(1)
45 45 assert_equal '<a href="/versions/5" title="07/01/2006">Alpha</a>', link_to_version(Version.find(5))
46 46 end
47 47
48 48 def test_link_to_version
49 49 User.current = User.find(1)
50 assert_equal '<a href="/versions/5" title="07/01/2006">OnlineStore - Alpha</a>', link_to_version(Version.find(5))
50 assert_equal '<a href="/versions/5" title="07/01/2006">Alpha</a>', link_to_version(Version.find(5))
51 51 end
52 52
53 53 def test_link_to_version_without_effective_date
54 54 User.current = User.find(1)
55 55 version = Version.find(5)
56 56 version.effective_date = nil
57 assert_equal '<a href="/versions/5">OnlineStore - Alpha</a>', link_to_version(version)
57 assert_equal '<a href="/versions/5">Alpha</a>', link_to_version(version)
58 58 end
59 59
60 60 def test_link_to_private_version
61 assert_equal 'OnlineStore - Alpha', link_to_version(Version.find(5))
61 assert_equal 'Alpha', link_to_version(Version.find(5))
62 62 end
63 63
64 64 def test_link_to_version_invalid_version
65 65 assert_equal '', link_to_version(Object)
66 66 end
67 67
68 68 def test_format_version_name_within_project
69 69 @project = Project.find(1)
70 70 assert_equal "0.1", format_version_name(Version.find(1))
71 71 end
72 72
73 73 def test_format_version_name
74 assert_equal "eCookbook - 0.1", format_version_name(Version.find(1))
74 assert_equal "0.1", format_version_name(Version.find(1))
75 end
76
77 def test_format_version_name_for_shared_version_within_project_should_not_display_project_name
78 @project = Project.find(1)
79 version = Version.find(1)
80 version.sharing = 'system'
81 assert_equal "0.1", format_version_name(version)
75 82 end
76 83
77 def test_format_version_name_for_system_version
78 assert_equal "OnlineStore - Systemwide visible version", format_version_name(Version.find(7))
84 def test_format_version_name_for_shared_version_should_display_project_name
85 version = Version.find(1)
86 version.sharing = 'system'
87 assert_equal "eCookbook - 0.1", format_version_name(version)
79 88 end
80 89
81 90 def test_version_options_for_select_with_no_versions
82 91 assert_equal '', version_options_for_select([])
83 92 assert_equal '', version_options_for_select([], Version.find(1))
84 93 end
85 94 end
General Comments 0
You need to be logged in to leave comments. Login now