From 2a9a3987d3c76cba8d23cd50ab5f810b3f2f1e4b Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Sat, 30 May 2026 10:52:49 +0900 Subject: [PATCH] Add relative date option for date format custom field defaults --- .../custom_field_default_value_controller.js | 24 +++++++++ app/models/custom_field.rb | 26 ++++++++- .../custom_fields/formats/_date.html.erb | 36 ++++++++++++- app/views/custom_fields/index.api.rsb | 3 +- config/locales/en.yml | 3 ++ config/locales/ja.yml | 3 ++ lib/redmine/field_format.rb | 9 ++++ test/functional/issues_controller_test.rb | 47 +++++++++++----- .../api_test/custom_fields_test.rb | 19 +++++++ test/integration/api_test/issues_test.rb | 53 +++++++++++++++++++ test/unit/custom_field_test.rb | 46 ++++++++++++++++ 11 files changed, 252 insertions(+), 17 deletions(-) create mode 100644 app/javascript/controllers/custom_field_default_value_controller.js diff --git a/app/javascript/controllers/custom_field_default_value_controller.js b/app/javascript/controllers/custom_field_default_value_controller.js new file mode 100644 index 000000000..3d0e334d6 --- /dev/null +++ b/app/javascript/controllers/custom_field_default_value_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = [ + "mode", + "fixedDate", + "fixedDateInput", + "dateOffset", + "dateOffsetInput" + ] + + connect() { + this.update() + } + + update() { + const mode = this.modeTarget.value + + this.fixedDateTarget.hidden = mode !== "fixed_date" + this.fixedDateInputTarget.disabled = mode !== "fixed_date" + this.dateOffsetTarget.hidden = mode !== "date_offset" + this.dateOffsetInputTarget.disabled = mode !== "date_offset" + } +} diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index 0eed2bf31..96758f440 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -89,6 +89,7 @@ class CustomField < ApplicationRecord 'position', 'searchable', 'default_value', + 'default_value_mode', 'editable', 'visible', 'multiple', @@ -129,6 +130,16 @@ class CustomField < ApplicationRecord @format ||= Redmine::FieldFormat.find(field_format) end + def default_value + if field_format == 'date' && default_value_mode == 'date_offset' && self[:default_value].present? + (User.current.today + Integer(self[:default_value], 10)).to_s + else + self[:default_value] + end + rescue ArgumentError + self[:default_value] + end + def field_format=(arg) # cannot change format of a saved custom field if new_record? @@ -158,13 +169,24 @@ class CustomField < ApplicationRecord end end - if default_value.present? && errors[:regexp].blank? - validate_field_value(default_value).each do |message| + if field_format == 'date' && default_value_mode == 'date_offset' + validate_date_default_value_offset + elsif self[:default_value].present? && errors[:regexp].blank? + validate_field_value(self[:default_value]).each do |message| errors.add :default_value, message end end end + def validate_date_default_value_offset + value = self[:default_value].to_s.strip + return if value.blank? + + Integer(value, 10) + rescue ArgumentError + errors.add :default_value, :not_a_number + end + def possible_custom_value_options(custom_value) format.possible_custom_value_options(custom_value) end diff --git a/app/views/custom_fields/formats/_date.html.erb b/app/views/custom_fields/formats/_date.html.erb index b52c06310..eb8910051 100644 --- a/app/views/custom_fields/formats/_date.html.erb +++ b/app/views/custom_fields/formats/_date.html.erb @@ -1,3 +1,35 @@ -

<%= f.date_field(:default_value, :value => @custom_field.default_value, :size => 10) %>

-<%= calendar_for('custom_field_default_value') %> +<% default_value_mode = @custom_field.default_value_mode.presence || 'fixed_date' %> +<% fixed_date_default_value = default_value_mode == 'fixed_date' ? @custom_field[:default_value] : nil %> +<% date_offset_default_value = default_value_mode == 'date_offset' ? @custom_field[:default_value] : nil %> +
+

+ <%= f.select(:default_value_mode, + [ + [l(:label_absolute), 'fixed_date'], + [l(:label_relative), 'date_offset'] + ], + {:selected => default_value_mode}, + :data => { + :custom_field_default_value_target => 'mode', + :action => 'custom-field-default-value#update' + }) %> +

+

> + <%= f.date_field(:default_value, + :value => fixed_date_default_value, + :size => 10, + :disabled => default_value_mode != 'fixed_date', + :data => {:custom_field_default_value_target => 'fixedDateInput'}) %> + <%= calendar_for('custom_field_default_value') %> +

+

