##// END OF EJS Templates
Show long text custom field changes as a diff (#15236)....
Jean-Philippe Lang -
r13572:e11f77a4b038
parent child
Show More
@@ -1,101 +1,109
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2015 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 JournalsController < ApplicationController
19 19 before_filter :find_journal, :only => [:edit, :diff]
20 20 before_filter :find_issue, :only => [:new]
21 21 before_filter :find_optional_project, :only => [:index]
22 22 before_filter :authorize, :only => [:new, :edit, :diff]
23 23 accept_rss_auth :index
24 24 menu_item :issues
25 25
26 26 helper :issues
27 27 helper :custom_fields
28 28 helper :queries
29 29 include QueriesHelper
30 30 helper :sort
31 31 include SortHelper
32 32
33 33 def index
34 34 retrieve_query
35 35 sort_init 'id', 'desc'
36 36 sort_update(@query.sortable_columns)
37 37 if @query.valid?
38 38 @journals = @query.journals(:order => "#{Journal.table_name}.created_on DESC",
39 39 :limit => 25)
40 40 end
41 41 @title = (@project ? @project.name : Setting.app_title) + ": " + (@query.new_record? ? l(:label_changes_details) : @query.name)
42 42 render :layout => false, :content_type => 'application/atom+xml'
43 43 rescue ActiveRecord::RecordNotFound
44 44 render_404
45 45 end
46 46
47 47 def diff
48 48 @issue = @journal.issue
49 49 if params[:detail_id].present?
50 50 @detail = @journal.details.find_by_id(params[:detail_id])
51 51 else
52 @detail = @journal.details.detect {|d| d.prop_key == 'description'}
52 @detail = @journal.details.detect {|d| d.property == 'attr' && d.prop_key == 'description'}
53 end
54 unless @issue && @detail
55 render_404
56 return false
57 end
58 if @detail.property == 'cf'
59 unless @detail.custom_field && @detail.custom_field.visible_by?(@issue.project, User.current)
60 raise ::Unauthorized
61 end
53 62 end
54 (render_404; return false) unless @issue && @detail
55 63 @diff = Redmine::Helpers::Diff.new(@detail.value, @detail.old_value)
56 64 end
57 65
58 66 def new
59 67 @journal = Journal.visible.find(params[:journal_id]) if params[:journal_id]
60 68 if @journal
61 69 user = @journal.user
62 70 text = @journal.notes
63 71 else
64 72 user = @issue.author
65 73 text = @issue.description
66 74 end
67 75 # Replaces pre blocks with [...]
68 76 text = text.to_s.strip.gsub(%r{<pre>(.*?)</pre>}m, '[...]')
69 77 @content = "#{ll(Setting.default_language, :text_user_wrote, user)}\n> "
70 78 @content << text.gsub(/(\r?\n|\r\n?)/, "\n> ") + "\n\n"
71 79 rescue ActiveRecord::RecordNotFound
72 80 render_404
73 81 end
74 82
75 83 def edit
76 84 (render_403; return false) unless @journal.editable_by?(User.current)
77 85 if request.post?
78 86 @journal.update_attributes(:notes => params[:notes]) if params[:notes]
79 87 @journal.destroy if @journal.details.empty? && @journal.notes.blank?
80 88 call_hook(:controller_journals_edit_post, { :journal => @journal, :params => params})
81 89 respond_to do |format|
82 90 format.html { redirect_to issue_path(@journal.journalized) }
83 91 format.js { render :action => 'update' }
84 92 end
85 93 else
86 94 respond_to do |format|
87 95 # TODO: implement non-JS journal update
88 96 format.js
89 97 end
90 98 end
91 99 end
92 100
93 101 private
94 102
95 103 def find_journal
96 104 @journal = Journal.visible.find(params[:id])
97 105 @project = @journal.journalized.project
98 106 rescue ActiveRecord::RecordNotFound
99 107 render_404
100 108 end
101 109 end
@@ -1,436 +1,445
1 1 # encoding: utf-8
2 2 #
3 3 # Redmine - project management software
4 4 # Copyright (C) 2006-2015 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 module IssuesHelper
21 21 include ApplicationHelper
22 22 include Redmine::Export::PDF::IssuesPdfHelper
23 23
24 24 def issue_list(issues, &block)
25 25 ancestors = []
26 26 issues.each do |issue|
27 27 while (ancestors.any? && !issue.is_descendant_of?(ancestors.last))
28 28 ancestors.pop
29 29 end
30 30 yield issue, ancestors.size
31 31 ancestors << issue unless issue.leaf?
32 32 end
33 33 end
34 34
35 35 # Renders a HTML/CSS tooltip
36 36 #
37 37 # To use, a trigger div is needed. This is a div with the class of "tooltip"
38 38 # that contains this method wrapped in a span with the class of "tip"
39 39 #
40 40 # <div class="tooltip"><%= link_to_issue(issue) %>
41 41 # <span class="tip"><%= render_issue_tooltip(issue) %></span>
42 42 # </div>
43 43 #
44 44 def render_issue_tooltip(issue)
45 45 @cached_label_status ||= l(:field_status)
46 46 @cached_label_start_date ||= l(:field_start_date)
47 47 @cached_label_due_date ||= l(:field_due_date)
48 48 @cached_label_assigned_to ||= l(:field_assigned_to)
49 49 @cached_label_priority ||= l(:field_priority)
50 50 @cached_label_project ||= l(:field_project)
51 51
52 52 link_to_issue(issue) + "<br /><br />".html_safe +
53 53 "<strong>#{@cached_label_project}</strong>: #{link_to_project(issue.project)}<br />".html_safe +
54 54 "<strong>#{@cached_label_status}</strong>: #{h(issue.status.name)}<br />".html_safe +
55 55 "<strong>#{@cached_label_start_date}</strong>: #{format_date(issue.start_date)}<br />".html_safe +
56 56 "<strong>#{@cached_label_due_date}</strong>: #{format_date(issue.due_date)}<br />".html_safe +
57 57 "<strong>#{@cached_label_assigned_to}</strong>: #{h(issue.assigned_to)}<br />".html_safe +
58 58 "<strong>#{@cached_label_priority}</strong>: #{h(issue.priority.name)}".html_safe
59 59 end
60 60
61 61 def issue_heading(issue)
62 62 h("#{issue.tracker} ##{issue.id}")
63 63 end
64 64
65 65 def render_issue_subject_with_tree(issue)
66 66 s = ''
67 67 ancestors = issue.root? ? [] : issue.ancestors.visible.to_a
68 68 ancestors.each do |ancestor|
69 69 s << '<div>' + content_tag('p', link_to_issue(ancestor, :project => (issue.project_id != ancestor.project_id)))
70 70 end
71 71 s << '<div>'
72 72 subject = h(issue.subject)
73 73 if issue.is_private?
74 74 subject = content_tag('span', l(:field_is_private), :class => 'private') + ' ' + subject
75 75 end
76 76 s << content_tag('h3', subject)
77 77 s << '</div>' * (ancestors.size + 1)
78 78 s.html_safe
79 79 end
80 80
81 81 def render_descendants_tree(issue)
82 82 s = '<form><table class="list issues">'
83 83 issue_list(issue.descendants.visible.sort_by(&:lft)) do |child, level|
84 84 css = "issue issue-#{child.id} hascontextmenu"
85 85 css << " idnt idnt-#{level}" if level > 0
86 86 s << content_tag('tr',
87 87 content_tag('td', check_box_tag("ids[]", child.id, false, :id => nil), :class => 'checkbox') +
88 88 content_tag('td', link_to_issue(child, :project => (issue.project_id != child.project_id)), :class => 'subject', :style => 'width: 50%') +
89 89 content_tag('td', h(child.status)) +
90 90 content_tag('td', link_to_user(child.assigned_to)) +
91 91 content_tag('td', progress_bar(child.done_ratio, :width => '80px')),
92 92 :class => css)
93 93 end
94 94 s << '</table></form>'
95 95 s.html_safe
96 96 end
97 97
98 98 # Returns an array of error messages for bulk edited issues
99 99 def bulk_edit_error_messages(issues)
100 100 messages = {}
101 101 issues.each do |issue|
102 102 issue.errors.full_messages.each do |message|
103 103 messages[message] ||= []
104 104 messages[message] << issue
105 105 end
106 106 end
107 107 messages.map { |message, issues|
108 108 "#{message}: " + issues.map {|i| "##{i.id}"}.join(', ')
109 109 }
110 110 end
111 111
112 112 # Returns a link for adding a new subtask to the given issue
113 113 def link_to_new_subtask(issue)
114 114 attrs = {
115 115 :tracker_id => issue.tracker,
116 116 :parent_issue_id => issue
117 117 }
118 118 link_to(l(:button_add), new_project_issue_path(issue.project, :issue => attrs))
119 119 end
120 120
121 121 class IssueFieldsRows
122 122 include ActionView::Helpers::TagHelper
123 123
124 124 def initialize
125 125 @left = []
126 126 @right = []
127 127 end
128 128
129 129 def left(*args)
130 130 args.any? ? @left << cells(*args) : @left
131 131 end
132 132
133 133 def right(*args)
134 134 args.any? ? @right << cells(*args) : @right
135 135 end
136 136
137 137 def size
138 138 @left.size > @right.size ? @left.size : @right.size
139 139 end
140 140
141 141 def to_html
142 142 html = ''.html_safe
143 143 blank = content_tag('th', '') + content_tag('td', '')
144 144 size.times do |i|
145 145 left = @left[i] || blank
146 146 right = @right[i] || blank
147 147 html << content_tag('tr', left + right)
148 148 end
149 149 html
150 150 end
151 151
152 152 def cells(label, text, options={})
153 153 content_tag('th', "#{label}:", options) + content_tag('td', text, options)
154 154 end
155 155 end
156 156
157 157 def issue_fields_rows
158 158 r = IssueFieldsRows.new
159 159 yield r
160 160 r.to_html
161 161 end
162 162
163 163 def render_custom_fields_rows(issue)
164 164 values = issue.visible_custom_field_values
165 165 return if values.empty?
166 166 ordered_values = []
167 167 half = (values.size / 2.0).ceil
168 168 half.times do |i|
169 169 ordered_values << values[i]
170 170 ordered_values << values[i + half]
171 171 end
172 172 s = "<tr>\n"
173 173 n = 0
174 174 ordered_values.compact.each do |value|
175 175 css = "cf_#{value.custom_field.id}"
176 176 s << "</tr>\n<tr>\n" if n > 0 && (n % 2) == 0
177 177 s << "\t<th class=\"#{css}\">#{ h(value.custom_field.name) }:</th><td class=\"#{css}\">#{ h(show_value(value)) }</td>\n"
178 178 n += 1
179 179 end
180 180 s << "</tr>\n"
181 181 s.html_safe
182 182 end
183 183
184 184 # Returns the number of descendants for an array of issues
185 185 def issues_descendant_count(issues)
186 186 ids = issues.reject(&:leaf?).map {|issue| issue.descendants.ids}.flatten.uniq
187 187 ids -= issues.map(&:id)
188 188 ids.size
189 189 end
190 190
191 191 def issues_destroy_confirmation_message(issues)
192 192 issues = [issues] unless issues.is_a?(Array)
193 193 message = l(:text_issues_destroy_confirmation)
194 194
195 195 descendant_count = issues_descendant_count(issues)
196 196 if descendant_count > 0
197 197 message << "\n" + l(:text_issues_destroy_descendants_confirmation, :count => descendant_count)
198 198 end
199 199 message
200 200 end
201 201
202 202 def sidebar_queries
203 203 unless @sidebar_queries
204 204 @sidebar_queries = IssueQuery.visible.
205 205 order("#{Query.table_name}.name ASC").
206 206 # Project specific queries and global queries
207 207 where(@project.nil? ? ["project_id IS NULL"] : ["project_id IS NULL OR project_id = ?", @project.id]).
208 208 to_a
209 209 end
210 210 @sidebar_queries
211 211 end
212 212
213 213 def query_links(title, queries)
214 214 return '' if queries.empty?
215 215 # links to #index on issues/show
216 216 url_params = controller_name == 'issues' ? {:controller => 'issues', :action => 'index', :project_id => @project} : params
217 217
218 218 content_tag('h3', title) + "\n" +
219 219 content_tag('ul',
220 220 queries.collect {|query|
221 221 css = 'query'
222 222 css << ' selected' if query == @query
223 223 content_tag('li', link_to(query.name, url_params.merge(:query_id => query), :class => css))
224 224 }.join("\n").html_safe,
225 225 :class => 'queries'
226 226 ) + "\n"
227 227 end
228 228
229 229 def render_sidebar_queries
230 230 out = ''.html_safe
231 231 out << query_links(l(:label_my_queries), sidebar_queries.select(&:is_private?))
232 232 out << query_links(l(:label_query_plural), sidebar_queries.reject(&:is_private?))
233 233 out
234 234 end
235 235
236 236 def email_issue_attributes(issue, user)
237 237 items = []
238 238 %w(author status priority assigned_to category fixed_version).each do |attribute|
239 239 unless issue.disabled_core_fields.include?(attribute+"_id")
240 240 items << "#{l("field_#{attribute}")}: #{issue.send attribute}"
241 241 end
242 242 end
243 243 issue.visible_custom_field_values(user).each do |value|
244 244 items << "#{value.custom_field.name}: #{show_value(value, false)}"
245 245 end
246 246 items
247 247 end
248 248
249 249 def render_email_issue_attributes(issue, user, html=false)
250 250 items = email_issue_attributes(issue, user)
251 251 if html
252 252 content_tag('ul', items.map{|s| content_tag('li', s)}.join("\n").html_safe)
253 253 else
254 254 items.map{|s| "* #{s}"}.join("\n")
255 255 end
256 256 end
257 257
258 258 # Returns the textual representation of a journal details
259 259 # as an array of strings
260 260 def details_to_strings(details, no_html=false, options={})
261 261 options[:only_path] = (options[:only_path] == false ? false : true)
262 262 strings = []
263 263 values_by_field = {}
264 264 details.each do |detail|
265 265 if detail.property == 'cf'
266 266 field = detail.custom_field
267 267 if field && field.multiple?
268 268 values_by_field[field] ||= {:added => [], :deleted => []}
269 269 if detail.old_value
270 270 values_by_field[field][:deleted] << detail.old_value
271 271 end
272 272 if detail.value
273 273 values_by_field[field][:added] << detail.value
274 274 end
275 275 next
276 276 end
277 277 end
278 278 strings << show_detail(detail, no_html, options)
279 279 end
280 280 values_by_field.each do |field, changes|
281 281 detail = JournalDetail.new(:property => 'cf', :prop_key => field.id.to_s)
282 282 detail.instance_variable_set "@custom_field", field
283 283 if changes[:added].any?
284 284 detail.value = changes[:added]
285 285 strings << show_detail(detail, no_html, options)
286 286 elsif changes[:deleted].any?
287 287 detail.old_value = changes[:deleted]
288 288 strings << show_detail(detail, no_html, options)
289 289 end
290 290 end
291 291 strings
292 292 end
293 293
294 294 # Returns the textual representation of a single journal detail
295 295 def show_detail(detail, no_html=false, options={})
296 296 multiple = false
297 show_diff = false
298
297 299 case detail.property
298 300 when 'attr'
299 301 field = detail.prop_key.to_s.gsub(/\_id$/, "")
300 302 label = l(("field_" + field).to_sym)
301 303 case detail.prop_key
302 304 when 'due_date', 'start_date'
303 305 value = format_date(detail.value.to_date) if detail.value
304 306 old_value = format_date(detail.old_value.to_date) if detail.old_value
305 307
306 308 when 'project_id', 'status_id', 'tracker_id', 'assigned_to_id',
307 309 'priority_id', 'category_id', 'fixed_version_id'
308 310 value = find_name_by_reflection(field, detail.value)
309 311 old_value = find_name_by_reflection(field, detail.old_value)
310 312
311 313 when 'estimated_hours'
312 314 value = "%0.02f" % detail.value.to_f unless detail.value.blank?
313 315 old_value = "%0.02f" % detail.old_value.to_f unless detail.old_value.blank?
314 316
315 317 when 'parent_id'
316 318 label = l(:field_parent_issue)
317 319 value = "##{detail.value}" unless detail.value.blank?
318 320 old_value = "##{detail.old_value}" unless detail.old_value.blank?
319 321
320 322 when 'is_private'
321 323 value = l(detail.value == "0" ? :general_text_No : :general_text_Yes) unless detail.value.blank?
322 324 old_value = l(detail.old_value == "0" ? :general_text_No : :general_text_Yes) unless detail.old_value.blank?
325
326 when 'description'
327 show_diff = true
323 328 end
324 329 when 'cf'
325 330 custom_field = detail.custom_field
326 331 if custom_field
327 multiple = custom_field.multiple?
328 332 label = custom_field.name
329 value = format_value(detail.value, custom_field) if detail.value
330 old_value = format_value(detail.old_value, custom_field) if detail.old_value
333 if custom_field.format.class.change_as_diff
334 show_diff = true
335 else
336 multiple = custom_field.multiple?
337 value = format_value(detail.value, custom_field) if detail.value
338 old_value = format_value(detail.old_value, custom_field) if detail.old_value
339 end
331 340 end
332 341 when 'attachment'
333 342 label = l(:label_attachment)
334 343 when 'relation'
335 344 if detail.value && !detail.old_value
336 345 rel_issue = Issue.visible.find_by_id(detail.value)
337 346 value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.value}" :
338 347 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
339 348 elsif detail.old_value && !detail.value
340 349 rel_issue = Issue.visible.find_by_id(detail.old_value)
341 350 old_value = rel_issue.nil? ? "#{l(:label_issue)} ##{detail.old_value}" :
342 351 (no_html ? rel_issue : link_to_issue(rel_issue, :only_path => options[:only_path]))
343 352 end
344 353 relation_type = IssueRelation::TYPES[detail.prop_key]
345 354 label = l(relation_type[:name]) if relation_type
346 355 end
347 356 call_hook(:helper_issues_show_detail_after_setting,
348 357 {:detail => detail, :label => label, :value => value, :old_value => old_value })
349 358
350 359 label ||= detail.prop_key
351 360 value ||= detail.value
352 361 old_value ||= detail.old_value
353 362
354 363 unless no_html
355 364 label = content_tag('strong', label)
356 365 old_value = content_tag("i", h(old_value)) if detail.old_value
357 366 if detail.old_value && detail.value.blank? && detail.property != 'relation'
358 367 old_value = content_tag("del", old_value)
359 368 end
360 369 if detail.property == 'attachment' && value.present? &&
361 370 atta = detail.journal.journalized.attachments.detect {|a| a.id == detail.prop_key.to_i}
362 371 # Link to the attachment if it has not been removed
363 372 value = link_to_attachment(atta, :download => true, :only_path => options[:only_path])
364 373 if options[:only_path] != false && atta.is_text?
365 374 value += link_to(
366 375 image_tag('magnifier.png'),
367 376 :controller => 'attachments', :action => 'show',
368 377 :id => atta, :filename => atta.filename
369 378 )
370 379 end
371 380 else
372 381 value = content_tag("i", h(value)) if value
373 382 end
374 383 end
375 384
376 if detail.property == 'attr' && detail.prop_key == 'description'
385 if show_diff
377 386 s = l(:text_journal_changed_no_detail, :label => label)
378 387 unless no_html
379 388 diff_link = link_to 'diff',
380 389 {:controller => 'journals', :action => 'diff', :id => detail.journal_id,
381 390 :detail_id => detail.id, :only_path => options[:only_path]},
382 391 :title => l(:label_view_diff)
383 392 s << " (#{ diff_link })"
384 393 end
385 394 s.html_safe
386 395 elsif detail.value.present?
387 396 case detail.property
388 397 when 'attr', 'cf'
389 398 if detail.old_value.present?
390 399 l(:text_journal_changed, :label => label, :old => old_value, :new => value).html_safe
391 400 elsif multiple
392 401 l(:text_journal_added, :label => label, :value => value).html_safe
393 402 else
394 403 l(:text_journal_set_to, :label => label, :value => value).html_safe
395 404 end
396 405 when 'attachment', 'relation'
397 406 l(:text_journal_added, :label => label, :value => value).html_safe
398 407 end
399 408 else
400 409 l(:text_journal_deleted, :label => label, :old => old_value).html_safe
401 410 end
402 411 end
403 412
404 413 # Find the name of an associated record stored in the field attribute
405 414 def find_name_by_reflection(field, id)
406 415 unless id.present?
407 416 return nil
408 417 end
409 418 @detail_value_name_by_reflection ||= Hash.new do |hash, key|
410 419 association = Issue.reflect_on_association(key.first.to_sym)
411 420 if association
412 421 record = association.klass.find_by_id(key.last)
413 422 if record
414 423 record.name.force_encoding('UTF-8')
415 424 hash[key] = record.name
416 425 end
417 426 end
418 427 hash[key] ||= nil
419 428 end
420 429 @detail_value_name_by_reflection[[field, id]]
421 430 end
422 431
423 432 # Renders issue children recursively
424 433 def render_api_issue_children(issue, api)
425 434 return if issue.leaf?
426 435 api.array :children do
427 436 issue.children.each do |child|
428 437 api.issue(:id => child.id) do
429 438 api.tracker(:id => child.tracker_id, :name => child.tracker.name) unless child.tracker.nil?
430 439 api.subject child.subject
431 440 render_api_issue_children(child, api)
432 441 end
433 442 end
434 443 end
435 444 end
436 445 end
@@ -1,710 +1,714
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2015 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 FieldFormat
20 20 def self.add(name, klass)
21 21 all[name.to_s] = klass.instance
22 22 end
23 23
24 24 def self.delete(name)
25 25 all.delete(name.to_s)
26 26 end
27 27
28 28 def self.all
29 29 @formats ||= Hash.new(Base.instance)
30 30 end
31 31
32 32 def self.available_formats
33 33 all.keys
34 34 end
35 35
36 36 def self.find(name)
37 37 all[name.to_s]
38 38 end
39 39
40 40 # Return an array of custom field formats which can be used in select_tag
41 41 def self.as_select(class_name=nil)
42 42 formats = all.values.select do |format|
43 43 format.class.customized_class_names.nil? || format.class.customized_class_names.include?(class_name)
44 44 end
45 45 formats.map {|format| [::I18n.t(format.label), format.name] }.sort_by(&:first)
46 46 end
47 47
48 48 class Base
49 49 include Singleton
50 50 include Redmine::I18n
51 51 include ERB::Util
52 52
53 53 class_attribute :format_name
54 54 self.format_name = nil
55 55
56 56 # Set this to true if the format supports multiple values
57 57 class_attribute :multiple_supported
58 58 self.multiple_supported = false
59 59
60 60 # Set this to true if the format supports textual search on custom values
61 61 class_attribute :searchable_supported
62 62 self.searchable_supported = false
63 63
64 64 # Restricts the classes that the custom field can be added to
65 65 # Set to nil for no restrictions
66 66 class_attribute :customized_class_names
67 67 self.customized_class_names = nil
68 68
69 69 # Name of the partial for editing the custom field
70 70 class_attribute :form_partial
71 71 self.form_partial = nil
72 72
73 class_attribute :change_as_diff
74 self.change_as_diff = false
75
73 76 def self.add(name)
74 77 self.format_name = name
75 78 Redmine::FieldFormat.add(name, self)
76 79 end
77 80 private_class_method :add
78 81
79 82 def self.field_attributes(*args)
80 83 CustomField.store_accessor :format_store, *args
81 84 end
82 85
83 86 field_attributes :url_pattern
84 87
85 88 def name
86 89 self.class.format_name
87 90 end
88 91
89 92 def label
90 93 "label_#{name}"
91 94 end
92 95
93 96 def cast_custom_value(custom_value)
94 97 cast_value(custom_value.custom_field, custom_value.value, custom_value.customized)
95 98 end
96 99
97 100 def cast_value(custom_field, value, customized=nil)
98 101 if value.blank?
99 102 nil
100 103 elsif value.is_a?(Array)
101 104 casted = value.map do |v|
102 105 cast_single_value(custom_field, v, customized)
103 106 end
104 107 casted.compact.sort
105 108 else
106 109 cast_single_value(custom_field, value, customized)
107 110 end
108 111 end
109 112
110 113 def cast_single_value(custom_field, value, customized=nil)
111 114 value.to_s
112 115 end
113 116
114 117 def target_class
115 118 nil
116 119 end
117 120
118 121 def possible_custom_value_options(custom_value)
119 122 possible_values_options(custom_value.custom_field, custom_value.customized)
120 123 end
121 124
122 125 def possible_values_options(custom_field, object=nil)
123 126 []
124 127 end
125 128
126 129 # Returns the validation errors for custom_field
127 130 # Should return an empty array if custom_field is valid
128 131 def validate_custom_field(custom_field)
129 132 []
130 133 end
131 134
132 135 # Returns the validation error messages for custom_value
133 136 # Should return an empty array if custom_value is valid
134 137 def validate_custom_value(custom_value)
135 138 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
136 139 errors = values.map do |value|
137 140 validate_single_value(custom_value.custom_field, value, custom_value.customized)
138 141 end
139 142 errors.flatten.uniq
140 143 end
141 144
142 145 def validate_single_value(custom_field, value, customized=nil)
143 146 []
144 147 end
145 148
146 149 def formatted_custom_value(view, custom_value, html=false)
147 150 formatted_value(view, custom_value.custom_field, custom_value.value, custom_value.customized, html)
148 151 end
149 152
150 153 def formatted_value(view, custom_field, value, customized=nil, html=false)
151 154 casted = cast_value(custom_field, value, customized)
152 155 if html && custom_field.url_pattern.present?
153 156 texts_and_urls = Array.wrap(casted).map do |single_value|
154 157 text = view.format_object(single_value, false).to_s
155 158 url = url_from_pattern(custom_field, single_value, customized)
156 159 [text, url]
157 160 end
158 161 links = texts_and_urls.sort_by(&:first).map {|text, url| view.link_to text, url}
159 162 links.join(', ').html_safe
160 163 else
161 164 casted
162 165 end
163 166 end
164 167
165 168 # Returns an URL generated with the custom field URL pattern
166 169 # and variables substitution:
167 170 # %value% => the custom field value
168 171 # %id% => id of the customized object
169 172 # %project_id% => id of the project of the customized object if defined
170 173 # %project_identifier% => identifier of the project of the customized object if defined
171 174 # %m1%, %m2%... => capture groups matches of the custom field regexp if defined
172 175 def url_from_pattern(custom_field, value, customized)
173 176 url = custom_field.url_pattern.to_s.dup
174 177 url.gsub!('%value%') {value.to_s}
175 178 url.gsub!('%id%') {customized.id.to_s}
176 179 url.gsub!('%project_id%') {(customized.respond_to?(:project) ? customized.project.try(:id) : nil).to_s}
177 180 url.gsub!('%project_identifier%') {(customized.respond_to?(:project) ? customized.project.try(:identifier) : nil).to_s}
178 181 if custom_field.regexp.present?
179 182 url.gsub!(%r{%m(\d+)%}) do
180 183 m = $1.to_i
181 184 if matches ||= value.to_s.match(Regexp.new(custom_field.regexp))
182 185 matches[m].to_s
183 186 end
184 187 end
185 188 end
186 189 url
187 190 end
188 191 protected :url_from_pattern
189 192
190 193 def edit_tag(view, tag_id, tag_name, custom_value, options={})
191 194 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id))
192 195 end
193 196
194 197 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
195 198 view.text_field_tag(tag_name, value, options.merge(:id => tag_id)) +
196 199 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
197 200 end
198 201
199 202 def bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
200 203 if custom_field.is_required?
201 204 ''.html_safe
202 205 else
203 206 view.content_tag('label',
204 207 view.check_box_tag(tag_name, '__none__', (value == '__none__'), :id => nil, :data => {:disables => "##{tag_id}"}) + l(:button_clear),
205 208 :class => 'inline'
206 209 )
207 210 end
208 211 end
209 212 protected :bulk_clear_tag
210 213
211 214 def query_filter_options(custom_field, query)
212 215 {:type => :string}
213 216 end
214 217
215 218 def before_custom_field_save(custom_field)
216 219 end
217 220
218 221 # Returns a ORDER BY clause that can used to sort customized
219 222 # objects by their value of the custom field.
220 223 # Returns nil if the custom field can not be used for sorting.
221 224 def order_statement(custom_field)
222 225 # COALESCE is here to make sure that blank and NULL values are sorted equally
223 226 "COALESCE(#{join_alias custom_field}.value, '')"
224 227 end
225 228
226 229 # Returns a GROUP BY clause that can used to group by custom value
227 230 # Returns nil if the custom field can not be used for grouping.
228 231 def group_statement(custom_field)
229 232 nil
230 233 end
231 234
232 235 # Returns a JOIN clause that is added to the query when sorting by custom values
233 236 def join_for_order_statement(custom_field)
234 237 alias_name = join_alias(custom_field)
235 238
236 239 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
237 240 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
238 241 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
239 242 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
240 243 " AND (#{custom_field.visibility_by_project_condition})" +
241 244 " AND #{alias_name}.value <> ''" +
242 245 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
243 246 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
244 247 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
245 248 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)"
246 249 end
247 250
248 251 def join_alias(custom_field)
249 252 "cf_#{custom_field.id}"
250 253 end
251 254 protected :join_alias
252 255 end
253 256
254 257 class Unbounded < Base
255 258 def validate_single_value(custom_field, value, customized=nil)
256 259 errs = super
257 260 value = value.to_s
258 261 unless custom_field.regexp.blank? or value =~ Regexp.new(custom_field.regexp)
259 262 errs << ::I18n.t('activerecord.errors.messages.invalid')
260 263 end
261 264 if custom_field.min_length && value.length < custom_field.min_length
262 265 errs << ::I18n.t('activerecord.errors.messages.too_short', :count => custom_field.min_length)
263 266 end
264 267 if custom_field.max_length && custom_field.max_length > 0 && value.length > custom_field.max_length
265 268 errs << ::I18n.t('activerecord.errors.messages.too_long', :count => custom_field.max_length)
266 269 end
267 270 errs
268 271 end
269 272 end
270 273
271 274 class StringFormat < Unbounded
272 275 add 'string'
273 276 self.searchable_supported = true
274 277 self.form_partial = 'custom_fields/formats/string'
275 278 field_attributes :text_formatting
276 279
277 280 def formatted_value(view, custom_field, value, customized=nil, html=false)
278 281 if html
279 282 if custom_field.url_pattern.present?
280 283 super
281 284 elsif custom_field.text_formatting == 'full'
282 285 view.textilizable(value, :object => customized)
283 286 else
284 287 value.to_s
285 288 end
286 289 else
287 290 value.to_s
288 291 end
289 292 end
290 293 end
291 294
292 295 class TextFormat < Unbounded
293 296 add 'text'
294 297 self.searchable_supported = true
295 298 self.form_partial = 'custom_fields/formats/text'
299 self.change_as_diff = true
296 300
297 301 def formatted_value(view, custom_field, value, customized=nil, html=false)
298 302 if html
299 303 if custom_field.text_formatting == 'full'
300 304 view.textilizable(value, :object => customized)
301 305 else
302 306 view.simple_format(html_escape(value))
303 307 end
304 308 else
305 309 value.to_s
306 310 end
307 311 end
308 312
309 313 def edit_tag(view, tag_id, tag_name, custom_value, options={})
310 314 view.text_area_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :rows => 3))
311 315 end
312 316
313 317 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
314 318 view.text_area_tag(tag_name, value, options.merge(:id => tag_id, :rows => 3)) +
315 319 '<br />'.html_safe +
316 320 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
317 321 end
318 322
319 323 def query_filter_options(custom_field, query)
320 324 {:type => :text}
321 325 end
322 326 end
323 327
324 328 class LinkFormat < StringFormat
325 329 add 'link'
326 330 self.searchable_supported = false
327 331 self.form_partial = 'custom_fields/formats/link'
328 332
329 333 def formatted_value(view, custom_field, value, customized=nil, html=false)
330 334 if html
331 335 if custom_field.url_pattern.present?
332 336 url = url_from_pattern(custom_field, value, customized)
333 337 else
334 338 url = value.to_s
335 339 unless url =~ %r{\A[a-z]+://}i
336 340 # no protocol found, use http by default
337 341 url = "http://" + url
338 342 end
339 343 end
340 344 view.link_to value.to_s, url
341 345 else
342 346 value.to_s
343 347 end
344 348 end
345 349 end
346 350
347 351 class Numeric < Unbounded
348 352 self.form_partial = 'custom_fields/formats/numeric'
349 353
350 354 def order_statement(custom_field)
351 355 # Make the database cast values into numeric
352 356 # Postgresql will raise an error if a value can not be casted!
353 357 # CustomValue validations should ensure that it doesn't occur
354 358 "CAST(CASE #{join_alias custom_field}.value WHEN '' THEN '0' ELSE #{join_alias custom_field}.value END AS decimal(30,3))"
355 359 end
356 360 end
357 361
358 362 class IntFormat < Numeric
359 363 add 'int'
360 364
361 365 def label
362 366 "label_integer"
363 367 end
364 368
365 369 def cast_single_value(custom_field, value, customized=nil)
366 370 value.to_i
367 371 end
368 372
369 373 def validate_single_value(custom_field, value, customized=nil)
370 374 errs = super
371 375 errs << ::I18n.t('activerecord.errors.messages.not_a_number') unless value.to_s =~ /^[+-]?\d+$/
372 376 errs
373 377 end
374 378
375 379 def query_filter_options(custom_field, query)
376 380 {:type => :integer}
377 381 end
378 382
379 383 def group_statement(custom_field)
380 384 order_statement(custom_field)
381 385 end
382 386 end
383 387
384 388 class FloatFormat < Numeric
385 389 add 'float'
386 390
387 391 def cast_single_value(custom_field, value, customized=nil)
388 392 value.to_f
389 393 end
390 394
391 395 def validate_single_value(custom_field, value, customized=nil)
392 396 errs = super
393 397 errs << ::I18n.t('activerecord.errors.messages.invalid') unless (Kernel.Float(value) rescue nil)
394 398 errs
395 399 end
396 400
397 401 def query_filter_options(custom_field, query)
398 402 {:type => :float}
399 403 end
400 404 end
401 405
402 406 class DateFormat < Unbounded
403 407 add 'date'
404 408 self.form_partial = 'custom_fields/formats/date'
405 409
406 410 def cast_single_value(custom_field, value, customized=nil)
407 411 value.to_date rescue nil
408 412 end
409 413
410 414 def validate_single_value(custom_field, value, customized=nil)
411 415 if value =~ /^\d{4}-\d{2}-\d{2}$/ && (value.to_date rescue false)
412 416 []
413 417 else
414 418 [::I18n.t('activerecord.errors.messages.not_a_date')]
415 419 end
416 420 end
417 421
418 422 def edit_tag(view, tag_id, tag_name, custom_value, options={})
419 423 view.text_field_tag(tag_name, custom_value.value, options.merge(:id => tag_id, :size => 10)) +
420 424 view.calendar_for(tag_id)
421 425 end
422 426
423 427 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
424 428 view.text_field_tag(tag_name, value, options.merge(:id => tag_id, :size => 10)) +
425 429 view.calendar_for(tag_id) +
426 430 bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
427 431 end
428 432
429 433 def query_filter_options(custom_field, query)
430 434 {:type => :date}
431 435 end
432 436
433 437 def group_statement(custom_field)
434 438 order_statement(custom_field)
435 439 end
436 440 end
437 441
438 442 class List < Base
439 443 self.multiple_supported = true
440 444 field_attributes :edit_tag_style
441 445
442 446 def edit_tag(view, tag_id, tag_name, custom_value, options={})
443 447 if custom_value.custom_field.edit_tag_style == 'check_box'
444 448 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
445 449 else
446 450 select_edit_tag(view, tag_id, tag_name, custom_value, options)
447 451 end
448 452 end
449 453
450 454 def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
451 455 opts = []
452 456 opts << [l(:label_no_change_option), ''] unless custom_field.multiple?
453 457 opts << [l(:label_none), '__none__'] unless custom_field.is_required?
454 458 opts += possible_values_options(custom_field, objects)
455 459 view.select_tag(tag_name, view.options_for_select(opts, value), options.merge(:multiple => custom_field.multiple?))
456 460 end
457 461
458 462 def query_filter_options(custom_field, query)
459 463 {:type => :list_optional, :values => possible_values_options(custom_field, query.project)}
460 464 end
461 465
462 466 protected
463 467
464 468 # Renders the edit tag as a select tag
465 469 def select_edit_tag(view, tag_id, tag_name, custom_value, options={})
466 470 blank_option = ''.html_safe
467 471 unless custom_value.custom_field.multiple?
468 472 if custom_value.custom_field.is_required?
469 473 unless custom_value.custom_field.default_value.present?
470 474 blank_option = view.content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '')
471 475 end
472 476 else
473 477 blank_option = view.content_tag('option', '&nbsp;'.html_safe, :value => '')
474 478 end
475 479 end
476 480 options_tags = blank_option + view.options_for_select(possible_custom_value_options(custom_value), custom_value.value)
477 481 s = view.select_tag(tag_name, options_tags, options.merge(:id => tag_id, :multiple => custom_value.custom_field.multiple?))
478 482 if custom_value.custom_field.multiple?
479 483 s << view.hidden_field_tag(tag_name, '')
480 484 end
481 485 s
482 486 end
483 487
484 488 # Renders the edit tag as check box or radio tags
485 489 def check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
486 490 opts = []
487 491 unless custom_value.custom_field.multiple? || custom_value.custom_field.is_required?
488 492 opts << ["(#{l(:label_none)})", '']
489 493 end
490 494 opts += possible_custom_value_options(custom_value)
491 495 s = ''.html_safe
492 496 tag_method = custom_value.custom_field.multiple? ? :check_box_tag : :radio_button_tag
493 497 opts.each do |label, value|
494 498 value ||= label
495 499 checked = (custom_value.value.is_a?(Array) && custom_value.value.include?(value)) || custom_value.value.to_s == value
496 500 tag = view.send(tag_method, tag_name, value, checked, :id => tag_id)
497 501 # set the id on the first tag only
498 502 tag_id = nil
499 503 s << view.content_tag('label', tag + ' ' + label)
500 504 end
501 505 if custom_value.custom_field.multiple?
502 506 s << view.hidden_field_tag(tag_name, '')
503 507 end
504 508 css = "#{options[:class]} check_box_group"
505 509 view.content_tag('span', s, options.merge(:class => css))
506 510 end
507 511 end
508 512
509 513 class ListFormat < List
510 514 add 'list'
511 515 self.searchable_supported = true
512 516 self.form_partial = 'custom_fields/formats/list'
513 517
514 518 def possible_custom_value_options(custom_value)
515 519 options = possible_values_options(custom_value.custom_field)
516 520 missing = [custom_value.value].flatten.reject(&:blank?) - options
517 521 if missing.any?
518 522 options += missing
519 523 end
520 524 options
521 525 end
522 526
523 527 def possible_values_options(custom_field, object=nil)
524 528 custom_field.possible_values
525 529 end
526 530
527 531 def validate_custom_field(custom_field)
528 532 errors = []
529 533 errors << [:possible_values, :blank] if custom_field.possible_values.blank?
530 534 errors << [:possible_values, :invalid] unless custom_field.possible_values.is_a? Array
531 535 errors
532 536 end
533 537
534 538 def validate_custom_value(custom_value)
535 539 values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''}
536 540 invalid_values = values - Array.wrap(custom_value.value_was) - custom_value.custom_field.possible_values
537 541 if invalid_values.any?
538 542 [::I18n.t('activerecord.errors.messages.inclusion')]
539 543 else
540 544 []
541 545 end
542 546 end
543 547
544 548 def group_statement(custom_field)
545 549 order_statement(custom_field)
546 550 end
547 551 end
548 552
549 553 class BoolFormat < List
550 554 add 'bool'
551 555 self.multiple_supported = false
552 556 self.form_partial = 'custom_fields/formats/bool'
553 557
554 558 def label
555 559 "label_boolean"
556 560 end
557 561
558 562 def cast_single_value(custom_field, value, customized=nil)
559 563 value == '1' ? true : false
560 564 end
561 565
562 566 def possible_values_options(custom_field, object=nil)
563 567 [[::I18n.t(:general_text_Yes), '1'], [::I18n.t(:general_text_No), '0']]
564 568 end
565 569
566 570 def group_statement(custom_field)
567 571 order_statement(custom_field)
568 572 end
569 573
570 574 def edit_tag(view, tag_id, tag_name, custom_value, options={})
571 575 case custom_value.custom_field.edit_tag_style
572 576 when 'check_box'
573 577 single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
574 578 when 'radio'
575 579 check_box_edit_tag(view, tag_id, tag_name, custom_value, options)
576 580 else
577 581 select_edit_tag(view, tag_id, tag_name, custom_value, options)
578 582 end
579 583 end
580 584
581 585 # Renders the edit tag as a simple check box
582 586 def single_check_box_edit_tag(view, tag_id, tag_name, custom_value, options={})
583 587 s = ''.html_safe
584 588 s << view.hidden_field_tag(tag_name, '0', :id => nil)
585 589 s << view.check_box_tag(tag_name, '1', custom_value.value.to_s == '1', :id => tag_id)
586 590 view.content_tag('span', s, options)
587 591 end
588 592 end
589 593
590 594 class RecordList < List
591 595 self.customized_class_names = %w(Issue TimeEntry Version Project)
592 596
593 597 def cast_single_value(custom_field, value, customized=nil)
594 598 target_class.find_by_id(value.to_i) if value.present?
595 599 end
596 600
597 601 def target_class
598 602 @target_class ||= self.class.name[/^(.*::)?(.+)Format$/, 2].constantize rescue nil
599 603 end
600 604
601 605 def reset_target_class
602 606 @target_class = nil
603 607 end
604 608
605 609 def possible_custom_value_options(custom_value)
606 610 options = possible_values_options(custom_value.custom_field, custom_value.customized)
607 611 missing = [custom_value.value_was].flatten.reject(&:blank?) - options.map(&:last)
608 612 if missing.any?
609 613 options += target_class.where(:id => missing.map(&:to_i)).map {|o| [o.to_s, o.id.to_s]}
610 614 options.sort_by!(&:first)
611 615 end
612 616 options
613 617 end
614 618
615 619 def order_statement(custom_field)
616 620 if target_class.respond_to?(:fields_for_order_statement)
617 621 target_class.fields_for_order_statement(value_join_alias(custom_field))
618 622 end
619 623 end
620 624
621 625 def group_statement(custom_field)
622 626 "COALESCE(#{join_alias custom_field}.value, '')"
623 627 end
624 628
625 629 def join_for_order_statement(custom_field)
626 630 alias_name = join_alias(custom_field)
627 631
628 632 "LEFT OUTER JOIN #{CustomValue.table_name} #{alias_name}" +
629 633 " ON #{alias_name}.customized_type = '#{custom_field.class.customized_class.base_class.name}'" +
630 634 " AND #{alias_name}.customized_id = #{custom_field.class.customized_class.table_name}.id" +
631 635 " AND #{alias_name}.custom_field_id = #{custom_field.id}" +
632 636 " AND (#{custom_field.visibility_by_project_condition})" +
633 637 " AND #{alias_name}.value <> ''" +
634 638 " AND #{alias_name}.id = (SELECT max(#{alias_name}_2.id) FROM #{CustomValue.table_name} #{alias_name}_2" +
635 639 " WHERE #{alias_name}_2.customized_type = #{alias_name}.customized_type" +
636 640 " AND #{alias_name}_2.customized_id = #{alias_name}.customized_id" +
637 641 " AND #{alias_name}_2.custom_field_id = #{alias_name}.custom_field_id)" +
638 642 " LEFT OUTER JOIN #{target_class.table_name} #{value_join_alias custom_field}" +
639 643 " ON CAST(CASE #{alias_name}.value WHEN '' THEN '0' ELSE #{alias_name}.value END AS decimal(30,0)) = #{value_join_alias custom_field}.id"
640 644 end
641 645
642 646 def value_join_alias(custom_field)
643 647 join_alias(custom_field) + "_" + custom_field.field_format
644 648 end
645 649 protected :value_join_alias
646 650 end
647 651
648 652 class UserFormat < RecordList
649 653 add 'user'
650 654 self.form_partial = 'custom_fields/formats/user'
651 655 field_attributes :user_role
652 656
653 657 def possible_values_options(custom_field, object=nil)
654 658 if object.is_a?(Array)
655 659 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
656 660 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
657 661 elsif object.respond_to?(:project) && object.project
658 662 scope = object.project.users
659 663 if custom_field.user_role.is_a?(Array)
660 664 role_ids = custom_field.user_role.map(&:to_s).reject(&:blank?).map(&:to_i)
661 665 if role_ids.any?
662 666 scope = scope.where("#{Member.table_name}.id IN (SELECT DISTINCT member_id FROM #{MemberRole.table_name} WHERE role_id IN (?))", role_ids)
663 667 end
664 668 end
665 669 scope.sorted.collect {|u| [u.to_s, u.id.to_s]}
666 670 else
667 671 []
668 672 end
669 673 end
670 674
671 675 def before_custom_field_save(custom_field)
672 676 super
673 677 if custom_field.user_role.is_a?(Array)
674 678 custom_field.user_role.map!(&:to_s).reject!(&:blank?)
675 679 end
676 680 end
677 681 end
678 682
679 683 class VersionFormat < RecordList
680 684 add 'version'
681 685 self.form_partial = 'custom_fields/formats/version'
682 686 field_attributes :version_status
683 687
684 688 def possible_values_options(custom_field, object=nil)
685 689 if object.is_a?(Array)
686 690 projects = object.map {|o| o.respond_to?(:project) ? o.project : nil}.compact.uniq
687 691 projects.map {|project| possible_values_options(custom_field, project)}.reduce(:&) || []
688 692 elsif object.respond_to?(:project) && object.project
689 693 scope = object.project.shared_versions
690 694 if custom_field.version_status.is_a?(Array)
691 695 statuses = custom_field.version_status.map(&:to_s).reject(&:blank?)
692 696 if statuses.any?
693 697 scope = scope.where(:status => statuses.map(&:to_s))
694 698 end
695 699 end
696 700 scope.sort.collect {|u| [u.to_s, u.id.to_s]}
697 701 else
698 702 []
699 703 end
700 704 end
701 705
702 706 def before_custom_field_save(custom_field)
703 707 super
704 708 if custom_field.version_status.is_a?(Array)
705 709 custom_field.version_status.map!(&:to_s).reject!(&:blank?)
706 710 end
707 711 end
708 712 end
709 713 end
710 714 end
@@ -1,157 +1,181
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2015 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 JournalsControllerTest < ActionController::TestCase
21 21 fixtures :projects, :users, :members, :member_roles, :roles, :issues, :journals, :journal_details, :enabled_modules,
22 22 :trackers, :issue_statuses, :enumerations, :custom_fields, :custom_values, :custom_fields_projects
23 23
24 24 def setup
25 25 User.current = nil
26 26 end
27 27
28 28 def test_index
29 29 get :index, :project_id => 1
30 30 assert_response :success
31 31 assert_not_nil assigns(:journals)
32 32 assert_equal 'application/atom+xml', @response.content_type
33 33 end
34 34
35 35 def test_index_with_invalid_query_id
36 36 get :index, :project_id => 1, :query_id => 999
37 37 assert_response 404
38 38 end
39 39
40 40 def test_index_should_return_privates_notes_with_permission_only
41 41 journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Privates notes', :private_notes => true, :user_id => 1)
42 42 @request.session[:user_id] = 2
43 43
44 44 get :index, :project_id => 1
45 45 assert_response :success
46 46 assert_include journal, assigns(:journals)
47 47
48 48 Role.find(1).remove_permission! :view_private_notes
49 49 get :index, :project_id => 1
50 50 assert_response :success
51 51 assert_not_include journal, assigns(:journals)
52 52 end
53 53
54 def test_diff
54 def test_diff_for_description_change
55 55 get :diff, :id => 3, :detail_id => 4
56 56 assert_response :success
57 57 assert_template 'diff'
58 58
59 59 assert_select 'span.diff_out', :text => /removed/
60 60 assert_select 'span.diff_in', :text => /added/
61 61 end
62 62
63 def test_diff_for_custom_field
64 field = IssueCustomField.create!(:name => "Long field", :field_format => 'text')
65 journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Notes', :user_id => 1)
66 detail = JournalDetail.create!(:journal => journal, :property => 'cf', :prop_key => field.id,
67 :old_value => 'Foo', :value => 'Bar')
68
69 get :diff, :id => journal.id, :detail_id => detail.id
70 assert_response :success
71 assert_template 'diff'
72
73 assert_select 'span.diff_out', :text => /Foo/
74 assert_select 'span.diff_in', :text => /Bar/
75 end
76
77 def test_diff_for_custom_field_should_be_denied_if_custom_field_is_not_visible
78 field = IssueCustomField.create!(:name => "Long field", :field_format => 'text', :visible => false, :role_ids => [1])
79 journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Notes', :user_id => 1)
80 detail = JournalDetail.create!(:journal => journal, :property => 'cf', :prop_key => field.id,
81 :old_value => 'Foo', :value => 'Bar')
82
83 get :diff, :id => journal.id, :detail_id => detail.id
84 assert_response 302
85 end
86
63 87 def test_diff_should_default_to_description_diff
64 88 get :diff, :id => 3
65 89 assert_response :success
66 90 assert_template 'diff'
67 91
68 92 assert_select 'span.diff_out', :text => /removed/
69 93 assert_select 'span.diff_in', :text => /added/
70 94 end
71 95
72 96 def test_reply_to_issue
73 97 @request.session[:user_id] = 2
74 98 xhr :get, :new, :id => 6
75 99 assert_response :success
76 100 assert_template 'new'
77 101 assert_equal 'text/javascript', response.content_type
78 102 assert_include '> This is an issue', response.body
79 103 end
80 104
81 105 def test_reply_to_issue_without_permission
82 106 @request.session[:user_id] = 7
83 107 xhr :get, :new, :id => 6
84 108 assert_response 403
85 109 end
86 110
87 111 def test_reply_to_note
88 112 @request.session[:user_id] = 2
89 113 xhr :get, :new, :id => 6, :journal_id => 4
90 114 assert_response :success
91 115 assert_template 'new'
92 116 assert_equal 'text/javascript', response.content_type
93 117 assert_include '> A comment with a private version', response.body
94 118 end
95 119
96 120 def test_reply_to_private_note_should_fail_without_permission
97 121 journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Privates notes', :private_notes => true)
98 122 @request.session[:user_id] = 2
99 123
100 124 xhr :get, :new, :id => 2, :journal_id => journal.id
101 125 assert_response :success
102 126 assert_template 'new'
103 127 assert_equal 'text/javascript', response.content_type
104 128 assert_include '> Privates notes', response.body
105 129
106 130 Role.find(1).remove_permission! :view_private_notes
107 131 xhr :get, :new, :id => 2, :journal_id => journal.id
108 132 assert_response 404
109 133 end
110 134
111 135 def test_edit_xhr
112 136 @request.session[:user_id] = 1
113 137 xhr :get, :edit, :id => 2
114 138 assert_response :success
115 139 assert_template 'edit'
116 140 assert_equal 'text/javascript', response.content_type
117 141 assert_include 'textarea', response.body
118 142 end
119 143
120 144 def test_edit_private_note_should_fail_without_permission
121 145 journal = Journal.create!(:journalized => Issue.find(2), :notes => 'Privates notes', :private_notes => true)
122 146 @request.session[:user_id] = 2
123 147 Role.find(1).add_permission! :edit_issue_notes
124 148
125 149 xhr :get, :edit, :id => journal.id
126 150 assert_response :success
127 151 assert_template 'edit'
128 152 assert_equal 'text/javascript', response.content_type
129 153 assert_include 'textarea', response.body
130 154
131 155 Role.find(1).remove_permission! :view_private_notes
132 156 xhr :get, :edit, :id => journal.id
133 157 assert_response 404
134 158 end
135 159
136 160 def test_update_xhr
137 161 @request.session[:user_id] = 1
138 162 xhr :post, :edit, :id => 2, :notes => 'Updated notes'
139 163 assert_response :success
140 164 assert_template 'update'
141 165 assert_equal 'text/javascript', response.content_type
142 166 assert_equal 'Updated notes', Journal.find(2).notes
143 167 assert_include 'journal-2-notes', response.body
144 168 end
145 169
146 170 def test_update_xhr_with_empty_notes_should_delete_the_journal
147 171 @request.session[:user_id] = 1
148 172 assert_difference 'Journal.count', -1 do
149 173 xhr :post, :edit, :id => 2, :notes => ''
150 174 assert_response :success
151 175 assert_template 'update'
152 176 assert_equal 'text/javascript', response.content_type
153 177 end
154 178 assert_nil Journal.find_by_id(2)
155 179 assert_include 'change-2', response.body
156 180 end
157 181 end
@@ -1,285 +1,298
1 1 # Redmine - project management software
2 2 # Copyright (C) 2006-2015 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 IssuesHelperTest < ActionView::TestCase
21 21 include Redmine::I18n
22 22 include IssuesHelper
23 23 include CustomFieldsHelper
24 24 include ERB::Util
25 25 include Rails.application.routes.url_helpers
26 26
27 27 fixtures :projects, :trackers, :issue_statuses, :issues,
28 28 :enumerations, :users, :issue_categories,
29 29 :projects_trackers,
30 30 :roles,
31 31 :member_roles,
32 32 :members,
33 33 :enabled_modules,
34 34 :custom_fields,
35 35 :attachments,
36 36 :versions
37 37
38 38 def setup
39 39 super
40 40 set_language_if_valid('en')
41 41 User.current = nil
42 42 end
43 43
44 44 def test_issue_heading
45 45 assert_equal "Bug #1", issue_heading(Issue.find(1))
46 46 end
47 47
48 48 def test_issues_destroy_confirmation_message_with_one_root_issue
49 49 assert_equal l(:text_issues_destroy_confirmation),
50 50 issues_destroy_confirmation_message(Issue.find(1))
51 51 end
52 52
53 53 def test_issues_destroy_confirmation_message_with_an_arrayt_of_root_issues
54 54 assert_equal l(:text_issues_destroy_confirmation),
55 55 issues_destroy_confirmation_message(Issue.find([1, 2]))
56 56 end
57 57
58 58 def test_issues_destroy_confirmation_message_with_one_parent_issue
59 59 Issue.find(2).update_attribute :parent_issue_id, 1
60 60 assert_equal l(:text_issues_destroy_confirmation) + "\n" +
61 61 l(:text_issues_destroy_descendants_confirmation, :count => 1),
62 62 issues_destroy_confirmation_message(Issue.find(1))
63 63 end
64 64
65 65 def test_issues_destroy_confirmation_message_with_one_parent_issue_and_its_child
66 66 Issue.find(2).update_attribute :parent_issue_id, 1
67 67 assert_equal l(:text_issues_destroy_confirmation),
68 68 issues_destroy_confirmation_message(Issue.find([1, 2]))
69 69 end
70 70
71 71 def test_issues_destroy_confirmation_message_with_issues_that_share_descendants
72 72 root = Issue.generate!
73 73 child = Issue.generate!(:parent_issue_id => root.id)
74 74 Issue.generate!(:parent_issue_id => child.id)
75 75
76 76 assert_equal l(:text_issues_destroy_confirmation) + "\n" +
77 77 l(:text_issues_destroy_descendants_confirmation, :count => 1),
78 78 issues_destroy_confirmation_message([root.reload, child.reload])
79 79 end
80 80
81 81 test 'show_detail with no_html should show a changing attribute' do
82 82 detail = JournalDetail.new(:property => 'attr', :old_value => '40',
83 83 :value => '100', :prop_key => 'done_ratio')
84 84 assert_equal "% Done changed from 40 to 100", show_detail(detail, true)
85 85 end
86 86
87 87 test 'show_detail with no_html should show a new attribute' do
88 88 detail = JournalDetail.new(:property => 'attr', :old_value => nil,
89 89 :value => '100', :prop_key => 'done_ratio')
90 90 assert_equal "% Done set to 100", show_detail(detail, true)
91 91 end
92 92
93 93 test 'show_detail with no_html should show a deleted attribute' do
94 94 detail = JournalDetail.new(:property => 'attr', :old_value => '50',
95 95 :value => nil, :prop_key => 'done_ratio')
96 96 assert_equal "% Done deleted (50)", show_detail(detail, true)
97 97 end
98 98
99 99 test 'show_detail with html should show a changing attribute with HTML highlights' do
100 100 detail = JournalDetail.new(:property => 'attr', :old_value => '40',
101 101 :value => '100', :prop_key => 'done_ratio')
102 102 html = show_detail(detail, false)
103 103 assert_include '<strong>% Done</strong>', html
104 104 assert_include '<i>40</i>', html
105 105 assert_include '<i>100</i>', html
106 106 end
107 107
108 108 test 'show_detail with html should show a new attribute with HTML highlights' do
109 109 detail = JournalDetail.new(:property => 'attr', :old_value => nil,
110 110 :value => '100', :prop_key => 'done_ratio')
111 111 html = show_detail(detail, false)
112 112 assert_include '<strong>% Done</strong>', html
113 113 assert_include '<i>100</i>', html
114 114 end
115 115
116 116 test 'show_detail with html should show a deleted attribute with HTML highlights' do
117 117 detail = JournalDetail.new(:property => 'attr', :old_value => '50',
118 118 :value => nil, :prop_key => 'done_ratio')
119 119 html = show_detail(detail, false)
120 120 assert_include '<strong>% Done</strong>', html
121 121 assert_include '<del><i>50</i></del>', html
122 122 end
123 123
124 124 test 'show_detail with a start_date attribute should format the dates' do
125 125 detail = JournalDetail.new(
126 126 :property => 'attr',
127 127 :old_value => '2010-01-01',
128 128 :value => '2010-01-31',
129 129 :prop_key => 'start_date'
130 130 )
131 131 with_settings :date_format => '%m/%d/%Y' do
132 132 assert_match "01/31/2010", show_detail(detail, true)
133 133 assert_match "01/01/2010", show_detail(detail, true)
134 134 end
135 135 end
136 136
137 137 test 'show_detail with a due_date attribute should format the dates' do
138 138 detail = JournalDetail.new(
139 139 :property => 'attr',
140 140 :old_value => '2010-01-01',
141 141 :value => '2010-01-31',
142 142 :prop_key => 'due_date'
143 143 )
144 144 with_settings :date_format => '%m/%d/%Y' do
145 145 assert_match "01/31/2010", show_detail(detail, true)
146 146 assert_match "01/01/2010", show_detail(detail, true)
147 147 end
148 148 end
149 149
150 150 test 'show_detail should show old and new values with a project attribute' do
151 151 detail = JournalDetail.new(:property => 'attr', :prop_key => 'project_id',
152 152 :old_value => 1, :value => 2)
153 153 assert_match 'eCookbook', show_detail(detail, true)
154 154 assert_match 'OnlineStore', show_detail(detail, true)
155 155 end
156 156
157 157 test 'show_detail should show old and new values with a issue status attribute' do
158 158 detail = JournalDetail.new(:property => 'attr', :prop_key => 'status_id',
159 159 :old_value => 1, :value => 2)
160 160 assert_match 'New', show_detail(detail, true)
161 161 assert_match 'Assigned', show_detail(detail, true)
162 162 end
163 163
164 164 test 'show_detail should show old and new values with a tracker attribute' do
165 165 detail = JournalDetail.new(:property => 'attr', :prop_key => 'tracker_id',
166 166 :old_value => 1, :value => 2)
167 167 assert_match 'Bug', show_detail(detail, true)
168 168 assert_match 'Feature request', show_detail(detail, true)
169 169 end
170 170
171 171 test 'show_detail should show old and new values with a assigned to attribute' do
172 172 detail = JournalDetail.new(:property => 'attr', :prop_key => 'assigned_to_id',
173 173 :old_value => 1, :value => 2)
174 174 assert_match 'Redmine Admin', show_detail(detail, true)
175 175 assert_match 'John Smith', show_detail(detail, true)
176 176 end
177 177
178 178 test 'show_detail should show old and new values with a priority attribute' do
179 179 detail = JournalDetail.new(:property => 'attr', :prop_key => 'priority_id',
180 180 :old_value => 4, :value => 5)
181 181 assert_match 'Low', show_detail(detail, true)
182 182 assert_match 'Normal', show_detail(detail, true)
183 183 end
184 184
185 185 test 'show_detail should show old and new values with a category attribute' do
186 186 detail = JournalDetail.new(:property => 'attr', :prop_key => 'category_id',
187 187 :old_value => 1, :value => 2)
188 188 assert_match 'Printing', show_detail(detail, true)
189 189 assert_match 'Recipes', show_detail(detail, true)
190 190 end
191 191
192 192 test 'show_detail should show old and new values with a fixed version attribute' do
193 193 detail = JournalDetail.new(:property => 'attr', :prop_key => 'fixed_version_id',
194 194 :old_value => 1, :value => 2)
195 195 assert_match '0.1', show_detail(detail, true)
196 196 assert_match '1.0', show_detail(detail, true)
197 197 end
198 198
199 199 test 'show_detail should show old and new values with a estimated hours attribute' do
200 200 detail = JournalDetail.new(:property => 'attr', :prop_key => 'estimated_hours',
201 201 :old_value => '5', :value => '6.3')
202 202 assert_match '5.00', show_detail(detail, true)
203 203 assert_match '6.30', show_detail(detail, true)
204 204 end
205 205
206 test 'show_detail should not show values with a description attribute' do
207 detail = JournalDetail.new(:property => 'attr', :prop_key => 'description',
208 :old_value => 'Foo', :value => 'Bar')
209 assert_equal 'Description updated', show_detail(detail, true)
210 end
211
206 212 test 'show_detail should show old and new values with a custom field' do
207 213 detail = JournalDetail.new(:property => 'cf', :prop_key => '1',
208 214 :old_value => 'MySQL', :value => 'PostgreSQL')
209 215 assert_equal 'Database changed from MySQL to PostgreSQL', show_detail(detail, true)
210 216 end
211 217
218 test 'show_detail should not show values with a long text custom field' do
219 field = IssueCustomField.create!(:name => "Long field", :field_format => 'text')
220 detail = JournalDetail.new(:property => 'cf', :prop_key => field.id,
221 :old_value => 'Foo', :value => 'Bar')
222 assert_equal 'Long field updated', show_detail(detail, true)
223 end
224
212 225 test 'show_detail should show added file' do
213 226 detail = JournalDetail.new(:property => 'attachment', :prop_key => '1',
214 227 :old_value => nil, :value => 'error281.txt')
215 228 assert_match 'error281.txt', show_detail(detail, true)
216 229 end
217 230
218 231 test 'show_detail should show removed file' do
219 232 detail = JournalDetail.new(:property => 'attachment', :prop_key => '1',
220 233 :old_value => 'error281.txt', :value => nil)
221 234 assert_match 'error281.txt', show_detail(detail, true)
222 235 end
223 236
224 237 def test_show_detail_relation_added
225 238 detail = JournalDetail.new(:property => 'relation',
226 239 :prop_key => 'precedes',
227 240 :value => 1)
228 241 assert_equal "Precedes Bug #1: Cannot print recipes added", show_detail(detail, true)
229 242 str = link_to("Bug #1", "/issues/1", :class => Issue.find(1).css_classes)
230 243 assert_equal "<strong>Precedes</strong> <i>#{str}: Cannot print recipes</i> added",
231 244 show_detail(detail, false)
232 245 end
233 246
234 247 def test_show_detail_relation_added_with_inexistant_issue
235 248 inexistant_issue_number = 9999
236 249 assert_nil Issue.find_by_id(inexistant_issue_number)
237 250 detail = JournalDetail.new(:property => 'relation',
238 251 :prop_key => 'precedes',
239 252 :value => inexistant_issue_number)
240 253 assert_equal "Precedes Issue ##{inexistant_issue_number} added", show_detail(detail, true)
241 254 assert_equal "<strong>Precedes</strong> <i>Issue ##{inexistant_issue_number}</i> added", show_detail(detail, false)
242 255 end
243 256
244 257 def test_show_detail_relation_added_should_not_disclose_issue_that_is_not_visible
245 258 issue = Issue.generate!(:is_private => true)
246 259 detail = JournalDetail.new(:property => 'relation',
247 260 :prop_key => 'precedes',
248 261 :value => issue.id)
249 262
250 263 assert_equal "Precedes Issue ##{issue.id} added", show_detail(detail, true)
251 264 assert_equal "<strong>Precedes</strong> <i>Issue ##{issue.id}</i> added", show_detail(detail, false)
252 265 end
253 266
254 267 def test_show_detail_relation_deleted
255 268 detail = JournalDetail.new(:property => 'relation',
256 269 :prop_key => 'precedes',
257 270 :old_value => 1)
258 271 assert_equal "Precedes deleted (Bug #1: Cannot print recipes)", show_detail(detail, true)
259 272 str = link_to("Bug #1",
260 273 "/issues/1",
261 274 :class => Issue.find(1).css_classes)
262 275 assert_equal "<strong>Precedes</strong> deleted (<i>#{str}: Cannot print recipes</i>)",
263 276 show_detail(detail, false)
264 277 end
265 278
266 279 def test_show_detail_relation_deleted_with_inexistant_issue
267 280 inexistant_issue_number = 9999
268 281 assert_nil Issue.find_by_id(inexistant_issue_number)
269 282 detail = JournalDetail.new(:property => 'relation',
270 283 :prop_key => 'precedes',
271 284 :old_value => inexistant_issue_number)
272 285 assert_equal "Precedes deleted (Issue #9999)", show_detail(detail, true)
273 286 assert_equal "<strong>Precedes</strong> deleted (<i>Issue #9999</i>)", show_detail(detail, false)
274 287 end
275 288
276 289 def test_show_detail_relation_deleted_should_not_disclose_issue_that_is_not_visible
277 290 issue = Issue.generate!(:is_private => true)
278 291 detail = JournalDetail.new(:property => 'relation',
279 292 :prop_key => 'precedes',
280 293 :old_value => issue.id)
281 294
282 295 assert_equal "Precedes deleted (Issue ##{issue.id})", show_detail(detail, true)
283 296 assert_equal "<strong>Precedes</strong> deleted (<i>Issue ##{issue.id}</i>)", show_detail(detail, false)
284 297 end
285 298 end
General Comments 0
You need to be logged in to leave comments. Login now