Project

General

Profile

Patch #30919 » group_issues_custom_fields.patch

port of patches to latest master branch - philippe lhardy, 2024-01-20 12:59

View differences:

app/controllers/projects_controller.rb
203 203
    @issue_category ||= IssueCategory.new
204 204
    @member ||= @project.members.new
205 205
    @trackers = Tracker.sorted.to_a
206
    @cfs=AttributeGroup.joins(:custom_fields).joins(:tracker).
207
      where(project_id: @project, tracker_id: @trackers, :custom_fields => {id: @project.all_issue_custom_fields.pluck(:id)}).
208
      pluck("trackers.id", "id", "name", "position","attribute_group_fields.id", "attribute_group_fields.position",
209
        "custom_fields.id", "custom_fields.name", "custom_fields.position").sort_by{|x| [x[3], x[5]]}
206 210

  
207 211
    @version_status = params[:version_status] || 'open'
208 212
    @version_name = params[:version_name]
......
233 237
    end
234 238
  end
235 239

  
240
  def groupissuescustomfields
241
    # clean invalid values: invalid cfs, empty cf lists, empty groups
242
    group_issues_custom_fields = (JSON.parse params[:group_issues_custom_fields]).
243
      each{|tid,v| v.replace(v.select{|k,v| v["cfs"] ? v["cfs"].delete_if{|k,v| @project.all_issue_custom_fields.pluck(:id).include?(v)} : v})}.
244
      each{|tid,v| v.delete_if{|k,v| v["cfs"].blank?}}.
245
      delete_if{|k,v| v.blank?}
246

  
247
    groups = AttributeGroup.where(project_id: @project.id).collect(&:id)
248
    fields = AttributeGroupField.where(attribute_group_id: groups).collect(&:id)
249
    group_issues_custom_fields.each do |tid,v|
250
      v.each do |gp, g|
251
        gid = groups.shift
252
        if gid.nil?
253
          gid=AttributeGroup.create(project_id: @project.id, tracker_id: tid, name: g["name"].nil? ? nil : g["name"], position: gp).id
254
        else
255
          AttributeGroup.update(gid, project_id: @project.id, tracker_id: tid, name: g["name"].nil? ? nil : g["name"], position: gp)
256
        end
257
        g['cfs'].each do |cfp, cf|
258
          cfid = fields.shift
259
          if cfid.nil?
260
            AttributeGroupField.create(attribute_group_id: gid, custom_field_id: cf, position: cfp)
261
          else
262
            AttributeGroupField.update(cfid, attribute_group_id: gid, custom_field_id: cf, position: cfp)
263
          end
264
        end
265
      end
266
    end
267
    AttributeGroupField.where(id: fields).delete_all
268
    AttributeGroup.where(id: groups).destroy_all
269
    flash[:notice] = l(:notice_successful_update)
270
    redirect_to settings_project_path(@project, :tab => 'groupissuescustomfields')
271
  end
272

  
236 273
  def archive
237 274
    unless @project.archive
238 275
      error = l(:error_can_not_archive_project)
app/helpers/issues_helper.rb
368 368
    r.to_html
369 369
  end
370 370

  
371
  def render_half_width_custom_fields_rows(issue)
372
    values = issue.visible_custom_field_values.reject {|value| value.custom_field.full_width_layout?}
373
    return if values.empty?
374

  
375
    half = (values.size / 2.0).ceil
376
    issue_fields_rows do |rows|
377
      values.each_with_index do |value, i|
378
        m = (i < half ? :left : :right)
379
        rows.send m, custom_field_name_tag(value.custom_field), custom_field_value_tag(value), :class => value.custom_field.css_classes
380
      end
381
    end
382
  end
383

  
384
  def render_full_width_custom_fields_rows(issue)
385
    values = issue.visible_custom_field_values.select {|value| value.custom_field.full_width_layout?}
386
    return if values.empty?
387

  
371
  def group_by_keys(project_id, tracker_id, custom_field_values)
372
    keys_grouped = AttributeGroupField.joins(:attribute_group).
373
      where(:attribute_groups => {project_id: project_id, tracker_id: tracker_id}).
374
      order("attribute_groups.position", :position).pluck(:name, :custom_field_id).group_by(&:shift)
375
    custom_fields_grouped = { nil => (keys_grouped[nil].nil? ? [] :
376
      keys_grouped[nil].map{|n| custom_field_values.select{|x| x.custom_field[:id] == n[0]}}.flatten) |
377
      custom_field_values.select{|y| ! keys_grouped.values.flatten.include?(y.custom_field[:id])}}
