Skip to content

Commit 0642992

Browse files
authored
Merge pull request #14 from gtt-project/feature/datetime-custom-field
Add a Date and time custom field format
2 parents c834e87 + 08b3ec7 commit 0642992

6 files changed

Lines changed: 243 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<p>
2+
<%= f.text_field :default_value, size: 20,
3+
type: 'datetime-local',
4+
step: RedmineIssueDatetime.time_step_seconds %>
5+
</p>
6+
<p>
7+
<em class="info">
8+
<%= l(:text_datetime_cf_zone_hint, zone: RedmineIssueDatetime.zone_name) %>
9+
</em>
10+
</p>
11+
<p><%= f.text_field :url_pattern, size: 50, label: :label_link_values_to %></p>

config/locales/en.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ en:
22
field_start_time: Start time
33
field_due_time: Due time
44
field_all_day: All day
5+
label_datetime: Date and time
6+
text_datetime_cf_zone_hint: "Values are entered and shown in %{zone}, the reference time zone configured for this plugin."
57
label_issue_datetime_trackers: Enabled for trackers
68
label_issue_datetime_step: Time input step
79
label_issue_datetime_zone: Reference time zone

config/locales/ja.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ ja:
22
field_start_time: 開始時刻
33
field_due_time: 期日時刻
44
field_all_day: 終日
5+
label_datetime: 日付と時刻
6+
text_datetime_cf_zone_hint: "値はこのプラグインで設定した基準タイムゾーン %{zone} で入力・表示されます。"
57
label_issue_datetime_trackers: 有効にするトラッカー
68
label_issue_datetime_step: 時刻入力の間隔
79
label_issue_datetime_zone: 基準タイムゾーン

