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
6 changes: 4 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Unreleased ##

* New action option `:confirm_submit` - turns the fields dialog into two steps, the second one replaces the
fields with amount of affected records and the changes to be applied
* New action option `:confirm_summary` - tells the user how many records the action affects
* Form field can be defined as `{type: 'text', class: 'my-widget'}` to render input with own class
* Tested against Ruby 3.3, 3.4 and 4.0 with Rails 8.0 and 8.1; dropped EOL Ruby 3.2 and Rails 7.1/7.2 and raised the required Ruby version to 3.3

## ActiveAdmin Scoped Collection Actions 1.0.1 (March 02, 2024) ##
Expand All @@ -14,12 +18,10 @@

* Swedish locale (@buren #27)


## ActiveAdmin Scoped Collection Actions 0.3.3 (July 17, 2017) ##

* Fixed compatibility with Rails 5.1


## ActiveAdmin Scoped Collection Actions 0.3.2 (July 17, 2017) ##

* Added I18n support.
Expand Down
82 changes: 82 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ In this example Phone model has fields:

Parameter "form" is a proc object which returns Hash. It defines what fields you want to be able to update. Hash keys are column names in database. Hash values are a types of HTML inputs. We support only "text", "datepicker" and "selectbox". If you want something more complex - you can build your own forms.

A field value can also be a Hash `{type: 'text', class: 'my-widget'}`. Such field is rendered as a plain input with your own
class on it, so you can turn it into any widget(datetime picker, autocomplete, etc.) with your own JavaScript. The dialog
triggers `mass_update_modal_dialog:after_open` event on `body` with the form as an argument, this is the place to initialize
your widgets:

```javascript
$(document).on('mass_update_modal_dialog:after_open', function (event, form) {
$(form).find('input.my-widget').myWidget();
});
```

# Custom Actions

Example: We have Phone resource and it has column "manufactured_at". We need an action which will erase this date.
Expand Down Expand Up @@ -198,6 +209,77 @@ Similar to button title. Use option `:confirm`
```


### How can I tell the user how many records are affected?

Option `:confirm_summary` adds a sentence with the amount of affected records to the dialog:

```ruby
scoped_collection_action :scoped_collection_destroy, confirm_summary: true
```

```
Delete all?

You are going to delete 100500 record(s).

[ OK ] [ Cancel ]
```

`true` uses the default text - `confirm_destroy_summary` locale key for `:scoped_collection_destroy` and
`confirm_action_summary` for any other action. Pass a String or a Proc to use your own text. `{count}` in the
text is replaced with the number.

The amount is requested when the dialog is opened, with the same filters, scope and checked records the action
itself will use, so it costs one extra COUNT query per opened dialog. Until it arrives the message is not
displayed at all - there is a spinner in its place and OK is disabled, so nothing can be confirmed before the
user sees what they confirm. Without `:confirm_summary` nothing is counted and nothing is requested.

Both options in one flow - an update action with `confirm_submit:` and the summary it shows, from the sidebar
button to the result:

![confirm_summary and confirm_submit](/screenshots/example_confirm_summary.png)

### How can I ask an extra confirmation before the form is submitted?

An action without a form is confirmed by the modal dialog itself - it has no fields and the user just presses OK.
An action with a form applies changes immediately after OK is pressed. Use option `:confirm_submit` to turn such
action into two steps - the second one replaces the fields with a summary of what is going to happen:

```ruby
scoped_collection_action :scoped_collection_update,
confirm_submit: true,
form: -> { { body: 'text' } }
```

```
Are you sure?

You are going to update 100500 record(s) with:
Body: Text here...
Author_id: Jane

[ OK ] [ Back ]
```

"Back" returns to the form with everything the user filled in still there. Only the fields the user checked are
listed, and when nothing is checked there is nothing to confirm - the action is performed right away. Text of
the sentence is `confirm_submit_summary` locale key, or `:confirm_summary` option when you set it.

```ruby
scoped_collection_action :scoped_collection_update,
confirm_submit: true,
form: -> { { name: 'text' } }
```

`true` shows the default message ("Are you sure?"). Pass a String or a Proc to use your own:

```ruby
scoped_collection_action :scoped_collection_update,
confirm_submit: 'Update all filtered phones?',
form: -> { { name: 'text' } }
```


### Can I replace you pop-up form with my own?

Yes. But also you must take care of mandatory parameters passed to the server.
Expand Down
3 changes: 3 additions & 0 deletions config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ de:
active_admin_scoped_collection_actions:
sidebar_msg: 'Diese Aktion betrifft alle ausgewählten Datensätze. Wenn keine ausgewählt sind, betrifft sie alle gefilterten Datensätze.'
confirm_action_message: 'Sind Sie sicher?'
confirm_submit_summary: 'Sie werden {count} Datensätze aktualisieren mit:'
confirm_destroy_summary: 'Sie werden {count} Datensätze löschen.'
confirm_action_summary: 'Sie werden diese Aktion für {count} Datensätze ausführen.'
actions:
delete: 'Löschen'
update: 'Aktualisieren'
Expand Down
4 changes: 4 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ en:
active_admin_scoped_collection_actions:
sidebar_msg: 'This batch operations affect selected records. Or if none is selected, it will involve all records by current filters and scopes.'
confirm_action_message: 'Are you sure?'
# {count} is replaced with amount of affected records
confirm_submit_summary: 'You are going to update {count} record(s) with:'
confirm_destroy_summary: 'You are going to delete {count} record(s).'
confirm_action_summary: 'You are going to perform this action on {count} record(s).'
actions:
delete: 'Delete batch'
update: 'Update batch'
Expand Down
3 changes: 3 additions & 0 deletions config/locales/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ es:
active_admin_scoped_collection_actions:
sidebar_msg: 'Estas operaciones afectan las filas seleccionadas o en caso que no halla seleccionado ninguna, afectará a todo los registros afectados por los filtros aplicados.'
confirm_action_message: '¿Está seguro que quiere realizar esta acción?'
confirm_submit_summary: 'Va a actualizar {count} registro(s) con:'
confirm_destroy_summary: 'Va a eliminar {count} registro(s).'
confirm_action_summary: 'Va a realizar esta acción sobre {count} registro(s).'
actions:
delete: 'Batch Borrado'
update: 'Batch Actualizado'
Expand Down
3 changes: 3 additions & 0 deletions config/locales/sv.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ sv:
active_admin_scoped_collection_actions:
sidebar_msg: 'Den här bulkoperationen påverkar markerade poster. Eller om inget är markerat så kommer det påverka alla poster i nuvarande filter och scope.'
confirm_action_message: 'Är du säker?'
confirm_submit_summary: 'Du kommer att uppdatera {count} post(er) med:'
confirm_destroy_summary: 'Du kommer att ta bort {count} post(er).'
confirm_action_summary: 'Du kommer att utföra den här åtgärden på {count} post(er).'
actions:
delete: 'Ta bort batch'
update: 'Uppdatera batch'
Expand Down
19 changes: 19 additions & 0 deletions lib/active_admin_scoped_collection_actions/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module ActiveAdminScopedCollectionActions
module DSL

def scoped_collection_action(name, options = {}, &block)
add_scoped_collection_records_count_action
if name == :scoped_collection_destroy
options[:title] = I18n.t('active_admin_scoped_collection_actions.actions.delete') if options[:title].nil?
add_scoped_collection_action_default_destroy(options, &block)
Expand All @@ -15,6 +16,24 @@ def scoped_collection_action(name, options = {}, &block)
config.add_scoped_collection_action(name, options)
end

# Amount of records affected by a scoped collection action. Used by the confirmation
# dialog of an action with :confirm_submit option. Collection is already limited by
# authorization scope, so it exposes nothing the user can not see on the index page.
def add_scoped_collection_records_count_action
batch_action :scoped_collection_records_count, if: proc { false } do
# Count the rows this relation yields, not the rows its WHERE matches.
# The action walks the same relation with find_each, which honours
# limit and offset, so stripping them here would promise more records
# than it goes on to touch. Counting through a subquery keeps every
# clause and collapses a grouped scope -- whose bare .count answers
# with a Hash the dialog would render as [object Object] -- into the
# single number the dialog needs.
scope = scoped_collection_records.except(:eager_load, :select, :order)
count = scope.model.unscoped.from(scope.select('1'), :subquery_for_count).count
render json: { count: count }
end
end

def add_scoped_collection_action_default_update(options, &block)
batch_action :scoped_collection_update, if: proc { false } do
unless authorized?(:batch_edit, resource_class)
Expand Down
12 changes: 12 additions & 0 deletions lib/active_admin_scoped_collection_actions/resource_extension.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ def scoped_collection_actions_sidebar_section
end
b_data[:confirm] = options.fetch(:confirm, I18n.t('active_admin_scoped_collection_actions.confirm_action_message'))
b_data[:confirm] = b_data[:confirm].call if b_data[:confirm].is_a?(Proc)
confirm_submit = options.fetch(:confirm_submit, false)
confirm_submit = confirm_submit.call if confirm_submit.is_a?(Proc)
if confirm_submit
b_data[:confirm_submit] = confirm_submit.is_a?(String) ? confirm_submit : I18n.t('active_admin_scoped_collection_actions.confirm_action_message')
b_data[:confirm_submit_summary] = I18n.t('active_admin_scoped_collection_actions.confirm_submit_summary')
end
confirm_summary = options.fetch(:confirm_summary, false)
confirm_summary = confirm_summary.call if confirm_summary.is_a?(Proc)
if confirm_summary
default_key = key.to_sym == :scoped_collection_destroy ? 'confirm_destroy_summary' : 'confirm_action_summary'
b_data[:confirm_summary] = confirm_summary.is_a?(String) ? confirm_summary : I18n.t("active_admin_scoped_collection_actions.#{default_key}")
end
b_options[:data] = b_data.to_json
button b_title, b_options
end
Expand Down
Binary file added screenshots/example_confirm_summary.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
47 changes: 47 additions & 0 deletions spec/authors_actions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@
end


context 'scoped collection action UPDATE with custom input class' do
# last_name is unique, so only one author is updated
let(:author) { Author.first }
let(:new_last_name) { 'Smith' }

before do
find("#batch_action_item_#{author.id}", visible: true).click
page.find('#collection_actions_sidebar_section button', text: 'Update').click
page.within ('body>.active_admin_dialog_mass_update_by_filter') do
page.find('input#mass_update_dialog_last_name').click
page.find('input[name="last_name"].my-widget').set(new_last_name)
page.find('button', text: 'OK').click
end
end

it 'new last name was set for checked author' do
expect(page).to have_css('.flashes .flash.flash_notice')
expect(author.reload.last_name).to eq(new_last_name)
end
end

context 'scoped collection action DELETE' do
before do
page.find('#collection_actions_sidebar_section button', text: 'Delete').click
Expand All @@ -67,6 +88,32 @@
end
end

context 'summary' do
it 'tells how many records are going to be deleted' do
expect(page).to have_css('.dialog_records_summary',
text: "You are going to delete #{Author.count} record(s).")
end
end

context 'summary is not loaded yet' do
# count request never responds
before do
page.find('button', text: 'Cancel').click
page.execute_script('ActiveAdmin.scopedCollectionRecordsCount = () => $.Deferred().promise()')
page.find('#collection_actions_sidebar_section button', text: 'Delete').click
end

it 'shows spinner and does not allow to confirm' do
page.within ('body>.active_admin_dialog_mass_update_by_filter') do
expect(page).to have_css('.dialog_spinner')
expect(page).to_not have_css('.dialog_records_summary')
expect(page).to have_button('OK', disabled: true)
end
expect(page).to_not have_css('.flashes .flash.flash_notice')
expect(Author.count).to eq(2)
end
end

context 'action' do
before do
page.within ('body>.active_admin_dialog_mass_update_by_filter') do
Expand Down
Loading