issues_helper.rb 19.8 KB
Newer Older
1 2
# encoding: utf-8
#
3
# Redmine - project management software
jplang's avatar
jplang committed
4
# Copyright (C) 2006-2017  Jean-Philippe Lang
5 6 7 8 9
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
10
#
11 12 13 14
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
15
#
16 17 18 19 20
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

module IssuesHelper
21
  include ApplicationHelper
22
  include Redmine::Export::PDF::IssuesPdfHelper
23

24 25 26 27 28 29 30
  def issue_list(issues, &block)
    ancestors = []
    issues.each do |issue|
      while (ancestors.any? && !issue.is_descendant_of?(ancestors.last))
        ancestors.pop
      end
      yield issue, ancestors.size
31
      ancestors << issue unless issue.leaf?
32 33
    end
  end
edavis10's avatar
edavis10 committed
34

35
  def grouped_issue_list(issues, query, &block)
36
    ancestors = []
37
    grouped_query_results(issues, query) do |issue, group_name, group_count, group_totals|
38 39
      while (ancestors.any? && !issue.is_descendant_of?(ancestors.last))
        ancestors.pop
40
      end
41 42
      yield issue, ancestors.size, group_name, group_count, group_totals
      ancestors << issue unless issue.leaf?
43 44 45
    end
  end

edavis10's avatar
edavis10 committed
46 47 48 49 50 51 52 53 54
  # Renders a HTML/CSS tooltip
  #
  # To use, a trigger div is needed.  This is a div with the class of "tooltip"
  # that contains this method wrapped in a span with the class of "tip"
  #
  #    <div class="tooltip"><%= link_to_issue(issue) %>
  #      <span class="tip"><%= render_issue_tooltip(issue) %></span>
  #    </div>
  #
55
  def render_issue_tooltip(issue)
56
    @cached_label_status ||= l(:field_status)
57 58 59 60
    @cached_label_start_date ||= l(:field_start_date)
    @cached_label_due_date ||= l(:field_due_date)
    @cached_label_assigned_to ||= l(:field_assigned_to)
    @cached_label_priority ||= l(:field_priority)
edavis10's avatar
edavis10 committed
61 62
    @cached_label_project ||= l(:field_project)

63 64 65 66 67 68 69
    link_to_issue(issue) + "<br /><br />".html_safe +
      "<strong>#{@cached_label_project}</strong>: #{link_to_project(issue.project)}<br />".html_safe +
      "<strong>#{@cached_label_status}</strong>: #{h(issue.status.name)}<br />".html_safe +
      "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />".html_safe +
      "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />".html_safe +
      "<strong>#{@cached_label_assigned_to}</strong>: #{h(issue.assigned_to)}<br />".html_safe +
      "<strong>#{@cached_label_priority}</strong>: #{h(issue.priority.name)}".html_safe
70
  end
71

72 73 74
  def issue_heading(issue)
    h("#{issue.tracker} ##{issue.id}")
  end
75

jplang's avatar
jplang committed
76 77
  def render_issue_subject_with_tree(issue)
    s = ''
jplang's avatar
jplang committed
78
    ancestors = issue.root? ? [] : issue.ancestors.visible.to_a
79
    ancestors.each do |ancestor|
80
      s << '<div>' + content_tag('p', link_to_issue(ancestor, :project => (issue.project_id != ancestor.project_id)))
jplang's avatar
jplang committed
81
    end
jplang's avatar
jplang committed
82 83 84
    s << '<div>'
    subject = h(issue.subject)
    if issue.is_private?
85
      subject = subject + ' ' + content_tag('span', l(:field_is_private), :class => 'private') 
jplang's avatar
jplang committed
86 87
    end
    s << content_tag('h3', subject)
88
    s << '</div>' * (ancestors.size + 1)
89
    s.html_safe
jplang's avatar
jplang committed
90
  end
91

jplang's avatar
jplang committed
92
  def render_descendants_tree(issue)
93
    s = '<table class="list issues odd-even">'
jplang's avatar
jplang committed
94
    issue_list(issue.descendants.visible.preload(:status, :priority, :tracker, :assigned_to).sort_by(&:lft)) do |child, level|
95
      css = "issue issue-#{child.id} hascontextmenu #{child.css_classes}"
jplang's avatar
jplang committed
96
      css << " idnt idnt-#{level}" if level > 0
