0004-Add-workspace-functionality-to-reuse-roles-and-track.patch

for Redmine 4.0 (1/2) - Frederico Camara, 2019-11-21 13:20

Download (52.9 KB)

View differences:

app/controllers/admin_controller.rb
38 38
    @project_pages = Paginator.new @project_count, per_page_option, params['page']
39 39
    @projects = scope.limit(@project_pages.per_page).offset(@project_pages.offset).to_a
40 40

  
41
    @workspaces = Hash[Workspace.pluck(:id, :name)]
42

  
41 43
    render :action => "projects", :layout => false if request.xhr?
42 44
  end
43 45

  
app/controllers/workflows_controller.rb
24 24
  def index
25 25
    @roles = Role.sorted.select(&:consider_workflow?)
26 26
    @trackers = Tracker.sorted
27
    @workflow_counts = WorkflowTransition.group(:tracker_id, :role_id).count
27
    @workspaces = Workspace.sorted
28
    @workflow_counts = WorkflowTransition.group(:tracker_id, :role_id, :workspace_id).count
28 29
  end
29 30

  
30 31
  def edit
31 32
    find_trackers_roles_and_statuses_for_edit
32 33

  
33
    if request.post? && @roles && @trackers && params[:transitions]
34
    if request.post? && @roles && @trackers && @workspaces && params[:transitions]
34 35
      transitions = params[:transitions].deep_dup
35 36
      transitions.each do |old_status_id, transitions_by_new_status|
36 37
        transitions_by_new_status.each do |new_status_id, transition_by_rule|
37 38
          transition_by_rule.reject! {|rule, transition| transition == 'no_change'}
38 39
        end
39 40
      end
40
      WorkflowTransition.replace_transitions(@trackers, @roles, transitions)
41
      WorkflowTransition.replace_transitions(@trackers, @roles, transitions, @workspaces)
41 42
      flash[:notice] = l(:notice_successful_update)
42 43
      redirect_to_referer_or workflows_edit_path
43 44
      return
44 45
    end
45 46

  
46
    if @trackers && @roles && @statuses.any?
47
    if @trackers && @roles && @workspaces && @statuses.any?
47 48
      workflows = WorkflowTransition.
48
        where(:role_id => @roles.map(&:id), :tracker_id => @trackers.map(&:id)).
49
        where(:role_id => @roles.map(&:id), :tracker_id => @trackers.map(&:id), :workspace_id => @workspaces.map(&:id)).
49 50
        preload(:old_status, :new_status)
50 51
      @workflows = {}
51 52
      @workflows['always'] = workflows.select {|w| !w.author && !w.assignee}
......
57 58
  def permissions
58 59
    find_trackers_roles_and_statuses_for_edit
59 60

  
60
    if request.post? && @roles && @trackers && params[:permissions]
61
    if request.post? && @roles && @trackers && @workspaces && params[:permissions]
61 62
      permissions = params[:permissions].deep_dup
62 63
      permissions.each { |field, rule_by_status_id|
63 64
        rule_by_status_id.reject! {|status_id, rule| rule == 'no_change'}
64 65
      }
65
      WorkflowPermission.replace_permissions(@trackers, @roles, permissions)
66
      WorkflowPermission.replace_permissions(@trackers, @roles, permissions, @workspaces)
66 67
      flash[:notice] = l(:notice_successful_update)
67 68
      redirect_to_referer_or workflows_permissions_path
68 69
      return
69 70
    end
70 71

  
71
    if @roles && @trackers
72
    if @roles && @trackers && @workspaces
72 73
      @fields = (Tracker::CORE_FIELDS_ALL - @trackers.map(&:disabled_core_fields).reduce(:&)).map {|field| [field, l("field_"+field.sub(/_id$/, ''))]}
73 74
      @custom_fields = @trackers.map(&:custom_fields).flatten.uniq.sort
74
      @permissions = WorkflowPermission.rules_by_status_id(@trackers, @roles)
75
      @permissions = WorkflowPermission.rules_by_status_id(@trackers, @roles, @workspaces)
75 76
      @statuses.each {|status| @permissions[status.id] ||= {}}
76 77
    end
77 78
  end
......
79 80
  def copy
80 81
    @roles = Role.sorted.select(&:consider_workflow?)
81 82
    @trackers = Tracker.sorted
83
    @workspaces = Workspace.sorted
82 84

  
83 85
    if params[:source_tracker_id].blank? || params[:source_tracker_id] == 'any'
84 86
      @source_tracker = nil
......
90 92
    else
91 93
      @source_role = Role.find_by_id(params[:source_role_id].to_i)
92 94
    end
95
    if params[:source_workspace_id].blank? || params[:source_workspace_id] == 'any'
96
      @source_workspace = nil
97
    else
98
      @source_workspace = Workspace.find_by_id(params[:source_workspace_id].to_i)
99
    end
93 100
    @target_trackers = params[:target_tracker_ids].blank? ?
94 101
        nil : Tracker.where(:id => params[:target_tracker_ids]).to_a
95 102
    @target_roles = params[:target_role_ids].blank? ?
96 103
        nil : Role.where(:id => params[:target_role_ids]).to_a
104
    @target_workspaces = params[:target_workspace_ids].blank? ?
105
        nil : Workspace.where(:id => params[:target_workspace_ids]).to_a
97 106
    if request.post?
98
      if params[:source_tracker_id].blank? || params[:source_role_id].blank? || (@source_tracker.nil? && @source_role.nil?)
107
      if params[:source_tracker_id].blank? || params[:source_role_id].blank? || params[:source_workspace_id].blank? || (@source_tracker.nil? && @source_role.nil? && @source_workspace.nil?)
99 108
        flash.now[:error] = l(:error_workflow_copy_source)
100
      elsif @target_trackers.blank? || @target_roles.blank?
109
      elsif @target_trackers.blank? || @target_roles.blank? || @target_workspaces.blank?
101 110
        flash.now[:error] = l(:error_workflow_copy_target)
102 111
      else
103
        WorkflowRule.copy(@source_tracker, @source_role, @target_trackers, @target_roles)
112
        WorkflowRule.copy(@source_tracker, @source_role, @source_workspace, @target_trackers, @target_roles, @target_workspaces)
