From c11f51f5f1dd3394d7a099ca3b22848e3502996e Mon Sep 17 00:00:00 2001
From: Jens Kraemer <jk@jkraemer.net>
Date: Thu, 20 Feb 2025 15:01:52 +0800
Subject: [PATCH] progress bar field format #1168755

---
 config/locales/de.yml                         |  1 +
 config/locales/en.yml                         |  1 +
 config/locales/fr.yml                         |  1 +
 lib/redmine/field_format.rb                   | 49 +++++++++++++
 .../field_format/progressbar_format_test.rb   | 69 +++++++++++++++++++
 .../planio/progressbar_field_format_test.rb   | 66 ++++++++++++++++++
 6 files changed, 187 insertions(+)
 create mode 100644 test/unit/lib/redmine/field_format/progressbar_format_test.rb
 create mode 100644 test/unit/planio/progressbar_field_format_test.rb

diff --git a/config/locales/de.yml b/config/locales/de.yml
index 4947ead29a..d97a07e2d9 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -843,6 +843,7 @@ de:
   label_year: Jahr
   label_yesterday: gestern
   label_default_query: Standardabfrage
+  label_progressbar: Fortschrittsbalken
 
   mail_body_account_activation_request: "Ein neuer Benutzer (%{value}) hat sich registriert. Sein Konto wartet auf Ihre Genehmigung:"
   mail_body_account_information: Ihre Konto-Informationen
diff --git a/config/locales/en.yml b/config/locales/en.yml
index aa15095c65..aac5afe6be 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1150,6 +1150,7 @@ en:
   label_edited: Edited
   label_time_by_author: "%{time} by %{author}"
   label_involved_principals: Author / Previous assignee
+  label_progressbar: Progress bar
 
   button_login: Login
   button_submit: Submit
diff --git a/config/locales/fr.yml b/config/locales/fr.yml
index 5319feccf1..aba16b6fef 100644
--- a/config/locales/fr.yml
+++ b/config/locales/fr.yml
@@ -1025,6 +1025,7 @@ fr:
     for_this_user: For this user
   label_trackers_description: Description des trackers
   label_open_trackers_description: Afficher la description des trackers
+  label_progressbar: Barre de progression
 
   button_login: Connexion
   button_submit: Soumettre
diff --git a/lib/redmine/field_format.rb b/lib/redmine/field_format.rb
index b41b2986c2..8ccc1195a9 100644
--- a/lib/redmine/field_format.rb
+++ b/lib/redmine/field_format.rb
@@ -1082,5 +1082,54 @@ module Redmine
             })
       end
     end
+
+    class ProgressbarFormat < Numeric
+      add 'progressbar'
+
+      self.form_partial = nil
+      self.totalable_supported = false
+
+      def label
+        "label_progressbar"
+      end
+
+      def cast_single_value(custom_field, value, customized=nil)
+        value.to_i.clamp(0, 100)
+      end
+
+      def validate_single_value(custom_field, value, customized=nil)
+        errs = super
+        errs << ::I18n.t('activerecord.errors.messages.not_a_number') unless /^\d*$/.match?(value.to_s.strip)
+        errs << ::I18n.t('activerecord.errors.messages.invalid') unless value.to_i.between?(0, 100)
+        errs
+      end
+
+      def query_filter_options(custom_field, query)
+        {:type => :integer}
+      end
+
+      def group_statement(custom_field)
+        order_statement(custom_field)
+      end
+
+      def edit_tag(view, tag_id, tag_name, custom_value, options={})
+        view.select_tag tag_name, view.options_for_select((0..100).step(Setting.issue_done_ratio_interval.to_i).to_a.collect {|r| ["#{r} %", r]}, custom_value.value), options.merge(id: tag_id, style: "width: 75px;")
+      end
+
+      def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={})
+        opts = view.options_for_select([[l(:label_no_change_option), '']] + (0..100).step(Setting.issue_done_ratio_interval.to_i).to_a.collect {|r| ["#{r} %", r]})
+        view.select_tag(tag_name, opts, options.merge(id: tag_id, style: "width: 75px;")) +
+          bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
+      end
+
+      def formatted_value(view, custom_field, value, customized=nil, html=false)
+        text = "#{value}%"
+        if html
+          view.progress_bar(value.to_i, legend: (text if view.action_name == 'show'))
+        else
+          text
+        end
+      end
+    end
   end
 end