jplang's avatar
jplang committed
97
      s << content_tag('tr',
98
             content_tag('td', check_box_tag("ids[]", child.id, false, :id => nil), :class => 'checkbox') +
99
             content_tag('td', link_to_issue(child, :project => (issue.project_id != child.project_id)), :class => 'subject', :style => 'width: 50%') +
100 101 102
             content_tag('td', h(child.status), :class => 'status') +
             content_tag('td', link_to_user(child.assigned_to), :class => 'assigned_to') +
             content_tag('td', child.disabled_core_fields.include?('done_ratio') ? '' : progress_bar(child.done_ratio), :class=> 'done_ratio'),
jplang's avatar
jplang committed
103
             :class => css)
jplang's avatar
jplang committed
104
    end
105
    s << '</table>'
106
    s.html_safe
jplang's avatar
jplang committed
107
  end
108

109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131
  # Renders the list of related issues on the issue details view
  def render_issue_relations(issue, relations)
    manage_relations = User.current.allowed_to?(:manage_issue_relations, issue.project)

    s = ''.html_safe
    relations.each do |relation|
      other_issue = relation.other_issue(issue)
      css = "issue hascontextmenu #{other_issue.css_classes}"
      link = manage_relations ? link_to(l(:label_relation_delete),
                                  relation_path(relation),
                                  :remote => true,
                                  :method => :delete,
                                  :data => {:confirm => l(:text_are_you_sure)},
                                  :title => l(:label_relation_delete),
                                  :class => 'icon-only icon-link-break'
                                 ) : nil

      s << content_tag('tr',
             content_tag('td', check_box_tag("ids[]", other_issue.id, false, :id => nil), :class => 'checkbox') +
             content_tag('td', relation.to_s(@issue) {|other| link_to_issue(other, :project => Setting.cross_project_issue_relations?)}.html_safe, :class => 'subject', :style => 'width: 50%') +
             content_tag('td', other_issue.status, :class => 'status') +
             content_tag('td', other_issue.start_date, :class => 'start_date') +
             content_tag('td', other_issue.due_date, :class => 'due_date') +
132
             content_tag('td', other_issue.disabled_core_fields.include?('done_ratio') ? '' : progress_bar(other_issue.done_ratio), :class=> 'done_ratio') +
133 134 135 136 137
             content_tag('td', link, :class => 'buttons'),
             :id => "relation-#{relation.id}",
             :class => css)
    end

138
    content_tag('table', s, :class => 'list issues odd-even')
139 140
  end

141
  def issue_estimated_hours_details(issue)
142 143 144 145 146 147 148 149 150 151 152 153 154
    if issue.total_estimated_hours.present?
      if issue.total_estimated_hours == issue.estimated_hours
        l_hours_short(issue.estimated_hours)
      else
        s = issue.estimated_hours.present? ? l_hours_short(issue.estimated_hours) : ""
        s << " (#{l(:label_total)}: #{l_hours_short(issue.total_estimated_hours)})"
        s.html_safe
      end
    end
  end

  def issue_spent_hours_details(issue)
    if issue.total_spent_hours > 0
155 156
      path = project_time_entries_path(issue.project, :issue_id => "~#{issue.id}")

157
      if issue.total_spent_hours == issue.spent_hours
158
        link_to(l_hours_short(issue.spent_hours), path)
159 160
      else
        s = issue.spent_hours > 0 ? l_hours_short(issue.spent_hours) : ""
161
        s << " (#{l(:label_total)}: #{link_to l_hours_short(issue.total_spent_hours), path})"
162 163
        s.html_safe
      end
164 165 166
    end
  end

167 168 169 170 171 172 173 174 175 176 177 178 179 180
  # Returns an array of error messages for bulk edited issues
  def bulk_edit_error_messages(issues)
    messages = {}
    issues.each do |issue|
      issue.errors.full_messages.each do |message|
        messages[message] ||= []
        messages[message] << issue
      end
    end
    messages.map { |message, issues|
      "#{message}: " + issues.map {|i| "##{i.id}"}.join(', ')
    }
 end

181 182
  # Returns a link for adding a new subtask to the given issue
  def link_to_new_subtask(issue)
183 184 185
    attrs = {
      :parent_issue_id => issue
    }
186
    attrs[:tracker_id] = issue.tracker unless issue.tracker.disabled_core_fields.include?('parent_issue_id')
187
    link_to(l(:button_add), new_project_issue_path(issue.project, :issue => attrs, :back_url => issue_path(issue)))
188 189
  end