104 113
        flash[:notice] = l(:notice_successful_update)
105
        redirect_to workflows_copy_path(:source_tracker_id => @source_tracker, :source_role_id => @source_role)
114
        redirect_to workflows_copy_path(:source_tracker_id => @source_tracker, :source_role_id => @source_role, :source_workspace_id => @source_workspace)
106 115
      end
107 116
    end
108 117
  end
......
112 121
  def find_trackers_roles_and_statuses_for_edit
113 122
    find_roles
114 123
    find_trackers
124
    find_workspaces
115 125
    find_statuses
116 126
  end
117 127

  
......
135 145
    @trackers = nil if @trackers.blank?
136 146
  end
137 147

  
148
  def find_workspaces
149
    ids = Array.wrap(params[:workspace_id])
150
    if ids == ['all']
151
      @workspaces = Workspace.sorted.to_a
152
    elsif ids.present?
153
      @workspaces = Workspace.where(:id => ids).to_a
154
    end
155
    @workspaces = nil if @workspaces.blank?
156
  end
157

  
138 158
  def find_statuses
139 159
    @used_statuses_only = (params[:used_statuses_only] == '0' ? false : true)
140 160
    if @trackers && @used_statuses_only
app/controllers/workspaces_controller.rb
1
# Redmine - project management software
2
# Copyright (C) 2006-2017  Jean-Philippe Lang
3
#
4
# This program is free software; you can redistribute it and/or
5
# modify it under the terms of the GNU General Public License
6
# as published by the Free Software Foundation; either version 2
7
# of the License, or (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
#
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17

  
18
class WorkspacesController < ApplicationController
19
  layout 'admin'
20
  self.main_menu = false
21

  
22
  before_action :require_admin, :except => :index
23
  before_action :require_admin_or_api_request, :only => :index
24
  accept_api_auth :index
25

  
26
  def index
27
    @workspaces = Workspace.sorted.to_a
28
    respond_to do |format|
29
      format.html { render :layout => false if request.xhr? }
30
      format.api
31
    end
32
  end
33

  
34
  def new
35
    @workspace = Workspace.new
36
  end
37

  
38
  def create
39
    @workspace = Workspace.new
40
    @workspace.safe_attributes = params[:workspace]
41
    if @workspace.save
42
      flash[:notice] = l(:notice_successful_create)
43
      redirect_to workspaces_path
44
    else
45
      render :action => 'new'
46
    end
47
  end
48

  
49
  def edit
50
    @workspace = Workspace.find(params[:id])
51
  end
52

  
53
  def update
54
    @workspace = Workspace.find(params[:id])
55
    @workspace.safe_attributes = params[:workspace]
56
    if @workspace.save
57
      respond_to do |format|
58
        format.html {
59
          flash[:notice] = l(:notice_successful_update)
60
          redirect_to workspaces_path(:page => params[:page])
61
        }
62
        format.js { head 200 }
63
      end
64
    else
65
      respond_to do |format|
66
        format.html { render :action => 'edit' }
67
        format.js { head 422 }
68
      end
69
    end
70
  end
71

  
72
  def destroy
73
    unless Project.where(:workspace_id => params[:id]).any? || params[:id] == "1"
74
      Workspace.find(params[:id]).destroy
75
    else
76
      flash[:error] = l(:error_unable_delete_workspace)
77
    end
78
    redirect_to workspaces_path
79
  end
80
end
app/helpers/projects_helper.rb
95 95
    principals_options_for_select(assignable_users, project.default_assigned_to)
96 96
  end
97 97

  
98
  def project_workspace_options(project)
99
    grouped = Hash.new {|h,k| h[k] = []}
100
    Workspace.all.sorted.each do |workspace|
101
      grouped[workspace.name] = workspace.id
102
    end
103
    options_for_select(grouped, project.workspace_id)
104
  end
105

  
98 106
  def format_version_sharing(sharing)
99 107
    sharing = 'none' unless Version::VERSION_SHARINGS.include?(sharing)
100 108
    l("label_version_sharing_#{sharing}")
app/helpers/workflows_helper.rb
21 21
  def options_for_workflow_select(name, objects, selected, options={})
22 22
    option_tags = ''.html_safe
23 23
    multiple = false
24
    if selected 
24
    if selected
25 25
      if selected.size == objects.size
26 26
        selected = 'all'
27 27
      else
......
51 51
    options = [["", ""], [l(:label_readonly), "readonly"]]
52 52
    options << [l(:label_required), "required"] unless field_required?(field)
53 53
    html_options = {}
54
    
54

  
55 55
    if perm = permissions[status.id][name]
56
      if perm.uniq.size > 1 || perm.size < @roles.size * @trackers.size
56
      if perm.uniq.size > 1 || perm.size < @roles.size * @trackers.size * @workspaces.size
57 57
        options << [l(:label_no_change_option), "no_change"]
58 58
        selected = 'no_change'
59 59
      else
......
76 76

  
77 77
  def transition_tag(workflows, old_status, new_status, name)
78 78
    w = workflows.select {|w| w.old_status == old_status && w.new_status == new_status}.size
79
    
79

  
80 80
    tag_name = "transitions[#{ old_status.try(:id) || 0 }][#{new_status.id}][#{name}]"
81 81
    if old_status == new_status
82 82
      check_box_tag(tag_name, "1", true,
83 83
        {:disabled => true, :class => "old-status-#{old_status.try(:id) || 0} new-status-#{new_status.id}"})
84
    elsif w == 0 || w == @roles.size * @trackers.size
84
    elsif w == 0 || w == @roles.size * @trackers.size * @workspaces.size
85 85
      hidden_field_tag(tag_name, "0", :id => nil) +
86 86
      check_box_tag(tag_name, "1", w != 0,
87 87
            :class => "old-status-#{old_status.try(:id) || 0} new-status-#{new_status.id}")
app/models/issue.rb
655 655
    return {} if roles.empty?
656 656

  
657 657
    result = {}
658
    workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id)).to_a
658
    workflow_permissions = WorkflowPermission.where(:tracker_id => tracker_id, :old_status_id => status_id, :role_id => roles.map(&:id), :workspace_id => project.workspace_id).to_a