378
    keys_grouped.reject{|k,v| k == nil}.each{|k,v| custom_fields_grouped[k] =
379
      v.map{|n| custom_field_values.select{|x| x.custom_field[:id] == n[0]}}.flatten}
380
    custom_fields_grouped
381
  end
382

  
383
  def render_custom_fields_rows(issue)
388 384
    s = ''.html_safe
389
    values.each_with_index do |value, i|
390
      attr_value_tag = custom_field_value_tag(value)
391
      next if attr_value_tag.blank?
392

  
393
      content =
394
        content_tag('hr') +
395
        content_tag('p', content_tag('strong', custom_field_name_tag(value.custom_field) )) +
396
        content_tag('div', attr_value_tag, class: 'value')
397
      s << content_tag('div', content, class: "#{value.custom_field.css_classes} attribute")
385
    group_by_keys(issue.project_id, issue.tracker_id, issue.visible_custom_field_values).
386
      each do |title, values|
387
      if values.present?
388
        s << content_tag('h4', title, :style => 'background: #0001; padding: 0.3em;') unless title.nil?
389
        while values.present?
390
          unless values[0].custom_field.full_width_layout?
391
            lr_values = []
392
            while values.present? && ! values[0].custom_field.full_width_layout?
393
              lr_values += [ values.shift ]
394
            end
395
            half = (lr_values.size / 2.0).ceil
396
            s << issue_fields_rows do |rows|
397
              lr_values.each_with_index do |value, i|
398
                m = (i < half ? :left : :right)
399
                rows.send m, custom_field_name_tag(value.custom_field), custom_field_value_tag(value), :class => value.custom_field.css_classes
400
              end
401
            end
402
          else
403
            while values.present? && values[0].custom_field.full_width_layout?
404
              value=values.shift
405
              content = content_tag('div', custom_field_name_tag(value.custom_field) + ":", :class => 'label') +
406
                        content_tag('div', custom_field_value_tag(value), :class => 'value')
407
              content = content_tag('div', content, :class => "#{value.custom_field.css_classes} attribute")
408
              s << content_tag('div', content, :class => 'splitcontent')
409
            end
410
          end
411
        end
412
      end
398 413
    end
399 414
    s
400 415
  end
......
457 472
        end
458 473
      end
459 474
    end
460
    issue.visible_custom_field_values(user).each do |value|
461
      cf_value = show_value(value, false)
462
      next if cf_value.blank?
463

  
464
      if html
465
        items << content_tag('strong', "#{value.custom_field.name}: ") + cf_value
466
      else
467
        items << "#{value.custom_field.name}: #{cf_value}"
475
    group_by_keys(issue.project_id, issue.tracker_id, issue.visible_custom_field_values(user)).each do |title, values|
476
      if values.present?
477
        unless title.nil?
478
          if html
479
            items << content_tag('strong', "#{title}")
480
          else
481
            items << "#{title}"
482
          end
483
        end
484
        values.each do |value|
485
          cf_value = show_value(value, false)
486
          if html
487
            items << content_tag('strong', "#{value.custom_field.name}: ") + cf_value
488
          else
489
            items << "#{value.custom_field.name}: #{cf_value}"
490
          end
491
        end
468 492
      end
469 493
    end
470 494
    items
......
473 497
  def render_email_issue_attributes(issue, user, html=false)
474 498
    items = email_issue_attributes(issue, user, html)
475 499
    if html
476
      content_tag('ul', items.map{|s| content_tag('li', s)}.join("\n").html_safe, :class => "details")
500
      content_tag('ul', items.select{|s| s.is_a? String}.map{|s| content_tag('li', s)}.join("\n").html_safe, :class => "details") + "\n" +
501
        items.select{|s| !s.is_a? String}.map{|item| content_tag('div', item.shift) + "\n" +
502
        content_tag('ul', item.map{|s| content_tag('li', s)}.join("\n").html_safe, :class => "details")}.join("\n").html_safe
477 503
    else
478
      items.map{|s| "* #{s}"}.join("\n")
504
      items.select{|s| s.is_a? String}.map{|s| "* #{s}"}.join("\n") + "\n" +
505
        items.select{|s| !s.is_a? String}.map{|item| "#{item.shift}\n" + item.map{|s| "* #{s}"}.join("\n")}.join("\n")
479 506
    end
480 507
  end
481 508

  
app/helpers/projects_helper.rb
39 39
        {:name => 'boards', :action => :manage_boards,
40 40
         :partial => 'projects/settings/boards', :label => :label_board_plural},
