1<?php 2 3namespace Drupal\taxonomy\Form; 4 5use Drupal\Component\Utility\Unicode; 6use Drupal\Core\Access\AccessResult; 7use Drupal\Core\Entity\EntityRepositoryInterface; 8use Drupal\Core\Entity\EntityTypeManagerInterface; 9use Drupal\Core\Form\FormBase; 10use Drupal\Core\Extension\ModuleHandlerInterface; 11use Drupal\Core\Form\FormStateInterface; 12use Drupal\Core\Pager\PagerManagerInterface; 13use Drupal\Core\Render\RendererInterface; 14use Drupal\Core\Url; 15use Drupal\taxonomy\VocabularyInterface; 16use Symfony\Component\DependencyInjection\ContainerInterface; 17 18/** 19 * Provides terms overview form for a taxonomy vocabulary. 20 * 21 * @internal 22 */ 23class OverviewTerms extends FormBase { 24 25 /** 26 * The module handler service. 27 * 28 * @var \Drupal\Core\Extension\ModuleHandlerInterface 29 */ 30 protected $moduleHandler; 31 32 /** 33 * The entity type manager. 34 * 35 * @var \Drupal\Core\Entity\EntityTypeManagerInterface 36 */ 37 protected $entityTypeManager; 38 39 /** 40 * The term storage handler. 41 * 42 * @var \Drupal\taxonomy\TermStorageInterface 43 */ 44 protected $storageController; 45 46 /** 47 * The term list builder. 48 * 49 * @var \Drupal\Core\Entity\EntityListBuilderInterface 50 */ 51 protected $termListBuilder; 52 53 /** 54 * The renderer service. 55 * 56 * @var \Drupal\Core\Render\RendererInterface 57 */ 58 protected $renderer; 59 60 /** 61 * The entity repository. 62 * 63 * @var \Drupal\Core\Entity\EntityRepositoryInterface 64 */ 65 protected $entityRepository; 66 67 /** 68 * The pager manager. 69 * 70 * @var \Drupal\Core\Pager\PagerManagerInterface 71 */ 72 protected $pagerManager; 73 74 /** 75 * Constructs an OverviewTerms object. 76 * 77 * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler 78 * The module handler service. 79 * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager 80 * The entity type manager service. 81 * @param \Drupal\Core\Render\RendererInterface $renderer 82 * The renderer service. 83 * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository 84 * The entity repository. 85 * @param \Drupal\Core\Pager\PagerManagerInterface $pager_manager 86 * The pager manager. 87 */ 88 public function __construct(ModuleHandlerInterface $module_handler, EntityTypeManagerInterface $entity_type_manager, RendererInterface $renderer, EntityRepositoryInterface $entity_repository, PagerManagerInterface $pager_manager) { 89 $this->moduleHandler = $module_handler; 90 $this->entityTypeManager = $entity_type_manager; 91 $this->storageController = $entity_type_manager->getStorage('taxonomy_term'); 92 $this->termListBuilder = $entity_type_manager->getListBuilder('taxonomy_term'); 93 $this->renderer = $renderer; 94 $this->entityRepository = $entity_repository; 95 $this->pagerManager = $pager_manager; 96 } 97 98 /** 99 * {@inheritdoc} 100 */ 101 public static function create(ContainerInterface $container) { 102 return new static( 103 $container->get('module_handler'), 104 $container->get('entity_type.manager'), 105 $container->get('renderer'), 106 $container->get('entity.repository'), 107 $container->get('pager.manager') 108 ); 109 } 110 111 /** 112 * {@inheritdoc} 113 */ 114 public function getFormId() { 115 return 'taxonomy_overview_terms'; 116 } 117 118 /** 119 * Form constructor. 120 * 121 * Display a tree of all the terms in a vocabulary, with options to edit 122 * each one. The form is made drag and drop by the theme function. 123 * 124 * @param array $form 125 * An associative array containing the structure of the form. 126 * @param \Drupal\Core\Form\FormStateInterface $form_state 127 * The current state of the form. 128 * @param \Drupal\taxonomy\VocabularyInterface $taxonomy_vocabulary 129 * The vocabulary to display the overview form for. 130 * 131 * @return array 132 * The form structure. 133 */ 134 public function buildForm(array $form, FormStateInterface $form_state, VocabularyInterface $taxonomy_vocabulary = NULL) { 135 $form_state->set(['taxonomy', 'vocabulary'], $taxonomy_vocabulary); 136 $vocabulary_hierarchy = $this->storageController->getVocabularyHierarchyType($taxonomy_vocabulary->id()); 137 $parent_fields = FALSE; 138 139 $page = $this->pagerManager->findPage(); 140 // Number of terms per page. 141 $page_increment = $this->config('taxonomy.settings')->get('terms_per_page_admin'); 142 // Elements shown on this page. 143 $page_entries = 0; 144 // Elements at the root level before this page. 145 $before_entries = 0; 146 // Elements at the root level after this page. 147 $after_entries = 0; 148 // Elements at the root level on this page. 149 $root_entries = 0; 150 151 // Terms from previous and next pages are shown if the term tree would have 152 // been cut in the middle. Keep track of how many extra terms we show on 153 // each page of terms. 154 $back_step = NULL; 155 $forward_step = 0; 156 157 // An array of the terms to be displayed on this page. 158 $current_page = []; 159 160 $delta = 0; 161 $term_deltas = []; 162 $tree = $this->storageController->loadTree($taxonomy_vocabulary->id(), 0, NULL, TRUE); 163 $tree_index = 0; 164 do { 165 // In case this tree is completely empty. 166 if (empty($tree[$tree_index])) { 167 break; 168 } 169 $delta++; 170 // Count entries before the current page. 171 if ($page && ($page * $page_increment) > $before_entries && !isset($back_step)) { 172 $before_entries++; 173 continue; 174 } 175 // Count entries after the current page. 176 elseif ($page_entries > $page_increment && isset($complete_tree)) { 177 $after_entries++; 178 continue; 179 } 180 181 // Do not let a term start the page that is not at the root. 182 $term = $tree[$tree_index]; 183 if (isset($term->depth) && ($term->depth > 0) && !isset($back_step)) { 184 $back_step = 0; 185 while ($pterm = $tree[--$tree_index]) { 186 $before_entries--; 187 $back_step++; 188 if ($pterm->depth == 0) { 189 $tree_index--; 190 // Jump back to the start of the root level parent. 191 continue 2; 192 } 193 } 194 } 195 $back_step = isset($back_step) ? $back_step : 0; 196 197 // Continue rendering the tree until we reach the a new root item. 198 if ($page_entries >= $page_increment + $back_step + 1 && $term->depth == 0 && $root_entries > 1) { 199 $complete_tree = TRUE; 200 // This new item at the root level is the first item on the next page. 201 $after_entries++; 202 continue; 203 } 204 if ($page_entries >= $page_increment + $back_step) { 205 $forward_step++; 206 } 207 208 // Finally, if we've gotten down this far, we're rendering a term on this 209 // page. 210 $page_entries++; 211 $term_deltas[$term->id()] = isset($term_deltas[$term->id()]) ? $term_deltas[$term->id()] + 1 : 0; 212 $key = 'tid:' . $term->id() . ':' . $term_deltas[$term->id()]; 213 214 // Keep track of the first term displayed on this page. 215 if ($page_entries == 1) { 216 $form['#first_tid'] = $term->id(); 217 } 218 // Keep a variable to make sure at least 2 root elements are displayed. 219 if ($term->parents[0] == 0) { 220 $root_entries++; 221 } 222 $current_page[$key] = $term; 223 } while (isset($tree[++$tree_index])); 224 225 // Because we didn't use a pager query, set the necessary pager variables. 226 $total_entries = $before_entries + $page_entries + $after_entries; 227 $this->pagerManager->createPager($total_entries, $page_increment); 228 229 // If this form was already submitted once, it's probably hit a validation 230 // error. Ensure the form is rebuilt in the same order as the user 231 // submitted. 232 $user_input = $form_state->getUserInput(); 233 if (!empty($user_input)) { 234 // Get the POST order. 235 $order = array_flip(array_keys($user_input['terms'])); 236 // Update our form with the new order. 237 $current_page = array_merge($order, $current_page); 238 foreach ($current_page as $key => $term) { 239 // Verify this is a term for the current page and set at the current 240 // depth. 241 if (is_array($user_input['terms'][$key]) && is_numeric($user_input['terms'][$key]['term']['tid'])) { 242 $current_page[$key]->depth = $user_input['terms'][$key]['term']['depth']; 243 } 244 else { 245 unset($current_page[$key]); 246 } 247 } 248 } 249 250 $args = [ 251 '%capital_name' => Unicode::ucfirst($taxonomy_vocabulary->label()), 252 '%name' => $taxonomy_vocabulary->label(), 253 ]; 254 if ($this->currentUser()->hasPermission('administer taxonomy') || $this->currentUser()->hasPermission('edit terms in ' . $taxonomy_vocabulary->id())) { 255 switch ($vocabulary_hierarchy) { 256 case VocabularyInterface::HIERARCHY_DISABLED: 257 $help_message = $this->t('You can reorganize the terms in %capital_name using their drag-and-drop handles, and group terms under a parent term by sliding them under and to the right of the parent.', $args); 258 break; 259 260 case VocabularyInterface::HIERARCHY_SINGLE: 261 $help_message = $this->t('%capital_name contains terms grouped under parent terms. You can reorganize the terms in %capital_name using their drag-and-drop handles.', $args); 262 break; 263 264 case VocabularyInterface::HIERARCHY_MULTIPLE: 265 $help_message = $this->t('%capital_name contains terms with multiple parents. Drag and drop of terms with multiple parents is not supported, but you can re-enable drag-and-drop support by editing each term to include only a single parent.', $args); 266 break; 267 } 268 } 269 else { 270 switch ($vocabulary_hierarchy) { 271 case VocabularyInterface::HIERARCHY_DISABLED: 272 $help_message = $this->t('%capital_name contains the following terms.', $args); 273 break; 274 275 case VocabularyInterface::HIERARCHY_SINGLE: 276 $help_message = $this->t('%capital_name contains terms grouped under parent terms', $args); 277 break; 278 279 case VocabularyInterface::HIERARCHY_MULTIPLE: 280 $help_message = $this->t('%capital_name contains terms with multiple parents.', $args); 281 break; 282 } 283 } 284 285 // Get the IDs of the terms edited on the current page which have pending 286 // revisions. 287 $edited_term_ids = array_map(function ($item) { 288 return $item->id(); 289 }, $current_page); 290 $pending_term_ids = array_intersect($this->storageController->getTermIdsWithPendingRevisions(), $edited_term_ids); 291 if ($pending_term_ids) { 292 $help_message = $this->formatPlural( 293 count($pending_term_ids), 294 '%capital_name contains 1 term with pending revisions. Drag and drop of terms with pending revisions is not supported, but you can re-enable drag-and-drop support by getting each term to a published state.', 295 '%capital_name contains @count terms with pending revisions. Drag and drop of terms with pending revisions is not supported, but you can re-enable drag-and-drop support by getting each term to a published state.', 296 $args 297 ); 298 } 299 300 // Only allow access to change parents and reorder the tree if there are no 301 // pending revisions and there are no terms with multiple parents. 302 $update_tree_access = AccessResult::allowedIf(empty($pending_term_ids) && $vocabulary_hierarchy !== VocabularyInterface::HIERARCHY_MULTIPLE); 303 304 $form['help'] = [ 305 '#type' => 'container', 306 'message' => ['#markup' => $help_message], 307 ]; 308 if (!$update_tree_access->isAllowed()) { 309 $form['help']['#attributes']['class'] = ['messages', 'messages--warning']; 310 } 311 312 $errors = $form_state->getErrors(); 313 $row_position = 0; 314 // Build the actual form. 315 $access_control_handler = $this->entityTypeManager->getAccessControlHandler('taxonomy_term'); 316 $create_access = $access_control_handler->createAccess($taxonomy_vocabulary->id(), NULL, [], TRUE); 317 if ($create_access->isAllowed()) { 318 $empty = $this->t('No terms available. <a href=":link">Add term</a>.', [':link' => Url::fromRoute('entity.taxonomy_term.add_form', ['taxonomy_vocabulary' => $taxonomy_vocabulary->id()])->toString()]); 319 } 320 else { 321 $empty = $this->t('No terms available.'); 322 } 323 $form['terms'] = [ 324 '#type' => 'table', 325 '#empty' => $empty, 326 '#header' => [ 327 'term' => $this->t('Name'), 328 'operations' => $this->t('Operations'), 329 'weight' => $update_tree_access->isAllowed() ? $this->t('Weight') : NULL, 330 ], 331 '#attributes' => [ 332 'id' => 'taxonomy', 333 ], 334 ]; 335 $this->renderer->addCacheableDependency($form['terms'], $create_access); 336 337 foreach ($current_page as $key => $term) { 338 $form['terms'][$key] = [ 339 'term' => [], 340 'operations' => [], 341 'weight' => $update_tree_access->isAllowed() ? [] : NULL, 342 ]; 343 /** @var \Drupal\Core\Entity\EntityInterface $term */ 344 $term = $this->entityRepository->getTranslationFromContext($term); 345 $form['terms'][$key]['#term'] = $term; 346 $indentation = []; 347 if (isset($term->depth) && $term->depth > 0) { 348 $indentation = [ 349 '#theme' => 'indentation', 350 '#size' => $term->depth, 351 ]; 352 } 353 $form['terms'][$key]['term'] = [ 354 '#prefix' => !empty($indentation) ? $this->renderer->render($indentation) : '', 355 '#type' => 'link', 356 '#title' => $term->getName(), 357 '#url' => $term->toUrl(), 358 ]; 359 360 // Add a special class for terms with pending revision so we can highlight 361 // them in the form. 362 $form['terms'][$key]['#attributes']['class'] = []; 363 if (in_array($term->id(), $pending_term_ids)) { 364 $form['terms'][$key]['#attributes']['class'][] = 'color-warning'; 365 $form['terms'][$key]['#attributes']['class'][] = 'taxonomy-term--pending-revision'; 366 } 367 368 if ($update_tree_access->isAllowed() && count($tree) > 1) { 369 $parent_fields = TRUE; 370 $form['terms'][$key]['term']['tid'] = [ 371 '#type' => 'hidden', 372 '#value' => $term->id(), 373 '#attributes' => [ 374 'class' => ['term-id'], 375 ], 376 ]; 377 $form['terms'][$key]['term']['parent'] = [ 378 '#type' => 'hidden', 379 // Yes, default_value on a hidden. It needs to be changeable by the 380 // javascript. 381 '#default_value' => $term->parents[0], 382 '#attributes' => [ 383 'class' => ['term-parent'], 384 ], 385 ]; 386 $form['terms'][$key]['term']['depth'] = [ 387 '#type' => 'hidden', 388 // Same as above, the depth is modified by javascript, so it's a 389 // default_value. 390 '#default_value' => $term->depth, 391 '#attributes' => [ 392 'class' => ['term-depth'], 393 ], 394 ]; 395 } 396 $update_access = $term->access('update', NULL, TRUE); 397 $update_tree_access = $update_tree_access->andIf($update_access); 398 399 if ($update_tree_access->isAllowed()) { 400 $form['terms'][$key]['weight'] = [ 401 '#type' => 'weight', 402 '#delta' => $delta, 403 '#title' => $this->t('Weight for added term'), 404 '#title_display' => 'invisible', 405 '#default_value' => $term->getWeight(), 406 '#attributes' => ['class' => ['term-weight']], 407 ]; 408 } 409 410 if ($operations = $this->termListBuilder->getOperations($term)) { 411 $form['terms'][$key]['operations'] = [ 412 '#type' => 'operations', 413 '#links' => $operations, 414 ]; 415 } 416 417 if ($parent_fields) { 418 $form['terms'][$key]['#attributes']['class'][] = 'draggable'; 419 } 420 421 // Add classes that mark which terms belong to previous and next pages. 422 if ($row_position < $back_step || $row_position >= $page_entries - $forward_step) { 423 $form['terms'][$key]['#attributes']['class'][] = 'taxonomy-term-preview'; 424 } 425 426 if ($row_position !== 0 && $row_position !== count($tree) - 1) { 427 if ($row_position == $back_step - 1 || $row_position == $page_entries - $forward_step - 1) { 428 $form['terms'][$key]['#attributes']['class'][] = 'taxonomy-term-divider-top'; 429 } 430 elseif ($row_position == $back_step || $row_position == $page_entries - $forward_step) { 431 $form['terms'][$key]['#attributes']['class'][] = 'taxonomy-term-divider-bottom'; 432 } 433 } 434 435 // Add an error class if this row contains a form error. 436 foreach ($errors as $error_key => $error) { 437 if (strpos($error_key, $key) === 0) { 438 $form['terms'][$key]['#attributes']['class'][] = 'error'; 439 } 440 } 441 $row_position++; 442 } 443 444 $this->renderer->addCacheableDependency($form['terms'], $update_tree_access); 445 if ($update_tree_access->isAllowed()) { 446 if ($parent_fields) { 447 $form['terms']['#tabledrag'][] = [ 448 'action' => 'match', 449 'relationship' => 'parent', 450 'group' => 'term-parent', 451 'subgroup' => 'term-parent', 452 'source' => 'term-id', 453 'hidden' => FALSE, 454 ]; 455 $form['terms']['#tabledrag'][] = [ 456 'action' => 'depth', 457 'relationship' => 'group', 458 'group' => 'term-depth', 459 'hidden' => FALSE, 460 ]; 461 $form['terms']['#attached']['library'][] = 'taxonomy/drupal.taxonomy'; 462 $form['terms']['#attached']['drupalSettings']['taxonomy'] = [ 463 'backStep' => $back_step, 464 'forwardStep' => $forward_step, 465 ]; 466 } 467 $form['terms']['#tabledrag'][] = [ 468 'action' => 'order', 469 'relationship' => 'sibling', 470 'group' => 'term-weight', 471 ]; 472 } 473 474 if ($update_tree_access->isAllowed() && count($tree) > 1) { 475 $form['actions'] = ['#type' => 'actions', '#tree' => FALSE]; 476 $form['actions']['submit'] = [ 477 '#type' => 'submit', 478 '#value' => $this->t('Save'), 479 '#button_type' => 'primary', 480 ]; 481 $form['actions']['reset_alphabetical'] = [ 482 '#type' => 'submit', 483 '#submit' => ['::submitReset'], 484 '#value' => $this->t('Reset to alphabetical'), 485 ]; 486 } 487 488 $form['pager_pager'] = ['#type' => 'pager']; 489 return $form; 490 } 491 492 /** 493 * Form submission handler. 494 * 495 * Rather than using a textfield or weight field, this form depends entirely 496 * upon the order of form elements on the page to determine new weights. 497 * 498 * Because there might be hundreds or thousands of taxonomy terms that need to 499 * be ordered, terms are weighted from 0 to the number of terms in the 500 * vocabulary, rather than the standard -10 to 10 scale. Numbers are sorted 501 * lowest to highest, but are not necessarily sequential. Numbers may be 502 * skipped when a term has children so that reordering is minimal when a child 503 * is added or removed from a term. 504 * 505 * @param array $form 506 * An associative array containing the structure of the form. 507 * @param \Drupal\Core\Form\FormStateInterface $form_state 508 * The current state of the form. 509 */ 510 public function submitForm(array &$form, FormStateInterface $form_state) { 511 // Sort term order based on weight. 512 uasort($form_state->getValue('terms'), ['Drupal\Component\Utility\SortArray', 'sortByWeightElement']); 513 514 $vocabulary = $form_state->get(['taxonomy', 'vocabulary']); 515 $changed_terms = []; 516 $tree = $this->storageController->loadTree($vocabulary->id(), 0, NULL, TRUE); 517 518 if (empty($tree)) { 519 return; 520 } 521 522 // Build a list of all terms that need to be updated on previous pages. 523 $weight = 0; 524 $term = $tree[0]; 525 while ($term->id() != $form['#first_tid']) { 526 if ($term->parents[0] == 0 && $term->getWeight() != $weight) { 527 $term->setWeight($weight); 528 $changed_terms[$term->id()] = $term; 529 } 530 $weight++; 531 $term = $tree[$weight]; 532 } 533 534 // Renumber the current page weights and assign any new parents. 535 $level_weights = []; 536 foreach ($form_state->getValue('terms') as $tid => $values) { 537 if (isset($form['terms'][$tid]['#term'])) { 538 $term = $form['terms'][$tid]['#term']; 539 // Give terms at the root level a weight in sequence with terms on previous pages. 540 if ($values['term']['parent'] == 0 && $term->getWeight() != $weight) { 541 $term->setWeight($weight); 542 $changed_terms[$term->id()] = $term; 543 } 544 // Terms not at the root level can safely start from 0 because they're all on this page. 545 elseif ($values['term']['parent'] > 0) { 546 $level_weights[$values['term']['parent']] = isset($level_weights[$values['term']['parent']]) ? $level_weights[$values['term']['parent']] + 1 : 0; 547 if ($level_weights[$values['term']['parent']] != $term->getWeight()) { 548 $term->setWeight($level_weights[$values['term']['parent']]); 549 $changed_terms[$term->id()] = $term; 550 } 551 } 552 // Update any changed parents. 553 if ($values['term']['parent'] != $term->parents[0]) { 554 $term->parent->target_id = $values['term']['parent']; 555 $changed_terms[$term->id()] = $term; 556 } 557 $weight++; 558 } 559 } 560 561 // Build a list of all terms that need to be updated on following pages. 562 for ($weight; $weight < count($tree); $weight++) { 563 $term = $tree[$weight]; 564 if ($term->parents[0] == 0 && $term->getWeight() != $weight) { 565 $term->parent->target_id = $term->parents[0]; 566 $term->setWeight($weight); 567 $changed_terms[$term->id()] = $term; 568 } 569 } 570 571 if (!empty($changed_terms)) { 572 $pending_term_ids = $this->storageController->getTermIdsWithPendingRevisions(); 573 574 // Force a form rebuild if any of the changed terms has a pending 575 // revision. 576 if (array_intersect_key(array_flip($pending_term_ids), $changed_terms)) { 577 $this->messenger()->addError($this->t('The terms with updated parents have been modified by another user, the changes could not be saved.')); 578 $form_state->setRebuild(); 579 580 return; 581 } 582 583 // Save all updated terms. 584 foreach ($changed_terms as $term) { 585 $term->save(); 586 } 587 588 $this->messenger()->addStatus($this->t('The configuration options have been saved.')); 589 } 590 } 591 592 /** 593 * Redirects to confirmation form for the reset action. 594 */ 595 public function submitReset(array &$form, FormStateInterface $form_state) { 596 /** @var \Drupal\taxonomy\VocabularyInterface $vocabulary */ 597 $vocabulary = $form_state->get(['taxonomy', 'vocabulary']); 598 $form_state->setRedirectUrl($vocabulary->toUrl('reset-form')); 599 } 600 601} 602