From a4e70e0d990c6edcdf8a32e2e1eee82c01e3ea04 Mon Sep 17 00:00:00 2001 From: Akihiro MATOBA Date: Tue, 23 Nov 2021 06:25:14 +0000 Subject: Patch for task/4437-add-timestamp-in-custom-fileds --- app/helpers/application_helper.rb | 8 + .../custom_fields/formats/_timestamp.html.erb | 4 + config/locales/en.yml | 1 + lib/redmine/field_format.rb | 55 +++++ test/fixtures/custom_fields.yml | 9 + test/fixtures/custom_fields_trackers.yml | 3 + test/fixtures/custom_values.yml | 6 + .../custom_field_timestamp_test.rb | 200 ++++++++++++++++++ test/unit/custom_field_test.rb | 14 ++ 9 files changed, 300 insertions(+) create mode 100644 app/views/custom_fields/formats/_timestamp.html.erb create mode 100644 test/integration/custom_field_timestamp_test.rb diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 03fb26d4c..ed7e0dc0c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1615,6 +1615,14 @@ module ApplicationHelper ) end + def datetimepicker_for(field_id) + javascript_tag( + "$(function() { $('##{field_id}').attr('type','datetime-local')" + + ".attr('pattern','[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}')" + + ".attr('placeholder','yyyy-mm-ddThh:mm'); });" + ) + end + def include_calendar_headers_tags unless @calendar_headers_tags_included tags = ''.html_safe diff --git a/app/views/custom_fields/formats/_timestamp.html.erb b/app/views/custom_fields/formats/_timestamp.html.erb new file mode 100644 index 000000000..93370ba5d --- /dev/null +++ b/app/views/custom_fields/formats/_timestamp.html.erb @@ -0,0 +1,4 @@ +

<%= f.datetime_local_field(:default_value, :value => Redmine::FieldFormat::TimestampFormat.time_local(@custom_field.default_value), :size => 12) %> +(<%= Redmine::FieldFormat::TimestampFormat.timezone(@custom_field.default_value) %>)

+<%= datetimepicker_for('custom_field_default_value') %> +

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

