##// END OF EJS Templates
Merged ajax_upload branch (#3957)....
Jean-Philippe Lang -
r10748:ef25210aca92
parent child
Show More
@@ -0,0 +1,1
1 $('#attachments_<%= j params[:attachment_id] %>').remove();
@@ -0,0 +1,9
1 var fileSpan = $('#attachments_<%= j params[:attachment_id] %>');
2 $('<input>', { type: 'hidden', name: 'attachments[<%= j params[:attachment_id] %>][token]' } ).val('<%= j @attachment.token %>').appendTo(fileSpan);
3 fileSpan.find('a.remove-upload')
4 .attr({
5 "data-remote": true,
6 "data-method": 'delete',
7 href: '<%= j attachment_path(@attachment, :attachment_id => params[:attachment_id], :format => 'js') %>'
8 })
9 .off('click');
1 NO CONTENT: new file 100644, binary diff hidden
@@ -0,0 +1,189
1 /* Redmine - project management software
2 Copyright (C) 2006-2012 Jean-Philippe Lang */
3
4 function addFile(inputEl, file, eagerUpload) {
5
6 if ($('#attachments_fields').children().length < 10) {
7
8 var attachmentId = addFile.nextAttachmentId++;
9
10 var fileSpan = $('<span>', { id: 'attachments_' + attachmentId });
11
12 fileSpan.append(
13 $('<input>', { type: 'text', 'class': 'filename readonly', name: 'attachments[' + attachmentId + '][filename]', readonly: 'readonly'} ).val(file.name),
14 $('<input>', { type: 'text', 'class': 'description', name: 'attachments[' + attachmentId + '][description]', maxlength: 255, placeholder: $(inputEl).data('description-placeholder') } ).toggle(!eagerUpload),
15 $('<a>&nbsp</a>').attr({ href: "#", 'class': 'remove-upload' }).click(removeFile).toggle(!eagerUpload)
16 ).appendTo('#attachments_fields');
17
18 if(eagerUpload) {
19 ajaxUpload(file, attachmentId, fileSpan, inputEl);
20 }
21
22 return attachmentId;
23 }
24 return null;
25 }
26
27 addFile.nextAttachmentId = 1;
28
29 function ajaxUpload(file, attachmentId, fileSpan, inputEl) {
30
31 function onLoadstart(e) {
32 fileSpan.removeClass('ajax-waiting');
33 fileSpan.addClass('ajax-loading');
34 $('input:submit', $(this).parents('form')).attr('disabled', 'disabled');
35 }
36
37 function onProgress(e) {
38 if(e.lengthComputable) {
39 this.progressbar( 'value', e.loaded * 100 / e.total );
40 }
41 }
42
43 function actualUpload(file, attachmentId, fileSpan, inputEl) {
44
45 ajaxUpload.uploading++;
46
47 uploadBlob(file, $(inputEl).data('upload-path'), attachmentId, {
48 loadstartEventHandler: onLoadstart.bind(progressSpan),
49 progressEventHandler: onProgress.bind(progressSpan)
50 })
51 .done(function(result) {
52 progressSpan.progressbar( 'value', 100 ).remove();
53 fileSpan.find('input.description, a').css('display', 'inline-block');
54 })
55 .fail(function(result) {
56 progressSpan.text(result.statusText);
57 }).always(function() {
58 ajaxUpload.uploading--;
59 fileSpan.removeClass('ajax-loading');
60 var form = fileSpan.parents('form');
61 if (form.queue('upload').length == 0 && ajaxUpload.uploading == 0) {
62 $('input:submit', form).removeAttr('disabled');
63 }
64 form.dequeue('upload');
65 });
66 }
67
68 var progressSpan = $('<div>').insertAfter(fileSpan.find('input.filename'));
69 progressSpan.progressbar();
70 fileSpan.addClass('ajax-waiting');
71
72 var maxSyncUpload = $(inputEl).data('max-concurrent-uploads');
73
74 if(maxSyncUpload == null || maxSyncUpload <= 0 || ajaxUpload.uploading < maxSyncUpload)
75 actualUpload(file, attachmentId, fileSpan, inputEl);
76 else
77 $(inputEl).parents('form').queue('upload', actualUpload.bind(this, file, attachmentId, fileSpan, inputEl));
78 }
79
80 ajaxUpload.uploading = 0;
81
82 function removeFile() {
83 $(this).parent('span').remove();
84 return false;
85 }
86
87 function uploadBlob(blob, uploadUrl, attachmentId, options) {
88
89 var actualOptions = $.extend({
90 loadstartEventHandler: $.noop,
91 progressEventHandler: $.noop
92 }, options);
93
94 uploadUrl = uploadUrl + '?attachment_id=' + attachmentId;
95 if (blob instanceof window.File) {
96 uploadUrl += '&filename=' + encodeURIComponent(blob.name);
97 }
98
99 return $.ajax(uploadUrl, {
100 type: 'POST',
101 contentType: 'application/octet-stream',
102 beforeSend: function(jqXhr) {
103 jqXhr.setRequestHeader('Accept', 'application/js');
104 },
105 xhr: function() {
106 var xhr = $.ajaxSettings.xhr();
107 xhr.upload.onloadstart = actualOptions.loadstartEventHandler;
108 xhr.upload.onprogress = actualOptions.progressEventHandler;
109 return xhr;
110 },
111 data: blob,
112 cache: false,
113 processData: false
114 });
115 }
116
117 function addInputFiles(inputEl) {
118 var clearedFileInput = $(inputEl).clone().val('');
119
120 if (inputEl.files) {
121 // upload files using ajax
122 uploadAndAttachFiles(inputEl.files, inputEl);
123 $(inputEl).remove();
124 } else {
125 // browser not supporting the file API, upload on form submission
126 var attachmentId;
127 var aFilename = inputEl.value.split(/\/|\\/);
128 attachmentId = addFile(inputEl, { name: aFilename[ aFilename.length - 1 ] }, false);
129 if (attachmentId) {
130 $(inputEl).attr({ name: 'attachments[' + attachmentId + '][file]', style: 'display:none;' }).appendTo('#attachments_' + attachmentId);
131 }
132 }
133
134 clearedFileInput.insertAfter('#attachments_fields');
135 }
136
137 function uploadAndAttachFiles(files, inputEl) {
138
139 var maxFileSize = $(inputEl).data('max-file-size');
140 var maxFileSizeExceeded = $(inputEl).data('max-file-size-message');
141
142 var sizeExceeded = false;
143 $.each(files, function() {
144 if (this.size && maxFileSize && this.size > parseInt(maxFileSize)) {sizeExceeded=true;}
145 });
146 if (sizeExceeded) {
147 window.alert(maxFileSizeExceeded);
148 } else {
149 $.each(files, function() {addFile(inputEl, this, true);});
150 }
151 }
152
153 function handleFileDropEvent(e) {
154
155 $(this).removeClass('fileover');
156 blockEventPropagation(e);
157
158 if ($.inArray('Files', e.dataTransfer.types) > -1) {
159
160 uploadAndAttachFiles(e.dataTransfer.files, $('input:file[name=attachments_files]'));
161 }
162 }
163
164 function dragOverHandler(e) {
165 $(this).addClass('fileover');
166 blockEventPropagation(e);
167 }
168
169 function dragOutHandler(e) {
170 $(this).removeClass('fileover');
171 blockEventPropagation(e);
172 }
173
174 function setupFileDrop() {
175 if (window.File && window.FileList && window.ProgressEvent && window.FormData) {
176
177 $.event.fixHooks.drop = { props: [ 'dataTransfer' ] };
178
179 $('form div.box').has('input:file').each(function() {
180 $(this).on({
181 dragover: dragOverHandler,
182 dragleave: dragOutHandler,
183 drop: handleFileDropEvent
184 });
185 });
186 }
187 }
188
189 $(document).ready(setupFileDrop);
@@ -0,0 +1,132
1 # Redmine - project management software
2 # Copyright (C) 2006-2012 Jean-Philippe Lang
3 #
4 # This program is free software; you can redistribute it and/or
5 # modify it under the terms of the GNU General Public License
6 # as published by the Free Software Foundation; either version 2
7 # of the License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
13 #
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, write to the Free Software
16 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
18 require File.expand_path('../../test_helper', __FILE__)
19
20 class AttachmentsTest < ActionController::IntegrationTest
21 fixtures :projects, :enabled_modules,
22 :users, :roles, :members, :member_roles,
23 :trackers, :projects_trackers,
24 :issue_statuses, :enumerations
25
26 def test_upload_as_js_and_attach_to_an_issue
27 log_user('jsmith', 'jsmith')
28
29 token = ajax_upload('myupload.txt', 'File content')
30
31 assert_difference 'Issue.count' do
32 post '/projects/ecookbook/issues', {
33 :issue => {:tracker_id => 1, :subject => 'Issue with upload'},
34 :attachments => {'1' => {:filename => 'myupload.txt', :description => 'My uploaded file', :token => token}}
35 }
36 assert_response 302
37 end
38
39 issue = Issue.order('id DESC').first
40 assert_equal 'Issue with upload', issue.subject
41 assert_equal 1, issue.attachments.count
42
43 attachment = issue.attachments.first
44 assert_equal 'myupload.txt', attachment.filename
45 assert_equal 'My uploaded file', attachment.description
46 assert_equal 'File content'.length, attachment.filesize
47 end
48
49 def test_upload_as_js_and_preview_as_inline_attachment
50 log_user('jsmith', 'jsmith')
51
52 token = ajax_upload('myupload.jpg', 'JPEG content')
53
54 post '/issues/preview/new/ecookbook', {
55 :issue => {:tracker_id => 1, :description => 'Inline upload: !myupload.jpg!'},
56 :attachments => {'1' => {:filename => 'myupload.jpg', :description => 'My uploaded file', :token => token}}
57 }
58 assert_response :success
59
60 attachment_path = response.body.match(%r{<img src="(/attachments/download/\d+)"})[1]
61 assert_not_nil token, "No attachment path found in response:\n#{response.body}"
62
63 get attachment_path
64 assert_response :success
65 assert_equal 'JPEG content', response.body
66 end
67
68 def test_upload_and_resubmit_after_validation_failure
69 log_user('jsmith', 'jsmith')
70
71 token = ajax_upload('myupload.txt', 'File content')
72
73 assert_no_difference 'Issue.count' do
74 post '/projects/ecookbook/issues', {
75 :issue => {:tracker_id => 1, :subject => ''},
76 :attachments => {'1' => {:filename => 'myupload.txt', :description => 'My uploaded file', :token => token}}
77 }
78 assert_response :success
79 end
80 assert_select 'input[type=hidden][name=?][value=?]', 'attachments[p0][token]', token
81 assert_select 'input[name=?][value=?]', 'attachments[p0][filename]', 'myupload.txt'
82 assert_select 'input[name=?][value=?]', 'attachments[p0][description]', 'My uploaded file'
83
84 assert_difference 'Issue.count' do
85 post '/projects/ecookbook/issues', {
86 :issue => {:tracker_id => 1, :subject => 'Issue with upload'},
87 :attachments => {'p0' => {:filename => 'myupload.txt', :description => 'My uploaded file', :token => token}}
88 }
89 assert_response 302
90 end
91
92 issue = Issue.order('id DESC').first
93 assert_equal 'Issue with upload', issue.subject
94 assert_equal 1, issue.attachments.count
95
96 attachment = issue.attachments.first
97 assert_equal 'myupload.txt', attachment.filename
98 assert_equal 'My uploaded file', attachment.description
99 assert_equal 'File content'.length, attachment.filesize
100 end
101
102 def test_upload_as_js_and_destroy
103 log_user('jsmith', 'jsmith')
104
105 token = ajax_upload('myupload.txt', 'File content')
106
107 attachment = Attachment.order('id DESC').first
108 attachment_path = "/attachments/#{attachment.id}.js?attachment_id=1"
109 assert_include "href: '#{attachment_path}'", response.body, "Path to attachment: #{attachment_path} not found in response:\n#{response.body}"
110
111 assert_difference 'Attachment.count', -1 do
112 delete attachment_path
113 assert_response :success
114 end
115
116 assert_include "$('#attachments_1').remove();", response.body
117 end
118
119 private
120
121 def ajax_upload(filename, content, attachment_id=1)
122 assert_difference 'Attachment.count' do
123 post "/uploads.js?attachment_id=#{attachment_id}&filename=#{filename}", content, {"CONTENT_TYPE" => 'application/octet-stream'}
124 assert_response :success
125 assert_equal 'text/javascript', response.content_type
126 end
127
128 token = response.body.match(/\.val\('(\d+\.[0-9a-f]+)'\)/)[1]
129 assert_not_nil token, "No upload token found in response:\n#{response.body}"
130 token
131 end
132 end
@@ -1,590 +1,600
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 'uri'
19 19 require 'cgi'
20 20
21 21 class Unauthorized < Exception; end
22 22
23 23 class ApplicationController < ActionController::Base
24 24 include Redmine::I18n
25 25
26 26 class_attribute :accept_api_auth_actions
27 27 class_attribute :accept_rss_auth_actions
28 28 class_attribute :model_object
29 29
30 30 layout 'base'
31 31
32 32 protect_from_forgery
33 33 def handle_unverified_request
34 34 super
35 35 cookies.delete(:autologin)
36 36 end
37 37
38 38 before_filter :session_expiration, :user_setup, :check_if_login_required, :set_localization
39 39
40 40 rescue_from ActionController::InvalidAuthenticityToken, :with => :invalid_authenticity_token
41 41 rescue_from ::Unauthorized, :with => :deny_access
42 42 rescue_from ::ActionView::MissingTemplate, :with => :missing_template
43 43
44 44 include Redmine::Search::Controller
45 45 include Redmine::MenuManager::MenuController
46 46 helper Redmine::MenuManager::MenuHelper
47 47
48 48 def session_expiration
49 49 if session[:user_id]
50 50 if session_expired? && !try_to_autologin
51 51 reset_session
52 52 flash[:error] = l(:error_session_expired)
53 53 redirect_to signin_url
54 54 else
55 55 session[:atime] = Time.now.utc.to_i
56 56 end
57 57 end
58 58 end
59 59
60 60 def session_expired?
61 61 if Setting.session_lifetime?
62 62 unless session[:ctime] && (Time.now.utc.to_i - session[:ctime].to_i <= Setting.session_lifetime.to_i * 60)
63 63 return true
64 64 end
65 65 end
66 66 if Setting.session_timeout?
67 67 unless session[:atime] && (Time.now.utc.to_i - session[:atime].to_i <= Setting.session_timeout.to_i * 60)
68 68 return true
69 69 end
70 70 end
71 71 false
72 72 end
73 73
74 74 def start_user_session(user)
75 75 session[:user_id] = user.id
76 76 session[:ctime] = Time.now.utc.to_i
77 77 session[:atime] = Time.now.utc.to_i
78 78 end
79 79
80 80 def user_setup
81 81 # Check the settings cache for each request
82 82 Setting.check_cache
83 83 # Find the current user
84 84 User.current = find_current_user
85 85 logger.info(" Current user: " + (User.current.logged? ? "#{User.current.login} (id=#{User.current.id})" : "anonymous")) if logger
86 86 end
87 87
88 88 # Returns the current user or nil if no user is logged in
89 89 # and starts a session if needed
90 90 def find_current_user
91 91 user = nil
92 92 unless api_request?
93 93 if session[:user_id]
94 94 # existing session
95 95 user = (User.active.find(session[:user_id]) rescue nil)
96 96 elsif autologin_user = try_to_autologin
97 97 user = autologin_user
98 98 elsif params[:format] == 'atom' && params[:key] && request.get? && accept_rss_auth?
99 99 # RSS key authentication does not start a session
100 100 user = User.find_by_rss_key(params[:key])
101 101 end
102 102 end
103 103 if user.nil? && Setting.rest_api_enabled? && accept_api_auth?
104 104 if (key = api_key_from_request)
105 105 # Use API key
106 106 user = User.find_by_api_key(key)
107 107 else
108 108 # HTTP Basic, either username/password or API key/random
109 109 authenticate_with_http_basic do |username, password|
110 110 user = User.try_to_login(username, password) || User.find_by_api_key(username)
111 111 end
112 112 end
113 113 # Switch user if requested by an admin user
114 114 if user && user.admin? && (username = api_switch_user_from_request)
115 115 su = User.find_by_login(username)
116 116 if su && su.active?
117 117 logger.info(" User switched by: #{user.login} (id=#{user.id})") if logger
118 118 user = su
119 119 else
120 120 render_error :message => 'Invalid X-Redmine-Switch-User header', :status => 412
121 121 end
122 122 end
123 123 end
124 124 user
125 125 end
126 126
127 127 def try_to_autologin
128 128 if cookies[:autologin] && Setting.autologin?
129 129 # auto-login feature starts a new session
130 130 user = User.try_to_autologin(cookies[:autologin])
131 131 if user
132 132 reset_session
133 133 start_user_session(user)
134 134 end
135 135 user
136 136 end
137 137 end
138 138
139 139 # Sets the logged in user
140 140 def logged_user=(user)
141 141 reset_session
142 142 if user && user.is_a?(User)
143 143 User.current = user
144 144 start_user_session(user)
145 145 else
146 146 User.current = User.anonymous
147 147 end
148 148 end
149 149
150 150 # Logs out current user
151 151 def logout_user
152 152 if User.current.logged?
153 153 cookies.delete :autologin
154 154 Token.delete_all(["user_id = ? AND action = ?", User.current.id, 'autologin'])
155 155 self.logged_user = nil
156 156 end
157 157 end
158 158
159 159 # check if login is globally required to access the application
160 160 def check_if_login_required
161 161 # no check needed if user is already logged in
162 162 return true if User.current.logged?
163 163 require_login if Setting.login_required?
164 164 end
165 165
166 166 def set_localization
167 167 lang = nil
168 168 if User.current.logged?
169 169 lang = find_language(User.current.language)
170 170 end
171 171 if lang.nil? && request.env['HTTP_ACCEPT_LANGUAGE']
172 172 accept_lang = parse_qvalues(request.env['HTTP_ACCEPT_LANGUAGE']).first
173 173 if !accept_lang.blank?
174 174 accept_lang = accept_lang.downcase
175 175 lang = find_language(accept_lang) || find_language(accept_lang.split('-').first)
176 176 end
177 177 end
178 178 lang ||= Setting.default_language
179 179 set_language_if_valid(lang)
180 180 end
181 181
182 182 def require_login
183 183 if !User.current.logged?
184 184 # Extract only the basic url parameters on non-GET requests
185 185 if request.get?
186 186 url = url_for(params)
187 187 else
188 188 url = url_for(:controller => params[:controller], :action => params[:action], :id => params[:id], :project_id => params[:project_id])
189 189 end
190 190 respond_to do |format|
191 191 format.html { redirect_to :controller => "account", :action => "login", :back_url => url }
192 192 format.atom { redirect_to :controller => "account", :action => "login", :back_url => url }
193 193 format.xml { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
194 194 format.js { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
195 195 format.json { head :unauthorized, 'WWW-Authenticate' => 'Basic realm="Redmine API"' }
196 196 end
197 197 return false
198 198 end
199 199 true
200 200 end
201 201
202 202 def require_admin
203 203 return unless require_login
204 204 if !User.current.admin?
205 205 render_403
206 206 return false
207 207 end
208 208 true
209 209 end
210 210
211 211 def deny_access
212 212 User.current.logged? ? render_403 : require_login
213 213 end
214 214
215 215 # Authorize the user for the requested action
216 216 def authorize(ctrl = params[:controller], action = params[:action], global = false)
217 217 allowed = User.current.allowed_to?({:controller => ctrl, :action => action}, @project || @projects, :global => global)
218 218 if allowed
219 219 true
220 220 else
221 221 if @project && @project.archived?
222 222 render_403 :message => :notice_not_authorized_archived_project
223 223 else
224 224 deny_access
225 225 end
226 226 end
227 227 end
228 228
229 229 # Authorize the user for the requested action outside a project
230 230 def authorize_global(ctrl = params[:controller], action = params[:action], global = true)
231 231 authorize(ctrl, action, global)
232 232 end
233 233
234 234 # Find project of id params[:id]
235 235 def find_project
236 236 @project = Project.find(params[:id])
237 237 rescue ActiveRecord::RecordNotFound
238 238 render_404
239 239 end
240 240
241 241 # Find project of id params[:project_id]
242 242 def find_project_by_project_id
243 243 @project = Project.find(params[:project_id])
244 244 rescue ActiveRecord::RecordNotFound
245 245 render_404
246 246 end
247 247
248 248 # Find a project based on params[:project_id]
249 249 # TODO: some subclasses override this, see about merging their logic
250 250 def find_optional_project
251 251 @project = Project.find(params[:project_id]) unless params[:project_id].blank?
252 252 allowed = User.current.allowed_to?({:controller => params[:controller], :action => params[:action]}, @project, :global => true)
253 253 allowed ? true : deny_access
254 254 rescue ActiveRecord::RecordNotFound
255 255 render_404
256 256 end
257 257
258 258 # Finds and sets @project based on @object.project
259 259 def find_project_from_association
260 260 render_404 unless @object.present?
261 261
262 262 @project = @object.project
263 263 end
264 264
265 265 def find_model_object
266 266 model = self.class.model_object
267 267 if model
268 268 @object = model.find(params[:id])
269 269 self.instance_variable_set('@' + controller_name.singularize, @object) if @object
270 270 end
271 271 rescue ActiveRecord::RecordNotFound
272 272 render_404
273 273 end
274 274
275 275 def self.model_object(model)
276 276 self.model_object = model
277 277 end
278 278
279 279 # Find the issue whose id is the :id parameter
280 280 # Raises a Unauthorized exception if the issue is not visible
281 281 def find_issue
282 282 # Issue.visible.find(...) can not be used to redirect user to the login form
283 283 # if the issue actually exists but requires authentication
284 284 @issue = Issue.find(params[:id])
285 285 raise Unauthorized unless @issue.visible?
286 286 @project = @issue.project
287 287 rescue ActiveRecord::RecordNotFound
288 288 render_404
289 289 end
290 290
291 291 # Find issues with a single :id param or :ids array param
292 292 # Raises a Unauthorized exception if one of the issues is not visible
293 293 def find_issues
294 294 @issues = Issue.find_all_by_id(params[:id] || params[:ids])
295 295 raise ActiveRecord::RecordNotFound if @issues.empty?
296 296 raise Unauthorized unless @issues.all?(&:visible?)
297 297 @projects = @issues.collect(&:project).compact.uniq
298 298 @project = @projects.first if @projects.size == 1
299 299 rescue ActiveRecord::RecordNotFound
300 300 render_404
301 301 end
302 302
303 def find_attachments
304 if (attachments = params[:attachments]).present?
305 att = attachments.values.collect do |attachment|
306 Attachment.find_by_token( attachment[:token] ) if attachment[:token].present?
307 end
308 att.compact!
309 end
310 @attachments = att || []
311 end
312
303 313 # make sure that the user is a member of the project (or admin) if project is private
304 314 # used as a before_filter for actions that do not require any particular permission on the project
305 315 def check_project_privacy
306 316 if @project && !@project.archived?
307 317 if @project.visible?
308 318 true
309 319 else
310 320 deny_access
311 321 end
312 322 else
313 323 @project = nil
314 324 render_404
315 325 false
316 326 end
317 327 end
318 328
319 329 def back_url
320 330 url = params[:back_url]
321 331 if url.nil? && referer = request.env['HTTP_REFERER']
322 332 url = CGI.unescape(referer.to_s)
323 333 end
324 334 url
325 335 end
326 336
327 337 def redirect_back_or_default(default)
328 338 back_url = params[:back_url].to_s
329 339 if back_url.present?
330 340 begin
331 341 uri = URI.parse(back_url)
332 342 # do not redirect user to another host or to the login or register page
333 343 if (uri.relative? || (uri.host == request.host)) && !uri.path.match(%r{/(login|account/register)})
334 344 redirect_to(back_url)
335 345 return
336 346 end
337 347 rescue URI::InvalidURIError
338 348 logger.warn("Could not redirect to invalid URL #{back_url}")
339 349 # redirect to default
340 350 end
341 351 end
342 352 redirect_to default
343 353 false
344 354 end
345 355
346 356 # Redirects to the request referer if present, redirects to args or call block otherwise.
347 357 def redirect_to_referer_or(*args, &block)
348 358 redirect_to :back
349 359 rescue ::ActionController::RedirectBackError
350 360 if args.any?
351 361 redirect_to *args
352 362 elsif block_given?
353 363 block.call
354 364 else
355 365 raise "#redirect_to_referer_or takes arguments or a block"
356 366 end
357 367 end
358 368
359 369 def render_403(options={})
360 370 @project = nil
361 371 render_error({:message => :notice_not_authorized, :status => 403}.merge(options))
362 372 return false
363 373 end
364 374
365 375 def render_404(options={})
366 376 render_error({:message => :notice_file_not_found, :status => 404}.merge(options))
367 377 return false
368 378 end
369 379
370 380 # Renders an error response
371 381 def render_error(arg)
372 382 arg = {:message => arg} unless arg.is_a?(Hash)
373 383
374 384 @message = arg[:message]
375 385 @message = l(@message) if @message.is_a?(Symbol)
376 386 @status = arg[:status] || 500
377 387
378 388 respond_to do |format|
379 389 format.html {
380 390 render :template => 'common/error', :layout => use_layout, :status => @status
381 391 }
382 392 format.any { head @status }
383 393 end
384 394 end
385 395
386 396 # Handler for ActionView::MissingTemplate exception
387 397 def missing_template
388 398 logger.warn "Missing template, responding with 404"
389 399 @project = nil
390 400 render_404
391 401 end
392 402
393 403 # Filter for actions that provide an API response
394 404 # but have no HTML representation for non admin users
395 405 def require_admin_or_api_request
396 406 return true if api_request?
397 407 if User.current.admin?
398 408 true
399 409 elsif User.current.logged?
400 410 render_error(:status => 406)
401 411 else
402 412 deny_access
403 413 end
404 414 end
405 415
406 416 # Picks which layout to use based on the request
407 417 #
408 418 # @return [boolean, string] name of the layout to use or false for no layout
409 419 def use_layout
410 420 request.xhr? ? false : 'base'
411 421 end
412 422
413 423 def invalid_authenticity_token
414 424 if api_request?
415 425 logger.error "Form authenticity token is missing or is invalid. API calls must include a proper Content-type header (text/xml or text/json)."
416 426 end
417 427 render_error "Invalid form authenticity token."
418 428 end
419 429
420 430 def render_feed(items, options={})
421 431 @items = items || []
422 432 @items.sort! {|x,y| y.event_datetime <=> x.event_datetime }
423 433 @items = @items.slice(0, Setting.feeds_limit.to_i)
424 434 @title = options[:title] || Setting.app_title
425 435 render :template => "common/feed", :formats => [:atom], :layout => false,
426 436 :content_type => 'application/atom+xml'
427 437 end
428 438
429 439 def self.accept_rss_auth(*actions)
430 440 if actions.any?
431 441 self.accept_rss_auth_actions = actions
432 442 else
433 443 self.accept_rss_auth_actions || []
434 444 end
435 445 end
436 446
437 447 def accept_rss_auth?(action=action_name)
438 448 self.class.accept_rss_auth.include?(action.to_sym)
439 449 end
440 450
441 451 def self.accept_api_auth(*actions)
442 452 if actions.any?
443 453 self.accept_api_auth_actions = actions
444 454 else
445 455 self.accept_api_auth_actions || []
446 456 end
447 457 end
448 458
449 459 def accept_api_auth?(action=action_name)
450 460 self.class.accept_api_auth.include?(action.to_sym)
451 461 end
452 462
453 463 # Returns the number of objects that should be displayed
454 464 # on the paginated list
455 465 def per_page_option
456 466 per_page = nil
457 467 if params[:per_page] && Setting.per_page_options_array.include?(params[:per_page].to_s.to_i)
458 468 per_page = params[:per_page].to_s.to_i
459 469 session[:per_page] = per_page
460 470 elsif session[:per_page]
461 471 per_page = session[:per_page]
462 472 else
463 473 per_page = Setting.per_page_options_array.first || 25
464 474 end
465 475 per_page
466 476 end
467 477
468 478 # Returns offset and limit used to retrieve objects
469 479 # for an API response based on offset, limit and page parameters
470 480 def api_offset_and_limit(options=params)
471 481 if options[:offset].present?
472 482 offset = options[:offset].to_i
473 483 if offset < 0
474 484 offset = 0
475 485 end
476 486 end
477 487 limit = options[:limit].to_i
478 488 if limit < 1
479 489 limit = 25
480 490 elsif limit > 100
481 491 limit = 100
482 492 end
483 493 if offset.nil? && options[:page].present?
484 494 offset = (options[:page].to_i - 1) * limit
485 495 offset = 0 if offset < 0
486 496 end
487 497 offset ||= 0
488 498
489 499 [offset, limit]
490 500 end
491 501
492 502 # qvalues http header parser
493 503 # code taken from webrick
494 504 def parse_qvalues(value)
495 505 tmp = []
496 506 if value
497 507 parts = value.split(/,\s*/)
498 508 parts.each {|part|
499 509 if m = %r{^([^\s,]+?)(?:;\s*q=(\d+(?:\.\d+)?))?$}.match(part)
500 510 val = m[1]
501 511 q = (m[2] or 1).to_f
502 512 tmp.push([val, q])
503 513 end
504 514 }
505 515 tmp = tmp.sort_by{|val, q| -q}
506 516 tmp.collect!{|val, q| val}
507 517 end
508 518 return tmp
509 519 rescue
510 520 nil
511 521 end
512 522
513 523 # Returns a string that can be used as filename value in Content-Disposition header
514 524 def filename_for_content_disposition(name)
515 525 request.env['HTTP_USER_AGENT'] =~ %r{MSIE} ? ERB::Util.url_encode(name) : name
516 526 end
517 527
518 528 def api_request?
519 529 %w(xml json).include? params[:format]
520 530 end
521 531
522 532 # Returns the API key present in the request
523 533 def api_key_from_request
524 534 if params[:key].present?
525 535 params[:key].to_s
526 536 elsif request.headers["X-Redmine-API-Key"].present?
527 537 request.headers["X-Redmine-API-Key"].to_s
528 538 end
529 539 end
530 540
531 541 # Returns the API 'switch user' value if present
532 542 def api_switch_user_from_request
533 543 request.headers["X-Redmine-Switch-User"].to_s.presence
534 544 end
535 545
536 546 # Renders a warning flash if obj has unsaved attachments
537 547 def render_attachment_warning_if_needed(obj)
538 548 flash[:warning] = l(:warning_attachments_not_saved, obj.unsaved_attachments.size) if obj.unsaved_attachments.present?
539 549 end
540 550
541 551 # Sets the `flash` notice or error based the number of issues that did not save
542 552 #
543 553 # @param [Array, Issue] issues all of the saved and unsaved Issues
544 554 # @param [Array, Integer] unsaved_issue_ids the issue ids that were not saved
545 555 def set_flash_from_bulk_issue_save(issues, unsaved_issue_ids)
546 556 if unsaved_issue_ids.empty?
547 557 flash[:notice] = l(:notice_successful_update) unless issues.empty?
548 558 else
549 559 flash[:error] = l(:notice_failed_to_save_issues,
550 560 :count => unsaved_issue_ids.size,
551 561 :total => issues.size,
552 562 :ids => '#' + unsaved_issue_ids.join(', #'))
553 563 end
554 564 end
555 565
556 566 # Rescues an invalid query statement. Just in case...
557 567 def query_statement_invalid(exception)
558 568 logger.error "Query::StatementInvalid: #{exception.message}" if logger
559 569 session.delete(:query)
560 570 sort_clear if respond_to?(:sort_clear)
561 571 render_error "An error occurred while executing the query and has been logged. Please report this error to your Redmine administrator."
562 572 end
563 573
564 574 # Renders a 200 response for successfull updates or deletions via the API
565 575 def render_api_ok
566 576 render_api_head :ok
567 577 end
568 578
569 579 # Renders a head API response
570 580 def render_api_head(status)
571 581 # #head would return a response body with one space
572 582 render :text => '', :status => status, :layout => nil
573 583 end
574 584
575 585 # Renders API response on validation failure
576 586 def render_validation_errors(objects)
577 587 if objects.is_a?(Array)
578 588 @error_messages = objects.map {|object| object.errors.full_messages}.flatten
579 589 else
580 590 @error_messages = objects.errors.full_messages
581 591 end
582 592 render :template => 'common/error_messages.api', :status => :unprocessable_entity, :layout => nil
583 593 end
584 594
585 595 # Overrides #_include_layout? so that #render with no arguments
586 596 # doesn't use the layout for api requests
587 597 def _include_layout?(*args)
588 598 api_request? ? false : super
589 599 end
590 600 end
@@ -1,139 +1,149
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 AttachmentsController < ApplicationController
19 19 before_filter :find_project, :except => :upload
20 20 before_filter :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
21 21 before_filter :delete_authorize, :only => :destroy
22 22 before_filter :authorize_global, :only => :upload
23 23
24 24 accept_api_auth :show, :download, :upload
25 25
26 26 def show
27 27 respond_to do |format|
28 28 format.html {
29 29 if @attachment.is_diff?
30 30 @diff = File.new(@attachment.diskfile, "rb").read
31 31 @diff_type = params[:type] || User.current.pref[:diff_type] || 'inline'
32 32 @diff_type = 'inline' unless %w(inline sbs).include?(@diff_type)
33 33 # Save diff type as user preference
34 34 if User.current.logged? && @diff_type != User.current.pref[:diff_type]
35 35 User.current.pref[:diff_type] = @diff_type
36 36 User.current.preference.save
37 37 end
38 38 render :action => 'diff'
39 39 elsif @attachment.is_text? && @attachment.filesize <= Setting.file_max_size_displayed.to_i.kilobyte
40 40 @content = File.new(@attachment.diskfile, "rb").read
41 41 render :action => 'file'
42 42 else
43 43 download
44 44 end
45 45 }
46 46 format.api
47 47 end
48 48 end
49 49
50 50 def download
51 51 if @attachment.container.is_a?(Version) || @attachment.container.is_a?(Project)
52 52 @attachment.increment_download
53 53 end
54 54
55 55 if stale?(:etag => @attachment.digest)
56 56 # images are sent inline
57 57 send_file @attachment.diskfile, :filename => filename_for_content_disposition(@attachment.filename),
58 58 :type => detect_content_type(@attachment),
59 59 :disposition => (@attachment.image? ? 'inline' : 'attachment')
60 60 end
61 61 end
62 62
63 63 def thumbnail
64 64 if @attachment.thumbnailable? && thumbnail = @attachment.thumbnail(:size => params[:size])
65 65 if stale?(:etag => thumbnail)
66 66 send_file thumbnail,
67 67 :filename => filename_for_content_disposition(@attachment.filename),
68 68 :type => detect_content_type(@attachment),
69 69 :disposition => 'inline'
70 70 end
71 71 else
72 72 # No thumbnail for the attachment or thumbnail could not be created
73 73 render :nothing => true, :status => 404
74 74 end
75 75 end
76 76
77 77 def upload
78 78 # Make sure that API users get used to set this content type
79 79 # as it won't trigger Rails' automatic parsing of the request body for parameters
80 80 unless request.content_type == 'application/octet-stream'
81 81 render :nothing => true, :status => 406
82 82 return
83 83 end
84 84
85 85 @attachment = Attachment.new(:file => request.raw_post)
86 86 @attachment.author = User.current
87 87 @attachment.filename = params[:filename].presence || Redmine::Utils.random_hex(16)
88 saved = @attachment.save
88 89
89 if @attachment.save
90 respond_to do |format|
91 format.api { render :action => 'upload', :status => :created }
92 end
93 else
94 respond_to do |format|
95 format.api { render_validation_errors(@attachment) }
96 end
90 respond_to do |format|
91 format.js
92 format.api {
93 if saved
94 render :action => 'upload', :status => :created
95 else
96 render_validation_errors(@attachment)
97 end
98 }
97 99 end
98 100 end
99 101
100 102 def destroy
101 103 if @attachment.container.respond_to?(:init_journal)
102 104 @attachment.container.init_journal(User.current)
103 105 end
104 # Make sure association callbacks are called
105 @attachment.container.attachments.delete(@attachment)
106 redirect_to_referer_or project_path(@project)
106 if @attachment.container
107 # Make sure association callbacks are called
108 @attachment.container.attachments.delete(@attachment)
109 else
110 @attachment.destroy
111 end
112
113 respond_to do |format|
114 format.html { redirect_to_referer_or project_path(@project) }
115 format.js
116 end
107 117 end
108 118
109 119 private
110 120 def find_project
111 121 @attachment = Attachment.find(params[:id])
112 122 # Show 404 if the filename in the url is wrong
113 123 raise ActiveRecord::RecordNotFound if params[:filename] && params[:filename] != @attachment.filename
114 124 @project = @attachment.project
115 125 rescue ActiveRecord::RecordNotFound
116 126 render_404
117 127 end
118 128
119 129 # Checks that the file exists and is readable
120 130 def file_readable
121 131 @attachment.readable? ? true : render_404
122 132 end
123 133
124 134 def read_authorize
125 135 @attachment.visible? ? true : deny_access
126 136 end
127 137
128 138 def delete_authorize
129 139 @attachment.deletable? ? true : deny_access
130 140 end
131 141
132 142 def detect_content_type(attachment)
133 143 content_type = attachment.content_type
134 144 if content_type.blank?
135 145 content_type = Redmine::MimeType.of(attachment.filename)
136 146 end
137 147 content_type.to_s
138 148 end
139 149 end
@@ -1,141 +1,141
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 MessagesController < ApplicationController
19 19 menu_item :boards
20 20 default_search_scope :messages
21 21 before_filter :find_board, :only => [:new, :preview]
22 before_filter :find_attachments, :only => [:preview]
22 23 before_filter :find_message, :except => [:new, :preview]
23 24 before_filter :authorize, :except => [:preview, :edit, :destroy]
24 25
25 26 helper :boards
26 27 helper :watchers
27 28 helper :attachments
28 29 include AttachmentsHelper
29 30
30 31 REPLIES_PER_PAGE = 25 unless const_defined?(:REPLIES_PER_PAGE)
31 32
32 33 # Show a topic and its replies
33 34 def show
34 35 page = params[:page]
35 36 # Find the page of the requested reply
36 37 if params[:r] && page.nil?
37 38 offset = @topic.children.count(:conditions => ["#{Message.table_name}.id < ?", params[:r].to_i])
38 39 page = 1 + offset / REPLIES_PER_PAGE
39 40 end
40 41
41 42 @reply_count = @topic.children.count
42 43 @reply_pages = Paginator.new self, @reply_count, REPLIES_PER_PAGE, page
43 44 @replies = @topic.children.
44 45 includes(:author, :attachments, {:board => :project}).
45 46 reorder("#{Message.table_name}.created_on ASC").
46 47 limit(@reply_pages.items_per_page).
47 48 offset(@reply_pages.current.offset).
48 49 all
49 50
50 51 @reply = Message.new(:subject => "RE: #{@message.subject}")
51 52 render :action => "show", :layout => false if request.xhr?
52 53 end
53 54
54 55 # Create a new topic
55 56 def new
56 57 @message = Message.new
57 58 @message.author = User.current
58 59 @message.board = @board
59 60 @message.safe_attributes = params[:message]
60 61 if request.post?
61 62 @message.save_attachments(params[:attachments])
62 63 if @message.save
63 64 call_hook(:controller_messages_new_after_save, { :params => params, :message => @message})
64 65 render_attachment_warning_if_needed(@message)
65 66 redirect_to board_message_path(@board, @message)
66 67 end
67 68 end
68 69 end
69 70
70 71 # Reply to a topic
71 72 def reply
72 73 @reply = Message.new
73 74 @reply.author = User.current
74 75 @reply.board = @board
75 76 @reply.safe_attributes = params[:reply]
76 77 @topic.children << @reply
77 78 if !@reply.new_record?
78 79 call_hook(:controller_messages_reply_after_save, { :params => params, :message => @reply})
79 80 attachments = Attachment.attach_files(@reply, params[:attachments])
80 81 render_attachment_warning_if_needed(@reply)
81 82 end
82 83 redirect_to board_message_path(@board, @topic, :r => @reply)
83 84 end
84 85
85 86 # Edit a message
86 87 def edit
87 88 (render_403; return false) unless @message.editable_by?(User.current)
88 89 @message.safe_attributes = params[:message]
89 90 if request.post? && @message.save
90 91 attachments = Attachment.attach_files(@message, params[:attachments])
91 92 render_attachment_warning_if_needed(@message)
92 93 flash[:notice] = l(:notice_successful_update)
93 94 @message.reload
94 95 redirect_to board_message_path(@message.board, @message.root, :r => (@message.parent_id && @message.id))
95 96 end
96 97 end
97 98
98 99 # Delete a messages
99 100 def destroy
100 101 (render_403; return false) unless @message.destroyable_by?(User.current)
101 102 r = @message.to_param
102 103 @message.destroy
103 104 if @message.parent
104 105 redirect_to board_message_path(@board, @message.parent, :r => r)
105 106 else
106 107 redirect_to project_board_path(@project, @board)
107 108 end
108 109 end
109 110
110 111 def quote
111 112 @subject = @message.subject
112 113 @subject = "RE: #{@subject}" unless @subject.starts_with?('RE:')
113 114
114 115 @content = "#{ll(Setting.default_language, :text_user_wrote, @message.author)}\n> "
115 116 @content << @message.content.to_s.strip.gsub(%r{<pre>((.|\s)*?)</pre>}m, '[...]').gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
116 117 end
117 118
118 119 def preview
119 120 message = @board.messages.find_by_id(params[:id])
120 @attachements = message.attachments if message
121 121 @text = (params[:message] || params[:reply])[:content]
122 122 @previewed = message
123 123 render :partial => 'common/preview'
124 124 end
125 125
126 126 private
127 127 def find_message
128 128 find_board
129 129 @message = @board.messages.find(params[:id], :include => :parent)
130 130 @topic = @message.root
131 131 rescue ActiveRecord::RecordNotFound
132 132 render_404
133 133 end
134 134
135 135 def find_board
136 136 @board = Board.find(params[:board_id], :include => :project)
137 137 @project = @board.project
138 138 rescue ActiveRecord::RecordNotFound
139 139 render_404
140 140 end
141 141 end
@@ -1,55 +1,53
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 PreviewsController < ApplicationController
19 before_filter :find_project
19 before_filter :find_project, :find_attachments
20 20
21 21 def issue
22 22 @issue = @project.issues.find_by_id(params[:id]) unless params[:id].blank?
23 23 if @issue
24 @attachements = @issue.attachments
25 24 @description = params[:issue] && params[:issue][:description]
26 25 if @description && @description.gsub(/(\r?\n|\n\r?)/, "\n") == @issue.description.to_s.gsub(/(\r?\n|\n\r?)/, "\n")
27 26 @description = nil
28 27 end
29 28 # params[:notes] is useful for preview of notes in issue history
30 29 @notes = params[:notes] || (params[:issue] ? params[:issue][:notes] : nil)
31 30 else
32 31 @description = (params[:issue] ? params[:issue][:description] : nil)
33 32 end
34 33 render :layout => false
35 34 end
36 35
37 36 def news
38 37 if params[:id].present? && news = News.visible.find_by_id(params[:id])
39 38 @previewed = news
40 @attachments = news.attachments
41 39 end
42 40 @text = (params[:news] ? params[:news][:description] : nil)
43 41 render :partial => 'common/preview'
44 42 end
45 43
46 44 private
47 45
48 46 def find_project
49 47 project_id = (params[:issue] && params[:issue][:project_id]) || params[:project_id]
50 48 @project = Project.find(project_id)
51 49 rescue ActiveRecord::RecordNotFound
52 50 render_404
53 51 end
54 52
55 53 end
@@ -1,355 +1,356
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 'diff'
19 19
20 20 # The WikiController follows the Rails REST controller pattern but with
21 21 # a few differences
22 22 #
23 23 # * index - shows a list of WikiPages grouped by page or date
24 24 # * new - not used
25 25 # * create - not used
26 26 # * show - will also show the form for creating a new wiki page
27 27 # * edit - used to edit an existing or new page
28 28 # * update - used to save a wiki page update to the database, including new pages
29 29 # * destroy - normal
30 30 #
31 31 # Other member and collection methods are also used
32 32 #
33 33 # TODO: still being worked on
34 34 class WikiController < ApplicationController
35 35 default_search_scope :wiki_pages
36 36 before_filter :find_wiki, :authorize
37 37 before_filter :find_existing_or_new_page, :only => [:show, :edit, :update]
38 38 before_filter :find_existing_page, :only => [:rename, :protect, :history, :diff, :annotate, :add_attachment, :destroy, :destroy_version]
39 39 accept_api_auth :index, :show, :update, :destroy
40 before_filter :find_attachments, :only => [:preview]
40 41
41 42 helper :attachments
42 43 include AttachmentsHelper
43 44 helper :watchers
44 45 include Redmine::Export::PDF
45 46
46 47 # List of pages, sorted alphabetically and by parent (hierarchy)
47 48 def index
48 49 load_pages_for_index
49 50
50 51 respond_to do |format|
51 52 format.html {
52 53 @pages_by_parent_id = @pages.group_by(&:parent_id)
53 54 }
54 55 format.api
55 56 end
56 57 end
57 58
58 59 # List of page, by last update
59 60 def date_index
60 61 load_pages_for_index
61 62 @pages_by_date = @pages.group_by {|p| p.updated_on.to_date}
62 63 end
63 64
64 65 # display a page (in editing mode if it doesn't exist)
65 66 def show
66 67 if @page.new_record?
67 68 if User.current.allowed_to?(:edit_wiki_pages, @project) && editable? && !api_request?
68 69 edit
69 70 render :action => 'edit'
70 71 else
71 72 render_404
72 73 end
73 74 return
74 75 end
75 76 if params[:version] && !User.current.allowed_to?(:view_wiki_edits, @project)
76 77 deny_access
77 78 return
78 79 end
79 80 @content = @page.content_for_version(params[:version])
80 81 if User.current.allowed_to?(:export_wiki_pages, @project)
81 82 if params[:format] == 'pdf'
82 83 send_data(wiki_page_to_pdf(@page, @project), :type => 'application/pdf', :filename => "#{@page.title}.pdf")
83 84 return
84 85 elsif params[:format] == 'html'
85 86 export = render_to_string :action => 'export', :layout => false
86 87 send_data(export, :type => 'text/html', :filename => "#{@page.title}.html")
87 88 return
88 89 elsif params[:format] == 'txt'
89 90 send_data(@content.text, :type => 'text/plain', :filename => "#{@page.title}.txt")
90 91 return
91 92 end
92 93 end
93 94 @editable = editable?
94 95 @sections_editable = @editable && User.current.allowed_to?(:edit_wiki_pages, @page.project) &&
95 96 @content.current_version? &&
96 97 Redmine::WikiFormatting.supports_section_edit?
97 98
98 99 respond_to do |format|
99 100 format.html
100 101 format.api
101 102 end
102 103 end
103 104
104 105 # edit an existing page or a new one
105 106 def edit
106 107 return render_403 unless editable?
107 108 if @page.new_record?
108 109 @page.content = WikiContent.new(:page => @page)
109 110 if params[:parent].present?
110 111 @page.parent = @page.wiki.find_page(params[:parent].to_s)
111 112 end
112 113 end
113 114
114 115 @content = @page.content_for_version(params[:version])
115 116 @content.text = initial_page_content(@page) if @content.text.blank?
116 117 # don't keep previous comment
117 118 @content.comments = nil
118 119
119 120 # To prevent StaleObjectError exception when reverting to a previous version
120 121 @content.version = @page.content.version
121 122
122 123 @text = @content.text
123 124 if params[:section].present? && Redmine::WikiFormatting.supports_section_edit?
124 125 @section = params[:section].to_i
125 126 @text, @section_hash = Redmine::WikiFormatting.formatter.new(@text).get_section(@section)
126 127 render_404 if @text.blank?
127 128 end
128 129 end
129 130
130 131 # Creates a new page or updates an existing one
131 132 def update
132 133 return render_403 unless editable?
133 134 was_new_page = @page.new_record?
134 135 @page.content = WikiContent.new(:page => @page) if @page.new_record?
135 136 @page.safe_attributes = params[:wiki_page]
136 137
137 138 @content = @page.content
138 139 content_params = params[:content]
139 140 if content_params.nil? && params[:wiki_page].is_a?(Hash)
140 141 content_params = params[:wiki_page].slice(:text, :comments, :version)
141 142 end
142 143 content_params ||= {}
143 144
144 145 @content.comments = content_params[:comments]
145 146 @text = content_params[:text]
146 147 if params[:section].present? && Redmine::WikiFormatting.supports_section_edit?
147 148 @section = params[:section].to_i
148 149 @section_hash = params[:section_hash]
149 150 @content.text = Redmine::WikiFormatting.formatter.new(@content.text).update_section(params[:section].to_i, @text, @section_hash)
150 151 else
151 152 @content.version = content_params[:version] if content_params[:version]
152 153 @content.text = @text
153 154 end
154 155 @content.author = User.current
155 156
156 157 if @page.save_with_content
157 158 attachments = Attachment.attach_files(@page, params[:attachments])
158 159 render_attachment_warning_if_needed(@page)
159 160 call_hook(:controller_wiki_edit_after_save, { :params => params, :page => @page})
160 161
161 162 respond_to do |format|
162 163 format.html { redirect_to :action => 'show', :project_id => @project, :id => @page.title }
163 164 format.api {
164 165 if was_new_page
165 166 render :action => 'show', :status => :created, :location => url_for(:controller => 'wiki', :action => 'show', :project_id => @project, :id => @page.title)
166 167 else
167 168 render_api_ok
168 169 end
169 170 }
170 171 end
171 172 else
172 173 respond_to do |format|
173 174 format.html { render :action => 'edit' }
174 175 format.api { render_validation_errors(@content) }
175 176 end
176 177 end
177 178
178 179 rescue ActiveRecord::StaleObjectError, Redmine::WikiFormatting::StaleSectionError
179 180 # Optimistic locking exception
180 181 respond_to do |format|
181 182 format.html {
182 183 flash.now[:error] = l(:notice_locking_conflict)
183 184 render :action => 'edit'
184 185 }
185 186 format.api { render_api_head :conflict }
186 187 end
187 188 rescue ActiveRecord::RecordNotSaved
188 189 respond_to do |format|
189 190 format.html { render :action => 'edit' }
190 191 format.api { render_validation_errors(@content) }
191 192 end
192 193 end
193 194
194 195 # rename a page
195 196 def rename
196 197 return render_403 unless editable?
197 198 @page.redirect_existing_links = true
198 199 # used to display the *original* title if some AR validation errors occur
199 200 @original_title = @page.pretty_title
200 201 if request.post? && @page.update_attributes(params[:wiki_page])
201 202 flash[:notice] = l(:notice_successful_update)
202 203 redirect_to :action => 'show', :project_id => @project, :id => @page.title
203 204 end
204 205 end
205 206
206 207 def protect
207 208 @page.update_attribute :protected, params[:protected]
208 209 redirect_to :action => 'show', :project_id => @project, :id => @page.title
209 210 end
210 211
211 212 # show page history
212 213 def history
213 214 @version_count = @page.content.versions.count
214 215 @version_pages = Paginator.new self, @version_count, per_page_option, params['page']
215 216 # don't load text
216 217 @versions = @page.content.versions.
217 218 select("id, author_id, comments, updated_on, version").
218 219 reorder('version DESC').
219 220 limit(@version_pages.items_per_page + 1).
220 221 offset(@version_pages.current.offset).
221 222 all
222 223
223 224 render :layout => false if request.xhr?
224 225 end
225 226
226 227 def diff
227 228 @diff = @page.diff(params[:version], params[:version_from])
228 229 render_404 unless @diff
229 230 end
230 231
231 232 def annotate
232 233 @annotate = @page.annotate(params[:version])
233 234 render_404 unless @annotate
234 235 end
235 236
236 237 # Removes a wiki page and its history
237 238 # Children can be either set as root pages, removed or reassigned to another parent page
238 239 def destroy
239 240 return render_403 unless editable?
240 241
241 242 @descendants_count = @page.descendants.size
242 243 if @descendants_count > 0
243 244 case params[:todo]
244 245 when 'nullify'
245 246 # Nothing to do
246 247 when 'destroy'
247 248 # Removes all its descendants
248 249 @page.descendants.each(&:destroy)
249 250 when 'reassign'
250 251 # Reassign children to another parent page
251 252 reassign_to = @wiki.pages.find_by_id(params[:reassign_to_id].to_i)
252 253 return unless reassign_to
253 254 @page.children.each do |child|
254 255 child.update_attribute(:parent, reassign_to)
255 256 end
256 257 else
257 258 @reassignable_to = @wiki.pages - @page.self_and_descendants
258 259 # display the destroy form if it's a user request
259 260 return unless api_request?
260 261 end
261 262 end
262 263 @page.destroy
263 264 respond_to do |format|
264 265 format.html { redirect_to :action => 'index', :project_id => @project }
265 266 format.api { render_api_ok }
266 267 end
267 268 end
268 269
269 270 def destroy_version
270 271 return render_403 unless editable?
271 272
272 273 @content = @page.content_for_version(params[:version])
273 274 @content.destroy
274 275 redirect_to_referer_or :action => 'history', :id => @page.title, :project_id => @project
275 276 end
276 277
277 278 # Export wiki to a single pdf or html file
278 279 def export
279 280 @pages = @wiki.pages.all(:order => 'title', :include => [:content, {:attachments => :author}])
280 281 respond_to do |format|
281 282 format.html {
282 283 export = render_to_string :action => 'export_multiple', :layout => false
283 284 send_data(export, :type => 'text/html', :filename => "wiki.html")
284 285 }
285 286 format.pdf {
286 287 send_data(wiki_pages_to_pdf(@pages, @project), :type => 'application/pdf', :filename => "#{@project.identifier}.pdf")
287 288 }
288 289 end
289 290 end
290 291
291 292 def preview
292 293 page = @wiki.find_page(params[:id])
293 294 # page is nil when previewing a new page
294 295 return render_403 unless page.nil? || editable?(page)
295 296 if page
296 @attachements = page.attachments
297 @attachments += page.attachments
297 298 @previewed = page.content
298 299 end
299 300 @text = params[:content][:text]
300 301 render :partial => 'common/preview'
301 302 end
302 303
303 304 def add_attachment
304 305 return render_403 unless editable?
305 306 attachments = Attachment.attach_files(@page, params[:attachments])
306 307 render_attachment_warning_if_needed(@page)
307 308 redirect_to :action => 'show', :id => @page.title, :project_id => @project
308 309 end
309 310
310 311 private
311 312
312 313 def find_wiki
313 314 @project = Project.find(params[:project_id])
314 315 @wiki = @project.wiki
315 316 render_404 unless @wiki
316 317 rescue ActiveRecord::RecordNotFound
317 318 render_404
318 319 end
319 320
320 321 # Finds the requested page or a new page if it doesn't exist
321 322 def find_existing_or_new_page
322 323 @page = @wiki.find_or_new_page(params[:id])
323 324 if @wiki.page_found_with_redirect?
324 325 redirect_to params.update(:id => @page.title)
325 326 end
326 327 end
327 328
328 329 # Finds the requested page and returns a 404 error if it doesn't exist
329 330 def find_existing_page
330 331 @page = @wiki.find_page(params[:id])
331 332 if @page.nil?
332 333 render_404
333 334 return
334 335 end
335 336 if @wiki.page_found_with_redirect?
336 337 redirect_to params.update(:id => @page.title)
337 338 end
338 339 end
339 340
340 341 # Returns true if the current user is allowed to edit the page, otherwise false
341 342 def editable?(page = @page)
342 343 page.editable_by?(User.current)
343 344 end
344 345
345 346 # Returns the default content of a new wiki page
346 347 def initial_page_content(page)
347 348 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
348 349 extend helper unless self.instance_of?(helper)
349 350 helper.instance_method(:initial_page_content).bind(self).call(page)
350 351 end
351 352
352 353 def load_pages_for_index
353 354 @pages = @wiki.pages.with_updated_on.order("#{WikiPage.table_name}.title").includes(:wiki => :project).includes(:parent).all
354 355 end
355 356 end
@@ -1,1285 +1,1286
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 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
28 28 extend Forwardable
29 29 def_delegators :wiki_helper, :wikitoolbar_for, :heads_for_wiki_formatter
30 30
31 31 # Return true if user is authorized for controller/action, otherwise false
32 32 def authorize_for(controller, action)
33 33 User.current.allowed_to?({:controller => controller, :action => action}, @project)
34 34 end
35 35
36 36 # Display a link if user is authorized
37 37 #
38 38 # @param [String] name Anchor text (passed to link_to)
39 39 # @param [Hash] options Hash params. This will checked by authorize_for to see if the user is authorized
40 40 # @param [optional, Hash] html_options Options passed to link_to
41 41 # @param [optional, Hash] parameters_for_method_reference Extra parameters for link_to
42 42 def link_to_if_authorized(name, options = {}, html_options = nil, *parameters_for_method_reference)
43 43 link_to(name, options, html_options, *parameters_for_method_reference) if authorize_for(options[:controller] || params[:controller], options[:action])
44 44 end
45 45
46 46 # Displays a link to user's account page if active
47 47 def link_to_user(user, options={})
48 48 if user.is_a?(User)
49 49 name = h(user.name(options[:format]))
50 50 if user.active? || (User.current.admin? && user.logged?)
51 51 link_to name, user_path(user), :class => user.css_classes
52 52 else
53 53 name
54 54 end
55 55 else
56 56 h(user.to_s)
57 57 end
58 58 end
59 59
60 60 # Displays a link to +issue+ with its subject.
61 61 # Examples:
62 62 #
63 63 # link_to_issue(issue) # => Defect #6: This is the subject
64 64 # link_to_issue(issue, :truncate => 6) # => Defect #6: This i...
65 65 # link_to_issue(issue, :subject => false) # => Defect #6
66 66 # link_to_issue(issue, :project => true) # => Foo - Defect #6
67 67 # link_to_issue(issue, :subject => false, :tracker => false) # => #6
68 68 #
69 69 def link_to_issue(issue, options={})
70 70 title = nil
71 71 subject = nil
72 72 text = options[:tracker] == false ? "##{issue.id}" : "#{issue.tracker} ##{issue.id}"
73 73 if options[:subject] == false
74 74 title = truncate(issue.subject, :length => 60)
75 75 else
76 76 subject = issue.subject
77 77 if options[:truncate]
78 78 subject = truncate(subject, :length => options[:truncate])
79 79 end
80 80 end
81 81 s = link_to text, issue_path(issue), :class => issue.css_classes, :title => title
82 82 s << h(": #{subject}") if subject
83 83 s = h("#{issue.project} - ") + s if options[:project]
84 84 s
85 85 end
86 86
87 87 # Generates a link to an attachment.
88 88 # Options:
89 89 # * :text - Link text (default to attachment filename)
90 90 # * :download - Force download (default: false)
91 91 def link_to_attachment(attachment, options={})
92 92 text = options.delete(:text) || attachment.filename
93 93 action = options.delete(:download) ? 'download' : 'show'
94 94 opt_only_path = {}
95 95 opt_only_path[:only_path] = (options[:only_path] == false ? false : true)
96 96 options.delete(:only_path)
97 97 link_to(h(text),
98 98 {:controller => 'attachments', :action => action,
99 99 :id => attachment, :filename => attachment.filename}.merge(opt_only_path),
100 100 options)
101 101 end
102 102
103 103 # Generates a link to a SCM revision
104 104 # Options:
105 105 # * :text - Link text (default to the formatted revision)
106 106 def link_to_revision(revision, repository, options={})
107 107 if repository.is_a?(Project)
108 108 repository = repository.repository
109 109 end
110 110 text = options.delete(:text) || format_revision(revision)
111 111 rev = revision.respond_to?(:identifier) ? revision.identifier : revision
112 112 link_to(
113 113 h(text),
114 114 {:controller => 'repositories', :action => 'revision', :id => repository.project, :repository_id => repository.identifier_param, :rev => rev},
115 115 :title => l(:label_revision_id, format_revision(revision))
116 116 )
117 117 end
118 118
119 119 # Generates a link to a message
120 120 def link_to_message(message, options={}, html_options = nil)
121 121 link_to(
122 122 h(truncate(message.subject, :length => 60)),
123 123 { :controller => 'messages', :action => 'show',
124 124 :board_id => message.board_id,
125 125 :id => (message.parent_id || message.id),
126 126 :r => (message.parent_id && message.id),
127 127 :anchor => (message.parent_id ? "message-#{message.id}" : nil)
128 128 }.merge(options),
129 129 html_options
130 130 )
131 131 end
132 132
133 133 # Generates a link to a project if active
134 134 # Examples:
135 135 #
136 136 # link_to_project(project) # => link to the specified project overview
137 137 # link_to_project(project, :action=>'settings') # => link to project settings
138 138 # link_to_project(project, {:only_path => false}, :class => "project") # => 3rd arg adds html options
139 139 # link_to_project(project, {}, :class => "project") # => html options with default url (project overview)
140 140 #
141 141 def link_to_project(project, options={}, html_options = nil)
142 142 if project.archived?
143 143 h(project)
144 144 else
145 145 url = {:controller => 'projects', :action => 'show', :id => project}.merge(options)
146 146 link_to(h(project), url, html_options)
147 147 end
148 148 end
149 149
150 150 def wiki_page_path(page, options={})
151 151 url_for({:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title}.merge(options))
152 152 end
153 153
154 154 def thumbnail_tag(attachment)
155 155 link_to image_tag(url_for(:controller => 'attachments', :action => 'thumbnail', :id => attachment)),
156 156 {:controller => 'attachments', :action => 'show', :id => attachment, :filename => attachment.filename},
157 157 :title => attachment.filename
158 158 end
159 159
160 160 def toggle_link(name, id, options={})
161 161 onclick = "$('##{id}').toggle(); "
162 162 onclick << (options[:focus] ? "$('##{options[:focus]}').focus(); " : "this.blur(); ")
163 163 onclick << "return false;"
164 164 link_to(name, "#", :onclick => onclick)
165 165 end
166 166
167 167 def image_to_function(name, function, html_options = {})
168 168 html_options.symbolize_keys!
169 169 tag(:input, html_options.merge({
170 170 :type => "image", :src => image_path(name),
171 171 :onclick => (html_options[:onclick] ? "#{html_options[:onclick]}; " : "") + "#{function};"
172 172 }))
173 173 end
174 174
175 175 def format_activity_title(text)
176 176 h(truncate_single_line(text, :length => 100))
177 177 end
178 178
179 179 def format_activity_day(date)
180 180 date == User.current.today ? l(:label_today).titleize : format_date(date)
181 181 end
182 182
183 183 def format_activity_description(text)
184 184 h(truncate(text.to_s, :length => 120).gsub(%r{[\r\n]*<(pre|code)>.*$}m, '...')
185 185 ).gsub(/[\r\n]+/, "<br />").html_safe
186 186 end
187 187
188 188 def format_version_name(version)
189 189 if version.project == @project
190 190 h(version)
191 191 else
192 192 h("#{version.project} - #{version}")
193 193 end
194 194 end
195 195
196 196 def due_date_distance_in_words(date)
197 197 if date
198 198 l((date < Date.today ? :label_roadmap_overdue : :label_roadmap_due_in), distance_of_date_in_words(Date.today, date))
199 199 end
200 200 end
201 201
202 202 # Renders a tree of projects as a nested set of unordered lists
203 203 # The given collection may be a subset of the whole project tree
204 204 # (eg. some intermediate nodes are private and can not be seen)
205 205 def render_project_nested_lists(projects)
206 206 s = ''
207 207 if projects.any?
208 208 ancestors = []
209 209 original_project = @project
210 210 projects.sort_by(&:lft).each do |project|
211 211 # set the project environment to please macros.
212 212 @project = project
213 213 if (ancestors.empty? || project.is_descendant_of?(ancestors.last))
214 214 s << "<ul class='projects #{ ancestors.empty? ? 'root' : nil}'>\n"
215 215 else
216 216 ancestors.pop
217 217 s << "</li>"
218 218 while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
219 219 ancestors.pop
220 220 s << "</ul></li>\n"
221 221 end
222 222 end
223 223 classes = (ancestors.empty? ? 'root' : 'child')
224 224 s << "<li class='#{classes}'><div class='#{classes}'>"
225 225 s << h(block_given? ? yield(project) : project.name)
226 226 s << "</div>\n"
227 227 ancestors << project
228 228 end
229 229 s << ("</li></ul>\n" * ancestors.size)
230 230 @project = original_project
231 231 end
232 232 s.html_safe
233 233 end
234 234
235 235 def render_page_hierarchy(pages, node=nil, options={})
236 236 content = ''
237 237 if pages[node]
238 238 content << "<ul class=\"pages-hierarchy\">\n"
239 239 pages[node].each do |page|
240 240 content << "<li>"
241 241 content << link_to(h(page.pretty_title), {:controller => 'wiki', :action => 'show', :project_id => page.project, :id => page.title, :version => nil},
242 242 :title => (options[:timestamp] && page.updated_on ? l(:label_updated_time, distance_of_time_in_words(Time.now, page.updated_on)) : nil))
243 243 content << "\n" + render_page_hierarchy(pages, page.id, options) if pages[page.id]
244 244 content << "</li>\n"
245 245 end
246 246 content << "</ul>\n"
247 247 end
248 248 content.html_safe
249 249 end
250 250
251 251 # Renders flash messages
252 252 def render_flash_messages
253 253 s = ''
254 254 flash.each do |k,v|
255 255 s << content_tag('div', v.html_safe, :class => "flash #{k}", :id => "flash_#{k}")
256 256 end
257 257 s.html_safe
258 258 end
259 259
260 260 # Renders tabs and their content
261 261 def render_tabs(tabs)
262 262 if tabs.any?
263 263 render :partial => 'common/tabs', :locals => {:tabs => tabs}
264 264 else
265 265 content_tag 'p', l(:label_no_data), :class => "nodata"
266 266 end
267 267 end
268 268
269 269 # Renders the project quick-jump box
270 270 def render_project_jump_box
271 271 return unless User.current.logged?
272 272 projects = User.current.memberships.collect(&:project).compact.select(&:active?).uniq
273 273 if projects.any?
274 274 options =
275 275 ("<option value=''>#{ l(:label_jump_to_a_project) }</option>" +
276 276 '<option value="" disabled="disabled">---</option>').html_safe
277 277
278 278 options << project_tree_options_for_select(projects, :selected => @project) do |p|
279 279 { :value => project_path(:id => p, :jump => current_menu_item) }
280 280 end
281 281
282 282 select_tag('project_quick_jump_box', options, :onchange => 'if (this.value != \'\') { window.location = this.value; }')
283 283 end
284 284 end
285 285
286 286 def project_tree_options_for_select(projects, options = {})
287 287 s = ''
288 288 project_tree(projects) do |project, level|
289 289 name_prefix = (level > 0 ? '&nbsp;' * 2 * level + '&#187; ' : '').html_safe
290 290 tag_options = {:value => project.id}
291 291 if project == options[:selected] || (options[:selected].respond_to?(:include?) && options[:selected].include?(project))
292 292 tag_options[:selected] = 'selected'
293 293 else
294 294 tag_options[:selected] = nil
295 295 end
296 296 tag_options.merge!(yield(project)) if block_given?
297 297 s << content_tag('option', name_prefix + h(project), tag_options)
298 298 end
299 299 s.html_safe
300 300 end
301 301
302 302 # Yields the given block for each project with its level in the tree
303 303 #
304 304 # Wrapper for Project#project_tree
305 305 def project_tree(projects, &block)
306 306 Project.project_tree(projects, &block)
307 307 end
308 308
309 309 def principals_check_box_tags(name, principals)
310 310 s = ''
311 311 principals.sort.each do |principal|
312 312 s << "<label>#{ check_box_tag name, principal.id, false } #{h principal}</label>\n"
313 313 end
314 314 s.html_safe
315 315 end
316 316
317 317 # Returns a string for users/groups option tags
318 318 def principals_options_for_select(collection, selected=nil)
319 319 s = ''
320 320 if collection.include?(User.current)
321 321 s << content_tag('option', "<< #{l(:label_me)} >>", :value => User.current.id)
322 322 end
323 323 groups = ''
324 324 collection.sort.each do |element|
325 325 selected_attribute = ' selected="selected"' if option_value_selected?(element, selected)
326 326 (element.is_a?(Group) ? groups : s) << %(<option value="#{element.id}"#{selected_attribute}>#{h element.name}</option>)
327 327 end
328 328 unless groups.empty?
329 329 s << %(<optgroup label="#{h(l(:label_group_plural))}">#{groups}</optgroup>)
330 330 end
331 331 s.html_safe
332 332 end
333 333
334 334 # Options for the new membership projects combo-box
335 335 def options_for_membership_project_select(principal, projects)
336 336 options = content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---")
337 337 options << project_tree_options_for_select(projects) do |p|
338 338 {:disabled => principal.projects.include?(p)}
339 339 end
340 340 options
341 341 end
342 342
343 343 # Truncates and returns the string as a single line
344 344 def truncate_single_line(string, *args)
345 345 truncate(string.to_s, *args).gsub(%r{[\r\n]+}m, ' ')
346 346 end
347 347
348 348 # Truncates at line break after 250 characters or options[:length]
349 349 def truncate_lines(string, options={})
350 350 length = options[:length] || 250
351 351 if string.to_s =~ /\A(.{#{length}}.*?)$/m
352 352 "#{$1}..."
353 353 else
354 354 string
355 355 end
356 356 end
357 357
358 358 def anchor(text)
359 359 text.to_s.gsub(' ', '_')
360 360 end
361 361
362 362 def html_hours(text)
363 363 text.gsub(%r{(\d+)\.(\d+)}, '<span class="hours hours-int">\1</span><span class="hours hours-dec">.\2</span>').html_safe
364 364 end
365 365
366 366 def authoring(created, author, options={})
367 367 l(options[:label] || :label_added_time_by, :author => link_to_user(author), :age => time_tag(created)).html_safe
368 368 end
369 369
370 370 def time_tag(time)
371 371 text = distance_of_time_in_words(Time.now, time)
372 372 if @project
373 373 link_to(text, {:controller => 'activities', :action => 'index', :id => @project, :from => User.current.time_to_date(time)}, :title => format_time(time))
374 374 else
375 375 content_tag('acronym', text, :title => format_time(time))
376 376 end
377 377 end
378 378
379 379 def syntax_highlight_lines(name, content)
380 380 lines = []
381 381 syntax_highlight(name, content).each_line { |line| lines << line }
382 382 lines
383 383 end
384 384
385 385 def syntax_highlight(name, content)
386 386 Redmine::SyntaxHighlighting.highlight_by_filename(content, name)
387 387 end
388 388
389 389 def to_path_param(path)
390 390 str = path.to_s.split(%r{[/\\]}).select{|p| !p.blank?}.join("/")
391 391 str.blank? ? nil : str
392 392 end
393 393
394 394 def pagination_links_full(paginator, count=nil, options={})
395 395 page_param = options.delete(:page_param) || :page
396 396 per_page_links = options.delete(:per_page_links)
397 397 url_param = params.dup
398 398
399 399 html = ''
400 400 if paginator.current.previous
401 401 # \xc2\xab(utf-8) = &#171;
402 402 html << link_to_content_update(
403 403 "\xc2\xab " + l(:label_previous),
404 404 url_param.merge(page_param => paginator.current.previous)) + ' '
405 405 end
406 406
407 407 html << (pagination_links_each(paginator, options) do |n|
408 408 link_to_content_update(n.to_s, url_param.merge(page_param => n))
409 409 end || '')
410 410
411 411 if paginator.current.next
412 412 # \xc2\xbb(utf-8) = &#187;
413 413 html << ' ' + link_to_content_update(
414 414 (l(:label_next) + " \xc2\xbb"),
415 415 url_param.merge(page_param => paginator.current.next))
416 416 end
417 417
418 418 unless count.nil?
419 419 html << " (#{paginator.current.first_item}-#{paginator.current.last_item}/#{count})"
420 420 if per_page_links != false && links = per_page_links(paginator.items_per_page, count)
421 421 html << " | #{links}"
422 422 end
423 423 end
424 424
425 425 html.html_safe
426 426 end
427 427
428 428 def per_page_links(selected=nil, item_count=nil)
429 429 values = Setting.per_page_options_array
430 430 if item_count && values.any?
431 431 if item_count > values.first
432 432 max = values.detect {|value| value >= item_count} || item_count
433 433 else
434 434 max = item_count
435 435 end
436 436 values = values.select {|value| value <= max || value == selected}
437 437 end
438 438 if values.empty? || (values.size == 1 && values.first == selected)
439 439 return nil
440 440 end
441 441 links = values.collect do |n|
442 442 n == selected ? n : link_to_content_update(n, params.merge(:per_page => n))
443 443 end
444 444 l(:label_display_per_page, links.join(', '))
445 445 end
446 446
447 447 def reorder_links(name, url, method = :post)
448 448 link_to(image_tag('2uparrow.png', :alt => l(:label_sort_highest)),
449 449 url.merge({"#{name}[move_to]" => 'highest'}),
450 450 :method => method, :title => l(:label_sort_highest)) +
451 451 link_to(image_tag('1uparrow.png', :alt => l(:label_sort_higher)),
452 452 url.merge({"#{name}[move_to]" => 'higher'}),
453 453 :method => method, :title => l(:label_sort_higher)) +
454 454 link_to(image_tag('1downarrow.png', :alt => l(:label_sort_lower)),
455 455 url.merge({"#{name}[move_to]" => 'lower'}),
456 456 :method => method, :title => l(:label_sort_lower)) +
457 457 link_to(image_tag('2downarrow.png', :alt => l(:label_sort_lowest)),
458 458 url.merge({"#{name}[move_to]" => 'lowest'}),
459 459 :method => method, :title => l(:label_sort_lowest))
460 460 end
461 461
462 462 def breadcrumb(*args)
463 463 elements = args.flatten
464 464 elements.any? ? content_tag('p', (args.join(" \xc2\xbb ") + " \xc2\xbb ").html_safe, :class => 'breadcrumb') : nil
465 465 end
466 466
467 467 def other_formats_links(&block)
468 468 concat('<p class="other-formats">'.html_safe + l(:label_export_to))
469 469 yield Redmine::Views::OtherFormatsBuilder.new(self)
470 470 concat('</p>'.html_safe)
471 471 end
472 472
473 473 def page_header_title
474 474 if @project.nil? || @project.new_record?
475 475 h(Setting.app_title)
476 476 else
477 477 b = []
478 478 ancestors = (@project.root? ? [] : @project.ancestors.visible.all)
479 479 if ancestors.any?
480 480 root = ancestors.shift
481 481 b << link_to_project(root, {:jump => current_menu_item}, :class => 'root')
482 482 if ancestors.size > 2
483 483 b << "\xe2\x80\xa6"
484 484 ancestors = ancestors[-2, 2]
485 485 end
486 486 b += ancestors.collect {|p| link_to_project(p, {:jump => current_menu_item}, :class => 'ancestor') }
487 487 end
488 488 b << h(@project)
489 489 b.join(" \xc2\xbb ").html_safe
490 490 end
491 491 end
492 492
493 493 def html_title(*args)
494 494 if args.empty?
495 495 title = @html_title || []
496 496 title << @project.name if @project
497 497 title << Setting.app_title unless Setting.app_title == title.last
498 498 title.select {|t| !t.blank? }.join(' - ')
499 499 else
500 500 @html_title ||= []
501 501 @html_title += args
502 502 end
503 503 end
504 504
505 505 # Returns the theme, controller name, and action as css classes for the
506 506 # HTML body.
507 507 def body_css_classes
508 508 css = []
509 509 if theme = Redmine::Themes.theme(Setting.ui_theme)
510 510 css << 'theme-' + theme.name
511 511 end
512 512
513 513 css << 'controller-' + controller_name
514 514 css << 'action-' + action_name
515 515 css.join(' ')
516 516 end
517 517
518 518 def accesskey(s)
519 519 Redmine::AccessKeys.key_for s
520 520 end
521 521
522 522 # Formats text according to system settings.
523 523 # 2 ways to call this method:
524 524 # * with a String: textilizable(text, options)
525 525 # * with an object and one of its attribute: textilizable(issue, :description, options)
526 526 def textilizable(*args)
527 527 options = args.last.is_a?(Hash) ? args.pop : {}
528 528 case args.size
529 529 when 1
530 530 obj = options[:object]
531 531 text = args.shift
532 532 when 2
533 533 obj = args.shift
534 534 attr = args.shift
535 535 text = obj.send(attr).to_s
536 536 else
537 537 raise ArgumentError, 'invalid arguments to textilizable'
538 538 end
539 539 return '' if text.blank?
540 540 project = options[:project] || @project || (obj && obj.respond_to?(:project) ? obj.project : nil)
541 541 only_path = options.delete(:only_path) == false ? false : true
542 542
543 543 text = text.dup
544 544 macros = catch_macros(text)
545 545 text = Redmine::WikiFormatting.to_html(Setting.text_formatting, text, :object => obj, :attribute => attr)
546 546
547 547 @parsed_headings = []
548 548 @heading_anchors = {}
549 549 @current_section = 0 if options[:edit_section_links]
550 550
551 551 parse_sections(text, project, obj, attr, only_path, options)
552 552 text = parse_non_pre_blocks(text, obj, macros) do |text|
553 553 [:parse_inline_attachments, :parse_wiki_links, :parse_redmine_links].each do |method_name|
554 554 send method_name, text, project, obj, attr, only_path, options
555 555 end
556 556 end
557 557 parse_headings(text, project, obj, attr, only_path, options)
558 558
559 559 if @parsed_headings.any?
560 560 replace_toc(text, @parsed_headings)
561 561 end
562 562
563 563 text.html_safe
564 564 end
565 565
566 566 def parse_non_pre_blocks(text, obj, macros)
567 567 s = StringScanner.new(text)
568 568 tags = []
569 569 parsed = ''
570 570 while !s.eos?
571 571 s.scan(/(.*?)(<(\/)?(pre|code)(.*?)>|\z)/im)
572 572 text, full_tag, closing, tag = s[1], s[2], s[3], s[4]
573 573 if tags.empty?
574 574 yield text
575 575 inject_macros(text, obj, macros) if macros.any?
576 576 else
577 577 inject_macros(text, obj, macros, false) if macros.any?
578 578 end
579 579 parsed << text
580 580 if tag
581 581 if closing
582 582 if tags.last == tag.downcase
583 583 tags.pop
584 584 end
585 585 else
586 586 tags << tag.downcase
587 587 end
588 588 parsed << full_tag
589 589 end
590 590 end
591 591 # Close any non closing tags
592 592 while tag = tags.pop
593 593 parsed << "</#{tag}>"
594 594 end
595 595 parsed
596 596 end
597 597
598 598 def parse_inline_attachments(text, project, obj, attr, only_path, options)
599 599 # when using an image link, try to use an attachment, if possible
600 if options[:attachments] || (obj && obj.respond_to?(:attachments))
601 attachments = options[:attachments] || obj.attachments
600 if options[:attachments].present? || (obj && obj.respond_to?(:attachments))
601 attachments = options[:attachments] || []
602 attachments += obj.attachments if obj
602 603 text.gsub!(/src="([^\/"]+\.(bmp|gif|jpg|jpe|jpeg|png))"(\s+alt="([^"]*)")?/i) do |m|
603 604 filename, ext, alt, alttext = $1.downcase, $2, $3, $4
604 605 # search for the picture in attachments
605 606 if found = Attachment.latest_attach(attachments, filename)
606 607 image_url = url_for :only_path => only_path, :controller => 'attachments',
607 608 :action => 'download', :id => found
608 609 desc = found.description.to_s.gsub('"', '')
609 610 if !desc.blank? && alttext.blank?
610 611 alt = " title=\"#{desc}\" alt=\"#{desc}\""
611 612 end
612 613 "src=\"#{image_url}\"#{alt}"
613 614 else
614 615 m
615 616 end
616 617 end
617 618 end
618 619 end
619 620
620 621 # Wiki links
621 622 #
622 623 # Examples:
623 624 # [[mypage]]
624 625 # [[mypage|mytext]]
625 626 # wiki links can refer other project wikis, using project name or identifier:
626 627 # [[project:]] -> wiki starting page
627 628 # [[project:|mytext]]
628 629 # [[project:mypage]]
629 630 # [[project:mypage|mytext]]
630 631 def parse_wiki_links(text, project, obj, attr, only_path, options)
631 632 text.gsub!(/(!)?(\[\[([^\]\n\|]+)(\|([^\]\n\|]+))?\]\])/) do |m|
632 633 link_project = project
633 634 esc, all, page, title = $1, $2, $3, $5
634 635 if esc.nil?
635 636 if page =~ /^([^\:]+)\:(.*)$/
636 637 link_project = Project.find_by_identifier($1) || Project.find_by_name($1)
637 638 page = $2
638 639 title ||= $1 if page.blank?
639 640 end
640 641
641 642 if link_project && link_project.wiki
642 643 # extract anchor
643 644 anchor = nil
644 645 if page =~ /^(.+?)\#(.+)$/
645 646 page, anchor = $1, $2
646 647 end
647 648 anchor = sanitize_anchor_name(anchor) if anchor.present?
648 649 # check if page exists
649 650 wiki_page = link_project.wiki.find_page(page)
650 651 url = if anchor.present? && wiki_page.present? && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version)) && obj.page == wiki_page
651 652 "##{anchor}"
652 653 else
653 654 case options[:wiki_links]
654 655 when :local; "#{page.present? ? Wiki.titleize(page) : ''}.html" + (anchor.present? ? "##{anchor}" : '')
655 656 when :anchor; "##{page.present? ? Wiki.titleize(page) : title}" + (anchor.present? ? "_#{anchor}" : '') # used for single-file wiki export
656 657 else
657 658 wiki_page_id = page.present? ? Wiki.titleize(page) : nil
658 659 parent = wiki_page.nil? && obj.is_a?(WikiContent) && obj.page && project == link_project ? obj.page.title : nil
659 660 url_for(:only_path => only_path, :controller => 'wiki', :action => 'show', :project_id => link_project,
660 661 :id => wiki_page_id, :version => nil, :anchor => anchor, :parent => parent)
661 662 end
662 663 end
663 664 link_to(title.present? ? title.html_safe : h(page), url, :class => ('wiki-page' + (wiki_page ? '' : ' new')))
664 665 else
665 666 # project or wiki doesn't exist
666 667 all
667 668 end
668 669 else
669 670 all
670 671 end
671 672 end
672 673 end
673 674
674 675 # Redmine links
675 676 #
676 677 # Examples:
677 678 # Issues:
678 679 # #52 -> Link to issue #52
679 680 # Changesets:
680 681 # r52 -> Link to revision 52
681 682 # commit:a85130f -> Link to scmid starting with a85130f
682 683 # Documents:
683 684 # document#17 -> Link to document with id 17
684 685 # document:Greetings -> Link to the document with title "Greetings"
685 686 # document:"Some document" -> Link to the document with title "Some document"
686 687 # Versions:
687 688 # version#3 -> Link to version with id 3
688 689 # version:1.0.0 -> Link to version named "1.0.0"
689 690 # version:"1.0 beta 2" -> Link to version named "1.0 beta 2"
690 691 # Attachments:
691 692 # attachment:file.zip -> Link to the attachment of the current object named file.zip
692 693 # Source files:
693 694 # source:some/file -> Link to the file located at /some/file in the project's repository
694 695 # source:some/file@52 -> Link to the file's revision 52
695 696 # source:some/file#L120 -> Link to line 120 of the file
696 697 # source:some/file@52#L120 -> Link to line 120 of the file's revision 52
697 698 # export:some/file -> Force the download of the file
698 699 # Forum messages:
699 700 # message#1218 -> Link to message with id 1218
700 701 #
701 702 # Links can refer other objects from other projects, using project identifier:
702 703 # identifier:r52
703 704 # identifier:document:"Some document"
704 705 # identifier:version:1.0.0
705 706 # identifier:source:some/file
706 707 def parse_redmine_links(text, project, obj, attr, only_path, options)
707 708 text.gsub!(%r{([\s\(,\-\[\>]|^)(!)?(([a-z0-9\-_]+):)?(attachment|document|version|forum|news|message|project|commit|source|export)?(((#)|((([a-z0-9\-]+)\|)?(r)))((\d+)((#note)?-(\d+))?)|(:)([^"\s<>][^\s<>]*?|"[^"]+?"))(?=(?=[[:punct:]][^A-Za-z0-9_/])|,|\s|\]|<|$)}) do |m|
708 709 leading, esc, project_prefix, project_identifier, prefix, repo_prefix, repo_identifier, sep, identifier, comment_suffix, comment_id = $1, $2, $3, $4, $5, $10, $11, $8 || $12 || $18, $14 || $19, $15, $17
709 710 link = nil
710 711 if project_identifier
711 712 project = Project.visible.find_by_identifier(project_identifier)
712 713 end
713 714 if esc.nil?
714 715 if prefix.nil? && sep == 'r'
715 716 if project
716 717 repository = nil
717 718 if repo_identifier
718 719 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
719 720 else
720 721 repository = project.repository
721 722 end
722 723 # project.changesets.visible raises an SQL error because of a double join on repositories
723 724 if repository && (changeset = Changeset.visible.find_by_repository_id_and_revision(repository.id, identifier))
724 725 link = link_to(h("#{project_prefix}#{repo_prefix}r#{identifier}"), {:only_path => only_path, :controller => 'repositories', :action => 'revision', :id => project, :repository_id => repository.identifier_param, :rev => changeset.revision},
725 726 :class => 'changeset',
726 727 :title => truncate_single_line(changeset.comments, :length => 100))
727 728 end
728 729 end
729 730 elsif sep == '#'
730 731 oid = identifier.to_i
731 732 case prefix
732 733 when nil
733 734 if oid.to_s == identifier && issue = Issue.visible.find_by_id(oid, :include => :status)
734 735 anchor = comment_id ? "note-#{comment_id}" : nil
735 736 link = link_to("##{oid}", {:only_path => only_path, :controller => 'issues', :action => 'show', :id => oid, :anchor => anchor},
736 737 :class => issue.css_classes,
737 738 :title => "#{truncate(issue.subject, :length => 100)} (#{issue.status.name})")
738 739 end
739 740 when 'document'
740 741 if document = Document.visible.find_by_id(oid)
741 742 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
742 743 :class => 'document'
743 744 end
744 745 when 'version'
745 746 if version = Version.visible.find_by_id(oid)
746 747 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
747 748 :class => 'version'
748 749 end
749 750 when 'message'
750 751 if message = Message.visible.find_by_id(oid, :include => :parent)
751 752 link = link_to_message(message, {:only_path => only_path}, :class => 'message')
752 753 end
753 754 when 'forum'
754 755 if board = Board.visible.find_by_id(oid)
755 756 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
756 757 :class => 'board'
757 758 end
758 759 when 'news'
759 760 if news = News.visible.find_by_id(oid)
760 761 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
761 762 :class => 'news'
762 763 end
763 764 when 'project'
764 765 if p = Project.visible.find_by_id(oid)
765 766 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
766 767 end
767 768 end
768 769 elsif sep == ':'
769 770 # removes the double quotes if any
770 771 name = identifier.gsub(%r{^"(.*)"$}, "\\1")
771 772 case prefix
772 773 when 'document'
773 774 if project && document = project.documents.visible.find_by_title(name)
774 775 link = link_to h(document.title), {:only_path => only_path, :controller => 'documents', :action => 'show', :id => document},
775 776 :class => 'document'
776 777 end
777 778 when 'version'
778 779 if project && version = project.versions.visible.find_by_name(name)
779 780 link = link_to h(version.name), {:only_path => only_path, :controller => 'versions', :action => 'show', :id => version},
780 781 :class => 'version'
781 782 end
782 783 when 'forum'
783 784 if project && board = project.boards.visible.find_by_name(name)
784 785 link = link_to h(board.name), {:only_path => only_path, :controller => 'boards', :action => 'show', :id => board, :project_id => board.project},
785 786 :class => 'board'
786 787 end
787 788 when 'news'
788 789 if project && news = project.news.visible.find_by_title(name)
789 790 link = link_to h(news.title), {:only_path => only_path, :controller => 'news', :action => 'show', :id => news},
790 791 :class => 'news'
791 792 end
792 793 when 'commit', 'source', 'export'
793 794 if project
794 795 repository = nil
795 796 if name =~ %r{^(([a-z0-9\-]+)\|)(.+)$}
796 797 repo_prefix, repo_identifier, name = $1, $2, $3
797 798 repository = project.repositories.detect {|repo| repo.identifier == repo_identifier}
798 799 else
799 800 repository = project.repository
800 801 end
801 802 if prefix == 'commit'
802 803 if repository && (changeset = Changeset.visible.where("repository_id = ? AND scmid LIKE ?", repository.id, "#{name}%").first)
803 804 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},
804 805 :class => 'changeset',
805 806 :title => truncate_single_line(h(changeset.comments), :length => 100)
806 807 end
807 808 else
808 809 if repository && User.current.allowed_to?(:browse_repository, project)
809 810 name =~ %r{^[/\\]*(.*?)(@([0-9a-f]+))?(#(L\d+))?$}
810 811 path, rev, anchor = $1, $3, $5
811 812 link = link_to h("#{project_prefix}#{prefix}:#{repo_prefix}#{name}"), {:controller => 'repositories', :action => (prefix == 'export' ? 'raw' : 'entry'), :id => project, :repository_id => repository.identifier_param,
812 813 :path => to_path_param(path),
813 814 :rev => rev,
814 815 :anchor => anchor},
815 816 :class => (prefix == 'export' ? 'source download' : 'source')
816 817 end
817 818 end
818 819 repo_prefix = nil
819 820 end
820 821 when 'attachment'
821 822 attachments = options[:attachments] || (obj && obj.respond_to?(:attachments) ? obj.attachments : nil)
822 823 if attachments && attachment = attachments.detect {|a| a.filename == name }
823 824 link = link_to h(attachment.filename), {:only_path => only_path, :controller => 'attachments', :action => 'download', :id => attachment},
824 825 :class => 'attachment'
825 826 end
826 827 when 'project'
827 828 if p = Project.visible.where("identifier = :s OR LOWER(name) = :s", :s => name.downcase).first
828 829 link = link_to_project(p, {:only_path => only_path}, :class => 'project')
829 830 end
830 831 end
831 832 end
832 833 end
833 834 (leading + (link || "#{project_prefix}#{prefix}#{repo_prefix}#{sep}#{identifier}#{comment_suffix}"))
834 835 end
835 836 end
836 837
837 838 HEADING_RE = /(<h(\d)( [^>]+)?>(.+?)<\/h(\d)>)/i unless const_defined?(:HEADING_RE)
838 839
839 840 def parse_sections(text, project, obj, attr, only_path, options)
840 841 return unless options[:edit_section_links]
841 842 text.gsub!(HEADING_RE) do
842 843 heading = $1
843 844 @current_section += 1
844 845 if @current_section > 1
845 846 content_tag('div',
846 847 link_to(image_tag('edit.png'), options[:edit_section_links].merge(:section => @current_section)),
847 848 :class => 'contextual',
848 849 :title => l(:button_edit_section)) + heading.html_safe
849 850 else
850 851 heading
851 852 end
852 853 end
853 854 end
854 855
855 856 # Headings and TOC
856 857 # Adds ids and links to headings unless options[:headings] is set to false
857 858 def parse_headings(text, project, obj, attr, only_path, options)
858 859 return if options[:headings] == false
859 860
860 861 text.gsub!(HEADING_RE) do
861 862 level, attrs, content = $2.to_i, $3, $4
862 863 item = strip_tags(content).strip
863 864 anchor = sanitize_anchor_name(item)
864 865 # used for single-file wiki export
865 866 anchor = "#{obj.page.title}_#{anchor}" if options[:wiki_links] == :anchor && (obj.is_a?(WikiContent) || obj.is_a?(WikiContent::Version))
866 867 @heading_anchors[anchor] ||= 0
867 868 idx = (@heading_anchors[anchor] += 1)
868 869 if idx > 1
869 870 anchor = "#{anchor}-#{idx}"
870 871 end
871 872 @parsed_headings << [level, anchor, item]
872 873 "<a name=\"#{anchor}\"></a>\n<h#{level} #{attrs}>#{content}<a href=\"##{anchor}\" class=\"wiki-anchor\">&para;</a></h#{level}>"
873 874 end
874 875 end
875 876
876 877 MACROS_RE = /(
877 878 (!)? # escaping
878 879 (
879 880 \{\{ # opening tag
880 881 ([\w]+) # macro name
881 882 (\(([^\n\r]*?)\))? # optional arguments
882 883 ([\n\r].*?[\n\r])? # optional block of text
883 884 \}\} # closing tag
884 885 )
885 886 )/mx unless const_defined?(:MACROS_RE)
886 887
887 888 MACRO_SUB_RE = /(
888 889 \{\{
889 890 macro\((\d+)\)
890 891 \}\}
891 892 )/x unless const_defined?(:MACRO_SUB_RE)
892 893
893 894 # Extracts macros from text
894 895 def catch_macros(text)
895 896 macros = {}
896 897 text.gsub!(MACROS_RE) do
897 898 all, macro = $1, $4.downcase
898 899 if macro_exists?(macro) || all =~ MACRO_SUB_RE
899 900 index = macros.size
900 901 macros[index] = all
901 902 "{{macro(#{index})}}"
902 903 else
903 904 all
904 905 end
905 906 end
906 907 macros
907 908 end
908 909
909 910 # Executes and replaces macros in text
910 911 def inject_macros(text, obj, macros, execute=true)
911 912 text.gsub!(MACRO_SUB_RE) do
912 913 all, index = $1, $2.to_i
913 914 orig = macros.delete(index)
914 915 if execute && orig && orig =~ MACROS_RE
915 916 esc, all, macro, args, block = $2, $3, $4.downcase, $6.to_s, $7.try(:strip)
916 917 if esc.nil?
917 918 h(exec_macro(macro, obj, args, block) || all)
918 919 else
919 920 h(all)
920 921 end
921 922 elsif orig
922 923 h(orig)
923 924 else
924 925 h(all)
925 926 end
926 927 end
927 928 end
928 929
929 930 TOC_RE = /<p>\{\{([<>]?)toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
930 931
931 932 # Renders the TOC with given headings
932 933 def replace_toc(text, headings)
933 934 text.gsub!(TOC_RE) do
934 935 # Keep only the 4 first levels
935 936 headings = headings.select{|level, anchor, item| level <= 4}
936 937 if headings.empty?
937 938 ''
938 939 else
939 940 div_class = 'toc'
940 941 div_class << ' right' if $1 == '>'
941 942 div_class << ' left' if $1 == '<'
942 943 out = "<ul class=\"#{div_class}\"><li>"
943 944 root = headings.map(&:first).min
944 945 current = root
945 946 started = false
946 947 headings.each do |level, anchor, item|
947 948 if level > current
948 949 out << '<ul><li>' * (level - current)
949 950 elsif level < current
950 951 out << "</li></ul>\n" * (current - level) + "</li><li>"
951 952 elsif started
952 953 out << '</li><li>'
953 954 end
954 955 out << "<a href=\"##{anchor}\">#{item}</a>"
955 956 current = level
956 957 started = true
957 958 end
958 959 out << '</li></ul>' * (current - root)
959 960 out << '</li></ul>'
960 961 end
961 962 end
962 963 end
963 964
964 965 # Same as Rails' simple_format helper without using paragraphs
965 966 def simple_format_without_paragraph(text)
966 967 text.to_s.
967 968 gsub(/\r\n?/, "\n"). # \r\n and \r -> \n
968 969 gsub(/\n\n+/, "<br /><br />"). # 2+ newline -> 2 br
969 970 gsub(/([^\n]\n)(?=[^\n])/, '\1<br />'). # 1 newline -> br
970 971 html_safe
971 972 end
972 973
973 974 def lang_options_for_select(blank=true)
974 975 (blank ? [["(auto)", ""]] : []) + languages_options
975 976 end
976 977
977 978 def label_tag_for(name, option_tags = nil, options = {})
978 979 label_text = l(("field_"+field.to_s.gsub(/\_id$/, "")).to_sym) + (options.delete(:required) ? @template.content_tag("span", " *", :class => "required"): "")
979 980 content_tag("label", label_text)
980 981 end
981 982
982 983 def labelled_form_for(*args, &proc)
983 984 args << {} unless args.last.is_a?(Hash)
984 985 options = args.last
985 986 if args.first.is_a?(Symbol)
986 987 options.merge!(:as => args.shift)
987 988 end
988 989 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
989 990 form_for(*args, &proc)
990 991 end
991 992
992 993 def labelled_fields_for(*args, &proc)
993 994 args << {} unless args.last.is_a?(Hash)
994 995 options = args.last
995 996 options.merge!({:builder => Redmine::Views::LabelledFormBuilder})
996 997 fields_for(*args, &proc)
997 998 end
998 999
999 1000 def labelled_remote_form_for(*args, &proc)
1000 1001 ActiveSupport::Deprecation.warn "ApplicationHelper#labelled_remote_form_for is deprecated and will be removed in Redmine 2.2."
1001 1002 args << {} unless args.last.is_a?(Hash)
1002 1003 options = args.last
1003 1004 options.merge!({:builder => Redmine::Views::LabelledFormBuilder, :remote => true})
1004 1005 form_for(*args, &proc)
1005 1006 end
1006 1007
1007 1008 def error_messages_for(*objects)
1008 1009 html = ""
1009 1010 objects = objects.map {|o| o.is_a?(String) ? instance_variable_get("@#{o}") : o}.compact
1010 1011 errors = objects.map {|o| o.errors.full_messages}.flatten
1011 1012 if errors.any?
1012 1013 html << "<div id='errorExplanation'><ul>\n"
1013 1014 errors.each do |error|
1014 1015 html << "<li>#{h error}</li>\n"
1015 1016 end
1016 1017 html << "</ul></div>\n"
1017 1018 end
1018 1019 html.html_safe
1019 1020 end
1020 1021
1021 1022 def delete_link(url, options={})
1022 1023 options = {
1023 1024 :method => :delete,
1024 1025 :data => {:confirm => l(:text_are_you_sure)},
1025 1026 :class => 'icon icon-del'
1026 1027 }.merge(options)
1027 1028
1028 1029 link_to l(:button_delete), url, options
1029 1030 end
1030 1031
1031 1032 def preview_link(url, form, target='preview', options={})
1032 1033 content_tag 'a', l(:label_preview), {
1033 1034 :href => "#",
1034 1035 :onclick => %|submitPreview("#{escape_javascript url_for(url)}", "#{escape_javascript form}", "#{escape_javascript target}"); return false;|,
1035 1036 :accesskey => accesskey(:preview)
1036 1037 }.merge(options)
1037 1038 end
1038 1039
1039 1040 def link_to_function(name, function, html_options={})
1040 1041 content_tag(:a, name, {:href => '#', :onclick => "#{function}; return false;"}.merge(html_options))
1041 1042 end
1042 1043
1043 1044 # Helper to render JSON in views
1044 1045 def raw_json(arg)
1045 1046 arg.to_json.to_s.gsub('/', '\/').html_safe
1046 1047 end
1047 1048
1048 1049 def back_url
1049 1050 url = params[:back_url]
1050 1051 if url.nil? && referer = request.env['HTTP_REFERER']
1051 1052 url = CGI.unescape(referer.to_s)
1052 1053 end
1053 1054 url
1054 1055 end
1055 1056
1056 1057 def back_url_hidden_field_tag
1057 1058 url = back_url
1058 1059 hidden_field_tag('back_url', url, :id => nil) unless url.blank?
1059 1060 end
1060 1061
1061 1062 def check_all_links(form_name)
1062 1063 link_to_function(l(:button_check_all), "checkAll('#{form_name}', true)") +
1063 1064 " | ".html_safe +
1064 1065 link_to_function(l(:button_uncheck_all), "checkAll('#{form_name}', false)")
1065 1066 end
1066 1067
1067 1068 def progress_bar(pcts, options={})
1068 1069 pcts = [pcts, pcts] unless pcts.is_a?(Array)
1069 1070 pcts = pcts.collect(&:round)
1070 1071 pcts[1] = pcts[1] - pcts[0]
1071 1072 pcts << (100 - pcts[1] - pcts[0])
1072 1073 width = options[:width] || '100px;'
1073 1074 legend = options[:legend] || ''
1074 1075 content_tag('table',
1075 1076 content_tag('tr',
1076 1077 (pcts[0] > 0 ? content_tag('td', '', :style => "width: #{pcts[0]}%;", :class => 'closed') : ''.html_safe) +
1077 1078 (pcts[1] > 0 ? content_tag('td', '', :style => "width: #{pcts[1]}%;", :class => 'done') : ''.html_safe) +
1078 1079 (pcts[2] > 0 ? content_tag('td', '', :style => "width: #{pcts[2]}%;", :class => 'todo') : ''.html_safe)
1079 1080 ), :class => 'progress', :style => "width: #{width};").html_safe +
1080 1081 content_tag('p', legend, :class => 'pourcent').html_safe
1081 1082 end
1082 1083
1083 1084 def checked_image(checked=true)
1084 1085 if checked
1085 1086 image_tag 'toggle_check.png'
1086 1087 end
1087 1088 end
1088 1089
1089 1090 def context_menu(url)
1090 1091 unless @context_menu_included
1091 1092 content_for :header_tags do
1092 1093 javascript_include_tag('context_menu') +
1093 1094 stylesheet_link_tag('context_menu')
1094 1095 end
1095 1096 if l(:direction) == 'rtl'
1096 1097 content_for :header_tags do
1097 1098 stylesheet_link_tag('context_menu_rtl')
1098 1099 end
1099 1100 end
1100 1101 @context_menu_included = true
1101 1102 end
1102 1103 javascript_tag "contextMenuInit('#{ url_for(url) }')"
1103 1104 end
1104 1105
1105 1106 def calendar_for(field_id)
1106 1107 include_calendar_headers_tags
1107 1108 javascript_tag("$(function() { $('##{field_id}').datepicker(datepickerOptions); });")
1108 1109 end
1109 1110
1110 1111 def include_calendar_headers_tags
1111 1112 unless @calendar_headers_tags_included
1112 1113 @calendar_headers_tags_included = true
1113 1114 content_for :header_tags do
1114 1115 start_of_week = Setting.start_of_week
1115 1116 start_of_week = l(:general_first_day_of_week, :default => '1') if start_of_week.blank?
1116 1117 # Redmine uses 1..7 (monday..sunday) in settings and locales
1117 1118 # JQuery uses 0..6 (sunday..saturday), 7 needs to be changed to 0
1118 1119 start_of_week = start_of_week.to_i % 7
1119 1120
1120 1121 tags = javascript_tag(
1121 1122 "var datepickerOptions={dateFormat: 'yy-mm-dd', firstDay: #{start_of_week}, " +
1122 1123 "showOn: 'button', buttonImageOnly: true, buttonImage: '" +
1123 1124 path_to_image('/images/calendar.png') +
1124 1125 "', showButtonPanel: true};")
1125 1126 jquery_locale = l('jquery.locale', :default => current_language.to_s)
1126 1127 unless jquery_locale == 'en'
1127 1128 tags << javascript_include_tag("i18n/jquery.ui.datepicker-#{jquery_locale}.js")
1128 1129 end
1129 1130 tags
1130 1131 end
1131 1132 end
1132 1133 end
1133 1134
1134 1135 # Overrides Rails' stylesheet_link_tag with themes and plugins support.
1135 1136 # Examples:
1136 1137 # stylesheet_link_tag('styles') # => picks styles.css from the current theme or defaults
1137 1138 # stylesheet_link_tag('styles', :plugin => 'foo) # => picks styles.css from plugin's assets
1138 1139 #
1139 1140 def stylesheet_link_tag(*sources)
1140 1141 options = sources.last.is_a?(Hash) ? sources.pop : {}
1141 1142 plugin = options.delete(:plugin)
1142 1143 sources = sources.map do |source|
1143 1144 if plugin
1144 1145 "/plugin_assets/#{plugin}/stylesheets/#{source}"
1145 1146 elsif current_theme && current_theme.stylesheets.include?(source)
1146 1147 current_theme.stylesheet_path(source)
1147 1148 else
1148 1149 source
1149 1150 end
1150 1151 end
1151 1152 super sources, options
1152 1153 end
1153 1154
1154 1155 # Overrides Rails' image_tag with themes and plugins support.
1155 1156 # Examples:
1156 1157 # image_tag('image.png') # => picks image.png from the current theme or defaults
1157 1158 # image_tag('image.png', :plugin => 'foo) # => picks image.png from plugin's assets
1158 1159 #
1159 1160 def image_tag(source, options={})
1160 1161 if plugin = options.delete(:plugin)
1161 1162 source = "/plugin_assets/#{plugin}/images/#{source}"
1162 1163 elsif current_theme && current_theme.images.include?(source)
1163 1164 source = current_theme.image_path(source)
1164 1165 end
1165 1166 super source, options
1166 1167 end
1167 1168
1168 1169 # Overrides Rails' javascript_include_tag with plugins support
1169 1170 # Examples:
1170 1171 # javascript_include_tag('scripts') # => picks scripts.js from defaults
1171 1172 # javascript_include_tag('scripts', :plugin => 'foo) # => picks scripts.js from plugin's assets
1172 1173 #
1173 1174 def javascript_include_tag(*sources)
1174 1175 options = sources.last.is_a?(Hash) ? sources.pop : {}
1175 1176 if plugin = options.delete(:plugin)
1176 1177 sources = sources.map do |source|
1177 1178 if plugin
1178 1179 "/plugin_assets/#{plugin}/javascripts/#{source}"
1179 1180 else
1180 1181 source
1181 1182 end
1182 1183 end
1183 1184 end
1184 1185 super sources, options
1185 1186 end
1186 1187
1187 1188 def content_for(name, content = nil, &block)
1188 1189 @has_content ||= {}
1189 1190 @has_content[name] = true
1190 1191 super(name, content, &block)
1191 1192 end
1192 1193
1193 1194 def has_content?(name)
1194 1195 (@has_content && @has_content[name]) || false
1195 1196 end
1196 1197
1197 1198 def sidebar_content?
1198 1199 has_content?(:sidebar) || view_layouts_base_sidebar_hook_response.present?
1199 1200 end
1200 1201
1201 1202 def view_layouts_base_sidebar_hook_response
1202 1203 @view_layouts_base_sidebar_hook_response ||= call_hook(:view_layouts_base_sidebar)
1203 1204 end
1204 1205
1205 1206 def email_delivery_enabled?
1206 1207 !!ActionMailer::Base.perform_deliveries
1207 1208 end
1208 1209
1209 1210 # Returns the avatar image tag for the given +user+ if avatars are enabled
1210 1211 # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
1211 1212 def avatar(user, options = { })
1212 1213 if Setting.gravatar_enabled?
1213 1214 options.merge!({:ssl => (request && request.ssl?), :default => Setting.gravatar_default})
1214 1215 email = nil
1215 1216 if user.respond_to?(:mail)
1216 1217 email = user.mail
1217 1218 elsif user.to_s =~ %r{<(.+?)>}
1218 1219 email = $1
1219 1220 end
1220 1221 return gravatar(email.to_s.downcase, options) unless email.blank? rescue nil
1221 1222 else
1222 1223 ''
1223 1224 end
1224 1225 end
1225 1226
1226 1227 def sanitize_anchor_name(anchor)
1227 1228 if ''.respond_to?(:encoding) || RUBY_PLATFORM == 'java'
1228 1229 anchor.gsub(%r{[^\p{Word}\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1229 1230 else
1230 1231 # TODO: remove when ruby1.8 is no longer supported
1231 1232 anchor.gsub(%r{[^\w\s\-]}, '').gsub(%r{\s+(\-+\s*)?}, '-')
1232 1233 end
1233 1234 end
1234 1235
1235 1236 # Returns the javascript tags that are included in the html layout head
1236 1237 def javascript_heads
1237 1238 tags = javascript_include_tag('jquery-1.7.2-ui-1.8.21-ujs-2.0.3', 'application')
1238 1239 unless User.current.pref.warn_on_leaving_unsaved == '0'
1239 1240 tags << "\n".html_safe + javascript_tag("$(window).load(function(){ warnLeavingUnsaved('#{escape_javascript l(:text_warn_on_leaving_unsaved)}'); });")
1240 1241 end
1241 1242 tags
1242 1243 end
1243 1244
1244 1245 def favicon
1245 1246 "<link rel='shortcut icon' href='#{image_path('/favicon.ico')}' />".html_safe
1246 1247 end
1247 1248
1248 1249 def robot_exclusion_tag
1249 1250 '<meta name="robots" content="noindex,follow,noarchive" />'.html_safe
1250 1251 end
1251 1252
1252 1253 # Returns true if arg is expected in the API response
1253 1254 def include_in_api_response?(arg)
1254 1255 unless @included_in_api_response
1255 1256 param = params[:include]
1256 1257 @included_in_api_response = param.is_a?(Array) ? param.collect(&:to_s) : param.to_s.split(',')
1257 1258 @included_in_api_response.collect!(&:strip)
1258 1259 end
1259 1260 @included_in_api_response.include?(arg.to_s)
1260 1261 end
1261 1262
1262 1263 # Returns options or nil if nometa param or X-Redmine-Nometa header
1263 1264 # was set in the request
1264 1265 def api_meta(options)
1265 1266 if params[:nometa].present? || request.headers['X-Redmine-Nometa']
1266 1267 # compatibility mode for activeresource clients that raise
1267 1268 # an error when unserializing an array with attributes
1268 1269 nil
1269 1270 else
1270 1271 options
1271 1272 end
1272 1273 end
1273 1274
1274 1275 private
1275 1276
1276 1277 def wiki_helper
1277 1278 helper = Redmine::WikiFormatting.helper_for(Setting.text_formatting)
1278 1279 extend helper
1279 1280 return self
1280 1281 end
1281 1282
1282 1283 def link_to_content_update(text, url_params = {}, html_options = {})
1283 1284 link_to(text, url_params, html_options)
1284 1285 end
1285 1286 end
@@ -1,287 +1,295
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 "digest/md5"
19 19
20 20 class Attachment < ActiveRecord::Base
21 21 belongs_to :container, :polymorphic => true
22 22 belongs_to :author, :class_name => "User", :foreign_key => "author_id"
23 23
24 24 validates_presence_of :filename, :author
25 25 validates_length_of :filename, :maximum => 255
26 26 validates_length_of :disk_filename, :maximum => 255
27 27 validates_length_of :description, :maximum => 255
28 28 validate :validate_max_file_size
29 29
30 30 acts_as_event :title => :filename,
31 31 :url => Proc.new {|o| {:controller => 'attachments', :action => 'download', :id => o.id, :filename => o.filename}}
32 32
33 33 acts_as_activity_provider :type => 'files',
34 34 :permission => :view_files,
35 35 :author_key => :author_id,
36 36 :find_options => {:select => "#{Attachment.table_name}.*",
37 37 :joins => "LEFT JOIN #{Version.table_name} ON #{Attachment.table_name}.container_type='Version' AND #{Version.table_name}.id = #{Attachment.table_name}.container_id " +
38 38 "LEFT JOIN #{Project.table_name} ON #{Version.table_name}.project_id = #{Project.table_name}.id OR ( #{Attachment.table_name}.container_type='Project' AND #{Attachment.table_name}.container_id = #{Project.table_name}.id )"}
39 39
40 40 acts_as_activity_provider :type => 'documents',
41 41 :permission => :view_documents,
42 42 :author_key => :author_id,
43 43 :find_options => {:select => "#{Attachment.table_name}.*",
44 44 :joins => "LEFT JOIN #{Document.table_name} ON #{Attachment.table_name}.container_type='Document' AND #{Document.table_name}.id = #{Attachment.table_name}.container_id " +
45 45 "LEFT JOIN #{Project.table_name} ON #{Document.table_name}.project_id = #{Project.table_name}.id"}
46 46
47 47 cattr_accessor :storage_path
48 48 @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files")
49 49
50 50 cattr_accessor :thumbnails_storage_path
51 51 @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails")
52 52
53 53 before_save :files_to_final_location
54 54 after_destroy :delete_from_disk
55 55
56 56 # Returns an unsaved copy of the attachment
57 57 def copy(attributes=nil)
58 58 copy = self.class.new
59 59 copy.attributes = self.attributes.dup.except("id", "downloads")
60 60 copy.attributes = attributes if attributes
61 61 copy
62 62 end
63 63
64 64 def validate_max_file_size
65 65 if @temp_file && self.filesize > Setting.attachment_max_size.to_i.kilobytes
66 66 errors.add(:base, l(:error_attachment_too_big, :max_size => Setting.attachment_max_size.to_i.kilobytes))
67 67 end
68 68 end
69 69
70 70 def file=(incoming_file)
71 71 unless incoming_file.nil?
72 72 @temp_file = incoming_file
73 73 if @temp_file.size > 0
74 74 if @temp_file.respond_to?(:original_filename)
75 75 self.filename = @temp_file.original_filename
76 76 self.filename.force_encoding("UTF-8") if filename.respond_to?(:force_encoding)
77 77 end
78 78 if @temp_file.respond_to?(:content_type)
79 79 self.content_type = @temp_file.content_type.to_s.chomp
80 80 end
81 81 if content_type.blank? && filename.present?
82 82 self.content_type = Redmine::MimeType.of(filename)
83 83 end
84 84 self.filesize = @temp_file.size
85 85 end
86 86 end
87 87 end
88 88
89 89 def file
90 90 nil
91 91 end
92 92
93 93 def filename=(arg)
94 94 write_attribute :filename, sanitize_filename(arg.to_s)
95 95 if new_record? && disk_filename.blank?
96 96 self.disk_filename = Attachment.disk_filename(filename)
97 97 end
98 98 filename
99 99 end
100 100
101 101 # Copies the temporary file to its final location
102 102 # and computes its MD5 hash
103 103 def files_to_final_location
104 104 if @temp_file && (@temp_file.size > 0)
105 105 logger.info("Saving attachment '#{self.diskfile}' (#{@temp_file.size} bytes)")
106 106 md5 = Digest::MD5.new
107 107 File.open(diskfile, "wb") do |f|
108 108 if @temp_file.respond_to?(:read)
109 109 buffer = ""
110 110 while (buffer = @temp_file.read(8192))
111 111 f.write(buffer)
112 112 md5.update(buffer)
113 113 end
114 114 else
115 115 f.write(@temp_file)
116 116 md5.update(@temp_file)
117 117 end
118 118 end
119 119 self.digest = md5.hexdigest
120 120 end
121 121 @temp_file = nil
122 122 # Don't save the content type if it's longer than the authorized length
123 123 if self.content_type && self.content_type.length > 255
124 124 self.content_type = nil
125 125 end
126 126 end
127 127
128 128 # Deletes the file from the file system if it's not referenced by other attachments
129 129 def delete_from_disk
130 130 if Attachment.where("disk_filename = ? AND id <> ?", disk_filename, id).empty?
131 131 delete_from_disk!
132 132 end
133 133 end
134 134
135 135 # Returns file's location on disk
136 136 def diskfile
137 137 File.join(self.class.storage_path, disk_filename.to_s)
138 138 end
139 139
140 140 def title
141 141 title = filename.to_s
142 142 if description.present?
143 143 title << " (#{description})"
144 144 end
145 145 title
146 146 end
147 147
148 148 def increment_download
149 149 increment!(:downloads)
150 150 end
151 151
152 152 def project
153 153 container.try(:project)
154 154 end
155 155
156 156 def visible?(user=User.current)
157 container && container.attachments_visible?(user)
157 if container_id
158 container && container.attachments_visible?(user)
159 else
160 author == user
161 end
158 162 end
159 163
160 164 def deletable?(user=User.current)
161 container && container.attachments_deletable?(user)
165 if container_id
166 container && container.attachments_deletable?(user)
167 else
168 author == user
169 end
162 170 end
163 171
164 172 def image?
165 173 !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i)
166 174 end
167 175
168 176 def thumbnailable?
169 177 image?
170 178 end
171 179
172 180 # Returns the full path the attachment thumbnail, or nil
173 181 # if the thumbnail cannot be generated.
174 182 def thumbnail(options={})
175 183 if thumbnailable? && readable?
176 184 size = options[:size].to_i
177 185 if size > 0
178 186 # Limit the number of thumbnails per image
179 187 size = (size / 50) * 50
180 188 # Maximum thumbnail size
181 189 size = 800 if size > 800
182 190 else
183 191 size = Setting.thumbnails_size.to_i
184 192 end
185 193 size = 100 unless size > 0
186 194 target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb")
187 195
188 196 begin
189 197 Redmine::Thumbnail.generate(self.diskfile, target, size)
190 198 rescue => e
191 199 logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger
192 200 return nil
193 201 end
194 202 end
195 203 end
196 204
197 205 # Deletes all thumbnails
198 206 def self.clear_thumbnails
199 207 Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file|
200 208 File.delete file
201 209 end
202 210 end
203 211
204 212 def is_text?
205 213 Redmine::MimeType.is_type?('text', filename)
206 214 end
207 215
208 216 def is_diff?
209 217 self.filename =~ /\.(patch|diff)$/i
210 218 end
211 219
212 220 # Returns true if the file is readable
213 221 def readable?
214 222 File.readable?(diskfile)
215 223 end
216 224
217 225 # Returns the attachment token
218 226 def token
219 227 "#{id}.#{digest}"
220 228 end
221 229
222 230 # Finds an attachment that matches the given token and that has no container
223 231 def self.find_by_token(token)
224 232 if token.to_s =~ /^(\d+)\.([0-9a-f]+)$/
225 233 attachment_id, attachment_digest = $1, $2
226 234 attachment = Attachment.where(:id => attachment_id, :digest => attachment_digest).first
227 235 if attachment && attachment.container.nil?
228 236 attachment
229 237 end
230 238 end
231 239 end
232 240
233 241 # Bulk attaches a set of files to an object
234 242 #
235 243 # Returns a Hash of the results:
236 244 # :files => array of the attached files
237 245 # :unsaved => array of the files that could not be attached
238 246 def self.attach_files(obj, attachments)
239 247 result = obj.save_attachments(attachments, User.current)
240 248 obj.attach_saved_attachments
241 249 result
242 250 end
243 251
244 252 def self.latest_attach(attachments, filename)
245 253 attachments.sort_by(&:created_on).reverse.detect {
246 254 |att| att.filename.downcase == filename.downcase
247 255 }
248 256 end
249 257
250 258 def self.prune(age=1.day)
251 259 Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
252 260 end
253 261
254 262 private
255 263
256 264 # Physically deletes the file from the file system
257 265 def delete_from_disk!
258 266 if disk_filename.present? && File.exist?(diskfile)
259 267 File.delete(diskfile)
260 268 end
261 269 end
262 270
263 271 def sanitize_filename(value)
264 272 # get only the filename, not the whole path
265 273 just_filename = value.gsub(/^.*(\\|\/)/, '')
266 274
267 275 # Finally, replace invalid characters with underscore
268 276 @filename = just_filename.gsub(/[\/\?\%\*\:\|\"\'<>]+/, '_')
269 277 end
270 278
271 279 # Returns an ASCII or hashed filename
272 280 def self.disk_filename(filename)
273 281 timestamp = DateTime.now.strftime("%y%m%d%H%M%S")
274 282 ascii = ''
275 283 if filename =~ %r{^[a-zA-Z0-9_\.\-]*$}
276 284 ascii = filename
277 285 else
278 286 ascii = Digest::MD5.hexdigest(filename)
279 287 # keep the extension if any
280 288 ascii << $1 if filename =~ %r{(\.[a-zA-Z0-9]+)$}
281 289 end
282 290 while File.exist?(File.join(@@storage_path, "#{timestamp}_#{ascii}"))
283 291 timestamp.succ!
284 292 end
285 293 "#{timestamp}_#{ascii}"
286 294 end
287 295 end
@@ -1,18 +1,28
1 <span id="attachments_fields">
1 2 <% if defined?(container) && container && container.saved_attachments %>
2 3 <% container.saved_attachments.each_with_index do |attachment, i| %>
3 <span class="icon icon-attachment" style="display:block; line-height:1.5em;">
4 <%= h(attachment.filename) %> (<%= number_to_human_size(attachment.filesize) %>)
5 <%= hidden_field_tag "attachments[p#{i}][token]", "#{attachment.id}.#{attachment.digest}" %>
4 <span id="attachments_p<%= i %>">
5 <%= text_field_tag("attachments[p#{i}][filename]", attachment.filename, :class => 'filename') +
6 text_field_tag("attachments[p#{i}][description]", attachment.description, :maxlength => 255, :placeholder => l(:label_optional_description), :class => 'description') +
7 link_to('&nbsp;'.html_safe, attachment_path(attachment, :attachment_id => "p#{i}", :format => 'js'), :method => 'delete', :remote => true, :class => 'remove-upload') %>
8 <%= hidden_field_tag "attachments[p#{i}][token]", "#{attachment.token}" %>
6 9 </span>
7 10 <% end %>
8 11 <% end %>
9 <span id="attachments_fields">
10 <span>
11 <%= file_field_tag 'attachments[1][file]', :id => nil, :class => 'file',
12 :onchange => "checkFileSize(this, #{Setting.attachment_max_size.to_i.kilobytes}, '#{escape_javascript(l(:error_attachment_too_big, :max_size => number_to_human_size(Setting.attachment_max_size.to_i.kilobytes)))}');" -%>
13 <%= text_field_tag 'attachments[1][description]', '', :id => nil, :class => 'description', :maxlength => 255, :placeholder => l(:label_optional_description) %>
14 <%= link_to_function(image_tag('delete.png'), 'removeFileField(this)', :title => (l(:button_delete))) %>
15 </span>
16 12 </span>
17 <span class="add_attachment"><%= link_to l(:label_add_another_file), '#', :onclick => 'addFileField(); return false;', :class => 'add_attachment' %>
18 (<%= l(:label_max_size) %>: <%= number_to_human_size(Setting.attachment_max_size.to_i.kilobytes) %>)</span>
13 <span class="add_attachment">
14 <%= file_field_tag 'attachments_files',
15 :id => nil,
16 :multiple => true,
17 :onchange => 'addInputFiles(this);',
18 :data => {
19 :max_file_size => Setting.attachment_max_size.to_i.kilobytes,
20 :max_file_size_message => l(:error_attachment_too_big, :max_size => number_to_human_size(Setting.attachment_max_size.to_i.kilobytes)),
21 :max_concurrent_uploads => Redmine::Configuration['max_concurrent_ajax_uploads'].to_i,
22 :upload_path => uploads_path(:format => 'js'),
23 :description_placeholder => l(:label_optional_description)
24 } %>
25 (<%= l(:label_max_size) %>: <%= number_to_human_size(Setting.attachment_max_size.to_i.kilobytes) %>)
26 </span>
27
28 <%= javascript_include_tag 'attachments' %>
@@ -1,3 +1,3
1 1 <fieldset class="preview"><legend><%= l(:label_preview) %></legend>
2 <%= textilizable @text, :attachments => @attachements, :object => @previewed %>
2 <%= textilizable @text, :attachments => @attachments, :object => @previewed %>
3 3 </fieldset>
@@ -1,11 +1,11
1 1 <% if @notes %>
2 2 <fieldset class="preview"><legend><%= l(:field_notes) %></legend>
3 <%= textilizable @notes, :attachments => @attachements, :object => @issue %>
3 <%= textilizable @notes, :attachments => @attachments, :object => @issue %>
4 4 </fieldset>
5 5 <% end %>
6 6
7 7 <% if @description %>
8 8 <fieldset class="preview"><legend><%= l(:field_description) %></legend>
9 <%= textilizable @description, :attachments => @attachements, :object => @issue %>
9 <%= textilizable @description, :attachments => @attachments, :object => @issue %>
10 10 </fieldset>
11 11 <% end %>
@@ -1,197 +1,200
1 1 # = Redmine configuration file
2 2 #
3 3 # Each environment has it's own configuration options. If you are only
4 4 # running in production, only the production block needs to be configured.
5 5 # Environment specific configuration options override the default ones.
6 6 #
7 7 # Note that this file needs to be a valid YAML file.
8 8 # DO NOT USE TABS! Use 2 spaces instead of tabs for identation.
9 9 #
10 10 # == Outgoing email settings (email_delivery setting)
11 11 #
12 12 # === Common configurations
13 13 #
14 14 # ==== Sendmail command
15 15 #
16 16 # production:
17 17 # email_delivery:
18 18 # delivery_method: :sendmail
19 19 #
20 20 # ==== Simple SMTP server at localhost
21 21 #
22 22 # production:
23 23 # email_delivery:
24 24 # delivery_method: :smtp
25 25 # smtp_settings:
26 26 # address: "localhost"
27 27 # port: 25
28 28 #
29 29 # ==== SMTP server at example.com using LOGIN authentication and checking HELO for foo.com
30 30 #
31 31 # production:
32 32 # email_delivery:
33 33 # delivery_method: :smtp
34 34 # smtp_settings:
35 35 # address: "example.com"
36 36 # port: 25
37 37 # authentication: :login
38 38 # domain: 'foo.com'
39 39 # user_name: 'myaccount'
40 40 # password: 'password'
41 41 #
42 42 # ==== SMTP server at example.com using PLAIN authentication
43 43 #
44 44 # production:
45 45 # email_delivery:
46 46 # delivery_method: :smtp
47 47 # smtp_settings:
48 48 # address: "example.com"
49 49 # port: 25
50 50 # authentication: :plain
51 51 # domain: 'example.com'
52 52 # user_name: 'myaccount'
53 53 # password: 'password'
54 54 #
55 55 # ==== SMTP server at using TLS (GMail)
56 56 #
57 57 # This might require some additional configuration. See the guides at:
58 58 # http://www.redmine.org/projects/redmine/wiki/EmailConfiguration
59 59 #
60 60 # production:
61 61 # email_delivery:
62 62 # delivery_method: :smtp
63 63 # smtp_settings:
64 64 # enable_starttls_auto: true
65 65 # address: "smtp.gmail.com"
66 66 # port: 587
67 67 # domain: "smtp.gmail.com" # 'your.domain.com' for GoogleApps
68 68 # authentication: :plain
69 69 # user_name: "your_email@gmail.com"
70 70 # password: "your_password"
71 71 #
72 72 #
73 73 # === More configuration options
74 74 #
75 75 # See the "Configuration options" at the following website for a list of the
76 76 # full options allowed:
77 77 #
78 78 # http://wiki.rubyonrails.org/rails/pages/HowToSendEmailsWithActionMailer
79 79
80 80
81 81 # default configuration options for all environments
82 82 default:
83 83 # Outgoing emails configuration (see examples above)
84 84 email_delivery:
85 85 delivery_method: :smtp
86 86 smtp_settings:
87 87 address: smtp.example.net
88 88 port: 25
89 89 domain: example.net
90 90 authentication: :login
91 91 user_name: "redmine@example.net"
92 92 password: "redmine"
93 93
94 94 # Absolute path to the directory where attachments are stored.
95 95 # The default is the 'files' directory in your Redmine instance.
96 96 # Your Redmine instance needs to have write permission on this
97 97 # directory.
98 98 # Examples:
99 99 # attachments_storage_path: /var/redmine/files
100 100 # attachments_storage_path: D:/redmine/files
101 101 attachments_storage_path:
102 102
103 103 # Configuration of the autologin cookie.
104 104 # autologin_cookie_name: the name of the cookie (default: autologin)
105 105 # autologin_cookie_path: the cookie path (default: /)
106 106 # autologin_cookie_secure: true sets the cookie secure flag (default: false)
107 107 autologin_cookie_name:
108 108 autologin_cookie_path:
109 109 autologin_cookie_secure:
110 110
111 111 # Configuration of SCM executable command.
112 112 #
113 113 # Absolute path (e.g. /usr/local/bin/hg) or command name (e.g. hg.exe, bzr.exe)
114 114 # On Windows + CRuby, *.cmd, *.bat (e.g. hg.cmd, bzr.bat) does not work.
115 115 #
116 116 # On Windows + JRuby 1.6.2, path which contains spaces does not work.
117 117 # For example, "C:\Program Files\TortoiseHg\hg.exe".
118 118 # If you want to this feature, you need to install to the path which does not contains spaces.
119 119 # For example, "C:\TortoiseHg\hg.exe".
120 120 #
121 121 # Examples:
122 122 # scm_subversion_command: svn # (default: svn)
123 123 # scm_mercurial_command: C:\Program Files\TortoiseHg\hg.exe # (default: hg)
124 124 # scm_git_command: /usr/local/bin/git # (default: git)
125 125 # scm_cvs_command: cvs # (default: cvs)
126 126 # scm_bazaar_command: bzr.exe # (default: bzr)
127 127 # scm_darcs_command: darcs-1.0.9-i386-linux # (default: darcs)
128 128 #
129 129 scm_subversion_command:
130 130 scm_mercurial_command:
131 131 scm_git_command:
132 132 scm_cvs_command:
133 133 scm_bazaar_command:
134 134 scm_darcs_command:
135 135
136 136 # Key used to encrypt sensitive data in the database (SCM and LDAP passwords).
137 137 # If you don't want to enable data encryption, just leave it blank.
138 138 # WARNING: losing/changing this key will make encrypted data unreadable.
139 139 #
140 140 # If you want to encrypt existing passwords in your database:
141 141 # * set the cipher key here in your configuration file
142 142 # * encrypt data using 'rake db:encrypt RAILS_ENV=production'
143 143 #
144 144 # If you have encrypted data and want to change this key, you have to:
145 145 # * decrypt data using 'rake db:decrypt RAILS_ENV=production' first
146 146 # * change the cipher key here in your configuration file
147 147 # * encrypt data using 'rake db:encrypt RAILS_ENV=production'
148 148 database_cipher_key:
149 149
150 150 # Set this to false to disable plugins' assets mirroring on startup.
151 151 # You can use `rake redmine:plugins:assets` to manually mirror assets
152 152 # to public/plugin_assets when you install/upgrade a Redmine plugin.
153 153 #
154 154 #mirror_plugins_assets_on_startup: false
155 155
156 156 # Your secret key for verifying cookie session data integrity. If you
157 157 # change this key, all old sessions will become invalid! Make sure the
158 158 # secret is at least 30 characters and all random, no regular words or
159 159 # you'll be exposed to dictionary attacks.
160 160 #
161 161 # If you have a load-balancing Redmine cluster, you have to use the
162 162 # same secret token on each machine.
163 163 #secret_token: 'change it to a long random string'
164 164
165 165 # Absolute path (e.g. /usr/bin/convert, c:/im/convert.exe) to
166 166 # the ImageMagick's `convert` binary. Used to generate attachment thumbnails.
167 167 #imagemagick_convert_command:
168 168
169 169 # Configuration of RMagcik font.
170 170 #
171 171 # Redmine uses RMagcik in order to export gantt png.
172 172 # You don't need this setting if you don't install RMagcik.
173 173 #
174 174 # In CJK (Chinese, Japanese and Korean),
175 175 # in order to show CJK characters correctly,
176 176 # you need to set this configuration.
177 177 #
178 178 # Because there is no standard font across platforms in CJK,
179 179 # you need to set a font installed in your server.
180 180 #
181 181 # This setting is not necessary in non CJK.
182 182 #
183 183 # Examples for Japanese:
184 184 # Windows:
185 185 # rmagick_font_path: C:\windows\fonts\msgothic.ttc
186 186 # Linux:
187 187 # rmagick_font_path: /usr/share/fonts/ipa-mincho/ipam.ttf
188 188 #
189 189 rmagick_font_path:
190 190
191 # Maximum number of simultaneous AJAX uploads
192 #max_concurrent_ajax_uploads: 2
193
191 194 # specific configuration options for production environment
192 195 # that overrides the default ones
193 196 production:
194 197
195 198 # specific configuration options for development environment
196 199 # that overrides the default ones
197 200 development:
@@ -1,113 +1,114
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 module Redmine
19 19 module Configuration
20 20
21 21 # Configuration default values
22 22 @defaults = {
23 'email_delivery' => nil
23 'email_delivery' => nil,
24 'max_concurrent_ajax_uploads' => 2
24 25 }
25 26
26 27 @config = nil
27 28
28 29 class << self
29 30 # Loads the Redmine configuration file
30 31 # Valid options:
31 32 # * <tt>:file</tt>: the configuration file to load (default: config/configuration.yml)
32 33 # * <tt>:env</tt>: the environment to load the configuration for (default: Rails.env)
33 34 def load(options={})
34 35 filename = options[:file] || File.join(Rails.root, 'config', 'configuration.yml')
35 36 env = options[:env] || Rails.env
36 37
37 38 @config = @defaults.dup
38 39
39 40 load_deprecated_email_configuration(env)
40 41 if File.file?(filename)
41 42 @config.merge!(load_from_yaml(filename, env))
42 43 end
43 44
44 45 # Compatibility mode for those who copy email.yml over configuration.yml
45 46 %w(delivery_method smtp_settings sendmail_settings).each do |key|
46 47 if value = @config.delete(key)
47 48 @config['email_delivery'] ||= {}
48 49 @config['email_delivery'][key] = value
49 50 end
50 51 end
51 52
52 53 if @config['email_delivery']
53 54 ActionMailer::Base.perform_deliveries = true
54 55 @config['email_delivery'].each do |k, v|
55 56 v.symbolize_keys! if v.respond_to?(:symbolize_keys!)
56 57 ActionMailer::Base.send("#{k}=", v)
57 58 end
58 59 end
59 60
60 61 @config
61 62 end
62 63
63 64 # Returns a configuration setting
64 65 def [](name)
65 66 load unless @config
66 67 @config[name]
67 68 end
68 69
69 70 # Yields a block with the specified hash configuration settings
70 71 def with(settings)
71 72 settings.stringify_keys!
72 73 load unless @config
73 74 was = settings.keys.inject({}) {|h,v| h[v] = @config[v]; h}
74 75 @config.merge! settings
75 76 yield if block_given?
76 77 @config.merge! was
77 78 end
78 79
79 80 private
80 81
81 82 def load_from_yaml(filename, env)
82 83 yaml = nil
83 84 begin
84 85 yaml = YAML::load_file(filename)
85 86 rescue ArgumentError
86 87 $stderr.puts "Your Redmine configuration file located at #{filename} is not a valid YAML file and could not be loaded."
87 88 exit 1
88 89 end
89 90 conf = {}
90 91 if yaml.is_a?(Hash)
91 92 if yaml['default']
92 93 conf.merge!(yaml['default'])
93 94 end
94 95 if yaml[env]
95 96 conf.merge!(yaml[env])
96 97 end
97 98 else
98 99 $stderr.puts "Your Redmine configuration file located at #{filename} is not a valid Redmine configuration file."
99 100 exit 1
100 101 end
101 102 conf
102 103 end
103 104
104 105 def load_deprecated_email_configuration(env)
105 106 deprecated_email_conf = File.join(Rails.root, 'config', 'email.yml')
106 107 if File.file?(deprecated_email_conf)
107 108 warn "Storing outgoing emails configuration in config/email.yml is deprecated. You should now store it in config/configuration.yml using the email_delivery setting."
108 109 @config.merge!({'email_delivery' => load_from_yaml(deprecated_email_conf, env)})
109 110 end
110 111 end
111 112 end
112 113 end
113 114 end
@@ -1,611 +1,582
1 1 /* Redmine - project management software
2 2 Copyright (C) 2006-2012 Jean-Philippe Lang */
3 3
4 4 function checkAll(id, checked) {
5 5 if (checked) {
6 6 $('#'+id).find('input[type=checkbox]').attr('checked', true);
7 7 } else {
8 8 $('#'+id).find('input[type=checkbox]').removeAttr('checked');
9 9 }
10 10 }
11 11
12 12 function toggleCheckboxesBySelector(selector) {
13 13 var all_checked = true;
14 14 $(selector).each(function(index) {
15 15 if (!$(this).is(':checked')) { all_checked = false; }
16 16 });
17 17 $(selector).attr('checked', !all_checked)
18 18 }
19 19
20 20 function showAndScrollTo(id, focus) {
21 21 $('#'+id).show();
22 22 if (focus!=null) {
23 23 $('#'+focus).focus();
24 24 }
25 25 $('html, body').animate({scrollTop: $('#'+id).offset().top}, 100);
26 26 }
27 27
28 28 function toggleRowGroup(el) {
29 29 var tr = $(el).parents('tr').first();
30 30 var n = tr.next();
31 31 tr.toggleClass('open');
32 32 while (n.length && !n.hasClass('group')) {
33 33 n.toggle();
34 34 n = n.next('tr');
35 35 }
36 36 }
37 37
38 38 function collapseAllRowGroups(el) {
39 39 var tbody = $(el).parents('tbody').first();
40 40 tbody.children('tr').each(function(index) {
41 41 if ($(this).hasClass('group')) {
42 42 $(this).removeClass('open');
43 43 } else {
44 44 $(this).hide();
45 45 }
46 46 });
47 47 }
48 48
49 49 function expandAllRowGroups(el) {
50 50 var tbody = $(el).parents('tbody').first();
51 51 tbody.children('tr').each(function(index) {
52 52 if ($(this).hasClass('group')) {
53 53 $(this).addClass('open');
54 54 } else {
55 55 $(this).show();
56 56 }
57 57 });
58 58 }
59 59
60 60 function toggleAllRowGroups(el) {
61 61 var tr = $(el).parents('tr').first();
62 62 if (tr.hasClass('open')) {
63 63 collapseAllRowGroups(el);
64 64 } else {
65 65 expandAllRowGroups(el);
66 66 }
67 67 }
68 68
69 69 function toggleFieldset(el) {
70 70 var fieldset = $(el).parents('fieldset').first();
71 71 fieldset.toggleClass('collapsed');
72 72 fieldset.children('div').toggle();
73 73 }
74 74
75 75 function hideFieldset(el) {
76 76 var fieldset = $(el).parents('fieldset').first();
77 77 fieldset.toggleClass('collapsed');
78 78 fieldset.children('div').hide();
79 79 }
80 80
81 81 function initFilters(){
82 82 $('#add_filter_select').change(function(){
83 83 addFilter($(this).val(), '', []);
84 84 });
85 85 $('#filters-table td.field input[type=checkbox]').each(function(){
86 86 toggleFilter($(this).val());
87 87 });
88 88 $('#filters-table td.field input[type=checkbox]').live('click',function(){
89 89 toggleFilter($(this).val());
90 90 });
91 91 $('#filters-table .toggle-multiselect').live('click',function(){
92 92 toggleMultiSelect($(this).siblings('select'));
93 93 });
94 94 $('#filters-table input[type=text]').live('keypress', function(e){
95 95 if (e.keyCode == 13) submit_query_form("query_form");
96 96 });
97 97 }
98 98
99 99 function addFilter(field, operator, values) {
100 100 var fieldId = field.replace('.', '_');
101 101 var tr = $('#tr_'+fieldId);
102 102 if (tr.length > 0) {
103 103 tr.show();
104 104 } else {
105 105 buildFilterRow(field, operator, values);
106 106 }
107 107 $('#cb_'+fieldId).attr('checked', true);
108 108 toggleFilter(field);
109 109 $('#add_filter_select').val('').children('option').each(function(){
110 110 if ($(this).attr('value') == field) {
111 111 $(this).attr('disabled', true);
112 112 }
113 113 });
114 114 }
115 115
116 116 function buildFilterRow(field, operator, values) {
117 117 var fieldId = field.replace('.', '_');
118 118 var filterTable = $("#filters-table");
119 119 var filterOptions = availableFilters[field];
120 120 var operators = operatorByType[filterOptions['type']];
121 121 var filterValues = filterOptions['values'];
122 122 var i, select;
123 123
124 124 var tr = $('<tr class="filter">').attr('id', 'tr_'+fieldId).html(
125 125 '<td class="field"><input checked="checked" id="cb_'+fieldId+'" name="f[]" value="'+field+'" type="checkbox"><label for="cb_'+fieldId+'"> '+filterOptions['name']+'</label></td>' +
126 126 '<td class="operator"><select id="operators_'+fieldId+'" name="op['+field+']"></td>' +
127 127 '<td class="values"></td>'
128 128 );
129 129 filterTable.append(tr);
130 130
131 131 select = tr.find('td.operator select');
132 132 for (i=0;i<operators.length;i++){
133 133 var option = $('<option>').val(operators[i]).text(operatorLabels[operators[i]]);
134 134 if (operators[i] == operator) {option.attr('selected', true)};
135 135 select.append(option);
136 136 }
137 137 select.change(function(){toggleOperator(field)});
138 138
139 139 switch (filterOptions['type']){
140 140 case "list":
141 141 case "list_optional":
142 142 case "list_status":
143 143 case "list_subprojects":
144 144 tr.find('td.values').append(
145 145 '<span style="display:none;"><select class="value" id="values_'+fieldId+'_1" name="v['+field+'][]"></select>' +
146 146 ' <span class="toggle-multiselect">&nbsp;</span></span>'
147 147 );
148 148 select = tr.find('td.values select');
149 149 if (values.length > 1) {select.attr('multiple', true)};
150 150 for (i=0;i<filterValues.length;i++){
151 151 var filterValue = filterValues[i];
152 152 var option = $('<option>');
153 153 if ($.isArray(filterValue)) {
154 154 option.val(filterValue[1]).text(filterValue[0]);
155 155 if ($.inArray(filterValue[1], values) > -1) {option.attr('selected', true);}
156 156 } else {
157 157 option.val(filterValue).text(filterValue);
158 158 if ($.inArray(filterValue, values) > -1) {option.attr('selected', true);}
159 159 }
160 160 select.append(option);
161 161 }
162 162 break;
163 163 case "date":
164 164 case "date_past":
165 165 tr.find('td.values').append(
166 166 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="10" class="value date_value" /></span>' +
167 167 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="10" class="value date_value" /></span>' +
168 168 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="3" class="value" /> '+labelDayPlural+'</span>'
169 169 );
170 170 $('#values_'+fieldId+'_1').val(values[0]).datepicker(datepickerOptions);
171 171 $('#values_'+fieldId+'_2').val(values[1]).datepicker(datepickerOptions);
172 172 $('#values_'+fieldId).val(values[0]);
173 173 break;
174 174 case "string":
175 175 case "text":
176 176 tr.find('td.values').append(
177 177 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="30" class="value" /></span>'
178 178 );
179 179 $('#values_'+fieldId).val(values[0]);
180 180 break;
181 181 case "relation":
182 182 tr.find('td.values').append(
183 183 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'" size="6" class="value" /></span>' +
184 184 '<span style="display:none;"><select class="value" name="v['+field+'][]" id="values_'+fieldId+'_1"></select></span>'
185 185 );
186 186 $('#values_'+fieldId).val(values[0]);
187 187 select = tr.find('td.values select');
188 188 for (i=0;i<allProjects.length;i++){
189 189 var filterValue = allProjects[i];
190 190 var option = $('<option>');
191 191 option.val(filterValue[1]).text(filterValue[0]);
192 192 if (values[0] == filterValue[1]) {option.attr('selected', true)};
193 193 select.append(option);
194 194 }
195 195 case "integer":
196 196 case "float":
197 197 tr.find('td.values').append(
198 198 '<span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_1" size="6" class="value" /></span>' +
199 199 ' <span style="display:none;"><input type="text" name="v['+field+'][]" id="values_'+fieldId+'_2" size="6" class="value" /></span>'
200 200 );
201 201 $('#values_'+fieldId+'_1').val(values[0]);
202 202 $('#values_'+fieldId+'_2').val(values[1]);
203 203 break;
204 204 }
205 205 }
206 206
207 207 function toggleFilter(field) {
208 208 var fieldId = field.replace('.', '_');
209 209 if ($('#cb_' + fieldId).is(':checked')) {
210 210 $("#operators_" + fieldId).show().removeAttr('disabled');
211 211 toggleOperator(field);
212 212 } else {
213 213 $("#operators_" + fieldId).hide().attr('disabled', true);
214 214 enableValues(field, []);
215 215 }
216 216 }
217 217
218 218 function enableValues(field, indexes) {
219 219 var fieldId = field.replace('.', '_');
220 220 $('#tr_'+fieldId+' td.values .value').each(function(index) {
221 221 if ($.inArray(index, indexes) >= 0) {
222 222 $(this).removeAttr('disabled');
223 223 $(this).parents('span').first().show();
224 224 } else {
225 225 $(this).val('');
226 226 $(this).attr('disabled', true);
227 227 $(this).parents('span').first().hide();
228 228 }
229 229
230 230 if ($(this).hasClass('group')) {
231 231 $(this).addClass('open');
232 232 } else {
233 233 $(this).show();
234 234 }
235 235 });
236 236 }
237 237
238 238 function toggleOperator(field) {
239 239 var fieldId = field.replace('.', '_');
240 240 var operator = $("#operators_" + fieldId);
241 241 switch (operator.val()) {
242 242 case "!*":
243 243 case "*":
244 244 case "t":
245 245 case "ld":
246 246 case "w":
247 247 case "lw":
248 248 case "l2w":
249 249 case "m":
250 250 case "lm":
251 251 case "y":
252 252 case "o":
253 253 case "c":
254 254 enableValues(field, []);
255 255 break;
256 256 case "><":
257 257 enableValues(field, [0,1]);
258 258 break;
259 259 case "<t+":
260 260 case ">t+":
261 261 case "><t+":
262 262 case "t+":
263 263 case ">t-":
264 264 case "<t-":
265 265 case "><t-":
266 266 case "t-":
267 267 enableValues(field, [2]);
268 268 break;
269 269 case "=p":
270 270 case "=!p":
271 271 case "!p":
272 272 enableValues(field, [1]);
273 273 break;
274 274 default:
275 275 enableValues(field, [0]);
276 276 break;
277 277 }
278 278 }
279 279
280 280 function toggleMultiSelect(el) {
281 281 if (el.attr('multiple')) {
282 282 el.removeAttr('multiple');
283 283 } else {
284 284 el.attr('multiple', true);
285 285 }
286 286 }
287 287
288 288 function submit_query_form(id) {
289 289 selectAllOptions("selected_columns");
290 290 $('#'+id).submit();
291 291 }
292 292
293 var fileFieldCount = 1;
294 function addFileField() {
295 var fields = $('#attachments_fields');
296 if (fields.children().length >= 10) return false;
297 fileFieldCount++;
298 var s = fields.children('span').first().clone();
299 s.children('input.file').attr('name', "attachments[" + fileFieldCount + "][file]").val('');
300 s.children('input.description').attr('name', "attachments[" + fileFieldCount + "][description]").val('');
301 fields.append(s);
302 }
303
304 function removeFileField(el) {
305 var fields = $('#attachments_fields');
306 var s = $(el).parents('span').first();
307 if (fields.children().length > 1) {
308 s.remove();
309 } else {
310 s.children('input.file').val('');
311 s.children('input.description').val('');
312 }
313 }
314
315 function checkFileSize(el, maxSize, message) {
316 var files = el.files;
317 if (files) {
318 for (var i=0; i<files.length; i++) {
319 if (files[i].size > maxSize) {
320 alert(message);
321 el.value = "";
322 }
323 }
324 }
325 }
326
327 293 function showTab(name) {
328 294 $('div#content .tab-content').hide();
329 295 $('div.tabs a').removeClass('selected');
330 296 $('#tab-content-' + name).show();
331 297 $('#tab-' + name).addClass('selected');
332 298 return false;
333 299 }
334 300
335 301 function moveTabRight(el) {
336 302 var lis = $(el).parents('div.tabs').first().find('ul').children();
337 303 var tabsWidth = 0;
338 304 var i = 0;
339 305 lis.each(function(){
340 306 if ($(this).is(':visible')) {
341 307 tabsWidth += $(this).width() + 6;
342 308 }
343 309 });
344 310 if (tabsWidth < $(el).parents('div.tabs').first().width() - 60) { return; }
345 311 while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
346 312 lis.eq(i).hide();
347 313 }
348 314
349 315 function moveTabLeft(el) {
350 316 var lis = $(el).parents('div.tabs').first().find('ul').children();
351 317 var i = 0;
352 318 while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
353 319 if (i>0) {
354 320 lis.eq(i-1).show();
355 321 }
356 322 }
357 323
358 324 function displayTabsButtons() {
359 325 var lis;
360 326 var tabsWidth = 0;
361 327 var el;
362 328 $('div.tabs').each(function() {
363 329 el = $(this);
364 330 lis = el.find('ul').children();
365 331 lis.each(function(){
366 332 if ($(this).is(':visible')) {
367 333 tabsWidth += $(this).width() + 6;
368 334 }
369 335 });
370 336 if ((tabsWidth < el.width() - 60) && (lis.first().is(':visible'))) {
371 337 el.find('div.tabs-buttons').hide();
372 338 } else {
373 339 el.find('div.tabs-buttons').show();
374 340 }
375 341 });
376 342 }
377 343
378 344 function setPredecessorFieldsVisibility() {
379 345 var relationType = $('#relation_relation_type');
380 346 if (relationType.val() == "precedes" || relationType.val() == "follows") {
381 347 $('#predecessor_fields').show();
382 348 } else {
383 349 $('#predecessor_fields').hide();
384 350 }
385 351 }
386 352
387 353 function showModal(id, width) {
388 354 var el = $('#'+id).first();
389 355 if (el.length == 0 || el.is(':visible')) {return;}
390 356 var title = el.find('h3.title').text();
391 357 el.dialog({
392 358 width: width,
393 359 modal: true,
394 360 resizable: false,
395 361 dialogClass: 'modal',
396 362 title: title
397 363 });
398 364 el.find("input[type=text], input[type=submit]").first().focus();
399 365 }
400 366
401 367 function hideModal(el) {
402 368 var modal;
403 369 if (el) {
404 370 modal = $(el).parents('.ui-dialog-content');
405 371 } else {
406 372 modal = $('#ajax-modal');
407 373 }
408 374 modal.dialog("close");
409 375 }
410 376
411 377 function submitPreview(url, form, target) {
412 378 $.ajax({
413 379 url: url,
414 380 type: 'post',
415 381 data: $('#'+form).serialize(),
416 382 success: function(data){
417 383 $('#'+target).html(data);
418 384 }
419 385 });
420 386 }
421 387
422 388 function collapseScmEntry(id) {
423 389 $('.'+id).each(function() {
424 390 if ($(this).hasClass('open')) {
425 391 collapseScmEntry($(this).attr('id'));
426 392 }
427 393 $(this).hide();
428 394 });
429 395 $('#'+id).removeClass('open');
430 396 }
431 397
432 398 function expandScmEntry(id) {
433 399 $('.'+id).each(function() {
434 400 $(this).show();
435 401 if ($(this).hasClass('loaded') && !$(this).hasClass('collapsed')) {
436 402 expandScmEntry($(this).attr('id'));
437 403 }
438 404 });
439 405 $('#'+id).addClass('open');
440 406 }
441 407
442 408 function scmEntryClick(id, url) {
443 409 el = $('#'+id);
444 410 if (el.hasClass('open')) {
445 411 collapseScmEntry(id);
446 412 el.addClass('collapsed');
447 413 return false;
448 414 } else if (el.hasClass('loaded')) {
449 415 expandScmEntry(id);
450 416 el.removeClass('collapsed');
451 417 return false;
452 418 }
453 419 if (el.hasClass('loading')) {
454 420 return false;
455 421 }
456 422 el.addClass('loading');
457 423 $.ajax({
458 424 url: url,
459 425 success: function(data){
460 426 el.after(data);
461 427 el.addClass('open').addClass('loaded').removeClass('loading');
462 428 }
463 429 });
464 430 return true;
465 431 }
466 432
467 433 function randomKey(size) {
468 434 var chars = new Array('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z');
469 435 var key = '';
470 436 for (i = 0; i < size; i++) {
471 437 key += chars[Math.floor(Math.random() * chars.length)];
472 438 }
473 439 return key;
474 440 }
475 441
476 442 // Can't use Rails' remote select because we need the form data
477 443 function updateIssueFrom(url) {
478 444 $.ajax({
479 445 url: url,
480 446 type: 'post',
481 447 data: $('#issue-form').serialize()
482 448 });
483 449 }
484 450
485 451 function updateBulkEditFrom(url) {
486 452 $.ajax({
487 453 url: url,
488 454 type: 'post',
489 455 data: $('#bulk_edit_form').serialize()
490 456 });
491 457 }
492 458
493 459 function observeAutocompleteField(fieldId, url) {
494 460 $(document).ready(function() {
495 461 $('#'+fieldId).autocomplete({
496 462 source: url,
497 463 minLength: 2
498 464 });
499 465 });
500 466 }
501 467
502 468 function observeSearchfield(fieldId, targetId, url) {
503 469 $('#'+fieldId).each(function() {
504 470 var $this = $(this);
505 471 $this.attr('data-value-was', $this.val());
506 472 var check = function() {
507 473 var val = $this.val();
508 474 if ($this.attr('data-value-was') != val){
509 475 $this.attr('data-value-was', val);
510 476 $.ajax({
511 477 url: url,
512 478 type: 'get',
513 479 data: {q: $this.val()},
514 480 success: function(data){ $('#'+targetId).html(data); },
515 481 beforeSend: function(){ $this.addClass('ajax-loading'); },
516 482 complete: function(){ $this.removeClass('ajax-loading'); }
517 483 });
518 484 }
519 485 };
520 486 var reset = function() {
521 487 if (timer) {
522 488 clearInterval(timer);
523 489 timer = setInterval(check, 300);
524 490 }
525 491 };
526 492 var timer = setInterval(check, 300);
527 493 $this.bind('keyup click mousemove', reset);
528 494 });
529 495 }
530 496
531 497 function observeProjectModules() {
532 498 var f = function() {
533 499 /* Hides trackers and issues custom fields on the new project form when issue_tracking module is disabled */
534 500 if ($('#project_enabled_module_names_issue_tracking').attr('checked')) {
535 501 $('#project_trackers').show();
536 502 }else{
537 503 $('#project_trackers').hide();
538 504 }
539 505 };
540 506
541 507 $(window).load(f);
542 508 $('#project_enabled_module_names_issue_tracking').change(f);
543 509 }
544 510
545 511 function initMyPageSortable(list, url) {
546 512 $('#list-'+list).sortable({
547 513 connectWith: '.block-receiver',
548 514 tolerance: 'pointer',
549 515 update: function(){
550 516 $.ajax({
551 517 url: url,
552 518 type: 'post',
553 519 data: {'blocks': $.map($('#list-'+list).children(), function(el){return $(el).attr('id');})}
554 520 });
555 521 }
556 522 });
557 523 $("#list-top, #list-left, #list-right").disableSelection();
558 524 }
559 525
560 526 var warnLeavingUnsavedMessage;
561 527 function warnLeavingUnsaved(message) {
562 528 warnLeavingUnsavedMessage = message;
563 529
564 530 $('form').submit(function(){
565 531 $('textarea').removeData('changed');
566 532 });
567 533 $('textarea').change(function(){
568 534 $(this).data('changed', 'changed');
569 535 });
570 536 window.onbeforeunload = function(){
571 537 var warn = false;
572 538 $('textarea').blur().each(function(){
573 539 if ($(this).data('changed')) {
574 540 warn = true;
575 541 }
576 542 });
577 543 if (warn) {return warnLeavingUnsavedMessage;}
578 544 };
579 545 };
580 546
581 547 $(document).ready(function(){
582 $('#ajax-indicator').bind('ajaxSend', function(){
583 if ($('.ajax-loading').length == 0) {
548 $('#ajax-indicator').bind('ajaxSend', function(event, xhr, settings){
549 if ($('.ajax-loading').length == 0 && settings.contentType != 'application/octet-stream') {
584 550 $('#ajax-indicator').show();
585 551 }
586 552 });
587 553 $('#ajax-indicator').bind('ajaxStop', function(){
588 554 $('#ajax-indicator').hide();
589 555 });
590 556 });
591 557
592 558 function hideOnLoad() {
593 559 $('.hol').hide();
594 560 }
595 561
596 562 function addFormObserversForDoubleSubmit() {
597 563 $('form[method=post]').each(function() {
598 564 if (!$(this).hasClass('multiple-submit')) {
599 565 $(this).submit(function(form_submission) {
600 566 if ($(form_submission.target).attr('data-submitted')) {
601 567 form_submission.preventDefault();
602 568 } else {
603 569 $(form_submission.target).attr('data-submitted', true);
604 570 }
605 571 });
606 572 }
607 573 });
608 574 }
609 575
576 function blockEventPropagation(event) {
577 event.stopPropagation();
578 event.preventDefault();
579 }
580
610 581 $(document).ready(hideOnLoad);
611 582 $(document).ready(addFormObserversForDoubleSubmit);
@@ -1,1140 +1,1147
1 1 html {overflow-y:scroll;}
2 2 body { font-family: Verdana, sans-serif; font-size: 12px; color:#484848; margin: 0; padding: 0; min-width: 900px; }
3 3
4 4 h1, h2, h3, h4 {font-family: "Trebuchet MS", Verdana, sans-serif;padding: 2px 10px 1px 0px;margin: 0 0 10px 0;}
5 5 #content h1, h2, h3, h4 {color: #555;}
6 6 h2, .wiki h1 {font-size: 20px;}
7 7 h3, .wiki h2 {font-size: 16px;}
8 8 h4, .wiki h3 {font-size: 13px;}
9 9 h4 {border-bottom: 1px dotted #bbb;}
10 10
11 11 /***** Layout *****/
12 12 #wrapper {background: white;}
13 13
14 14 #top-menu {background: #3E5B76; color: #fff; height:1.8em; font-size: 0.8em; padding: 2px 2px 0px 6px;}
15 15 #top-menu ul {margin: 0; padding: 0;}
16 16 #top-menu li {
17 17 float:left;
18 18 list-style-type:none;
19 19 margin: 0px 0px 0px 0px;
20 20 padding: 0px 0px 0px 0px;
21 21 white-space:nowrap;
22 22 }
23 23 #top-menu a {color: #fff; margin-right: 8px; font-weight: bold;}
24 24 #top-menu #loggedas { float: right; margin-right: 0.5em; color: #fff; }
25 25
26 26 #account {float:right;}
27 27
28 28 #header {height:5.3em;margin:0;background-color:#628DB6;color:#f8f8f8; padding: 4px 8px 0px 6px; position:relative;}
29 29 #header a {color:#f8f8f8;}
30 30 #header h1 a.ancestor { font-size: 80%; }
31 31 #quick-search {float:right;}
32 32
33 33 #main-menu {position: absolute; bottom: 0px; left:6px; margin-right: -500px;}
34 34 #main-menu ul {margin: 0; padding: 0;}
35 35 #main-menu li {
36 36 float:left;
37 37 list-style-type:none;
38 38 margin: 0px 2px 0px 0px;
39 39 padding: 0px 0px 0px 0px;
40 40 white-space:nowrap;
41 41 }
42 42 #main-menu li a {
43 43 display: block;
44 44 color: #fff;
45 45 text-decoration: none;
46 46 font-weight: bold;
47 47 margin: 0;
48 48 padding: 4px 10px 4px 10px;
49 49 }
50 50 #main-menu li a:hover {background:#759FCF; color:#fff;}
51 51 #main-menu li a.selected, #main-menu li a.selected:hover {background:#fff; color:#555;}
52 52
53 53 #admin-menu ul {margin: 0; padding: 0;}
54 54 #admin-menu li {margin: 0; padding: 0 0 6px 0; list-style-type:none;}
55 55
56 56 #admin-menu a { background-position: 0% 40%; background-repeat: no-repeat; padding-left: 20px; padding-top: 2px; padding-bottom: 3px;}
57 57 #admin-menu a.projects { background-image: url(../images/projects.png); }
58 58 #admin-menu a.users { background-image: url(../images/user.png); }
59 59 #admin-menu a.groups { background-image: url(../images/group.png); }
60 60 #admin-menu a.roles { background-image: url(../images/database_key.png); }
61 61 #admin-menu a.trackers { background-image: url(../images/ticket.png); }
62 62 #admin-menu a.issue_statuses { background-image: url(../images/ticket_edit.png); }
63 63 #admin-menu a.workflows { background-image: url(../images/ticket_go.png); }
64 64 #admin-menu a.custom_fields { background-image: url(../images/textfield.png); }
65 65 #admin-menu a.enumerations { background-image: url(../images/text_list_bullets.png); }
66 66 #admin-menu a.settings { background-image: url(../images/changeset.png); }
67 67 #admin-menu a.plugins { background-image: url(../images/plugin.png); }
68 68 #admin-menu a.info { background-image: url(../images/help.png); }
69 69 #admin-menu a.server_authentication { background-image: url(../images/server_key.png); }
70 70
71 71 #main {background-color:#EEEEEE;}
72 72
73 73 #sidebar{ float: right; width: 22%; position: relative; z-index: 9; padding: 0; margin: 0;}
74 74 * html #sidebar{ width: 22%; }
75 75 #sidebar h3{ font-size: 14px; margin-top:14px; color: #666; }
76 76 #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; }
77 77 * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; }
78 78 #sidebar .contextual { margin-right: 1em; }
79 79
80 80 #content { width: 75%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; }
81 81 * html #content{ width: 75%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;}
82 82 html>body #content { min-height: 600px; }
83 83 * html body #content { height: 600px; } /* IE */
84 84
85 85 #main.nosidebar #sidebar{ display: none; }
86 86 #main.nosidebar #content{ width: auto; border-right: 0; }
87 87
88 88 #footer {clear: both; border-top: 1px solid #bbb; font-size: 0.9em; color: #aaa; padding: 5px; text-align:center; background:#fff;}
89 89
90 90 #login-form table {margin-top:5em; padding:1em; margin-left: auto; margin-right: auto; border: 2px solid #FDBF3B; background-color:#FFEBC1; }
91 91 #login-form table td {padding: 6px;}
92 92 #login-form label {font-weight: bold;}
93 93 #login-form input#username, #login-form input#password { width: 300px; }
94 94
95 95 div.modal { border-radius:5px; background:#fff; z-index:50; padding:4px;}
96 96 div.modal h3.title {display:none;}
97 97 div.modal p.buttons {text-align:right; margin-bottom:0;}
98 98
99 99 input#openid_url { background: url(../images/openid-bg.gif) no-repeat; background-color: #fff; background-position: 0 50%; padding-left: 18px; }
100 100
101 101 .clear:after{ content: "."; display: block; height: 0; clear: both; visibility: hidden; }
102 102
103 103 /***** Links *****/
104 104 a, a:link, a:visited{ color: #169; text-decoration: none; }
105 105 a:hover, a:active{ color: #c61a1a; text-decoration: underline;}
106 106 a img{ border: 0; }
107 107
108 108 a.issue.closed, a.issue.closed:link, a.issue.closed:visited { color: #999; text-decoration: line-through; }
109 109 a.project.closed, a.project.closed:link, a.project.closed:visited { color: #999; }
110 110 a.user.locked, a.user.locked:link, a.user.locked:visited {color: #999;}
111 111
112 112 #sidebar a.selected {line-height:1.7em; padding:1px 3px 2px 2px; margin-left:-2px; background-color:#9DB9D5; color:#fff; border-radius:2px;}
113 113 #sidebar a.selected:hover {text-decoration:none;}
114 114 #admin-menu a {line-height:1.7em;}
115 115 #admin-menu a.selected {padding-left: 20px !important; background-position: 2px 40%;}
116 116
117 117 a.collapsible {padding-left: 12px; background: url(../images/arrow_expanded.png) no-repeat -3px 40%;}
118 118 a.collapsible.collapsed {background: url(../images/arrow_collapsed.png) no-repeat -5px 40%;}
119 119
120 120 a#toggle-completed-versions {color:#999;}
121 121 /***** Tables *****/
122 122 table.list { border: 1px solid #e4e4e4; border-collapse: collapse; width: 100%; margin-bottom: 4px; }
123 123 table.list th { background-color:#EEEEEE; padding: 4px; white-space:nowrap; }
124 124 table.list td { vertical-align: top; padding-right:10px; }
125 125 table.list td.id { width: 2%; text-align: center;}
126 126 table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
127 127 table.list td.checkbox input {padding:0px;}
128 128 table.list td.buttons { width: 15%; white-space:nowrap; text-align: right; }
129 129 table.list td.buttons a { padding-right: 0.6em; }
130 130 table.list caption { text-align: left; padding: 0.5em 0.5em 0.5em 0; }
131 131
132 132 tr.project td.name a { white-space:nowrap; }
133 133 tr.project.closed, tr.project.archived { color: #aaa; }
134 134 tr.project.closed a, tr.project.archived a { color: #aaa; }
135 135
136 136 tr.project.idnt td.name span {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
137 137 tr.project.idnt-1 td.name {padding-left: 0.5em;}
138 138 tr.project.idnt-2 td.name {padding-left: 2em;}
139 139 tr.project.idnt-3 td.name {padding-left: 3.5em;}
140 140 tr.project.idnt-4 td.name {padding-left: 5em;}
141 141 tr.project.idnt-5 td.name {padding-left: 6.5em;}
142 142 tr.project.idnt-6 td.name {padding-left: 8em;}
143 143 tr.project.idnt-7 td.name {padding-left: 9.5em;}
144 144 tr.project.idnt-8 td.name {padding-left: 11em;}
145 145 tr.project.idnt-9 td.name {padding-left: 12.5em;}
146 146
147 147 tr.issue { text-align: center; white-space: nowrap; }
148 148 tr.issue td.subject, tr.issue td.category, td.assigned_to, tr.issue td.string, tr.issue td.text, tr.issue td.relations { white-space: normal; }
149 149 tr.issue td.subject, tr.issue td.relations { text-align: left; }
150 150 tr.issue td.done_ratio table.progress { margin-left:auto; margin-right: auto;}
151 151 tr.issue td.relations span {white-space: nowrap;}
152 152 table.issues td.description {color:#777; font-size:90%; padding:4px 4px 4px 24px; text-align:left; white-space:normal;}
153 153 table.issues td.description pre {white-space:normal;}
154 154
155 155 tr.issue.idnt td.subject a {background: url(../images/bullet_arrow_right.png) no-repeat 0 50%; padding-left: 16px;}
156 156 tr.issue.idnt-1 td.subject {padding-left: 0.5em;}
157 157 tr.issue.idnt-2 td.subject {padding-left: 2em;}
158 158 tr.issue.idnt-3 td.subject {padding-left: 3.5em;}
159 159 tr.issue.idnt-4 td.subject {padding-left: 5em;}
160 160 tr.issue.idnt-5 td.subject {padding-left: 6.5em;}
161 161 tr.issue.idnt-6 td.subject {padding-left: 8em;}
162 162 tr.issue.idnt-7 td.subject {padding-left: 9.5em;}
163 163 tr.issue.idnt-8 td.subject {padding-left: 11em;}
164 164 tr.issue.idnt-9 td.subject {padding-left: 12.5em;}
165 165
166 166 tr.entry { border: 1px solid #f8f8f8; }
167 167 tr.entry td { white-space: nowrap; }
168 168 tr.entry td.filename { width: 30%; }
169 169 tr.entry td.filename_no_report { width: 70%; }
170 170 tr.entry td.size { text-align: right; font-size: 90%; }
171 171 tr.entry td.revision, tr.entry td.author { text-align: center; }
172 172 tr.entry td.age { text-align: right; }
173 173 tr.entry.file td.filename a { margin-left: 16px; }
174 174 tr.entry.file td.filename_no_report a { margin-left: 16px; }
175 175
176 176 tr span.expander {background-image: url(../images/bullet_toggle_plus.png); padding-left: 8px; margin-left: 0; cursor: pointer;}
177 177 tr.open span.expander {background-image: url(../images/bullet_toggle_minus.png);}
178 178
179 179 tr.changeset { height: 20px }
180 180 tr.changeset ul, ol { margin-top: 0px; margin-bottom: 0px; }
181 181 tr.changeset td.revision_graph { width: 15%; background-color: #fffffb; }
182 182 tr.changeset td.author { text-align: center; width: 15%; white-space:nowrap;}
183 183 tr.changeset td.committed_on { text-align: center; width: 15%; white-space:nowrap;}
184 184
185 185 table.files tr.file td { text-align: center; }
186 186 table.files tr.file td.filename { text-align: left; padding-left: 24px; }
187 187 table.files tr.file td.digest { font-size: 80%; }
188 188
189 189 table.members td.roles, table.memberships td.roles { width: 45%; }
190 190
191 191 tr.message { height: 2.6em; }
192 192 tr.message td.subject { padding-left: 20px; }
193 193 tr.message td.created_on { white-space: nowrap; }
194 194 tr.message td.last_message { font-size: 80%; white-space: nowrap; }
195 195 tr.message.locked td.subject { background: url(../images/locked.png) no-repeat 0 1px; }
196 196 tr.message.sticky td.subject { background: url(../images/bullet_go.png) no-repeat 0 1px; font-weight: bold; }
197 197
198 198 tr.version.closed, tr.version.closed a { color: #999; }
199 199 tr.version td.name { padding-left: 20px; }
200 200 tr.version.shared td.name { background: url(../images/link.png) no-repeat 0% 70%; }
201 201 tr.version td.date, tr.version td.status, tr.version td.sharing { text-align: center; white-space:nowrap; }
202 202
203 203 tr.user td { width:13%; }
204 204 tr.user td.email { width:18%; }
205 205 tr.user td { white-space: nowrap; }
206 206 tr.user.locked, tr.user.registered { color: #aaa; }
207 207 tr.user.locked a, tr.user.registered a { color: #aaa; }
208 208
209 209 table.permissions td.role {color:#999;font-size:90%;font-weight:normal !important;text-align:center;vertical-align:bottom;}
210 210
211 211 tr.wiki-page-version td.updated_on, tr.wiki-page-version td.author {text-align:center;}
212 212
213 213 tr.time-entry { text-align: center; white-space: nowrap; }
214 214 tr.time-entry td.issue, tr.time-entry td.comments { text-align: left; white-space: normal; }
215 215 td.hours { text-align: right; font-weight: bold; padding-right: 0.5em; }
216 216 td.hours .hours-dec { font-size: 0.9em; }
217 217
218 218 table.plugins td { vertical-align: middle; }
219 219 table.plugins td.configure { text-align: right; padding-right: 1em; }
220 220 table.plugins span.name { font-weight: bold; display: block; margin-bottom: 6px; }
221 221 table.plugins span.description { display: block; font-size: 0.9em; }
222 222 table.plugins span.url { display: block; font-size: 0.9em; }
223 223
224 224 table.list tbody tr.group td { padding: 0.8em 0 0.5em 0.3em; font-weight: bold; border-bottom: 1px solid #ccc; }
225 225 table.list tbody tr.group span.count {position:relative; top:-1px; color:#fff; font-size:10px; background:#9DB9D5; padding:0px 6px 1px 6px; border-radius:3px; margin-left:4px;}
226 226 tr.group a.toggle-all { color: #aaa; font-size: 80%; font-weight: normal; display:none;}
227 227 tr.group:hover a.toggle-all { display:inline;}
228 228 a.toggle-all:hover {text-decoration:none;}
229 229
230 230 table.list tbody tr:hover { background-color:#ffffdd; }
231 231 table.list tbody tr.group:hover { background-color:inherit; }
232 232 table td {padding:2px;}
233 233 table p {margin:0;}
234 234 .odd {background-color:#f6f7f8;}
235 235 .even {background-color: #fff;}
236 236
237 237 a.sort { padding-right: 16px; background-position: 100% 50%; background-repeat: no-repeat; }
238 238 a.sort.asc { background-image: url(../images/sort_asc.png); }
239 239 a.sort.desc { background-image: url(../images/sort_desc.png); }
240 240
241 241 table.attributes { width: 100% }
242 242 table.attributes th { vertical-align: top; text-align: left; }
243 243 table.attributes td { vertical-align: top; }
244 244
245 245 table.boards a.board, h3.comments { background: url(../images/comment.png) no-repeat 0% 50%; padding-left: 20px; }
246 246 table.boards td.topic-count, table.boards td.message-count {text-align:center;}
247 247 table.boards td.last-message {font-size:80%;}
248 248
249 249 table.messages td.author, table.messages td.created_on, table.messages td.reply-count {text-align:center;}
250 250
251 251 table.query-columns {
252 252 border-collapse: collapse;
253 253 border: 0;
254 254 }
255 255
256 256 table.query-columns td.buttons {
257 257 vertical-align: middle;
258 258 text-align: center;
259 259 }
260 260
261 261 td.center {text-align:center;}
262 262
263 263 h3.version { background: url(../images/package.png) no-repeat 0% 50%; padding-left: 20px; }
264 264
265 265 div.issues h3 { background: url(../images/ticket.png) no-repeat 0% 50%; padding-left: 20px; }
266 266 div.members h3 { background: url(../images/group.png) no-repeat 0% 50%; padding-left: 20px; }
267 267 div.news h3 { background: url(../images/news.png) no-repeat 0% 50%; padding-left: 20px; }
268 268 div.projects h3 { background: url(../images/projects.png) no-repeat 0% 50%; padding-left: 20px; }
269 269
270 270 #watchers ul {margin: 0; padding: 0;}
271 271 #watchers li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;}
272 272 #watchers select {width: 95%; display: block;}
273 273 #watchers a.delete {opacity: 0.4;}
274 274 #watchers a.delete:hover {opacity: 1;}
275 275 #watchers img.gravatar {margin: 0 4px 2px 0;}
276 276
277 277 span#watchers_inputs {overflow:auto; display:block;}
278 278 span.search_for_watchers {display:block;}
279 279 span.search_for_watchers, span.add_attachment {font-size:80%; line-height:2.5em;}
280 280 span.search_for_watchers a, span.add_attachment a {padding-left:16px; background: url(../images/bullet_add.png) no-repeat 0 50%; }
281 281
282 282
283 283 .highlight { background-color: #FCFD8D;}
284 284 .highlight.token-1 { background-color: #faa;}
285 285 .highlight.token-2 { background-color: #afa;}
286 286 .highlight.token-3 { background-color: #aaf;}
287 287
288 288 .box{
289 289 padding:6px;
290 290 margin-bottom: 10px;
291 291 background-color:#f6f6f6;
292 292 color:#505050;
293 293 line-height:1.5em;
294 294 border: 1px solid #e4e4e4;
295 295 }
296 296
297 297 div.square {
298 298 border: 1px solid #999;
299 299 float: left;
300 300 margin: .3em .4em 0 .4em;
301 301 overflow: hidden;
302 302 width: .6em; height: .6em;
303 303 }
304 304 .contextual {float:right; white-space: nowrap; line-height:1.4em;margin-top:5px; padding-left: 10px; font-size:0.9em;}
305 305 .contextual input, .contextual select {font-size:0.9em;}
306 306 .message .contextual { margin-top: 0; }
307 307
308 308 .splitcontent {overflow:auto;}
309 309 .splitcontentleft{float:left; width:49%;}
310 310 .splitcontentright{float:right; width:49%;}
311 311 form {display: inline;}
312 312 input, select {vertical-align: middle; margin-top: 1px; margin-bottom: 1px;}
313 313 fieldset {border: 1px solid #e4e4e4; margin:0;}
314 314 legend {color: #484848;}
315 315 hr { width: 100%; height: 1px; background: #ccc; border: 0;}
316 316 blockquote { font-style: italic; border-left: 3px solid #e0e0e0; padding-left: 0.6em; margin-left: 2.4em;}
317 317 blockquote blockquote { margin-left: 0;}
318 318 acronym { border-bottom: 1px dotted; cursor: help; }
319 319 textarea.wiki-edit {width:99%; resize:vertical;}
320 320 li p {margin-top: 0;}
321 321 div.issue {background:#ffffdd; padding:6px; margin-bottom:6px;border: 1px solid #d7d7d7;}
322 322 p.breadcrumb { font-size: 0.9em; margin: 4px 0 4px 0;}
323 323 p.subtitle { font-size: 0.9em; margin: -6px 0 12px 0; font-style: italic; }
324 324 p.footnote { font-size: 0.9em; margin-top: 0px; margin-bottom: 0px; }
325 325
326 326 div.issue div.subject div div { padding-left: 16px; }
327 327 div.issue div.subject p {margin: 0; margin-bottom: 0.1em; font-size: 90%; color: #999;}
328 328 div.issue div.subject>div>p { margin-top: 0.5em; }
329 329 div.issue div.subject h3 {margin: 0; margin-bottom: 0.1em;}
330 330 div.issue span.private { position:relative; bottom: 2px; text-transform: uppercase; background: #d22; color: #fff; font-weight:bold; padding: 0px 2px 0px 2px; font-size: 60%; margin-right: 2px; border-radius: 2px;}
331 331 div.issue .next-prev-links {color:#999;}
332 332 div.issue table.attributes th {width:22%;}
333 333 div.issue table.attributes td {width:28%;}
334 334
335 335 #issue_tree table.issues, #relations table.issues { border: 0; }
336 336 #issue_tree td.checkbox, #relations td.checkbox {display:none;}
337 337 #relations td.buttons {padding:0;}
338 338
339 339 fieldset.collapsible { border-width: 1px 0 0 0; font-size: 0.9em; }
340 340 fieldset.collapsible legend { padding-left: 16px; background: url(../images/arrow_expanded.png) no-repeat 0% 40%; cursor:pointer; }
341 341 fieldset.collapsible.collapsed legend { background-image: url(../images/arrow_collapsed.png); }
342 342
343 343 fieldset#date-range p { margin: 2px 0 2px 0; }
344 344 fieldset#filters table { border-collapse: collapse; }
345 345 fieldset#filters table td { padding: 0; vertical-align: middle; }
346 346 fieldset#filters tr.filter { height: 2.1em; }
347 347 fieldset#filters td.field { width:230px; }
348 348 fieldset#filters td.operator { width:180px; }
349 349 fieldset#filters td.operator select {max-width:170px;}
350 350 fieldset#filters td.values { white-space:nowrap; }
351 351 fieldset#filters td.values select {min-width:130px;}
352 352 fieldset#filters td.values input {height:1em;}
353 353 fieldset#filters td.add-filter { text-align: right; vertical-align: top; }
354 354
355 355 .toggle-multiselect {background: url(../images/bullet_toggle_plus.png) no-repeat 0% 40%; padding-left:8px; margin-left:0; cursor:pointer;}
356 356 .buttons { font-size: 0.9em; margin-bottom: 1.4em; margin-top: 1em; }
357 357
358 358 div#issue-changesets {float:right; width:45%; margin-left: 1em; margin-bottom: 1em; background: #fff; padding-left: 1em; font-size: 90%;}
359 359 div#issue-changesets div.changeset { padding: 4px;}
360 360 div#issue-changesets div.changeset { border-bottom: 1px solid #ddd; }
361 361 div#issue-changesets p { margin-top: 0; margin-bottom: 1em;}
362 362
363 363 .journal ul.details img {margin:0 0 -3px 4px;}
364 364 div.journal {overflow:auto;}
365 365 div.journal.private-notes {border-left:2px solid #d22; padding-left:4px; margin-left:-6px;}
366 366
367 367 div#activity dl, #search-results { margin-left: 2em; }
368 368 div#activity dd, #search-results dd { margin-bottom: 1em; padding-left: 18px; font-size: 0.9em; }
369 369 div#activity dt, #search-results dt { margin-bottom: 0px; padding-left: 20px; line-height: 18px; background-position: 0 50%; background-repeat: no-repeat; }
370 370 div#activity dt.me .time { border-bottom: 1px solid #999; }
371 371 div#activity dt .time { color: #777; font-size: 80%; }
372 372 div#activity dd .description, #search-results dd .description { font-style: italic; }
373 373 div#activity span.project:after, #search-results span.project:after { content: " -"; }
374 374 div#activity dd span.description, #search-results dd span.description { display:block; color: #808080; }
375 375 div#activity dt.grouped {margin-left:5em;}
376 376 div#activity dd.grouped {margin-left:9em;}
377 377
378 378 #search-results dd { margin-bottom: 1em; padding-left: 20px; margin-left:0px; }
379 379
380 380 div#search-results-counts {float:right;}
381 381 div#search-results-counts ul { margin-top: 0.5em; }
382 382 div#search-results-counts li { list-style-type:none; float: left; margin-left: 1em; }
383 383
384 384 dt.issue { background-image: url(../images/ticket.png); }
385 385 dt.issue-edit { background-image: url(../images/ticket_edit.png); }
386 386 dt.issue-closed { background-image: url(../images/ticket_checked.png); }
387 387 dt.issue-note { background-image: url(../images/ticket_note.png); }
388 388 dt.changeset { background-image: url(../images/changeset.png); }
389 389 dt.news { background-image: url(../images/news.png); }
390 390 dt.message { background-image: url(../images/message.png); }
391 391 dt.reply { background-image: url(../images/comments.png); }
392 392 dt.wiki-page { background-image: url(../images/wiki_edit.png); }
393 393 dt.attachment { background-image: url(../images/attachment.png); }
394 394 dt.document { background-image: url(../images/document.png); }
395 395 dt.project { background-image: url(../images/projects.png); }
396 396 dt.time-entry { background-image: url(../images/time.png); }
397 397
398 398 #search-results dt.issue.closed { background-image: url(../images/ticket_checked.png); }
399 399
400 400 div#roadmap .related-issues { margin-bottom: 1em; }
401 401 div#roadmap .related-issues td.checkbox { display: none; }
402 402 div#roadmap .wiki h1:first-child { display: none; }
403 403 div#roadmap .wiki h1 { font-size: 120%; }
404 404 div#roadmap .wiki h2 { font-size: 110%; }
405 405 body.controller-versions.action-show div#roadmap .related-issues {width:70%;}
406 406
407 407 div#version-summary { float:right; width:28%; margin-left: 16px; margin-bottom: 16px; background-color: #fff; }
408 408 div#version-summary fieldset { margin-bottom: 1em; }
409 409 div#version-summary fieldset.time-tracking table { width:100%; }
410 410 div#version-summary th, div#version-summary td.total-hours { text-align: right; }
411 411
412 412 table#time-report td.hours, table#time-report th.period, table#time-report th.total { text-align: right; padding-right: 0.5em; }
413 413 table#time-report tbody tr.subtotal { font-style: italic; color:#777;}
414 414 table#time-report tbody tr.subtotal td.hours { color:#b0b0b0; }
415 415 table#time-report tbody tr.total { font-weight: bold; background-color:#EEEEEE; border-top:1px solid #e4e4e4;}
416 416 table#time-report .hours-dec { font-size: 0.9em; }
417 417
418 418 div.wiki-page .contextual a {opacity: 0.4}
419 419 div.wiki-page .contextual a:hover {opacity: 1}
420 420
421 421 form .attributes select { width: 60%; }
422 422 input#issue_subject { width: 99%; }
423 423 select#issue_done_ratio { width: 95px; }
424 424
425 425 ul.projects {margin:0; padding-left:1em;}
426 426 ul.projects ul {padding-left:1.6em;}
427 427 ul.projects.root {margin:0; padding:0;}
428 428 ul.projects li {list-style-type:none;}
429 429
430 430 #projects-index ul.projects ul.projects { border-left: 3px solid #e0e0e0; padding-left:1em;}
431 431 #projects-index ul.projects li.root {margin-bottom: 1em;}
432 432 #projects-index ul.projects li.child {margin-top: 1em;}
433 433 #projects-index ul.projects div.root a.project { font-family: "Trebuchet MS", Verdana, sans-serif; font-weight: bold; font-size: 16px; margin: 0 0 10px 0; }
434 434 .my-project { padding-left: 18px; background: url(../images/fav.png) no-repeat 0 50%; }
435 435
436 436 #notified-projects ul, #tracker_project_ids ul {max-height:250px; overflow-y:auto;}
437 437
438 438 #related-issues li img {vertical-align:middle;}
439 439
440 440 ul.properties {padding:0; font-size: 0.9em; color: #777;}
441 441 ul.properties li {list-style-type:none;}
442 442 ul.properties li span {font-style:italic;}
443 443
444 444 .total-hours { font-size: 110%; font-weight: bold; }
445 445 .total-hours span.hours-int { font-size: 120%; }
446 446
447 447 .autoscroll {overflow-x: auto; padding:1px; margin-bottom: 1.2em;}
448 448 #user_login, #user_firstname, #user_lastname, #user_mail, #my_account_form select, #user_form select, #user_identity_url { width: 90%; }
449 449
450 450 #workflow_copy_form select { width: 200px; }
451 451 table.transitions td.enabled {background: #bfb;}
452 452 table.fields_permissions select {font-size:90%}
453 453 table.fields_permissions td.readonly {background:#ddd;}
454 454 table.fields_permissions td.required {background:#d88;}
455 455
456 456 textarea#custom_field_possible_values {width: 99%}
457 457 input#content_comments {width: 99%}
458 458
459 459 .pagination {font-size: 90%}
460 460 p.pagination {margin-top:8px;}
461 461
462 462 /***** Tabular forms ******/
463 463 .tabular p{
464 464 margin: 0;
465 465 padding: 3px 0 3px 0;
466 466 padding-left: 180px; /* width of left column containing the label elements */
467 467 min-height: 1.8em;
468 468 clear:left;
469 469 }
470 470
471 471 html>body .tabular p {overflow:hidden;}
472 472
473 473 .tabular label{
474 474 font-weight: bold;
475 475 float: left;
476 476 text-align: right;
477 477 /* width of left column */
478 478 margin-left: -180px;
479 479 /* width of labels. Should be smaller than left column to create some right margin */
480 480 width: 175px;
481 481 }
482 482
483 483 .tabular label.floating{
484 484 font-weight: normal;
485 485 margin-left: 0px;
486 486 text-align: left;
487 487 width: 270px;
488 488 }
489 489
490 490 .tabular label.block{
491 491 font-weight: normal;
492 492 margin-left: 0px !important;
493 493 text-align: left;
494 494 float: none;
495 495 display: block;
496 496 width: auto;
497 497 }
498 498
499 499 .tabular label.inline{
500 500 font-weight: normal;
501 501 float:none;
502 502 margin-left: 5px !important;
503 503 width: auto;
504 504 }
505 505
506 506 label.no-css {
507 507 font-weight: inherit;
508 508 float:none;
509 509 text-align:left;
510 510 margin-left:0px;
511 511 width:auto;
512 512 }
513 513 input#time_entry_comments { width: 90%;}
514 514
515 515 #preview fieldset {margin-top: 1em; background: url(../images/draft.png)}
516 516
517 517 .tabular.settings p{ padding-left: 300px; }
518 518 .tabular.settings label{ margin-left: -300px; width: 295px; }
519 519 .tabular.settings textarea { width: 99%; }
520 520
521 521 .settings.enabled_scm table {width:100%}
522 522 .settings.enabled_scm td.scm_name{ font-weight: bold; }
523 523
524 524 fieldset.settings label { display: block; }
525 525 fieldset#notified_events .parent { padding-left: 20px; }
526 526
527 527 span.required {color: #bb0000;}
528 528 .summary {font-style: italic;}
529 529
530 #attachments_fields input.description {margin-left: 8px; width:340px;}
530 #attachments_fields input.description {margin-left:4px; width:340px;}
531 531 #attachments_fields span {display:block; white-space:nowrap;}
532 #attachments_fields img {vertical-align: middle;}
532 #attachments_fields input.filename {border:0; height:1.8em; width:250px; color:#555; background-color:inherit; background:url(../images/attachment.png) no-repeat 1px 50%; padding-left:18px;}
533 #attachments_fields .ajax-waiting input.filename {background:url(../images/hourglass.png) no-repeat 0px 50%;}
534 #attachments_fields .ajax-loading input.filename {background:url(../images/loading.gif) no-repeat 0px 50%;}
535 #attachments_fields div.ui-progressbar { width: 100px; height:14px; margin: 2px 0 -5px 8px; display: inline-block; }
536 a.remove-upload {background: url(../images/delete.png) no-repeat 1px 50%; width:1px; display:inline-block; padding-left:16px;}
537 a.remove-upload:hover {text-decoration:none !important;}
538
539 div.fileover { background-color: lavender; }
533 540
534 541 div.attachments { margin-top: 12px; }
535 542 div.attachments p { margin:4px 0 2px 0; }
536 543 div.attachments img { vertical-align: middle; }
537 544 div.attachments span.author { font-size: 0.9em; color: #888; }
538 545
539 546 div.thumbnails {margin-top:0.6em;}
540 547 div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
541 548 div.thumbnails img {margin: 3px;}
542 549
543 550 p.other-formats { text-align: right; font-size:0.9em; color: #666; }
544 551 .other-formats span + span:before { content: "| "; }
545 552
546 553 a.atom { background: url(../images/feed.png) no-repeat 1px 50%; padding: 2px 0px 3px 16px; }
547 554
548 555 em.info {font-style:normal;font-size:90%;color:#888;display:block;}
549 556 em.info.error {padding-left:20px; background:url(../images/exclamation.png) no-repeat 0 50%;}
550 557
551 558 textarea.text_cf {width:90%;}
552 559
553 560 /* Project members tab */
554 561 div#tab-content-members .splitcontentleft, div#tab-content-memberships .splitcontentleft, div#tab-content-users .splitcontentleft { width: 64% }
555 562 div#tab-content-members .splitcontentright, div#tab-content-memberships .splitcontentright, div#tab-content-users .splitcontentright { width: 34% }
556 563 div#tab-content-members fieldset, div#tab-content-memberships fieldset, div#tab-content-users fieldset { padding:1em; margin-bottom: 1em; }
557 564 div#tab-content-members fieldset legend, div#tab-content-memberships fieldset legend, div#tab-content-users fieldset legend { font-weight: bold; }
558 565 div#tab-content-members fieldset label, div#tab-content-memberships fieldset label, div#tab-content-users fieldset label { display: block; }
559 566 div#tab-content-members fieldset div, div#tab-content-users fieldset div { max-height: 400px; overflow:auto; }
560 567
561 568 #users_for_watcher {height: 200px; overflow:auto;}
562 569 #users_for_watcher label {display: block;}
563 570
564 571 table.members td.group { padding-left: 20px; background: url(../images/group.png) no-repeat 0% 50%; }
565 572
566 573 input#principal_search, input#user_search {width:100%}
567 574 input#principal_search, input#user_search {
568 575 background: url(../images/magnifier.png) no-repeat 2px 50%; padding-left:20px;
569 576 border:1px solid #9EB1C2; border-radius:3px; height:1.5em; width:95%;
570 577 }
571 578 input#principal_search.ajax-loading, input#user_search.ajax-loading {
572 579 background-image: url(../images/loading.gif);
573 580 }
574 581
575 582 * html div#tab-content-members fieldset div { height: 450px; }
576 583
577 584 /***** Flash & error messages ****/
578 585 #errorExplanation, div.flash, .nodata, .warning, .conflict {
579 586 padding: 4px 4px 4px 30px;
580 587 margin-bottom: 12px;
581 588 font-size: 1.1em;
582 589 border: 2px solid;
583 590 }
584 591
585 592 div.flash {margin-top: 8px;}
586 593
587 594 div.flash.error, #errorExplanation {
588 595 background: url(../images/exclamation.png) 8px 50% no-repeat;
589 596 background-color: #ffe3e3;
590 597 border-color: #dd0000;
591 598 color: #880000;
592 599 }
593 600
594 601 div.flash.notice {
595 602 background: url(../images/true.png) 8px 5px no-repeat;
596 603 background-color: #dfffdf;
597 604 border-color: #9fcf9f;
598 605 color: #005f00;
599 606 }
600 607
601 608 div.flash.warning, .conflict {
602 609 background: url(../images/warning.png) 8px 5px no-repeat;
603 610 background-color: #FFEBC1;
604 611 border-color: #FDBF3B;
605 612 color: #A6750C;
606 613 text-align: left;
607 614 }
608 615
609 616 .nodata, .warning {
610 617 text-align: center;
611 618 background-color: #FFEBC1;
612 619 border-color: #FDBF3B;
613 620 color: #A6750C;
614 621 }
615 622
616 623 #errorExplanation ul { font-size: 0.9em;}
617 624 #errorExplanation h2, #errorExplanation p { display: none; }
618 625
619 626 .conflict-details {font-size:80%;}
620 627
621 628 /***** Ajax indicator ******/
622 629 #ajax-indicator {
623 630 position: absolute; /* fixed not supported by IE */
624 631 background-color:#eee;
625 632 border: 1px solid #bbb;
626 633 top:35%;
627 634 left:40%;
628 635 width:20%;
629 636 font-weight:bold;
630 637 text-align:center;
631 638 padding:0.6em;
632 639 z-index:100;
633 640 opacity: 0.5;
634 641 }
635 642
636 643 html>body #ajax-indicator { position: fixed; }
637 644
638 645 #ajax-indicator span {
639 646 background-position: 0% 40%;
640 647 background-repeat: no-repeat;
641 648 background-image: url(../images/loading.gif);
642 649 padding-left: 26px;
643 650 vertical-align: bottom;
644 651 }
645 652
646 653 /***** Calendar *****/
647 654 table.cal {border-collapse: collapse; width: 100%; margin: 0px 0 6px 0;border: 1px solid #d7d7d7;}
648 655 table.cal thead th {width: 14%; background-color:#EEEEEE; padding: 4px; }
649 656 table.cal thead th.week-number {width: auto;}
650 657 table.cal tbody tr {height: 100px;}
651 658 table.cal td {border: 1px solid #d7d7d7; vertical-align: top; font-size: 0.9em;}
652 659 table.cal td.week-number { background-color:#EEEEEE; padding: 4px; border:none; font-size: 1em;}
653 660 table.cal td p.day-num {font-size: 1.1em; text-align:right;}
654 661 table.cal td.odd p.day-num {color: #bbb;}
655 662 table.cal td.today {background:#ffffdd;}
656 663 table.cal td.today p.day-num {font-weight: bold;}
657 664 table.cal .starting a, p.cal.legend .starting {background: url(../images/bullet_go.png) no-repeat -1px -2px; padding-left:16px;}
658 665 table.cal .ending a, p.cal.legend .ending {background: url(../images/bullet_end.png) no-repeat -1px -2px; padding-left:16px;}
659 666 table.cal .starting.ending a, p.cal.legend .starting.ending {background: url(../images/bullet_diamond.png) no-repeat -1px -2px; padding-left:16px;}
660 667 p.cal.legend span {display:block;}
661 668
662 669 /***** Tooltips ******/
663 670 .tooltip{position:relative;z-index:24;}
664 671 .tooltip:hover{z-index:25;color:#000;}
665 672 .tooltip span.tip{display: none; text-align:left;}
666 673
667 674 div.tooltip:hover span.tip{
668 675 display:block;
669 676 position:absolute;
670 677 top:12px; left:24px; width:270px;
671 678 border:1px solid #555;
672 679 background-color:#fff;
673 680 padding: 4px;
674 681 font-size: 0.8em;
675 682 color:#505050;
676 683 }
677 684
678 685 img.ui-datepicker-trigger {
679 686 cursor: pointer;
680 687 vertical-align: middle;
681 688 margin-left: 4px;
682 689 }
683 690
684 691 /***** Progress bar *****/
685 692 table.progress {
686 693 border-collapse: collapse;
687 694 border-spacing: 0pt;
688 695 empty-cells: show;
689 696 text-align: center;
690 697 float:left;
691 698 margin: 1px 6px 1px 0px;
692 699 }
693 700
694 701 table.progress td { height: 1em; }
695 702 table.progress td.closed { background: #BAE0BA none repeat scroll 0%; }
696 703 table.progress td.done { background: #D3EDD3 none repeat scroll 0%; }
697 704 table.progress td.todo { background: #eee none repeat scroll 0%; }
698 705 p.pourcent {font-size: 80%;}
699 706 p.progress-info {clear: left; font-size: 80%; margin-top:-4px; color:#777;}
700 707
701 708 #roadmap table.progress td { height: 1.2em; }
702 709 /***** Tabs *****/
703 710 #content .tabs {height: 2.6em; margin-bottom:1.2em; position:relative; overflow:hidden;}
704 711 #content .tabs ul {margin:0; position:absolute; bottom:0; padding-left:0.5em; width: 2000px; border-bottom: 1px solid #bbbbbb;}
705 712 #content .tabs ul li {
706 713 float:left;
707 714 list-style-type:none;
708 715 white-space:nowrap;
709 716 margin-right:4px;
710 717 background:#fff;
711 718 position:relative;
712 719 margin-bottom:-1px;
713 720 }
714 721 #content .tabs ul li a{
715 722 display:block;
716 723 font-size: 0.9em;
717 724 text-decoration:none;
718 725 line-height:1.3em;
719 726 padding:4px 6px 4px 6px;
720 727 border: 1px solid #ccc;
721 728 border-bottom: 1px solid #bbbbbb;
722 729 background-color: #f6f6f6;
723 730 color:#999;
724 731 font-weight:bold;
725 732 border-top-left-radius:3px;
726 733 border-top-right-radius:3px;
727 734 }
728 735
729 736 #content .tabs ul li a:hover {
730 737 background-color: #ffffdd;
731 738 text-decoration:none;
732 739 }
733 740
734 741 #content .tabs ul li a.selected {
735 742 background-color: #fff;
736 743 border: 1px solid #bbbbbb;
737 744 border-bottom: 1px solid #fff;
738 745 color:#444;
739 746 }
740 747
741 748 #content .tabs ul li a.selected:hover {background-color: #fff;}
742 749
743 750 div.tabs-buttons { position:absolute; right: 0; width: 48px; height: 24px; background: white; bottom: 0; border-bottom: 1px solid #bbbbbb; }
744 751
745 752 button.tab-left, button.tab-right {
746 753 font-size: 0.9em;
747 754 cursor: pointer;
748 755 height:24px;
749 756 border: 1px solid #ccc;
750 757 border-bottom: 1px solid #bbbbbb;
751 758 position:absolute;
752 759 padding:4px;
753 760 width: 20px;
754 761 bottom: -1px;
755 762 }
756 763
757 764 button.tab-left {
758 765 right: 20px;
759 766 background: #eeeeee url(../images/bullet_arrow_left.png) no-repeat 50% 50%;
760 767 border-top-left-radius:3px;
761 768 }
762 769
763 770 button.tab-right {
764 771 right: 0;
765 772 background: #eeeeee url(../images/bullet_arrow_right.png) no-repeat 50% 50%;
766 773 border-top-right-radius:3px;
767 774 }
768 775
769 776 /***** Diff *****/
770 777 .diff_out { background: #fcc; }
771 778 .diff_out span { background: #faa; }
772 779 .diff_in { background: #cfc; }
773 780 .diff_in span { background: #afa; }
774 781
775 782 .text-diff {
776 783 padding: 1em;
777 784 background-color:#f6f6f6;
778 785 color:#505050;
779 786 border: 1px solid #e4e4e4;
780 787 }
781 788
782 789 /***** Wiki *****/
783 790 div.wiki table {
784 791 border-collapse: collapse;
785 792 margin-bottom: 1em;
786 793 }
787 794
788 795 div.wiki table, div.wiki td, div.wiki th {
789 796 border: 1px solid #bbb;
790 797 padding: 4px;
791 798 }
792 799
793 800 div.wiki .noborder, div.wiki .noborder td, div.wiki .noborder th {border:0;}
794 801
795 802 div.wiki .external {
796 803 background-position: 0% 60%;
797 804 background-repeat: no-repeat;
798 805 padding-left: 12px;
799 806 background-image: url(../images/external.png);
800 807 }
801 808
802 809 div.wiki a.new {color: #b73535;}
803 810
804 811 div.wiki ul, div.wiki ol {margin-bottom:1em;}
805 812
806 813 div.wiki pre {
807 814 margin: 1em 1em 1em 1.6em;
808 815 padding: 8px;
809 816 background-color: #fafafa;
810 817 border: 1px solid #e2e2e2;
811 818 width:auto;
812 819 overflow-x: auto;
813 820 overflow-y: hidden;
814 821 }
815 822
816 823 div.wiki ul.toc {
817 824 background-color: #ffffdd;
818 825 border: 1px solid #e4e4e4;
819 826 padding: 4px;
820 827 line-height: 1.2em;
821 828 margin-bottom: 12px;
822 829 margin-right: 12px;
823 830 margin-left: 0;
824 831 display: table
825 832 }
826 833 * html div.wiki ul.toc { width: 50%; } /* IE6 doesn't autosize div */
827 834
828 835 div.wiki ul.toc.right { float: right; margin-left: 12px; margin-right: 0; width: auto; }
829 836 div.wiki ul.toc.left { float: left; margin-right: 12px; margin-left: 0; width: auto; }
830 837 div.wiki ul.toc ul { margin: 0; padding: 0; }
831 838 div.wiki ul.toc li {list-style-type:none; margin: 0; font-size:12px;}
832 839 div.wiki ul.toc li li {margin-left: 1.5em; font-size:10px;}
833 840 div.wiki ul.toc a {
834 841 font-size: 0.9em;
835 842 font-weight: normal;
836 843 text-decoration: none;
837 844 color: #606060;
838 845 }
839 846 div.wiki ul.toc a:hover { color: #c61a1a; text-decoration: underline;}
840 847
841 848 a.wiki-anchor { display: none; margin-left: 6px; text-decoration: none; }
842 849 a.wiki-anchor:hover { color: #aaa !important; text-decoration: none; }
843 850 h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor { display: inline; color: #ddd; }
844 851
845 852 div.wiki img { vertical-align: middle; }
846 853
847 854 /***** My page layout *****/
848 855 .block-receiver {
849 856 border:1px dashed #c0c0c0;
850 857 margin-bottom: 20px;
851 858 padding: 15px 0 15px 0;
852 859 }
853 860
854 861 .mypage-box {
855 862 margin:0 0 20px 0;
856 863 color:#505050;
857 864 line-height:1.5em;
858 865 }
859 866
860 867 .handle {cursor: move;}
861 868
862 869 a.close-icon {
863 870 display:block;
864 871 margin-top:3px;
865 872 overflow:hidden;
866 873 width:12px;
867 874 height:12px;
868 875 background-repeat: no-repeat;
869 876 cursor:pointer;
870 877 background-image:url('../images/close.png');
871 878 }
872 879 a.close-icon:hover {background-image:url('../images/close_hl.png');}
873 880
874 881 /***** Gantt chart *****/
875 882 .gantt_hdr {
876 883 position:absolute;
877 884 top:0;
878 885 height:16px;
879 886 border-top: 1px solid #c0c0c0;
880 887 border-bottom: 1px solid #c0c0c0;
881 888 border-right: 1px solid #c0c0c0;
882 889 text-align: center;
883 890 overflow: hidden;
884 891 }
885 892
886 893 .gantt_hdr.nwday {background-color:#f1f1f1;}
887 894
888 895 .gantt_subjects { font-size: 0.8em; }
889 896 .gantt_subjects div { line-height:16px;height:16px;overflow:hidden;white-space:nowrap;text-overflow: ellipsis; }
890 897
891 898 .task {
892 899 position: absolute;
893 900 height:8px;
894 901 font-size:0.8em;
895 902 color:#888;
896 903 padding:0;
897 904 margin:0;
898 905 line-height:16px;
899 906 white-space:nowrap;
900 907 }
901 908
902 909 .task.label {width:100%;}
903 910 .task.label.project, .task.label.version { font-weight: bold; }
904 911
905 912 .task_late { background:#f66 url(../images/task_late.png); border: 1px solid #f66; }
906 913 .task_done { background:#00c600 url(../images/task_done.png); border: 1px solid #00c600; }
907 914 .task_todo { background:#aaa url(../images/task_todo.png); border: 1px solid #aaa; }
908 915
909 916 .task_todo.parent { background: #888; border: 1px solid #888; height: 3px;}
910 917 .task_late.parent, .task_done.parent { height: 3px;}
911 918 .task.parent.marker.starting { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; left: 0px; top: -1px;}
912 919 .task.parent.marker.ending { position: absolute; background: url(../images/task_parent_end.png) no-repeat 0 0; width: 8px; height: 16px; margin-left: -4px; right: 0px; top: -1px;}
913 920
914 921 .version.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
915 922 .version.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
916 923 .version.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
917 924 .version.marker { background-image:url(../images/version_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
918 925
919 926 .project.task_late { background:#f66 url(../images/milestone_late.png); border: 1px solid #f66; height: 2px; margin-top: 3px;}
920 927 .project.task_done { background:#00c600 url(../images/milestone_done.png); border: 1px solid #00c600; height: 2px; margin-top: 3px;}
921 928 .project.task_todo { background:#fff url(../images/milestone_todo.png); border: 1px solid #fff; height: 2px; margin-top: 3px;}
922 929 .project.marker { background-image:url(../images/project_marker.png); background-repeat: no-repeat; border: 0; margin-left: -4px; margin-top: 1px; }
923 930
924 931 .version-behind-schedule a, .issue-behind-schedule a {color: #f66914;}
925 932 .version-overdue a, .issue-overdue a, .project-overdue a {color: #f00;}
926 933
927 934 /***** Icons *****/
928 935 .icon {
929 936 background-position: 0% 50%;
930 937 background-repeat: no-repeat;
931 938 padding-left: 20px;
932 939 padding-top: 2px;
933 940 padding-bottom: 3px;
934 941 }
935 942
936 943 .icon-add { background-image: url(../images/add.png); }
937 944 .icon-edit { background-image: url(../images/edit.png); }
938 945 .icon-copy { background-image: url(../images/copy.png); }
939 946 .icon-duplicate { background-image: url(../images/duplicate.png); }
940 947 .icon-del { background-image: url(../images/delete.png); }
941 948 .icon-move { background-image: url(../images/move.png); }
942 949 .icon-save { background-image: url(../images/save.png); }
943 950 .icon-cancel { background-image: url(../images/cancel.png); }
944 951 .icon-multiple { background-image: url(../images/table_multiple.png); }
945 952 .icon-folder { background-image: url(../images/folder.png); }
946 953 .open .icon-folder { background-image: url(../images/folder_open.png); }
947 954 .icon-package { background-image: url(../images/package.png); }
948 955 .icon-user { background-image: url(../images/user.png); }
949 956 .icon-projects { background-image: url(../images/projects.png); }
950 957 .icon-help { background-image: url(../images/help.png); }
951 958 .icon-attachment { background-image: url(../images/attachment.png); }
952 959 .icon-history { background-image: url(../images/history.png); }
953 960 .icon-time { background-image: url(../images/time.png); }
954 961 .icon-time-add { background-image: url(../images/time_add.png); }
955 962 .icon-stats { background-image: url(../images/stats.png); }
956 963 .icon-warning { background-image: url(../images/warning.png); }
957 964 .icon-fav { background-image: url(../images/fav.png); }
958 965 .icon-fav-off { background-image: url(../images/fav_off.png); }
959 966 .icon-reload { background-image: url(../images/reload.png); }
960 967 .icon-lock { background-image: url(../images/locked.png); }
961 968 .icon-unlock { background-image: url(../images/unlock.png); }
962 969 .icon-checked { background-image: url(../images/true.png); }
963 970 .icon-details { background-image: url(../images/zoom_in.png); }
964 971 .icon-report { background-image: url(../images/report.png); }
965 972 .icon-comment { background-image: url(../images/comment.png); }
966 973 .icon-summary { background-image: url(../images/lightning.png); }
967 974 .icon-server-authentication { background-image: url(../images/server_key.png); }
968 975 .icon-issue { background-image: url(../images/ticket.png); }
969 976 .icon-zoom-in { background-image: url(../images/zoom_in.png); }
970 977 .icon-zoom-out { background-image: url(../images/zoom_out.png); }
971 978 .icon-passwd { background-image: url(../images/textfield_key.png); }
972 979 .icon-test { background-image: url(../images/bullet_go.png); }
973 980
974 981 .icon-file { background-image: url(../images/files/default.png); }
975 982 .icon-file.text-plain { background-image: url(../images/files/text.png); }
976 983 .icon-file.text-x-c { background-image: url(../images/files/c.png); }
977 984 .icon-file.text-x-csharp { background-image: url(../images/files/csharp.png); }
978 985 .icon-file.text-x-java { background-image: url(../images/files/java.png); }
979 986 .icon-file.text-x-javascript { background-image: url(../images/files/js.png); }
980 987 .icon-file.text-x-php { background-image: url(../images/files/php.png); }
981 988 .icon-file.text-x-ruby { background-image: url(../images/files/ruby.png); }
982 989 .icon-file.text-xml { background-image: url(../images/files/xml.png); }
983 990 .icon-file.text-css { background-image: url(../images/files/css.png); }
984 991 .icon-file.text-html { background-image: url(../images/files/html.png); }
985 992 .icon-file.image-gif { background-image: url(../images/files/image.png); }
986 993 .icon-file.image-jpeg { background-image: url(../images/files/image.png); }
987 994 .icon-file.image-png { background-image: url(../images/files/image.png); }
988 995 .icon-file.image-tiff { background-image: url(../images/files/image.png); }
989 996 .icon-file.application-pdf { background-image: url(../images/files/pdf.png); }
990 997 .icon-file.application-zip { background-image: url(../images/files/zip.png); }
991 998 .icon-file.application-x-gzip { background-image: url(../images/files/zip.png); }
992 999
993 1000 img.gravatar {
994 1001 padding: 2px;
995 1002 border: solid 1px #d5d5d5;
996 1003 background: #fff;
997 1004 vertical-align: middle;
998 1005 }
999 1006
1000 1007 div.issue img.gravatar {
1001 1008 float: left;
1002 1009 margin: 0 6px 0 0;
1003 1010 padding: 5px;
1004 1011 }
1005 1012
1006 1013 div.issue table img.gravatar {
1007 1014 height: 14px;
1008 1015 width: 14px;
1009 1016 padding: 2px;
1010 1017 float: left;
1011 1018 margin: 0 0.5em 0 0;
1012 1019 }
1013 1020
1014 1021 h2 img.gravatar {margin: -2px 4px -4px 0;}
1015 1022 h3 img.gravatar {margin: -4px 4px -4px 0;}
1016 1023 h4 img.gravatar {margin: -6px 4px -4px 0;}
1017 1024 td.username img.gravatar {margin: 0 0.5em 0 0; vertical-align: top;}
1018 1025 #activity dt img.gravatar {float: left; margin: 0 1em 1em 0;}
1019 1026 /* Used on 12px Gravatar img tags without the icon background */
1020 1027 .icon-gravatar {float: left; margin-right: 4px;}
1021 1028
1022 1029 #activity dt, .journal {clear: left;}
1023 1030
1024 1031 .journal-link {float: right;}
1025 1032
1026 1033 h2 img { vertical-align:middle; }
1027 1034
1028 1035 .hascontextmenu { cursor: context-menu; }
1029 1036
1030 1037 /************* CodeRay styles *************/
1031 1038 .syntaxhl div {display: inline;}
1032 1039 .syntaxhl .line-numbers {padding: 2px 4px 2px 4px; background-color: #eee; margin:0px 5px 0px 0px;}
1033 1040 .syntaxhl .code pre { overflow: auto }
1034 1041 .syntaxhl .debug { color: white !important; background: blue !important; }
1035 1042
1036 1043 .syntaxhl .annotation { color:#007 }
1037 1044 .syntaxhl .attribute-name { color:#b48 }
1038 1045 .syntaxhl .attribute-value { color:#700 }
1039 1046 .syntaxhl .binary { color:#509 }
1040 1047 .syntaxhl .char .content { color:#D20 }
1041 1048 .syntaxhl .char .delimiter { color:#710 }
1042 1049 .syntaxhl .char { color:#D20 }
1043 1050 .syntaxhl .class { color:#258; font-weight:bold }
1044 1051 .syntaxhl .class-variable { color:#369 }
1045 1052 .syntaxhl .color { color:#0A0 }
1046 1053 .syntaxhl .comment { color:#385 }
1047 1054 .syntaxhl .comment .char { color:#385 }
1048 1055 .syntaxhl .comment .delimiter { color:#385 }
1049 1056 .syntaxhl .complex { color:#A08 }
1050 1057 .syntaxhl .constant { color:#258; font-weight:bold }
1051 1058 .syntaxhl .decorator { color:#B0B }
1052 1059 .syntaxhl .definition { color:#099; font-weight:bold }
1053 1060 .syntaxhl .delimiter { color:black }
1054 1061 .syntaxhl .directive { color:#088; font-weight:bold }
1055 1062 .syntaxhl .doc { color:#970 }
1056 1063 .syntaxhl .doc-string { color:#D42; font-weight:bold }
1057 1064 .syntaxhl .doctype { color:#34b }
1058 1065 .syntaxhl .entity { color:#800; font-weight:bold }
1059 1066 .syntaxhl .error { color:#F00; background-color:#FAA }
1060 1067 .syntaxhl .escape { color:#666 }
1061 1068 .syntaxhl .exception { color:#C00; font-weight:bold }
1062 1069 .syntaxhl .float { color:#06D }
1063 1070 .syntaxhl .function { color:#06B; font-weight:bold }
1064 1071 .syntaxhl .global-variable { color:#d70 }
1065 1072 .syntaxhl .hex { color:#02b }
1066 1073 .syntaxhl .imaginary { color:#f00 }
1067 1074 .syntaxhl .include { color:#B44; font-weight:bold }
1068 1075 .syntaxhl .inline { background-color: hsla(0,0%,0%,0.07); color: black }
1069 1076 .syntaxhl .inline-delimiter { font-weight: bold; color: #666 }
1070 1077 .syntaxhl .instance-variable { color:#33B }
1071 1078 .syntaxhl .integer { color:#06D }
1072 1079 .syntaxhl .key .char { color: #60f }
1073 1080 .syntaxhl .key .delimiter { color: #404 }
1074 1081 .syntaxhl .key { color: #606 }
1075 1082 .syntaxhl .keyword { color:#939; font-weight:bold }
1076 1083 .syntaxhl .label { color:#970; font-weight:bold }
1077 1084 .syntaxhl .local-variable { color:#963 }
1078 1085 .syntaxhl .namespace { color:#707; font-weight:bold }
1079 1086 .syntaxhl .octal { color:#40E }
1080 1087 .syntaxhl .operator { }
1081 1088 .syntaxhl .predefined { color:#369; font-weight:bold }
1082 1089 .syntaxhl .predefined-constant { color:#069 }
1083 1090 .syntaxhl .predefined-type { color:#0a5; font-weight:bold }
1084 1091 .syntaxhl .preprocessor { color:#579 }
1085 1092 .syntaxhl .pseudo-class { color:#00C; font-weight:bold }
1086 1093 .syntaxhl .regexp .content { color:#808 }
1087 1094 .syntaxhl .regexp .delimiter { color:#404 }
1088 1095 .syntaxhl .regexp .modifier { color:#C2C }
1089 1096 .syntaxhl .regexp { background-color:hsla(300,100%,50%,0.06); }
1090 1097 .syntaxhl .reserved { color:#080; font-weight:bold }
1091 1098 .syntaxhl .shell .content { color:#2B2 }
1092 1099 .syntaxhl .shell .delimiter { color:#161 }
1093 1100 .syntaxhl .shell { background-color:hsla(120,100%,50%,0.06); }
1094 1101 .syntaxhl .string .char { color: #46a }
1095 1102 .syntaxhl .string .content { color: #46a }
1096 1103 .syntaxhl .string .delimiter { color: #46a }
1097 1104 .syntaxhl .string .modifier { color: #46a }
1098 1105 .syntaxhl .symbol .content { color:#d33 }
1099 1106 .syntaxhl .symbol .delimiter { color:#d33 }
1100 1107 .syntaxhl .symbol { color:#d33 }
1101 1108 .syntaxhl .tag { color:#070 }
1102 1109 .syntaxhl .type { color:#339; font-weight:bold }
1103 1110 .syntaxhl .value { color: #088; }
1104 1111 .syntaxhl .variable { color:#037 }
1105 1112
1106 1113 .syntaxhl .insert { background: hsla(120,100%,50%,0.12) }
1107 1114 .syntaxhl .delete { background: hsla(0,100%,50%,0.12) }
1108 1115 .syntaxhl .change { color: #bbf; background: #007; }
1109 1116 .syntaxhl .head { color: #f8f; background: #505 }
1110 1117 .syntaxhl .head .filename { color: white; }
1111 1118
1112 1119 .syntaxhl .delete .eyecatcher { background-color: hsla(0,100%,50%,0.2); border: 1px solid hsla(0,100%,45%,0.5); margin: -1px; border-bottom: none; border-top-left-radius: 5px; border-top-right-radius: 5px; }
1113 1120 .syntaxhl .insert .eyecatcher { background-color: hsla(120,100%,50%,0.2); border: 1px solid hsla(120,100%,25%,0.5); margin: -1px; border-top: none; border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; }
1114 1121
1115 1122 .syntaxhl .insert .insert { color: #0c0; background:transparent; font-weight:bold }
1116 1123 .syntaxhl .delete .delete { color: #c00; background:transparent; font-weight:bold }
1117 1124 .syntaxhl .change .change { color: #88f }
1118 1125 .syntaxhl .head .head { color: #f4f }
1119 1126
1120 1127 /***** Media print specific styles *****/
1121 1128 @media print {
1122 1129 #top-menu, #header, #main-menu, #sidebar, #footer, .contextual, .other-formats { display:none; }
1123 1130 #main { background: #fff; }
1124 1131 #content { width: 99%; margin: 0; padding: 0; border: 0; background: #fff; overflow: visible !important;}
1125 1132 #wiki_add_attachment { display:none; }
1126 1133 .hide-when-print { display: none; }
1127 1134 .autoscroll {overflow-x: visible;}
1128 1135 table.list {margin-top:0.5em;}
1129 1136 table.list th, table.list td {border: 1px solid #aaa;}
1130 1137 }
1131 1138
1132 1139 /* Accessibility specific styles */
1133 1140 .hidden-for-sighted {
1134 1141 position:absolute;
1135 1142 left:-10000px;
1136 1143 top:auto;
1137 1144 width:1px;
1138 1145 height:1px;
1139 1146 overflow:hidden;
1140 1147 }
@@ -1,376 +1,385
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2012 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 File.expand_path('../../test_helper', __FILE__)
21 21
22 22 class AttachmentsControllerTest < ActionController::TestCase
23 23 fixtures :users, :projects, :roles, :members, :member_roles,
24 24 :enabled_modules, :issues, :trackers, :attachments,
25 25 :versions, :wiki_pages, :wikis, :documents
26 26
27 27 def setup
28 28 User.current = nil
29 29 set_fixtures_attachments_directory
30 30 end
31 31
32 32 def teardown
33 33 set_tmp_attachments_directory
34 34 end
35 35
36 36 def test_show_diff
37 37 ['inline', 'sbs'].each do |dt|
38 38 # 060719210727_changeset_utf8.diff
39 39 get :show, :id => 14, :type => dt
40 40 assert_response :success
41 41 assert_template 'diff'
42 42 assert_equal 'text/html', @response.content_type
43 43 assert_tag 'th',
44 44 :attributes => {:class => /filename/},
45 45 :content => /issues_controller.rb\t\(rΓ©vision 1484\)/
46 46 assert_tag 'td',
47 47 :attributes => {:class => /line-code/},
48 48 :content => /Demande créée avec succès/
49 49 end
50 50 set_tmp_attachments_directory
51 51 end
52 52
53 53 def test_show_diff_replcace_cannot_convert_content
54 54 with_settings :repositories_encodings => 'UTF-8' do
55 55 ['inline', 'sbs'].each do |dt|
56 56 # 060719210727_changeset_iso8859-1.diff
57 57 get :show, :id => 5, :type => dt
58 58 assert_response :success
59 59 assert_template 'diff'
60 60 assert_equal 'text/html', @response.content_type
61 61 assert_tag 'th',
62 62 :attributes => {:class => "filename"},
63 63 :content => /issues_controller.rb\t\(r\?vision 1484\)/
64 64 assert_tag 'td',
65 65 :attributes => {:class => /line-code/},
66 66 :content => /Demande cr\?\?e avec succ\?s/
67 67 end
68 68 end
69 69 set_tmp_attachments_directory
70 70 end
71 71
72 72 def test_show_diff_latin_1
73 73 with_settings :repositories_encodings => 'UTF-8,ISO-8859-1' do
74 74 ['inline', 'sbs'].each do |dt|
75 75 # 060719210727_changeset_iso8859-1.diff
76 76 get :show, :id => 5, :type => dt
77 77 assert_response :success
78 78 assert_template 'diff'
79 79 assert_equal 'text/html', @response.content_type
80 80 assert_tag 'th',
81 81 :attributes => {:class => "filename"},
82 82 :content => /issues_controller.rb\t\(rΓ©vision 1484\)/
83 83 assert_tag 'td',
84 84 :attributes => {:class => /line-code/},
85 85 :content => /Demande créée avec succès/
86 86 end
87 87 end
88 88 set_tmp_attachments_directory
89 89 end
90 90
91 91 def test_save_diff_type
92 92 user1 = User.find(1)
93 93 user1.pref[:diff_type] = nil
94 94 user1.preference.save
95 95 user = User.find(1)
96 96 assert_nil user.pref[:diff_type]
97 97
98 98 @request.session[:user_id] = 1 # admin
99 99 get :show, :id => 5
100 100 assert_response :success
101 101 assert_template 'diff'
102 102 user.reload
103 103 assert_equal "inline", user.pref[:diff_type]
104 104 get :show, :id => 5, :type => 'sbs'
105 105 assert_response :success
106 106 assert_template 'diff'
107 107 user.reload
108 108 assert_equal "sbs", user.pref[:diff_type]
109 109 end
110 110
111 111 def test_diff_show_filename_in_mercurial_export
112 112 set_tmp_attachments_directory
113 113 a = Attachment.new(:container => Issue.find(1),
114 114 :file => uploaded_test_file("hg-export.diff", "text/plain"),
115 115 :author => User.find(1))
116 116 assert a.save
117 117 assert_equal 'hg-export.diff', a.filename
118 118
119 119 get :show, :id => a.id, :type => 'inline'
120 120 assert_response :success
121 121 assert_template 'diff'
122 122 assert_equal 'text/html', @response.content_type
123 123 assert_select 'th.filename', :text => 'test1.txt'
124 124 end
125 125
126 126 def test_show_text_file
127 127 get :show, :id => 4
128 128 assert_response :success
129 129 assert_template 'file'
130 130 assert_equal 'text/html', @response.content_type
131 131 set_tmp_attachments_directory
132 132 end
133 133
134 134 def test_show_text_file_utf_8
135 135 set_tmp_attachments_directory
136 136 a = Attachment.new(:container => Issue.find(1),
137 137 :file => uploaded_test_file("japanese-utf-8.txt", "text/plain"),
138 138 :author => User.find(1))
139 139 assert a.save
140 140 assert_equal 'japanese-utf-8.txt', a.filename
141 141
142 142 str_japanese = "\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e"
143 143 str_japanese.force_encoding('UTF-8') if str_japanese.respond_to?(:force_encoding)
144 144
145 145 get :show, :id => a.id
146 146 assert_response :success
147 147 assert_template 'file'
148 148 assert_equal 'text/html', @response.content_type
149 149 assert_tag :tag => 'th',
150 150 :content => '1',
151 151 :attributes => { :class => 'line-num' },
152 152 :sibling => { :tag => 'td', :content => /#{str_japanese}/ }
153 153 end
154 154
155 155 def test_show_text_file_replcace_cannot_convert_content
156 156 set_tmp_attachments_directory
157 157 with_settings :repositories_encodings => 'UTF-8' do
158 158 a = Attachment.new(:container => Issue.find(1),
159 159 :file => uploaded_test_file("iso8859-1.txt", "text/plain"),
160 160 :author => User.find(1))
161 161 assert a.save
162 162 assert_equal 'iso8859-1.txt', a.filename
163 163
164 164 get :show, :id => a.id
165 165 assert_response :success
166 166 assert_template 'file'
167 167 assert_equal 'text/html', @response.content_type
168 168 assert_tag :tag => 'th',
169 169 :content => '7',
170 170 :attributes => { :class => 'line-num' },
171 171 :sibling => { :tag => 'td', :content => /Demande cr\?\?e avec succ\?s/ }
172 172 end
173 173 end
174 174
175 175 def test_show_text_file_latin_1
176 176 set_tmp_attachments_directory
177 177 with_settings :repositories_encodings => 'UTF-8,ISO-8859-1' do
178 178 a = Attachment.new(:container => Issue.find(1),
179 179 :file => uploaded_test_file("iso8859-1.txt", "text/plain"),
180 180 :author => User.find(1))
181 181 assert a.save
182 182 assert_equal 'iso8859-1.txt', a.filename
183 183
184 184 get :show, :id => a.id
185 185 assert_response :success
186 186 assert_template 'file'
187 187 assert_equal 'text/html', @response.content_type
188 188 assert_tag :tag => 'th',
189 189 :content => '7',
190 190 :attributes => { :class => 'line-num' },
191 191 :sibling => { :tag => 'td', :content => /Demande créée avec succès/ }
192 192 end
193 193 end
194 194
195 195 def test_show_text_file_should_send_if_too_big
196 196 Setting.file_max_size_displayed = 512
197 197 Attachment.find(4).update_attribute :filesize, 754.kilobyte
198 198
199 199 get :show, :id => 4
200 200 assert_response :success
201 201 assert_equal 'application/x-ruby', @response.content_type
202 202 set_tmp_attachments_directory
203 203 end
204 204
205 205 def test_show_other
206 206 get :show, :id => 6
207 207 assert_response :success
208 208 assert_equal 'application/octet-stream', @response.content_type
209 209 set_tmp_attachments_directory
210 210 end
211 211
212 212 def test_show_file_from_private_issue_without_permission
213 213 get :show, :id => 15
214 214 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2F15'
215 215 set_tmp_attachments_directory
216 216 end
217 217
218 218 def test_show_file_from_private_issue_with_permission
219 219 @request.session[:user_id] = 2
220 220 get :show, :id => 15
221 221 assert_response :success
222 222 assert_tag 'h2', :content => /private.diff/
223 223 set_tmp_attachments_directory
224 224 end
225 225
226 def test_show_file_without_container_should_be_denied
226 def test_show_file_without_container_should_be_allowed_to_author
227 227 set_tmp_attachments_directory
228 228 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
229 229
230 230 @request.session[:user_id] = 2
231 231 get :show, :id => attachment.id
232 assert_response 200
233 end
234
235 def test_show_file_without_container_should_be_allowed_to_author
236 set_tmp_attachments_directory
237 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
238
239 @request.session[:user_id] = 3
240 get :show, :id => attachment.id
232 241 assert_response 403
233 242 end
234 243
235 244 def test_show_invalid_should_respond_with_404
236 245 get :show, :id => 999
237 246 assert_response 404
238 247 end
239 248
240 249 def test_download_text_file
241 250 get :download, :id => 4
242 251 assert_response :success
243 252 assert_equal 'application/x-ruby', @response.content_type
244 253 set_tmp_attachments_directory
245 254 end
246 255
247 256 def test_download_version_file_with_issue_tracking_disabled
248 257 Project.find(1).disable_module! :issue_tracking
249 258 get :download, :id => 9
250 259 assert_response :success
251 260 end
252 261
253 262 def test_download_should_assign_content_type_if_blank
254 263 Attachment.find(4).update_attribute(:content_type, '')
255 264
256 265 get :download, :id => 4
257 266 assert_response :success
258 267 assert_equal 'text/x-ruby', @response.content_type
259 268 set_tmp_attachments_directory
260 269 end
261 270
262 271 def test_download_missing_file
263 272 get :download, :id => 2
264 273 assert_response 404
265 274 set_tmp_attachments_directory
266 275 end
267 276
268 277 def test_download_should_be_denied_without_permission
269 278 get :download, :id => 7
270 279 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdownload%2F7'
271 280 set_tmp_attachments_directory
272 281 end
273 282
274 283 if convert_installed?
275 284 def test_thumbnail
276 285 Attachment.clear_thumbnails
277 286 @request.session[:user_id] = 2
278 287
279 288 get :thumbnail, :id => 16
280 289 assert_response :success
281 290 assert_equal 'image/png', response.content_type
282 291 end
283 292
284 293 def test_thumbnail_should_not_exceed_maximum_size
285 294 Redmine::Thumbnail.expects(:generate).with {|source, target, size| size == 800}
286 295
287 296 @request.session[:user_id] = 2
288 297 get :thumbnail, :id => 16, :size => 2000
289 298 end
290 299
291 300 def test_thumbnail_should_round_size
292 301 Redmine::Thumbnail.expects(:generate).with {|source, target, size| size == 250}
293 302
294 303 @request.session[:user_id] = 2
295 304 get :thumbnail, :id => 16, :size => 260
296 305 end
297 306
298 307 def test_thumbnail_should_return_404_for_non_image_attachment
299 308 @request.session[:user_id] = 2
300 309
301 310 get :thumbnail, :id => 15
302 311 assert_response 404
303 312 end
304 313
305 314 def test_thumbnail_should_return_404_if_thumbnail_generation_failed
306 315 Attachment.any_instance.stubs(:thumbnail).returns(nil)
307 316 @request.session[:user_id] = 2
308 317
309 318 get :thumbnail, :id => 16
310 319 assert_response 404
311 320 end
312 321
313 322 def test_thumbnail_should_be_denied_without_permission
314 323 get :thumbnail, :id => 16
315 324 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fthumbnail%2F16'
316 325 end
317 326 else
318 327 puts '(ImageMagick convert not available)'
319 328 end
320 329
321 330 def test_destroy_issue_attachment
322 331 set_tmp_attachments_directory
323 332 issue = Issue.find(3)
324 333 @request.session[:user_id] = 2
325 334
326 335 assert_difference 'issue.attachments.count', -1 do
327 336 assert_difference 'Journal.count' do
328 337 delete :destroy, :id => 1
329 338 assert_redirected_to '/projects/ecookbook'
330 339 end
331 340 end
332 341 assert_nil Attachment.find_by_id(1)
333 342 j = Journal.first(:order => 'id DESC')
334 343 assert_equal issue, j.journalized
335 344 assert_equal 'attachment', j.details.first.property
336 345 assert_equal '1', j.details.first.prop_key
337 346 assert_equal 'error281.txt', j.details.first.old_value
338 347 assert_equal User.find(2), j.user
339 348 end
340 349
341 350 def test_destroy_wiki_page_attachment
342 351 set_tmp_attachments_directory
343 352 @request.session[:user_id] = 2
344 353 assert_difference 'Attachment.count', -1 do
345 354 delete :destroy, :id => 3
346 355 assert_response 302
347 356 end
348 357 end
349 358
350 359 def test_destroy_project_attachment
351 360 set_tmp_attachments_directory
352 361 @request.session[:user_id] = 2
353 362 assert_difference 'Attachment.count', -1 do
354 363 delete :destroy, :id => 8
355 364 assert_response 302
356 365 end
357 366 end
358 367
359 368 def test_destroy_version_attachment
360 369 set_tmp_attachments_directory
361 370 @request.session[:user_id] = 2
362 371 assert_difference 'Attachment.count', -1 do
363 372 delete :destroy, :id => 9
364 373 assert_response 302
365 374 end
366 375 end
367 376
368 377 def test_destroy_without_permission
369 378 set_tmp_attachments_directory
370 379 assert_no_difference 'Attachment.count' do
371 380 delete :destroy, :id => 3
372 381 end
373 382 assert_response 302
374 383 assert Attachment.find_by_id(3)
375 384 end
376 385 end
@@ -1,3846 +1,3845
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 IssuesControllerTest < ActionController::TestCase
21 21 fixtures :projects,
22 22 :users,
23 23 :roles,
24 24 :members,
25 25 :member_roles,
26 26 :issues,
27 27 :issue_statuses,
28 28 :versions,
29 29 :trackers,
30 30 :projects_trackers,
31 31 :issue_categories,
32 32 :enabled_modules,
33 33 :enumerations,
34 34 :attachments,
35 35 :workflows,
36 36 :custom_fields,
37 37 :custom_values,
38 38 :custom_fields_projects,
39 39 :custom_fields_trackers,
40 40 :time_entries,
41 41 :journals,
42 42 :journal_details,
43 43 :queries,
44 44 :repositories,
45 45 :changesets
46 46
47 47 include Redmine::I18n
48 48
49 49 def setup
50 50 User.current = nil
51 51 end
52 52
53 53 def test_index
54 54 with_settings :default_language => "en" do
55 55 get :index
56 56 assert_response :success
57 57 assert_template 'index'
58 58 assert_not_nil assigns(:issues)
59 59 assert_nil assigns(:project)
60 60
61 61 # links to visible issues
62 62 assert_select 'a[href=/issues/1]', :text => /Can&#x27;t print recipes/
63 63 assert_select 'a[href=/issues/5]', :text => /Subproject issue/
64 64 # private projects hidden
65 65 assert_select 'a[href=/issues/6]', 0
66 66 assert_select 'a[href=/issues/4]', 0
67 67 # project column
68 68 assert_select 'th', :text => /Project/
69 69 end
70 70 end
71 71
72 72 def test_index_should_not_list_issues_when_module_disabled
73 73 EnabledModule.delete_all("name = 'issue_tracking' AND project_id = 1")
74 74 get :index
75 75 assert_response :success
76 76 assert_template 'index'
77 77 assert_not_nil assigns(:issues)
78 78 assert_nil assigns(:project)
79 79
80 80 assert_select 'a[href=/issues/1]', 0
81 81 assert_select 'a[href=/issues/5]', :text => /Subproject issue/
82 82 end
83 83
84 84 def test_index_should_list_visible_issues_only
85 85 get :index, :per_page => 100
86 86 assert_response :success
87 87 assert_not_nil assigns(:issues)
88 88 assert_nil assigns(:issues).detect {|issue| !issue.visible?}
89 89 end
90 90
91 91 def test_index_with_project
92 92 Setting.display_subprojects_issues = 0
93 93 get :index, :project_id => 1
94 94 assert_response :success
95 95 assert_template 'index'
96 96 assert_not_nil assigns(:issues)
97 97
98 98 assert_select 'a[href=/issues/1]', :text => /Can&#x27;t print recipes/
99 99 assert_select 'a[href=/issues/5]', 0
100 100 end
101 101
102 102 def test_index_with_project_and_subprojects
103 103 Setting.display_subprojects_issues = 1
104 104 get :index, :project_id => 1
105 105 assert_response :success
106 106 assert_template 'index'
107 107 assert_not_nil assigns(:issues)
108 108
109 109 assert_select 'a[href=/issues/1]', :text => /Can&#x27;t print recipes/
110 110 assert_select 'a[href=/issues/5]', :text => /Subproject issue/
111 111 assert_select 'a[href=/issues/6]', 0
112 112 end
113 113
114 114 def test_index_with_project_and_subprojects_should_show_private_subprojects_with_permission
115 115 @request.session[:user_id] = 2
116 116 Setting.display_subprojects_issues = 1
117 117 get :index, :project_id => 1
118 118 assert_response :success
119 119 assert_template 'index'
120 120 assert_not_nil assigns(:issues)
121 121
122 122 assert_select 'a[href=/issues/1]', :text => /Can&#x27;t print recipes/
123 123 assert_select 'a[href=/issues/5]', :text => /Subproject issue/
124 124 assert_select 'a[href=/issues/6]', :text => /Issue of a private subproject/
125 125 end
126 126
127 127 def test_index_with_project_and_default_filter
128 128 get :index, :project_id => 1, :set_filter => 1
129 129 assert_response :success
130 130 assert_template 'index'
131 131 assert_not_nil assigns(:issues)
132 132
133 133 query = assigns(:query)
134 134 assert_not_nil query
135 135 # default filter
136 136 assert_equal({'status_id' => {:operator => 'o', :values => ['']}}, query.filters)
137 137 end
138 138
139 139 def test_index_with_project_and_filter
140 140 get :index, :project_id => 1, :set_filter => 1,
141 141 :f => ['tracker_id'],
142 142 :op => {'tracker_id' => '='},
143 143 :v => {'tracker_id' => ['1']}
144 144 assert_response :success
145 145 assert_template 'index'
146 146 assert_not_nil assigns(:issues)
147 147
148 148 query = assigns(:query)
149 149 assert_not_nil query
150 150 assert_equal({'tracker_id' => {:operator => '=', :values => ['1']}}, query.filters)
151 151 end
152 152
153 153 def test_index_with_short_filters
154 154 to_test = {
155 155 'status_id' => {
156 156 'o' => { :op => 'o', :values => [''] },
157 157 'c' => { :op => 'c', :values => [''] },
158 158 '7' => { :op => '=', :values => ['7'] },
159 159 '7|3|4' => { :op => '=', :values => ['7', '3', '4'] },
160 160 '=7' => { :op => '=', :values => ['7'] },
161 161 '!3' => { :op => '!', :values => ['3'] },
162 162 '!7|3|4' => { :op => '!', :values => ['7', '3', '4'] }},
163 163 'subject' => {
164 164 'This is a subject' => { :op => '=', :values => ['This is a subject'] },
165 165 'o' => { :op => '=', :values => ['o'] },
166 166 '~This is part of a subject' => { :op => '~', :values => ['This is part of a subject'] },
167 167 '!~This is part of a subject' => { :op => '!~', :values => ['This is part of a subject'] }},
168 168 'tracker_id' => {
169 169 '3' => { :op => '=', :values => ['3'] },
170 170 '=3' => { :op => '=', :values => ['3'] }},
171 171 'start_date' => {
172 172 '2011-10-12' => { :op => '=', :values => ['2011-10-12'] },
173 173 '=2011-10-12' => { :op => '=', :values => ['2011-10-12'] },
174 174 '>=2011-10-12' => { :op => '>=', :values => ['2011-10-12'] },
175 175 '<=2011-10-12' => { :op => '<=', :values => ['2011-10-12'] },
176 176 '><2011-10-01|2011-10-30' => { :op => '><', :values => ['2011-10-01', '2011-10-30'] },
177 177 '<t+2' => { :op => '<t+', :values => ['2'] },
178 178 '>t+2' => { :op => '>t+', :values => ['2'] },
179 179 't+2' => { :op => 't+', :values => ['2'] },
180 180 't' => { :op => 't', :values => [''] },
181 181 'w' => { :op => 'w', :values => [''] },
182 182 '>t-2' => { :op => '>t-', :values => ['2'] },
183 183 '<t-2' => { :op => '<t-', :values => ['2'] },
184 184 't-2' => { :op => 't-', :values => ['2'] }},
185 185 'created_on' => {
186 186 '>=2011-10-12' => { :op => '>=', :values => ['2011-10-12'] },
187 187 '<t-2' => { :op => '<t-', :values => ['2'] },
188 188 '>t-2' => { :op => '>t-', :values => ['2'] },
189 189 't-2' => { :op => 't-', :values => ['2'] }},
190 190 'cf_1' => {
191 191 'c' => { :op => '=', :values => ['c'] },
192 192 '!c' => { :op => '!', :values => ['c'] },
193 193 '!*' => { :op => '!*', :values => [''] },
194 194 '*' => { :op => '*', :values => [''] }},
195 195 'estimated_hours' => {
196 196 '=13.4' => { :op => '=', :values => ['13.4'] },
197 197 '>=45' => { :op => '>=', :values => ['45'] },
198 198 '<=125' => { :op => '<=', :values => ['125'] },
199 199 '><10.5|20.5' => { :op => '><', :values => ['10.5', '20.5'] },
200 200 '!*' => { :op => '!*', :values => [''] },
201 201 '*' => { :op => '*', :values => [''] }}
202 202 }
203 203
204 204 default_filter = { 'status_id' => {:operator => 'o', :values => [''] }}
205 205
206 206 to_test.each do |field, expression_and_expected|
207 207 expression_and_expected.each do |filter_expression, expected|
208 208
209 209 get :index, :set_filter => 1, field => filter_expression
210 210
211 211 assert_response :success
212 212 assert_template 'index'
213 213 assert_not_nil assigns(:issues)
214 214
215 215 query = assigns(:query)
216 216 assert_not_nil query
217 217 assert query.has_filter?(field)
218 218 assert_equal(default_filter.merge({field => {:operator => expected[:op], :values => expected[:values]}}), query.filters)
219 219 end
220 220 end
221 221 end
222 222
223 223 def test_index_with_project_and_empty_filters
224 224 get :index, :project_id => 1, :set_filter => 1, :fields => ['']
225 225 assert_response :success
226 226 assert_template 'index'
227 227 assert_not_nil assigns(:issues)
228 228
229 229 query = assigns(:query)
230 230 assert_not_nil query
231 231 # no filter
232 232 assert_equal({}, query.filters)
233 233 end
234 234
235 235 def test_index_with_project_custom_field_filter
236 236 field = ProjectCustomField.create!(:name => 'Client', :is_filter => true, :field_format => 'string')
237 237 CustomValue.create!(:custom_field => field, :customized => Project.find(3), :value => 'Foo')
238 238 CustomValue.create!(:custom_field => field, :customized => Project.find(5), :value => 'Foo')
239 239 filter_name = "project.cf_#{field.id}"
240 240 @request.session[:user_id] = 1
241 241
242 242 get :index, :set_filter => 1,
243 243 :f => [filter_name],
244 244 :op => {filter_name => '='},
245 245 :v => {filter_name => ['Foo']}
246 246 assert_response :success
247 247 assert_template 'index'
248 248 assert_equal [3, 5], assigns(:issues).map(&:project_id).uniq.sort
249 249 end
250 250
251 251 def test_index_with_query
252 252 get :index, :project_id => 1, :query_id => 5
253 253 assert_response :success
254 254 assert_template 'index'
255 255 assert_not_nil assigns(:issues)
256 256 assert_nil assigns(:issue_count_by_group)
257 257 end
258 258
259 259 def test_index_with_query_grouped_by_tracker
260 260 get :index, :project_id => 1, :query_id => 6
261 261 assert_response :success
262 262 assert_template 'index'
263 263 assert_not_nil assigns(:issues)
264 264 assert_not_nil assigns(:issue_count_by_group)
265 265 end
266 266
267 267 def test_index_with_query_grouped_by_list_custom_field
268 268 get :index, :project_id => 1, :query_id => 9
269 269 assert_response :success
270 270 assert_template 'index'
271 271 assert_not_nil assigns(:issues)
272 272 assert_not_nil assigns(:issue_count_by_group)
273 273 end
274 274
275 275 def test_index_with_query_grouped_by_user_custom_field
276 276 cf = IssueCustomField.create!(:name => 'User', :is_for_all => true, :tracker_ids => [1,2,3], :field_format => 'user')
277 277 CustomValue.create!(:custom_field => cf, :customized => Issue.find(1), :value => '2')
278 278 CustomValue.create!(:custom_field => cf, :customized => Issue.find(2), :value => '3')
279 279 CustomValue.create!(:custom_field => cf, :customized => Issue.find(3), :value => '3')
280 280 CustomValue.create!(:custom_field => cf, :customized => Issue.find(5), :value => '')
281 281
282 282 get :index, :project_id => 1, :set_filter => 1, :group_by => "cf_#{cf.id}"
283 283 assert_response :success
284 284
285 285 assert_select 'tr.group', 3
286 286 assert_select 'tr.group' do
287 287 assert_select 'a', :text => 'John Smith'
288 288 assert_select 'span.count', :text => '1'
289 289 end
290 290 assert_select 'tr.group' do
291 291 assert_select 'a', :text => 'Dave Lopper'
292 292 assert_select 'span.count', :text => '2'
293 293 end
294 294 end
295 295
296 296 def test_index_with_query_grouped_by_tracker
297 297 3.times {|i| Issue.generate!(:tracker_id => (i + 1))}
298 298
299 299 get :index, :set_filter => 1, :group_by => 'tracker', :sort => 'id:desc'
300 300 assert_response :success
301 301
302 302 trackers = assigns(:issues).map(&:tracker).uniq
303 303 assert_equal [1, 2, 3], trackers.map(&:id)
304 304 end
305 305
306 306 def test_index_with_query_grouped_by_tracker_in_reverse_order
307 307 3.times {|i| Issue.generate!(:tracker_id => (i + 1))}
308 308
309 309 get :index, :set_filter => 1, :group_by => 'tracker', :sort => 'id:desc,tracker:desc'
310 310 assert_response :success
311 311
312 312 trackers = assigns(:issues).map(&:tracker).uniq
313 313 assert_equal [3, 2, 1], trackers.map(&:id)
314 314 end
315 315
316 316 def test_index_with_query_id_and_project_id_should_set_session_query
317 317 get :index, :project_id => 1, :query_id => 4
318 318 assert_response :success
319 319 assert_kind_of Hash, session[:query]
320 320 assert_equal 4, session[:query][:id]
321 321 assert_equal 1, session[:query][:project_id]
322 322 end
323 323
324 324 def test_index_with_invalid_query_id_should_respond_404
325 325 get :index, :project_id => 1, :query_id => 999
326 326 assert_response 404
327 327 end
328 328
329 329 def test_index_with_cross_project_query_in_session_should_show_project_issues
330 330 q = IssueQuery.create!(:name => "test", :user_id => 2, :is_public => false, :project => nil)
331 331 @request.session[:query] = {:id => q.id, :project_id => 1}
332 332
333 333 with_settings :display_subprojects_issues => '0' do
334 334 get :index, :project_id => 1
335 335 end
336 336 assert_response :success
337 337 assert_not_nil assigns(:query)
338 338 assert_equal q.id, assigns(:query).id
339 339 assert_equal 1, assigns(:query).project_id
340 340 assert_equal [1], assigns(:issues).map(&:project_id).uniq
341 341 end
342 342
343 343 def test_private_query_should_not_be_available_to_other_users
344 344 q = IssueQuery.create!(:name => "private", :user => User.find(2), :is_public => false, :project => nil)
345 345 @request.session[:user_id] = 3
346 346
347 347 get :index, :query_id => q.id
348 348 assert_response 403
349 349 end
350 350
351 351 def test_private_query_should_be_available_to_its_user
352 352 q = IssueQuery.create!(:name => "private", :user => User.find(2), :is_public => false, :project => nil)
353 353 @request.session[:user_id] = 2
354 354
355 355 get :index, :query_id => q.id
356 356 assert_response :success
357 357 end
358 358
359 359 def test_public_query_should_be_available_to_other_users
360 360 q = IssueQuery.create!(:name => "private", :user => User.find(2), :is_public => true, :project => nil)
361 361 @request.session[:user_id] = 3
362 362
363 363 get :index, :query_id => q.id
364 364 assert_response :success
365 365 end
366 366
367 367 def test_index_should_omit_page_param_in_export_links
368 368 get :index, :page => 2
369 369 assert_response :success
370 370 assert_select 'a.atom[href=/issues.atom]'
371 371 assert_select 'a.csv[href=/issues.csv]'
372 372 assert_select 'a.pdf[href=/issues.pdf]'
373 373 assert_select 'form#csv-export-form[action=/issues.csv]'
374 374 end
375 375
376 376 def test_index_csv
377 377 get :index, :format => 'csv'
378 378 assert_response :success
379 379 assert_not_nil assigns(:issues)
380 380 assert_equal 'text/csv; header=present', @response.content_type
381 381 assert @response.body.starts_with?("#,")
382 382 lines = @response.body.chomp.split("\n")
383 383 assert_equal assigns(:query).columns.size + 1, lines[0].split(',').size
384 384 end
385 385
386 386 def test_index_csv_with_project
387 387 get :index, :project_id => 1, :format => 'csv'
388 388 assert_response :success
389 389 assert_not_nil assigns(:issues)
390 390 assert_equal 'text/csv; header=present', @response.content_type
391 391 end
392 392
393 393 def test_index_csv_with_description
394 394 get :index, :format => 'csv', :description => '1'
395 395 assert_response :success
396 396 assert_not_nil assigns(:issues)
397 397 assert_equal 'text/csv; header=present', @response.content_type
398 398 assert @response.body.starts_with?("#,")
399 399 lines = @response.body.chomp.split("\n")
400 400 assert_equal assigns(:query).columns.size + 2, lines[0].split(',').size
401 401 end
402 402
403 403 def test_index_csv_with_spent_time_column
404 404 issue = Issue.create!(:project_id => 1, :tracker_id => 1, :subject => 'test_index_csv_with_spent_time_column', :author_id => 2)
405 405 TimeEntry.create!(:project => issue.project, :issue => issue, :hours => 7.33, :user => User.find(2), :spent_on => Date.today)
406 406
407 407 get :index, :format => 'csv', :set_filter => '1', :c => %w(subject spent_hours)
408 408 assert_response :success
409 409 assert_equal 'text/csv; header=present', @response.content_type
410 410 lines = @response.body.chomp.split("\n")
411 411 assert_include "#{issue.id},#{issue.subject},7.33", lines
412 412 end
413 413
414 414 def test_index_csv_with_all_columns
415 415 get :index, :format => 'csv', :columns => 'all'
416 416 assert_response :success
417 417 assert_not_nil assigns(:issues)
418 418 assert_equal 'text/csv; header=present', @response.content_type
419 419 assert @response.body.starts_with?("#,")
420 420 lines = @response.body.chomp.split("\n")
421 421 assert_equal assigns(:query).available_inline_columns.size + 1, lines[0].split(',').size
422 422 end
423 423
424 424 def test_index_csv_with_multi_column_field
425 425 CustomField.find(1).update_attribute :multiple, true
426 426 issue = Issue.find(1)
427 427 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
428 428 issue.save!
429 429
430 430 get :index, :format => 'csv', :columns => 'all'
431 431 assert_response :success
432 432 lines = @response.body.chomp.split("\n")
433 433 assert lines.detect {|line| line.include?('"MySQL, Oracle"')}
434 434 end
435 435
436 436 def test_index_csv_big_5
437 437 with_settings :default_language => "zh-TW" do
438 438 str_utf8 = "\xe4\xb8\x80\xe6\x9c\x88"
439 439 str_big5 = "\xa4@\xa4\xeb"
440 440 if str_utf8.respond_to?(:force_encoding)
441 441 str_utf8.force_encoding('UTF-8')
442 442 str_big5.force_encoding('Big5')
443 443 end
444 444 issue = Issue.generate!(:subject => str_utf8)
445 445
446 446 get :index, :project_id => 1,
447 447 :f => ['subject'],
448 448 :op => '=', :values => [str_utf8],
449 449 :format => 'csv'
450 450 assert_equal 'text/csv; header=present', @response.content_type
451 451 lines = @response.body.chomp.split("\n")
452 452 s1 = "\xaa\xac\xbaA"
453 453 if str_utf8.respond_to?(:force_encoding)
454 454 s1.force_encoding('Big5')
455 455 end
456 456 assert lines[0].include?(s1)
457 457 assert lines[1].include?(str_big5)
458 458 end
459 459 end
460 460
461 461 def test_index_csv_cannot_convert_should_be_replaced_big_5
462 462 with_settings :default_language => "zh-TW" do
463 463 str_utf8 = "\xe4\xbb\xa5\xe5\x86\x85"
464 464 if str_utf8.respond_to?(:force_encoding)
465 465 str_utf8.force_encoding('UTF-8')
466 466 end
467 467 issue = Issue.generate!(:subject => str_utf8)
468 468
469 469 get :index, :project_id => 1,
470 470 :f => ['subject'],
471 471 :op => '=', :values => [str_utf8],
472 472 :c => ['status', 'subject'],
473 473 :format => 'csv',
474 474 :set_filter => 1
475 475 assert_equal 'text/csv; header=present', @response.content_type
476 476 lines = @response.body.chomp.split("\n")
477 477 s1 = "\xaa\xac\xbaA" # status
478 478 if str_utf8.respond_to?(:force_encoding)
479 479 s1.force_encoding('Big5')
480 480 end
481 481 assert lines[0].include?(s1)
482 482 s2 = lines[1].split(",")[2]
483 483 if s1.respond_to?(:force_encoding)
484 484 s3 = "\xa5H?" # subject
485 485 s3.force_encoding('Big5')
486 486 assert_equal s3, s2
487 487 elsif RUBY_PLATFORM == 'java'
488 488 assert_equal "??", s2
489 489 else
490 490 assert_equal "\xa5H???", s2
491 491 end
492 492 end
493 493 end
494 494
495 495 def test_index_csv_tw
496 496 with_settings :default_language => "zh-TW" do
497 497 str1 = "test_index_csv_tw"
498 498 issue = Issue.generate!(:subject => str1, :estimated_hours => '1234.5')
499 499
500 500 get :index, :project_id => 1,
501 501 :f => ['subject'],
502 502 :op => '=', :values => [str1],
503 503 :c => ['estimated_hours', 'subject'],
504 504 :format => 'csv',
505 505 :set_filter => 1
506 506 assert_equal 'text/csv; header=present', @response.content_type
507 507 lines = @response.body.chomp.split("\n")
508 508 assert_equal "#{issue.id},1234.50,#{str1}", lines[1]
509 509 end
510 510 end
511 511
512 512 def test_index_csv_fr
513 513 with_settings :default_language => "fr" do
514 514 str1 = "test_index_csv_fr"
515 515 issue = Issue.generate!(:subject => str1, :estimated_hours => '1234.5')
516 516
517 517 get :index, :project_id => 1,
518 518 :f => ['subject'],
519 519 :op => '=', :values => [str1],
520 520 :c => ['estimated_hours', 'subject'],
521 521 :format => 'csv',
522 522 :set_filter => 1
523 523 assert_equal 'text/csv; header=present', @response.content_type
524 524 lines = @response.body.chomp.split("\n")
525 525 assert_equal "#{issue.id};1234,50;#{str1}", lines[1]
526 526 end
527 527 end
528 528
529 529 def test_index_pdf
530 530 ["en", "zh", "zh-TW", "ja", "ko"].each do |lang|
531 531 with_settings :default_language => lang do
532 532
533 533 get :index
534 534 assert_response :success
535 535 assert_template 'index'
536 536
537 537 if lang == "ja"
538 538 if RUBY_PLATFORM != 'java'
539 539 assert_equal "CP932", l(:general_pdf_encoding)
540 540 end
541 541 if RUBY_PLATFORM == 'java' && l(:general_pdf_encoding) == "CP932"
542 542 next
543 543 end
544 544 end
545 545
546 546 get :index, :format => 'pdf'
547 547 assert_response :success
548 548 assert_not_nil assigns(:issues)
549 549 assert_equal 'application/pdf', @response.content_type
550 550
551 551 get :index, :project_id => 1, :format => 'pdf'
552 552 assert_response :success
553 553 assert_not_nil assigns(:issues)
554 554 assert_equal 'application/pdf', @response.content_type
555 555
556 556 get :index, :project_id => 1, :query_id => 6, :format => 'pdf'
557 557 assert_response :success
558 558 assert_not_nil assigns(:issues)
559 559 assert_equal 'application/pdf', @response.content_type
560 560 end
561 561 end
562 562 end
563 563
564 564 def test_index_pdf_with_query_grouped_by_list_custom_field
565 565 get :index, :project_id => 1, :query_id => 9, :format => 'pdf'
566 566 assert_response :success
567 567 assert_not_nil assigns(:issues)
568 568 assert_not_nil assigns(:issue_count_by_group)
569 569 assert_equal 'application/pdf', @response.content_type
570 570 end
571 571
572 572 def test_index_atom
573 573 get :index, :project_id => 'ecookbook', :format => 'atom'
574 574 assert_response :success
575 575 assert_template 'common/feed'
576 576 assert_equal 'application/atom+xml', response.content_type
577 577
578 578 assert_select 'feed' do
579 579 assert_select 'link[rel=self][href=?]', 'http://test.host/projects/ecookbook/issues.atom'
580 580 assert_select 'link[rel=alternate][href=?]', 'http://test.host/projects/ecookbook/issues'
581 581 assert_select 'entry link[href=?]', 'http://test.host/issues/1'
582 582 end
583 583 end
584 584
585 585 def test_index_sort
586 586 get :index, :sort => 'tracker,id:desc'
587 587 assert_response :success
588 588
589 589 sort_params = @request.session['issues_index_sort']
590 590 assert sort_params.is_a?(String)
591 591 assert_equal 'tracker,id:desc', sort_params
592 592
593 593 issues = assigns(:issues)
594 594 assert_not_nil issues
595 595 assert !issues.empty?
596 596 assert_equal issues.sort {|a,b| a.tracker == b.tracker ? b.id <=> a.id : a.tracker <=> b.tracker }.collect(&:id), issues.collect(&:id)
597 597 end
598 598
599 599 def test_index_sort_by_field_not_included_in_columns
600 600 Setting.issue_list_default_columns = %w(subject author)
601 601 get :index, :sort => 'tracker'
602 602 end
603 603
604 604 def test_index_sort_by_assigned_to
605 605 get :index, :sort => 'assigned_to'
606 606 assert_response :success
607 607 assignees = assigns(:issues).collect(&:assigned_to).compact
608 608 assert_equal assignees.sort, assignees
609 609 end
610 610
611 611 def test_index_sort_by_assigned_to_desc
612 612 get :index, :sort => 'assigned_to:desc'
613 613 assert_response :success
614 614 assignees = assigns(:issues).collect(&:assigned_to).compact
615 615 assert_equal assignees.sort.reverse, assignees
616 616 end
617 617
618 618 def test_index_group_by_assigned_to
619 619 get :index, :group_by => 'assigned_to', :sort => 'priority'
620 620 assert_response :success
621 621 end
622 622
623 623 def test_index_sort_by_author
624 624 get :index, :sort => 'author'
625 625 assert_response :success
626 626 authors = assigns(:issues).collect(&:author)
627 627 assert_equal authors.sort, authors
628 628 end
629 629
630 630 def test_index_sort_by_author_desc
631 631 get :index, :sort => 'author:desc'
632 632 assert_response :success
633 633 authors = assigns(:issues).collect(&:author)
634 634 assert_equal authors.sort.reverse, authors
635 635 end
636 636
637 637 def test_index_group_by_author
638 638 get :index, :group_by => 'author', :sort => 'priority'
639 639 assert_response :success
640 640 end
641 641
642 642 def test_index_sort_by_spent_hours
643 643 get :index, :sort => 'spent_hours:desc'
644 644 assert_response :success
645 645 hours = assigns(:issues).collect(&:spent_hours)
646 646 assert_equal hours.sort.reverse, hours
647 647 end
648 648
649 649 def test_index_sort_by_user_custom_field
650 650 cf = IssueCustomField.create!(:name => 'User', :is_for_all => true, :tracker_ids => [1,2,3], :field_format => 'user')
651 651 CustomValue.create!(:custom_field => cf, :customized => Issue.find(1), :value => '2')
652 652 CustomValue.create!(:custom_field => cf, :customized => Issue.find(2), :value => '3')
653 653 CustomValue.create!(:custom_field => cf, :customized => Issue.find(3), :value => '3')
654 654 CustomValue.create!(:custom_field => cf, :customized => Issue.find(5), :value => '')
655 655
656 656 get :index, :project_id => 1, :set_filter => 1, :sort => "cf_#{cf.id},id"
657 657 assert_response :success
658 658
659 659 assert_equal [2, 3, 1], assigns(:issues).select {|issue| issue.custom_field_value(cf).present?}.map(&:id)
660 660 end
661 661
662 662 def test_index_with_columns
663 663 columns = ['tracker', 'subject', 'assigned_to']
664 664 get :index, :set_filter => 1, :c => columns
665 665 assert_response :success
666 666
667 667 # query should use specified columns
668 668 query = assigns(:query)
669 669 assert_kind_of IssueQuery, query
670 670 assert_equal columns, query.column_names.map(&:to_s)
671 671
672 672 # columns should be stored in session
673 673 assert_kind_of Hash, session[:query]
674 674 assert_kind_of Array, session[:query][:column_names]
675 675 assert_equal columns, session[:query][:column_names].map(&:to_s)
676 676
677 677 # ensure only these columns are kept in the selected columns list
678 678 assert_select 'select#selected_columns option' do
679 679 assert_select 'option', 3
680 680 assert_select 'option[value=tracker]'
681 681 assert_select 'option[value=project]', 0
682 682 end
683 683 end
684 684
685 685 def test_index_without_project_should_implicitly_add_project_column_to_default_columns
686 686 Setting.issue_list_default_columns = ['tracker', 'subject', 'assigned_to']
687 687 get :index, :set_filter => 1
688 688
689 689 # query should use specified columns
690 690 query = assigns(:query)
691 691 assert_kind_of IssueQuery, query
692 692 assert_equal [:project, :tracker, :subject, :assigned_to], query.columns.map(&:name)
693 693 end
694 694
695 695 def test_index_without_project_and_explicit_default_columns_should_not_add_project_column
696 696 Setting.issue_list_default_columns = ['tracker', 'subject', 'assigned_to']
697 697 columns = ['tracker', 'subject', 'assigned_to']
698 698 get :index, :set_filter => 1, :c => columns
699 699
700 700 # query should use specified columns
701 701 query = assigns(:query)
702 702 assert_kind_of IssueQuery, query
703 703 assert_equal columns.map(&:to_sym), query.columns.map(&:name)
704 704 end
705 705
706 706 def test_index_with_custom_field_column
707 707 columns = %w(tracker subject cf_2)
708 708 get :index, :set_filter => 1, :c => columns
709 709 assert_response :success
710 710
711 711 # query should use specified columns
712 712 query = assigns(:query)
713 713 assert_kind_of IssueQuery, query
714 714 assert_equal columns, query.column_names.map(&:to_s)
715 715
716 716 assert_select 'table.issues td.cf_2.string'
717 717 end
718 718
719 719 def test_index_with_multi_custom_field_column
720 720 field = CustomField.find(1)
721 721 field.update_attribute :multiple, true
722 722 issue = Issue.find(1)
723 723 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
724 724 issue.save!
725 725
726 726 get :index, :set_filter => 1, :c => %w(tracker subject cf_1)
727 727 assert_response :success
728 728
729 729 assert_select 'table.issues td.cf_1', :text => 'MySQL, Oracle'
730 730 end
731 731
732 732 def test_index_with_multi_user_custom_field_column
733 733 field = IssueCustomField.create!(:name => 'Multi user', :field_format => 'user', :multiple => true,
734 734 :tracker_ids => [1], :is_for_all => true)
735 735 issue = Issue.find(1)
736 736 issue.custom_field_values = {field.id => ['2', '3']}
737 737 issue.save!
738 738
739 739 get :index, :set_filter => 1, :c => ['tracker', 'subject', "cf_#{field.id}"]
740 740 assert_response :success
741 741
742 742 assert_select "table.issues td.cf_#{field.id}" do
743 743 assert_select 'a', 2
744 744 assert_select 'a[href=?]', '/users/2', :text => 'John Smith'
745 745 assert_select 'a[href=?]', '/users/3', :text => 'Dave Lopper'
746 746 end
747 747 end
748 748
749 749 def test_index_with_date_column
750 750 with_settings :date_format => '%d/%m/%Y' do
751 751 Issue.find(1).update_attribute :start_date, '1987-08-24'
752 752
753 753 get :index, :set_filter => 1, :c => %w(start_date)
754 754
755 755 assert_select "table.issues td.start_date", :text => '24/08/1987'
756 756 end
757 757 end
758 758
759 759 def test_index_with_done_ratio_column
760 760 Issue.find(1).update_attribute :done_ratio, 40
761 761
762 762 get :index, :set_filter => 1, :c => %w(done_ratio)
763 763
764 764 assert_select 'table.issues td.done_ratio' do
765 765 assert_select 'table.progress' do
766 766 assert_select 'td.closed[style=?]', 'width: 40%;'
767 767 end
768 768 end
769 769 end
770 770
771 771 def test_index_with_spent_hours_column
772 772 get :index, :set_filter => 1, :c => %w(subject spent_hours)
773 773
774 774 assert_select 'table.issues tr#issue-3 td.spent_hours', :text => '1.00'
775 775 end
776 776
777 777 def test_index_should_not_show_spent_hours_column_without_permission
778 778 Role.anonymous.remove_permission! :view_time_entries
779 779 get :index, :set_filter => 1, :c => %w(subject spent_hours)
780 780
781 781 assert_select 'td.spent_hours', 0
782 782 end
783 783
784 784 def test_index_with_fixed_version_column
785 785 get :index, :set_filter => 1, :c => %w(fixed_version)
786 786
787 787 assert_select 'table.issues td.fixed_version' do
788 788 assert_select 'a[href=?]', '/versions/2', :text => '1.0'
789 789 end
790 790 end
791 791
792 792 def test_index_with_relations_column
793 793 IssueRelation.delete_all
794 794 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(1), :issue_to => Issue.find(7))
795 795 IssueRelation.create!(:relation_type => "relates", :issue_from => Issue.find(8), :issue_to => Issue.find(1))
796 796 IssueRelation.create!(:relation_type => "blocks", :issue_from => Issue.find(1), :issue_to => Issue.find(11))
797 797 IssueRelation.create!(:relation_type => "blocks", :issue_from => Issue.find(12), :issue_to => Issue.find(2))
798 798
799 799 get :index, :set_filter => 1, :c => %w(subject relations)
800 800 assert_response :success
801 801 assert_select "tr#issue-1 td.relations" do
802 802 assert_select "span", 3
803 803 assert_select "span", :text => "Related to #7"
804 804 assert_select "span", :text => "Related to #8"
805 805 assert_select "span", :text => "Blocks #11"
806 806 end
807 807 assert_select "tr#issue-2 td.relations" do
808 808 assert_select "span", 1
809 809 assert_select "span", :text => "Blocked by #12"
810 810 end
811 811 assert_select "tr#issue-3 td.relations" do
812 812 assert_select "span", 0
813 813 end
814 814
815 815 get :index, :set_filter => 1, :c => %w(relations), :format => 'csv'
816 816 assert_response :success
817 817 assert_equal 'text/csv; header=present', response.content_type
818 818 lines = response.body.chomp.split("\n")
819 819 assert_include '1,"Related to #7, Related to #8, Blocks #11"', lines
820 820 assert_include '2,Blocked by #12', lines
821 821 assert_include '3,""', lines
822 822
823 823 get :index, :set_filter => 1, :c => %w(subject relations), :format => 'pdf'
824 824 assert_response :success
825 825 assert_equal 'application/pdf', response.content_type
826 826 end
827 827
828 828 def test_index_with_description_column
829 829 get :index, :set_filter => 1, :c => %w(subject description)
830 830
831 831 assert_select 'table.issues thead th', 3 # columns: chekbox + id + subject
832 832 assert_select 'td.description[colspan=3]', :text => 'Unable to print recipes'
833 833
834 834 get :index, :set_filter => 1, :c => %w(subject description), :format => 'pdf'
835 835 assert_response :success
836 836 assert_equal 'application/pdf', response.content_type
837 837 end
838 838
839 839 def test_index_send_html_if_query_is_invalid
840 840 get :index, :f => ['start_date'], :op => {:start_date => '='}
841 841 assert_equal 'text/html', @response.content_type
842 842 assert_template 'index'
843 843 end
844 844
845 845 def test_index_send_nothing_if_query_is_invalid
846 846 get :index, :f => ['start_date'], :op => {:start_date => '='}, :format => 'csv'
847 847 assert_equal 'text/csv', @response.content_type
848 848 assert @response.body.blank?
849 849 end
850 850
851 851 def test_show_by_anonymous
852 852 get :show, :id => 1
853 853 assert_response :success
854 854 assert_template 'show'
855 855 assert_equal Issue.find(1), assigns(:issue)
856 856
857 857 assert_select 'div.issue div.description', :text => /Unable to print recipes/
858 858
859 859 # anonymous role is allowed to add a note
860 860 assert_select 'form#issue-form' do
861 861 assert_select 'fieldset' do
862 862 assert_select 'legend', :text => 'Notes'
863 863 assert_select 'textarea[name=?]', 'issue[notes]'
864 864 end
865 865 end
866 866
867 867 assert_select 'title', :text => "Bug #1: Can&#x27;t print recipes - eCookbook - Redmine"
868 868 end
869 869
870 870 def test_show_by_manager
871 871 @request.session[:user_id] = 2
872 872 get :show, :id => 1
873 873 assert_response :success
874 874
875 875 assert_select 'a', :text => /Quote/
876 876
877 877 assert_select 'form#issue-form' do
878 878 assert_select 'fieldset' do
879 879 assert_select 'legend', :text => 'Change properties'
880 880 assert_select 'input[name=?]', 'issue[subject]'
881 881 end
882 882 assert_select 'fieldset' do
883 883 assert_select 'legend', :text => 'Log time'
884 884 assert_select 'input[name=?]', 'time_entry[hours]'
885 885 end
886 886 assert_select 'fieldset' do
887 887 assert_select 'legend', :text => 'Notes'
888 888 assert_select 'textarea[name=?]', 'issue[notes]'
889 889 end
890 890 end
891 891 end
892 892
893 893 def test_show_should_display_update_form
894 894 @request.session[:user_id] = 2
895 895 get :show, :id => 1
896 896 assert_response :success
897 897
898 898 assert_tag 'form', :attributes => {:id => 'issue-form'}
899 899 assert_tag 'input', :attributes => {:name => 'issue[is_private]'}
900 900 assert_tag 'select', :attributes => {:name => 'issue[project_id]'}
901 901 assert_tag 'select', :attributes => {:name => 'issue[tracker_id]'}
902 902 assert_tag 'input', :attributes => {:name => 'issue[subject]'}
903 903 assert_tag 'textarea', :attributes => {:name => 'issue[description]'}
904 904 assert_tag 'select', :attributes => {:name => 'issue[status_id]'}
905 905 assert_tag 'select', :attributes => {:name => 'issue[priority_id]'}
906 906 assert_tag 'select', :attributes => {:name => 'issue[assigned_to_id]'}
907 907 assert_tag 'select', :attributes => {:name => 'issue[category_id]'}
908 908 assert_tag 'select', :attributes => {:name => 'issue[fixed_version_id]'}
909 909 assert_tag 'input', :attributes => {:name => 'issue[parent_issue_id]'}
910 910 assert_tag 'input', :attributes => {:name => 'issue[start_date]'}
911 911 assert_tag 'input', :attributes => {:name => 'issue[due_date]'}
912 912 assert_tag 'select', :attributes => {:name => 'issue[done_ratio]'}
913 913 assert_tag 'input', :attributes => { :name => 'issue[custom_field_values][2]' }
914 914 assert_no_tag 'input', :attributes => {:name => 'issue[watcher_user_ids][]'}
915 915 assert_tag 'textarea', :attributes => {:name => 'issue[notes]'}
916 916 end
917 917
918 918 def test_show_should_display_update_form_with_minimal_permissions
919 919 Role.find(1).update_attribute :permissions, [:view_issues, :add_issue_notes]
920 920 WorkflowTransition.delete_all :role_id => 1
921 921
922 922 @request.session[:user_id] = 2
923 923 get :show, :id => 1
924 924 assert_response :success
925 925
926 926 assert_tag 'form', :attributes => {:id => 'issue-form'}
927 927 assert_no_tag 'input', :attributes => {:name => 'issue[is_private]'}
928 928 assert_no_tag 'select', :attributes => {:name => 'issue[project_id]'}
929 929 assert_no_tag 'select', :attributes => {:name => 'issue[tracker_id]'}
930 930 assert_no_tag 'input', :attributes => {:name => 'issue[subject]'}
931 931 assert_no_tag 'textarea', :attributes => {:name => 'issue[description]'}
932 932 assert_no_tag 'select', :attributes => {:name => 'issue[status_id]'}
933 933 assert_no_tag 'select', :attributes => {:name => 'issue[priority_id]'}
934 934 assert_no_tag 'select', :attributes => {:name => 'issue[assigned_to_id]'}
935 935 assert_no_tag 'select', :attributes => {:name => 'issue[category_id]'}
936 936 assert_no_tag 'select', :attributes => {:name => 'issue[fixed_version_id]'}
937 937 assert_no_tag 'input', :attributes => {:name => 'issue[parent_issue_id]'}
938 938 assert_no_tag 'input', :attributes => {:name => 'issue[start_date]'}
939 939 assert_no_tag 'input', :attributes => {:name => 'issue[due_date]'}
940 940 assert_no_tag 'select', :attributes => {:name => 'issue[done_ratio]'}
941 941 assert_no_tag 'input', :attributes => { :name => 'issue[custom_field_values][2]' }
942 942 assert_no_tag 'input', :attributes => {:name => 'issue[watcher_user_ids][]'}
943 943 assert_tag 'textarea', :attributes => {:name => 'issue[notes]'}
944 944 end
945 945
946 946 def test_show_should_display_update_form_with_workflow_permissions
947 947 Role.find(1).update_attribute :permissions, [:view_issues, :add_issue_notes]
948 948
949 949 @request.session[:user_id] = 2
950 950 get :show, :id => 1
951 951 assert_response :success
952 952
953 953 assert_tag 'form', :attributes => {:id => 'issue-form'}
954 954 assert_no_tag 'input', :attributes => {:name => 'issue[is_private]'}
955 955 assert_no_tag 'select', :attributes => {:name => 'issue[project_id]'}
956 956 assert_no_tag 'select', :attributes => {:name => 'issue[tracker_id]'}
957 957 assert_no_tag 'input', :attributes => {:name => 'issue[subject]'}
958 958 assert_no_tag 'textarea', :attributes => {:name => 'issue[description]'}
959 959 assert_tag 'select', :attributes => {:name => 'issue[status_id]'}
960 960 assert_no_tag 'select', :attributes => {:name => 'issue[priority_id]'}
961 961 assert_tag 'select', :attributes => {:name => 'issue[assigned_to_id]'}
962 962 assert_no_tag 'select', :attributes => {:name => 'issue[category_id]'}
963 963 assert_tag 'select', :attributes => {:name => 'issue[fixed_version_id]'}
964 964 assert_no_tag 'input', :attributes => {:name => 'issue[parent_issue_id]'}
965 965 assert_no_tag 'input', :attributes => {:name => 'issue[start_date]'}
966 966 assert_no_tag 'input', :attributes => {:name => 'issue[due_date]'}
967 967 assert_tag 'select', :attributes => {:name => 'issue[done_ratio]'}
968 968 assert_no_tag 'input', :attributes => { :name => 'issue[custom_field_values][2]' }
969 969 assert_no_tag 'input', :attributes => {:name => 'issue[watcher_user_ids][]'}
970 970 assert_tag 'textarea', :attributes => {:name => 'issue[notes]'}
971 971 end
972 972
973 973 def test_show_should_not_display_update_form_without_permissions
974 974 Role.find(1).update_attribute :permissions, [:view_issues]
975 975
976 976 @request.session[:user_id] = 2
977 977 get :show, :id => 1
978 978 assert_response :success
979 979
980 980 assert_select 'form#issue-form', 0
981 981 end
982 982
983 983 def test_update_form_should_not_display_inactive_enumerations
984 984 assert !IssuePriority.find(15).active?
985 985
986 986 @request.session[:user_id] = 2
987 987 get :show, :id => 1
988 988 assert_response :success
989 989
990 990 assert_select 'form#issue-form' do
991 991 assert_select 'select[name=?]', 'issue[priority_id]' do
992 992 assert_select 'option[value=4]'
993 993 assert_select 'option[value=15]', 0
994 994 end
995 995 end
996 996 end
997 997
998 998 def test_update_form_should_allow_attachment_upload
999 999 @request.session[:user_id] = 2
1000 1000 get :show, :id => 1
1001 1001
1002 1002 assert_select 'form#issue-form[method=post][enctype=multipart/form-data]' do
1003 assert_select 'input[type=file][name=?]', 'attachments[1][file]'
1003 assert_select 'input[type=file][name=?]', 'attachments_files'
1004 1004 end
1005 1005 end
1006 1006
1007 1007 def test_show_should_deny_anonymous_access_without_permission
1008 1008 Role.anonymous.remove_permission!(:view_issues)
1009 1009 get :show, :id => 1
1010 1010 assert_response :redirect
1011 1011 end
1012 1012
1013 1013 def test_show_should_deny_anonymous_access_to_private_issue
1014 1014 Issue.update_all(["is_private = ?", true], "id = 1")
1015 1015 get :show, :id => 1
1016 1016 assert_response :redirect
1017 1017 end
1018 1018
1019 1019 def test_show_should_deny_non_member_access_without_permission
1020 1020 Role.non_member.remove_permission!(:view_issues)
1021 1021 @request.session[:user_id] = 9
1022 1022 get :show, :id => 1
1023 1023 assert_response 403
1024 1024 end
1025 1025
1026 1026 def test_show_should_deny_non_member_access_to_private_issue
1027 1027 Issue.update_all(["is_private = ?", true], "id = 1")
1028 1028 @request.session[:user_id] = 9
1029 1029 get :show, :id => 1
1030 1030 assert_response 403
1031 1031 end
1032 1032
1033 1033 def test_show_should_deny_member_access_without_permission
1034 1034 Role.find(1).remove_permission!(:view_issues)
1035 1035 @request.session[:user_id] = 2
1036 1036 get :show, :id => 1
1037 1037 assert_response 403
1038 1038 end
1039 1039
1040 1040 def test_show_should_deny_member_access_to_private_issue_without_permission
1041 1041 Issue.update_all(["is_private = ?", true], "id = 1")
1042 1042 @request.session[:user_id] = 3
1043 1043 get :show, :id => 1
1044 1044 assert_response 403
1045 1045 end
1046 1046
1047 1047 def test_show_should_allow_author_access_to_private_issue
1048 1048 Issue.update_all(["is_private = ?, author_id = 3", true], "id = 1")
1049 1049 @request.session[:user_id] = 3
1050 1050 get :show, :id => 1
1051 1051 assert_response :success
1052 1052 end
1053 1053
1054 1054 def test_show_should_allow_assignee_access_to_private_issue
1055 1055 Issue.update_all(["is_private = ?, assigned_to_id = 3", true], "id = 1")
1056 1056 @request.session[:user_id] = 3
1057 1057 get :show, :id => 1
1058 1058 assert_response :success
1059 1059 end
1060 1060
1061 1061 def test_show_should_allow_member_access_to_private_issue_with_permission
1062 1062 Issue.update_all(["is_private = ?", true], "id = 1")
1063 1063 User.find(3).roles_for_project(Project.find(1)).first.update_attribute :issues_visibility, 'all'
1064 1064 @request.session[:user_id] = 3
1065 1065 get :show, :id => 1
1066 1066 assert_response :success
1067 1067 end
1068 1068
1069 1069 def test_show_should_not_disclose_relations_to_invisible_issues
1070 1070 Setting.cross_project_issue_relations = '1'
1071 1071 IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(2), :relation_type => 'relates')
1072 1072 # Relation to a private project issue
1073 1073 IssueRelation.create!(:issue_from => Issue.find(1), :issue_to => Issue.find(4), :relation_type => 'relates')
1074 1074
1075 1075 get :show, :id => 1
1076 1076 assert_response :success
1077 1077
1078 1078 assert_select 'div#relations' do
1079 1079 assert_select 'a', :text => /#2$/
1080 1080 assert_select 'a', :text => /#4$/, :count => 0
1081 1081 end
1082 1082 end
1083 1083
1084 1084 def test_show_should_list_subtasks
1085 1085 Issue.create!(:project_id => 1, :author_id => 1, :tracker_id => 1, :parent_issue_id => 1, :subject => 'Child Issue')
1086 1086
1087 1087 get :show, :id => 1
1088 1088 assert_response :success
1089 1089
1090 1090 assert_select 'div#issue_tree' do
1091 1091 assert_select 'td.subject', :text => /Child Issue/
1092 1092 end
1093 1093 end
1094 1094
1095 1095 def test_show_should_list_parents
1096 1096 issue = Issue.create!(:project_id => 1, :author_id => 1, :tracker_id => 1, :parent_issue_id => 1, :subject => 'Child Issue')
1097 1097
1098 1098 get :show, :id => issue.id
1099 1099 assert_response :success
1100 1100
1101 1101 assert_select 'div.subject' do
1102 1102 assert_select 'h3', 'Child Issue'
1103 1103 assert_select 'a[href=/issues/1]'
1104 1104 end
1105 1105 end
1106 1106
1107 1107 def test_show_should_not_display_prev_next_links_without_query_in_session
1108 1108 get :show, :id => 1
1109 1109 assert_response :success
1110 1110 assert_nil assigns(:prev_issue_id)
1111 1111 assert_nil assigns(:next_issue_id)
1112 1112
1113 1113 assert_select 'div.next-prev-links', 0
1114 1114 end
1115 1115
1116 1116 def test_show_should_display_prev_next_links_with_query_in_session
1117 1117 @request.session[:query] = {:filters => {'status_id' => {:values => [''], :operator => 'o'}}, :project_id => nil}
1118 1118 @request.session['issues_index_sort'] = 'id'
1119 1119
1120 1120 with_settings :display_subprojects_issues => '0' do
1121 1121 get :show, :id => 3
1122 1122 end
1123 1123
1124 1124 assert_response :success
1125 1125 # Previous and next issues for all projects
1126 1126 assert_equal 2, assigns(:prev_issue_id)
1127 1127 assert_equal 5, assigns(:next_issue_id)
1128 1128
1129 1129 count = Issue.open.visible.count
1130 1130
1131 1131 assert_select 'div.next-prev-links' do
1132 1132 assert_select 'a[href=/issues/2]', :text => /Previous/
1133 1133 assert_select 'a[href=/issues/5]', :text => /Next/
1134 1134 assert_select 'span.position', :text => "3 of #{count}"
1135 1135 end
1136 1136 end
1137 1137
1138 1138 def test_show_should_display_prev_next_links_with_saved_query_in_session
1139 1139 query = IssueQuery.create!(:name => 'test', :is_public => true, :user_id => 1,
1140 1140 :filters => {'status_id' => {:values => ['5'], :operator => '='}},
1141 1141 :sort_criteria => [['id', 'asc']])
1142 1142 @request.session[:query] = {:id => query.id, :project_id => nil}
1143 1143
1144 1144 get :show, :id => 11
1145 1145
1146 1146 assert_response :success
1147 1147 assert_equal query, assigns(:query)
1148 1148 # Previous and next issues for all projects
1149 1149 assert_equal 8, assigns(:prev_issue_id)
1150 1150 assert_equal 12, assigns(:next_issue_id)
1151 1151
1152 1152 assert_select 'div.next-prev-links' do
1153 1153 assert_select 'a[href=/issues/8]', :text => /Previous/
1154 1154 assert_select 'a[href=/issues/12]', :text => /Next/
1155 1155 end
1156 1156 end
1157 1157
1158 1158 def test_show_should_display_prev_next_links_with_query_and_sort_on_association
1159 1159 @request.session[:query] = {:filters => {'status_id' => {:values => [''], :operator => 'o'}}, :project_id => nil}
1160 1160
1161 1161 %w(project tracker status priority author assigned_to category fixed_version).each do |assoc_sort|
1162 1162 @request.session['issues_index_sort'] = assoc_sort
1163 1163
1164 1164 get :show, :id => 3
1165 1165 assert_response :success, "Wrong response status for #{assoc_sort} sort"
1166 1166
1167 1167 assert_select 'div.next-prev-links' do
1168 1168 assert_select 'a', :text => /(Previous|Next)/
1169 1169 end
1170 1170 end
1171 1171 end
1172 1172
1173 1173 def test_show_should_display_prev_next_links_with_project_query_in_session
1174 1174 @request.session[:query] = {:filters => {'status_id' => {:values => [''], :operator => 'o'}}, :project_id => 1}
1175 1175 @request.session['issues_index_sort'] = 'id'
1176 1176
1177 1177 with_settings :display_subprojects_issues => '0' do
1178 1178 get :show, :id => 3
1179 1179 end
1180 1180
1181 1181 assert_response :success
1182 1182 # Previous and next issues inside project
1183 1183 assert_equal 2, assigns(:prev_issue_id)
1184 1184 assert_equal 7, assigns(:next_issue_id)
1185 1185
1186 1186 assert_select 'div.next-prev-links' do
1187 1187 assert_select 'a[href=/issues/2]', :text => /Previous/
1188 1188 assert_select 'a[href=/issues/7]', :text => /Next/
1189 1189 end
1190 1190 end
1191 1191
1192 1192 def test_show_should_not_display_prev_link_for_first_issue
1193 1193 @request.session[:query] = {:filters => {'status_id' => {:values => [''], :operator => 'o'}}, :project_id => 1}
1194 1194 @request.session['issues_index_sort'] = 'id'
1195 1195
1196 1196 with_settings :display_subprojects_issues => '0' do
1197 1197 get :show, :id => 1
1198 1198 end
1199 1199
1200 1200 assert_response :success
1201 1201 assert_nil assigns(:prev_issue_id)
1202 1202 assert_equal 2, assigns(:next_issue_id)
1203 1203
1204 1204 assert_select 'div.next-prev-links' do
1205 1205 assert_select 'a', :text => /Previous/, :count => 0
1206 1206 assert_select 'a[href=/issues/2]', :text => /Next/
1207 1207 end
1208 1208 end
1209 1209
1210 1210 def test_show_should_not_display_prev_next_links_for_issue_not_in_query_results
1211 1211 @request.session[:query] = {:filters => {'status_id' => {:values => [''], :operator => 'c'}}, :project_id => 1}
1212 1212 @request.session['issues_index_sort'] = 'id'
1213 1213
1214 1214 get :show, :id => 1
1215 1215
1216 1216 assert_response :success
1217 1217 assert_nil assigns(:prev_issue_id)
1218 1218 assert_nil assigns(:next_issue_id)
1219 1219
1220 1220 assert_select 'a', :text => /Previous/, :count => 0
1221 1221 assert_select 'a', :text => /Next/, :count => 0
1222 1222 end
1223 1223
1224 1224 def test_show_show_should_display_prev_next_links_with_query_sort_by_user_custom_field
1225 1225 cf = IssueCustomField.create!(:name => 'User', :is_for_all => true, :tracker_ids => [1,2,3], :field_format => 'user')
1226 1226 CustomValue.create!(:custom_field => cf, :customized => Issue.find(1), :value => '2')
1227 1227 CustomValue.create!(:custom_field => cf, :customized => Issue.find(2), :value => '3')
1228 1228 CustomValue.create!(:custom_field => cf, :customized => Issue.find(3), :value => '3')
1229 1229 CustomValue.create!(:custom_field => cf, :customized => Issue.find(5), :value => '')
1230 1230
1231 1231 query = IssueQuery.create!(:name => 'test', :is_public => true, :user_id => 1, :filters => {},
1232 1232 :sort_criteria => [["cf_#{cf.id}", 'asc'], ['id', 'asc']])
1233 1233 @request.session[:query] = {:id => query.id, :project_id => nil}
1234 1234
1235 1235 get :show, :id => 3
1236 1236 assert_response :success
1237 1237
1238 1238 assert_equal 2, assigns(:prev_issue_id)
1239 1239 assert_equal 1, assigns(:next_issue_id)
1240 1240
1241 1241 assert_select 'div.next-prev-links' do
1242 1242 assert_select 'a[href=/issues/2]', :text => /Previous/
1243 1243 assert_select 'a[href=/issues/1]', :text => /Next/
1244 1244 end
1245 1245 end
1246 1246
1247 1247 def test_show_should_display_link_to_the_assignee
1248 1248 get :show, :id => 2
1249 1249 assert_response :success
1250 1250 assert_select '.assigned-to' do
1251 1251 assert_select 'a[href=/users/3]'
1252 1252 end
1253 1253 end
1254 1254
1255 1255 def test_show_should_display_visible_changesets_from_other_projects
1256 1256 project = Project.find(2)
1257 1257 issue = project.issues.first
1258 1258 issue.changeset_ids = [102]
1259 1259 issue.save!
1260 1260 # changesets from other projects should be displayed even if repository
1261 1261 # is disabled on issue's project
1262 1262 project.disable_module! :repository
1263 1263
1264 1264 @request.session[:user_id] = 2
1265 1265 get :show, :id => issue.id
1266 1266
1267 1267 assert_select 'a[href=?]', '/projects/ecookbook/repository/revisions/3'
1268 1268 end
1269 1269
1270 1270 def test_show_should_display_watchers
1271 1271 @request.session[:user_id] = 2
1272 1272 Issue.find(1).add_watcher User.find(2)
1273 1273
1274 1274 get :show, :id => 1
1275 1275 assert_select 'div#watchers ul' do
1276 1276 assert_select 'li' do
1277 1277 assert_select 'a[href=/users/2]'
1278 1278 assert_select 'a img[alt=Delete]'
1279 1279 end
1280 1280 end
1281 1281 end
1282 1282
1283 1283 def test_show_should_display_watchers_with_gravatars
1284 1284 @request.session[:user_id] = 2
1285 1285 Issue.find(1).add_watcher User.find(2)
1286 1286
1287 1287 with_settings :gravatar_enabled => '1' do
1288 1288 get :show, :id => 1
1289 1289 end
1290 1290
1291 1291 assert_select 'div#watchers ul' do
1292 1292 assert_select 'li' do
1293 1293 assert_select 'img.gravatar'
1294 1294 assert_select 'a[href=/users/2]'
1295 1295 assert_select 'a img[alt=Delete]'
1296 1296 end
1297 1297 end
1298 1298 end
1299 1299
1300 1300 def test_show_with_thumbnails_enabled_should_display_thumbnails
1301 1301 @request.session[:user_id] = 2
1302 1302
1303 1303 with_settings :thumbnails_enabled => '1' do
1304 1304 get :show, :id => 14
1305 1305 assert_response :success
1306 1306 end
1307 1307
1308 1308 assert_select 'div.thumbnails' do
1309 1309 assert_select 'a[href=/attachments/16/testfile.png]' do
1310 1310 assert_select 'img[src=/attachments/thumbnail/16]'
1311 1311 end
1312 1312 end
1313 1313 end
1314 1314
1315 1315 def test_show_with_thumbnails_disabled_should_not_display_thumbnails
1316 1316 @request.session[:user_id] = 2
1317 1317
1318 1318 with_settings :thumbnails_enabled => '0' do
1319 1319 get :show, :id => 14
1320 1320 assert_response :success
1321 1321 end
1322 1322
1323 1323 assert_select 'div.thumbnails', 0
1324 1324 end
1325 1325
1326 1326 def test_show_with_multi_custom_field
1327 1327 field = CustomField.find(1)
1328 1328 field.update_attribute :multiple, true
1329 1329 issue = Issue.find(1)
1330 1330 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
1331 1331 issue.save!
1332 1332
1333 1333 get :show, :id => 1
1334 1334 assert_response :success
1335 1335
1336 1336 assert_select 'td', :text => 'MySQL, Oracle'
1337 1337 end
1338 1338
1339 1339 def test_show_with_multi_user_custom_field
1340 1340 field = IssueCustomField.create!(:name => 'Multi user', :field_format => 'user', :multiple => true,
1341 1341 :tracker_ids => [1], :is_for_all => true)
1342 1342 issue = Issue.find(1)
1343 1343 issue.custom_field_values = {field.id => ['2', '3']}
1344 1344 issue.save!
1345 1345
1346 1346 get :show, :id => 1
1347 1347 assert_response :success
1348 1348
1349 1349 # TODO: should display links
1350 1350 assert_select 'td', :text => 'Dave Lopper, John Smith'
1351 1351 end
1352 1352
1353 1353 def test_show_should_display_private_notes_with_permission_only
1354 1354 journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Privates notes', :private_notes => true, :user_id => 1)
1355 1355 @request.session[:user_id] = 2
1356 1356
1357 1357 get :show, :id => 2
1358 1358 assert_response :success
1359 1359 assert_include journal, assigns(:journals)
1360 1360
1361 1361 Role.find(1).remove_permission! :view_private_notes
1362 1362 get :show, :id => 2
1363 1363 assert_response :success
1364 1364 assert_not_include journal, assigns(:journals)
1365 1365 end
1366 1366
1367 1367 def test_show_atom
1368 1368 get :show, :id => 2, :format => 'atom'
1369 1369 assert_response :success
1370 1370 assert_template 'journals/index'
1371 1371 # Inline image
1372 1372 assert_select 'content', :text => Regexp.new(Regexp.quote('http://test.host/attachments/download/10'))
1373 1373 end
1374 1374
1375 1375 def test_show_export_to_pdf
1376 1376 get :show, :id => 3, :format => 'pdf'
1377 1377 assert_response :success
1378 1378 assert_equal 'application/pdf', @response.content_type
1379 1379 assert @response.body.starts_with?('%PDF')
1380 1380 assert_not_nil assigns(:issue)
1381 1381 end
1382 1382
1383 1383 def test_show_export_to_pdf_with_ancestors
1384 1384 issue = Issue.generate!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => 1)
1385 1385
1386 1386 get :show, :id => issue.id, :format => 'pdf'
1387 1387 assert_response :success
1388 1388 assert_equal 'application/pdf', @response.content_type
1389 1389 assert @response.body.starts_with?('%PDF')
1390 1390 end
1391 1391
1392 1392 def test_show_export_to_pdf_with_descendants
1393 1393 c1 = Issue.generate!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => 1)
1394 1394 c2 = Issue.generate!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => 1)
1395 1395 c3 = Issue.generate!(:project_id => 1, :author_id => 2, :tracker_id => 1, :subject => 'child', :parent_issue_id => c1.id)
1396 1396
1397 1397 get :show, :id => 1, :format => 'pdf'
1398 1398 assert_response :success
1399 1399 assert_equal 'application/pdf', @response.content_type
1400 1400 assert @response.body.starts_with?('%PDF')
1401 1401 end
1402 1402
1403 1403 def test_show_export_to_pdf_with_journals
1404 1404 get :show, :id => 1, :format => 'pdf'
1405 1405 assert_response :success
1406 1406 assert_equal 'application/pdf', @response.content_type
1407 1407 assert @response.body.starts_with?('%PDF')
1408 1408 end
1409 1409
1410 1410 def test_show_export_to_pdf_with_changesets
1411 1411 Issue.find(3).changesets = Changeset.find_all_by_id(100, 101, 102)
1412 1412
1413 1413 get :show, :id => 3, :format => 'pdf'
1414 1414 assert_response :success
1415 1415 assert_equal 'application/pdf', @response.content_type
1416 1416 assert @response.body.starts_with?('%PDF')
1417 1417 end
1418 1418
1419 1419 def test_show_invalid_should_respond_with_404
1420 1420 get :show, :id => 999
1421 1421 assert_response 404
1422 1422 end
1423 1423
1424 1424 def test_get_new
1425 1425 @request.session[:user_id] = 2
1426 1426 get :new, :project_id => 1, :tracker_id => 1
1427 1427 assert_response :success
1428 1428 assert_template 'new'
1429 1429
1430 1430 assert_tag 'input', :attributes => {:name => 'issue[is_private]'}
1431 1431 assert_no_tag 'select', :attributes => {:name => 'issue[project_id]'}
1432 1432 assert_tag 'select', :attributes => {:name => 'issue[tracker_id]'}
1433 1433 assert_tag 'input', :attributes => {:name => 'issue[subject]'}
1434 1434 assert_tag 'textarea', :attributes => {:name => 'issue[description]'}
1435 1435 assert_tag 'select', :attributes => {:name => 'issue[status_id]'}
1436 1436 assert_tag 'select', :attributes => {:name => 'issue[priority_id]'}
1437 1437 assert_tag 'select', :attributes => {:name => 'issue[assigned_to_id]'}
1438 1438 assert_tag 'select', :attributes => {:name => 'issue[category_id]'}
1439 1439 assert_tag 'select', :attributes => {:name => 'issue[fixed_version_id]'}
1440 1440 assert_tag 'input', :attributes => {:name => 'issue[parent_issue_id]'}
1441 1441 assert_tag 'input', :attributes => {:name => 'issue[start_date]'}
1442 1442 assert_tag 'input', :attributes => {:name => 'issue[due_date]'}
1443 1443 assert_tag 'select', :attributes => {:name => 'issue[done_ratio]'}
1444 1444 assert_tag 'input', :attributes => { :name => 'issue[custom_field_values][2]', :value => 'Default string' }
1445 1445 assert_tag 'input', :attributes => {:name => 'issue[watcher_user_ids][]'}
1446 1446
1447 1447 # Be sure we don't display inactive IssuePriorities
1448 1448 assert ! IssuePriority.find(15).active?
1449 1449 assert_no_tag :option, :attributes => {:value => '15'},
1450 1450 :parent => {:tag => 'select', :attributes => {:id => 'issue_priority_id'} }
1451 1451 end
1452 1452
1453 1453 def test_get_new_with_minimal_permissions
1454 1454 Role.find(1).update_attribute :permissions, [:add_issues]
1455 1455 WorkflowTransition.delete_all :role_id => 1
1456 1456
1457 1457 @request.session[:user_id] = 2
1458 1458 get :new, :project_id => 1, :tracker_id => 1
1459 1459 assert_response :success
1460 1460 assert_template 'new'
1461 1461
1462 1462 assert_no_tag 'input', :attributes => {:name => 'issue[is_private]'}
1463 1463 assert_no_tag 'select', :attributes => {:name => 'issue[project_id]'}
1464 1464 assert_tag 'select', :attributes => {:name => 'issue[tracker_id]'}
1465 1465 assert_tag 'input', :attributes => {:name => 'issue[subject]'}
1466 1466 assert_tag 'textarea', :attributes => {:name => 'issue[description]'}
1467 1467 assert_tag 'select', :attributes => {:name => 'issue[status_id]'}
1468 1468 assert_tag 'select', :attributes => {:name => 'issue[priority_id]'}
1469 1469 assert_tag 'select', :attributes => {:name => 'issue[assigned_to_id]'}
1470 1470 assert_tag 'select', :attributes => {:name => 'issue[category_id]'}
1471 1471 assert_tag 'select', :attributes => {:name => 'issue[fixed_version_id]'}
1472 1472 assert_no_tag 'input', :attributes => {:name => 'issue[parent_issue_id]'}
1473 1473 assert_tag 'input', :attributes => {:name => 'issue[start_date]'}
1474 1474 assert_tag 'input', :attributes => {:name => 'issue[due_date]'}
1475 1475 assert_tag 'select', :attributes => {:name => 'issue[done_ratio]'}
1476 1476 assert_tag 'input', :attributes => { :name => 'issue[custom_field_values][2]', :value => 'Default string' }
1477 1477 assert_no_tag 'input', :attributes => {:name => 'issue[watcher_user_ids][]'}
1478 1478 end
1479 1479
1480 1480 def test_get_new_with_list_custom_field
1481 1481 @request.session[:user_id] = 2
1482 1482 get :new, :project_id => 1, :tracker_id => 1
1483 1483 assert_response :success
1484 1484 assert_template 'new'
1485 1485
1486 1486 assert_select 'select.list_cf[name=?]', 'issue[custom_field_values][1]' do
1487 1487 assert_select 'option', 4
1488 1488 assert_select 'option[value=MySQL]', :text => 'MySQL'
1489 1489 end
1490 1490 end
1491 1491
1492 1492 def test_get_new_with_multi_custom_field
1493 1493 field = IssueCustomField.find(1)
1494 1494 field.update_attribute :multiple, true
1495 1495
1496 1496 @request.session[:user_id] = 2
1497 1497 get :new, :project_id => 1, :tracker_id => 1
1498 1498 assert_response :success
1499 1499 assert_template 'new'
1500 1500
1501 1501 assert_select 'select[name=?][multiple=multiple]', 'issue[custom_field_values][1][]' do
1502 1502 assert_select 'option', 3
1503 1503 assert_select 'option[value=MySQL]', :text => 'MySQL'
1504 1504 end
1505 1505 assert_select 'input[name=?][type=hidden][value=?]', 'issue[custom_field_values][1][]', ''
1506 1506 end
1507 1507
1508 1508 def test_get_new_with_multi_user_custom_field
1509 1509 field = IssueCustomField.create!(:name => 'Multi user', :field_format => 'user', :multiple => true,
1510 1510 :tracker_ids => [1], :is_for_all => true)
1511 1511
1512 1512 @request.session[:user_id] = 2
1513 1513 get :new, :project_id => 1, :tracker_id => 1
1514 1514 assert_response :success
1515 1515 assert_template 'new'
1516 1516
1517 1517 assert_select 'select[name=?][multiple=multiple]', "issue[custom_field_values][#{field.id}][]" do
1518 1518 assert_select 'option', Project.find(1).users.count
1519 1519 assert_select 'option[value=2]', :text => 'John Smith'
1520 1520 end
1521 1521 assert_select 'input[name=?][type=hidden][value=?]', "issue[custom_field_values][#{field.id}][]", ''
1522 1522 end
1523 1523
1524 1524 def test_get_new_with_date_custom_field
1525 1525 field = IssueCustomField.create!(:name => 'Date', :field_format => 'date', :tracker_ids => [1], :is_for_all => true)
1526 1526
1527 1527 @request.session[:user_id] = 2
1528 1528 get :new, :project_id => 1, :tracker_id => 1
1529 1529 assert_response :success
1530 1530
1531 1531 assert_select 'input[name=?]', "issue[custom_field_values][#{field.id}]"
1532 1532 end
1533 1533
1534 1534 def test_get_new_with_text_custom_field
1535 1535 field = IssueCustomField.create!(:name => 'Text', :field_format => 'text', :tracker_ids => [1], :is_for_all => true)
1536 1536
1537 1537 @request.session[:user_id] = 2
1538 1538 get :new, :project_id => 1, :tracker_id => 1
1539 1539 assert_response :success
1540 1540
1541 1541 assert_select 'textarea[name=?]', "issue[custom_field_values][#{field.id}]"
1542 1542 end
1543 1543
1544 1544 def test_get_new_without_default_start_date_is_creation_date
1545 1545 Setting.default_issue_start_date_to_creation_date = 0
1546 1546
1547 1547 @request.session[:user_id] = 2
1548 1548 get :new, :project_id => 1, :tracker_id => 1
1549 1549 assert_response :success
1550 1550 assert_template 'new'
1551 1551
1552 1552 assert_select 'input[name=?]', 'issue[start_date]'
1553 1553 assert_select 'input[name=?][value]', 'issue[start_date]', 0
1554 1554 end
1555 1555
1556 1556 def test_get_new_with_default_start_date_is_creation_date
1557 1557 Setting.default_issue_start_date_to_creation_date = 1
1558 1558
1559 1559 @request.session[:user_id] = 2
1560 1560 get :new, :project_id => 1, :tracker_id => 1
1561 1561 assert_response :success
1562 1562 assert_template 'new'
1563 1563
1564 1564 assert_select 'input[name=?][value=?]', 'issue[start_date]', Date.today.to_s
1565 1565 end
1566 1566
1567 1567 def test_get_new_form_should_allow_attachment_upload
1568 1568 @request.session[:user_id] = 2
1569 1569 get :new, :project_id => 1, :tracker_id => 1
1570 1570
1571 1571 assert_select 'form[id=issue-form][method=post][enctype=multipart/form-data]' do
1572 assert_select 'input[name=?][type=file]', 'attachments[1][file]'
1573 assert_select 'input[name=?][maxlength=255]', 'attachments[1][description]'
1572 assert_select 'input[name=?][type=file]', 'attachments_files'
1574 1573 end
1575 1574 end
1576 1575
1577 1576 def test_get_new_should_prefill_the_form_from_params
1578 1577 @request.session[:user_id] = 2
1579 1578 get :new, :project_id => 1,
1580 1579 :issue => {:tracker_id => 3, :description => 'Prefilled', :custom_field_values => {'2' => 'Custom field value'}}
1581 1580
1582 1581 issue = assigns(:issue)
1583 1582 assert_equal 3, issue.tracker_id
1584 1583 assert_equal 'Prefilled', issue.description
1585 1584 assert_equal 'Custom field value', issue.custom_field_value(2)
1586 1585
1587 1586 assert_select 'select[name=?]', 'issue[tracker_id]' do
1588 1587 assert_select 'option[value=3][selected=selected]'
1589 1588 end
1590 1589 assert_select 'textarea[name=?]', 'issue[description]', :text => /Prefilled/
1591 1590 assert_select 'input[name=?][value=?]', 'issue[custom_field_values][2]', 'Custom field value'
1592 1591 end
1593 1592
1594 1593 def test_get_new_should_mark_required_fields
1595 1594 cf1 = IssueCustomField.create!(:name => 'Foo', :field_format => 'string', :is_for_all => true, :tracker_ids => [1, 2])
1596 1595 cf2 = IssueCustomField.create!(:name => 'Bar', :field_format => 'string', :is_for_all => true, :tracker_ids => [1, 2])
1597 1596 WorkflowPermission.delete_all
1598 1597 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1, :role_id => 1, :field_name => 'due_date', :rule => 'required')
1599 1598 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1, :role_id => 1, :field_name => cf2.id.to_s, :rule => 'required')
1600 1599 @request.session[:user_id] = 2
1601 1600
1602 1601 get :new, :project_id => 1
1603 1602 assert_response :success
1604 1603 assert_template 'new'
1605 1604
1606 1605 assert_select 'label[for=issue_start_date]' do
1607 1606 assert_select 'span[class=required]', 0
1608 1607 end
1609 1608 assert_select 'label[for=issue_due_date]' do
1610 1609 assert_select 'span[class=required]'
1611 1610 end
1612 1611 assert_select 'label[for=?]', "issue_custom_field_values_#{cf1.id}" do
1613 1612 assert_select 'span[class=required]', 0
1614 1613 end
1615 1614 assert_select 'label[for=?]', "issue_custom_field_values_#{cf2.id}" do
1616 1615 assert_select 'span[class=required]'
1617 1616 end
1618 1617 end
1619 1618
1620 1619 def test_get_new_should_not_display_readonly_fields
1621 1620 cf1 = IssueCustomField.create!(:name => 'Foo', :field_format => 'string', :is_for_all => true, :tracker_ids => [1, 2])
1622 1621 cf2 = IssueCustomField.create!(:name => 'Bar', :field_format => 'string', :is_for_all => true, :tracker_ids => [1, 2])
1623 1622 WorkflowPermission.delete_all
1624 1623 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1, :role_id => 1, :field_name => 'due_date', :rule => 'readonly')
1625 1624 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 1, :role_id => 1, :field_name => cf2.id.to_s, :rule => 'readonly')
1626 1625 @request.session[:user_id] = 2
1627 1626
1628 1627 get :new, :project_id => 1
1629 1628 assert_response :success
1630 1629 assert_template 'new'
1631 1630
1632 1631 assert_select 'input[name=?]', 'issue[start_date]'
1633 1632 assert_select 'input[name=?]', 'issue[due_date]', 0
1634 1633 assert_select 'input[name=?]', "issue[custom_field_values][#{cf1.id}]"
1635 1634 assert_select 'input[name=?]', "issue[custom_field_values][#{cf2.id}]", 0
1636 1635 end
1637 1636
1638 1637 def test_get_new_without_tracker_id
1639 1638 @request.session[:user_id] = 2
1640 1639 get :new, :project_id => 1
1641 1640 assert_response :success
1642 1641 assert_template 'new'
1643 1642
1644 1643 issue = assigns(:issue)
1645 1644 assert_not_nil issue
1646 1645 assert_equal Project.find(1).trackers.first, issue.tracker
1647 1646 end
1648 1647
1649 1648 def test_get_new_with_no_default_status_should_display_an_error
1650 1649 @request.session[:user_id] = 2
1651 1650 IssueStatus.delete_all
1652 1651
1653 1652 get :new, :project_id => 1
1654 1653 assert_response 500
1655 1654 assert_error_tag :content => /No default issue/
1656 1655 end
1657 1656
1658 1657 def test_get_new_with_no_tracker_should_display_an_error
1659 1658 @request.session[:user_id] = 2
1660 1659 Tracker.delete_all
1661 1660
1662 1661 get :new, :project_id => 1
1663 1662 assert_response 500
1664 1663 assert_error_tag :content => /No tracker/
1665 1664 end
1666 1665
1667 1666 def test_update_new_form
1668 1667 @request.session[:user_id] = 2
1669 1668 xhr :post, :new, :project_id => 1,
1670 1669 :issue => {:tracker_id => 2,
1671 1670 :subject => 'This is the test_new issue',
1672 1671 :description => 'This is the description',
1673 1672 :priority_id => 5}
1674 1673 assert_response :success
1675 1674 assert_template 'update_form'
1676 1675 assert_template 'form'
1677 1676 assert_equal 'text/javascript', response.content_type
1678 1677
1679 1678 issue = assigns(:issue)
1680 1679 assert_kind_of Issue, issue
1681 1680 assert_equal 1, issue.project_id
1682 1681 assert_equal 2, issue.tracker_id
1683 1682 assert_equal 'This is the test_new issue', issue.subject
1684 1683 end
1685 1684
1686 1685 def test_update_new_form_should_propose_transitions_based_on_initial_status
1687 1686 @request.session[:user_id] = 2
1688 1687 WorkflowTransition.delete_all
1689 1688 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 2)
1690 1689 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 5)
1691 1690 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 5, :new_status_id => 4)
1692 1691
1693 1692 xhr :post, :new, :project_id => 1,
1694 1693 :issue => {:tracker_id => 1,
1695 1694 :status_id => 5,
1696 1695 :subject => 'This is an issue'}
1697 1696
1698 1697 assert_equal 5, assigns(:issue).status_id
1699 1698 assert_equal [1,2,5], assigns(:allowed_statuses).map(&:id).sort
1700 1699 end
1701 1700
1702 1701 def test_post_create
1703 1702 @request.session[:user_id] = 2
1704 1703 assert_difference 'Issue.count' do
1705 1704 post :create, :project_id => 1,
1706 1705 :issue => {:tracker_id => 3,
1707 1706 :status_id => 2,
1708 1707 :subject => 'This is the test_new issue',
1709 1708 :description => 'This is the description',
1710 1709 :priority_id => 5,
1711 1710 :start_date => '2010-11-07',
1712 1711 :estimated_hours => '',
1713 1712 :custom_field_values => {'2' => 'Value for field 2'}}
1714 1713 end
1715 1714 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
1716 1715
1717 1716 issue = Issue.find_by_subject('This is the test_new issue')
1718 1717 assert_not_nil issue
1719 1718 assert_equal 2, issue.author_id
1720 1719 assert_equal 3, issue.tracker_id
1721 1720 assert_equal 2, issue.status_id
1722 1721 assert_equal Date.parse('2010-11-07'), issue.start_date
1723 1722 assert_nil issue.estimated_hours
1724 1723 v = issue.custom_values.where(:custom_field_id => 2).first
1725 1724 assert_not_nil v
1726 1725 assert_equal 'Value for field 2', v.value
1727 1726 end
1728 1727
1729 1728 def test_post_new_with_group_assignment
1730 1729 group = Group.find(11)
1731 1730 project = Project.find(1)
1732 1731 project.members << Member.new(:principal => group, :roles => [Role.givable.first])
1733 1732
1734 1733 with_settings :issue_group_assignment => '1' do
1735 1734 @request.session[:user_id] = 2
1736 1735 assert_difference 'Issue.count' do
1737 1736 post :create, :project_id => project.id,
1738 1737 :issue => {:tracker_id => 3,
1739 1738 :status_id => 1,
1740 1739 :subject => 'This is the test_new_with_group_assignment issue',
1741 1740 :assigned_to_id => group.id}
1742 1741 end
1743 1742 end
1744 1743 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
1745 1744
1746 1745 issue = Issue.find_by_subject('This is the test_new_with_group_assignment issue')
1747 1746 assert_not_nil issue
1748 1747 assert_equal group, issue.assigned_to
1749 1748 end
1750 1749
1751 1750 def test_post_create_without_start_date_and_default_start_date_is_not_creation_date
1752 1751 Setting.default_issue_start_date_to_creation_date = 0
1753 1752
1754 1753 @request.session[:user_id] = 2
1755 1754 assert_difference 'Issue.count' do
1756 1755 post :create, :project_id => 1,
1757 1756 :issue => {:tracker_id => 3,
1758 1757 :status_id => 2,
1759 1758 :subject => 'This is the test_new issue',
1760 1759 :description => 'This is the description',
1761 1760 :priority_id => 5,
1762 1761 :estimated_hours => '',
1763 1762 :custom_field_values => {'2' => 'Value for field 2'}}
1764 1763 end
1765 1764 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
1766 1765
1767 1766 issue = Issue.find_by_subject('This is the test_new issue')
1768 1767 assert_not_nil issue
1769 1768 assert_nil issue.start_date
1770 1769 end
1771 1770
1772 1771 def test_post_create_without_start_date_and_default_start_date_is_creation_date
1773 1772 Setting.default_issue_start_date_to_creation_date = 1
1774 1773
1775 1774 @request.session[:user_id] = 2
1776 1775 assert_difference 'Issue.count' do
1777 1776 post :create, :project_id => 1,
1778 1777 :issue => {:tracker_id => 3,
1779 1778 :status_id => 2,
1780 1779 :subject => 'This is the test_new issue',
1781 1780 :description => 'This is the description',
1782 1781 :priority_id => 5,
1783 1782 :estimated_hours => '',
1784 1783 :custom_field_values => {'2' => 'Value for field 2'}}
1785 1784 end
1786 1785 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
1787 1786
1788 1787 issue = Issue.find_by_subject('This is the test_new issue')
1789 1788 assert_not_nil issue
1790 1789 assert_equal Date.today, issue.start_date
1791 1790 end
1792 1791
1793 1792 def test_post_create_and_continue
1794 1793 @request.session[:user_id] = 2
1795 1794 assert_difference 'Issue.count' do
1796 1795 post :create, :project_id => 1,
1797 1796 :issue => {:tracker_id => 3, :subject => 'This is first issue', :priority_id => 5},
1798 1797 :continue => ''
1799 1798 end
1800 1799
1801 1800 issue = Issue.first(:order => 'id DESC')
1802 1801 assert_redirected_to :controller => 'issues', :action => 'new', :project_id => 'ecookbook', :issue => {:tracker_id => 3}
1803 1802 assert_not_nil flash[:notice], "flash was not set"
1804 1803 assert_include %|<a href="/issues/#{issue.id}" title="This is first issue">##{issue.id}</a>|, flash[:notice], "issue link not found in the flash message"
1805 1804 end
1806 1805
1807 1806 def test_post_create_without_custom_fields_param
1808 1807 @request.session[:user_id] = 2
1809 1808 assert_difference 'Issue.count' do
1810 1809 post :create, :project_id => 1,
1811 1810 :issue => {:tracker_id => 1,
1812 1811 :subject => 'This is the test_new issue',
1813 1812 :description => 'This is the description',
1814 1813 :priority_id => 5}
1815 1814 end
1816 1815 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
1817 1816 end
1818 1817
1819 1818 def test_post_create_with_multi_custom_field
1820 1819 field = IssueCustomField.find_by_name('Database')
1821 1820 field.update_attribute(:multiple, true)
1822 1821
1823 1822 @request.session[:user_id] = 2
1824 1823 assert_difference 'Issue.count' do
1825 1824 post :create, :project_id => 1,
1826 1825 :issue => {:tracker_id => 1,
1827 1826 :subject => 'This is the test_new issue',
1828 1827 :description => 'This is the description',
1829 1828 :priority_id => 5,
1830 1829 :custom_field_values => {'1' => ['', 'MySQL', 'Oracle']}}
1831 1830 end
1832 1831 assert_response 302
1833 1832 issue = Issue.first(:order => 'id DESC')
1834 1833 assert_equal ['MySQL', 'Oracle'], issue.custom_field_value(1).sort
1835 1834 end
1836 1835
1837 1836 def test_post_create_with_empty_multi_custom_field
1838 1837 field = IssueCustomField.find_by_name('Database')
1839 1838 field.update_attribute(:multiple, true)
1840 1839
1841 1840 @request.session[:user_id] = 2
1842 1841 assert_difference 'Issue.count' do
1843 1842 post :create, :project_id => 1,
1844 1843 :issue => {:tracker_id => 1,
1845 1844 :subject => 'This is the test_new issue',
1846 1845 :description => 'This is the description',
1847 1846 :priority_id => 5,
1848 1847 :custom_field_values => {'1' => ['']}}
1849 1848 end
1850 1849 assert_response 302
1851 1850 issue = Issue.first(:order => 'id DESC')
1852 1851 assert_equal [''], issue.custom_field_value(1).sort
1853 1852 end
1854 1853
1855 1854 def test_post_create_with_multi_user_custom_field
1856 1855 field = IssueCustomField.create!(:name => 'Multi user', :field_format => 'user', :multiple => true,
1857 1856 :tracker_ids => [1], :is_for_all => true)
1858 1857
1859 1858 @request.session[:user_id] = 2
1860 1859 assert_difference 'Issue.count' do
1861 1860 post :create, :project_id => 1,
1862 1861 :issue => {:tracker_id => 1,
1863 1862 :subject => 'This is the test_new issue',
1864 1863 :description => 'This is the description',
1865 1864 :priority_id => 5,
1866 1865 :custom_field_values => {field.id.to_s => ['', '2', '3']}}
1867 1866 end
1868 1867 assert_response 302
1869 1868 issue = Issue.first(:order => 'id DESC')
1870 1869 assert_equal ['2', '3'], issue.custom_field_value(field).sort
1871 1870 end
1872 1871
1873 1872 def test_post_create_with_required_custom_field_and_without_custom_fields_param
1874 1873 field = IssueCustomField.find_by_name('Database')
1875 1874 field.update_attribute(:is_required, true)
1876 1875
1877 1876 @request.session[:user_id] = 2
1878 1877 assert_no_difference 'Issue.count' do
1879 1878 post :create, :project_id => 1,
1880 1879 :issue => {:tracker_id => 1,
1881 1880 :subject => 'This is the test_new issue',
1882 1881 :description => 'This is the description',
1883 1882 :priority_id => 5}
1884 1883 end
1885 1884 assert_response :success
1886 1885 assert_template 'new'
1887 1886 issue = assigns(:issue)
1888 1887 assert_not_nil issue
1889 1888 assert_error_tag :content => /Database can&#x27;t be blank/
1890 1889 end
1891 1890
1892 1891 def test_create_should_validate_required_fields
1893 1892 cf1 = IssueCustomField.create!(:name => 'Foo', :field_format => 'string', :is_for_all => true, :tracker_ids => [1, 2])
1894 1893 cf2 = IssueCustomField.create!(:name => 'Bar', :field_format => 'string', :is_for_all => true, :tracker_ids => [1, 2])
1895 1894 WorkflowPermission.delete_all
1896 1895 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2, :role_id => 1, :field_name => 'due_date', :rule => 'required')
1897 1896 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2, :role_id => 1, :field_name => cf2.id.to_s, :rule => 'required')
1898 1897 @request.session[:user_id] = 2
1899 1898
1900 1899 assert_no_difference 'Issue.count' do
1901 1900 post :create, :project_id => 1, :issue => {
1902 1901 :tracker_id => 2,
1903 1902 :status_id => 1,
1904 1903 :subject => 'Test',
1905 1904 :start_date => '',
1906 1905 :due_date => '',
1907 1906 :custom_field_values => {cf1.id.to_s => '', cf2.id.to_s => ''}
1908 1907 }
1909 1908 assert_response :success
1910 1909 assert_template 'new'
1911 1910 end
1912 1911
1913 1912 assert_error_tag :content => /Due date can&#x27;t be blank/i
1914 1913 assert_error_tag :content => /Bar can&#x27;t be blank/i
1915 1914 end
1916 1915
1917 1916 def test_create_should_ignore_readonly_fields
1918 1917 cf1 = IssueCustomField.create!(:name => 'Foo', :field_format => 'string', :is_for_all => true, :tracker_ids => [1, 2])
1919 1918 cf2 = IssueCustomField.create!(:name => 'Bar', :field_format => 'string', :is_for_all => true, :tracker_ids => [1, 2])
1920 1919 WorkflowPermission.delete_all
1921 1920 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2, :role_id => 1, :field_name => 'due_date', :rule => 'readonly')
1922 1921 WorkflowPermission.create!(:old_status_id => 1, :tracker_id => 2, :role_id => 1, :field_name => cf2.id.to_s, :rule => 'readonly')
1923 1922 @request.session[:user_id] = 2
1924 1923
1925 1924 assert_difference 'Issue.count' do
1926 1925 post :create, :project_id => 1, :issue => {
1927 1926 :tracker_id => 2,
1928 1927 :status_id => 1,
1929 1928 :subject => 'Test',
1930 1929 :start_date => '2012-07-14',
1931 1930 :due_date => '2012-07-16',
1932 1931 :custom_field_values => {cf1.id.to_s => 'value1', cf2.id.to_s => 'value2'}
1933 1932 }
1934 1933 assert_response 302
1935 1934 end
1936 1935
1937 1936 issue = Issue.first(:order => 'id DESC')
1938 1937 assert_equal Date.parse('2012-07-14'), issue.start_date
1939 1938 assert_nil issue.due_date
1940 1939 assert_equal 'value1', issue.custom_field_value(cf1)
1941 1940 assert_nil issue.custom_field_value(cf2)
1942 1941 end
1943 1942
1944 1943 def test_post_create_with_watchers
1945 1944 @request.session[:user_id] = 2
1946 1945 ActionMailer::Base.deliveries.clear
1947 1946
1948 1947 assert_difference 'Watcher.count', 2 do
1949 1948 post :create, :project_id => 1,
1950 1949 :issue => {:tracker_id => 1,
1951 1950 :subject => 'This is a new issue with watchers',
1952 1951 :description => 'This is the description',
1953 1952 :priority_id => 5,
1954 1953 :watcher_user_ids => ['2', '3']}
1955 1954 end
1956 1955 issue = Issue.find_by_subject('This is a new issue with watchers')
1957 1956 assert_not_nil issue
1958 1957 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
1959 1958
1960 1959 # Watchers added
1961 1960 assert_equal [2, 3], issue.watcher_user_ids.sort
1962 1961 assert issue.watched_by?(User.find(3))
1963 1962 # Watchers notified
1964 1963 mail = ActionMailer::Base.deliveries.last
1965 1964 assert_not_nil mail
1966 1965 assert [mail.bcc, mail.cc].flatten.include?(User.find(3).mail)
1967 1966 end
1968 1967
1969 1968 def test_post_create_subissue
1970 1969 @request.session[:user_id] = 2
1971 1970
1972 1971 assert_difference 'Issue.count' do
1973 1972 post :create, :project_id => 1,
1974 1973 :issue => {:tracker_id => 1,
1975 1974 :subject => 'This is a child issue',
1976 1975 :parent_issue_id => '2'}
1977 1976 assert_response 302
1978 1977 end
1979 1978 issue = Issue.order('id DESC').first
1980 1979 assert_equal Issue.find(2), issue.parent
1981 1980 end
1982 1981
1983 1982 def test_post_create_subissue_with_sharp_parent_id
1984 1983 @request.session[:user_id] = 2
1985 1984
1986 1985 assert_difference 'Issue.count' do
1987 1986 post :create, :project_id => 1,
1988 1987 :issue => {:tracker_id => 1,
1989 1988 :subject => 'This is a child issue',
1990 1989 :parent_issue_id => '#2'}
1991 1990 assert_response 302
1992 1991 end
1993 1992 issue = Issue.order('id DESC').first
1994 1993 assert_equal Issue.find(2), issue.parent
1995 1994 end
1996 1995
1997 1996 def test_post_create_subissue_with_non_visible_parent_id_should_not_validate
1998 1997 @request.session[:user_id] = 2
1999 1998
2000 1999 assert_no_difference 'Issue.count' do
2001 2000 post :create, :project_id => 1,
2002 2001 :issue => {:tracker_id => 1,
2003 2002 :subject => 'This is a child issue',
2004 2003 :parent_issue_id => '4'}
2005 2004
2006 2005 assert_response :success
2007 2006 assert_select 'input[name=?][value=?]', 'issue[parent_issue_id]', '4'
2008 2007 assert_error_tag :content => /Parent task is invalid/i
2009 2008 end
2010 2009 end
2011 2010
2012 2011 def test_post_create_subissue_with_non_numeric_parent_id_should_not_validate
2013 2012 @request.session[:user_id] = 2
2014 2013
2015 2014 assert_no_difference 'Issue.count' do
2016 2015 post :create, :project_id => 1,
2017 2016 :issue => {:tracker_id => 1,
2018 2017 :subject => 'This is a child issue',
2019 2018 :parent_issue_id => '01ABC'}
2020 2019
2021 2020 assert_response :success
2022 2021 assert_select 'input[name=?][value=?]', 'issue[parent_issue_id]', '01ABC'
2023 2022 assert_error_tag :content => /Parent task is invalid/i
2024 2023 end
2025 2024 end
2026 2025
2027 2026 def test_post_create_private
2028 2027 @request.session[:user_id] = 2
2029 2028
2030 2029 assert_difference 'Issue.count' do
2031 2030 post :create, :project_id => 1,
2032 2031 :issue => {:tracker_id => 1,
2033 2032 :subject => 'This is a private issue',
2034 2033 :is_private => '1'}
2035 2034 end
2036 2035 issue = Issue.first(:order => 'id DESC')
2037 2036 assert issue.is_private?
2038 2037 end
2039 2038
2040 2039 def test_post_create_private_with_set_own_issues_private_permission
2041 2040 role = Role.find(1)
2042 2041 role.remove_permission! :set_issues_private
2043 2042 role.add_permission! :set_own_issues_private
2044 2043
2045 2044 @request.session[:user_id] = 2
2046 2045
2047 2046 assert_difference 'Issue.count' do
2048 2047 post :create, :project_id => 1,
2049 2048 :issue => {:tracker_id => 1,
2050 2049 :subject => 'This is a private issue',
2051 2050 :is_private => '1'}
2052 2051 end
2053 2052 issue = Issue.first(:order => 'id DESC')
2054 2053 assert issue.is_private?
2055 2054 end
2056 2055
2057 2056 def test_post_create_should_send_a_notification
2058 2057 ActionMailer::Base.deliveries.clear
2059 2058 @request.session[:user_id] = 2
2060 2059 assert_difference 'Issue.count' do
2061 2060 post :create, :project_id => 1,
2062 2061 :issue => {:tracker_id => 3,
2063 2062 :subject => 'This is the test_new issue',
2064 2063 :description => 'This is the description',
2065 2064 :priority_id => 5,
2066 2065 :estimated_hours => '',
2067 2066 :custom_field_values => {'2' => 'Value for field 2'}}
2068 2067 end
2069 2068 assert_redirected_to :controller => 'issues', :action => 'show', :id => Issue.last.id
2070 2069
2071 2070 assert_equal 1, ActionMailer::Base.deliveries.size
2072 2071 end
2073 2072
2074 2073 def test_post_create_should_preserve_fields_values_on_validation_failure
2075 2074 @request.session[:user_id] = 2
2076 2075 post :create, :project_id => 1,
2077 2076 :issue => {:tracker_id => 1,
2078 2077 # empty subject
2079 2078 :subject => '',
2080 2079 :description => 'This is a description',
2081 2080 :priority_id => 6,
2082 2081 :custom_field_values => {'1' => 'Oracle', '2' => 'Value for field 2'}}
2083 2082 assert_response :success
2084 2083 assert_template 'new'
2085 2084
2086 2085 assert_tag :textarea, :attributes => { :name => 'issue[description]' },
2087 2086 :content => "\nThis is a description"
2088 2087 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
2089 2088 :child => { :tag => 'option', :attributes => { :selected => 'selected',
2090 2089 :value => '6' },
2091 2090 :content => 'High' }
2092 2091 # Custom fields
2093 2092 assert_tag :select, :attributes => { :name => 'issue[custom_field_values][1]' },
2094 2093 :child => { :tag => 'option', :attributes => { :selected => 'selected',
2095 2094 :value => 'Oracle' },
2096 2095 :content => 'Oracle' }
2097 2096 assert_tag :input, :attributes => { :name => 'issue[custom_field_values][2]',
2098 2097 :value => 'Value for field 2'}
2099 2098 end
2100 2099
2101 2100 def test_post_create_with_failure_should_preserve_watchers
2102 2101 assert !User.find(8).member_of?(Project.find(1))
2103 2102
2104 2103 @request.session[:user_id] = 2
2105 2104 post :create, :project_id => 1,
2106 2105 :issue => {:tracker_id => 1,
2107 2106 :watcher_user_ids => ['3', '8']}
2108 2107 assert_response :success
2109 2108 assert_template 'new'
2110 2109
2111 2110 assert_tag 'input', :attributes => {:name => 'issue[watcher_user_ids][]', :value => '2', :checked => nil}
2112 2111 assert_tag 'input', :attributes => {:name => 'issue[watcher_user_ids][]', :value => '3', :checked => 'checked'}
2113 2112 assert_tag 'input', :attributes => {:name => 'issue[watcher_user_ids][]', :value => '8', :checked => 'checked'}
2114 2113 end
2115 2114
2116 2115 def test_post_create_should_ignore_non_safe_attributes
2117 2116 @request.session[:user_id] = 2
2118 2117 assert_nothing_raised do
2119 2118 post :create, :project_id => 1, :issue => { :tracker => "A param can not be a Tracker" }
2120 2119 end
2121 2120 end
2122 2121
2123 2122 def test_post_create_with_attachment
2124 2123 set_tmp_attachments_directory
2125 2124 @request.session[:user_id] = 2
2126 2125
2127 2126 assert_difference 'Issue.count' do
2128 2127 assert_difference 'Attachment.count' do
2129 2128 post :create, :project_id => 1,
2130 2129 :issue => { :tracker_id => '1', :subject => 'With attachment' },
2131 2130 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'test file'}}
2132 2131 end
2133 2132 end
2134 2133
2135 2134 issue = Issue.first(:order => 'id DESC')
2136 2135 attachment = Attachment.first(:order => 'id DESC')
2137 2136
2138 2137 assert_equal issue, attachment.container
2139 2138 assert_equal 2, attachment.author_id
2140 2139 assert_equal 'testfile.txt', attachment.filename
2141 2140 assert_equal 'text/plain', attachment.content_type
2142 2141 assert_equal 'test file', attachment.description
2143 2142 assert_equal 59, attachment.filesize
2144 2143 assert File.exists?(attachment.diskfile)
2145 2144 assert_equal 59, File.size(attachment.diskfile)
2146 2145 end
2147 2146
2148 2147 def test_post_create_with_failure_should_save_attachments
2149 2148 set_tmp_attachments_directory
2150 2149 @request.session[:user_id] = 2
2151 2150
2152 2151 assert_no_difference 'Issue.count' do
2153 2152 assert_difference 'Attachment.count' do
2154 2153 post :create, :project_id => 1,
2155 2154 :issue => { :tracker_id => '1', :subject => '' },
2156 2155 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'test file'}}
2157 2156 assert_response :success
2158 2157 assert_template 'new'
2159 2158 end
2160 2159 end
2161 2160
2162 2161 attachment = Attachment.first(:order => 'id DESC')
2163 2162 assert_equal 'testfile.txt', attachment.filename
2164 2163 assert File.exists?(attachment.diskfile)
2165 2164 assert_nil attachment.container
2166 2165
2167 2166 assert_tag 'input', :attributes => {:name => 'attachments[p0][token]', :value => attachment.token}
2168 assert_tag 'span', :content => /testfile.txt/
2167 assert_tag 'input', :attributes => {:name => 'attachments[p0][filename]', :value => 'testfile.txt'}
2169 2168 end
2170 2169
2171 2170 def test_post_create_with_failure_should_keep_saved_attachments
2172 2171 set_tmp_attachments_directory
2173 2172 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
2174 2173 @request.session[:user_id] = 2
2175 2174
2176 2175 assert_no_difference 'Issue.count' do
2177 2176 assert_no_difference 'Attachment.count' do
2178 2177 post :create, :project_id => 1,
2179 2178 :issue => { :tracker_id => '1', :subject => '' },
2180 2179 :attachments => {'p0' => {'token' => attachment.token}}
2181 2180 assert_response :success
2182 2181 assert_template 'new'
2183 2182 end
2184 2183 end
2185 2184
2186 2185 assert_tag 'input', :attributes => {:name => 'attachments[p0][token]', :value => attachment.token}
2187 assert_tag 'span', :content => /testfile.txt/
2186 assert_tag 'input', :attributes => {:name => 'attachments[p0][filename]', :value => 'testfile.txt'}
2188 2187 end
2189 2188
2190 2189 def test_post_create_should_attach_saved_attachments
2191 2190 set_tmp_attachments_directory
2192 2191 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
2193 2192 @request.session[:user_id] = 2
2194 2193
2195 2194 assert_difference 'Issue.count' do
2196 2195 assert_no_difference 'Attachment.count' do
2197 2196 post :create, :project_id => 1,
2198 2197 :issue => { :tracker_id => '1', :subject => 'Saved attachments' },
2199 2198 :attachments => {'p0' => {'token' => attachment.token}}
2200 2199 assert_response 302
2201 2200 end
2202 2201 end
2203 2202
2204 2203 issue = Issue.first(:order => 'id DESC')
2205 2204 assert_equal 1, issue.attachments.count
2206 2205
2207 2206 attachment.reload
2208 2207 assert_equal issue, attachment.container
2209 2208 end
2210 2209
2211 2210 context "without workflow privilege" do
2212 2211 setup do
2213 2212 WorkflowTransition.delete_all(["role_id = ?", Role.anonymous.id])
2214 2213 Role.anonymous.add_permission! :add_issues, :add_issue_notes
2215 2214 end
2216 2215
2217 2216 context "#new" do
2218 2217 should "propose default status only" do
2219 2218 get :new, :project_id => 1
2220 2219 assert_response :success
2221 2220 assert_template 'new'
2222 2221 assert_tag :tag => 'select',
2223 2222 :attributes => {:name => 'issue[status_id]'},
2224 2223 :children => {:count => 1},
2225 2224 :child => {:tag => 'option', :attributes => {:value => IssueStatus.default.id.to_s}}
2226 2225 end
2227 2226
2228 2227 should "accept default status" do
2229 2228 assert_difference 'Issue.count' do
2230 2229 post :create, :project_id => 1,
2231 2230 :issue => {:tracker_id => 1,
2232 2231 :subject => 'This is an issue',
2233 2232 :status_id => 1}
2234 2233 end
2235 2234 issue = Issue.last(:order => 'id')
2236 2235 assert_equal IssueStatus.default, issue.status
2237 2236 end
2238 2237
2239 2238 should "ignore unauthorized status" do
2240 2239 assert_difference 'Issue.count' do
2241 2240 post :create, :project_id => 1,
2242 2241 :issue => {:tracker_id => 1,
2243 2242 :subject => 'This is an issue',
2244 2243 :status_id => 3}
2245 2244 end
2246 2245 issue = Issue.last(:order => 'id')
2247 2246 assert_equal IssueStatus.default, issue.status
2248 2247 end
2249 2248 end
2250 2249
2251 2250 context "#update" do
2252 2251 should "ignore status change" do
2253 2252 assert_difference 'Journal.count' do
2254 2253 put :update, :id => 1, :issue => {:status_id => 3, :notes => 'just trying'}
2255 2254 end
2256 2255 assert_equal 1, Issue.find(1).status_id
2257 2256 end
2258 2257
2259 2258 should "ignore attributes changes" do
2260 2259 assert_difference 'Journal.count' do
2261 2260 put :update, :id => 1, :issue => {:subject => 'changed', :assigned_to_id => 2, :notes => 'just trying'}
2262 2261 end
2263 2262 issue = Issue.find(1)
2264 2263 assert_equal "Can't print recipes", issue.subject
2265 2264 assert_nil issue.assigned_to
2266 2265 end
2267 2266 end
2268 2267 end
2269 2268
2270 2269 context "with workflow privilege" do
2271 2270 setup do
2272 2271 WorkflowTransition.delete_all(["role_id = ?", Role.anonymous.id])
2273 2272 WorkflowTransition.create!(:role => Role.anonymous, :tracker_id => 1, :old_status_id => 1, :new_status_id => 3)
2274 2273 WorkflowTransition.create!(:role => Role.anonymous, :tracker_id => 1, :old_status_id => 1, :new_status_id => 4)
2275 2274 Role.anonymous.add_permission! :add_issues, :add_issue_notes
2276 2275 end
2277 2276
2278 2277 context "#update" do
2279 2278 should "accept authorized status" do
2280 2279 assert_difference 'Journal.count' do
2281 2280 put :update, :id => 1, :issue => {:status_id => 3, :notes => 'just trying'}
2282 2281 end
2283 2282 assert_equal 3, Issue.find(1).status_id
2284 2283 end
2285 2284
2286 2285 should "ignore unauthorized status" do
2287 2286 assert_difference 'Journal.count' do
2288 2287 put :update, :id => 1, :issue => {:status_id => 2, :notes => 'just trying'}
2289 2288 end
2290 2289 assert_equal 1, Issue.find(1).status_id
2291 2290 end
2292 2291
2293 2292 should "accept authorized attributes changes" do
2294 2293 assert_difference 'Journal.count' do
2295 2294 put :update, :id => 1, :issue => {:assigned_to_id => 2, :notes => 'just trying'}
2296 2295 end
2297 2296 issue = Issue.find(1)
2298 2297 assert_equal 2, issue.assigned_to_id
2299 2298 end
2300 2299
2301 2300 should "ignore unauthorized attributes changes" do
2302 2301 assert_difference 'Journal.count' do
2303 2302 put :update, :id => 1, :issue => {:subject => 'changed', :notes => 'just trying'}
2304 2303 end
2305 2304 issue = Issue.find(1)
2306 2305 assert_equal "Can't print recipes", issue.subject
2307 2306 end
2308 2307 end
2309 2308
2310 2309 context "and :edit_issues permission" do
2311 2310 setup do
2312 2311 Role.anonymous.add_permission! :add_issues, :edit_issues
2313 2312 end
2314 2313
2315 2314 should "accept authorized status" do
2316 2315 assert_difference 'Journal.count' do
2317 2316 put :update, :id => 1, :issue => {:status_id => 3, :notes => 'just trying'}
2318 2317 end
2319 2318 assert_equal 3, Issue.find(1).status_id
2320 2319 end
2321 2320
2322 2321 should "ignore unauthorized status" do
2323 2322 assert_difference 'Journal.count' do
2324 2323 put :update, :id => 1, :issue => {:status_id => 2, :notes => 'just trying'}
2325 2324 end
2326 2325 assert_equal 1, Issue.find(1).status_id
2327 2326 end
2328 2327
2329 2328 should "accept authorized attributes changes" do
2330 2329 assert_difference 'Journal.count' do
2331 2330 put :update, :id => 1, :issue => {:subject => 'changed', :assigned_to_id => 2, :notes => 'just trying'}
2332 2331 end
2333 2332 issue = Issue.find(1)
2334 2333 assert_equal "changed", issue.subject
2335 2334 assert_equal 2, issue.assigned_to_id
2336 2335 end
2337 2336 end
2338 2337 end
2339 2338
2340 2339 def test_new_as_copy
2341 2340 @request.session[:user_id] = 2
2342 2341 get :new, :project_id => 1, :copy_from => 1
2343 2342
2344 2343 assert_response :success
2345 2344 assert_template 'new'
2346 2345
2347 2346 assert_not_nil assigns(:issue)
2348 2347 orig = Issue.find(1)
2349 2348 assert_equal 1, assigns(:issue).project_id
2350 2349 assert_equal orig.subject, assigns(:issue).subject
2351 2350 assert assigns(:issue).copy?
2352 2351
2353 2352 assert_tag 'form', :attributes => {:id => 'issue-form', :action => '/projects/ecookbook/issues'}
2354 2353 assert_tag 'select', :attributes => {:name => 'issue[project_id]'}
2355 2354 assert_tag 'select', :attributes => {:name => 'issue[project_id]'},
2356 2355 :child => {:tag => 'option', :attributes => {:value => '1', :selected => 'selected'}, :content => 'eCookbook'}
2357 2356 assert_tag 'select', :attributes => {:name => 'issue[project_id]'},
2358 2357 :child => {:tag => 'option', :attributes => {:value => '2', :selected => nil}, :content => 'OnlineStore'}
2359 2358 assert_tag 'input', :attributes => {:name => 'copy_from', :value => '1'}
2360 2359 end
2361 2360
2362 2361 def test_new_as_copy_with_attachments_should_show_copy_attachments_checkbox
2363 2362 @request.session[:user_id] = 2
2364 2363 issue = Issue.find(3)
2365 2364 assert issue.attachments.count > 0
2366 2365 get :new, :project_id => 1, :copy_from => 3
2367 2366
2368 2367 assert_tag 'input', :attributes => {:name => 'copy_attachments', :type => 'checkbox', :checked => 'checked', :value => '1'}
2369 2368 end
2370 2369
2371 2370 def test_new_as_copy_without_attachments_should_not_show_copy_attachments_checkbox
2372 2371 @request.session[:user_id] = 2
2373 2372 issue = Issue.find(3)
2374 2373 issue.attachments.delete_all
2375 2374 get :new, :project_id => 1, :copy_from => 3
2376 2375
2377 2376 assert_no_tag 'input', :attributes => {:name => 'copy_attachments', :type => 'checkbox', :checked => 'checked', :value => '1'}
2378 2377 end
2379 2378
2380 2379 def test_new_as_copy_with_subtasks_should_show_copy_subtasks_checkbox
2381 2380 @request.session[:user_id] = 2
2382 2381 issue = Issue.generate_with_descendants!
2383 2382 get :new, :project_id => 1, :copy_from => issue.id
2384 2383
2385 2384 assert_select 'input[type=checkbox][name=copy_subtasks][checked=checked][value=1]'
2386 2385 end
2387 2386
2388 2387 def test_new_as_copy_with_invalid_issue_should_respond_with_404
2389 2388 @request.session[:user_id] = 2
2390 2389 get :new, :project_id => 1, :copy_from => 99999
2391 2390 assert_response 404
2392 2391 end
2393 2392
2394 2393 def test_create_as_copy_on_different_project
2395 2394 @request.session[:user_id] = 2
2396 2395 assert_difference 'Issue.count' do
2397 2396 post :create, :project_id => 1, :copy_from => 1,
2398 2397 :issue => {:project_id => '2', :tracker_id => '3', :status_id => '1', :subject => 'Copy'}
2399 2398
2400 2399 assert_not_nil assigns(:issue)
2401 2400 assert assigns(:issue).copy?
2402 2401 end
2403 2402 issue = Issue.first(:order => 'id DESC')
2404 2403 assert_redirected_to "/issues/#{issue.id}"
2405 2404
2406 2405 assert_equal 2, issue.project_id
2407 2406 assert_equal 3, issue.tracker_id
2408 2407 assert_equal 'Copy', issue.subject
2409 2408 end
2410 2409
2411 2410 def test_create_as_copy_should_copy_attachments
2412 2411 @request.session[:user_id] = 2
2413 2412 issue = Issue.find(3)
2414 2413 count = issue.attachments.count
2415 2414 assert count > 0
2416 2415
2417 2416 assert_difference 'Issue.count' do
2418 2417 assert_difference 'Attachment.count', count do
2419 2418 assert_no_difference 'Journal.count' do
2420 2419 post :create, :project_id => 1, :copy_from => 3,
2421 2420 :issue => {:project_id => '1', :tracker_id => '3', :status_id => '1', :subject => 'Copy with attachments'},
2422 2421 :copy_attachments => '1'
2423 2422 end
2424 2423 end
2425 2424 end
2426 2425 copy = Issue.first(:order => 'id DESC')
2427 2426 assert_equal count, copy.attachments.count
2428 2427 assert_equal issue.attachments.map(&:filename).sort, copy.attachments.map(&:filename).sort
2429 2428 end
2430 2429
2431 2430 def test_create_as_copy_without_copy_attachments_option_should_not_copy_attachments
2432 2431 @request.session[:user_id] = 2
2433 2432 issue = Issue.find(3)
2434 2433 count = issue.attachments.count
2435 2434 assert count > 0
2436 2435
2437 2436 assert_difference 'Issue.count' do
2438 2437 assert_no_difference 'Attachment.count' do
2439 2438 assert_no_difference 'Journal.count' do
2440 2439 post :create, :project_id => 1, :copy_from => 3,
2441 2440 :issue => {:project_id => '1', :tracker_id => '3', :status_id => '1', :subject => 'Copy with attachments'}
2442 2441 end
2443 2442 end
2444 2443 end
2445 2444 copy = Issue.first(:order => 'id DESC')
2446 2445 assert_equal 0, copy.attachments.count
2447 2446 end
2448 2447
2449 2448 def test_create_as_copy_with_attachments_should_add_new_files
2450 2449 @request.session[:user_id] = 2
2451 2450 issue = Issue.find(3)
2452 2451 count = issue.attachments.count
2453 2452 assert count > 0
2454 2453
2455 2454 assert_difference 'Issue.count' do
2456 2455 assert_difference 'Attachment.count', count + 1 do
2457 2456 assert_no_difference 'Journal.count' do
2458 2457 post :create, :project_id => 1, :copy_from => 3,
2459 2458 :issue => {:project_id => '1', :tracker_id => '3', :status_id => '1', :subject => 'Copy with attachments'},
2460 2459 :copy_attachments => '1',
2461 2460 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'test file'}}
2462 2461 end
2463 2462 end
2464 2463 end
2465 2464 copy = Issue.first(:order => 'id DESC')
2466 2465 assert_equal count + 1, copy.attachments.count
2467 2466 end
2468 2467
2469 2468 def test_create_as_copy_should_add_relation_with_copied_issue
2470 2469 @request.session[:user_id] = 2
2471 2470
2472 2471 assert_difference 'Issue.count' do
2473 2472 assert_difference 'IssueRelation.count' do
2474 2473 post :create, :project_id => 1, :copy_from => 1,
2475 2474 :issue => {:project_id => '1', :tracker_id => '3', :status_id => '1', :subject => 'Copy'}
2476 2475 end
2477 2476 end
2478 2477 copy = Issue.first(:order => 'id DESC')
2479 2478 assert_equal 1, copy.relations.size
2480 2479 end
2481 2480
2482 2481 def test_create_as_copy_should_copy_subtasks
2483 2482 @request.session[:user_id] = 2
2484 2483 issue = Issue.generate_with_descendants!
2485 2484 count = issue.descendants.count
2486 2485
2487 2486 assert_difference 'Issue.count', count+1 do
2488 2487 assert_no_difference 'Journal.count' do
2489 2488 post :create, :project_id => 1, :copy_from => issue.id,
2490 2489 :issue => {:project_id => '1', :tracker_id => '3', :status_id => '1', :subject => 'Copy with subtasks'},
2491 2490 :copy_subtasks => '1'
2492 2491 end
2493 2492 end
2494 2493 copy = Issue.where(:parent_id => nil).first(:order => 'id DESC')
2495 2494 assert_equal count, copy.descendants.count
2496 2495 assert_equal issue.descendants.map(&:subject).sort, copy.descendants.map(&:subject).sort
2497 2496 end
2498 2497
2499 2498 def test_create_as_copy_without_copy_subtasks_option_should_not_copy_subtasks
2500 2499 @request.session[:user_id] = 2
2501 2500 issue = Issue.generate_with_descendants!
2502 2501
2503 2502 assert_difference 'Issue.count', 1 do
2504 2503 assert_no_difference 'Journal.count' do
2505 2504 post :create, :project_id => 1, :copy_from => 3,
2506 2505 :issue => {:project_id => '1', :tracker_id => '3', :status_id => '1', :subject => 'Copy with subtasks'}
2507 2506 end
2508 2507 end
2509 2508 copy = Issue.where(:parent_id => nil).first(:order => 'id DESC')
2510 2509 assert_equal 0, copy.descendants.count
2511 2510 end
2512 2511
2513 2512 def test_create_as_copy_with_failure
2514 2513 @request.session[:user_id] = 2
2515 2514 post :create, :project_id => 1, :copy_from => 1,
2516 2515 :issue => {:project_id => '2', :tracker_id => '3', :status_id => '1', :subject => ''}
2517 2516
2518 2517 assert_response :success
2519 2518 assert_template 'new'
2520 2519
2521 2520 assert_not_nil assigns(:issue)
2522 2521 assert assigns(:issue).copy?
2523 2522
2524 2523 assert_tag 'form', :attributes => {:id => 'issue-form', :action => '/projects/ecookbook/issues'}
2525 2524 assert_tag 'select', :attributes => {:name => 'issue[project_id]'}
2526 2525 assert_tag 'select', :attributes => {:name => 'issue[project_id]'},
2527 2526 :child => {:tag => 'option', :attributes => {:value => '1', :selected => nil}, :content => 'eCookbook'}
2528 2527 assert_tag 'select', :attributes => {:name => 'issue[project_id]'},
2529 2528 :child => {:tag => 'option', :attributes => {:value => '2', :selected => 'selected'}, :content => 'OnlineStore'}
2530 2529 assert_tag 'input', :attributes => {:name => 'copy_from', :value => '1'}
2531 2530 end
2532 2531
2533 2532 def test_create_as_copy_on_project_without_permission_should_ignore_target_project
2534 2533 @request.session[:user_id] = 2
2535 2534 assert !User.find(2).member_of?(Project.find(4))
2536 2535
2537 2536 assert_difference 'Issue.count' do
2538 2537 post :create, :project_id => 1, :copy_from => 1,
2539 2538 :issue => {:project_id => '4', :tracker_id => '3', :status_id => '1', :subject => 'Copy'}
2540 2539 end
2541 2540 issue = Issue.first(:order => 'id DESC')
2542 2541 assert_equal 1, issue.project_id
2543 2542 end
2544 2543
2545 2544 def test_get_edit
2546 2545 @request.session[:user_id] = 2
2547 2546 get :edit, :id => 1
2548 2547 assert_response :success
2549 2548 assert_template 'edit'
2550 2549 assert_not_nil assigns(:issue)
2551 2550 assert_equal Issue.find(1), assigns(:issue)
2552 2551
2553 2552 # Be sure we don't display inactive IssuePriorities
2554 2553 assert ! IssuePriority.find(15).active?
2555 2554 assert_no_tag :option, :attributes => {:value => '15'},
2556 2555 :parent => {:tag => 'select', :attributes => {:id => 'issue_priority_id'} }
2557 2556 end
2558 2557
2559 2558 def test_get_edit_should_display_the_time_entry_form_with_log_time_permission
2560 2559 @request.session[:user_id] = 2
2561 2560 Role.find_by_name('Manager').update_attribute :permissions, [:view_issues, :edit_issues, :log_time]
2562 2561
2563 2562 get :edit, :id => 1
2564 2563 assert_tag 'input', :attributes => {:name => 'time_entry[hours]'}
2565 2564 end
2566 2565
2567 2566 def test_get_edit_should_not_display_the_time_entry_form_without_log_time_permission
2568 2567 @request.session[:user_id] = 2
2569 2568 Role.find_by_name('Manager').remove_permission! :log_time
2570 2569
2571 2570 get :edit, :id => 1
2572 2571 assert_no_tag 'input', :attributes => {:name => 'time_entry[hours]'}
2573 2572 end
2574 2573
2575 2574 def test_get_edit_with_params
2576 2575 @request.session[:user_id] = 2
2577 2576 get :edit, :id => 1, :issue => { :status_id => 5, :priority_id => 7 },
2578 2577 :time_entry => { :hours => '2.5', :comments => 'test_get_edit_with_params', :activity_id => TimeEntryActivity.first.id }
2579 2578 assert_response :success
2580 2579 assert_template 'edit'
2581 2580
2582 2581 issue = assigns(:issue)
2583 2582 assert_not_nil issue
2584 2583
2585 2584 assert_equal 5, issue.status_id
2586 2585 assert_tag :select, :attributes => { :name => 'issue[status_id]' },
2587 2586 :child => { :tag => 'option',
2588 2587 :content => 'Closed',
2589 2588 :attributes => { :selected => 'selected' } }
2590 2589
2591 2590 assert_equal 7, issue.priority_id
2592 2591 assert_tag :select, :attributes => { :name => 'issue[priority_id]' },
2593 2592 :child => { :tag => 'option',
2594 2593 :content => 'Urgent',
2595 2594 :attributes => { :selected => 'selected' } }
2596 2595
2597 2596 assert_tag :input, :attributes => { :name => 'time_entry[hours]', :value => '2.5' }
2598 2597 assert_tag :select, :attributes => { :name => 'time_entry[activity_id]' },
2599 2598 :child => { :tag => 'option',
2600 2599 :attributes => { :selected => 'selected', :value => TimeEntryActivity.first.id } }
2601 2600 assert_tag :input, :attributes => { :name => 'time_entry[comments]', :value => 'test_get_edit_with_params' }
2602 2601 end
2603 2602
2604 2603 def test_get_edit_with_multi_custom_field
2605 2604 field = CustomField.find(1)
2606 2605 field.update_attribute :multiple, true
2607 2606 issue = Issue.find(1)
2608 2607 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
2609 2608 issue.save!
2610 2609
2611 2610 @request.session[:user_id] = 2
2612 2611 get :edit, :id => 1
2613 2612 assert_response :success
2614 2613 assert_template 'edit'
2615 2614
2616 2615 assert_tag 'select', :attributes => {:name => 'issue[custom_field_values][1][]', :multiple => 'multiple'}
2617 2616 assert_tag 'select', :attributes => {:name => 'issue[custom_field_values][1][]'},
2618 2617 :child => {:tag => 'option', :attributes => {:value => 'MySQL', :selected => 'selected'}}
2619 2618 assert_tag 'select', :attributes => {:name => 'issue[custom_field_values][1][]'},
2620 2619 :child => {:tag => 'option', :attributes => {:value => 'PostgreSQL', :selected => nil}}
2621 2620 assert_tag 'select', :attributes => {:name => 'issue[custom_field_values][1][]'},
2622 2621 :child => {:tag => 'option', :attributes => {:value => 'Oracle', :selected => 'selected'}}
2623 2622 end
2624 2623
2625 2624 def test_update_edit_form
2626 2625 @request.session[:user_id] = 2
2627 2626 xhr :put, :new, :project_id => 1,
2628 2627 :id => 1,
2629 2628 :issue => {:tracker_id => 2,
2630 2629 :subject => 'This is the test_new issue',
2631 2630 :description => 'This is the description',
2632 2631 :priority_id => 5}
2633 2632 assert_response :success
2634 2633 assert_equal 'text/javascript', response.content_type
2635 2634 assert_template 'update_form'
2636 2635 assert_template 'form'
2637 2636
2638 2637 issue = assigns(:issue)
2639 2638 assert_kind_of Issue, issue
2640 2639 assert_equal 1, issue.id
2641 2640 assert_equal 1, issue.project_id
2642 2641 assert_equal 2, issue.tracker_id
2643 2642 assert_equal 'This is the test_new issue', issue.subject
2644 2643 end
2645 2644
2646 2645 def test_update_edit_form_should_keep_issue_author
2647 2646 @request.session[:user_id] = 3
2648 2647 xhr :put, :new, :project_id => 1, :id => 1, :issue => {:subject => 'Changed'}
2649 2648 assert_response :success
2650 2649 assert_equal 'text/javascript', response.content_type
2651 2650
2652 2651 issue = assigns(:issue)
2653 2652 assert_equal User.find(2), issue.author
2654 2653 assert_equal 2, issue.author_id
2655 2654 assert_not_equal User.current, issue.author
2656 2655 end
2657 2656
2658 2657 def test_update_edit_form_should_propose_transitions_based_on_initial_status
2659 2658 @request.session[:user_id] = 2
2660 2659 WorkflowTransition.delete_all
2661 2660 WorkflowTransition.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 2, :new_status_id => 1)
2662 2661 WorkflowTransition.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 2, :new_status_id => 5)
2663 2662 WorkflowTransition.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 5, :new_status_id => 4)
2664 2663
2665 2664 xhr :put, :new, :project_id => 1,
2666 2665 :id => 2,
2667 2666 :issue => {:tracker_id => 2,
2668 2667 :status_id => 5,
2669 2668 :subject => 'This is an issue'}
2670 2669
2671 2670 assert_equal 5, assigns(:issue).status_id
2672 2671 assert_equal [1,2,5], assigns(:allowed_statuses).map(&:id).sort
2673 2672 end
2674 2673
2675 2674 def test_update_edit_form_with_project_change
2676 2675 @request.session[:user_id] = 2
2677 2676 xhr :put, :new, :project_id => 1,
2678 2677 :id => 1,
2679 2678 :issue => {:project_id => 2,
2680 2679 :tracker_id => 2,
2681 2680 :subject => 'This is the test_new issue',
2682 2681 :description => 'This is the description',
2683 2682 :priority_id => 5}
2684 2683 assert_response :success
2685 2684 assert_template 'form'
2686 2685
2687 2686 issue = assigns(:issue)
2688 2687 assert_kind_of Issue, issue
2689 2688 assert_equal 1, issue.id
2690 2689 assert_equal 2, issue.project_id
2691 2690 assert_equal 2, issue.tracker_id
2692 2691 assert_equal 'This is the test_new issue', issue.subject
2693 2692 end
2694 2693
2695 2694 def test_put_update_without_custom_fields_param
2696 2695 @request.session[:user_id] = 2
2697 2696 ActionMailer::Base.deliveries.clear
2698 2697
2699 2698 issue = Issue.find(1)
2700 2699 assert_equal '125', issue.custom_value_for(2).value
2701 2700 old_subject = issue.subject
2702 2701 new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
2703 2702
2704 2703 assert_difference('Journal.count') do
2705 2704 assert_difference('JournalDetail.count', 2) do
2706 2705 put :update, :id => 1, :issue => {:subject => new_subject,
2707 2706 :priority_id => '6',
2708 2707 :category_id => '1' # no change
2709 2708 }
2710 2709 end
2711 2710 end
2712 2711 assert_redirected_to :action => 'show', :id => '1'
2713 2712 issue.reload
2714 2713 assert_equal new_subject, issue.subject
2715 2714 # Make sure custom fields were not cleared
2716 2715 assert_equal '125', issue.custom_value_for(2).value
2717 2716
2718 2717 mail = ActionMailer::Base.deliveries.last
2719 2718 assert_not_nil mail
2720 2719 assert mail.subject.starts_with?("[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}]")
2721 2720 assert_mail_body_match "Subject changed from #{old_subject} to #{new_subject}", mail
2722 2721 end
2723 2722
2724 2723 def test_put_update_with_project_change
2725 2724 @request.session[:user_id] = 2
2726 2725 ActionMailer::Base.deliveries.clear
2727 2726
2728 2727 assert_difference('Journal.count') do
2729 2728 assert_difference('JournalDetail.count', 3) do
2730 2729 put :update, :id => 1, :issue => {:project_id => '2',
2731 2730 :tracker_id => '1', # no change
2732 2731 :priority_id => '6',
2733 2732 :category_id => '3'
2734 2733 }
2735 2734 end
2736 2735 end
2737 2736 assert_redirected_to :action => 'show', :id => '1'
2738 2737 issue = Issue.find(1)
2739 2738 assert_equal 2, issue.project_id
2740 2739 assert_equal 1, issue.tracker_id
2741 2740 assert_equal 6, issue.priority_id
2742 2741 assert_equal 3, issue.category_id
2743 2742
2744 2743 mail = ActionMailer::Base.deliveries.last
2745 2744 assert_not_nil mail
2746 2745 assert mail.subject.starts_with?("[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}]")
2747 2746 assert_mail_body_match "Project changed from eCookbook to OnlineStore", mail
2748 2747 end
2749 2748
2750 2749 def test_put_update_with_tracker_change
2751 2750 @request.session[:user_id] = 2
2752 2751 ActionMailer::Base.deliveries.clear
2753 2752
2754 2753 assert_difference('Journal.count') do
2755 2754 assert_difference('JournalDetail.count', 2) do
2756 2755 put :update, :id => 1, :issue => {:project_id => '1',
2757 2756 :tracker_id => '2',
2758 2757 :priority_id => '6'
2759 2758 }
2760 2759 end
2761 2760 end
2762 2761 assert_redirected_to :action => 'show', :id => '1'
2763 2762 issue = Issue.find(1)
2764 2763 assert_equal 1, issue.project_id
2765 2764 assert_equal 2, issue.tracker_id
2766 2765 assert_equal 6, issue.priority_id
2767 2766 assert_equal 1, issue.category_id
2768 2767
2769 2768 mail = ActionMailer::Base.deliveries.last
2770 2769 assert_not_nil mail
2771 2770 assert mail.subject.starts_with?("[#{issue.project.name} - #{issue.tracker.name} ##{issue.id}]")
2772 2771 assert_mail_body_match "Tracker changed from Bug to Feature request", mail
2773 2772 end
2774 2773
2775 2774 def test_put_update_with_custom_field_change
2776 2775 @request.session[:user_id] = 2
2777 2776 issue = Issue.find(1)
2778 2777 assert_equal '125', issue.custom_value_for(2).value
2779 2778
2780 2779 assert_difference('Journal.count') do
2781 2780 assert_difference('JournalDetail.count', 3) do
2782 2781 put :update, :id => 1, :issue => {:subject => 'Custom field change',
2783 2782 :priority_id => '6',
2784 2783 :category_id => '1', # no change
2785 2784 :custom_field_values => { '2' => 'New custom value' }
2786 2785 }
2787 2786 end
2788 2787 end
2789 2788 assert_redirected_to :action => 'show', :id => '1'
2790 2789 issue.reload
2791 2790 assert_equal 'New custom value', issue.custom_value_for(2).value
2792 2791
2793 2792 mail = ActionMailer::Base.deliveries.last
2794 2793 assert_not_nil mail
2795 2794 assert_mail_body_match "Searchable field changed from 125 to New custom value", mail
2796 2795 end
2797 2796
2798 2797 def test_put_update_with_multi_custom_field_change
2799 2798 field = CustomField.find(1)
2800 2799 field.update_attribute :multiple, true
2801 2800 issue = Issue.find(1)
2802 2801 issue.custom_field_values = {1 => ['MySQL', 'Oracle']}
2803 2802 issue.save!
2804 2803
2805 2804 @request.session[:user_id] = 2
2806 2805 assert_difference('Journal.count') do
2807 2806 assert_difference('JournalDetail.count', 3) do
2808 2807 put :update, :id => 1,
2809 2808 :issue => {
2810 2809 :subject => 'Custom field change',
2811 2810 :custom_field_values => { '1' => ['', 'Oracle', 'PostgreSQL'] }
2812 2811 }
2813 2812 end
2814 2813 end
2815 2814 assert_redirected_to :action => 'show', :id => '1'
2816 2815 assert_equal ['Oracle', 'PostgreSQL'], Issue.find(1).custom_field_value(1).sort
2817 2816 end
2818 2817
2819 2818 def test_put_update_with_status_and_assignee_change
2820 2819 issue = Issue.find(1)
2821 2820 assert_equal 1, issue.status_id
2822 2821 @request.session[:user_id] = 2
2823 2822 assert_difference('TimeEntry.count', 0) do
2824 2823 put :update,
2825 2824 :id => 1,
2826 2825 :issue => { :status_id => 2, :assigned_to_id => 3, :notes => 'Assigned to dlopper' },
2827 2826 :time_entry => { :hours => '', :comments => '', :activity_id => TimeEntryActivity.first }
2828 2827 end
2829 2828 assert_redirected_to :action => 'show', :id => '1'
2830 2829 issue.reload
2831 2830 assert_equal 2, issue.status_id
2832 2831 j = Journal.order('id DESC').first
2833 2832 assert_equal 'Assigned to dlopper', j.notes
2834 2833 assert_equal 2, j.details.size
2835 2834
2836 2835 mail = ActionMailer::Base.deliveries.last
2837 2836 assert_mail_body_match "Status changed from New to Assigned", mail
2838 2837 # subject should contain the new status
2839 2838 assert mail.subject.include?("(#{ IssueStatus.find(2).name })")
2840 2839 end
2841 2840
2842 2841 def test_put_update_with_note_only
2843 2842 notes = 'Note added by IssuesControllerTest#test_update_with_note_only'
2844 2843 # anonymous user
2845 2844 put :update,
2846 2845 :id => 1,
2847 2846 :issue => { :notes => notes }
2848 2847 assert_redirected_to :action => 'show', :id => '1'
2849 2848 j = Journal.order('id DESC').first
2850 2849 assert_equal notes, j.notes
2851 2850 assert_equal 0, j.details.size
2852 2851 assert_equal User.anonymous, j.user
2853 2852
2854 2853 mail = ActionMailer::Base.deliveries.last
2855 2854 assert_mail_body_match notes, mail
2856 2855 end
2857 2856
2858 2857 def test_put_update_with_private_note_only
2859 2858 notes = 'Private note'
2860 2859 @request.session[:user_id] = 2
2861 2860
2862 2861 assert_difference 'Journal.count' do
2863 2862 put :update, :id => 1, :issue => {:notes => notes, :private_notes => '1'}
2864 2863 assert_redirected_to :action => 'show', :id => '1'
2865 2864 end
2866 2865
2867 2866 j = Journal.order('id DESC').first
2868 2867 assert_equal notes, j.notes
2869 2868 assert_equal true, j.private_notes
2870 2869 end
2871 2870
2872 2871 def test_put_update_with_private_note_and_changes
2873 2872 notes = 'Private note'
2874 2873 @request.session[:user_id] = 2
2875 2874
2876 2875 assert_difference 'Journal.count', 2 do
2877 2876 put :update, :id => 1, :issue => {:subject => 'New subject', :notes => notes, :private_notes => '1'}
2878 2877 assert_redirected_to :action => 'show', :id => '1'
2879 2878 end
2880 2879
2881 2880 j = Journal.order('id DESC').first
2882 2881 assert_equal notes, j.notes
2883 2882 assert_equal true, j.private_notes
2884 2883 assert_equal 0, j.details.count
2885 2884
2886 2885 j = Journal.order('id DESC').offset(1).first
2887 2886 assert_nil j.notes
2888 2887 assert_equal false, j.private_notes
2889 2888 assert_equal 1, j.details.count
2890 2889 end
2891 2890
2892 2891 def test_put_update_with_note_and_spent_time
2893 2892 @request.session[:user_id] = 2
2894 2893 spent_hours_before = Issue.find(1).spent_hours
2895 2894 assert_difference('TimeEntry.count') do
2896 2895 put :update,
2897 2896 :id => 1,
2898 2897 :issue => { :notes => '2.5 hours added' },
2899 2898 :time_entry => { :hours => '2.5', :comments => 'test_put_update_with_note_and_spent_time', :activity_id => TimeEntryActivity.first.id }
2900 2899 end
2901 2900 assert_redirected_to :action => 'show', :id => '1'
2902 2901
2903 2902 issue = Issue.find(1)
2904 2903
2905 2904 j = Journal.order('id DESC').first
2906 2905 assert_equal '2.5 hours added', j.notes
2907 2906 assert_equal 0, j.details.size
2908 2907
2909 2908 t = issue.time_entries.find_by_comments('test_put_update_with_note_and_spent_time')
2910 2909 assert_not_nil t
2911 2910 assert_equal 2.5, t.hours
2912 2911 assert_equal spent_hours_before + 2.5, issue.spent_hours
2913 2912 end
2914 2913
2915 2914 def test_put_update_with_attachment_only
2916 2915 set_tmp_attachments_directory
2917 2916
2918 2917 # Delete all fixtured journals, a race condition can occur causing the wrong
2919 2918 # journal to get fetched in the next find.
2920 2919 Journal.delete_all
2921 2920
2922 2921 # anonymous user
2923 2922 assert_difference 'Attachment.count' do
2924 2923 put :update, :id => 1,
2925 2924 :issue => {:notes => ''},
2926 2925 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'test file'}}
2927 2926 end
2928 2927
2929 2928 assert_redirected_to :action => 'show', :id => '1'
2930 2929 j = Issue.find(1).journals.reorder('id DESC').first
2931 2930 assert j.notes.blank?
2932 2931 assert_equal 1, j.details.size
2933 2932 assert_equal 'testfile.txt', j.details.first.value
2934 2933 assert_equal User.anonymous, j.user
2935 2934
2936 2935 attachment = Attachment.first(:order => 'id DESC')
2937 2936 assert_equal Issue.find(1), attachment.container
2938 2937 assert_equal User.anonymous, attachment.author
2939 2938 assert_equal 'testfile.txt', attachment.filename
2940 2939 assert_equal 'text/plain', attachment.content_type
2941 2940 assert_equal 'test file', attachment.description
2942 2941 assert_equal 59, attachment.filesize
2943 2942 assert File.exists?(attachment.diskfile)
2944 2943 assert_equal 59, File.size(attachment.diskfile)
2945 2944
2946 2945 mail = ActionMailer::Base.deliveries.last
2947 2946 assert_mail_body_match 'testfile.txt', mail
2948 2947 end
2949 2948
2950 2949 def test_put_update_with_failure_should_save_attachments
2951 2950 set_tmp_attachments_directory
2952 2951 @request.session[:user_id] = 2
2953 2952
2954 2953 assert_no_difference 'Journal.count' do
2955 2954 assert_difference 'Attachment.count' do
2956 2955 put :update, :id => 1,
2957 2956 :issue => { :subject => '' },
2958 2957 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain'), 'description' => 'test file'}}
2959 2958 assert_response :success
2960 2959 assert_template 'edit'
2961 2960 end
2962 2961 end
2963 2962
2964 2963 attachment = Attachment.first(:order => 'id DESC')
2965 2964 assert_equal 'testfile.txt', attachment.filename
2966 2965 assert File.exists?(attachment.diskfile)
2967 2966 assert_nil attachment.container
2968 2967
2969 2968 assert_tag 'input', :attributes => {:name => 'attachments[p0][token]', :value => attachment.token}
2970 assert_tag 'span', :content => /testfile.txt/
2969 assert_tag 'input', :attributes => {:name => 'attachments[p0][filename]', :value => 'testfile.txt'}
2971 2970 end
2972 2971
2973 2972 def test_put_update_with_failure_should_keep_saved_attachments
2974 2973 set_tmp_attachments_directory
2975 2974 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
2976 2975 @request.session[:user_id] = 2
2977 2976
2978 2977 assert_no_difference 'Journal.count' do
2979 2978 assert_no_difference 'Attachment.count' do
2980 2979 put :update, :id => 1,
2981 2980 :issue => { :subject => '' },
2982 2981 :attachments => {'p0' => {'token' => attachment.token}}
2983 2982 assert_response :success
2984 2983 assert_template 'edit'
2985 2984 end
2986 2985 end
2987 2986
2988 2987 assert_tag 'input', :attributes => {:name => 'attachments[p0][token]', :value => attachment.token}
2989 assert_tag 'span', :content => /testfile.txt/
2988 assert_tag 'input', :attributes => {:name => 'attachments[p0][filename]', :value => 'testfile.txt'}
2990 2989 end
2991 2990
2992 2991 def test_put_update_should_attach_saved_attachments
2993 2992 set_tmp_attachments_directory
2994 2993 attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", "text/plain"), :author_id => 2)
2995 2994 @request.session[:user_id] = 2
2996 2995
2997 2996 assert_difference 'Journal.count' do
2998 2997 assert_difference 'JournalDetail.count' do
2999 2998 assert_no_difference 'Attachment.count' do
3000 2999 put :update, :id => 1,
3001 3000 :issue => {:notes => 'Attachment added'},
3002 3001 :attachments => {'p0' => {'token' => attachment.token}}
3003 3002 assert_redirected_to '/issues/1'
3004 3003 end
3005 3004 end
3006 3005 end
3007 3006
3008 3007 attachment.reload
3009 3008 assert_equal Issue.find(1), attachment.container
3010 3009
3011 3010 journal = Journal.first(:order => 'id DESC')
3012 3011 assert_equal 1, journal.details.size
3013 3012 assert_equal 'testfile.txt', journal.details.first.value
3014 3013 end
3015 3014
3016 3015 def test_put_update_with_attachment_that_fails_to_save
3017 3016 set_tmp_attachments_directory
3018 3017
3019 3018 # Delete all fixtured journals, a race condition can occur causing the wrong
3020 3019 # journal to get fetched in the next find.
3021 3020 Journal.delete_all
3022 3021
3023 3022 # Mock out the unsaved attachment
3024 3023 Attachment.any_instance.stubs(:create).returns(Attachment.new)
3025 3024
3026 3025 # anonymous user
3027 3026 put :update,
3028 3027 :id => 1,
3029 3028 :issue => {:notes => ''},
3030 3029 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}}
3031 3030 assert_redirected_to :action => 'show', :id => '1'
3032 3031 assert_equal '1 file(s) could not be saved.', flash[:warning]
3033 3032 end
3034 3033
3035 3034 def test_put_update_with_no_change
3036 3035 issue = Issue.find(1)
3037 3036 issue.journals.clear
3038 3037 ActionMailer::Base.deliveries.clear
3039 3038
3040 3039 put :update,
3041 3040 :id => 1,
3042 3041 :issue => {:notes => ''}
3043 3042 assert_redirected_to :action => 'show', :id => '1'
3044 3043
3045 3044 issue.reload
3046 3045 assert issue.journals.empty?
3047 3046 # No email should be sent
3048 3047 assert ActionMailer::Base.deliveries.empty?
3049 3048 end
3050 3049
3051 3050 def test_put_update_should_send_a_notification
3052 3051 @request.session[:user_id] = 2
3053 3052 ActionMailer::Base.deliveries.clear
3054 3053 issue = Issue.find(1)
3055 3054 old_subject = issue.subject
3056 3055 new_subject = 'Subject modified by IssuesControllerTest#test_post_edit'
3057 3056
3058 3057 put :update, :id => 1, :issue => {:subject => new_subject,
3059 3058 :priority_id => '6',
3060 3059 :category_id => '1' # no change
3061 3060 }
3062 3061 assert_equal 1, ActionMailer::Base.deliveries.size
3063 3062 end
3064 3063
3065 3064 def test_put_update_with_invalid_spent_time_hours_only
3066 3065 @request.session[:user_id] = 2
3067 3066 notes = 'Note added by IssuesControllerTest#test_post_edit_with_invalid_spent_time'
3068 3067
3069 3068 assert_no_difference('Journal.count') do
3070 3069 put :update,
3071 3070 :id => 1,
3072 3071 :issue => {:notes => notes},
3073 3072 :time_entry => {"comments"=>"", "activity_id"=>"", "hours"=>"2z"}
3074 3073 end
3075 3074 assert_response :success
3076 3075 assert_template 'edit'
3077 3076
3078 3077 assert_error_tag :descendant => {:content => /Activity can&#x27;t be blank/}
3079 3078 assert_tag :textarea, :attributes => { :name => 'issue[notes]' }, :content => "\n"+notes
3080 3079 assert_tag :input, :attributes => { :name => 'time_entry[hours]', :value => "2z" }
3081 3080 end
3082 3081
3083 3082 def test_put_update_with_invalid_spent_time_comments_only
3084 3083 @request.session[:user_id] = 2
3085 3084 notes = 'Note added by IssuesControllerTest#test_post_edit_with_invalid_spent_time'
3086 3085
3087 3086 assert_no_difference('Journal.count') do
3088 3087 put :update,
3089 3088 :id => 1,
3090 3089 :issue => {:notes => notes},
3091 3090 :time_entry => {"comments"=>"this is my comment", "activity_id"=>"", "hours"=>""}
3092 3091 end
3093 3092 assert_response :success
3094 3093 assert_template 'edit'
3095 3094
3096 3095 assert_error_tag :descendant => {:content => /Activity can&#x27;t be blank/}
3097 3096 assert_error_tag :descendant => {:content => /Hours can&#x27;t be blank/}
3098 3097 assert_tag :textarea, :attributes => { :name => 'issue[notes]' }, :content => "\n"+notes
3099 3098 assert_tag :input, :attributes => { :name => 'time_entry[comments]', :value => "this is my comment" }
3100 3099 end
3101 3100
3102 3101 def test_put_update_should_allow_fixed_version_to_be_set_to_a_subproject
3103 3102 issue = Issue.find(2)
3104 3103 @request.session[:user_id] = 2
3105 3104
3106 3105 put :update,
3107 3106 :id => issue.id,
3108 3107 :issue => {
3109 3108 :fixed_version_id => 4
3110 3109 }
3111 3110
3112 3111 assert_response :redirect
3113 3112 issue.reload
3114 3113 assert_equal 4, issue.fixed_version_id
3115 3114 assert_not_equal issue.project_id, issue.fixed_version.project_id
3116 3115 end
3117 3116
3118 3117 def test_put_update_should_redirect_back_using_the_back_url_parameter
3119 3118 issue = Issue.find(2)
3120 3119 @request.session[:user_id] = 2
3121 3120
3122 3121 put :update,
3123 3122 :id => issue.id,
3124 3123 :issue => {
3125 3124 :fixed_version_id => 4
3126 3125 },
3127 3126 :back_url => '/issues'
3128 3127
3129 3128 assert_response :redirect
3130 3129 assert_redirected_to '/issues'
3131 3130 end
3132 3131
3133 3132 def test_put_update_should_not_redirect_back_using_the_back_url_parameter_off_the_host
3134 3133 issue = Issue.find(2)
3135 3134 @request.session[:user_id] = 2
3136 3135
3137 3136 put :update,
3138 3137 :id => issue.id,
3139 3138 :issue => {
3140 3139 :fixed_version_id => 4
3141 3140 },
3142 3141 :back_url => 'http://google.com'
3143 3142
3144 3143 assert_response :redirect
3145 3144 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue.id
3146 3145 end
3147 3146
3148 3147 def test_get_bulk_edit
3149 3148 @request.session[:user_id] = 2
3150 3149 get :bulk_edit, :ids => [1, 2]
3151 3150 assert_response :success
3152 3151 assert_template 'bulk_edit'
3153 3152
3154 3153 assert_tag :select, :attributes => {:name => 'issue[project_id]'}
3155 3154 assert_tag :input, :attributes => {:name => 'issue[parent_issue_id]'}
3156 3155
3157 3156 # Project specific custom field, date type
3158 3157 field = CustomField.find(9)
3159 3158 assert !field.is_for_all?
3160 3159 assert_equal 'date', field.field_format
3161 3160 assert_tag :input, :attributes => {:name => 'issue[custom_field_values][9]'}
3162 3161
3163 3162 # System wide custom field
3164 3163 assert CustomField.find(1).is_for_all?
3165 3164 assert_tag :select, :attributes => {:name => 'issue[custom_field_values][1]'}
3166 3165
3167 3166 # Be sure we don't display inactive IssuePriorities
3168 3167 assert ! IssuePriority.find(15).active?
3169 3168 assert_no_tag :option, :attributes => {:value => '15'},
3170 3169 :parent => {:tag => 'select', :attributes => {:id => 'issue_priority_id'} }
3171 3170 end
3172 3171
3173 3172 def test_get_bulk_edit_on_different_projects
3174 3173 @request.session[:user_id] = 2
3175 3174 get :bulk_edit, :ids => [1, 2, 6]
3176 3175 assert_response :success
3177 3176 assert_template 'bulk_edit'
3178 3177
3179 3178 # Can not set issues from different projects as children of an issue
3180 3179 assert_no_tag :input, :attributes => {:name => 'issue[parent_issue_id]'}
3181 3180
3182 3181 # Project specific custom field, date type
3183 3182 field = CustomField.find(9)
3184 3183 assert !field.is_for_all?
3185 3184 assert !field.project_ids.include?(Issue.find(6).project_id)
3186 3185 assert_no_tag :input, :attributes => {:name => 'issue[custom_field_values][9]'}
3187 3186 end
3188 3187
3189 3188 def test_get_bulk_edit_with_user_custom_field
3190 3189 field = IssueCustomField.create!(:name => 'Tester', :field_format => 'user', :is_for_all => true)
3191 3190
3192 3191 @request.session[:user_id] = 2
3193 3192 get :bulk_edit, :ids => [1, 2]
3194 3193 assert_response :success
3195 3194 assert_template 'bulk_edit'
3196 3195
3197 3196 assert_tag :select,
3198 3197 :attributes => {:name => "issue[custom_field_values][#{field.id}]", :class => 'user_cf'},
3199 3198 :children => {
3200 3199 :only => {:tag => 'option'},
3201 3200 :count => Project.find(1).users.count + 2 # "no change" + "none" options
3202 3201 }
3203 3202 end
3204 3203
3205 3204 def test_get_bulk_edit_with_version_custom_field
3206 3205 field = IssueCustomField.create!(:name => 'Affected version', :field_format => 'version', :is_for_all => true)
3207 3206
3208 3207 @request.session[:user_id] = 2
3209 3208 get :bulk_edit, :ids => [1, 2]
3210 3209 assert_response :success
3211 3210 assert_template 'bulk_edit'
3212 3211
3213 3212 assert_tag :select,
3214 3213 :attributes => {:name => "issue[custom_field_values][#{field.id}]"},
3215 3214 :children => {
3216 3215 :only => {:tag => 'option'},
3217 3216 :count => Project.find(1).shared_versions.count + 2 # "no change" + "none" options
3218 3217 }
3219 3218 end
3220 3219
3221 3220 def test_get_bulk_edit_with_multi_custom_field
3222 3221 field = CustomField.find(1)
3223 3222 field.update_attribute :multiple, true
3224 3223
3225 3224 @request.session[:user_id] = 2
3226 3225 get :bulk_edit, :ids => [1, 2]
3227 3226 assert_response :success
3228 3227 assert_template 'bulk_edit'
3229 3228
3230 3229 assert_tag :select,
3231 3230 :attributes => {:name => "issue[custom_field_values][1][]"},
3232 3231 :children => {
3233 3232 :only => {:tag => 'option'},
3234 3233 :count => field.possible_values.size + 1 # "none" options
3235 3234 }
3236 3235 end
3237 3236
3238 3237 def test_bulk_edit_should_only_propose_statuses_allowed_for_all_issues
3239 3238 WorkflowTransition.delete_all
3240 3239 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 1)
3241 3240 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 3)
3242 3241 WorkflowTransition.create!(:role_id => 1, :tracker_id => 1, :old_status_id => 1, :new_status_id => 4)
3243 3242 WorkflowTransition.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 2, :new_status_id => 1)
3244 3243 WorkflowTransition.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 2, :new_status_id => 3)
3245 3244 WorkflowTransition.create!(:role_id => 1, :tracker_id => 2, :old_status_id => 2, :new_status_id => 5)
3246 3245 @request.session[:user_id] = 2
3247 3246 get :bulk_edit, :ids => [1, 2]
3248 3247
3249 3248 assert_response :success
3250 3249 statuses = assigns(:available_statuses)
3251 3250 assert_not_nil statuses
3252 3251 assert_equal [1, 3], statuses.map(&:id).sort
3253 3252
3254 3253 assert_tag 'select', :attributes => {:name => 'issue[status_id]'},
3255 3254 :children => {:count => 3} # 2 statuses + "no change" option
3256 3255 end
3257 3256
3258 3257 def test_bulk_edit_should_propose_target_project_open_shared_versions
3259 3258 @request.session[:user_id] = 2
3260 3259 post :bulk_edit, :ids => [1, 2, 6], :issue => {:project_id => 1}
3261 3260 assert_response :success
3262 3261 assert_template 'bulk_edit'
3263 3262 assert_equal Project.find(1).shared_versions.open.all.sort, assigns(:versions).sort
3264 3263 assert_tag 'select',
3265 3264 :attributes => {:name => 'issue[fixed_version_id]'},
3266 3265 :descendant => {:tag => 'option', :content => '2.0'}
3267 3266 end
3268 3267
3269 3268 def test_bulk_edit_should_propose_target_project_categories
3270 3269 @request.session[:user_id] = 2
3271 3270 post :bulk_edit, :ids => [1, 2, 6], :issue => {:project_id => 1}
3272 3271 assert_response :success
3273 3272 assert_template 'bulk_edit'
3274 3273 assert_equal Project.find(1).issue_categories.sort, assigns(:categories).sort
3275 3274 assert_tag 'select',
3276 3275 :attributes => {:name => 'issue[category_id]'},
3277 3276 :descendant => {:tag => 'option', :content => 'Recipes'}
3278 3277 end
3279 3278
3280 3279 def test_bulk_update
3281 3280 @request.session[:user_id] = 2
3282 3281 # update issues priority
3283 3282 post :bulk_update, :ids => [1, 2], :notes => 'Bulk editing',
3284 3283 :issue => {:priority_id => 7,
3285 3284 :assigned_to_id => '',
3286 3285 :custom_field_values => {'2' => ''}}
3287 3286
3288 3287 assert_response 302
3289 3288 # check that the issues were updated
3290 3289 assert_equal [7, 7], Issue.find_all_by_id([1, 2]).collect {|i| i.priority.id}
3291 3290
3292 3291 issue = Issue.find(1)
3293 3292 journal = issue.journals.reorder('created_on DESC').first
3294 3293 assert_equal '125', issue.custom_value_for(2).value
3295 3294 assert_equal 'Bulk editing', journal.notes
3296 3295 assert_equal 1, journal.details.size
3297 3296 end
3298 3297
3299 3298 def test_bulk_update_with_group_assignee
3300 3299 group = Group.find(11)
3301 3300 project = Project.find(1)
3302 3301 project.members << Member.new(:principal => group, :roles => [Role.givable.first])
3303 3302
3304 3303 @request.session[:user_id] = 2
3305 3304 # update issues assignee
3306 3305 post :bulk_update, :ids => [1, 2], :notes => 'Bulk editing',
3307 3306 :issue => {:priority_id => '',
3308 3307 :assigned_to_id => group.id,
3309 3308 :custom_field_values => {'2' => ''}}
3310 3309
3311 3310 assert_response 302
3312 3311 assert_equal [group, group], Issue.find_all_by_id([1, 2]).collect {|i| i.assigned_to}
3313 3312 end
3314 3313
3315 3314 def test_bulk_update_on_different_projects
3316 3315 @request.session[:user_id] = 2
3317 3316 # update issues priority
3318 3317 post :bulk_update, :ids => [1, 2, 6], :notes => 'Bulk editing',
3319 3318 :issue => {:priority_id => 7,
3320 3319 :assigned_to_id => '',
3321 3320 :custom_field_values => {'2' => ''}}
3322 3321
3323 3322 assert_response 302
3324 3323 # check that the issues were updated
3325 3324 assert_equal [7, 7, 7], Issue.find([1,2,6]).map(&:priority_id)
3326 3325
3327 3326 issue = Issue.find(1)
3328 3327 journal = issue.journals.reorder('created_on DESC').first
3329 3328 assert_equal '125', issue.custom_value_for(2).value
3330 3329 assert_equal 'Bulk editing', journal.notes
3331 3330 assert_equal 1, journal.details.size
3332 3331 end
3333 3332
3334 3333 def test_bulk_update_on_different_projects_without_rights
3335 3334 @request.session[:user_id] = 3
3336 3335 user = User.find(3)
3337 3336 action = { :controller => "issues", :action => "bulk_update" }
3338 3337 assert user.allowed_to?(action, Issue.find(1).project)
3339 3338 assert ! user.allowed_to?(action, Issue.find(6).project)
3340 3339 post :bulk_update, :ids => [1, 6], :notes => 'Bulk should fail',
3341 3340 :issue => {:priority_id => 7,
3342 3341 :assigned_to_id => '',
3343 3342 :custom_field_values => {'2' => ''}}
3344 3343 assert_response 403
3345 3344 assert_not_equal "Bulk should fail", Journal.last.notes
3346 3345 end
3347 3346
3348 3347 def test_bullk_update_should_send_a_notification
3349 3348 @request.session[:user_id] = 2
3350 3349 ActionMailer::Base.deliveries.clear
3351 3350 post(:bulk_update,
3352 3351 {
3353 3352 :ids => [1, 2],
3354 3353 :notes => 'Bulk editing',
3355 3354 :issue => {
3356 3355 :priority_id => 7,
3357 3356 :assigned_to_id => '',
3358 3357 :custom_field_values => {'2' => ''}
3359 3358 }
3360 3359 })
3361 3360
3362 3361 assert_response 302
3363 3362 assert_equal 2, ActionMailer::Base.deliveries.size
3364 3363 end
3365 3364
3366 3365 def test_bulk_update_project
3367 3366 @request.session[:user_id] = 2
3368 3367 post :bulk_update, :ids => [1, 2], :issue => {:project_id => '2'}
3369 3368 assert_redirected_to :controller => 'issues', :action => 'index', :project_id => 'ecookbook'
3370 3369 # Issues moved to project 2
3371 3370 assert_equal 2, Issue.find(1).project_id
3372 3371 assert_equal 2, Issue.find(2).project_id
3373 3372 # No tracker change
3374 3373 assert_equal 1, Issue.find(1).tracker_id
3375 3374 assert_equal 2, Issue.find(2).tracker_id
3376 3375 end
3377 3376
3378 3377 def test_bulk_update_project_on_single_issue_should_follow_when_needed
3379 3378 @request.session[:user_id] = 2
3380 3379 post :bulk_update, :id => 1, :issue => {:project_id => '2'}, :follow => '1'
3381 3380 assert_redirected_to '/issues/1'
3382 3381 end
3383 3382
3384 3383 def test_bulk_update_project_on_multiple_issues_should_follow_when_needed
3385 3384 @request.session[:user_id] = 2
3386 3385 post :bulk_update, :id => [1, 2], :issue => {:project_id => '2'}, :follow => '1'
3387 3386 assert_redirected_to '/projects/onlinestore/issues'
3388 3387 end
3389 3388
3390 3389 def test_bulk_update_tracker
3391 3390 @request.session[:user_id] = 2
3392 3391 post :bulk_update, :ids => [1, 2], :issue => {:tracker_id => '2'}
3393 3392 assert_redirected_to :controller => 'issues', :action => 'index', :project_id => 'ecookbook'
3394 3393 assert_equal 2, Issue.find(1).tracker_id
3395 3394 assert_equal 2, Issue.find(2).tracker_id
3396 3395 end
3397 3396
3398 3397 def test_bulk_update_status
3399 3398 @request.session[:user_id] = 2
3400 3399 # update issues priority
3401 3400 post :bulk_update, :ids => [1, 2], :notes => 'Bulk editing status',
3402 3401 :issue => {:priority_id => '',
3403 3402 :assigned_to_id => '',
3404 3403 :status_id => '5'}
3405 3404
3406 3405 assert_response 302
3407 3406 issue = Issue.find(1)
3408 3407 assert issue.closed?
3409 3408 end
3410 3409
3411 3410 def test_bulk_update_priority
3412 3411 @request.session[:user_id] = 2
3413 3412 post :bulk_update, :ids => [1, 2], :issue => {:priority_id => 6}
3414 3413
3415 3414 assert_redirected_to :controller => 'issues', :action => 'index', :project_id => 'ecookbook'
3416 3415 assert_equal 6, Issue.find(1).priority_id
3417 3416 assert_equal 6, Issue.find(2).priority_id
3418 3417 end
3419 3418
3420 3419 def test_bulk_update_with_notes
3421 3420 @request.session[:user_id] = 2
3422 3421 post :bulk_update, :ids => [1, 2], :notes => 'Moving two issues'
3423 3422
3424 3423 assert_redirected_to :controller => 'issues', :action => 'index', :project_id => 'ecookbook'
3425 3424 assert_equal 'Moving two issues', Issue.find(1).journals.sort_by(&:id).last.notes
3426 3425 assert_equal 'Moving two issues', Issue.find(2).journals.sort_by(&:id).last.notes
3427 3426 end
3428 3427
3429 3428 def test_bulk_update_parent_id
3430 3429 @request.session[:user_id] = 2
3431 3430 post :bulk_update, :ids => [1, 3],
3432 3431 :notes => 'Bulk editing parent',
3433 3432 :issue => {:priority_id => '', :assigned_to_id => '', :status_id => '', :parent_issue_id => '2'}
3434 3433
3435 3434 assert_response 302
3436 3435 parent = Issue.find(2)
3437 3436 assert_equal parent.id, Issue.find(1).parent_id
3438 3437 assert_equal parent.id, Issue.find(3).parent_id
3439 3438 assert_equal [1, 3], parent.children.collect(&:id).sort
3440 3439 end
3441 3440
3442 3441 def test_bulk_update_custom_field
3443 3442 @request.session[:user_id] = 2
3444 3443 # update issues priority
3445 3444 post :bulk_update, :ids => [1, 2], :notes => 'Bulk editing custom field',
3446 3445 :issue => {:priority_id => '',
3447 3446 :assigned_to_id => '',
3448 3447 :custom_field_values => {'2' => '777'}}
3449 3448
3450 3449 assert_response 302
3451 3450
3452 3451 issue = Issue.find(1)
3453 3452 journal = issue.journals.reorder('created_on DESC').first
3454 3453 assert_equal '777', issue.custom_value_for(2).value
3455 3454 assert_equal 1, journal.details.size
3456 3455 assert_equal '125', journal.details.first.old_value
3457 3456 assert_equal '777', journal.details.first.value
3458 3457 end
3459 3458
3460 3459 def test_bulk_update_custom_field_to_blank
3461 3460 @request.session[:user_id] = 2
3462 3461 post :bulk_update, :ids => [1, 3], :notes => 'Bulk editing custom field',
3463 3462 :issue => {:priority_id => '',
3464 3463 :assigned_to_id => '',
3465 3464 :custom_field_values => {'1' => '__none__'}}
3466 3465 assert_response 302
3467 3466 assert_equal '', Issue.find(1).custom_field_value(1)
3468 3467 assert_equal '', Issue.find(3).custom_field_value(1)
3469 3468 end
3470 3469
3471 3470 def test_bulk_update_multi_custom_field
3472 3471 field = CustomField.find(1)
3473 3472 field.update_attribute :multiple, true
3474 3473
3475 3474 @request.session[:user_id] = 2
3476 3475 post :bulk_update, :ids => [1, 2, 3], :notes => 'Bulk editing multi custom field',
3477 3476 :issue => {:priority_id => '',
3478 3477 :assigned_to_id => '',
3479 3478 :custom_field_values => {'1' => ['MySQL', 'Oracle']}}
3480 3479
3481 3480 assert_response 302
3482 3481
3483 3482 assert_equal ['MySQL', 'Oracle'], Issue.find(1).custom_field_value(1).sort
3484 3483 assert_equal ['MySQL', 'Oracle'], Issue.find(3).custom_field_value(1).sort
3485 3484 # the custom field is not associated with the issue tracker
3486 3485 assert_nil Issue.find(2).custom_field_value(1)
3487 3486 end
3488 3487
3489 3488 def test_bulk_update_multi_custom_field_to_blank
3490 3489 field = CustomField.find(1)
3491 3490 field.update_attribute :multiple, true
3492 3491
3493 3492 @request.session[:user_id] = 2
3494 3493 post :bulk_update, :ids => [1, 3], :notes => 'Bulk editing multi custom field',
3495 3494 :issue => {:priority_id => '',
3496 3495 :assigned_to_id => '',
3497 3496 :custom_field_values => {'1' => ['__none__']}}
3498 3497 assert_response 302
3499 3498 assert_equal [''], Issue.find(1).custom_field_value(1)
3500 3499 assert_equal [''], Issue.find(3).custom_field_value(1)
3501 3500 end
3502 3501
3503 3502 def test_bulk_update_unassign
3504 3503 assert_not_nil Issue.find(2).assigned_to
3505 3504 @request.session[:user_id] = 2
3506 3505 # unassign issues
3507 3506 post :bulk_update, :ids => [1, 2], :notes => 'Bulk unassigning', :issue => {:assigned_to_id => 'none'}
3508 3507 assert_response 302
3509 3508 # check that the issues were updated
3510 3509 assert_nil Issue.find(2).assigned_to
3511 3510 end
3512 3511
3513 3512 def test_post_bulk_update_should_allow_fixed_version_to_be_set_to_a_subproject
3514 3513 @request.session[:user_id] = 2
3515 3514
3516 3515 post :bulk_update, :ids => [1,2], :issue => {:fixed_version_id => 4}
3517 3516
3518 3517 assert_response :redirect
3519 3518 issues = Issue.find([1,2])
3520 3519 issues.each do |issue|
3521 3520 assert_equal 4, issue.fixed_version_id
3522 3521 assert_not_equal issue.project_id, issue.fixed_version.project_id
3523 3522 end
3524 3523 end
3525 3524
3526 3525 def test_post_bulk_update_should_redirect_back_using_the_back_url_parameter
3527 3526 @request.session[:user_id] = 2
3528 3527 post :bulk_update, :ids => [1,2], :back_url => '/issues'
3529 3528
3530 3529 assert_response :redirect
3531 3530 assert_redirected_to '/issues'
3532 3531 end
3533 3532
3534 3533 def test_post_bulk_update_should_not_redirect_back_using_the_back_url_parameter_off_the_host
3535 3534 @request.session[:user_id] = 2
3536 3535 post :bulk_update, :ids => [1,2], :back_url => 'http://google.com'
3537 3536
3538 3537 assert_response :redirect
3539 3538 assert_redirected_to :controller => 'issues', :action => 'index', :project_id => Project.find(1).identifier
3540 3539 end
3541 3540
3542 3541 def test_bulk_update_with_failure_should_set_flash
3543 3542 @request.session[:user_id] = 2
3544 3543 Issue.update_all("subject = ''", "id = 2") # Make it invalid
3545 3544 post :bulk_update, :ids => [1, 2], :issue => {:priority_id => 6}
3546 3545
3547 3546 assert_redirected_to :controller => 'issues', :action => 'index', :project_id => 'ecookbook'
3548 3547 assert_equal 'Failed to save 1 issue(s) on 2 selected: #2.', flash[:error]
3549 3548 end
3550 3549
3551 3550 def test_get_bulk_copy
3552 3551 @request.session[:user_id] = 2
3553 3552 get :bulk_edit, :ids => [1, 2, 3], :copy => '1'
3554 3553 assert_response :success
3555 3554 assert_template 'bulk_edit'
3556 3555
3557 3556 issues = assigns(:issues)
3558 3557 assert_not_nil issues
3559 3558 assert_equal [1, 2, 3], issues.map(&:id).sort
3560 3559
3561 3560 assert_select 'input[name=copy_attachments]'
3562 3561 end
3563 3562
3564 3563 def test_bulk_copy_to_another_project
3565 3564 @request.session[:user_id] = 2
3566 3565 assert_difference 'Issue.count', 2 do
3567 3566 assert_no_difference 'Project.find(1).issues.count' do
3568 3567 post :bulk_update, :ids => [1, 2], :issue => {:project_id => '2'}, :copy => '1'
3569 3568 end
3570 3569 end
3571 3570 assert_redirected_to '/projects/ecookbook/issues'
3572 3571
3573 3572 copies = Issue.all(:order => 'id DESC', :limit => issues.size)
3574 3573 copies.each do |copy|
3575 3574 assert_equal 2, copy.project_id
3576 3575 end
3577 3576 end
3578 3577
3579 3578 def test_bulk_copy_should_allow_not_changing_the_issue_attributes
3580 3579 @request.session[:user_id] = 2
3581 3580 issues = [
3582 3581 Issue.create!(:project_id => 1, :tracker_id => 1, :status_id => 1, :priority_id => 2, :subject => 'issue 1', :author_id => 1, :assigned_to_id => nil),
3583 3582 Issue.create!(:project_id => 2, :tracker_id => 3, :status_id => 2, :priority_id => 1, :subject => 'issue 2', :author_id => 2, :assigned_to_id => 3)
3584 3583 ]
3585 3584
3586 3585 assert_difference 'Issue.count', issues.size do
3587 3586 post :bulk_update, :ids => issues.map(&:id), :copy => '1',
3588 3587 :issue => {
3589 3588 :project_id => '', :tracker_id => '', :assigned_to_id => '',
3590 3589 :status_id => '', :start_date => '', :due_date => ''
3591 3590 }
3592 3591 end
3593 3592
3594 3593 copies = Issue.all(:order => 'id DESC', :limit => issues.size)
3595 3594 issues.each do |orig|
3596 3595 copy = copies.detect {|c| c.subject == orig.subject}
3597 3596 assert_not_nil copy
3598 3597 assert_equal orig.project_id, copy.project_id
3599 3598 assert_equal orig.tracker_id, copy.tracker_id
3600 3599 assert_equal orig.status_id, copy.status_id
3601 3600 assert_equal orig.assigned_to_id, copy.assigned_to_id
3602 3601 assert_equal orig.priority_id, copy.priority_id
3603 3602 end
3604 3603 end
3605 3604
3606 3605 def test_bulk_copy_should_allow_changing_the_issue_attributes
3607 3606 # Fixes random test failure with Mysql
3608 3607 # where Issue.all(:limit => 2, :order => 'id desc', :conditions => {:project_id => 2})
3609 3608 # doesn't return the expected results
3610 3609 Issue.delete_all("project_id=2")
3611 3610
3612 3611 @request.session[:user_id] = 2
3613 3612 assert_difference 'Issue.count', 2 do
3614 3613 assert_no_difference 'Project.find(1).issues.count' do
3615 3614 post :bulk_update, :ids => [1, 2], :copy => '1',
3616 3615 :issue => {
3617 3616 :project_id => '2', :tracker_id => '', :assigned_to_id => '4',
3618 3617 :status_id => '1', :start_date => '2009-12-01', :due_date => '2009-12-31'
3619 3618 }
3620 3619 end
3621 3620 end
3622 3621
3623 3622 copied_issues = Issue.all(:limit => 2, :order => 'id desc', :conditions => {:project_id => 2})
3624 3623 assert_equal 2, copied_issues.size
3625 3624 copied_issues.each do |issue|
3626 3625 assert_equal 2, issue.project_id, "Project is incorrect"
3627 3626 assert_equal 4, issue.assigned_to_id, "Assigned to is incorrect"
3628 3627 assert_equal 1, issue.status_id, "Status is incorrect"
3629 3628 assert_equal '2009-12-01', issue.start_date.to_s, "Start date is incorrect"
3630 3629 assert_equal '2009-12-31', issue.due_date.to_s, "Due date is incorrect"
3631 3630 end
3632 3631 end
3633 3632
3634 3633 def test_bulk_copy_should_allow_adding_a_note
3635 3634 @request.session[:user_id] = 2
3636 3635 assert_difference 'Issue.count', 1 do
3637 3636 post :bulk_update, :ids => [1], :copy => '1',
3638 3637 :notes => 'Copying one issue',
3639 3638 :issue => {
3640 3639 :project_id => '', :tracker_id => '', :assigned_to_id => '4',
3641 3640 :status_id => '3', :start_date => '2009-12-01', :due_date => '2009-12-31'
3642 3641 }
3643 3642 end
3644 3643
3645 3644 issue = Issue.first(:order => 'id DESC')
3646 3645 assert_equal 1, issue.journals.size
3647 3646 journal = issue.journals.first
3648 3647 assert_equal 0, journal.details.size
3649 3648 assert_equal 'Copying one issue', journal.notes
3650 3649 end
3651 3650
3652 3651 def test_bulk_copy_should_allow_not_copying_the_attachments
3653 3652 attachment_count = Issue.find(3).attachments.size
3654 3653 assert attachment_count > 0
3655 3654 @request.session[:user_id] = 2
3656 3655
3657 3656 assert_difference 'Issue.count', 1 do
3658 3657 assert_no_difference 'Attachment.count' do
3659 3658 post :bulk_update, :ids => [3], :copy => '1',
3660 3659 :issue => {
3661 3660 :project_id => ''
3662 3661 }
3663 3662 end
3664 3663 end
3665 3664 end
3666 3665
3667 3666 def test_bulk_copy_should_allow_copying_the_attachments
3668 3667 attachment_count = Issue.find(3).attachments.size
3669 3668 assert attachment_count > 0
3670 3669 @request.session[:user_id] = 2
3671 3670
3672 3671 assert_difference 'Issue.count', 1 do
3673 3672 assert_difference 'Attachment.count', attachment_count do
3674 3673 post :bulk_update, :ids => [3], :copy => '1', :copy_attachments => '1',
3675 3674 :issue => {
3676 3675 :project_id => ''
3677 3676 }
3678 3677 end
3679 3678 end
3680 3679 end
3681 3680
3682 3681 def test_bulk_copy_should_add_relations_with_copied_issues
3683 3682 @request.session[:user_id] = 2
3684 3683
3685 3684 assert_difference 'Issue.count', 2 do
3686 3685 assert_difference 'IssueRelation.count', 2 do
3687 3686 post :bulk_update, :ids => [1, 3], :copy => '1',
3688 3687 :issue => {
3689 3688 :project_id => '1'
3690 3689 }
3691 3690 end
3692 3691 end
3693 3692 end
3694 3693
3695 3694 def test_bulk_copy_should_allow_not_copying_the_subtasks
3696 3695 issue = Issue.generate_with_descendants!
3697 3696 @request.session[:user_id] = 2
3698 3697
3699 3698 assert_difference 'Issue.count', 1 do
3700 3699 post :bulk_update, :ids => [issue.id], :copy => '1',
3701 3700 :issue => {
3702 3701 :project_id => ''
3703 3702 }
3704 3703 end
3705 3704 end
3706 3705
3707 3706 def test_bulk_copy_should_allow_copying_the_subtasks
3708 3707 issue = Issue.generate_with_descendants!
3709 3708 count = issue.descendants.count
3710 3709 @request.session[:user_id] = 2
3711 3710
3712 3711 assert_difference 'Issue.count', count+1 do
3713 3712 post :bulk_update, :ids => [issue.id], :copy => '1', :copy_subtasks => '1',
3714 3713 :issue => {
3715 3714 :project_id => ''
3716 3715 }
3717 3716 end
3718 3717 copy = Issue.where(:parent_id => nil).order("id DESC").first
3719 3718 assert_equal count, copy.descendants.count
3720 3719 end
3721 3720
3722 3721 def test_bulk_copy_should_not_copy_selected_subtasks_twice
3723 3722 issue = Issue.generate_with_descendants!
3724 3723 count = issue.descendants.count
3725 3724 @request.session[:user_id] = 2
3726 3725
3727 3726 assert_difference 'Issue.count', count+1 do
3728 3727 post :bulk_update, :ids => issue.self_and_descendants.map(&:id), :copy => '1', :copy_subtasks => '1',
3729 3728 :issue => {
3730 3729 :project_id => ''
3731 3730 }
3732 3731 end
3733 3732 copy = Issue.where(:parent_id => nil).order("id DESC").first
3734 3733 assert_equal count, copy.descendants.count
3735 3734 end
3736 3735
3737 3736 def test_bulk_copy_to_another_project_should_follow_when_needed
3738 3737 @request.session[:user_id] = 2
3739 3738 post :bulk_update, :ids => [1], :copy => '1', :issue => {:project_id => 2}, :follow => '1'
3740 3739 issue = Issue.first(:order => 'id DESC')
3741 3740 assert_redirected_to :controller => 'issues', :action => 'show', :id => issue
3742 3741 end
3743 3742
3744 3743 def test_destroy_issue_with_no_time_entries
3745 3744 assert_nil TimeEntry.find_by_issue_id(2)
3746 3745 @request.session[:user_id] = 2
3747 3746
3748 3747 assert_difference 'Issue.count', -1 do
3749 3748 delete :destroy, :id => 2
3750 3749 end
3751 3750 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
3752 3751 assert_nil Issue.find_by_id(2)
3753 3752 end
3754 3753
3755 3754 def test_destroy_issues_with_time_entries
3756 3755 @request.session[:user_id] = 2
3757 3756
3758 3757 assert_no_difference 'Issue.count' do
3759 3758 delete :destroy, :ids => [1, 3]
3760 3759 end
3761 3760 assert_response :success
3762 3761 assert_template 'destroy'
3763 3762 assert_not_nil assigns(:hours)
3764 3763 assert Issue.find_by_id(1) && Issue.find_by_id(3)
3765 3764 assert_tag 'form',
3766 3765 :descendant => {:tag => 'input', :attributes => {:name => '_method', :value => 'delete'}}
3767 3766 end
3768 3767
3769 3768 def test_destroy_issues_and_destroy_time_entries
3770 3769 @request.session[:user_id] = 2
3771 3770
3772 3771 assert_difference 'Issue.count', -2 do
3773 3772 assert_difference 'TimeEntry.count', -3 do
3774 3773 delete :destroy, :ids => [1, 3], :todo => 'destroy'
3775 3774 end
3776 3775 end
3777 3776 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
3778 3777 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
3779 3778 assert_nil TimeEntry.find_by_id([1, 2])
3780 3779 end
3781 3780
3782 3781 def test_destroy_issues_and_assign_time_entries_to_project
3783 3782 @request.session[:user_id] = 2
3784 3783
3785 3784 assert_difference 'Issue.count', -2 do
3786 3785 assert_no_difference 'TimeEntry.count' do
3787 3786 delete :destroy, :ids => [1, 3], :todo => 'nullify'
3788 3787 end
3789 3788 end
3790 3789 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
3791 3790 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
3792 3791 assert_nil TimeEntry.find(1).issue_id
3793 3792 assert_nil TimeEntry.find(2).issue_id
3794 3793 end
3795 3794
3796 3795 def test_destroy_issues_and_reassign_time_entries_to_another_issue
3797 3796 @request.session[:user_id] = 2
3798 3797
3799 3798 assert_difference 'Issue.count', -2 do
3800 3799 assert_no_difference 'TimeEntry.count' do
3801 3800 delete :destroy, :ids => [1, 3], :todo => 'reassign', :reassign_to_id => 2
3802 3801 end
3803 3802 end
3804 3803 assert_redirected_to :action => 'index', :project_id => 'ecookbook'
3805 3804 assert !(Issue.find_by_id(1) || Issue.find_by_id(3))
3806 3805 assert_equal 2, TimeEntry.find(1).issue_id
3807 3806 assert_equal 2, TimeEntry.find(2).issue_id
3808 3807 end
3809 3808
3810 3809 def test_destroy_issues_from_different_projects
3811 3810 @request.session[:user_id] = 2
3812 3811
3813 3812 assert_difference 'Issue.count', -3 do
3814 3813 delete :destroy, :ids => [1, 2, 6], :todo => 'destroy'
3815 3814 end
3816 3815 assert_redirected_to :controller => 'issues', :action => 'index'
3817 3816 assert !(Issue.find_by_id(1) || Issue.find_by_id(2) || Issue.find_by_id(6))
3818 3817 end
3819 3818
3820 3819 def test_destroy_parent_and_child_issues
3821 3820 parent = Issue.create!(:project_id => 1, :author_id => 1, :tracker_id => 1, :subject => 'Parent Issue')
3822 3821 child = Issue.create!(:project_id => 1, :author_id => 1, :tracker_id => 1, :subject => 'Child Issue', :parent_issue_id => parent.id)
3823 3822 assert child.is_descendant_of?(parent.reload)
3824 3823
3825 3824 @request.session[:user_id] = 2
3826 3825 assert_difference 'Issue.count', -2 do
3827 3826 delete :destroy, :ids => [parent.id, child.id], :todo => 'destroy'
3828 3827 end
3829 3828 assert_response 302
3830 3829 end
3831 3830
3832 3831 def test_destroy_invalid_should_respond_with_404
3833 3832 @request.session[:user_id] = 2
3834 3833 assert_no_difference 'Issue.count' do
3835 3834 delete :destroy, :id => 999
3836 3835 end
3837 3836 assert_response 404
3838 3837 end
3839 3838
3840 3839 def test_default_search_scope
3841 3840 get :index
3842 3841 assert_tag :div, :attributes => {:id => 'quick-search'},
3843 3842 :child => {:tag => 'form',
3844 3843 :child => {:tag => 'input', :attributes => {:name => 'issues', :type => 'hidden', :value => '1'}}}
3845 3844 end
3846 3845 end
@@ -1,263 +1,263
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2012 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 require 'issues_controller'
20 20
21 21 class IssuesControllerTransactionTest < ActionController::TestCase
22 22 tests IssuesController
23 23 fixtures :projects,
24 24 :users,
25 25 :roles,
26 26 :members,
27 27 :member_roles,
28 28 :issues,
29 29 :issue_statuses,
30 30 :versions,
31 31 :trackers,
32 32 :projects_trackers,
33 33 :issue_categories,
34 34 :enabled_modules,
35 35 :enumerations,
36 36 :attachments,
37 37 :workflows,
38 38 :custom_fields,
39 39 :custom_values,
40 40 :custom_fields_projects,
41 41 :custom_fields_trackers,
42 42 :time_entries,
43 43 :journals,
44 44 :journal_details,
45 45 :queries
46 46
47 47 self.use_transactional_fixtures = false
48 48
49 49 def setup
50 50 User.current = nil
51 51 end
52 52
53 53 def test_update_stale_issue_should_not_update_the_issue
54 54 issue = Issue.find(2)
55 55 @request.session[:user_id] = 2
56 56
57 57 assert_no_difference 'Journal.count' do
58 58 assert_no_difference 'TimeEntry.count' do
59 59 put :update,
60 60 :id => issue.id,
61 61 :issue => {
62 62 :fixed_version_id => 4,
63 63 :notes => 'My notes',
64 64 :lock_version => (issue.lock_version - 1)
65 65 },
66 66 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id }
67 67 end
68 68 end
69 69
70 70 assert_response :success
71 71 assert_template 'edit'
72 72
73 73 assert_select 'div.conflict'
74 74 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'overwrite'
75 75 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'add_notes'
76 76 assert_select 'label' do
77 77 assert_select 'input[name=?][value=?]', 'conflict_resolution', 'cancel'
78 78 assert_select 'a[href=/issues/2]'
79 79 end
80 80 end
81 81
82 82 def test_update_stale_issue_should_save_attachments
83 83 set_tmp_attachments_directory
84 84 issue = Issue.find(2)
85 85 @request.session[:user_id] = 2
86 86
87 87 assert_no_difference 'Journal.count' do
88 88 assert_no_difference 'TimeEntry.count' do
89 89 assert_difference 'Attachment.count' do
90 90 put :update,
91 91 :id => issue.id,
92 92 :issue => {
93 93 :fixed_version_id => 4,
94 94 :notes => 'My notes',
95 95 :lock_version => (issue.lock_version - 1)
96 96 },
97 97 :attachments => {'1' => {'file' => uploaded_test_file('testfile.txt', 'text/plain')}},
98 98 :time_entry => { :hours => '2.5', :comments => '', :activity_id => TimeEntryActivity.first.id }
99 99 end
100 100 end
101 101 end
102 102
103 103 assert_response :success
104 104 assert_template 'edit'
105 105 attachment = Attachment.first(:order => 'id DESC')
106 106 assert_tag 'input', :attributes => {:name => 'attachments[p0][token]', :value => attachment.token}
107 assert_tag 'span', :content => /testfile.txt/
107 assert_tag 'input', :attributes => {:name => 'attachments[p0][filename]', :value => 'testfile.txt'}
108 108 end
109 109
110 110 def test_update_stale_issue_without_notes_should_not_show_add_notes_option
111 111 issue = Issue.find(2)
112 112 @request.session[:user_id] = 2
113 113
114 114 put :update, :id => issue.id,
115 115 :issue => {
116 116 :fixed_version_id => 4,
117 117 :notes => '',
118 118 :lock_version => (issue.lock_version - 1)
119 119 }
120 120
121 121 assert_tag 'div', :attributes => {:class => 'conflict'}
122 122 assert_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'overwrite'}
123 123 assert_no_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'add_notes'}
124 124 assert_tag 'input', :attributes => {:name => 'conflict_resolution', :value => 'cancel'}
125 125 end
126 126
127 127 def test_update_stale_issue_should_show_conflicting_journals
128 128 @request.session[:user_id] = 2
129 129
130 130 put :update, :id => 1,
131 131 :issue => {
132 132 :fixed_version_id => 4,
133 133 :notes => '',
134 134 :lock_version => 2
135 135 },
136 136 :last_journal_id => 1
137 137
138 138 assert_not_nil assigns(:conflict_journals)
139 139 assert_equal 1, assigns(:conflict_journals).size
140 140 assert_equal 2, assigns(:conflict_journals).first.id
141 141 assert_tag 'div', :attributes => {:class => 'conflict'},
142 142 :descendant => {:content => /Some notes with Redmine links/}
143 143 end
144 144
145 145 def test_update_stale_issue_without_previous_journal_should_show_all_journals
146 146 @request.session[:user_id] = 2
147 147
148 148 put :update, :id => 1,
149 149 :issue => {
150 150 :fixed_version_id => 4,
151 151 :notes => '',
152 152 :lock_version => 2
153 153 },
154 154 :last_journal_id => ''
155 155
156 156 assert_not_nil assigns(:conflict_journals)
157 157 assert_equal 2, assigns(:conflict_journals).size
158 158 assert_tag 'div', :attributes => {:class => 'conflict'},
159 159 :descendant => {:content => /Some notes with Redmine links/}
160 160 assert_tag 'div', :attributes => {:class => 'conflict'},
161 161 :descendant => {:content => /Journal notes/}
162 162 end
163 163
164 164 def test_update_stale_issue_should_show_private_journals_with_permission_only
165 165 journal = Journal.create!(:journalized => Issue.find(1), :notes => 'Privates notes', :private_notes => true, :user_id => 1)
166 166
167 167 @request.session[:user_id] = 2
168 168 put :update, :id => 1, :issue => {:fixed_version_id => 4, :lock_version => 2}, :last_journal_id => ''
169 169 assert_include journal, assigns(:conflict_journals)
170 170
171 171 Role.find(1).remove_permission! :view_private_notes
172 172 put :update, :id => 1, :issue => {:fixed_version_id => 4, :lock_version => 2}, :last_journal_id => ''
173 173 assert_not_include journal, assigns(:conflict_journals)
174 174 end
175 175
176 176 def test_update_stale_issue_with_overwrite_conflict_resolution_should_update
177 177 @request.session[:user_id] = 2
178 178
179 179 assert_difference 'Journal.count' do
180 180 put :update, :id => 1,
181 181 :issue => {
182 182 :fixed_version_id => 4,
183 183 :notes => 'overwrite_conflict_resolution',
184 184 :lock_version => 2
185 185 },
186 186 :conflict_resolution => 'overwrite'
187 187 end
188 188
189 189 assert_response 302
190 190 issue = Issue.find(1)
191 191 assert_equal 4, issue.fixed_version_id
192 192 journal = Journal.first(:order => 'id DESC')
193 193 assert_equal 'overwrite_conflict_resolution', journal.notes
194 194 assert journal.details.any?
195 195 end
196 196
197 197 def test_update_stale_issue_with_add_notes_conflict_resolution_should_update
198 198 @request.session[:user_id] = 2
199 199
200 200 assert_difference 'Journal.count' do
201 201 put :update, :id => 1,
202 202 :issue => {
203 203 :fixed_version_id => 4,
204 204 :notes => 'add_notes_conflict_resolution',
205 205 :lock_version => 2
206 206 },
207 207 :conflict_resolution => 'add_notes'
208 208 end
209 209
210 210 assert_response 302
211 211 issue = Issue.find(1)
212 212 assert_nil issue.fixed_version_id
213 213 journal = Journal.first(:order => 'id DESC')
214 214 assert_equal 'add_notes_conflict_resolution', journal.notes
215 215 assert journal.details.empty?
216 216 end
217 217
218 218 def test_update_stale_issue_with_cancel_conflict_resolution_should_redirect_without_updating
219 219 @request.session[:user_id] = 2
220 220
221 221 assert_no_difference 'Journal.count' do
222 222 put :update, :id => 1,
223 223 :issue => {
224 224 :fixed_version_id => 4,
225 225 :notes => 'add_notes_conflict_resolution',
226 226 :lock_version => 2
227 227 },
228 228 :conflict_resolution => 'cancel'
229 229 end
230 230
231 231 assert_redirected_to '/issues/1'
232 232 issue = Issue.find(1)
233 233 assert_nil issue.fixed_version_id
234 234 end
235 235
236 236 def test_put_update_with_spent_time_and_failure_should_not_add_spent_time
237 237 @request.session[:user_id] = 2
238 238
239 239 assert_no_difference('TimeEntry.count') do
240 240 put :update,
241 241 :id => 1,
242 242 :issue => { :subject => '' },
243 243 :time_entry => { :hours => '2.5', :comments => 'should not be added', :activity_id => TimeEntryActivity.first.id }
244 244 assert_response :success
245 245 end
246 246
247 247 assert_select 'input[name=?][value=?]', 'time_entry[hours]', '2.5'
248 248 assert_select 'input[name=?][value=?]', 'time_entry[comments]', 'should not be added'
249 249 assert_select 'select[name=?]', 'time_entry[activity_id]' do
250 250 assert_select 'option[value=?][selected=selected]', TimeEntryActivity.first.id
251 251 end
252 252 end
253 253
254 254 def test_index_should_rescue_invalid_sql_query
255 255 IssueQuery.any_instance.stubs(:statement).returns("INVALID STATEMENT")
256 256
257 257 get :index
258 258 assert_response 500
259 259 assert_tag 'p', :content => /An error occurred/
260 260 assert_nil session[:query]
261 261 assert_nil session[:issues_index_sort]
262 262 end
263 263 end
General Comments 0
You need to be logged in to leave comments. Login now