diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c98ddf..4d0a960 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ permissions: jobs: test: - name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} / AA ${{ matrix.activeadmin }} + name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} / ${{ matrix.layout }} runs-on: ubuntu-latest strategy: fail-fast: false @@ -19,12 +19,17 @@ jobs: ruby: ['3.3', '3.4', '4.0'] # Rails 7.2 went eol 2026-08-09; 8.2 is not released yet. rails: ['8.0.0', '8.1.0'] - # Every activeadmin 3.2+ resolves and passes against Rails 8.0 and 8.1, - # so no exclude rules are needed. - activeadmin: ['3.2.0', '3.3.0', '3.4.0', '3.5.0'] + # Stock ActiveAdmin 4, and ActiveAdmin 4 with active_admin_theme, + # which wraps the filters sidebar in a panel. + layout: ['stock', 'theme'] + include: + - layout: stock + gemfile: Gemfile + - layout: theme + gemfile: gemfiles/active_admin_theme.gemfile env: RAILS: ${{ matrix.rails }} - AA: ${{ matrix.activeadmin }} + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 @@ -32,4 +37,4 @@ jobs: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Run tests - run: bundle exec rspec spec + run: bundle exec rspec diff --git a/.gitignore b/.gitignore index de4622b..6eb7d2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ /.bundle/ /bin/ /Gemfile.lock +/gemfiles/*.lock /pkg/ /tmp/ -spec/rails/rails-* /coverage +/src/ +/node_modules/ +/spec/dummy/tmp/ +/spec/dummy/log/ +/spec/dummy/app/assets/builds/* +!/spec/dummy/app/assets/builds/.keep diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/Gemfile b/Gemfile index bf16145..c975a11 100644 --- a/Gemfile +++ b/Gemfile @@ -3,19 +3,20 @@ source 'https://rubygems.org' gemspec default_rails_version = '8.1.0' -default_activeadmin_version = '3.5.0' +default_activeadmin_version = '4.0.0.beta23' gem 'rails', "~> #{ENV['RAILS'] || default_rails_version}" -gem 'activeadmin', "~> #{ENV['AA'] || default_activeadmin_version}" -gem 'sprockets-rails' -gem 'sass-rails' +gem 'activeadmin', ENV['AA'] || default_activeadmin_version group :test do gem 'simplecov', require: false gem 'rspec-rails' gem 'sqlite3', '~> 2.0' - gem 'database_cleaner' + gem 'propshaft' + gem 'importmap-rails' + # Builds the dummy app's ActiveAdmin stylesheet with the plugin CSS. + gem 'tailwindcss-ruby', '~> 4.0' gem 'capybara' gem 'cuprite' - gem 'webrick', require: false + gem 'puma' end diff --git a/README.md b/README.md index 997bb3b..528cfec 100644 --- a/README.md +++ b/README.md @@ -4,174 +4,165 @@ # ActiveAdminFiltersVisibility -ActiveAdmin plugin allows to hide any filter from Filters Sidebar. -Useful when page has many filters, but admin user needs only some of them. -Every filter saves its state using browser's LocalStorage. +Lets an admin hide filters in the ActiveAdmin filters sidebar, and optionally reorder them. Useful when a page has many filters and each admin needs only some of them. The choice is saved per page in the browser's localStorage. -![Demonstration](https://raw.githubusercontent.com/activeadmin-plugins/active_admin_filters_visibility/master/screen/example_aa_filters_visibility.gif "Visibility example") +![Filters visibility panel](https://raw.githubusercontent.com/activeadmin-plugins/active_admin_filters_visibility/master/screen/filters_visibility.png "Stock ActiveAdmin 4") +![Filters visibility panel with active_admin_theme, dark mode](https://raw.githubusercontent.com/activeadmin-plugins/active_admin_filters_visibility/master/screen/filters_visibility_theme_dark.png "active_admin_theme 4, dark mode") -Also you can use drag&drop to change filters order +Version 2 supports ActiveAdmin 4 only. For ActiveAdmin 3, use version 1.x. See [Upgrading from 1.x](#upgrading-from-1x). -![Demonstration](https://raw.githubusercontent.com/activeadmin-plugins/active_admin_filters_visibility/master/screen/example_aa_filters_ordering.gif "Ordering example") +It works with stock ActiveAdmin 4 and with [active_admin_theme](https://github.com/activeadmin-plugins/active_admin_theme) 4, which wraps the filters sidebar in a panel: the toggle goes into the panel header. ## Install -In Gemfile add - ```ruby - gem 'active_admin_filters_visibility', git: 'https://github.com/activeadmin-plugins/active_admin_filters_visibility' -``` - -##### Using assets via Sprockets -in the -``` - app/assets/javascript/active_admin.coffee +gem 'active_admin_filters_visibility' ``` -and +### JavaScript -```coffeescript - #= require active_admin_filters_visibility -``` +The gem pins the `active_admin_filters_visibility` module into `ActiveAdmin.importmap`, the import map ActiveAdmin pages render. ActiveAdmin pages then need to import it. Pick the route that matches your app: -##### Using assets via Webpacker (or any other assets bundler) as a NPM module (Yarn package) +- **ActiveAdmin with the `javascript_modules` setting** ([activeadmin/activeadmin#9148](https://github.com/activeadmin/activeadmin/pull/9148)): nothing to do. The gem adds itself to that setting. +- **active_admin_theme 4**: add the module to the theme's list in an initializer: -Execute: + ```ruby + ActiveAdminTheme.javascripts += %w[active_admin_filters_visibility] + ``` - $ npm i @activeadmin-plugins/active_admin_filters_visibility +- **Otherwise**: copy `app/views/active_admin/_html_head.html.erb` from ActiveAdmin into your app and add this line after `javascript_importmap_tags`: -Or + ```erb + <%= javascript_import_module_tag "active_admin_filters_visibility" %> + ``` - $ yarn add @activeadmin-plugins/active_admin_filters_visibility +**jsbundling hosts** install the npm package and import it from the ActiveAdmin entry file: -Or add manually to `package.json`: +```sh +npm install @activeadmin-plugins/active_admin_filters_visibility +``` -```json -"dependencies": { - "@activeadmin-plugins/active_admin_filters_visibility": "1.2.0" -} +```javascript +import "@activeadmin-plugins/active_admin_filters_visibility" ``` -and execute: - $ yarn +The module sets itself up on `#filters_sidebar_section` when the page loads. -Add the following line into `app/assets/javascripts/active_admin.js`: +### CSS -```javascript -import '@activeadmin-plugins/active_admin_filters_visibility'; -``` +The styles are plain CSS. Import them into the Tailwind entry file that builds ActiveAdmin's stylesheet (usually `app/assets/stylesheets/active_admin.css`), after `@import "tailwindcss"`. -##### Initialization: +From the npm package: -```javascript - $(document).ready(function() { - $('#filters_sidebar_section').activeAdminFiltersVisibility(); - }); +```css +@import "@activeadmin-plugins/active_admin_filters_visibility/css"; ``` -## Customization +Or from the installed gem. Its path differs between machines, so write it into a gitignored file before each CSS build instead of committing it: -```coffeescript - $('.jquery-selector').activeAdminFiltersVisibility(options) +```sh +echo "@import \"$(bundle show active_admin_filters_visibility)/app/assets/stylesheets/active_admin_filters_visibility.css\";" > app/assets/stylesheets/active_admin_filters_visibility.generated.css ``` -ActiveAdminFiltersVisibility is a standard jQuery Plugin, and accepts some "options" as a hash. -Default is: - -```javascript -{ - sidebarUniqId: function() { - return window.location.pathname; - }, - icon: '☶', - iconClass: '', - iconStyle: '', - skipDefaultCss: false, - title: 'Visibility:', - ordering: false, - orderHint: 'Drag&Drop to reorder filters', - resetButtonTitle: 'Reset' -} +```css +@import "./active_admin_filters_visibility.generated.css"; ``` -You can change icon - this is a HTML text or symbol. You can pass empty string and customize it with your CSS. -Or you can set class("iconClass") for icon or inline styles("iconStyle"). +Dark mode follows the `.dark` class on ``, as ActiveAdmin 4 does. With active_admin_theme the colors follow the theme's `--aat-*` custom properties. -This plugin has minimal CSS styling. -In case you want to use custom CSS, default styling can be ignored: -set ```skipDefaultCss``` to ```true``` +## Configuration +Change `defaults` from a module that ActiveAdmin pages import. The plugin sets itself up on `DOMContentLoaded`, after every module has run, so the new values apply: -### Ordering +```javascript +import { defaults } from "active_admin_filters_visibility" -By default ordering is disabled. You can turn it: set option ```ordering``` to ```true```. +defaults.ordering = true +defaults.title = "Filtres visibles" +``` +| Option | Default | Description | +| --- | --- | --- | +| `autoInit` | `true` | Set up the filters sidebar on page load. Set to `false` to call `setup()` yourself. | +| `selector` | `"#filters_sidebar_section"` | The sidebar section to set up on page load. | +| `storageKey` | `() => window.location.pathname` | The key the state is saved under. One key per page by default. | +| `ordering` | `false` | Let admins reorder filters by drag and drop. | +| `icon` | sliders SVG | HTML for the toggle button. | +| `buttonLabel` | `"Choose visible filters"` | The toggle's `aria-label` and tooltip. | +| `title` | `"Visible filters"` | The panel title. | +| `resetLabel` | `"Reset"` | The reset button text. | +| `orderHint` | `"Drag filters to reorder them"` | The hint shown when ordering is on. | -### Texts -Change text in options: ```title```, ```orderHint``` and ```resetButtonTitle``` +To set up a section by hand, pass options for that call only: +```javascript +import { defaults, setup } from "active_admin_filters_visibility" -### Dont hide filters which has selected value +defaults.autoInit = false +document.addEventListener("DOMContentLoaded", () => { + setup(document.getElementById("filters_sidebar_section"), { ordering: true }) +}) +``` + +### Filters with a value stay visible -If filter has selected value - that filter will not be hidden on page reload. Even if "visibility checkbox" is unchecked. -This was made for preventing unpredictable filtering: for example when user came to page by some link with predefined filtering params. -All default filters types(string, numeric, date range, etc.) are implemented. -But if you have some specific filter, you need to add custom handler. +A hidden filter that has a value when the page loads stays visible, for example when an admin follows a link with filter params. Otherwise the list would be filtered by something the admin cannot see. -For example you have customized filter by ID. -And you want to consider "1" as default value and allow hiding it. -In this case you should add uniq class to wrapper. +The plugin detects a value by the filter's type, which is a class on the filter wrapper (`select`, `string`, `date_range`, ...). Select and boolean filters count a selected option with a value. Every other type counts a filled text field or a checked box. To change that for one filter, give its wrapper a class and register a detector for it. A detector you register wins over the built-in ones, whatever the class order: ```ruby -filter :id, wrapper_html: { class: 'filter_customized_id' } +filter :id, wrapper_html: { class: 'customized-id' } ``` ```javascript - // return TRUE if this filter has value - // elWrapper is jQuery element "div.filter_form_field.filter_customized_id" - // if not "1" then consider this filter as active, and return TRUE to prevent hiding - $.fn.activeAdminFiltersVisibility.registerFilterType('customized_id', function(elWrapper) { - var inputValue = elWrapper.find('input').val(); - return inputValue != '1'; - }); -``` +import { registerFilterType } from "active_admin_filters_visibility" +// Return true when the filter has a value. "1" counts as empty here. +registerFilterType("customized-id", (wrapper) => { + const value = wrapper.querySelector("input:not([type=hidden])").value + return value !== "" && value !== "1" +}) +``` ### Saving state -Plugin saves list of hidden filters in LocalStorage, using jQuery plugin "Lockr" https://github.com/tsironis/lockr -If you need to save this in cookies or user profile, you should write your own implementation - rewrite "$.fn.activeAdminFiltersVisibility.storage". -For example: +The state is saved in localStorage under `active_admin_filters_visibility:`, as JSON: `{"hidden": ["q_name_input"], "order": [...]}`. Filters are identified by the id of their wrapper (`q__input`). To save it elsewhere, replace the `storage` methods: ```javascript -$.fn.activeAdminFiltersVisibility.storage = function(storageUniqId) { - // initialize storage with "storageUniqId" - // every page(sidebar filters) must has its uniq storageUniqId - var myStorage = new MyCustomizedStorage(storageUniqId); - - return { - add: function(labelText) { - // add hidden filter "labelText" to storage myStorage - myStorage.add(labelText); - }, - remove: function(labelText) { - // drop hidden filter "labelText" to storage myStorage - // makes filter visible again - myStorage.remove(labelText); - }, - has: function(labelText) { - // check if labelText already hidden(in storage) - // should return true if exists - return myStorage.find(labelText) ? true : false; - }, - all: function() { - // return array of hidden labels(filters) - return myStorage.getAll(); // ['Id', 'Name', 'Created At'] - }, - any: function() { - // check if current Sidebar Filter has some hidden elements - return this.all().length > 0; - } - } -}; +import { storage } from "active_admin_filters_visibility" + +storage.load = (key) => myStore.read(key) || {} +storage.save = (key, state) => myStore.write(key, state) +storage.clear = (key) => myStore.delete(key) +``` + +### Test hooks + +The markup the plugin adds carries `data-*` attributes, so tests do not depend on CSS classes: + +- `data-filters-visibility` on the sidebar section once it is set up. +- `data-filters-visibility-header` on the header that holds the toggle. +- `data-filters-visibility-toggle` on the toggle button. It also has `aria-expanded`, and `data-filters-visibility-active` while any filter is hidden. +- `data-filters-visibility-panel` on the panel. +- `data-filters-visibility-item=""` on each row and `data-filters-visibility-checkbox=""` on its checkbox. +- `data-filters-visibility-reset` on the reset button. +- `data-filters-visibility-hidden` on a hidden filter wrapper. + +## Upgrading from 1.x + +- ActiveAdmin 4 only. ActiveAdmin 3 apps stay on 1.x. +- No jQuery and no Sprockets. The asset is an ES module loaded through ActiveAdmin's import map or the npm package, and it sets itself up: remove the `$('#filters_sidebar_section').activeAdminFiltersVisibility()` call. +- Options moved from the jQuery call to `defaults`. `iconClass`, `iconStyle` and `skipDefaultCss` are gone: style the toggle with CSS, or leave the stylesheet out. `sidebarUniqId` is now `storageKey`. `$.fn.activeAdminFiltersVisibility.registerFilterType` and `.storage` are now the `registerFilterType` export and the `storage` object. +- The styles are a stylesheet you import into the ActiveAdmin Tailwind build. They are no longer injected at runtime. +- Saved state uses a new key and format, and identifies filters by wrapper id instead of label text. Hidden filters and custom orders saved by 1.x are not carried over. +- Reset is always shown and no longer reloads the page. +- Filter type detectors are keyed by any class on the wrapper, such as Formtastic's type (`select`) or a class you set, instead of a `filter_` class. + +## Development + +```sh +bundle exec rspec +BUNDLE_GEMFILE=gemfiles/active_admin_theme.gemfile bundle exec rspec ``` + +The second run uses active_admin_theme from git. Set `ACTIVE_ADMIN_THEME_PATH` to use a local checkout. The specs build the dummy app's stylesheet with the standalone Tailwind CLI from `tailwindcss-ruby`. diff --git a/Rakefile b/Rakefile index dedca81..74de2d3 100644 --- a/Rakefile +++ b/Rakefile @@ -1,5 +1,8 @@ -require "bundler" +require 'bundler' require 'rake' Bundler.setup Bundler::GemHelper.install_tasks -FileList['tasks/**/*.rake'].each { |task| import task } + +require 'rspec/core/rake_task' +RSpec::Core::RakeTask.new(:spec) +task default: :spec diff --git a/active_admin_filters_visibility.gemspec b/active_admin_filters_visibility.gemspec index 86ccded..9fd045a 100644 --- a/active_admin_filters_visibility.gemspec +++ b/active_admin_filters_visibility.gemspec @@ -9,11 +9,11 @@ Gem::Specification.new do |s| s.email = ['workgena@gmail.com'] s.homepage = 'https://github.com/workgena/active_admin_filters_visibility' s.summary = %q{active_admin_filters_visibility gem} - s.description = %q{extension for activeadmin gem to hide any filters from sidebar-filters panel} + s.description = %q{extension for activeadmin gem to hide and reorder filters in the ActiveAdmin 4 filters sidebar} s.required_ruby_version = '>= 3.3' - s.add_dependency 'activeadmin', '>= 3.2', '< 4.0' + s.add_dependency 'activeadmin', '>= 4.0.0.beta1', '< 5.0.0.a' # Whitelist, not a reject list: a new directory in the repo does not # reach consumers until it is named here. The reject form needs a new diff --git a/app/assets/javascripts/active_admin_filters_visibility.js b/app/assets/javascripts/active_admin_filters_visibility.js deleted file mode 100644 index c564810..0000000 --- a/app/assets/javascripts/active_admin_filters_visibility.js +++ /dev/null @@ -1,517 +0,0 @@ -(function($) { - - // Plugin definition - $.fn.activeAdminFiltersVisibility = function(options) { - var settings = $.extend({}, $.fn.activeAdminFiltersVisibility.defaults, options || {}); - - var storageKey = settings.sidebarUniqId(), - storage = $.fn.activeAdminFiltersVisibility.storage(storageKey), - panel = $(this); - - if (panel.length > 1) { - console.log('Cant be applied to multiple sidebars'); - return panel; - } - - - function diffArray(arr1, arr2) { - return arr1.concat(arr2).filter(function (val) { - if (!(arr1.includes(val) && arr2.includes(val))) - return val; - }); - } - - - - function findFilterWrapperByLabelText(labelText) { - return panel.find(".filter_form_field label").filter(function() { - var nodeText = $(this)[0].childNodes[0].textContent; - return nodeText === labelText; - }).closest('.filter_form_field'); - } - - function buildCheckboxesHtml() { - var html = ''; - - var labels = panel.find('.filter_form_field label:first-child').map(function() { - var text = $(this)[0].childNodes[0].textContent.trim() - $(this).closest('.filter_form_field').attr('data-pseudo-id', encodeURIComponent(text)); - return text; - }).get(); - - var orderedLabels = storage.getOrder(); - - //diffArray - if (orderedLabels.length > 0) { - if (diffArray(labels, orderedLabels).length) { - storage.reset(); - } else { - labels = orderedLabels; - } - } - - $.each(labels, function(i, text) { - var checked = storage.has(text) ? '' : 'checked="checked"'; - html += '
'; - }); - - return html; - } - - function renderNotice() { - var btn = panel.find('.filters-visibility-button'); - if (storage.any()) { - btn.addClass('active'); - } else { - btn.removeClass('active'); - } - } - - function render() { - // Button - panel.find('h3:first') - .append('' + settings.icon + ''); - - // Settings Panel - panel.find('.panel_contents').prepend( - '
' + - '
' + settings.title + '
' + - buildCheckboxesHtml() + - '
' - ); - - // Hide filters - $.each(storage.all(), function(i, labelText) { - var elWrapper = findFilterWrapperByLabelText(labelText); - if ( false == isFilterFilled(elWrapper) ) { - elWrapper.hide(); - } - }); - - // Mark button if some filters are hidden - renderNotice(); - } - - - - function hide(labelText) { - findFilterWrapperByLabelText(labelText).hide(); - storage.add(labelText); - renderNotice(); - } - - function show(labelText) { - findFilterWrapperByLabelText(labelText).show(); - storage.remove(labelText); - renderNotice(); - } - - // prevent hiding filter if it is no empty (triggers only on page render) - function isFilterFilled(elWrapper) { - if (elWrapper.length == 0) { - return false; - } - var klasses = elWrapper.attr('class').split(' '); - - var filterTypeName = $.grep(klasses, function(v) { - return v != 'filter_form_field' && /^filter_/.test(v) - })[0].replace(/^filter_/, ""); - - function _detectIsFilled(filterTypeName, elWrapper) { - var functionOrString = $.fn.activeAdminFiltersVisibility.filtersTypesDefinitions[filterTypeName]; - - if (typeof functionOrString == 'string' && functionOrString != filterTypeName) { - return $.fn.activeAdminFiltersVisibility.filtersTypesDefinitions[functionOrString].call(this, elWrapper); - } else if (typeof functionOrString == 'function') { - return functionOrString.call(this, elWrapper); - } else { - return $.fn.activeAdminFiltersVisibility.filtersTypesDefinitions['default'].call(this, elWrapper); - } - } - - return _detectIsFilled(filterTypeName, elWrapper); - } - - - // apply styles - if (settings.skipDefaultCss == false) { - $.fn.activeAdminFiltersVisibility.css(); - } - - - panel.on('click', '.filters-visibility-button', function() { - panel.find('.filters-visibility-panel').toggle(); - }); - - panel.on('change', '.filters-visibility-panel input[type=checkbox]', function() { - var $this = $(this), - labelText = $this.parent().text().trim(); - - if ($this.prop('checked')) { - show(labelText); - } else { - hide(labelText); - } - }); - - render(); - - if (settings.ordering) { - $.fn.activeAdminFiltersVisibility.applyOrder(panel, storage); - var resetButtonHtml = '' + settings.resetButtonTitle + ''; - panel.find('.filters-visibility-panel div:first') - .append(resetButtonHtml) - .after('
' + settings.orderHint + '
'); - - panel.on('click', '.filters-visibility-reset-btn', function(e) { - e.preventDefault(); - storage.reset(); - window.location.reload(); - }); - } - - return panel; - }; - - // Ordering - $.fn.activeAdminFiltersVisibility.applyOrder = function(panel, storage) { - - // re-render sidebar filters(even if they are not visible, show/hide event is independent and knows nothing about order) - function renderReorderedFilters() { - var wrapper = panel.find('form'); - var order = storage.getOrder(); - - var existingOrder = panel.find('.filter_form_field label:first-child').map(function() { - return $(this)[0].childNodes[0].textContent.trim(); - }).get(); - - if (order.length > 0 && JSON.stringify(order) != JSON.stringify(existingOrder)) { - var pos = 0; - $.each(order, function(i, text) { - var textEncoded = encodeURIComponent(text); - var el = wrapper.find('div[data-pseudo-id="' + textEncoded + '"]'); - if (el.length > 0) { - var elOnThisPosition = wrapper.find('.filter_form_field').eq(pos); - if (elOnThisPosition.data('pseudo-id') != textEncoded) { - wrapper[0].insertBefore(el[0], elOnThisPosition[0]); - } - pos++; - } - }); - } - } - - - var isOrdered = storage.getOrder().length > 0; - - // define draggable elements - - panel.find('.filters-visibility-panel label').attr('draggable', 'true'); - - // Attach events - panel.on('drop', '.filters-visibility-panel label', function(e) { - e.preventDefault(); - var drag_el_id = e.originalEvent.dataTransfer.getData("text"), - drag_el = panel.find('label[data-pseudo-id="' + drag_el_id + '"]'), - drop_el = $(this), - wrapper = panel.find('.filters-visibility-panel'); - - if (drag_el != drop_el) { - wrapper[0].insertBefore(drag_el.parent()[0], drop_el.parent()[0]); - - var orderedList = wrapper.find('label').map(function() { - return $(this).text().trim(); - }).get(); - - storage.setOrder(orderedList); - - renderReorderedFilters(); - } - }); - - panel.on('dragover', '.filters-visibility-panel', function(e) { - e.preventDefault(); - }); - - panel.find('.filters-visibility-panel label').on('dragstart', function(e) { - var key = $(this).data('pseudo-id'); - e.originalEvent.dataTransfer.setData("text", key); - }); - - - // Apply Order - if (isOrdered) { - // Apply reorder !!! - renderReorderedFilters(); - } - - return panel; - }; - - - - // Plugin Storage method - $.fn.activeAdminFiltersVisibility.storage = function(storageUniqId) { - // https://github.com/tsironis/lockr - var Lockr = function(){ - if (!Array.prototype.indexOf) { - Array.prototype.indexOf = function(elt) - { - var len = this.length >>> 0; - - var from = Number(arguments[1]) || 0; - from = (from < 0) - ? Math.ceil(from) - : Math.floor(from); - if (from < 0) - from += len; - - for (; from < len; from++) - { - if (from in this && - this[from] === elt) - return from; - } - return -1; - }; - } - - var Lockr = {}; - - Lockr.prefix = ""; - - Lockr._getPrefixedKey = function(key, options) { - options = options || {}; - - if (options.noPrefix) { - return key; - } else { - return this.prefix + key; - } - - }; - - Lockr.set = function (key, value, options) { - var query_key = this._getPrefixedKey(key, options); - - try { - localStorage.setItem(query_key, JSON.stringify({"data": value})); - } catch (e) { - if (console) console.warn("Lockr didn't successfully save the '{"+ key +": "+ value +"}' pair, because the localStorage is full."); - } - }; - - Lockr.get = function (key, missing, options) { - var query_key = this._getPrefixedKey(key, options), - value; - - try { - value = JSON.parse(localStorage.getItem(query_key)); - } catch (e) { - if(localStorage[query_key]) { - value = {data: localStorage.getItem(query_key)}; - } else{ - value = null; - } - } - if(value === null) { - return missing; - } else if (typeof value === 'object' && typeof value.data !== 'undefined') { - return value.data; - } else { - return missing; - } - }; - - Lockr.sadd = function(key, value, options) { - var query_key = this._getPrefixedKey(key, options), - json; - - var values = Lockr.smembers(key); - - if (values.indexOf(value) > -1) { - return null; - } - - try { - values.push(value); - json = JSON.stringify({"data": values}); - localStorage.setItem(query_key, json); - } catch (e) { - console.log(e); - if (console) console.warn("Lockr didn't successfully add the "+ value +" to "+ key +" set, because the localStorage is full."); - } - }; - - Lockr.smembers = function(key, options) { - var query_key = this._getPrefixedKey(key, options), - value; - - try { - value = JSON.parse(localStorage.getItem(query_key)); - } catch (e) { - value = null; - } - - if (value === null) - return []; - else - return (value.data || []); - }; - - Lockr.sismember = function(key, value, options) { - return Lockr.smembers(key).indexOf(value) > -1; - }; - - Lockr.keys = function() { - var keys = []; - var allKeys = Object.keys(localStorage); - - if (Lockr.prefix.length === 0) { - return allKeys; - } - - allKeys.forEach(function (key) { - if (key.indexOf(Lockr.prefix) !== -1) { - keys.push(key.replace(Lockr.prefix, '')); - } - }); - - return keys; - }; - - Lockr.getAll = function () { - var keys = Lockr.keys(); - return keys.map(function (key) { - return Lockr.get(key); - }); - }; - - Lockr.srem = function(key, value, options) { - var query_key = this._getPrefixedKey(key, options), - json, - index; - - var values = Lockr.smembers(key, value); - - index = values.indexOf(value); - - if (index > -1) - values.splice(index, 1); - - json = JSON.stringify({"data": values}); - - try { - localStorage.setItem(query_key, json); - } catch (e) { - if (console) console.warn("Lockr couldn't remove the "+ value +" from the set "+ key); - } - }; - - Lockr.rm = function (key) { - localStorage.removeItem(key); - }; - - Lockr.flush = function () { - if (Lockr.prefix.length) { - Lockr.keys().forEach(function(key) { - localStorage.removeItem(Lockr._getPrefixedKey(key)); - }); - } else { - localStorage.clear(); - } - }; - return Lockr; - - }(); - - var storageIdForOrder = '__ordered__' + storageUniqId; - - return { - setOrder: function(orderedList) { - Lockr.set(storageIdForOrder, orderedList); - }, - getOrder: function() { - return Lockr.get(storageIdForOrder) || []; - }, - reset: function() { - Lockr.rm(storageIdForOrder); - Lockr.rm(storageUniqId); - }, - add: function(key) { - Lockr.sadd(storageUniqId, key); - }, - remove: function(key) { - Lockr.srem(storageUniqId, key); - }, - has: function(key) { - return Lockr.sismember(storageUniqId, key); - }, - all: function() { - return Lockr.smembers(storageUniqId); - }, - any: function() { - return this.all().length > 0; - } - } - }; - - - // Style - $.fn.activeAdminFiltersVisibility.css = function() { - var lines = []; - - lines.push('.filters-visibility-button { margin: 0 5px 0 5px; padding: 0 4px 0 4px; background-color: rgba(0, 0, 0, 0.05); float: right; cursor: pointer; }'); - lines.push('.filters-visibility-button.active { color: #007ab8; }'); - lines.push('.filters-visibility-panel { display: none; border-left: 10px solid rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.02); margin-bottom: 10px; }'); - lines.push('.filters-visibility-panel > div { margin-left: 5px; }'); - lines.push('.filters-visibility-panel > div:first-child { display: flex; }'); - lines.push('.filters-visibility-panel .filters-visibility-reset-btn { display: inline-block; margin-left: auto; }'); - - var sheet = document.createElement('style'); - sheet.type = 'text/css'; - sheet.innerHTML = lines.join(' '); - document.body.appendChild(sheet); - }; - - - // Prevent filter from being hidden this filter is was used for filtering data - $.fn.activeAdminFiltersVisibility.filtersTypesDefinitions = { - 'default': function (elWrapper) { - var notEmptyInput = elWrapper.find('input[id!=""][type!="hidden"][value][value!=""]'); - return notEmptyInput.length > 0; - } - }; - - $.fn.activeAdminFiltersVisibility.registerFilterType = function(name, filterTypeDefinitionFunc) { - $.fn.activeAdminFiltersVisibility.filtersTypesDefinitions[name] = filterTypeDefinitionFunc; - }; - - $.fn.activeAdminFiltersVisibility.registerFilterType('check_boxes', function(elWrapper) { - return elWrapper.find('input[id!=""][type!="hidden"][value][value!=""]:checked').length > 0; - }); - - $.fn.activeAdminFiltersVisibility.registerFilterType('select', function(elWrapper) { - return elWrapper.find('select[id]:first').val().length > 0; - }); - - $.fn.activeAdminFiltersVisibility.registerFilterType('boolean', 'select'); - - - - // Plugin defaults - $.fn.activeAdminFiltersVisibility.defaults = { - sidebarUniqId: function() { - return window.location.pathname; - }, - icon: '☶', - iconClass: '', - iconStyle: '', - skipDefaultCss: false, - ordering: false, - title: 'Visibility:', - orderHint: 'Drag&Drop to reorder filters', - resetButtonTitle: 'Reset' - }; - -})(jQuery); diff --git a/app/assets/stylesheets/active_admin_filters_visibility.css b/app/assets/stylesheets/active_admin_filters_visibility.css new file mode 100644 index 0000000..efafce0 --- /dev/null +++ b/app/assets/stylesheets/active_admin_filters_visibility.css @@ -0,0 +1,132 @@ +/* + * Styles for active_admin_filters_visibility. Plain CSS: import it into the + * ActiveAdmin Tailwind entry file after `@import "tailwindcss"`. + * + * Colors are custom properties. They follow active_admin_theme's `--aat-*` + * colors when the theme is present and fall back to ActiveAdmin 4's grays. + * Dark mode keys on the `.dark` class, as ActiveAdmin 4 does. + */ + +.filters-visibility-header, +.filters-visibility-panel { + --filters-visibility-text: var(--aat-text, #374151); + --filters-visibility-muted: var(--aat-text-muted, #6b7280); + --filters-visibility-border: var(--aat-border, #e5e7eb); + --filters-visibility-surface: var(--aat-surface-2, #f9fafb); + --filters-visibility-hover: var(--aat-surface-hover, #f3f4f6); + --filters-visibility-accent: var(--aat-link, #2563eb); +} + +:where(.dark, .dark *):is(.filters-visibility-header, .filters-visibility-panel) { + --filters-visibility-text: var(--aat-text, #e5e7eb); + --filters-visibility-muted: var(--aat-text-muted, #9ca3af); + --filters-visibility-border: var(--aat-border, #374151); + --filters-visibility-surface: var(--aat-surface-2, #1f2937); + --filters-visibility-hover: var(--aat-surface-hover, #374151); + --filters-visibility-accent: var(--aat-link, #60a5fa); +} + +.filters-visibility-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.filters-visibility-toggle { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + border-radius: 0.25rem; + background: transparent; + color: var(--filters-visibility-muted); + font: inherit; + cursor: pointer; +} + +.filters-visibility-toggle:hover, +.filters-visibility-toggle[aria-expanded="true"] { + background-color: var(--filters-visibility-hover); + color: var(--filters-visibility-text); +} + +.filters-visibility-toggle:focus-visible { + outline: 2px solid var(--filters-visibility-accent); + outline-offset: 1px; +} + +/* Some filters are hidden. */ +.filters-visibility-toggle-active, +.filters-visibility-toggle-active:hover { + color: var(--filters-visibility-accent); +} + +.filters-visibility-panel { + margin-bottom: 1rem; + padding: 0.5rem 0.625rem; + border: 1px solid var(--filters-visibility-border); + border-radius: 0.375rem; + background-color: var(--filters-visibility-surface); + color: var(--filters-visibility-text); + font-size: 0.875rem; + font-weight: normal; +} + +.filters-visibility-panel[hidden], +.filters-form-field[data-filters-visibility-hidden] { + display: none; +} + +.filters-visibility-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.375rem; +} + +.filters-visibility-reset { + padding: 0; + border: 0; + background: transparent; + color: var(--filters-visibility-accent); + font: inherit; + cursor: pointer; +} + +.filters-visibility-reset:hover { + text-decoration: underline; +} + +.filters-visibility-list { + margin: 0; + padding: 0; + list-style: none; +} + +.filters-visibility-item label { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0; + cursor: pointer; +} + +.filters-visibility-item[draggable="true"] label { + cursor: grab; +} + +.filters-visibility-item[data-filters-visibility-dragging] { + opacity: 0.5; +} + +.filters-visibility-hint { + margin: 0.375rem 0 0; + color: var(--filters-visibility-muted); + font-size: 0.75rem; +} diff --git a/app/javascript/active_admin_filters_visibility.js b/app/javascript/active_admin_filters_visibility.js new file mode 100644 index 0000000..7356549 --- /dev/null +++ b/app/javascript/active_admin_filters_visibility.js @@ -0,0 +1,294 @@ +// Lets an admin hide, show and (optionally) reorder individual filters in the +// ActiveAdmin 4 filters sidebar. The choice is kept per page in localStorage. + +const STORAGE_PREFIX = "active_admin_filters_visibility:" + +const ICON = + '' + +export const defaults = { + // Set to false to call setup() yourself. + autoInit: true, + selector: "#filters_sidebar_section", + // One stored state per page. + storageKey: () => window.location.pathname, + ordering: false, + icon: ICON, + buttonLabel: "Choose visible filters", + title: "Visible filters", + resetLabel: "Reset", + orderHint: "Drag filters to reorder them" +} + +export const storage = { + load(key) { + try { + const state = JSON.parse(window.localStorage.getItem(STORAGE_PREFIX + key)) + return state && typeof state === "object" ? state : {} + } catch { + return {} + } + }, + save(key, state) { + try { + window.localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(state)) + } catch { + // Storage full or disabled: the choice lasts until the page reloads. + } + }, + clear(key) { + try { + window.localStorage.removeItem(STORAGE_PREFIX + key) + } catch { + // Same as save. + } + } +} + +// A filter that has a value is never hidden on page load, so a link with +// filter params cannot filter by something the admin cannot see. Detectors +// are keyed by a class on the filter wrapper: Formtastic's input type +// ("select", "string", ...) or a class set with `wrapper_html`. +const hasEnteredValue = (wrapper) => + Array.from(wrapper.querySelectorAll("input, textarea")).some((input) => { + if (input.disabled || ["hidden", "submit", "button", "reset"].includes(input.type)) return false + if (input.type === "checkbox" || input.type === "radio") return input.checked && input.value !== "" + return input.value !== "" + }) + +const hasSelectedValue = (wrapper) => + Array.from(wrapper.querySelectorAll("select")).some((select) => + Array.from(select.selectedOptions).some((option) => option.value !== "") + ) + +export const filterTypes = { + default: hasEnteredValue, + select: hasSelectedValue, + boolean: hasSelectedValue +} + +const builtInTypes = new Set(Object.keys(filterTypes)) + +export function registerFilterType(name, detector) { + filterTypes[name] = detector +} + +// A detector registered for a class beats Formtastic's type class, whatever +// the class order on the wrapper. +function isFilled(wrapper) { + const names = Array.from(wrapper.classList).filter((klass) => klass !== "default" && Object.hasOwn(filterTypes, klass)) + const name = names.find((klass) => !builtInTypes.has(klass)) || names[0] + return Boolean((filterTypes[name] || filterTypes.default)(wrapper)) +} + +function labelFor(wrapper) { + const label = wrapper.querySelector("label") + const text = label ? (label.firstChild?.nodeType === Node.TEXT_NODE ? label.firstChild.textContent : label.textContent) : "" + return text.trim() || wrapper.id +} + +function sameList(a, b) { + return a.length === b.length && a.every((value, index) => value === b[index]) +} + +// The header the toggle goes into. Stock ActiveAdmin 4 titles the section with +// h3.filters-form-title. A layout that wraps sidebar sections in a panel (such +// as active_admin_theme 4) hides that title and shows the panel's instead. +function findHeader(section, form) { + const panel = form.closest(".panel") + const panelTitle = panel && section.contains(panel) && panel.querySelector(":scope > .panel-title") + return panelTitle || section.querySelector(".filters-form-title") +} + +export function setup(section, options = {}) { + if (!section || section.hasAttribute("data-filters-visibility")) return null + + const settings = { ...defaults, ...options } + const form = section.querySelector("form.filters-form") || section.querySelector("form") + const header = form && findHeader(section, form) + if (!header) return null + + const fields = Array.from(form.querySelectorAll(".filters-form-field[id]")).filter( + (wrapper) => wrapper.parentElement === form + ) + if (fields.length === 0) return null + + section.setAttribute("data-filters-visibility", "") + const key = settings.storageKey() + const byId = new Map(fields.map((wrapper) => [wrapper.id, wrapper])) + const initialOrder = fields.map((wrapper) => wrapper.id) + const marker = fields[fields.length - 1].nextSibling + + const saved = storage.load(key) + let hidden = (Array.isArray(saved.hidden) ? saved.hidden : []).filter((id) => byId.has(id)) + let order = Array.isArray(saved.order) ? saved.order.filter((id) => byId.has(id)) : [] + if (order.length) order = order.concat(initialOrder.filter((id) => !order.includes(id))) + + const save = () => { + const state = { hidden } + if (settings.ordering && order.length && !sameList(order, initialOrder)) state.order = order + if (state.hidden.length || state.order) { + storage.save(key, state) + } else { + storage.clear(key) + } + } + + // Toggle button in the header. + const panelId = `filters-visibility-panel-${section.id || "filters"}` + const toggle = document.createElement("button") + toggle.type = "button" + toggle.className = "filters-visibility-toggle" + toggle.setAttribute("data-filters-visibility-toggle", "") + toggle.setAttribute("aria-expanded", "false") + toggle.setAttribute("aria-controls", panelId) + toggle.setAttribute("aria-label", settings.buttonLabel) + toggle.title = settings.buttonLabel + toggle.innerHTML = settings.icon + header.classList.add("filters-visibility-header") + header.setAttribute("data-filters-visibility-header", "") + header.appendChild(toggle) + + // Settings panel above the form. + const panel = document.createElement("div") + panel.id = panelId + panel.className = "filters-visibility-panel" + panel.setAttribute("data-filters-visibility-panel", "") + panel.hidden = true + + const bar = document.createElement("div") + bar.className = "filters-visibility-panel-header" + const title = document.createElement("strong") + title.textContent = settings.title + const reset = document.createElement("button") + reset.type = "button" + reset.className = "filters-visibility-reset" + reset.setAttribute("data-filters-visibility-reset", "") + reset.textContent = settings.resetLabel + bar.append(title, reset) + + const list = document.createElement("ul") + list.className = "filters-visibility-list" + panel.append(bar, list) + + if (settings.ordering) { + const hint = document.createElement("p") + hint.className = "filters-visibility-hint" + hint.textContent = settings.orderHint + panel.appendChild(hint) + } + form.parentElement.insertBefore(panel, form) + + const items = new Map() + for (const id of initialOrder) { + const item = document.createElement("li") + item.className = "filters-visibility-item" + item.setAttribute("data-filters-visibility-item", id) + const label = document.createElement("label") + const checkbox = document.createElement("input") + checkbox.type = "checkbox" + checkbox.value = id + checkbox.setAttribute("data-filters-visibility-checkbox", id) + label.append(checkbox, document.createTextNode(` ${labelFor(byId.get(id))}`)) + item.appendChild(label) + if (settings.ordering) item.draggable = true + items.set(id, item) + } + + const setFieldHidden = (id, value) => { + const wrapper = byId.get(id) + wrapper.hidden = value + wrapper.toggleAttribute("data-filters-visibility-hidden", value) + } + + const renderOrder = () => { + const ids = order.length ? order : initialOrder + for (const id of ids) { + list.appendChild(items.get(id)) + form.insertBefore(byId.get(id), marker) + } + } + + const renderState = () => { + for (const [id, item] of items) { + item.querySelector("input").checked = !hidden.includes(id) + } + toggle.toggleAttribute("data-filters-visibility-active", hidden.length > 0) + toggle.classList.toggle("filters-visibility-toggle-active", hidden.length > 0) + } + + for (const id of initialOrder) setFieldHidden(id, hidden.includes(id) && !isFilled(byId.get(id))) + if (settings.ordering) renderOrder() + else for (const id of initialOrder) list.appendChild(items.get(id)) + renderState() + + toggle.addEventListener("click", () => { + panel.hidden = !panel.hidden + toggle.setAttribute("aria-expanded", String(!panel.hidden)) + }) + + list.addEventListener("change", (event) => { + const id = event.target.getAttribute("data-filters-visibility-checkbox") + if (!id) return + hidden = event.target.checked ? hidden.filter((other) => other !== id) : hidden.concat(id) + setFieldHidden(id, !event.target.checked) + renderState() + save() + }) + + reset.addEventListener("click", () => { + hidden = [] + order = [] + storage.clear(key) + for (const id of initialOrder) setFieldHidden(id, false) + renderOrder() + renderState() + }) + + if (settings.ordering) { + let dragged = null + list.addEventListener("dragstart", (event) => { + dragged = event.target.closest("[data-filters-visibility-item]") + if (!dragged) return + event.dataTransfer.effectAllowed = "move" + event.dataTransfer.setData("text/plain", dragged.getAttribute("data-filters-visibility-item")) + dragged.setAttribute("data-filters-visibility-dragging", "") + }) + list.addEventListener("dragover", (event) => { + if (dragged) event.preventDefault() + }) + list.addEventListener("drop", (event) => { + const target = event.target.closest("[data-filters-visibility-item]") + if (!dragged || !target || target === dragged) return + event.preventDefault() + const rect = target.getBoundingClientRect() + const after = event.clientY > rect.top + rect.height / 2 + list.insertBefore(dragged, after ? target.nextSibling : target) + order = Array.from(list.children).map((item) => item.getAttribute("data-filters-visibility-item")) + renderOrder() + save() + }) + list.addEventListener("dragend", () => { + dragged?.removeAttribute("data-filters-visibility-dragging") + dragged = null + }) + } + + return { section, toggle, panel } +} + +function autoSetup() { + if (defaults.autoInit) document.querySelectorAll(defaults.selector).forEach((section) => setup(section)) +} + +// Wait for DOMContentLoaded even when the DOM is already parsed: module +// scripts run before it fires, so a host module that changes `defaults` after +// importing this one still gets its settings applied. setup() runs once per +// section, so the load fallback is harmless. +if (document.readyState === "complete") { + autoSetup() +} else { + document.addEventListener("DOMContentLoaded", autoSetup, { once: true }) + window.addEventListener("load", autoSetup, { once: true }) +} diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 0000000..cf06ecd --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,3 @@ +# Pinned into ActiveAdmin.importmap (the map the ActiveAdmin layout renders), +# not into the host's own importmap. +pin "active_admin_filters_visibility", to: "active_admin_filters_visibility.js", preload: false diff --git a/gemfiles/active_admin_theme.gemfile b/gemfiles/active_admin_theme.gemfile new file mode 100644 index 0000000..76fb298 --- /dev/null +++ b/gemfiles/active_admin_theme.gemfile @@ -0,0 +1,10 @@ +# The suite against active_admin_theme 4, which wraps the filters sidebar in a +# panel. The theme is not released for ActiveAdmin 4 yet, so it comes from git; +# ACTIVE_ADMIN_THEME_PATH points it at a local checkout instead. +eval_gemfile File.expand_path('../Gemfile', __dir__) + +if ENV['ACTIVE_ADMIN_THEME_PATH'] + gem 'active_admin_theme', path: ENV['ACTIVE_ADMIN_THEME_PATH'] +else + gem 'active_admin_theme', github: 'senid231/active_admin_theme', branch: 'activeadmin-v4' +end diff --git a/lib/active_admin_filters_visibility.rb b/lib/active_admin_filters_visibility.rb index 95d6e90..e15fb81 100644 --- a/lib/active_admin_filters_visibility.rb +++ b/lib/active_admin_filters_visibility.rb @@ -2,8 +2,28 @@ require 'active_admin_filters_visibility/version' module ActiveAdminFiltersVisibility + JAVASCRIPT_MODULE = 'active_admin_filters_visibility' + module Rails class Engine < ::Rails::Engine + # ActiveAdmin renders its own import map, ActiveAdmin.importmap, so the + # module is pinned there. A host without importmap-rails bundles the npm + # package instead. + initializer 'active_admin_filters_visibility.importmap', after: 'active_admin.importmap' do |app| + next unless app.config.respond_to?(:importmap) + + ActiveAdmin.importmap.draw(root.join('config', 'importmap.rb')) + app.config.assets.paths << root.join('app/javascript') if app.config.respond_to?(:assets) + end + + # javascript_modules comes from activeadmin/activeadmin#9148. Without it, + # or without importmap-rails (nothing pinned), the host imports the + # module itself (see README). + config.after_initialize do |app| + if app.config.respond_to?(:importmap) && 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 2377c11..fe68bf9 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,20 @@ { "name": "@activeadmin-plugins/active_admin_filters_visibility", "version": "1.2.0", - "description": "Extension for activeadmin gem to hide any filters from sidebar-filters panel", + "description": "Hide and reorder filters in the ActiveAdmin 4 filters sidebar", "main": "src/active_admin_filters_visibility.js", - "repository": "git@github.com:activeadmin-plugins/active_admin_filters_visibility.git", - "author": "Gena M. ", - "license": "MIT", - "private": false, + "type": "module", + "exports": { + ".": "./src/active_admin_filters_visibility.js", + "./css": "./src/active_admin_filters_visibility.css" + }, "repository": { "type": "git", "url": "git+https://github.com/activeadmin-plugins/active_admin_filters_visibility.git" }, + "author": "Gena M. ", + "license": "MIT", + "private": false, "bugs": { "url": "https://github.com/activeadmin-plugins/active_admin_filters_visibility/issues" }, @@ -25,6 +29,6 @@ "src/**/*" ], "scripts": { - "prepublishOnly": "rm -rf src && cp -R app/assets/javascripts/ src" + "prepublishOnly": "rm -rf src && mkdir src && cp app/javascript/active_admin_filters_visibility.js app/assets/stylesheets/active_admin_filters_visibility.css src/" } } diff --git a/screen/example_aa_filters_ordering.gif b/screen/example_aa_filters_ordering.gif deleted file mode 100644 index 9cae8eb..0000000 Binary files a/screen/example_aa_filters_ordering.gif and /dev/null differ diff --git a/screen/example_aa_filters_visibility.gif b/screen/example_aa_filters_visibility.gif deleted file mode 100644 index 49709f2..0000000 Binary files a/screen/example_aa_filters_visibility.gif and /dev/null differ diff --git a/screen/filters_visibility.png b/screen/filters_visibility.png new file mode 100644 index 0000000..ee5f192 Binary files /dev/null and b/screen/filters_visibility.png differ diff --git a/screen/filters_visibility_theme_dark.png b/screen/filters_visibility_theme_dark.png new file mode 100644 index 0000000..997fc61 Binary files /dev/null and b/screen/filters_visibility_theme_dark.png differ diff --git a/spec/dummy/app/admin/authors.rb b/spec/dummy/app/admin/authors.rb new file mode 100644 index 0000000..3fa5ea2 --- /dev/null +++ b/spec/dummy/app/admin/authors.rb @@ -0,0 +1,9 @@ +ActiveAdmin.register Author do + filter :name + filter :last_name + filter :birthday + # The custom detector counts only "Yes", so it must win over the built-in + # select detector. + filter :active, as: :select, collection: [['Yes', true], ['No', false]], wrapper_html: { class: 'select yes-only' } + filter :created_at +end diff --git a/spec/dummy/app/admin/dashboard.rb b/spec/dummy/app/admin/dashboard.rb new file mode 100644 index 0000000..8cf7766 --- /dev/null +++ b/spec/dummy/app/admin/dashboard.rb @@ -0,0 +1,3 @@ +ActiveAdmin.register_page 'Dashboard' do + content { para 'Dashboard' } +end diff --git a/spec/dummy/app/assets/builds/.keep b/spec/dummy/app/assets/builds/.keep new file mode 100644 index 0000000..e69de29 diff --git a/spec/dummy/app/controllers/application_controller.rb b/spec/dummy/app/controllers/application_controller.rb new file mode 100644 index 0000000..09705d1 --- /dev/null +++ b/spec/dummy/app/controllers/application_controller.rb @@ -0,0 +1,2 @@ +class ApplicationController < ActionController::Base +end diff --git a/spec/dummy/app/javascript/filters_visibility_config.js b/spec/dummy/app/javascript/filters_visibility_config.js new file mode 100644 index 0000000..595ab85 --- /dev/null +++ b/spec/dummy/app/javascript/filters_visibility_config.js @@ -0,0 +1,5 @@ +import { defaults, registerFilterType } from "active_admin_filters_visibility" + +defaults.ordering = true + +registerFilterType("yes-only", (wrapper) => wrapper.querySelector("select").value === "true") diff --git a/spec/dummy/app/models/application_record.rb b/spec/dummy/app/models/application_record.rb new file mode 100644 index 0000000..b63caeb --- /dev/null +++ b/spec/dummy/app/models/application_record.rb @@ -0,0 +1,3 @@ +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/spec/dummy/app/models/author.rb b/spec/dummy/app/models/author.rb new file mode 100644 index 0000000..8cdf319 --- /dev/null +++ b/spec/dummy/app/models/author.rb @@ -0,0 +1,9 @@ +class Author < ApplicationRecord + def self.ransackable_attributes(_auth_object = nil) + %w[name last_name birthday active created_at] + end + + def self.ransackable_associations(_auth_object = nil) + [] + end +end diff --git a/spec/dummy/app/views_stock/active_admin/_html_head.html.erb b/spec/dummy/app/views_stock/active_admin/_html_head.html.erb new file mode 100644 index 0000000..667c3a2 --- /dev/null +++ b/spec/dummy/app/views_stock/active_admin/_html_head.html.erb @@ -0,0 +1,13 @@ +<%= stylesheet_link_tag "active_admin" %> + +<%= csrf_meta_tags %> +<%= csp_meta_tag %> +<%= javascript_tag nonce: true do %> + if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('dark') + } else { + document.documentElement.classList.remove('dark') + } +<% end %> +<%= javascript_importmap_tags "active_admin", importmap: ActiveAdmin.importmap %> +<%= javascript_import_module_tag "active_admin_filters_visibility", "filters_visibility_config" %> diff --git a/spec/dummy/config.ru b/spec/dummy/config.ru new file mode 100644 index 0000000..47bbd40 --- /dev/null +++ b/spec/dummy/config.ru @@ -0,0 +1,2 @@ +require_relative 'config/environment' +run Rails.application diff --git a/spec/dummy/config/application.rb b/spec/dummy/config/application.rb new file mode 100644 index 0000000..e4e88eb --- /dev/null +++ b/spec/dummy/config/application.rb @@ -0,0 +1,36 @@ +# Minimal ActiveAdmin 4 host (importmap + propshaft). The stylesheet is built +# by spec/support/tailwind.rb before the suite runs. +require 'rails' +require 'active_record/railtie' +require 'action_controller/railtie' +require 'action_view/railtie' +require 'propshaft' +require 'importmap-rails' + +# Loaded only by gemfiles/active_admin_theme.gemfile. +begin + require 'active_admin_theme' +rescue LoadError + nil +end +require 'active_admin' +require 'active_admin_filters_visibility' + +module Dummy + class Application < Rails::Application + config.root = File.expand_path('..', __dir__) + config.load_defaults Rails::VERSION::STRING.to_f + config.eager_load = false + config.consider_all_requests_local = true + config.secret_key_base = 'dummy-secret-key-base-for-tests-only' + config.hosts.clear + config.active_support.deprecation = :stderr + config.logger = Logger.new(nil) + config.public_file_server.enabled = true + + # Stock ActiveAdmin 4.0.0.beta23 has no javascript_modules setting, so the + # dummy imports the module from an _html_head override, as the README + # tells hosts to. The theme brings its own _html_head. + config.paths['app/views'] << 'app/views_stock' unless defined?(ActiveAdminTheme) + end +end diff --git a/spec/dummy/config/database.yml b/spec/dummy/config/database.yml new file mode 100644 index 0000000..c00bc67 --- /dev/null +++ b/spec/dummy/config/database.yml @@ -0,0 +1,4 @@ +test: + adapter: sqlite3 + database: tmp/test.sqlite3 + pool: 5 diff --git a/spec/dummy/config/environment.rb b/spec/dummy/config/environment.rb new file mode 100644 index 0000000..875ab0f --- /dev/null +++ b/spec/dummy/config/environment.rb @@ -0,0 +1,3 @@ +require_relative 'application' + +Rails.application.initialize! diff --git a/spec/dummy/config/importmap.rb b/spec/dummy/config/importmap.rb new file mode 100644 index 0000000..f3134a3 --- /dev/null +++ b/spec/dummy/config/importmap.rb @@ -0,0 +1,2 @@ +# ActiveAdmin pages render ActiveAdmin.importmap, not this one. Kept because +# importmap-rails expects the file. diff --git a/spec/dummy/config/initializers/active_admin.rb b/spec/dummy/config/initializers/active_admin.rb new file mode 100644 index 0000000..f846c31 --- /dev/null +++ b/spec/dummy/config/initializers/active_admin.rb @@ -0,0 +1,17 @@ +ActiveAdmin.setup do |config| + config.site_title = 'Dummy' + config.authentication_method = false + config.current_user_method = false + config.comments = false + config.batch_actions = false +end + +# The dummy's own module turns ordering on, the way a host configures the +# plugin. +ActiveAdmin.importmap.pin 'filters_visibility_config', to: 'filters_visibility_config.js', preload: false + +# The theme's _html_head imports this list. Without the theme the dummy +# overrides _html_head instead (app/views_stock, see config/application.rb). +if defined?(ActiveAdminTheme) + ActiveAdminTheme.javascripts += %w[active_admin_filters_visibility filters_visibility_config] +end diff --git a/spec/dummy/config/routes.rb b/spec/dummy/config/routes.rb new file mode 100644 index 0000000..36d2dac --- /dev/null +++ b/spec/dummy/config/routes.rb @@ -0,0 +1,3 @@ +Rails.application.routes.draw do + ActiveAdmin.routes(self) +end diff --git a/spec/filters_visibility_spec.rb b/spec/filters_visibility_spec.rb index d6527e2..afaf68a 100644 --- a/spec/filters_visibility_spec.rb +++ b/spec/filters_visibility_spec.rb @@ -1,90 +1,164 @@ -require 'spec_helper' - -describe 'filters visibility', type: :feature, js: true do - - # The dummy app's active_admin.js calls - # `$('#filters_sidebar_section').activeAdminFiltersVisibility()`, so these - # are the filters Active Admin derives from the authors table. +describe 'filters visibility', type: :feature do FILTERS = { - 'Name' => '#q_name_input', - 'Last name' => '#q_last_name_input', - 'Birthday' => '#q_birthday_input', - 'Created at' => '#q_created_at_input', - 'Updated at' => '#q_updated_at_input' + 'q_name_input' => 'Name', + 'q_last_name_input' => 'Last name', + 'q_birthday_input' => 'Birthday', + 'q_active_input' => 'Active', + 'q_created_at_input' => 'Created at' }.freeze - # The visibility panel is `display: none` until the gear button is clicked. - def open_visibility_panel - page.find('#filters_sidebar_section .filters-visibility-button').click - expect(page).to have_css('.filters-visibility-panel') + let(:section) { '#filters_sidebar_section' } + + def theme? + defined?(ActiveAdminTheme) + end + + def open_panel + find("#{section} [data-filters-visibility-toggle]").click + expect(page).to have_css('[data-filters-visibility-panel]', visible: :visible) + end + + def checkbox(id) + find("[data-filters-visibility-checkbox='#{id}']") + end + + def form_order + page.evaluate_script(<<~JS) + Array.from(document.querySelectorAll('#{section} form .filters-form-field')).map((el) => el.id) + JS end - def visibility_checkbox(label) - page.find(".filters-visibility-panel input[type=checkbox][value='#{label}']") + # Chrome DevTools has no HTML5 drag and drop, so dispatch the events. + def drag(source_id, onto:, below: false) + page.execute_script(<<~JS) + const list = document.querySelector('[data-filters-visibility-panel] ul') + const source = list.querySelector('[data-filters-visibility-item="#{source_id}"]') + const target = list.querySelector('[data-filters-visibility-item="#{onto}"]') + const rect = target.getBoundingClientRect() + const dataTransfer = new DataTransfer() + const fire = (el, type) => el.dispatchEvent(new DragEvent(type, { bubbles: true, cancelable: true, dataTransfer, clientY: #{below ? 'rect.bottom - 1' : 'rect.top + 1'} })) + fire(source, 'dragstart') + fire(target, 'dragover') + fire(target, 'drop') + fire(source, 'dragend') + JS end before do Author.create!(name: 'John', last_name: 'Doe') - Author.create!(name: 'Jane', last_name: 'Roe') - add_author_resource visit '/admin/authors' end - it 'renders the gear button and the visibility panel in the filters sidebar' do - expect(page).to have_css('#filters_sidebar_section h3 span.filters-visibility-button') - # hidden until the button is clicked - expect(page).to have_css('#filters_sidebar_section .panel_contents > .filters-visibility-panel', visible: :hidden) - expect(page).to have_css('.filters-visibility-panel > div > strong', text: 'Visibility:', visible: :hidden) - - open_visibility_panel + it 'puts the toggle in the header the layout shows' do + header = theme? ? "#{section} .panel > .panel-title" : "#{section} > h3.filters-form-title" + expect(page).to have_css("#{header}[data-filters-visibility-header] > [data-filters-visibility-toggle]", visible: :visible) + expect(page).to have_css('[data-filters-visibility-panel]', visible: :hidden) + expect(find("#{section} [data-filters-visibility-toggle]")['aria-expanded']).to eq('false') end - it 'has one checked checkbox per filter and shows every filter' do - open_visibility_panel + it 'lists every filter, checked, in the panel' do + open_panel - expect(page).to have_css('.filters-visibility-panel input[type=checkbox]', count: FILTERS.size) - FILTERS.each do |label, selector| - expect(visibility_checkbox(label)).to be_checked - expect(page).to have_css(selector) + expect(find("#{section} [data-filters-visibility-toggle]")['aria-expanded']).to eq('true') + expect(page).to have_css('[data-filters-visibility-checkbox]', count: FILTERS.size) + FILTERS.each do |id, label| + expect(page).to have_css("[data-filters-visibility-item='#{id}']", text: label) + expect(checkbox(id)).to be_checked + expect(page).to have_css("##{id}", visible: :visible) end - expect(page).not_to have_css('.filters-visibility-button.active') + expect(page).not_to have_css('[data-filters-visibility-toggle][data-filters-visibility-active]') end - it 'hides a filter when its checkbox is unchecked' do - open_visibility_panel - visibility_checkbox('Birthday').click + it 'hides a filter, keeps it hidden after a reload, and shows it again' do + open_panel + checkbox('q_birthday_input').uncheck + expect(page).to have_css('#q_birthday_input[data-filters-visibility-hidden]', visible: :hidden) + expect(page).to have_css('[data-filters-visibility-toggle][data-filters-visibility-active]') + expect(page).to have_css('#q_name_input', visible: :visible) + + visit '/admin/authors' expect(page).to have_css('#q_birthday_input', visible: :hidden) - expect(page).to have_css('.filters-visibility-button.active') - # the other filters are untouched - expect(page).to have_css('#q_name_input') - expect(page).to have_css('#q_created_at_input') + expect(page).to have_css('[data-filters-visibility-toggle][data-filters-visibility-active]') + + open_panel + expect(checkbox('q_birthday_input')).not_to be_checked + checkbox('q_birthday_input').check + expect(page).to have_css('#q_birthday_input', visible: :visible) + expect(page).not_to have_css('[data-filters-visibility-toggle][data-filters-visibility-active]') + + visit '/admin/authors' + expect(page).to have_css('#q_birthday_input', visible: :visible) end - it 'keeps the filter hidden after a page reload' do - open_visibility_panel - visibility_checkbox('Birthday').click + it 'stores the state per page under a prefixed key' do + open_panel + checkbox('q_birthday_input').uncheck expect(page).to have_css('#q_birthday_input', visible: :hidden) + stored = page.evaluate_script("localStorage.getItem('active_admin_filters_visibility:/admin/authors')") + expect(JSON.parse(stored)).to eq('hidden' => ['q_birthday_input']) + end + + it 'keeps a hidden filter visible while it has a value' do + open_panel + checkbox('q_name_input').uncheck + checkbox('q_active_input').uncheck + + visit '/admin/authors?q[name_cont]=Jo&q[active_eq]=true' + + expect(page).to have_css('#q_name_input', visible: :visible) + expect(page).to have_css('#q_active_input', visible: :visible) + visit '/admin/authors' + expect(page).to have_css('#q_name_input', visible: :hidden) + expect(page).to have_css('#q_active_input', visible: :hidden) + end - expect(page).to have_css('#q_birthday_input', visible: :hidden) - expect(page).to have_css('.filters-visibility-button.active') + it 'prefers a registered detector over the built-in one' do + open_panel + checkbox('q_active_input').uncheck - open_visibility_panel - expect(visibility_checkbox('Birthday')).not_to be_checked - expect(visibility_checkbox('Name')).to be_checked + # "No" is a selected option with a value, but the yes-only detector + # counts it as empty, although the wrapper lists "select" first. + visit '/admin/authors?q[active_eq]=false' + expect(find('#q_active_input', visible: :all)[:class]).to start_with('select yes-only') + expect(page).to have_css('#q_active_input', visible: :hidden) end - it 'shows the filter again when the checkbox is re-checked' do - open_visibility_panel - visibility_checkbox('Birthday').click - expect(page).to have_css('#q_birthday_input', visible: :hidden) + it 'reorders filters by drag and drop and keeps the order' do + open_panel + drag('q_created_at_input', onto: 'q_name_input') + + expected = %w[q_created_at_input q_name_input q_last_name_input q_birthday_input q_active_input] + expect(form_order).to eq(expected) - visibility_checkbox('Birthday').click + visit '/admin/authors' + expect(form_order).to eq(expected) + items = all('[data-filters-visibility-item]', visible: :all).map { |item| item['data-filters-visibility-item'] } + expect(items).to eq(expected) + end - expect(page).to have_css('#q_birthday_input') - expect(page).not_to have_css('.filters-visibility-button.active') + it 'drops a filter below the target when released on its lower half' do + open_panel + drag('q_name_input', onto: 'q_active_input', below: true) + + expect(form_order).to eq(%w[q_last_name_input q_birthday_input q_active_input q_name_input q_created_at_input]) end + it 'resets visibility and order' do + open_panel + checkbox('q_birthday_input').uncheck + drag('q_created_at_input', onto: 'q_name_input') + + find('[data-filters-visibility-reset]').click + + expect(page).to have_css('#q_birthday_input', visible: :visible) + expect(form_order).to eq(FILTERS.keys) + expect(page).not_to have_css('[data-filters-visibility-toggle][data-filters-visibility-active]') + + visit '/admin/authors' + expect(page).to have_css('#q_birthday_input', visible: :visible) + expect(form_order).to eq(FILTERS.keys) + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ed44357..1f784b2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,54 +3,19 @@ add_filter '/spec/' end -$LOAD_PATH.unshift(File.dirname(__FILE__)) -$LOAD_PATH << File.expand_path('../support', __FILE__) - -ENV['BUNDLE_GEMFILE'] = File.expand_path('../../Gemfile', __FILE__) -require "bundler" -Bundler.setup - ENV['RAILS_ENV'] = 'test' -require 'rails' -ENV['RAILS'] = Rails.version -ENV['RAILS_ROOT'] = File.expand_path("../rails/rails-#{ENV['RAILS']}", __FILE__) -unless File.exist?(ENV['RAILS_ROOT']) - system 'rake setup' -end +require_relative 'dummy/config/environment' -require 'rails/all' -require 'active_admin' -ActiveAdmin.application.load_paths = [ENV['RAILS_ROOT'] + "/app/admin"] -require ENV['RAILS_ROOT'] + '/config/environment.rb' -ActiveAdmin.application.authentication_method = false -ActiveAdmin.application.current_user_method = false - -# Rails 8.1 draws routes lazily, so ActiveAdmin's `before_load` hooks -- the -# ones that mix BatchActions/Filters into ActiveAdmin::Resource -- have not run -# by the time a spec calls `ActiveAdmin.register`. Resources built before them -# come out half-initialized (`@batch_actions` nil) and the index page blows up. -ActiveAdmin.application.load! +ActiveRecord::Migration.verbose = false +load File.expand_path('support/schema.rb', __dir__) require 'rspec/rails' -require 'capybara/rails' require 'capybara/rspec' - -require 'support/admin' -require 'support/capybara' +require_relative 'support/capybara' +require_relative 'support/tailwind' RSpec.configure do |config| config.use_transactional_fixtures = false - - config.before(:suite) do - ActiveRecord::Migration.maintain_test_schema! - DatabaseCleaner.strategy = :truncation - DatabaseCleaner.clean_with(:truncation) - end - config.before(:each) do - DatabaseCleaner.strategy = :truncation - DatabaseCleaner.start - end - config.after(:each) do - DatabaseCleaner.clean - end + config.before(:suite) { TailwindBuild.run! } + config.after { Author.delete_all } end diff --git a/spec/support/admin.rb b/spec/support/admin.rb deleted file mode 100644 index 19c0c2a..0000000 --- a/spec/support/admin.rb +++ /dev/null @@ -1,9 +0,0 @@ -def add_author_resource(options = {}, &block) - - ActiveAdmin.register Author do - config.filters = true - end - - Rails.application.reload_routes! - -end diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index afdcda4..2bbba76 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -1,29 +1,13 @@ require 'capybara/cuprite' -Capybara.server = :webrick +Capybara.server = :puma, { Silent: true } Capybara.register_driver :cuprite do |app| - # Chrome intermittently fails to hand back a websocket URL on GitHub - # runners and the leg dies with Ferrum::ProcessTimeoutError. The cause - # is not established. Two theories were tried and both are ruled out: - # - # * /dev/shm exhaustion -- Ferrum already passes - # --disable-dev-shm-usage by default - # (Ferrum::Browser::Options::Chrome::DEFAULT_OPTIONS), so the runs - # that failed were already using it. - # * the ubuntu-24.04 AppArmor restriction on unprivileged user - # namespaces -- the sibling repo capybara_active_admin passes - # --no-sandbox and --disable-setuid-sandbox already, and flakes - # the same way. - # - # So: --no-sandbox is kept because it is harmless on a single-tenant - # CI VM serving only this suite's dummy app and may still help, and - # process_timeout is raised from Ferrum's 10s default because a slow - # start is at least a failure mode a budget can cover. Neither is - # demonstrated to fix it. A rerun is currently the answer when it - # happens. + # --no-sandbox and the longer process_timeout keep Chrome starting on + # GitHub runners, where it sometimes takes over Ferrum's 10s default. Capybara::Cuprite::Driver.new(app, headless: true, window_size: [1280, 800], process_timeout: 30, browser_options: { 'no-sandbox': nil }) end +Capybara.default_driver = :cuprite Capybara.javascript_driver = :cuprite Capybara.default_max_wait_time = 5 diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb deleted file mode 100644 index 9a755f8..0000000 --- a/spec/support/rails_template.rb +++ /dev/null @@ -1,67 +0,0 @@ -# Rails 8.1 generates `stale_when_importmap_changes` into ApplicationController, -# but importmap-rails is not in this bundle (the suite serves assets through -# Sprockets), so the constant never gets defined and loading the controller -# raises NameError. -# -# This has to happen before anything else in this template: `generate` shells -# out to `rails generate`, which boots the app, and `config/environments/test.rb` -# sets `config.eager_load = ENV["CI"].present?` -- so on CI the controller is -# eager-loaded during generation and the template dies on its first model. -# Locally (no CI env var) it stays lazy and only the specs would notice. -gsub_file "app/controllers/application_controller.rb", /^\s*stale_when_importmap_changes\s*\n/, "", verbose: false - -# Ensure Sprockets manifest exists (required by Rails 8+) -FileUtils.mkdir_p("app/assets/config") -File.write("app/assets/config/manifest.js", - "//= link_directory ../javascripts .js\n//= link_directory ../stylesheets .css\n") - -generate :model, 'author name:string{10}:uniq last_name:string birthday:date --force' -generate :model, 'post title:string:uniq body:text author:references --force' - -inject_into_file "app/models/author.rb", " validates_presence_of :name\n validates_uniqueness_of :last_name\n", after: "ApplicationRecord\n" -inject_into_file "app/models/post.rb", " validates_presence_of :author\n", after: ":author\n" - -# Add ransackable_attributes for Ransack 4+ -inject_into_file "app/models/author.rb", - " def self.ransackable_attributes(auth_object = nil)\n" \ - " [\"name\", \"last_name\", \"birthday\", \"created_at\"]\n" \ - " end\n", - after: "ApplicationRecord\n" - -inject_into_file "app/models/post.rb", - " def self.ransackable_attributes(auth_object = nil)\n" \ - " [\"title\", \"body\", \"author_id\"]\n" \ - " end\n" \ - " def self.ransackable_associations(auth_object = nil)\n" \ - " [\"author\"]\n" \ - " end\n", - after: "ApplicationRecord\n" - -# Add our local Active Admin to the load path (Rails 7.1+) -gsub_file "config/environment.rb", - 'require_relative "application"', - "require_relative \"application\"\n$LOAD_PATH.unshift('#{File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'lib'))}')\nrequire \"active_admin\"\n" - -$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) - -generate :'active_admin:install --skip-users' -generate :'formtastic:install' - -# Wire the plugin into the dummy app: require the asset, then invoke the -# jQuery plugin on the filters sidebar panel. Without the invocation the -# plugin never runs and no spec can see anything. -inject_into_file "app/assets/javascripts/active_admin.js", - "//= require active_admin_filters_visibility\n", - after: "//= require active_admin/base\n" - -append_to_file "app/assets/javascripts/active_admin.js", <<~JS - $(document).ready(function() { - $('#filters_sidebar_section').activeAdminFiltersVisibility(); - }); -JS - -run "rm -rf test" -route "root :to => 'admin/dashboard#index'" -rake "db:migrate" - -run "rm -f Gemfile Gemfile.lock" diff --git a/spec/support/schema.rb b/spec/support/schema.rb new file mode 100644 index 0000000..393bb43 --- /dev/null +++ b/spec/support/schema.rb @@ -0,0 +1,9 @@ +ActiveRecord::Schema.define do + create_table :authors, force: true do |t| + t.string :name + t.string :last_name + t.date :birthday + t.boolean :active + t.timestamps + end +end diff --git a/spec/support/tailwind.rb b/spec/support/tailwind.rb new file mode 100644 index 0000000..8324882 --- /dev/null +++ b/spec/support/tailwind.rb @@ -0,0 +1,40 @@ +require 'fileutils' + +# Builds the dummy app's ActiveAdmin stylesheet the way a host does: Tailwind +# with ActiveAdmin's plugin, then the theme (when bundled), then this gem's +# plain CSS. +module TailwindBuild + module_function + + def run! + dummy = File.expand_path('../dummy', __dir__) + aa = Gem.loaded_specs.fetch('activeadmin').full_gem_path + theme = Gem.loaded_specs['active_admin_theme']&.full_gem_path + gem_css = File.expand_path('../../app/assets/stylesheets/active_admin_filters_visibility.css', __dir__) + + lines = [ + '@import "tailwindcss";', + '@custom-variant dark (&:where(.dark, .dark *));', + %(@plugin "#{aa}/plugin.js";), + %(@source "#{aa}/app/views";), + %(@source "#{aa}/lib/active_admin";), + %(@source "#{aa}/vendor/javascript/flowbite.js";), + %(@source "#{dummy}/app";) + ] + if theme + lines << %(@import "#{theme}/app/tailwind/active_admin_theme.css";) + lines << %(@source "#{theme}/app";) + lines << %(@source "#{theme}/lib";) + end + lines << %(@import "#{gem_css}";) + + input = File.join(dummy, 'tmp/active_admin.tailwind.css') + output = File.join(dummy, 'app/assets/builds/active_admin.css') + FileUtils.mkdir_p(File.dirname(input)) + File.write(input, lines.join("\n") + "\n") + + cli = Gem.bin_path('tailwindcss-ruby', 'tailwindcss') + system(cli, '-i', input, '-o', output, out: File::NULL, err: File::NULL) or + raise 'tailwindcss failed to build the dummy stylesheet' + end +end diff --git a/tasks/test.rake b/tasks/test.rake deleted file mode 100644 index f46bf45..0000000 --- a/tasks/test.rake +++ /dev/null @@ -1,13 +0,0 @@ -desc "Creates a test rails app for the specs to run against" -task :setup do - require 'rails/version' - - rails_new_opts = %w( - --skip-turbolinks - --skip-spring - --skip-bootsnap - -m - spec/support/rails_template.rb - ) - system "bundle exec rails new spec/rails/rails-#{Rails::VERSION::STRING} #{rails_new_opts.join(' ')}" -end