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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..79fa1c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# 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: `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 + +- 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`, + 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. +- 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/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/README.md b/README.md index 23e77c2..96bba76 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: + +```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`, with `:SS` when its `step` allows seconds | +| 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`. Defaults are read each time a menu +opens, so they apply to pairs that were set up before you changed them. ```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/active_admin_date_range_preset.gemspec b/active_admin_date_range_preset.gemspec index 20cb29b..e12436a 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.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 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/package.json b/package.json index 7124a6e..2d1e79b 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", @@ -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/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..8bb0c82 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,28 @@ 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/keyboard_and_defaults_spec.rb b/spec/keyboard_and_defaults_spec.rb new file mode 100644 index 0000000..ef81088 --- /dev/null +++ b/spec/keyboard_and_defaults_spec.rb @@ -0,0 +1,114 @@ +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 + + 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 + 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 + + 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/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..7767acf --- /dev/null +++ b/spec/native_inputs_spec.rb @@ -0,0 +1,84 @@ +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 + + # 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 + +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..3bbb086 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,24 @@ 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 bdcc4d7..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 @@ -14,6 +18,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" @@ -27,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. diff --git a/vendor/assets/javascripts/active_admin_date_range_preset.js b/vendor/assets/javascripts/active_admin_date_range_preset.js index 8129686..7dee0ef 100644 --- a/vendor/assets/javascripts/active_admin_date_range_preset.js +++ b/vendor/assets/javascripts/active_admin_date_range_preset.js @@ -1,245 +1,317 @@ -$(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) + ]; + } } + ]; + + 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; + } - // local datetime now - let now = new Date(); - // UTC datetime now - let now_utc = new Date( + 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()); + var hms = hm + ':' + pad(date.getSeconds()); + if (input.type === 'date') { return 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 + // 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'); - } + // Also takes a jQuery object, which hosts migrating from the jQuery plugin + // still pass. + function resolveInput(option) { + if (!option) { return null; } + if (typeof option === 'string') { return document.querySelector(option); } + return option.jquery ? option[0] || null : 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 .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 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]; + } - lteq_input.val(formatDate(end, el_opts)); - } + var closeOpenMenu = function () {}; + + 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'; - $this.on('click.dateRangeExtPreset', '.btn_timerange', function(e) { - unbindClickEventBlockTimerange(); - e.stopPropagation(); + 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); + 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); + // 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. + 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'); + + // 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) { + // 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. + 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]; + 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); + } - 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(); - } - }); + 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); } + 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, + var api = { defaults: defaults, init: init }; + window.ActiveAdminDateRangePreset = api; - // 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; - } - } - } -} -