From 6c540c752adce166df916529e7e3bbbe236d1044 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 10:28:53 +0200 Subject: [PATCH 1/6] Run the suite against ActiveAdmin 4 as well AA=4.0.0.beta23 swaps Sprockets for Propshaft and importmap-rails, and the test app is built per ActiveAdmin major. The xdsoft feature specs are tagged aa: 3 since ActiveAdmin 4 pages load no jQuery. Co-Authored-By: Clanker --- .github/workflows/ci.yml | 2 +- Gemfile | 13 ++++++++++--- spec/edit_form_spec.rb | 2 +- spec/filter_form_spec.rb | 2 +- spec/spec_helper.rb | 6 +++++- spec/support/rails_template.rb | 27 +++++++++++++++++---------- tasks/test.rake | 3 ++- 7 files changed, 37 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c51a8ef..4952cda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: matrix: ruby: ['3.3', '3.4', '4.0'] rails: ['8.0.0', '8.1.0'] - activeadmin: ['3.2.0', '3.3.0', '3.4.0', '3.5.1'] + activeadmin: ['3.2.0', '3.3.0', '3.4.0', '3.5.1', '4.0.0.beta23'] env: RAILS: ${{ matrix.rails }} AA: ${{ matrix.activeadmin }} diff --git a/Gemfile b/Gemfile index c1b79dc..ef01d33 100644 --- a/Gemfile +++ b/Gemfile @@ -5,9 +5,16 @@ default_rails_version = '8.0.0' default_activeadmin_version = '3.2.0' gem 'rails', "~> #{ENV['RAILS'] || default_rails_version}" -gem 'activeadmin', "~> #{ENV['AA'] || default_activeadmin_version}" -gem 'sprockets-rails' -gem 'sass-rails' +activeadmin_version = ENV['AA'] || default_activeadmin_version + +gem 'activeadmin', "~> #{activeadmin_version}" +if Gem::Version.new(activeadmin_version) >= Gem::Version.new('4.0.0.beta1') + gem 'propshaft' + gem 'importmap-rails' +else + gem 'sprockets-rails' + gem 'sass-rails' +end group :test do gem 'simplecov', require: false diff --git a/spec/edit_form_spec.rb b/spec/edit_form_spec.rb index 7a30229..d71bd8b 100644 --- a/spec/edit_form_spec.rb +++ b/spec/edit_form_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe 'authors index', type: :feature, js: true do +describe 'authors index', type: :feature, js: true, aa: 3 do before do add_author_resource end diff --git a/spec/filter_form_spec.rb b/spec/filter_form_spec.rb index 781439f..048df15 100644 --- a/spec/filter_form_spec.rb +++ b/spec/filter_form_spec.rb @@ -10,7 +10,7 @@ def pick_date(input_selector, date_num) sleep 0.2 end -describe 'authors index', type: :feature, js: true do +describe 'authors index', type: :feature, js: true, aa: 3 do before do add_author_resource end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9817182..2f122fb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -14,7 +14,9 @@ # Ensure the Active Admin load path is happy require 'rails' ENV['RAILS'] = Rails.version -ENV['RAILS_ROOT'] = File.expand_path("../rails/rails-#{ENV['RAILS']}", __FILE__) +# One test app per Rails and ActiveAdmin major: v3 and v4 apps differ in their asset setup. +activeadmin_major = Gem.loaded_specs['activeadmin'].version.segments.first +ENV['RAILS_ROOT'] = File.expand_path("../rails/rails-#{ENV['RAILS']}-aa#{activeadmin_major}", __FILE__) # Create the test app if it doesn't exists unless File.exist?(ENV['RAILS_ROOT']) system 'rake setup' @@ -40,6 +42,8 @@ RSpec.configure do |config| config.use_transactional_fixtures = false + # Specs tagged `aa: 3` exercise the xdsoft picker, `aa: 4` the native inputs. + config.filter_run_excluding aa: (ActiveAdminDatetimepicker.aa_v4? ? 3 : 4) config.before(:suite) do ActiveRecord::Migration.maintain_test_schema! diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 0b9fb2b..e71ea05 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -1,9 +1,13 @@ # Rails template to build the sample app for specs +aa_v4 = Gem.loaded_specs['activeadmin'].version >= Gem::Version.new('4.0.0.beta1') + # Ensure Sprockets manifest exists (required by Rails 8+) -FileUtils.mkdir_p("app/assets/config") -File.write("app/assets/config/manifest.js", - "//= link_directory ../javascripts .js\n//= link_directory ../stylesheets .css\n") +unless aa_v4 + FileUtils.mkdir_p("app/assets/config") + File.write("app/assets/config/manifest.js", + "//= link_directory ../javascripts .js\n//= link_directory ../stylesheets .css\n") +end generate :model, 'author name:string{10}:uniq last_name:string birthday:date --force' generate :model, 'post title:string:uniq body:text author:references --force' @@ -62,16 +66,19 @@ def self.ransackable_attributes(auth_object=nil) generate :'active_admin:install --skip-users' generate :'formtastic:install' -# Install active_admin_datetimepicker assets -inject_into_file "app/assets/stylesheets/active_admin.scss" do - "@import \"active_admin_datetimepicker\";\n" -end +# Install active_admin_datetimepicker assets (ActiveAdmin 4 needs none: native inputs) +unless aa_v4 + inject_into_file "app/assets/stylesheets/active_admin.scss" do + "@import \"active_admin_datetimepicker\";\n" + end -inject_into_file "app/assets/javascripts/active_admin.js" do - "//= require active_admin_datetimepicker\n" + inject_into_file "app/assets/javascripts/active_admin.js" do + "//= require active_admin_datetimepicker\n" + end end -run "rm -rf test" +# rspec-rails generates model specs that the suite would otherwise pick up +run "rm -rf test spec" route "root :to => 'admin/dashboard#index'" rake "db:migrate" diff --git a/tasks/test.rake b/tasks/test.rake index 3eacb6a..58bd241 100644 --- a/tasks/test.rake +++ b/tasks/test.rake @@ -10,5 +10,6 @@ task :setup do -m spec/support/rails_template.rb ) - system "bundle exec rails new spec/rails/rails-#{Rails::VERSION::STRING} #{rails_new_opts.join(' ')}" + app_path = ENV['RAILS_ROOT'] || "spec/rails/rails-#{Rails::VERSION::STRING}" + system "bundle exec rails new #{app_path} #{rails_new_opts.join(' ')}" end From 1a38709e1207c0b6388a467f9091c6df600e2790 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 10:28:53 +0200 Subject: [PATCH 2/6] Render native datetime-local inputs on ActiveAdmin 4 ActiveAdmin 4 has no jQuery, and its date range filter renders date-only fields. The form input and the range filter now emit datetime-local fields with ISO values; a Ransack formatter turns the ISO "T" into a space for ransackers without a type, which compare as strings. Co-Authored-By: Clanker --- README.md | 32 +++++ .../active_admin_datetimepicker.rb | 13 +- lib/active_admin_datetimepicker.rb | 9 ++ lib/active_admin_datetimepicker/base.rb | 64 ++++++++- .../inputs/filters/date_time_range_input.rb | 31 +++- spec/data_hooks_spec.rb | 20 +++ spec/native_inputs_spec.rb | 133 ++++++++++++++++++ 7 files changed, 294 insertions(+), 8 deletions(-) create mode 100644 spec/data_hooks_spec.rb create mode 100644 spec/native_inputs_spec.rb diff --git a/README.md b/README.md index 9cfad58..5074d23 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Adds XDSoft's DateTime picker as a `date_time_picker` input for forms, and `date_time_range` for filters. +On ActiveAdmin 4 the same inputs render the browser's native date and time controls instead; see [ActiveAdmin 4](#activeadmin-4). + ![ActiveAdminDatetimepicker](https://raw.githubusercontent.com/activeadmin-plugins/active_admin_datetimepicker/master/screen/screen.png "ActiveAdminDatetimepicker") ## Installation @@ -89,6 +91,36 @@ The `date_time_picker` input accepts many of the options available to the standa end ``` +## ActiveAdmin 4 + +ActiveAdmin 4 ships without jQuery and Sprockets, so on ActiveAdmin 4 the gem +does not load the XDSoft picker. The `date_time_picker` input and the +`date_time_range` filter render native `` fields +(`type="date"` for a `date` column), styled by ActiveAdmin itself. There is no +JavaScript or CSS to install: skip the Sprockets and NPM steps above. + +Values use the HTML format, `YYYY-MM-DDTHH:MM` (`YYYY-MM-DD` for date fields). +`ActiveAdminDatetimepicker::Base.format` applies to ActiveAdmin 3 only. + +Only these `datepicker_options` (and `default_datetime_picker_options`) have a +native equivalent and are applied on ActiveAdmin 4. Everything else is ignored. + +| Option | Native attribute | +| --- | --- | +| `min_date`, `min_date_time` | `min` | +| `max_date`, `max_date_time` | `max` | +| `step` (minutes) | `step` (seconds, `datetime-local` only) | + +`min`/`max` must be absolute dates (a `Date`, `Time` or a string like +`"2024-01-31"`); relative values such as `"+3D"` are dropped. An explicit +`input_html: { min: ... }` wins over the mapped value. + +The markup carries `data-*` hooks for tests and other plugins: + +- every input: `data-datetimepicker-input` +- the filter wrapper: `data-datetimepicker-range` (on ActiveAdmin 4 it also keeps the `filter_date_time_range` class) +- the filter inputs: `data-datetimepicker-range-input="from"` and `"to"` + ## Override behaviour in initializer ```ruby diff --git a/config/initializers/active_admin_datetimepicker.rb b/config/initializers/active_admin_datetimepicker.rb index 65f6806..54e4c96 100644 --- a/config/initializers/active_admin_datetimepicker.rb +++ b/config/initializers/active_admin_datetimepicker.rb @@ -1,7 +1,16 @@ +# ActiveAdmin 4 submits native datetime-local values ("2024-01-31T10:00"). +# Typed columns cast them to times; for an untyped ransacker the value stays a +# string, so the "T" is swapped for the space the database compares against. +iso_to_db = lambda do |value| + value.is_a?(String) ? value.sub(/\A(\d{4}-\d{2}-\d{2})T(?=\d{2}:\d{2})/, '\1 ') : value +end + Ransack.configure do |config| config.add_predicate 'gteq_datetime_picker', - arel_predicate: 'gteq' + arel_predicate: 'gteq', + formatter: iso_to_db config.add_predicate 'lteq_datetime_picker', - arel_predicate: 'lt' + arel_predicate: 'lt', + formatter: iso_to_db end diff --git a/lib/active_admin_datetimepicker.rb b/lib/active_admin_datetimepicker.rb index 6d97cd5..80ee8f2 100644 --- a/lib/active_admin_datetimepicker.rb +++ b/lib/active_admin_datetimepicker.rb @@ -1,4 +1,13 @@ require 'activeadmin' + +module ActiveAdminDatetimepicker + # ActiveAdmin 4 drops jQuery and Sprockets, so the plugin renders native + # date/time inputs there instead of the xdsoft picker. + def self.aa_v4? + Gem::Version.new(ActiveAdmin::VERSION) >= Gem::Version.new('4.0.0.beta1') + end +end + require 'active_admin_datetimepicker/version' require 'active_admin_datetimepicker/base' require 'active_admin_datetimepicker/inputs/date_time_picker_input' diff --git a/lib/active_admin_datetimepicker/base.rb b/lib/active_admin_datetimepicker/base.rb index 4b29b66..64da87e 100644 --- a/lib/active_admin_datetimepicker/base.rb +++ b/lib/active_admin_datetimepicker/base.rb @@ -5,6 +5,12 @@ module Base mattr_accessor :format @@format = '%Y-%m-%d %H:%M' + # Value formats the browser accepts for native inputs (HTML spec), used on ActiveAdmin 4. + NATIVE_FORMATS = { + 'datetime-local' => '%Y-%m-%dT%H:%M', + 'date' => '%Y-%m-%d' + }.freeze + def html_class 'date-time-picker' end @@ -17,9 +23,14 @@ def input_html_options(input_name = nil, placeholder = nil) super().tap do |options| options[:class] = [self.options[:class], html_class].compact.join(' ') options[:data] ||= input_html_data - options[:data].merge!(datepicker_options: datetime_picker_options) - options[:value] = input_value(input_name) - options[:maxlength] = 19 + if ActiveAdminDatetimepicker.aa_v4? + native_input_html_options(options, input_name) + else + options[:data].merge!(datepicker_options: datetime_picker_options) + options[:value] = input_value(input_name) + options[:maxlength] = 19 + end + options[:data].merge!(datetimepicker_input: true) options[:placeholder] = placeholder unless placeholder.nil? end end @@ -45,8 +56,54 @@ def datetime_picker_options end end + # A date column gets a date-only control; datetime columns and + # ransackers without a column get a date and time control. + def native_input_type + column && column.type == :date ? 'date' : 'datetime-local' + end + + def native_input_value(input_name = nil) + format_native(object.public_send(input_name || method)) + end + protected + def native_input_html_options(options, input_name) + options[:type] = native_input_type + options[:value] = native_input_value(input_name) + options.delete(:maxlength) + options.delete(:size) + native_picker_attributes.each { |name, value| options[name] = value unless options.key?(name) } + end + + # Only the xdsoft options with a native equivalent are mapped; the rest + # configure the jQuery widget, which ActiveAdmin 4 pages do not load. + def native_picker_attributes + picker_options = datetime_picker_options.transform_keys { |k| k.to_s.camelcase(:lower) } + attributes = {} + min = format_native(picker_options['minDateTime']) || format_native(picker_options['minDate']) + max = format_native(picker_options['maxDateTime']) || format_native(picker_options['maxDate']) + attributes[:min] = min if min + attributes[:max] = max if max + step = picker_options['step'].to_i + # xdsoft counts the step in minutes, the HTML attribute in seconds. + attributes[:step] = step * 60 if step > 0 && native_input_type == 'datetime-local' + attributes + end + + def format_native(value) + value = value.call if value.respond_to?(:call) + return nil if value.blank? + unless value.respond_to?(:strftime) + # Relative xdsoft values such as "+3D" have no native equivalent. + return nil unless value.to_s.match?(%r{\A\d{4}[-/]\d{1,2}[-/]\d{1,2}}) + value = DateTime.parse(value.to_s) + end + value.strftime(NATIVE_FORMATS.fetch(native_input_type)) + rescue ArgumentError + nil + end + def _default_datetime_picker_options res = default_datetime_picker_options.map do |k, v| if v.respond_to?(:call) || v.is_a?(Proc) @@ -59,4 +116,3 @@ def _default_datetime_picker_options end end end - diff --git a/lib/active_admin_datetimepicker/inputs/filters/date_time_range_input.rb b/lib/active_admin_datetimepicker/inputs/filters/date_time_range_input.rb index 5f3e859..cce9af9 100644 --- a/lib/active_admin_datetimepicker/inputs/filters/date_time_range_input.rb +++ b/lib/active_admin_datetimepicker/inputs/filters/date_time_range_input.rb @@ -5,6 +5,29 @@ class DateTimeRangeInput < DateRangeInput include Base include ActiveAdminDatetimepicker::Base + # ActiveAdmin 4's DateRangeInput renders date_field pairs, which drop + # the time; this keeps its markup but emits native datetime fields. + def to_html + return super unless ActiveAdminDatetimepicker.aa_v4? + + input_wrapping do + [ label_html, + '
', + builder.text_field(gt_input_name, input_html_options_for(gt_input_name, gt_input_placeholder)), + builder.text_field(lt_input_name, input_html_options_for(lt_input_name, lt_input_placeholder)), + '
' + ].join("\n").html_safe + end + end + + def wrapper_html_options + super.tap do |options| + # ActiveAdmin 4 dropped the filter_ class that active_admin_date_range_preset looks for. + options[:class] = "#{options[:class]} filter_date_time_range" if ActiveAdminDatetimepicker.aa_v4? + options[:data] = (options[:data] || {}).merge(datetimepicker_range: true) + end + end + def input_html_options(input_name = gt_input_name, placeholder = gt_input_placeholder) super.tap do |options| options[:class] = html_class @@ -13,8 +36,12 @@ def input_html_options(input_name = gt_input_name, placeholder = gt_input_placeh # This method is for compatibility for ActiveAdmin 2.6 def input_html_options_for(input_name, placeholder) - super.merge placeholder: placeholder, - value: input_value(input_name) + options = super + value = ActiveAdminDatetimepicker.aa_v4? ? native_input_value(input_name) : input_value(input_name) + bound = input_name == gt_input_name ? 'from' : 'to' + options.merge placeholder: placeholder, + value: value, + data: (options[:data] || {}).merge(datetimepicker_range_input: bound) end def gt_input_name diff --git a/spec/data_hooks_spec.rb b/spec/data_hooks_spec.rb new file mode 100644 index 0000000..5e9740f --- /dev/null +++ b/spec/data_hooks_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe 'data-* hooks', type: :feature do + before { add_author_resource } + + it 'marks the filter range wrapper and its from/to inputs' do + visit '/admin/authors' + + within('#q_created_at_input[data-datetimepicker-range]') do + expect(page).to have_css('input#q_created_at_gteq_datetime_picker[data-datetimepicker-input][data-datetimepicker-range-input="from"]') + expect(page).to have_css('input#q_created_at_lteq_datetime_picker[data-datetimepicker-input][data-datetimepicker-range-input="to"]') + end + end + + it 'marks the form input' do + visit '/admin/authors/new' + + expect(page).to have_css('input#author_birthday[data-datetimepicker-input]') + end +end diff --git a/spec/native_inputs_spec.rb b/spec/native_inputs_spec.rb new file mode 100644 index 0000000..1dfce5a --- /dev/null +++ b/spec/native_inputs_spec.rb @@ -0,0 +1,133 @@ +require 'spec_helper' + +describe 'native inputs on ActiveAdmin 4', type: :feature, aa: 4 do + def register_author_with_datetime_form(created_at_options = {}) + ActiveAdmin.register Author do + permit_params :name, :last_name, :birthday, :created_at + + filter :birthday, as: :date_time_range + filter :created_at, as: :date_time_range + filter :last_seen_at, as: :date_time_range + + form do |f| + f.inputs do + f.input :name + f.input :last_name + f.input :birthday, as: :date_time_picker + f.input :created_at, { as: :date_time_picker }.merge(created_at_options) + end + f.actions + end + end + Rails.application.reload_routes! + end + + context 'form input' do + before { register_author_with_datetime_form } + + it 'renders datetime-local for a datetime column and date for a date column' do + visit '/admin/authors/new' + + expect(page).to have_css('input#author_created_at[type="datetime-local"][data-datetimepicker-input]') + expect(page).to have_css('input#author_birthday[type="date"][data-datetimepicker-input]') + expect(page).not_to have_css('[data-datepicker-options]') + end + + it 'saves the ISO value and shows it back' do + visit '/admin/authors/new' + fill_in 'author_name', with: 'Ren' + fill_in 'author_last_name', with: 'Iso' + fill_in 'author_birthday', with: '1990-05-17' + fill_in 'author_created_at', with: '2024-01-31T10:45' + find('input[type=submit]').click + + author = Author.find_by!(last_name: 'Iso') + expect(author.birthday).to eq Date.new(1990, 5, 17) + expect(author.created_at.strftime('%Y-%m-%d %H:%M')).to eq '2024-01-31 10:45' + + visit "/admin/authors/#{author.id}/edit" + expect(find('#author_created_at').value).to eq '2024-01-31T10:45' + expect(find('#author_birthday').value).to eq '1990-05-17' + end + end + + context 'form input with datepicker_options' do + before do + register_author_with_datetime_form( + datepicker_options: { min_date: '2024-01-01', max_date: Date.new(2024, 12, 31), step: 15, format: 'd/m/Y H:i' } + ) + end + + it 'maps min, max and step to native attributes' do + visit '/admin/authors/new' + + input = find('#author_created_at') + expect(input[:min]).to eq '2024-01-01T00:00' + expect(input[:max]).to eq '2024-12-31T00:00' + expect(input[:step]).to eq '900' + end + end + + context 'form input with relative xdsoft limits' do + before { register_author_with_datetime_form(datepicker_options: { min_date: '+3D' }) } + + it 'leaves min out' do + visit '/admin/authors/new' + expect(find('#author_created_at')[:min]).to be_nil + end + end + + context 'filters' do + before do + register_author_with_datetime_form + Author.create!(name: 'Ren', last_name: 'before', created_at: Time.zone.parse('2024-01-20 09:00'), updated_at: Time.zone.parse('2024-01-20 09:00')) + Author.create!(name: 'Rey', last_name: 'inside', created_at: Time.zone.parse('2024-01-20 11:00'), updated_at: Time.zone.parse('2024-01-20 11:00')) + Author.create!(name: 'Ron', last_name: 'after', created_at: Time.zone.parse('2024-01-20 13:00'), updated_at: Time.zone.parse('2024-01-20 13:00')) + visit '/admin/authors' + end + + it 'renders the pair inside the ActiveAdmin 4 filter markup' do + wrapper = find('#q_created_at_input') + expect(wrapper[:class].split).to include('filters-form-field', 'filter_date_time_range') + expect(wrapper['data-datetimepicker-range']).to eq 'true' + expect(wrapper).to have_css('.filters-form-input-group input[type="datetime-local"][name="q[created_at_gteq_datetime_picker]"][data-datetimepicker-range-input="from"]') + expect(wrapper).to have_css('.filters-form-input-group input[type="datetime-local"][name="q[created_at_lteq_datetime_picker]"][data-datetimepicker-range-input="to"]') + expect(page).to have_css('#q_birthday_input input[type="date"][name="q[birthday_gteq]"]') + end + + it 'filters a datetime column with ISO values and keeps them in the inputs' do + fill_in 'q_created_at_gteq_datetime_picker', with: '2024-01-20T10:00' + fill_in 'q_created_at_lteq_datetime_picker', with: '2024-01-20T12:00' + find('.filters-form-submit').click + + expect(page).to have_text('inside') + expect(page).not_to have_text('before') + expect(page).not_to have_text('after') + expect(find('#q_created_at_gteq_datetime_picker').value).to eq '2024-01-20T10:00' + expect(find('#q_created_at_lteq_datetime_picker').value).to eq '2024-01-20T12:00' + end + + it 'filters an untyped ransacker with ISO values' do + fill_in 'q_last_seen_at_gteq_datetime_picker', with: '2024-01-20T10:00' + fill_in 'q_last_seen_at_lteq_datetime_picker', with: '2024-01-20T12:00' + find('.filters-form-submit').click + + expect(page).to have_text('inside') + expect(page).not_to have_text('before') + expect(page).not_to have_text('after') + expect(find('#q_last_seen_at_gteq_datetime_picker').value).to eq '2024-01-20T10:00' + end + end + + context 'in a browser', js: true do + before do + register_author_with_datetime_form + visit '/admin/authors?q[created_at_gteq_datetime_picker]=2024-01-20T10:00' + end + + it 'accepts the rendered value as a valid datetime-local value' do + # Browsers blank a datetime-local whose value does not match the spec format. + expect(page.evaluate_script("document.getElementById('q_created_at_gteq_datetime_picker').value")).to eq '2024-01-20T10:00' + end + end +end From d7046a1636fd468a6959af1bf5445bb8ae3c4e0a Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 10:28:53 +0200 Subject: [PATCH 3/6] Allow ActiveAdmin 4 in the gemspec Co-Authored-By: Clanker --- active_admin_datetimepicker.gemspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/active_admin_datetimepicker.gemspec b/active_admin_datetimepicker.gemspec index 5889802..2ae9cea 100644 --- a/active_admin_datetimepicker.gemspec +++ b/active_admin_datetimepicker.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |spec| spec.email = ["fedoronchuk@gmail.com"] spec.summary = %q{datetimepicker extension for ActiveAdmin} - spec.description = %q{Integrate jQuery xdan datetimepicker plugin to ActiveAdmin} + spec.description = %q{Integrate jQuery xdan datetimepicker plugin to ActiveAdmin 3, native date and time inputs on ActiveAdmin 4} spec.homepage = "https://github.com/activeadmin-plugins/activeadmin_datetimepicker" spec.license = "MIT" @@ -25,5 +25,5 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "activeadmin", ">= 3.0", "< 4.0" + spec.add_dependency "activeadmin", ">= 3.0", "< 5" end From 2c778ca9cc90c5af91a0320dc8ea996bebe07b04 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 14:00:07 +0200 Subject: [PATCH 4/6] End a datetime-local max_date at 23:59 of that day XDSoft's maxDate allows the whole last day; midnight blocked every later time. Time options now render in the app's zone, and the README notes that the browser enforces min, max and step on submit. Co-Authored-By: Clanker --- README.md | 4 ++++ lib/active_admin_datetimepicker/base.rb | 10 +++++++++- spec/native_inputs_spec.rb | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5074d23..1d6432f 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ native equivalent and are applied on ActiveAdmin 4. Everything else is ignored. `"2024-01-31"`); relative values such as `"+3D"` are dropped. An explicit `input_html: { min: ... }` wins over the mapped value. +Unlike the XDSoft picker, the browser enforces `min`, `max` and `step` when the +form is submitted. A stored value outside the range, or off the step (10:07 with +`step: 15`), blocks the save until it is changed. + The markup carries `data-*` hooks for tests and other plugins: - every input: `data-datetimepicker-input` diff --git a/lib/active_admin_datetimepicker/base.rb b/lib/active_admin_datetimepicker/base.rb index 64da87e..edc72d8 100644 --- a/lib/active_admin_datetimepicker/base.rb +++ b/lib/active_admin_datetimepicker/base.rb @@ -82,7 +82,7 @@ def native_picker_attributes picker_options = datetime_picker_options.transform_keys { |k| k.to_s.camelcase(:lower) } attributes = {} min = format_native(picker_options['minDateTime']) || format_native(picker_options['minDate']) - max = format_native(picker_options['maxDateTime']) || format_native(picker_options['maxDate']) + max = format_native(picker_options['maxDateTime']) || native_max_date(picker_options['maxDate']) attributes[:min] = min if min attributes[:max] = max if max step = picker_options['step'].to_i @@ -91,6 +91,12 @@ def native_picker_attributes attributes end + # xdsoft's maxDate allows the whole last day, so a datetime-local max ends at 23:59. + def native_max_date(value) + max = format_native(value) + max && native_input_type == 'datetime-local' ? "#{max[0, 10]}T23:59" : max + end + def format_native(value) value = value.call if value.respond_to?(:call) return nil if value.blank? @@ -99,6 +105,8 @@ def format_native(value) return nil unless value.to_s.match?(%r{\A\d{4}[-/]\d{1,2}[-/]\d{1,2}}) value = DateTime.parse(value.to_s) end + # A Time option is shown in the app's zone, like the record values. + value = value.in_time_zone if value.is_a?(Time) value.strftime(NATIVE_FORMATS.fetch(native_input_type)) rescue ArgumentError nil diff --git a/spec/native_inputs_spec.rb b/spec/native_inputs_spec.rb index 1dfce5a..e2d89db 100644 --- a/spec/native_inputs_spec.rb +++ b/spec/native_inputs_spec.rb @@ -63,7 +63,7 @@ def register_author_with_datetime_form(created_at_options = {}) input = find('#author_created_at') expect(input[:min]).to eq '2024-01-01T00:00' - expect(input[:max]).to eq '2024-12-31T00:00' + expect(input[:max]).to eq '2024-12-31T23:59' expect(input[:step]).to eq '900' end end From cbb893193d867fa61d85bf91f698404f7dc2cf03 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 14:06:34 +0200 Subject: [PATCH 5/6] Exclude ActiveAdmin 5 prereleases Co-Authored-By: Clanker --- active_admin_datetimepicker.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/active_admin_datetimepicker.gemspec b/active_admin_datetimepicker.gemspec index 2ae9cea..427744b 100644 --- a/active_admin_datetimepicker.gemspec +++ b/active_admin_datetimepicker.gemspec @@ -25,5 +25,5 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ["lib"] - spec.add_dependency "activeadmin", ">= 3.0", "< 5" + spec.add_dependency "activeadmin", ">= 3.0", "< 5.0.0.a" end From 9cfb7b988711d61d0bccd3a8ca06b53cec1491b3 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 14:13:04 +0200 Subject: [PATCH 6/6] Keep non-ISO values and day-level minDate on native inputs Stored strings that are not ISO no longer render blank and get wiped on save, and a Time minDate starts at midnight like xdsoft's. Co-Authored-By: Clanker --- lib/active_admin_datetimepicker.rb | 4 +- lib/active_admin_datetimepicker/base.rb | 37 ++++++++++++------- .../inputs/filters/date_time_range_input.rb | 12 +++--- spec/native_inputs_spec.rb | 36 ++++++++++++++++++ tasks/test.rake | 6 ++- 5 files changed, 74 insertions(+), 21 deletions(-) diff --git a/lib/active_admin_datetimepicker.rb b/lib/active_admin_datetimepicker.rb index 80ee8f2..5da2048 100644 --- a/lib/active_admin_datetimepicker.rb +++ b/lib/active_admin_datetimepicker.rb @@ -4,7 +4,9 @@ module ActiveAdminDatetimepicker # ActiveAdmin 4 drops jQuery and Sprockets, so the plugin renders native # date/time inputs there instead of the xdsoft picker. def self.aa_v4? - Gem::Version.new(ActiveAdmin::VERSION) >= Gem::Version.new('4.0.0.beta1') + return @aa_v4 if defined?(@aa_v4) + + @aa_v4 = Gem::Version.new(ActiveAdmin::VERSION) >= Gem::Version.new('4.0.0.beta1') end end diff --git a/lib/active_admin_datetimepicker/base.rb b/lib/active_admin_datetimepicker/base.rb index edc72d8..b5c07dc 100644 --- a/lib/active_admin_datetimepicker/base.rb +++ b/lib/active_admin_datetimepicker/base.rb @@ -59,11 +59,20 @@ def datetime_picker_options # A date column gets a date-only control; datetime columns and # ransackers without a column get a date and time control. def native_input_type - column && column.type == :date ? 'date' : 'datetime-local' + date_column? ? 'date' : 'datetime-local' end + # Covers DB columns and attributes declared with the Attributes API. + def date_column? + return column.type == :date if column&.type + + object.class.respond_to?(:attribute_types) && object.class.attribute_types[method.to_s]&.type == :date + end + + # Stored strings are parsed as leniently as input_value does on ActiveAdmin 3, + # so a non-ISO value is not blanked and wiped on save. def native_input_value(input_name = nil) - format_native(object.public_send(input_name || method)) + format_native(object.public_send(input_name || method), strict: false) end protected @@ -81,8 +90,8 @@ def native_input_html_options(options, input_name) def native_picker_attributes picker_options = datetime_picker_options.transform_keys { |k| k.to_s.camelcase(:lower) } attributes = {} - min = format_native(picker_options['minDateTime']) || format_native(picker_options['minDate']) - max = format_native(picker_options['maxDateTime']) || native_max_date(picker_options['maxDate']) + min = format_native(picker_options['minDateTime']) || native_day_bound(picker_options['minDate'], '00:00') + max = format_native(picker_options['maxDateTime']) || native_day_bound(picker_options['maxDate'], '23:59') attributes[:min] = min if min attributes[:max] = max if max step = picker_options['step'].to_i @@ -91,22 +100,24 @@ def native_picker_attributes attributes end - # xdsoft's maxDate allows the whole last day, so a datetime-local max ends at 23:59. - def native_max_date(value) - max = format_native(value) - max && native_input_type == 'datetime-local' ? "#{max[0, 10]}T23:59" : max + # xdsoft's minDate and maxDate are whole days, so a datetime-local bound + # starts at 00:00 of the first day and ends at 23:59 of the last. + def native_day_bound(value, time) + bound = format_native(value) + bound && native_input_type == 'datetime-local' ? "#{bound[0, 10]}T#{time}" : bound end - def format_native(value) + def format_native(value, strict: true) value = value.call if value.respond_to?(:call) return nil if value.blank? - unless value.respond_to?(:strftime) + if value.respond_to?(:strftime) + # Times are shown in the app's zone, like the record values. + value = value.in_time_zone if value.is_a?(Time) || value.is_a?(DateTime) + else # Relative xdsoft values such as "+3D" have no native equivalent. - return nil unless value.to_s.match?(%r{\A\d{4}[-/]\d{1,2}[-/]\d{1,2}}) + return nil if strict && !value.to_s.match?(%r{\A\d{4}[-/]\d{1,2}[-/]\d{1,2}}) value = DateTime.parse(value.to_s) end - # A Time option is shown in the app's zone, like the record values. - value = value.in_time_zone if value.is_a?(Time) value.strftime(NATIVE_FORMATS.fetch(native_input_type)) rescue ArgumentError nil diff --git a/lib/active_admin_datetimepicker/inputs/filters/date_time_range_input.rb b/lib/active_admin_datetimepicker/inputs/filters/date_time_range_input.rb index cce9af9..bee36b3 100644 --- a/lib/active_admin_datetimepicker/inputs/filters/date_time_range_input.rb +++ b/lib/active_admin_datetimepicker/inputs/filters/date_time_range_input.rb @@ -39,17 +39,19 @@ def input_html_options_for(input_name, placeholder) options = super value = ActiveAdminDatetimepicker.aa_v4? ? native_input_value(input_name) : input_value(input_name) bound = input_name == gt_input_name ? 'from' : 'to' - options.merge placeholder: placeholder, - value: value, - data: (options[:data] || {}).merge(datetimepicker_range_input: bound) + options.merge( + placeholder: placeholder, + value: value, + data: (options[:data] || {}).merge(datetimepicker_range_input: bound) + ) end def gt_input_name - column && column.type == :date ? super : "#{method}_gteq_datetime_picker" + date_column? ? super : "#{method}_gteq_datetime_picker" end def lt_input_name - column && column.type == :date ? super : "#{method}_lteq_datetime_picker" + date_column? ? super : "#{method}_lteq_datetime_picker" end end diff --git a/spec/native_inputs_spec.rb b/spec/native_inputs_spec.rb index e2d89db..f44cae0 100644 --- a/spec/native_inputs_spec.rb +++ b/spec/native_inputs_spec.rb @@ -68,6 +68,42 @@ def register_author_with_datetime_form(created_at_options = {}) end end + context 'input helpers' do + let(:form_class) do + Class.new do + include ActiveModel::Model + include ActiveModel::Attributes + attribute :due_on, :date + attribute :starts_at, :string + end + end + + def input_for(method, object, options = {}) + ActiveAdmin::Inputs::DateTimePickerInput.new(Object.new, Object.new, object, 'form', method, options) + end + + it 'renders a date control for a date attribute without a column' do + input = input_for(:due_on, form_class.new(due_on: Date.new(2024, 1, 31))) + expect(input.native_input_type).to eq 'date' + expect(input.native_input_value).to eq '2024-01-31' + end + + it 'keeps a stored non-ISO string value' do + input = input_for(:starts_at, form_class.new(starts_at: '31.01.2024 10:00')) + expect(input.native_input_value).to eq '2024-01-31T10:00' + end + + it 'starts a Time min_date at midnight and shows a DateTime limit in the app zone' do + Time.use_zone('Berlin') do + input = input_for(:starts_at, form_class.new, datepicker_options: { + min_date: Time.zone.parse('2024-01-10 14:32'), + max_date_time: DateTime.new(2024, 1, 20, 12, 0, 0, '+00:00') + }) + expect(input.send(:native_picker_attributes)).to include(min: '2024-01-10T00:00', max: '2024-01-20T13:00') + end + end + end + context 'form input with relative xdsoft limits' do before { register_author_with_datetime_form(datepicker_options: { min_date: '+3D' }) } diff --git a/tasks/test.rake b/tasks/test.rake index 58bd241..62a0e55 100644 --- a/tasks/test.rake +++ b/tasks/test.rake @@ -10,6 +10,8 @@ task :setup do -m spec/support/rails_template.rb ) - app_path = ENV['RAILS_ROOT'] || "spec/rails/rails-#{Rails::VERSION::STRING}" - system "bundle exec rails new #{app_path} #{rails_new_opts.join(' ')}" + # Same path spec_helper.rb uses, so the suite finds the app. + activeadmin_major = Gem.loaded_specs['activeadmin'].version.segments.first + app_path = ENV['RAILS_ROOT'] || "spec/rails/rails-#{Rails::VERSION::STRING}-aa#{activeadmin_major}" + system 'bundle', 'exec', 'rails', 'new', app_path, *rails_new_opts end