From 2f51dc11cfdf9cdbd2c67ec12cf93f4b112646bc Mon Sep 17 00:00:00 2001 From: Jean-Philippe Lang Date: Sun, 25 Oct 2015 08:32:47 +0000 Subject: [PATCH] Adds Enumeration custom field format (#21060). Similar to List format but stores possible values as records. git-svn-id: http://svn.redmine.org/redmine/trunk@14745 e93f8b46-1217-0410-a6f0-8f06a7374b81 --- .../custom_field_enumerations_controller.rb | 69 +++++++++++ app/models/custom_field.rb | 4 + app/models/custom_field_enumeration.rb | 74 ++++++++++++ .../custom_field_enumerations/create.js.erb | 2 + .../destroy.html.erb | 14 +++ .../custom_field_enumerations/index.html.erb | 49 ++++++++ .../custom_fields/formats/_enumeration.erb | 12 ++ config/locales/en.yml | 1 + config/locales/fr.yml | 1 + config/routes.rb | 5 +- ...072118_create_custom_field_enumerations.rb | 10 ++ lib/redmine/field_format.rb | 29 ++++- public/stylesheets/application.css | 5 +- ...stom_field_enumerations_controller_test.rb | 111 ++++++++++++++++++ .../integration/routing/custom_fields_test.rb | 7 ++ .../field_format/enumeration_format_test.rb | 86 ++++++++++++++ 16 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 app/controllers/custom_field_enumerations_controller.rb create mode 100644 app/models/custom_field_enumeration.rb create mode 100644 app/views/custom_field_enumerations/create.js.erb create mode 100644 app/views/custom_field_enumerations/destroy.html.erb create mode 100644 app/views/custom_field_enumerations/index.html.erb create mode 100644 app/views/custom_fields/formats/_enumeration.erb create mode 100644 db/migrate/20151025072118_create_custom_field_enumerations.rb create mode 100644 test/functional/custom_field_enumerations_controller_test.rb create mode 100644 test/unit/lib/redmine/field_format/enumeration_format_test.rb diff --git a/app/controllers/custom_field_enumerations_controller.rb b/app/controllers/custom_field_enumerations_controller.rb new file mode 100644 index 000000000..ba746d2b5 --- /dev/null +++ b/app/controllers/custom_field_enumerations_controller.rb @@ -0,0 +1,69 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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. + +class CustomFieldEnumerationsController < ApplicationController + layout 'admin' + + before_filter :require_admin + before_filter :find_custom_field + before_filter :find_enumeration, :only => :destroy + + def index + @values = @custom_field.enumerations.order(:position) + end + + def create + @value = @custom_field.enumerations.build(params[:custom_field_enumeration]) + @value.save + respond_to do |format| + format.html { redirect_to custom_field_enumerations_path(@custom_field) } + format.js + end + end + + def update_each + if CustomFieldEnumeration.update_each(@custom_field, params[:custom_field_enumerations]) + flash[:notice] = l(:notice_successful_update) + end + redirect_to :action => 'index' + end + + def destroy + reassign_to = @custom_field.enumerations.find_by_id(params[:reassign_to_id]) + if reassign_to.nil? && @value.in_use? + @enumerations = @custom_field.enumerations - [@value] + render :action => 'destroy' + return + end + @value.destroy(reassign_to) + redirect_to custom_field_enumerations_path(@custom_field) + end + + private + + def find_custom_field + @custom_field = CustomField.find(params[:custom_field_id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def find_enumeration + @value = @custom_field.enumerations.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end +end diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index ad87aa34e..a1373d12b 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -18,6 +18,10 @@ class CustomField < ActiveRecord::Base include Redmine::SubclassFactory + has_many :enumerations, + lambda { order(:position) }, + :class_name => 'CustomFieldEnumeration', + :dependent => :delete_all has_many :custom_values, :dependent => :delete_all has_and_belongs_to_many :roles, :join_table => "#{table_name_prefix}custom_fields_roles#{table_name_suffix}", :foreign_key => "custom_field_id" acts_as_list :scope => 'type = \'#{self.class}\'' diff --git a/app/models/custom_field_enumeration.rb b/app/models/custom_field_enumeration.rb new file mode 100644 index 000000000..26a580def --- /dev/null +++ b/app/models/custom_field_enumeration.rb @@ -0,0 +1,74 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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. + +class CustomFieldEnumeration < ActiveRecord::Base + belongs_to :custom_field + attr_accessible :name, :active, :position + + validates_presence_of :name, :position, :custom_field_id + validates_length_of :name, :maximum => 60 + validates_numericality_of :position, :only_integer => true + before_create :set_position + + scope :active, lambda { where(:active => true) } + + def to_s + name.to_s + end + + def objects_count + custom_values.count + end + + def in_use? + objects_count > 0 + end + + alias :destroy_without_reassign :destroy + def destroy(reassign_to=nil) + if reassign_to + custom_values.update_all(:value => reassign_to.id.to_s) + end + destroy_without_reassign + end + + def custom_values + custom_field.custom_values.where(:value => id.to_s) + end + + def self.update_each(custom_field, attributes) + return unless attributes.is_a?(Hash) + transaction do + attributes.each do |enumeration_id, enumeration_attributes| + enumeration = custom_field.enumerations.find_by_id(enumeration_id) + if enumeration + enumeration.attributes = enumeration_attributes + unless enumeration.save + raise ActiveRecord::Rollback + end + end + end + end + end + + private + + def set_position + max = self.class.where(:custom_field_id => custom_field_id).maximum(:position) || 0 + self.position = max + 1 + end +end diff --git a/app/views/custom_field_enumerations/create.js.erb b/app/views/custom_field_enumerations/create.js.erb new file mode 100644 index 000000000..9a9d40433 --- /dev/null +++ b/app/views/custom_field_enumerations/create.js.erb @@ -0,0 +1,2 @@ +$('#content').html('<%= escape_javascript(render(:template => 'custom_field_enumerations/index')) %>'); +$('#custom_field_enumeration_name').focus(); diff --git a/app/views/custom_field_enumerations/destroy.html.erb b/app/views/custom_field_enumerations/destroy.html.erb new file mode 100644 index 000000000..d2ae2ed79 --- /dev/null +++ b/app/views/custom_field_enumerations/destroy.html.erb @@ -0,0 +1,14 @@ +<%= title [l(:label_custom_field_plural), custom_fields_path], + [l(@custom_field.type_name), custom_fields_path(:tab => @custom_field.class.name)], + @custom_field.name %> + +<%= form_tag(custom_field_enumeration_path(@custom_field, @value), :method => :delete) do %> +
+

<%= l(:text_enumeration_destroy_question, :name => @value.name, :count => @value.objects_count) %>

+

+<%= select_tag('reassign_to_id', content_tag('option', "--- #{l(:actionview_instancetag_blank_option)} ---", :value => '') + options_from_collection_for_select(@enumerations, 'id', 'name')) %>

+
+ +<%= submit_tag l(:button_apply) %> +<%= link_to l(:button_cancel), custom_field_enumerations_path(@custom_field) %> +<% end %> diff --git a/app/views/custom_field_enumerations/index.html.erb b/app/views/custom_field_enumerations/index.html.erb new file mode 100644 index 000000000..9ca9314eb --- /dev/null +++ b/app/views/custom_field_enumerations/index.html.erb @@ -0,0 +1,49 @@ +<%= title [l(:label_custom_field_plural), custom_fields_path], + [l(@custom_field.type_name), custom_fields_path(:tab => @custom_field.class.name)], + @custom_field.name %> + +<% if @custom_field.enumerations.any? %> +<%= form_tag custom_field_enumerations_path(@custom_field), :method => 'put' do %> +
+ +
+

+ <%= submit_tag(l(:button_save)) %> | + <%= link_to l(:button_back), edit_custom_field_path(@custom_field) %> +

+<% end %> +<% end %> + +

<%= l(:label_enumeration_new) %>

+ +<%= form_tag custom_field_enumerations_path(@custom_field), :method => 'post', :remote => true do %> +

<%= text_field_tag 'custom_field_enumeration[name]', '', :size => 40 %> + <%= submit_tag(l(:button_add)) %>

+<% end %> + +<%= javascript_tag do %> +$(function() { + $("#custom_field_enumerations").sortable({ + handle: ".sort-handle", + update: function(event, ui) { + $("#custom_field_enumerations li").each(function(){ + $(this).find("input.position").val($(this).index()+1); + }); + } + }); +}); +<% end %> diff --git a/app/views/custom_fields/formats/_enumeration.erb b/app/views/custom_fields/formats/_enumeration.erb new file mode 100644 index 000000000..07e4cf463 --- /dev/null +++ b/app/views/custom_fields/formats/_enumeration.erb @@ -0,0 +1,12 @@ +<% unless @custom_field.new_record? %> +

+ + <%= link_to l(:button_edit), custom_field_enumerations_path(@custom_field), :class => 'icon icon-edit' %> +

+<% if @custom_field.enumerations.active.any? %> +

<%= f.select :default_value, @custom_field.enumerations.active.map{|v| [v.name, v.id.to_s]}, :include_blank => true %>

+<% end %> +<% end %> + +

<%= f.text_field :url_pattern, :size => 50, :label => :label_link_values_to %>

+

<%= edit_tag_style_tag f %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index 3e1e94906..dada67a9f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -970,6 +970,7 @@ en: label_file_content_preview: File content preview label_create_missing_values: Create missing values label_api: API + label_field_format_enumeration: Key/value list button_login: Login button_submit: Submit diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 517b227bb..2cedaa2cd 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -988,6 +988,7 @@ fr: label_file_content_preview: Aperçu du contenu du fichier label_create_missing_values: Créer les valeurs manquantes label_api: API + label_field_format_enumeration: Liste clé/valeur button_login: Connexion button_submit: Soumettre diff --git a/config/routes.rb b/config/routes.rb index d7ddac190..47451453f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -311,7 +311,10 @@ Rails.application.routes.draw do post 'update_issue_done_ratio' end end - resources :custom_fields, :except => :show + resources :custom_fields, :except => :show do + resources :enumerations, :controller => 'custom_field_enumerations', :except => [:show, :new, :edit] + put 'enumerations', :to => 'custom_field_enumerations#update_each' + end resources :roles do collection do match 'permissions', :via => [:get, :post] diff --git a/db/migrate/20151025072118_create_custom_field_enumerations.rb b/db/migrate/20151025072118_create_custom_field_enumerations.rb new file mode 100644 index 000000000..ea2e5103f --- /dev/null +++ b/db/migrate/20151025072118_create_custom_field_enumerations.rb @@ -0,0 +1,10 @@ +class CreateCustomFieldEnumerations < ActiveRecord::Migration + def change + create_table :custom_field_enumerations do |t| + t.integer :custom_field_id, :null => false + t.string :name, :null => false + t.boolean :active, :default => true, :null => false + t.integer :position, :default => 1, :null => false + end + end +end diff --git a/lib/redmine/field_format.rb b/lib/redmine/field_format.rb index 3d97f601b..841d6a08d 100644 --- a/lib/redmine/field_format.rb +++ b/lib/redmine/field_format.rb @@ -539,7 +539,7 @@ module Redmine add 'list' self.searchable_supported = true self.form_partial = 'custom_fields/formats/list' - + def possible_custom_value_options(custom_value) options = possible_values_options(custom_value.custom_field) missing = [custom_value.value].flatten.reject(&:blank?) - options @@ -636,7 +636,6 @@ module Redmine missing = [custom_value.value_was].flatten.reject(&:blank?) - options.map(&:last) if missing.any? options += target_class.where(:id => missing.map(&:to_i)).map {|o| [o.to_s, o.id.to_s]} - options.sort_by!(&:first) end options end @@ -674,6 +673,32 @@ module Redmine protected :value_join_alias end + class EnumerationFormat < RecordList + add 'enumeration' + self.form_partial = 'custom_fields/formats/enumeration' + + def label + "label_field_format_enumeration" + end + + def target_class + @target_class ||= CustomFieldEnumeration + end + + def possible_values_options(custom_field, object=nil) + possible_values_records(custom_field, object).map {|u| [u.name, u.id.to_s]} + end + + def possible_values_records(custom_field, object=nil) + custom_field.enumerations.active + end + + def value_from_keyword(custom_field, keyword, object) + value = custom_field.enumerations.where("LOWER(name) LIKE LOWER(?)", keyword) + value ? value.id : nil + end + end + class UserFormat < RecordList add 'user' self.form_partial = 'custom_fields/formats/user' diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index bb07cb621..aa5872c4e 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -77,8 +77,8 @@ pre, code {font-family: Consolas, Menlo, "Liberation Mono", Courier, monospace;} #sidebar hr{ width: 100%; margin: 0 auto; height: 1px; background: #ccc; border: 0; } * html #sidebar hr{ width: 95%; position: relative; left: -6px; color: #ccc; } #sidebar .contextual { margin-right: 1em; } -#sidebar ul {margin: 0; padding: 0;} -#sidebar ul li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;} +#sidebar ul, ul.flat {margin: 0; padding: 0;} +#sidebar ul li, ul.flat li {list-style-type:none;margin: 0px 2px 0px 0px; padding: 0px 0px 0px 0px;} #content { width: 75%; background-color: #fff; margin: 0px; border-right: 1px solid #ddd; padding: 6px 10px 10px 10px; z-index: 10; } * html #content{ width: 75%; padding-left: 0; margin-top: 0px; padding: 6px 10px 10px 10px;} @@ -483,6 +483,7 @@ select.expandable {vertical-align:top;} textarea#custom_field_possible_values {width: 95%; resize:vertical} textarea#custom_field_default_value {width: 95%; resize:vertical} +.sort-handle {display:inline-block; vertical-align:middle;} input#content_comments {width: 99%} diff --git a/test/functional/custom_field_enumerations_controller_test.rb b/test/functional/custom_field_enumerations_controller_test.rb new file mode 100644 index 000000000..6bcddac5e --- /dev/null +++ b/test/functional/custom_field_enumerations_controller_test.rb @@ -0,0 +1,111 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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 CustomFieldEnumerationsControllerTest < ActionController::TestCase + fixtures :users, :email_addresses + + def setup + @request.session[:user_id] = 1 + @field = GroupCustomField.create!(:name => 'List', :field_format => 'enumeration', :is_required => false) + @foo = CustomFieldEnumeration.new(:name => 'Foo') + @bar = CustomFieldEnumeration.new(:name => 'Bar') + @field.enumerations << @foo + @field.enumerations << @bar + end + + def test_index + get :index, :custom_field_id => @field.id + assert_response :success + assert_template 'index' + end + + def test_create + assert_difference 'CustomFieldEnumeration.count' do + post :create, :custom_field_id => @field.id, :custom_field_enumeration => { :name => 'Baz' } + assert_redirected_to "/custom_fields/#{@field.id}/enumerations" + end + + assert_equal 3, @field.reload.enumerations.count + enum = @field.enumerations.last + assert_equal 'Baz', enum.name + assert_equal true, enum.active + assert_equal 3, enum.position + end + + def test_create_xhr + assert_difference 'CustomFieldEnumeration.count' do + xhr :post, :create, :custom_field_id => @field.id, :custom_field_enumeration => { :name => 'Baz' } + assert_response :success + end + end + + def test_update_each + put :update_each, :custom_field_id => @field.id, :custom_field_enumerations => { + @bar.id => {:position => "1", :name => "Baz", :active => "1"}, + @foo.id => {:position => "2", :name => "Foo", :active => "0"} + } + assert_response 302 + + @bar.reload + assert_equal "Baz", @bar.name + assert_equal true, @bar.active + assert_equal 1, @bar.position + + @foo.reload + assert_equal "Foo", @foo.name + assert_equal false, @foo.active + assert_equal 2, @foo.position + end + + def test_destroy + assert_difference 'CustomFieldEnumeration.count', -1 do + delete :destroy, :custom_field_id => @field.id, :id => @foo.id + assert_redirected_to "/custom_fields/#{@field.id}/enumerations" + end + + assert_equal 1, @field.reload.enumerations.count + enum = @field.enumerations.last + assert_equal 'Bar', enum.name + end + + def test_destroy_enumeration_in_use_should_display_destroy_form + group = Group.generate! + group.custom_field_values = {@field.id.to_s => @foo.id.to_s} + group.save! + + assert_no_difference 'CustomFieldEnumeration.count' do + delete :destroy, :custom_field_id => @field.id, :id => @foo.id + assert_response 200 + assert_template 'destroy' + end + end + + def test_destroy_enumeration_in_use_should_destroy_and_reassign_values + group = Group.generate! + group.custom_field_values = {@field.id.to_s => @foo.id.to_s} + group.save! + + assert_difference 'CustomFieldEnumeration.count', -1 do + delete :destroy, :custom_field_id => @field.id, :id => @foo.id, :reassign_to_id => @bar.id + assert_response 302 + end + + assert_equal @bar.id.to_s, group.reload.custom_field_value(@field) + end +end diff --git a/test/integration/routing/custom_fields_test.rb b/test/integration/routing/custom_fields_test.rb index e0fccb7c6..0fbb10b14 100644 --- a/test/integration/routing/custom_fields_test.rb +++ b/test/integration/routing/custom_fields_test.rb @@ -27,4 +27,11 @@ class RoutingCustomFieldsTest < Redmine::RoutingTest should_route 'PUT /custom_fields/2' => 'custom_fields#update', :id => '2' should_route 'DELETE /custom_fields/2' => 'custom_fields#destroy', :id => '2' end + + def test_custom_field_enumerations + should_route 'GET /custom_fields/3/enumerations' => 'custom_field_enumerations#index', :custom_field_id => '3' + should_route 'POST /custom_fields/3/enumerations' => 'custom_field_enumerations#create', :custom_field_id => '3' + should_route 'PUT /custom_fields/3/enumerations' => 'custom_field_enumerations#update_each', :custom_field_id => '3' + should_route 'DELETE /custom_fields/3/enumerations/6' => 'custom_field_enumerations#destroy', :custom_field_id => '3', :id => '6' + end end diff --git a/test/unit/lib/redmine/field_format/enumeration_format_test.rb b/test/unit/lib/redmine/field_format/enumeration_format_test.rb new file mode 100644 index 000000000..961957ff8 --- /dev/null +++ b/test/unit/lib/redmine/field_format/enumeration_format_test.rb @@ -0,0 +1,86 @@ +# Redmine - project management software +# Copyright (C) 2006-2015 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::EnumerationFieldFormatTest < ActionView::TestCase + include ApplicationHelper + + def setup + @field = IssueCustomField.create!(:name => 'List', :field_format => 'enumeration', :is_required => false) + @foo = CustomFieldEnumeration.new(:name => 'Foo') + @bar = CustomFieldEnumeration.new(:name => 'Bar') + @field.enumerations << @foo + @field.enumerations << @bar + end + + def test_edit_tag_should_contain_possible_values + value = CustomFieldValue.new(:custom_field => @field, :customized => Issue.new) + + tag = @field.format.edit_tag(self, 'id', 'name', value) + assert_select_in tag, 'select' do + assert_select 'option', 3 + assert_select 'option[value=""]' + assert_select 'option[value=?]', @foo.id.to_s, :text => 'Foo' + assert_select 'option[value=?]', @bar.id.to_s, :text => 'Bar' + end + end + + def test_edit_tag_should_select_current_value + value = CustomFieldValue.new(:custom_field => @field, :customized => Issue.new, :value => @bar.id.to_s) + + tag = @field.format.edit_tag(self, 'id', 'name', value) + assert_select_in tag, 'select' do + assert_select 'option[selected=selected]', 1 + assert_select 'option[value=?][selected=selected]', @bar.id.to_s, :text => 'Bar' + end + end + + def test_edit_tag_with_multiple_should_select_current_values + @field.multiple = true + @field.save! + value = CustomFieldValue.new(:custom_field => @field, :customized => Issue.new, :value => [@foo.id.to_s, @bar.id.to_s]) + + tag = @field.format.edit_tag(self, 'id', 'name', value) + assert_select_in tag, 'select[multiple=multiple]' do + assert_select 'option[selected=selected]', 2 + assert_select 'option[value=?][selected=selected]', @foo.id.to_s, :text => 'Foo' + assert_select 'option[value=?][selected=selected]', @bar.id.to_s, :text => 'Bar' + end + end + + def test_edit_tag_with_check_box_style_should_contain_possible_values + @field.edit_tag_style = 'check_box' + @field.save! + value = CustomFieldValue.new(:custom_field => @field, :customized => Issue.new) + + tag = @field.format.edit_tag(self, 'id', 'name', value) + assert_select_in tag, 'span' do + assert_select 'input[type=radio]', 3 + assert_select 'label', :text => '(none)' do + assert_select 'input[value=""]' + end + assert_select 'label', :text => 'Foo' do + assert_select 'input[value=?]', @foo.id.to_s + end + assert_select 'label', :text => 'Bar' do + assert_select 'input[value=?]', @bar.id.to_s + end + end + end +end -- 2.39.5