lib/redmine_issue_datetime.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require_relative 'redmine_issue_datetime/drift_check'
2+
require_relative 'redmine_issue_datetime/datetime_format'
23
require_relative 'redmine_issue_datetime/issue_extension'
34
require_relative 'redmine_issue_datetime/issue_query_extension'
45
require_relative 'redmine_issue_datetime/hooks'
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
module RedmineIssueDatetime
2+
# A "Date and time" custom field format, offered alongside Redmine's built-in
3+
# "Date" rather than replacing it: a field either wants a time or it does not,
4+
# and that is per field.
5+
#
6+
# No sidecar table is involved. The whole mirror machinery elsewhere in this
7+
# plugin exists because issues.start_date is a real `date` column that cannot
8+
# be changed; a custom value is a string column, so a timestamp fits natively.
9+
#
10+
# Stored as naive local time in the instance reference zone ("2026-08-03T09:15",
11+
# no offset). Three reasons: it matches how the built-in date format stores,
12+
# it keeps ISO strings sortable as plain strings so ordering needs no special
13+
# casing, and it is consistent with this plugin showing one clock for everyone
14+
# rather than converting per viewer.
15+
# Named DatetimeFormat in datetime_format.rb on purpose: Redmine adds every
16+
# plugin's lib/ as an eager-load path, so Zeitwerk derives the constant from
17+
# the filename. A mismatch here passes every test (test and development load
18+
# lazily) and then kills a production boot with a NameError.
19+
class DatetimeFormat < Redmine::FieldFormat::Unbounded
20+
add 'datetime'
21+
self.form_partial = 'custom_fields/formats/datetime'
22+
self.searchable_supported = false
23+
24+
# Naive local: date and time, deliberately no zone offset.
25+
PATTERN = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}\z/
26+
STORAGE_FORMAT = '%Y-%m-%dT%H:%M'.freeze
27+
28+
def label
29+
'label_datetime'
30+
end
31+
32+
def cast_single_value(custom_field, value, _customized = nil)
33+
return nil if value.blank?
34+
35+
# Interpreted in the reference zone, which is the clock the value was
36+
# entered on.
37+
RedmineIssueDatetime.reference_zone.strptime(value.to_s, STORAGE_FORMAT)
38+
rescue ArgumentError
39+
nil
40+
end
41+
42+
def validate_single_value(custom_field, value, customized = nil)
43+
errors = super
44+
return errors if value.blank?
45+
46+
unless PATTERN.match?(value.to_s) && cast_single_value(custom_field, value)
47+
errors << ::I18n.t('activerecord.errors.messages.invalid')
48+
end
49+
errors
50+
end
51+
52+
# Formatted on the reference zone, deliberately not via the view's
53+
# format_time, which converts to the viewer's own zone: this plugin shows one
54+
# clock for everyone. Admin date/time settings are honoured when set, and the
55+
# fallbacks are explicit rather than a locale default, because those can omit
56+
# the year (:short renders "03 Aug 09:15").
57+
def formatted_value(_view, custom_field, value, customized = nil, _html = false)
58+
time = cast_single_value(custom_field, value, customized)
59+
return '' if time.nil?
60+
61+
date_part = Setting.date_format.presence || '%Y-%m-%d'
62+
time_part = Setting.time_format.presence || '%H:%M'
63+
time.strftime("#{date_part} #{time_part}")
64+
end
65+
66+
def edit_tag(view, tag_id, tag_name, custom_value, options = {})
67+
datetime_field(view, tag_name, custom_value.value,
68+
options.merge(id: tag_id))
69+
end
70+
71+
def bulk_edit_tag(view, tag_id, tag_name, custom_field, objects, value, options = {})
72+
datetime_field(view, tag_name, value, options.merge(id: tag_id)) +
73+
bulk_clear_tag(view, tag_id, tag_name, custom_field, value)
74+
end
75+
76+
# Filtering and grouping come from the framework once the type is declared.
77+
def query_filter_options(_custom_field, _query)
78+
{type: :datetime}
79+
end
80+
81+
def group_statement(custom_field)
82+
order_statement(custom_field)
83+
end
84+
85+
private
86+
87+
# The step makes the browser's picker move in the configured interval, the
88+
# same grid as the start/due time fields. It is deliberately not enforced
89+
# server-side: unlike the scheduling times, an arbitrary datetime field may
90+
# legitimately record something off the grid, such as when an incident was
91+
# reported.
92+
def datetime_field(view, name, value, options = {})
93+
value = value.strftime(STORAGE_FORMAT) if value.respond_to?(:strftime)
94+
view.text_field_tag(name, value, options.merge(
95+
type: 'datetime-local',
96+
step: RedmineIssueDatetime.time_step_seconds
97+
))
98+
end
99+
end
100+
end