659 659
    if workflow_permissions.any?
660 660
      workflow_rules = workflow_permissions.inject({}) do |h, wp|
661 661
        h[wp.field_name] ||= {}
......
995 995
      initial_status,
996 996
      user.admin ? Role.all.to_a : user.roles_for_project(project),
997 997
      tracker,
998
      project.workspace_id,
998 999
      author == user,
999 1000
      assignee_transitions_allowed
1000 1001
    )
app/models/issue_status.rb
51 51
  end
52 52

  
53 53
  # Returns an array of all statuses the given role can switch to
54
  def new_statuses_allowed_to(roles, tracker, author=false, assignee=false)
55
    self.class.new_statuses_allowed(self, roles, tracker, author, assignee)
54
  def new_statuses_allowed_to(roles, tracker, workspace_id, author=false, assignee=false)
55
    self.class.new_statuses_allowed(self, roles, tracker, workspace_id, author, assignee)
56 56
  end
57 57
  alias :find_new_statuses_allowed_to :new_statuses_allowed_to
58 58

  
59
  def self.new_statuses_allowed(status, roles, tracker, author=false, assignee=false)
60
    if roles.present? && tracker
59
  def self.new_statuses_allowed(status, roles, tracker, workspace_id, author=false, assignee=false)
60
    if roles.present? && tracker && workspace_id
61 61
      status_id = status.try(:id) || 0
62 62

  
63 63
      scope = IssueStatus.
64 64
        joins(:workflow_transitions_as_new_status).
65
        where(:workflows => {:old_status_id => status_id, :role_id => roles.map(&:id), :tracker_id => tracker.id})
65
        where(:workflows => {:old_status_id => status_id, :role_id => roles.map(&:id), :tracker_id => tracker.id, :workspace_id => workspace_id})
66 66

  
67 67
      unless author && assignee
68 68
        if author || assignee
app/models/project.rb
40 40
  has_many :versions, :dependent => :destroy
41 41
  belongs_to :default_version, :class_name => 'Version'
42 42
  belongs_to :default_assigned_to, :class_name => 'Principal'
43
  belongs_to :workspace
43 44
  has_many :time_entries, :dependent => :destroy
44 45
  has_many :queries, :dependent => :delete_all
45 46
  has_many :documents, :dependent => :destroy
......
753 754
    'issue_custom_field_ids',
754 755
    'parent_id',
755 756
    'default_version_id',
757
    'workspace_id',
756 758
    'default_assigned_to_id'
757 759

  
758 760
  safe_attributes 'enabled_module_names',
app/models/role.rb
257 257
  end
258 258

  
259 259
  def copy_workflow_rules(source_role)
260
    WorkflowRule.copy(nil, source_role, nil, self)
260
    WorkflowRule.copy(nil, source_role, nil, nil, self, nil)
261 261
  end
262 262

  
263 263
  # Find all the roles that can be given to a project member
app/models/tracker.rb
115 115
  end
116 116

  
117 117
  def copy_workflow_rules(source_tracker)
118
    WorkflowRule.copy(source_tracker, nil, self, nil)
118
    WorkflowRule.copy(source_tracker, nil, nil, self, nil, nil)
119 119
  end
120 120

  
121 121
  # Returns the fields that are disabled for all the given trackers
app/models/workflow_permission.rb
20 20
  validates_presence_of :old_status
21 21
  validate :validate_field_name
22 22

  
23
  # Returns the workflow permissions for the given trackers and roles
23
  # Returns the workflow permissions for the given trackers, roles and workspaces
24 24
  # grouped by status_id
25 25
  #
26 26
  # Example:
27
  #   WorkflowPermission.rules_by_status_id trackers, roles
27
  #   WorkflowPermission.rules_by_status_id trackers, roles, workspaces
28 28
  #   # => {1 => {'start_date' => 'required', 'due_date' => 'readonly'}}
29
  def self.rules_by_status_id(trackers, roles)
30
    WorkflowPermission.where(:tracker_id => trackers.map(&:id), :role_id => roles.map(&:id)).inject({}) do |h, w|
29
  def self.rules_by_status_id(trackers, roles, workspaces)
30
    WorkflowPermission.where(:tracker_id => trackers.map(&:id), :role_id => roles.map(&:id), :workspace_id => workspaces.map(&:id)).inject({}) do |h, w|
31 31
      h[w.old_status_id] ||= {}
32 32
      h[w.old_status_id][w.field_name] ||= []
33 33
      h[w.old_status_id][w.field_name] << w.rule
......
35 35
    end
36 36
  end
37 37

  
38
  # Replaces the workflow permissions for the given trackers and roles
38
  # Replaces the workflow permissions for the given trackers, roles and workspaces
39 39
  #
40 40
  # Example:
41
  #   WorkflowPermission.replace_permissions trackers, roles, {'1' => {'start_date' => 'required', 'due_date' => 'readonly'}}
42
  def self.replace_permissions(trackers, roles, permissions)
41
  #   WorkflowPermission.replace_permissions trackers, roles, {'1' => {'start_date' => 'required', 'due_date' => 'readonly'}}, workspaces
42
  def self.replace_permissions(trackers, roles, permissions, workspaces)
43 43
    trackers = Array.wrap trackers
44 44
    roles = Array.wrap roles
45
    workspaces = Array.wrap workspaces
45 46

  
46 47
    transaction do