41 41
        {:name => 'activities', :action => :manage_project_activities,
42
         :partial => 'projects/settings/activities', :label => :label_time_tracking}
42
         :partial => 'projects/settings/activities', :label => :label_time_tracking},
43
        {:name => 'groupissuescustomfields', :action => :edit_project,
44
         :partial => 'projects/settings/groupissuescustomfields', :label => :grouped_cf}
43 45
      ]
44 46
    tabs.
45 47
      select {|tab| User.current.allowed_to?(tab[:action], @project)}.
app/models/attribute_group.rb
1
class AttributeGroup < ActiveRecord::Base
2
  belongs_to :project
3
  belongs_to :tracker
4
  has_many :attribute_group_fields, :dependent => :delete_all
5
  has_many :custom_fields, :through => :attribute_group_fields
6
  acts_as_positioned
7

  
8
  scope :sorted, lambda { order(:position) }
9
end
app/models/attribute_group_field.rb
1
class AttributeGroupField < ActiveRecord::Base
2
  belongs_to :attribute_group
3
  belongs_to :custom_field
4
  acts_as_positioned
5

  
6
  scope :sorted, lambda { order(:position) }
7
end
app/models/custom_field.rb
26 26
           :class_name => 'CustomFieldEnumeration',
27 27
           :dependent => :delete_all
28 28
  has_many :custom_values, :dependent => :delete_all
29
  has_many :attribute_group_fields, :dependent => :delete_all
29 30
  has_and_belongs_to_many :roles,
30 31
                          :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}",
31 32
                          :foreign_key => "custom_field_id"
app/models/project.rb
55 55
  has_many :changesets, :through => :repository
56 56
  has_one :wiki, :dependent => :destroy
57 57
  # Custom field for the project issues
58
  has_many :attribute_groups, :dependent => :destroy
59
  has_many :attribute_group_fields, :through => :attribute_groups
58 60
  has_and_belongs_to_many :issue_custom_fields,
59 61
                          lambda {order(:position)},
60 62
                          :class_name => 'IssueCustomField',
app/models/tracker.rb
36 36
  has_and_belongs_to_many :custom_fields, :class_name => 'IssueCustomField',
37 37
                          :join_table => "#{table_name_prefix}custom_fields_trackers#{table_name_suffix}",
38 38
                          :association_foreign_key => 'custom_field_id'
39
  has_many :attribute_groups, :dependent => :destroy
40
  has_many :attribute_group_fields, :through => :attribute_groups
39 41
  acts_as_positioned
40 42

  
41 43
  validates_presence_of :default_status
app/views/issues/_form_custom_fields.html.erb
1
<% custom_field_values = @issue.editable_custom_field_values %>
2
<% custom_field_values_full_width = custom_field_values.select { |value| value.custom_field.full_width_layout? } %>
3
<% custom_field_values -= custom_field_values_full_width %>
4

  
5
<% if custom_field_values.present? %>
6
<div class="splitcontent">
7
<div class="splitcontentleft">
8
<% i = 0 %>
9
<% split_on = (custom_field_values.size / 2.0).ceil - 1 %>
10
<% custom_field_values.each do |value| %>
11
  <p><%= custom_field_tag_with_label :issue, value, :required => @issue.required_attribute?(value.custom_field_id) %></p>
12
<% if i == split_on -%>
13
</div><div class="splitcontentright">
14
<% end -%>
15
<% i += 1 -%>
16
<% end -%>
17
</div>
18
</div>
19
<% end %>
20

  
21
<% custom_field_values_full_width.each do |value| %>
22
  <p><%= custom_field_tag_with_label :issue, value, :required => @issue.required_attribute?(value.custom_field_id) %></p>
23
  <%= wikitoolbar_for "issue_custom_field_values_#{value.custom_field_id}", preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) if value.custom_field.full_text_formatting? %>
1
<% group_by_keys(@issue.project_id, @issue.tracker_id, @issue.editable_custom_field_values).each do |title,values| %>
2
  <% if values.present? %>
3
    <%= content_tag('h4', title, :style => 'background: #0001; padding: 0.3em;') unless title.nil? %>
4
    <% while values.present? %>
5
      <% if values[0].custom_field.full_width_layout? %>
6
        <% while values.present? && values[0].custom_field.full_width_layout? %>
7
          <% value = values.shift %>
8
          <p><%= custom_field_tag_with_label :issue, value, :required => @issue.required_attribute?(value.custom_field_id) %></p>
9
          <%= wikitoolbar_for "issue_custom_field_values_#{value.custom_field_id}", preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) if value.custom_field.full_text_formatting? %>
