bulk_edit_terms-8.x-1.1/src/Form/NodeSelectTerms.php
src/Form/NodeSelectTerms.php
<?php
declare(strict_types=1);
namespace Drupal\bulk_edit_terms\Form;
use Drupal\bulk_edit_terms\UpdateAction;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Entity\EntityTypeManager;
use Drupal\Core\Field\FieldDefinitionInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\Url;
use Drupal\node\NodeInterface;
use Drupal\taxonomy\TermStorageInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Provides a form for bulk editing term assignments on nodes.
*/
class NodeSelectTerms extends ConfirmFormBase {
/**
* The array of nodes to edit terms from.
*
* @var array
*/
protected array $nodes = [];
public function __construct(
protected PrivateTempStoreFactory $tempStoreFactory,
protected EntityTypeManager $entityTypeManager,
protected AccountInterface $currentUser,
protected TimeInterface $time,
) {}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container): static {
return new static(
$container->get('tempstore.private'),
$container->get('entity_type.manager'),
$container->get('current_user'),
$container->get('datetime.time'),
);
}
/**
* {@inheritdoc}
*/
public function getFormId(): string {
return 'node_select_terms';
}
/**
* {@inheritdoc}
*/
public function getQuestion(): TranslatableMarkup {
return $this->t('Which taxonomy term assignments do you want to update?');
}
/**
* {@inheritdoc}
*/
public function getCancelUrl(): Url {
return new Url('system.admin_content');
}
/**
* {@inheritdoc}
*/
public function getConfirmText(): TranslatableMarkup {
return $this->t('Update terms');
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state): array|Response {
$node_ids = $this->tempStoreFactory->get('node_edit_terms')->get($this->currentUser->id());
if (empty($node_ids)) {
return new RedirectResponse($this->getCancelUrl()->setAbsolute()->toString());
}
$this->nodes = $this->loadNodes($node_ids);
$term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
assert($term_storage instanceof TermStorageInterface);
// Gather all taxonomy fields from the nodes and offer the options available
// to select.
$term_reference_fields = [];
foreach ($this->nodes as $node) {
assert($node instanceof NodeInterface);
$fields = $node->getFieldDefinitions();
foreach ($fields as $name => $field) {
assert($field instanceof FieldDefinitionInterface);
if ($field->getType() !== 'entity_reference' || !str_starts_with($name, 'field_')) {
continue;
}
$field_settings = $field->getSettings();
if ($field_settings['target_type'] !== 'taxonomy_term') {
continue;
}
if (!$node->get($name)->access('edit', $this->currentUser, FALSE)) {
continue;
}
if (!isset($term_reference_fields[$name])) {
$term_reference_fields[$name] = [
'used_on_bundles' => [],
'field_instance' => $field,
];
}
if (!in_array($node->bundle(), $term_reference_fields[$name]['used_on_bundles'])) {
$term_reference_fields[$name]['used_on_bundles'][] = $node->bundle();
}
}
}
$config = $this->config('bulk_edit_terms.settings');
// Present a fieldset for each field to allow user to specify what action
// to take.
$content_type_manager = $this->entityTypeManager->getStorage('node_type');
foreach ($term_reference_fields as $name => $data) {
// Convert bundle machine names to their proper labels.
$bundles = $data['used_on_bundles'];
array_walk($bundles, function (&$bundle) use ($content_type_manager) {
$bundle = $content_type_manager->load($bundle)->label();
});
$field = $data['field_instance'];
$field_info = $field->getFieldStorageDefinition();
$field_settings = $field->getSettings();
$target_bundles = $field_settings['handler_settings']['target_bundles'];
$auto_create = $field->getSettings()['handler_settings']['auto_create'] ?? FALSE;
$single_value_only = $field_info->getCardinality() === 1;
$form["{$name}_group"] = [
'#type' => 'fieldset',
'#title' => $field->getLabel() . ' (' . $this->t('used on') . ' ' . implode(', ', $bundles) . ')',
];
if ($single_value_only && $target_bundles && !$auto_create) {
$form["{$name}_group"]["{$name}_action"] = [
'#type' => 'radios',
'#title' => $this->t('Action'),
'#options' => [
UpdateAction::None->value => $this->t('No changes'),
UpdateAction::Replace->value => $this->t('Replace with selected term'),
UpdateAction::Clear->value => $this->t('Clear existing term'),
],
'#default_value' => UpdateAction::None->value,
'#required' => TRUE,
];
// Search terms and create select element.
$form["{$name}_group"][$name] = [
'#type' => 'select',
'#title' => $this->t('Term'),
'#options' => $this->getTermsAsSelectOptions($target_bundles),
'#empty_option' => $this->t('- Select -'),
'#states' => [
'visible' => [
'input[name="' . $name . '_action"]' => ['value' => UpdateAction::Replace->value],
],
],
];
}
else {
$form["{$name}_group"]["{$name}_action"] = [
'#type' => 'radios',
'#title' => $this->t('Action'),
'#options' => [
UpdateAction::None->value => $this->t('No changes'),
UpdateAction::Replace->value => $this->t('Replace all existing term(s) with selected term(s)'),
UpdateAction::Clear->value => $this->t('Clear all existing term(s)'),
UpdateAction::Append->value => $this->t('Add selected term(s)'),
UpdateAction::Remove->value => $this->t('Remove selected term(s)'),
],
'#default_value' => UpdateAction::None->value,
'#required' => TRUE,
];
$states = [
'invisible' => [
[
'input[name="' . $name . '_action"]' => ['value' => UpdateAction::Clear->value],
],
[
'input[name="' . $name . '_action"]' => ['value' => UpdateAction::None->value],
],
],
];
if ($config->get('multi_value_widget_type') === 'entity_autocomplete') {
$form["{$name}_group"][$name] = [
'#type' => 'entity_autocomplete',
'#target_type' => $field_settings['target_type'],
'#title' => $this->t('Term(s)'),
'#default_value' => FALSE,
'#tags' => TRUE,
'#selection_settings' => [
'target_bundles' => $target_bundles,
],
'#states' => $states,
];
if ($auto_create) {
$auto_create_bundle = $field_settings['handler_settings']['auto_create_bundle'] ?? NULL;
if ($auto_create_bundle) {
$form[$name]['#autocreate'] = [
'bundle' => $auto_create_bundle,
];
}
}
}
else {
$form["{$name}_group"][$name] = [
'#type' => 'select',
'#title' => $this->t('Term(s)'),
'#options' => $this->getTermsAsSelectOptions($target_bundles),
'#multiple' => TRUE,
'#states' => $states,
];
}
}
}
// Back out now if we don't have any term reference fields that can be
// modified.
if (empty($term_reference_fields)) {
$form['warning'] = [
'#prefix' => '<p>',
'#suffix' => '</p>',
'#markup' => $this->t('No term reference fields were found in the selected nodes.'),
];
return $form;
}
$form['intro'] = [
'#prefix' => '<p>',
'#suffix' => '</p>',
'#markup' => $this->t('The following fields appear in the content types of at least one of the selected items.'),
'#weight' => -1,
];
$form['create_revision'] = [
'#type' => 'checkbox',
'#title' => $this->t('Create new revision for each update'),
'#default_value' => TRUE,
];
$form['revision_log_message'] = [
'#type' => 'textarea',
'#title' => $this->t('Revision log message'),
'#rows' => 4,
'#states' => [
'visible' => [
'input[name="create_revision"]' => ['checked' => TRUE],
],
],
];
// Store list of field names we presented on the form so we can access
// them in the form submit.
$form_state->set('field_names', array_keys($term_reference_fields));
return parent::buildForm($form, $form_state);
}
/**
* Get list of terms in the given vids suitable for select form options.
*
* @param array $vids
* The list of taxonomy vocabularies to include.
*
* @return array
* The term options.
*/
protected function getTermsAsSelectOptions(array $vids): array {
$options = [];
$term_storage = $this->entityTypeManager->getStorage('taxonomy_term');
$vocab_storage = $this->entityTypeManager->getStorage('taxonomy_vocabulary');
assert($term_storage instanceof TermStorageInterface);
foreach ($vids as $vid) {
$vocab = $vocab_storage->load($vid);
$tree = $term_storage->loadTree($vid);
if (!empty($tree)) {
foreach ($tree as $item) {
$label = str_repeat('-', $item->depth) . $item->name;
if (count($vids) > 1) {
// Group terms by their vocabulary.
$options[$vocab->label()][$item->tid] = $label;
}
else {
$options[$item->tid] = $label;
}
}
}
}
return $options;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state): void {
foreach ($form_state->get('field_names') as $fieldName) {
$field_value = $form_state->getValue($fieldName);
$field_action = $form_state->getValue($fieldName . '_action');
if (empty($field_action) && !empty($field_value)) {
$form_state->setErrorByName($fieldName, $this->t('Select an action for each field you provided a value for.'));
}
$allowedEmptyActions = [
UpdateAction::None->value,
UpdateAction::Clear->value,
];
if (empty($field_value) && !empty($field_action) && !in_array($field_action, $allowedEmptyActions)) {
$form_state->setErrorByName($fieldName, $this->t('You must provide a value for each field.'));
}
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
if ($form_state->getValue('confirm') && !empty($this->nodes)) {
$updated_nodes_count = 0;
foreach ($this->nodes as $node) {
assert($node instanceof NodeInterface);
$changes_made_to_node = FALSE;
foreach ($form_state->get('field_names') as $fieldName) {
// Skip if this node doesn't have this field.
$field = $node->getFieldDefinition($fieldName);
if (!$field instanceof FieldDefinitionInterface) {
continue;
}
// Skip if there's no action to take for this field.
$field_value = $form_state->getValue($fieldName);
$field_action = $form_state->getValue($fieldName . '_action');
if (empty($field_action)) {
continue;
}
// Skip if user has no access to update the field on this node.
if (!$node->get($fieldName)->access('edit', $this->currentUser, FALSE)) {
continue;
}
// The field value may be an array of target_ids if the form widget
// was an entity autocomplete. Normalize to just a list of term
// IDs if so.
$submitted_tids = [];
if (is_array($field_value)) {
foreach ($field_value as $val) {
$submitted_tids[] = (int) ($val['target_id'] ?? $val);
}
}
else {
$field_value = (int) $field_value;
}
sort($submitted_tids);
$existing_tids = array_map(fn($val) => (int) $val['target_id'], $node->get($fieldName)->getValue());
sort($existing_tids);
$changes_made_to_field_value = FALSE;
$value_to_set = NULL;
$field_action = UpdateAction::from($field_action);
switch ($field_action) {
case UpdateAction::Clear:
if (!$node->get($fieldName)->isEmpty()) {
$value_to_set = [];
$changes_made_to_field_value = TRUE;
}
break;
case UpdateAction::Replace:
// Only take action if there was no existing value or the existing
// value doesn't match the new value.
if ($node->get($fieldName)->isEmpty() || $existing_tids != $submitted_tids) {
$value_to_set = $field_value;
$changes_made_to_field_value = TRUE;
}
break;
case UpdateAction::Remove:
$value_to_set = array_diff($existing_tids, $submitted_tids);
if ($value_to_set !== $existing_tids) {
$changes_made_to_field_value = TRUE;
}
break;
case UpdateAction::Append:
$value_to_set = array_merge($existing_tids, $submitted_tids);
$value_to_set = array_unique($value_to_set);
if ($value_to_set !== $existing_tids) {
$changes_made_to_field_value = TRUE;
}
break;
default:
break;
}
if ($changes_made_to_field_value) {
$node->set($fieldName, $value_to_set);
$changes_made_to_node = TRUE;
}
}
if ($changes_made_to_node) {
if ($form_state->getValue('create_revision')) {
$msg = $form_state->getValue('revision_log_message');
if (!empty($msg)) {
$node->set('revision_log', $msg);
}
$node->setNewRevision();
$node->setRevisionUserId($this->currentUser->id());
$node->setRevisionCreationTime($this->time->getCurrentTime());
}
$node->save();
$updated_nodes_count++;
}
}
$this->messenger()->addStatus($this->formatPlural($updated_nodes_count, '1 node was updated.', '@count nodes were updated.'));
}
}
/**
* Load latest revisions of the provided node ids.
*
* It's important we load the latest version instead of the default revision.
* Sites using content moderation may have forward drafts of a node which
* should be edited, not the default/published version. Basically, we want
* to mimic what would be edited had the user edited the node directly via
* the node form.
*
* @param array $nids
* The node ids.
*
* @return array
* The loaded nodes.
*/
protected function loadNodes(array $nids): array {
$node_storage = $this->entityTypeManager->getStorage('node');
$nodes = [];
foreach ($nids as $nid) {
$latest_vid = $node_storage->getLatestRevisionId($nid);
$nodes[] = $node_storage->loadRevision($latest_vid);
}
return $nodes;
}
}