47 48
      permissions.each { |status_id, rule_by_field|
48 49
        rule_by_field.each { |field, rule|
49
          where(:tracker_id => trackers.map(&:id), :role_id => roles.map(&:id), :old_status_id => status_id, :field_name => field).destroy_all
50
          where(:tracker_id => trackers.map(&:id), :role_id => roles.map(&:id), :old_status_id => status_id, :field_name => field, :workspace_id => workspaces.map(&:id)).destroy_all
50 51
          if rule.present?
51 52
            trackers.each do |tracker|
52 53
              roles.each do |role|
53
                WorkflowPermission.create(:role_id => role.id, :tracker_id => tracker.id, :old_status_id => status_id, :field_name => field, :rule => rule)
54
                workspaces.each do |workspace|
55
                  WorkflowPermission.create(:role_id => role.id, :tracker_id => tracker.id, :old_status_id => status_id, :field_name => field, :rule => rule, :workspace_id => workspace.id)
56
                end
54 57
              end
55 58
            end
56 59
          end
app/models/workflow_rule.rb
22 22
  belongs_to :tracker
23 23
  belongs_to :old_status, :class_name => 'IssueStatus'
24 24
  belongs_to :new_status, :class_name => 'IssueStatus'
25
  belongs_to :workspace
25 26

  
26
  validates_presence_of :role, :tracker
27
  validates_presence_of :role, :tracker, :workspace
27 28

  
28 29
  # Copies workflows from source to targets
29
  def self.copy(source_tracker, source_role, target_trackers, target_roles)
30
    unless source_tracker.is_a?(Tracker) || source_role.is_a?(Role)
31
      raise ArgumentError.new("source_tracker or source_role must be specified, given: #{source_tracker.class.name} and #{source_role.class.name}")
30
  def self.copy(source_tracker, source_role, source_workspace, target_trackers, target_roles, target_workspaces)
31
    unless source_tracker.is_a?(Tracker) || source_role.is_a?(Role) || source_workspace.is_a?(Workspace)
32
      raise ArgumentError.new("source_tracker, source_role or source_workspace must be specified, given: #{source_tracker.class.name}, #{source_role.class.name} and #{source_workspace.class.name}")
32 33
    end
33 34

  
34 35
    target_trackers = [target_trackers].flatten.compact
35 36
    target_roles = [target_roles].flatten.compact
37
    target_workspaces = [target_workspaces].flatten.compact
36 38

  
37 39
    target_trackers = Tracker.sorted.to_a if target_trackers.empty?
38 40
    target_roles = Role.all.select(&:consider_workflow?) if target_roles.empty?
41
    target_workspaces = Workspace.sorted.to_a if target_workspaces.empty?
39 42

  
40 43
    target_trackers.each do |target_tracker|
41 44
      target_roles.each do |target_role|
42
        copy_one(source_tracker || target_tracker,
43
                   source_role || target_role,
44
                   target_tracker,
45
                   target_role)
45
        target_workspaces.each do |target_workspace|
46
          copy_one(source_tracker || target_tracker,
47
                     source_role || target_role,
48
                     source_workspace || target_workspace,
49
                     target_tracker,
50
                     target_role,
51
                     target_workspace)
52
        end
46 53
      end
47 54
    end
48 55
  end
49 56

  
50 57
  # Copies a single set of workflows from source to target
51
  def self.copy_one(source_tracker, source_role, target_tracker, target_role)
58
  def self.copy_one(source_tracker, source_role, source_workspace, target_tracker, target_role, target_workspace)
52 59
    unless source_tracker.is_a?(Tracker) && !source_tracker.new_record? &&
53 60
      source_role.is_a?(Role) && !source_role.new_record? &&
61
      source_workspace.is_a?(Workspace) && !source_workspace.new_record? &&
54 62
      target_tracker.is_a?(Tracker) && !target_tracker.new_record? &&
55
      target_role.is_a?(Role) && !target_role.new_record?
63
      target_role.is_a?(Role) && !target_role.new_record? &&
64
      target_workspace.is_a?(Workspace) && !target_workspace.new_record?
56 65

  
57 66
      raise ArgumentError.new("arguments can not be nil or unsaved objects")
58 67
    end
59 68

  
60
    if source_tracker == target_tracker && source_role == target_role
69
    if source_tracker == target_tracker && source_role == target_role && source_workspace == target_workspace
61 70
      false
62 71
    else
63 72
      transaction do
64
        where(:tracker_id => target_tracker.id, :role_id => target_role.id).delete_all
65
        connection.insert "INSERT INTO #{WorkflowRule.table_name} (tracker_id, role_id, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type)" +
66
                          " SELECT #{target_tracker.id}, #{target_role.id}, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type" +
73
        where(:tracker_id => target_tracker.id, :role_id => target_role.id, :workspace_id => target_workspace.id).delete_all
74
        connection.insert "INSERT INTO #{WorkflowRule.table_name} (tracker_id, role_id, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type, workspace_id)" +
75
                          " SELECT #{target_tracker.id}, #{target_role.id}, old_status_id, new_status_id, author, assignee, field_name, #{connection.quote_column_name 'rule'}, type, #{target_workspace.id}" +
67 76
                          " FROM #{WorkflowRule.table_name}" +
68
                          " WHERE tracker_id = #{source_tracker.id} AND role_id = #{source_role.id}"
77
                          " WHERE tracker_id = #{source_tracker.id} AND role_id = #{source_role.id} AND workspace_id = #{source_workspace.id}"
69 78
      end
70 79
      true
71 80
    end
app/models/workflow_transition.rb
18 18
class WorkflowTransition < WorkflowRule
19 19
  validates_presence_of :new_status
20 20

  
21
  def self.replace_transitions(trackers, roles, transitions)
21
  def self.replace_transitions(trackers, roles, transitions, workspaces)
22 22
    trackers = Array.wrap trackers
23 23
    roles = Array.wrap roles
24
    workspaces = Array.wrap workspaces
24 25

  
25 26
    transaction do
26
      records = WorkflowTransition.where(:tracker_id => trackers.map(&:id), :role_id => roles.map(&:id)).to_a
27
      records = WorkflowTransition.where(:tracker_id => trackers.map(&:id), :role_id => roles.map(&:id), :workspace_id => workspaces.map(&:id)).to_a
27 28

  
28 29
      transitions.each do |old_status_id, transitions_by_new_status|
29 30
        transitions_by_new_status.each do |new_status_id, transition_by_rule|
30 31
          transition_by_rule.each do |rule, transition|
31 32
            trackers.each do |tracker|
32 33
              roles.each do |role|
33
                w = records.select {|r|
34
                  r.old_status_id == old_status_id.to_i &&
35
                  r.new_status_id == new_status_id.to_i &&
36
                  r.tracker_id == tracker.id &&
37
                  r.role_id == role.id &&
38
                  !r.destroyed?
39
                }
34
                workspaces.each do |workspace|
35
                  w = records.select {|r|
36
                    r.old_status_id == old_status_id.to_i &&
37
                    r.new_status_id == new_status_id.to_i &&
38
                    r.tracker_id == tracker.id &&
39
                    r.role_id == role.id &&
40
                    r.workspace_id == workspace.id &&
41
                    !r.destroyed?
42
                  }
40 43

  
41
                if rule == 'always'
42
                  w = w.select {|r| !r.author && !r.assignee}
43
                else
44
                  w = w.select {|r| r.author || r.assignee}
45
                end
46
                if w.size > 1
47
                  w[1..-1].each(&:destroy)
48
                end
49
                w = w.first
50

  
51
                if transition == "1" || transition == true
52
                  unless w
53
                    w = WorkflowTransition.new(:old_status_id => old_status_id, :new_status_id => new_status_id, :tracker_id => tracker.id, :role_id => role.id)
54
                    records << w
55
                  end
56
                  w.author = true if rule == "author"
57
                  w.assignee = true if rule == "assignee"
58
                  w.save if w.changed?
59
                elsif w
60 44
                  if rule == 'always'
61
                    w.destroy
62
                  elsif rule == 'author'
63
                    if w.assignee
64
                      w.author = false
65
                      w.save if w.changed?
66
                    else
67
                      w.destroy
45
                    w = w.select {|r| !r.author && !r.assignee}
46
                  else
47
                    w = w.select {|r| r.author || r.assignee}
48
                  end
49
                  if w.size > 1
50
                    w[1..-1].each(&:destroy)
51
                  end
52
                  w = w.first
53

  
54
                  if transition == "1" || transition == true
55
                    unless w
56
                      w = WorkflowTransition.new(:old_status_id => old_status_id, :new_status_id => new_status_id, :tracker_id => tracker.id, :role_id => role.id, :workspace_id => workspace.id)
57
                      records << w
68 58
                    end
69
                  elsif rule == 'assignee'
70
                    if w.author
71
                      w.assignee = false
72
                      w.save if w.changed?
73
                    else
59
                    w.author = true if rule == "author"
60
                    w.assignee = true if rule == "assignee"
61
                    w.save if w.changed?
62
                  elsif w
63
                    if rule == 'always'
74 64
                      w.destroy
65
                    elsif rule == 'author'
66
                      if w.assignee
67
                        w.author = false
68
                        w.save if w.changed?
69
                      else
70
                        w.destroy
71
                      end
72
                    elsif rule == 'assignee'
73
                      if w.author
74
                        w.assignee = false
75
                        w.save if w.changed?
76
                      else
77
                        w.destroy
78
                      end
75 79
                    end
76 80
                  end
77 81
                end
app/models/workspace.rb
1
# Redmine - project management software
2
# Copyright (C) 2006-2017  Jean-Philippe Lang
3
#
4
# This program is free software; you can redistribute it and/or
5
# modify it under the terms of the GNU General Public License
6
# as published by the Free Software Foundation; either version 2
7
# of the License, or (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU General Public License for more details.
13
#
14
# You should have received a copy of the GNU General Public License
15
# along with this program; if not, write to the Free Software
16
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17

  
18
class Workspace < ActiveRecord::Base
19
  include Redmine::SafeAttributes
20

  
21
  before_destroy :check_integrity
22
  has_many :projects
23
  has_many :workflow_rules, :dependent => :delete_all
24
  acts_as_positioned
25

  
26
  validates_presence_of :name
27
  validates_uniqueness_of :name
28
  validates_length_of :name, :maximum => 30
29

  
30
  scope :sorted, lambda { order(:position) }
31

  
32
  safe_attributes 'name',
33
    'description',
34
    'position'
35

  
36
  def <=>(workspace)
37
    position <=> workspace.position
38
  end
39

  
40
  def to_s; name end
41

  
42
private
43
  def check_integrity
44
    raise Exception.new("Cannot delete workspace") if Project.where(:workspace_id => self.id).any?
45
  end
46
end
app/views/admin/projects.html.erb
23 23
  <th><%=l(:label_project)%></th>
24 24
  <th><%=l(:field_is_public)%></th>
25 25
  <th><%=l(:field_created_on)%></th>
26
  <th><%=l(:field_workspace)%></th>
26 27
  <th></th>
27 28
  </tr></thead>
28 29
  <tbody>
......
31 32
  <td class="name"><span><%= link_to_project_settings(project, {}, :title => project.short_description) %></span></td>
32 33
  <td><%= checked_image project.is_public? %></td>
33 34
  <td><%= format_date(project.created_on) %></td>
35
  <td><%= @workspaces[project.workspace_id] %></td>
34 36
  <td class="buttons">
35 37
    <%= link_to(l(:button_archive), archive_project_path(project, :status => params[:status]), :data => {:confirm => l(:text_are_you_sure)}, :method => :post, :class => 'icon icon-lock') unless project.archived? %>
36 38
    <%= link_to(l(:button_unarchive), unarchive_project_path(project, :status => params[:status]), :method => :post, :class => 'icon icon-unlock') if project.archived? %>
app/views/projects/_form.html.erb
19 19
    <p><%= label(:project, :parent_id, l(:field_parent)) %><%= parent_project_select_tag(@project) %></p>
20 20
<% end %>
21 21

  
22
<% if @project.safe_attribute? 'workspace_id' %>
23
<p><%= f.select :workspace_id, project_workspace_options(@project) %></p>
24
<% end %>
25

  
22 26
<% if @project.safe_attribute? 'inherit_members' %>
23 27
<p><%= f.check_box :inherit_members %></p>
24 28
<% end %>
app/views/workflows/copy.html.erb
3 3
<%= form_tag({}, :id => 'workflow_copy_form') do %>
4 4
<fieldset class="tabular box">
5 5
<legend><%= l(:label_copy_source) %></legend>
6
<p>
7
  <label><%= l(:label_role) %></label>
8
  <%= select_tag('source_role_id',
9
                  content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '') +
10
                  content_tag('option', "--- #{ l(:label_copy_same_as_target) } ---", :value => 'any') +
11
                  options_from_collection_for_select(@roles, 'id', 'name', @source_role && @source_role.id)) %>
12
</p>
6 13
<p>
7 14
  <label><%= l(:label_tracker) %></label>
8 15
  <%= select_tag('source_tracker_id',
......
11 18
                  options_from_collection_for_select(@trackers, 'id', 'name', @source_tracker && @source_tracker.id)) %>
12 19
</p>
13 20
<p>
14
  <label><%= l(:label_role) %></label>
15
  <%= select_tag('source_role_id',
21
  <label><%= l(:label_workspace) %></label>
22
  <%= select_tag('source_workspace_id',
16 23
                  content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '') +
17 24
                  content_tag('option', "--- #{ l(:label_copy_same_as_target) } ---", :value => 'any') +
18
                  options_from_collection_for_select(@roles, 'id', 'name', @source_role && @source_role.id)) %>
25
                  options_from_collection_for_select(@workspaces, 'id', 'name', @source_workspace && @source_workspace.id)) %>