190 191 192 193 194 195 196 197 198 199
  def trackers_options_for_select(issue)
    trackers = issue.allowed_target_trackers
    if issue.new_record? && issue.parent_issue_id.present?
      trackers = trackers.reject do |tracker|
        issue.tracker_id != tracker.id && tracker.disabled_core_fields.include?('parent_issue_id')
      end
    end
    trackers.collect {|t| [t.name, t.id]}
  end

200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220
  class IssueFieldsRows
    include ActionView::Helpers::TagHelper

    def initialize
      @left = []
      @right = []
    end

    def left(*args)
      args.any? ? @left << cells(*args) : @left
    end

    def right(*args)
      args.any? ? @right << cells(*args) : @right
    end

    def size
      @left.size > @right.size ? @left.size : @right.size
    end

    def to_html
221 222 223 224 225
      content =
        content_tag('div', @left.reduce(&:+), :class => 'splitcontentleft') +
        content_tag('div', @right.reduce(&:+), :class => 'splitcontentleft')

      content_tag('div', content, :class => 'splitcontent')
226 227 228
    end

    def cells(label, text, options={})
229 230 231 232
      options[:class] = [options[:class] || "", 'attribute'].join(' ')
      content_tag 'div',
        content_tag('div', label + ":", :class => 'label') + content_tag('div', text, :class => 'value'),
        options
233 234 235 236 237 238 239 240 241
    end
  end

  def issue_fields_rows
    r = IssueFieldsRows.new
    yield r
    r.to_html
  end

242 243
  def render_half_width_custom_fields_rows(issue)
    values = issue.visible_custom_field_values.reject {|value| value.custom_field.full_width_layout?}
244 245
    return if values.empty?
    half = (values.size / 2.0).ceil
246 247 248 249 250 251 252
    issue_fields_rows do |rows|
      values.each_with_index do |value, i|
        css = "cf_#{value.custom_field.id}"
        m = (i < half ? :left : :right)
        rows.send m, custom_field_name_tag(value.custom_field), show_value(value), :class => css
      end
    end
253
  end
254

255 256 257 258
  def render_full_width_custom_fields_rows(issue)
    values = issue.visible_custom_field_values.select {|value| value.custom_field.full_width_layout?}
    return if values.empty?

jplang's avatar
jplang committed
259
    s = ''.html_safe
260
    values.each_with_index do |value, i|
261 262 263
      attr_value = show_value(value)
      next if attr_value.blank?

264
      if value.custom_field.text_formatting == 'full'
265
        attr_value = content_tag('div', attr_value, class: 'wiki')
266
      end
267

268 269 270 271 272 273
      content =
          content_tag('hr') +
          content_tag('p', content_tag('strong', custom_field_name_tag(value.custom_field) )) +
          content_tag('div', attr_value, class: 'value')
      s << content_tag('div', content, class: "cf_#{value.custom_field.id} attribute")
    end
jplang's avatar
jplang committed
274
    s
275 276
  end

277 278 279
  # Returns the path for updating the issue form
  # with project as the current project
  def update_issue_form_path(project, issue)
280
    options = {:format => 'js'}
281
    if issue.new_record?
282 283 284 285 286
      if project
        new_project_issue_path(project, options)
      else
        new_issue_path(options)
      end
287
    else
288
      edit_issue_path(issue, options)
289 290 291
    end
  end

292 293 294 295 296 297 298
  # Returns the number of descendants for an array of issues
  def issues_descendant_count(issues)
    ids = issues.reject(&:leaf?).map {|issue| issue.descendants.ids}.flatten.uniq
    ids -= issues.map(&:id)
    ids.size
  end

299 300 301
  def issues_destroy_confirmation_message(issues)
    issues = [issues] unless issues.is_a?(Array)
    message = l(:text_issues_destroy_confirmation)
302

303
    descendant_count = issues_descendant_count(issues)
304
    if descendant_count > 0
305
      message << "\n" + l(:text_issues_destroy_descendants_confirmation, :count => descendant_count)
306 307 308
    end
    message
  end
309

310 311 312 313 314 315 316 317 318 319
  # Returns an array of users that are proposed as watchers
  # on the new issue form
  def users_for_new_issue_watchers(issue)
    users = issue.watcher_users
    if issue.project.users.count <= 20
      users = (users + issue.project.users.sort).uniq
    end
    users
  end

320
  def email_issue_attributes(issue, user, html)
