Views bulk operations are a great tool for mass content operations in Drupal, and creating a bulk action is pretty simple.

For this post, we’ll set ourselves the task of creating a bulk action for adding tags to the selected nodes. I have created a basic installation for my Drupal 9 Code Examples, and we’ll be using this as a baseline.

Disclaimer this example does not aim to meet all possible implementations but is targeted at the specific installation. This is not contrib, it’s custom.

Action

Annotation

/**
 * VBO Action for adding tags to nodes.
 *
 * @Action(
 *   id = "vbo_examples_add_tags_to_node",
 *   label = @Translation("Add Tags to Node"),
 *   type = "node"
 * )
 */

Annotations have become the backbone of plugins for Drupal 9, and VBO makes use of the @Action annotation implemented in Drupal core (@see Drupal\Core\Annotation\Action). For this example, we use the most basic form by adding an id and label to identify our action. The id should be unique. It’s good practice to prefix your action with the module name because it makes it easier to ensure its uniqueness. Next, we’ll set the type to node, to tell the action system we only want to apply the action to nodes. As stated, we’re only interested in adding tags to nodes.

Class definition

class AddTagsToNodeAction extends ViewsBulkOperationsActionBase implements PluginFormInterface {}

Naming here is easy. We name it AddTagsToNode because that’s what the action does. Naming things after what they do always makes sense because it makes it easier to identify a class. Next, we place it in the src/Plugin/Action directory of our module, and namespace it with Drupal\vbo_examples\Plugin\Action (Drupal\{module_name}\Plugin\Action). The plugin system will look for it in this directory, so placing the action there is important. We’ll extend the ViewsBulkOperationsActionBase class and implement the PluginFormInterface as required for actions with user input, but more on that shortly.

User input

For user input, we’ll add a taxonomy field where the user can find taxonomy terms (tags) to add to the selected nodes. Here we’ll need the PluginFormInterface methods buildConfigurationForm and submitConfigurationForm. To the first method, we’ll add a form field with the type entity_autocomplete and target type taxonomy_term as we’ll be looking for terms. Under selection settings, we’ll specify that we need to look up only terms from the tags vocabulary. If you are unfamiliar with the entity_autocomplete form element you can find more information here.

Upon submitting this form, we’ll update the action config to use as storage for the selected terms. This is a form of temporary storage for the action, and can later be accessed during the execute method.

   /**
    * {@inheritdoc}
    */
    public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
        $form['new_tags'] = [
          '#title' => $this->t('New Tags'),
          '#type' => 'entity_autocomplete',
          '#target_type' => 'taxonomy_term',
          '#tags' => TRUE,
          '#selection_settings' => [
            'target_bundles' => ['tags'],
          ],
        ];

        return $form;
    }

   /**
    * {@inheritdoc}
    */
    public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
        $this->configuration['new_tags'] = $form_state->getValue('new_tags');
    }

Execute/ExecuteMultiple

Next, we’ll need to implement the execute method, where we add the tags to our node entity. You can override the executeMultiple method as well, but by default, this iterates over the chosen nodes and runs execute on them. Since we’re not doing anything fancy in this example, we’ll simply implement the execute method. It only has entity as an argument, and on it, we’ll need to do a little validation. We need to ensure the node has the correct field (in this case field_tags), so we’ll start by checking the entity is a Node (we already set the type, so this just helps with code completion in my IDE) and use the hasField method to check if the field is present. Next, we’ll need to make sure the node only has each tag once (unique tags), and then we’ll add the tags as relations to field_tags and save the node.

  /**
   * {@inheritdoc}
   */
  public function execute($entity = NULL) {
    if ($entity instanceof Node) {
      if ($entity->hasField('field_tags')) {
        $tags = $entity->get('field_tags')->getValue();

        $flat_tags = [];
        foreach ($tags as $tag) {
          $flat_tags[] = $tag['target_id'];
        }

        foreach ($this->configuration['new_tags'] as $new_tag) {
          $flat_tags[] = $new_tag['target_id'];
        }

        $flat_tags = array_unique($flat_tags);

        $new_tags = [];
        foreach ($flat_tags as $flat_tag) {
          $new_tags[] = ['target_id' => $flat_tag];
        }


        $entity->set('field_tags', $new_tags);
        $entity->save();

        return $this->t('Tags were added to the node.');
      }
      else {
        return $this->t('Node %type does not have tags.', ['%type' => $entity->bundle()]);
      }
    }

    return $this->t('Action can only be performed on nodes.');
  }

Access checks

The last method we need to look at is access. As you may have guessed it’s used to determine if a user has access to perform a certain operation on an object. In this case, we simply check if the user is allowed to update a node. If not, the bulk operation will fail.

  /**
   * {@inheritdoc}
   */
  public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) {
    /** @var \Drupal\Core\Access\AccessResultInterface $result */
    $result = $object->access('update', $account, TRUE);

    return $return_as_object ? $result : $result->isAllowed();
  }

Adding bulk actions to views

To use your new bulk action, you need a view with the field Global: Views bulk operations added. You can edit the standard /admin/content view if you wish. I have done so in the example project, to avoid having to make a custom view. VBO Actions don’t show up in the Node operations bulk form, and I’m not sure why. But until I figure it out, it works with the global field.