10
        <% end %>
11
      <% else %>
12
        <div class="splitcontent">
13
          <% lr_values = [] %>
14
          <% while values.present? && ! values[0].custom_field.full_width_layout? %>
15
            <% lr_values += [ values.shift ] %>
16
          <% end %>
17
          <div class="splitcontentleft">
18
            <% i = 0 %>
19
            <% split_on = (lr_values.size / 2.0).ceil - 1 %>
20
            <% lr_values.each do |value| %>
21
              <p><%= custom_field_tag_with_label :issue, value, :required => @issue.required_attribute?(value.custom_field_id) %></p>
22
              <%= wikitoolbar_for "issue_custom_field_values_#{value.custom_field_id}", preview_issue_path(:project_id => @issue.project, :issue_id => @issue.id) if value.custom_field.full_text_formatting? %>
23
              <% if i == split_on %>
24
                </div><div class="splitcontentright">
25
              <% end %>
26
              <% i += 1 %>
27
            <% end %>
28
          </div>
29
        </div>
30
      <% end %>
31
    <% end %>
32
  <% end %>
24 33
<% end %>
app/views/issues/show.html.erb
77 77
    rows.right l(:label_spent_time), issue_spent_hours_details(@issue), :class => 'spent-time'
78 78
  end
79 79
end %>
80
<%= render_half_width_custom_fields_rows(@issue) %>
80
<%= render_custom_fields_rows(@issue) %>
81 81
<%= call_hook(:view_issues_show_details_bottom, :issue => @issue) %>
82 82
</div>
83 83

  
......
100 100
  <%= link_to_attachments @issue, :thumbnails => true %>
101 101
<% end %>
102 102

  
103
<%= render_full_width_custom_fields_rows(@issue) %>
104

  
105 103
<%= call_hook(:view_issues_show_description_bottom, :issue => @issue) %>
106 104

  
107 105
<% if !@issue.leaf? || User.current.allowed_to?(:manage_subtasks, @project) %>
app/views/projects/settings/_groupissuescustomfields.html.erb
1
<p><%= select_tag "tracker_id", options_from_collection_for_select(@project.trackers.collect, "id", "name"), {:required => true, :onchange => "refresh_trackers(this);"} %><p>
2

  
3
<div id='custom_fields_form'>
4
  <% @project.trackers.each do |t| %>
5
  <div id='tracker_<%= t.id %>' class='tracker'>
6
    <span style="color: #888;"><%= l(:changed_cf_position) %></span>
7
    <ul class='sortable_items sortable_tracker-<%= t.id %> nil not_a_group'>
8
      <% @cfs.select{|x| x[0]==t.id && x[2]==nil}.each do |x| %>
9
      <li id="<%= x[6] %>" pos="<%= x[8] %>"><%= x[7] %></li>
10
      <% end %>
11
    </ul>
12
    <span style="color: #888;"><%= l(:global_cf_position) %></span>
13
    <ul class="sortable_items sortable_tracker-<%= t.id %> unsorted not_a_group">
14
      <% Issue.new(project_id: @project.id, tracker_id: t.id).custom_field_values.
15
           select{|x| ! @cfs.select{|c| c[0]==t.id}.map{|c| c[6]}.include?(x.custom_field_id)}.
16
           collect{|x| [x.custom_field.id, x.custom_field.name, x.custom_field.position]}.each do |x| %>
17
      <li id="<%= x[0] %>" pos="<%= x[2] %>"><%= x[1] %></li>
18
      <% end %>
19
    </ul>
20
    <span style="color: #888;"><%= l(:grouped_cf) %></span>
21
    <div class='sortable_groups groups_tracker-<%= t.id %>'>
22
      <% @cfs.select{|x| x[0]==t.id && x[2]!=nil}.map{|x| x[2]}.uniq.each do |g| %>
23
      <div class="sortable_group">
24
        <input type="text" value="<%= g %>" style="background: #ddf; padding: 5px; width: 360px"/>
25
        <img src="/images/delete.png" onclick="remove_label(this, 'tracker-<%= t.id %>');"/>
26
        <ul class='sortable_items sortable_tracker-<%= t.id %>'>
27
          <% @cfs.select{|x| x[0]==t.id && x[2]==g}.each do |x| %>
28
          <li id="<%= x[6] %>" pos="<%= x[8] %>"><%= x[7] %></li>
29
          <% end %>
30
        </ul>
31
      </div>
32
      <% end %>
33
      <div>
34
        <input type="text" value="" style="background: #ddf; padding: 5px; width: 370px"/>