321 322 323
    items = []
    %w(author status priority assigned_to category fixed_version).each do |attribute|
      unless issue.disabled_core_fields.include?(attribute+"_id")
324 325 326 327 328
        if html
          items << content_tag('strong', "#{l("field_#{attribute}")}: ") + (issue.send attribute)
        else
          items << "#{l("field_#{attribute}")}: #{issue.send attribute}"
        end
329 330 331
      end
    end
    issue.visible_custom_field_values(user).each do |value|
332 333 334 335 336
      if html
        items << content_tag('strong', "#{value.custom_field.name}: ") + show_value(value, false)
      else
        items << "#{value.custom_field.name}: #{show_value(value, false)}"
      end
337 338 339 340 341
    end
    items
  end

  def render_email_issue_attributes(issue, user, html=false)
342
    items = email_issue_attributes(issue, user, html)
343
    if html
344
      content_tag('ul', items.map{|s| content_tag('li', s)}.join("\n").html_safe, :class => "details")
345 346 347 348 349
    else
      items.map{|s| "* #{s}"}.join("\n")
    end
  end

350 351
  MultipleValuesDetail = Struct.new(:property, :prop_key, :custom_field, :old_value, :value)

352 353
  # Returns the textual representation of a journal details
  # as an array of strings
354 355
  def details_to_strings(details, no_html=false, options={})
    options[:only_path] = (options[:only_path] == false ? false : true)
356 357 358 359
    strings = []
    values_by_field = {}
    details.each do |detail|
      if detail.property == 'cf'
360
        field = detail.custom_field
361
        if field && field.multiple?
362
          values_by_field[field] ||= {:added => [], :deleted => []}
363
          if detail.old_value
364
            values_by_field[field][:deleted] << detail.old_value
365 366
          end
          if detail.value
367
            values_by_field[field][:added] << detail.value
368 369 370 371
          end
          next
        end
      end
372
      strings << show_detail(detail, no_html, options)
373
    end
374 375 376
    if values_by_field.present?
      values_by_field.each do |field, changes|
        if changes[:added].any?
377
          detail = MultipleValuesDetail.new('cf', field.id.to_s, field)
378 379 380 381
          detail.value = changes[:added]
          strings << show_detail(detail, no_html, options)
        end
        if changes[:deleted].any?
382
          detail = MultipleValuesDetail.new('cf', field.id.to_s, field)
383 384 385
          detail.old_value = changes[:deleted]
          strings << show_detail(detail, no_html, options)
        end
386 387 388 389 390 391
      end
    end
    strings
  end

  # Returns the textual representation of a single journal detail
392
  def show_detail(detail, no_html=false, options={})
393
    multiple = false
394
    show_diff = false
395
    no_details = false
396

397 398
    case detail.property
    when 'attr'
399 400
      field = detail.prop_key.to_s.gsub(/\_id$/, "")
      label = l(("field_" + field).to_sym)
jplang's avatar
jplang committed
401 402
      case detail.prop_key
      when 'due_date', 'start_date'
403 404
        value = format_date(detail.value.to_date) if detail.value
        old_value = format_date(detail.old_value.to_date) if detail.old_value
405

jplang's avatar
jplang committed
406 407
      when 'project_id', 'status_id', 'tracker_id', 'assigned_to_id',
            'priority_id', 'category_id', 'fixed_version_id'
408 409
        value = find_name_by_reflection(field, detail.value)
        old_value = find_name_by_reflection(field, detail.old_value)
410

jplang's avatar
jplang committed
411
      when 'estimated_hours'
412 413
        value = l_hours_short(detail.value.to_f) unless detail.value.blank?
        old_value = l_hours_short(detail.old_value.to_f) unless detail.old_value.blank?
414

jplang's avatar
jplang committed
415
      when 'parent_id'
416 417 418
        label = l(:field_parent_issue)
        value = "##{detail.value}" unless detail.value.blank?
        old_value = "##{detail.old_value}" unless detail.old_value.blank?
419

jplang's avatar
jplang committed
420
      when 'is_private'
jplang's avatar
jplang committed
421 422
        value = l(detail.value == "0" ? :general_text_No : :general_text_Yes) unless detail.value.blank?
        old_value = l(detail.old_value == "0" ? :general_text_No : :general_text_Yes) unless detail.old_value.blank?
423 424 425

      when 'description'
        show_diff = true
426 427
      end
    when 'cf'
428
      custom_field = detail.custom_field
429 430
      if custom_field
        label = custom_field.name
431 432 433
        if custom_field.format.class.change_no_details
          no_details = true
        elsif custom_field.format.class.change_as_diff
434 435 436 437 438 439
          show_diff = true
        else
          multiple = custom_field.multiple?
          value = format_value(detail.value, custom_field) if detail.value
          old_value = format_value(detail.old_value, custom_field) if detail.old_value
        end
440
      end
441 442
    when 'attachment'
      label = l(:label_attachment)
443 444
    when 'relation'
      if detail.value && !detail.old_value
445
        rel_issue = Issue.visible.find_by_id(detail.value)
446
        value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.value}" :