19 26
</p>
20 27
</fieldset>
21 28

  
22 29
<fieldset class="tabular box">
23 30
<legend><%= l(:label_copy_target) %></legend>
31
<p>
32
  <label><%= l(:label_role) %></label>
33
  <%= select_tag 'target_role_ids',
34
                  content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '', :disabled => true) +
35
                  options_from_collection_for_select(@roles, 'id', 'name', @target_roles && @target_roles.map(&:id)), :multiple => true %>
36
</p>
24 37
<p>
25 38
  <label><%= l(:label_tracker) %></label>
26 39
  <%= select_tag 'target_tracker_ids',
......
28 41
                  options_from_collection_for_select(@trackers, 'id', 'name', @target_trackers && @target_trackers.map(&:id)), :multiple => true %>
29 42
</p>
30 43
<p>
31
  <label><%= l(:label_role) %></label>
32
  <%= select_tag 'target_role_ids',
44
  <label><%= l(:label_workspace) %></label>
45
  <%= select_tag 'target_workspace_ids',
33 46
                  content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '', :disabled => true) +
34
                  options_from_collection_for_select(@roles, 'id', 'name', @target_roles && @target_roles.map(&:id)), :multiple => true %>
47
                  options_from_collection_for_select(@workspaces, 'id', 'name', @target_workspaces && @target_workspaces.map(&:id)), :multiple => true %>
