diff --git a/core/modules/forum/config/optional/taxonomy.vocabulary.forums.yml b/core/modules/forum/config/optional/taxonomy.vocabulary.forums.yml index b5c51eb61e..ef2e54afd6 100644 --- a/core/modules/forum/config/optional/taxonomy.vocabulary.forums.yml +++ b/core/modules/forum/config/optional/taxonomy.vocabulary.forums.yml @@ -9,3 +9,4 @@ vid: forums description: 'Forum navigation vocabulary' hierarchy: 1 weight: -10 +prevent_duplicates: false diff --git a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php index abada74b35..dc9f8cc34c 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/Vocabulary/VocabularyResourceTestBase.php @@ -56,6 +56,7 @@ protected function getExpectedNormalizedEntity() { 'description' => NULL, 'hierarchy' => 0, 'weight' => 0, + 'prevent_duplicates' => FALSE, ]; } diff --git a/core/modules/taxonomy/config/schema/taxonomy.schema.yml b/core/modules/taxonomy/config/schema/taxonomy.schema.yml index efa08e154e..e3c6c82598 100644 --- a/core/modules/taxonomy/config/schema/taxonomy.schema.yml +++ b/core/modules/taxonomy/config/schema/taxonomy.schema.yml @@ -33,6 +33,9 @@ taxonomy.vocabulary.*: weight: type: integer label: 'Weight' + prevent_duplicates: + type: boolean + label: 'Prevent duplicates' field.formatter.settings.entity_reference_rss_category: type: mapping diff --git a/core/modules/taxonomy/src/Entity/Term.php b/core/modules/taxonomy/src/Entity/Term.php index 535fa8c425..a8987afdd7 100644 --- a/core/modules/taxonomy/src/Entity/Term.php +++ b/core/modules/taxonomy/src/Entity/Term.php @@ -128,7 +128,8 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { 'type' => 'string_textfield', 'weight' => -5, ]) - ->setDisplayConfigurable('form', TRUE); + ->setDisplayConfigurable('form', TRUE) + ->addConstraint('TermName', []); $fields['description'] = BaseFieldDefinition::create('text_long') ->setLabel(t('Description')) diff --git a/core/modules/taxonomy/src/Entity/Vocabulary.php b/core/modules/taxonomy/src/Entity/Vocabulary.php index b0d1ac13d2..e3d522c8d6 100644 --- a/core/modules/taxonomy/src/Entity/Vocabulary.php +++ b/core/modules/taxonomy/src/Entity/Vocabulary.php @@ -43,6 +43,7 @@ * "description", * "hierarchy", * "weight", + * "prevent_duplicates", * } * ) */ @@ -88,6 +89,13 @@ class Vocabulary extends ConfigEntityBundleBase implements VocabularyInterface { */ protected $weight = 0; + /** + * Prevents to create terms with the same name. + * + * @var bool + */ + protected $prevent_duplicates = FALSE; + /** * {@inheritdoc} */ @@ -117,6 +125,13 @@ public function getDescription() { return $this->description; } + /** + * {@inheritdoc} + */ + public function preventDuplicates() { + return $this->prevent_duplicates; + } + /** * {@inheritdoc} */ diff --git a/core/modules/taxonomy/src/Plugin/Validation/Constraint/TermNameConstraint.php b/core/modules/taxonomy/src/Plugin/Validation/Constraint/TermNameConstraint.php new file mode 100644 index 0000000000..4bf8fbef84 --- /dev/null +++ b/core/modules/taxonomy/src/Plugin/Validation/Constraint/TermNameConstraint.php @@ -0,0 +1,20 @@ +first()) { + return; + } + + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $items->getEntity(); + + if (!Vocabulary::load($entity->bundle())->preventDuplicates()) { + return; + } + + $field_definition = $items->getFieldDefinition(); + + $entity_type_id = $entity->getEntityTypeId(); + $entity_type = $entity->getEntityType(); + $bundle_key = $entity_type->getKey('bundle'); + $id_key = $entity_type->getKey('id'); + + $value_taken = (bool) \Drupal::entityQuery($entity_type_id) + // The id could be NULL, so we cast it to 0 in that case. + ->condition($id_key, (int) $entity->id(), '<>') + ->condition($field_definition->getName(), $item->value) + ->condition($bundle_key, $entity->bundle()) + ->accessCheck(FALSE) + ->range(0, 1) + ->count() + ->execute(); + + if ($value_taken) { + $this->context->addViolation($constraint->message, [ + '%value' => $item->value, + '%bundle' => $entity->bundle(), + '@entity_type' => $entity_type->getLowercaseLabel(), + '@field_name' => Unicode::strtolower($field_definition->getLabel()), + ]); + } + + } + +} diff --git a/core/modules/taxonomy/src/Tests/TermTest.php b/core/modules/taxonomy/src/Tests/TermTest.php index 42f92b1b4b..ff529ea424 100644 --- a/core/modules/taxonomy/src/Tests/TermTest.php +++ b/core/modules/taxonomy/src/Tests/TermTest.php @@ -5,6 +5,7 @@ use Drupal\Component\Utility\Tags; use Drupal\Component\Utility\Unicode; use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\Language\LanguageInterface; use Drupal\field\Entity\FieldConfig; use Drupal\taxonomy\Entity\Term; use Drupal\taxonomy\Entity\Vocabulary; @@ -585,4 +586,59 @@ public function testTermBreadcrumbs() { $this->assertEscaped((string) $breadcrumbs[1], 'breadcrumbs displayed and escaped.'); } + /** + * Check that it's not possible to add a term with same name multiple times. + */ + public function testTermUniqueName() { + + $vocabulary = Vocabulary::create([ + 'name' => $this->randomMachineName(), + 'description' => $this->randomMachineName(), + 'vid' => Unicode::strtolower($this->randomMachineName()), + 'langcode' => LanguageInterface::LANGCODE_NOT_SPECIFIED, + 'weight' => mt_rand(0, 10), + 'prevent_duplicates' => TRUE, + ]); + $vocabulary->save(); + + $termName = $this->randomMachineName(14); + $edit = [ + 'name[0][value]' => $termName, + 'description[0][value]' => $this->randomMachineName(100), + 'parent[]' => [0], + ]; + + // Create the term. + $this->drupalPostForm('admin/structure/taxonomy/manage/' . $vocabulary->id() . '/add', $edit, t('Save')); + + // Check if term was created. + $terms = taxonomy_term_load_multiple_by_name($edit['name[0][value]']); + /** @var \Drupal\taxonomy\Entity\Term $term */ + $term = reset($terms); + $this->assertNotNull($term, 'Term found in database.'); + $this->assertText('Created new term ' . $term->label()); + + // Try to create a term with the same name. + $this->drupalPostForm('admin/structure/taxonomy/manage/' . $vocabulary->id() . '/add', $edit, t('Save')); + + $this->assertText('The term ' . $term->label() . ' already exists in vocabulary ' . $term->bundle()); + + // Create a new term name. + $edit = [ + 'name[0][value]' => $edit['name[0][value]'] . '_2', + 'description[0][value]' => $this->randomMachineName(100), + 'parent[]' => [0], + ]; + + // Create the term. + $this->drupalPostForm('admin/structure/taxonomy/manage/' . $vocabulary->id() . '/add', $edit, t('Save')); + + // Check if term was created. + $terms = taxonomy_term_load_multiple_by_name($edit['name[0][value]']); + /** @var \Drupal\taxonomy\Entity\Term $term */ + $term = reset($terms); + $this->assertNotNull($term, 'Term found in database.'); + $this->assertText('Created new term ' . $term->label()); + } + } diff --git a/core/modules/taxonomy/src/VocabularyForm.php b/core/modules/taxonomy/src/VocabularyForm.php index 63f0ccb8ab..cf388377fa 100644 --- a/core/modules/taxonomy/src/VocabularyForm.php +++ b/core/modules/taxonomy/src/VocabularyForm.php @@ -73,6 +73,11 @@ public function form(array $form, FormStateInterface $form_state) { '#title' => $this->t('Description'), '#default_value' => $vocabulary->getDescription(), ]; + $form['prevent_duplicates'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Prevent duplicates'), + '#default_value' => $vocabulary->preventDuplicates(), + ]; // $form['langcode'] is not wrapped in an // if ($this->moduleHandler->moduleExists('language')) check because the diff --git a/core/modules/taxonomy/src/VocabularyInterface.php b/core/modules/taxonomy/src/VocabularyInterface.php index cb54e20184..6a7e2c4044 100644 --- a/core/modules/taxonomy/src/VocabularyInterface.php +++ b/core/modules/taxonomy/src/VocabularyInterface.php @@ -54,4 +54,12 @@ public function setHierarchy($hierarchy); */ public function getDescription(); + /** + * Indicates if multiple terms with same name are forbidden in the vocabulary. + * + * @return bool + * TRUE if same name is forbidden, FALSE if allowed. + */ + public function preventDuplicates(); + } diff --git a/core/profiles/standard/config/install/taxonomy.vocabulary.tags.yml b/core/profiles/standard/config/install/taxonomy.vocabulary.tags.yml index 8fac8f5a2d..b9991f0523 100644 --- a/core/profiles/standard/config/install/taxonomy.vocabulary.tags.yml +++ b/core/profiles/standard/config/install/taxonomy.vocabulary.tags.yml @@ -6,3 +6,4 @@ vid: tags description: 'Use tags to group articles on similar topics into categories.' hierarchy: 0 weight: 0 +prevent_duplicates: false