diff --git a/core/modules/comment/config/optional/views.view.comment.yml b/core/modules/comment/config/optional/views.view.comment.yml index a700325..f6cb0d9 100644 --- a/core/modules/comment/config/optional/views.view.comment.yml +++ b/core/modules/comment/config/optional/views.view.comment.yml @@ -4,6 +4,9 @@ dependencies: module: - comment - user + config: + - system.action.comment_delete_action + - system.action.comment_unpublish_action id: comment label: Comments module: comment @@ -75,6 +78,9 @@ display: selected_actions: - comment_delete_action - comment_unpublish_action + actions_order: + - comment_unpublish_action + - comment_delete_action subject: id: subject table: comment_field_data @@ -953,6 +959,9 @@ display: selected_actions: - comment_delete_action - comment_publish_action + actions_order: + - comment_publish_action + - comment_delete_action subject: id: subject table: comment_field_data diff --git a/core/modules/media/config/optional/views.view.media.yml b/core/modules/media/config/optional/views.view.media.yml index 0f2cba9..7253497 100644 --- a/core/modules/media/config/optional/views.view.media.yml +++ b/core/modules/media/config/optional/views.view.media.yml @@ -3,6 +3,10 @@ status: true dependencies: config: - image.style.thumbnail + - system.action.media_delete_action + - system.action.media_publish_action + - system.action.media_save_action + - system.action.media_unpublish_action module: - image - media @@ -76,6 +80,11 @@ display: action_title: Action include_exclude: exclude selected_actions: { } + actions_order: + - media_publish_action + - media_save_action + - media_unpublish_action + - media_delete_action thumbnail__target_id: id: thumbnail__target_id table: media_field_data diff --git a/core/modules/media_library/config/install/views.view.media_library.yml b/core/modules/media_library/config/install/views.view.media_library.yml index 329f0aa..25b378c 100644 --- a/core/modules/media_library/config/install/views.view.media_library.yml +++ b/core/modules/media_library/config/install/views.view.media_library.yml @@ -4,6 +4,10 @@ dependencies: config: - core.entity_view_mode.media.media_library - image.style.media_library + - system.action.media_delete_action + - system.action.media_publish_action + - system.action.media_save_action + - system.action.media_unpublish_action module: - image - media @@ -81,6 +85,11 @@ display: action_title: Action include_exclude: exclude selected_actions: { } + actions_order: + - media_publish_action + - media_save_action + - media_unpublish_action + - media_delete_action rendered_entity: id: rendered_entity table: media @@ -542,6 +551,11 @@ display: action_title: Action include_exclude: exclude selected_actions: { } + actions_order: + - media_publish_action + - media_save_action + - media_unpublish_action + - media_delete_action name: id: name table: media_field_data diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/MediaOverviewTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/MediaOverviewTest.php index e3eaab2..5b4fdc9 100644 --- a/core/modules/media_library/tests/src/FunctionalJavascript/MediaOverviewTest.php +++ b/core/modules/media_library/tests/src/FunctionalJavascript/MediaOverviewTest.php @@ -101,6 +101,7 @@ public function testAdministrationPage() { // This tests that anchor tags clicked inside the preview are suppressed. $this->getSession()->executeScript('jQuery(".js-click-to-select-trigger a")[4].click()'); + $this->getSession()->getPage()->selectFieldOption('Action', 'Delete media'); $this->submitForm([], 'Apply to selected items'); $assert_session->pageTextContains('Dog'); $assert_session->pageTextNotContains('Cat'); diff --git a/core/modules/node/config/optional/views.view.content.yml b/core/modules/node/config/optional/views.view.content.yml index a784f21..3f8c2b4 100644 --- a/core/modules/node/config/optional/views.view.content.yml +++ b/core/modules/node/config/optional/views.view.content.yml @@ -4,6 +4,15 @@ dependencies: module: - node - user + config: + - system.action.node_delete_action + - system.action.node_make_sticky_action + - system.action.node_make_unsticky_action + - system.action.node_promote_action + - system.action.node_publish_action + - system.action.node_save_action + - system.action.node_unpromote_action + - system.action.node_unpublish_action id: content label: Content module: node @@ -36,6 +45,18 @@ display: hide_empty: false empty_zero: false hide_alter_empty: true + action_title: Action + include_exclude: exclude + selected_actions: { } + actions_order: + - node_make_sticky_action + - node_make_unsticky_action + - node_promote_action + - node_publish_action + - node_save_action + - node_unpromote_action + - node_unpublish_action + - node_delete_action title: id: title table: node_field_data diff --git a/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_bulk_form.yml b/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_bulk_form.yml index 78536b8..c86db96 100644 --- a/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_bulk_form.yml +++ b/core/modules/node/tests/modules/node_test_views/test_views/views.view.test_node_bulk_form.yml @@ -3,6 +3,8 @@ status: true dependencies: module: - node + config: + - system.action.node_delete_action id: test_node_bulk_form label: '' module: views @@ -28,6 +30,11 @@ display: field: node_bulk_form plugin_id: node_bulk_form entity_type: node + action_title: Action + include_exclude: exclude + selected_actions: { } + actions_order: + - node_delete_action title: id: title table: node_field_data diff --git a/core/modules/user/config/optional/views.view.user_admin_people.yml b/core/modules/user/config/optional/views.view.user_admin_people.yml index d49503f..5f77998 100644 --- a/core/modules/user/config/optional/views.view.user_admin_people.yml +++ b/core/modules/user/config/optional/views.view.user_admin_people.yml @@ -69,6 +69,10 @@ display: hide_empty: false empty_zero: false hide_alter_empty: true + action_title: Action + include_exclude: exclude + selected_actions: { } + actions_order: [] name: id: name table: users_field_data diff --git a/core/modules/views/config/schema/views.data_types.schema.yml b/core/modules/views/config/schema/views.data_types.schema.yml index 4c88562..363f214 100644 --- a/core/modules/views/config/schema/views.data_types.schema.yml +++ b/core/modules/views/config/schema/views.data_types.schema.yml @@ -849,3 +849,9 @@ views_field_bulk_form: sequence: type: string label: 'Action' + actions_order: + type: sequence + label: 'Actions order' + sequence: + type: string + label: 'Action' diff --git a/core/modules/views/src/Plugin/views/field/BulkForm.php b/core/modules/views/src/Plugin/views/field/BulkForm.php index 04c58e4..0b37cb1 100644 --- a/core/modules/views/src/Plugin/views/field/BulkForm.php +++ b/core/modules/views/src/Plugin/views/field/BulkForm.php @@ -12,7 +12,9 @@ use Drupal\Core\Messenger\MessengerInterface; use Drupal\Core\Routing\RedirectDestinationTrait; use Drupal\Core\TypedData\TranslatableInterface; +use Drupal\system\ActionConfigEntityInterface; use Drupal\views\Entity\Render\EntityTranslationRenderTrait; +use Drupal\views\Plugin\DependentWithRemovalPluginInterface; use Drupal\views\Plugin\views\display\DisplayPluginBase; use Drupal\views\Plugin\views\style\Table; use Drupal\views\ResultRow; @@ -24,7 +26,7 @@ * * @ViewsField("bulk_form") */ -class BulkForm extends FieldPluginBase implements CacheableDependencyInterface { +class BulkForm extends FieldPluginBase implements CacheableDependencyInterface, DependentWithRemovalPluginInterface { use RedirectDestinationTrait; use UncacheableFieldHandlerTrait; @@ -200,6 +202,9 @@ protected function defineOptions() { $options['selected_actions'] = [ 'default' => [], ]; + $options['actions_order'] = [ + 'default' => [], + ]; return $options; } @@ -223,13 +228,49 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { ], '#default_value' => $this->options['include_exclude'], ]; + $actions = $this->getBulkOptions(FALSE); $form['selected_actions'] = [ '#type' => 'checkboxes', '#title' => $this->t('Selected actions'), - '#options' => $this->getBulkOptions(FALSE), + '#options' => $actions, '#default_value' => $this->options['selected_actions'], ]; + $form['actions_order'] = [ + '#type' => 'table', + '#title' => $this->t('Actions order'), + '#tabledrag' => [ + [ + 'action' => 'order', + 'relationship' => 'sibling', + 'group' => 'action-order-weight', + ], + ], + '#tree' => FALSE, + '#input' => FALSE, + '#theme_wrappers' => ['form_element'], + ]; + + // If there is an user defined order, apply it. + $this->orderActions($actions); + $weight = 0; + foreach ($actions as $action_id => $action_label) { + $form['actions_order'][$action_id]['#attributes']['class'][] = 'draggable'; + $form['actions_order'][$action_id]['#weight'] = $weight++; + $form['actions_order'][$action_id]['action'] = [ + '#markup' => $action_label, + ]; + $form['actions_order'][$action_id]['weight'] = [ + '#type' => 'weight', + '#title' => $this->t('Weight for @title', ['@title' => $action_label]), + '#title_display' => 'invisible', + '#delta' => 50, + '#default_value' => $weight, + '#parents' => ['options', 'actions_order', $action_id, 'weight'], + '#attributes' => ['class' => ['action-order-weight']], + ]; + + } parent::buildOptionsForm($form, $form_state); } @@ -241,6 +282,11 @@ public function validateOptionsForm(&$form, FormStateInterface $form_state) { $selected_actions = $form_state->getValue(['options', 'selected_actions']); $form_state->setValue(['options', 'selected_actions'], array_values(array_filter($selected_actions))); + $actions_order = array_map(function (array $weight): int { + return (int) $weight['weight']; + }, $form_state->getValue(['options', 'actions_order'])); + asort($actions_order); + $form_state->setValue(['options', 'actions_order'], array_keys($actions_order)); } /** @@ -318,7 +364,7 @@ public function viewsForm(&$form, FormStateInterface $form_state) { $form['header'][$this->options['id']]['action'] = [ '#type' => 'select', '#title' => $this->options['action_title'], - '#options' => $this->getBulkOptions(), + '#options' => $this->getBulkOptions(TRUE, TRUE), ]; // Duplicate the form actions into the action container in the header. @@ -335,14 +381,22 @@ public function viewsForm(&$form, FormStateInterface $form_state) { * * @param bool $filtered * (optional) Whether to filter actions to selected actions. + * @param bool $ordered + * (optional) Whether the actions are ordered by user preference. * * @return array * An associative array of operations, suitable for a select element. */ - protected function getBulkOptions($filtered = TRUE) { + protected function getBulkOptions($filtered = TRUE, bool $ordered = FALSE) { $options = []; + + $actions = $this->actions; + if ($ordered) { + $this->orderActions($actions); + } + // Filter the action list. - foreach ($this->actions as $id => $action) { + foreach ($actions as $id => $action) { if ($filtered) { $in_selected = in_array($id, $this->options['selected_actions']); // If the field is configured to include only the selected actions, @@ -468,6 +522,54 @@ public function clickSortable() { } /** + * {@inheritdoc} + */ + public function calculateDependencies() { + $dependencies = parent::calculateDependencies(); + + $configured_action_ids = array_unique(array_merge($this->options['selected_actions'], $this->options['actions_order'])); + if ($configured_action_ids) { + // Narrow down to configured actions. + $actions = array_intersect_key($this->actions, array_flip($configured_action_ids)); + foreach ($actions as $action) { + $plugin_provider = $action->getPluginDefinition()['provider']; + if (!in_array($plugin_provider, $dependencies['module'], TRUE)) { + // Depend on the module that provided the action plugin. + $dependencies['module'][] = $plugin_provider; + } + // Depend on the action config entity. + $dependencies[$action->getConfigDependencyKey()][] = $action->getConfigDependencyName(); + } + } + + return $dependencies; + } + + /** + * {@inheritdoc} + */ + public function onDependencyRemoval(array $dependencies): bool { + $remove = FALSE; + + foreach ($dependencies['config'] as $removed_config) { + if ($removed_config instanceof ActionConfigEntityInterface) { + foreach (['selected_actions', 'actions_order'] as $option) { + if (in_array($removed_config->id(), $this->options[$option], TRUE)) { + // If the removed action is listed either in selected_actions or in + // actions_order options, instruct the View entity to remove the + // handler and disable the view. + // @see \Drupal\views\Entity\View::onDependencyRemoval() + $remove = TRUE; + break 2; + } + } + } + } + + return $remove; + } + + /** * Calculates a bulk form key. * * This generates a key that is used as the checkbox return value when @@ -536,4 +638,17 @@ protected function loadEntityFromBulkFormKey($bulk_form_key) { return $entity; } + /** + * Orders a list of actions based on user preference, if any. + * + * @param array $actions + * The list of actions, keyed by action ID, to be ordered. + */ + protected function orderActions(array &$actions): void { + // Only order if there is a user specified order. + if ($actions && $this->options['actions_order']) { + $actions = array_merge(array_flip($this->options['actions_order']), $actions); + } + } + } diff --git a/core/modules/views/src/ViewsConfigUpdater.php b/core/modules/views/src/ViewsConfigUpdater.php index dfb89e3..c5c389e 100644 --- a/core/modules/views/src/ViewsConfigUpdater.php +++ b/core/modules/views/src/ViewsConfigUpdater.php @@ -10,6 +10,7 @@ use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\Sql\DefaultTableMapping; +use Drupal\views\Plugin\views\field\BulkForm; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -514,4 +515,45 @@ protected function processSortFieldIdentifierUpdateHandler(array &$handler, stri return FALSE; } + /** + * Updates the bulk form fields by adding the actions order configuration. + * + * @param \Drupal\views\ViewEntityInterface $view + * The View to update. + * + * @return bool + * Whether the view was updated. + */ + public function needsBulkFormActionsOrderUpdate(ViewEntityInterface $view): bool { + return $this->processDisplayHandlers($view, FALSE, function (array &$handler, string $handler_type): bool { + return $this->processBulkFormActionsOrderUpdate($handler, $handler_type); + }); + } + + /** + * Processes the bulk form fields by adding the actions order configuration. + * + * @param array $handler + * A display handler. + * @param string $handler_type + * The handler type. + * + * @return bool + * Whether the handler was updated. + */ + protected function processBulkFormActionsOrderUpdate(array &$handler, string $handler_type): bool { + if ($handler_type === 'field' && !isset($handler['actions_order']) && isset($handler['plugin_id'])) { + /** @var \Drupal\views\Plugin\ViewsPluginManager $plugin_manager */ + $plugin_manager = \Drupal::service('plugin.manager.views.field'); + if ($plugin_manager->hasDefinition($handler['plugin_id'])) { + $definition = $plugin_manager->getDefinition($handler['plugin_id']); + if (is_subclass_of($definition['class'], BulkForm::class)) { + $handler['actions_order'] = []; + return TRUE; + } + } + } + return FALSE; + } + } diff --git a/core/modules/views/tests/modules/action_bulk_test/config/install/views.view.test_bulk_form.yml b/core/modules/views/tests/modules/action_bulk_test/config/install/views.view.test_bulk_form.yml index 1766576..528eff9 100644 --- a/core/modules/views/tests/modules/action_bulk_test/config/install/views.view.test_bulk_form.yml +++ b/core/modules/views/tests/modules/action_bulk_test/config/install/views.view.test_bulk_form.yml @@ -4,6 +4,15 @@ dependencies: module: - node - user + config: + - system.action.node_delete_action + - system.action.node_make_sticky_action + - system.action.node_make_unsticky_action + - system.action.node_promote_action + - system.action.node_publish_action + - system.action.node_save_action + - system.action.node_unpromote_action + - system.action.node_unpublish_action id: test_bulk_form label: form module: views @@ -89,6 +98,18 @@ display: hide_empty: false empty_zero: false hide_alter_empty: true + action_title: Action + include_exclude: exclude + selected_actions: { } + actions_order: + - node_make_sticky_action + - node_make_unsticky_action + - node_promote_action + - node_publish_action + - node_save_action + - node_unpromote_action + - node_unpublish_action + - node_delete_action pager: type: full options: diff --git a/core/modules/views/tests/src/Functional/Plugin/ViewsBulkTest.php b/core/modules/views/tests/src/Functional/Plugin/ViewsBulkTest.php index edabcb0..52987e5 100644 --- a/core/modules/views/tests/src/Functional/Plugin/ViewsBulkTest.php +++ b/core/modules/views/tests/src/Functional/Plugin/ViewsBulkTest.php @@ -63,7 +63,8 @@ public function testBulkSelection() { // Now click 'Apply to selected items' and assert the first node is selected // on the confirm form. - $this->submitForm(['node_bulk_form[0]' => TRUE], 'Apply to selected items'); + $edit = ['node_bulk_form[0]' => TRUE, 'action' => 'node_delete_action']; + $this->submitForm($edit, 'Apply to selected items'); $this->assertSession()->pageTextContains($node_1->getTitle()); $this->assertSession()->pageTextNotContains($node_2->getTitle()); @@ -81,7 +82,8 @@ public function testBulkSelection() { // Now click 'Apply to selected items' and assert the second node is // selected on the confirm form. - $this->submitForm(['node_bulk_form[1]' => TRUE], 'Apply to selected items'); + $edit = ['node_bulk_form[1]' => TRUE, 'action' => 'node_delete_action']; + $this->submitForm($edit, 'Apply to selected items'); $this->assertSession()->pageTextContains($node_1->getTitle()); $this->assertSession()->pageTextNotContains($node_3->getTitle()); } diff --git a/core/modules/views/tests/src/Functional/Update/ViewsBulkFormActionsOrderUpdate.php b/core/modules/views/tests/src/Functional/Update/ViewsBulkFormActionsOrderUpdate.php new file mode 100644 index 0000000..a634c81 --- /dev/null +++ b/core/modules/views/tests/src/Functional/Update/ViewsBulkFormActionsOrderUpdate.php @@ -0,0 +1,40 @@ +databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.0.0.bare.standard.php.gz', + ]; + } + + /** + * Tests views_post_update_bulk_form_action_order(). + * + * @see views_post_update_bulk_form_action_order() + */ + public function testBulkFormActionsOrderPostUpdate(): void { + $config_factory = \Drupal::configFactory(); + $path = 'display.default.display_options.fields.user_bulk_form'; + $config = $config_factory->get('views.view.user_admin_people'); + $this->assertArrayNotHasKey('actions_order', $config->get($path)); + $this->runUpdates(); + $config = $config_factory->get('views.view.user_admin_people'); + $this->assertArrayHasKey('actions_order', $config->get($path)); + $this->assertSame([], $config->get("$path.actions_order")); + } + +} diff --git a/core/modules/views/tests/src/Kernel/Handler/FieldBulkFormDependencyTest.php b/core/modules/views/tests/src/Kernel/Handler/FieldBulkFormDependencyTest.php new file mode 100644 index 0000000..2dae60d --- /dev/null +++ b/core/modules/views/tests/src/Kernel/Handler/FieldBulkFormDependencyTest.php @@ -0,0 +1,84 @@ +installConfig(['node']); + if ($import_test_views) { + ViewTestData::createTestViews(static::class, ['node_test_views']); + } + } + + /** + * Tests bulk form field dependencies. + */ + public function testDependencies(): void { + /** @var \Drupal\views\ViewEntityInterface $view */ + $view = View::load('test_node_bulk_form'); + + $this->assertSame([ + 'system.action.node_delete_action', + ], $view->getDependencies()['config']); + + $display =& $view->getDisplay('default'); + $display['display_options']['fields']['node_bulk_form']['selected_actions'] = [ + 'node_delete_action', + 'node_make_sticky_action', + ]; + $display['display_options']['fields']['node_bulk_form']['actions_order'] = [ + 'node_make_sticky_action', + 'node_delete_action', + ]; + + $view->save(); + + // Reload the view to check that the new dependency has been added. + $view = View::load('test_node_bulk_form'); + $this->assertSame([ + 'system.action.node_delete_action', + 'system.action.node_make_sticky_action', + ], $view->getDependencies()['config']); + + // Delete a dependent action and reload the view. + Action::load('node_delete_action')->delete(); + $view = View::load('test_node_bulk_form'); + + // Check that the handler has been removed and the view has been disabled. + // @see \Drupal\views\Entity\View::onDependencyRemoval() + $this->assertArrayNotHasKey('node_bulk_form', $view->toArray()['display']['default']['display_options']['fields']); + $this->assertFalse($view->status()); + } + +} diff --git a/core/modules/views/views.post_update.php b/core/modules/views/views.post_update.php index b540736..ad07ee7 100644 --- a/core/modules/views/views.post_update.php +++ b/core/modules/views/views.post_update.php @@ -94,3 +94,14 @@ function views_post_update_sort_identifier(?array &$sandbox = NULL): void { function views_post_update_provide_revision_table_relationship() { // Empty post-update hook. } + +/** + * Add the actions order option to all bulk form field configurations. + */ +function views_post_update_bulk_form_action_order(?array &$sandbox = NULL): void { + /** @var \Drupal\views\ViewsConfigUpdater $view_config_updater */ + $view_config_updater = \Drupal::classResolver(ViewsConfigUpdater::class); + \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', function (ViewEntityInterface $view) use ($view_config_updater): bool { + return $view_config_updater->needsBulkFormActionsOrderUpdate($view); + }); +} diff --git a/core/profiles/demo_umami/config/optional/views.view.media.yml b/core/profiles/demo_umami/config/optional/views.view.media.yml index 0f2cba9..e505057 100644 --- a/core/profiles/demo_umami/config/optional/views.view.media.yml +++ b/core/profiles/demo_umami/config/optional/views.view.media.yml @@ -76,6 +76,11 @@ display: action_title: Action include_exclude: exclude selected_actions: { } + actions_order: + - media_publish_action + - media_save_action + - media_unpublish_action + - media_delete_action thumbnail__target_id: id: thumbnail__target_id table: media_field_data