diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e375a6..3235233 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. - Clarified in the README that the button label is a string (#12) ### Changed +- Batch action buttons are now `button_to` forms that are truly disabled until rows are selected (#48) - Batch action routes are now `POST //administrate_batch_actions//`, named `__`, e.g. `admin_delete_batch_action_users_path` ## [0.4.0] - 2026-03-26 diff --git a/app/assets/javascripts/administrate_batch_actions/script.js b/app/assets/javascripts/administrate_batch_actions/script.js index 068f678..6b7a465 100644 --- a/app/assets/javascripts/administrate_batch_actions/script.js +++ b/app/assets/javascripts/administrate_batch_actions/script.js @@ -1,80 +1,122 @@ -const init = function() { - var buttons = document.querySelectorAll("[data-batch-action-option='button']"); - var checkboxes = document.querySelectorAll("[data-batch-action-option='checkbox']"); - var selectAllCheckboxes = document.querySelector("[data-batch-action-option='select_all']"); +const boundForms = new WeakSet(); +const boundCheckboxes = new WeakSet(); +const boundSelectAllCheckboxes = new WeakSet(); - if (selectAllCheckboxes && checkboxes && buttons) { +const init = function () { + var buttons = document.querySelectorAll( + "[data-batch-action-option='button']", + ); + var checkboxes = document.querySelectorAll( + "[data-batch-action-option='checkbox']", + ); + var selectAllCheckboxes = document.querySelector( + "[data-batch-action-option='select_all']", + ); - window.onpageshow = function(event) { - if (selectedIds().length > 0) { - checkboxes.forEach(function(checkbox) { + if (selectAllCheckboxes && checkboxes && buttons) { + // Reset selections restored by the browser's back/forward cache + window.onpageshow = function (event) { + if (selectedItemIds().length > 0) { + checkboxes.forEach(function (checkbox) { checkbox.checked = false; }); selectAllCheckboxes.checked = false; } + + checkAndToggleActionButtons(); }; - selectAllCheckboxes.addEventListener('click', function(){ - checkboxes.forEach(function(checkbox) { - checkbox.checked = selectAllCheckboxes.checked; + if (!isBoundElement(boundSelectAllCheckboxes, selectAllCheckboxes)) { + boundSelectAllCheckboxes.add(selectAllCheckboxes); + + selectAllCheckboxes.addEventListener("click", function () { + checkboxes.forEach(function (checkbox) { + checkbox.checked = selectAllCheckboxes.checked; + }); + + checkAndToggleActionButtons(); }); + } - checkAndToggleActionButtons(); - }); + buttons.forEach(function (button) { + var form = button.form; + if (!form || isBoundElement(boundForms, form)) return; + boundForms.add(form); - buttons.forEach(function(button){ - var originalHref = button.getAttribute('href'); + form.addEventListener("submit", function (event) { + var confirmMessage = button.dataset.batchActionConfirm; - button.addEventListener('click', function(event){ - // Rebuild from the original href so existing query params are kept - // and repeated clicks (e.g. a cancelled confirm) don't append twice. - var url = new URL(originalHref, window.location.href); + if (confirmMessage && confirm(confirmMessage) !== true) { + event.preventDefault(); + return; + } - selectedIds().forEach(function(id) { - url.searchParams.append('batch_action_ids[]', id); - }); + // Replace ids from a previous submission (e.g. after navigating back) + form + .querySelectorAll("[data-batch-action-option='hidden_id']") + .forEach(function (hiddenInput) { + hiddenInput.remove(); + }); - button.href = url.toString(); + selectedItemIds().forEach(function (id) { + var hiddenInput = document.createElement("input"); + hiddenInput.type = "hidden"; + hiddenInput.name = "batch_action_ids[]"; + hiddenInput.value = id; + hiddenInput.dataset.batchActionOption = "hidden_id"; + form.appendChild(hiddenInput); + }); }); }); - checkboxes.forEach(function(checkbox){ - checkbox.closest('td').addEventListener('click', function(event){ + checkboxes.forEach(function (checkbox) { + if (isBoundElement(boundCheckboxes, checkbox)) { + return; + } + boundCheckboxes.add(checkbox); + + checkbox.closest("td").addEventListener("click", function (event) { event.stopImmediatePropagation(); - }) + }); - checkbox.addEventListener('click', function(event) { + checkbox.addEventListener("click", function (event) { event.stopImmediatePropagation(); checkAndToggleActionButtons(); + }); + }); + } + + function selectedItemIds() { + return Array.prototype.filter + .call(checkboxes, function (checkbox) { + return checkbox.checked; }) - }) + .map(function (checkbox) { + return checkbox.value; + }); } - function selectedIds() { - return Array.prototype.filter.call(checkboxes, function(checkbox) { - return checkbox.checked; - }).map(function(checkbox) { - return checkbox.value; + function checkAndToggleActionButtons() { + var disabled = selectedItemIds().length === 0; + + buttons.forEach(function (button) { + button.disabled = disabled; }); } - function checkAndToggleActionButtons() { - if (selectedIds().length > 0) { - buttons.forEach(function(button){ - button.classList.remove('disabled'); - button.removeAttribute('disabled'); - }); - } else { - buttons.forEach(function(button){ - button.classList.add('disabled'); - button.setAttribute('disabled', 'disabled'); - }); - } + function isBoundElement(boundElement, incomingElement) { + return boundElement.has(incomingElement); } }; -document.addEventListener("DOMContentLoaded", function() { init() }); -document.addEventListener("turbolinks:load", function() { init() }); -document.addEventListener("turbo:load", function() { init() }); +document.addEventListener("DOMContentLoaded", function () { + init(); +}); +document.addEventListener("turbolinks:load", function () { + init(); +}); +document.addEventListener("turbo:load", function () { + init(); +}); diff --git a/app/views/shared/administrate_batch_actions/_button.html.erb b/app/views/shared/administrate_batch_actions/_button.html.erb index f9eea45..517eaba 100644 --- a/app/views/shared/administrate_batch_actions/_button.html.erb +++ b/app/views/shared/administrate_batch_actions/_button.html.erb @@ -1 +1,8 @@ -<%= link_to name, path, class: "btn disabled #{html_options[:class]}", data: { batch_action_option: 'button', confirm: html_options[:confirm] }, method: :post %> +<%= button_to path, + class: "btn #{html_options[:class]}", + data: { batch_action_option: 'button', batch_action_confirm: html_options[:confirm].presence }, + form: { data: { turbo: false } }, + method: :post, + disabled: true do %> + <%= name %> +<% end %> diff --git a/app/views/shared/administrate_batch_actions/_checkbox.html.erb b/app/views/shared/administrate_batch_actions/_checkbox.html.erb index accd3d4..f39c71f 100644 --- a/app/views/shared/administrate_batch_actions/_checkbox.html.erb +++ b/app/views/shared/administrate_batch_actions/_checkbox.html.erb @@ -1,3 +1,3 @@ - + diff --git a/spec/helpers_spec.rb b/spec/helpers_spec.rb index 58471cc..15dadc1 100644 --- a/spec/helpers_spec.rb +++ b/spec/helpers_spec.rb @@ -2,15 +2,37 @@ RSpec.describe 'Helpers', type: :helper do it 'renders action button (default)' do - expect(helper.administrate_batch_actions_button('Delete All', '/')).to include 'Delete All' + expected_html = <<~HTML +
+ HTML + + expect(helper.administrate_batch_actions_button('Delete All', '/').squish).to include(expected_html.squish) end it 'renders action button (with class)' do - expect(helper.administrate_batch_actions_button('Delete All', '/', class: 'button text-danger')).to include 'Delete All' + expected_html = <<~HTML +
+ HTML + + expect(helper.administrate_batch_actions_button('Delete All', '/', class: 'button text-danger').squish).to include(expected_html.squish) end it 'renders action button (with confirm window)' do - expect(helper.administrate_batch_actions_button('Delete All', '/', class: 'button', confirm: 'Are you sure to do this?')).to include 'Delete All' + expected_html = <<~HTML +
+ HTML + + expect(helper.administrate_batch_actions_button('Delete All', '/', class: 'button', confirm: 'Are you sure to do this?').squish).to include(expected_html.squish) + end + + it 'renders action button (keeps query params in the path)' do + expect(helper.administrate_batch_actions_button('Delete All', '/?scope=active')).to include 'action="/?scope=active"' end it 'renders select all' do @@ -18,6 +40,10 @@ end it 'renders checkbox' do - expect(helper.administrate_batch_actions_checkbox(1)).to include '' + expected_html = <<~HTML + + HTML + + expect(helper.administrate_batch_actions_checkbox(1).squish).to include(expected_html.squish) end end