> + <%= f.text_field(:default_value, + :id => 'custom_field_default_value_offset', + :value => date_offset_default_value, + :size => 6, + :disabled => default_value_mode != 'date_offset', + :data => {:custom_field_default_value_target => 'dateOffsetInput'}) %> + <%= l(:label_days_relative_to_today) %> +

+

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

diff --git a/app/views/custom_fields/index.api.rsb b/app/views/custom_fields/index.api.rsb index d4b19d62b..ba466152b 100644 --- a/app/views/custom_fields/index.api.rsb +++ b/app/views/custom_fields/index.api.rsb @@ -13,7 +13,8 @@ api.array :custom_fields do api.is_filter field.is_filter? api.searchable field.searchable api.multiple field.multiple? - api.default_value field.default_value + api.default_value field[:default_value] + api.default_value_mode(field.default_value_mode.presence || 'fixed_date') if field.field_format == 'date' api.visible field.visible? api.editable field.editable? diff --git a/config/locales/en.yml b/config/locales/en.yml index 433d20931..2a30a9d3a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -372,6 +372,7 @@ en: field_time_zone: Time zone field_searchable: Searchable field_default_value: Default value + field_default_value_mode: Default value mode field_comments_sorting: Display comments field_parent_title: Parent page field_editable: Editable @@ -784,6 +785,8 @@ en: label_all: all label_any: any label_none: none + label_absolute: Absolute + label_relative: Relative label_nobody: nobody label_next: Next label_previous: Previous diff --git a/config/locales/ja.yml b/config/locales/ja.yml index af441b35e..05b9ab47d 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -326,6 +326,7 @@ ja: field_time_zone: タイムゾーン field_searchable: 検索対象 field_default_value: デフォルト値 + field_default_value_mode: デフォルト値の指定方法 field_comments_sorting: コメントの表示順 field_parent_title: 親ページ field_editable: 編集可能 @@ -596,6 +597,8 @@ ja: label_new_statuses_allowed: 遷移できるステータス label_all: すべて label_none: なし + label_absolute: 絶対値 + label_relative: 相対値 label_nobody: 無記名 label_next: 次 label_previous: 前 diff --git a/lib/redmine/field_format.rb b/lib/redmine/field_format.rb index bb0185b51..46ada2817 100644 --- a/lib/redmine/field_format.rb +++ b/lib/redmine/field_format.rb @@ -555,6 +555,7 @@ module Redmine class DateFormat < Unbounded add 'date' self.form_partial = 'custom_fields/formats/date' + field_attributes :default_value_mode def cast_single_value(custom_field, value, customized=nil) value.to_date rescue nil @@ -583,6 +584,14 @@ module Redmine {:type => :date} end + def before_custom_field_save(custom_field) + super + + custom_field.default_value_mode = + custom_field.default_value_mode == 'date_offset' ? 'date_offset' : 'fixed_date' + custom_field.default_value = custom_field[:default_value].to_s.strip if custom_field.default_value_mode == 'date_offset' + end + def group_statement(custom_field) order_statement(custom_field) end diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb index f7b083a05..070927da3 100644 --- a/test/functional/issues_controller_test.rb +++ b/test/functional/issues_controller_test.rb @@ -3802,18 +3802,41 @@ class IssuesControllerTest < Redmine::ControllerTest end def test_get_new_with_date_custom_field - field = IssueCustomField.create!(:name => 'Date', :field_format => 'date', - :tracker_ids => [1], :is_for_all => true) - @request.session[:user_id] = 2 - get( - :new, - :params => { - :project_id => 1, - :tracker_id => 1 - } - ) - assert_response :success - assert_select 'input[name=?]', "issue[custom_field_values][#{field.id}]" + travel_to Time.zone.parse('2026-05-24T23:00:00Z') do + # 2026-05-24 23:00 UTC is 2026-05-25 08:00 in Tokyo + User.find(2).pref.update!(:time_zone => 'Tokyo') + fixed_date_field = + IssueCustomField.create!( + :name => 'Fixed date default value', + :field_format => 'date', + :default_value_mode => 'fixed_date', + :default_value => '2026-03-21', + :tracker_ids => [1], + :is_for_all => true + ) + date_offset_field = + IssueCustomField.create!( + :name => 'Date offset default value', + :field_format => 'date', + :default_value_mode => 'date_offset', + :default_value => '5', + :tracker_ids => [1], + :is_for_all => true + ) + @request.session[:user_id] = 2 + get( + :new, + :params => { + :project_id => 1, + :tracker_id => 1 + } + ) + assert_response :success + assert_select 'input[name=?][value=?]', "issue[custom_field_values][#{fixed_date_field.id}]", '2026-03-21' + assert_select 'input[name=?][value=?]', + "issue[custom_field_values][#{date_offset_field.id}]", + '2026-05-30' # 5 days after in user's time zone + end end def test_get_new_with_text_custom_field diff --git a/test/integration/api_test/custom_fields_test.rb b/test/integration/api_test/custom_fields_test.rb index 4fb06636e..cf4473615 100644 --- a/test/integration/api_test/custom_fields_test.rb +++ b/test/integration/api_test/custom_fields_test.rb @@ -56,4 +56,23 @@ class Redmine::ApiTest::CustomFieldsTest < Redmine::ApiTest::Base assert_select "value:contains(?) + label:contains(?)", bar.id.to_s, 'Bar' end end + + test "GET /custom_fields.xml should include date offset default value mode" do + field = + IssueCustomField.generate!( + :field_format => 'date', + :default_value_mode => 'date_offset', + :default_value => '-3' + ) + + get '/custom_fields.xml', :headers => credentials('admin') + assert_response :success + + assert_select 'custom_field' do |elements| + element = elements.detect {|e| e.at('id')&.text == field.id.to_s} + assert_not_nil element + assert_equal 'date_offset', element.at('default_value_mode').text + assert_equal '-3', element.at('default_value').text + end + end end diff --git a/test/integration/api_test/issues_test.rb b/test/integration/api_test/issues_test.rb index cadadc1ec..131dc181b 100644 --- a/test/integration/api_test/issues_test.rb +++ b/test/integration/api_test/issues_test.rb @@ -681,6 +681,59 @@ class Redmine::ApiTest::IssuesTest < Redmine::ApiTest::Base assert_equal "", issue.custom_field_value(field) end + test "POST /issues.json with omitted date custom field should set date offset default" do + User.current = User.find_by_login('jsmith') + field = + IssueCustomField.generate!( + :field_format => 'date', + :default_value_mode => 'date_offset', + :default_value => '5', + :trackers => Tracker.all, + :is_for_all => true + ) + issue = new_record(Issue) do + post( + '/issues.json', + :params => { + :issue => { + :project_id => 1, + :tracker_id => 1, + :subject => 'API date default', + :custom_field_values => {} + } + }, + :headers => credentials('jsmith')) + end + + assert_equal (User.current.today + 5).to_s, issue.custom_field_value(field) + end + + test "POST /issues.json with date custom field set to blank should not set date offset default" do + field = + IssueCustomField.generate!( + :field_format => 'date', + :default_value_mode => 'date_offset', + :default_value => '5', + :trackers => Tracker.all, + :is_for_all => true + ) + issue = new_record(Issue) do + post( + '/issues.json', + :params => { + :issue => { + :project_id => 1, + :tracker_id => 1, + :subject => 'API blank date default', + :custom_field_values => {field.id.to_s => ''} + } + }, + :headers => credentials('jsmith')) + end + + assert_equal "", issue.custom_field_value(field) + end + test "POST /issues.json with failure should return errors" do assert_no_difference('Issue.count') do post( diff --git a/test/unit/custom_field_test.rb b/test/unit/custom_field_test.rb index 121e865d1..4508d35eb 100644 --- a/test/unit/custom_field_test.rb +++ b/test/unit/custom_field_test.rb @@ -72,6 +72,52 @@ class CustomFieldTest < ActiveSupport::TestCase assert field.valid? end + def test_date_default_value_should_return_date_offset_from_today + user = User.generate! + user.stubs(:today).returns(Date.parse('2026-03-21')) + User.current = user + + field = IssueCustomField.new(:name => 'Date', :field_format => 'date', :default_value_mode => 'date_offset') + + field.default_value = '0' + assert_equal '2026-03-21', field.default_value + + field.default_value = '5' + assert_equal '2026-03-26', field.default_value + + field.default_value = '-3' + assert_equal '2026-03-18', field.default_value + end + + def test_date_default_value_should_be_validated_when_date_offset_mode_is_selected + field = IssueCustomField.new(:name => 'Date', :field_format => 'date', :default_value_mode => 'date_offset') + + field.default_value = 'invalid' + assert field.invalid? + + field.default_value = '1.5' + assert field.invalid? + + field.default_value = '+5' + assert field.valid? + + field.default_value = '-3' + assert field.valid? + + field.default_value = '' + assert field.valid? + end + + def test_date_default_value_should_be_validated_when_fixed_date_mode_is_selected + field = IssueCustomField.new(:name => 'Date', :field_format => 'date', :default_value_mode => 'fixed_date') + + field.default_value = 'invalid' + assert field.invalid? + + field.default_value = '2026-03-21' + assert field.valid? + end + def test_field_format_should_be_validated field = CustomField.new(:name => 'Test', :field_format => 'foo') assert field.invalid? -- 2.50.1