35
        <img src="/images/add.png" style="" onclick="add_label(this, 'tracker-<%= t.id %>');"/>
36
      </div>
37
    </div>
38
  </div>
39
  <% end %>
40
<%= form_for @project, :url => { :action => 'groupissuescustomfields', :id => @project },
41
            :html => {:id => 'groupissuescustomfields-form',
42
                      :method => :post} do |f| %>
43

  
44
<%= hidden_field :group_issues_custom_fields, '', :id => 'group_issues_custom_fields', :name => 'group_issues_custom_fields' %>
45
<p><%= submit_tag l(:button_save), :onclick => "fill_json_data();" %></p>
46
<% end %>
47
</div>
48

  
49
<script>
50
function init_sortables(labelclass) {
51
  $( '.groups_' + labelclass ).sortable({
52
    items: 'div.sortable_group',
53
    connectWith: '.groups_' + labelclass,
54
    start: function(event, ui) {
55
      ui.placeholder.height(ui.item.height());
56
    },
57
    axis: 'y'
58
  });
59
  $( '.sortable_' + labelclass ).sortable({
60
    connectWith: '.sortable_' + labelclass,
61
    update: function(event, ui) {
62
      if (ui.item.parent().hasClass('unsorted')) {
63
        ui.item.parent().prepend(ui.item);
64
        ui.item.parent().children('li').each(function () {
65
          if (parseInt($(this).attr('pos')) < parseInt(ui.item.attr('pos'))) {
66
            ui.item.insertAfter(this);
67
          }
68
        });
69
      }
70
    },
71
    axis: 'y'
72
  }).disableSelection();
73
}
74
function refresh_trackers(tracker) {
75
  $('.tracker').hide();
76
  $('#tracker_' + tracker.value).show();
77
}
78
function add_label(label, labelclass) {
79
  var old_label = $(label), new_label = $(label).parent().clone();
80
  new_label.children().first().val('').trigger('change');
81
  old_label.attr('src','/images/delete.png').attr('onclick','remove_label(this, "' + labelclass + '");');
82
  old_label.prev().width('360px');
83
  old_label.parent().addClass('ui-sortable-handle sortable_group');
84
  old_label.parent().sortable({
85
    connectWith: '.groups_' + labelclass
86
  }).disableSelection();
87
  old_label.parent().append('<ul id="' + labelclass + '_" class="sortable_items sortable_' + labelclass + '"/>');
88
  old_label.next().sortable({
89
    connectWith: '.sortable_' + labelclass
90
  }).disableSelection();
91
  old_label.parent().parent().append(new_label);
92
}
93
function remove_label(label, labelclass) {
94
  $(label).next().children().each(function () {
95
    var item = this;
96
    $('.unsorted.sortable_' + labelclass).prepend(this);
97
    $('.unsorted.sortable_' + labelclass).children('li').each(function () {
98
      if (parseInt($(this).attr('pos')) < parseInt($(item).attr('pos'))) {
99
        $(item).insertAfter(this);
100
      }
101
    });
102
  });
103
  $(label).parent().hide();
104
}
105
function fill_json_data() {
106
  var r = {}, gp = 0, cp = 0;
107
  $('#custom_fields_form').children('div').each(function () {
108
    var tracker_id = this.id.split('_').pop();
109
    gp = 0, cp = 0;
110

  
111
    // Group 'nil'
112
    r[tracker_id] = {};
113
    r[tracker_id][++gp] = {'cfs': {}};
114
    $(this).children('.nil').children().each(function () {
115
      cp++;
116
      r[tracker_id][gp]['cfs'][cp] = this.id;
117
    });
118
    if (! r[tracker_id][gp]['cfs'][1]) { delete r[tracker_id][gp]; }
119
    $(this).children('.sortable_groups').children().each(function () {
120
      if ($(this).children('ul').length) {
121
        r[tracker_id][++gp] = {
122
          'name': $(this).children('input').val(),
123
          'cfs': {}
124
        };
125
        cp = 0;
126
        $(this).children('ul').children().each(function () {
127
          r[tracker_id][gp]['cfs'][++cp] = this.id;
128
        });
129
        if (! r[tracker_id][gp]['cfs'][1]) { r[tracker_id][gp] = ''; }
130
      }
131
    });
132
    if ( ! r[tracker_id] ) { r[tracker_id] = ''; }
133
  });
134
  console.log(r);
135
  $('#group_issues_custom_fields').val(JSON.stringify(r)); }