test/unit/datetime_format_test.rb

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
require File.expand_path('../test_helper', __dir__)
2+
3+
class DatetimeFormatTest < ActiveSupport::TestCase
4+
include IssueDatetimeTestHelper
5+
6+
fixtures :projects, :users, :email_addresses, :trackers, :projects_trackers,
7+
:issue_statuses, :issues, :enumerations, :enabled_modules,
8+
:members, :member_roles, :roles, :custom_fields, :custom_values
9+
10+
def setup
11+
enable_issue_datetime(Issue.find(1).tracker_id, zone: 'Tokyo')
12+
@field = IssueCustomField.create!(name: 'Inspection at', field_format: 'datetime',
13+
is_for_all: true, tracker_ids: Tracker.pluck(:id))
14+
@issue = Issue.find(1)
15+
end
16+
17+
test 'the format is registered and offered alongside date' do
18+
names = Redmine::FieldFormat.available_formats
19+
20+
assert_includes names, 'datetime'
21+
assert_includes names, 'date', 'the built-in date format must remain available'
22+
end
23+
24+
test 'the format is labelled for the picker' do
25+
assert_equal 'label_datetime', Redmine::FieldFormat.find('datetime').label
26+
end
27+
28+
test 'a naive local value round-trips through the custom field' do
29+
@issue.custom_field_values = {@field.id.to_s => '2026-08-03T09:15'}
30+
31+
assert @issue.save
32+
assert_equal '2026-08-03T09:15', @issue.reload.custom_field_value(@field)
33+
end
34+
35+
# Stored naive, interpreted in the reference zone: the cast must land on the
36+
# same wall clock the user typed, not shift it.
37+
test 'casting interprets the stored value in the reference zone' do
38+
cast = @field.format.cast_single_value(@field, '2026-08-03T09:15')
39+
40+
assert_equal 'Asia/Tokyo', cast.time_zone.tzinfo.name
41+
assert_equal '09:15', cast.strftime('%H:%M')
42+
assert_equal Time.utc(2026, 8, 3, 0, 15), cast.utc
43+
end
44+
45+
test 'a value without a time is rejected' do
46+
@issue.custom_field_values = {@field.id.to_s => '2026-08-03'}
47+
48+
assert_not @issue.save
49+
end
50+
51+
test 'a value with a zone offset is rejected, since storage is naive' do
52+
@issue.custom_field_values = {@field.id.to_s => '2026-08-03T09:15+09:00'}
53+
54+
assert_not @issue.save
55+
end
56+
57+
test 'an impossible time is rejected' do
58+
@issue.custom_field_values = {@field.id.to_s => '2026-08-03T25:99'}
59+
60+
assert_not @issue.save
61+
end
62+
63+
test 'a blank value is allowed when the field is not required' do
64+
@issue.custom_field_values = {@field.id.to_s => ''}
65+
66+
assert @issue.save
67+
end
68+
69+
# The year must always be present: a locale default such as :short renders
70+
# "03 Aug 09:15", which is ambiguous for anything not in the current year.
71+
test 'formatting shows the full date and the time' do
72+
formatted = @field.format.formatted_value(nil, @field, '2026-08-03T09:15')
73+
74+
assert_includes formatted, '09:15'
75+
assert_includes formatted, '2026'
76+
end
77+
78+
test 'formatting honours the admin date and time settings' do
79+
with_settings date_format: '%d/%m/%Y', time_format: '%H:%M' do
80+
assert_equal '03/08/2026 09:15',
81+
@field.format.formatted_value(nil, @field, '2026-08-03T09:15')
82+
end
83+
end
84+
85+
# Values are shown on one clock for everyone, so formatting must not follow the
86+
# viewer's own time zone the way Redmine's format_time helper would.
87+
test 'formatting does not follow the viewer time zone' do
88+
# Captured before anything that can raise, so the ensure block always
89+
# restores rather than assuming what the surrounding state was.
90+
previous_user = User.current
91+
user = User.find(2)
92+
original_zone = user.pref.time_zone
93+
User.current = user
94+
user.pref.update(time_zone: 'UTC')
95+
96+
assert_includes @field.format.formatted_value(nil, @field, '2026-08-03T09:15'), '09:15'
97+
ensure
98+
user&.pref&.update(time_zone: original_zone)
99+
User.current = previous_user
100+
end
101+
102+
test 'formatting a blank value yields an empty string, not an error' do
103+
assert_equal '', @field.format.formatted_value(nil, @field, '')
104+
end
105+
106+
# ISO 8601 sorts correctly as a plain string, which is why storage uses it:
107+
# ordering needs no special casing.
108+
test 'stored values sort chronologically as strings' do
109+
values = ['2026-08-03T09:15', '2026-08-03T08:00', '2026-01-15T23:59', '2026-08-03T10:00']
110+
111+
assert_equal ['2026-01-15T23:59', '2026-08-03T08:00', '2026-08-03T09:15', '2026-08-03T10:00'],
112+
values.sort
113+
end
114+
115+
test 'the filter is declared as a datetime so the framework can filter on it' do
116+
assert_equal({type: :datetime}, @field.format.query_filter_options(@field, nil))
117+
end
118+
119+
# Unlike the start/due times, an arbitrary datetime field is not forced onto
120+
# the interval grid: it may legitimately record an off-grid moment.
121+
test 'an off-grid minute is accepted' do
122+
@issue.custom_field_values = {@field.id.to_s => '2026-08-03T09:07'}
123+
124+
assert @issue.save
125+
assert_equal '2026-08-03T09:07', @issue.reload.custom_field_value(@field)
126+
end
127+
end

0 commit comments

Comments
 (0)