From 7a64a419704757a324dce482a2639280aafa3e22 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 10:30:25 +0200 Subject: [PATCH 1/7] Allowlist Post associations for Ransack 6 in the dummy app Ransack 6 raises when ransackable_associations is undefined, which failed every filter page in the suite. Co-Authored-By: Clanker --- spec/support/rails_template.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index bdcc4d7..b4a1b26 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -14,6 +14,9 @@ inject_into_file "app/models/post.rb", " def self.ransackable_attributes(auth_object = nil)\n" \ " [\"title\", \"body\", \"published_at\", \"created_at\"]\n" \ + " end\n\n" \ + " def self.ransackable_associations(auth_object = nil)\n" \ + " []\n" \ " end\n", after: "ApplicationRecord\n" From 82dd73570fada9736fb4e3c03e7418b1d4be83e0 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 10:32:35 +0200 Subject: [PATCH 2/7] Rewrite the plugin in plain JavaScript without jQuery One classic script with no imports or exports, so Sprockets and an import map can both load it. $.fn.date_range_ext_preset becomes ActiveAdminDateRangePreset.init and .defaults; options keep their names. Native date and datetime-local inputs get the ISO value their type needs. Co-Authored-By: Clanker --- CHANGELOG.md | 31 ++ README.md | 321 +++++++----- package.json | 2 +- .../active_admin_date_range_preset.js | 468 ++++++++++-------- .../active_admin_date_range_preset.css | 63 +++ .../active_admin_date_range_preset.scss | 50 -- 6 files changed, 540 insertions(+), 395 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 vendor/assets/stylesheets/active_admin_date_range_preset.css delete mode 100644 vendor/assets/stylesheets/active_admin_date_range_preset.scss diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..21935ba --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +## Unreleased + +### Breaking + +- The plugin is plain JavaScript; jQuery is no longer used. `$.fn.date_range_ext_preset` + is removed. Use `ActiveAdminDateRangePreset.init(selector, options)` and + `ActiveAdminDateRangePreset.defaults`. Options keep their names, so + `$('.my_pairs').date_range_ext_preset({ show_time: true })` becomes: + + ```javascript + document.addEventListener('DOMContentLoaded', function () { + ActiveAdminDateRangePreset.init('.my_pairs', { show_time: true }); + }); + ``` + + and `$.fn.date_range_ext_preset.defaults.show_time = true` becomes + `ActiveAdminDateRangePreset.defaults.show_time = true`. +- `gteq_input` and `lteq_input` take CSS selectors or elements, not jQuery selectors. +- The stylesheet is plain CSS. `@import "active_admin_date_range_preset";` still works. + +### Added + +- Active Admin 4 support: the module is pinned into `ActiveAdmin.importmap`. +- `date` inputs get `YYYY-MM-DD` and `datetime-local` inputs get `YYYY-MM-DDTHH:MM`. +- Dark mode for the menu under the `.dark` class. +- `data-date-range-preset-toggle`, `data-date-range-preset-menu` and + `data-date-range-preset=""` hooks. +- A `.datetime_preset_pair` wrapper that holds both inputs, such as a range filter, + fills its own first and last input. diff --git a/README.md b/README.md index 23e77c2..8b43610 100644 --- a/README.md +++ b/README.md @@ -12,24 +12,69 @@ This is how it looks like combined + ## Compatibility -Active Admin 3.2 or newer, in the 3.x series. Rails 8.0 or newer, Ruby 3.3 or -newer. CI covers Active Admin 3.2 and 3.5 on Ruby 3.3, 3.4 and 4.0 against -Rails 8.0 and 8.1 — every Rails series still supported upstream. +Active Admin 3.2 or newer, including Active Admin 4. Rails 8.0 or newer, Ruby +3.3 or newer. CI covers Active Admin 3.2 and 3.5 on Ruby 3.3, 3.4 and 4.0 +against Rails 8.0 and 8.1, and Active Admin 4.0.0.beta23 on Rails 8.0 and 8.1. + +The plugin is one plain JavaScript file with no jQuery. Active Admin 3 loads it +through Sprockets, Active Admin 4 as an ES module from its import map. Both get +the same API, `window.ActiveAdminDateRangePreset`. -Version 0.4.0 raised the Rails floor from 7.1 to 8.0. Every 7.x series is out -of support — 7.1 since October 2025, 7.2 since August 2026 — and none of them -is tested. Stay on 0.3.x if you are still on Rails 7. +Version 0.4.0 raised the Rails floor from 7.1 to 8.0. Stay on 0.3.x if you are +still on Rails 7. The Rails and Ruby floors are declared explicitly because Active Admin 3.2 still allows `railties >= 6.1` and Ruby `>= 2.6`, both long past end of life and neither tested here. -Active Admin 4 is not supported. It drops `jquery-rails` and the -`app/assets/javascripts` tree, and this gem is a jQuery plugin shipped through -Sprockets, so nothing here loads there. Supporting it means a rewrite rather -than a port, and that is tracked separately. +## Upgrading from 0.x + +The jQuery plugin `$.fn.date_range_ext_preset` is gone. Replace each call with +`ActiveAdminDateRangePreset.init`, which takes a CSS selector (or an element, +or a list of elements) and the same options: + +```javascript +// before +$('form.filter_form div.filter_date_range').date_range_ext_preset({ show_time: true }); +$.fn.date_range_ext_preset.defaults.hours_offset = 3; + +// after +document.addEventListener('DOMContentLoaded', function () { + ActiveAdminDateRangePreset.init('form.filter_form div.filter_date_range', { show_time: true }); +}); +ActiveAdminDateRangePreset.defaults.hours_offset = 3; +``` + +Every option keeps its name and meaning, `show_time` included. A call that +turned time on for some inputs becomes: + +```javascript +// before +$('.my_pairs').date_range_ext_preset({ show_time: true }); + +// after +document.addEventListener('DOMContentLoaded', function () { + ActiveAdminDateRangePreset.init('.my_pairs', { show_time: true }); +}); +``` + +To turn it on for every pair, including the ones set up automatically, set +the default instead, outside any `DOMContentLoaded` callback: + +```javascript +ActiveAdminDateRangePreset.defaults.show_time = true; +``` + +`show_time` still affects text inputs only. Native `date` and `datetime-local` +inputs get the format their `type` requires. + +`gteq_input` and `lteq_input` are now CSS selectors or elements instead of +jQuery selectors. The markup and CSS classes are unchanged. The stylesheet is +plain CSS now, so `@import "active_admin_date_range_preset";` in +`active_admin.scss` keeps working. ## Installation @@ -43,162 +88,173 @@ And then execute: $ bundle install -Or install it yourself as: +### Active Admin 3 (Sprockets) - $ gem install active_admin_date_range_preset +In `app/assets/javascripts/active_admin.js`: -##### Using assets via Sprockets - - JS asset - ```//= require active_admin_date_range_preset``` - - CSS - ```@import "active_admin_date_range_preset";``` +```javascript +//= require active_admin_date_range_preset +``` -##### Using assets via Webpacker (or any other assets bundler) as a NPM module (Yarn package) +In `app/assets/stylesheets/active_admin.scss`: -Execute: +```scss +@import "active_admin_date_range_preset"; +``` - $ npm i @activeadmin-plugins/active_admin_date_range_preset +### Active Admin 4 (import map) -Or +The gem pins the module `active_admin_date_range_preset` into +`ActiveAdmin.importmap`. If your Active Admin has the `javascript_modules` +setting, the gem adds itself to it and pages import it automatically. +Otherwise, override `app/views/active_admin/_html_head.html.erb` (copy it from +Active Admin) and add after the `javascript_importmap_tags` line: - $ yarn add @activeadmin-plugins/active_admin_date_range_preset +```erb +<%= javascript_import_module_tag "active_admin_date_range_preset" %> +``` -Or add manually to `package.json`: +If you use active_admin_theme 4, add the module to its list in an initializer +instead: -```json -"dependencies": { - "@activeadmin-plugins/active_admin_date_range_preset": "0.3.1" -} +```ruby +ActiveAdminTheme.javascripts += %w[active_admin_date_range_preset] ``` -and execute: - $ yarn +For the CSS, either link the stylesheet in the same `_html_head` override +(Propshaft serves it from the gem): -Add the following line into `app/assets/javascripts/active_admin.js`: - -```javascript -import '@activeadmin-plugins/active_admin_date_range_preset'; +```erb +<%= stylesheet_link_tag "active_admin_date_range_preset" %> ``` -Add the following line into `app/assets/stylesheets/active_admin.scss`: +or import it into the Tailwind entry that builds `active_admin.css`, from the +npm package or from the gem's path (`bundle show active_admin_date_range_preset`): ```css -@import '@activeadmin-plugins/active_admin_date_range_preset'; +@import "@activeadmin-plugins/active_admin_date_range_preset"; ``` -Your sidebar filters should now have link "Set Range" +The menu follows Active Admin 4's dark mode (the `.dark` class on ``). -## Development +### npm package -The specs run against a throwaway Rails app built by `spec/support/rails_template.rb`. -It is generated automatically on the first run (or via `bundle exec rake setup`) into -`spec/rails/rails-/`, which is gitignored. + $ npm i @activeadmin-plugins/active_admin_date_range_preset -```sh -bundle install -bundle exec rspec spec +```javascript +import '@activeadmin-plugins/active_admin_date_range_preset'; ``` -Pick the versions under test with env vars: - -```sh -RAILS=8.0.0 AA=3.5.0 bundle install -RAILS=8.0.0 AA=3.5.0 bundle exec rspec spec +```css +@import '@activeadmin-plugins/active_admin_date_range_preset'; ``` ## Usage -in New/Edit formtastic forms: +### Forms + +Put `datetime_preset_pair` on the wrapper of the first input of a pair. The +next input is the end of the range. Pairs with this class are set up on page +load. ```ruby -f.input :date_from, as: :date_time_picker, wrapper_html: { class: 'datetime_preset_pair', data: { show_time: 'true' } } -f.input :date_to, as: :date_time_picker +f.input :date_from, as: :string, wrapper_html: { class: 'datetime_preset_pair', data: { show_time: 'true' } } +f.input :date_to, as: :string ``` -Input can be "as :string" or any other type compatible with "input type=text" -Main point is to set for first input-pair wrapper-class +The value format follows the input's `type`: -```wrapper_html: { class: 'datetime_preset_pair' }``` +| Input | Value | +| --- | --- | +| `type="date"` | `2015-06-10` | +| `type="datetime-local"` | `2015-06-10T00:00` | +| text | `2015-06-10`, or `2015-06-10 00:00:00` with `show_time` | -input name('date_from' and 'date_to') can be named whatever your need +`data-show-time` overrides `show_time` for that pair, in both directions. -By default inputs are filled with date("yyyy-mm-dd"). If you need time add +### Filters -```data: { show_time: 'true' }``` +A wrapper that holds both inputs uses its first and last input. Add the class +to a date_range filter: +```ruby +filter :created_at, as: :date_range, wrapper_html: { class: 'datetime_preset_pair' } +``` -## Using with ActiveAdminDatetimepicker +Or set up every date_range filter from JavaScript: -If you use GEM https://github.com/activeadmin-plugins/active_admin_datetimepicker -and want apply this plugin to filter-inputs for this gem you need: +```javascript +// Active Admin 3 +ActiveAdminDateRangePreset.init('form.filter_form div.filter_date_range'); +// Active Admin 4 +ActiveAdminDateRangePreset.init('form.filters-form div.date_range'); +``` -First apply ActiveAdminDatetimepicker to any filters your need +On Active Admin 4, put that call in a module script in your `_html_head` +override, after the import: -```ruby -filter :time_start, as: :date_time_range +```erb + ``` -In active_admin.js +Active Admin 4's own date_range filter renders native date inputs, so the +presets write `YYYY-MM-DD` there. -Add following lines to JavaScript +### With active_admin_datetimepicker + +[active_admin_datetimepicker](https://github.com/activeadmin-plugins/active_admin_datetimepicker) +range filters (`filter :time_start, as: :date_time_range`) render as +`div.filter_date_time_range` on Active Admin 3 and 4. Init them by that +selector, the same way on both: ```javascript -$(function () { - $('form.filter_form div.filter_date_time_range').date_range_ext_preset(); -}); +ActiveAdminDateRangePreset.init('form div.filter_date_time_range'); ``` -Now all you "date_time_range" inputs has button "Set range" +On Active Admin 4 their inputs are `datetime-local` (or `date` for a date +column), so the presets write `YYYY-MM-DDTHH:MM` (or `YYYY-MM-DD`). In forms, +put `datetime_preset_pair` on the first picker's `wrapper_html` as for any +other pair. +### Custom inputs -## Custom usage - -You can assign "Set range" almost to any input-text-pair filters/forms. -For example, you have complex form where input-pairs are not close to each other and not standard. +The link goes into the first `label` of the element you init. When the inputs +are elsewhere on the page, point at them: ```javascript -$(function () { - $('.any_jquery_selector').date_range_ext_preset({ - lteq_input: '.jquery_selector_to_first_input', - gteq_input: '.jquery_selector_to_second_input' - }); +ActiveAdminDateRangePreset.init('.any_selector', { + gteq_input: '#date_from', + lteq_input: '#date_to' }); ``` -".any_jquery_selector" is pointed to place where button "Set range" will appear. -Set lteq_input and gteq_input to point to inputs if they not near main selector. +Calling `init` again on the same element replaces its setup, so a call with +options after the automatic one wins. -## Global and local settings +### Test hooks -There are several settings, which can be set globally or locally. +The "Set range" link carries `data-date-range-preset-toggle`, the menu +`data-date-range-preset-menu`, and each preset `data-date-range-preset` with its +key: `today`, `yesterday`, `week`, `month`, `last_week`, `last_month`, then +`custom_0`, `custom_1`, ... for `add_range` entries. -Example how to set settings for only specific inputs +## Settings -```javascript -$('.any_jquery_selector').date_range_ext_preset({ - setting_name: "setting_value" -}); -``` -Example how to set global settings. Write it before the `$(function () { ... })` block +Pass settings to `init` for some elements, or set them for every later `init` +on `ActiveAdminDateRangePreset.defaults`. Set defaults before the page finishes +loading, since pairs are set up on `DOMContentLoaded`. ```javascript -$.fn.date_range_ext_preset.defaults.setting_name = "setting_value" -``` +// End date is the same day, not the next one. +// Today true : 2015-06-12 - 2015-06-12 +// Today false: 2015-06-12 - 2015-06-13 +ActiveAdminDateRangePreset.defaults.date_to_human_readable = true; -You can set global defaults in your active_admin.js like this: - -```javascript -# End date will be full-day, not next. -# Today true : 2015-06-12 - 2015-06-12 -# Today false: 2015-06-12 - 2015-06-13 -$.fn.date_range_ext_preset.defaults.date_to_human_readable = true - -# Display time -# Today: 2015-06-12 00:00:00 - 2015-06-13 00:00:00 -# Today with human_readable=true: 2015-06-12 00:00:00 - 2015-16-12 23:59:59 -$.fn.date_range_ext_preset.defaults.show_time = true +// Text inputs get the time too: 2015-06-12 00:00:00 - 2015-06-13 00:00:00 +ActiveAdminDateRangePreset.defaults.show_time = true; ``` ### date_to_human_readable @@ -221,7 +277,8 @@ value: true/false default: false -If true then will show date and time, usually it will be 00:00:00 +If true, text inputs get date and time, usually 00:00:00. Native inputs ignore +it: their `type` decides. ### hours_offset @@ -231,29 +288,45 @@ default: 0 To work correctly this plugin needs to detect current date-time. And it uses UTC. But if you need your local timezone or some other time-shift, your can set this option: -Example: ```javascript -$.fn.date_range_ext_preset.defaults.hours_offset = +3 +ActiveAdminDateRangePreset.defaults.hours_offset = +3; // or -$.fn.date_range_ext_preset.defaults.hours_offset = -3 +ActiveAdminDateRangePreset.defaults.hours_offset = -3; ``` -### Addition ranges +### add_range ```javascript -$(function () { - - $('.filter_form .filter_date_range').date_range_ext_preset({ - date_to_human_readable: true, # affects last day - add_range: [ - { - title: 'Last 30 days', - // date_to_human_readable affects end-date, sow must do this: - start: new Date(new Date().setDate(new Date().getDate() - 29)), - end: new Date(new Date().setDate(new Date().getDate() + 1)) - } - ] - }); - +ActiveAdminDateRangePreset.init('form.filter_form div.filter_date_range', { + date_to_human_readable: true, // affects the end date + add_range: [ + { + title: 'Last 30 days', + // date_to_human_readable moves the end date back a second, so end tomorrow: + start: new Date(new Date().setDate(new Date().getDate() - 29)), + end: new Date(new Date().setDate(new Date().getDate() + 1)) + } + ] }); ``` + +## Development + +The specs run against a throwaway Rails app built by `spec/support/rails_template.rb`. +It is generated automatically on the first run (or via `bundle exec rake setup`) into +`spec/rails/rails--aa/`, which is gitignored. + +```sh +bundle install +bundle exec rspec spec +``` + +Pick the versions under test with env vars. `AA=4.x` switches the bundle from +Sprockets to Propshaft, importmap-rails and Tailwind: + +```sh +RAILS=8.0.0 AA=3.5.0 bundle install +RAILS=8.0.0 AA=3.5.0 bundle exec rspec spec +AA=4.0.0.beta23 bundle install +AA=4.0.0.beta23 bundle exec rspec spec +``` diff --git a/package.json b/package.json index 7124a6e..c6a9a6f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.4.0", "description": "Integrate useful fast links to set date ranges in to ActiveAdmin, for example today range, week range, month range", "main": "src/active_admin_date_range_preset.js", - "style": "src/active_admin_date_range_preset.scss", + "style": "src/active_admin_date_range_preset.css", "repository": "git@github.com:activeadmin-plugins/active_admin_date_range_preset.git", "author": "Gena M. ", "license": "MIT", diff --git a/vendor/assets/javascripts/active_admin_date_range_preset.js b/vendor/assets/javascripts/active_admin_date_range_preset.js index 8129686..8470ee9 100644 --- a/vendor/assets/javascripts/active_admin_date_range_preset.js +++ b/vendor/assets/javascripts/active_admin_date_range_preset.js @@ -1,245 +1,273 @@ -$(function () { - // options: - // gteq_input: jQuery-selecotr for input date_from - // lteq_input: jQuery-selecotr for input date_to - // hours_offset: Int number - hours +/- to correct time - $.fn.date_range_ext_preset = function(options) { - // settings - options = options || {}; - let opts = $.extend({}, $.fn.date_range_ext_preset.defaults, options); - - // aditional functions - function num_with_leading_zero(num, digitsCount = 2) { - let s = num + ''; - while (s.length < digitsCount) { - s = '0' + s; - } - return s; - } +// Plain script with no imports or exports, so the same file runs as a classic +// script (Sprockets on Active Admin 3) and as an ES module (importmap on +// Active Admin 4). Either way the API is window.ActiveAdminDateRangePreset. +(function () { + if (window.ActiveAdminDateRangePreset) { return; } - // formated date YYYY-MM-DD, with converting to UTC - // note: getMonth Returns the month (from 0-11), so we do +1 - function formatDate(date, el_opts) { - let str = date.getFullYear() + '-' + num_with_leading_zero(date.getMonth()+1) + '-' + num_with_leading_zero(date.getDate()); - if (el_opts.show_time) { - str += (' ' + num_with_leading_zero(date.getHours()) + ':' + num_with_leading_zero(date.getMinutes()) + ':' + num_with_leading_zero(date.getSeconds())); - } - return str; - } + var defaults = { + // Manual global time shift, from UTC can be +/- number + hours_offset: 0, - // Days from the Monday that opens this date's week. getDay() counts from - // Sunday, so a plain `- getDay() + 1` lands on tomorrow for Sundays and - // hands back the week that has not started yet. - function days_since_monday(date) { - return (date.getDay() + 6) % 7; - } + // date_to_human_readable = true, then "date_to" consider as including full day without last second + // For example Today will be: + // true + // 2015-06-10 - 2015-06-10 + // 2015-06-10 00:00:00 - 2015-06-10 23:59:59 + // false + // 2015-06-10 - 2015-06-11 + // 2015-06-10 00:00:00 - 2015-06-11 00:00:00 + date_to_human_readable: false, - function unbindClickEventBlockTimerange() { - $('.block_timerange').remove(); - $('body').off('click.CalendarRangeSet'); - } + // Display time or not on text inputs: 2015-06-10 vs 2015-06-10 00:00:00 + show_time: false, + + // Array of additional ranges: { title: 'Last 30 days', start: Date, end: Date } + add_range: [] + }; + + var PRESETS = [ + { key: 'today', title: 'Today', range: function (now) { + var start = day(now, 0); + return [start, day(start, 1)]; + } }, + { key: 'yesterday', title: 'Yesterday', range: function (now) { + return [day(now, -1), day(now, 0)]; + } }, + { key: 'week', title: 'This Week', range: function (now) { + var start = day(now, -daysSinceMonday(now)); + return [start, day(start, 7)]; + } }, + { key: 'month', title: 'This Month', range: function (now) { + return [new Date(now.getFullYear(), now.getMonth(), 1), + new Date(now.getFullYear(), now.getMonth() + 1, 1)]; + } }, + { key: 'last_week', title: 'Last Week', range: function (now) { + var end = day(now, -daysSinceMonday(now)); + return [day(end, -7), end]; + } }, + { key: 'last_month', title: 'Last Month', range: function (now) { + return [new Date(now.getFullYear(), now.getMonth() - 1, 1), + new Date(now.getFullYear(), now.getMonth(), 1)]; + } } + ]; - // local datetime now - let now = new Date(); - // UTC datetime now - let now_utc = new Date( + function day(date, shift) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() + shift); + } + + // getDay() counts from Sunday, so a plain `- getDay() + 1` lands on + // tomorrow for Sundays and hands back the week that has not started yet. + function daysSinceMonday(date) { + return (date.getDay() + 6) % 7; + } + + function pad(num) { + return (num < 10 ? '0' : '') + num; + } + + // Native date inputs only accept ISO values, so the input's type picks the + // format; text inputs keep the format the plugin has always written. + function formatFor(input, date, opts) { + var ymd = date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()); + var hm = pad(date.getHours()) + ':' + pad(date.getMinutes()); + if (input.type === 'date') { return ymd; } + if (input.type === 'datetime-local') { return ymd + 'T' + hm; } + return opts.show_time ? ymd + ' ' + hm + ':' + pad(date.getSeconds()) : ymd; + } + + // "Now" in UTC shifted by hours_offset, as a local Date whose fields hold + // those wall-clock values. + function shiftedNow(opts) { + var now = new Date(); + var utc = new Date( now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds(), now.getUTCMilliseconds() ); - // datetime object, UTC with hours offset - let datetime = new Date(now_utc.getTime() + opts.hours_offset * 60 * 60 * 1000); - - // PLUGIN BEGIN - return this.each(function(i, el) { - let $this = $(el); - - // Re-initialising an element replaces its wiring instead of adding to - // it. The auto-init below was dead from jQuery 3 onwards, so hosts - // worked around it by calling the plugin themselves; now that it works - // again both run, and without this the label grows a second "Set range" - // whose handler tears down the first one's popup. - // - // Replacing rather than skipping matters: the auto-init registers its - // ready callback before anything a host adds to active_admin.js, so - // bailing out on the second call would silently drop the host's - // options. Reading the link itself rather than a data flag keeps the - // guard and the thing it guards from drifting apart across clone() and - // Turbo cache restores, which copy the node but not jQuery's data. - $this.find('a.btn_timerange').remove(); - $this.off('click.dateRangeExtPreset'); - - // Per element, because data-show-time is read off the element itself. - // Writing it back into the shared opts let one pair's setting reach - // every other pair in the same call, and formatDate reads the flag at - // click time rather than here, so the leak was not even order-dependent. - let el_opts = $.extend({}, opts); - - // The attribute is authoritative in both directions when present. Only - // ever assigning true made it impossible for one element to opt out of - // a show_time the call or the defaults had turned on. - if (typeof $this.data('show-time') != 'undefined') { - el_opts.show_time = $this.data('show-time').toString() == 'true'; - } + return new Date(utc.getTime() + opts.hours_offset * 60 * 60 * 1000); + } - // detect inputs - let lteq_input = $this.find('input:last'); - if (options.lteq_input) { - lteq_input = $(options.lteq_input); - } else if ($this.hasClass('datetime_preset_pair')) { - lteq_input = $this.next().find('input'); - } + function setValue(input, value) { + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + } - let gteq_input = $this.find('input:first'); - if (options.gteq_input) { - gteq_input = $(options.gteq_input); - } else if ($this.hasClass('datetime_preset_pair')) { - gteq_input = $this.find('input'); - } + function resolveInput(option) { + if (!option) { return null; } + return typeof option === 'string' ? document.querySelector(option) : option; + } - // filter modifying - let main_btn_html = 'Set range'; - $this.find('label').addClass('datetime_preset_filter_label').append(main_btn_html); + function fieldInputs(el) { + return el.querySelectorAll('input:not([type="hidden"]):not([type="submit"])'); + } - // helper - function fillInputs(start, end) { - gteq_input.val(formatDate(start, el_opts)); - if (el_opts.date_to_human_readable) { - end.setTime(end.getTime() - 1000); - } + // A wrapper holding both inputs (a date_range filter) uses its first and + // last. A Formtastic form pair is two wrappers with one input each, so the + // "to" input lives in the next wrapper. + function detectInputs(el, options) { + var inputs = fieldInputs(el); + var gteq = inputs[0]; + var lteq = inputs[inputs.length - 1]; + var next = el.nextElementSibling; + if (inputs.length < 2 && next && fieldInputs(next)[0]) { + lteq = fieldInputs(next)[0]; + } + return [resolveInput(options.gteq_input) || gteq, resolveInput(options.lteq_input) || lteq]; + } - lteq_input.val(formatDate(end, el_opts)); - } + var closeOpenMenu = function () {}; - $this.on('click.dateRangeExtPreset', '.btn_timerange', function(e) { - unbindClickEventBlockTimerange(); - e.stopPropagation(); + function menuItems(opts) { + var items = PRESETS.map(function (preset) { + return { key: preset.key, className: 'btn_' + preset.key, title: preset.title, range: preset.range }; + }); + opts.add_range.forEach(function (range, i) { + items.push({ key: 'custom_' + i, className: 'btn_date_range_' + i, title: range.title, range: function () { + return [day(range.start, 0), day(range.end, 0)]; + } }); + }); + return items; + } + + function openMenu(toggle, opts, fill) { + closeOpenMenu(); + + var items = menuItems(opts); + var menu = document.createElement('div'); + menu.className = 'block_timerange'; + menu.setAttribute('data-date-range-preset-menu', ''); + menu.setAttribute('role', 'menu'); + + items.forEach(function (item) { + var row = document.createElement('div'); + var button = document.createElement('span'); + button.className = item.className; + button.setAttribute('data-date-range-preset', item.key); + button.setAttribute('role', 'menuitem'); + button.tabIndex = 0; + button.textContent = item.title; + row.appendChild(button); + menu.appendChild(row); + }); + + var box = toggle.getBoundingClientRect(); + menu.style.minWidth = box.width + 'px'; + menu.style.top = (box.top + window.scrollY) + 'px'; + menu.style.left = (box.left + window.scrollX) + 'px'; + + function choose(target) { + var button = target.closest('[data-date-range-preset]'); + if (!button) { return; } + var key = button.getAttribute('data-date-range-preset'); + var item = items.filter(function (it) { return it.key === key; })[0]; + closeOpenMenu(); + fill(item.range(shiftedNow(opts))); + } + + menu.addEventListener('click', function (e) { choose(e.target); }); + menu.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); + choose(e.target); + } else if (e.key === 'Escape') { + closeOpenMenu(); + toggle.focus(); + } + }); + + // No stopPropagation: this listener only watches for a click that should + // close the menu. Active Admin delegates Clear Filters and the has_many + // buttons from , so swallowing the event would break them. + function onOutsideClick(e) { + if (!menu.contains(e.target)) { closeOpenMenu(); } + } + + closeOpenMenu = function () { + menu.remove(); + document.body.removeEventListener('click', onOutsideClick); + toggle.setAttribute('aria-expanded', 'false'); + closeOpenMenu = function () {}; + }; + + // Append and bind in the same tick, so the presets are live as soon as + // the menu is in the document. + document.body.appendChild(menu); + document.body.addEventListener('click', onOutsideClick); + + // A link at the right edge of a sidebar would push the menu off screen. + var overflow = menu.getBoundingClientRect().right - document.documentElement.clientWidth; + if (overflow > 0) { menu.style.left = Math.max(0, box.left + window.scrollX - overflow) + 'px'; } + toggle.setAttribute('aria-expanded', 'true'); + } + + function initElement(el, options) { + // Re-initialising replaces the wiring instead of adding to it, so a host + // call with options after the auto-init wins. The listener lives on the + // link, so dropping the old link drops the old wiring with it. + el.querySelectorAll('[data-date-range-preset-toggle]').forEach(function (link) { link.remove(); }); + + var opts = Object.assign({}, defaults, options); + opts.add_range = opts.add_range || []; + + // The attribute is authoritative in both directions when present. + var showTime = el.getAttribute('data-show-time'); + if (showTime !== null) { opts.show_time = showTime === 'true'; } - let additional_items_html = ''; - opts.add_range.forEach(function(el, i) { - return additional_items_html += '
' + el['title'] + '
'; - }); - - // Bind in the same tick the popup is appended. Active Admin 3 ships - // jQuery 3, which rebuilt .ready() on top of a Deferred whose .then - // schedules through setTimeout, so handlers attached inside it landed - // a macrotask late and the popup was on screen but inert until then. - // On a non-document collection the method is deprecated anyway. - let container = $('
' + - '
Today
' + - '
Yesterday
' + - '
This Week
' + - '
This Month
' + - '
Last Week
' + - '
Last Month
' + - additional_items_html + - '
' - ).appendTo('body'); - - // additional ranges - opts.add_range.forEach(function(el, i) { - container.on('click.CalendarRangeSet', '.btn_date_range_' + i, function(e) { - unbindClickEventBlockTimerange(); - let start = new Date(el['start'].getFullYear(), el['start'].getMonth(), el['start'].getDate()); - let end = new Date(el['end'].getFullYear(), el['end'].getMonth(), el['end'].getDate()); - fillInputs(start, end) - }); - }); - - // Today - container.on('click.CalendarRangeSet', '.btn_today', function(e) { - unbindClickEventBlockTimerange(); - let start = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate()); - let end = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate() + 1); - fillInputs(start, end); - }); - - // Yesterday - container.on('click.CalendarRangeSet', '.btn_yesterday', function(e) { - unbindClickEventBlockTimerange(); - let start = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate() - 1); - let end = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate()); - fillInputs(start, end); - }); - - // Week - container.on('click.CalendarRangeSet', '.btn_week', function(e) { - unbindClickEventBlockTimerange(); - let start = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate() - days_since_monday(datetime)); - let end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 7); - fillInputs(start, end); - }); - - // Month - container.on('click.CalendarRangeSet', '.btn_month', function(e) { - unbindClickEventBlockTimerange(); - let start = new Date(datetime.getFullYear(), datetime.getMonth(), 1); - let end = new Date(datetime.getFullYear(), datetime.getMonth() + 1, 1); - fillInputs(start, end); - }); - - // Last Week - container.on('click.CalendarRangeSet', '.btn_last_week', function(e) { - unbindClickEventBlockTimerange(); - let end = new Date(datetime.getFullYear(), datetime.getMonth(), datetime.getDate() - days_since_monday(datetime)); - let start = new Date(end.getFullYear(), end.getMonth(), end.getDate() - 7); - fillInputs(start, end); - }); - - // Last Month - container.on('click.CalendarRangeSet', '.btn_last_month', function(e) { - unbindClickEventBlockTimerange(); - let end = new Date(datetime.getFullYear(), datetime.getMonth(), 1); - let start = new Date(end.getFullYear(), end.getMonth() - 1, 1); - fillInputs(start, end); - }); - - // Outer - // No stopPropagation here: this listener only watches for a click - // that should close the popup. Active Admin delegates Clear Filters - // and the has_many buttons from , which is upstream of - // , so swallowing the event would break them for as long as - // the popup is open. - $('body').on('click.CalendarRangeSet', function(e) { - if ($(e.target).closest('.block_timerange').length == 0) { - unbindClickEventBlockTimerange(); - } - }); + var inputs = detectInputs(el, options); + var gteq = inputs[0]; + var lteq = inputs[1]; + + var toggle = document.createElement('a'); + toggle.href = '#'; + toggle.className = 'btn_timerange'; + toggle.textContent = 'Set range'; + toggle.setAttribute('data-date-range-preset-toggle', ''); + toggle.setAttribute('aria-haspopup', 'menu'); + toggle.setAttribute('aria-expanded', 'false'); + + var label = el.querySelector('label'); + if (label) { + label.classList.add('datetime_preset_filter_label'); + label.appendChild(toggle); + } else { + el.insertBefore(toggle, el.firstChild); + } + + toggle.addEventListener('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + openMenu(toggle, opts, function (range) { + var end = range[1]; + if (opts.date_to_human_readable) { end = new Date(end.getTime() - 1000); } + if (gteq) { setValue(gteq, formatFor(gteq, range[0], opts)); } + if (lteq) { setValue(lteq, formatFor(lteq, end, opts)); } }); }); } - $.fn.date_range_ext_preset.defaults = { - // Manual global time shift, from UTC can be +/- number - hours_offset: 0, + function toElements(target) { + if (typeof target === 'string') { return Array.prototype.slice.call(document.querySelectorAll(target)); } + if (target instanceof Element) { return [target]; } + return Array.prototype.slice.call(target || []); + } - // date_to_human_readable = true, then "date_to" consider as including full day without last second - // For example Today will be: - // true - // 2015-06-10 - 2015-06-10 - // 2015-06-10 00:00:00 - 2015-06-10 23:59:59 - // false - // 2015-06-10 - 2015-06-11 - // 2015-06-10 00:00:00 - 2015-06-11 00:00:00 - date_to_human_readable: false, + // target: a CSS selector, an Element, or a list of Elements. + function init(target, options) { + var elements = toElements(target); + elements.forEach(function (el) { initElement(el, options || {}); }); + return elements; + } - // Display time or not: 2015-06-10 vs 2015-06-10 00:00:00 - show_time: false, + window.ActiveAdminDateRangePreset = { defaults: defaults, init: init }; - // Array of addition ranges - // example: - // { - // title: 'Last 30 days', - // start: new Date().setDate((new Date()).getDate() - 30) - // end: new Date() - // } - add_range: [] + function autoInit() { + init('.datetime_preset_pair'); } -}); -// jQuery 3 removed $(document).on('ready'), so this must stay $(fn). -$(function() { - // Init in forms - $('.datetime_preset_pair').date_range_ext_preset(); -}); + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', autoInit); + } else { + autoInit(); + } +})(); diff --git a/vendor/assets/stylesheets/active_admin_date_range_preset.css b/vendor/assets/stylesheets/active_admin_date_range_preset.css new file mode 100644 index 0000000..2a3aada --- /dev/null +++ b/vendor/assets/stylesheets/active_admin_date_range_preset.css @@ -0,0 +1,63 @@ +/* Plain CSS: Sprockets serves it to Active Admin 3 and a Tailwind entry can + @import it on Active Admin 4. Dark mode follows Active Admin 4's .dark class. */ + +form label.datetime_preset_filter_label .btn_timerange { + float: right; + margin: -2px 8px 0 0; + text-decoration: none; + border-bottom: 1px dashed; + line-height: 1.4em; +} + +form label.datetime_preset_filter_label .btn_timerange:hover { + border-bottom: 0 none; +} + +/* A form pair gets Active Admin's full label column, wide enough that the + float above parks the link far from the inputs it drives. Put it on its own + line under the label text; `display: table` keeps it shrink-to-fit. Sidebar + filters are divs, not lis, and keep the float. */ +li.datetime_preset_pair > label.datetime_preset_filter_label .btn_timerange { + float: none; + display: table; + margin: 2px 0 0 0; +} + +.block_timerange { + position: absolute; + z-index: 50; + display: block; + border: 1px solid #000; + padding: 5px 10px; + text-align: center; + background-color: #f4f4f4; +} + +.block_timerange > div { + margin-top: 10px; +} + +.block_timerange > div:last-child { + margin-bottom: 10px; +} + +.block_timerange > div span { + color: #2e86cc; + cursor: pointer; + text-decoration: none; + font-size: 14px; + border-bottom: 1px dashed; +} + +.block_timerange > div span:hover { + border-bottom: 0 none; +} + +.block_timerange:where(.dark, .dark *) { + border-color: #4b5563; + background-color: #1f2937; +} + +.block_timerange:where(.dark, .dark *) > div span { + color: #60a5fa; +} diff --git a/vendor/assets/stylesheets/active_admin_date_range_preset.scss b/vendor/assets/stylesheets/active_admin_date_range_preset.scss deleted file mode 100644 index 106f24f..0000000 --- a/vendor/assets/stylesheets/active_admin_date_range_preset.scss +++ /dev/null @@ -1,50 +0,0 @@ -form label.datetime_preset_filter_label .btn_timerange { - float: right; - margin: -2px 8px 0 0; - text-decoration: none; - border-bottom: 1px dashed; - line-height: 1.4em; - &:hover { - border-bottom: 0 none; - } -} - -// A form pair gets Active Admin's full label column, which is wide enough -// that the float above parks the link at the far right of it, away from the -// inputs it drives. Put it on its own line under the label text instead. -// `display: table` keeps it shrink-to-fit, so the dashed underline stays -// under the words rather than stretching the whole column. -// -// The sidebar filter keeps the float: there the label and the link share a -// narrow panel and right alignment is what the plugin has always shown. -.datetime_preset_pair > label.datetime_preset_filter_label .btn_timerange { - float: none; - display: table; - margin: 2px 0 0 0; -} - -.block_timerange { - position: absolute; - display: block; - border: 1px solid #000; - padding: 5px 10px 5px 10px; - text-align: center; - background-color: #f4f4f4; - >div { - margin-top: 10px; - &:last-child { - margin-bottom: 10px; - } - span { - color: #2e86cc; - cursor: pointer; - text-decoration: none; - font-size: 14px; - border-bottom: 1px dashed; - &:hover { - border-bottom: 0 none; - } - } - } -} - From cb02541adba4b72945befd49356d5c8d74439f45 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 10:32:35 +0200 Subject: [PATCH 3/7] Load the plugin on Active Admin 4 The engine pins the module into ActiveAdmin.importmap. The suite now also runs on Active Admin 4.0.0.beta23 with a Tailwind build that imports the gem's CSS. Co-Authored-By: Clanker --- .github/workflows/ci.yml | 2 +- Gemfile | 17 ++++-- active_admin_date_range_preset.gemspec | 11 ++-- config/importmap.rb | 2 + lib/active_admin_date_range_preset.rb | 25 +++++++-- spec/date_range_preset_spec.rb | 40 +++++++------- spec/datetimepicker_markup_spec.rb | 51 ++++++++++++++++++ spec/init_once_spec.rb | 39 ++++++-------- spec/link_placement_spec.rb | 2 +- spec/native_inputs_spec.rb | 73 ++++++++++++++++++++++++++ spec/outside_click_spec.rb | 4 +- spec/per_element_options_spec.rb | 2 +- spec/popup_binding_spec.rb | 8 +-- spec/spec_helper.rb | 2 + spec/support/admin.rb | 26 +++++++++ spec/support/browser_time.rb | 9 ++-- spec/support/page_helpers.rb | 26 +++++++++ spec/support/rails_template.rb | 55 ++++++++++++------- spec/support/tailwind.rb | 41 +++++++++++++++ spec/week_boundaries_spec.rb | 5 +- 20 files changed, 350 insertions(+), 90 deletions(-) create mode 100644 config/importmap.rb create mode 100644 spec/datetimepicker_markup_spec.rb create mode 100644 spec/native_inputs_spec.rb create mode 100644 spec/support/page_helpers.rb create mode 100644 spec/support/tailwind.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 772f701..fc544d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: matrix: ruby: ['3.3', '3.4', '4.0'] rails: ['8.0.0', '8.1.0'] - activeadmin: ['3.2.0', '3.5.0'] + activeadmin: ['3.2.0', '3.5.0', '4.0.0.beta23'] env: RAILS: ${{ matrix.rails }} AA: ${{ matrix.activeadmin }} diff --git a/Gemfile b/Gemfile index d2ed288..3a7a879 100644 --- a/Gemfile +++ b/Gemfile @@ -6,10 +6,21 @@ gemspec default_rails_version = '8.0.0' default_activeadmin_version = '3.5.0' +activeadmin_version = ENV['AA'] || default_activeadmin_version + gem 'rails', "~> #{ENV['RAILS'] || default_rails_version}" -gem 'activeadmin', "~> #{ENV['AA'] || default_activeadmin_version}" -gem 'sprockets-rails' -gem 'sass-rails' +gem 'activeadmin', "~> #{activeadmin_version}" + +# Active Admin 4 has no Sprockets: its layout renders an import map and a +# Tailwind-built stylesheet. +if Gem::Version.new(activeadmin_version) >= Gem::Version.new('4.0.0.beta1') + gem 'propshaft' + gem 'importmap-rails' + gem 'tailwindcss-ruby', '~> 4.0' +else + gem 'sprockets-rails' + gem 'sass-rails' +end group :test do gem 'simplecov', require: false diff --git a/active_admin_date_range_preset.gemspec b/active_admin_date_range_preset.gemspec index 20cb29b..ce573ed 100644 --- a/active_admin_date_range_preset.gemspec +++ b/active_admin_date_range_preset.gemspec @@ -18,18 +18,17 @@ Gem::Specification.new do |spec| # reach consumers until it is named here. The reject form needs a new # pattern every time the repo grows one, which is how 91 KB of README # screenshots ended up inside the published gem before this changed. - # `vendor/` is the shipped JS. + # `vendor/` is the shipped JS and CSS, `config/` the import map pin. spec.files = `git ls-files -z -- lib app vendor config exe bin README.md LICENSE.txt`.split("\x0") spec.bindir = "bin" spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ["lib"] # lib/ requires "activeadmin" at load time, so this is a hard runtime - # dependency, not an assumed-present host. The floor is what CI covers; - # the ceiling is real rather than cautious: Active Admin 4 drops the - # jquery-rails dependency and the app/assets/javascripts tree this gem - # plugs into, so none of it loads there. - spec.add_dependency "activeadmin", "~> 3.2" + # dependency, not an assumed-present host. The floor is what CI covers. + # Active Admin 3 loads the script through Sprockets, Active Admin 4 through + # its import map. + spec.add_dependency "activeadmin", ">= 3.2", "< 5" # Active Admin 3.2 itself still allows railties >= 6.1 and Ruby >= 2.6, # both of which reached end of life years ago. Declare what CI covers, so diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 0000000..0fc20df --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,2 @@ +# Pinned into ActiveAdmin.importmap, the map the Active Admin 4 layout renders. +pin "active_admin_date_range_preset", to: "active_admin_date_range_preset.js", preload: false diff --git a/lib/active_admin_date_range_preset.rb b/lib/active_admin_date_range_preset.rb index 13859f7..9142d5b 100644 --- a/lib/active_admin_date_range_preset.rb +++ b/lib/active_admin_date_range_preset.rb @@ -2,10 +2,29 @@ require "active_admin_date_range_preset/version" module ActiveAdminDateRangePreset - module Rails - class Engine < ::Rails::Engine + JAVASCRIPT_MODULE = "active_admin_date_range_preset".freeze + def self.aa_v4? + Gem::Version.new(ActiveAdmin::VERSION) >= Gem::Version.new("4.0.0.beta1") + end + + module Rails + class Engine < ::Rails::Engine + # Active Admin 4 renders its own import map, so the module is pinned + # there. Propshaft serves it from vendor/assets/javascripts, which every + # engine adds to the asset paths. + initializer "active_admin_date_range_preset.importmap", after: "active_admin.importmap" do |app| + next unless ActiveAdminDateRangePreset.aa_v4? && app.config.respond_to?(:importmap) + + ActiveAdmin.importmap.draw(root.join("config", "importmap.rb")) end - end + # javascript_modules is pending upstream (activeadmin/activeadmin#9148). + config.after_initialize do + if ActiveAdmin.application.respond_to?(:javascript_modules) + ActiveAdmin.application.javascript_modules |= [JAVASCRIPT_MODULE] + end + end + end + end end diff --git a/spec/date_range_preset_spec.rb b/spec/date_range_preset_spec.rb index 90a3912..8638273 100644 --- a/spec/date_range_preset_spec.rb +++ b/spec/date_range_preset_spec.rb @@ -7,15 +7,10 @@ before { add_post_resource } - def preset_inputs(scope) - inputs = scope.all('input[type="text"]') - [inputs.first, inputs.last] - end - context 'date_range filter in the sidebar' do before { visit '/admin/posts' } - let(:filter) { page.find('.filter_date_range') } + let(:filter) { page.find(PageHelpers::FILTER) } it 'adds a "Set range" link to the filter label' do expect(filter).to have_css('label.datetime_preset_filter_label a.btn_timerange', text: /set range/i) @@ -45,9 +40,7 @@ def preset_inputs(scope) filter.find('a.btn_timerange').click page.find('.block_timerange .btn_today').click - gteq, lteq = preset_inputs(filter) - expect(gteq.value).to eq(today.strftime('%Y-%m-%d')) - expect(lteq.value).to eq((today + 1).strftime('%Y-%m-%d')) + expect(range_in(filter)).to eq([today.strftime('%Y-%m-%d'), (today + 1).strftime('%Y-%m-%d')]) expect(page).to have_no_css('.block_timerange') end @@ -55,18 +48,14 @@ def preset_inputs(scope) filter.find('a.btn_timerange').click page.find('.block_timerange .btn_yesterday').click - gteq, lteq = preset_inputs(filter) - expect(gteq.value).to eq((today - 1).strftime('%Y-%m-%d')) - expect(lteq.value).to eq(today.strftime('%Y-%m-%d')) + expect(range_in(filter)).to eq([(today - 1).strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')]) end it 'fills both inputs with this month' do filter.find('a.btn_timerange').click page.find('.block_timerange .btn_month').click - gteq, lteq = preset_inputs(filter) - expect(gteq.value).to eq(today.strftime('%Y-%m-01')) - expect(lteq.value).to eq((today.next_month).strftime('%Y-%m-01')) + expect(range_in(filter)).to eq([today.strftime('%Y-%m-01'), today.next_month.strftime('%Y-%m-01')]) end it 'fills both inputs with this week, monday to monday' do @@ -74,9 +63,7 @@ def preset_inputs(scope) page.find('.block_timerange .btn_week').click monday = today - ((today.wday - 1) % 7) - gteq, lteq = preset_inputs(filter) - expect(gteq.value).to eq(monday.strftime('%Y-%m-%d')) - expect(lteq.value).to eq((monday + 7).strftime('%Y-%m-%d')) + expect(range_in(filter)).to eq([monday.strftime('%Y-%m-%d'), (monday + 7).strftime('%Y-%m-%d')]) end it 'filters the collection by the chosen range' do @@ -84,7 +71,7 @@ def preset_inputs(scope) Post.create!(title: 'fresh') visit '/admin/posts' - page.find('.filter_date_range a.btn_timerange').click + page.find("#{PageHelpers::FILTER} a.btn_timerange").click page.find('.block_timerange .btn_today').click page.find('input[type="submit"]').click @@ -110,4 +97,19 @@ def preset_inputs(scope) .to eq(today.strftime('%Y-%m-%d 00:00:00')) end end + + # active_admin_datetimepicker puts .datetime_preset_pair on a range filter + # wrapper that holds both inputs, so the auto-init has to fill both of them. + context 'datetime_preset_pair auto-init on a range filter' do + before { visit '/admin/posts' } + + let(:filter) { page.find('#q_published_at_input') } + + it 'fills the first and last input of the wrapper' do + filter.find('a.btn_timerange').click + page.find('.block_timerange .btn_today').click + + expect(range_in(filter)).to eq([today.strftime('%Y-%m-%d'), (today + 1).strftime('%Y-%m-%d')]) + end + end end diff --git a/spec/datetimepicker_markup_spec.rb b/spec/datetimepicker_markup_spec.rb new file mode 100644 index 0000000..78c207b --- /dev/null +++ b/spec/datetimepicker_markup_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +# The range filter markup active_admin_datetimepicker renders on Active Admin +# 4, injected as-is so the spec does not depend on that gem. +describe 'active_admin_datetimepicker range filters', type: :feature, js: true do + + WEDNESDAY_NOON_UTC = '2026-09-30T12:00:00Z'.freeze + + before do + add_post_resource + freeze_browser_time(WEDNESDAY_NOON_UTC) + visit '/admin/posts' + end + + def inject_filter(id, type, attr) + suffix = type == 'date' ? '' : '_datetime_picker' + page.execute_script(<<~JS) + var form = document.querySelector('form'); + form.insertAdjacentHTML('afterbegin', + '
' + + '
' + + '' + + '' + + '
'); + ActiveAdminDateRangePreset.init('##{id}'); + JS + end + + def pick_today(id) + page.find("##{id} [data-date-range-preset-toggle]").click + page.find("[data-date-range-preset-menu] [data-date-range-preset='today']").click + end + + def range(id) + %w[from to].map { |bound| page.find("##{id} [data-datetimepicker-range-input='#{bound}']").value } + end + + it 'fills datetime-local inputs with YYYY-MM-DDTHH:MM' do + inject_filter('dtp_datetime', 'datetime-local', 'published_at') + pick_today('dtp_datetime') + + expect(range('dtp_datetime')).to eq(['2026-09-30T00:00', '2026-10-01T00:00']) + end + + it 'fills date inputs of a date column with YYYY-MM-DD' do + inject_filter('dtp_date', 'date', 'published_on') + pick_today('dtp_date') + + expect(range('dtp_date')).to eq(['2026-09-30', '2026-10-01']) + end +end diff --git a/spec/init_once_spec.rb b/spec/init_once_spec.rb index 2ffe52c..95e83f4 100644 --- a/spec/init_once_spec.rb +++ b/spec/init_once_spec.rb @@ -8,30 +8,24 @@ before { visit '/admin/posts' } def filter - page.find('.filter_date_range') + page.find(PageHelpers::FILTER) end - def init_again(options = '') + def init_again(options = '{}') page.execute_script( - "$('form.filter_form div.filter_date_range').date_range_ext_preset(#{options});" + "ActiveAdminDateRangePreset.init('#{PageHelpers::FILTER}', #{options});" ) end # first, not find: with the bug there are two links, and an ambiguous-match # error would mask whatever the example is actually asserting. def pick_today - page.first('.filter_date_range a.btn_timerange').click + page.first("#{PageHelpers::FILTER} a.btn_timerange").click page.find('.block_timerange .btn_today').click end - def chosen_range - inputs = filter.all('input[type="text"]') - [inputs.first.value, inputs.last.value] - end - - # The auto-init was dead from jQuery 3 until it was restored, so the - # workaround -- calling the plugin by hand in active_admin.js -- is what - # host apps are carrying. Both run after an upgrade. + # The dummy app already initialises the filter once, so these examples see + # a second call on the same element. context 'with no options' do before { init_again } @@ -39,33 +33,30 @@ def chosen_range expect(filter).to have_css('a.btn_timerange', count: 1) end - it 'leaves one click handler, not one per call' do - count = page.evaluate_script( - "$._data($('form.filter_form div.filter_date_range')[0], 'events').click.length" - ) + it 'opens one menu, not one per call' do + page.first("#{PageHelpers::FILTER} a.btn_timerange").click - expect(count).to eq(1) + expect(page).to have_css('.block_timerange', count: 1) end it 'still fills the inputs' do pick_today - expect(chosen_range).to eq([today.strftime('%Y-%m-%d'), - (today + 1).strftime('%Y-%m-%d')]) + expect(range_in(filter)).to eq([today.strftime('%Y-%m-%d'), + (today + 1).strftime('%Y-%m-%d')]) end end - # Sprockets puts the plugin's own ready callback ahead of anything the host - # appends to active_admin.js, so a guard that kept the first wiring would - # drop the host's configuration without a word. + # A guard that kept the first wiring would drop the host's configuration + # without a word. context 'when the second call passes options' do before { init_again('{ date_to_human_readable: true }') } it 'honours them instead of discarding them' do pick_today - expect(chosen_range).to eq([today.strftime('%Y-%m-%d'), - today.strftime('%Y-%m-%d')]) + expect(range_in(filter)).to eq([today.strftime('%Y-%m-%d'), + today.strftime('%Y-%m-%d')]) end end end diff --git a/spec/link_placement_spec.rb b/spec/link_placement_spec.rb index 4f6455d..417beed 100644 --- a/spec/link_placement_spec.rb +++ b/spec/link_placement_spec.rb @@ -49,7 +49,7 @@ def geometry_of(scope) context 'in the sidebar filter' do before { visit '/admin/posts' } - let(:geometry) { geometry_of('.filter_date_range') } + let(:geometry) { geometry_of(PageHelpers::FILTER) } it 'stays pinned to the right of the label' do label_right = geometry['label']['left'] + geometry['label']['width'] diff --git a/spec/native_inputs_spec.rb b/spec/native_inputs_spec.rb new file mode 100644 index 0000000..8a706d7 --- /dev/null +++ b/spec/native_inputs_spec.rb @@ -0,0 +1,73 @@ +require 'spec_helper' + +# Native pickers only accept ISO values; anything else leaves them empty. +describe 'native date and datetime-local inputs', type: :feature, js: true do + + # Midday UTC, so the runner's own offset cannot move the date. + WEDNESDAY_NOON = '2026-09-30T12:00:00Z'.freeze + + before do + add_post_resource_with_native_pickers + freeze_browser_time(WEDNESDAY_NOON) + visit '/admin/posts/new' + end + + def pick(wrapper_id, preset) + page.find("##{wrapper_id} [data-date-range-preset-toggle]").click + page.find("[data-date-range-preset-menu] [data-date-range-preset='#{preset}']").click + end + + def value_of(wrapper_id) + page.find("##{wrapper_id} input").value + end + + it 'writes YYYY-MM-DDTHH:MM into datetime-local inputs' do + pick('post_published_at_input', 'today') + + expect(value_of('post_published_at_input')).to eq('2026-09-30T00:00') + expect(value_of('post_created_at_input')).to eq('2026-10-01T00:00') + end + + it 'writes YYYY-MM-DD into date inputs' do + pick('post_updated_at_input', 'week') + + expect(value_of('post_updated_at_input')).to eq('2026-09-28') + expect(value_of('post_title_input')).to eq('2026-10-05') + end + + it 'keeps the end of a human-readable range on the same day' do + page.execute_script( + "ActiveAdminDateRangePreset.init('#post_published_at_input', { date_to_human_readable: true });" + ) + pick('post_published_at_input', 'today') + + expect(value_of('post_created_at_input')).to eq('2026-09-30T23:59') + end +end + +describe 'test hooks and dark mode', type: :feature, js: true do + before do + add_post_resource + visit '/admin/posts' + page.find("#{PageHelpers::FILTER} [data-date-range-preset-toggle]").click + end + + it 'marks the menu and every preset with data attributes' do + menu = page.find('[data-date-range-preset-menu]') + keys = menu.all('[data-date-range-preset]').map { |item| item['data-date-range-preset'] } + + expect(keys).to eq(%w[today yesterday week month last_week last_month]) + end + + it 'darkens the menu under the .dark class' do + background = lambda do + page.evaluate_script( + "getComputedStyle(document.querySelector('[data-date-range-preset-menu]')).backgroundColor" + ) + end + + expect(background.call).to eq('rgb(244, 244, 244)') + page.execute_script("document.documentElement.classList.add('dark')") + expect(background.call).to eq('rgb(31, 41, 55)') + end +end diff --git a/spec/outside_click_spec.rb b/spec/outside_click_spec.rb index b71f913..6ee620f 100644 --- a/spec/outside_click_spec.rb +++ b/spec/outside_click_spec.rb @@ -6,7 +6,7 @@ before { visit '/admin/posts' } def open_presets - page.find('.filter_date_range a.btn_timerange').click + page.find("#{PageHelpers::FILTER} a.btn_timerange").click end def apply_todays_range @@ -23,7 +23,7 @@ def apply_todays_range expect(page).to have_current_path(/q(%5B|%5b|\[)/) open_presets - page.find('.clear_filters_btn').click + page.find(clear_filters_selector).click # _clearForm drops the q[...] params and keeps the rest, so assert on # the filter params alone rather than on a bare path. diff --git a/spec/per_element_options_spec.rb b/spec/per_element_options_spec.rb index 0e77a80..550930a 100644 --- a/spec/per_element_options_spec.rb +++ b/spec/per_element_options_spec.rb @@ -33,7 +33,7 @@ def value_of(wrapper_id) # them to opt out, and the attribute was write-only: the guard assigned # true and never false. it 'lets data-show-time="false" opt back out of a call that asked for time' do - page.execute_script("$('.manual_preset_pair').date_range_ext_preset({ show_time: true });") + page.execute_script("ActiveAdminDateRangePreset.init('.manual_preset_pair', { show_time: true });") pick_today_in('post_title_input') diff --git a/spec/popup_binding_spec.rb b/spec/popup_binding_spec.rb index bf357ad..dfc8606 100644 --- a/spec/popup_binding_spec.rb +++ b/spec/popup_binding_spec.rb @@ -8,7 +8,7 @@ before { visit '/admin/posts' } def gteq_value - page.find('.filter_date_range').all('input[type="text"]').first.value + field_inputs(page.find(PageHelpers::FILTER)).first.value end # Both clicks happen in one task, so the preset is clicked in the same tick @@ -16,11 +16,11 @@ def gteq_value # yet and the click lands on nothing. # Wait for the link through Capybara first, otherwise a page that has not # finished rendering fails as a JS TypeError instead of an assertion. - before { page.find('.filter_date_range a.btn_timerange') } + before { page.find("#{PageHelpers::FILTER} a.btn_timerange") } it 'has the presets live as soon as the popup is in the document' do page.execute_script(<<~JS) - var btn = document.querySelector('.filter_date_range a.btn_timerange'); + var btn = document.querySelector('#q_created_at_input a.btn_timerange'); btn.click(); document.querySelector('.block_timerange .btn_today').click(); JS @@ -30,7 +30,7 @@ def gteq_value it 'has the dismissal listener live just as soon' do page.execute_script(<<~JS) - document.querySelector('.filter_date_range a.btn_timerange').click(); + document.querySelector('#q_created_at_input a.btn_timerange').click(); document.body.click(); JS diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 916bd09..8b0b21e 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -44,6 +44,8 @@ require 'support/admin' require 'support/capybara' require 'support/browser_time' +require 'support/tailwind' +require 'support/page_helpers' RSpec.configure do |config| config.use_transactional_fixtures = false diff --git a/spec/support/admin.rb b/spec/support/admin.rb index e6f62d1..20b0d85 100644 --- a/spec/support/admin.rb +++ b/spec/support/admin.rb @@ -5,6 +5,9 @@ def add_post_resource permit_params :title, :body, :published_at filter :created_at, as: :date_range + # Both inputs in one .datetime_preset_pair wrapper, the markup + # active_admin_datetimepicker's range filter renders. Auto-initialised. + filter :published_at, as: :date_range, wrapper_html: { class: 'datetime_preset_pair' } form do |f| f.inputs do @@ -48,3 +51,26 @@ def add_post_resource_with_two_pairs Rails.application.reload_routes! end + +# Native pickers: the plugin writes ISO values into them on any Active Admin. +def add_post_resource_with_native_pickers + ActiveAdmin.register Post do + config.filters = false + + permit_params :title, :body, :published_at, :created_at, :updated_at + + form do |f| + f.inputs do + f.input :published_at, as: :datetime_picker, + wrapper_html: { class: 'datetime_preset_pair' } + f.input :created_at, as: :datetime_picker + f.input :updated_at, as: :date_picker, + wrapper_html: { class: 'datetime_preset_pair' } + f.input :title, as: :date_picker + end + f.actions + end + end + + Rails.application.reload_routes! +end diff --git a/spec/support/browser_time.rb b/spec/support/browser_time.rb index 22e8125..88c515d 100644 --- a/spec/support/browser_time.rb +++ b/spec/support/browser_time.rb @@ -1,8 +1,7 @@ -# Every preset the plugin offers is derived from one "now", read once when -# date_range_ext_preset() runs. Pinning that value is the only way to assert a -# specific calendar day, so install the stub through CDP before any of the -# page's own scripts run rather than after the plugin has already read the -# clock. +# Every preset the plugin offers is derived from "now", read when a preset is +# picked. Pinning that value is the only way to assert a specific calendar +# day, so install the stub through CDP before any of the page's own scripts +# run. module BrowserTime def freeze_browser_time(iso) source = <<~JS diff --git a/spec/support/page_helpers.rb b/spec/support/page_helpers.rb new file mode 100644 index 0000000..8f4075b --- /dev/null +++ b/spec/support/page_helpers.rb @@ -0,0 +1,26 @@ +# The few places where Active Admin 3 and 4 render the same thing differently. +module PageHelpers + FILTER = '#q_created_at_input'.freeze + + def aa_v4? + ActiveAdminDateRangePreset.aa_v4? + end + + def clear_filters_selector + aa_v4? ? '.filters-form-clear' : '.clear_filters_btn' + end + + # Text inputs on Active Admin 3, native date inputs on Active Admin 4. + def field_inputs(scope) + scope.all('input:not([type="hidden"])') + end + + def range_in(scope) + inputs = field_inputs(scope) + [inputs.first.value, inputs.last.value] + end +end + +RSpec.configure do |config| + config.include PageHelpers, type: :feature +end diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index b4a1b26..f3393fa 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -1,8 +1,12 @@ # Rails template to build the sample app for specs -# Ensure Sprockets manifest exists (Rails 8.0+ no longer generates it) -FileUtils.mkdir_p("app/assets/config") -File.write("app/assets/config/manifest.js", "//= link_tree ../images\n") +aa_v4 = Gem.loaded_specs["activeadmin"].version >= Gem::Version.new("4.0.0.beta1") + +unless aa_v4 + # Ensure Sprockets manifest exists (Rails 8.0+ no longer generates it) + FileUtils.mkdir_p("app/assets/config") + File.write("app/assets/config/manifest.js", "//= link_tree ../images\n") +end # The generated test env eager loads only when CI is set, and without eager # loading Zeitwerk drops Active Admin's own constants (ActiveAdmin::BatchActions @@ -30,21 +34,36 @@ generate :'active_admin:install --skip-users' generate :'formtastic:install' -# Install active_admin_date_range_preset assets -inject_into_file "app/assets/stylesheets/active_admin.scss", - "@import \"active_admin_date_range_preset\";\n", - after: "@import \"active_admin/base\";\n" - -inject_into_file "app/assets/javascripts/active_admin.js", - "//= require active_admin_date_range_preset\n", - after: "//= require active_admin/base\n" - -# Documented wiring for date_range filters (README "Using with ActiveAdminDatetimepicker") -append_to_file "app/assets/javascripts/active_admin.js", <<~JS - $(function () { - $('form.filter_form div.filter_date_range').date_range_ext_preset(); - }); -JS +if aa_v4 + # The documented Active Admin 4 wiring: import the module after Active + # Admin's own, then init the date_range filter. spec/support/tailwind.rb + # builds active_admin.css with the gem's CSS imported. + aa_head = File.join(Gem.loaded_specs["activeadmin"].full_gem_path, + "app/views/active_admin/_html_head.html.erb") + create_file "app/views/active_admin/_html_head.html.erb", File.read(aa_head) + <<~ERB + <%= javascript_import_module_tag "active_admin_date_range_preset" %> + + ERB +else + # Install active_admin_date_range_preset assets + inject_into_file "app/assets/stylesheets/active_admin.scss", + "@import \"active_admin_date_range_preset\";\n", + after: "@import \"active_admin/base\";\n" + + inject_into_file "app/assets/javascripts/active_admin.js", + "//= require active_admin_date_range_preset\n", + after: "//= require active_admin/base\n" + + # Documented wiring for date_range filters + append_to_file "app/assets/javascripts/active_admin.js", <<~JS + document.addEventListener('DOMContentLoaded', function () { + ActiveAdminDateRangePreset.init('form.filter_form div.filter_date_range'); + }); + JS +end # The dummy app's own test/ and generator-made spec/ would otherwise be # picked up by `rspec spec` from the gem root. diff --git a/spec/support/tailwind.rb b/spec/support/tailwind.rb new file mode 100644 index 0000000..9e2ab98 --- /dev/null +++ b/spec/support/tailwind.rb @@ -0,0 +1,41 @@ +require 'fileutils' + +# Active Admin 4 serves a Tailwind build of the host's active_admin.css. Build +# it with the gem's CSS imported, the way the README tells hosts to, so the +# layout specs measure what a host would see. Rebuilt every run because the +# dummy app is cached and the gem's CSS may have changed since. +module TailwindBuild + module_function + + def compile! + return unless ActiveAdminDateRangePreset.aa_v4? + + aa_path = Gem.loaded_specs['activeadmin'].full_gem_path + gem_css = File.expand_path('../../vendor/assets/stylesheets/active_admin_date_range_preset.css', __dir__) + app_root = ENV['RAILS_ROOT'] + input = File.join(app_root, 'tmp/active_admin_tailwind_input.css') + output = File.join(app_root, 'app/assets/stylesheets/active_admin.css') + + FileUtils.mkdir_p(File.dirname(input)) + File.write(input, <<~CSS) + @import "tailwindcss"; + @import "#{gem_css}"; + @plugin "#{aa_path}/plugin.js"; + @custom-variant dark (&:where(.dark, .dark *)); + @source "#{aa_path}/app/views/**/*"; + @source "#{aa_path}/lib/active_admin/**/*"; + @source "#{aa_path}/vendor/javascript/flowbite.js"; + @source "#{aa_path}/plugin.js"; + @source "#{app_root}/app/admin/**/*"; + @source "#{app_root}/app/views/**/*"; + CSS + + cli = Gem.bin_path('tailwindcss-ruby', 'tailwindcss') + system(cli, '-i', input, '-o', output, out: File::NULL, err: File::NULL) or + raise 'tailwindcss build failed' + end +end + +RSpec.configure do |config| + config.before(:suite) { TailwindBuild.compile! } +end diff --git a/spec/week_boundaries_spec.rb b/spec/week_boundaries_spec.rb index b00a774..936df9b 100644 --- a/spec/week_boundaries_spec.rb +++ b/spec/week_boundaries_spec.rb @@ -5,12 +5,11 @@ before { add_post_resource } def open_presets - page.find('.filter_date_range a.btn_timerange').click + page.find("#{PageHelpers::FILTER} a.btn_timerange").click end def chosen_range - inputs = page.find('.filter_date_range').all('input[type="text"]') - [inputs.first.value, inputs.last.value] + range_in(page.find(PageHelpers::FILTER)) end # Midday UTC, so the runner's own offset cannot move the date. From e8c442bab5439cf1d6bb1e29e77fc42996fc9465 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 14:01:02 +0200 Subject: [PATCH 4/7] Read defaults at click time and make the menu keyboard-usable On Active Admin 4 the module's auto-init runs before a host module that imports it, so defaults set there never reached auto-initialised pairs. Co-Authored-By: Clanker --- CHANGELOG.md | 8 ++- README.md | 6 +- spec/keyboard_and_defaults_spec.rb | 69 +++++++++++++++++++ spec/per_element_options_spec.rb | 3 +- .../active_admin_date_range_preset.js | 34 ++++++--- 5 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 spec/keyboard_and_defaults_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 21935ba..3f470b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,11 @@ and `$.fn.date_range_ext_preset.defaults.show_time = true` becomes `ActiveAdminDateRangePreset.defaults.show_time = true`. - `gteq_input` and `lteq_input` take CSS selectors or elements, not jQuery selectors. -- The stylesheet is plain CSS. `@import "active_admin_date_range_preset";` still works. +- The stylesheet is plain CSS: `active_admin_date_range_preset.scss` is now + `active_admin_date_range_preset.css`, in the gem and the npm package. A bare + `@import "active_admin_date_range_preset";` still works. +- Filling an input fires bubbling `input` and `change` events. +- `add_range` titles are plain text; HTML in a title is shown as written. ### Added @@ -29,3 +33,5 @@ `data-date-range-preset=""` hooks. - A `.datetime_preset_pair` wrapper that holds both inputs, such as a range filter, fills its own first and last input. +- Opening the menu focuses the first preset. Arrow keys move between presets, + Enter or Space picks one and Escape closes the menu. diff --git a/README.md b/README.md index 8b43610..ac98117 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ document.addEventListener('DOMContentLoaded', function () { ``` To turn it on for every pair, including the ones set up automatically, set -the default instead, outside any `DOMContentLoaded` callback: +the default instead: ```javascript ActiveAdminDateRangePreset.defaults.show_time = true; @@ -244,8 +244,8 @@ key: `today`, `yesterday`, `week`, `month`, `last_week`, `last_month`, then ## Settings Pass settings to `init` for some elements, or set them for every later `init` -on `ActiveAdminDateRangePreset.defaults`. Set defaults before the page finishes -loading, since pairs are set up on `DOMContentLoaded`. +on `ActiveAdminDateRangePreset.defaults`. Defaults are read each time a menu +opens, so they apply to pairs that were set up before you changed them. ```javascript // End date is the same day, not the next one. diff --git a/spec/keyboard_and_defaults_spec.rb b/spec/keyboard_and_defaults_spec.rb new file mode 100644 index 0000000..64540ad --- /dev/null +++ b/spec/keyboard_and_defaults_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +describe 'keyboard, late defaults and lone inputs', type: :feature, js: true do + + let(:today) { Time.now.utc.to_date } + + before { add_post_resource } + + def active_preset + page.evaluate_script("document.activeElement.getAttribute('data-date-range-preset')") + end + + def toggle_focused?(scope) + page.evaluate_script( + "document.activeElement === document.querySelector('#{scope} [data-date-range-preset-toggle]')" + ) + end + + context 'in the filter sidebar' do + before { visit '/admin/posts' } + + let(:filter) { page.find(PageHelpers::FILTER) } + + it 'focuses the first preset, moves with arrows and picks with Enter' do + filter.find('[data-date-range-preset-toggle]').click + expect(active_preset).to eq('today') + + page.send_keys(:down) + expect(active_preset).to eq('yesterday') + + page.send_keys(:enter) + expect(page).to have_no_css('[data-date-range-preset-menu]') + expect(range_in(filter)).to eq([(today - 1).strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')]) + expect(toggle_focused?(PageHelpers::FILTER)).to be(true) + end + + it 'closes on Escape and returns focus to the link' do + filter.find('[data-date-range-preset-toggle]').click + page.send_keys(:escape) + + expect(page).to have_no_css('[data-date-range-preset-menu]') + expect(toggle_focused?(PageHelpers::FILTER)).to be(true) + end + + # On Active Admin 4 the module, and its auto-init, runs before a host + # module that imports it, so defaults are set after the pairs are wired. + it 'applies defaults set after the automatic init' do + page.execute_script('ActiveAdminDateRangePreset.defaults.date_to_human_readable = true;') + auto = page.find('#q_published_at_input') + auto.find('[data-date-range-preset-toggle]').click + page.find("[data-date-range-preset-menu] [data-date-range-preset='today']").click + + expect(range_in(auto)).to eq([today.strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')]) + end + end + + it 'fills only the start of a pair whose partner has no input' do + visit '/admin/posts' + page.execute_script(<<~JS) + document.querySelector('form').insertAdjacentHTML('afterbegin', + '
'); + ActiveAdminDateRangePreset.init('#lone'); + JS + page.find('#lone [data-date-range-preset-toggle]').click + page.find("[data-date-range-preset-menu] [data-date-range-preset='today']").click + + expect(page.find('#lone_input').value).to eq(today.strftime('%Y-%m-%d')) + end +end diff --git a/spec/per_element_options_spec.rb b/spec/per_element_options_spec.rb index 550930a..ddf877e 100644 --- a/spec/per_element_options_spec.rb +++ b/spec/per_element_options_spec.rb @@ -37,6 +37,7 @@ def value_of(wrapper_id) pick_today_in('post_title_input') - expect(value_of('post_title_input')).to eq((today + 1).strftime('%Y-%m-%d')) + # The title input is the last on the form, so it only gets the start. + expect(value_of('post_title_input')).to eq(today.strftime('%Y-%m-%d')) end end diff --git a/vendor/assets/javascripts/active_admin_date_range_preset.js b/vendor/assets/javascripts/active_admin_date_range_preset.js index 8470ee9..ed08a8a 100644 --- a/vendor/assets/javascripts/active_admin_date_range_preset.js +++ b/vendor/assets/javascripts/active_admin_date_range_preset.js @@ -110,8 +110,9 @@ var gteq = inputs[0]; var lteq = inputs[inputs.length - 1]; var next = el.nextElementSibling; - if (inputs.length < 2 && next && fieldInputs(next)[0]) { - lteq = fieldInputs(next)[0]; + // A lone input with no partner gets only the start, not both ends. + if (inputs.length < 2) { + lteq = (next && fieldInputs(next)[0]) || null; } return [resolveInput(options.gteq_input) || gteq, resolveInput(options.lteq_input) || lteq]; } @@ -170,9 +171,16 @@ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); choose(e.target); + toggle.focus(); } else if (e.key === 'Escape') { closeOpenMenu(); toggle.focus(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + var buttons = Array.prototype.slice.call(menu.querySelectorAll('[data-date-range-preset]')); + var step = e.key === 'ArrowDown' ? 1 : -1; + var at = buttons.indexOf(document.activeElement); + buttons[(at + step + buttons.length) % buttons.length].focus(); } }); @@ -199,20 +207,29 @@ var overflow = menu.getBoundingClientRect().right - document.documentElement.clientWidth; if (overflow > 0) { menu.style.left = Math.max(0, box.left + window.scrollX - overflow) + 'px'; } toggle.setAttribute('aria-expanded', 'true'); - } - function initElement(el, options) { - // Re-initialising replaces the wiring instead of adding to it, so a host - // call with options after the auto-init wins. The listener lives on the - // link, so dropping the old link drops the old wiring with it. - el.querySelectorAll('[data-date-range-preset-toggle]').forEach(function (link) { link.remove(); }); + // The menu sits at the end of , out of the tab order near the link. + var first = menu.querySelector('[data-date-range-preset]'); + if (first) { first.focus(); } + } + // Read at click time: on Active Admin 4 the module, and so the auto-init, + // runs before a host module that imports it can set defaults. + function currentOptions(el, options) { var opts = Object.assign({}, defaults, options); opts.add_range = opts.add_range || []; // The attribute is authoritative in both directions when present. var showTime = el.getAttribute('data-show-time'); if (showTime !== null) { opts.show_time = showTime === 'true'; } + return opts; + } + + function initElement(el, options) { + // Re-initialising replaces the wiring instead of adding to it, so a host + // call with options after the auto-init wins. The listener lives on the + // link, so dropping the old link drops the old wiring with it. + el.querySelectorAll('[data-date-range-preset-toggle]').forEach(function (link) { link.remove(); }); var inputs = detectInputs(el, options); var gteq = inputs[0]; @@ -237,6 +254,7 @@ toggle.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); + var opts = currentOptions(el, options); openMenu(toggle, opts, function (range) { var end = range[1]; if (opts.date_to_human_readable) { end = new Date(end.getTime() - 1000); } From 1b31cc6ad8bb01c862868a1a9f601d7ae53ed71e Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 14:06:28 +0200 Subject: [PATCH 5/7] Exclude ActiveAdmin 5 prereleases RubyGems treats 5.0.0.beta1 as satisfying "< 5". Co-Authored-By: Clanker --- active_admin_date_range_preset.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/active_admin_date_range_preset.gemspec b/active_admin_date_range_preset.gemspec index ce573ed..e12436a 100644 --- a/active_admin_date_range_preset.gemspec +++ b/active_admin_date_range_preset.gemspec @@ -28,7 +28,7 @@ Gem::Specification.new do |spec| # dependency, not an assumed-present host. The floor is what CI covers. # Active Admin 3 loads the script through Sprockets, Active Admin 4 through # its import map. - spec.add_dependency "activeadmin", ">= 3.2", "< 5" + spec.add_dependency "activeadmin", ">= 3.2", "< 5.0.0.a" # Active Admin 3.2 itself still allows railties >= 6.1 and Ruby >= 2.6, # both of which reached end of life years ago. Declare what CI covers, so From ecd66589a7f4223729c15c703873a529b9e31860 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 14:10:48 +0200 Subject: [PATCH 6/7] Keep seconds in datetime-local values when the step allows them With date_to_human_readable the end is 23:59:59, and cutting it to 23:59 dropped the last minute from an inclusive lteq filter. Seconds are only written when the input's step permits them, since the default 60-second step rejects such a value and blocks the submit. Co-Authored-By: Clanker --- CHANGELOG.md | 3 ++- README.md | 2 +- spec/native_inputs_spec.rb | 11 +++++++++++ .../javascripts/active_admin_date_range_preset.js | 12 ++++++++++-- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f470b7..79fa1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,8 @@ ### Added - Active Admin 4 support: the module is pinned into `ActiveAdmin.importmap`. -- `date` inputs get `YYYY-MM-DD` and `datetime-local` inputs get `YYYY-MM-DDTHH:MM`. +- `date` inputs get `YYYY-MM-DD` and `datetime-local` inputs get `YYYY-MM-DDTHH:MM`, + plus seconds when the input's `step` allows them. - Dark mode for the menu under the `.dark` class. - `data-date-range-preset-toggle`, `data-date-range-preset-menu` and `data-date-range-preset=""` hooks. diff --git a/README.md b/README.md index ac98117..96bba76 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ The value format follows the input's `type`: | Input | Value | | --- | --- | | `type="date"` | `2015-06-10` | -| `type="datetime-local"` | `2015-06-10T00:00` | +| `type="datetime-local"` | `2015-06-10T00:00`, with `:SS` when its `step` allows seconds | | text | `2015-06-10`, or `2015-06-10 00:00:00` with `show_time` | `data-show-time` overrides `show_time` for that pair, in both directions. diff --git a/spec/native_inputs_spec.rb b/spec/native_inputs_spec.rb index 8a706d7..7767acf 100644 --- a/spec/native_inputs_spec.rb +++ b/spec/native_inputs_spec.rb @@ -35,12 +35,23 @@ def value_of(wrapper_id) expect(value_of('post_title_input')).to eq('2026-10-05') end + # Formtastic's datetime_picker renders step="1", so seconds are valid. it 'keeps the end of a human-readable range on the same day' do page.execute_script( "ActiveAdminDateRangePreset.init('#post_published_at_input', { date_to_human_readable: true });" ) pick('post_published_at_input', 'today') + expect(value_of('post_created_at_input')).to eq('2026-09-30T23:59:59') + end + + it 'drops the seconds when the input step is whole minutes' do + page.execute_script(<<~JS) + document.querySelector('#post_created_at_input input').removeAttribute('step'); + ActiveAdminDateRangePreset.init('#post_published_at_input', { date_to_human_readable: true }); + JS + pick('post_published_at_input', 'today') + expect(value_of('post_created_at_input')).to eq('2026-09-30T23:59') end end diff --git a/vendor/assets/javascripts/active_admin_date_range_preset.js b/vendor/assets/javascripts/active_admin_date_range_preset.js index ed08a8a..09536ba 100644 --- a/vendor/assets/javascripts/active_admin_date_range_preset.js +++ b/vendor/assets/javascripts/active_admin_date_range_preset.js @@ -70,9 +70,17 @@ function formatFor(input, date, opts) { var ymd = date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()); var hm = pad(date.getHours()) + ':' + pad(date.getMinutes()); + var hms = hm + ':' + pad(date.getSeconds()); if (input.type === 'date') { return ymd; } - if (input.type === 'datetime-local') { return ymd + 'T' + hm; } - return opts.show_time ? ymd + ' ' + hm + ':' + pad(date.getSeconds()) : ymd; + if (input.type === 'datetime-local') { return ymd + 'T' + (allowsSeconds(input) ? hms : hm); } + return opts.show_time ? ymd + ' ' + hms : ymd; + } + + // The default step of a datetime-local input is 60 seconds, and a value with + // seconds then fails validation and blocks the form submit. + function allowsSeconds(input) { + var step = input.getAttribute('step'); + return step === 'any' || (step !== null && Number(step) % 60 !== 0); } // "Now" in UTC shifted by hours_offset, as a local Date whose fields hold From 6601616eeef59a50f553f538f72ec3155ad00198 Mon Sep 17 00:00:00 2001 From: Denis Talakevich Date: Thu, 12 Nov 2026 14:18:14 +0200 Subject: [PATCH 7/7] Fix review findings in input detection, keyboard and packaging Only a .datetime_preset_pair looks for its end input in the next sibling again, as before the rewrite, so init() on any other element cannot write into an unrelated field. Tabbing out closes the menu, ArrowUp with no preset focused starts at the bottom, a replaced defaults object and jQuery input options are honoured, and the npm build puts the CSS where package.json's style field points. Co-Authored-By: Clanker --- package.json | 2 +- spec/init_once_spec.rb | 6 +-- spec/keyboard_and_defaults_spec.rb | 47 ++++++++++++++++++- spec/per_element_options_spec.rb | 3 +- spec/support/admin.rb | 6 +-- .../active_admin_date_range_preset.js | 40 +++++++++++----- 6 files changed, 81 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index c6a9a6f..2d1e79b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,6 @@ "src/**/*" ], "scripts": { - "prepublishOnly": "rm -rf src && cp -R vendor/assets/javascripts/ src && cp -R vendor/assets/stylesheets/ src" + "prepublishOnly": "rm -rf src && mkdir src && cp vendor/assets/javascripts/*.js vendor/assets/stylesheets/*.css src/" } } diff --git a/spec/init_once_spec.rb b/spec/init_once_spec.rb index 95e83f4..8bb0c82 100644 --- a/spec/init_once_spec.rb +++ b/spec/init_once_spec.rb @@ -42,8 +42,7 @@ def pick_today it 'still fills the inputs' do pick_today - expect(range_in(filter)).to eq([today.strftime('%Y-%m-%d'), - (today + 1).strftime('%Y-%m-%d')]) + expect(range_in(filter)).to eq([today.strftime('%Y-%m-%d'), (today + 1).strftime('%Y-%m-%d')]) end end @@ -55,8 +54,7 @@ def pick_today it 'honours them instead of discarding them' do pick_today - expect(range_in(filter)).to eq([today.strftime('%Y-%m-%d'), - today.strftime('%Y-%m-%d')]) + expect(range_in(filter)).to eq([today.strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')]) end end end diff --git a/spec/keyboard_and_defaults_spec.rb b/spec/keyboard_and_defaults_spec.rb index 64540ad..ef81088 100644 --- a/spec/keyboard_and_defaults_spec.rb +++ b/spec/keyboard_and_defaults_spec.rb @@ -42,6 +42,37 @@ def toggle_focused?(scope) expect(toggle_focused?(PageHelpers::FILTER)).to be(true) end + it 'starts from the last preset on ArrowUp when no preset has focus' do + filter.find('[data-date-range-preset-toggle]').click + page.execute_script('document.activeElement.blur();') + page.execute_script(<<~JS) + document.querySelector('[data-date-range-preset-menu]') + .dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + JS + + expect(active_preset).to eq('last_month') + end + + it 'closes when focus tabs out of the menu' do + filter.find('[data-date-range-preset-toggle]').click + page.execute_script("document.querySelector('[data-date-range-preset=\"last_month\"]').focus();") + page.execute_script("document.querySelector('form input:not([type=\"hidden\"])').focus();") + + expect(page).to have_no_css('[data-date-range-preset-menu]') + end + + it 'reads a defaults object the host replaced' do + page.execute_script(<<~JS) + ActiveAdminDateRangePreset.defaults = Object.assign({}, ActiveAdminDateRangePreset.defaults, + { date_to_human_readable: true }); + JS + auto = page.find('#q_published_at_input') + auto.find('[data-date-range-preset-toggle]').click + page.find("[data-date-range-preset-menu] [data-date-range-preset='today']").click + + expect(range_in(auto)).to eq([today.strftime('%Y-%m-%d'), today.strftime('%Y-%m-%d')]) + end + # On Active Admin 4 the module, and its auto-init, runs before a host # module that imports it, so defaults are set after the pairs are wired. it 'applies defaults set after the automatic init' do @@ -58,7 +89,7 @@ def toggle_focused?(scope) visit '/admin/posts' page.execute_script(<<~JS) document.querySelector('form').insertAdjacentHTML('afterbegin', - '
'); + '
'); ActiveAdminDateRangePreset.init('#lone'); JS page.find('#lone [data-date-range-preset-toggle]').click @@ -66,4 +97,18 @@ def toggle_focused?(scope) expect(page.find('#lone_input').value).to eq(today.strftime('%Y-%m-%d')) end + + it 'leaves the next field alone for an element that is not a pair' do + visit '/admin/posts' + page.execute_script(<<~JS) + document.querySelector('form').insertAdjacentHTML('afterbegin', + '
' + + '
'); + ActiveAdminDateRangePreset.init('#single'); + JS + page.find('#single [data-date-range-preset-toggle]').click + page.find("[data-date-range-preset-menu] [data-date-range-preset='today']").click + + expect(page.find('#unrelated').value).to eq('') + end end diff --git a/spec/per_element_options_spec.rb b/spec/per_element_options_spec.rb index ddf877e..550930a 100644 --- a/spec/per_element_options_spec.rb +++ b/spec/per_element_options_spec.rb @@ -37,7 +37,6 @@ def value_of(wrapper_id) pick_today_in('post_title_input') - # The title input is the last on the form, so it only gets the start. - expect(value_of('post_title_input')).to eq(today.strftime('%Y-%m-%d')) + expect(value_of('post_title_input')).to eq((today + 1).strftime('%Y-%m-%d')) end end diff --git a/spec/support/admin.rb b/spec/support/admin.rb index 20b0d85..3bbb086 100644 --- a/spec/support/admin.rb +++ b/spec/support/admin.rb @@ -61,11 +61,9 @@ def add_post_resource_with_native_pickers form do |f| f.inputs do - f.input :published_at, as: :datetime_picker, - wrapper_html: { class: 'datetime_preset_pair' } + f.input :published_at, as: :datetime_picker, wrapper_html: { class: 'datetime_preset_pair' } f.input :created_at, as: :datetime_picker - f.input :updated_at, as: :date_picker, - wrapper_html: { class: 'datetime_preset_pair' } + f.input :updated_at, as: :date_picker, wrapper_html: { class: 'datetime_preset_pair' } f.input :title, as: :date_picker end f.actions diff --git a/vendor/assets/javascripts/active_admin_date_range_preset.js b/vendor/assets/javascripts/active_admin_date_range_preset.js index 09536ba..7dee0ef 100644 --- a/vendor/assets/javascripts/active_admin_date_range_preset.js +++ b/vendor/assets/javascripts/active_admin_date_range_preset.js @@ -38,16 +38,20 @@ return [start, day(start, 7)]; } }, { key: 'month', title: 'This Month', range: function (now) { - return [new Date(now.getFullYear(), now.getMonth(), 1), - new Date(now.getFullYear(), now.getMonth() + 1, 1)]; + return [ + new Date(now.getFullYear(), now.getMonth(), 1), + new Date(now.getFullYear(), now.getMonth() + 1, 1) + ]; } }, { key: 'last_week', title: 'Last Week', range: function (now) { var end = day(now, -daysSinceMonday(now)); return [day(end, -7), end]; } }, { key: 'last_month', title: 'Last Month', range: function (now) { - return [new Date(now.getFullYear(), now.getMonth() - 1, 1), - new Date(now.getFullYear(), now.getMonth(), 1)]; + return [ + new Date(now.getFullYear(), now.getMonth() - 1, 1), + new Date(now.getFullYear(), now.getMonth(), 1) + ]; } } ]; @@ -101,9 +105,12 @@ input.dispatchEvent(new Event('change', { bubbles: true })); } + // Also takes a jQuery object, which hosts migrating from the jQuery plugin + // still pass. function resolveInput(option) { if (!option) { return null; } - return typeof option === 'string' ? document.querySelector(option) : option; + if (typeof option === 'string') { return document.querySelector(option); } + return option.jquery ? option[0] || null : option; } function fieldInputs(el) { @@ -111,15 +118,16 @@ } // A wrapper holding both inputs (a date_range filter) uses its first and - // last. A Formtastic form pair is two wrappers with one input each, so the - // "to" input lives in the next wrapper. + // last. A Formtastic .datetime_preset_pair is two wrappers with one input + // each, so the "to" input lives in the next wrapper. Only that class looks + // there: any other element must not write into an unrelated next field. function detectInputs(el, options) { var inputs = fieldInputs(el); var gteq = inputs[0]; var lteq = inputs[inputs.length - 1]; var next = el.nextElementSibling; - // A lone input with no partner gets only the start, not both ends. - if (inputs.length < 2) { + // A lone pair input with no partner gets only the start, not both ends. + if (inputs.length < 2 && el.classList.contains('datetime_preset_pair')) { lteq = (next && fieldInputs(next)[0]) || null; } return [resolveInput(options.gteq_input) || gteq, resolveInput(options.lteq_input) || lteq]; @@ -188,10 +196,18 @@ var buttons = Array.prototype.slice.call(menu.querySelectorAll('[data-date-range-preset]')); var step = e.key === 'ArrowDown' ? 1 : -1; var at = buttons.indexOf(document.activeElement); + // With no preset focused, ArrowUp starts from the bottom. + if (at === -1) { at = step === 1 ? -1 : 0; } buttons[(at + step + buttons.length) % buttons.length].focus(); } }); + // Tabbing out of the menu closes it. A null relatedTarget is a click on + // something unfocusable, which onOutsideClick already handles. + menu.addEventListener('focusout', function (e) { + if (e.relatedTarget && !menu.contains(e.relatedTarget)) { closeOpenMenu(); } + }); + // No stopPropagation: this listener only watches for a click that should // close the menu. Active Admin delegates Clear Filters and the has_many // buttons from , so swallowing the event would break them. @@ -224,7 +240,8 @@ // Read at click time: on Active Admin 4 the module, and so the auto-init, // runs before a host module that imports it can set defaults. function currentOptions(el, options) { - var opts = Object.assign({}, defaults, options); + // api.defaults, so a host that replaces the whole object is still heard. + var opts = Object.assign({}, api.defaults, options); opts.add_range = opts.add_range || []; // The attribute is authoritative in both directions when present. @@ -285,7 +302,8 @@ return elements; } - window.ActiveAdminDateRangePreset = { defaults: defaults, init: init }; + var api = { defaults: defaults, init: init }; + window.ActiveAdminDateRangePreset = api; function autoInit() { init('.datetime_preset_pair');