136
$('#tracker_id').change();
137
<% @project.trackers.each do |t| %>
138
init_sortables('tracker-<%= t.id %>');
139
<% end %>
140
</script>
config/locales/en.yml
1366 1366
  description_issue_category_reassign: Choose issue category
1367 1367
  description_wiki_subpages_reassign: Choose new parent page
1368 1368
  text_repository_identifier_info: 'Only lower case letters (a-z), numbers, dashes and underscores are allowed.<br />Once saved, the identifier cannot be changed.'
1369
  grouped_cf: Grouped Custom Fields
1370
  global_cf_position: Global Custom Fields Position
1371
  changed_cf_position: Changed Custom Fields Position
1369 1372
  text_login_required_html: When not requiring authentication, public projects and their contents are openly available on the network. You can <a href="%{anonymous_role_path}">edit the applicable permissions</a>.
1370 1373
  label_login_required_yes: "Yes"
1371 1374
  label_login_required_no: "No, allow anonymous access to public projects"
config/locales/pt-BR.yml
1227 1227
  permission_view_news: Ver notícias
1228 1228
  label_no_preview_alternative_html: Visualização não disponível. Faça o %{link} do arquivo.
1229 1229
  label_no_preview_download: download
1230
  grouped_cf: Agrupar campos personalizados
1231
  global_cf_position: Posição global de campos personalizados
1232
  changed_cf_position: Alterar posição de campos personalizados
1230 1233
  setting_close_duplicate_issues: Fechar tarefas duplicadas automaticamente
1231 1234
  error_exceeds_maximum_hours_per_day: Não é possível registrar mais de %{max_hours} horas no mesmo dia (%{logged_hours} horas já foram registradas)
1232 1235
  setting_time_entry_list_defaults: Registro de horas padrão
config/routes.rb
137 137

  
138 138
    member do
139 139
      get 'settings(/:tab)', :action => 'settings', :as => 'settings'
140
      post 'groupissuescustomfields'
140 141
      match 'archive', :via => [:post, :put]
141 142
      match 'unarchive', :via => [:post, :put]
142 143
      match 'close', :via => [:post, :put]
db/migrate/20180913211420_create_attribute_groups.rb
1
class CreateAttributeGroups < ActiveRecord::Migration[4.2]
2
  def change
3
    create_table :attribute_groups do |t|
4
      t.references :project, index: true, foreign_key: true
5
      t.references :tracker, index: true, foreign_key: true
6
      t.string :name
7
      t.integer :position, :default => nil, :null => true
8

  
9
      t.timestamps null: false
10
    end
11
  end
12
end
db/migrate/20180913212008_create_attribute_group_fields.rb
1
class CreateAttributeGroupFields < ActiveRecord::Migration[4.2]
2
  def change
3
    create_table :attribute_group_fields do |t|
4
      t.references :attribute_group, index: true, foreign_key: true
5
      t.references :custom_field, index: true, foreign_key: true
6
      t.integer :position
7

  
8
      t.timestamps null: false
9
    end
10
  end
11
end
lib/redmine/export/pdf/issues_pdf_helper.rb
66 66
          left  << nil while left.size  < rows
67 67
          right << nil while right.size < rows
68 68

  
69
          custom_field_values = issue.visible_custom_field_values.reject {|value| value.custom_field.full_width_layout?}
70
          half = (custom_field_values.size / 2.0).ceil
71
          custom_field_values.each_with_index do |custom_value, i|
72
            (i < half ? left : right) << [custom_value.custom_field.name, show_value(custom_value, false)]
73
          end
74

  
75 69
          if pdf.get_rtl
76 70
            border_first_top = 'RT'
77 71
            border_last_top  = 'LT'
......
83 77
            border_first = 'L'
84 78
            border_last  = 'R'
85 79
          end
80
          border_middle_top  = 'T'
86 81

  
87 82
          rows = [left.size, right.size].max
88 83
          rows.times do |i|
......
105 100
                             (i == 0 ? border_first_top : border_first), '', 0, 0)
106 101
            pdf.SetFontStyle('', 9)
107 102
            pdf.RDMMultiCell(60, height, item ? item.last.to_s : "",
108
                             (i == 0 ? border_last_top : border_last), '', 0, 0)
103
                             (i == 0 ? border_middle_top : ""), '', 0, 0)
109 104

  
110 105
            item = right[i]
111 106
            pdf.SetFontStyle('B', 9)
112 107
            pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "",
113
                             (i == 0 ? border_first_top : border_first), '', 0, 0)
108
                             (i == 0 ? border_middle_top : ""), '', 0, 0)
