diff --git a/composer.json b/composer.json
index 30e32b85a7003a8a43db8475194b946c3fbe097c..880282bb7a100069b3f5341feb0c5c4c676e1726 100644
--- a/composer.json
+++ b/composer.json
@@ -174,7 +174,7 @@
         "drupal/views_ajax_history": "1.6",
         "drupal/views_autocomplete_filters": "1.3",
         "drupal/views_bootstrap": "3.6",
-        "drupal/views_bulk_operations": "3.13",
+        "drupal/views_bulk_operations": "4.1.2",
         "drupal/views_fieldsets": "^3.4",
         "drupal/views_infinite_scroll": "1.9",
         "drupal/webform": "^6",
diff --git a/composer.lock b/composer.lock
index e4accdd3d049efdc07d4e75a9d08de35cf193197..2128b21a1febc65998cefb914bc2ac453d7cdec4 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "4d692bfd0abd7ce37d05150a17a6ccc3",
+    "content-hash": "ca74f702b216b1e9478fd9448cc02a06",
     "packages": [
         {
             "name": "alchemy/zippy",
@@ -7836,20 +7836,20 @@
         },
         {
             "name": "drupal/views_bulk_operations",
-            "version": "3.13.0",
+            "version": "4.1.2",
             "source": {
                 "type": "git",
                 "url": "https://git.drupalcode.org/project/views_bulk_operations.git",
-                "reference": "8.x-3.13"
+                "reference": "4.1.2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://ftp.drupal.org/files/projects/views_bulk_operations-8.x-3.13.zip",
-                "reference": "8.x-3.13",
-                "shasum": "70583d08b91be3b5e008f571589425c2176eb73b"
+                "url": "https://ftp.drupal.org/files/projects/views_bulk_operations-4.1.2.zip",
+                "reference": "4.1.2",
+                "shasum": "1cfa10f755240cae46de8d8684272a9a8907a730"
             },
             "require": {
-                "drupal/core": "^8.8 || ^9"
+                "drupal/core": "^9"
             },
             "require-dev": {
                 "drush/drush": "^10"
@@ -7860,8 +7860,8 @@
             "type": "drupal-module",
             "extra": {
                 "drupal": {
-                    "version": "8.x-3.13",
-                    "datestamp": "1619697066",
+                    "version": "4.1.2",
+                    "datestamp": "1647943086",
                     "security-coverage": {
                         "status": "covered",
                         "message": "Covered by Drupal's security advisory policy"
@@ -12942,16 +12942,16 @@
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v4.4.39",
+            "version": "v4.4.42",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "4e9215a8b533802ba84a3cc5bd3c43103e7a6dc3"
+                "reference": "be5a04618e5d44e71d013f177df80d3ec4b192a0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4e9215a8b533802ba84a3cc5bd3c43103e7a6dc3",
-                "reference": "4e9215a8b533802ba84a3cc5bd3c43103e7a6dc3",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/be5a04618e5d44e71d013f177df80d3ec4b192a0",
+                "reference": "be5a04618e5d44e71d013f177df80d3ec4b192a0",
                 "shasum": ""
             },
             "require": {
@@ -12996,7 +12996,7 @@
             "description": "Eases DOM navigation for HTML and XML documents",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dom-crawler/tree/v4.4.39"
+                "source": "https://github.com/symfony/dom-crawler/tree/v4.4.42"
             },
             "funding": [
                 {
@@ -13012,7 +13012,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-02-25T10:38:15+00:00"
+            "time": "2022-04-30T18:34:00+00:00"
         },
         {
             "name": "symfony/error-handler",
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index 600b6f84a6b99ba97aa7216f41fefb739be2350a..4146e7a5c84a49184c12d9e828ba0edfbda89811 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -8147,21 +8147,21 @@
         },
         {
             "name": "drupal/views_bulk_operations",
-            "version": "3.13.0",
-            "version_normalized": "3.13.0.0",
+            "version": "4.1.2",
+            "version_normalized": "4.1.2.0",
             "source": {
                 "type": "git",
                 "url": "https://git.drupalcode.org/project/views_bulk_operations.git",
-                "reference": "8.x-3.13"
+                "reference": "4.1.2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://ftp.drupal.org/files/projects/views_bulk_operations-8.x-3.13.zip",
-                "reference": "8.x-3.13",
-                "shasum": "70583d08b91be3b5e008f571589425c2176eb73b"
+                "url": "https://ftp.drupal.org/files/projects/views_bulk_operations-4.1.2.zip",
+                "reference": "4.1.2",
+                "shasum": "1cfa10f755240cae46de8d8684272a9a8907a730"
             },
             "require": {
-                "drupal/core": "^8.8 || ^9"
+                "drupal/core": "^9"
             },
             "require-dev": {
                 "drush/drush": "^10"
@@ -8172,8 +8172,8 @@
             "type": "drupal-module",
             "extra": {
                 "drupal": {
-                    "version": "8.x-3.13",
-                    "datestamp": "1619697066",
+                    "version": "4.1.2",
+                    "datestamp": "1647943086",
                     "security-coverage": {
                         "status": "covered",
                         "message": "Covered by Drupal's security advisory policy"
@@ -13309,17 +13309,17 @@
         },
         {
             "name": "symfony/dom-crawler",
-            "version": "v4.4.39",
-            "version_normalized": "4.4.39.0",
+            "version": "v4.4.42",
+            "version_normalized": "4.4.42.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/dom-crawler.git",
-                "reference": "4e9215a8b533802ba84a3cc5bd3c43103e7a6dc3"
+                "reference": "be5a04618e5d44e71d013f177df80d3ec4b192a0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4e9215a8b533802ba84a3cc5bd3c43103e7a6dc3",
-                "reference": "4e9215a8b533802ba84a3cc5bd3c43103e7a6dc3",
+                "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/be5a04618e5d44e71d013f177df80d3ec4b192a0",
+                "reference": "be5a04618e5d44e71d013f177df80d3ec4b192a0",
                 "shasum": ""
             },
             "require": {
@@ -13338,7 +13338,7 @@
             "suggest": {
                 "symfony/css-selector": ""
             },
-            "time": "2022-02-25T10:38:15+00:00",
+            "time": "2022-04-30T18:34:00+00:00",
             "type": "library",
             "installation-source": "dist",
             "autoload": {
@@ -13366,7 +13366,7 @@
             "description": "Eases DOM navigation for HTML and XML documents",
             "homepage": "https://symfony.com",
             "support": {
-                "source": "https://github.com/symfony/dom-crawler/tree/v4.4.39"
+                "source": "https://github.com/symfony/dom-crawler/tree/v4.4.42"
             },
             "funding": [
                 {
diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php
index 32754976359ce7329f86ea1f336db475339ff121..1bc9123907fc932e1a5bb1b58b1769f5b2d52284 100644
--- a/vendor/composer/installed.php
+++ b/vendor/composer/installed.php
@@ -5,7 +5,7 @@
         'type' => 'project',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
-        'reference' => 'cf6e62cb8884f75da3b28fac12b8ce2c6e1fc77b',
+        'reference' => '041c3df39d99c77a37adea73f38c4380bad4c507',
         'name' => 'osu-asc-webservices/d8-upstream',
         'dev' => true,
     ),
@@ -1811,12 +1811,12 @@
             'dev_requirement' => false,
         ),
         'drupal/views_bulk_operations' => array(
-            'pretty_version' => '3.13.0',
-            'version' => '3.13.0.0',
+            'pretty_version' => '4.1.2',
+            'version' => '4.1.2.0',
             'type' => 'drupal-module',
             'install_path' => __DIR__ . '/../../web/modules/views_bulk_operations',
             'aliases' => array(),
-            'reference' => '8.x-3.13',
+            'reference' => '4.1.2',
             'dev_requirement' => false,
         ),
         'drupal/views_fieldsets' => array(
@@ -2101,7 +2101,7 @@
             'type' => 'project',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
-            'reference' => 'cf6e62cb8884f75da3b28fac12b8ce2c6e1fc77b',
+            'reference' => '041c3df39d99c77a37adea73f38c4380bad4c507',
             'dev_requirement' => false,
         ),
         'pantheon-systems/quicksilver-pushback' => array(
@@ -2658,12 +2658,12 @@
             'dev_requirement' => false,
         ),
         'symfony/dom-crawler' => array(
-            'pretty_version' => 'v4.4.39',
-            'version' => '4.4.39.0',
+            'pretty_version' => 'v4.4.42',
+            'version' => '4.4.42.0',
             'type' => 'library',
             'install_path' => __DIR__ . '/../symfony/dom-crawler',
             'aliases' => array(),
-            'reference' => '4e9215a8b533802ba84a3cc5bd3c43103e7a6dc3',
+            'reference' => 'be5a04618e5d44e71d013f177df80d3ec4b192a0',
             'dev_requirement' => false,
         ),
         'symfony/error-handler' => array(
diff --git a/vendor/symfony/dom-crawler/Crawler.php b/vendor/symfony/dom-crawler/Crawler.php
index 462b6b1129d9e6d46ecc03a44419de97391f7274..4f89eec75a74bce314d9b890f4697b1926b25f6f 100644
--- a/vendor/symfony/dom-crawler/Crawler.php
+++ b/vendor/symfony/dom-crawler/Crawler.php
@@ -1214,11 +1214,11 @@ private function convertToHtmlEntities(string $htmlContent, string $charset = 'U
         set_error_handler(function () { throw new \Exception(); });
 
         try {
-            return mb_encode_numericentity($htmlContent, [0x80, 0xFFFF, 0, 0xFFFF], $charset);
+            return mb_encode_numericentity($htmlContent, [0x80, 0x10FFFF, 0, 0x1FFFFF], $charset);
         } catch (\Exception|\ValueError $e) {
             try {
                 $htmlContent = iconv($charset, 'UTF-8', $htmlContent);
-                $htmlContent = mb_encode_numericentity($htmlContent, [0x80, 0xFFFF, 0, 0xFFFF], 'UTF-8');
+                $htmlContent = mb_encode_numericentity($htmlContent, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8');
             } catch (\Exception|\ValueError $e) {
             }
 
diff --git a/web/modules/views_bulk_operations/composer.json b/web/modules/views_bulk_operations/composer.json
index 0c9001044841b059804b9eb9a14ae5ed0788db30..09322768a917a23aa4426d305e64f25ce00b31a3 100644
--- a/web/modules/views_bulk_operations/composer.json
+++ b/web/modules/views_bulk_operations/composer.json
@@ -17,7 +17,7 @@
   "license": "GPL-2.0-or-later",
   "minimum-stability": "dev",
   "require": {
-    "drupal/core": "^8.8 || ^9"
+    "drupal/core": "^9"
   },
   "require-dev": {
     "drush/drush": "^10"
diff --git a/web/modules/views_bulk_operations/config/schema/views_bulk_operations.data_types.schema.yml b/web/modules/views_bulk_operations/config/schema/views_bulk_operations.data_types.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..806b2924f7ba9c4412c9314661ad88e918350de8
--- /dev/null
+++ b/web/modules/views_bulk_operations/config/schema/views_bulk_operations.data_types.schema.yml
@@ -0,0 +1,9 @@
+views_bulk_operations_action_config:
+  type: mapping
+  mapping:
+    add_confirmation:
+      type: boolean
+      label: 'Should a confirmation step be added?'
+    label_override:
+      type: string
+      label: 'Action label override'
diff --git a/web/modules/views_bulk_operations/config/schema/views_bulk_operations.views.schema.yml b/web/modules/views_bulk_operations/config/schema/views_bulk_operations.views.schema.yml
index 97cd68b497ff4f10ba85acd6f9086ff4a966f1ed..d433d32803fbdc34556ac84c3fcac84c6a1c32a2 100644
--- a/web/modules/views_bulk_operations/config/schema/views_bulk_operations.views.schema.yml
+++ b/web/modules/views_bulk_operations/config/schema/views_bulk_operations.views.schema.yml
@@ -33,5 +33,9 @@ views.field.views_bulk_operations_bulk_form:
             type: string
             label: 'Action plugin ID'
           preconfiguration:
-            label: 'Preliminary configuration array for the plugin'
-            type: ignore
+            label: 'Configuration array for the plugin'
+            type: views_bulk_operations.action_config.[%parent.action_id]
+
+views_bulk_operations.action_config.*:
+  type: views_bulk_operations_action_config
+  label: 'Default'
diff --git a/web/modules/views_bulk_operations/drush.services.yml b/web/modules/views_bulk_operations/drush.services.yml
index 3d629c0fafacef52f7a20533d59a1a6923ef23c6..093032c6e3c4e75d4bf30ae6a6bf10791ed1d993 100644
--- a/web/modules/views_bulk_operations/drush.services.yml
+++ b/web/modules/views_bulk_operations/drush.services.yml
@@ -3,6 +3,7 @@ services:
     class: \Drupal\views_bulk_operations\Commands\ViewsBulkOperationsCommands
     arguments:
       - '@current_user'
+      - '@entity_type.manager'
       - '@views_bulk_operations.data'
       - '@plugin.manager.views_bulk_operations_action'
     tags:
diff --git a/web/modules/views_bulk_operations/js/adminUi.js b/web/modules/views_bulk_operations/js/adminUi.js
index e7dba636957d7deec58bbcfdc69fec1292ad99b2..356d0b56d616b068c3747cd40aaa60b01d82c6dc 100644
--- a/web/modules/views_bulk_operations/js/adminUi.js
+++ b/web/modules/views_bulk_operations/js/adminUi.js
@@ -12,18 +12,18 @@
    */
   Drupal.behaviors.views_bulk_operations = {
     attach: function (context, settings) {
-      $('.views-bulk-operations-ui').once('views-bulk-operations-ui').each(Drupal.viewsBulkOperationsUi);
+      once('views-bulk-operations-ui', '.views-bulk-operations-ui', context).forEach(Drupal.viewsBulkOperationsUi);
     }
   };
 
   /**
    * Callback used in {@link Drupal.behaviors.views_bulk_operations}.
    */
-  Drupal.viewsBulkOperationsUi = function () {
-    var uiElement = $(this);
+  Drupal.viewsBulkOperationsUi = function (element) {
+    var $uiElement = $(element);
 
     // Select / deselect all functionality.
-    var actionsElementWrapper = uiElement.find('details.vbo-actions-widget > .details-wrapper');
+    var actionsElementWrapper = $uiElement.find('details.vbo-actions-widget > .details-wrapper');
     if (actionsElementWrapper.length) {
       var checked = false;
       var allHandle = $('<a href="#" class="vbo-all-switch">' + Drupal.t('Select / deselect all') + '</a>');
diff --git a/web/modules/views_bulk_operations/js/frontUi.js b/web/modules/views_bulk_operations/js/frontUi.js
index c48e4428c077001e1eeea77a14a05450ef38ce87..7e604cb2daf0fc4ba5262229fa90cba3769294ff 100644
--- a/web/modules/views_bulk_operations/js/frontUi.js
+++ b/web/modules/views_bulk_operations/js/frontUi.js
@@ -12,7 +12,7 @@
    */
   Drupal.behaviors.views_bulk_operations = {
     attach: function (context, settings) {
-      $('.vbo-view-form').once('vbo-init').each(Drupal.viewsBulkOperationsFrontUi);
+      once('vbo-init', '.vbo-view-form', context).forEach(Drupal.viewsBulkOperationsFrontUi);
     }
   };
 
@@ -23,7 +23,7 @@
     view_id: '',
     display_id: '',
     list: {},
-    $placeholder: null,
+    $summary: null,
 
     /**
      * Bind event handlers to an element.
@@ -85,7 +85,7 @@
           op = state ? 'add' : 'remove';
         }
 
-        var $placeholder = this.$placeholder;
+        var $summary = this.$summary;
         var $selectionInfo = this.$selectionInfo;
         var target_uri = drupalSettings.path.baseUrl + drupalSettings.path.pathPrefix + 'views-bulk-operations/ajax/' + this.view_id + '/' + this.display_id;
 
@@ -96,8 +96,8 @@
             op: op
           },
           success: function (data) {
-            $selectionInfo.html($(data.selection_info).html());
-            $placeholder.text(data.count);
+            $selectionInfo.html(data.selection_info);
+            $summary.text(Drupal.formatPlural(data.count, 'Selected 1 item in this view', 'Selected @count items in this view'));
           }
         });
       }
@@ -107,8 +107,8 @@
   /**
    * Callback used in {@link Drupal.behaviors.views_bulk_operations}.
    */
-  Drupal.viewsBulkOperationsFrontUi = function () {
-    var $vboForm = $(this);
+  Drupal.viewsBulkOperationsFrontUi = function (element) {
+    var $vboForm = $(element);
     var $viewsTables = $('.vbo-table', $vboForm);
     var $primarySelectAll = $('.vbo-select-all', $vboForm);
     var tableSelectAll = [];
@@ -116,7 +116,7 @@
     // When grouping is enabled, there can be multiple tables.
     if ($viewsTables.length) {
       $viewsTables.each(function (index) {
-        tableSelectAll[index] = $(this).find('.select-all input').first();
+        tableSelectAll[index] = $vboForm.find('.select-all input').first();
       });
       var $tableSelectAll = $(tableSelectAll);
     }
@@ -126,7 +126,7 @@
     if ($multiSelectElement.length) {
 
       Drupal.viewsBulkOperationsSelection.$selectionInfo = $multiSelectElement.find('.vbo-info-list-wrapper').first();
-      Drupal.viewsBulkOperationsSelection.$placeholder = $multiSelectElement.find('.placeholder').first();
+      Drupal.viewsBulkOperationsSelection.$summary = $multiSelectElement.find('summary').first();
       Drupal.viewsBulkOperationsSelection.view_id = $multiSelectElement.attr('data-view-id');
       Drupal.viewsBulkOperationsSelection.display_id = $multiSelectElement.attr('data-display-id');
       Drupal.viewsBulkOperationsSelection.vbo_form = $vboForm;
diff --git a/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.info.yml b/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.info.yml
index ab281d2a00890a55f3aba14aa773d405ccc6201e..ed1d4dcfe5cdb900769a8af5723a622edd3e6c54 100644
--- a/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.info.yml
+++ b/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.info.yml
@@ -6,7 +6,7 @@ core_version_requirement: ^8 || ^9
 dependencies:
   - drupal:views_bulk_operations
 
-# Information added by Drupal.org packaging script on 2021-04-29
-version: '8.x-3.13'
+# Information added by Drupal.org packaging script on 2022-03-19
+version: '4.1.2'
 project: 'views_bulk_operations'
-datestamp: 1619697069
+datestamp: 1647723470
diff --git a/web/modules/views_bulk_operations/modules/actions_permissions/src/EventSubscriber/ActionsPermissionsEventSubscriber.php b/web/modules/views_bulk_operations/modules/actions_permissions/src/EventSubscriber/ActionsPermissionsEventSubscriber.php
index 695e9942f90f3bc21d318801e9daee1ab150d7c9..93167b98e3707eea7a86ee3b30bacf2719a27065 100644
--- a/web/modules/views_bulk_operations/modules/actions_permissions/src/EventSubscriber/ActionsPermissionsEventSubscriber.php
+++ b/web/modules/views_bulk_operations/modules/actions_permissions/src/EventSubscriber/ActionsPermissionsEventSubscriber.php
@@ -3,7 +3,7 @@
 namespace Drupal\actions_permissions\EventSubscriber;
 
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\EventDispatcher\Event;
+use Drupal\Component\EventDispatcher\Event;
 use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager;
 
 /**
@@ -21,14 +21,17 @@ class ActionsPermissionsEventSubscriber implements EventSubscriberInterface {
    * {@inheritdoc}
    */
   public static function getSubscribedEvents() {
-    $events[ViewsBulkOperationsActionManager::ALTER_ACTIONS_EVENT][] = ['alterActions', static::PRIORITY];
+    $events[ViewsBulkOperationsActionManager::ALTER_ACTIONS_EVENT][] = [
+      'alterActions',
+      static::PRIORITY,
+    ];
     return $events;
   }
 
   /**
    * Alter the actions' definitions.
    *
-   * @var \Symfony\Component\EventDispatcher\Event $event
+   * @var \Drupal\Component\EventDispatcher\Event $event
    *   The event to respond to.
    */
   public function alterActions(Event $event) {
diff --git a/web/modules/views_bulk_operations/modules/views_bulk_operations_example/config/schema/views_bulk_operations_example.schema.yml b/web/modules/views_bulk_operations/modules/views_bulk_operations_example/config/schema/views_bulk_operations_example.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..667347fea7ff525f3a7cff5e3a10d76633551048
--- /dev/null
+++ b/web/modules/views_bulk_operations/modules/views_bulk_operations_example/config/schema/views_bulk_operations_example.schema.yml
@@ -0,0 +1,7 @@
+views_bulk_operations.action_config.views_bulk_operations_example:
+  type: views_bulk_operations_action_config
+  label: 'Example preliminary configuration'
+  mapping:
+    example_preconfig_setting:
+      type: string
+      label: 'Example setting'
diff --git a/web/modules/views_bulk_operations/modules/views_bulk_operations_example/views_bulk_operations_example.info.yml b/web/modules/views_bulk_operations/modules/views_bulk_operations_example/views_bulk_operations_example.info.yml
index 560f130c23e1a40498caaea77951dacf106ce00c..0bf99a30b70d75af94a55afd2e348d16df16a4c1 100644
--- a/web/modules/views_bulk_operations/modules/views_bulk_operations_example/views_bulk_operations_example.info.yml
+++ b/web/modules/views_bulk_operations/modules/views_bulk_operations_example/views_bulk_operations_example.info.yml
@@ -6,7 +6,7 @@ core_version_requirement: ^8 || ^9
 dependencies:
   - drupal:views_bulk_operations
 
-# Information added by Drupal.org packaging script on 2021-04-29
-version: '8.x-3.13'
+# Information added by Drupal.org packaging script on 2022-03-19
+version: '4.1.2'
 project: 'views_bulk_operations'
-datestamp: 1619697069
+datestamp: 1647723470
diff --git a/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionBase.php b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionBase.php
index 3a37707aa82295f79e9f55c7d0c9a6fd277a2a09..8f9f98d76ad88d64ac04b50dc20311b67ebcf51a 100644
--- a/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionBase.php
+++ b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionBase.php
@@ -16,6 +16,8 @@
  */
 abstract class ViewsBulkOperationsActionBase extends ActionBase implements ViewsBulkOperationsActionInterface, ConfigurableInterface {
 
+  use ViewsBulkOperationsActionCompletedTrait;
+
   /**
    * Action context.
    *
diff --git a/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionCompletedTrait.php b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionCompletedTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..2d5f43fa5061a37ff5f18f1e9b171d78ec12da8a
--- /dev/null
+++ b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionCompletedTrait.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Drupal\views_bulk_operations\Action;
+
+use Symfony\Component\HttpFoundation\RedirectResponse;
+
+/**
+ * Defines action completion logic.
+ */
+trait ViewsBulkOperationsActionCompletedTrait {
+
+  /**
+   * Set message function wrapper.
+   *
+   * @see \Drupal\Core\Messenger\MessengerInterface
+   */
+  public static function message($message = NULL, $type = 'status', $repeat = TRUE) {
+    \Drupal::messenger()->addMessage($message, $type, $repeat);
+  }
+
+  /**
+   * Translation function wrapper.
+   *
+   * @see \Drupal\Core\StringTranslation\TranslationInterface:translate()
+   */
+  public static function translate($string, array $args = [], array $options = []) {
+    return \Drupal::translation()->translate($string, $args, $options);
+  }
+
+  /**
+   * Batch finished callback.
+   *
+   * @param bool $success
+   *   Was the process successful?
+   * @param array $results
+   *   Batch process results array.
+   * @param array $operations
+   *   Performed operations array.
+   */
+  public static function finished($success, array $results, array $operations): ?RedirectResponse {
+    if ($success) {
+      $operations = array_count_values($results['operations']);
+      $details = [];
+      foreach ($operations as $op => $count) {
+        $details[] = $op . ' (' . $count . ')';
+      }
+      $message = static::translate('Action processing results: @operations.', [
+        '@operations' => implode(', ', $details),
+      ]);
+      static::message($message);
+    }
+    else {
+      $message = static::translate('Finished with an error.');
+      static::message($message, 'error');
+    }
+    return NULL;
+  }
+
+}
diff --git a/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionInterface.php b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionInterface.php
index d6c8824a4ed1fce71e01ccccbe9e3311fe4e7a80..296108da839e143ba8eea622a60beb92a6d90c96 100644
--- a/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionInterface.php
+++ b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionInterface.php
@@ -2,12 +2,14 @@
 
 namespace Drupal\views_bulk_operations\Action;
 
+use Drupal\Core\Action\ActionInterface;
 use Drupal\views\ViewExecutable;
+use Symfony\Component\HttpFoundation\RedirectResponse;
 
 /**
  * Defines Views Bulk Operations action interface.
  */
-interface ViewsBulkOperationsActionInterface {
+interface ViewsBulkOperationsActionInterface extends ActionInterface {
 
   /**
    * Set action context.
@@ -44,4 +46,21 @@ public function setView(ViewExecutable $view);
    */
   public function executeMultiple(array $objects);
 
+  /**
+   * Action batch execution finished callback.
+   *
+   * Used to set finished message, redirect or execute some final logic.
+   *
+   * @param bool $success
+   *   Was the process successful?
+   * @param array $results
+   *   Batch process results array.
+   * @param array $operations
+   *   Performed operations array.
+   *
+   * @return \Symfony\Component\HttpFoundation\RedirectResponse|null
+   *   Bach redirect response or NULL.
+   */
+  public static function finished($success, array $results, array $operations): ?RedirectResponse;
+
 }
diff --git a/web/modules/views_bulk_operations/src/Commands/ViewsBulkOperationsCommands.php b/web/modules/views_bulk_operations/src/Commands/ViewsBulkOperationsCommands.php
index 523557289501aebfa954dcd49b7a54aa0b97d00d..0ca556790e4e89b06bd4986e83bdd61370331aee 100644
--- a/web/modules/views_bulk_operations/src/Commands/ViewsBulkOperationsCommands.php
+++ b/web/modules/views_bulk_operations/src/Commands/ViewsBulkOperationsCommands.php
@@ -2,11 +2,12 @@
 
 namespace Drupal\views_bulk_operations\Commands;
 
+use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
 use Drush\Commands\DrushCommands;
 use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\views_bulk_operations\Service\ViewsbulkOperationsViewDataInterface;
 use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager;
-use Drupal\user\Entity\User;
 use Drupal\views\Views;
 use Drupal\views_bulk_operations\ViewsBulkOperationsBatch;
 
@@ -22,6 +23,13 @@ class ViewsBulkOperationsCommands extends DrushCommands {
    */
   protected $currentUser;
 
+  /**
+   * The user storage.
+   *
+   * @var \Drupal\user\UserStorageInterface
+   */
+  protected $userStorage;
+
   /**
    * Object that gets the current view data.
    *
@@ -41,6 +49,8 @@ class ViewsBulkOperationsCommands extends DrushCommands {
    *
    * @param \Drupal\Core\Session\AccountInterface $currentUser
    *   The current user object.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
+   *   The entity type manager.
    * @param \Drupal\views_bulk_operations\Service\ViewsbulkOperationsViewDataInterface $viewData
    *   VBO View data service.
    * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager $actionManager
@@ -48,10 +58,12 @@ class ViewsBulkOperationsCommands extends DrushCommands {
    */
   public function __construct(
     AccountInterface $currentUser,
+    EntityTypeManagerInterface $entityTypeManager,
     ViewsbulkOperationsViewDataInterface $viewData,
     ViewsBulkOperationsActionManager $actionManager
   ) {
     $this->currentUser = $currentUser;
+    $this->userStorage = $entityTypeManager->getStorage('user');
     $this->viewData = $viewData;
     $this->actionManager = $actionManager;
   }
@@ -71,7 +83,7 @@ public function __construct(
    * @return string
    *   The summary message.
    *
-   * @command views-bulk-operations:execute
+   * @command views:bulk-operations:execute
    *
    * @option display-id
    *   ID of the display to use.
@@ -86,7 +98,7 @@ public function __construct(
    * @option user-id
    *   The ID of the user account used for performing the operation.
    *
-   * @usage drush views-bulk-operations:execute some_view some_action
+   * @usage drush views:bulk-operations:execute some_view some_action
    *   Execute some action on some view.
    * @usage drush vbo-execute some_view some_action --args=arg1/arg2 --batch-size=50
    *   Execute some action on some view with arg1 and arg2 as
@@ -94,7 +106,7 @@ public function __construct(
    * @usage drush vbo-exec some_view some_action --configuration=&quot;key1=value1&amp;key2=value2&quot;
    *   Execute some action on some view with the specified action configuration.
    *
-   * @aliases vbo-execute, vbo-exec
+   * @aliases vbo-execute, vbo-exec, views-bulk-operations:execute
    */
   public function vboExecute(
     $view_id,
@@ -108,9 +120,8 @@ public function vboExecute(
       'user-id' => 1,
     ]
   ) {
-
     if (empty($view_id) || empty($action_id)) {
-      throw new \Exception($this->t('You must specify the view ID and the action ID parameters.'));
+      throw new \Exception('You must specify the view ID and the action ID parameters.');
     }
 
     $this->timer($options['verbose']);
@@ -147,19 +158,20 @@ public function vboExecute(
       // We set the clear_on_exposed parameter to true, otherwise with empty
       // selection exposed filters are not taken into account.
       'clear_on_exposed' => TRUE,
+      'exclude_mode' => FALSE,
     ];
 
-    // Login as superuser, as drush 9 doesn't support the
-    // --user parameter.
-    $account = User::load($options['user-id']);
+    // Login as the provided user, as drush 9+ doesn't support the
+    // --user parameter. Default: user 1.
+    $account = $this->userStorage->load($options['user-id']);
     $this->currentUser->setAccount($account);
 
     // Initialize the view to check if parameters are correct.
     if (!$view = Views::getView($vbo_data['view_id'])) {
-      throw new \Exception($this->t('Incorrect view ID provided.'));
+      throw new \Exception('Incorrect view ID provided.');
     }
     if (!$view->setDisplay($vbo_data['display_id'])) {
-      throw new \Exception($this->t('Incorrect view display ID provided.'));
+      throw new \Exception('Incorrect view display ID provided.');
     }
     if (!empty($vbo_data['arguments'])) {
       $view->setArguments($vbo_data['arguments']);
@@ -225,12 +237,56 @@ public function vboExecute(
     // Display debug information.
     if ($options['verbose']) {
       $this->timer($options['verbose'], 'execute');
-      $this->logger->info($this->t('Initialization time: @time ms.', ['@time' => $this->timer($options['verbose'], 'init')]));
-      $this->logger->info($this->t('Entity list generation time: @time ms.', ['@time' => $this->timer($options['verbose'], 'list')]));
-      $this->logger->info($this->t('Execution time: @time ms.', ['@time' => $this->timer($options['verbose'], 'execute')]));
+      $this->logger->info($this->t('Initialization time: @time ms.', [
+        '@time' => $this->timer($options['verbose'], 'init'),
+      ]));
+      $this->logger->info($this->t('Entity list generation time: @time ms.', [
+        '@time' => $this->timer($options['verbose'], 'list'),
+      ]));
+      $this->logger->info($this->t('Execution time: @time ms.', [
+        '@time' => $this->timer($options['verbose'], 'execute'),
+      ]));
+    }
+
+    return $this->t('Action processing results: @results.', [
+      '@results' => implode(', ', $details),
+    ]);
+  }
+
+  /**
+   * List available actions for a view.
+   *
+   * @return string
+   *   The summary message.
+   *
+   * @command views:bulk-operations:list
+   *
+   * @table-style default
+   * @field-labels
+   *   id: ID
+   *   label: Label
+   *   entity_type_id: Entity type ID
+   * @default-fields id,label,entity_type_id
+   *
+   * @usage drush views:bulk-operations:list some_view some_action
+   *   Execute some action on some view.
+   * @usage drush vbo-list
+   *   List all available actions info.
+   *
+   * @aliases vbo-list
+   */
+  public function vboList($options = ['format' => 'table']) {
+    $rows = [];
+    $actions = $this->actionManager->getDefinitions(['nocache' => TRUE]);
+    foreach ($actions as $id => $definition) {
+      $rows[] = [
+        'id' => $id,
+        'label' => $definition['label'],
+        'entity_type_id' => $definition['type'] ? $definition['type'] : dt('(any)'),
+      ];
     }
 
-    return $this->t('Action processing results: @results.', ['@results' => implode(', ', $details)]);
+    return new RowsOfFields($rows);
   }
 
   /**
diff --git a/web/modules/views_bulk_operations/src/EventSubscriber/ViewsBulkOperationsEventSubscriber.php b/web/modules/views_bulk_operations/src/EventSubscriber/ViewsBulkOperationsEventSubscriber.php
index ee15c35561f2e4092a289ef3750834ed484e3771..7a56d0cc0cffe71ca518b29e0aa950377fab2bcf 100644
--- a/web/modules/views_bulk_operations/src/EventSubscriber/ViewsBulkOperationsEventSubscriber.php
+++ b/web/modules/views_bulk_operations/src/EventSubscriber/ViewsBulkOperationsEventSubscriber.php
@@ -38,7 +38,10 @@ public function __construct(ViewsBulkOperationsViewDataInterface $viewData) {
    * {@inheritdoc}
    */
   public static function getSubscribedEvents() {
-    $events[ViewsBulkOperationsEvent::NAME][] = ['provideViewData', self::PRIORITY];
+    $events[ViewsBulkOperationsEvent::NAME][] = [
+      'provideViewData',
+      self::PRIORITY,
+    ];
     return $events;
   }
 
diff --git a/web/modules/views_bulk_operations/src/Form/ConfigureAction.php b/web/modules/views_bulk_operations/src/Form/ConfigureAction.php
index a5469c4ba3646b5ffec1de694c913ce4efb7d431..ec66bdb5c00cccb95f19a02b9c0fee8b2ff53b9f 100644
--- a/web/modules/views_bulk_operations/src/Form/ConfigureAction.php
+++ b/web/modules/views_bulk_operations/src/Form/ConfigureAction.php
@@ -82,9 +82,10 @@ public function buildForm(array $form, FormStateInterface $form_state, $view_id
 
     $form_data = $this->getFormData($view_id, $display_id);
 
-    // TODO: display an error msg, redirect back.
     if (!isset($form_data['action_id'])) {
-      return;
+      return [
+        '#markup' => $this->t('No items selected. Go back and try again.'),
+      ];
     }
 
     $form['#title'] = $this->t('Configure "%action" action applied to the selection', ['%action' => $form_data['action_label']]);
diff --git a/web/modules/views_bulk_operations/src/Form/ConfirmAction.php b/web/modules/views_bulk_operations/src/Form/ConfirmAction.php
index 8c5c742fb2635e6aa07b473b82cf54af5041c36e..93b1be427cde64e5742ffe48b789c9d691697667 100644
--- a/web/modules/views_bulk_operations/src/Form/ConfirmAction.php
+++ b/web/modules/views_bulk_operations/src/Form/ConfirmAction.php
@@ -82,7 +82,7 @@ public function buildForm(array $form, FormStateInterface $form_state, $view_id
 
     $form_data = $this->getFormData($view_id, $display_id);
 
-    // TODO: display an error msg, redirect back.
+    // @todo Display an error msg, redirect back.
     if (!isset($form_data['action_id'])) {
       return;
     }
diff --git a/web/modules/views_bulk_operations/src/Form/ViewsBulkOperationsFormTrait.php b/web/modules/views_bulk_operations/src/Form/ViewsBulkOperationsFormTrait.php
index 91fea73670c73aeeac0b1454480a68d4891844f3..725f011b0ff686f96f1a5db22f95d9a634f7af3d 100644
--- a/web/modules/views_bulk_operations/src/Form/ViewsBulkOperationsFormTrait.php
+++ b/web/modules/views_bulk_operations/src/Form/ViewsBulkOperationsFormTrait.php
@@ -66,7 +66,7 @@ protected function addListData(&$form_data) {
       $form_data['entity_labels'] = $this->actionProcessor->getLabels($modified_form_data);
     }
     else {
-      $form_data['selected_count'] = $form_data['total_results'];
+      $form_data['selected_count'] = $form_data['total_results'] ?? 0;
     }
   }
 
@@ -83,7 +83,6 @@ protected function getSelectionInfoTitle(array $tempstore_data) {
     if (!empty($tempstore_data['list'])) {
       return empty($tempstore_data['exclude_mode']) ? $this->t('Items selected:') : $this->t('Selected all items except:');
     }
-    return $this->t('No items selected.');
   }
 
   /**
@@ -126,12 +125,12 @@ protected function getListRenderable(array $form_data) {
           '#wrapper_attributes' => ['class' => ['more']],
         ];
       }
+      $renderable['#title'] = $this->getSelectionInfoTitle($form_data);
     }
     elseif (!empty($form_data['exclude_mode'])) {
-      $renderable['#empty'] = $this->t('All items');
+      $renderable['#empty'] = $this->t('Action will be executed on all items in the view.');
     }
 
-    $renderable['#title'] = $this->getSelectionInfoTitle($form_data);
     $renderable['#wrapper_attributes'] = ['class' => ['vbo-info-list-wrapper']];
 
     return $renderable;
@@ -147,6 +146,8 @@ protected function getListRenderable(array $form_data) {
    *   The entity to calculate a bulk form key for.
    * @param mixed $base_field_value
    *   The value of the base field for this view result.
+   * @param mixed $row_index
+   *   Index of view result.
    *
    * @return string
    *   The bulk form key representing the entity id, language and revision (if
@@ -154,7 +155,7 @@ protected function getListRenderable(array $form_data) {
    *
    * @see self::loadEntityFromBulkFormKey()
    */
-  public static function calculateEntityBulkFormKey(EntityInterface $entity, $base_field_value) {
+  public static function calculateEntityBulkFormKey(EntityInterface $entity, $base_field_value, $row_index) {
     // We don't really need the entity ID or type ID, since only the
     // base field value and language are used to select rows, but
     // other modules may need those values.
@@ -163,6 +164,7 @@ public static function calculateEntityBulkFormKey(EntityInterface $entity, $base
       $entity->language()->getId(),
       $entity->getEntityTypeId(),
       $entity->id(),
+      $row_index,
     ];
 
     // An entity ID could be an arbitrary string (although they are typically
diff --git a/web/modules/views_bulk_operations/src/Plugin/views/field/ViewsBulkOperationsBulkForm.php b/web/modules/views_bulk_operations/src/Plugin/views/field/ViewsBulkOperationsBulkForm.php
index f133287f73ead593447e3b7112a12d2a384a0fc6..1b082cee5a4856f9c89c7ba2482d4e916b545e9f 100644
--- a/web/modules/views_bulk_operations/src/Plugin/views/field/ViewsBulkOperationsBulkForm.php
+++ b/web/modules/views_bulk_operations/src/Plugin/views/field/ViewsBulkOperationsBulkForm.php
@@ -637,7 +637,8 @@ public function viewsForm(array &$form, FormStateInterface $form_state) {
         if ($entity = $this->getEntity($row)) {
           $bulk_form_keys[$row_index] = self::calculateEntityBulkFormKey(
             $entity,
-            $row->{$base_field}
+            $row->{$base_field},
+            $row_index
           );
           $entity_labels[$row_index] = $entity->label();
         }
@@ -649,7 +650,7 @@ public function viewsForm(array &$form, FormStateInterface $form_state) {
       // displayed, but not when the form is being built before submission
       // (data is subject to change - new entities added or deleted after
       // the form display). TODO: consider using $form_state->set() instead.
-      if (empty($form_state->getUserInput())) {
+      if (empty($form_state->getUserInput()['op'])) {
         $this->updateTempstoreData($bulk_form_keys);
       }
       else {
@@ -780,9 +781,10 @@ public function viewsForm(array &$form, FormStateInterface $form_state) {
         $form['header'][$this->options['id']]['multipage'] = [
           '#type' => 'details',
           '#open' => FALSE,
-          '#title' => $this->t('Selected %count items in this view', [
-            '%count' => $count,
-          ]),
+          '#title' => $this->formatPlural($count,
+            'Selected 1 item in this view',
+            'Selected @count items in this view'
+          ),
           '#attributes' => [
             // Add view_id and display_id to be available for
             // js multipage selector functionality.
@@ -935,17 +937,12 @@ public function viewsFormSubmit(array &$form, FormStateInterface $form_state) {
       }
 
       // Update exclude mode setting.
-      if ($form_state->getValue('select_all') && !empty($this->tempStoreData['list'])) {
-        $this->tempStoreData['exclude_mode'] = TRUE;
-      }
-      else {
-        $this->tempStoreData['exclude_mode'] = FALSE;
-      }
+      $this->tempStoreData['exclude_mode'] = !empty($select_all);
 
       // Routing - determine redirect route.
       //
       // Set default redirection due to issue #2952498.
-      // TODO: remove the next line when core cause is eliminated.
+      // @todo remove the next line when core cause is eliminated.
       $redirect_route = 'views_bulk_operations.execute_batch';
 
       if ($this->options['form_step'] && $configurable) {
diff --git a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionManager.php b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionManager.php
index 1a0bdb3752909c4a4b1ebbd8ef269ece17de45a4..c3f69a63561035b6967a20e3f5457c32a67f6dc6 100644
--- a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionManager.php
+++ b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionManager.php
@@ -2,13 +2,13 @@
 
 namespace Drupal\views_bulk_operations\Service;
 
+use Drupal\Component\EventDispatcher\Event;
+use Drupal\Component\Plugin\Exception\PluginNotFoundException;
 use Drupal\Core\Action\ActionManager;
 use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Entity\EntityTypeManagerInterface;
 use Drupal\Core\Extension\ModuleHandlerInterface;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Symfony\Component\EventDispatcher\Event;
-use Drupal\Component\Plugin\Exception\PluginNotFoundException;
 
 /**
  * Defines Views Bulk Operations action manager.
@@ -119,7 +119,11 @@ protected function findDefinitions() {
       }
       // If this plugin was provided by a module that does not exist, remove the
       // plugin definition.
-      if (isset($plugin_definition['provider']) && !in_array($plugin_definition['provider'], ['core', 'component']) && !$this->providerExists($plugin_definition['provider'])) {
+      if (
+        isset($plugin_definition['provider']) &&
+        !in_array($plugin_definition['provider'], ['core', 'component']) &&
+        !$this->providerExists($plugin_definition['provider'])
+      ) {
         unset($definitions[$plugin_id]);
       }
     }
@@ -212,7 +216,8 @@ protected function alterDefinitions(&$definitions) {
     $event = new Event();
     $event->alterParameters = $this->alterParameters;
     $event->definitions = &$definitions;
-    $this->eventDispatcher->dispatch(static::ALTER_ACTIONS_EVENT, $event);
+
+    $this->eventDispatcher->dispatch($event, static::ALTER_ACTIONS_EVENT);
 
     // Include the expected behaviour (hook system) to avoid security issues.
     parent::alterDefinitions($definitions);
diff --git a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessor.php b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessor.php
index c40294a84a2bb3793607f56732ed42b5a775cdbb..eb350cddb12aacf516edceb78ba69d394b27e0b5 100644
--- a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessor.php
+++ b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessor.php
@@ -2,12 +2,14 @@
 
 namespace Drupal\views_bulk_operations\Service;
 
+use Drupal\Core\Access\AccessResultReasonInterface;
 use Drupal\views\Views;
 use Drupal\Core\Session\AccountProxyInterface;
 use Drupal\Core\Extension\ModuleHandlerInterface;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\views_bulk_operations\ViewsBulkOperationsBatch;
+use Drupal\views_bulk_operations\Action\ViewsBulkOperationsActionInterface;
 
 /**
  * Defines VBO action processor.
@@ -118,7 +120,7 @@ public function __construct(
   /**
    * {@inheritdoc}
    */
-  public function initialize(array $view_data, $view = NULL) {
+  public function initialize(array $view_data, $view = NULL): void {
 
     // It may happen that the service was already initialized
     // in this request (e.g. multiple Batch API operation calls).
@@ -159,7 +161,7 @@ public function initialize(array $view_data, $view = NULL) {
    * @param mixed $view
    *   The current view object or NULL.
    */
-  protected function setView($view = NULL) {
+  protected function setView($view = NULL): void {
     if (!is_null($view)) {
       $this->view = $view;
     }
@@ -210,15 +212,15 @@ public function getPageList($page) {
       $this->view->setExposedInput(['_views_bulk_operations_override' => TRUE]);
     }
 
+    $base_field = $this->view->storage->get('base_field');
+
     // In some cases we may encounter nondeterministic behaviour in
     // db queries with sorts allowing different order of results.
     // To fix this we're removing all sorts and setting one sorting
     // rule by the view base id field.
-    $sorts = $this->view->getHandlers('sort');
-    foreach ($sorts as $id => $sort) {
+    foreach (array_keys($this->view->getHandlers('sort')) as $id) {
       $this->view->setHandler($this->bulkFormData['display_id'], 'sort', $id, NULL);
     }
-    $base_field = $this->view->storage->get('base_field');
     $this->view->setHandler($this->bulkFormData['display_id'], 'sort', $base_field, [
       'id' => $base_field,
       'table' => $this->view->storage->get('base_table'),
@@ -226,7 +228,7 @@ public function getPageList($page) {
       'order' => 'ASC',
       'relationship' => 'none',
       'group_type' => 'group',
-      'exposed' => 'FALSE',
+      'exposed' => FALSE,
       'plugin_id' => 'standard',
     ]);
 
@@ -245,7 +247,6 @@ public function getPageList($page) {
     $this->moduleHandler->invokeAll('views_pre_execute', [$this->view]);
     $this->view->query->execute($this->view);
 
-    $base_field = $this->view->storage->get('base_field');
     foreach ($this->view->result as $row) {
       $entity = $this->viewDataService->getEntity($row);
 
@@ -352,6 +353,13 @@ public function populateQueue(array $data, array &$context = []) {
     // query. Give those modules the opportunity to alter the query again.
     $this->view->query->alter($this->view);
 
+    // Use a different pager ID so we don't break the real pager.
+    // @todo Check if we can use something else to set this value.
+    $pager = $this->view->getPager();
+    if (array_key_exists('id', $pager->options)) {
+      $pager->options['id'] += (1000 + $this->view->getItemsPerPage());
+    }
+
     // Execute the view.
     $this->moduleHandler->invokeAll('views_pre_execute', [$this->view]);
     $this->view->query->execute($this->view);
@@ -449,8 +457,21 @@ public function process() {
 
     // Check access.
     foreach ($this->queue as $delta => $entity) {
-      if (!$this->action->access($entity, $this->currentUser)) {
-        $output[] = $this->t('Access denied');
+      $accessResult = $this->action->access($entity, $this->currentUser, TRUE);
+      if ($accessResult->isAllowed() === FALSE) {
+        $message = $this->t('Access denied');
+
+        // If we're given a reason why access was denied, display it.
+        if ($accessResult instanceof AccessResultReasonInterface) {
+          $reason = $accessResult->getReason();
+          if (!empty($reason)) {
+            $message = $this->t('Access denied: @reason', [
+              '@reason' => $accessResult->getReason(),
+            ]);
+          }
+        }
+
+        $output[] = $message;
         unset($this->queue[$delta]);
       }
     }
@@ -473,10 +494,21 @@ public function process() {
    * {@inheritdoc}
    */
   public function executeProcessing(array &$data, $view = NULL) {
-    if ($data['exclude_mode'] && empty($data['exclude_list'])) {
+    if (empty($data['prepopulated']) && $data['exclude_mode'] && empty($data['exclude_list'])) {
       $data['exclude_list'] = $data['list'];
       $data['list'] = [];
     }
+
+    // Get action finished callable.
+    $definition = $this->actionManager->getDefinition($data['action_id']);
+    if (in_array(ViewsBulkOperationsActionInterface::class, class_implements($definition['class']), TRUE)) {
+      $data['finished_callback'] = [$definition['class']];
+    }
+    else {
+      $data['finished_callback'] = [ViewsBulkOperationsBatch::class];
+    }
+    $data['finished_callback'][] = 'finished';
+
     if ($data['batch']) {
       $batch = ViewsBulkOperationsBatch::getBatch($data);
       batch_set($batch);
@@ -495,7 +527,7 @@ public function executeProcessing(array &$data, $view = NULL) {
       foreach ($batch_results as $result) {
         $results['operations'][] = (string) $result;
       }
-      ViewsBulkOperationsBatch::finished(TRUE, $results, []);
+      $data['finished_callback'](TRUE, $results, []);
     }
   }
 
diff --git a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewData.php b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewData.php
index 0b71658de9fc5d3e94ee7df8b3ffc571f86c6182..93f4ccc8ee855574315aebdd846fb29f9400c20c 100644
--- a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewData.php
+++ b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewData.php
@@ -49,7 +49,7 @@ class ViewsBulkOperationsViewData implements ViewsBulkOperationsViewDataInterfac
    *
    * @var array
    */
-  protected $data;
+  protected $data = [];
 
   /**
    * Entity type ids returned by this view.
@@ -91,7 +91,9 @@ public function init(ViewExecutable $view, DisplayPluginBase $display, $relation
 
     // Get view entity types and results fetcher callable.
     $event = new ViewsBulkOperationsEvent($this->getViewProvider(), $this->getData(), $view);
-    $this->eventDispatcher->dispatch(ViewsBulkOperationsEvent::NAME, $event);
+
+    $this->eventDispatcher->dispatch($event, ViewsBulkOperationsEvent::NAME);
+
     $this->entityTypeIds = $event->getEntityTypeIds();
     $this->entityGetter = $event->getEntityGetter();
   }
@@ -110,18 +112,21 @@ public function getEntityTypeIds() {
    *   Part of views data that refers to the current view.
    */
   protected function getData() {
-    if (!$this->data) {
+    if (!empty($this->relationship) && $this->relationship != 'none') {
+      $relationship = $this->displayHandler->getOption('relationships')[$this->relationship];
+      $table_data = $viewsData->get($relationship['table']);
+      $key = $table_data[$relationship['field']]['relationship']['base'];
+    }
+    else {
+      $key = $this->view->storage->get('base_table');
+    }
+
+    if (!array_key_exists($key, $this->data)) {
       $viewsData = Views::viewsData();
-      if (!empty($this->relationship) && $this->relationship != 'none') {
-        $relationship = $this->displayHandler->getOption('relationships')[$this->relationship];
-        $table_data = $viewsData->get($relationship['table']);
-        $this->data = $viewsData->get($table_data[$relationship['field']]['relationship']['base']);
-      }
-      else {
-        $this->data = $viewsData->get($this->view->storage->get('base_table'));
-      }
+      $this->data[$key] = $viewsData->get($key);
     }
-    return $this->data;
+
+    return $this->data[$key];
   }
 
   /**
@@ -220,7 +225,7 @@ public function getTotalResults($clear_on_exposed = FALSE) {
       $total_results = $view->total_rows;
     }
 
-    if (!empty($pager_options) && !empty($pager_options['id'])) {
+    if (!empty($pager_options) && isset($pager_options['id'])) {
       $this->pagerManager->createPager($pager_options['total_items'], $pager_options['items_per_page'], $pager_options['id']);
     }
 
diff --git a/web/modules/views_bulk_operations/src/ViewsBulkOperationsBatch.php b/web/modules/views_bulk_operations/src/ViewsBulkOperationsBatch.php
index 97356a16fa3cac36b3a57cfb060fb4e2ef666d6f..005b860ed5477be83398a75a6f712676d813d07f 100644
--- a/web/modules/views_bulk_operations/src/ViewsBulkOperationsBatch.php
+++ b/web/modules/views_bulk_operations/src/ViewsBulkOperationsBatch.php
@@ -3,30 +3,14 @@
 namespace Drupal\views_bulk_operations;
 
 use Drupal\Core\Url;
-use Symfony\Component\HttpFoundation\RedirectResponse;
+use Drupal\views_bulk_operations\Action\ViewsBulkOperationsActionCompletedTrait;
 
 /**
  * Defines module Batch API methods.
  */
 class ViewsBulkOperationsBatch {
 
-  /**
-   * Translation function wrapper.
-   *
-   * @see \Drupal\Core\StringTranslation\TranslationInterface:translate()
-   */
-  public static function t($string, array $args = [], array $options = []) {
-    return \Drupal::translation()->translate($string, $args, $options);
-  }
-
-  /**
-   * Set message function wrapper.
-   *
-   * @see \Drupal\Core\Messenger\MessengerInterface
-   */
-  public static function message($message = NULL, $type = 'status', $repeat = TRUE) {
-    \Drupal::messenger()->addMessage($message, $type, $repeat);
-  }
+  use ViewsBulkOperationsActionCompletedTrait;
 
   /**
    * Gets the list of entities to process.
@@ -65,7 +49,7 @@ public static function getList(array $data, array &$context) {
     if ($context['sandbox']['page'] <= $context['sandbox']['npages']) {
       $context['finished'] = 0;
       $context['finished'] = $context['sandbox']['processed'] / $context['sandbox']['total'];
-      $context['message'] = static::t('Prepared @count of @total entities for processing.', [
+      $context['message'] = static::translate('Prepared @count of @total entities for processing.', [
         '@count' => $context['sandbox']['processed'],
         '@total' => $context['sandbox']['total'],
       ]);
@@ -134,44 +118,13 @@ public static function operation(array $data, array &$context) {
       $context['finished'] = 0;
 
       $context['finished'] = $context['sandbox']['processed'] / $context['sandbox']['total'];
-      $context['message'] = static::t('Processed @count of @total entities.', [
+      $context['message'] = static::translate('Processed @count of @total entities.', [
         '@count' => $context['sandbox']['processed'],
         '@total' => $context['sandbox']['total'],
       ]);
     }
   }
 
-  /**
-   * Batch finished callback.
-   *
-   * @param bool $success
-   *   Was the process successful?
-   * @param array $results
-   *   Batch process results array.
-   * @param array $operations
-   *   Performed operations array.
-   */
-  public static function finished($success, array $results, array $operations) {
-    if ($success) {
-      $operations = array_count_values($results['operations']);
-      $details = [];
-      foreach ($operations as $op => $count) {
-        $details[] = $op . ' (' . $count . ')';
-      }
-      $message = static::t('Action processing results: @operations.', [
-        '@operations' => implode(', ', $details),
-      ]);
-      static::message($message);
-      if (isset($results['redirect_url'])) {
-        return new RedirectResponse($results['redirect_url']->setAbsolute()->toString());
-      }
-    }
-    else {
-      $message = static::t('Finished with an error.');
-      static::message($message, 'error');
-    }
-  }
-
   /**
    * Batch builder function.
    *
@@ -192,14 +145,14 @@ public static function getBatch(array &$view_data) {
       ]);
 
       $batch = [
-        'title' => static::t('Prepopulating entity list for processing.'),
+        'title' => static::translate('Prepopulating entity list for processing.'),
         'operations' => [
           [
             [$current_class, 'getList'],
             [$view_data],
           ],
         ],
-        'progress_message' => static::t('Prepopulating, estimated time left: @estimate, elapsed: @elapsed.'),
+        'progress_message' => static::translate('Prepopulating, estimated time left: @estimate, elapsed: @elapsed.'),
         'finished' => [$current_class, 'saveList'],
       ];
     }
@@ -207,15 +160,15 @@ public static function getBatch(array &$view_data) {
     // Execute action.
     else {
       $batch = [
-        'title' => static::t('Performing @operation on selected entities.', ['@operation' => $view_data['action_label']]),
+        'title' => static::translate('Performing @operation on selected entities.', ['@operation' => $view_data['action_label']]),
         'operations' => [
           [
             [$current_class, 'operation'],
             [$view_data],
           ],
         ],
-        'progress_message' => static::t('Processing, estimated time left: @estimate, elapsed: @elapsed.'),
-        'finished' => [$current_class, 'finished'],
+        'progress_message' => static::translate('Processing, estimated time left: @estimate, elapsed: @elapsed.'),
+        'finished' => $view_data['finished_callback'],
       ];
     }
 
diff --git a/web/modules/views_bulk_operations/src/ViewsBulkOperationsEvent.php b/web/modules/views_bulk_operations/src/ViewsBulkOperationsEvent.php
index 44ad3e522c685f59211d6c947b09e57f5b768e88..595786ad45f3709dd55ae3fc8bbac86680952284 100644
--- a/web/modules/views_bulk_operations/src/ViewsBulkOperationsEvent.php
+++ b/web/modules/views_bulk_operations/src/ViewsBulkOperationsEvent.php
@@ -2,7 +2,7 @@
 
 namespace Drupal\views_bulk_operations;
 
-use Symfony\Component\EventDispatcher\Event;
+use Drupal\Component\EventDispatcher\Event;
 use Drupal\views\ViewExecutable;
 
 /**
diff --git a/web/modules/views_bulk_operations/tests/src/Functional/DrushCommandsTest.php b/web/modules/views_bulk_operations/tests/src/Functional/DrushCommandsTest.php
index 67241da11baf697cb2ca36564c2418d18bc5a86c..753a67df8e7d5364069d23be86011bfe63be49ea 100644
--- a/web/modules/views_bulk_operations/tests/src/Functional/DrushCommandsTest.php
+++ b/web/modules/views_bulk_operations/tests/src/Functional/DrushCommandsTest.php
@@ -24,7 +24,7 @@ class DrushCommandsTest extends BrowserTestBase {
    *
    * @var array
    */
-  public static $modules = [
+  protected static $modules = [
     'node',
     'views',
     'views_bulk_operations',
@@ -34,7 +34,7 @@ class DrushCommandsTest extends BrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
     // Create some nodes for testing.
@@ -61,14 +61,19 @@ protected function setUp() {
    * Tests the VBO Drush command.
    */
   public function testDrushCommand() {
+    $arguments = [
+      'views_bulk_operations_test',
+      'views_bulk_operations_simple_test_action',
+    ];
+
     // Basic test.
-    $this->drush('vbo-exec', ['views_bulk_operations_test', 'views_bulk_operations_simple_test_action']);
+    $this->drush('vbo-exec', $arguments);
     for ($i = 0; $i < self::TEST_NODE_COUNT; $i++) {
       $this->assertStringContainsString("Test action (preconfig: , label: Title $i)", $this->getErrorOutput());
     }
 
     // Exposed filters test.
-    $this->drush('vbo-exec', ['views_bulk_operations_test', 'views_bulk_operations_simple_test_action'], ['exposed' => 'sticky=1']);
+    $this->drush('vbo-exec', $arguments, ['exposed' => 'sticky=1']);
     for ($i = 0; $i < self::TEST_NODE_COUNT; $i++) {
       $test_string = "Test action (preconfig: , label: Title $i)";
       if ($i % 2) {
diff --git a/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsBulkFormTest.php b/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsBulkFormTest.php
index 4a981ef31cd8cdcfe5b1eb636cb209a44a23b5ee..47d1a6e3a7bacb6b8bfc7f19a6a2fbe683136dc6 100644
--- a/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsBulkFormTest.php
+++ b/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsBulkFormTest.php
@@ -2,8 +2,6 @@
 
 namespace Drupal\Tests\views_bulk_operations\Functional;
 
-use Drupal\Component\Render\FormattableMarkup;
-
 /**
  * @coversDefaultClass \Drupal\views_bulk_operations\Plugin\views\field\ViewsBulkOperationsBulkForm
  * @group views_bulk_operations
@@ -27,8 +25,7 @@ public function testViewsBulkOperationsBulkFormSimple() {
     // the correct label.
     for ($i = 0; $i < 4; $i++) {
       $checkbox_selector = 'edit-views-bulk-operations-bulk-form-' . $i;
-      $assertSession->fieldExists($checkbox_selector, NULL, new FormattableMarkup('The checkbox on row @row appears.', ['@row' => $i]));
-      $assertSession->elementTextContains('css', "label[for=$checkbox_selector]", $this->testNodes[$i]->label());
+      $assertSession->fieldExists($checkbox_selector);
     }
 
     // The advanced action should not be shown on the form - no permission.
@@ -48,15 +45,10 @@ public function testViewsBulkOperationsBulkFormSimple() {
     $preconfig_setting = $configData['display']['default']['display_options']['fields']['views_bulk_operations_bulk_form']['selected_actions'][0]['preconfiguration']['preconfig'];
 
     foreach ($selected as $index) {
-      $assertSession->pageTextContains(
-        sprintf('Test action (preconfig: %s, label: %s)',
-          $preconfig_setting,
-          $this->testNodes[$index]->label()
-        ),
-        sprintf('Action has been executed on node "%s".',
-          $this->testNodes[$index]->label()
-        )
-      );
+      $assertSession->pageTextContains(sprintf('Test action (preconfig: %s, label: %s)',
+        $preconfig_setting,
+        $this->testNodes[$index]->label()
+      ));
     }
 
     // Test the select all functionality.
@@ -67,10 +59,7 @@ public function testViewsBulkOperationsBulkFormSimple() {
     $data = ['select_all' => 1];
     $this->executeAction(NULL, t('Simple test action'), $selected, $data);
 
-    $assertSession->pageTextContains(
-      sprintf('Action processing results: Test (%d).', self::TEST_NODE_COUNT),
-      sprintf('Action has been executed on %d nodes.', self::TEST_NODE_COUNT)
-    );
+    $assertSession->pageTextContains(sprintf('Action processing results: Test (%d).', self::TEST_NODE_COUNT));
 
   }
 
@@ -85,7 +74,10 @@ public function testViewsBulkOperationsBulkFormAdvanced() {
 
     // Log in as a user with 'edit any page content' permission
     // to have access to perform the test operation.
-    $admin_user = $this->drupalCreateUser(['edit any page content', 'execute advanced test action']);
+    $admin_user = $this->drupalCreateUser([
+      'edit any page content',
+      'execute advanced test action',
+    ]);
     $this->drupalLogin($admin_user);
 
     // First execute the simple action to test
@@ -94,10 +86,7 @@ public function testViewsBulkOperationsBulkFormAdvanced() {
     $data = ['action' => 0];
     $this->executeAction('views-bulk-operations-test-advanced', t('Apply to selected items'), $selected, $data);
 
-    $assertSession->pageTextContains(
-      sprintf('Action processing results: Test (%d).', count($selected)),
-      sprintf('Action has been executed on %d nodes.', count($selected))
-    );
+    $assertSession->pageTextContains(sprintf('Action processing results: Test (%d).', count($selected)));
 
     // Execute the advanced test action.
     $selected = [0, 1, 3];
@@ -106,7 +95,7 @@ public function testViewsBulkOperationsBulkFormAdvanced() {
 
     // Check if the configuration form is open and contains the
     // test_config field.
-    $assertSession->fieldExists('edit-test-config', NULL, 'The configuration field appears.');
+    $assertSession->fieldExists('edit-test-config');
 
     // Check if the configuration form contains selected entity labels.
     // NOTE: The view pager has an offset set on this view, so checkbox
@@ -119,11 +108,11 @@ public function testViewsBulkOperationsBulkFormAdvanced() {
     $edit = [
       'test_config' => $config_value,
     ];
-    $this->drupalPostForm(NULL, $edit, t('Apply'));
+    $this->submitForm($edit, t('Apply'));
 
     // Execute action by posting the confirmation form
     // (also tests if the submit button exists on the page).
-    $this->drupalPostForm(NULL, [], t('Execute action'));
+    $this->submitForm([], t('Execute action'));
 
     // If all went well and Batch API did its job,
     // the next page should display results.
@@ -151,15 +140,13 @@ public function testViewsBulkOperationsBulkFormAdvanced() {
     foreach ([0, 2] as $index) {
       $edit["views_bulk_operations_bulk_form[$index]"] = TRUE;
     }
-    $this->drupalPostForm(NULL, $edit, t('Apply to selected items'));
-    $this->drupalPostForm(NULL, ['test_config' => 'unpublish'], t('Apply'));
-    $this->drupalPostForm(NULL, [], t('Execute action'));
+    $this->submitForm($edit, t('Apply to selected items'));
+    $this->submitForm(['test_config' => 'unpublish'], t('Apply'));
+    $this->submitForm([], t('Execute action'));
     // Again, take offset into account (-1), also take 2 excluded
     // rows into account (-2).
-    $assertSession->pageTextContains(
-      sprintf('Action processing results: Test (%d).', (count($this->testNodes) - 3)),
-      sprintf('Action has been executed on all %d nodes.', (count($this->testNodes) - 3))
-    );
+    // Also, check if the custom completed message appears.
+    $assertSession->pageTextContains(sprintf('Custom processing message: Test (%d).', (count($this->testNodes) - 3)));
 
     $this->assertNotEmpty((count($this->cssSelect('table.vbo-table tbody tr')) === 2), "The view shows only excluded results.");
   }
@@ -232,7 +219,7 @@ public function testViewsBulkOperationsBulkFormPassing() {
       }
 
       $this->drupalGet('views-bulk-operations-test-advanced', $options);
-      $this->drupalPostForm(NULL, $edit, t('Apply to selected items'));
+      $this->submitForm($edit, t('Apply to selected items'));
 
       // On batch-enabled processes check if provided context data is correct.
       if ($case['batch']) {
@@ -251,7 +238,7 @@ public function testViewsBulkOperationsBulkFormPassing() {
             'Processed %s of %s.',
             $processed,
             $total
-          ), 'The correct processed info message appears.');
+          ));
         }
       }
 
diff --git a/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsFunctionalTestBase.php b/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsFunctionalTestBase.php
index eb7fd6f615a9b125112873ef6d0954ee1b2df8fd..ce01362df83086101f4dac4b4ad0d0f6035b54bf 100644
--- a/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsFunctionalTestBase.php
+++ b/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsFunctionalTestBase.php
@@ -22,7 +22,7 @@ abstract class ViewsBulkOperationsFunctionalTestBase extends BrowserTestBase {
    *
    * @var array
    */
-  public static $modules = [
+  protected static $modules = [
     'node',
     'views',
     'views_bulk_operations',
@@ -32,7 +32,7 @@ abstract class ViewsBulkOperationsFunctionalTestBase extends BrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
     // Create some nodes for testing.
@@ -56,7 +56,7 @@ protected function setUp() {
   }
 
   /**
-   * Helper function that gets configuration for a selected view.
+   * Helper function that executes en operation.
    *
    * @param string|null $path
    *   The path of the View page that includes VBO.
@@ -71,7 +71,10 @@ protected function executeAction($path, TranslatableMarkup $button_text, array $
     foreach ($selection as $index) {
       $data["views_bulk_operations_bulk_form[$index]"] = TRUE;
     }
-    $this->drupalPostForm($path, $data, $button_text);
+    if ($path !== NULL) {
+      $this->drupalGet($path);
+    }
+    $this->submitForm($data, $button_text);
   }
 
 }
diff --git a/web/modules/views_bulk_operations/tests/src/FunctionalJavascript/ViewsBulkOperationsBulkFormTest.php b/web/modules/views_bulk_operations/tests/src/FunctionalJavascript/ViewsBulkOperationsBulkFormTest.php
index 6468565a67d40101ca94b9d0e054993d2fcdc1db..60f20ebdd86c02e76a20f9f7762fdd0fae19e431 100644
--- a/web/modules/views_bulk_operations/tests/src/FunctionalJavascript/ViewsBulkOperationsBulkFormTest.php
+++ b/web/modules/views_bulk_operations/tests/src/FunctionalJavascript/ViewsBulkOperationsBulkFormTest.php
@@ -64,7 +64,7 @@ class ViewsBulkOperationsBulkFormTest extends WebDriverTestBase {
    *
    * @var array
    */
-  public static $modules = [
+  protected static $modules = [
     'node',
     'views',
     'views_bulk_operations',
@@ -74,13 +74,13 @@ class ViewsBulkOperationsBulkFormTest extends WebDriverTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
     // Create some nodes for testing.
     $this->drupalCreateContentType(['type' => 'page']);
     for ($i = 0; $i <= self::TEST_NODE_COUNT; $i++) {
-      $node = $this->drupalCreateNode([
+      $this->drupalCreateNode([
         'type' => 'page',
         'title' => 'Title ' . $i,
       ]);
@@ -96,13 +96,20 @@ protected function setUp() {
     $this->assertSession = $this->assertSession();
     $this->page = $this->getSession()->getPage();
 
+    $testViewConfig = \Drupal::service('config.factory')->getEditable('views.view.' . self::TEST_VIEW_ID);
+
     // Get useful config data from the test view.
-    $config_data = \Drupal::service('config.factory')->get('views.view.' . self::TEST_VIEW_ID)->getRawData();
+    $config_data = $testViewConfig->getRawData();
     $this->testViewParams = [
       'items_per_page' => $config_data['display']['default']['display_options']['pager']['options']['items_per_page'],
       'path' => $config_data['display']['page_1']['display_options']['path'],
     ];
 
+    // Enable AJAX on the view.
+    $config_data['display']['default']['display_options']['use_ajax'] = TRUE;
+    $testViewConfig->setData($config_data);
+    $testViewConfig->save();
+
     $this->drupalGet('/' . $this->testViewParams['path']);
   }
 
@@ -119,16 +126,17 @@ public function testViewsBulkOperationsAjaxUi() {
     // Select some items on the first page.
     foreach ([0, 1, 3] as $selected_index) {
       $this->selectedIndexes[] = $selected_index;
-      $this->page->checkField('edit-views-bulk-operations-bulk-form-' . $selected_index);
+      $this->page->checkField('views_bulk_operations_bulk_form[' . $selected_index . ']');
     }
 
     // Go to the next page and select some more.
     $this->page->clickLink('Go to next page');
+    $this->assertSession->assertWaitOnAjaxRequest();
     foreach ([1, 2] as $selected_index) {
       // This is page one so indexes are incremented by page count and
       // checkbox selectors start from 0 again.
       $this->selectedIndexes[] = $selected_index + $this->testViewParams['items_per_page'];
-      $this->page->checkField('edit-views-bulk-operations-bulk-form-' . $selected_index);
+      $this->page->checkField('views_bulk_operations_bulk_form[' . $selected_index . ']');
     }
 
     // Execute test operation.
@@ -157,7 +165,7 @@ public function testViewsBulkOperationsWithDynamicInsertion() {
     $this->selectedIndexes = [0, 1, 3];
 
     foreach ($this->selectedIndexes as $selected_index) {
-      $this->page->checkField('edit-views-bulk-operations-bulk-form-' . $selected_index);
+      $this->page->checkField('views_bulk_operations_bulk_form[' . $selected_index . ']');
     }
 
     // Insert nodes.
diff --git a/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsActionProcessorTest.php b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsActionProcessorTest.php
index 7f250106df2c24454288d76dd6c155d68d447739..d185ee97d73078fa2a92b05e14d47e80faa9bc9b 100644
--- a/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsActionProcessorTest.php
+++ b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsActionProcessorTest.php
@@ -11,7 +11,7 @@ class ViewsBulkOperationsActionProcessorTest extends ViewsBulkOperationsKernelTe
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  public function setUp(): void {
     parent::setUp();
 
     $this->createTestNodes([
diff --git a/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsDataServiceTest.php b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsDataServiceTest.php
index 1a004a32b1d45d9f162240ad1f8ee65e7498762f..ccb681b5622a905fbd677e62efcede7f66bdd919 100644
--- a/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsDataServiceTest.php
+++ b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsDataServiceTest.php
@@ -13,7 +13,7 @@ class ViewsBulkOperationsDataServiceTest extends ViewsBulkOperationsKernelTestBa
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  public function setUp(): void {
     parent::setUp();
 
     $this->createTestNodes([
diff --git a/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsKernelTestBase.php b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsKernelTestBase.php
index 032e3a45cc72c81adcda62fc8d57a4682d906922..7fbce1057b175fa77c0a227830277bf791bc5229 100644
--- a/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsKernelTestBase.php
+++ b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsKernelTestBase.php
@@ -61,7 +61,7 @@ abstract class ViewsBulkOperationsKernelTestBase extends KernelTestBase {
   /**
    * {@inheritdoc}
    */
-  public static $modules = [
+  protected static $modules = [
     'user',
     'node',
     'field',
@@ -79,14 +79,13 @@ abstract class ViewsBulkOperationsKernelTestBase extends KernelTestBase {
   /**
    * {@inheritdoc}
    */
-  public function setUp() {
+  public function setUp(): void {
     parent::setUp();
 
     $this->installEntitySchema('user');
     $this->installEntitySchema('node');
     $this->installSchema('node', 'node_access');
     $this->installSchema('system', 'sequences');
-    $this->installSchema('system', 'key_value_expire');
 
     $user = User::create();
     $user->setPassword('password');
diff --git a/web/modules/views_bulk_operations/tests/src/Unit/TestViewsBulkOperationsBatch.php b/web/modules/views_bulk_operations/tests/src/Unit/TestViewsBulkOperationsBatch.php
index cbf30569b30c26822ab91755c749dfc8cad6f599..0d933ed428fc715e175a6b3a6371daeb48c5760b 100644
--- a/web/modules/views_bulk_operations/tests/src/Unit/TestViewsBulkOperationsBatch.php
+++ b/web/modules/views_bulk_operations/tests/src/Unit/TestViewsBulkOperationsBatch.php
@@ -12,7 +12,7 @@ class TestViewsBulkOperationsBatch extends ViewsBulkOperationsBatch {
   /**
    * Override t method.
    */
-  public static function t($string, array $args = [], array $options = []) {
+  public static function translate($string, array $args = [], array $options = []) {
     return strtr($string, $args);
   }
 
diff --git a/web/modules/views_bulk_operations/tests/src/Unit/ViewsBulkOperationsBatchTest.php b/web/modules/views_bulk_operations/tests/src/Unit/ViewsBulkOperationsBatchTest.php
index 3ba5a30b802a27a6f0ef8629262a4385fb2aa91a..1d71b44193355a2aed4f7767266e0b327125ca67 100644
--- a/web/modules/views_bulk_operations/tests/src/Unit/ViewsBulkOperationsBatchTest.php
+++ b/web/modules/views_bulk_operations/tests/src/Unit/ViewsBulkOperationsBatchTest.php
@@ -17,12 +17,12 @@ class ViewsBulkOperationsBatchTest extends UnitTestCase {
    *
    * @var array
    */
-  public static $modules = ['node'];
+  protected static $modules = ['node'];
 
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
 
     $this->container = new ContainerBuilder();
@@ -67,6 +67,7 @@ public function testGetBatch() {
       'list' => [[0, 'en', 'node', 1]],
       'some_data' => [],
       'action_label' => '',
+      'finished_callback' => [TestViewsBulkOperationsBatch::class, 'finished'],
     ];
     $batch = TestViewsBulkOperationsBatch::getBatch($data);
     $this->assertArrayHasKey('title', $batch);
@@ -128,6 +129,7 @@ public function testOperation() {
       'display_id' => 'test_display',
       'batch_size' => $batch_size,
       'list' => [],
+      'finished_callback' => [TestViewsBulkOperationsBatch::class, 'finished'],
     ];
     $context = [
       'sandbox' => [
diff --git a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test.yml b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test.yml
index 911613c93e247ab54a2761e9d0dc17530eb54406..5723efcc51269db5c705fc4e4e41caa225b02990 100644
--- a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test.yml
+++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test.yml
@@ -175,10 +175,12 @@ display:
               action_id: views_bulk_operations_simple_test_action
               preconfiguration:
                 label_override: 'Simple test action'
+                add_confirmation: false
                 preconfig: 'Test setting'
             -
               action_id: views_bulk_operations_advanced_test_action
               preconfiguration:
+                add_confirmation: false
                 preconfig: 'Test setting'
           plugin_id: views_bulk_operations_bulk_form
       filters:
diff --git a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test_advanced.yml b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test_advanced.yml
index f19c26356332f537a69a7af12ab87a70acfa0338..56fee531564f2c0aef0e29675fa10b1a661988b4 100644
--- a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test_advanced.yml
+++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test_advanced.yml
@@ -173,10 +173,12 @@ display:
               action_id: views_bulk_operations_simple_test_action
               preconfiguration:
                 label_override: 'Simple test action'
+                add_confirmation: false
                 preconfig: 'Test setting'
             1:
               action_id: views_bulk_operations_advanced_test_action
               preconfiguration:
+                add_confirmation: false
                 test_preconfig: 'Test setting'
             2:
               action_id: views_bulk_operations_passing_test_action
diff --git a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/schema/views_bulk_operations_test.schema.yml b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/schema/views_bulk_operations_test.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a9b1711e02210adb0554fa8d6c16839151ac6390
--- /dev/null
+++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/schema/views_bulk_operations_test.schema.yml
@@ -0,0 +1,27 @@
+# Since 4.x preliminary configuration schema should be defined for actions
+# that have additional configuration.
+views_bulk_operations.action_config.views_bulk_operations_advanced_test_action:
+  type: views_bulk_operations_action_config
+  label: 'Test preliminary configuration 1'
+  mapping:
+    # This key is in the action configuration form, so it can be set in
+    # the UI.
+    test_preconfig:
+      type: string
+      label: 'Another configuration item'
+    # This key is not in the action configuration form but is set directly
+    # in the view config so needs to be included here as well.
+    # @todo remove this from tests or add that config form field to avoid
+    # confusion.
+    preconfig:
+      type: string
+      label: 'Preconfig'
+
+views_bulk_operations.action_config.views_bulk_operations_simple_test_action:
+  type: views_bulk_operations_action_config
+  label: 'Test preliminary configuration 1'
+  mapping:
+    preconfig:
+      type: string
+      label: 'Preconfig'
+
diff --git a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsAdvancedTestAction.php b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsAdvancedTestAction.php
index ef16c633ced18a4dd2c2d074627778ad6cb6b905..88872565023719cd40071d82c78edf7753d5270c 100644
--- a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsAdvancedTestAction.php
+++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsAdvancedTestAction.php
@@ -9,6 +9,7 @@
 use Drupal\Core\Plugin\PluginFormInterface;
 use Drupal\Core\Session\AccountInterface;
 use Drupal\views\ViewExecutable;
+use Symfony\Component\HttpFoundation\RedirectResponse;
 
 /**
  * Action for test purposes only.
@@ -83,7 +84,7 @@ public function buildPreConfigurationForm(array $element, array $values, FormSta
    */
   public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
     $form['test_config'] = [
-      '#title' => t('Config'),
+      '#title' => $this->t('Config'),
       '#type' => 'textfield',
       '#default_value' => $form_state->getValue('config'),
     ];
@@ -97,4 +98,22 @@ public function access($object, AccountInterface $account = NULL, $return_as_obj
     return $object->access('update', $account, $return_as_object);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public static function finished($success, array $results, array $operations): ?RedirectResponse {
+    // Let's return a bit different message. We don't except faliures
+    // in tests as well so no need to check for a success.
+    $operations = array_count_values($results['operations']);
+    $details = [];
+    foreach ($operations as $op => $count) {
+      $details[] = $op . ' (' . $count . ')';
+    }
+    $message = static::translate('Custom processing message: @operations.', [
+      '@operations' => implode(', ', $details),
+    ]);
+    static::message($message);
+    return NULL;
+  }
+
 }
diff --git a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/views_bulk_operations_test.info.yml b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/views_bulk_operations_test.info.yml
index e7aad18fb5e05ce00e2486681e9db2380f379b99..04444fe3243c683fcf8b2b4c30d81a159f6f688a 100644
--- a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/views_bulk_operations_test.info.yml
+++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/views_bulk_operations_test.info.yml
@@ -7,7 +7,7 @@ dependencies:
   - drupal:views_bulk_operations
   - drupal:node
 
-# Information added by Drupal.org packaging script on 2021-04-29
-version: '8.x-3.13'
+# Information added by Drupal.org packaging script on 2022-03-19
+version: '4.1.2'
 project: 'views_bulk_operations'
-datestamp: 1619697069
+datestamp: 1647723470
diff --git a/web/modules/views_bulk_operations/views_bulk_operations.drush.inc b/web/modules/views_bulk_operations/views_bulk_operations.drush.inc
deleted file mode 100644
index 8ac8bd17600bca0fbf10920e1312110187a7043d..0000000000000000000000000000000000000000
--- a/web/modules/views_bulk_operations/views_bulk_operations.drush.inc
+++ /dev/null
@@ -1,207 +0,0 @@
-<?php
-
-/**
- * @file
- * Contains code providing drush commands functionality.
- */
-
-use Drupal\views\Views;
-use Drupal\views_bulk_operations\ViewsBulkOperationsBatch;
-
-/**
- * Implements hook_drush_command().
- */
-function views_bulk_operations_drush_command() {
-  return [
-    'views-bulk-operations-execute' => [
-      'description' => 'Execute an action on all results of the given view.',
-      'aliases' => ['vbo-execute', 'vbo-exec'],
-      'arguments' => [
-        'view_id' => 'The ID of the view to use',
-        'action_id' => 'The ID of the action to execute',
-      ],
-      'options' => [
-        'display-id' => 'ID of the display to use (default: default)',
-        'args' => 'View arguments (slash is a delimiter, default: none)',
-        'exposed' => 'Exposed filters (query string format)',
-        'batch-size' => 'Processing batch size (default: 100)',
-        'config' => 'Action configuration (query string format)',
-        'debug' => 'Include additional debugging information.',
-      ],
-      'examples' => [
-        'drush vbo-execute some_view some_action --user=1' => 'Execute some action on some view as the superuser.',
-        'drush vbo-execute some_view some_action --args=arg1/arg2 --batch-size=50' => 'Execute some action on some view with arg1 and arg2 as view arguments and 50 entities processed per batch.',
-        'drush vbo-execute some_view some_action --config="key1=value1&key2=value2"' => 'Execute some action on some view with action configuration set.',
-      ],
-    ],
-  ];
-}
-
-/**
- * Helper function to set / get timer.
- *
- * @param bool $debug
- *   Should the function do anything at all?
- * @param string $id
- *   ID of a specific timer span.
- *
- * @return mixed
- *   NULL or value of a specific timer if set.
- */
-function _views_bulk_operations_timer($debug = TRUE, $id = NULL) {
-  if (!$debug) {
-    return;
-  }
-
-  static $timers = [];
-
-  if (!isset($id)) {
-    $timers['start'] = microtime(TRUE);
-  }
-  else {
-    if (isset($timers[$id])) {
-      end($timers);
-      do {
-        if (key($timers) === $id) {
-          return round((current($timers) - prev($timers)) * 1000, 3);
-        }
-        else {
-          $result = prev($timers);
-        }
-      } while ($result);
-    }
-    else {
-      $timers[$id] = microtime(TRUE);
-    }
-  }
-}
-
-/**
- * The vbo-exec command execution function.
- *
- * @param string $view_id
- *   The ID of the view to use.
- * @param string $action_id
- *   The ID of the action to execute.
- */
-function drush_views_bulk_operations_execute($view_id, $action_id) {
-
-  $debug = drush_get_option('debug', FALSE);
-  _views_bulk_operations_timer($debug);
-
-  // Prepare parameters.
-  $arguments = drush_get_option('args', FALSE);
-  if ($arguments) {
-    $arguments = explode('/', $arguments);
-  }
-
-  $qs_config = [
-    'config' => [],
-    'exposed' => [],
-  ];
-  foreach ($qs_config as $name => $value) {
-    $config_data = drush_get_option($name, []);
-    if (!empty($config_data)) {
-      parse_str($config_data, $qs_config[$name]);
-    }
-  }
-
-  $vbo_data = [
-    'list' => [],
-    'view_id' => $view_id,
-    'display_id' => drush_get_option('display-id', 'default'),
-    'action_id' => $action_id,
-    'preconfiguration' => $qs_config['config'],
-    'batch' => TRUE,
-    'arguments' => $arguments,
-    'exposed_input' => $qs_config['exposed'],
-    'batch_size' => drush_get_option('batch-size', 100),
-    'relationship_id' => 'none',
-  ];
-
-  // Initialize the view to check if parameters are correct.
-  if (!$view = Views::getView($vbo_data['view_id'])) {
-    drush_set_error('Incorrect view ID provided.');
-    return;
-  }
-  if (!$view->setDisplay($vbo_data['display_id'])) {
-    drush_set_error('Incorrect view display ID provided.');
-    return;
-  }
-  if (!empty($vbo_data['arguments'])) {
-    $view->setArguments($vbo_data['arguments']);
-  }
-  if (!empty($vbo_data['exposed_input'])) {
-    $view->setExposedInput($vbo_data['exposed_input']);
-  }
-
-  // We need total rows count for proper progress message display.
-  $view->get_total_rows = TRUE;
-  $view->execute();
-
-  // Get relationship ID if VBO field exists.
-  $vbo_data['relationship_id'] = 'none';
-  foreach ($view->field as $field) {
-    if ($field->options['id'] === 'views_bulk_operations_bulk_form') {
-      $vbo_data['relationship_id'] = $field->options['relationship'];
-    }
-  }
-
-  // Get total rows count.
-  $viewDataService = \Drupal::service('views_bulk_operations.data');
-  $viewDataService->init($view, $view->getDisplay(), $vbo_data['relationship_id']);
-  $vbo_data['total_results'] = $viewDataService->getTotalResults();
-
-  // Get action definition and check if action ID is correct.
-  try {
-    $action_definition = \Drupal::service('plugin.manager.views_bulk_operations_action')->getDefinition($action_id);
-  }
-  catch (\Exception $e) {
-    drush_set_error($e->getMessage());
-    return;
-  }
-  $vbo_data['action_label'] = (string) $action_definition['label'];
-
-  _views_bulk_operations_timer($debug, 'init');
-
-  // Populate entity list.
-  $context = [];
-  do {
-    $context['finished'] = 1;
-    $context['message'] = '';
-    ViewsBulkOperationsBatch::getList($vbo_data, $context);
-    if (!empty($context['message'])) {
-      drush_log($context['message'], 'ok');
-    }
-  } while ($context['finished'] < 1);
-  $vbo_data = $context['results'];
-
-  _views_bulk_operations_timer($debug, 'list');
-
-  // Execute the selected action.
-  $context = [];
-  do {
-    $context['finished'] = 1;
-    $context['message'] = '';
-    ViewsBulkOperationsBatch::operation($vbo_data, $context);
-    if (!empty($context['message'])) {
-      drush_log($context['message'], 'ok');
-    }
-  } while ($context['finished'] < 1);
-
-  // Output a summary message.
-  $operations = array_count_values($context['results']['operations']);
-  $details = [];
-  foreach ($operations as $op => $count) {
-    $details[] = $op . ' (' . $count . ')';
-  }
-  drush_log(dt('Action processing results: @results.', ['@results' => implode(', ', $details)]), 'ok');
-
-  // Display debug information.
-  if ($debug) {
-    _views_bulk_operations_timer($debug, 'execute');
-    drush_print(sprintf('Initialization time: %d ms.', _views_bulk_operations_timer($debug, 'init')));
-    drush_print(sprintf('Entity list generation time: %d ms.', _views_bulk_operations_timer($debug, 'list')));
-    drush_print(sprintf('Execution time: %d ms.', _views_bulk_operations_timer($debug, 'execute')));
-  }
-}
diff --git a/web/modules/views_bulk_operations/views_bulk_operations.info.yml b/web/modules/views_bulk_operations/views_bulk_operations.info.yml
index 2860f5e129ede5ec959d4296877339cc0a8602b4..f088cd9513669f2188ade4a817e577a354f84cad 100644
--- a/web/modules/views_bulk_operations/views_bulk_operations.info.yml
+++ b/web/modules/views_bulk_operations/views_bulk_operations.info.yml
@@ -2,11 +2,11 @@ type: module
 name: 'Views Bulk Operations'
 description: 'Adds an ability to perform bulk operations on selected entities from view results.'
 package: 'Views'
-core_version_requirement: ^8.8 || ^9
+core_version_requirement: ^9
 dependencies:
   - drupal:views
 
-# Information added by Drupal.org packaging script on 2021-04-29
-version: '8.x-3.13'
+# Information added by Drupal.org packaging script on 2022-03-19
+version: '4.1.2'
 project: 'views_bulk_operations'
-datestamp: 1619697069
+datestamp: 1647723470
diff --git a/web/modules/views_bulk_operations/views_bulk_operations.libraries.yml b/web/modules/views_bulk_operations/views_bulk_operations.libraries.yml
index bed306fba03904e50c448fe7a3bafc52aaf913b8..8bdbbf52028950ffea0f02a80acd8c245bb7409f 100644
--- a/web/modules/views_bulk_operations/views_bulk_operations.libraries.yml
+++ b/web/modules/views_bulk_operations/views_bulk_operations.libraries.yml
@@ -8,7 +8,7 @@ frontUi:
   dependencies:
     - core/drupal
     - core/jquery
-    - core/jquery.once
+    - core/once
 
 adminUi:
   version: 1.0
@@ -17,4 +17,4 @@ adminUi:
   dependencies:
     - core/drupal
     - core/jquery
-    - core/jquery.once
+    - core/once