diff options
-rw-r--r-- | app/helpers/application_helper.rb | 2 | ||||
-rw-r--r-- | app/helpers/issues_helper.rb | 9 | ||||
-rw-r--r-- | app/models/custom_field.rb | 35 | ||||
-rw-r--r-- | app/models/custom_field_value.rb | 4 | ||||
-rw-r--r-- | app/models/custom_value.rb | 12 | ||||
-rw-r--r-- | app/views/attachments/_form.html.erb | 66 | ||||
-rw-r--r-- | app/views/attachments/destroy.js.erb | 1 | ||||
-rw-r--r-- | app/views/attachments/upload.js.erb | 2 | ||||
-rw-r--r-- | app/views/custom_fields/_form.html.erb | 12 | ||||
-rw-r--r-- | app/views/custom_fields/formats/_attachment.html.erb | 0 | ||||
-rw-r--r-- | lib/plugins/acts_as_attachable/lib/acts_as_attachable.rb | 11 | ||||
-rw-r--r-- | lib/plugins/acts_as_customizable/lib/acts_as_customizable.rb | 15 | ||||
-rw-r--r-- | lib/redmine/field_format.rb | 129 | ||||
-rw-r--r-- | public/javascripts/attachments.js | 41 | ||||
-rw-r--r-- | public/stylesheets/application.css | 26 | ||||
-rw-r--r-- | test/integration/lib/redmine/field_format/attachment_format_test.rb | 156 | ||||
-rw-r--r-- | test/unit/lib/redmine/field_format/attachment_format_test.rb | 163 |
17 files changed, 607 insertions, 77 deletions
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index d257894dd..c54858b6d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -197,6 +197,8 @@ module ApplicationHelper l(:general_text_No) when 'Issue' object.visible? && html ? link_to_issue(object) : "##{object.id}" + when 'Attachment' + html ? link_to_attachment(object, :download => true) : object.filename when 'CustomValue', 'CustomFieldValue' if object.custom_field f = object.custom_field.format.formatted_custom_value(self, object, html) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index a9286a2d2..08611c91e 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -329,6 +329,7 @@ module IssuesHelper def show_detail(detail, no_html=false, options={}) multiple = false show_diff = false + no_details = false case detail.property when 'attr' @@ -364,7 +365,9 @@ module IssuesHelper custom_field = detail.custom_field if custom_field label = custom_field.name - if custom_field.format.class.change_as_diff + if custom_field.format.class.change_no_details + no_details = true + elsif custom_field.format.class.change_as_diff show_diff = true else multiple = custom_field.multiple? @@ -417,7 +420,9 @@ module IssuesHelper end end - if show_diff + if no_details + s = l(:text_journal_changed_no_detail, :label => label).html_safe + elsif show_diff s = l(:text_journal_changed_no_detail, :label => label) unless no_html diff_link = link_to 'diff', diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index cd217e766..7f37ec120 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -163,6 +163,10 @@ class CustomField < ActiveRecord::Base end end + def set_custom_field_value(custom_field_value, value) + format.set_custom_field_value(self, custom_field_value, value) + end + def cast_value(value) format.cast_value(self, value) end @@ -254,20 +258,23 @@ class CustomField < ActiveRecord::Base # or an empty array if value is a valid value for the custom field def validate_custom_value(custom_value) value = custom_value.value - errs = [] - if value.is_a?(Array) - if !multiple? - errs << ::I18n.t('activerecord.errors.messages.invalid') - end - if is_required? && value.detect(&:present?).nil? - errs << ::I18n.t('activerecord.errors.messages.blank') - end - else - if is_required? && value.blank? - errs << ::I18n.t('activerecord.errors.messages.blank') + errs = format.validate_custom_value(custom_value) + + unless errs.any? + if value.is_a?(Array) + if !multiple? + errs << ::I18n.t('activerecord.errors.messages.invalid') + end + if is_required? && value.detect(&:present?).nil? + errs << ::I18n.t('activerecord.errors.messages.blank') + end + else + if is_required? && value.blank? + errs << ::I18n.t('activerecord.errors.messages.blank') + end end end - errs += format.validate_custom_value(custom_value) + errs end @@ -281,6 +288,10 @@ class CustomField < ActiveRecord::Base validate_field_value(value).empty? end + def after_save_custom_value(custom_value) + format.after_save_custom_value(self, custom_value) + end + def format_in?(*args) args.include?(field_format) end diff --git a/app/models/custom_field_value.rb b/app/models/custom_field_value.rb index a816f0d11..38cffc0e6 100644 --- a/app/models/custom_field_value.rb +++ b/app/models/custom_field_value.rb @@ -48,6 +48,10 @@ class CustomFieldValue value.to_s end + def value=(v) + @value = custom_field.set_custom_field_value(self, v) + end + def validate_value custom_field.validate_custom_value(self).each do |message| customized.errors.add(:base, custom_field.name + ' ' + message) diff --git a/app/models/custom_value.rb b/app/models/custom_value.rb index 8026d5f65..1dfb49941 100644 --- a/app/models/custom_value.rb +++ b/app/models/custom_value.rb @@ -20,6 +20,8 @@ class CustomValue < ActiveRecord::Base belongs_to :customized, :polymorphic => true attr_protected :id + after_save :custom_field_after_save_custom_value + def initialize(attributes=nil, *args) super if new_record? && custom_field && !attributes.key?(:value) @@ -40,6 +42,10 @@ class CustomValue < ActiveRecord::Base custom_field.visible? end + def attachments_visible?(user) + visible? && customized && customized.visible?(user) + end + def required? custom_field.is_required? end @@ -47,4 +53,10 @@ class CustomValue < ActiveRecord::Base def to_s value.to_s end + + private + + def custom_field_after_save_custom_value + custom_field.after_save_custom_value(self) + end end diff --git a/app/views/attachments/_form.html.erb b/app/views/attachments/_form.html.erb index 65ad8804a..6e19c230d 100644 --- a/app/views/attachments/_form.html.erb +++ b/app/views/attachments/_form.html.erb @@ -1,29 +1,45 @@ -<span id="attachments_fields"> -<% if defined?(container) && container && container.saved_attachments %> - <% container.saved_attachments.each_with_index do |attachment, i| %> - <span id="attachments_p<%= i %>"> - <%= text_field_tag("attachments[p#{i}][filename]", attachment.filename, :class => 'filename') + - text_field_tag("attachments[p#{i}][description]", attachment.description, :maxlength => 255, :placeholder => l(:label_optional_description), :class => 'description') + - link_to(' '.html_safe, attachment_path(attachment, :attachment_id => "p#{i}", :format => 'js'), :method => 'delete', :remote => true, :class => 'remove-upload') %> - <%= hidden_field_tag "attachments[p#{i}][token]", "#{attachment.token}" %> - </span> +<% attachment_param ||= 'attachments' %> +<% saved_attachments ||= container.saved_attachments if defined?(container) && container %> +<% multiple = true unless defined?(multiple) && multiple == false %> +<% show_add = multiple || saved_attachments.blank? %> +<% description = (defined?(description) && description == false ? false : true) %> +<% css_class = (defined?(filedrop) && filedrop == false ? '' : 'filedrop') %> + +<span class="attachments_form"> + <span class="attachments_fields"> + <% if saved_attachments.present? %> + <% saved_attachments.each_with_index do |attachment, i| %> + <span id="attachments_p<%= i %>"> + <%= text_field_tag("#{attachment_param}[p#{i}][filename]", attachment.filename, :class => 'filename') %> + <% if attachment.container_id.present? %> + <%= link_to l(:label_delete), "#", :onclick => "$(this).closest('.attachments_form').find('.add_attachment').show(); $(this).parent().remove(); return false;", :class => 'icon-only icon-del' %> + <%= hidden_field_tag "#{attachment_param}[p#{i}][id]", attachment.id %> + <% else %> + <%= text_field_tag("#{attachment_param}[p#{i}][description]", attachment.description, :maxlength => 255, :placeholder => l(:label_optional_description), :class => 'description') if description %> + <%= link_to(' '.html_safe, attachment_path(attachment, :attachment_id => "p#{i}", :format => 'js'), :method => 'delete', :remote => true, :class => 'remove-upload') %> + <%= hidden_field_tag "#{attachment_param}[p#{i}][token]", attachment.token %> + <% end %> + </span> + <% end %> <% end %> -<% end %> -</span> -<span class="add_attachment"> -<%= file_field_tag 'attachments[dummy][file]', - :id => nil, - :class => 'file_selector', - :multiple => true, - :onchange => 'addInputFiles(this);', - :data => { - :max_file_size => Setting.attachment_max_size.to_i.kilobytes, - :max_file_size_message => l(:error_attachment_too_big, :max_size => number_to_human_size(Setting.attachment_max_size.to_i.kilobytes)), - :max_concurrent_uploads => Redmine::Configuration['max_concurrent_ajax_uploads'].to_i, - :upload_path => uploads_path(:format => 'js'), - :description_placeholder => l(:label_optional_description) - } %> -(<%= l(:label_max_size) %>: <%= number_to_human_size(Setting.attachment_max_size.to_i.kilobytes) %>) + </span> + <span class="add_attachment" style="<%= show_add ? nil : 'display:none;' %>"> + <%= file_field_tag "#{attachment_param}[dummy][file]", + :id => nil, + :class => "file_selector #{css_class}", + :multiple => multiple, + :onchange => 'addInputFiles(this);', + :data => { + :max_file_size => Setting.attachment_max_size.to_i.kilobytes, + :max_file_size_message => l(:error_attachment_too_big, :max_size => number_to_human_size(Setting.attachment_max_size.to_i.kilobytes)), + :max_concurrent_uploads => Redmine::Configuration['max_concurrent_ajax_uploads'].to_i, + :upload_path => uploads_path(:format => 'js'), + :param => attachment_param, + :description => description, + :description_placeholder => l(:label_optional_description) + } %> + (<%= l(:label_max_size) %>: <%= number_to_human_size(Setting.attachment_max_size.to_i.kilobytes) %>) + </span> </span> <% content_for :header_tags do %> diff --git a/app/views/attachments/destroy.js.erb b/app/views/attachments/destroy.js.erb index 3cfb5845f..29b9a0c76 100644 --- a/app/views/attachments/destroy.js.erb +++ b/app/views/attachments/destroy.js.erb @@ -1 +1,2 @@ +$('#attachments_<%= j params[:attachment_id] %>').closest('.attachments_form').find('.add_attachment').show(); $('#attachments_<%= j params[:attachment_id] %>').remove(); diff --git a/app/views/attachments/upload.js.erb b/app/views/attachments/upload.js.erb index acd8f83e1..6b804a62b 100644 --- a/app/views/attachments/upload.js.erb +++ b/app/views/attachments/upload.js.erb @@ -3,7 +3,7 @@ var fileSpan = $('#attachments_<%= j params[:attachment_id] %>'); fileSpan.hide(); alert("<%= escape_javascript @attachment.errors.full_messages.join(', ') %>"); <% else %> -$('<input>', { type: 'hidden', name: 'attachments[<%= j params[:attachment_id] %>][token]' } ).val('<%= j @attachment.token %>').appendTo(fileSpan); +fileSpan.find('input.token').val('<%= j @attachment.token %>'); fileSpan.find('a.remove-upload') .attr({ "data-remote": true, diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb index 7c79189ac..2e50188a6 100644 --- a/app/views/custom_fields/_form.html.erb +++ b/app/views/custom_fields/_form.html.erb @@ -28,7 +28,9 @@ when "IssueCustomField" %> <p><%= f.check_box :is_required %></p> <p><%= f.check_box :is_for_all, :data => {:disables => '#custom_field_project_ids input'} %></p> + <% if @custom_field.format.is_filter_supported %> <p><%= f.check_box :is_filter %></p> + <% end %> <% if @custom_field.format.searchable_supported %> <p><%= f.check_box :searchable %></p> <% end %> @@ -57,7 +59,9 @@ when "IssueCustomField" %> <p><%= f.check_box :is_required %></p> <p><%= f.check_box :visible %></p> <p><%= f.check_box :editable %></p> + <% if @custom_field.format.is_filter_supported %> <p><%= f.check_box :is_filter %></p> + <% end %> <% when "ProjectCustomField" %> <p><%= f.check_box :is_required %></p> @@ -65,19 +69,27 @@ when "IssueCustomField" %> <% if @custom_field.format.searchable_supported %> <p><%= f.check_box :searchable %></p> <% end %> + <% if @custom_field.format.is_filter_supported %> <p><%= f.check_box :is_filter %></p> + <% end %> <% when "VersionCustomField" %> <p><%= f.check_box :is_required %></p> + <% if @custom_field.format.is_filter_supported %> <p><%= f.check_box :is_filter %></p> + <% end %> <% when "GroupCustomField" %> <p><%= f.check_box :is_required %></p> + <% if @custom_field.format.is_filter_supported %> <p><%= f.check_box :is_filter %></p> + <% end %> <% when "TimeEntryCustomField" %> <p><%= f.check_box :is_required %></p> + <% if @custom_field.format.is_filter_supported %> <p><%= f.check_box :is_filter %></p> + <% end %> <% else %> <p><%= f.check_box :is_required %></p> diff --git a/app/views/custom_fields/formats/_attachment.html.erb b/app/views/custom_fields/formats/_attachment.html.erb new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/app/views/custom_fields/formats/_attachment.html.erb diff --git a/lib/plugins/acts_as_attachable/lib/acts_as_attachable.rb b/lib/plugins/acts_as_attachable/lib/acts_as_attachable.rb index bc4242564..2e5fc841c 100644 --- a/lib/plugins/acts_as_attachable/lib/acts_as_attachable.rb +++ b/lib/plugins/acts_as_attachable/lib/acts_as_attachable.rb @@ -34,6 +34,7 @@ module Redmine options.merge(:as => :container, :dependent => :destroy, :inverse_of => :container) send :include, Redmine::Acts::Attachable::InstanceMethods before_save :attach_saved_attachments + after_rollback :detach_saved_attachments validate :warn_about_failed_attachments end end @@ -90,7 +91,7 @@ module Redmine if file = attachment['file'] next unless file.size > 0 a = Attachment.create(:file => file, :author => author) - elsif token = attachment['token'] + elsif token = attachment['token'].presence a = Attachment.find_by_token(token) unless a @failed_attachment_count += 1 @@ -117,6 +118,14 @@ module Redmine end end + def detach_saved_attachments + saved_attachments.each do |attachment| + # TODO: use #reload instead, after upgrading to Rails 5 + # (after_rollback is called when running transactional tests in Rails 4) + attachment.container = nil + end + end + def warn_about_failed_attachments if @failed_attachment_count && @failed_attachment_count > 0 errors.add :base, ::I18n.t('warning_attachments_not_saved', count: @failed_attachment_count) diff --git a/lib/plugins/acts_as_customizable/lib/acts_as_customizable.rb b/lib/plugins/acts_as_customizable/lib/acts_as_customizable.rb index f9da79c22..fb5edf0fc 100644 --- a/lib/plugins/acts_as_customizable/lib/acts_as_customizable.rb +++ b/lib/plugins/acts_as_customizable/lib/acts_as_customizable.rb @@ -68,16 +68,7 @@ module Redmine custom_field_values.each do |custom_field_value| key = custom_field_value.custom_field_id.to_s if values.has_key?(key) - value = values[key] - if value.is_a?(Array) - value = value.reject(&:blank?).map(&:to_s).uniq - if value.empty? - value << '' - end - else - value = value.to_s - end - custom_field_value.value = value + custom_field_value.value = values[key] end end @custom_field_values_changed = true @@ -93,11 +84,11 @@ module Redmine if values.empty? values << custom_values.build(:customized => self, :custom_field => field) end - x.value = values.map(&:value) + x.instance_variable_set("@value", values.map(&:value)) else cv = custom_values.detect { |v| v.custom_field == field } cv ||= custom_values.build(:customized => self, :custom_field => field) - x.value = cv.value + x.instance_variable_set("@value", cv.value) end x.value_was = x.value.dup if x.value x diff --git a/lib/redmine/field_format.rb b/lib/redmine/field_format.rb index a1feed44a..c2a02d06d 100644 --- a/lib/redmine/field_format.rb +++ b/lib/redmine/field_format.rb @@ -67,6 +67,10 @@ module Redmine class_attribute :multiple_supported self.multiple_supported = false + # Set this to true if the format supports filtering on custom values + class_attribute :is_filter_supported + self.is_filter_supported = true + # Set this to true if the format supports textual search on custom values class_attribute :searchable_supported self.searchable_supported = false @@ -87,6 +91,9 @@ module Redmine class_attribute :change_as_diff self.change_as_diff = false + class_attribute :change_no_details + self.change_no_details = false + def self.add(name) self.format_name = name Redmine::FieldFormat.add(name, self) @@ -107,6 +114,19 @@ module Redmine "label_#{name}" end + def set_custom_field_value(custom_field, custom_field_value, value) + if value.is_a?(Array) + value = value.map(&:to_s).reject{|v| v==''}.uniq + if value.empty? + value << '' + end + else + value = value.to_s + end + + value + end + def cast_custom_value(custom_value) cast_value(custom_value.custom_field, custom_value.value, custom_value.customized) end @@ -169,6 +189,7 @@ module Redmine # Returns the validation error messages for custom_value # Should return an empty array if custom_value is valid + # custom_value is a CustomFieldValue. def validate_custom_value(custom_value) values = Array.wrap(custom_value.value).reject {|value| value.to_s == ''} errors = values.map do |value| @@ -181,6 +202,10 @@ module Redmine [] end + # CustomValue after_save callback + def after_save_custom_value(custom_field, custom_value) + end + def formatted_custom_value(view, custom_value, html=false) formatted_value(view, custom_value.custom_field, custom_value.value, custom_value.customized, html) end @@ -830,5 +855,109 @@ module Redmine scope.sort.collect{|u| [u.to_s, u.id.to_s] } end end + + class AttachementFormat < Base + add 'attachment' + self.form_partial = 'custom_fields/formats/attachment' + self.is_filter_supported = false + self.change_no_details = true + + def set_custom_field_value(custom_field, custom_field_value, value) + attachment_present = false + + if value.is_a?(Hash) + attachment_present = true + value = value.except(:blank) + + if value.values.any? && value.values.all? {|v| v.is_a?(Hash)} + value = value.values.first + end + + if value.key?(:id) + value = set_custom_field_value_by_id(custom_field, custom_field_value, value[:id]) + elsif value[:token].present? + if attachment = Attachment.find_by_token(value[:token]) + value = attachment.id.to_s + else + value = '' + end + elsif value.key?(:file) + attachment = Attachment.new(:file => value[:file], :author => User.current) + if attachment.save + value = attachment.id.to_s + else + value = '' + end + else + attachment_present = false + value = '' + end + elsif value.is_a?(String) + value = set_custom_field_value_by_id(custom_field, custom_field_value, value) + end + custom_field_value.instance_variable_set "@attachment_present", attachment_present + + value + end + + def set_custom_field_value_by_id(custom_field, custom_field_value, id) + attachment = Attachment.find_by_id(id) + if attachment && attachment.container.is_a?(CustomValue) && attachment.container.customized == custom_field_value.customized + id.to_s + else + '' + end + end + private :set_custom_field_value_by_id + + def cast_single_value(custom_field, value, customized=nil) + Attachment.find_by_id(value.to_i) if value.present? && value.respond_to?(:to_i) + end + + def validate_custom_value(custom_value) + errors = [] + + if custom_value.instance_variable_get("@attachment_present") && custom_value.value.blank? + errors << ::I18n.t('activerecord.errors.messages.invalid') + end + + errors.uniq + end + + def after_save_custom_value(custom_field, custom_value) + if custom_value.value_changed? + if custom_value.value.present? + attachment = Attachment.where(:id => custom_value.value.to_s).first + if attachment + attachment.container = custom_value + attachment.save! + end + end + if custom_value.value_was.present? + attachment = Attachment.where(:id => custom_value.value_was.to_s).first + if attachment + attachment.destroy + end + end + end + end + + def edit_tag(view, tag_id, tag_name, custom_value, options={}) + attachment = nil + if custom_value.value.present? #&& custom_value.value == custom_value.value_was + attachment = Attachment.find_by_id(custom_value.value) + end + + view.hidden_field_tag("#{tag_name}[blank]", "") + + view.render(:partial => 'attachments/form', + :locals => { + :attachment_param => tag_name, + :multiple => false, + :description => false, + :saved_attachments => [attachment].compact, + :filedrop => false + }) + end + end end end diff --git a/public/javascripts/attachments.js b/public/javascripts/attachments.js index c5e6b962a..dbba6543c 100644 --- a/public/javascripts/attachments.js +++ b/public/javascripts/attachments.js @@ -2,23 +2,32 @@ Copyright (C) 2006-2016 Jean-Philippe Lang */ function addFile(inputEl, file, eagerUpload) { + var attachmentsFields = $(inputEl).closest('.attachments_form').find('.attachments_fields'); + var addAttachment = $(inputEl).closest('.attachments_form').find('.add_attachment'); + var maxFiles = ($(inputEl).prop('multiple') == true ? 10 : 1); - if ($('#attachments_fields').children().length < 10) { - + if (attachmentsFields.children().length < maxFiles) { var attachmentId = addFile.nextAttachmentId++; - var fileSpan = $('<span>', { id: 'attachments_' + attachmentId }); + var param = $(inputEl).data('param'); + if (!param) {param = 'attachments'}; fileSpan.append( - $('<input>', { type: 'text', 'class': 'filename readonly', name: 'attachments[' + attachmentId + '][filename]', readonly: 'readonly'} ).val(file.name), - $('<input>', { type: 'text', 'class': 'description', name: 'attachments[' + attachmentId + '][description]', maxlength: 255, placeholder: $(inputEl).data('description-placeholder') } ).toggle(!eagerUpload), + $('<input>', { type: 'text', 'class': 'filename readonly', name: param +'[' + attachmentId + '][filename]', readonly: 'readonly'} ).val(file.name), + $('<input>', { type: 'text', 'class': 'description', name: param + '[' + attachmentId + '][description]', maxlength: 255, placeholder: $(inputEl).data('description-placeholder') } ).toggle(!eagerUpload), + $('<input>', { type: 'hidden', 'class': 'token', name: param + '[' + attachmentId + '][token]'} ), $('<a> </a>').attr({ href: "#", 'class': 'remove-upload' }).click(removeFile).toggle(!eagerUpload) - ).appendTo('#attachments_fields'); + ).appendTo(attachmentsFields); + + if ($(inputEl).data('description') == 0) { + fileSpan.find('input.description').remove(); + } if(eagerUpload) { ajaxUpload(file, attachmentId, fileSpan, inputEl); } - + + addAttachment.toggle(attachmentsFields.children().length < maxFiles); return attachmentId; } return null; @@ -118,11 +127,16 @@ function uploadBlob(blob, uploadUrl, attachmentId, options) { } function addInputFiles(inputEl) { + var attachmentsFields = $(inputEl).closest('.attachments_form').find('.attachments_fields'); + var addAttachment = $(inputEl).closest('.attachments_form').find('.add_attachment'); var clearedFileInput = $(inputEl).clone().val(''); + var sizeExceeded = false; + var param = $(inputEl).data('param'); + if (!param) {param = 'attachments'}; if ($.ajaxSettings.xhr().upload && inputEl.files) { // upload files using ajax - uploadAndAttachFiles(inputEl.files, inputEl); + sizeExceeded = uploadAndAttachFiles(inputEl.files, inputEl); $(inputEl).remove(); } else { // browser not supporting the file API, upload on form submission @@ -130,11 +144,11 @@ function addInputFiles(inputEl) { var aFilename = inputEl.value.split(/\/|\\/); attachmentId = addFile(inputEl, { name: aFilename[ aFilename.length - 1 ] }, false); if (attachmentId) { - $(inputEl).attr({ name: 'attachments[' + attachmentId + '][file]', style: 'display:none;' }).appendTo('#attachments_' + attachmentId); + $(inputEl).attr({ name: param + '[' + attachmentId + '][file]', style: 'display:none;' }).appendTo('#attachments_' + attachmentId); } } - clearedFileInput.insertAfter('#attachments_fields'); + clearedFileInput.prependTo(addAttachment); } function uploadAndAttachFiles(files, inputEl) { @@ -151,6 +165,7 @@ function uploadAndAttachFiles(files, inputEl) { } else { $.each(files, function() {addFile(inputEl, this, true);}); } + return sizeExceeded; } function handleFileDropEvent(e) { @@ -159,7 +174,7 @@ function handleFileDropEvent(e) { blockEventPropagation(e); if ($.inArray('Files', e.dataTransfer.types) > -1) { - uploadAndAttachFiles(e.dataTransfer.files, $('input:file.file_selector')); + uploadAndAttachFiles(e.dataTransfer.files, $('input:file.filedrop').first()); } } @@ -178,12 +193,12 @@ function setupFileDrop() { $.event.fixHooks.drop = { props: [ 'dataTransfer' ] }; - $('form div.box').has('input:file').each(function() { + $('form div.box:not(.filedroplistner)').has('input:file.filedrop').each(function() { $(this).on({ dragover: dragOverHandler, dragleave: dragOutHandler, drop: handleFileDropEvent - }); + }).addClass('filedroplistner'); }); } } diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index 44100fed7..68d2d0fe4 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -600,7 +600,7 @@ span.pagination>span {white-space:nowrap;} margin: 0; padding: 3px 0 3px 0; padding-left: 180px; /* width of left column containing the label elements */ - min-height: 1.8em; + line-height: 2em; clear:left; } @@ -626,13 +626,16 @@ html>body .tabular p {overflow:hidden;} width: 270px; } +label.block { + display: block; + width: auto !important; +} + .tabular label.block{ font-weight: normal; margin-left: 0px !important; text-align: left; float: none; - display: block; - width: auto !important; } .tabular label.inline{ @@ -687,13 +690,14 @@ span.required {color: #bb0000;} .check_box_group.bool_cf {border:0; background:inherit;} .check_box_group.bool_cf label {display: inline;} -#attachments_fields input.description, #existing-attachments input.description {margin-left:4px; width:340px;} -#attachments_fields>span, #existing-attachments>span {display:block; white-space:nowrap;} -#attachments_fields input.filename, #existing-attachments .filename {border:0; width:250px; color:#555; background-color:inherit; background:url(../images/attachment.png) no-repeat 1px 50%; padding-left:18px;} -#attachments_fields input.filename {height:1.8em;} -#attachments_fields .ajax-waiting input.filename {background:url(../images/hourglass.png) no-repeat 0px 50%;} -#attachments_fields .ajax-loading input.filename {background:url(../images/loading.gif) no-repeat 0px 50%;} -#attachments_fields div.ui-progressbar { width: 100px; height:14px; margin: 2px 0 -5px 8px; display: inline-block; } +.attachments_fields input.description, #existing-attachments input.description {margin-left:4px; width:340px;} +.attachments_fields>span, #existing-attachments>span {display:block; white-space:nowrap;} +.attachments_fields input.filename, #existing-attachments .filename {border:0; width:250px; color:#555; background-color:inherit; background:url(../images/attachment.png) no-repeat 1px 50%; padding-left:18px;} +.tabular input.filename {max-width:75% !important;} +.attachments_fields input.filename {height:1.8em;} +.attachments_fields .ajax-waiting input.filename {background:url(../images/hourglass.png) no-repeat 0px 50%;} +.attachments_fields .ajax-loading input.filename {background:url(../images/loading.gif) no-repeat 0px 50%;} +.attachments_fields div.ui-progressbar { width: 100px; height:14px; margin: 2px 0 -5px 8px; display: inline-block; } a.remove-upload {background: url(../images/delete.png) no-repeat 1px 50%; width:1px; display:inline-block; padding-left:16px;} a.remove-upload:hover {text-decoration:none !important;} .existing-attachment.deleted .filename {text-decoration:line-through; color:#999 !important;} @@ -1160,7 +1164,7 @@ a.close-icon:hover {background-image:url('../images/close_hl.png');} padding-top: 0; padding-bottom: 0; font-size: 8px; - vertical-align: text-bottom; + vertical-align: middle; } .icon-only::after { content: " "; diff --git a/test/integration/lib/redmine/field_format/attachment_format_test.rb b/test/integration/lib/redmine/field_format/attachment_format_test.rb new file mode 100644 index 000000000..447ba686f --- /dev/null +++ b/test/integration/lib/redmine/field_format/attachment_format_test.rb @@ -0,0 +1,156 @@ +# Redmine - project management software +# Copyright (C) 2006-2016 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require File.expand_path('../../../../../test_helper', __FILE__) + +class AttachmentFieldFormatTest < Redmine::IntegrationTest + fixtures :projects, + :users, :email_addresses, + :roles, + :members, + :member_roles, + :trackers, + :projects_trackers, + :enabled_modules, + :issue_statuses, + :issues, + :enumerations, + :custom_fields, + :custom_values, + :custom_fields_trackers, + :attachments + + def setup + set_tmp_attachments_directory + @field = IssueCustomField.generate!(:name => "File", :field_format => "attachment") + log_user "jsmith", "jsmith" + end + + def test_new_should_include_inputs + get '/projects/ecookbook/issues/new' + assert_response :success + + assert_select '[name^=?]', "issue[custom_field_values][#{@field.id}]", 2 + assert_select 'input[name=?][type=hidden][value=""]', "issue[custom_field_values][#{@field.id}][blank]" + end + + def test_create_with_attachment + issue = new_record(Issue) do + assert_difference 'Attachment.count' do + post '/projects/ecookbook/issues', { + :issue => { + :subject => "Subject", + :custom_field_values => { + @field.id => { + 'blank' => '', + '1' => {:file => uploaded_test_file("testfile.txt", "text/plain")} + } + } + } + } + assert_response 302 + end + end + + custom_value = issue.custom_value_for(@field) + assert custom_value + assert custom_value.value.present? + + attachment = Attachment.find_by_id(custom_value.value) + assert attachment + assert_equal custom_value, attachment.container + + follow_redirect! + assert_response :success + + # link to the attachment + link = css_select(".cf_#{@field.id} .value a") + assert_equal 1, link.size + assert_equal "testfile.txt", link.text + + # download the attachment + get link.attr('href') + assert_response :success + assert_equal "text/plain", response.content_type + end + + def test_create_without_attachment + issue = new_record(Issue) do + assert_no_difference 'Attachment.count' do + post '/projects/ecookbook/issues', { + :issue => { + :subject => "Subject", + :custom_field_values => { + @field.id => {:blank => ''} + } + } + } + assert_response 302 + end + end + + custom_value = issue.custom_value_for(@field) + assert custom_value + assert custom_value.value.blank? + + follow_redirect! + assert_response :success + + # no links to the attachment + assert_select ".cf_#{@field.id} .value a", 0 + end + + def test_failure_on_create_should_preserve_attachment + attachment = new_record(Attachment) do + assert_no_difference 'Issue.count' do + post '/projects/ecookbook/issues', { + :issue => { + :subject => "", + :custom_field_values => { + @field.id => {:file => uploaded_test_file("testfile.txt", "text/plain")} + } + } + } + assert_response :success + assert_select_error /Subject cannot be blank/ + end + end + + assert_nil attachment.container_id + assert_select 'input[name=?][value=?][type=hidden]', "issue[custom_field_values][#{@field.id}][p0][token]", attachment.token + assert_select 'input[name=?][value=?]', "issue[custom_field_values][#{@field.id}][p0][filename]", 'testfile.txt' + + issue = new_record(Issue) do + assert_no_difference 'Attachment.count' do + post '/projects/ecookbook/issues', { + :issue => { + :subject => "Subject", + :custom_field_values => { + @field.id => {:token => attachment.token} + } + } + } + assert_response 302 + end + end + + custom_value = issue.custom_value_for(@field) + assert custom_value + assert_equal attachment.id.to_s, custom_value.value + assert_equal custom_value, attachment.reload.container + end +end diff --git a/test/unit/lib/redmine/field_format/attachment_format_test.rb b/test/unit/lib/redmine/field_format/attachment_format_test.rb new file mode 100644 index 000000000..e6653b149 --- /dev/null +++ b/test/unit/lib/redmine/field_format/attachment_format_test.rb @@ -0,0 +1,163 @@ +# Redmine - project management software +# Copyright (C) 2006-2016 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require File.expand_path('../../../../../test_helper', __FILE__) +require 'redmine/field_format' + +class Redmine::AttachmentFieldFormatTest < ActionView::TestCase + include ApplicationHelper + include Redmine::I18n + + fixtures :users + + def setup + set_language_if_valid 'en' + set_tmp_attachments_directory + end + + def test_should_accept_a_hash_with_upload_on_create + field = GroupCustomField.generate!(:name => "File", :field_format => 'attachment') + group = Group.new(:name => 'Group') + attachment = nil + + custom_value = new_record(CustomValue) do + attachment = new_record(Attachment) do + group.custom_field_values = {field.id => {:file => mock_file}} + assert group.save + end + end + + assert_equal 'a_file.png', attachment.filename + assert_equal custom_value, attachment.container + assert_equal field, attachment.container.custom_field + assert_equal group, attachment.container.customized + end + + def test_should_accept_a_hash_with_no_upload_on_create + field = GroupCustomField.generate!(:name => "File", :field_format => 'attachment') + group = Group.new(:name => 'Group') + attachment = nil + + custom_value = new_record(CustomValue) do + assert_no_difference 'Attachment.count' do + group.custom_field_values = {field.id => {}} + assert group.save + end + end + + assert_equal '', custom_value.value + end + + def test_should_not_validate_with_invalid_upload_on_create + field = GroupCustomField.generate!(:name => "File", :field_format => 'attachment') + group = Group.new(:name => 'Group') + + with_settings :attachment_max_size => 0 do + assert_no_difference 'CustomValue.count' do + assert_no_difference 'Attachment.count' do + group.custom_field_values = {field.id => {:file => mock_file}} + assert_equal false, group.save + end + end + end + end + + def test_should_accept_a_hash_with_token_on_create + field = GroupCustomField.generate!(:name => "File", :field_format => 'attachment') + group = Group.new(:name => 'Group') + + attachment = Attachment.create!(:file => mock_file, :author => User.find(2)) + assert_nil attachment.container + + custom_value = new_record(CustomValue) do + assert_no_difference 'Attachment.count' do + group.custom_field_values = {field.id => {:token => attachment.token}} + assert group.save + end + end + + attachment.reload + assert_equal custom_value, attachment.container + assert_equal field, attachment.container.custom_field + assert_equal group, attachment.container.customized + end + + def test_should_not_validate_with_invalid_token_on_create + field = GroupCustomField.generate!(:name => "File", :field_format => 'attachment') + group = Group.new(:name => 'Group') + + assert_no_difference 'CustomValue.count' do + assert_no_difference 'Attachment.count' do + group.custom_field_values = {field.id => {:token => "123.0123456789abcdef"}} + assert_equal false, group.save + end + end + end + + def test_should_replace_attachment_on_update + field = GroupCustomField.generate!(:name => "File", :field_format => 'attachment') + group = Group.new(:name => 'Group') + attachment = nil + custom_value = new_record(CustomValue) do + attachment = new_record(Attachment) do + group.custom_field_values = {field.id => {:file => mock_file}} + assert group.save + end + end + group.reload + + assert_no_difference 'Attachment.count' do + assert_no_difference 'CustomValue.count' do + group.custom_field_values = {field.id => {:file => mock_file}} + assert group.save + end + end + + assert !Attachment.exists?(attachment.id) + assert CustomValue.exists?(custom_value.id) + + new_attachment = Attachment.order(:id => :desc).first + custom_value.reload + assert_equal custom_value, new_attachment.container + end + + def test_should_delete_attachment_on_update + field = GroupCustomField.generate!(:name => "File", :field_format => 'attachment') + group = Group.new(:name => 'Group') + attachment = nil + custom_value = new_record(CustomValue) do + attachment = new_record(Attachment) do + group.custom_field_values = {field.id => {:file => mock_file}} + assert group.save + end + end + group.reload + + assert_difference 'Attachment.count', -1 do + assert_no_difference 'CustomValue.count' do + group.custom_field_values = {field.id => {}} + assert group.save + end + end + + assert !Attachment.exists?(attachment.id) + assert CustomValue.exists?(custom_value.id) + + custom_value.reload + assert_equal '', custom_value.value + end +end |