35 48
</p>
36 49
</fieldset>
37 50
<%= submit_tag l(:button_copy) %>
app/views/workflows/edit.html.erb
4 4

  
5 5
<div class="tabs">
6 6
  <ul>
7
    <li><%= link_to l(:label_status_transitions), workflows_edit_path(:role_id => @roles, :tracker_id => @trackers), :class => 'selected' %></li>
8
    <li><%= link_to l(:label_fields_permissions), workflows_permissions_path(:role_id => @roles, :tracker_id => @trackers) %></li>
7
    <li><%= link_to l(:label_status_transitions), workflows_edit_path(:role_id => @roles, :tracker_id => @trackers, :workspace_id => @workspaces, :used_statuses_only => @used_statuses_only ? 1 : 0), :class => 'selected' %></li>
8
    <li><%= link_to l(:label_fields_permissions), workflows_permissions_path(:role_id => @roles, :tracker_id => @trackers, :workspace_id => @workspaces, :used_statuses_only => @used_statuses_only ? 1 : 0) %></li>
9 9
  </ul>
10 10
</div>
11 11

  
......
23 23
  </label>
24 24
  <a href="#" data-expands="#tracker_id"><span class="toggle-multiselect"></span></a>
25 25

  
26
  <label><%=l(:label_workspace)%>:
27
  <%= options_for_workflow_select 'workspace_id[]', Workspace.sorted, @workspaces, :id => 'workspace_id', :class => 'expandable' %>
28
  </label>
29
  <a href="#" data-expands="#workspace_id"><span class="toggle-multiselect"></span></a>
26 30
  <%= submit_tag l(:button_edit), :name => nil %>
27 31

  
28 32
  <%= hidden_field_tag 'used_statuses_only', '0', :id => nil %>
......
31 35
</p>
32 36
<% end %>
33 37

  
34
<% if @trackers && @roles && @statuses.any? %>
38
<% if @trackers && @roles && @workspaces && @statuses.any? %>
35 39
  <%= form_tag({}, :id => 'workflow_form' ) do %>
36 40
    <%= @trackers.map {|tracker| hidden_field_tag 'tracker_id[]', tracker.id, :id => nil}.join.html_safe %>
37 41
    <%= @roles.map {|role| hidden_field_tag 'role_id[]', role.id, :id => nil}.join.html_safe %>
42
    <%= @workspaces.map {|workspace| hidden_field_tag 'workspace_id[]', workspace.id, :id => nil}.join.html_safe %>
38 43
    <%= hidden_field_tag 'used_statuses_only', params[:used_statuses_only], :id => nil %>
39 44
    <div class="autoscroll">
40 45
      <%= render :partial => 'form', :locals => {:name => 'always', :workflows => @workflows['always']} %>
app/views/workflows/index.html.erb
3 3
<% if @roles.empty? || @trackers.empty? %>
4 4
<p class="nodata"><%= l(:label_no_data) %></p>
5 5
<% else %>
6
<p><label><%=l(:label_workspace)%>:
7
<%= options_for_workflow_select 'workspace_id[]', Workspace.sorted, @workspaces, :id => 'workspace_id', :class => 'expandable', :onchange=>'refresh_table(this.value);' %>
8
</label></p>
6 9
<div class="autoscroll">
7
<table class="list">
10
<table class="list collapsed">
8 11
<thead>
9 12
    <tr>
10
    <th></th>
13
    <th onclick='expand_table();'><i><span class='tip_exp_off'><%= l(:button_expand_all) %></span><span class='tip_exp_on'><%= l(:button_collapse_all) %></span></i></th>
11 14
    <% @roles.each do |role| %>
12 15
    <th>
13 16
        <%= content_tag(role.builtin? ? 'em' : 'span', role.name) %>
......
20 23
<tr>
21 24
  <td class="name"><%= tracker.name %></td>
22 25
  <% @roles.each do |role| -%>
23
  <% count = @workflow_counts[[tracker.id, role.id]] || 0 %>
24 26
    <td>
27
      <% countall = 0 %>
