##// END OF EJS Templates
Lists can be reordered with drag and drop (#12909)....
Jean-Philippe Lang -
r14954:42b5c332b2c2
parent child
Show More
@@ -1,88 +1,96
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class CustomFieldsController < ApplicationController
19 19 layout 'admin'
20 20
21 21 before_filter :require_admin
22 22 before_filter :build_new_custom_field, :only => [:new, :create]
23 23 before_filter :find_custom_field, :only => [:edit, :update, :destroy]
24 24 accept_api_auth :index
25 25
26 26 def index
27 27 respond_to do |format|
28 28 format.html {
29 29 @custom_fields_by_type = CustomField.all.group_by {|f| f.class.name }
30 30 }
31 31 format.api {
32 32 @custom_fields = CustomField.all
33 33 }
34 34 end
35 35 end
36 36
37 37 def new
38 38 @custom_field.field_format = 'string' if @custom_field.field_format.blank?
39 39 @custom_field.default_value = nil
40 40 end
41 41
42 42 def create
43 43 if @custom_field.save
44 44 flash[:notice] = l(:notice_successful_create)
45 45 call_hook(:controller_custom_fields_new_after_save, :params => params, :custom_field => @custom_field)
46 46 redirect_to edit_custom_field_path(@custom_field)
47 47 else
48 48 render :action => 'new'
49 49 end
50 50 end
51 51
52 52 def edit
53 53 end
54 54
55 55 def update
56 56 if @custom_field.update_attributes(params[:custom_field])
57 flash[:notice] = l(:notice_successful_update)
58 57 call_hook(:controller_custom_fields_edit_after_save, :params => params, :custom_field => @custom_field)
59 redirect_back_or_default edit_custom_field_path(@custom_field)
58 respond_to do |format|
59 format.html {
60 flash[:notice] = l(:notice_successful_update)
61 redirect_back_or_default edit_custom_field_path(@custom_field)
62 }
63 format.js { render :nothing => true }
64 end
60 65 else
61 render :action => 'edit'
66 respond_to do |format|
67 format.html { render :action => 'edit' }
68 format.js { render :nothing => true, :status => 422 }
69 end
62 70 end
63 71 end
64 72
65 73 def destroy
66 74 begin
67 75 @custom_field.destroy
68 76 rescue
69 77 flash[:error] = l(:error_can_not_delete_custom_field)
70 78 end
71 79 redirect_to custom_fields_path(:tab => @custom_field.class.name)
72 80 end
73 81
74 82 private
75 83
76 84 def build_new_custom_field
77 85 @custom_field = CustomField.new_subclass_instance(params[:type], params[:custom_field])
78 86 if @custom_field.nil?
79 87 render :action => 'select_type'
80 88 end
81 89 end
82 90
83 91 def find_custom_field
84 92 @custom_field = CustomField.find(params[:id])
85 93 rescue ActiveRecord::RecordNotFound
86 94 render_404
87 95 end
88 96 end
@@ -1,96 +1,104
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class EnumerationsController < ApplicationController
19 19 layout 'admin'
20 20
21 21 before_filter :require_admin, :except => :index
22 22 before_filter :require_admin_or_api_request, :only => :index
23 23 before_filter :build_new_enumeration, :only => [:new, :create]
24 24 before_filter :find_enumeration, :only => [:edit, :update, :destroy]
25 25 accept_api_auth :index
26 26
27 27 helper :custom_fields
28 28
29 29 def index
30 30 respond_to do |format|
31 31 format.html
32 32 format.api {
33 33 @klass = Enumeration.get_subclass(params[:type])
34 34 if @klass
35 35 @enumerations = @klass.shared.sorted.to_a
36 36 else
37 37 render_404
38 38 end
39 39 }
40 40 end
41 41 end
42 42
43 43 def new
44 44 end
45 45
46 46 def create
47 47 if request.post? && @enumeration.save
48 48 flash[:notice] = l(:notice_successful_create)
49 49 redirect_to enumerations_path
50 50 else
51 51 render :action => 'new'
52 52 end
53 53 end
54 54
55 55 def edit
56 56 end
57 57
58 58 def update
59 59 if @enumeration.update_attributes(params[:enumeration])
60 flash[:notice] = l(:notice_successful_update)
61 redirect_to enumerations_path
60 respond_to do |format|
61 format.html {
62 flash[:notice] = l(:notice_successful_update)
63 redirect_to enumerations_path
64 }
65 format.js { render :nothing => true }
66 end
62 67 else
63 render :action => 'edit'
68 respond_to do |format|
69 format.html { render :action => 'edit' }
70 format.js { render :nothing => true, :status => 422 }
71 end
64 72 end
65 73 end
66 74
67 75 def destroy
68 76 if !@enumeration.in_use?
69 77 # No associated objects
70 78 @enumeration.destroy
71 79 redirect_to enumerations_path
72 80 return
73 81 elsif params[:reassign_to_id].present? && (reassign_to = @enumeration.class.find_by_id(params[:reassign_to_id].to_i))
74 82 @enumeration.destroy(reassign_to)
75 83 redirect_to enumerations_path
76 84 return
77 85 end
78 86 @enumerations = @enumeration.class.system.to_a - [@enumeration]
79 87 end
80 88
81 89 private
82 90
83 91 def build_new_enumeration
84 92 class_name = params[:enumeration] && params[:enumeration][:type] || params[:type]
85 93 @enumeration = Enumeration.new_subclass_instance(class_name, params[:enumeration])
86 94 if @enumeration.nil?
87 95 render_404
88 96 end
89 97 end
90 98
91 99 def find_enumeration
92 100 @enumeration = Enumeration.find(params[:id])
93 101 rescue ActiveRecord::RecordNotFound
94 102 render_404
95 103 end
96 104 end
@@ -1,77 +1,85
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class IssueStatusesController < ApplicationController
19 19 layout 'admin'
20 20
21 21 before_filter :require_admin, :except => :index
22 22 before_filter :require_admin_or_api_request, :only => :index
23 23 accept_api_auth :index
24 24
25 25 def index
26 26 @issue_statuses = IssueStatus.sorted.to_a
27 27 respond_to do |format|
28 28 format.html { render :layout => false if request.xhr? }
29 29 format.api
30 30 end
31 31 end
32 32
33 33 def new
34 34 @issue_status = IssueStatus.new
35 35 end
36 36
37 37 def create
38 38 @issue_status = IssueStatus.new(params[:issue_status])
39 39 if @issue_status.save
40 40 flash[:notice] = l(:notice_successful_create)
41 41 redirect_to issue_statuses_path
42 42 else
43 43 render :action => 'new'
44 44 end
45 45 end
46 46
47 47 def edit
48 48 @issue_status = IssueStatus.find(params[:id])
49 49 end
50 50
51 51 def update
52 52 @issue_status = IssueStatus.find(params[:id])
53 53 if @issue_status.update_attributes(params[:issue_status])
54 flash[:notice] = l(:notice_successful_update)
55 redirect_to issue_statuses_path(:page => params[:page])
54 respond_to do |format|
55 format.html {
56 flash[:notice] = l(:notice_successful_update)
57 redirect_to issue_statuses_path(:page => params[:page])
58 }
59 format.js { render :nothing => true }
60 end
56 61 else
57 render :action => 'edit'
62 respond_to do |format|
63 format.html { render :action => 'edit' }
64 format.js { render :nothing => true, :status => 422 }
65 end
58 66 end
59 67 end
60 68
61 69 def destroy
62 70 IssueStatus.find(params[:id]).destroy
63 71 redirect_to issue_statuses_path
64 72 rescue
65 73 flash[:error] = l(:error_unable_delete_issue_status)
66 74 redirect_to issue_statuses_path
67 75 end
68 76
69 77 def update_issue_done_ratio
70 78 if request.post? && IssueStatus.update_issue_done_ratios
71 79 flash[:notice] = l(:notice_issue_done_ratios_updated)
72 80 else
73 81 flash[:error] = l(:error_issue_done_ratios_not_updated)
74 82 end
75 83 redirect_to issue_statuses_path
76 84 end
77 85 end
@@ -1,110 +1,118
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class RolesController < ApplicationController
19 19 layout 'admin'
20 20
21 21 before_filter :require_admin, :except => [:index, :show]
22 22 before_filter :require_admin_or_api_request, :only => [:index, :show]
23 23 before_filter :find_role, :only => [:show, :edit, :update, :destroy]
24 24 accept_api_auth :index, :show
25 25
26 26 require_sudo_mode :create, :update, :destroy
27 27
28 28 def index
29 29 respond_to do |format|
30 30 format.html {
31 31 @roles = Role.sorted.to_a
32 32 render :layout => false if request.xhr?
33 33 }
34 34 format.api {
35 35 @roles = Role.givable.to_a
36 36 }
37 37 end
38 38 end
39 39
40 40 def show
41 41 respond_to do |format|
42 42 format.api
43 43 end
44 44 end
45 45
46 46 def new
47 47 # Prefills the form with 'Non member' role permissions by default
48 48 @role = Role.new(params[:role] || {:permissions => Role.non_member.permissions})
49 49 if params[:copy].present? && @copy_from = Role.find_by_id(params[:copy])
50 50 @role.copy_from(@copy_from)
51 51 end
52 52 @roles = Role.sorted.to_a
53 53 end
54 54
55 55 def create
56 56 @role = Role.new(params[:role])
57 57 if request.post? && @role.save
58 58 # workflow copy
59 59 if !params[:copy_workflow_from].blank? && (copy_from = Role.find_by_id(params[:copy_workflow_from]))
60 60 @role.workflow_rules.copy(copy_from)
61 61 end
62 62 flash[:notice] = l(:notice_successful_create)
63 63 redirect_to roles_path
64 64 else
65 65 @roles = Role.sorted.to_a
66 66 render :action => 'new'
67 67 end
68 68 end
69 69
70 70 def edit
71 71 end
72 72
73 73 def update
74 74 if @role.update_attributes(params[:role])
75 flash[:notice] = l(:notice_successful_update)
76 redirect_to roles_path(:page => params[:page])
75 respond_to do |format|
76 format.html {
77 flash[:notice] = l(:notice_successful_update)
78 redirect_to roles_path(:page => params[:page])
79 }
80 format.js { render :nothing => true }
81 end
77 82 else
78 render :action => 'edit'
83 respond_to do |format|
84 format.html { render :action => 'edit' }
85 format.js { render :nothing => true, :status => 422 }
86 end
79 87 end
80 88 end
81 89
82 90 def destroy
83 91 @role.destroy
84 92 redirect_to roles_path
85 93 rescue
86 94 flash[:error] = l(:error_can_not_remove_role)
87 95 redirect_to roles_path
88 96 end
89 97
90 98 def permissions
91 99 @roles = Role.sorted.to_a
92 100 @permissions = Redmine::AccessControl.permissions.select { |p| !p.public? }
93 101 if request.post?
94 102 @roles.each do |role|
95 103 role.permissions = params[:permissions][role.id.to_s]
96 104 role.save
97 105 end
98 106 flash[:notice] = l(:notice_successful_update)
99 107 redirect_to roles_path
100 108 end
101 109 end
102 110
103 111 private
104 112
105 113 def find_role
106 114 @role = Role.find(params[:id])
107 115 rescue ActiveRecord::RecordNotFound
108 116 render_404
109 117 end
110 118 end
@@ -1,97 +1,107
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 class TrackersController < ApplicationController
19 19 layout 'admin'
20 20
21 21 before_filter :require_admin, :except => :index
22 22 before_filter :require_admin_or_api_request, :only => :index
23 23 accept_api_auth :index
24 24
25 25 def index
26 26 @trackers = Tracker.sorted.to_a
27 27 respond_to do |format|
28 28 format.html { render :layout => false if request.xhr? }
29 29 format.api
30 30 end
31 31 end
32 32
33 33 def new
34 34 @tracker ||= Tracker.new(params[:tracker])
35 35 @trackers = Tracker.sorted.to_a
36 36 @projects = Project.all
37 37 end
38 38
39 39 def create
40 40 @tracker = Tracker.new(params[:tracker])
41 41 if @tracker.save
42 42 # workflow copy
43 43 if !params[:copy_workflow_from].blank? && (copy_from = Tracker.find_by_id(params[:copy_workflow_from]))
44 44 @tracker.workflow_rules.copy(copy_from)
45 45 end
46 46 flash[:notice] = l(:notice_successful_create)
47 47 redirect_to trackers_path
48 48 return
49 49 end
50 50 new
51 51 render :action => 'new'
52 52 end
53 53
54 54 def edit
55 55 @tracker ||= Tracker.find(params[:id])
56 56 @projects = Project.all
57 57 end
58 58
59 59 def update
60 60 @tracker = Tracker.find(params[:id])
61 61 if @tracker.update_attributes(params[:tracker])
62 flash[:notice] = l(:notice_successful_update)
63 redirect_to trackers_path(:page => params[:page])
64 return
62 respond_to do |format|
63 format.html {
64 flash[:notice] = l(:notice_successful_update)
65 redirect_to trackers_path(:page => params[:page])
66 }
67 format.js { render :nothing => true }
68 end
69 else
70 respond_to do |format|
71 format.html {
72 edit
73 render :action => 'edit'
74 }
75 format.js { render :nothing => true, :status => 422 }
76 end
65 77 end
66 edit
67 render :action => 'edit'
68 78 end
69 79
70 80 def destroy
71 81 @tracker = Tracker.find(params[:id])
72 82 unless @tracker.issues.empty?
73 83 flash[:error] = l(:error_can_not_delete_tracker)
74 84 else
75 85 @tracker.destroy
76 86 end
77 87 redirect_to trackers_path
78 88 end
79 89
80 90 def fields
81 91 if request.post? && params[:trackers]
82 92 params[:trackers].each do |tracker_id, tracker_params|
83 93 tracker = Tracker.find_by_id(tracker_id)
84 94 if tracker
85 95 tracker.core_fields = tracker_params[:core_fields]
86 96 tracker.custom_field_ids = tracker_params[:custom_field_ids]
87 97 tracker.save
88 98 end
89 99 end
90 100 flash[:notice] = l(:notice_successful_update)
91 101 redirect_to fields_trackers_path
92 102 return
93 103 end
94 104 @trackers = Tracker.sorted.to_a
95 105 @custom_fields = IssueCustomField.all.sort
96 106 end
97 107 end
@@ -1,1346 +1,1356
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2016 Jean-Philippe Lang
5 5 #
6 6 # This program is free software; you can redistribute it and/or
7 7 # modify it under the terms of the GNU General Public License
8 8 # as published by the Free Software Foundation; either version 2
9 9 # of the License, or (at your option) any later version.
10 10 #
11 11 # This program is distributed in the hope that it will be useful,
12 12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 14 # GNU General Public License for more details.
15 15 #
16 16 # You should have received a copy of the GNU General Public License
17 17 # along with this program; if not, write to the Free Software
18 18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 19
20 20 require 'forwardable'
21 21 require 'cgi'
22 22
23 23 module ApplicationHelper
24 24 include Redmine::WikiFormatting::Macros::Definitions
25 25 include Redmine::I18n
26 26 include GravatarHelper::PublicMethods
27 27 include Redmine::Pagination::Helper
28 28 include Redmine::SudoMode::Helper
29 29 include Redmine::Themes::Helper
30 30 include Redmine::Hook::Helper
31 31
32 32 extend Forwardable
33 33 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
34 34
35 35 # Return true if user is authorized for controller/action, otherwise false
36 36 def authorize_for(controller, action)
37 37 User.current.allowed_to?({:controller => controller, :action => action}, @project)
38 38 end
39 39
40 40 # Display a link if user is authorized
41 41 #
42 42 # @param [String] name Anchor text (passed to link_to)
43 43 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
44 44 # @param [optional, Hash] html_options Options passed to link_to
45 45 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
46 46 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
47 47 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
48 48 end
49 49
50 50 # Displays a link to user's account page if active
51 51 def link_to_user(user, options={})
52 52 if user.is_a?(User)
53 53 name = h(user.name(options[:format]))
54 54 if user.active? || (User.current.admin? && user.logged?)
55 55 link_to name, user_path(user), :class => user.css_classes
56 56 else
57 57 name
58 58 end
59 59 else
60 60 h(user.to_s)
61 61 end
62 62 end
63 63
64 64 # Displays a link to +issue+ with its subject.
65 65 # Examples:
66 66 #
67 67 # link_to_issue(issue) # => Defect #6: This is the subject
68 68 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
69 69 # link_to_issue(issue, :subject => false) # => Defect #6
70 70 # link_to_issue(issue, :project => true) # => Foo - Defect #6
71 71 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
72 72 #
73 73 def link_to_issue(issue, options={})
74 74 title = nil
75 75 subject = nil
76 76 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
77 77 if options[:subject] == false
78 78 title = issue.subject.truncate(60)
79 79 else
80 80 subject = issue.subject
81 81 if truncate_length = options[:truncate]
82 82 subject = subject.truncate(truncate_length)
83 83 end
84 84 end
85 85 only_path = options[:only_path].nil? ? true : options[:only_path]
86 86 s = link_to(text, issue_url(issue, :only_path => only_path),
87 87 :class => issue.css_classes, :title => title)
88 88 s << h(": #{subject}") if subject
89 89 s = h("#{issue.project} - ") + s if options[:project]
90 90 s
91 91 end
92 92
93 93 # Generates a link to an attachment.
94 94 # Options:
95 95 # * :text - Link text (default to attachment filename)
96 96 # * :download - Force download (default: false)
97 97 def link_to_attachment(attachment, options={})
98 98 text = options.delete(:text) || attachment.filename
99 99 route_method = options.delete(:download) ? :download_named_attachment_url : :named_attachment_url
100 100 html_options = options.slice!(:only_path)
101 101 options[:only_path] = true unless options.key?(:only_path)
102 102 url = send(route_method, attachment, attachment.filename, options)
103 103 link_to text, url, html_options
104 104 end
105 105
106 106 # Generates a link to a SCM revision
107 107 # Options:
108 108 # * :text - Link text (default to the formatted revision)
109 109 def link_to_revision(revision, repository, options={})
110 110 if repository.is_a?(Project)
111 111 repository = repository.repository
112 112 end
113 113 text = options.delete(:text) || format_revision(revision)
114 114 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
115 115 link_to(
116 116 h(text),
117 117 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
118 118 :title => l(:label_revision_id, format_revision(revision)),
119 119 :accesskey => options[:accesskey]
120 120 )
121 121 end
122 122
123 123 # Generates a link to a message
124 124 def link_to_message(message, options={}, html_options = nil)
125 125 link_to(
126 126 message.subject.truncate(60),
127 127 board_message_url(message.board_id, message.parent_id || message.id, {
128 128 :r => (message.parent_id && message.id),
129 129 :anchor => (message.parent_id ? "message-#{message.id}" : nil),
130 130 :only_path => true
131 131 }.merge(options)),
132 132 html_options
133 133 )
134 134 end
135 135
136 136 # Generates a link to a project if active
137 137 # Examples:
138 138 #
139 139 # link_to_project(project) # => link to the specified project overview
140 140 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
141 141 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
142 142 #
143 143 def link_to_project(project, options={}, html_options = nil)
144 144 if project.archived?
145 145 h(project.name)
146 146 else
147 147 link_to project.name,
148 148 project_url(project, {:only_path => true}.merge(options)),
149 149 html_options
150 150 end
151 151 end
152 152
153 153 # Generates a link to a project settings if active
154 154 def link_to_project_settings(project, options={}, html_options=nil)
155 155 if project.active?
156 156 link_to project.name, settings_project_path(project, options), html_options
157 157 elsif project.archived?
158 158 h(project.name)
159 159 else
160 160 link_to project.name, project_path(project, options), html_options
161 161 end
162 162 end
163 163
164 164 # Generates a link to a version
165 165 def link_to_version(version, options = {})
166 166 return '' unless version && version.is_a?(Version)
167 167 options = {:title => format_date(version.effective_date)}.merge(options)
168 168 link_to_if version.visible?, format_version_name(version), version_path(version), options
169 169 end
170 170
171 171 # Helper that formats object for html or text rendering
172 172 def format_object(object, html=true, &block)
173 173 if block_given?
174 174 object = yield object
175 175 end
176 176 case object.class.name
177 177 when 'Array'
178 178 object.map {|o| format_object(o, html)}.join(', ').html_safe
179 179 when 'Time'
180 180 format_time(object)
181 181 when 'Date'
182 182 format_date(object)
183 183 when 'Fixnum'
184 184 object.to_s
185 185 when 'Float'
186 186 sprintf "%.2f", object
187 187 when 'User'
188 188 html ? link_to_user(object) : object.to_s
189 189 when 'Project'
190 190 html ? link_to_project(object) : object.to_s
191 191 when 'Version'
192 192 html ? link_to_version(object) : object.to_s
193 193 when 'TrueClass'
194 194 l(:general_text_Yes)
195 195 when 'FalseClass'
196 196 l(:general_text_No)
197 197 when 'Issue'
198 198 object.visible? && html ? link_to_issue(object) : "##{object.id}"
199 199 when 'CustomValue', 'CustomFieldValue'
200 200 if object.custom_field
201 201 f = object.custom_field.format.formatted_custom_value(self, object, html)
202 202 if f.nil? || f.is_a?(String)
203 203 f
204 204 else
205 205 format_object(f, html, &block)
206 206 end
207 207 else
208 208 object.value.to_s
209 209 end
210 210 else
211 211 html ? h(object) : object.to_s
212 212 end
213 213 end
214 214
215 215 def wiki_page_path(page, options={})
216 216 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
217 217 end
218 218
219 219 def thumbnail_tag(attachment)
220 220 link_to image_tag(thumbnail_path(attachment)),
221 221 named_attachment_path(attachment, attachment.filename),
222 222 :title => attachment.filename
223 223 end
224 224
225 225 def toggle_link(name, id, options={})
226 226 onclick = "$('##{id}').toggle(); "
227 227 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
228 228 onclick << "return false;"
229 229 link_to(name, "#", :onclick => onclick)
230 230 end
231 231
232 232 def format_activity_title(text)
233 233 h(truncate_single_line_raw(text, 100))
234 234 end
235 235
236 236 def format_activity_day(date)
237 237 date == User.current.today ? l(:label_today).titleize : format_date(date)
238 238 end
239 239
240 240 def format_activity_description(text)
241 241 h(text.to_s.truncate(120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
242 242 ).gsub(/[\r\n]+/, "<br />").html_safe
243 243 end
244 244
245 245 def format_version_name(version)
246 246 if version.project == @project
247 247 h(version)
248 248 else
249 249 h("#{version.project} - #{version}")
250 250 end
251 251 end
252 252
253 253 def due_date_distance_in_words(date)
254 254 if date
255 255 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
256 256 end
257 257 end
258 258
259 259 # Renders a tree of projects as a nested set of unordered lists
260 260 # The given collection may be a subset of the whole project tree
261 261 # (eg. some intermediate nodes are private and can not be seen)
262 262 def render_project_nested_lists(projects, &block)
263 263 s = ''
264 264 if projects.any?
265 265 ancestors = []
266 266 original_project = @project
267 267 projects.sort_by(&:lft).each do |project|
268 268 # set the project environment to please macros.
269 269 @project = project
270 270 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
271 271 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
272 272 else
273 273 ancestors.pop
274 274 s << "</li>"
275 275 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
276 276 ancestors.pop
277 277 s << "</ul></li>\n"
278 278 end
279 279 end
280 280 classes = (ancestors.empty? ? 'root' : 'child')
281 281 s << "<li class='#{classes}'><div class='#{classes}'>"
282 282 s << h(block_given? ? capture(project, &block) : project.name)
283 283 s << "</div>\n"
284 284 ancestors << project
285 285 end
286 286 s << ("</li></ul>\n" * ancestors.size)
287 287 @project = original_project
288 288 end
289 289 s.html_safe
290 290 end
291 291
292 292 def render_page_hierarchy(pages, node=nil, options={})
293 293 content = ''
294 294 if pages[node]
295 295 content << "<ul class=\"pages-hierarchy\">\n"
296 296 pages[node].each do |page|
297 297 content << "<li>"
298 298 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
299 299 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
300 300 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
301 301 content << "</li>\n"
302 302 end
303 303 content << "</ul>\n"
304 304 end
305 305 content.html_safe
306 306 end
307 307
308 308 # Renders flash messages
309 309 def render_flash_messages
310 310 s = ''
311 311 flash.each do |k,v|
312 312 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
313 313 end
314 314 s.html_safe
315 315 end
316 316
317 317 # Renders tabs and their content
318 318 def render_tabs(tabs, selected=params[:tab])
319 319 if tabs.any?
320 320 unless tabs.detect {|tab| tab[:name] == selected}
321 321 selected = nil
322 322 end
323 323 selected ||= tabs.first[:name]
324 324 render :partial => 'common/tabs', :locals => {:tabs => tabs, :selected_tab => selected}
325 325 else
326 326 content_tag 'p', l(:label_no_data), :class => "nodata"
327 327 end
328 328 end
329 329
330 330 # Renders the project quick-jump box
331 331 def render_project_jump_box
332 332 return unless User.current.logged?
333 333 projects = User.current.projects.active.select(:id, :name, :identifier, :lft, :rgt).to_a
334 334 if projects.any?
335 335 options =
336 336 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
337 337 '<option value="" disabled="disabled">---</option>').html_safe
338 338
339 339 options << project_tree_options_for_select(projects, :selected => @project) do |p|
340 340 { :value => project_path(:id => p, :jump => current_menu_item) }
341 341 end
342 342
343 343 content_tag( :span, nil, :class => 'jump-box-arrow') +
344 344 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
345 345 end
346 346 end
347 347
348 348 def project_tree_options_for_select(projects, options = {})
349 349 s = ''.html_safe
350 350 if blank_text = options[:include_blank]
351 351 if blank_text == true
352 352 blank_text = '&nbsp;'.html_safe
353 353 end
354 354 s << content_tag('option', blank_text, :value => '')
355 355 end
356 356 project_tree(projects) do |project, level|
357 357 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
358 358 tag_options = {:value => project.id}
359 359 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
360 360 tag_options[:selected] = 'selected'
361 361 else
362 362 tag_options[:selected] = nil
363 363 end
364 364 tag_options.merge!(yield(project)) if block_given?
365 365 s << content_tag('option', name_prefix + h(project), tag_options)
366 366 end
367 367 s.html_safe
368 368 end
369 369
370 370 # Yields the given block for each project with its level in the tree
371 371 #
372 372 # Wrapper for Project#project_tree
373 373 def project_tree(projects, &block)
374 374 Project.project_tree(projects, &block)
375 375 end
376 376
377 377 def principals_check_box_tags(name, principals)
378 378 s = ''
379 379 principals.each do |principal|
380 380 s << "<label>#{ check_box_tag name, principal.id, false, :id => nil } #{h principal}</label>\n"
381 381 end
382 382 s.html_safe
383 383 end
384 384
385 385 # Returns a string for users/groups option tags
386 386 def principals_options_for_select(collection, selected=nil)
387 387 s = ''
388 388 if collection.include?(User.current)
389 389 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
390 390 end
391 391 groups = ''
392 392 collection.sort.each do |element|
393 393 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected) || element.id.to_s == selected
394 394 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
395 395 end
396 396 unless groups.empty?
397 397 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
398 398 end
399 399 s.html_safe
400 400 end
401 401
402 402 def option_tag(name, text, value, selected=nil, options={})
403 403 content_tag 'option', value, options.merge(:value => value, :selected => (value == selected))
404 404 end
405 405
406 406 def truncate_single_line_raw(string, length)
407 407 string.to_s.truncate(length).gsub(%r{[\r\n]+}m, ' ')
408 408 end
409 409
410 410 # Truncates at line break after 250 characters or options[:length]
411 411 def truncate_lines(string, options={})
412 412 length = options[:length] || 250
413 413 if string.to_s =~ /\A(.{#{length}}.*?)$/m
414 414 "#{$1}..."
415 415 else
416 416 string
417 417 end
418 418 end
419 419
420 420 def anchor(text)
421 421 text.to_s.gsub(' ', '_')
422 422 end
423 423
424 424 def html_hours(text)
425 425 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
426 426 end
427 427
428 428 def authoring(created, author, options={})
429 429 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
430 430 end
431 431
432 432 def time_tag(time)
433 433 text = distance_of_time_in_words(Time.now, time)
434 434 if @project
435 435 link_to(text, project_activity_path(@project, :from => User.current.time_to_date(time)), :title => format_time(time))
436 436 else
437 437 content_tag('abbr', text, :title => format_time(time))
438 438 end
439 439 end
440 440
441 441 def syntax_highlight_lines(name, content)
442 442 lines = []
443 443 syntax_highlight(name, content).each_line { |line| lines << line }
444 444 lines
445 445 end
446 446
447 447 def syntax_highlight(name, content)
448 448 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
449 449 end
450 450
451 451 def to_path_param(path)
452 452 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
453 453 str.blank? ? nil : str
454 454 end
455 455
456 456 def reorder_links(name, url, method = :post)
457 457 link_to(l(:label_sort_highest),
458 458 url.merge({"#{name}[move_to]" => 'highest'}), :method => method,
459 459 :title => l(:label_sort_highest), :class => 'icon-only icon-move-top') +
460 460 link_to(l(:label_sort_higher),
461 461 url.merge({"#{name}[move_to]" => 'higher'}), :method => method,
462 462 :title => l(:label_sort_higher), :class => 'icon-only icon-move-up') +
463 463 link_to(l(:label_sort_lower),
464 464 url.merge({"#{name}[move_to]" => 'lower'}), :method => method,
465 465 :title => l(:label_sort_lower), :class => 'icon-only icon-move-down') +
466 466 link_to(l(:label_sort_lowest),
467 467 url.merge({"#{name}[move_to]" => 'lowest'}), :method => method,
468 468 :title => l(:label_sort_lowest), :class => 'icon-only icon-move-bottom')
469 469 end
470 470
471 def reorder_handle(object, options={})
472 data = {
473 :reorder_url => options[:url] || url_for(object),
474 :reorder_param => options[:param] || object.class.name.underscore
475 }
476 content_tag('span', '',
477 :class => "sort-handle ui-icon ui-icon-arrowthick-2-n-s",
478 :data => data)
479 end
480
471 481 def breadcrumb(*args)
472 482 elements = args.flatten
473 483 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
474 484 end
475 485
476 486 def other_formats_links(&block)
477 487 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
478 488 yield Redmine::Views::OtherFormatsBuilder.new(self)
479 489 concat('</p>'.html_safe)
480 490 end
481 491
482 492 def page_header_title
483 493 if @project.nil? || @project.new_record?
484 494 h(Setting.app_title)
485 495 else
486 496 b = []
487 497 ancestors = (@project.root? ? [] : @project.ancestors.visible.to_a)
488 498 if ancestors.any?
489 499 root = ancestors.shift
490 500 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
491 501 if ancestors.size > 2
492 502 b << "\xe2\x80\xa6"
493 503 ancestors = ancestors[-2, 2]
494 504 end
495 505 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
496 506 end
497 507 b << content_tag(:span, h(@project), class: 'current-project')
498 508 if b.size > 1
499 509 separator = content_tag(:span, ' &raquo; '.html_safe, class: 'separator')
500 510 path = safe_join(b[0..-2], separator) + separator
501 511 b = [content_tag(:span, path.html_safe, class: 'breadcrumbs'), b[-1]]
502 512 end
503 513 safe_join b
504 514 end
505 515 end
506 516
507 517 # Returns a h2 tag and sets the html title with the given arguments
508 518 def title(*args)
509 519 strings = args.map do |arg|
510 520 if arg.is_a?(Array) && arg.size >= 2
511 521 link_to(*arg)
512 522 else
513 523 h(arg.to_s)
514 524 end
515 525 end
516 526 html_title args.reverse.map {|s| (s.is_a?(Array) ? s.first : s).to_s}
517 527 content_tag('h2', strings.join(' &#187; ').html_safe)
518 528 end
519 529
520 530 # Sets the html title
521 531 # Returns the html title when called without arguments
522 532 # Current project name and app_title and automatically appended
523 533 # Exemples:
524 534 # html_title 'Foo', 'Bar'
525 535 # html_title # => 'Foo - Bar - My Project - Redmine'
526 536 def html_title(*args)
527 537 if args.empty?
528 538 title = @html_title || []
529 539 title << @project.name if @project
530 540 title << Setting.app_title unless Setting.app_title == title.last
531 541 title.reject(&:blank?).join(' - ')
532 542 else
533 543 @html_title ||= []
534 544 @html_title += args
535 545 end
536 546 end
537 547
538 548 # Returns the theme, controller name, and action as css classes for the
539 549 # HTML body.
540 550 def body_css_classes
541 551 css = []
542 552 if theme = Redmine::Themes.theme(Setting.ui_theme)
543 553 css << 'theme-' + theme.name
544 554 end
545 555
546 556 css << 'project-' + @project.identifier if @project && @project.identifier.present?
547 557 css << 'controller-' + controller_name
548 558 css << 'action-' + action_name
549 559 css.join(' ')
550 560 end
551 561
552 562 def accesskey(s)
553 563 @used_accesskeys ||= []
554 564 key = Redmine::AccessKeys.key_for(s)
555 565 return nil if @used_accesskeys.include?(key)
556 566 @used_accesskeys << key
557 567 key
558 568 end
559 569
560 570 # Formats text according to system settings.
561 571 # 2 ways to call this method:
562 572 # * with a String: textilizable(text, options)
563 573 # * with an object and one of its attribute: textilizable(issue, :description, options)
564 574 def textilizable(*args)
565 575 options = args.last.is_a?(Hash) ? args.pop : {}
566 576 case args.size
567 577 when 1
568 578 obj = options[:object]
569 579 text = args.shift
570 580 when 2
571 581 obj = args.shift
572 582 attr = args.shift
573 583 text = obj.send(attr).to_s
574 584 else
575 585 raise ArgumentError, 'invalid arguments to textilizable'
576 586 end
577 587 return '' if text.blank?
578 588 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
579 589 @only_path = only_path = options.delete(:only_path) == false ? false : true
580 590
581 591 text = text.dup
582 592 macros = catch_macros(text)
583 593 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
584 594
585 595 @parsed_headings = []
586 596 @heading_anchors = {}
587 597 @current_section = 0 if options[:edit_section_links]
588 598
589 599 parse_sections(text, project, obj, attr, only_path, options)
590 600 text = parse_non_pre_blocks(text, obj, macros) do |text|
591 601 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
592 602 send method_name, text, project, obj, attr, only_path, options
593 603 end
594 604 end
595 605 parse_headings(text, project, obj, attr, only_path, options)
596 606
597 607 if @parsed_headings.any?
598 608 replace_toc(text, @parsed_headings)
599 609 end
600 610
601 611 text.html_safe
602 612 end
603 613
604 614 def parse_non_pre_blocks(text, obj, macros)
605 615 s = StringScanner.new(text)
606 616 tags = []
607 617 parsed = ''
608 618 while !s.eos?
609 619 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
610 620 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
611 621 if tags.empty?
612 622 yield text
613 623 inject_macros(text, obj, macros) if macros.any?
614 624 else
615 625 inject_macros(text, obj, macros, false) if macros.any?
616 626 end
617 627 parsed << text
618 628 if tag
619 629 if closing
620 630 if tags.last && tags.last.casecmp(tag) == 0
621 631 tags.pop
622 632 end
623 633 else
624 634 tags << tag.downcase
625 635 end
626 636 parsed << full_tag
627 637 end
628 638 end
629 639 # Close any non closing tags
630 640 while tag = tags.pop
631 641 parsed << "</#{tag}>"
632 642 end
633 643 parsed
634 644 end
635 645
636 646 def parse_inline_attachments(text, project, obj, attr, only_path, options)
637 647 return if options[:inline_attachments] == false
638 648
639 649 # when using an image link, try to use an attachment, if possible
640 650 attachments = options[:attachments] || []
641 651 attachments += obj.attachments if obj.respond_to?(:attachments)
642 652 if attachments.present?
643 653 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
644 654 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
645 655 # search for the picture in attachments
646 656 if found = Attachment.latest_attach(attachments, CGI.unescape(filename))
647 657 image_url = download_named_attachment_url(found, found.filename, :only_path => only_path)
648 658 desc = found.description.to_s.gsub('"', '')
649 659 if !desc.blank? && alttext.blank?
650 660 alt = " title=\"#{desc}\" alt=\"#{desc}\""
651 661 end
652 662 "src=\"#{image_url}\"#{alt}"
653 663 else
654 664 m
655 665 end
656 666 end
657 667 end
658 668 end
659 669
660 670 # Wiki links
661 671 #
662 672 # Examples:
663 673 # [[mypage]]
664 674 # [[mypage|mytext]]
665 675 # wiki links can refer other project wikis, using project name or identifier:
666 676 # [[project:]] -> wiki starting page
667 677 # [[project:|mytext]]
668 678 # [[project:mypage]]
669 679 # [[project:mypage|mytext]]
670 680 def parse_wiki_links(text, project, obj, attr, only_path, options)
671 681 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
672 682 link_project = project
673 683 esc, all, page, title = $1, $2, $3, $5
674 684 if esc.nil?
675 685 if page =~ /^([^\:]+)\:(.*)$/
676 686 identifier, page = $1, $2
677 687 link_project = Project.find_by_identifier(identifier) || Project.find_by_name(identifier)
678 688 title ||= identifier if page.blank?
679 689 end
680 690
681 691 if link_project && link_project.wiki
682 692 # extract anchor
683 693 anchor = nil
684 694 if page =~ /^(.+?)\#(.+)$/
685 695 page, anchor = $1, $2
686 696 end
687 697 anchor = sanitize_anchor_name(anchor) if anchor.present?
688 698 # check if page exists
689 699 wiki_page = link_project.wiki.find_page(page)
690 700 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
691 701 "##{anchor}"
692 702 else
693 703 case options[:wiki_links]
694 704 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
695 705 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
696 706 else
697 707 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
698 708 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
699 709 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
700 710 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
701 711 end
702 712 end
703 713 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
704 714 else
705 715 # project or wiki doesn't exist
706 716 all
707 717 end
708 718 else
709 719 all
710 720 end
711 721 end
712 722 end
713 723
714 724 # Redmine links
715 725 #
716 726 # Examples:
717 727 # Issues:
718 728 # #52 -> Link to issue #52
719 729 # Changesets:
720 730 # r52 -> Link to revision 52
721 731 # commit:a85130f -> Link to scmid starting with a85130f
722 732 # Documents:
723 733 # document#17 -> Link to document with id 17
724 734 # document:Greetings -> Link to the document with title "Greetings"
725 735 # document:"Some document" -> Link to the document with title "Some document"
726 736 # Versions:
727 737 # version#3 -> Link to version with id 3
728 738 # version:1.0.0 -> Link to version named "1.0.0"
729 739 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
730 740 # Attachments:
731 741 # attachment:file.zip -> Link to the attachment of the current object named file.zip
732 742 # Source files:
733 743 # source:some/file -> Link to the file located at /some/file in the project's repository
734 744 # source:some/file@52 -> Link to the file's revision 52
735 745 # source:some/file#L120 -> Link to line 120 of the file
736 746 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
737 747 # export:some/file -> Force the download of the file
738 748 # Forum messages:
739 749 # message#1218 -> Link to message with id 1218
740 750 # Projects:
741 751 # project:someproject -> Link to project named "someproject"
742 752 # project#3 -> Link to project with id 3
743 753 #
744 754 # Links can refer other objects from other projects, using project identifier:
745 755 # identifier:r52
746 756 # identifier:document:"Some document"
747 757 # identifier:version:1.0.0
748 758 # identifier:source:some/file
749 759 def parse_redmine_links(text, default_project, obj, attr, only_path, options)
750 760 text.gsub!(%r{<a( [^>]+?)?>(.*?)</a>|([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-_]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
751 761 tag_content, leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $2, $3, $4, $5, $6, $7, $12, $13, $10 || $14 || $20, $16 || $21, $17, $19
752 762 if tag_content
753 763 $&
754 764 else
755 765 link = nil
756 766 project = default_project
757 767 if project_identifier
758 768 project = Project.visible.find_by_identifier(project_identifier)
759 769 end
760 770 if esc.nil?
761 771 if prefix.nil? && sep == 'r'
762 772 if project
763 773 repository = nil
764 774 if repo_identifier
765 775 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
766 776 else
767 777 repository = project.repository
768 778 end
769 779 # project.changesets.visible raises an SQL error because of a double join on repositories
770 780 if repository &&
771 781 (changeset = Changeset.visible.
772 782 find_by_repository_id_and_revision(repository.id, identifier))
773 783 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"),
774 784 {:only_path => only_path, :controller => 'repositories',
775 785 :action => 'revision', :id => project,
776 786 :repository_id => repository.identifier_param,
777 787 :rev => changeset.revision},
778 788 :class => 'changeset',
779 789 :title => truncate_single_line_raw(changeset.comments, 100))
780 790 end
781 791 end
782 792 elsif sep == '#'
783 793 oid = identifier.to_i
784 794 case prefix
785 795 when nil
786 796 if oid.to_s == identifier &&
787 797 issue = Issue.visible.find_by_id(oid)
788 798 anchor = comment_id ? "note-#{comment_id}" : nil
789 799 link = link_to("##{oid}#{comment_suffix}",
790 800 issue_url(issue, :only_path => only_path, :anchor => anchor),
791 801 :class => issue.css_classes,
792 802 :title => "#{issue.tracker.name}: #{issue.subject.truncate(100)} (#{issue.status.name})")
793 803 end
794 804 when 'document'
795 805 if document = Document.visible.find_by_id(oid)
796 806 link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
797 807 end
798 808 when 'version'
799 809 if version = Version.visible.find_by_id(oid)
800 810 link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
801 811 end
802 812 when 'message'
803 813 if message = Message.visible.find_by_id(oid)
804 814 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
805 815 end
806 816 when 'forum'
807 817 if board = Board.visible.find_by_id(oid)
808 818 link = link_to(board.name, project_board_url(board.project, board, :only_path => only_path), :class => 'board')
809 819 end
810 820 when 'news'
811 821 if news = News.visible.find_by_id(oid)
812 822 link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
813 823 end
814 824 when 'project'
815 825 if p = Project.visible.find_by_id(oid)
816 826 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
817 827 end
818 828 end
819 829 elsif sep == ':'
820 830 # removes the double quotes if any
821 831 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
822 832 name = CGI.unescapeHTML(name)
823 833 case prefix
824 834 when 'document'
825 835 if project && document = project.documents.visible.find_by_title(name)
826 836 link = link_to(document.title, document_url(document, :only_path => only_path), :class => 'document')
827 837 end
828 838 when 'version'
829 839 if project && version = project.versions.visible.find_by_name(name)
830 840 link = link_to(version.name, version_url(version, :only_path => only_path), :class => 'version')
831 841 end
832 842 when 'forum'
833 843 if project && board = project.boards.visible.find_by_name(name)
834 844 link = link_to(board.name, project_board_url(board.project, board, :only_path => only_path), :class => 'board')
835 845 end
836 846 when 'news'
837 847 if project && news = project.news.visible.find_by_title(name)
838 848 link = link_to(news.title, news_url(news, :only_path => only_path), :class => 'news')
839 849 end
840 850 when 'commit', 'source', 'export'
841 851 if project
842 852 repository = nil
843 853 if name =~ %r{^(([a-z0-9\-_]+)\|)(.+)$}
844 854 repo_prefix, repo_identifier, name = $1, $2, $3
845 855 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
846 856 else
847 857 repository = project.repository
848 858 end
849 859 if prefix == 'commit'
850 860 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
851 861 link = link_to h("#{project_prefix}#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.identifier},
852 862 :class => 'changeset',
853 863 :title => truncate_single_line_raw(changeset.comments, 100)
854 864 end
855 865 else
856 866 if repository && User.current.allowed_to?(:browse_repository, project)
857 867 name =~ %r{^[/\\]*(.*?)(@([^/\\@]+?))?(#(L\d+))?$}
858 868 path, rev, anchor = $1, $3, $5
859 869 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:only_path => only_path, :controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
860 870 :path => to_path_param(path),
861 871 :rev => rev,
862 872 :anchor => anchor},
863 873 :class => (prefix == 'export' ? 'source download' : 'source')
864 874 end
865 875 end
866 876 repo_prefix = nil
867 877 end
868 878 when 'attachment'
869 879 attachments = options[:attachments] || []
870 880 attachments += obj.attachments if obj.respond_to?(:attachments)
871 881 if attachments && attachment = Attachment.latest_attach(attachments, name)
872 882 link = link_to_attachment(attachment, :only_path => only_path, :download => true, :class => 'attachment')
873 883 end
874 884 when 'project'
875 885 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
876 886 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
877 887 end
878 888 end
879 889 end
880 890 end
881 891 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
882 892 end
883 893 end
884 894 end
885 895
886 896 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
887 897
888 898 def parse_sections(text, project, obj, attr, only_path, options)
889 899 return unless options[:edit_section_links]
890 900 text.gsub!(HEADING_RE) do
891 901 heading, level = $1, $2
892 902 @current_section += 1
893 903 if @current_section > 1
894 904 content_tag('div',
895 905 link_to(l(:button_edit_section), options[:edit_section_links].merge(:section => @current_section),
896 906 :class => 'icon-only icon-edit'),
897 907 :class => "contextual heading-#{level}",
898 908 :title => l(:button_edit_section),
899 909 :id => "section-#{@current_section}") + heading.html_safe
900 910 else
901 911 heading
902 912 end
903 913 end
904 914 end
905 915
906 916 # Headings and TOC
907 917 # Adds ids and links to headings unless options[:headings] is set to false
908 918 def parse_headings(text, project, obj, attr, only_path, options)
909 919 return if options[:headings] == false
910 920
911 921 text.gsub!(HEADING_RE) do
912 922 level, attrs, content = $2.to_i, $3, $4
913 923 item = strip_tags(content).strip
914 924 anchor = sanitize_anchor_name(item)
915 925 # used for single-file wiki export
916 926 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
917 927 @heading_anchors[anchor] ||= 0
918 928 idx = (@heading_anchors[anchor] += 1)
919 929 if idx > 1
920 930 anchor = "#{anchor}-#{idx}"
921 931 end
922 932 @parsed_headings << [level, anchor, item]
923 933 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
924 934 end
925 935 end
926 936
927 937 MACROS_RE = /(
928 938 (!)? # escaping
929 939 (
930 940 \{\{ # opening tag
931 941 ([\w]+) # macro name
932 942 (\(([^\n\r]*?)\))? # optional arguments
933 943 ([\n\r].*?[\n\r])? # optional block of text
934 944 \}\} # closing tag
935 945 )
936 946 )/mx unless const_defined?(:MACROS_RE)
937 947
938 948 MACRO_SUB_RE = /(
939 949 \{\{
940 950 macro\((\d+)\)
941 951 \}\}
942 952 )/x unless const_defined?(:MACRO_SUB_RE)
943 953
944 954 # Extracts macros from text
945 955 def catch_macros(text)
946 956 macros = {}
947 957 text.gsub!(MACROS_RE) do
948 958 all, macro = $1, $4.downcase
949 959 if macro_exists?(macro) || all =~ MACRO_SUB_RE
950 960 index = macros.size
951 961 macros[index] = all
952 962 "{{macro(#{index})}}"
953 963 else
954 964 all
955 965 end
956 966 end
957 967 macros
958 968 end
959 969
960 970 # Executes and replaces macros in text
961 971 def inject_macros(text, obj, macros, execute=true)
962 972 text.gsub!(MACRO_SUB_RE) do
963 973 all, index = $1, $2.to_i
964 974 orig = macros.delete(index)
965 975 if execute && orig && orig =~ MACROS_RE
966 976 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
967 977 if esc.nil?
968 978 h(exec_macro(macro, obj, args, block) || all)
969 979 else
970 980 h(all)
971 981 end
972 982 elsif orig
973 983 h(orig)
974 984 else
975 985 h(all)
976 986 end
977 987 end
978 988 end
979 989
980 990 TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
981 991
982 992 # Renders the TOC with given headings
983 993 def replace_toc(text, headings)
984 994 text.gsub!(TOC_RE) do
985 995 left_align, right_align = $2, $3
986 996 # Keep only the 4 first levels
987 997 headings = headings.select{|level, anchor, item| level <= 4}
988 998 if headings.empty?
989 999 ''
990 1000 else
991 1001 div_class = 'toc'
992 1002 div_class << ' right' if right_align
993 1003 div_class << ' left' if left_align
994 1004 out = "<ul class=\"#{div_class}\"><li>"
995 1005 root = headings.map(&:first).min
996 1006 current = root
997 1007 started = false
998 1008 headings.each do |level, anchor, item|
999 1009 if level > current
1000 1010 out << '<ul><li>' * (level - current)
1001 1011 elsif level < current
1002 1012 out << "</li></ul>\n" * (current - level) + "</li><li>"
1003 1013 elsif started
1004 1014 out << '</li><li>'
1005 1015 end
1006 1016 out << "<a href=\"##{anchor}\">#{item}</a>"
1007 1017 current = level
1008 1018 started = true
1009 1019 end
1010 1020 out << '</li></ul>' * (current - root)
1011 1021 out << '</li></ul>'
1012 1022 end
1013 1023 end
1014 1024 end
1015 1025
1016 1026 # Same as Rails' simple_format helper without using paragraphs
1017 1027 def simple_format_without_paragraph(text)
1018 1028 text.to_s.
1019 1029 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
1020 1030 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
1021 1031 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
1022 1032 html_safe
1023 1033 end
1024 1034
1025 1035 def lang_options_for_select(blank=true)
1026 1036 (blank ? [["(auto)", ""]] : []) + languages_options
1027 1037 end
1028 1038
1029 1039 def labelled_form_for(*args, &proc)
1030 1040 args << {} unless args.last.is_a?(Hash)
1031 1041 options = args.last
1032 1042 if args.first.is_a?(Symbol)
1033 1043 options.merge!(:as => args.shift)
1034 1044 end
1035 1045 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1036 1046 form_for(*args, &proc)
1037 1047 end
1038 1048
1039 1049 def labelled_fields_for(*args, &proc)
1040 1050 args << {} unless args.last.is_a?(Hash)
1041 1051 options = args.last
1042 1052 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
1043 1053 fields_for(*args, &proc)
1044 1054 end
1045 1055
1046 1056 def error_messages_for(*objects)
1047 1057 html = ""
1048 1058 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1049 1059 errors = objects.map {|o| o.errors.full_messages}.flatten
1050 1060 if errors.any?
1051 1061 html << "<div id='errorExplanation'><ul>\n"
1052 1062 errors.each do |error|
1053 1063 html << "<li>#{h error}</li>\n"
1054 1064 end
1055 1065 html << "</ul></div>\n"
1056 1066 end
1057 1067 html.html_safe
1058 1068 end
1059 1069
1060 1070 def delete_link(url, options={})
1061 1071 options = {
1062 1072 :method => :delete,
1063 1073 :data => {:confirm => l(:text_are_you_sure)},
1064 1074 :class => 'icon icon-del'
1065 1075 }.merge(options)
1066 1076
1067 1077 link_to l(:button_delete), url, options
1068 1078 end
1069 1079
1070 1080 def preview_link(url, form, target='preview', options={})
1071 1081 content_tag 'a', l(:label_preview), {
1072 1082 :href => "#",
1073 1083 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1074 1084 :accesskey => accesskey(:preview)
1075 1085 }.merge(options)
1076 1086 end
1077 1087
1078 1088 def link_to_function(name, function, html_options={})
1079 1089 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1080 1090 end
1081 1091
1082 1092 # Helper to render JSON in views
1083 1093 def raw_json(arg)
1084 1094 arg.to_json.to_s.gsub('/', '\/').html_safe
1085 1095 end
1086 1096
1087 1097 def back_url
1088 1098 url = params[:back_url]
1089 1099 if url.nil? && referer = request.env['HTTP_REFERER']
1090 1100 url = CGI.unescape(referer.to_s)
1091 1101 end
1092 1102 url
1093 1103 end
1094 1104
1095 1105 def back_url_hidden_field_tag
1096 1106 url = back_url
1097 1107 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1098 1108 end
1099 1109
1100 1110 def check_all_links(form_name)
1101 1111 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1102 1112 " | ".html_safe +
1103 1113 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1104 1114 end
1105 1115
1106 1116 def toggle_checkboxes_link(selector)
1107 1117 link_to_function '',
1108 1118 "toggleCheckboxesBySelector('#{selector}')",
1109 1119 :title => "#{l(:button_check_all)} / #{l(:button_uncheck_all)}",
1110 1120 :class => 'toggle-checkboxes'
1111 1121 end
1112 1122
1113 1123 def progress_bar(pcts, options={})
1114 1124 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1115 1125 pcts = pcts.collect(&:round)
1116 1126 pcts[1] = pcts[1] - pcts[0]
1117 1127 pcts << (100 - pcts[1] - pcts[0])
1118 1128 titles = options[:titles].to_a
1119 1129 titles[0] = "#{pcts[0]}%" if titles[0].blank?
1120 1130 legend = options[:legend] || ''
1121 1131 content_tag('table',
1122 1132 content_tag('tr',
1123 1133 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed', :title => titles[0]) : ''.html_safe) +
1124 1134 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done', :title => titles[1]) : ''.html_safe) +
1125 1135 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo', :title => titles[2]) : ''.html_safe)
1126 1136 ), :class => "progress progress-#{pcts[0]}").html_safe +
1127 1137 content_tag('p', legend, :class => 'percent').html_safe
1128 1138 end
1129 1139
1130 1140 def checked_image(checked=true)
1131 1141 if checked
1132 1142 @checked_image_tag ||= content_tag(:span, nil, :class => 'icon-only icon-checked')
1133 1143 end
1134 1144 end
1135 1145
1136 1146 def context_menu(url)
1137 1147 unless @context_menu_included
1138 1148 content_for :header_tags do
1139 1149 javascript_include_tag('context_menu') +
1140 1150 stylesheet_link_tag('context_menu')
1141 1151 end
1142 1152 if l(:direction) == 'rtl'
1143 1153 content_for :header_tags do
1144 1154 stylesheet_link_tag('context_menu_rtl')
1145 1155 end
1146 1156 end
1147 1157 @context_menu_included = true
1148 1158 end
1149 1159 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1150 1160 end
1151 1161
1152 1162 def calendar_for(field_id)
1153 1163 include_calendar_headers_tags
1154 1164 javascript_tag("$(function() { $('##{field_id}').addClass('date').datepicker(datepickerOptions); });")
1155 1165 end
1156 1166
1157 1167 def include_calendar_headers_tags
1158 1168 unless @calendar_headers_tags_included
1159 1169 tags = ''.html_safe
1160 1170 @calendar_headers_tags_included = true
1161 1171 content_for :header_tags do
1162 1172 start_of_week = Setting.start_of_week
1163 1173 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1164 1174 # Redmine uses 1..7 (monday..sunday) in settings and locales
1165 1175 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1166 1176 start_of_week = start_of_week.to_i % 7
1167 1177 tags << javascript_tag(
1168 1178 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1169 1179 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1170 1180 path_to_image('/images/calendar.png') +
1171 1181 "', showButtonPanel: true, showWeek: true, showOtherMonths: true, " +
1172 1182 "selectOtherMonths: true, changeMonth: true, changeYear: true, " +
1173 1183 "beforeShow: beforeShowDatePicker};")
1174 1184 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1175 1185 unless jquery_locale == 'en'
1176 1186 tags << javascript_include_tag("i18n/datepicker-#{jquery_locale}.js")
1177 1187 end
1178 1188 tags
1179 1189 end
1180 1190 end
1181 1191 end
1182 1192
1183 1193 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1184 1194 # Examples:
1185 1195 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1186 1196 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1187 1197 #
1188 1198 def stylesheet_link_tag(*sources)
1189 1199 options = sources.last.is_a?(Hash) ? sources.pop : {}
1190 1200 plugin = options.delete(:plugin)
1191 1201 sources = sources.map do |source|
1192 1202 if plugin
1193 1203 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1194 1204 elsif current_theme && current_theme.stylesheets.include?(source)
1195 1205 current_theme.stylesheet_path(source)
1196 1206 else
1197 1207 source
1198 1208 end
1199 1209 end
1200 1210 super *sources, options
1201 1211 end
1202 1212
1203 1213 # Overrides Rails' image_tag with themes and plugins support.
1204 1214 # Examples:
1205 1215 # image_tag('image.png') # => picks image.png from the current theme or defaults
1206 1216 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1207 1217 #
1208 1218 def image_tag(source, options={})
1209 1219 if plugin = options.delete(:plugin)
1210 1220 source = "/plugin_assets/#{plugin}/images/#{source}"
1211 1221 elsif current_theme && current_theme.images.include?(source)
1212 1222 source = current_theme.image_path(source)
1213 1223 end
1214 1224 super source, options
1215 1225 end
1216 1226
1217 1227 # Overrides Rails' javascript_include_tag with plugins support
1218 1228 # Examples:
1219 1229 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1220 1230 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1221 1231 #
1222 1232 def javascript_include_tag(*sources)
1223 1233 options = sources.last.is_a?(Hash) ? sources.pop : {}
1224 1234 if plugin = options.delete(:plugin)
1225 1235 sources = sources.map do |source|
1226 1236 if plugin
1227 1237 "/plugin_assets/#{plugin}/javascripts/#{source}"
1228 1238 else
1229 1239 source
1230 1240 end
1231 1241 end
1232 1242 end
1233 1243 super *sources, options
1234 1244 end
1235 1245
1236 1246 def sidebar_content?
1237 1247 content_for?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1238 1248 end
1239 1249
1240 1250 def view_layouts_base_sidebar_hook_response
1241 1251 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1242 1252 end
1243 1253
1244 1254 def email_delivery_enabled?
1245 1255 !!ActionMailer::Base.perform_deliveries
1246 1256 end
1247 1257
1248 1258 # Returns the avatar image tag for the given +user+ if avatars are enabled
1249 1259 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1250 1260 def avatar(user, options = { })
1251 1261 if Setting.gravatar_enabled?
1252 1262 options.merge!(:default => Setting.gravatar_default)
1253 1263 email = nil
1254 1264 if user.respond_to?(:mail)
1255 1265 email = user.mail
1256 1266 elsif user.to_s =~ %r{<(.+?)>}
1257 1267 email = $1
1258 1268 end
1259 1269 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1260 1270 else
1261 1271 ''
1262 1272 end
1263 1273 end
1264 1274
1265 1275 # Returns a link to edit user's avatar if avatars are enabled
1266 1276 def avatar_edit_link(user, options={})
1267 1277 if Setting.gravatar_enabled?
1268 1278 url = "https://gravatar.com"
1269 1279 link_to avatar(user, {:title => l(:button_edit)}.merge(options)), url, :target => '_blank'
1270 1280 end
1271 1281 end
1272 1282
1273 1283 def sanitize_anchor_name(anchor)
1274 1284 anchor.gsub(%r{[^\s\-\p{Word}]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1275 1285 end
1276 1286
1277 1287 # Returns the javascript tags that are included in the html layout head
1278 1288 def javascript_heads
1279 1289 tags = javascript_include_tag('jquery-1.11.1-ui-1.11.0-ujs-3.1.4', 'application', 'responsive')
1280 1290 unless User.current.pref.warn_on_leaving_unsaved == '0'
1281 1291 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1282 1292 end
1283 1293 tags
1284 1294 end
1285 1295
1286 1296 def favicon
1287 1297 "<link rel='shortcut icon' href='#{favicon_path}' />".html_safe
1288 1298 end
1289 1299
1290 1300 # Returns the path to the favicon
1291 1301 def favicon_path
1292 1302 icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : '/favicon.ico'
1293 1303 image_path(icon)
1294 1304 end
1295 1305
1296 1306 # Returns the full URL to the favicon
1297 1307 def favicon_url
1298 1308 # TODO: use #image_url introduced in Rails4
1299 1309 path = favicon_path
1300 1310 base = url_for(:controller => 'welcome', :action => 'index', :only_path => false)
1301 1311 base.sub(%r{/+$},'') + '/' + path.sub(%r{^/+},'')
1302 1312 end
1303 1313
1304 1314 def robot_exclusion_tag
1305 1315 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1306 1316 end
1307 1317
1308 1318 # Returns true if arg is expected in the API response
1309 1319 def include_in_api_response?(arg)
1310 1320 unless @included_in_api_response
1311 1321 param = params[:include]
1312 1322 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1313 1323 @included_in_api_response.collect!(&:strip)
1314 1324 end
1315 1325 @included_in_api_response.include?(arg.to_s)
1316 1326 end
1317 1327
1318 1328 # Returns options or nil if nometa param or X-Redmine-Nometa header
1319 1329 # was set in the request
1320 1330 def api_meta(options)
1321 1331 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1322 1332 # compatibility mode for activeresource clients that raise
1323 1333 # an error when deserializing an array with attributes
1324 1334 nil
1325 1335 else
1326 1336 options
1327 1337 end
1328 1338 end
1329 1339
1330 1340 def generate_csv(&block)
1331 1341 decimal_separator = l(:general_csv_decimal_separator)
1332 1342 encoding = l(:general_csv_encoding)
1333 1343 end
1334 1344
1335 1345 private
1336 1346
1337 1347 def wiki_helper
1338 1348 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1339 1349 extend helper
1340 1350 return self
1341 1351 end
1342 1352
1343 1353 def link_to_content_update(text, url_params = {}, html_options = {})
1344 1354 link_to(text, url_params, html_options)
1345 1355 end
1346 1356 end
@@ -1,31 +1,30
1 <table class="list">
1 <table class="list custom_fields">
2 2 <thead><tr>
3 3 <th><%=l(:field_name)%></th>
4 4 <th><%=l(:field_field_format)%></th>
5 5 <th><%=l(:field_is_required)%></th>
6 6 <% if tab[:name] == 'IssueCustomField' %>
7 7 <th><%=l(:field_is_for_all)%></th>
8 8 <th><%=l(:label_used_by)%></th>
9 9 <% end %>
10 <th><%=l(:button_sort)%></th>
11 10 <th></th>
12 11 </tr></thead>
13 12 <tbody>
14 13 <% (@custom_fields_by_type[tab[:name]] || []).sort.each do |custom_field| -%>
15 14 <% back_url = custom_fields_path(:tab => tab[:name]) %>
16 15 <tr class="<%= cycle("odd", "even") %>">
17 16 <td class="name"><%= link_to custom_field.name, edit_custom_field_path(custom_field) %></td>
18 17 <td><%= l(custom_field.format.label) %></td>
19 18 <td><%= checked_image custom_field.is_required? %></td>
20 19 <% if tab[:name] == 'IssueCustomField' %>
21 20 <td><%= checked_image custom_field.is_for_all? %></td>
22 21 <td><%= l(:label_x_projects, :count => custom_field.projects.count) if custom_field.is_a? IssueCustomField and !custom_field.is_for_all? %></td>
23 22 <% end %>
24 <td class="reorder"><%= reorder_links('custom_field', {:action => 'update', :id => custom_field, :back_url => back_url}, :put) %></td>
25 23 <td class="buttons">
24 <%= reorder_handle(custom_field, :url => custom_field_path(custom_field), :param => 'custom_field') %>
26 25 <%= delete_link custom_field_path(custom_field) %>
27 26 </td>
28 27 </tr>
29 28 <% end; reset_cycle %>
30 29 </tbody>
31 30 </table>
@@ -1,11 +1,15
1 1 <div class="contextual">
2 2 <%= link_to l(:label_custom_field_new), new_custom_field_path, :class => 'icon icon-add' %>
3 3 </div>
4 4
5 5 <%= title l(:label_custom_field_plural) %>
6 6
7 7 <% if @custom_fields_by_type.present? %>
8 8 <%= render_custom_fields_tabs(@custom_fields_by_type.keys) %>
9 9 <% else %>
10 10 <p class="nodata"><%= l(:label_no_data) %></p>
11 11 <% end %>
12
13 <%= javascript_tag do %>
14 $(function() { $("table.custom_fields tbody").positionedItems(); });
15 <% end %> No newline at end of file
@@ -1,32 +1,37
1 1 <h2><%=l(:label_enumerations)%></h2>
2 2
3 3 <% Enumeration.get_subclasses.each do |klass| %>
4 4 <h3><%= l(klass::OptionName) %></h3>
5 5
6 6 <% enumerations = klass.shared %>
7 7 <% if enumerations.any? %>
8 <table class="list"><thead>
8 <table class="list enumerations"><thead>
9 9 <tr>
10 10 <th><%= l(:field_name) %></th>
11 11 <th><%= l(:field_is_default) %></th>
12 12 <th><%= l(:field_active) %></th>
13 <th><%=l(:button_sort)%></th>
14 13 <th></th>
15 14 </tr></thead>
16 15 <% enumerations.each do |enumeration| %>
17 16 <tr class="<%= cycle('odd', 'even') %>">
18 17 <td class="name"><%= link_to enumeration, edit_enumeration_path(enumeration) %></td>
19 18 <td class="tick"><%= checked_image enumeration.is_default? %></td>
20 19 <td class="tick"><%= checked_image enumeration.active? %></td>
21 <td class="reorder"><%= reorder_links('enumeration', {:action => 'update', :id => enumeration}, :put) %></td>
22 <td class="buttons"><%= delete_link enumeration_path(enumeration) %></td>
20 <td class="buttons">
21 <%= reorder_handle(enumeration, :url => enumeration_path(enumeration), :param => 'enumeration') %>
22 <%= delete_link enumeration_path(enumeration) %>
23 </td>
23 24 </tr>
24 25 <% end %>
25 26 </table>
26 27 <% reset_cycle %>
27 28 <% end %>
28 29
29 30 <p><%= link_to l(:label_enumeration_new), new_enumeration_path(:type => klass.name) %></p>
30 31 <% end %>
31 32
32 33 <% html_title(l(:label_enumerations)) -%>
34
35 <%= javascript_tag do %>
36 $(function() { $("table.enumerations tbody").positionedItems(); });
37 <% end %> No newline at end of file
@@ -1,35 +1,38
1 1 <div class="contextual">
2 2 <%= link_to l(:label_issue_status_new), new_issue_status_path, :class => 'icon icon-add' %>
3 3 <%= link_to(l(:label_update_issue_done_ratios), update_issue_done_ratio_issue_statuses_path, :class => 'icon icon-multiple', :method => 'post', :data => {:confirm => l(:text_are_you_sure)}) if Issue.use_status_for_done_ratio? %>
4 4 </div>
5 5
6 6 <h2><%=l(:label_issue_status_plural)%></h2>
7 7
8 <table class="list">
8 <table class="list issue_statuses">
9 9 <thead><tr>
10 10 <th><%=l(:field_status)%></th>
11 11 <% if Issue.use_status_for_done_ratio? %>
12 12 <th><%=l(:field_done_ratio)%></th>
13 13 <% end %>
14 14 <th><%=l(:field_is_closed)%></th>
15 <th><%=l(:button_sort)%></th>
16 15 <th></th>
17 16 </tr></thead>
18 17 <tbody>
19 18 <% for status in @issue_statuses %>
20 19 <tr class="<%= cycle("odd", "even") %>">
21 20 <td class="name"><%= link_to status.name, edit_issue_status_path(status) %></td>
22 21 <% if Issue.use_status_for_done_ratio? %>
23 22 <td><%= status.default_done_ratio %></td>
24 23 <% end %>
25 24 <td><%= checked_image status.is_closed? %></td>
26 <td class="reorder"><%= reorder_links('issue_status', {:action => 'update', :id => status, :page => params[:page]}, :put) %></td>
27 25 <td class="buttons">
26 <%= reorder_handle(status) %>
28 27 <%= delete_link issue_status_path(status) %>
29 28 </td>
30 29 </tr>
31 30 <% end %>
32 31 </tbody>
33 32 </table>
34 33
35 34 <% html_title(l(:label_issue_status_plural)) -%>
35
36 <%= javascript_tag do %>
37 $(function() { $("table.issue_statuses tbody").positionedItems(); });
38 <% end %>
@@ -1,32 +1,31
1 1 <div class="contextual">
2 2 <%= link_to l(:label_role_new), new_role_path, :class => 'icon icon-add' %>
3 3 <%= link_to l(:label_permissions_report), permissions_roles_path, :class => 'icon icon-summary' %>
4 4 </div>
5 5
6 6 <h2><%=l(:label_role_plural)%></h2>
7 7
8 <table class="list">
8 <table class="list roles">
9 9 <thead><tr>
10 10 <th><%=l(:label_role)%></th>
11 <th><%=l(:button_sort)%></th>
12 11 <th></th>
13 12 </tr></thead>
14 13 <tbody>
15 14 <% for role in @roles %>
16 <tr class="<%= cycle("odd", "even") %>">
15 <tr class="<%= cycle("odd", "even") %> <%= role.builtin? ? "builtin" : "givable" %>">
17 16 <td class="name"><%= content_tag(role.builtin? ? 'em' : 'span', link_to(role.name, edit_role_path(role))) %></td>
18 <td class="reorder">
19 <% unless role.builtin? %>
20 <%= reorder_links('role', {:action => 'update', :id => role, :page => params[:page]}, :put) %>
21 <% end %>
22 </td>
23 17 <td class="buttons">
18 <%= reorder_handle(role) unless role.builtin? %>
24 19 <%= link_to l(:button_copy), new_role_path(:copy => role), :class => 'icon icon-copy' %>
25 20 <%= delete_link role_path(role) unless role.builtin? %>
26 21 </td>
27 22 </tr>
28 23 <% end %>
29 24 </tbody>
30 25 </table>
31 26
32 27 <% html_title(l(:label_role_plural)) -%>
28
29 <%= javascript_tag do %>
30 $(function() { $("table.roles tbody").positionedItems({items: ".givable"}); });
31 <% end %> No newline at end of file
@@ -1,37 +1,38
1 1 <div class="contextual">
2 2 <%= link_to l(:label_tracker_new), new_tracker_path, :class => 'icon icon-add' %>
3 3 <%= link_to l(:field_summary), fields_trackers_path, :class => 'icon icon-summary' %>
4 4 </div>
5 5
6 6 <h2><%=l(:label_tracker_plural)%></h2>
7 7
8 <table class="list">
8 <table class="list trackers">
9 9 <thead><tr>
10 10 <th><%=l(:label_tracker)%></th>
11 11 <th></th>
12 <th><%=l(:button_sort)%></th>
13 12 <th></th>
14 13 </tr></thead>
15 14 <tbody>
16 15 <% for tracker in @trackers %>
17 16 <tr class="<%= cycle("odd", "even") %>">
18 17 <td class="name"><%= link_to tracker.name, edit_tracker_path(tracker) %></td>
19 18 <td>
20 19 <% unless tracker.workflow_rules.count > 0 %>
21 20 <span class="icon icon-warning">
22 21 <%= l(:text_tracker_no_workflow) %> (<%= link_to l(:button_edit), workflows_edit_path(:tracker_id => tracker) %>)
23 22 </span>
24 23 <% end %>
25 24 </td>
26 <td class="reorder">
27 <%= reorder_links('tracker', {:action => 'update', :id => tracker, :page => params[:page]}, :put) %>
28 </td>
29 25 <td class="buttons">
26 <%= reorder_handle(tracker) %>
30 27 <%= delete_link tracker_path(tracker) %>
31 28 </td>
32 29 </tr>
33 30 <% end %>
34 31 </tbody>
35 32 </table>
36 33
37 34 <% html_title(l(:label_tracker_plural)) -%>
35
36 <%= javascript_tag do %>
37 $(function() { $("table.trackers tbody").positionedItems(); });
38 <% end %>
@@ -1,703 +1,742
1 1 /* Redmine - project management software
2 2 Copyright (C) 2006-2016 Jean-Philippe Lang */
3 3
4 4 function checkAll(id, checked) {
5 5 $('#'+id).find('input[type=checkbox]:enabled').prop('checked', checked);
6 6 }
7 7
8 8 function toggleCheckboxesBySelector(selector) {
9 9 var all_checked = true;
10 10 $(selector).each(function(index) {
11 11 if (!$(this).is(':checked')) { all_checked = false; }
12 12 });
13 13 $(selector).prop('checked', !all_checked);
14 14 }
15 15
16 16 function showAndScrollTo(id, focus) {
17 17 $('#'+id).show();
18 18 if (focus !== null) {
19 19 $('#'+focus).focus();
20 20 }
21 21 $('html, body').animate({scrollTop: $('#'+id).offset().top}, 100);
22 22 }
23 23
24 24 function toggleRowGroup(el) {
25 25 var tr = $(el).parents('tr').first();
26 26 var n = tr.next();
27 27 tr.toggleClass('open');
28 28 while (n.length && !n.hasClass('group')) {
29 29 n.toggle();
30 30 n = n.next('tr');
31 31 }
32 32 }
33 33
34 34 function collapseAllRowGroups(el) {
35 35 var tbody = $(el).parents('tbody').first();
36 36 tbody.children('tr').each(function(index) {
37 37 if ($(this).hasClass('group')) {
38 38 $(this).removeClass('open');
39 39 } else {
40 40 $(this).hide();
41 41 }
42 42 });
43 43 }
44 44
45 45 function expandAllRowGroups(el) {
46 46 var tbody = $(el).parents('tbody').first();
47 47 tbody.children('tr').each(function(index) {
48 48 if ($(this).hasClass('group')) {
49 49 $(this).addClass('open');
50 50 } else {
51 51 $(this).show();
52 52 }
53 53 });
54 54 }
55 55
56 56 function toggleAllRowGroups(el) {
57 57 var tr = $(el).parents('tr').first();
58 58 if (tr.hasClass('open')) {
59 59 collapseAllRowGroups(el);
60 60 } else {
61 61 expandAllRowGroups(el);
62 62 }
63 63 }
64 64
65 65 function toggleFieldset(el) {
66 66 var fieldset = $(el).parents('fieldset').first();
67 67 fieldset.toggleClass('collapsed');
68 68 fieldset.children('div').toggle();
69 69 }
70 70
71 71 function hideFieldset(el) {
72 72 var fieldset = $(el).parents('fieldset').first();
73 73 fieldset.toggleClass('collapsed');
74 74 fieldset.children('div').hide();
75 75 }
76 76
77 77 // columns selection
78 78 function moveOptions(theSelFrom, theSelTo) {
79 79 $(theSelFrom).find('option:selected').detach().prop("selected", false).appendTo($(theSelTo));
80 80 }
81 81
82 82 function moveOptionUp(theSel) {
83 83 $(theSel).find('option:selected').each(function(){
84 84 $(this).prev(':not(:selected)').detach().insertAfter($(this));
85 85 });
86 86 }
87 87
88 88 function moveOptionTop(theSel) {
89 89 $(theSel).find('option:selected').detach().prependTo($(theSel));
90 90 }
91 91
92 92 function moveOptionDown(theSel) {
93 93 $($(theSel).find('option:selected').get().reverse()).each(function(){
94 94 $(this).next(':not(:selected)').detach().insertBefore($(this));
95 95 });
96 96 }
97 97
98 98 function moveOptionBottom(theSel) {
99 99 $(theSel).find('option:selected').detach().appendTo($(theSel));
100 100 }
101 101
102 102 function initFilters() {
103 103 $('#add_filter_select').change(function() {
104 104 addFilter($(this).val(), '', []);
105 105 });
106 106 $('#filters-table td.field input[type=checkbox]').each(function() {
107 107 toggleFilter($(this).val());
108 108 });
109 109 $('#filters-table').on('click', 'td.field input[type=checkbox]', function() {
110 110 toggleFilter($(this).val());
111 111 });
112 112 $('#filters-table').on('click', '.toggle-multiselect', function() {
113 113 toggleMultiSelect($(this).siblings('select'));
114 114 });
115 115 $('#filters-table').on('keypress', 'input[type=text]', function(e) {
116 116 if (e.keyCode == 13) $(this).closest('form').submit();
117 117 });
118 118 }
119 119
120 120 function addFilter(field, operator, values) {
121 121 var fieldId = field.replace('.', '_');
122 122 var tr = $('#tr_'+fieldId);
123 123 if (tr.length > 0) {
124 124 tr.show();
125 125 } else {
126 126 buildFilterRow(field, operator, values);
127 127 }
128 128 $('#cb_'+fieldId).prop('checked', true);
129 129 toggleFilter(field);
130 130 $('#add_filter_select').val('').find('option').each(function() {
131 131 if ($(this).attr('value') == field) {
132 132 $(this).attr('disabled', true);
133 133 }
134 134 });
135 135 }
136 136
137 137 function buildFilterRow(field, operator, values) {
138 138 var fieldId = field.replace('.', '_');
139 139 var filterTable = $("#filters-table");
140 140 var filterOptions = availableFilters[field];
141 141 if (!filterOptions) return;
142 142 var operators = operatorByType[filterOptions['type']];
143 143 var filterValues = filterOptions['values'];
144 144 var i, select;
145 145
146 146 var tr = $('<tr class="filter">').attr('id', 'tr_'+fieldId).html(
147 147 '<td class="field"><input checked="checked" id="cb_'+fieldId+'" name="f[]" value="'+field+'" type="checkbox"><label for="cb_'+fieldId+'"> '+filterOptions['name']+'</label></td>' +
148 148 '<td class="operator"><select id="operators_'+fieldId+'" name="op['+field+']"></td>' +
149 149 '<td class="values"></td>'
150 150 );
151 151 filterTable.append(tr);
152 152
153 153 select = tr.find('td.operator select');
154 154 for (i = 0; i < operators.length; i++) {
155 155 var option = $('<option>').val(operators[i]).text(operatorLabels[operators[i]]);
156 156 if (operators[i] == operator) { option.attr('selected', true); }
157 157 select.append(option);
158 158 }
159 159 select.change(function(){ toggleOperator(field); });
160 160
161 161 switch (filterOptions['type']) {
162 162 case "list":
163 163 case "list_optional":
164 164 case "list_status":
165 165 case "list_subprojects":
166 166 tr.find('td.values').append(
167 167 '<span style="display:none;"><select class="value" id="values_'+fieldId+'_1" name="v['+field+'][]"></select>' +
168 168 ' <span class="toggle-multiselect">&nbsp;</span></span>'
169 169 );
170 170 select = tr.find('td.values select');
171 171 if (values.length > 1) { select.attr('multiple', true); }
172 172 for (i = 0; i < filterValues.length; i++) {
173 173 var filterValue = filterValues[i];
174 174 var option = $('<option>');
175 175 if ($.isArray(filterValue)) {
176 176 option.val(filterValue[1]).text(filterValue[0]);
177 177 if ($.inArray(filterValue[1], values) > -1) {option.attr('selected', true);}
178 178 } else {
179 179 option.val(filterValue).text(filterValue);
180 180 if ($.inArray(filterValue, values) > -1) {option.attr('selected', true);}
181 181 }
182 182 select.append(option);
183 183 }
184 184 break;
185 185 case "date":
186 186 case "date_past":
187 187 tr.find('td.values').append(
188 188 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="10" class="value date_value" /></span>' +
189 189 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="10" class="value date_value" /></span>' +
190 190 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="3" class="value" /> '+labelDayPlural+'</span>'
191 191 );
192 192 $('#values_'+fieldId+'_1').val(values[0]).datepicker(datepickerOptions);
193 193 $('#values_'+fieldId+'_2').val(values[1]).datepicker(datepickerOptions);
194 194 $('#values_'+fieldId).val(values[0]);
195 195 break;
196 196 case "string":
197 197 case "text":
198 198 tr.find('td.values').append(
199 199 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="30" class="value" /></span>'
200 200 );
201 201 $('#values_'+fieldId).val(values[0]);
202 202 break;
203 203 case "relation":
204 204 tr.find('td.values').append(
205 205 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="6" class="value" /></span>' +
206 206 '<span style="display:none;"><select class="value" name="v['+field+'][]" id="values_'+fieldId+'_1"></select></span>'
207 207 );
208 208 $('#values_'+fieldId).val(values[0]);
209 209 select = tr.find('td.values select');
210 210 for (i = 0; i < allProjects.length; i++) {
211 211 var filterValue = allProjects[i];
212 212 var option = $('<option>');
213 213 option.val(filterValue[1]).text(filterValue[0]);
214 214 if (values[0] == filterValue[1]) { option.attr('selected', true); }
215 215 select.append(option);
216 216 }
217 217 break;
218 218 case "integer":
219 219 case "float":
220 220 case "tree":
221 221 tr.find('td.values').append(
222 222 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="6" class="value" /></span>' +
223 223 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="6" class="value" /></span>'
224 224 );
225 225 $('#values_'+fieldId+'_1').val(values[0]);
226 226 $('#values_'+fieldId+'_2').val(values[1]);
227 227 break;
228 228 }
229 229 }
230 230
231 231 function toggleFilter(field) {
232 232 var fieldId = field.replace('.', '_');
233 233 if ($('#cb_' + fieldId).is(':checked')) {
234 234 $("#operators_" + fieldId).show().removeAttr('disabled');
235 235 toggleOperator(field);
236 236 } else {
237 237 $("#operators_" + fieldId).hide().attr('disabled', true);
238 238 enableValues(field, []);
239 239 }
240 240 }
241 241
242 242 function enableValues(field, indexes) {
243 243 var fieldId = field.replace('.', '_');
244 244 $('#tr_'+fieldId+' td.values .value').each(function(index) {
245 245 if ($.inArray(index, indexes) >= 0) {
246 246 $(this).removeAttr('disabled');
247 247 $(this).parents('span').first().show();
248 248 } else {
249 249 $(this).val('');
250 250 $(this).attr('disabled', true);
251 251 $(this).parents('span').first().hide();
252 252 }
253 253
254 254 if ($(this).hasClass('group')) {
255 255 $(this).addClass('open');
256 256 } else {
257 257 $(this).show();
258 258 }
259 259 });
260 260 }
261 261
262 262 function toggleOperator(field) {
263 263 var fieldId = field.replace('.', '_');
264 264 var operator = $("#operators_" + fieldId);
265 265 switch (operator.val()) {
266 266 case "!*":
267 267 case "*":
268 268 case "t":
269 269 case "ld":
270 270 case "w":
271 271 case "lw":
272 272 case "l2w":
273 273 case "m":
274 274 case "lm":
275 275 case "y":
276 276 case "o":
277 277 case "c":
278 278 case "*o":
279 279 case "!o":
280 280 enableValues(field, []);
281 281 break;
282 282 case "><":
283 283 enableValues(field, [0,1]);
284 284 break;
285 285 case "<t+":
286 286 case ">t+":
287 287 case "><t+":
288 288 case "t+":
289 289 case ">t-":
290 290 case "<t-":
291 291 case "><t-":
292 292 case "t-":
293 293 enableValues(field, [2]);
294 294 break;
295 295 case "=p":
296 296 case "=!p":
297 297 case "!p":
298 298 enableValues(field, [1]);
299 299 break;
300 300 default:
301 301 enableValues(field, [0]);
302 302 break;
303 303 }
304 304 }
305 305
306 306 function toggleMultiSelect(el) {
307 307 if (el.attr('multiple')) {
308 308 el.removeAttr('multiple');
309 309 el.attr('size', 1);
310 310 } else {
311 311 el.attr('multiple', true);
312 312 if (el.children().length > 10)
313 313 el.attr('size', 10);
314 314 else
315 315 el.attr('size', 4);
316 316 }
317 317 }
318 318
319 319 function showTab(name, url) {
320 320 $('#tab-content-' + name).parent().find('.tab-content').hide();
321 321 $('#tab-content-' + name).parent().find('div.tabs a').removeClass('selected');
322 322 $('#tab-content-' + name).show();
323 323 $('#tab-' + name).addClass('selected');
324 324 //replaces current URL with the "href" attribute of the current link
325 325 //(only triggered if supported by browser)
326 326 if ("replaceState" in window.history) {
327 327 window.history.replaceState(null, document.title, url);
328 328 }
329 329 return false;
330 330 }
331 331
332 332 function moveTabRight(el) {
333 333 var lis = $(el).parents('div.tabs').first().find('ul').children();
334 334 var bw = $(el).parents('div.tabs-buttons').outerWidth(true);
335 335 var tabsWidth = 0;
336 336 var i = 0;
337 337 lis.each(function() {
338 338 if ($(this).is(':visible')) {
339 339 tabsWidth += $(this).outerWidth(true);
340 340 }
341 341 });
342 342 if (tabsWidth < $(el).parents('div.tabs').first().width() - bw) { return; }
343 343 $(el).siblings('.tab-left').removeClass('disabled');
344 344 while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
345 345 var w = lis.eq(i).width();
346 346 lis.eq(i).hide();
347 347 if (tabsWidth - w < $(el).parents('div.tabs').first().width() - bw) {
348 348 $(el).addClass('disabled');
349 349 }
350 350 }
351 351
352 352 function moveTabLeft(el) {
353 353 var lis = $(el).parents('div.tabs').first().find('ul').children();
354 354 var i = 0;
355 355 while (i < lis.length && !lis.eq(i).is(':visible')) { i++; }
356 356 if (i > 0) {
357 357 lis.eq(i-1).show();
358 358 $(el).siblings('.tab-right').removeClass('disabled');
359 359 }
360 360 if (i <= 1) {
361 361 $(el).addClass('disabled');
362 362 }
363 363 }
364 364
365 365 function displayTabsButtons() {
366 366 var lis;
367 367 var tabsWidth;
368 368 var el;
369 369 var numHidden;
370 370 $('div.tabs').each(function() {
371 371 el = $(this);
372 372 lis = el.find('ul').children();
373 373 tabsWidth = 0;
374 374 numHidden = 0;
375 375 lis.each(function(){
376 376 if ($(this).is(':visible')) {
377 377 tabsWidth += $(this).outerWidth(true);
378 378 } else {
379 379 numHidden++;
380 380 }
381 381 });
382 382 var bw = $(el).parents('div.tabs-buttons').outerWidth(true);
383 383 if ((tabsWidth < el.width() - bw) && (lis.first().is(':visible'))) {
384 384 el.find('div.tabs-buttons').hide();
385 385 } else {
386 386 el.find('div.tabs-buttons').show().children('button.tab-left').toggleClass('disabled', numHidden == 0);
387 387 }
388 388 });
389 389 }
390 390
391 391 function setPredecessorFieldsVisibility() {
392 392 var relationType = $('#relation_relation_type');
393 393 if (relationType.val() == "precedes" || relationType.val() == "follows") {
394 394 $('#predecessor_fields').show();
395 395 } else {
396 396 $('#predecessor_fields').hide();
397 397 }
398 398 }
399 399
400 400 function showModal(id, width, title) {
401 401 var el = $('#'+id).first();
402 402 if (el.length === 0 || el.is(':visible')) {return;}
403 403 if (!title) title = el.find('h3.title').text();
404 404 // moves existing modals behind the transparent background
405 405 $(".modal").zIndex(99);
406 406 el.dialog({
407 407 width: width,
408 408 modal: true,
409 409 resizable: false,
410 410 dialogClass: 'modal',
411 411 title: title
412 412 }).on('dialogclose', function(){
413 413 $(".modal").zIndex(101);
414 414 });
415 415 el.find("input[type=text], input[type=submit]").first().focus();
416 416 }
417 417
418 418 function hideModal(el) {
419 419 var modal;
420 420 if (el) {
421 421 modal = $(el).parents('.ui-dialog-content');
422 422 } else {
423 423 modal = $('#ajax-modal');
424 424 }
425 425 modal.dialog("close");
426 426 }
427 427
428 428 function submitPreview(url, form, target) {
429 429 $.ajax({
430 430 url: url,
431 431 type: 'post',
432 432 data: $('#'+form).serialize(),
433 433 success: function(data){
434 434 $('#'+target).html(data);
435 435 }
436 436 });
437 437 }
438 438
439 439 function collapseScmEntry(id) {
440 440 $('.'+id).each(function() {
441 441 if ($(this).hasClass('open')) {
442 442 collapseScmEntry($(this).attr('id'));
443 443 }
444 444 $(this).hide();
445 445 });
446 446 $('#'+id).removeClass('open');
447 447 }
448 448
449 449 function expandScmEntry(id) {
450 450 $('.'+id).each(function() {
451 451 $(this).show();
452 452 if ($(this).hasClass('loaded') && !$(this).hasClass('collapsed')) {
453 453 expandScmEntry($(this).attr('id'));
454 454 }
455 455 });
456 456 $('#'+id).addClass('open');
457 457 }
458 458
459 459 function scmEntryClick(id, url) {
460 460 var el = $('#'+id);
461 461 if (el.hasClass('open')) {
462 462 collapseScmEntry(id);
463 463 el.addClass('collapsed');
464 464 return false;
465 465 } else if (el.hasClass('loaded')) {
466 466 expandScmEntry(id);
467 467 el.removeClass('collapsed');
468 468 return false;
469 469 }
470 470 if (el.hasClass('loading')) {
471 471 return false;
472 472 }
473 473 el.addClass('loading');
474 474 $.ajax({
475 475 url: url,
476 476 success: function(data) {
477 477 el.after(data);
478 478 el.addClass('open').addClass('loaded').removeClass('loading');
479 479 }
480 480 });
481 481 return true;
482 482 }
483 483
484 484 function randomKey(size) {
485 485 var chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
486 486 var key = '';
487 487 for (var i = 0; i < size; i++) {
488 488 key += chars.charAt(Math.floor(Math.random() * chars.length));
489 489 }
490 490 return key;
491 491 }
492 492
493 493 function updateIssueFrom(url, el) {
494 494 $('#all_attributes input, #all_attributes textarea, #all_attributes select').each(function(){
495 495 $(this).data('valuebeforeupdate', $(this).val());
496 496 });
497 497 if (el) {
498 498 $("#form_update_triggered_by").val($(el).attr('id'));
499 499 }
500 500 return $.ajax({
501 501 url: url,
502 502 type: 'post',
503 503 data: $('#issue-form').serialize()
504 504 });
505 505 }
506 506
507 507 function replaceIssueFormWith(html){
508 508 var replacement = $(html);
509 509 $('#all_attributes input, #all_attributes textarea, #all_attributes select').each(function(){
510 510 var object_id = $(this).attr('id');
511 511 if (object_id && $(this).data('valuebeforeupdate')!=$(this).val()) {
512 512 replacement.find('#'+object_id).val($(this).val());
513 513 }
514 514 });
515 515 $('#all_attributes').empty();
516 516 $('#all_attributes').prepend(replacement);
517 517 }
518 518
519 519 function updateBulkEditFrom(url) {
520 520 $.ajax({
521 521 url: url,
522 522 type: 'post',
523 523 data: $('#bulk_edit_form').serialize()
524 524 });
525 525 }
526 526
527 527 function observeAutocompleteField(fieldId, url, options) {
528 528 $(document).ready(function() {
529 529 $('#'+fieldId).autocomplete($.extend({
530 530 source: url,
531 531 minLength: 2,
532 532 position: {collision: "flipfit"},
533 533 search: function(){$('#'+fieldId).addClass('ajax-loading');},
534 534 response: function(){$('#'+fieldId).removeClass('ajax-loading');}
535 535 }, options));
536 536 $('#'+fieldId).addClass('autocomplete');
537 537 });
538 538 }
539 539
540 540 function observeSearchfield(fieldId, targetId, url) {
541 541 $('#'+fieldId).each(function() {
542 542 var $this = $(this);
543 543 $this.addClass('autocomplete');
544 544 $this.attr('data-value-was', $this.val());
545 545 var check = function() {
546 546 var val = $this.val();
547 547 if ($this.attr('data-value-was') != val){
548 548 $this.attr('data-value-was', val);
549 549 $.ajax({
550 550 url: url,
551 551 type: 'get',
552 552 data: {q: $this.val()},
553 553 success: function(data){ if(targetId) $('#'+targetId).html(data); },
554 554 beforeSend: function(){ $this.addClass('ajax-loading'); },
555 555 complete: function(){ $this.removeClass('ajax-loading'); }
556 556 });
557 557 }
558 558 };
559 559 var reset = function() {
560 560 if (timer) {
561 561 clearInterval(timer);
562 562 timer = setInterval(check, 300);
563 563 }
564 564 };
565 565 var timer = setInterval(check, 300);
566 566 $this.bind('keyup click mousemove', reset);
567 567 });
568 568 }
569 569
570 570 function beforeShowDatePicker(input, inst) {
571 571 var default_date = null;
572 572 switch ($(input).attr("id")) {
573 573 case "issue_start_date" :
574 574 if ($("#issue_due_date").size() > 0) {
575 575 default_date = $("#issue_due_date").val();
576 576 }
577 577 break;
578 578 case "issue_due_date" :
579 579 if ($("#issue_start_date").size() > 0) {
580 580 var start_date = $("#issue_start_date").val();
581 581 if (start_date != "") {
582 582 start_date = new Date(Date.parse(start_date));
583 583 if (start_date > new Date()) {
584 584 default_date = $("#issue_start_date").val();
585 585 }
586 586 }
587 587 }
588 588 break;
589 589 }
590 590 $(input).datepicker("option", "defaultDate", default_date);
591 591 }
592 592
593 (function($){
594 $.fn.positionedItems = function(sortableOptions, options){
595 var settings = $.extend({
596 firstPosition: 1
597 }, options );
598
599 return this.sortable($.extend({
600 handle: ".sort-handle",
601 helper: function(event, ui){
602 ui.children().each(function(){
603 $(this).width($(this).width());
604 });
605 return ui;
606 },
607 update: function(event, ui) {
608 var sortable = $(this);
609 var url = ui.item.find(".sort-handle").data("reorder-url");
610 var param = ui.item.find(".sort-handle").data("reorder-param");
611 var data = {};
612 data[param] = {position: ui.item.index() + settings['firstPosition']};
613 $.ajax({
614 url: url,
615 type: 'put',
616 dataType: 'script',
617 data: data,
618 success: function(data){
619 sortable.children(":even").removeClass("even").addClass("odd");
620 sortable.children(":odd").removeClass("odd").addClass("even");
621 },
622 error: function(jqXHR, textStatus, errorThrown){
623 alert(jqXHR.status);
624 sortable.sortable("cancel");
625 }
626 });
627 },
628 }, sortableOptions));
629 }
630 }( jQuery ));
631
593 632 function initMyPageSortable(list, url) {
594 633 $('#list-'+list).sortable({
595 634 connectWith: '.block-receiver',
596 635 tolerance: 'pointer',
597 636 update: function(){
598 637 $.ajax({
599 638 url: url,
600 639 type: 'post',
601 640 data: {'blocks': $.map($('#list-'+list).children(), function(el){return $(el).attr('id');})}
602 641 });
603 642 }
604 643 });
605 644 $("#list-top, #list-left, #list-right").disableSelection();
606 645 }
607 646
608 647 var warnLeavingUnsavedMessage;
609 648 function warnLeavingUnsaved(message) {
610 649 warnLeavingUnsavedMessage = message;
611 650 $(document).on('submit', 'form', function(){
612 651 $('textarea').removeData('changed');
613 652 });
614 653 $(document).on('change', 'textarea', function(){
615 654 $(this).data('changed', 'changed');
616 655 });
617 656 window.onbeforeunload = function(){
618 657 var warn = false;
619 658 $('textarea').blur().each(function(){
620 659 if ($(this).data('changed')) {
621 660 warn = true;
622 661 }
623 662 });
624 663 if (warn) {return warnLeavingUnsavedMessage;}
625 664 };
626 665 }
627 666
628 667 function setupAjaxIndicator() {
629 668 $(document).bind('ajaxSend', function(event, xhr, settings) {
630 669 if ($('.ajax-loading').length === 0 && settings.contentType != 'application/octet-stream') {
631 670 $('#ajax-indicator').show();
632 671 }
633 672 });
634 673 $(document).bind('ajaxStop', function() {
635 674 $('#ajax-indicator').hide();
636 675 });
637 676 }
638 677
639 678 function setupTabs() {
640 679 if($('.tabs').length > 0) {
641 680 displayTabsButtons();
642 681 $(window).resize(displayTabsButtons);
643 682 }
644 683 }
645 684
646 685 function hideOnLoad() {
647 686 $('.hol').hide();
648 687 }
649 688
650 689 function addFormObserversForDoubleSubmit() {
651 690 $('form[method=post]').each(function() {
652 691 if (!$(this).hasClass('multiple-submit')) {
653 692 $(this).submit(function(form_submission) {
654 693 if ($(form_submission.target).attr('data-submitted')) {
655 694 form_submission.preventDefault();
656 695 } else {
657 696 $(form_submission.target).attr('data-submitted', true);
658 697 }
659 698 });
660 699 }
661 700 });
662 701 }
663 702
664 703 function defaultFocus(){
665 704 if (($('#content :focus').length == 0) && (window.location.hash == '')) {
666 705 $('#content input[type=text], #content textarea').first().focus();
667 706 }
668 707 }
669 708
670 709 function blockEventPropagation(event) {
671 710 event.stopPropagation();
672 711 event.preventDefault();
673 712 }
674 713
675 714 function toggleDisabledOnChange() {
676 715 var checked = $(this).is(':checked');
677 716 $($(this).data('disables')).attr('disabled', checked);
678 717 $($(this).data('enables')).attr('disabled', !checked);
679 718 }
680 719 function toggleDisabledInit() {
681 720 $('input[data-disables], input[data-enables]').each(toggleDisabledOnChange);
682 721 }
683 722 $(document).ready(function(){
684 723 $('#content').on('change', 'input[data-disables], input[data-enables]', toggleDisabledOnChange);
685 724 toggleDisabledInit();
686 725 });
687 726
688 727 function keepAnchorOnSignIn(form){
689 728 var hash = decodeURIComponent(self.document.location.hash);
690 729 if (hash) {
691 730 if (hash.indexOf("#") === -1) {
692 731 hash = "#" + hash;
693 732 }
694 733 form.action = form.action + hash;
695 734 }
696 735 return true;
697 736 }
698 737
699 738 $(document).ready(setupAjaxIndicator);
700 739 $(document).ready(hideOnLoad);
701 740 $(document).ready(addFormObserversForDoubleSubmit);
702 741 $(document).ready(defaultFocus);
703 742 $(document).ready(setupTabs);
@@ -1,205 +1,205
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2016 Jean-Philippe Lang
3 3 #
4 4 # This program is free software; you can redistribute it and/or
5 5 # modify it under the terms of the GNU General Public License
6 6 # as published by the Free Software Foundation; either version 2
7 7 # of the License, or (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software
16 16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 17
18 18 require File.expand_path('../../test_helper', __FILE__)
19 19
20 20 class TrackersControllerTest < ActionController::TestCase
21 21 fixtures :trackers, :projects, :projects_trackers, :users, :issues, :custom_fields, :issue_statuses
22 22
23 23 def setup
24 24 User.current = nil
25 25 @request.session[:user_id] = 1 # admin
26 26 end
27 27
28 28 def test_index
29 29 get :index
30 30 assert_response :success
31 31 assert_template 'index'
32 32 end
33 33
34 34 def test_index_by_anonymous_should_redirect_to_login_form
35 35 @request.session[:user_id] = nil
36 36 get :index
37 37 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Ftrackers'
38 38 end
39 39
40 40 def test_index_by_user_should_respond_with_406
41 41 @request.session[:user_id] = 2
42 42 get :index
43 43 assert_response 406
44 44 end
45 45
46 46 def test_new
47 47 get :new
48 48 assert_response :success
49 49 assert_template 'new'
50 50 end
51 51
52 52 def test_create
53 53 assert_difference 'Tracker.count' do
54 54 post :create, :tracker => { :name => 'New tracker', :default_status_id => 1, :project_ids => ['1', '', ''], :custom_field_ids => ['1', '6', ''] }
55 55 end
56 56 assert_redirected_to :action => 'index'
57 57 tracker = Tracker.order('id DESC').first
58 58 assert_equal 'New tracker', tracker.name
59 59 assert_equal [1], tracker.project_ids.sort
60 60 assert_equal Tracker::CORE_FIELDS, tracker.core_fields
61 61 assert_equal [1, 6], tracker.custom_field_ids.sort
62 62 assert_equal 0, tracker.workflow_rules.count
63 63 end
64 64
65 65 def test_create_with_disabled_core_fields
66 66 assert_difference 'Tracker.count' do
67 67 post :create, :tracker => { :name => 'New tracker', :default_status_id => 1, :core_fields => ['assigned_to_id', 'fixed_version_id', ''] }
68 68 end
69 69 assert_redirected_to :action => 'index'
70 70 tracker = Tracker.order('id DESC').first
71 71 assert_equal 'New tracker', tracker.name
72 72 assert_equal %w(assigned_to_id fixed_version_id), tracker.core_fields
73 73 end
74 74
75 75 def test_create_new_with_workflow_copy
76 76 assert_difference 'Tracker.count' do
77 77 post :create, :tracker => { :name => 'New tracker', :default_status_id => 1 }, :copy_workflow_from => 1
78 78 end
79 79 assert_redirected_to :action => 'index'
80 80 tracker = Tracker.find_by_name('New tracker')
81 81 assert_equal 0, tracker.projects.count
82 82 assert_equal Tracker.find(1).workflow_rules.count, tracker.workflow_rules.count
83 83 end
84 84
85 85 def test_create_with_failure
86 86 assert_no_difference 'Tracker.count' do
87 87 post :create, :tracker => { :name => '', :project_ids => ['1', '', ''],
88 88 :custom_field_ids => ['1', '6', ''] }
89 89 end
90 90 assert_response :success
91 91 assert_template 'new'
92 92 assert_select_error /name cannot be blank/i
93 93 end
94 94
95 95 def test_edit
96 96 Tracker.find(1).project_ids = [1, 3]
97 97
98 98 get :edit, :id => 1
99 99 assert_response :success
100 100 assert_template 'edit'
101 101
102 102 assert_select 'input[name=?][value="1"][checked=checked]', 'tracker[project_ids][]'
103 103 assert_select 'input[name=?][value="2"]:not([checked])', 'tracker[project_ids][]'
104 104
105 105 assert_select 'input[name=?][value=""][type=hidden]', 'tracker[project_ids][]'
106 106 end
107 107
108 108 def test_edit_should_check_core_fields
109 109 tracker = Tracker.find(1)
110 110 tracker.core_fields = %w(assigned_to_id fixed_version_id)
111 111 tracker.save!
112 112
113 113 get :edit, :id => 1
114 114 assert_response :success
115 115 assert_template 'edit'
116 116
117 117 assert_select 'input[name=?][value=assigned_to_id][checked=checked]', 'tracker[core_fields][]'
118 118 assert_select 'input[name=?][value=fixed_version_id][checked=checked]', 'tracker[core_fields][]'
119 119
120 120 assert_select 'input[name=?][value=category_id]', 'tracker[core_fields][]'
121 121 assert_select 'input[name=?][value=category_id][checked=checked]', 'tracker[core_fields][]', 0
122 122
123 123 assert_select 'input[name=?][value=""][type=hidden]', 'tracker[core_fields][]'
124 124 end
125 125
126 126 def test_update
127 127 put :update, :id => 1, :tracker => { :name => 'Renamed',
128 128 :project_ids => ['1', '2', ''] }
129 129 assert_redirected_to :action => 'index'
130 130 assert_equal [1, 2], Tracker.find(1).project_ids.sort
131 131 end
132 132
133 133 def test_update_without_projects
134 134 put :update, :id => 1, :tracker => { :name => 'Renamed',
135 135 :project_ids => [''] }
136 136 assert_redirected_to :action => 'index'
137 137 assert Tracker.find(1).project_ids.empty?
138 138 end
139 139
140 140 def test_update_without_core_fields
141 141 put :update, :id => 1, :tracker => { :name => 'Renamed', :core_fields => [''] }
142 142 assert_redirected_to :action => 'index'
143 143 assert Tracker.find(1).core_fields.empty?
144 144 end
145 145
146 146 def test_update_with_failure
147 147 put :update, :id => 1, :tracker => { :name => '' }
148 148 assert_response :success
149 149 assert_template 'edit'
150 150 assert_select_error /name cannot be blank/i
151 151 end
152 152
153 153 def test_move_lower
154 154 tracker = Tracker.find_by_position(1)
155 put :update, :id => 1, :tracker => { :move_to => 'lower' }
155 put :update, :id => 1, :tracker => { :position => '2' }
156 156 assert_equal 2, tracker.reload.position
157 157 end
158 158
159 159 def test_destroy
160 160 tracker = Tracker.generate!(:name => 'Destroyable')
161 161 assert_difference 'Tracker.count', -1 do
162 162 delete :destroy, :id => tracker.id
163 163 end
164 164 assert_redirected_to :action => 'index'
165 165 assert_nil flash[:error]
166 166 end
167 167
168 168 def test_destroy_tracker_in_use
169 169 assert_no_difference 'Tracker.count' do
170 170 delete :destroy, :id => 1
171 171 end
172 172 assert_redirected_to :action => 'index'
173 173 assert_not_nil flash[:error]
174 174 end
175 175
176 176 def test_get_fields
177 177 get :fields
178 178 assert_response :success
179 179 assert_template 'fields'
180 180
181 181 assert_select 'form' do
182 182 assert_select 'input[type=checkbox][name=?][value=assigned_to_id]', 'trackers[1][core_fields][]'
183 183 assert_select 'input[type=checkbox][name=?][value="2"]', 'trackers[1][custom_field_ids][]'
184 184
185 185 assert_select 'input[type=hidden][name=?][value=""]', 'trackers[1][core_fields][]'
186 186 assert_select 'input[type=hidden][name=?][value=""]', 'trackers[1][custom_field_ids][]'
187 187 end
188 188 end
189 189
190 190 def test_post_fields
191 191 post :fields, :trackers => {
192 192 '1' => {'core_fields' => ['assigned_to_id', 'due_date', ''], 'custom_field_ids' => ['1', '2']},
193 193 '2' => {'core_fields' => [''], 'custom_field_ids' => ['']}
194 194 }
195 195 assert_redirected_to '/trackers/fields'
196 196
197 197 tracker = Tracker.find(1)
198 198 assert_equal %w(assigned_to_id due_date), tracker.core_fields
199 199 assert_equal [1, 2], tracker.custom_field_ids.sort
200 200
201 201 tracker = Tracker.find(2)
202 202 assert_equal [], tracker.core_fields
203 203 assert_equal [], tracker.custom_field_ids.sort
204 204 end
205 205 end
General Comments 0
You need to be logged in to leave comments. Login now