diff --git a/core/modules/taxonomy/src/Entity/Term.php b/core/modules/taxonomy/src/Entity/Term.php index 8fc3cedb0466a5609d46089d52505d01df94578c..949518a56f2f795bfa263b844a394614823763be 100644 --- a/core/modules/taxonomy/src/Entity/Term.php +++ b/core/modules/taxonomy/src/Entity/Term.php @@ -2,9 +2,7 @@ namespace Drupal\taxonomy\Entity; -use Drupal\Core\Entity\ContentEntityBase; -use Drupal\Core\Entity\EntityChangedTrait; -use Drupal\Core\Entity\EntityPublishedTrait; +use Drupal\Core\Entity\EditorialContentEntityBase; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; @@ -40,16 +38,24 @@ * }, * base_table = "taxonomy_term_data", * data_table = "taxonomy_term_field_data", + * revision_table = "taxonomy_term_revision", + * revision_data_table = "taxonomy_term_field_revision", * uri_callback = "taxonomy_term_uri", * translatable = TRUE, * entity_keys = { * "id" = "tid", + * "revision" = "revision_id", * "bundle" = "vid", * "label" = "name", * "langcode" = "langcode", * "uuid" = "uuid", * "published" = "status", * }, + * revision_metadata_keys = { + * "revision_user" = "revision_user", + * "revision_created" = "revision_created", + * "revision_log_message" = "revision_log_message", + * }, * bundle_entity_type = "taxonomy_vocabulary", * field_ui_base_route = "entity.taxonomy_vocabulary.overview_form", * common_reference_target = TRUE, @@ -59,13 +65,13 @@ * "edit-form" = "/taxonomy/term/{taxonomy_term}/edit", * "create" = "/taxonomy/term", * }, - * permission_granularity = "bundle" + * permission_granularity = "bundle", + * constraints = { + * "TaxonomyHierarchy" = {} + * } * ) */ -class Term extends ContentEntityBase implements TermInterface { - - use EntityChangedTrait; - use EntityPublishedTrait; +class Term extends EditorialContentEntityBase implements TermInterface { /** * {@inheritdoc} @@ -120,8 +126,6 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { /** @var \Drupal\Core\Field\BaseFieldDefinition[] $fields */ $fields = parent::baseFieldDefinitions($entity_type); - // Add the published field. - $fields += static::publishedBaseFieldDefinitions($entity_type); // @todo Remove the usage of StatusItem in // https://www.drupal.org/project/drupal/issues/2936864. $fields['status']->getItemDefinition()->setClass(StatusItem::class); @@ -139,6 +143,7 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['name'] = BaseFieldDefinition::create('string') ->setLabel(t('Name')) ->setTranslatable(TRUE) + ->setRevisionable(TRUE) ->setRequired(TRUE) ->setSetting('max_length', 255) ->setDisplayOptions('view', [ @@ -155,6 +160,7 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['description'] = BaseFieldDefinition::create('text_long') ->setLabel(t('Description')) ->setTranslatable(TRUE) + ->setRevisionable(TRUE) ->setDisplayOptions('view', [ 'label' => 'hidden', 'type' => 'text_default', @@ -181,7 +187,14 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['changed'] = BaseFieldDefinition::create('changed') ->setLabel(t('Changed')) ->setDescription(t('The time that the term was last edited.')) - ->setTranslatable(TRUE); + ->setTranslatable(TRUE) + ->setRevisionable(TRUE); + + // @todo Keep this field hidden until we have a revision UI for terms. + // @see https://www.drupal.org/project/drupal/issues/2936995 + $fields['revision_log_message']->setDisplayOptions('form', [ + 'region' => 'hidden', + ]); return $fields; } diff --git a/core/modules/taxonomy/src/Form/OverviewTerms.php b/core/modules/taxonomy/src/Form/OverviewTerms.php index 504816668d126ea90b1f23150b1c7b5aa8e32dd9..3656a751ae332498c5ef8b027c4da27dcd39d496 100644 --- a/core/modules/taxonomy/src/Form/OverviewTerms.php +++ b/core/modules/taxonomy/src/Form/OverviewTerms.php @@ -2,6 +2,7 @@ namespace Drupal\taxonomy\Form; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Access\AccessResult; use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait; use Drupal\Core\Entity\EntityRepositoryInterface; @@ -251,6 +252,64 @@ public function buildForm(array $form, FormStateInterface $form_state, Vocabular } } + $args = [ + '%capital_name' => Unicode::ucfirst($taxonomy_vocabulary->label()), + '%name' => $taxonomy_vocabulary->label(), + ]; + if ($this->currentUser()->hasPermission('administer taxonomy') || $this->currentUser()->hasPermission('edit terms in ' . $taxonomy_vocabulary->id())) { + switch ($vocabulary_hierarchy) { + case VocabularyInterface::HIERARCHY_DISABLED: + $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); + break; + case VocabularyInterface::HIERARCHY_SINGLE: + $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); + break; + case VocabularyInterface::HIERARCHY_MULTIPLE: + $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); + break; + } + } + else { + switch ($vocabulary_hierarchy) { + case VocabularyInterface::HIERARCHY_DISABLED: + $help_message = $this->t('%capital_name contains the following terms.', $args); + break; + case VocabularyInterface::HIERARCHY_SINGLE: + $help_message = $this->t('%capital_name contains terms grouped under parent terms', $args); + break; + case VocabularyInterface::HIERARCHY_MULTIPLE: + $help_message = $this->t('%capital_name contains terms with multiple parents.', $args); + break; + } + } + + // Get the IDs of the terms edited on the current page which have pending + // revisions. + $edited_term_ids = array_map(function ($item) { + return $item->id(); + }, $current_page); + $pending_term_ids = array_intersect($this->storageController->getTermIdsWithPendingRevisions(), $edited_term_ids); + if ($pending_term_ids) { + $help_message = $this->formatPlural( + count($pending_term_ids), + '%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.', + '%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.', + $args + ); + } + + // Only allow access to change parents and reorder the tree if there are no + // pending revisions and there are no terms with multiple parents. + $update_tree_access = AccessResult::allowedIf(empty($pending_term_ids) && $vocabulary_hierarchy !== VocabularyInterface::HIERARCHY_MULTIPLE); + + $form['help'] = [ + '#type' => 'container', + 'message' => ['#markup' => $help_message], + ]; + if (!$update_tree_access->isAllowed()) { + $form['help']['#attributes']['class'] = ['messages', 'messages--warning']; + } + $errors = $form_state->getErrors(); $row_position = 0; // Build the actual form. @@ -268,7 +327,7 @@ public function buildForm(array $form, FormStateInterface $form_state, Vocabular '#header' => [ 'term' => $this->t('Name'), 'operations' => $this->t('Operations'), - 'weight' => $this->t('Weight'), + 'weight' => $update_tree_access->isAllowed() ? $this->t('Weight') : NULL, ], '#attributes' => [ 'id' => 'taxonomy', @@ -276,14 +335,11 @@ public function buildForm(array $form, FormStateInterface $form_state, Vocabular ]; $this->renderer->addCacheableDependency($form['terms'], $create_access); - // Only allow access to changing weights if the user has update access for - // all terms. - $change_weight_access = AccessResult::allowed(); foreach ($current_page as $key => $term) { $form['terms'][$key] = [ 'term' => [], 'operations' => [], - 'weight' => [], + 'weight' => $update_tree_access->isAllowed() ? [] : NULL, ]; /** @var $term \Drupal\Core\Entity\EntityInterface */ $term = $this->entityRepository->getTranslationFromContext($term); @@ -301,7 +357,16 @@ public function buildForm(array $form, FormStateInterface $form_state, Vocabular '#title' => $term->getName(), '#url' => $term->toUrl(), ]; - if ($vocabulary_hierarchy != VocabularyInterface::HIERARCHY_MULTIPLE && count($tree) > 1) { + + // Add a special class for terms with pending revision so we can highlight + // them in the form. + $form['terms'][$key]['#attributes']['class'] = []; + if (in_array($term->id(), $pending_term_ids)) { + $form['terms'][$key]['#attributes']['class'][] = 'color-warning'; + $form['terms'][$key]['#attributes']['class'][] = 'taxonomy-term--pending-revision'; + } + + if ($update_tree_access->isAllowed() && count($tree) > 1) { $parent_fields = TRUE; $form['terms'][$key]['term']['tid'] = [ '#type' => 'hidden', @@ -330,9 +395,9 @@ public function buildForm(array $form, FormStateInterface $form_state, Vocabular ]; } $update_access = $term->access('update', NULL, TRUE); - $change_weight_access = $change_weight_access->andIf($update_access); + $update_tree_access = $update_tree_access->andIf($update_access); - if ($update_access->isAllowed()) { + if ($update_tree_access->isAllowed()) { $form['terms'][$key]['weight'] = [ '#type' => 'weight', '#delta' => $delta, @@ -350,7 +415,6 @@ public function buildForm(array $form, FormStateInterface $form_state, Vocabular ]; } - $form['terms'][$key]['#attributes']['class'] = []; if ($parent_fields) { $form['terms'][$key]['#attributes']['class'][] = 'draggable'; } @@ -378,8 +442,8 @@ public function buildForm(array $form, FormStateInterface $form_state, Vocabular $row_position++; } - $this->renderer->addCacheableDependency($form['terms'], $change_weight_access); - if ($change_weight_access->isAllowed()) { + $this->renderer->addCacheableDependency($form['terms'], $update_tree_access); + if ($update_tree_access->isAllowed()) { if ($parent_fields) { $form['terms']['#tabledrag'][] = [ 'action' => 'match', @@ -408,7 +472,7 @@ public function buildForm(array $form, FormStateInterface $form_state, Vocabular ]; } - if (($vocabulary_hierarchy !== VocabularyInterface::HIERARCHY_MULTIPLE && count($tree) > 1) && $change_weight_access->isAllowed()) { + if ($update_tree_access->isAllowed() && count($tree) > 1) { $form['actions'] = ['#type' => 'actions', '#tree' => FALSE]; $form['actions']['submit'] = [ '#type' => 'submit', @@ -505,12 +569,25 @@ public function submitForm(array &$form, FormStateInterface $form_state) { } } - // Save all updated terms. - foreach ($changed_terms as $term) { - $term->save(); - } + if (!empty($changed_terms)) { + $pending_term_ids = $this->storageController->getTermIdsWithPendingRevisions(); - $this->messenger()->addStatus($this->t('The configuration options have been saved.')); + // Force a form rebuild if any of the changed terms has a pending + // revision. + if (array_intersect_key(array_flip($pending_term_ids), $changed_terms)) { + $this->messenger()->addError($this->t('The terms with updated parents have been modified by another user, the changes could not be saved.')); + $form_state->setRebuild(); + + return; + } + + // Save all updated terms. + foreach ($changed_terms as $term) { + $term->save(); + } + + $this->messenger()->addStatus($this->t('The configuration options have been saved.')); + } } /** diff --git a/core/modules/taxonomy/src/Plugin/Validation/Constraint/TaxonomyTermHierarchyConstraint.php b/core/modules/taxonomy/src/Plugin/Validation/Constraint/TaxonomyTermHierarchyConstraint.php new file mode 100644 index 0000000000000000000000000000000000000000..93abb07ac8338952bae459268d9be749db9044f5 --- /dev/null +++ b/core/modules/taxonomy/src/Plugin/Validation/Constraint/TaxonomyTermHierarchyConstraint.php @@ -0,0 +1,31 @@ +<?php + +namespace Drupal\taxonomy\Plugin\Validation\Constraint; + +use Drupal\Core\Entity\Plugin\Validation\Constraint\CompositeConstraintBase; + +/** + * Validation constraint for changing the term hierarchy in pending revisions. + * + * @Constraint( + * id = "TaxonomyHierarchy", + * label = @Translation("Taxonomy term hierarchy.", context = "Validation"), + * ) + */ +class TaxonomyTermHierarchyConstraint extends CompositeConstraintBase { + + /** + * The default violation message. + * + * @var string + */ + public $message = 'You can only change the hierarchy for the <em>published</em> version of this term.'; + + /** + * {@inheritdoc} + */ + public function coversFields() { + return ['parent', 'weight']; + } + +} diff --git a/core/modules/taxonomy/src/Plugin/Validation/Constraint/TaxonomyTermHierarchyConstraintValidator.php b/core/modules/taxonomy/src/Plugin/Validation/Constraint/TaxonomyTermHierarchyConstraintValidator.php new file mode 100644 index 0000000000000000000000000000000000000000..4eeb80961bc102d6d9b7f1b328902faf1f5ff27c --- /dev/null +++ b/core/modules/taxonomy/src/Plugin/Validation/Constraint/TaxonomyTermHierarchyConstraintValidator.php @@ -0,0 +1,77 @@ +<?php + +namespace Drupal\taxonomy\Plugin\Validation\Constraint; + +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\taxonomy\TermStorageInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; + +/** + * Constraint validator for changing term parents in pending revisions. + */ +class TaxonomyTermHierarchyConstraintValidator extends ConstraintValidator implements ContainerInjectionInterface { + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + private $entityTypeManager; + + /** + * Creates a new TaxonomyTermHierarchyConstraintValidator instance. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager. + */ + public function __construct(EntityTypeManagerInterface $entity_type_manager) { + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function validate($entity, Constraint $constraint) { + $term_storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); + assert($term_storage instanceof TermStorageInterface); + + // Newly created entities should be able to specify a parent. + if ($entity && $entity->isNew()) { + return; + } + + $is_pending_revision = !$entity->isDefaultRevision(); + $pending_term_ids = $term_storage->getTermIdsWithPendingRevisions(); + $ancestors = $term_storage->loadAllParents($entity->id()); + $ancestor_is_pending_revision = (bool) array_intersect_key($ancestors, array_flip($pending_term_ids)); + + $new_parents = array_column($entity->parent->getValue(), 'target_id'); + $original_parents = array_keys($term_storage->loadParents($entity->id())) ?: [0]; + if (($is_pending_revision || $ancestor_is_pending_revision) && $new_parents != $original_parents) { + $a = 1; + $this->context->buildViolation($constraint->message) + ->atPath('parent') + ->addViolation(); + } + + $original = $term_storage->loadUnchanged($entity->id()); + if (($is_pending_revision || $ancestor_is_pending_revision) && !$entity->weight->equals($original->weight)) { + $this->context->buildViolation($constraint->message) + ->atPath('weight') + ->addViolation(); + } + } + +} diff --git a/core/modules/taxonomy/src/TermForm.php b/core/modules/taxonomy/src/TermForm.php index baca720fde77ddcc847ea052fed8ee41b5e8f172..865b12dec666bf7d5af38b51b51739aab0bb37db 100644 --- a/core/modules/taxonomy/src/TermForm.php +++ b/core/modules/taxonomy/src/TermForm.php @@ -3,6 +3,7 @@ namespace Drupal\taxonomy; use Drupal\Core\Entity\ContentEntityForm; +use Drupal\Core\Entity\EntityConstraintViolationListInterface; use Drupal\Core\Form\FormStateInterface; /** @@ -121,6 +122,31 @@ public function buildEntity(array $form, FormStateInterface $form_state) { return $term; } + /** + * {@inheritdoc} + */ + protected function getEditedFieldNames(FormStateInterface $form_state) { + return array_merge(['parent', 'weight'], parent::getEditedFieldNames($form_state)); + } + + /** + * {@inheritdoc} + */ + protected function flagViolations(EntityConstraintViolationListInterface $violations, array $form, FormStateInterface $form_state) { + // Manually flag violations of fields not handled by the form display. This + // is necessary as entity form displays only flag violations for fields + // contained in the display. + // @see ::form() + foreach ($violations->getByField('parent') as $violation) { + $form_state->setErrorByName('parent', $violation->getMessage()); + } + foreach ($violations->getByField('weight') as $violation) { + $form_state->setErrorByName('weight', $violation->getMessage()); + } + + parent::flagViolations($violations, $form, $form_state); + } + /** * {@inheritdoc} */ diff --git a/core/modules/taxonomy/src/TermInterface.php b/core/modules/taxonomy/src/TermInterface.php index 877c27d205d814419335130f5a0e0399b2221e1e..687691d0b1f07d7126697886eb61bd091c1025c3 100644 --- a/core/modules/taxonomy/src/TermInterface.php +++ b/core/modules/taxonomy/src/TermInterface.php @@ -5,11 +5,12 @@ use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityChangedInterface; use Drupal\Core\Entity\EntityPublishedInterface; +use Drupal\Core\Entity\RevisionLogInterface; /** * Provides an interface defining a taxonomy term entity. */ -interface TermInterface extends ContentEntityInterface, EntityChangedInterface, EntityPublishedInterface { +interface TermInterface extends ContentEntityInterface, EntityChangedInterface, EntityPublishedInterface, RevisionLogInterface { /** * Gets the term description. diff --git a/core/modules/taxonomy/src/TermStorage.php b/core/modules/taxonomy/src/TermStorage.php index 4b74ba8342416314b8b42e2651ae52e8e39455c3..118bdd107a5af39b1b18c7e14ea40673674a4490 100644 --- a/core/modules/taxonomy/src/TermStorage.php +++ b/core/modules/taxonomy/src/TermStorage.php @@ -372,6 +372,38 @@ public function getNodeTerms(array $nids, array $vocabs = [], $langcode = NULL) return $terms; } + /** + * {@inheritdoc} + */ + public function getTermIdsWithPendingRevisions() { + $table_mapping = $this->getTableMapping(); + $id_field = $table_mapping->getColumnNames($this->entityType->getKey('id'))['value']; + $revision_field = $table_mapping->getColumnNames($this->entityType->getKey('revision'))['value']; + $rta_field = $table_mapping->getColumnNames($this->entityType->getKey('revision_translation_affected'))['value']; + $langcode_field = $table_mapping->getColumnNames($this->entityType->getKey('langcode'))['value']; + $revision_default_field = $table_mapping->getColumnNames($this->entityType->getRevisionMetadataKey('revision_default'))['value']; + + $query = $this->database->select($this->getRevisionDataTable(), 'tfr'); + $query->fields('tfr', [$id_field]); + $query->addExpression("MAX(tfr.$revision_field)", $revision_field); + + $query->join($this->getRevisionTable(), 'tr', "tfr.$revision_field = tr.$revision_field AND tr.$revision_default_field = 0"); + + $inner_select = $this->database->select($this->getRevisionDataTable(), 't'); + $inner_select->condition("t.$rta_field", '1'); + $inner_select->fields('t', [$id_field, $langcode_field]); + $inner_select->addExpression("MAX(t.$revision_field)", $revision_field); + $inner_select + ->groupBy("t.$id_field") + ->groupBy("t.$langcode_field"); + + $query->join($inner_select, 'mr', "tfr.$revision_field = mr.$revision_field AND tfr.$langcode_field = mr.$langcode_field"); + + $query->groupBy("tfr.$id_field"); + + return $query->execute()->fetchAllKeyed(1, 0); + } + /** * {@inheritdoc} */ diff --git a/core/modules/taxonomy/src/TermStorageInterface.php b/core/modules/taxonomy/src/TermStorageInterface.php index fa0c36971a32c3d36ce4017a1e70996b30663ce9..4271805ac574d4456fb6025ff99b72759145f51c 100644 --- a/core/modules/taxonomy/src/TermStorageInterface.php +++ b/core/modules/taxonomy/src/TermStorageInterface.php @@ -141,4 +141,15 @@ public function getNodeTerms(array $nids, array $vocabs = [], $langcode = NULL); */ public function getVocabularyHierarchyType($vid); + /** + * Gets a list of term IDs with pending revisions. + * + * @return int[] + * An array of term IDs which have pending revisions, keyed by their + * revision IDs. + * + * @internal + */ + public function getTermIdsWithPendingRevisions(); + } diff --git a/core/modules/taxonomy/taxonomy.module b/core/modules/taxonomy/taxonomy.module index 652ba32d0790bb6907b990b961e7397dd7f44e31..1d724c2535aa6dae5f60fe7a4cc361ad03a4c6bd 100644 --- a/core/modules/taxonomy/taxonomy.module +++ b/core/modules/taxonomy/taxonomy.module @@ -6,7 +6,6 @@ */ use Drupal\Component\Utility\Tags; -use Drupal\Component\Utility\Unicode; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\Sql\SqlContentEntityStorage; use Drupal\Core\Render\Element; @@ -78,33 +77,19 @@ function taxonomy_help($route_name, RouteMatchInterface $route_match) { case 'entity.taxonomy_vocabulary.collection': $output = '<p>' . t('Taxonomy is for categorizing content. Terms are grouped into vocabularies. For example, a vocabulary called "Fruit" would contain the terms "Apple" and "Banana".') . '</p>'; return $output; - - case 'entity.taxonomy_vocabulary.overview_form': - $vocabulary = $route_match->getParameter('taxonomy_vocabulary'); - $vocabulary_hierarchy = \Drupal::entityTypeManager()->getStorage('taxonomy_term')->getVocabularyHierarchyType($vocabulary->id()); - if (\Drupal::currentUser()->hasPermission('administer taxonomy') || \Drupal::currentUser()->hasPermission('edit terms in ' . $vocabulary->id())) { - switch ($vocabulary_hierarchy) { - case VocabularyInterface::HIERARCHY_DISABLED: - return '<p>' . 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.', ['%capital_name' => Unicode::ucfirst($vocabulary->label()), '%name' => $vocabulary->label()]) . '</p>'; - case VocabularyInterface::HIERARCHY_SINGLE: - return '<p>' . t('%capital_name contains terms grouped under parent terms. You can reorganize the terms in %capital_name using their drag-and-drop handles.', ['%capital_name' => Unicode::ucfirst($vocabulary->label()), '%name' => $vocabulary->label()]) . '</p>'; - case VocabularyInterface::HIERARCHY_MULTIPLE: - return '<p>' . 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.', ['%capital_name' => Unicode::ucfirst($vocabulary->label())]) . '</p>'; - } - } - else { - switch ($vocabulary_hierarchy) { - case VocabularyInterface::HIERARCHY_DISABLED: - return '<p>' . t('%capital_name contains the following terms.', ['%capital_name' => Unicode::ucfirst($vocabulary->label())]) . '</p>'; - case VocabularyInterface::HIERARCHY_SINGLE: - return '<p>' . t('%capital_name contains terms grouped under parent terms', ['%capital_name' => Unicode::ucfirst($vocabulary->label())]) . '</p>'; - case VocabularyInterface::HIERARCHY_MULTIPLE: - return '<p>' . t('%capital_name contains terms with multiple parents.', ['%capital_name' => Unicode::ucfirst($vocabulary->label())]) . '</p>'; - } - } } } +/** + * Implements hook_entity_type_alter(). + */ +function taxonomy_entity_type_alter(array &$entity_types) { + // @todo Moderation is disabled for taxonomy terms until when we have an UI + // for them. + // @see https://www.drupal.org/project/drupal/issues/2899923 + $entity_types['taxonomy_term']->setHandlerClass('moderation', ''); +} + /** * Entity URI callback. */ diff --git a/core/modules/taxonomy/taxonomy.post_update.php b/core/modules/taxonomy/taxonomy.post_update.php index 6d84dfd69b08eb592ee49c26ee7bbcd467ea92e8..69fbb3d8648e68e1a9cf6b112ad1d5d4ce435f96 100644 --- a/core/modules/taxonomy/taxonomy.post_update.php +++ b/core/modules/taxonomy/taxonomy.post_update.php @@ -6,6 +6,8 @@ */ use Drupal\Core\Config\Entity\ConfigEntityUpdater; +use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\views\ViewExecutable; /** @@ -134,3 +136,94 @@ function taxonomy_post_update_remove_hierarchy_from_vocabularies(&$sandbox = NUL return TRUE; }); } + +/** + * Update taxonomy terms to be revisionable. + */ +function taxonomy_post_update_make_taxonomy_term_revisionable(&$sandbox) { + $definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + /** @var \Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface $last_installed_schema_repository */ + $last_installed_schema_repository = \Drupal::service('entity.last_installed_schema.repository'); + + $entity_type = $definition_update_manager->getEntityType('taxonomy_term'); + $field_storage_definitions = $last_installed_schema_repository->getLastInstalledFieldStorageDefinitions('taxonomy_term'); + + // Update the entity type definition. + $entity_keys = $entity_type->getKeys(); + $entity_keys['revision'] = 'revision_id'; + $entity_keys['revision_translation_affected'] = 'revision_translation_affected'; + $entity_type->set('entity_keys', $entity_keys); + $entity_type->set('revision_table', 'taxonomy_term_revision'); + $entity_type->set('revision_data_table', 'taxonomy_term_field_revision'); + $revision_metadata_keys = [ + 'revision_default' => 'revision_default', + 'revision_user' => 'revision_user', + 'revision_created' => 'revision_created', + 'revision_log_message' => 'revision_log_message', + ]; + $entity_type->set('revision_metadata_keys', $revision_metadata_keys); + + // Update the field storage definitions and add the new ones required by a + // revisionable entity type. + $field_storage_definitions['langcode']->setRevisionable(TRUE); + $field_storage_definitions['name']->setRevisionable(TRUE); + $field_storage_definitions['description']->setRevisionable(TRUE); + $field_storage_definitions['changed']->setRevisionable(TRUE); + + $field_storage_definitions['revision_id'] = BaseFieldDefinition::create('integer') + ->setName('revision_id') + ->setTargetEntityTypeId('taxonomy_term') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Revision ID')) + ->setReadOnly(TRUE) + ->setSetting('unsigned', TRUE); + + $field_storage_definitions['revision_default'] = BaseFieldDefinition::create('boolean') + ->setName('revision_default') + ->setTargetEntityTypeId('taxonomy_term') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Default revision')) + ->setDescription(new TranslatableMarkup('A flag indicating whether this was a default revision when it was saved.')) + ->setStorageRequired(TRUE) + ->setInternal(TRUE) + ->setTranslatable(FALSE) + ->setRevisionable(TRUE); + + $field_storage_definitions['revision_translation_affected'] = BaseFieldDefinition::create('boolean') + ->setName('revision_translation_affected') + ->setTargetEntityTypeId('taxonomy_term') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Revision translation affected')) + ->setDescription(new TranslatableMarkup('Indicates if the last edit of a translation belongs to current revision.')) + ->setReadOnly(TRUE) + ->setRevisionable(TRUE) + ->setTranslatable(TRUE); + + $field_storage_definitions['revision_created'] = BaseFieldDefinition::create('created') + ->setName('revision_created') + ->setTargetEntityTypeId('taxonomy_term') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Revision create time')) + ->setDescription(new TranslatableMarkup('The time that the current revision was created.')) + ->setRevisionable(TRUE); + $field_storage_definitions['revision_user'] = BaseFieldDefinition::create('entity_reference') + ->setName('revision_user') + ->setTargetEntityTypeId('taxonomy_term') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Revision user')) + ->setDescription(new TranslatableMarkup('The user ID of the author of the current revision.')) + ->setSetting('target_type', 'user') + ->setRevisionable(TRUE); + $field_storage_definitions['revision_log_message'] = BaseFieldDefinition::create('string_long') + ->setName('revision_log_message') + ->setTargetEntityTypeId('taxonomy_term') + ->setTargetBundle(NULL) + ->setLabel(new TranslatableMarkup('Revision log message')) + ->setDescription(new TranslatableMarkup('Briefly describe the changes you have made.')) + ->setRevisionable(TRUE) + ->setDefaultValue(''); + + $definition_update_manager->updateFieldableEntityType($entity_type, $field_storage_definitions, $sandbox); + + return t('Taxonomy terms have been converted to be revisionable.'); +} diff --git a/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php b/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php index f303072a5f8192961f9fabcbc84d8ec254c03299..91326511fe59bbe39a40d8d710fa7bc3cb7983f9 100644 --- a/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php +++ b/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php @@ -156,6 +156,9 @@ protected function getExpectedNormalizedEntity() { 'tid' => [ ['value' => 1], ], + 'revision_id' => [ + ['value' => 1], + ], 'uuid' => [ ['value' => $this->entity->uuid()], ], @@ -205,6 +208,16 @@ protected function getExpectedNormalizedEntity() { 'value' => TRUE, ], ], + 'revision_created' => [ + $this->formatExpectedTimestampItemValues((int) $this->entity->getRevisionCreationTime()), + ], + 'revision_user' => [], + 'revision_log_message' => [], + 'revision_translation_affected' => [ + [ + 'value' => TRUE, + ], + ], ]; } diff --git a/core/modules/taxonomy/tests/src/Functional/Update/TaxonomyTermUpdatePathTest.php b/core/modules/taxonomy/tests/src/Functional/Update/TaxonomyTermUpdatePathTest.php index 55f14915a7725deddd504ab66b8b150d9a7565bf..45e7e56a886d1f3cab7a7eb911cbedc4266b2227 100644 --- a/core/modules/taxonomy/tests/src/Functional/Update/TaxonomyTermUpdatePathTest.php +++ b/core/modules/taxonomy/tests/src/Functional/Update/TaxonomyTermUpdatePathTest.php @@ -129,6 +129,67 @@ public function testPublishingStatusUpdateForTaxonomyTermViews() { } } + /** + * Tests the conversion of taxonomy terms to be revisionable. + * + * @see taxonomy_post_update_make_taxonomy_term_revisionable() + */ + public function testConversionToRevisionable() { + $this->runUpdates(); + + // Check the database tables and the field storage definitions. + $schema = \Drupal::database()->schema(); + $this->assertTrue($schema->tableExists('taxonomy_term_data')); + $this->assertTrue($schema->tableExists('taxonomy_term_field_data')); + $this->assertTrue($schema->tableExists('taxonomy_term_revision')); + $this->assertTrue($schema->tableExists('taxonomy_term_field_revision')); + + $field_storage_definitions = \Drupal::service('entity.last_installed_schema.repository')->getLastInstalledFieldStorageDefinitions('taxonomy_term'); + $this->assertTrue($field_storage_definitions['langcode']->isRevisionable()); + $this->assertTrue($field_storage_definitions['name']->isRevisionable()); + $this->assertTrue($field_storage_definitions['description']->isRevisionable()); + $this->assertTrue($field_storage_definitions['changed']->isRevisionable()); + + // Log in as user 1. + $account = User::load(1); + $account->passRaw = 'drupal'; + $this->drupalLogin($account); + + // Make sure our vocabulary exists. + $this->drupalGet('admin/structure/taxonomy/manage/test_vocabulary/overview'); + + // Make sure our terms exist. + $assert_session = $this->assertSession(); + $assert_session->pageTextContains('Test root term'); + $assert_session->pageTextContains('Test child term'); + + $this->drupalGet('taxonomy/term/3'); + $assert_session->statusCodeEquals('200'); + + // Make sure the terms are still translated. + $this->drupalGet('taxonomy/term/2/translations'); + $assert_session->linkExists('Test root term - Spanish'); + + $storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); + + // Check that taxonomy terms can be created, saved and then loaded. + /** @var \Drupal\taxonomy\TermInterface $term */ + $term = $storage->create([ + 'name' => 'Test term', + 'vid' => 'article', + 'revision_log_message' => 'Initial revision.', + ]); + $term->save(); + + $storage->resetCache(); + $term = $storage->loadRevision($term->getRevisionId()); + + $this->assertEquals('Test term', $term->label()); + $this->assertEquals('article', $term->bundle()); + $this->assertEquals('Initial revision.', $term->getRevisionLogMessage()); + $this->assertTrue($term->isPublished()); + } + /** * {@inheritdoc} */ diff --git a/core/modules/taxonomy/tests/src/Kernel/TermHierarchyValidationTest.php b/core/modules/taxonomy/tests/src/Kernel/TermHierarchyValidationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..95e14f96b44507a75c40979527091304b70f6b62 --- /dev/null +++ b/core/modules/taxonomy/tests/src/Kernel/TermHierarchyValidationTest.php @@ -0,0 +1,194 @@ +<?php + +namespace Drupal\Tests\taxonomy\Kernel; + +use Drupal\KernelTests\Core\Entity\EntityKernelTestBase; +use Drupal\taxonomy\Entity\Vocabulary; + +/** + * Tests handling of pending revisions. + * + * @coversDefaultClass \Drupal\taxonomy\Plugin\Validation\Constraint\TaxonomyTermHierarchyConstraintValidator + * + * @group taxonomy + */ +class TermHierarchyValidationTest extends EntityKernelTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['taxonomy']; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->installEntitySchema('taxonomy_term'); + } + + /** + * Tests the term hierarchy validation with re-parenting in pending revisions. + */ + public function testTermHierarchyValidation() { + $vocabulary_id = mb_strtolower($this->randomMachineName()); + $vocabulary = Vocabulary::create([ + 'name' => $vocabulary_id, + 'vid' => $vocabulary_id, + ]); + $vocabulary->save(); + + // Create a simple hierarchy in the vocabulary, a root term and three parent + // terms. + /** @var \Drupal\Core\Entity\RevisionableStorageInterface $term_storage */ + $term_storage = \Drupal::entityTypeManager()->getStorage('taxonomy_term'); + $root = $term_storage->create([ + 'name' => $this->randomMachineName(), + 'vid' => $vocabulary_id, + ]); + $root->save(); + $parent1 = $term_storage->create([ + 'name' => $this->randomMachineName(), + 'vid' => $vocabulary_id, + 'parent' => $root->id(), + ]); + $parent1->save(); + $parent2 = $term_storage->create([ + 'name' => $this->randomMachineName(), + 'vid' => $vocabulary_id, + 'parent' => $root->id(), + ]); + $parent2->save(); + $parent3 = $term_storage->create([ + 'name' => $this->randomMachineName(), + 'vid' => $vocabulary_id, + 'parent' => $root->id(), + ]); + $parent3->save(); + + // Create a child term and assign one of the parents above. + $child1 = $term_storage->create([ + 'name' => $this->randomMachineName(), + 'vid' => $vocabulary_id, + 'parent' => $parent1->id(), + ]); + $violations = $child1->validate(); + $this->assertEmpty($violations); + $child1->save(); + + $validation_message = 'You can only change the hierarchy for the <em>published</em> version of this term.'; + + // Add a pending revision without changing the term parent. + $pending_name = $this->randomMachineName(); + $child_pending = $term_storage->createRevision($child1, FALSE); + $child_pending->name = $pending_name; + $violations = $child_pending->validate(); + $this->assertEmpty($violations); + + // Add a pending revision and change the parent. + $child_pending = $term_storage->createRevision($child1, FALSE); + $child_pending->parent = $parent2; + $violations = $child_pending->validate(); + $this->assertCount(1, $violations); + $this->assertEquals($validation_message, $violations[0]->getMessage()); + $this->assertEquals('parent', $violations[0]->getPropertyPath()); + + // Add a pending revision and add a new parent. + $child_pending = $term_storage->createRevision($child1, FALSE); + $child_pending->parent[0] = $parent1; + $child_pending->parent[1] = $parent3; + $violations = $child_pending->validate(); + $this->assertCount(1, $violations); + $this->assertEquals($validation_message, $violations[0]->getMessage()); + $this->assertEquals('parent', $violations[0]->getPropertyPath()); + + // Add a pending revision and use the root term as a parent. + $child_pending = $term_storage->createRevision($child1, FALSE); + $child_pending->parent[0] = $root; + $violations = $child_pending->validate(); + $this->assertCount(1, $violations); + $this->assertEquals($validation_message, $violations[0]->getMessage()); + $this->assertEquals('parent', $violations[0]->getPropertyPath()); + + // Add a pending revision and remove the parent. + $child_pending = $term_storage->createRevision($child1, FALSE); + $child_pending->parent[0] = NULL; + $violations = $child_pending->validate(); + $this->assertCount(1, $violations); + $this->assertEquals($validation_message, $violations[0]->getMessage()); + $this->assertEquals('parent', $violations[0]->getPropertyPath()); + + // Add a pending revision and change the weight. + $child_pending = $term_storage->createRevision($child1, FALSE); + $child_pending->weight = 10; + $violations = $child_pending->validate(); + $this->assertCount(1, $violations); + $this->assertEquals($validation_message, $violations[0]->getMessage()); + $this->assertEquals('weight', $violations[0]->getPropertyPath()); + + // Add a pending revision and change both the parent and the weight. + $child_pending = $term_storage->createRevision($child1, FALSE); + $child_pending->parent = $parent2; + $child_pending->weight = 10; + $violations = $child_pending->validate(); + $this->assertCount(2, $violations); + $this->assertEquals($validation_message, $violations[0]->getMessage()); + $this->assertEquals($validation_message, $violations[1]->getMessage()); + $this->assertEquals('parent', $violations[0]->getPropertyPath()); + $this->assertEquals('weight', $violations[1]->getPropertyPath()); + + // Add a published revision and change the parent. + $child_pending = $term_storage->createRevision($child1, TRUE); + $child_pending->parent[0] = $parent2; + $violations = $child_pending->validate(); + $this->assertEmpty($violations); + + // Add a new term as a third-level child. + // The taxonomy tree structure ends up as follows: + // root + // - parent1 + // - parent2 + // -- child1 <- this will be a term with a pending revision + // --- child2 + // - parent3 + $child2 = $term_storage->create([ + 'name' => $this->randomMachineName(), + 'vid' => $vocabulary_id, + 'parent' => $child1->id(), + ]); + $child2->save(); + + // Change 'child1' to be a pending revision. + $child1 = $term_storage->createRevision($child1, FALSE); + $child1->save(); + + // Check that a child of a pending term can not be re-parented. + $child2_pending = $term_storage->createRevision($child2, FALSE); + $child2_pending->parent = $parent3; + $violations = $child2_pending->validate(); + $this->assertCount(1, $violations); + $this->assertEquals($validation_message, $violations[0]->getMessage()); + $this->assertEquals('parent', $violations[0]->getPropertyPath()); + + // Check that another term which has a pending revision can not moved under + // another term which has pending revision. + $parent3_pending = $term_storage->createRevision($parent3, FALSE); + $parent3_pending->parent = $child1; + $violations = $parent3_pending->validate(); + $this->assertCount(1, $violations); + $this->assertEquals($validation_message, $violations[0]->getMessage()); + $this->assertEquals('parent', $violations[0]->getPropertyPath()); + + // Check that a new term can be created under a term that has a pending + // revision. + $child3 = $term_storage->create([ + 'name' => $this->randomMachineName(), + 'vid' => $vocabulary_id, + 'parent' => $child1->id(), + ]); + $violations = $child3->validate(); + $this->assertEmpty($violations); + } + +}