3 namespace Drupal\views\Plugin\views\field;
5 use Drupal\Core\Cache\CacheableDependencyInterface;
6 use Drupal\Core\Entity\EntityInterface;
7 use Drupal\Core\Entity\EntityManagerInterface;
8 use Drupal\Core\Entity\RevisionableInterface;
9 use Drupal\Core\Form\FormStateInterface;
10 use Drupal\Core\Language\LanguageManagerInterface;
11 use Drupal\Core\Messenger\MessengerInterface;
12 use Drupal\Core\Routing\RedirectDestinationTrait;
13 use Drupal\Core\TypedData\TranslatableInterface;
14 use Drupal\views\Entity\Render\EntityTranslationRenderTrait;
15 use Drupal\views\Plugin\views\display\DisplayPluginBase;
16 use Drupal\views\Plugin\views\style\Table;
17 use Drupal\views\ResultRow;
18 use Drupal\views\ViewExecutable;
19 use Symfony\Component\DependencyInjection\ContainerInterface;
22 * Defines a actions-based bulk operation form element.
24 * @ViewsField("bulk_form")
26 class BulkForm extends FieldPluginBase implements CacheableDependencyInterface {
28 use RedirectDestinationTrait;
29 use UncacheableFieldHandlerTrait;
30 use EntityTranslationRenderTrait;
35 * @var \Drupal\Core\Entity\EntityManagerInterface
37 protected $entityManager;
42 * @var \Drupal\Core\Entity\EntityStorageInterface
44 protected $actionStorage;
47 * An array of actions that can be executed.
49 * @var \Drupal\system\ActionConfigEntityInterface[]
51 protected $actions = [];
54 * The language manager.
56 * @var \Drupal\Core\Language\LanguageManagerInterface
58 protected $languageManager;
63 * @var \Drupal\Core\Messenger\MessengerInterface
68 * Constructs a new BulkForm object.
70 * @param array $configuration
71 * A configuration array containing information about the plugin instance.
72 * @param string $plugin_id
73 * The plugin ID for the plugin instance.
74 * @param mixed $plugin_definition
75 * The plugin implementation definition.
76 * @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
78 * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
79 * The language manager.
80 * @param \Drupal\Core\Messenger\MessengerInterface $messenger
83 * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
85 public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityManagerInterface $entity_manager, LanguageManagerInterface $language_manager, MessengerInterface $messenger) {
86 parent::__construct($configuration, $plugin_id, $plugin_definition);
88 $this->entityManager = $entity_manager;
89 $this->actionStorage = $entity_manager->getStorage('action');
90 $this->languageManager = $language_manager;
91 $this->messenger = $messenger;
97 public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
102 $container->get('entity.manager'),
103 $container->get('language_manager'),
104 $container->get('messenger')
111 public function init(ViewExecutable $view, DisplayPluginBase $display, array &$options = NULL) {
112 parent::init($view, $display, $options);
114 $entity_type = $this->getEntityType();
115 // Filter the actions to only include those for this entity type.
116 $this->actions = array_filter($this->actionStorage->loadMultiple(), function ($action) use ($entity_type) {
117 return $action->getType() == $entity_type;
124 public function getCacheMaxAge() {
125 // @todo Consider making the bulk operation form cacheable. See
126 // https://www.drupal.org/node/2503009.
133 public function getCacheContexts() {
134 return $this->languageManager->isMultilingual() ? $this->getEntityTranslationRenderer()->getCacheContexts() : [];
140 public function getCacheTags() {
147 public function getEntityTypeId() {
148 return $this->getEntityType();
154 protected function getEntityManager() {
155 return $this->entityManager;
161 protected function getLanguageManager() {
162 return $this->languageManager;
168 protected function getView() {
175 protected function defineOptions() {
176 $options = parent::defineOptions();
177 $options['action_title'] = ['default' => $this->t('Action')];
178 $options['include_exclude'] = [
179 'default' => 'exclude',
181 $options['selected_actions'] = [
190 public function buildOptionsForm(&$form, FormStateInterface $form_state) {
191 $form['action_title'] = [
192 '#type' => 'textfield',
193 '#title' => $this->t('Action title'),
194 '#default_value' => $this->options['action_title'],
195 '#description' => $this->t('The title shown above the actions dropdown.'),
198 $form['include_exclude'] = [
200 '#title' => $this->t('Available actions'),
202 'exclude' => $this->t('All actions, except selected'),
203 'include' => $this->t('Only selected actions'),
205 '#default_value' => $this->options['include_exclude'],
207 $form['selected_actions'] = [
208 '#type' => 'checkboxes',
209 '#title' => $this->t('Selected actions'),
210 '#options' => $this->getBulkOptions(FALSE),
211 '#default_value' => $this->options['selected_actions'],
214 parent::buildOptionsForm($form, $form_state);
220 public function validateOptionsForm(&$form, FormStateInterface $form_state) {
221 parent::validateOptionsForm($form, $form_state);
223 $selected_actions = $form_state->getValue(['options', 'selected_actions']);
224 $form_state->setValue(['options', 'selected_actions'], array_values(array_filter($selected_actions)));
230 public function preRender(&$values) {
231 parent::preRender($values);
233 // If the view is using a table style, provide a placeholder for a
234 // "select all" checkbox.
235 if (!empty($this->view->style_plugin) && $this->view->style_plugin instanceof Table) {
236 // Add the tableselect css classes.
237 $this->options['element_label_class'] .= 'select-all';
238 // Hide the actual label of the field on the table header.
239 $this->options['label'] = '';
246 public function getValue(ResultRow $row, $field = NULL) {
247 return '<!--form-item-' . $this->options['id'] . '--' . $row->index . '-->';
251 * Form constructor for the bulk form.
254 * An associative array containing the structure of the form.
255 * @param \Drupal\Core\Form\FormStateInterface $form_state
256 * The current state of the form.
258 public function viewsForm(&$form, FormStateInterface $form_state) {
259 // Make sure we do not accidentally cache this form.
260 // @todo Evaluate this again in https://www.drupal.org/node/2503009.
261 $form['#cache']['max-age'] = 0;
263 // Add the tableselect javascript.
264 $form['#attached']['library'][] = 'core/drupal.tableselect';
265 $use_revision = array_key_exists('revision', $this->view->getQuery()->getEntityTableInfo());
267 // Only add the bulk form options and buttons if there are results.
268 if (!empty($this->view->result)) {
269 // Render checkboxes for all rows.
270 $form[$this->options['id']]['#tree'] = TRUE;
271 foreach ($this->view->result as $row_index => $row) {
272 $entity = $this->getEntityTranslation($this->getEntity($row), $row);
274 $form[$this->options['id']][$row_index] = [
275 '#type' => 'checkbox',
276 // We are not able to determine a main "title" for each row, so we can
277 // only output a generic label.
278 '#title' => $this->t('Update this item'),
279 '#title_display' => 'invisible',
280 '#default_value' => !empty($form_state->getValue($this->options['id'])[$row_index]) ? 1 : NULL,
281 '#return_value' => $this->calculateEntityBulkFormKey($entity, $use_revision),
285 // Replace the form submit button label.
286 $form['actions']['submit']['#value'] = $this->t('Apply to selected items');
288 // Ensure a consistent container for filters/operations in the view header.
290 '#type' => 'container',
294 // Build the bulk operations action widget for the header.
295 // Allow themes to apply .container-inline on this separate container.
296 $form['header'][$this->options['id']] = [
297 '#type' => 'container',
299 $form['header'][$this->options['id']]['action'] = [
301 '#title' => $this->options['action_title'],
302 '#options' => $this->getBulkOptions(),
305 // Duplicate the form actions into the action container in the header.
306 $form['header'][$this->options['id']]['actions'] = $form['actions'];
309 // Remove the default actions build array.
310 unset($form['actions']);
315 * Returns the available operations for this form.
317 * @param bool $filtered
318 * (optional) Whether to filter actions to selected actions.
320 * An associative array of operations, suitable for a select element.
322 protected function getBulkOptions($filtered = TRUE) {
324 // Filter the action list.
325 foreach ($this->actions as $id => $action) {
327 $in_selected = in_array($id, $this->options['selected_actions']);
328 // If the field is configured to include only the selected actions,
329 // skip actions that were not selected.
330 if (($this->options['include_exclude'] == 'include') && !$in_selected) {
333 // Otherwise, if the field is configured to exclude the selected
334 // actions, skip actions that were selected.
335 elseif (($this->options['include_exclude'] == 'exclude') && $in_selected) {
340 $options[$id] = $action->label();
347 * Submit handler for the bulk form.
350 * An associative array containing the structure of the form.
351 * @param \Drupal\Core\Form\FormStateInterface $form_state
352 * The current state of the form.
354 * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
355 * Thrown when the user tried to access an action without access to it.
357 public function viewsFormSubmit(&$form, FormStateInterface $form_state) {
358 if ($form_state->get('step') == 'views_form_views_form') {
359 // Filter only selected checkboxes. Use the actual user input rather than
360 // the raw form values array, since the site data may change before the
361 // bulk form is submitted, which can lead to data loss.
362 $user_input = $form_state->getUserInput();
363 $selected = array_filter($user_input[$this->options['id']]);
365 $action = $this->actions[$form_state->getValue('action')];
368 foreach ($selected as $bulk_form_key) {
369 $entity = $this->loadEntityFromBulkFormKey($bulk_form_key);
371 // Skip execution if the user did not have access.
372 if (!$action->getPlugin()->access($entity, $this->view->getUser())) {
373 $this->messenger->addError($this->t('No access to execute %action on the @entity_type_label %entity_label.', [
374 '%action' => $action->label(),
375 '@entity_type_label' => $entity->getEntityType()->getLabel(),
376 '%entity_label' => $entity->label(),
383 $entities[$bulk_form_key] = $entity;
386 $action->execute($entities);
388 $operation_definition = $action->getPluginDefinition();
389 if (!empty($operation_definition['confirm_form_route_name'])) {
391 'query' => $this->getDestinationArray(),
393 $form_state->setRedirect($operation_definition['confirm_form_route_name'], [], $options);
396 // Don't display the message unless there are some elements affected and
397 // there is no confirmation form.
399 $this->messenger->addStatus($this->formatPlural($count, '%action was applied to @count item.', '%action was applied to @count items.', [
400 '%action' => $action->label(),
408 * Returns the message to be displayed when there are no selected items.
411 * Message displayed when no items are selected.
413 protected function emptySelectedMessage() {
414 return $this->t('No items selected.');
420 public function viewsFormValidate(&$form, FormStateInterface $form_state) {
421 $selected = array_filter($form_state->getValue($this->options['id']));
422 if (empty($selected)) {
423 $form_state->setErrorByName('', $this->emptySelectedMessage());
430 public function query() {
431 if ($this->languageManager->isMultilingual()) {
432 $this->getEntityTranslationRenderer()->query($this->query, $this->relationship);
439 public function clickSortable() {
444 * Calculates a bulk form key.
446 * This generates a key that is used as the checkbox return value when
447 * submitting a bulk form. This key allows the entity for the row to be loaded
448 * totally independently of the executed view row.
450 * @param \Drupal\Core\Entity\EntityInterface $entity
451 * The entity to calculate a bulk form key for.
452 * @param bool $use_revision
453 * Whether the revision id should be added to the bulk form key. This should
454 * be set to TRUE only if the view is listing entity revisions.
457 * The bulk form key representing the entity's id, language and revision (if
458 * applicable) as one string.
460 * @see self::loadEntityFromBulkFormKey()
462 protected function calculateEntityBulkFormKey(EntityInterface $entity, $use_revision) {
463 $key_parts = [$entity->language()->getId(), $entity->id()];
465 if ($entity instanceof RevisionableInterface && $use_revision) {
466 $key_parts[] = $entity->getRevisionId();
469 // An entity ID could be an arbitrary string (although they are typically
470 // numeric). JSON then Base64 encoding ensures the bulk_form_key is
471 // safe to use in HTML, and that the key parts can be retrieved.
472 $key = json_encode($key_parts);
473 return base64_encode($key);
477 * Loads an entity based on a bulk form key.
479 * @param string $bulk_form_key
480 * The bulk form key representing the entity's id, language and revision (if
481 * applicable) as one string.
483 * @return \Drupal\Core\Entity\EntityInterface
484 * The entity loaded in the state (language, optionally revision) specified
485 * as part of the bulk form key.
487 protected function loadEntityFromBulkFormKey($bulk_form_key) {
488 $key = base64_decode($bulk_form_key);
489 $key_parts = json_decode($key);
492 // If there are 3 items, vid will be last.
493 if (count($key_parts) === 3) {
494 $revision_id = array_pop($key_parts);
497 // The first two items will always be langcode and ID.
498 $id = array_pop($key_parts);
499 $langcode = array_pop($key_parts);
501 // Load the entity or a specific revision depending on the given key.
502 $storage = $this->entityManager->getStorage($this->getEntityType());
503 $entity = $revision_id ? $storage->loadRevision($revision_id) : $storage->load($id);
505 if ($entity instanceof TranslatableInterface) {
506 $entity = $entity->getTranslation($langcode);