114 109
            pdf.SetFontStyle('', 9)
115 110
            pdf.RDMMultiCell(60, height, item ? item.last.to_s : "",
116 111
                             (i == 0 ? border_last_top : border_last), '', 0, 2)
......
118 113
            pdf.set_x(base_x)
119 114
          end
120 115

  
116
          group_by_keys(issue.project_id, issue.tracker_id, issue.visible_custom_field_values).each do |title, values|
117
            if values.present?
118
              unless title.nil?
119
                pdf.RDMCell(35 + 155, 5, title, "LRT", 1)
120
              end
121

  
122
              while values.present?
123
                if values[0].custom_field.full_width_layout?
124
                  while values.present? && values[0].custom_field.full_width_layout?
125
                    heights = []
126
                    value = values.shift
127
                    pdf.SetFontStyle('B', 9)
128
                    heights << pdf.get_string_height(35, "#{value.custom_field.name}:")
129
                    pdf.SetFontStyle('',9)
130
                    heights << pdf.get_string_height(155, show_value(value, false))
131
                    height = heights.max
132

  
133
                    pdf.SetFontStyle('B',9)
134
                    pdf.RDMMultiCell(35, height, "#{value.custom_field.name}:", border_first, '', 0, 0)                               
135
                    pdf.SetFontStyle('', 9)
136
                    pdf.RDMMultiCell(155, height, show_value(value, false), border_last, '', 0, 2)
137

  
138
                    pdf.set_x(base_x)
139
                  end
140
                else
141
                  lr_values = []
142
                  while values.present? && ! values[0].custom_field.full_width_layout?
143
                    lr_values += [ values.shift ]
144
                  end
145

  
146
                  half = (lr_values.size / 2.0).ceil
147
                  left = []
148
                  right = []
149
                  lr_values.each_with_index do |custom_value, i|
150
                    (i < half ? left : right) << [custom_value.custom_field.name, show_value(custom_value, false)]
151
                  end
152

  
153
                  rows = left.size > right.size ? left.size : right.size
154
                  rows.times do |i|
155
                    heights = []
156
                    pdf.SetFontStyle('B', 9)
157
                    item = left[i]
158
                    heights << pdf.get_string_height(35, item ? "#{item.first}:" : "")
159
                    item = right[i]
160
                    heights << pdf.get_string_height(35, item ? "#{item.first}:" : "")
161
                    pdf.SetFontStyle('', 9)
162
                    item = left[i]
163
                    heights << pdf.get_string_height(60, item ? item.last.to_s  : "")
164
                    item = right[i]
165
                    heights << pdf.get_string_height(60, item ? item.last.to_s  : "")
166
                    height = heights.max
167

  
168
                    item = left[i]
169
                    pdf.SetFontStyle('B', 9)
170
                    pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "", border_first, '', 0, 0)
171
                    pdf.SetFontStyle('', 9)
172
                    pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", "", '', 0, 0)
173

  
174
                    item = right[i]
175
                    pdf.SetFontStyle('B', 9)
176
                    pdf.RDMMultiCell(35, height, item ? "#{item.first}:" : "",  "", '', 0, 0)
177
                    pdf.SetFontStyle('', 9)
178
                    pdf.RDMMultiCell(60, height, item ? item.last.to_s : "", border_last, '', 0, 2)
179

  
180
                    pdf.set_x(base_x)
181
                  end
182
                end
183
              end
184
            end
185
          end
186

  
121 187
          pdf.SetFontStyle('B', 9)
122 188
          pdf.RDMCell(35 + 155, 5, l(:field_description), "LRT", 1)
123 189
          pdf.SetFontStyle('', 9)
124 190

  
125 191
          # Set resize image scale
126 192
          pdf.set_image_scale(1.6)
193

  
127 194
          text = pdf_format_text(issue, :description)
128
          pdf.RDMwriteFormattedCell(35+155, 5, '', '', text, issue.attachments, "LRB")
195
          pdf.RDMwriteFormattedCell(35 + 155, 5, '', '', text, issue.attachments, "LRB")
129 196

  
130 197
          custom_field_values = issue.visible_custom_field_values.select {|value| value.custom_field.full_width_layout?}
131 198
          custom_field_values.each do |value|
......
134 201
            next if text.blank?
135 202

  
136 203
            pdf.SetFontStyle('B', 9)
137
            pdf.RDMCell(35+155, 5, value.custom_field.name, "LRT", 1)
204
            pdf.RDMCell(35 + 155, 5, value.custom_field.name, "LRT", 1)
138 205
            pdf.SetFontStyle('', 9)