diff --git a/test/unit/lib/redmine/field_format/progressbar_format_test.rb b/test/unit/lib/redmine/field_format/progressbar_format_test.rb
new file mode 100644
index 0000000000..273a2110ff
--- /dev/null
+++ b/test/unit/lib/redmine/field_format/progressbar_format_test.rb
@@ -0,0 +1,69 @@
+require_relative '../../../../test_helper'
+require 'redmine/field_format'
+
+module Planio
+  class ProgressbarFormatTest < ActiveSupport::TestCase
+
+    def setup
+      @field = IssueCustomField.new(name: 'ProgressbarTest', field_format: 'progressbar')
+      @format = Redmine::FieldFormat::ProgressbarFormat.instance
+    end
+
+    def test_validate_invalid_value
+      cv = CustomValue.new(custom_field: @field, value: '120')
+      assert_include ::I18n.t('activerecord.errors.messages.invalid'), @format.validate_custom_value(cv)
+    end
+
+    def test_validate_numericality
+      cv = CustomValue.new(custom_field: @field, value: 'abc')
+      assert_include ::I18n.t('activerecord.errors.messages.not_a_number'), @format.validate_custom_value(cv)
+    end
+
+    def test_cast_value_clamping
+      assert_equal 0, @field.cast_value('-10')
+      assert_equal 0, @field.cast_value('0')
+      assert_equal 50, @field.cast_value('50')
+      assert_equal 100, @field.cast_value('120')
+    end
+
+    def test_empty_value
+      assert_nil @field.cast_value('')
+    end
+
+    def test_totalable_support
+      assert_not @format.totalable_supported?
+    end
+
+    def test_validate_non_numeric_value_should_fail
+      assert_include ::I18n.t('activerecord.errors.messages.not_a_number'),
+        @format.validate_single_value(@field, "abc")
+    end
+
+    def test_validate_negative_value_should_fail 
+      assert_include ::I18n.t('activerecord.errors.messages.invalid'),
+        @format.validate_single_value(@field, "-10")
+    end
+
+    def test_validate_value_above_100_should_fail
+      assert_include ::I18n.t('activerecord.errors.messages.invalid'),
+        @format.validate_single_value(@field, "150")
+    end
+
+    def test_validate_valid_value_should_pass
+      assert_empty @format.validate_single_value(@field, "50")
+      assert_empty @format.validate_single_value(@field, "0")
+      assert_empty @format.validate_single_value(@field, "100")
+    end
+
+    def test_validate_blank_value_should_pass
+      assert_empty @format.validate_single_value(@field, "")
+    end
+
+    def test_query_filter_options
+      options = @format.query_filter_options(@field, nil)
+      assert_equal :integer, options[:type]
+    end
+
+  end
+end
+
diff --git a/test/unit/planio/progressbar_field_format_test.rb b/test/unit/planio/progressbar_field_format_test.rb
new file mode 100644
index 0000000000..5eb86e9c20
--- /dev/null
+++ b/test/unit/planio/progressbar_field_format_test.rb
@@ -0,0 +1,66 @@
+require File.expand_path('../../../test_helper', __FILE__)
+module Planio
+  class ProgressbarFieldFormatTest < ActiveSupport::TestCase
+
+    def setup
+      @field = IssueCustomField.new(name: 'ProgressbarTest', field_format: 'progressbar')
+      @format = Redmine::FieldFormat::ProgressbarFormat.instance
+    end
+
+    def test_validate_invalid_value
+      cv = CustomValue.new(custom_field: @field, value: '120')
+      assert_include ::I18n.t('activerecord.errors.messages.invalid'), @format.validate_custom_value(cv)
+    end
+
+    def test_validate_numericality
+      cv = CustomValue.new(custom_field: @field, value: 'abc')
+      assert_include ::I18n.t('activerecord.errors.messages.not_a_number'), @format.validate_custom_value(cv)
+    end
+
+    def test_cast_value_clamping
+      assert_equal 0, @field.cast_value('-10')
+      assert_equal 0, @field.cast_value('0')
+      assert_equal 50, @field.cast_value('50')
+      assert_equal 100, @field.cast_value('120')
+    end
+
+    def test_empty_value
+      assert_nil @field.cast_value('')
+    end
+
+    def test_totalable_support
+      assert_not @format.totalable_supported?
+    end
+
+    def test_validate_non_numeric_value_should_fail
+      assert_include ::I18n.t('activerecord.errors.messages.not_a_number'),
+        @format.validate_single_value(@field, "abc")
+    end
+
+    def test_validate_negative_value_should_fail 
+      assert_include ::I18n.t('activerecord.errors.messages.invalid'),
+        @format.validate_single_value(@field, "-10")
+    end
+
+    def test_validate_value_above_100_should_fail
+      assert_include ::I18n.t('activerecord.errors.messages.invalid'),
+        @format.validate_single_value(@field, "150")
+    end
+
+    def test_validate_valid_value_should_pass
+      assert_empty @format.validate_single_value(@field, "50")
+      assert_empty @format.validate_single_value(@field, "0")
+      assert_empty @format.validate_single_value(@field, "100")
+    end
+
+    def test_validate_blank_value_should_pass
+      assert_empty @format.validate_single_value(@field, "")
+    end
+
+    def test_query_filter_options
+      options = @format.query_filter_options(@field, nil)
+      assert_equal :integer, options[:type]
+    end
+
+  end
+end
-- 
2.48.1

