diff --git a/composer.json b/composer.json index 3f5d688105a34913bcae58ce1732f8e16ef3740d..436c01624746e8bf48a82cb0d0e20cb09b0df633 100644 --- a/composer.json +++ b/composer.json @@ -125,15 +125,16 @@ "drupal/simplesamlphp_auth": "3.0", "drupal/smtp": "1.0-beta3", "drupal/superfish": "1.2", - "drupal/svg_image": "^1.8", + "drupal/svg_image": "1.8", "drupal/token": "1.0", "drupal/userprotect": "1.0", "drupal/video_embed_field": "2.0", "drupal/views_accordion": "1.0-beta2", "drupal/views_autocomplete_filters": "1.1", "drupal/views_bootstrap": "3.x-dev", - "drupal/views_fieldsets": "^3.3", - "drupal/views_infinite_scroll": "^1.5", + "drupal/views_bulk_operations": "2.4", + "drupal/views_fieldsets": "3.3", + "drupal/views_infinite_scroll": "1.5", "drupal/views_slideshow": "4.4", "drupal/webform": "5.0-rc12", "drupal/webform_views": "5.0-alpha2", diff --git a/composer.lock b/composer.lock index d65e2754a879292f662d7fb810116dc74cc0dedf..423bd6c964a950eaddd0c2afed834b0bb918a529 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "0ba944d812e267fc1880081644b14083", + "content-hash": "844e9f7f4727de9fb76de196d29d0a23", "packages": [ { "name": "alchemy/zippy", @@ -6399,6 +6399,76 @@ "source": "http://cgit.drupalcode.org/views_bootstrap" } }, + { + "name": "drupal/views_bulk_operations", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://git.drupal.org/project/views_bulk_operations", + "reference": "8.x-2.4" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/views_bulk_operations-8.x-2.4.zip", + "reference": "8.x-2.4", + "shasum": "50c5778770f3a92e38ecf664301b77146e3cc931" + }, + "require": { + "drupal/core": "^8.4" + }, + "type": "drupal-module", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + }, + "drupal": { + "version": "8.x-2.4", + "datestamp": "1530516821", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + }, + "drush": { + "services": { + "drush.services.yml": "^9" + } + } + }, + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "Marcin Grabias", + "homepage": "https://www.drupal.org/u/graber" + }, + { + "name": "Jon Pugh", + "homepage": "https://www.drupal.org/user/17028" + }, + { + "name": "bojanz", + "homepage": "https://www.drupal.org/user/86106" + }, + { + "name": "infojunkie", + "homepage": "https://www.drupal.org/user/48424" + }, + { + "name": "joelpittet", + "homepage": "https://www.drupal.org/user/160302" + } + ], + "description": "Adds an ability to perform bulk operations on selected entities from view results. Provides an API to create such operations.", + "homepage": "https://www.drupal.org/project/views_bulk_operations", + "support": { + "source": "http://cgit.drupalcode.org/views_bulk_operations", + "issues": "https://www.drupal.org/project/issues/views_bulk_operations?version=8.x", + "docs": "https://www.drupal.org/docs/8/modules/views-bulk-operations-vbo" + } + }, { "name": "drupal/views_fieldsets", "version": "3.3.0", @@ -7636,6 +7706,11 @@ "shasum": "" }, "type": "drupal-library", + "extra": { + "patches_applied": { + "Fontawesome Tags": "patches/superfish-fontawesome-tags.patch" + } + }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json index 9b12a1f699d0b83bc50d396af4f5bc71ad4fd114..b3a74ee4e0a57598b978d3dc9082f494b85bc111 100644 --- a/vendor/composer/installed.json +++ b/vendor/composer/installed.json @@ -6600,6 +6600,78 @@ "source": "http://cgit.drupalcode.org/views_bootstrap" } }, + { + "name": "drupal/views_bulk_operations", + "version": "2.4.0", + "version_normalized": "2.4.0.0", + "source": { + "type": "git", + "url": "https://git.drupal.org/project/views_bulk_operations", + "reference": "8.x-2.4" + }, + "dist": { + "type": "zip", + "url": "https://ftp.drupal.org/files/projects/views_bulk_operations-8.x-2.4.zip", + "reference": "8.x-2.4", + "shasum": "50c5778770f3a92e38ecf664301b77146e3cc931" + }, + "require": { + "drupal/core": "^8.4" + }, + "type": "drupal-module", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + }, + "drupal": { + "version": "8.x-2.4", + "datestamp": "1530516821", + "security-coverage": { + "status": "covered", + "message": "Covered by Drupal's security advisory policy" + } + }, + "drush": { + "services": { + "drush.services.yml": "^9" + } + } + }, + "installation-source": "dist", + "notification-url": "https://packages.drupal.org/8/downloads", + "license": [ + "GPL-2.0+" + ], + "authors": [ + { + "name": "Marcin Grabias", + "homepage": "https://www.drupal.org/u/graber" + }, + { + "name": "Jon Pugh", + "homepage": "https://www.drupal.org/user/17028" + }, + { + "name": "bojanz", + "homepage": "https://www.drupal.org/user/86106" + }, + { + "name": "infojunkie", + "homepage": "https://www.drupal.org/user/48424" + }, + { + "name": "joelpittet", + "homepage": "https://www.drupal.org/user/160302" + } + ], + "description": "Adds an ability to perform bulk operations on selected entities from view results. Provides an API to create such operations.", + "homepage": "https://www.drupal.org/project/views_bulk_operations", + "support": { + "source": "http://cgit.drupalcode.org/views_bulk_operations", + "issues": "https://www.drupal.org/project/issues/views_bulk_operations?version=8.x", + "docs": "https://www.drupal.org/docs/8/modules/views-bulk-operations-vbo" + } + }, { "name": "drupal/views_fieldsets", "version": "3.3.0", diff --git a/web/modules/views_bulk_operations/LICENSE.txt b/web/modules/views_bulk_operations/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..d159169d1050894d3ea3b98e1c965c4058208fe1 --- /dev/null +++ b/web/modules/views_bulk_operations/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + <signature of Ty Coon>, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/web/modules/views_bulk_operations/README.txt b/web/modules/views_bulk_operations/README.txt new file mode 100644 index 0000000000000000000000000000000000000000..69a18df9f73ec93c764270bf63dc2786d7be275b --- /dev/null +++ b/web/modules/views_bulk_operations/README.txt @@ -0,0 +1,53 @@ +Introduction +------------ + +Views Bulk Operations augments Views by allowing actions +(provided by Drupal core or contrib modules) to be executed +on the selected view rows. + +It does so by showing a checkbox in front of each displayed row, and adding a +select box on top of the View containing operations that can be applied. + + +Getting started +----------------- + +1. Create a View with a page or block display. +2. Add a "Views bulk operations" field (global), available on + all entity types. +3. Configure the field by selecting at least one operation. +4. Go to the View page. VBO functionality should be present. + + +Creating custom actions +----------------------- + +Example that covers different possibilities is available in +modules/views_bulk_operatios_example/. + +In a module, create an action plugin (check the included example module, +test actions in /tests/views_bulk_operations_test/src/Plugin/Action +or \core\modules\node\src\Plugin\Action namespace for simple implementations). + +Available annotation parameters: + - id: The action ID (required), + - label: Action label (required), + - type: Entity type for the action, if left empty, action will be + applicable to all entity types (required), + - confirm: If set to TRUE and the next parameter is empty, + the module default confirmation form will be used (default: FALSE), + - confirm_form_route_name: Route name of the action confirmation form. + If left empty and the previous parameter is empty, there will be + no confirmation step (default: empty string). + - requirements: an array of requirements an action must meet + to be displayed on the action selection form. At the moment + only one possible requirement is supported: '_permission', if + the current user has that permission, the action execution will + be possible. + + +Additional notes +---------------- + +Full documentation with examples is available at +https://www.drupal.org/docs/8/modules/views-bulk-operations-vbo. diff --git a/web/modules/views_bulk_operations/composer.json b/web/modules/views_bulk_operations/composer.json new file mode 100644 index 0000000000000000000000000000000000000000..174bebe31231d1166b7bf0b8eb6b359012ff408f --- /dev/null +++ b/web/modules/views_bulk_operations/composer.json @@ -0,0 +1,26 @@ +{ + "name": "drupal/views_bulk_operations", + "description": "Adds an ability to perform bulk operations on selected entities from view results. Provides an API to create such operations.", + "type": "drupal-module", + "homepage": "https://www.drupal.org/project/views_bulk_operations", + "authors": [ + { + "name": "Marcin Grabias", + "homepage": "https://www.drupal.org/u/graber" + } + ], + "support": { + "issues": "https://www.drupal.org/project/issues/views_bulk_operations?version=8.x", + "docs": "https://www.drupal.org/docs/8/modules/views-bulk-operations-vbo" + }, + "license": "GPL-2.0+", + "minimum-stability": "dev", + "require": {}, + "extra": { + "drush": { + "services": { + "drush.services.yml": "^9" + } + } + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..546ae53e556559394491b7f204301b82ccadfe51 --- /dev/null +++ b/web/modules/views_bulk_operations/config/schema/views_bulk_operations.views.schema.yml @@ -0,0 +1,25 @@ +views.field.views_bulk_operations_bulk_form: + type: views_field + label: 'Views Bulk Operations' + mapping: + batch: + type: boolean + label: 'Process selected entities in a batch operation' + batch_size: + type: integer + label: 'Size of the processing batch' + form_step: + type: boolean + label: 'Display configuration form on a separate page' + buttons: + type: boolean + label: 'Display action options as buttons' + action_title: + type: string + label: 'Title of the action selector form element' + selected_actions: + type: ignore + label: 'Selected actions array' + preconfiguration: + type: ignore + label: 'Preliminary configuration array' diff --git a/web/modules/views_bulk_operations/css/frontUi.css b/web/modules/views_bulk_operations/css/frontUi.css new file mode 100644 index 0000000000000000000000000000000000000000..e5fb3c4578e4fe923206597c547038c3e7967011 --- /dev/null +++ b/web/modules/views_bulk_operations/css/frontUi.css @@ -0,0 +1,6 @@ +.views-table-row-vbo-select-all div { + text-align: center; +} +.views-field-views-bulk-operations-bulk-form.empty { + display: none; +} diff --git a/web/modules/views_bulk_operations/drush.services.yml b/web/modules/views_bulk_operations/drush.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..3d629c0fafacef52f7a20533d59a1a6923ef23c6 --- /dev/null +++ b/web/modules/views_bulk_operations/drush.services.yml @@ -0,0 +1,9 @@ +services: + views_bulk_operations.commands: + class: \Drupal\views_bulk_operations\Commands\ViewsBulkOperationsCommands + arguments: + - '@current_user' + - '@views_bulk_operations.data' + - '@plugin.manager.views_bulk_operations_action' + tags: + - { name: drush.command } diff --git a/web/modules/views_bulk_operations/js/adminUi.js b/web/modules/views_bulk_operations/js/adminUi.js new file mode 100644 index 0000000000000000000000000000000000000000..7a2b3f64dc56eb6d17a41be2892ccd94ba8c2355 --- /dev/null +++ b/web/modules/views_bulk_operations/js/adminUi.js @@ -0,0 +1,58 @@ +/** + * @file + * Views admin UI functionality. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * @type {Drupal~behavior} + */ + Drupal.behaviors.views_bulk_operations = { + attach: function (context, settings) { + $('.views-bulk-operations-ui').once('views-bulk-operations-ui').each(Drupal.viewsBulkOperationsUi); + } + }; + + /** + * Callback used in {@link Drupal.behaviors.views_bulk_operations}. + */ + Drupal.viewsBulkOperationsUi = function () { + var uiElement = $(this); + + // Show / hide actions' preliminary configuration. + uiElement.find('.vbo-action-state').each(function () { + var matches = $(this).attr('name').match(/.*\[.*?\]\[(.*?)\]\[.*?\]/); + if (typeof (matches[1]) != 'undefined') { + var preconfigurationElement = uiElement.find('*[data-for="' + matches[1] + '"]'); + $(this).change(function (event) { + if ($(this).is(':checked')) { + preconfigurationElement.show('fast'); + } + else { + preconfigurationElement.hide('fast'); + } + }); + } + }); + + // Select / deselect all functionality. + 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>'); + actionsElementWrapper.prepend(allHandle); + allHandle.on('click', function (event) { + event.preventDefault(); + checked = !checked; + actionsElementWrapper.find('.vbo-action-state').each(function () { + $(this).prop('checked', checked); + $(this).trigger('change'); + }); + return false; + }); + } + }; +})(jQuery, Drupal); diff --git a/web/modules/views_bulk_operations/js/frontUi.js b/web/modules/views_bulk_operations/js/frontUi.js new file mode 100644 index 0000000000000000000000000000000000000000..254c8a6557bf041169b6cf403294ceaff43c0a29 --- /dev/null +++ b/web/modules/views_bulk_operations/js/frontUi.js @@ -0,0 +1,234 @@ +/** + * @file + * Select-All Button functionality. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * @type {Drupal~behavior} + */ + Drupal.behaviors.views_bulk_operations = { + attach: function (context, settings) { + $('.vbo-view-form').once('vbo-init').each(Drupal.viewsBulkOperationsFrontUi); + } + }; + + /** + * Views Bulk Operation selection object. + */ + Drupal.viewsBulkOperationsSelection = { + view_id: '', + display_id: '', + list: {}, + $placeholder: null, + + /** + * Bind event handlers to an element. + * + * @param {jQuery} element + */ + bindEventHandlers: function ($element, index) { + if ($element.length) { + var selectionObject = this; + $element.on('keypress', function (event) { + // Emulate click action for enter key. + if (event.which === 13) { + event.preventDefault(); + event.stopPropagation(); + selectionObject.update(this.checked, index, $(this).val()); + $(this).trigger('click'); + } + if (event.which === 32) { + selectionObject.update(this.checked, index, $(this).val()); + } + }); + $element.on('mousedown', function (event) { + // Act only on left button click. + if (event.which === 1) { + selectionObject.update(this.checked, index, $(this).val()); + } + }); + } + }, + + /** + * Perform an AJAX request to update selection. + * + * @param {bool} state + * @param {string} value + */ + update: function (state, index, value) { + if (value === undefined) { + value = null; + } + if (this.view_id.length && this.display_id.length) { + var list = {}; + if (value && value != 'on') { + list[value] = this.list[index][value]; + } + else { + list = this.list[index]; + } + var op = state ? 'remove' : 'add'; + + var $placeholder = this.$placeholder; + var target_uri = '/' + drupalSettings.path.pathPrefix + 'views-bulk-operations/ajax/' + this.view_id + '/' + this.display_id; + $.ajax(target_uri, { + method: 'POST', + data: { + list: list, + op: op + }, + success: function (data) { + var count = parseInt($placeholder.text()); + count += data.change; + $placeholder.text(count); + } + }); + } + } + } + + /** + * Callback used in {@link Drupal.behaviors.views_bulk_operations}. + */ + Drupal.viewsBulkOperationsFrontUi = function () { + var $vboForm = $(this); + var $viewsTables = $('.vbo-table', $vboForm); + var $primarySelectAll = $('.vbo-select-all', $vboForm); + var tableSelectAll = []; + + // When grouping is enabled, there can be multiple tables. + if ($viewsTables.length) { + $viewsTables.each(function (index) { + tableSelectAll[index] = $(this).find('.select-all input').first(); + }); + var $tableSelectAll = $(tableSelectAll); + } + + // Add AJAX functionality to table checkboxes. + var $multiSelectElement = $vboForm.find('.vbo-multipage-selector').first(); + if ($multiSelectElement.length) { + + Drupal.viewsBulkOperationsSelection.$placeholder = $multiSelectElement.find('.placeholder').first(); + Drupal.viewsBulkOperationsSelection.view_id = $multiSelectElement.attr('data-view-id'); + Drupal.viewsBulkOperationsSelection.display_id = $multiSelectElement.attr('data-display-id'); + + // Get the list of all checkbox values and add AJAX callback. + Drupal.viewsBulkOperationsSelection.list = []; + + var $contentWrappers; + if ($viewsTables.length) { + $contentWrappers = $viewsTables; + } + else { + $contentWrappers = $([$vboForm]); + } + + $contentWrappers.each(function (index) { + var $contentWrapper = $(this); + Drupal.viewsBulkOperationsSelection.list[index] = {}; + + $contentWrapper.find('.views-field-views-bulk-operations-bulk-form input[type="checkbox"]').each(function () { + var value = $(this).val(); + if (value != 'on') { + Drupal.viewsBulkOperationsSelection.list[index][value] = $(this).parent().find('label').first().text(); + Drupal.viewsBulkOperationsSelection.bindEventHandlers($(this), index); + } + }); + + // Bind event handlers to select all checkbox. + if ($viewsTables.length && tableSelectAll.length) { + Drupal.viewsBulkOperationsSelection.bindEventHandlers(tableSelectAll[index], index); + } + }); + } + + // Initialize all selector if the primary select all and + // view table elements exist. + if ($primarySelectAll.length && $viewsTables.length) { + var strings = { + selectAll: $('label', $primarySelectAll.parent()).html(), + selectRegular: Drupal.t('Select only items on this page') + }; + + $primarySelectAll.parent().hide(); + + if ($viewsTables.length == 1) { + var colspan = $('thead th', $viewsTables.first()).length; + var $allSelector = $('<tr class="views-table-row-vbo-select-all even" style="display: none"><td colspan="' + colspan + '"><div><input type="submit" class="form-submit" value="' + strings.selectAll + '"></div></td></tr>'); + $('tbody', $viewsTables.first()).prepend($allSelector); + } + else { + var $allSelector = $('<div class="views-table-row-vbo-select-all" style="display: none"><div><input type="submit" class="form-submit" value="' + strings.selectAll + '"></div></div>'); + $($viewsTables.first()).before($allSelector); + } + + if ($primarySelectAll.is(':checked')) { + $('input', $allSelector).val(strings.selectRegular); + $allSelector.show(); + } + else { + var show_all_selector = true; + $tableSelectAll.each(function () { + if (!$(this).is(':checked')) { + show_all_selector = false; + } + }); + if (show_all_selector) { + $allSelector.show(); + } + } + + $('input', $allSelector).on('click', function (event) { + event.preventDefault(); + if ($primarySelectAll.is(':checked')) { + $multiSelectElement.show('fast'); + $primarySelectAll.prop('checked', false); + $allSelector.removeClass('all-selected'); + $(this).val(strings.selectAll); + } + else { + $multiSelectElement.hide('fast'); + $primarySelectAll.prop('checked', true); + $allSelector.addClass('all-selected'); + $(this).val(strings.selectRegular); + } + }); + + $(tableSelectAll).each(function () { + $(this).on('change', function (event) { + var show_all_selector = true; + $tableSelectAll.each(function () { + if (!$(this).is(':checked')) { + show_all_selector = false; + } + }); + if (show_all_selector) { + $allSelector.show(); + } + else { + $allSelector.hide(); + if ($primarySelectAll.is(':checked')) { + $('input', $allSelector).trigger('click'); + } + } + }); + }); + } + else { + $primarySelectAll.first().on('change', function (event) { + if (this.checked) { + $multiSelectElement.hide('fast'); + } + else { + $multiSelectElement.show('fast'); + } + }); + } + }; + +})(jQuery, Drupal); 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 new file mode 100644 index 0000000000000000000000000000000000000000..562a3de87ac6cb8409d094e1365692f1781749ed --- /dev/null +++ b/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.info.yml @@ -0,0 +1,13 @@ +type: module +name: 'Actions Permissions' +description: 'Adds access permissions on all actions allowing admins to restrict access on a per-role basis.' +package: 'Views Bulk Operations' +# core: 8.x +dependencies: + - drupal:views_bulk_operations + +# Information added by Drupal.org packaging script on 2018-07-02 +version: '8.x-2.4' +core: '8.x' +project: 'views_bulk_operations' +datestamp: 1530516826 diff --git a/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.permissions.yml b/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.permissions.yml new file mode 100644 index 0000000000000000000000000000000000000000..e2ccdd1f01915b1a0210014861b8e9bfc01ea57a --- /dev/null +++ b/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.permissions.yml @@ -0,0 +1,2 @@ +permission_callbacks: + - \Drupal\actions_permissions\ActionsPermissions::permissions diff --git a/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.services.yml b/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..a72918162b9f1ec929ff1a6186dd0e6618e27dc2 --- /dev/null +++ b/web/modules/views_bulk_operations/modules/actions_permissions/actions_permissions.services.yml @@ -0,0 +1,5 @@ +services: + actions_permissions.views_bulk_operations_actions: + class: Drupal\actions_permissions\EventSubscriber\ActionsPermissionsEventSubscriber + tags: + - { name: event_subscriber } diff --git a/web/modules/views_bulk_operations/modules/actions_permissions/src/ActionsPermissions.php b/web/modules/views_bulk_operations/modules/actions_permissions/src/ActionsPermissions.php new file mode 100644 index 0000000000000000000000000000000000000000..3e45e1ea72f216d580352cba383fb3cb63fb813d --- /dev/null +++ b/web/modules/views_bulk_operations/modules/actions_permissions/src/ActionsPermissions.php @@ -0,0 +1,106 @@ +<?php + +namespace Drupal\actions_permissions; + +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; + +/** + * Create permissions for existing actions. + */ +class ActionsPermissions implements ContainerInjectionInterface { + + use StringTranslationTrait; + + /** + * VBO Action manager service. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager + */ + protected $actionManager; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * Constructor. + * + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager $actionManager + * The action manager. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * Entity type manager. + */ + public function __construct(ViewsBulkOperationsActionManager $actionManager, EntityTypeManagerInterface $entityTypeManager) { + $this->actionManager = $actionManager; + $this->entityTypeManager = $entityTypeManager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('plugin.manager.views_bulk_operations_action'), + $container->get('entity_type.manager') + ); + } + + /** + * Get permissions for Actions. + * + * @return array + * Permissions array. + */ + public function permissions() { + $permissions = []; + $entity_type_definitions = $this->entityTypeManager->getDefinitions(); + + // Get definitions that will not be altered by actions_permissions. + foreach ($this->actionManager->getDefinitions([ + 'skip_actions_permissions' => TRUE, + 'nocache' => TRUE, + ]) as $definition) { + + // Skip actions that define their own requirements. + if (!empty($definition['requirements'])) { + continue; + } + + $id = 'execute ' . $definition['id']; + $entity_type = NULL; + if (empty($definition['type'])) { + $entity_type = $this->t('all entity types'); + $id .= ' all'; + } + elseif (isset($entity_type_definitions[$definition['type']])) { + $entity_type = $entity_type_definitions[$definition['type']]->getLabel(); + $id .= ' ' . $definition['type']; + } + + if (isset($entity_type)) { + $permissions[$id] = [ + 'title' => $this->t('Execute the %action action on %type.', [ + '%action' => $definition['label'], + '%type' => $entity_type, + ]), + ]; + } + } + + // Rebuild VBO action definitions cache with + // included action_permissions modifications. + $this->actionManager->getDefinitions([ + 'nocache' => TRUE, + ]); + + return $permissions; + } + +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..695e9942f90f3bc21d318801e9daee1ab150d7c9 --- /dev/null +++ b/web/modules/views_bulk_operations/modules/actions_permissions/src/EventSubscriber/ActionsPermissionsEventSubscriber.php @@ -0,0 +1,59 @@ +<?php + +namespace Drupal\actions_permissions\EventSubscriber; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\EventDispatcher\Event; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager; + +/** + * Defines module event subscriber class. + * + * Alters actions to make use of permissions created by the module. + */ +class ActionsPermissionsEventSubscriber implements EventSubscriberInterface { + + // Subscribe to the VBO event with low priority + // to let other modules alter requirements first. + const PRIORITY = -999; + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[ViewsBulkOperationsActionManager::ALTER_ACTIONS_EVENT][] = ['alterActions', static::PRIORITY]; + return $events; + } + + /** + * Alter the actions' definitions. + * + * @var \Symfony\Component\EventDispatcher\Event $event + * The event to respond to. + */ + public function alterActions(Event $event) { + + // Don't alter definitions if this is invoked by the + // own permissions creating method. + if (!empty($event->alterParameters['skip_actions_permissions'])) { + return; + } + + foreach ($event->definitions as $action_id => $definition) { + + // Only process actions that don't define their own requirements. + if (empty($definition['requirements'])) { + $permission_id = 'execute ' . $definition['id']; + if (empty($definition['type'])) { + $permission_id .= ' all'; + } + else { + $permission_id .= ' ' . $definition['type']; + } + $definition['requirements']['_permission'] = $permission_id; + $event->definitions[$action_id] = $definition; + } + } + } + +} diff --git a/web/modules/views_bulk_operations/modules/views_bulk_operations_example/src/Plugin/Action/ViewsBulkOperationExampleAction.php b/web/modules/views_bulk_operations/modules/views_bulk_operations_example/src/Plugin/Action/ViewsBulkOperationExampleAction.php new file mode 100644 index 0000000000000000000000000000000000000000..d4262e5f6779088fddf126a3b21e6f6ae34072e4 --- /dev/null +++ b/web/modules/views_bulk_operations/modules/views_bulk_operations_example/src/Plugin/Action/ViewsBulkOperationExampleAction.php @@ -0,0 +1,115 @@ +<?php + +namespace Drupal\views_bulk_operations_example\Plugin\Action; + +use Drupal\views_bulk_operations\Action\ViewsBulkOperationsActionBase; +use Drupal\views_bulk_operations\Action\ViewsBulkOperationsPreconfigurationInterface; +use Drupal\Core\Plugin\PluginFormInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Session\AccountInterface; + +/** + * An example action covering most of the possible options. + * + * If type is left empty, action will be selectable for all + * entity types. + * + * @Action( + * id = "views_bulk_operations_example", + * label = @Translation("VBO example action"), + * type = "", + * confirm = TRUE, + * ) + */ +class ViewsBulkOperationExampleAction extends ViewsBulkOperationsActionBase implements ViewsBulkOperationsPreconfigurationInterface, PluginFormInterface { + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + /* + * All config resides in $this->configuration. + * Passed view rows will be available in $this->context. + * Data about the view used to select results and optionally + * the batch context are available in $this->context or externally + * through the public getContext() method. + * The entire ViewExecutable object with selected result + * rows is available in $this->view or externally through + * the public getView() method. + */ + + // Do some processing.. + // ... + drupal_set_message($entity->label()); + return sprintf('Example action (configuration: %s)', print_r($this->configuration, TRUE)); + } + + /** + * {@inheritdoc} + */ + public function buildPreConfigurationForm(array $form, array $values, FormStateInterface $form_state) { + $form['example_preconfig_setting'] = [ + '#title' => $this->t('Example setting'), + '#type' => 'textfield', + '#default_value' => isset($values['example_preconfig_setting']) ? $values['example_preconfig_setting'] : '', + ]; + return $form; + } + + /** + * Configuration form builder. + * + * If this method has implementation, the action is + * considered to be configurable. + * + * @param array $form + * Form array. + * @param Drupal\Core\Form\FormStateInterface $form_state + * The form state object. + * + * @return array + * The configuration form. + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form['example_config_setting'] = [ + '#title' => t('Example setting pre-execute'), + '#type' => 'textfield', + '#default_value' => $form_state->getValue('example_config_setting'), + ]; + return $form; + } + + /** + * Submit handler for the action configuration form. + * + * If not implemented, the cleaned form values will be + * passed direclty to the action $configuration parameter. + * + * @param array $form + * Form array. + * @param Drupal\Core\Form\FormStateInterface $form_state + * The form state object. + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + // This is not required here, when this method is not defined, + // form values are assigned to the action configuration by default. + // This function is a must only when user input processing is needed. + $this->configuration['example_config_setting'] = $form_state->getValue('example_config_setting'); + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + if ($object->getEntityType() === 'node') { + $access = $object->access('update', $account, TRUE) + ->andIf($object->status->access('edit', $account, TRUE)); + return $return_as_object ? $access : $access->isAllowed(); + } + + // Other entity types may have different + // access methods and properties. + return TRUE; + } + +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..4eff3573ec2f956f51267b1bf3edfaefeb43fb72 --- /dev/null +++ b/web/modules/views_bulk_operations/modules/views_bulk_operations_example/views_bulk_operations_example.info.yml @@ -0,0 +1,13 @@ +type: module +name: 'Views Bulk Operations example' +description: 'Defines an example action with all possible options.' +package: 'Examples' +# core: 8.x +dependencies: + - drupal:views_bulk_operations + +# Information added by Drupal.org packaging script on 2018-07-02 +version: '8.x-2.4' +core: '8.x' +project: 'views_bulk_operations' +datestamp: 1530516826 diff --git a/web/modules/views_bulk_operations/src/Access/ViewsBulkOperationsAccess.php b/web/modules/views_bulk_operations/src/Access/ViewsBulkOperationsAccess.php new file mode 100644 index 0000000000000000000000000000000000000000..9f78a983bec6a14be3f84f4a2fac6054e179ef23 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Access/ViewsBulkOperationsAccess.php @@ -0,0 +1,50 @@ +<?php + +namespace Drupal\views_bulk_operations\Access; + +use Drupal\Core\Routing\Access\AccessInterface; +use Drupal\user\PrivateTempStoreFactory; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Routing\RouteMatch; +use Drupal\Core\Access\AccessResult; +use Drupal\views\Views; + +/** + * Defines module access rules. + */ +class ViewsBulkOperationsAccess implements AccessInterface { + + /** + * Temporary user storage object. + * + * @var \Drupal\user\PrivateTempStoreFactory + */ + protected $tempStoreFactory; + + /** + * Object constructor. + */ + public function __construct(PrivateTempStoreFactory $tempStoreFactory) { + $this->tempStoreFactory = $tempStoreFactory; + } + + /** + * A custom access check. + * + * @param \Drupal\Core\Session\AccountInterface $account + * Run access checks for this account. + * @param \Drupal\Core\Routing\RouteMatch $routeMatch + * The matched route. + */ + public function access(AccountInterface $account, RouteMatch $routeMatch) { + $parameters = $routeMatch->getParameters()->all(); + + if ($view = Views::getView($parameters['view_id'])) { + if ($view->access($parameters['display_id'], $account)) { + return AccessResult::allowed(); + } + } + return AccessResult::forbidden(); + } + +} diff --git a/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionBase.php b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionBase.php new file mode 100644 index 0000000000000000000000000000000000000000..2209fad10f0a5540bae13d894b106ca46984712b --- /dev/null +++ b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionBase.php @@ -0,0 +1,133 @@ +<?php + +namespace Drupal\views_bulk_operations\Action; + +use Drupal\Core\Action\ActionBase; +use Drupal\Component\Plugin\ConfigurablePluginInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\views\ViewExecutable; + +/** + * Views Bulk Operations action plugin base. + * + * Provides a base implementation for a configurable + * and preconfigurable VBO Action plugin. + */ +abstract class ViewsBulkOperationsActionBase extends ActionBase implements ViewsBulkOperationsActionInterface, ConfigurablePluginInterface { + + /** + * Action context. + * + * @var array + * Contains view data and optionally batch operation context. + */ + protected $context; + + /** + * The processed view. + * + * @var \Drupal\views\ViewExecutable + */ + protected $view; + + /** + * Configuration array. + * + * @var array + */ + protected $configuration; + + /** + * {@inheritdoc} + */ + public function setContext(array &$context) { + $this->context['sandbox'] = &$context['sandbox']; + foreach ($context as $key => $item) { + if ($key === 'sandbox') { + continue; + } + $this->context[$key] = $item; + } + } + + /** + * {@inheritdoc} + */ + public function setView(ViewExecutable $view) { + $this->view = $view; + } + + /** + * {@inheritdoc} + */ + public function executeMultiple(array $objects) { + $results = []; + foreach ($objects as $entity) { + $results[] = $this->execute($entity); + } + + return $results; + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return []; + } + + /** + * Default configuration form validator. + * + * This method will be needed if a child class will implement + * \Drupal\Core\Plugin\PluginFormInterface. Code saver. + * + * @param array &$form + * Form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state object. + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + + } + + /** + * Default configuration form submit handler. + * + * This method will be needed if a child class will implement + * \Drupal\Core\Plugin\PluginFormInterface. Code saver. + * + * @param array &$form + * Form array. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state object. + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $form_state->cleanValues(); + foreach ($form_state->getValues() as $key => $value) { + $this->configuration[$key] = $value; + } + } + + /** + * {@inheritdoc} + */ + public function getConfiguration() { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = $configuration; + } + + /** + * {@inheritdoc} + */ + public function calculateDependencies() { + return []; + } + +} diff --git a/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionInterface.php b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..d6c8824a4ed1fce71e01ccccbe9e3311fe4e7a80 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsActionInterface.php @@ -0,0 +1,47 @@ +<?php + +namespace Drupal\views_bulk_operations\Action; + +use Drupal\views\ViewExecutable; + +/** + * Defines Views Bulk Operations action interface. + */ +interface ViewsBulkOperationsActionInterface { + + /** + * Set action context. + * + * Implementation should have an option to add data to the + * context, not overwrite it on every method execution. + * + * @param array $context + * The context array. + * + * @see ViewsBulkOperationsActionBase::setContext + */ + public function setContext(array &$context); + + /** + * Set view object. + * + * @param \Drupal\views\ViewExecutable $view + * The processed view. + */ + public function setView(ViewExecutable $view); + + /** + * Execute action on multiple entities. + * + * Can return an array of results of processing, if no return value + * is provided, action label will be used for each result. + * + * @param array $objects + * An array of entities. + * + * @return array + * An array of translatable markup objects or strings (optional) + */ + public function executeMultiple(array $objects); + +} diff --git a/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsPreconfigurationInterface.php b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsPreconfigurationInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..2f87d43b9ad28e3e3ccb613a4eb90b66e9428f80 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Action/ViewsBulkOperationsPreconfigurationInterface.php @@ -0,0 +1,27 @@ +<?php + +namespace Drupal\views_bulk_operations\Action; + +use Drupal\Core\Form\FormStateInterface; + +/** + * Defines methods for a preconfigurable Views Bulk Operations action. + */ +interface ViewsBulkOperationsPreconfigurationInterface { + + /** + * Build preconfigure action form elements. + * + * @param array $element + * Element of the views API form where configuration resides. + * @param array $values + * Current values of the plugin pre-configuration. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * Form state interface object. + * + * @return array + * The action configuration form element. + */ + public function buildPreConfigurationForm(array $element, array $values, FormStateInterface $form_state); + +} diff --git a/web/modules/views_bulk_operations/src/Commands/ViewsBulkOperationsCommands.php b/web/modules/views_bulk_operations/src/Commands/ViewsBulkOperationsCommands.php new file mode 100644 index 0000000000000000000000000000000000000000..9c9d2f14bf547c571f4bcc6db14695b296e22aa8 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Commands/ViewsBulkOperationsCommands.php @@ -0,0 +1,287 @@ +<?php + +namespace Drupal\views_bulk_operations\Commands; + +use Drush\Commands\DrushCommands; +use Drupal\Core\Session\AccountInterface; +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; + +/** + * Defines Drush commands for the module. + */ +class ViewsBulkOperationsCommands extends DrushCommands { + + /** + * The current user object. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected $currentUser; + + /** + * Object that gets the current view data. + * + * @var \Drupal\views_bulk_operations\ViewsbulkOperationsViewDataInterface + */ + protected $viewData; + + /** + * Views Bulk Operations action manager. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager + */ + protected $actionManager; + + /** + * ViewsBulkOperationsCommands object constructor. + * + * @param \Drupal\Core\Session\AccountInterface $currentUser + * The current user object. + * @param \Drupal\views_bulk_operations\ViewsbulkOperationsViewDataInterface $viewData + * VBO View data service. + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager $actionManager + * VBO Action manager service. + */ + public function __construct( + AccountInterface $currentUser, + ViewsbulkOperationsViewDataInterface $viewData, + ViewsBulkOperationsActionManager $actionManager + ) { + $this->currentUser = $currentUser; + $this->viewData = $viewData; + $this->actionManager = $actionManager; + } + + /** + * Execute an action on all results of the specified view. + * + * Use the --verbose parameter to see progress messages. + * + * @param string $view_id + * The ID of the view to use. + * @param string $action_id + * The ID of the action to execute. + * @param array $options + * (optional) An array of options. + * + * @return string + * The summary message. + * + * @command views-bulk-operations:execute + * + * @option display-id + * ID of the display to use. + * @option args + * View arguments (slash is a delimeter). + * @option exposed + * Exposed filters (query string format). + * @option batch-size + * Processing batch size. + * @option configuration + * Action configuration (query string format). + * @option user-id + * The ID of the user account used for performing the operation. + * + * @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 + * the view arguments and 50 entities processed per batch. + * @usage drush vbo-exec some_view some_action --configuration="key1=value1&key2=value2" + * Execute some action on some view with the specified action configuration. + * + * @aliases vbo-execute, vbo-exec + */ + public function vboExecute( + $view_id, + $action_id, + array $options = [ + 'display-id' => 'default', + 'args' => '', + 'exposed' => '', + 'batch-size' => 100, + 'configuration' => '', + '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.')); + } + + $this->timer($options['verbose']); + + // Prepare options. + if ($options['args']) { + $options['args'] = explode('/', $options['args']); + } + else { + $options['args'] = []; + } + + // Decode query string format options. + foreach (['configuration', 'exposed'] as $name) { + if (!empty($options[$name]) && !is_array($options[$name])) { + parse_str($options[$name], $options[$name]); + } + else { + $options[$name] = []; + } + } + + $vbo_data = [ + 'list' => [], + 'view_id' => $view_id, + 'display_id' => $options['display-id'], + 'action_id' => $action_id, + 'preconfiguration' => $options['configuration'], + 'batch' => TRUE, + 'arguments' => $options['args'], + 'exposed_input' => $options['exposed'], + 'batch_size' => $options['batch-size'], + 'relationship_id' => 'none', + ]; + + // Login as superuser, as drush 9 doesn't support the + // --user parameter. + $account = User::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.')); + } + if (!$view->setDisplay($vbo_data['display_id'])) { + throw new \Exception($this->t('Incorrect view display ID provided.')); + } + 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. + $this->viewData->init($view, $view->getDisplay(), $vbo_data['relationship_id']); + $vbo_data['total_results'] = $this->viewData->getTotalResults(); + + // Get action definition and check if action ID is correct. + $action_definition = $this->actionManager->getDefinition($action_id); + $vbo_data['action_label'] = (string) $action_definition['label']; + + $this->timer($options['verbose'], 'init'); + + // Populate entity list. + $context = []; + do { + $context['finished'] = 1; + $context['message'] = ''; + ViewsBulkOperationsBatch::getList($vbo_data, $context); + if (!empty($context['message'])) { + $this->logger->info($context['message']); + } + } while ($context['finished'] < 1); + $vbo_data = $context['results']; + + $this->timer($options['verbose'], 'list'); + + // Execute the selected action. + $context = []; + do { + $context['finished'] = 1; + $context['message'] = ''; + ViewsBulkOperationsBatch::operation($vbo_data, $context); + if (!empty($context['message'])) { + $this->logger->info($context['message']); + } + } while ($context['finished'] < 1); + + // Output a summary message. + $operations = array_count_values($context['results']['operations']); + $details = []; + foreach ($operations as $op => $count) { + $details[] = $op . ' (' . $count . ')'; + } + + // 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')])); + } + + return $this->t('Action processing results: @results.', ['@results' => implode(', ', $details)]); + } + + /** + * 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. + */ + protected function 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); + } + } + } + + /** + * Translates a string using the dt function. + * + * @param string $message + * The message to translate. + * @param array $arguments + * (optional) The translation arguments. + * + * @return string + * The translated message. + */ + protected function t($message, array $arguments = []) { + return dt($message, $arguments); + } + +} diff --git a/web/modules/views_bulk_operations/src/Controller/ViewsBulkOperationsController.php b/web/modules/views_bulk_operations/src/Controller/ViewsBulkOperationsController.php new file mode 100644 index 0000000000000000000000000000000000000000..3908916ddac514770d7282e19dcd5d31b6a44af3 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Controller/ViewsBulkOperationsController.php @@ -0,0 +1,125 @@ +<?php + +namespace Drupal\views_bulk_operations\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\views_bulk_operations\Form\ViewsBulkOperationsFormTrait; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface; +use Drupal\user\PrivateTempStoreFactory; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; +use Drupal\Core\Ajax\AjaxResponse; + +/** + * Defines VBO controller class. + */ +class ViewsBulkOperationsController extends ControllerBase implements ContainerInjectionInterface { + + use ViewsBulkOperationsFormTrait; + + /** + * User private temporary storage factory. + * + * @var \Drupal\user\PrivateTempStoreFactory + */ + protected $tempStoreFactory; + + /** + * Views Bulk Operations action processor. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface + */ + protected $actionProcessor; + + /** + * Constructs a new controller object. + * + * @param \Drupal\user\PrivateTempStoreFactory $tempStoreFactory + * User private temporary storage factory. + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface $actionProcessor + * Views Bulk Operations action processor. + */ + public function __construct( + PrivateTempStoreFactory $tempStoreFactory, + ViewsBulkOperationsActionProcessorInterface $actionProcessor + ) { + $this->tempStoreFactory = $tempStoreFactory; + $this->actionProcessor = $actionProcessor; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('user.private_tempstore'), + $container->get('views_bulk_operations.processor') + ); + } + + /** + * The actual page callback. + * + * @param string $view_id + * The current view ID. + * @param string $display_id + * The display ID of the current view. + */ + public function execute($view_id, $display_id) { + $view_data = $this->getTempstoreData($view_id, $display_id); + if (empty($view_data)) { + throw new NotFoundHttpException(); + } + $this->deleteTempstoreData(); + + $this->actionProcessor->executeProcessing($view_data); + return batch_process($view_data['redirect_url']); + } + + /** + * AJAX callback to update selection (multipage). + * + * @param string $view_id + * The current view ID. + * @param string $display_id + * The display ID of the current view. + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + */ + public function updateSelection($view_id, $display_id, Request $request) { + $view_data = $this->getTempstoreData($view_id, $display_id); + if (empty($view_data)) { + throw new NotFoundHttpException(); + } + + $list = $request->request->get('list'); + + $op = $request->request->get('op', 'add'); + $change = 0; + + if ($op === 'add') { + foreach ($list as $bulkFormKey => $label) { + if (!isset($view_data['list'][$bulkFormKey])) { + $view_data['list'][$bulkFormKey] = $this->getListItem($bulkFormKey, $label); + $change++; + } + } + } + elseif ($op === 'remove') { + foreach ($list as $bulkFormKey => $label) { + if (isset($view_data['list'][$bulkFormKey])) { + unset($view_data['list'][$bulkFormKey]); + $change--; + } + } + } + $this->setTempstoreData($view_data); + + $response = new AjaxResponse(); + $response->setData(['change' => $change]); + return $response; + } + +} diff --git a/web/modules/views_bulk_operations/src/EventSubscriber/ViewsBulkOperationsEventSubscriber.php b/web/modules/views_bulk_operations/src/EventSubscriber/ViewsBulkOperationsEventSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..6ff28922541f9752997e8599fe74b5542bb73310 --- /dev/null +++ b/web/modules/views_bulk_operations/src/EventSubscriber/ViewsBulkOperationsEventSubscriber.php @@ -0,0 +1,61 @@ +<?php + +namespace Drupal\views_bulk_operations\EventSubscriber; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsViewDataInterface; +use Drupal\views_bulk_operations\ViewsBulkOperationsEvent; + +/** + * Defines module event subscriber class. + * + * Allows getting data of core entity views. + */ +class ViewsBulkOperationsEventSubscriber implements EventSubscriberInterface { + + // Subscribe to the VBO event with high priority + // to prepopulate the event data. + const PRIORITY = 999; + + /** + * Object that gets the current view data. + * + * @var \Drupal\views_bulk_operations\ViewsBulkOperationsViewDataInterface + */ + protected $viewData; + + /** + * Object constructor. + * + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsViewDataInterface $viewData + * The VBO View Data provider service. + */ + public function __construct(ViewsBulkOperationsViewDataInterface $viewData) { + $this->viewData = $viewData; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[ViewsBulkOperationsEvent::NAME][] = ['provideViewData', self::PRIORITY]; + return $events; + } + + /** + * Respond to view data request event. + * + * @var \Drupal\views_bulk_operations\ViewsBulkOperationsEvent $event + * The event to respond to. + */ + public function provideViewData(ViewsBulkOperationsEvent $event) { + $view_data = $event->getViewData(); + if (!empty($view_data['table']['entity type'])) { + $event->setEntityTypeIds([$view_data['table']['entity type']]); + $event->setEntityGetter([ + 'callable' => [$this->viewData, 'getEntityDefault'], + ]); + } + } + +} diff --git a/web/modules/views_bulk_operations/src/Form/ConfigureAction.php b/web/modules/views_bulk_operations/src/Form/ConfigureAction.php new file mode 100644 index 0000000000000000000000000000000000000000..ba769346acd742bd637fc66b71b8058c97e467bc --- /dev/null +++ b/web/modules/views_bulk_operations/src/Form/ConfigureAction.php @@ -0,0 +1,176 @@ +<?php + +namespace Drupal\views_bulk_operations\Form; + +use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\user\PrivateTempStoreFactory; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface; + +/** + * Action configuration form. + */ +class ConfigureAction extends FormBase { + + use ViewsBulkOperationsFormTrait; + + /** + * User private temporary storage factory. + * + * @var \Drupal\user\PrivateTempStoreFactory + */ + protected $tempStoreFactory; + + /** + * Views Bulk Operations action manager. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager + */ + protected $actionManager; + + /** + * Views Bulk Operations action processor. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface + */ + protected $actionProcessor; + + /** + * Constructor. + * + * @param \Drupal\user\PrivateTempStoreFactory $tempStoreFactory + * User private temporary storage factory. + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager $actionManager + * Extended action manager object. + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface $actionProcessor + * Views Bulk Operations action processor. + */ + public function __construct( + PrivateTempStoreFactory $tempStoreFactory, + ViewsBulkOperationsActionManager $actionManager, + ViewsBulkOperationsActionProcessorInterface $actionProcessor + ) { + $this->tempStoreFactory = $tempStoreFactory; + $this->actionManager = $actionManager; + $this->actionProcessor = $actionProcessor; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('user.private_tempstore'), + $container->get('plugin.manager.views_bulk_operations_action'), + $container->get('views_bulk_operations.processor') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'views_bulk_operations_configure_action'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $view_id = NULL, $display_id = NULL) { + + $form_data = $this->getFormData($view_id, $display_id); + + // TODO: display an error msg, redirect back. + if (!isset($form_data['action_id'])) { + return; + } + + $form['#title'] = $this->t('Configure "%action" action applied to the selection', ['%action' => $form_data['action_label']]); + + $selection = []; + if (!empty($form_data['entity_labels'])) { + $form['list'] = [ + '#theme' => 'item_list', + '#items' => $form_data['entity_labels'], + ]; + } + else { + $form['list'] = [ + '#type' => 'item', + '#markup' => $this->t('All view results'), + ]; + } + $form['list']['#title'] = $this->t('Selected @count entities:', ['@count' => $form_data['selected_count']]); + + // :D Make sure the submit button is at the bottom of the form + // and is editale from the action buildConfigurationForm method. + $form['actions']['#weight'] = 666; + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Apply'), + '#submit' => [ + [$this, 'submitForm'], + ], + ]; + $this->addCancelButton($form); + + $action = $this->actionManager->createInstance($form_data['action_id']); + + if (method_exists($action, 'setContext')) { + $action->setContext($form_data); + } + + $form_state->set('views_bulk_operations', $form_data); + $form = $action->buildConfigurationForm($form, $form_state); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + $form_data = $form_state->get('views_bulk_operations'); + + $action = $this->actionManager->createInstance($form_data['action_id']); + if (method_exists($action, 'validateConfigurationForm')) { + $action->validateConfigurationForm($form, $form_state); + } + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $form_data = $form_state->get('views_bulk_operations'); + + $action = $this->actionManager->createInstance($form_data['action_id']); + if (method_exists($action, 'submitConfigurationForm')) { + $action->submitConfigurationForm($form, $form_state); + $form_data['configuration'] = $action->getConfiguration(); + } + else { + $form_state->cleanValues(); + $form_data['configuration'] = $form_state->getValues(); + } + + $definition = $this->actionManager->getDefinition($form_data['action_id']); + if (!empty($definition['confirm_form_route_name'])) { + // Update tempStore data. + $this->setTempstoreData($form_data, $form_data['view_id'], $form_data['display_id']); + // Go to the confirm route. + $form_state->setRedirect($definition['confirm_form_route_name'], [ + 'view_id' => $form_data['view_id'], + 'display_id' => $form_data['display_id'], + ]); + } + else { + $this->deleteTempstoreData($form_data['view_id'], $form_data['display_id']); + $this->actionProcessor->executeProcessing($form_data); + $form_state->setRedirectUrl($form_data['redirect_url']); + } + } + +} diff --git a/web/modules/views_bulk_operations/src/Form/ConfirmAction.php b/web/modules/views_bulk_operations/src/Form/ConfirmAction.php new file mode 100644 index 0000000000000000000000000000000000000000..dda696db2a8014d9c3618ce2647ef23839e14dc2 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Form/ConfirmAction.php @@ -0,0 +1,131 @@ +<?php + +namespace Drupal\views_bulk_operations\Form; + +use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\Core\Form\FormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\user\PrivateTempStoreFactory; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface; + +/** + * Default action execution confirmation form. + */ +class ConfirmAction extends FormBase { + + use ViewsBulkOperationsFormTrait; + + /** + * User private temporary storage factory. + * + * @var \Drupal\user\PrivateTempStoreFactory + */ + protected $tempStoreFactory; + + /** + * Views Bulk Operations action manager. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager + */ + protected $actionManager; + + /** + * Views Bulk Operations action processor. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface + */ + protected $actionProcessor; + + /** + * Constructor. + * + * @param \Drupal\user\PrivateTempStoreFactory $tempStoreFactory + * User private temporary storage factory. + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager $actionManager + * Extended action manager object. + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface $actionProcessor + * Views Bulk Operations action processor. + */ + public function __construct( + PrivateTempStoreFactory $tempStoreFactory, + ViewsBulkOperationsActionManager $actionManager, + ViewsBulkOperationsActionProcessorInterface $actionProcessor + ) { + $this->tempStoreFactory = $tempStoreFactory; + $this->actionManager = $actionManager; + $this->actionProcessor = $actionProcessor; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('user.private_tempstore'), + $container->get('plugin.manager.views_bulk_operations_action'), + $container->get('views_bulk_operations.processor') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'views_bulk_operations_confirm_action'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state, $view_id = NULL, $display_id = NULL) { + + $form_data = $this->getFormData($view_id, $display_id); + + // TODO: display an error msg, redirect back. + if (!isset($form_data['action_id'])) { + return; + } + + if (!empty($form_data['entity_labels'])) { + $form['list'] = [ + '#theme' => 'item_list', + '#items' => $form_data['entity_labels'], + ]; + } + + $form['#title'] = $this->formatPlural( + $form_data['selected_count'], + 'Are you sure you wish to perform "%action" action on 1 entity?', + 'Are you sure you wish to perform "%action" action on %count entities?', + [ + '%action' => $form_data['action_label'], + '%count' => $form_data['selected_count'], + ] + ); + + $form['actions']['submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Execute action'), + '#submit' => [ + [$this, 'submitForm'], + ], + ]; + $this->addCancelButton($form); + + $form_state->set('views_bulk_operations', $form_data); + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $form_data = $form_state->get('views_bulk_operations'); + $this->deleteTempstoreData($form_data['view_id'], $form_data['display_id']); + $this->actionProcessor->executeProcessing($form_data); + $form_state->setRedirectUrl($form_data['redirect_url']); + } + +} diff --git a/web/modules/views_bulk_operations/src/Form/ViewsBulkOperationsFormTrait.php b/web/modules/views_bulk_operations/src/Form/ViewsBulkOperationsFormTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..fba98fc6070d4dc5cfb3d0263427932bfabbf278 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Form/ViewsBulkOperationsFormTrait.php @@ -0,0 +1,197 @@ +<?php + +namespace Drupal\views_bulk_operations\Form; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Form\FormStateInterface; + +/** + * Defines common methods for Views Bulk Operations forms. + */ +trait ViewsBulkOperationsFormTrait { + + /** + * The tempstore object associated with the current view. + * + * @var \Drupal\user\PrivateTempStore + */ + protected $viewTempstore; + + /** + * The tempstore name. + * + * @var string + */ + protected $tempStoreName; + + /** + * Helper function to prepare data needed for proper form display. + * + * @param string $view_id + * The current view ID. + * @param string $display_id + * The current view display ID. + * + * @return array + * Array containing data for the form builder. + */ + protected function getFormData($view_id, $display_id) { + + // Get tempstore data. + $form_data = $this->getTempstoreData($view_id, $display_id); + + // Get data needed for selected entities list. + if (!empty($form_data['list'])) { + $form_data['entity_labels'] = []; + $form_data['selected_count'] = 0; + foreach ($form_data['list'] as $item) { + $form_data['selected_count']++; + $form_data['entity_labels'][] = $item[4]; + } + } + elseif ($form_data['total_results']) { + $form_data['selected_count'] = $form_data['total_results']; + } + else { + $form_data['selected_count'] = (string) $this->t('all'); + } + + return $form_data; + } + + /** + * Calculates the bulk form key for an entity. + * + * This generates a key that is used as the checkbox return value when + * submitting the bulk form. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to calculate a bulk form key for. + * @param mixed $base_field_value + * The value of the base field for this view result. + * + * @return string + * The bulk form key representing the entity id, language and revision (if + * applicable) as one string. + * + * @see self::loadEntityFromBulkFormKey() + */ + public static function calculateEntityBulkFormKey(EntityInterface $entity, $base_field_value) { + // 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. + $key_parts = [ + $base_field_value, + $entity->language()->getId(), + $entity->getEntityTypeId(), + $entity->id(), + ]; + + // An entity ID could be an arbitrary string (although they are typically + // numeric). JSON then Base64 encoding ensures the bulk_form_key is + // safe to use in HTML, and that the key parts can be retrieved. + $key = json_encode($key_parts); + return base64_encode($key); + } + + /** + * Get an entity list item from a bulk form key and label. + * + * @param string $bulkFormKey + * A bulk form key. + * @param mixed $label + * Entity label, string or + * \Drupal\Core\StringTranslation\TranslatableMarkup. + * + * @return array + * Entity list item. + */ + protected function getListItem($bulkFormKey, $label) { + $item = json_decode(base64_decode($bulkFormKey)); + $item[] = $label; + return $item; + } + + /** + * Initialize the current view tempstore object. + */ + protected function getTempstore($view_id = NULL, $display_id = NULL) { + if (!isset($this->viewTempstore)) { + $this->tempStoreName = 'views_bulk_operations_' . $view_id . '_' . $display_id; + $this->viewTempstore = $this->tempStoreFactory->get($this->tempStoreName); + } + return $this->viewTempstore; + } + + /** + * Gets the current view user tempstore data. + * + * @param string $view_id + * The current view ID. + * @param string $display_id + * The display ID of the current view. + */ + protected function getTempstoreData($view_id = NULL, $display_id = NULL) { + $data = $this->getTempstore($view_id, $display_id)->get($this->currentUser()->id()); + + return $data; + } + + /** + * Sets the current view user tempstore data. + * + * @param array $data + * The data to set. + * @param string $view_id + * The current view ID. + * @param string $display_id + * The display ID of the current view. + */ + protected function setTempstoreData(array $data, $view_id = NULL, $display_id = NULL) { + return $this->getTempstore($view_id, $display_id)->set($this->currentUser()->id(), $data); + } + + /** + * Deletes the current view user tempstore data. + * + * @param string $view_id + * The current view ID. + * @param string $display_id + * The display ID of the current view. + */ + protected function deleteTempstoreData($view_id = NULL, $display_id = NULL) { + return $this->getTempstore($view_id, $display_id)->delete($this->currentUser()->id()); + } + + /** + * Add a cancel button into a VBO form. + * + * @param array $form + * The form definition. + */ + protected function addCancelButton(array &$form) { + $form['actions']['cancel'] = [ + '#type' => 'submit', + '#value' => $this->t('Cancel'), + '#submit' => [ + [$this, 'cancelForm'], + ], + '#limit_validation_errors' => [], + ]; + } + + /** + * Submit callback to cancel an action and return to the view. + * + * @param array $form + * The form definition. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + */ + public function cancelForm(array &$form, FormStateInterface $form_state) { + $form_data = $form_state->get('views_bulk_operations'); + drupal_set_message($this->t('Canceled "%action".', ['%action' => $form_data['action_label']])); + $form_state->setRedirectUrl($form_data['redirect_url']); + } + +} diff --git a/web/modules/views_bulk_operations/src/Plugin/Action/CancelUserAction.php b/web/modules/views_bulk_operations/src/Plugin/Action/CancelUserAction.php new file mode 100644 index 0000000000000000000000000000000000000000..1e73ffc30bb6bac28c97efc109ec802f341e039d --- /dev/null +++ b/web/modules/views_bulk_operations/src/Plugin/Action/CancelUserAction.php @@ -0,0 +1,160 @@ +<?php + +namespace Drupal\views_bulk_operations\Plugin\Action; + +use Drupal\views_bulk_operations\Action\ViewsBulkOperationsActionBase; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Plugin\PluginFormInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; + +/** + * Cancels a user account. + * + * @Action( + * id = "vbo_cancel_user_action", + * label = @Translation("Cancel the selected user accounts"), + * type = "user", + * ) + */ +class CancelUserAction extends ViewsBulkOperationsActionBase implements ContainerFactoryPluginInterface, PluginFormInterface { + + /** + * The current user. + * + * @var Drupal\Core\Session\AccountInterface + */ + protected $currentUser; + + /** + * User module config. + * + * @var \Drupal\Core\Config\ImmutableConfig + */ + protected $userConfig; + + /** + * Module handler service. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** + * Object constructor. + * + * @param array $configuration + * Plugin configuration. + * @param string $plugin_id + * The plugin Id. + * @param mixed $plugin_definition + * Plugin definition. + * @param Drupal\Core\Session\AccountInterface $currentUser + * The current user. + * @param \Drupal\Core\Config\ConfigFactoryInterface $configFactory + * The config factory object. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * Module handler service. + */ + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + AccountInterface $currentUser, + ConfigFactoryInterface $configFactory, + ModuleHandlerInterface $moduleHandler + ) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->currentUser = $currentUser; + $this->userConfig = $configFactory->get('user.settings'); + $this->moduleHandler = $moduleHandler; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('current_user'), + $container->get('config.factory'), + $container->get('module_handler') + ); + } + + /** + * {@inheritdoc} + */ + public function execute($account = NULL) { + if ($account->id() === $this->currentUser->id() && (empty($this->context['list']) || count($this->context['list'] > 1))) { + drupal_set_message($this->t('The current user account cannot be canceled in a batch operation. Select your account only or cancel it from your account page.'), 'error'); + } + elseif (intval($account->id()) === 1) { + drupal_set_message($this->t('The user 1 account (%label) cannot be canceled.', [ + '%label' => $account->label(), + ]), 'error'); + } + else { + // Allow other modules to act. + if ($this->configuration['user_cancel_method'] != 'user_cancel_delete') { + $this->moduleHandler->invokeAll('user_cancel', [ + $this->configuration, + $account, + $this->configuration['user_cancel_method'], + ]); + } + + // Cancel the account. + _user_cancel($this->configuration, $account, $this->configuration['user_cancel_method']); + + // If current user was cancelled, logout. + if ($account->id() == $this->currentUser->id()) { + _user_cancel_session_regenerate(); + } + } + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form['user_cancel_method'] = [ + '#type' => 'radios', + '#title' => $this->t('When cancelling these accounts'), + ]; + + $form['user_cancel_method'] += user_cancel_methods(); + + // Allow to send the account cancellation confirmation mail. + $form['user_cancel_confirm'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Require email confirmation to cancel account'), + '#default_value' => FALSE, + '#description' => $this->t('When enabled, the user must confirm the account cancellation via email.'), + ]; + // Also allow to send account canceled notification mail, if enabled. + $form['user_cancel_notify'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Notify user when account is canceled'), + '#default_value' => FALSE, + '#access' => $this->userConfig->get('notify.status_canceled'), + '#description' => $this->t('When enabled, the user will receive an email notification after the account has been canceled.'), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + /** @var \Drupal\user\UserInterface $object */ + return $object->access('delete', $account, $return_as_object); + } + +} diff --git a/web/modules/views_bulk_operations/src/Plugin/Action/EntityDeleteAction.php b/web/modules/views_bulk_operations/src/Plugin/Action/EntityDeleteAction.php new file mode 100644 index 0000000000000000000000000000000000000000..9243b259c3419df15e67a649d1e4626545d8fa8a --- /dev/null +++ b/web/modules/views_bulk_operations/src/Plugin/Action/EntityDeleteAction.php @@ -0,0 +1,39 @@ +<?php + +namespace Drupal\views_bulk_operations\Plugin\Action; + +use Drupal\views_bulk_operations\Action\ViewsBulkOperationsActionBase; +use Drupal\Core\Session\AccountInterface; + +/** + * Delete entity action with default confirmation form. + * + * @Action( + * id = "views_bulk_operations_delete_entity", + * label = @Translation("Delete selected entities"), + * type = "", + * confirm = TRUE, + * ) + */ +class EntityDeleteAction extends ViewsBulkOperationsActionBase { + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + $entity->delete(); + return $this->t('Delete entities'); + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + $access = $object->access('delete', $account, TRUE); + if ($object->getEntityType() === 'node') { + $access->andIf($object->status->access('delete', $account, TRUE)); + } + return $return_as_object ? $access : $access->isAllowed(); + } + +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..66d893f3c6ee7b28746171f9886fdc16501167b5 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Plugin/views/field/ViewsBulkOperationsBulkForm.php @@ -0,0 +1,894 @@ +<?php + +namespace Drupal\views_bulk_operations\Plugin\views\field; + +use Drupal\Core\Cache\CacheableDependencyInterface; +use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Routing\RedirectDestinationTrait; +use Drupal\views\Plugin\views\display\DisplayPluginBase; +use Drupal\views\Plugin\views\field\FieldPluginBase; +use Drupal\views\Plugin\views\field\UncacheableFieldHandlerTrait; +use Drupal\views\Plugin\views\style\Table; +use Drupal\views\ResultRow; +use Drupal\views\ViewExecutable; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Drupal\views_bulk_operations\Service\ViewsbulkOperationsViewDataInterface; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager; +use Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface; +use Drupal\views_bulk_operations\Form\ViewsBulkOperationsFormTrait; +use Drupal\user\PrivateTempStoreFactory; +use Drupal\Core\Session\AccountInterface; +use Symfony\Component\HttpFoundation\RequestStack; +use Drupal\Core\Url; + +/** + * Defines the Views Bulk Operations field plugin. + * + * @ingroup views_field_handlers + * + * @ViewsField("views_bulk_operations_bulk_form") + */ +class ViewsBulkOperationsBulkForm extends FieldPluginBase implements CacheableDependencyInterface, ContainerFactoryPluginInterface { + + use RedirectDestinationTrait; + use UncacheableFieldHandlerTrait; + use ViewsBulkOperationsFormTrait; + + /** + * Object that gets the current view data. + * + * @var \Drupal\views_bulk_operations\ViewsbulkOperationsViewDataInterface + */ + protected $viewData; + + /** + * Views Bulk Operations action manager. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager + */ + protected $actionManager; + + /** + * Views Bulk Operations action processor. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface + */ + protected $actionProcessor; + + /** + * User private temporary storage factory. + * + * @var \Drupal\user\PrivateTempStoreFactory + */ + protected $tempStoreFactory; + + /** + * The current user object. + * + * @var \Drupal\Core\Session\AccountInterface + */ + protected $currentUser; + + /** + * The request stack. + * + * @var \Symfony\Component\HttpFoundation\RequestStack + */ + protected $requestStack; + + /** + * An array of actions that can be executed. + * + * @var array + */ + protected $actions = []; + + /** + * An array of bulk form options. + * + * @var array + */ + protected $bulkOptions; + + /** + * Tempstore data. + * + * This gets passed to the next requests if needed + * or used in the views form submit handler directly. + * + * @var array + */ + protected $tempStoreData = []; + + /** + * Constructs a new BulkForm object. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin ID for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\views_bulk_operations\Service\ViewsbulkOperationsViewDataInterface $viewData + * The VBO View Data provider service. + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager $actionManager + * Extended action manager object. + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessorInterface $actionProcessor + * Views Bulk Operations action processor. + * @param \Drupal\user\PrivateTempStoreFactory $tempStoreFactory + * User private temporary storage factory. + * @param \Drupal\Core\Session\AccountInterface $currentUser + * The current user object. + * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack + * The request stack. + */ + public function __construct( + array $configuration, + $plugin_id, + $plugin_definition, + ViewsbulkOperationsViewDataInterface $viewData, + ViewsBulkOperationsActionManager $actionManager, + ViewsBulkOperationsActionProcessorInterface $actionProcessor, + PrivateTempStoreFactory $tempStoreFactory, + AccountInterface $currentUser, + RequestStack $requestStack + ) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + + $this->viewData = $viewData; + $this->actionManager = $actionManager; + $this->actionProcessor = $actionProcessor; + $this->tempStoreFactory = $tempStoreFactory; + $this->currentUser = $currentUser; + $this->requestStack = $requestStack; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('views_bulk_operations.data'), + $container->get('plugin.manager.views_bulk_operations_action'), + $container->get('views_bulk_operations.processor'), + $container->get('user.private_tempstore'), + $container->get('current_user'), + $container->get('request_stack') + ); + } + + /** + * {@inheritdoc} + */ + public function init(ViewExecutable $view, DisplayPluginBase $display, array &$options = NULL) { + parent::init($view, $display, $options); + + // Don't initialize if view has been built from VBO action processor. + if (!empty($this->view->views_bulk_operations_processor_built)) { + return; + } + + // Set this property to always have the total rows information. + $this->view->get_total_rows = TRUE; + + // Initialize VBO View Data object. + $this->viewData->init($view, $display, $this->options['relationship']); + + // Fetch actions. + $this->actions = []; + $entity_types = $this->viewData->getEntityTypeIds(); + + // Get actions only if there are any entity types set for the view. + if (!empty($entity_types)) { + foreach ($this->actionManager->getDefinitions() as $id => $definition) { + if (empty($definition['type']) || in_array($definition['type'], $entity_types, TRUE)) { + $this->actions[$id] = $definition; + } + } + } + + // Force form_step setting to TRUE due to #2879310. + $this->options['form_step'] = TRUE; + } + + /** + * Update tempstore data. + * + * This function must be called a bit later, when the view + * query has been built. Also, no point doing this on the view + * admin page. + */ + protected function updateTempstoreData() { + // Initialize tempstore object and get data if available. + $this->tempStoreData = $this->getTempstoreData($this->view->id(), $this->view->current_display); + + // Parameters subject to change (either by an admin or user action). + $variable = [ + 'batch' => $this->options['batch'], + 'batch_size' => $this->options['batch'] ? $this->options['batch_size'] : 0, + 'total_results' => $this->viewData->getTotalResults(), + 'arguments' => $this->view->args, + 'redirect_url' => Url::createFromRequest(clone $this->requestStack->getCurrentRequest()), + 'exposed_input' => $this->view->getExposedInput(), + ]; + + // Create tempstore data object if it doesn't exist. + if (!is_array($this->tempStoreData)) { + $this->tempStoreData = []; + + // Add constant parameters. + $this->tempStoreData += [ + 'view_id' => $this->view->id(), + 'display_id' => $this->view->current_display, + 'list' => [], + ]; + + // Add variable parameters. + $this->tempStoreData += $variable; + + $this->setTempstoreData($this->tempStoreData); + } + + // Update some of the tempstore data parameters if required. + else { + $update = FALSE; + + // Delete list if view arguments and optionally exposed filters changed. + // NOTE: this should be subject to a discussion, maybe tempstore + // should be arguments - specific? + $clear_triggers = ['arguments']; + if ($this->options['clear_on_exposed']) { + $clear_triggers[] = 'exposed_input'; + } + + foreach ($clear_triggers as $trigger) { + if ($variable[$trigger] !== $this->tempStoreData[$trigger]) { + $this->tempStoreData[$trigger] = $variable[$trigger]; + $this->tempStoreData['list'] = []; + } + unset($variable[$trigger]); + $update = TRUE; + } + + foreach ($variable as $param => $value) { + if (!isset($this->tempStoreData[$param]) || $this->tempStoreData[$param] != $value) { + $update = TRUE; + $this->tempStoreData[$param] = $value; + } + } + + if ($update) { + $this->setTempstoreData($this->tempStoreData); + } + } + + } + + /** + * Gets the current user. + * + * @return \Drupal\Core\Session\AccountInterface + * The current user. + */ + protected function currentUser() { + return $this->currentUser; + } + + /** + * {@inheritdoc} + */ + public function getCacheMaxAge() { + // @todo Consider making the bulk operation form cacheable. See + // https://www.drupal.org/node/2503009. + return 0; + } + + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + return []; + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return []; + } + + /** + * {@inheritdoc} + */ + public function getEntity(ResultRow $row) { + return $this->viewData->getEntity($row); + } + + /** + * {@inheritdoc} + */ + public function query() { + } + + /** + * {@inheritdoc} + */ + protected function defineOptions() { + $options = parent::defineOptions(); + $options['batch'] = ['default' => TRUE]; + $options['batch_size'] = ['default' => 10]; + $options['form_step'] = ['default' => TRUE]; + $options['buttons'] = ['default' => FALSE]; + $options['clear_on_exposed'] = ['default' => FALSE]; + $options['action_title'] = ['default' => $this->t('Action')]; + $options['selected_actions'] = ['default' => []]; + $options['preconfiguration'] = ['default' => []]; + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + // If the view type is not supported, suppress form display. + // Also display information note to the user. + if (empty($this->actions)) { + $form = [ + '#type' => 'item', + '#title' => $this->t('NOTE'), + '#markup' => $this->t('Views Bulk Operations will work only with normal entity views and contrib module views that are integrated. See \Drupal\views_bulk_operations\EventSubscriber\ViewsBulkOperationsEventSubscriber class for integration best practice.'), + '#prefix' => '<div class="scroll">', + '#suffix' => '</div>', + ]; + return; + } + + $form['#attributes']['class'][] = 'views-bulk-operations-ui'; + $form['#attached']['library'][] = 'views_bulk_operations/adminUi'; + + $form['batch'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Process in a batch operation'), + '#default_value' => $this->options['batch'], + ]; + + $form['batch_size'] = [ + '#title' => $this->t('Batch size'), + '#type' => 'number', + '#min' => 1, + '#step' => 1, + '#description' => $this->t('Only applicable if results are processed in a batch operation.'), + '#default_value' => $this->options['batch_size'], + ]; + + $form['form_step'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Configuration form on new page (configurable actions)'), + '#default_value' => $this->options['form_step'], + // Due to #2879310 this setting must always be at TRUE. + '#access' => FALSE, + ]; + + $form['buttons'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Display selectable actions as buttons.'), + '#default_value' => $this->options['buttons'], + ]; + + $form['clear_on_exposed'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Clear selection when exposed filters change.'), + '#default_value' => $this->options['clear_on_exposed'], + ]; + + $form['action_title'] = [ + '#type' => 'textfield', + '#title' => $this->t('Action title'), + '#default_value' => $this->options['action_title'], + '#description' => $this->t('The title shown above the actions dropdown.'), + ]; + + $form['selected_actions'] = [ + '#tree' => TRUE, + '#type' => 'details', + '#open' => TRUE, + '#title' => $this->t('Selected actions'), + '#attributes' => ['class' => ['vbo-actions-widget']], + ]; + + // Load values for display. + $form_values = $form_state->getValue(['options', 'selected_actions']); + if (is_null($form_values)) { + $selected_actions = $this->options['selected_actions']; + $preconfiguration = $this->options['preconfiguration']; + } + else { + $selected_actions = []; + $preconfiguration = []; + foreach ($form_values as $id => $value) { + $selected_actions[$id] = $value['state'] ? $id : 0; + $preconfiguration[$id] = isset($value['preconfiguration']) ? $value['preconfiguration'] : []; + } + } + + foreach ($this->actions as $id => $action) { + $form['selected_actions'][$id]['state'] = [ + '#type' => 'checkbox', + '#title' => $action['label'], + '#default_value' => empty($selected_actions[$id]) ? 0 : 1, + '#attributes' => ['class' => ['vbo-action-state']], + ]; + + // There are problems with AJAX on this form when adding + // new elements (Views issue), a workaround is to render + // all elements and show/hide them when needed. + $form['selected_actions'][$id]['preconfiguration'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Preconfiguration for "@action"', [ + '@action' => $action['label'], + ]), + '#attributes' => [ + 'data-for' => $id, + 'style' => empty($selected_actions[$id]) ? 'display: none' : NULL, + ], + ]; + + // Default label_override element. + $form['selected_actions'][$id]['preconfiguration']['label_override'] = [ + '#type' => 'textfield', + '#title' => $this->t('Override label'), + '#description' => $this->t('Leave empty for the default label.'), + '#default_value' => isset($preconfiguration[$id]['label_override']) ? $preconfiguration[$id]['label_override'] : '', + ]; + + // Load preconfiguration form if available. + if (method_exists($action['class'], 'buildPreConfigurationForm')) { + if (!isset($preconfiguration[$id])) { + $preconfiguration[$id] = []; + } + $actionObject = $this->actionManager->createInstance($id); + $form['selected_actions'][$id]['preconfiguration'] = $actionObject->buildPreConfigurationForm($form['selected_actions'][$id]['preconfiguration'], $preconfiguration[$id], $form_state); + } + } + + parent::buildOptionsForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitOptionsForm(&$form, FormStateInterface $form_state) { + $options = &$form_state->getValue('options'); + foreach ($options['selected_actions'] as $id => $action) { + if (!empty($action['state'])) { + if (isset($action['preconfiguration'])) { + $options['preconfiguration'][$id] = $action['preconfiguration']; + unset($options['selected_actions'][$id]['preconfiguration']); + } + $options['selected_actions'][$id] = $id; + } + else { + unset($options['preconfiguration'][$id]); + $options['selected_actions'][$id] = 0; + } + } + parent::submitOptionsForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function preRender(&$values) { + parent::preRender($values); + + // Add empty classes if there are no actions available. + if (empty($this->getBulkOptions())) { + $this->options['element_label_class'] .= 'empty'; + $this->options['element_class'] .= 'empty'; + $this->options['element_wrapper_class'] .= 'empty'; + $this->options['label'] = ''; + } + // If the view is using a table style, provide a placeholder for a + // "select all" checkbox. + elseif (!empty($this->view->style_plugin) && $this->view->style_plugin instanceof Table) { + // Add the tableselect css classes. + $this->options['element_label_class'] .= 'select-all'; + // Hide the actual label of the field on the table header. + $this->options['label'] = ''; + } + + } + + /** + * {@inheritdoc} + */ + public function getValue(ResultRow $row, $field = NULL) { + return '<!--form-item-' . $this->options['id'] . '--' . $row->index . '-->'; + } + + /** + * Form constructor for the bulk form. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function viewsForm(array &$form, FormStateInterface $form_state) { + // Make sure we do not accidentally cache this form. + // @todo Evaluate this again in https://www.drupal.org/node/2503009. + $form['#cache']['max-age'] = 0; + + // Add VBO class to the form. + $form['#attributes']['class'][] = 'vbo-view-form'; + + // Add VBO front UI and tableselect libraries for table display style. + if ($this->view->style_plugin instanceof Table) { + $form['#attached']['library'][] = 'core/drupal.tableselect'; + $this->view->style_plugin->options['views_bulk_operations_enabled'] = TRUE; + } + $form['#attached']['library'][] = 'views_bulk_operations/frontUi'; + // Only add the bulk form options and buttons if + // there are results and any actions are available. + $action_options = $this->getBulkOptions(); + if (!empty($this->view->result) && !empty($action_options)) { + + // Update tempstore data. + $this->updateTempstoreData(); + + $form[$this->options['id']]['#tree'] = TRUE; + + // Get pager data if available. + if (!empty($this->view->pager) && method_exists($this->view->pager, 'hasMoreRecords')) { + $pagerData = [ + 'current' => $this->view->pager->getCurrentPage(), + 'more' => $this->view->pager->hasMoreRecords(), + ]; + } + + // Render checkboxes for all rows. + $page_selected = []; + $base_field = $this->view->storage->get('base_field'); + foreach ($this->view->result as $row_index => $row) { + $entity = $this->getEntity($row); + $bulk_form_key = self::calculateEntityBulkFormKey( + $entity, + $row->{$base_field} + ); + + $checked = isset($this->tempStoreData['list'][$bulk_form_key]); + if ($checked) { + $page_selected[] = $bulk_form_key; + } + $form[$this->options['id']][$row_index] = [ + '#type' => 'checkbox', + '#title' => $entity->label(), + '#title_display' => 'invisible', + '#default_value' => $checked, + '#return_value' => $bulk_form_key, + ]; + } + + // Ensure a consistent container for filters/operations + // in the view header. + $form['header'] = [ + '#type' => 'container', + '#weight' => -100, + ]; + + // Build the bulk operations action widget for the header. + // Allow themes to apply .container-inline on this separate container. + $form['header'][$this->options['id']] = [ + '#type' => 'container', + '#attributes' => [ + 'id' => 'vbo-action-form-wrapper', + ], + ]; + + // Display actions buttons or selector. + if ($this->options['buttons']) { + unset($form['actions']['submit']); + foreach ($action_options as $id => $label) { + $form['actions'][$id] = [ + '#type' => 'submit', + '#value' => $label, + ]; + } + } + else { + // Replace the form submit button label. + $form['actions']['submit']['#value'] = $this->t('Apply to selected items'); + + $form['header'][$this->options['id']]['action'] = [ + '#type' => 'select', + '#title' => $this->options['action_title'], + '#options' => ['' => $this->t('-- Select action --')] + $action_options, + ]; + } + + // Add AJAX functionality if actions are configurable through this form. + if (empty($this->options['form_step'])) { + $form['header'][$this->options['id']]['action']['#ajax'] = [ + 'callback' => [__CLASS__, 'viewsFormAjax'], + 'wrapper' => 'vbo-action-configuration-wrapper', + ]; + $form['header'][$this->options['id']]['configuration'] = [ + '#type' => 'container', + '#attributes' => ['id' => 'vbo-action-configuration-wrapper'], + ]; + + $action_id = $form_state->getValue('action'); + if (!empty($action_id)) { + $action = $this->actions[$action_id]; + if ($this->isConfigurable($action)) { + $actionObject = $this->actionManager->createInstance($action_id); + $form['header'][$this->options['id']]['configuration'] += $actionObject->buildConfigurationForm($form['header'][$this->options['id']]['configuration'], $form_state); + $form['header'][$this->options['id']]['configuration']['#config_included'] = TRUE; + } + } + } + + $display_select_all = isset($pagerData) && ($pagerData['more'] || $pagerData['current'] > 0); + // Selection info: displayed if exposed filters are set and selection + // is not cleared when they change or "select all" element display + // conditions are met. + if ((!$this->options['clear_on_exposed'] && !empty($this->view->getExposedInput())) || $display_select_all) { + + $form['header'][$this->options['id']]['multipage'] = [ + '#type' => 'details', + '#open' => FALSE, + '#title' => $this->t('Selected %count items in this view', [ + '%count' => count($this->tempStoreData['list']), + ]), + '#attributes' => [ + // Add view_id and display_id to be available for + // js multipage selector functionality. + 'data-view-id' => $this->tempStoreData['view_id'], + 'data-display-id' => $this->tempStoreData['display_id'], + 'class' => ['vbo-multipage-selector'], + 'name' => 'somename', + ], + ]; + + // Display a list of items selected on other pages. + $form['header'][$this->options['id']]['multipage']['list'] = [ + '#theme' => 'item_list', + '#title' => $this->t('Items selected on other pages:'), + '#items' => [], + '#empty' => $this->t('No selection'), + ]; + if (count($this->tempStoreData['list']) > count($page_selected)) { + foreach ($this->tempStoreData['list'] as $bulk_form_key => $item) { + if (!in_array($bulk_form_key, $page_selected)) { + $form['header'][$this->options['id']]['multipage']['list']['#items'][] = $item[4]; + } + } + $form['header'][$this->options['id']]['multipage']['clear'] = [ + '#type' => 'submit', + '#value' => $this->t('Clear'), + '#submit' => [[$this, 'clearSelection']], + '#limit_validation_errors' => [], + ]; + } + } + + // Select all results checkbox. + if ($display_select_all) { + $form['header'][$this->options['id']]['select_all'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Select all@count results in this view', [ + '@count' => $this->tempStoreData['total_results'] ? ' ' . $this->tempStoreData['total_results'] : '', + ]), + '#attributes' => ['class' => ['vbo-select-all']], + ]; + } + + // Duplicate the form actions into the action container in the header. + $form['header'][$this->options['id']]['actions'] = $form['actions']; + } + else { + // Remove the default actions build array. + unset($form['actions']); + } + + } + + /** + * AJAX callback for the views form. + * + * Currently not used due to #2879310. + */ + public static function viewsFormAjax(array $form, FormStateInterface $form_state) { + $trigger = $form_state->getTriggeringElement(); + $plugin_id = $trigger['#array_parents'][1]; + return $form['header'][$plugin_id]['configuration']; + } + + /** + * Returns the available operations for this form. + * + * @return array + * An associative array of operations, suitable for a select element. + */ + protected function getBulkOptions() { + if (!isset($this->bulkOptions)) { + $this->bulkOptions = []; + foreach ($this->actions as $id => $definition) { + // Filter out actions that weren't selected. + if (!in_array($id, $this->options['selected_actions'], TRUE)) { + continue; + } + + // Check access permission, if defined. + if (!empty($definition['requirements']['_permission']) && !$this->currentUser->hasPermission($definition['requirements']['_permission'])) { + continue; + } + + // Override label if applicable. + if (!empty($this->options['preconfiguration'][$id]['label_override'])) { + $this->bulkOptions[$id] = $this->options['preconfiguration'][$id]['label_override']; + } + else { + $this->bulkOptions[$id] = $definition['label']; + } + } + } + + return $this->bulkOptions; + } + + /** + * Submit handler for the bulk form. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function viewsFormSubmit(array &$form, FormStateInterface $form_state) { + if ($form_state->get('step') == 'views_form_views_form') { + + $action_id = $form_state->getValue('action'); + + $action = $this->actions[$action_id]; + + $this->tempStoreData['action_id'] = $action_id; + $this->tempStoreData['action_label'] = empty($this->options['preconfiguration'][$action_id]['label_override']) ? (string) $action['label'] : $this->options['preconfiguration'][$action_id]['label_override']; + $this->tempStoreData['relationship_id'] = $this->options['relationship']; + $this->tempStoreData['preconfiguration'] = isset($this->options['preconfiguration'][$action_id]) ? $this->options['preconfiguration'][$action_id] : []; + + if (!$form_state->getValue('select_all')) { + + // Update list data with the current form selection. + foreach ($form_state->getValue($this->options['id']) as $row_index => $bulkFormKey) { + if ($bulkFormKey) { + $this->tempStoreData['list'][$bulkFormKey] = $this->getListItem($bulkFormKey, $form[$this->options['id']][$row_index]['#title']); + } + else { + unset($this->tempStoreData['list'][$form[$this->options['id']][$row_index]['#return_value']]); + } + } + } + else { + // Unset the list completely. + $this->tempStoreData['list'] = []; + } + + $configurable = $this->isConfigurable($action); + + // Get configuration if using AJAX. + if ($configurable && empty($this->options['form_step'])) { + $actionObject = $this->actionManager->createInstance($action_id); + if (method_exists($actionObject, 'submitConfigurationForm')) { + $actionObject->submitConfigurationForm($form, $form_state); + $this->tempStoreData['configuration'] = $actionObject->getConfiguration(); + } + else { + $form_state->cleanValues(); + $this->tempStoreData['configuration'] = $form_state->getValues(); + } + } + + // Routing - determine redirect route. + if ($this->options['form_step'] && $configurable) { + $redirect_route = 'views_bulk_operations.execute_configurable'; + } + elseif ($this->options['batch']) { + if (!empty($action['confirm_form_route_name'])) { + $redirect_route = $action['confirm_form_route_name']; + } + } + elseif (!empty($action['confirm_form_route_name'])) { + $redirect_route = $action['confirm_form_route_name']; + } + + // Redirect if needed. + if (!empty($redirect_route)) { + $this->setTempstoreData($this->tempStoreData); + + $form_state->setRedirect($redirect_route, [ + 'view_id' => $this->view->id(), + 'display_id' => $this->view->current_display, + ]); + } + // Or process rows here and now. + else { + $this->deleteTempstoreData(); + $this->actionProcessor->executeProcessing($this->tempStoreData, $this->view); + $form_state->setRedirectUrl($this->tempStoreData['redirect_url']); + } + } + } + + /** + * Clear the form selection along with entire tempstore. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function clearSelection(array &$form, FormStateInterface $form_state) { + $this->deleteTempstoreData(); + } + + /** + * {@inheritdoc} + */ + public function viewsFormValidate(&$form, FormStateInterface $form_state) { + if ($this->options['buttons']) { + $trigger = $form_state->getTriggeringElement(); + $action_id = end($trigger['#parents']); + $form_state->setValue('action', $action_id); + } + + if (empty($form_state->getValue('action'))) { + $form_state->setErrorByName('action', $this->t('Please select an action to perform.')); + } + + // This happened once, can't reproduce but here's a safety switch. + if (!isset($this->actions[$form_state->getValue('action')])) { + $form_state->setErrorByName('action', $this->t('Form error occurred, please try again.')); + } + + if (!$form_state->getValue('select_all')) { + // Update tempstore data to make sure we have also + // results selected in other requests and validate if + // anything is selected. + $this->tempStoreData = $this->getTempstoreData(); + $selected = array_filter($form_state->getValue($this->options['id'])); + if (empty($this->tempStoreData['list']) && empty($selected)) { + $form_state->setErrorByName('', $this->t('No items selected.')); + } + } + + // Action config validation (if implemented). + if (empty($this->options['form_step']) && !empty($form['header'][$this->options['id']]['configuration']['#config_included'])) { + $action_id = $form_state->getValue('action'); + $action = $this->actions[$action_id]; + if (method_exists($action['class'], 'validateConfigurationForm')) { + $actionObject = $this->actionManager->createInstance($action_id); + $actionObject->validateConfigurationForm($form['header'][$this->options['id']]['configuration'], $form_state); + } + } + } + + /** + * {@inheritdoc} + */ + public function clickSortable() { + return FALSE; + } + + /** + * Check if an action is configurable. + */ + protected function isConfigurable($action) { + return (in_array('Drupal\Core\Plugin\PluginFormInterface', class_implements($action['class']), TRUE) || method_exists($action['class'], 'buildConfigurationForm')); + } + +} diff --git a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionManager.php b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionManager.php new file mode 100644 index 0000000000000000000000000000000000000000..39fa43e004a26be7d18546c579df3667d9b9c646 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionManager.php @@ -0,0 +1,183 @@ +<?php + +namespace Drupal\views_bulk_operations\Service; + +use Drupal\Core\Action\ActionManager; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Symfony\Component\EventDispatcher\Event; +use Drupal\Component\Plugin\Exception\PluginNotFoundException; + +/** + * Defines Views Bulk Operations action manager. + * + * Extends the core Action Manager to allow VBO actions + * define additional configuration. + */ +class ViewsBulkOperationsActionManager extends ActionManager { + + const ALTER_ACTIONS_EVENT = 'views_bulk_operations.action_definitions'; + + /** + * Event dispatcher service. + * + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + protected $eventDispatcher; + + /** + * Additional parameters passed to alter event. + * + * @var array + */ + protected $alterParameters; + + /** + * Service constructor. + * + * @param \Traversable $namespaces + * An object that implements \Traversable which contains the root paths + * keyed by the corresponding namespace to look for plugin implementations. + * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend + * Cache backend instance to use. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * The module handler to invoke the alter hook with. + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher + * The event dispatcher service. + */ + public function __construct( + \Traversable $namespaces, + CacheBackendInterface $cacheBackend, + ModuleHandlerInterface $moduleHandler, + EventDispatcherInterface $eventDispatcher + ) { + parent::__construct($namespaces, $cacheBackend, $moduleHandler); + $this->eventDispatcher = $eventDispatcher; + $this->setCacheBackend($cacheBackend, 'views_bulk_operations_action_info'); + } + + /** + * {@inheritdoc} + */ + protected function findDefinitions() { + $definitions = $this->getDiscovery()->getDefinitions(); + + // Incompatible actions. + $incompatible = [ + 'node_delete_action', + 'user_cancel_user_action', + ]; + + foreach ($definitions as $plugin_id => &$definition) { + $this->processDefinition($definition, $plugin_id); + if (empty($definition) || in_array($definition['id'], $incompatible)) { + unset($definitions[$plugin_id]); + } + } + $this->alterDefinitions($definitions); + foreach ($definitions as $plugin_id => $plugin_definition) { + // If the plugin definition is an object, attempt to convert it to an + // array, if that is not possible, skip further processing. + if (is_object($plugin_definition) && !($plugin_definition = (array) $plugin_definition)) { + continue; + } + // 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'])) { + unset($definitions[$plugin_id]); + } + } + return $definitions; + } + + /** + * {@inheritdoc} + * + * @param array $parameters + * Parameters of the method. Passed to alter event. + */ + public function getDefinitions(array $parameters = []) { + if (empty($parameters['nocache'])) { + $definitions = $this->getCachedDefinitions(); + } + if (!isset($definitions)) { + $this->alterParameters = $parameters; + $definitions = $this->findDefinitions($parameters); + + $this->setCachedDefinitions($definitions); + } + + return $definitions; + } + + /** + * Gets a specific plugin definition. + * + * @param string $plugin_id + * A plugin id. + * @param bool $exception_on_invalid + * (optional) If TRUE, an invalid plugin ID will throw an exception. + * @param array $parameters + * Parameters of the method. Passed to alter event. + * + * @return mixed + * A plugin definition, or NULL if the plugin ID is invalid and + * $exception_on_invalid is FALSE. + * + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * Thrown if $plugin_id is invalid and $exception_on_invalid is TRUE. + */ + public function getDefinition($plugin_id, $exception_on_invalid = TRUE, array $parameters = []) { + // Loading all definitions here will not hurt much, as they're cached, + // and we need the option to alter a definition. + $definitions = $this->getDefinitions($parameters); + if (isset($definitions[$plugin_id])) { + return $definitions[$plugin_id]; + } + elseif (!$exception_on_invalid) { + return NULL; + } + + throw new PluginNotFoundException($plugin_id, sprintf('The "%s" plugin does not exist.', $plugin_id)); + } + + /** + * {@inheritdoc} + */ + public function processDefinition(&$definition, $plugin_id) { + // Only arrays can be operated on. + if (!is_array($definition)) { + return; + } + + if (!empty($this->defaults) && is_array($this->defaults)) { + $definition = NestedArray::mergeDeep($this->defaults, $definition); + } + + // Merge in defaults. + $definition += [ + 'confirm' => FALSE, + ]; + + // Add default confirmation form if confirm set to TRUE + // and not explicitly set. + if ($definition['confirm'] && empty($definition['confirm_form_route_name'])) { + $definition['confirm_form_route_name'] = 'views_bulk_operations.confirm'; + } + + } + + /** + * {@inheritdoc} + */ + protected function alterDefinitions(&$definitions) { + // Let other modules change definitions. + // Main purpose: Action permissions bridge. + $event = new Event(); + $event->alterParameters = $this->alterParameters; + $event->definitions = &$definitions; + $this->eventDispatcher->dispatch(static::ALTER_ACTIONS_EVENT, $event); + } + +} diff --git a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessor.php b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessor.php new file mode 100644 index 0000000000000000000000000000000000000000..cb9d7cb328a29438787c295834043385f0c94cb4 --- /dev/null +++ b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessor.php @@ -0,0 +1,419 @@ +<?php + +namespace Drupal\views_bulk_operations\Service; + +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; + +/** + * Defines VBO action processor. + */ +class ViewsBulkOperationsActionProcessor implements ViewsBulkOperationsActionProcessorInterface { + + use StringTranslationTrait; + + /** + * View data provider service. + * + * @var \Drupal\views_bulk_operations\Service\ViewsbulkOperationsViewDataInterface + */ + protected $viewDataService; + + /** + * VBO action manager. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager + */ + protected $actionManager; + + /** + * Current user object. + * + * @var \Drupal\Core\Session\AccountProxyInterface + */ + protected $currentUser; + + /** + * Module handler service. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** + * Is the object initialized? + * + * @var bool + */ + protected $initialized = FALSE; + + /** + * The processed action object. + * + * @var array + */ + protected $action; + + /** + * The current view object. + * + * @var \Drupal\views\ViewExecutable + */ + protected $view; + + /** + * View data from the bulk form. + * + * @var array + */ + protected $bulkFormData; + + /** + * Array of entities that will be processed in the current batch. + * + * @var array + */ + protected $queue = []; + + /** + * Constructor. + * + * @param \Drupal\views_bulk_operations\Service\ViewsbulkOperationsViewDataInterface $viewDataService + * View data provider service. + * @param \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager $actionManager + * VBO action manager. + * @param \Drupal\Core\Session\AccountProxyInterface $currentUser + * Current user object. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $moduleHandler + * Module handler service. + */ + public function __construct( + ViewsbulkOperationsViewDataInterface $viewDataService, + ViewsBulkOperationsActionManager $actionManager, + AccountProxyInterface $currentUser, + ModuleHandlerInterface $moduleHandler + ) { + $this->viewDataService = $viewDataService; + $this->actionManager = $actionManager; + $this->currentUser = $currentUser; + $this->moduleHandler = $moduleHandler; + } + + /** + * {@inheritdoc} + */ + public function initialize(array $view_data, $view = NULL) { + + // It may happen that the service was already initialized + // in this request (e.g. multiple Batch API operation calls). + // Clear the processing queue in such a case. + if ($this->initialized) { + $this->queue = []; + } + + if (!isset($view_data['configuration'])) { + $view_data['configuration'] = []; + } + if (!empty($view_data['preconfiguration'])) { + $view_data['configuration'] += $view_data['preconfiguration']; + } + + // Initialize action object. + $this->action = $this->actionManager->createInstance($view_data['action_id'], $view_data['configuration']); + + // Set action context. + $this->setActionContext($view_data); + + // Set entire view data as object parameter for future reference. + $this->bulkFormData = $view_data; + + // Set the current view. + $this->setView($view); + + $this->initialized = TRUE; + } + + /** + * Set the current view object. + * + * @param mixed $view + * The current view object or NULL. + */ + protected function setView($view = NULL) { + if (!is_null($view)) { + $this->view = $view; + } + else { + $this->view = Views::getView($this->bulkFormData['view_id']); + $this->view->setDisplay($this->bulkFormData['display_id']); + } + $this->view->get_total_rows = TRUE; + $this->view->views_bulk_operations_processor_built = TRUE; + if (!empty($this->bulkFormData['arguments'])) { + $this->view->setArguments($this->bulkFormData['arguments']); + } + } + + /** + * {@inheritdoc} + */ + public function getPageList($page) { + $list = []; + + $this->viewDataService->init($this->view, $this->view->getDisplay(), $this->bulkFormData['relationship_id']); + + // Set exposed filters and pager parameters. + if (!empty($this->bulkFormData['exposed_input'])) { + $this->view->setExposedInput($this->bulkFormData['exposed_input']); + } + $this->view->setItemsPerPage($this->bulkFormData['batch_size']); + $this->view->setCurrentPage($page); + $this->view->build(); + + $offset = $this->bulkFormData['batch_size'] * $page; + // If the view doesn't start from the first result, + // move the offset. + if ($pager_offset = $this->view->pager->getOffset()) { + $offset += $pager_offset; + } + $this->view->query->setLimit($this->bulkFormData['batch_size']); + $this->view->query->setOffset($offset); + $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); + + // We don't need entity label here. + $list[] = [ + $row->{$base_field}, + $entity->language()->getId(), + $entity->getEntityTypeId(), + $entity->id(), + ]; + } + + return $list; + } + + /** + * {@inheritdoc} + */ + public function populateQueue(array $list, array &$context = []) { + // Determine batch size and offset. + if (!empty($context)) { + $batch_size = $this->bulkFormData['batch_size']; + if (!isset($context['sandbox']['current_batch'])) { + $context['sandbox']['current_batch'] = 0; + } + $current_batch = &$context['sandbox']['current_batch']; + $offset = $current_batch * $batch_size; + } + else { + $batch_size = 0; + $current_batch = 0; + $offset = 0; + } + + if ($batch_size) { + $batch_list = array_slice($list, $offset, $batch_size); + } + else { + $batch_list = $list; + } + + $base_field_values = []; + foreach ($batch_list as $item) { + $base_field_values[] = $item[0]; + } + if (empty($base_field_values)) { + return 0; + } + + $this->view->setItemsPerPage(0); + $this->view->setCurrentPage(0); + $this->view->setOffset(0); + $this->view->initHandlers(); + + // Remove all exposed filters so we don't have any default filter + // values that could make the actual selection out of range. + if (!empty($this->view->filter)) { + foreach ($this->view->filter as $id => $filter) { + if (!empty($filter->options['exposed'])) { + unset($this->view->filter[$id]); + } + } + } + + // Build the view query. + $this->view->build(); + + // Modify the view query: determine and apply the base field condition. + $base_field = $this->view->storage->get('base_field'); + if (isset($this->view->query->fields[$base_field])) { + $base_field_alias = $this->view->query->fields[$base_field]['table'] . '.' . $this->view->query->fields[$base_field]['alias']; + } + else { + $base_field_alias = $base_field; + } + $this->view->query->addWhere(0, $base_field_alias, $base_field_values, 'IN'); + + // Rebuild the view query. + $this->view->query->build($this->view); + + // Execute the view. + $this->moduleHandler->invokeAll('views_pre_execute', [$this->view]); + $this->view->query->execute($this->view); + + // Get entities. + $this->viewDataService->init($this->view, $this->view->getDisplay(), $this->bulkFormData['relationship_id']); + foreach ($this->view->result as $row_index => $row) { + // This may return rows for all possible languages. + // Check if the current language is on the list. + $found = FALSE; + $entity = $this->viewDataService->getEntity($row); + foreach ($batch_list as $delta => $item) { + if ($row->{$base_field} === $item[0] && $entity->language()->getId() === $item[1]) { + $this->queue[] = $entity; + $found = TRUE; + unset($batch_list[$delta]); + break; + } + } + if (!$found) { + unset($this->view->result[$row_index]); + } + } + + // Extra processing when executed in a Batch API operation. + if (!empty($context)) { + if (!isset($context['sandbox']['total'])) { + if (empty($list)) { + $context['sandbox']['total'] = $this->viewDataService->getTotalResults(); + } + else { + $context['sandbox']['total'] = count($list); + } + } + // Add batch size to context array for potential use in actions. + $context['sandbox']['batch_size'] = $batch_size; + $this->setActionContext($context); + } + + if ($batch_size) { + $current_batch++; + } + + $this->setActionView(); + + return count($this->queue); + } + + /** + * Set action context if action method exists. + * + * @param array $context + * The context to be set. + */ + protected function setActionContext(array $context) { + if (isset($this->action) && method_exists($this->action, 'setContext')) { + $this->action->setContext($context); + } + } + + /** + * Sets the current view object as the executed action parameter. + */ + protected function setActionView() { + if (isset($this->action) && method_exists($this->action, 'setView')) { + $this->action->setView($this->view); + } + } + + /** + * {@inheritdoc} + */ + public function process() { + $output = []; + + // Check if all queue items are actually Drupal entities. + foreach ($this->queue as $delta => $entity) { + if (!($entity instanceof EntityInterface)) { + $output[] = $this->t('Skipped'); + unset($this->queue[$delta]); + } + } + + // Check entity type for multi-type views like search_api index. + $action_definition = $this->actionManager->getDefinition($this->bulkFormData['action_id']); + if (!empty($action_definition['type'])) { + foreach ($this->queue as $delta => $entity) { + if ($entity->getEntityTypeId() !== $action_definition['type']) { + $output[] = $this->t('Entity type not supported'); + unset($this->queue[$delta]); + } + } + } + + // Check access. + foreach ($this->queue as $delta => $entity) { + if (!$this->action->access($entity, $this->currentUser)) { + $output[] = $this->t('Access denied'); + unset($this->queue[$delta]); + } + } + + // Process queue. + $results = $this->action->executeMultiple($this->queue); + + // Populate output. + if (empty($results)) { + $count = count($this->queue); + for ($i = 0; $i < $count; $i++) { + $output[] = $this->bulkFormData['action_label']; + } + } + else { + foreach ($results as $result) { + $output[] = $result; + } + } + return $output; + } + + /** + * {@inheritdoc} + */ + public function executeProcessing(array &$data, $view = NULL) { + if ($data['batch']) { + $batch = ViewsBulkOperationsBatch::getBatch($data); + batch_set($batch); + } + else { + $list = $data['list']; + + // Populate and process queue. + if (!$this->initialized) { + $this->initialize($data, $view); + } + if (empty($list)) { + $list = $this->getPageList(0); + } + if ($this->populateQueue($list)) { + $batch_results = $this->process(); + } + + $results = ['operations' => []]; + foreach ($batch_results as $result) { + $results['operations'][] = (string) $result; + } + ViewsBulkOperationsBatch::finished(TRUE, $results, []); + } + } + +} diff --git a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessorInterface.php b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessorInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..219cc11fb55ff3b882d6b65c9eb21361375c6d3e --- /dev/null +++ b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsActionProcessorInterface.php @@ -0,0 +1,56 @@ +<?php + +namespace Drupal\views_bulk_operations\Service; + +/** + * Defines Views Bulk Operations action processor. + */ +interface ViewsBulkOperationsActionProcessorInterface { + + /** + * Set values. + * + * @param array $view_data + * Data concerning the view that will be processed. + * @param mixed $view + * The current view object or NULL. + */ + public function initialize(array $view_data, $view = NULL); + + /** + * Get full list of items from a specific view page. + * + * @param int $page + * Results page number. + * + * @return array + * Array of result data arrays. + */ + public function getPageList($page); + + /** + * Populate entity queue for processing. + * + * @param array $list + * Array of selected view results. + * @param array $context + * Batch API context. + */ + public function populateQueue(array $list, array &$context = []); + + /** + * Process results. + */ + public function process(); + + /** + * Helper function for processing results from view data. + * + * @param array $data + * Data concerning the view that will be processed. + * @param mixed $view + * The current view object or NULL. + */ + public function executeProcessing(array &$data, $view = NULL); + +} diff --git a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewData.php b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewData.php new file mode 100644 index 0000000000000000000000000000000000000000..bc41f82081ff409bf1b37fc9a689d6409350ea8f --- /dev/null +++ b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewData.php @@ -0,0 +1,227 @@ +<?php + +namespace Drupal\views_bulk_operations\Service; + +use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use Drupal\views\ViewExecutable; +use Drupal\views\Plugin\views\display\DisplayPluginBase; +use Drupal\views\Views; +use Drupal\views\ResultRow; +use Drupal\Core\TypedData\TranslatableInterface; +use Drupal\views_bulk_operations\ViewsBulkOperationsEvent; + +/** + * Gets Views data needed by VBO. + */ +class ViewsBulkOperationsViewData implements ViewsBulkOperationsViewDataInterface { + + /** + * Event dispatcher service. + * + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + protected $eventDispatcher; + + /** + * The current view. + * + * @var \Drupal\views\ViewExecutable + */ + protected $view; + + /** + * The realtionship ID. + * + * @var string + */ + protected $relationship; + + /** + * Views data concerning the current view. + * + * @var array + */ + protected $data; + + /** + * Entity type ids returned by this view. + * + * @var array + */ + protected $entityTypeIds; + + /** + * Entity getter data. + * + * @var array + */ + protected $entityGetter; + + /** + * Object constructor. + * + * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $eventDispatcher + * The event dispatcher service. + */ + public function __construct(EventDispatcherInterface $eventDispatcher) { + $this->eventDispatcher = $eventDispatcher; + } + + /** + * {@inheritdoc} + */ + public function init(ViewExecutable $view, DisplayPluginBase $display, $relationship) { + $this->view = $view; + $this->displayHandler = $display; + $this->relationship = $relationship; + + // Get view entity types and results fetcher callable. + $event = new ViewsBulkOperationsEvent($this->getViewProvider(), $this->getData(), $view); + $this->eventDispatcher->dispatch(ViewsBulkOperationsEvent::NAME, $event); + $this->entityTypeIds = $event->getEntityTypeIds(); + $this->entityGetter = $event->getEntityGetter(); + } + + /** + * {@inheritdoc} + */ + public function getEntityTypeIds() { + return $this->entityTypeIds; + } + + /** + * Helper function to get data of the current view. + * + * @return array + * Part of views data that refers to the current view. + */ + protected function getData() { + if (!$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')); + } + } + return $this->data; + } + + /** + * {@inheritdoc} + */ + public function getViewProvider() { + $views_data = $this->getData(); + if (isset($views_data['table']['provider'])) { + return $views_data['table']['provider']; + } + return FALSE; + } + + /** + * {@inheritdoc} + */ + public function getViewBaseField() { + $views_data = $this->getData(); + if (isset($views_data['table']['base']['field'])) { + return $views_data['table']['base']['field']; + } + throw new \Exception('Unable to get base field for the view.'); + } + + /** + * {@inheritdoc} + */ + public function getEntity(ResultRow $row) { + if (!empty($this->entityGetter['file'])) { + require_once $this->entityGetter['file']; + } + if (is_callable($this->entityGetter['callable'])) { + return call_user_func($this->entityGetter['callable'], $row, $this->relationship, $this->view); + } + else { + if (is_array($this->entityGetter['callable'])) { + if (is_object($this->entityGetter['callable'][0])) { + $info = get_class($this->entityGetter['callable'][0]); + } + else { + $info = $this->entityGetter['callable'][0]; + } + $info .= '::' . $this->entityGetter['callable'][1]; + } + else { + $info = $this->entityGetter['callable']; + } + throw new \Exception(sprintf("Entity getter method %s doesn't exist.", $info)); + } + } + + /** + * Get the total count of results on all pages. + * + * @return int + * The total number of results this view displays. + */ + public function getTotalResults() { + $total_results = NULL; + if (!empty($this->view->pager->total_items)) { + $total_results = $this->view->pager->total_items; + } + elseif (!empty($this->view->total_rows)) { + $total_results = $this->view->total_rows; + } + + return $total_results; + } + + /** + * {@inheritdoc} + */ + public function getEntityDefault(ResultRow $row, $relationship_id, ViewExecutable $view) { + if ($relationship_id == 'none') { + if (!empty($row->_entity)) { + $entity = $row->_entity; + } + } + elseif (isset($row->_relationship_entities[$relationship_id])) { + $entity = $row->_relationship_entities[$relationship_id]; + } + else { + throw new \Exception('Unexpected view result row structure.'); + } + + if ($entity instanceof TranslatableInterface && $entity->isTranslatable()) { + + // Try to find a field alias for the langcode. + // Assumption: translatable entities always + // have a langcode key. + $language_field = ''; + $langcode_key = $entity->getEntityType()->getKey('langcode'); + $base_table = $view->storage->get('base_table'); + foreach ($view->query->fields as $field) { + if ( + $field['field'] === $langcode_key && ( + empty($field['base_table']) || + $field['base_table'] === $base_table + ) + ) { + $language_field = $field['alias']; + break; + } + } + if (!$language_field) { + $language_field = $langcode_key; + } + + if (isset($row->{$language_field})) { + return $entity->getTranslation($row->{$language_field}); + } + } + + return $entity; + } + +} diff --git a/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewDataInterface.php b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewDataInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..ed41b78514b8132573fdd9c17fd4cd15395c7e8f --- /dev/null +++ b/web/modules/views_bulk_operations/src/Service/ViewsBulkOperationsViewDataInterface.php @@ -0,0 +1,86 @@ +<?php + +namespace Drupal\views_bulk_operations\Service; + +use Drupal\views\ViewExecutable; +use Drupal\views\Plugin\views\display\DisplayPluginBase; +use Drupal\views\ResultRow; + +/** + * Defines view data service for Views Bulk Operations. + */ +interface ViewsBulkOperationsViewDataInterface { + + /** + * Initialize additional variables. + * + * @param \Drupal\views\ViewExecutable $view + * The view object. + * @param \Drupal\views\Plugin\views\display\DisplayPluginBase $display + * The current display plugin. + * @param string $relationship + * Relationship ID. + */ + public function init(ViewExecutable $view, DisplayPluginBase $display, $relationship); + + /** + * Get entity type IDs. + * + * @return array + * Array of entity type IDs. + */ + public function getEntityTypeIds(); + + /** + * Get view provider. + * + * @return string + * View provider ID. + */ + public function getViewProvider(); + + /** + * Get base field for the current view. + * + * @return sting + * The base field name. + */ + public function getViewBaseField(); + + /** + * Get entity from views row. + * + * @param \Drupal\views\ResultRow $row + * Views row object. + * + * @return \Drupal\Core\Entity\EntityInterface + * An entity object. + */ + public function getEntity(ResultRow $row); + + /** + * Get the total count of results on all pages. + * + * @return int + * The total number of results this view displays. + */ + public function getTotalResults(); + + /** + * The default entity getter function. + * + * Must work well with standard Drupal core entity views. + * + * @param \Drupal\views\ResultRow $row + * Views result row. + * @param string $relationship_id + * Id of the view relationship. + * @param \Drupal\views\ViewExecutable $view + * The current view object. + * + * @return \Drupal\Core\Entity\FieldableEntityInterface + * The translated entity. + */ + public function getEntityDefault(ResultRow $row, $relationship_id, ViewExecutable $view); + +} diff --git a/web/modules/views_bulk_operations/src/ViewsBulkOperationsBatch.php b/web/modules/views_bulk_operations/src/ViewsBulkOperationsBatch.php new file mode 100644 index 0000000000000000000000000000000000000000..f43f7ebc8af54cbdbef88d94ab3d4dc70e64fb85 --- /dev/null +++ b/web/modules/views_bulk_operations/src/ViewsBulkOperationsBatch.php @@ -0,0 +1,233 @@ +<?php + +namespace Drupal\views_bulk_operations; + +use Drupal\Core\Url; + +/** + * 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_set_message() + */ + public static function message($message = NULL, $type = 'status', $repeat = TRUE) { + drupal_set_message($message, $type, $repeat); + } + + /** + * Gets the list of entities to process. + * + * Used in "all results" batch operation. + * + * @param array $data + * Processed view data. + * @param array $context + * Batch context. + */ + public static function getList(array $data, array &$context) { + // Initialize batch. + if (empty($context['sandbox'])) { + $context['sandbox']['processed'] = 0; + $context['sandbox']['page'] = 0; + $context['results'] = $data; + } + + $actionProcessor = \Drupal::service('views_bulk_operations.processor'); + $actionProcessor->initialize($data); + + // Populate queue. + $list = $actionProcessor->getPageList($context['sandbox']['page']); + $count = count($list); + + if ($count) { + foreach ($list as $item) { + $context['results']['list'][] = $item; + } + + $context['sandbox']['page']++; + $context['sandbox']['processed'] += $count; + + // There may be cases where we don't know the total number of + // results (e.g. mini pager with a search_api view) + $context['finished'] = 0; + if ($data['total_results']) { + $context['finished'] = $context['sandbox']['processed'] / $data['total_results']; + $context['message'] = static::t('Prepared @count of @total entities for processing.', [ + '@count' => $context['sandbox']['processed'], + '@total' => $data['total_results'], + ]); + } + else { + $context['message'] = static::t('Prepared @count entities for processing.', [ + '@count' => $context['sandbox']['processed'], + ]); + } + } + + } + + /** + * Save generated list to user tempstore. + * + * @param bool $success + * Was the process successfull? + * @param array $results + * Batch process results array. + * @param array $operations + * Performed operations array. + */ + public static function saveList($success, array $results, array $operations) { + if ($success) { + $results['redirect_url'] = $results['redirect_after_processing']; + unset($results['redirect_after_processing']); + $tempstore_factory = \Drupal::service('user.private_tempstore'); + $current_user = \Drupal::service('current_user'); + $tempstore_name = 'views_bulk_operations_' . $results['view_id'] . '_' . $results['display_id']; + $results['prepopulated'] = TRUE; + $tempstore_factory->get($tempstore_name)->set($current_user->id(), $results); + } + } + + /** + * Batch operation callback. + * + * @param array $data + * Processed view data. + * @param array $context + * Batch context. + */ + public static function operation(array $data, array &$context) { + // Initialize batch. + if (empty($context['sandbox'])) { + $context['sandbox']['processed'] = 0; + $context['results']['operations'] = []; + } + + // Get entities to process. + $actionProcessor = \Drupal::service('views_bulk_operations.processor'); + $actionProcessor->initialize($data); + + // Do the processing. + $count = $actionProcessor->populateQueue($data['list'], $context); + if ($count) { + $batch_results = $actionProcessor->process(); + if (!empty($batch_results)) { + // Convert translatable markup to strings in order to allow + // correct operation of array_count_values function. + foreach ($batch_results as $result) { + $context['results']['operations'][] = (string) $result; + } + } + $context['sandbox']['processed'] += $count; + + $context['finished'] = 0; + // There may be cases where we don't know the total number of + // results (probably all of them were already eliminated but + // leaving this code just in case). + if ($context['sandbox']['total']) { + $context['finished'] = $context['sandbox']['processed'] / $context['sandbox']['total']; + $context['message'] = static::t('Processed @count of @total entities.', [ + '@count' => $context['sandbox']['processed'], + '@total' => $context['sandbox']['total'], + ]); + } + else { + $context['message'] = static::t('Processed @count entities.', [ + '@count' => $context['sandbox']['processed'], + ]); + } + } + } + + /** + * Batch finished callback. + * + * @param bool $success + * Was the process successfull? + * @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); + } + else { + $message = static::t('Finished with an error.'); + static::message($message, 'error'); + } + } + + /** + * Batch builder function. + * + * @param array $view_data + * Processed view data. + */ + public static function getBatch(array &$view_data) { + $current_class = get_called_class(); + + // Prepopulate results. + if (empty($view_data['list'])) { + // Redirect this batch to the processing URL and set + // previous redirect under a different key for later use. + $view_data['redirect_after_processing'] = $view_data['redirect_url']; + $view_data['redirect_url'] = Url::fromRoute('views_bulk_operations.execute_batch', [ + 'view_id' => $view_data['view_id'], + 'display_id' => $view_data['display_id'], + ]); + + $batch = [ + 'title' => static::t('Prepopulating entity list for processing.'), + 'operations' => [ + [ + [$current_class, 'getList'], + [$view_data], + ], + ], + 'progress_message' => static::t('Prepopulating, estimated time left: @estimate, elapsed: @elapsed.'), + 'finished' => [$current_class, 'saveList'], + ]; + } + + // Execute action. + else { + $batch = [ + 'title' => static::t('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'], + ]; + } + + return $batch; + } + +} diff --git a/web/modules/views_bulk_operations/src/ViewsBulkOperationsEvent.php b/web/modules/views_bulk_operations/src/ViewsBulkOperationsEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..44ad3e522c685f59211d6c947b09e57f5b768e88 --- /dev/null +++ b/web/modules/views_bulk_operations/src/ViewsBulkOperationsEvent.php @@ -0,0 +1,139 @@ +<?php + +namespace Drupal\views_bulk_operations; + +use Symfony\Component\EventDispatcher\Event; +use Drupal\views\ViewExecutable; + +/** + * Defines Views Bulk Operations event type. + */ +class ViewsBulkOperationsEvent extends Event { + + const NAME = 'views_bulk_operations.view_data'; + + /** + * The provider of the current view. + * + * @var string + */ + protected $provider; + + /** + * The views data of the current view. + * + * @var array + */ + protected $viewData; + + /** + * The current view object. + * + * @var \Drupal\views\ViewExecutable + */ + protected $view; + + /** + * IDs of entity types returned by the view. + * + * @var array + */ + protected $entityTypeIds; + + /** + * Row entity getter information. + * + * @var array + */ + protected $entityGetter; + + /** + * Object constructor. + * + * @param string $provider + * The provider of the current view. + * @param array $viewData + * The views data of the current view. + * @param \Drupal\views\ViewExecutable $view + * The current view. + */ + public function __construct($provider, array $viewData, ViewExecutable $view) { + $this->provider = $provider; + $this->viewData = $viewData; + $this->view = $view; + } + + /** + * Get view provider. + * + * @return string + * The view provider + */ + public function getProvider() { + return $this->provider; + } + + /** + * Get view data. + * + * @return string + * The current view data + */ + public function getViewData() { + return $this->viewData; + } + + /** + * Get current view. + * + * @return \Drupal\views\ViewExecutable + * The current view object + */ + public function getView() { + return $this->view; + } + + /** + * Get entity type IDs displayed by the current view. + * + * @return array + * Entity type IDs. + */ + public function getEntityTypeIds() { + return $this->entityTypeIds; + } + + /** + * Get entity getter callable. + * + * @return array + * Entity getter information. + */ + public function getEntityGetter() { + return $this->entityGetter; + } + + /** + * Set entity type IDs. + * + * @param array $entityTypeIds + * Entity type IDs. + */ + public function setEntityTypeIds(array $entityTypeIds) { + $this->entityTypeIds = $entityTypeIds; + } + + /** + * Set entity getter callable. + * + * @param array $entityGetter + * Entity getter information. + */ + public function setEntityGetter(array $entityGetter) { + if (!isset($entityGetter['callable'])) { + throw new \Exception('Views Bulk Operations entity getter callable is not defined.'); + } + $this->entityGetter = $entityGetter; + } + +} diff --git a/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsBulkFormTest.php b/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsBulkFormTest.php new file mode 100644 index 0000000000000000000000000000000000000000..74c1170eb73f06fc10aea4bff4177371da20cb5f --- /dev/null +++ b/web/modules/views_bulk_operations/tests/src/Functional/ViewsBulkOperationsBulkFormTest.php @@ -0,0 +1,344 @@ +<?php + +namespace Drupal\Tests\views_bulk_operations\Functional; + +use Drupal\Tests\BrowserTestBase; + +/** + * @coversDefaultClass \Drupal\views_bulk_operations\Plugin\views\field\ViewsBulkOperationsBulkForm + * @group views_bulk_operations + */ +class ViewsBulkOperationsBulkFormTest extends BrowserTestBase { + + /** + * Modules to install. + * + * @var array + */ + public static $modules = [ + 'node', + 'views', + 'views_bulk_operations', + 'views_bulk_operations_test', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + // Create some nodes for testing. + $this->drupalCreateContentType(['type' => 'page']); + + $this->testNodes = []; + $time = $this->container->get('datetime.time')->getRequestTime(); + for ($i = 0; $i < 15; $i++) { + // Ensure nodes are sorted in the same order they are inserted in the + // array. + $time -= $i; + $this->testNodes[] = $this->drupalCreateNode([ + 'type' => 'page', + 'title' => 'Title ' . $i, + 'sticky' => FALSE, + 'created' => $time, + 'changed' => $time, + ]); + } + + } + + /** + * Helper function to test a batch process. + * + * After checking if we're on a Batch API page, + * the iterations are executed, the finished page is opened + * and browser redirects to the final destination. + * + * NOTE: As of Drupal 8.4, functional test + * automatically redirects user through all Batch API pages, + * so this function is not longer needed. + */ + protected function assertBatchProcess() { + // Get the current batch ID. + $current_url = $this->getUrl(); + $q = substr($current_url, strrpos($current_url, '/') + 1); + $this->assertEquals('batch?', substr($q, 0, 6), 'We are on a Batch API page.'); + + preg_match('#id=([0-9]+)#', $q, $matches); + $batch_id = $matches[1]; + + // Proceed with the operations. + // Assumption: all operations will be completed within a single request. + // TODO: modify code to include an option when the assumption is false. + do { + $this->drupalGet('batch', [ + 'query' => [ + 'id' => $batch_id, + 'op' => 'do_nojs', + ], + ]); + } while (FALSE); + + // Get the finished page. + $this->drupalGet('batch', [ + 'query' => [ + 'id' => $batch_id, + 'op' => 'finished', + ], + ]); + } + + /** + * Tests the VBO bulk form with simple test action. + */ + public function testViewsBulkOperationsBulkFormSimple() { + + $assertSession = $this->assertSession(); + + $this->drupalGet('views-bulk-operations-test'); + + // Test that the views edit header appears first. + $first_form_element = $this->xpath('//form/div[1][@id = :id]', [':id' => 'edit-header']); + $this->assertTrue($first_form_element, 'The views form edit header appears first.'); + + // Make sure a checkbox appears on all rows. + $edit = []; + for ($i = 0; $i < 4; $i++) { + $assertSession->fieldExists('edit-views-bulk-operations-bulk-form-' . $i, NULL, format_string('The checkbox on row @row appears.', ['@row' => $i])); + } + + // The advanced action should not be shown on the form - no permission. + $this->assertTrue(empty($this->cssSelect('select[name=views_bulk_operations_advanced_test_action]')), t('Advanced action is not selectable.')); + + // 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']); + $this->drupalLogin($admin_user); + + // Execute the simple test action. + $edit = []; + $selected = [0, 2, 3]; + foreach ($selected as $index) { + $edit["views_bulk_operations_bulk_form[$index]"] = TRUE; + } + + // Tests: actions as buttons, label override. + $this->drupalPostForm('views-bulk-operations-test', $edit, t('Simple test action')); + + $testViewConfig = \Drupal::service('config.factory')->get('views.view.views_bulk_operations_test'); + $configData = $testViewConfig->getRawData(); + $preconfig_setting = $configData['display']['default']['display_options']['fields']['views_bulk_operations_bulk_form']['preconfiguration']['views_bulk_operations_simple_test_action']['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() + ) + ); + } + + // Test the select all functionality. + $edit = [ + 'select_all' => 1, + ]; + $this->drupalPostForm(NULL, $edit, t('Simple test action')); + + $assertSession->pageTextContains( + sprintf('Action processing results: Test (%d).', count($this->testNodes)), + sprintf('Action has been executed on %d nodes.', count($this->testNodes)) + ); + + } + + /** + * More advanced test. + * + * Uses the ViewsBulkOperationsAdvancedTestAction. + */ + public function testViewsBulkOperationsBulkFormAdvanced() { + + $assertSession = $this->assertSession(); + + // 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']); + $this->drupalLogin($admin_user); + + // First execute the simple action to test + // the ViewsBulkOperationsController class. + $edit = [ + 'action' => 'views_bulk_operations_simple_test_action', + ]; + $selected = [0, 2]; + foreach ($selected as $index) { + $edit["views_bulk_operations_bulk_form[$index]"] = TRUE; + } + $this->drupalPostForm('views-bulk-operations-test-advanced', $edit, t('Apply to selected items')); + + $assertSession->pageTextContains( + sprintf('Action processing results: Test (%d).', count($selected)), + sprintf('Action has been executed on %d nodes.', count($selected)) + ); + + // Execute the advanced test action. + $edit = [ + 'action' => 'views_bulk_operations_advanced_test_action', + ]; + $selected = [0, 1, 3]; + foreach ($selected as $index) { + $edit["views_bulk_operations_bulk_form[$index]"] = TRUE; + } + $this->drupalPostForm('views-bulk-operations-test-advanced', $edit, t('Apply to selected items')); + + // Check if the configuration form is open and contains the + // test_config field. + $assertSession->fieldExists('edit-test-config', NULL, 'The configuration field appears.'); + + // Check if the configuration form contains selected entity labels. + // NOTE: The view pager has an offset set on this view, so checkbox + // indexes are not equal to test nodes array keys. Hence the $index + 1. + foreach ($selected as $index) { + $assertSession->pageTextContains($this->testNodes[$index + 1]->label()); + } + + $config_value = 'test value'; + $edit = [ + 'test_config' => $config_value, + ]; + $this->drupalPostForm(NULL, $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')); + + // If all went well and Batch API did its job, + // the next page should display results. + $testViewConfig = \Drupal::service('config.factory')->get('views.view.views_bulk_operations_test_advanced'); + $configData = $testViewConfig->getRawData(); + $preconfig_setting = $configData['display']['default']['display_options']['fields']['views_bulk_operations_bulk_form']['preconfiguration']['views_bulk_operations_advanced_test_action']['test_preconfig']; + + // NOTE: The view pager has an offset set on this view, so checkbox + // indexes are not equal to test nodes array keys. Hence the $index + 1. + foreach ($selected as $index) { + $assertSession->pageTextContains(sprintf('Test action (preconfig: %s, config: %s, label: %s)', + $preconfig_setting, + $config_value, + $this->testNodes[$index + 1]->label() + )); + } + + // Test the select all functionality with batching and entity + // property changes affecting view query results. + $edit = [ + 'action' => 'views_bulk_operations_advanced_test_action', + 'select_all' => 1, + ]; + $this->drupalPostForm(NULL, $edit, t('Apply to selected items')); + $this->drupalPostForm(NULL, ['test_config' => 'unpublish'], t('Apply')); + $this->drupalPostForm(NULL, [], t('Execute action')); + // Again, take offset into account (-1). + $assertSession->pageTextContains( + sprintf('Action processing results: Test (%d).', (count($this->testNodes) - 1)), + sprintf('Action has been executed on all %d nodes.', (count($this->testNodes) - 1)) + ); + $this->assertTrue(empty($this->cssSelect('table.views-table tr')), t("The view doesn't show any results.")); + } + + /** + * View and context passing test. + * + * Uses the ViewsBulkOperationsPassTestAction. + */ + public function testViewsBulkOperationsBulkFormPassing() { + + $assertSession = $this->assertSession(); + + // Log in as a user with 'administer content' permission + // to have access to perform the test operation. + $admin_user = $this->drupalCreateUser(['bypass node access']); + $this->drupalLogin($admin_user); + + // Test with all selected and specific selection, with batch + // size greater than items per page and lower than items per page, + // using Batch API process and without it. + $cases = [ + ['batch' => FALSE, 'selection' => TRUE, 'page' => 1], + ['batch' => FALSE, 'selection' => FALSE], + ['batch' => TRUE, 'batch_size' => 3, 'selection' => TRUE, 'page' => 1], + ['batch' => TRUE, 'batch_size' => 7, 'selection' => TRUE], + ['batch' => TRUE, 'batch_size' => 3, 'selection' => FALSE], + ['batch' => TRUE, 'batch_size' => 7, 'selection' => FALSE], + ]; + + // Custom selection. + $selected = [0, 1, 3, 4]; + + $testViewConfig = \Drupal::service('config.factory')->getEditable('views.view.views_bulk_operations_test_advanced'); + $configData = $testViewConfig->getRawData(); + $configData['display']['default']['display_options']['pager']['options']['items_per_page'] = 5; + + foreach ($cases as $case) { + + // Populate form values. + $edit = [ + 'action' => 'views_bulk_operations_passing_test_action', + ]; + if ($case['selection']) { + foreach ($selected as $index) { + $edit["views_bulk_operations_bulk_form[$index]"] = TRUE; + } + } + else { + $edit['select_all'] = 1; + } + + // Update test view configuration. + $configData['display']['default']['display_options']['pager']['options']['items_per_page']++; + $configData['display']['default']['display_options']['fields']['views_bulk_operations_bulk_form']['batch'] = $case['batch']; + if (isset($case['batch_size'])) { + $configData['display']['default']['display_options']['fields']['views_bulk_operations_bulk_form']['batch_size'] = $case['batch_size']; + } + $testViewConfig->setData($configData); + $testViewConfig->save(); + + $options = []; + if (!empty($case['page'])) { + $options['query'] = ['page' => $case['page']]; + } + + $this->drupalGet('views-bulk-operations-test-advanced', $options); + $this->drupalPostForm(NULL, $edit, t('Apply to selected items')); + + // On batch-enabled processes check if provided context data is correct. + if ($case['batch']) { + if ($case['selection']) { + $total = count($selected); + } + else { + // Again, include offset. + $total = count($this->testNodes) - 1; + } + $n_batches = ceil($total / $case['batch_size']); + + for ($i = 0; $i < $n_batches; $i++) { + $processed = $i * $case['batch_size']; + $assertSession->pageTextContains(sprintf( + 'Processed %s of %s.', + $processed, + $total + ), 'The correct processed info message appears.'); + } + } + + // Passed view integrity check. + $assertSession->pageTextContains('Passed view results match the entity queue.'); + } + + } + +} diff --git a/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsActionProcessorTest.php b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsActionProcessorTest.php new file mode 100644 index 0000000000000000000000000000000000000000..a62a0623354de1e2b7a70ff339cd92e8a48a62ed --- /dev/null +++ b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsActionProcessorTest.php @@ -0,0 +1,89 @@ +<?php + +namespace Drupal\Tests\views_bulk_operations\Kernel; + +use Drupal\node\NodeInterface; + +/** + * @coversDefaultClass \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessor + * @group views_bulk_operations + */ +class ViewsBulkOperationsActionProcessorTest extends ViewsBulkOperationsKernelTestBase { + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + + $this->createTestNodes([ + 'page' => [ + 'count' => 20, + ], + ]); + } + + /** + * Tests general functionality of ViewsBulkOperationsActionProcessor. + * + * @covers ::getPageList + * @covers ::populateQueue + * @covers ::process + */ + public function testViewsbulkOperationsActionProcessor() { + $vbo_data = [ + 'view_id' => 'views_bulk_operations_test', + 'action_id' => 'views_bulk_operations_simple_test_action', + 'configuration' => [ + 'preconfig' => 'test', + ], + ]; + + // Test executing all view results first. + $results = $this->executeAction($vbo_data); + + // The default batch size is 10 and there are 20 result rows total + // (10 nodes, each having a translation), check messages: + $this->assertEquals('Processed 10 of 20 entities.', $results['messages'][0]); + $this->assertEquals('Processed 20 of 20 entities.', $results['messages'][1]); + $this->assertEquals(20, $results['operations']['Test']); + + // For a more advanced test, check if randomly selected entities + // have been unpublished. + $vbo_data = [ + 'view_id' => 'views_bulk_operations_test', + 'action_id' => 'views_bulk_operations_advanced_test_action', + 'preconfiguration' => [ + 'test_preconfig' => 'test', + 'test_config' => 'unpublish', + ], + ]; + + // Get list of rows to process from different view pages. + $selection = [0, 3, 6, 8, 15, 16, 18]; + $vbo_data['list'] = $this->getResultsList($vbo_data, $selection); + + // Execute the action. + $results = $this->executeAction($vbo_data); + + $nodeStorage = $this->container->get('entity_type.manager')->getStorage('node'); + + $statuses = []; + + foreach ($this->testNodesData as $id => $lang_data) { + $node = $nodeStorage->load($id); + $statuses[$id] = intval($node->status->value); + } + + foreach ($statuses as $id => $status) { + foreach ($vbo_data['list'] as $item) { + if ($item[3] == $id) { + $this->assertEquals(NodeInterface::NOT_PUBLISHED, $status); + break 2; + } + } + $this->assertEquals(NodeInterface::PUBLISHED, $status); + } + } + +} diff --git a/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsDataServiceTest.php b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsDataServiceTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1a004a32b1d45d9f162240ad1f8ee65e7498762f --- /dev/null +++ b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsDataServiceTest.php @@ -0,0 +1,58 @@ +<?php + +namespace Drupal\Tests\views_bulk_operations\Kernel; + +use Drupal\views\Views; + +/** + * @coversDefaultClass \Drupal\views_bulk_operations\Service\ViewsBulkOperationsViewData + * @group views_bulk_operations + */ +class ViewsBulkOperationsDataServiceTest extends ViewsBulkOperationsKernelTestBase { + + /** + * {@inheritdoc} + */ + public function setUp() { + parent::setUp(); + + $this->createTestNodes([ + 'page' => [ + 'languages' => ['pl', 'es', 'it', 'fr', 'de'], + 'count' => 20, + ], + ]); + } + + /** + * Tests the getEntityDefault() method. + * + * @covers ::getEntityDefault + */ + public function testViewsbulkOperationsViewDataEntityGetter() { + // Initialize and execute the test view with all items displayed. + $view = Views::getView('views_bulk_operations_test'); + $view->setDisplay('page_1'); + $view->setItemsPerPage(0); + $view->setCurrentPage(0); + $view->execute(); + + $test_data = $this->testNodesData; + foreach ($view->result as $row) { + $entity = $this->vboDataService->getEntityDefault($row, 'none', $view); + + $expected_label = $test_data[$entity->id()][$entity->language()->getId()]; + + $this->assertEquals($expected_label, $entity->label(), 'Title matches'); + if ($expected_label === $entity->label()) { + unset($test_data[$entity->id()][$entity->language()->getId()]); + if (empty($test_data[$entity->id()])) { + unset($test_data[$entity->id()]); + } + } + } + + $this->assertEmpty($test_data, 'All created entities and their translations were returned.'); + } + +} diff --git a/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsKernelTestBase.php b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsKernelTestBase.php new file mode 100644 index 0000000000000000000000000000000000000000..8dcf355154874f29739de8e6d3f7ba13b661f784 --- /dev/null +++ b/web/modules/views_bulk_operations/tests/src/Kernel/ViewsBulkOperationsKernelTestBase.php @@ -0,0 +1,297 @@ +<?php + +namespace Drupal\Tests\views_bulk_operations\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Drupal\simpletest\NodeCreationTrait; +use Drupal\node\Entity\NodeType; +use Drupal\user\Entity\User; +use Drupal\views\Views; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\views_bulk_operations\ViewsBulkOperationsBatch; + +/** + * Base class for Views Bulk Operations kernel tests. + */ +abstract class ViewsBulkOperationsKernelTestBase extends KernelTestBase { + + use NodeCreationTrait { + getNodeByTitle as drupalGetNodeByTitle; + createNode as drupalCreateNode; + } + + // To be removed. + const TEST_NODES_COUNT = 10; + + const VBO_DEFAULTS = [ + 'list' => [], + 'display_id' => 'default', + 'preconfiguration' => [], + 'batch' => TRUE, + 'arguments' => [], + 'exposed_input' => [], + 'batch_size' => 10, + 'relationship_id' => 'none', + ]; + + /** + * Test node types already created. + * + * @var array + */ + protected $testNodesTypes; + + + /** + * Test nodes data including titles and languages. + * + * @var array + */ + protected $testNodesData; + + /** + * VBO views data service. + * + * @var \Drupal\views_bulk_operations\Service\ViewsBulkOperationsViewDataInterface + */ + protected $vboDataService; + + /** + * {@inheritdoc} + */ + public static $modules = [ + 'user', + 'node', + 'field', + 'content_translation', + 'views_bulk_operations', + 'views_bulk_operations_test', + 'views', + 'filter', + 'language', + 'text', + 'action', + 'system', + ]; + + /** + * {@inheritdoc} + */ + public function setUp() { + 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'); + $user->enforceIsNew(); + $user->setEmail('email'); + $user->setUsername('user_name'); + $user->save(); + user_login_finalize($user); + + $this->installConfig([ + 'system', + 'filter', + 'views_bulk_operations_test', + 'language', + ]); + + // Get time and VBO view data services. + $this->time = $this->container->get('datetime.time'); + $this->vboDataService = $this->container->get('views_bulk_operations.data'); + } + + /** + * Create some test nodes. + * + * @param array $test_node_data + * Describes test node bundles and properties. + * + * @see Drupal\Tests\views_bulk_operations\Kernel\ViewsBulkOperationsDataServiceTest::setUp() + */ + protected function createTestNodes(array $test_node_data) { + $this->testNodesData = []; + foreach ($test_node_data as $type_name => $type_data) { + $type = NodeType::create([ + 'type' => $type_name, + 'name' => $type_name, + ]); + $type->save(); + + $count_languages = isset($type_data['languages']) ? count($type_data['languages']) : 0; + if ($count_languages) { + for ($i = 0; $i < $count_languages; $i++) { + $language = ConfigurableLanguage::createFromLangcode($type_data['languages'][$i]); + $language->save(); + } + $this->container->get('content_translation.manager')->setEnabled('node', $type_name, TRUE); + // $this->container->get('entity_type.manager')->clearCachedDefinitions(); + } + + // Create some test nodes. + $time = $this->time->getRequestTime(); + if (!isset($type_data['count'])) { + $type_data['count'] = 10; + } + for ($i = 0; $i < $type_data['count']; $i++) { + $time -= $i; + $title = 'Title ' . $i; + $node = $this->drupalCreateNode([ + 'type' => $type_name, + 'title' => $title, + 'sticky' => FALSE, + 'created' => $time, + 'changed' => $time, + ]); + $this->testNodesData[$node->id()]['en'] = $title; + + if ($count_languages) { + // It doesn't really matter to which languages we translate + // from the API point of view so some randomness should be fine. + $langcode = $type_data['languages'][rand(0, $count_languages - 1)]; + $title = 'Translated title ' . $langcode . ' ' . $i; + $translation = $node->addTranslation($langcode, [ + 'title' => $title, + ]); + $translation->save(); + $this->testNodesData[$node->id()][$langcode] = $title; + } + } + } + } + + /** + * Initialize and return the view described by $vbo_data. + * + * @param array $vbo_data + * An array of data passed to VBO Processor service. + * + * @return \Drupal\views\ViewExecutable + * The view object. + */ + protected function initializeView(array $vbo_data) { + if (!$view = Views::getView($vbo_data['view_id'])) { + throw new \Exception('Incorrect view ID provided.'); + } + if (!$view->setDisplay($vbo_data['display_id'])) { + throw new \Exception('Incorrect view display ID provided.'); + } + $view->built = FALSE; + $view->executed = FALSE; + + return $view; + } + + /** + * Get a random list of results bulk keys. + * + * @param array $vbo_data + * An array of data passed to VBO Processor service. + * @param array $deltas + * Array of result rows deltas. + * + * @return array + * List of results to process. + */ + protected function getResultsList(array $vbo_data, array $deltas) { + // Merge in defaults. + $vbo_data += static::VBO_DEFAULTS; + + $view = $this->initializeView($vbo_data); + if (!empty($vbo_data['arguments'])) { + $view->setArguments($vbo_data['arguments']); + } + if (!empty($vbo_data['exposed_input'])) { + $view->setExposedInput($vbo_data['exposed_input']); + } + + $view->setItemsPerPage(0); + $view->setCurrentPage(0); + $view->execute(); + + $this->vboDataService->init($view, $view->getDisplay(), $vbo_data['relationship_id']); + + $list = []; + $base_field = $view->storage->get('base_field'); + foreach ($deltas as $delta) { + $entity = $this->vboDataService->getEntity($view->result[$delta]); + + $list[] = [ + $view->result[$delta]->{$base_field}, + $entity->language()->getId(), + $entity->getEntityTypeId(), + $entity->id(), + ]; + } + + $view->destroy(); + + return $list; + } + + /** + * Execute an action on a specific view results. + * + * @param array $vbo_data + * An array of data passed to VBO Processor service. + */ + protected function executeAction(array $vbo_data) { + + // Merge in defaults. + $vbo_data += static::VBO_DEFAULTS; + + $view = $this->initializeView($vbo_data); + $view->get_total_rows = TRUE; + + $view->execute(); + + // Get total rows count. + $this->vboDataService->init($view, $view->getDisplay(), $vbo_data['relationship_id']); + $vbo_data['total_results'] = $this->vboDataService->getTotalResults(); + + // Get action definition and check if action ID is correct. + $action_definition = $this->container->get('plugin.manager.views_bulk_operations_action')->getDefinition($vbo_data['action_id']); + if (!isset($vbo_data['action_label'])) { + $vbo_data['action_label'] = (string) $action_definition['label']; + } + + // Populate entity list if empty. + if (empty($vbo_data['list'])) { + $context = []; + do { + $context['finished'] = 1; + $context['message'] = ''; + ViewsBulkOperationsBatch::getList($vbo_data, $context); + } while ($context['finished'] < 1); + $vbo_data = $context['results']; + } + + $summary = [ + 'messages' => [], + ]; + + // Execute the selected action. + $context = []; + do { + $context['finished'] = 1; + $context['message'] = ''; + ViewsBulkOperationsBatch::operation($vbo_data, $context); + if (!empty($context['message'])) { + $summary['messages'][] = (string) $context['message']; + } + } while ($context['finished'] < 1); + + // Add information to the summary array. + $summary += [ + 'operations' => array_count_values($context['results']['operations']), + ]; + + return $summary; + } + +} diff --git a/web/modules/views_bulk_operations/tests/src/Unit/TestViewsBulkOperationsBatch.php b/web/modules/views_bulk_operations/tests/src/Unit/TestViewsBulkOperationsBatch.php new file mode 100644 index 0000000000000000000000000000000000000000..cbf30569b30c26822ab91755c749dfc8cad6f599 --- /dev/null +++ b/web/modules/views_bulk_operations/tests/src/Unit/TestViewsBulkOperationsBatch.php @@ -0,0 +1,34 @@ +<?php + +namespace Drupal\Tests\views_bulk_operations\Unit; + +use Drupal\views_bulk_operations\ViewsBulkOperationsBatch; + +/** + * Override some class methods for proper testing. + */ +class TestViewsBulkOperationsBatch extends ViewsBulkOperationsBatch { + + /** + * Override t method. + */ + public static function t($string, array $args = [], array $options = []) { + return strtr($string, $args); + } + + /** + * Override message method. + */ + public static function message($message = NULL, $type = 'status', $repeat = TRUE) { + static $storage; + if (isset($storage)) { + $output = $storage; + $storage = NULL; + return $output; + } + else { + $storage = (string) $message; + } + } + +} diff --git a/web/modules/views_bulk_operations/tests/src/Unit/ViewsBulkOperationsBatchTest.php b/web/modules/views_bulk_operations/tests/src/Unit/ViewsBulkOperationsBatchTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5c8f16eebe3d5f06866b59cc79a2fdab90a694d1 --- /dev/null +++ b/web/modules/views_bulk_operations/tests/src/Unit/ViewsBulkOperationsBatchTest.php @@ -0,0 +1,160 @@ +<?php + +namespace Drupal\Tests\views_bulk_operations\Unit; + +use Drupal\Tests\UnitTestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Drupal\views\Entity\View; + +/** + * @coversDefaultClass \Drupal\views_bulk_operations\ViewsBulkOperationsBatch + * @group views_bulk_operations + */ +class ViewsBulkOperationsBatchTest extends UnitTestCase { + + /** + * Modules to install. + * + * @var array + */ + public static $modules = ['node']; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->container = new ContainerBuilder(); + \Drupal::setContainer($this->container); + } + + /** + * Returns a stub ViewsBulkOperationsActionProcessor that returns dummy data. + * + * @return \Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessor + * A mocked action processor. + */ + public function getViewsBulkOperationsActionProcessorStub($entities_count) { + $actionProcessor = $this->getMockBuilder('Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessor') + ->disableOriginalConstructor() + ->getMock(); + + $actionProcessor->expects($this->any()) + ->method('populateQueue') + ->will($this->returnValue($entities_count)); + + $actionProcessor->expects($this->any()) + ->method('process') + ->will($this->returnCallback(function () use ($entities_count) { + $return = []; + for ($i = 0; $i < $entities_count; $i++) { + $return[] = 'Some action'; + } + return $return; + })); + + return $actionProcessor; + } + + /** + * Tests the getBatch() method. + * + * @covers ::getBatch + */ + public function testGetBatch() { + $data = [ + 'list' => [[0, 'en', 'node', 1]], + 'some_data' => [], + 'action_label' => '', + ]; + $batch = TestViewsBulkOperationsBatch::getBatch($data); + $this->assertArrayHasKey('title', $batch); + $this->assertArrayHasKey('operations', $batch); + $this->assertArrayHasKey('finished', $batch); + } + + /** + * Tests the operation() method. + * + * @covers ::operation + */ + public function testOperation() { + $batch_size = 2; + $entities_count = 10; + + $this->container->set('views_bulk_operations.processor', $this->getViewsBulkOperationsActionProcessorStub($batch_size)); + + $view = new View(['id' => 'test_view'], 'view'); + $view_storage = $this->getMockBuilder('Drupal\Core\Config\Entity\ConfigEntityStorage') + ->disableOriginalConstructor() + ->getMock(); + $view_storage->expects($this->any()) + ->method('load') + ->with('test_view') + ->will($this->returnValue($view)); + + $entity_manager = $this->getMock('Drupal\Core\Entity\EntityManagerInterface'); + $entity_manager->expects($this->any()) + ->method('getStorage') + ->with('view') + ->will($this->returnValue($view_storage)); + $this->container->set('entity.manager', $entity_manager); + + $executable = $this->getMockBuilder('Drupal\views\ViewExecutable') + ->disableOriginalConstructor() + ->getMock(); + + $executable->result = []; + + // We set only $batch_size entities because + // $view->setItemsPerPage will not have effect. + for ($i = 0; $i < $batch_size; $i++) { + $row = new \stdClass(); + $row->_entity = new \stdClass(); + $executable->result[] = $row; + } + + $viewExecutableFactory = $this->getMockBuilder('Drupal\views\ViewExecutableFactory') + ->disableOriginalConstructor() + ->getMock(); + $viewExecutableFactory->expects($this->any()) + ->method('get') + ->will($this->returnValue($executable)); + $this->container->set('views.executable', $viewExecutableFactory); + + $data = [ + 'view_id' => 'test_view', + 'display_id' => 'test_display', + 'batch_size' => $batch_size, + 'list' => [], + ]; + $context = [ + 'sandbox' => [ + 'processed' => 0, + 'total' => $entities_count, + ], + ]; + + TestViewsBulkOperationsBatch::operation($data, $context); + + $this->assertEquals(count($context['results']['operations']), $batch_size); + $this->assertEquals($context['finished'], ($batch_size / $entities_count)); + } + + /** + * Tests the finished() method. + * + * @covers ::finished + */ + public function testFinished() { + $results = ['operations' => ['Some operation', 'Some operation']]; + TestViewsBulkOperationsBatch::finished(TRUE, $results, []); + $this->assertEquals(TestViewsBulkOperationsBatch::message(), 'Action processing results: Some operation (2).'); + + $results = ['operations' => ['Some operation1', 'Some operation2']]; + TestViewsBulkOperationsBatch::finished(TRUE, $results, []); + $this->assertEquals(TestViewsBulkOperationsBatch::message(), 'Action processing results: Some operation1 (1), Some operation2 (1).'); + } + +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..9a2d0057ff8432655c8d677883ca9849a2abefa1 --- /dev/null +++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test.yml @@ -0,0 +1,232 @@ +langcode: en +status: true +dependencies: + module: + - node + - user + - views_bulk_operations +id: views_bulk_operations_test +label: 'Views Bulk Operations Test' +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: full + options: + items_per_page: 4 + offset: 0 + id: 0 + total_pages: null + tags: + previous: ‹‹ + next: ›› + first: '« First' + last: 'Last »' + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + quantity: 9 + style: + type: table + row: + type: fields + fields: + title: + id: title + table: node_field_data + field: title + entity_type: node + entity_field: title + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + settings: + link_to_entity: true + plugin_id: field + relationship: none + group_type: group + admin_label: '' + label: Title + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + click_sort_column: value + type: string + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + views_bulk_operations_bulk_form: + id: views_bulk_operations_bulk_form + table: views + field: views_bulk_operations_bulk_form + relationship: none + group_type: group + admin_label: '' + label: 'Views bulk operations' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + batch: false + batch_size: 10 + form_step: true + buttons: true + action_title: Action + selected_actions: + views_bulk_operations_simple_test_action: views_bulk_operations_simple_test_action + views_bulk_operations_advanced_test_action: views_bulk_operations_advanced_test_action + preconfiguration: + views_bulk_operations_simple_test_action: + label_override: 'Simple test action' + preconfig: 'Test setting' + views_bulk_operations_advanced_test_action: + label_override: '' + preconfig: 'Test setting' + plugin_id: views_bulk_operations_bulk_form + filters: { } + sorts: + created: + id: created + table: node_field_data + field: created + relationship: none + group_type: group + admin_label: '' + order: DESC + exposed: false + expose: + label: '' + granularity: second + entity_type: node + entity_field: created + plugin_id: date + title: 'Views Bulk Operations Test' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: views-bulk-operations-test + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } 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 new file mode 100644 index 0000000000000000000000000000000000000000..67805a381b99262d1edd2784cc3673ac7a14b74d --- /dev/null +++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/config/install/views.view.views_bulk_operations_test_advanced.yml @@ -0,0 +1,270 @@ +langcode: en +status: true +dependencies: + module: + - node + - user + - views_bulk_operations +id: views_bulk_operations_test_advanced +label: 'Views Bulk Operations Advanced Test' +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + cache: + type: none + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: mini + options: + items_per_page: 4 + offset: 1 + id: 0 + total_pages: null + tags: + previous: ‹‹ + next: ›› + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + style: + type: table + row: + type: fields + fields: + title: + id: title + table: node_field_data + field: title + entity_type: node + entity_field: title + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + settings: + link_to_entity: true + plugin_id: field + relationship: none + group_type: group + admin_label: '' + label: Title + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + click_sort_column: value + type: string + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + views_bulk_operations_bulk_form: + id: views_bulk_operations_bulk_form + table: views + field: views_bulk_operations_bulk_form + relationship: none + group_type: group + admin_label: '' + label: 'Views bulk operations' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + batch: true + batch_size: 2 + form_step: true + buttons: false + action_title: Action + selected_actions: + views_bulk_operations_simple_test_action: views_bulk_operations_simple_test_action + views_bulk_operations_advanced_test_action: views_bulk_operations_advanced_test_action + views_bulk_operations_passing_test_action: views_bulk_operations_passing_test_action + preconfiguration: + views_bulk_operations_simple_test_action: + label_override: 'Simple test action' + preconfig: 'Test setting' + views_bulk_operations_advanced_test_action: + label_override: '' + test_preconfig: 'Test setting' + views_bulk_operations_passing_test_action: + label_override: '' + plugin_id: views_bulk_operations_bulk_form + filters: + status: + id: status + table: node_field_data + field: status + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: '1' + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: node + entity_field: status + plugin_id: boolean + sorts: + created: + id: created + table: node_field_data + field: created + relationship: none + group_type: group + admin_label: '' + order: DESC + exposed: false + expose: + label: '' + granularity: second + entity_type: node + entity_field: created + plugin_id: date + title: 'Views Bulk Operations Test' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: views-bulk-operations-test-advanced + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } 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 new file mode 100644 index 0000000000000000000000000000000000000000..b5670268800a868498dce15e776ec04153a09e81 --- /dev/null +++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsAdvancedTestAction.php @@ -0,0 +1,98 @@ +<?php + +namespace Drupal\views_bulk_operations_test\Plugin\Action; + +use Drupal\views_bulk_operations\Action\ViewsBulkOperationsActionBase; +use Drupal\views_bulk_operations\Action\ViewsBulkOperationsPreconfigurationInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Plugin\PluginFormInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\views\ViewExecutable; + +/** + * Action for test purposes only. + * + * @Action( + * id = "views_bulk_operations_advanced_test_action", + * label = @Translation("VBO advanced test action"), + * type = "", + * confirm = TRUE, + * requirements = { + * "_permission" = "execute advanced test action", + * }, + * ) + */ +class ViewsBulkOperationsAdvancedTestAction extends ViewsBulkOperationsActionBase implements ViewsBulkOperationsPreconfigurationInterface, PluginFormInterface { + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + // Check if $this->view is an instance of ViewsExecutable. + if (!($this->view instanceof ViewExecutable)) { + throw new \Exception('View passed to action object is not an instance of \Drupal\views\ViewExecutable.'); + } + + // Check if context array has been passed to the action. + if (empty($this->context)) { + throw new \Exception('Context array empty in action object.'); + } + + drupal_set_message(sprintf('Test action (preconfig: %s, config: %s, label: %s)', + $this->configuration['test_preconfig'], + $this->configuration['test_config'], + $entity->label() + )); + + // Unpublish entity. + if ($this->configuration['test_config'] === 'unpublish') { + if (!$entity->isDefaultTranslation()) { + $entity = \Drupal::service('entity_type.manager')->getStorage('node')->load($entity->id()); + } + $entity->setPublished(FALSE); + $entity->save(); + } + + return 'Test'; + } + + /** + * {@inheritdoc} + */ + public function buildPreConfigurationForm(array $element, array $values, FormStateInterface $form_state) { + $element['test_preconfig'] = [ + '#title' => $this->t('Preliminary configuration'), + '#type' => 'textfield', + '#default_value' => isset($values['preconfig']) ? $values['preconfig'] : '', + ]; + return $element; + } + + /** + * Configuration form builder. + * + * @param array $form + * Form array. + * @param Drupal\Core\Form\FormStateInterface $form_state + * The form state object. + * + * @return array + * The configuration form. + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form['test_config'] = [ + '#title' => t('Config'), + '#type' => 'textfield', + '#default_value' => $form_state->getValue('config'), + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + return $object->access('update', $account, $return_as_object); + } + +} diff --git a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsPassTestAction.php b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsPassTestAction.php new file mode 100644 index 0000000000000000000000000000000000000000..c2c0eecb5a013c3e928dd7581d4a15a2ebf07f8b --- /dev/null +++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsPassTestAction.php @@ -0,0 +1,71 @@ +<?php + +namespace Drupal\views_bulk_operations_test\Plugin\Action; + +use Drupal\views_bulk_operations\Action\ViewsBulkOperationsActionBase; +use Drupal\Core\Session\AccountInterface; + +/** + * Action for test purposes only. + * + * @Action( + * id = "views_bulk_operations_passing_test_action", + * label = @Translation("VBO parameters passing test action"), + * type = "node", + * ) + */ +class ViewsBulkOperationsPassTestAction extends ViewsBulkOperationsActionBase { + + /** + * {@inheritdoc} + */ + public function executeMultiple(array $nodes) { + if (!empty($this->context['sandbox'])) { + drupal_set_message(sprintf( + 'Processed %s of %s.', + $this->context['sandbox']['processed'], + $this->context['sandbox']['total'] + )); + } + + // Check if the passed view result rows contain the correct nodes. + if (empty($this->context['sandbox']['result_pass_error'])) { + $this->view->result = array_values($this->view->result); + foreach ($nodes as $index => $node) { + $result_node = $this->view->result[$index]->_entity; + if ( + $node->id() !== $result_node->id() || + $node->label() !== $result_node->label() + ) { + $this->context['sandbox']['result_pass_error'] = TRUE; + } + } + } + + $batch_size = isset($this->context['sandbox']['batch_size']) ? $this->context['sandbox']['batch_size'] : 0; + $total = isset($this->context['sandbox']['total']) ? $this->context['sandbox']['total'] : 0; + $processed = isset($this->context['sandbox']['processed']) ? $this->context['sandbox']['processed'] : 0; + + // On last batch display message if passed rows match. + if ($processed + $batch_size >= $total) { + if (empty($this->context['sandbox']['result_pass_error'])) { + drupal_set_message('Passed view results match the entity queue.'); + } + } + } + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + $this->executeMultiple([$entity]); + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + return $object->access('update', $account, $return_as_object); + } + +} diff --git a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsSimpleTestAction.php b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsSimpleTestAction.php new file mode 100644 index 0000000000000000000000000000000000000000..a8e0d33886da442666ee29ef1e858a5ce804504a --- /dev/null +++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/src/Plugin/Action/ViewsBulkOperationsSimpleTestAction.php @@ -0,0 +1,37 @@ +<?php + +namespace Drupal\views_bulk_operations_test\Plugin\Action; + +use Drupal\views_bulk_operations\Action\ViewsBulkOperationsActionBase; +use Drupal\Core\Session\AccountInterface; + +/** + * Action for test purposes only. + * + * @Action( + * id = "views_bulk_operations_simple_test_action", + * label = @Translation("VBO simple test action"), + * type = "node" + * ) + */ +class ViewsBulkOperationsSimpleTestAction extends ViewsBulkOperationsActionBase { + + /** + * {@inheritdoc} + */ + public function execute($entity = NULL) { + drupal_set_message(sprintf('Test action (preconfig: %s, label: %s)', + $this->configuration['preconfig'], + $entity->label() + )); + return 'Test'; + } + + /** + * {@inheritdoc} + */ + public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) { + return $object->access('update', $account, $return_as_object); + } + +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..b990f79660fdb401782195cd497623e46f632567 --- /dev/null +++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/views_bulk_operations_test.info.yml @@ -0,0 +1,14 @@ +name: 'Views Bulk Operations test' +type: module +description: 'Support module for testing Views Bulk Operations.' +package: Testing +# core: 8.x +dependencies: + - drupal:views_bulk_operations + - drupal:node + +# Information added by Drupal.org packaging script on 2018-07-02 +version: '8.x-2.4' +core: '8.x' +project: 'views_bulk_operations' +datestamp: 1530516826 diff --git a/web/modules/views_bulk_operations/tests/views_bulk_operations_test/views_bulk_operations_test.permissions.yml b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/views_bulk_operations_test.permissions.yml new file mode 100644 index 0000000000000000000000000000000000000000..0fa2f9e7bef92577277c9660965100c9c68aeb7f --- /dev/null +++ b/web/modules/views_bulk_operations/tests/views_bulk_operations_test/views_bulk_operations_test.permissions.yml @@ -0,0 +1,2 @@ +execute advanced test action: + title: 'Execute advanced test action' diff --git a/web/modules/views_bulk_operations/views_bulk_operations.drush.inc b/web/modules/views_bulk_operations/views_bulk_operations.drush.inc new file mode 100644 index 0000000000000000000000000000000000000000..4593d13f58f4cf606e594752219be1e9e158d597 --- /dev/null +++ b/web/modules/views_bulk_operations/views_bulk_operations.drush.inc @@ -0,0 +1,207 @@ +<?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 delimeter, 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 executtion 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 new file mode 100644 index 0000000000000000000000000000000000000000..f78de2e72707e0f3ef065ad4280ad3303df73a33 --- /dev/null +++ b/web/modules/views_bulk_operations/views_bulk_operations.info.yml @@ -0,0 +1,13 @@ +type: module +name: 'Views Bulk Operations' +description: 'Adds an ability to perform bulk operations on selected entities from view results.' +package: 'Views' +# core: 8.x +dependencies: + - drupal:views (>=8.4) + +# Information added by Drupal.org packaging script on 2018-07-02 +version: '8.x-2.4' +core: '8.x' +project: 'views_bulk_operations' +datestamp: 1530516826 diff --git a/web/modules/views_bulk_operations/views_bulk_operations.libraries.yml b/web/modules/views_bulk_operations/views_bulk_operations.libraries.yml new file mode 100644 index 0000000000000000000000000000000000000000..bed306fba03904e50c448fe7a3bafc52aaf913b8 --- /dev/null +++ b/web/modules/views_bulk_operations/views_bulk_operations.libraries.yml @@ -0,0 +1,20 @@ +frontUi: + version: 1.0 + js: + js/frontUi.js: {} + css: + component: + css/frontUi.css: {} + dependencies: + - core/drupal + - core/jquery + - core/jquery.once + +adminUi: + version: 1.0 + js: + js/adminUi.js: {} + dependencies: + - core/drupal + - core/jquery + - core/jquery.once diff --git a/web/modules/views_bulk_operations/views_bulk_operations.module b/web/modules/views_bulk_operations/views_bulk_operations.module new file mode 100644 index 0000000000000000000000000000000000000000..74791d9f291200748b112a4f1bbadc2a70967dc4 --- /dev/null +++ b/web/modules/views_bulk_operations/views_bulk_operations.module @@ -0,0 +1,47 @@ +<?php + +/** + * @file + * Allows operations to be performed on items selected in a view. + */ + +use Drupal\Core\Routing\RouteMatchInterface; + +/** + * Implements hook_views_data_alter(). + */ +function views_bulk_operations_views_data_alter(&$data) { + $data['views']['views_bulk_operations_bulk_form'] = [ + 'title' => t('Views bulk operations'), + 'help' => t("Process entities returned by the view with Views Bulk Operations' actions."), + 'field' => [ + 'id' => 'views_bulk_operations_bulk_form', + ], + ]; +} + +/** + * Implements hook_help(). + */ +function views_bulk_operations_help($route_name, RouteMatchInterface $route_match) { + switch ($route_name) { + case 'help.page.views_bulk_operations': + $filepath = dirname(__FILE__) . '/README.txt'; + if (file_exists($filepath)) { + $readme = file_get_contents($filepath); + $output = '<pre>' . $readme . '</pre>'; + + return $output; + } + } +} + +/** + * Implements hook_preprocess_THEME(). + */ +function views_bulk_operations_preprocess_views_view_table(&$variables) { + if (!empty($variables['view']->style_plugin->options['views_bulk_operations_enabled'])) { + // Add module own class to improve resistance to theme overrides. + $variables['attributes']['class'][] = 'vbo-table'; + } +} diff --git a/web/modules/views_bulk_operations/views_bulk_operations.routing.yml b/web/modules/views_bulk_operations/views_bulk_operations.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..36649a0ee7e7cf3a4232c537a1d1da3f6dfac3c2 --- /dev/null +++ b/web/modules/views_bulk_operations/views_bulk_operations.routing.yml @@ -0,0 +1,32 @@ +views_bulk_operations.execute_batch: + path: '/views-bulk-operations/execute/{view_id}/{display_id}' + defaults: + _controller: '\Drupal\views_bulk_operations\Controller\ViewsBulkOperationsController::execute' + _title: 'Views Bulk Operations batch starter' + requirements: + _views_bulk_operation_access: 'TRUE' +views_bulk_operations.update_selection: + path: '/views-bulk-operations/ajax/{view_id}/{display_id}' + defaults: + _controller: '\Drupal\views_bulk_operations\Controller\ViewsBulkOperationsController::updateSelection' + _title: 'Views Bulk Operations multipage AJAX' + requirements: + _views_bulk_operation_access: 'TRUE' +views_bulk_operations.execute_configurable: + path: '/views-bulk-operations/configure/{view_id}/{display_id}' + defaults: + _form: '\Drupal\views_bulk_operations\Form\ConfigureAction' + _title: 'Views Bulk Operations configure step' + requirements: + _views_bulk_operation_access: 'TRUE' + options: + _admin_route: TRUE +views_bulk_operations.confirm: + path: '/views-bulk-operations/confirm/{view_id}/{display_id}' + defaults: + _form: '\Drupal\views_bulk_operations\Form\ConfirmAction' + _title: 'Views Bulk Operations confirm execution' + requirements: + _views_bulk_operation_access: 'TRUE' + options: + _admin_route: TRUE diff --git a/web/modules/views_bulk_operations/views_bulk_operations.services.yml b/web/modules/views_bulk_operations/views_bulk_operations.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..ef82b7ab8cc2abde7dabd4150d8afd515bc52e48 --- /dev/null +++ b/web/modules/views_bulk_operations/views_bulk_operations.services.yml @@ -0,0 +1,20 @@ +services: + views_bulk_operations.data: + class: Drupal\views_bulk_operations\Service\ViewsBulkOperationsViewData + arguments: ['@event_dispatcher'] + views_bulk_operations.processor: + class: Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionProcessor + arguments: ['@views_bulk_operations.data', '@plugin.manager.views_bulk_operations_action', '@current_user', '@module_handler'] + plugin.manager.views_bulk_operations_action: + class: Drupal\views_bulk_operations\Service\ViewsBulkOperationsActionManager + arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@event_dispatcher'] + views_bulk_operations.access: + class: Drupal\views_bulk_operations\Access\ViewsBulkOperationsAccess + arguments: ['@user.private_tempstore'] + tags: + - { name: access_check, applies_to: _views_bulk_operation_access } + views_bulk_operations.view_data_provider: + class: Drupal\views_bulk_operations\EventSubscriber\ViewsBulkOperationsEventSubscriber + arguments: ['@views_bulk_operations.data'] + tags: + - { name: event_subscriber }