139 206
            if is_html
140
              pdf.RDMwriteFormattedCell(35+155, 5, '', '', text, issue.attachments, "LRB")
207
              pdf.RDMwriteFormattedCell(35 + 155, 5, '', '', text, issue.attachments, "LRB")
141 208
            else
142
              pdf.RDMwriteHTMLCell(35+155, 5, '', '', text, issue.attachments, "LRB")
209
              pdf.RDMwriteHTMLCell(35 + 155, 5, '', '', text, issue.attachments, "LRB")
143 210
            end
144 211
          end
145 212

  
146 213
          unless issue.leaf?
147 214
            truncate_length = (!is_cjk? ? 90 : 65)
148 215
            pdf.SetFontStyle('B', 9)
149
            pdf.RDMCell(35+155, 5, l(:label_subtask_plural) + ":", "LTR")
216
            pdf.RDMCell(35 + 155, 5, l(:label_subtask_plural) + ":", "LTR")
150 217
            pdf.ln
151 218
            issue_list(issue.descendants.visible.sort_by(&:lft)) do |child, level|
152 219
              buf = "#{child.tracker} # #{child.id}: #{child.subject}".
......
177 244
              end
178 245
              buf = buf.truncate(truncate_length)
179 246
              pdf.SetFontStyle('', 8)
180
              pdf.RDMCell(35+155-60, 5, buf, border_first)
247
              pdf.RDMCell(35 + 155 - 60, 5, buf, border_first)
181 248
              pdf.SetFontStyle('B', 8)
182 249
              pdf.RDMCell(20, 5, relation.other_issue(issue).status.to_s, "")
183 250
              pdf.RDMCell(20, 5, format_date(relation.other_issue(issue).start_date), "")
lib/redmine/preparation.rb
36 36
        map.permission :view_project, {:projects => [:show, :bookmark], :activities => [:index]}, :public => true, :read => true
37 37
        map.permission :search_project, {:search => :index}, :public => true, :read => true
38 38
        map.permission :add_project, {:projects => [:new, :create]}, :require => :loggedin
39
        map.permission :edit_project, {:projects => [:settings, :edit, :update]}, :require => :member
39
        map.permission :edit_project, {:projects => [:settings, :edit, :update, :groupissuescustomfields]}, :require => :member
40 40
        map.permission :close_project, {:projects => [:close, :reopen]}, :require => :member, :read => true
41 41
        map.permission :delete_project, {:projects => :destroy}, :require => :member, :read => true
42 42
        map.permission :select_project_publicity, {}, :require => :member
public/stylesheets/application.css
1971 1971
	display: inline;
1972 1972
	opacity: 1;
1973 1973
}
1974

  
1975
/* Custom Field Groups */
1976
.sortable_groups {
1977
  background: #eee;
1978
  border: 1px solid #888;
1979
  width: 445px;
1980
  min-height: 30px;
1981
  margin: 5px;
1982
  padding: 5px;
1983
}
1984
.sortable_groups div {
1985
  background: #ddf;
1986
  border: 1px solid #888;
1987
  min-height: 30px;
1988
  margin: 5px;
1989
  padding: 5px;
1990
}
1991
.sortable_items {
1992
  background: #eee;
1993
  border: 1px solid #888;
1994
  width: 400px;
1995
  min-height: 30px;
1996
  margin: 5px;
1997
  padding: 5px;
1998
}
1999
.sortable_items li {
2000
  background: #ffffff;
2001
  border: 1px solid #888;
2002
  margin: 5px;
2003
  padding: 5px;
2004
  list-style-type: none;
2005
  font-size: 1.2em;
2006
}
2007
.not_a_group {
2008
  padding: 5px 27px;
2009
}
2010
.ui-sortable-handle:before {
2011
  content:url('/images/reorder.png');
2012
  margin-right: 5px;
2013
}
2014
.ui-sortable-placeholder {
2015
  background: #8888ff;
2016
  border: 1px solid #888;
2017
  visibility: visible;
2018
}
test/fixtures/attribute_group_fields.yml
1
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2

  
3
one:
4
  attribute_group_id:
5
  custom_field_id:
6
  position: 1
7

  
8
two:
9
  attribute_group_id:
10
  custom_field_id:
11
  position: 1
test/fixtures/attribute_groups.yml
1
# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2

  
3
one:
4
  project_id:
5
  tracker_id:
6
  name: MyString
7
  position: 1
8

  
9
two:
10
  project_id:
11
  tracker_id:
12
  name: MyString
13
  position: 1
(23-23/24)