diff --git a/config/locales/en.yml b/config/locales/en.yml index 2eb4af0e1..c82c11c22 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -686,6 +686,7 @@ en: label_min_max_length: Min - Max length label_list: List label_date: Date + label_timestamp: DateTime (timezone aware) label_integer: Integer label_float: Float label_boolean: Boolean diff --git a/lib/redmine/field_format.rb b/lib/redmine/field_format.rb index 9ca0a644d..50be9e5cb 100644 --- a/lib/redmine/field_format.rb +++ b/lib/redmine/field_format.rb @@ -583,6 +583,61 @@ module Redmine end end + class TimestampFormat < Unbounded + add 'timestamp' + self.is_filter_supported = false + self.searchable_supported = false + self.form_partial = 'custom_fields/formats/timestamp' + + def set_custom_field_value(custom_field, custom_field_value, value) + # modify already stored default_value at custom_fields_controller.rb#update (Line 65) + if !custom_field_value.customized.present? and custom_field.default_value === value + custom_field.default_value = custom_field.default_value.in_time_zone(User.current.time_zone).utc.iso8601 rescue custom_field.default_value + end + # value is datetime_local in user's time_zone but no timezone + # returns iso8601 formatted string trailing Z + value.in_time_zone(User.current.time_zone).utc.iso8601 rescue value + end + + def validate_single_value(custom_field, value, customized=nil) + if /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.match?(value) + [] + else + [::I18n.t('activerecord.errors.messages.not_a_date')] + end + end + + def cast_single_value(custom_field, value, customized=nil) + # value is a iso8601 formatted string trailing Z + # returns timewithzone in user's timezone + value.in_time_zone(User.current.time_zone) rescue nil + end + + def self.time_local(value) + # value is a iso8601 formatted string trailing Z + # .to_time transforms value to Time in utc with tz=utc + # .in_time_zone transforms Time to TimeWithZone in user's tz or Time if tz=nil + # .iso8601 transforms to string like yyyy-MM-ddThh:mm:ss+tz + # .slice trims the timezone, as datetime_local + value.to_time.in_time_zone(User.current.time_zone).iso8601.slice(0,16) rescue "" + end + + def self.timezone(value) + (value&.to_time || Time.now()).in_time_zone(User.current.time_zone).zone + end + + def edit_tag(view, tag_id, tag_name, custom_value, options={}) + view.datetime_local_field_tag(tag_name, TimestampFormat.time_local(custom_value.value), options.merge(:id => tag_id, :size => 12)) + + view.datetimepicker_for(tag_id) + " (#{TimestampFormat.timezone(custom_value.value)})" + end + + def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options={}) + view.datetime_local_field_tag(tag_name, TimestampFormat.time_local(value), options.merge(:id => tag_id, :size => 12)) + + view.datetimepicker_for(tag_id) + " (#{TimestampFormat.timezone(value)})" + + bulk_clear_tag(view, tag_id, tag_name, custom_field, value) + end + end + class List < Base self.multiple_supported = true field_attributes :edit_tag_style diff --git a/test/fixtures/custom_fields.yml b/test/fixtures/custom_fields.yml index 98dccdfcb..76937e6ef 100644 --- a/test/fixtures/custom_fields.yml +++ b/test/fixtures/custom_fields.yml @@ -146,3 +146,12 @@ custom_fields_011: - Other value field_format: list position: 1 +custom_fields_012: + id: 12 + name: Epoch + type: IssueCustomField + is_for_all: true + possible_values: "" + field_format: timestamp + position: 1 + diff --git a/test/fixtures/custom_fields_trackers.yml b/test/fixtures/custom_fields_trackers.yml index fc01b117e..da773f220 100644 --- a/test/fixtures/custom_fields_trackers.yml +++ b/test/fixtures/custom_fields_trackers.yml @@ -29,3 +29,6 @@ custom_fields_trackers_009: custom_fields_trackers_010: custom_field_id: 9 tracker_id: 1 +custom_fields_trackers_011: + custom_field_id: 12 + tracker_id: 1 diff --git a/test/fixtures/custom_values.yml b/test/fixtures/custom_values.yml index d0dfe3a55..288883475 100644 --- a/test/fixtures/custom_values.yml +++ b/test/fixtures/custom_values.yml @@ -101,3 +101,9 @@ custom_values_017: customized_id: 1 id: 17 value: '2009-12-01' +custom_values_018: + customized_type: Issue + custom_field_id: 12 + customized_id: 3 + id: 18 + value: '2011-03-11T05:46:00Z' diff --git a/test/integration/custom_field_timestamp_test.rb b/test/integration/custom_field_timestamp_test.rb new file mode 100644 index 000000000..caba9256e --- /dev/null +++ b/test/integration/custom_field_timestamp_test.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006-2021 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 CustomFieldsTimestampTest < Redmine::IntegrationTest + fixtures :projects, + :users, :email_addresses, + :user_preferences, + :roles, + :members, + :member_roles, + :trackers, + :projects_trackers, + :enabled_modules, + :issue_statuses, + :issues, + :enumerations, + :custom_fields, + :custom_values, + :custom_fields_trackers, + :attachments + + def setup + @field = IssueCustomField.find(12) + @issue = Issue.find(3) + log_user('jsmith', 'jsmith') + @user = User.find(session[:user_id]) + end + + def test_get_issue_with_timestamp_custom_field + assert_nil ENV['TZ'] + assert_equal 'UTC', RedmineApp::Application.config.time_zone + assert_equal :local, RedmineApp::Application.config.active_record.default_timezone + assert_equal 'en', Setting.default_language + + # get issues/14 in tz=nil + assert_nil @user.preference.time_zone + get "/issues/#{@issue.id}" + assert_response :success + assert_select ".cf_#{@field.id} .value", :text => '03/11/2011 05:46 AM' + assert_select 'input[name=?][value=?]', "issue[custom_field_values][#{@field.id}]", '2011-03-11T05:46' + + # get issues/14 in tz='UTC' + @user.preference.time_zone = 'UTC' + @user.preference.save + get "/issues/#{@issue.id}" + assert_response :success + assert_select ".cf_#{@field.id} .value", :text => '03/11/2011 05:46 AM' + assert_select 'input[name=?][value=?]', "issue[custom_field_values][#{@field.id}]", '2011-03-11T05:46' + + # get issues/14 in lang="ja", tz='Tokyo' (+0900) + @user.preference.time_zone = 'Tokyo' + @user.preference.save + @user.language = "ja" + @user.save + get "/issues/#{@issue.id}" + assert_response :success + assert_select ".cf_#{@field.id} .value", :text => '2011/03/11 14:46' + assert_select 'input[name=?][value=?]', "issue[custom_field_values][#{@field.id}]", '2011-03-11T14:46' + end + + def test_bulk_edit + get( + "/issues/bulk_edit", + :params => { + :ids => [1, @issue.id], + } + ) + assert_response :success + end + + def test_put_issue_with_timestamp_custom_field + # update issues/14 in lang="ja", tz='Tokyo' (+0900) + @user.preference.time_zone = 'Tokyo' + @user.preference.save + @user.language = "ja" + @user.save + put "/issues/#{@issue.id}", + :params => { + :issue => { + :custom_field_values => {@field.id.to_s => "1985-08-12T18:56"} # in tz=Asia/Tokyo +0900 + } + } + + assert_response :found + assert_equal "1985-08-12T09:56:00Z", CustomValue.find_by(:customized_id=>@issue.id, :custom_field_id => @field.id).value + + # update issues/14 in tz=nil + @user.preference.time_zone = nil + @user.preference.save + put "/issues/#{@issue.id}", + :params => { + :issue => { + :custom_field_values => {@field.id.to_s => "1912-04-14T23:40"} # in UTC + } + } + assert_equal "1912-04-14T23:40:00Z", CustomValue.find_by(:customized_id=>@issue.id, :custom_field_id => @field.id).value + end + + def test_put_issue_with_timestamp_custom_field_mail + + ActionMailer::Base.deliveries.clear + Setting.plain_text_mail = '0' + + @user.preference.time_zone = 'Tokyo' + @user.preference.save + @user.language = "ja" + @user.save + + watcher = User.find_by(:login => 'dlopper') + watcher.preference ||= UserPreference.new(:user_id => watcher.id) + watcher.preference.time_zone = 'Newfoundland' + watcher.preference.save + watcher.language = "fr" + watcher.save + + put "/issues/#{@issue.id}", + :params => { + :issue => { + :custom_field_values => {@field.id.to_s => "2005-04-25T09:18"} # in tz=Asia/Tokyo +0900 + } + } + + ActionMailer::Base.deliveries.each do |mail| + recipient = mail.header["To"].value.first + case + when recipient.starts_with?("jsmith@") + assert_mail_body_match 'Epoch を 2011/03/11 14:46 から 2005/04/25 09:18 に変更', mail + when recipient.starts_with?("dlopper@") + assert_mail_body_match 'Epoch changé de 11/03/2011 02:16 à 24/04/2005 21:48', mail + else + flunk + end + end + end + + test "timestamp may always utc.iso8601 via api" do + @user.preference.time_zone = 'Tokyo' + @user.preference.save + @user.language = "ja" + @user.save + with_settings :rest_api_enabled => '1' do + get '/issues/3.xml', :headers => credentials(@user.login) + assert_response :success + assert_equal 'application/xml', response.media_type + assert_select "custom_field[id=12] value", '2011-03-11T05:46:00Z', 'timestamp may always utc.iso8601 via api' + end + end + + test 'the value of custom_field_default_value should be presented in timezone of the user' do + user = User.find_by_login('admin') + post("/login",:params => {:username => user.login,:password => user.login}) + + assert_nil @field.default_value + get "/custom_fields/#{@field.id}/edit" + assert_response :success + assert_select '#custom_field_default_value', :value => '' + + @field.default_value = '1986-01-28T16:39:00Z' + @field.save + get "/custom_fields/#{@field.id}/edit" + assert_response :success + assert_select '#custom_field_default_value[value=?]', '1986-01-28T16:39' + assert_select 'p:has(#custom_field_default_value)', /(UTC)/ + + user.preference.time_zone = "Eastern Time (US & Canada)" + user.preference.save + get "/custom_fields/#{@field.id}/edit" + assert_response :success + assert_select '#custom_field_default_value[value=?]', '1986-01-28T11:39' + assert_select 'p:has(#custom_field_default_value)', /(EST)/ + + user.preference.time_zone = "Central Time (US & Canada)" + user.preference.save + put "/custom_fields/#{@field.id}", + :params => { + :custom_field => {:default_value => "2003-02-01T08:59"} # in tz=CST -0600 + } + assert_response :found + assert_equal "2003-02-01T14:59:00Z", CustomField.find(@field.id).default_value + end + +end diff --git a/test/unit/custom_field_test.rb b/test/unit/custom_field_test.rb index 1c058ec07..d2de7fa7e 100644 --- a/test/unit/custom_field_test.rb +++ b/test/unit/custom_field_test.rb @@ -195,6 +195,20 @@ class CustomFieldTest < ActiveSupport::TestCase assert !f.valid_field_value?('abc') end + def test_timestamp_field_validation + f = CustomField.new(:field_format => 'timestamp') + + assert f.valid_field_value?(nil) + assert f.valid_field_value?('') + assert !f.valid_field_value?(' ') + assert f.valid_field_value?('1975-07-14T00:00') + assert !f.valid_field_value?('1975-07-33T00:00') + assert !f.valid_field_value?('1975-07-14T25:00') + assert f.valid_field_value?('1975-07-14T00:59') + assert !f.valid_field_value?('1975-07-14T00:60') + assert !f.valid_field_value?('abc') + end + def test_list_field_validation f = CustomField.new(:field_format => 'list', :possible_values => ['value1', 'value2']) -- 2.33.1