28
      <% @workspaces.each do |workspace| %>
29
        <% count = @workflow_counts[[tracker.id, role.id, workspace.id]] || 0 %>
30
        <% countall += count %>
31
        <span class="ws ws<%= workspace.id%>" style="display: none;">
32
          <%= link_to((count > 0 ? count : content_tag(:span, nil, :class => 'icon-only icon-not-ok')),
33
                      {:action => 'edit', :role_id => role, :tracker_id => tracker, :workspace_id => workspace},
34
                      :title => l(:button_edit)) %>
35
        </span>
36
      <% end %>
37
      <span class="ws wsall" style="display: none;">
38
        <%= link_to((countall > 0 ? countall : content_tag(:span, nil, :class => 'icon-only icon-not-ok')),
39
                    {:action => 'edit', :role_id => role, :tracker_id => tracker, :workspace_id => 'all'},
40
                    :title => l(:button_edit)) %>
41
      </span>
25
      <%= link_to((count > 0 ? count : content_tag(:span, nil, :class => 'icon-only icon-not-ok')),
26
                  {:action => 'edit', :role_id => role, :tracker_id => tracker},
27
                  :title => l(:button_edit)) %>
28 42
    </td>
29 43
  <% end -%>
30 44
</tr>
......
32 46
</tbody>
33 47
</table>
34 48
</div>
49
  <script>
50
    function refresh_table(value) {
51
      $('.ws').css("display","none");
52
      $('.ws' + value).css("display","inline");
53
      collapse_table();
54
    }
55

  
56
    function expand_table() {
57
      $('table').toggleClass("collapsed");
58
      collapse_table();
59
    }
60

  
61
    function collapse_table() {
62
      i = $('#workspace_id').val();
63
      var x = !$('table').hasClass("collapsed");
64
      if (x) {
65
        $('.tip_exp_on').css("display", "");
66
        $('.tip_exp_off').css("display", "none");
67
      } else {
68
        $('.tip_exp_on').css("display", "none");
69
        $('.tip_exp_off').css("display", "");
70
      }
71
      $('th, td, tr').toggle(true);
72
      for (var j = 2; j <= $('thead > tr > th').length; j++) {
73
        if ($('tbody > tr > td:nth-child(' + j + ') > span:visible > a:not(:has(span))').length == 0) {
74
          $('tbody > tr > td:nth-child(' + j + ')').toggle(x);
75
          $('thead > tr > th:nth-child(' + j + ')').toggle(x);
76
        }
77
      }
78
      for (var j = 1; j <= $('tbody > tr').length; j++) {
79
        if ($('tbody > tr:nth-child(' + j + ') > td > span:visible > a:not(:has(span))').length == 0) {
80
          $('tbody > tr:nth-child(' + j + ')').toggle(x);
81
        }
82
      }
83
    }
84

  
85
    refresh_table($('#workspace_id').val());
86
  </script>
35 87
<% end %>
app/views/workflows/permissions.html.erb
4 4

  
5 5
<div class="tabs">
6 6
  <ul>
7
    <li><%= link_to l(:label_status_transitions), workflows_edit_path(:role_id => @roles, :tracker_id => @trackers) %></li>
8
    <li><%= link_to l(:label_fields_permissions), workflows_permissions_path(:role_id => @roles, :tracker_id => @trackers), :class => 'selected' %></li>
7
    <li><%= link_to l(:label_status_transitions), workflows_edit_path(:role_id => @roles, :tracker_id => @trackers, :workspace_id => @workspaces, :used_statuses_only => @used_statuses_only ? 1 : 0) %></li>
8
    <li><%= link_to l(:label_fields_permissions), workflows_permissions_path(:role_id => @roles, :tracker_id => @trackers, :workspace_id => @workspaces, :used_statuses_only => @used_statuses_only ? 1 : 0), :class => 'selected' %></li>
9 9
  </ul>
10 10
</div>
11 11

  
......
22 22
  <%= options_for_workflow_select 'tracker_id[]', Tracker.sorted, @trackers, :id => 'tracker_id', :class => 'expandable' %>
23 23
  </label>
24 24
  <a href="#" data-expands="#tracker_id"><span class="toggle-multiselect"></a>
25

  
26
  <label><%=l(:label_workspace)%>:
27
  <%= options_for_workflow_select 'workspace_id[]', Workspace.sorted, @workspaces, :id => 'workspace_id', :class => 'expandable' %>
28
  </label>
29
  <a href="#" data-expands="#workspace_id"><span class="toggle-multiselect"></span></a>
25 30

  
26 31
  <%= submit_tag l(:button_edit), :name => nil %>
27 32

  
......
30 35
</p>
31 36
<% end %>
32 37

  
33
<% if @trackers && @roles && @statuses.any? %>
38
<% if @trackers && @roles && @workspaces && @statuses.any? %>
34 39
  <%= form_tag({}, :id => 'workflow_form' ) do %>
35 40
    <%= @trackers.map {|tracker| hidden_field_tag 'tracker_id[]', tracker.id, :id => nil}.join.html_safe %>
36 41
    <%= @roles.map {|role| hidden_field_tag 'role_id[]', role.id, :id => nil}.join.html_safe %>
42
    <%= @workspaces.map {|workspace| hidden_field_tag 'workspace_id[]', workspace.id, :id => nil}.join.html_safe %>
37 43
    <%= hidden_field_tag 'used_statuses_only', params[:used_statuses_only], :id => nil %>
38 44
    <div class="autoscroll">
39 45
    <table class="list workflows fields_permissions">
app/views/workspaces/_form.html.erb
1
<%= error_messages_for 'workspace' %>
2

  
3
<div class="box tabular">
4
<p><%= f.text_field :name, :required => true %></p>
5
<p><%= f.text_field :description %></p>
6

  
7
<%= call_hook(:view_workspaces_form, :workspace => @workspace) %>
8
</div>
app/views/workspaces/edit.html.erb
1
<%= title [l(:label_workspace_plural), workspaces_path], @workspace.name %>
2

  
3
<%= labelled_form_for @workspace do |f| %>
4
  <%= render :partial => 'form', :locals => {:f => f} %>
5
  <%= submit_tag l(:button_save) %>
