Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<namespace>/administrate_batch_actions/<controller>/<action>`, named `<namespace>_<action>_<controller>`, e.g. `admin_delete_batch_action_users_path`

## [0.4.0] - 2026-03-26
Expand Down
138 changes: 90 additions & 48 deletions app/assets/javascripts/administrate_batch_actions/script.js
Original file line number Diff line number Diff line change
@@ -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();
});
9 changes: 8 additions & 1 deletion app/views/shared/administrate_batch_actions/_button.html.erb
Original file line number Diff line number Diff line change
@@ -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 %>
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<td>
<input type="checkbox" value="<%= value %>" name="batch_action_ids[]" data-batch-action-option="checkbox" />
<input type="checkbox" value="<%= value %>" data-batch-action-option="checkbox" />
</td>
34 changes: 30 additions & 4 deletions spec/helpers_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,48 @@

RSpec.describe 'Helpers', type: :helper do
it 'renders action button (default)' do
expect(helper.administrate_batch_actions_button('Delete All', '/')).to include '<a class="btn disabled button" data-batch-action-option="button" rel="nofollow" data-method="post" href="/">Delete All</a>'
expected_html = <<~HTML
<form data-turbo="false" class="button_to" method="post" action="/"><button class="btn button" data-batch-action-option="button" disabled="disabled" type="submit">
Delete All
</button></form>
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 '<a class="btn disabled button text-danger" data-batch-action-option="button" rel="nofollow" data-method="post" href="/">Delete All</a>'
expected_html = <<~HTML
<form data-turbo="false" class="button_to" method="post" action="/"><button class="btn button text-danger" data-batch-action-option="button" disabled="disabled" type="submit">
Delete All
</button></form>
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 '<a class="btn disabled button" data-batch-action-option="button" data-confirm="Are you sure to do this?" rel="nofollow" data-method="post" href="/">Delete All</a>'
expected_html = <<~HTML
<form data-turbo="false" class="button_to" method="post" action="/"><button class="btn button" data-batch-action-option="button" data-batch-action-confirm="Are you sure to do this?" disabled="disabled" type="submit">
Delete All
</button></form>
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
expect(helper.administrate_batch_actions_select_all).to include '<input type="checkbox" data-batch-action-option="select_all" />'
end

it 'renders checkbox' do
expect(helper.administrate_batch_actions_checkbox(1)).to include '<input type="checkbox" value="1" name="batch_action_ids[]" data-batch-action-option="checkbox" />'
expected_html = <<~HTML
<input type="checkbox" value="1" data-batch-action-option="checkbox" />
HTML

expect(helper.administrate_batch_actions_checkbox(1).squish).to include(expected_html.squish)
end
end
Loading