447
                  (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
448
      elsif detail.old_value && !detail.value
449
        rel_issue = Issue.visible.find_by_id(detail.old_value)
450
        old_value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.old_value}" :
451
                          (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
452
      end
453 454
      relation_type = IssueRelation::TYPES[detail.prop_key]
      label = l(relation_type[:name]) if relation_type
455
    end
456 457
    call_hook(:helper_issues_show_detail_after_setting,
              {:detail => detail, :label => label, :value => value, :old_value => old_value })
458

459 460 461
    label ||= detail.prop_key
    value ||= detail.value
    old_value ||= detail.old_value
462

jplang's avatar
jplang committed
463
    unless no_html
464 465
      label = content_tag('strong', label)
      old_value = content_tag("i", h(old_value)) if detail.old_value
466 467 468
      if detail.old_value && detail.value.blank? && detail.property != 'relation'
        old_value = content_tag("del", old_value)
      end
469 470
      if detail.property == 'attachment' && value.present? &&
          atta = detail.journal.journalized.attachments.detect {|a| a.id == detail.prop_key.to_i}
471
        # Link to the attachment if it has not been removed
472 473
        value = link_to_attachment(atta, only_path: options[:only_path])
        if options[:only_path] != false
474
          value += ' '
475
          value += link_to_attachment atta, class: 'icon-only icon-download', title: l(:button_download), download: true
476
        end
477 478 479
      else
        value = content_tag("i", h(value)) if value
      end
480
    end
481

482 483 484
    if no_details
      s = l(:text_journal_changed_no_detail, :label => label).html_safe
    elsif show_diff
485 486
      s = l(:text_journal_changed_no_detail, :label => label)
      unless no_html
487
        diff_link = link_to 'diff',
488
          diff_journal_url(detail.journal_id, :detail_id => detail.id, :only_path => options[:only_path]),
489 490 491
          :title => l(:label_view_diff)
        s << " (#{ diff_link })"
      end
492
      s.html_safe
jplang's avatar
jplang committed
493
    elsif detail.value.present?
494 495
      case detail.property
      when 'attr', 'cf'
jplang's avatar
jplang committed
496
        if detail.old_value.present?
497
          l(:text_journal_changed, :label => label, :old => old_value, :new => value).html_safe
498 499
        elsif multiple
          l(:text_journal_added, :label => label, :value => value).html_safe
500
        else
501
          l(:text_journal_set_to, :label => label, :value => value).html_safe
502
        end
503
      when 'attachment', 'relation'
504
        l(:text_journal_added, :label => label, :value => value).html_safe
505 506
      end
    else
507
      l(:text_journal_deleted, :label => label, :old => old_value).html_safe
508
    end
jplang's avatar
jplang committed
509
  end
510 511 512

  # Find the name of an associated record stored in the field attribute
  def find_name_by_reflection(field, id)
513 514 515
    unless id.present?
      return nil
    end
516 517
    @detail_value_name_by_reflection ||= Hash.new do |hash, key|
      association = Issue.reflect_on_association(key.first.to_sym)
518
      name = nil
519
      if association
520
        record = association.klass.find_by_id(key.last)
521
        if record
522
          name = record.name.force_encoding('UTF-8')
523
        end
524
      end
525
      hash[key] = name
526
    end
527
    @detail_value_name_by_reflection[[field, id]]
528
  end
529

530 531 532 533 534 535 536 537 538 539 540 541 542
  # Renders issue children recursively
  def render_api_issue_children(issue, api)
    return if issue.leaf?
    api.array :children do
      issue.children.each do |child|
        api.issue(:id => child.id) do
          api.tracker(:id => child.tracker_id, :name => child.tracker.name) unless child.tracker.nil?
          api.subject child.subject
          render_api_issue_children(child, api)
        end
      end
    end
  end
jplang's avatar
jplang committed
543
end