6
<% end %>
app/views/workspaces/index.api.rsb
1
api.array :workspaces do
2
  @workspaces.each do |workspace|
3
    api.workspace do
4
      api.id status.id
5
      api.name status.name
6
      api.description status.description
7
    end
8
  end
9
end
app/views/workspaces/index.html.erb
1
<div class="contextual">
2
<%= link_to l(:label_workspace_new), new_workspace_path, :class => 'icon icon-add' %>
3
</div>
4

  
5
<h2><%=l(:label_workspace_plural)%></h2>
6

  
7
<table class="list workspaces">
8
  <thead><tr>
9
  <th><%=l(:field_name)%></th>
10
  <th><%=l(:field_description)%></th>
11
  <th></th>
12
  </tr></thead>
13
  <tbody>
14
<% for workspace in @workspaces %>
15
  <tr class="<%= workspace.id == 1 ? "builtin" : "givable" %>">
16
  <td class="name"><%= link_to workspace.name, edit_workspace_path(workspace) %></td>
17
  <td class="description"><%= link_to workspace.description, edit_workspace_path(workspace) %></td>
18
  <td class="buttons">
19
    <%= reorder_handle(workspace) unless workspace.id == 1 %>
20
    <%= delete_link workspace_path(workspace) unless workspace.id == 1 %>
21
  </td>
22
  </tr>
23
<% end %>
24
  </tbody>
25
</table>
26

  
27
<% html_title(l(:label_workspace_plural)) -%>
28

  
29
<%= javascript_tag do %>
30
  $(function() { $("table.workspaces tbody").positionedItems({items: ".givable"}); });
31
<% end %>
app/views/workspaces/new.html.erb
1
<%= title [l(:label_workspace_plural), workspaces_path], l(:label_workspace_new) %>
2

  
3
<%= labelled_form_for @workspace do |f| %>
4
  <%= render :partial => 'form', :locals => {:f => f} %>
5
  <%= submit_tag l(:button_create) %>
6
<% end %>
config/locales/en.yml
588 588
  label_tracker_plural: Trackers
589 589
  label_tracker_all: All trackers
590 590
  label_tracker_new: New tracker
591
  label_workspace: Workspace
592
  label_workspace_plural: Workspaces
593
  label_workspace_new: New workspace
594
  error_unable_delete_workspace: Unable to delete workspace
595
  field_workspace: Workspace
591 596
  label_workflow: Workflow
592 597
  label_issue_status: Issue status
593 598
  label_issue_status_plural: Issue statuses
config/locales/pt-BR.yml
376 376
  label_tracker: Tipo de tarefa
377 377
  label_tracker_plural: Tipos de tarefas
378 378
  label_tracker_new: Novo tipo
379
  label_workspace: Espaço de trabalho
380
  label_workspace_plural: Espaços de trabalho
381
  label_workspace_new: Novo espaço de trabalho
382
  error_unable_delete_workspace: Não foi possível excluir espaço de trabalho
383
  field_workspace: Espaço de trabalho
379 384
  label_workflow: Fluxo de trabalho
380 385
  label_issue_status: Situação da tarefa
381 386
  label_issue_status_plural: Situação das tarefas
config/routes.rb
341 341
    end
342 342
  end
343 343

  
344
  resources :workspaces, :except => :show
345

  
344 346
  match 'workflows', :controller => 'workflows', :action => 'index', :via => :get
345 347
  match 'workflows/edit', :controller => 'workflows', :action => 'edit', :via => [:get, :post]
346 348
  match 'workflows/permissions', :controller => 'workflows', :action => 'permissions', :via => [:get, :post]
db/migrate/20160314174310_add_workspace_to_projects.rb
1
class AddWorkspaceToProjects < ActiveRecord::Migration[4.2]
2
  def self.up
3
    add_column :projects, :workspace_id, :integer, :default => 1
4
  end
5

  
6
  def self.down
7
    remove_column :projects, :workspace_id
8
  end
9
end
db/migrate/20160314174311_add_workspace_to_workflows.rb
1
class AddWorkspaceToWorkflows < ActiveRecord::Migration[4.2]
2
  def self.up
3
    add_column :workflows, :workspace_id, :integer, :default => 1
4
  end
5

  
6
  def self.down
7
    remove_column :workflows, :workspace_id
8
  end
9
end
db/migrate/20160314174312_create_workspaces.rb
1
class CreateWorkspaces < ActiveRecord::Migration[4.2]
2
  def self.up
3
    create_table :workspaces do |t|
4
      t.string :name
5
      t.string :description
6
      t.integer :position, :default => nil, :null => true
7
    end
8

  
9
    # create default workspace
10
    unless Workspace.exists?(1)
11
      Workspace.create(:name => "Default", :description => "Default workspace", :position => 1)
12
    end
13
  end
14

  
15
  def self.down
16
    drop_table :workspaces
17
  end
18
end
lib/redmine.rb
234 234
            :html => {:class => 'icon icon-issue'}
235 235
  menu.push :issue_statuses, {:controller => 'issue_statuses'}, :caption => :label_issue_status_plural,
236 236
            :html => {:class => 'icon icon-issue-edit'}
237
  menu.push :workspaces, {:controller => 'workspaces'}, :caption => :label_workspace_plural,
238
            :html => {:class => 'icon icon-multiple'}
237 239
  menu.push :workflows, {:controller => 'workflows', :action => 'edit'}, :caption => :label_workflow,
238 240
            :html => {:class => 'icon icon-workflows'}
239 241
  menu.push :custom_fields, {:controller => 'custom_fields'},  :caption => :label_custom_field_plural,
public/stylesheets/application.css
555 555
div#roadmap .wiki h2 { font-size: 110%; }
556 556
body.controller-versions.action-show div#roadmap .related-issues {width:70%;}
557 557

  
558
span.tip_exp_on {font-weight:bold; position:relative; color:#fff; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
559
span.tip_exp_off {font-weight:bold; position:relative; color:#fff; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
560

  
558 561
div#version-summary { float:right; width:28%; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
559 562
div#version-summary fieldset { margin-bottom: 1em; }
560 563
div#version-summary fieldset.time-tracking table { width:100%; }
561
-