From 1be0f07ef5c238e2088258a3db402497fb3bed72 Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
Date: Mon, 26 Feb 2024 17:43:51 +0000
Subject: [PATCH] Issue #2002174 by Wim Leers, xjm, fago, effulgentsia, catch,
 smustgrave: Allow vocabularies to be validated via the API, not just during
 form submissions

---
 core/modules/node/node.module                 | 15 +++++++
 core/modules/node/node.post_update.php        | 11 +----
 .../config/schema/taxonomy.schema.yml         | 11 ++++-
 .../taxonomy/src/Entity/Vocabulary.php        |  6 +--
 .../destination/EntityTaxonomyVocabulary.php  | 31 +++++++++++++
 core/modules/taxonomy/src/VocabularyForm.php  | 15 +++++++
 core/modules/taxonomy/taxonomy.module         | 13 ++++++
 .../modules/taxonomy/taxonomy.post_update.php | 12 +++++
 ...emove-description-from-tags-vocabulary.php | 24 ++++++++++
 .../Functional/Update/NullDescriptionTest.php | 45 +++++++++++++++++++
 .../src/Kernel/VocabularyValidationTest.php   |  5 +++
 11 files changed, 175 insertions(+), 13 deletions(-)
 create mode 100644 core/modules/taxonomy/src/Plugin/migrate/destination/EntityTaxonomyVocabulary.php
 create mode 100644 core/modules/taxonomy/tests/fixtures/update/remove-description-from-tags-vocabulary.php
 create mode 100644 core/modules/taxonomy/tests/src/Functional/Update/NullDescriptionTest.php

diff --git a/core/modules/node/node.module b/core/modules/node/node.module
index 7b795d72cfff..fe660625d190 100644
--- a/core/modules/node/node.module
+++ b/core/modules/node/node.module
@@ -1323,3 +1323,18 @@ function node_comment_delete($comment) {
 function node_config_translation_info_alter(&$info) {
   $info['node_type']['class'] = 'Drupal\node\ConfigTranslation\NodeTypeMapper';
 }
+
+/**
+ * Implements hook_ENTITY_TYPE_presave().
+ */
+function node_node_type_presave(NodeTypeInterface $node_type) {
+  // Content types' `help` and `description` fields must be stored as NULL
+  // at the config level if they are empty.
+  // @see node_post_update_set_node_type_description_and_help_to_null()
+  if (trim($node_type->getDescription()) === '') {
+    $node_type->set('description', NULL);
+  }
+  if (trim($node_type->getHelp()) === '') {
+    $node_type->set('help', NULL);
+  }
+}
diff --git a/core/modules/node/node.post_update.php b/core/modules/node/node.post_update.php
index c02da56a16a0..47894ee83a4d 100644
--- a/core/modules/node/node.post_update.php
+++ b/core/modules/node/node.post_update.php
@@ -14,15 +14,8 @@
 function node_post_update_set_node_type_description_and_help_to_null(array &$sandbox): void {
   \Drupal::classResolver(ConfigEntityUpdater::class)
     ->update($sandbox, 'node_type', function (NodeTypeInterface $node_type): bool {
-      // Content types' `help` and `description` fields must be stored as NULL
-      // at the config level if they are empty.
-      if (trim($node_type->getDescription()) === '') {
-        $node_type->set('description', NULL);
-      }
-      if (trim($node_type->getHelp()) === '') {
-        $node_type->set('help', NULL);
-      }
-      return TRUE;
+      // @see node_node_type_presave()
+      return trim($node_type->getDescription()) === '' || trim($node_type->getHelp()) === '';
     });
 }
 
diff --git a/core/modules/taxonomy/config/schema/taxonomy.schema.yml b/core/modules/taxonomy/config/schema/taxonomy.schema.yml
index 5fe96fce454d..4b4cbc0a22cd 100644
--- a/core/modules/taxonomy/config/schema/taxonomy.schema.yml
+++ b/core/modules/taxonomy/config/schema/taxonomy.schema.yml
@@ -22,6 +22,8 @@ taxonomy.settings:
 taxonomy.vocabulary.*:
   type: config_entity
   label: 'Vocabulary'
+  constraints:
+    FullyValidatable: ~
   mapping:
     name:
       type: required_label
@@ -35,11 +37,18 @@ taxonomy.vocabulary.*:
         Length:
           max: 32
     description:
-      type: label
+      type: text
       label: 'Description'
+      nullable: true
+      constraints:
+        NotBlank:
+          allowNull: true
     weight:
       type: integer
       label: 'Weight'
+      # A weight can be any integer, positive or negative.
+      constraints:
+        NotNull: []
     new_revision:
       type: boolean
       label: 'Whether a new revision should be created by default'
diff --git a/core/modules/taxonomy/src/Entity/Vocabulary.php b/core/modules/taxonomy/src/Entity/Vocabulary.php
index 606bdb011a17..790a3744c8e1 100644
--- a/core/modules/taxonomy/src/Entity/Vocabulary.php
+++ b/core/modules/taxonomy/src/Entity/Vocabulary.php
@@ -80,9 +80,9 @@ class Vocabulary extends ConfigEntityBundleBase implements VocabularyInterface {
   /**
    * Description of the vocabulary.
    *
-   * @var string
+   * @var string|null
    */
-  protected $description;
+  protected $description = NULL;
 
   /**
    * The weight of this vocabulary in relation to other vocabularies.
@@ -102,7 +102,7 @@ public function id() {
    * {@inheritdoc}
    */
   public function getDescription() {
-    return $this->description;
+    return $this->description ?? '';
   }
 
   /**
diff --git a/core/modules/taxonomy/src/Plugin/migrate/destination/EntityTaxonomyVocabulary.php b/core/modules/taxonomy/src/Plugin/migrate/destination/EntityTaxonomyVocabulary.php
new file mode 100644
index 000000000000..20990337acf4
--- /dev/null
+++ b/core/modules/taxonomy/src/Plugin/migrate/destination/EntityTaxonomyVocabulary.php
@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\taxonomy\Plugin\migrate\destination;
+
+use Drupal\migrate\Plugin\migrate\destination\EntityConfigBase;
+use Drupal\migrate\Row;
+
+/**
+ * @MigrateDestination(
+ *   id = "entity:taxonomy_vocabulary"
+ * )
+ */
+class EntityTaxonomyVocabulary extends EntityConfigBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getEntity(Row $row, array $old_destination_id_values) {
+    /** @var \Drupal\taxonomy\VocabularyInterface $vocabulary */
+    $vocabulary = parent::getEntity($row, $old_destination_id_values);
+
+    // Config schema does not allow description to be empty.
+    if (trim($vocabulary->getDescription()) === '') {
+      $vocabulary->set('description', NULL);
+    }
+    return $vocabulary;
+  }
+
+}
diff --git a/core/modules/taxonomy/src/VocabularyForm.php b/core/modules/taxonomy/src/VocabularyForm.php
index c91bf953c899..b822a34c0e75 100644
--- a/core/modules/taxonomy/src/VocabularyForm.php
+++ b/core/modules/taxonomy/src/VocabularyForm.php
@@ -42,6 +42,21 @@ public static function create(ContainerInterface $container) {
     );
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function buildEntity(array $form, FormStateInterface $form_state) {
+    /** @var \Drupal\taxonomy\VocabularyInterface $entity */
+    $entity = parent::buildEntity($form, $form_state);
+
+    // The description cannot be an empty string.
+    if (trim($form_state->getValue('description')) === '') {
+      $entity->set('description', NULL);
+    }
+
+    return $entity;
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/core/modules/taxonomy/taxonomy.module b/core/modules/taxonomy/taxonomy.module
index d76ef3f10358..787263ca20a6 100644
--- a/core/modules/taxonomy/taxonomy.module
+++ b/core/modules/taxonomy/taxonomy.module
@@ -11,6 +11,7 @@
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\Core\Url;
 use Drupal\taxonomy\Entity\Term;
+use Drupal\taxonomy\VocabularyInterface;
 
 /**
  * Implements hook_help().
@@ -279,3 +280,15 @@ function taxonomy_taxonomy_term_delete(Term $term) {
 /**
  * @} End of "defgroup taxonomy_index".
  */
+
+/**
+ * Implements hook_ENTITY_TYPE_presave().
+ */
+function taxonomy_taxonomy_vocabulary_presave(VocabularyInterface $vocabulary) {
+  // Vocabularies' `description` field must be stored as NULL at the config
+  // level if it is empty.
+  // @see taxonomy_post_update_set_vocabulary_description_to_null()
+  if (trim($vocabulary->getDescription()) === '') {
+    $vocabulary->set('description', NULL);
+  }
+}
diff --git a/core/modules/taxonomy/taxonomy.post_update.php b/core/modules/taxonomy/taxonomy.post_update.php
index 0a8cc89090b9..f80db2876d96 100644
--- a/core/modules/taxonomy/taxonomy.post_update.php
+++ b/core/modules/taxonomy/taxonomy.post_update.php
@@ -6,6 +6,7 @@
  */
 
 use Drupal\Core\Config\Entity\ConfigEntityUpdater;
+use Drupal\taxonomy\VocabularyInterface;
 
 /**
  * Implements hook_removed_post_updates().
@@ -31,3 +32,14 @@ function taxonomy_post_update_set_new_revision(&$sandbox = NULL) {
         return TRUE;
     });
 }
+
+/**
+ * Converts empty `description` in vocabularies to NULL.
+ */
+function taxonomy_post_update_set_vocabulary_description_to_null(array &$sandbox): void {
+  \Drupal::classResolver(ConfigEntityUpdater::class)
+    ->update($sandbox, 'taxonomy_vocabulary', function (VocabularyInterface $vocabulary): bool {
+      // @see taxonomy_taxonomy_vocabulary_presave()
+      return trim($vocabulary->getDescription()) === '';
+    });
+}
diff --git a/core/modules/taxonomy/tests/fixtures/update/remove-description-from-tags-vocabulary.php b/core/modules/taxonomy/tests/fixtures/update/remove-description-from-tags-vocabulary.php
new file mode 100644
index 000000000000..6cf86e877d7d
--- /dev/null
+++ b/core/modules/taxonomy/tests/fixtures/update/remove-description-from-tags-vocabulary.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * @file
+ * Empties the description of the `tags` vocabulary.
+ */
+
+use Drupal\Core\Database\Database;
+
+$connection = Database::getConnection();
+
+$data = $connection->select('config')
+  ->condition('name', 'taxonomy.vocabulary.tags')
+  ->fields('config', ['data'])
+  ->execute()
+  ->fetchField();
+$data = unserialize($data);
+$data['description'] = "\n";
+$connection->update('config')
+  ->condition('name', 'taxonomy.vocabulary.tags')
+  ->fields([
+    'data' => serialize($data),
+  ])
+  ->execute();
diff --git a/core/modules/taxonomy/tests/src/Functional/Update/NullDescriptionTest.php b/core/modules/taxonomy/tests/src/Functional/Update/NullDescriptionTest.php
new file mode 100644
index 000000000000..391ee7e7ae7b
--- /dev/null
+++ b/core/modules/taxonomy/tests/src/Functional/Update/NullDescriptionTest.php
@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Tests\taxonomy\Functional\Update;
+
+use Drupal\FunctionalTests\Update\UpdatePathTestBase;
+use Drupal\taxonomy\Entity\Vocabulary;
+
+/**
+ * Tests the upgrade path for making vocabularies' description NULL.
+ *
+ * @group taxonomy
+ * @see taxonomy_post_update_set_vocabulary_description_to_null()
+ */
+class NullDescriptionTest extends UpdatePathTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setDatabaseDumpFiles() {
+    $this->databaseDumpFiles = [
+      __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-9.4.0.filled.standard.php.gz',
+      __DIR__ . '/../../../fixtures/update/remove-description-from-tags-vocabulary.php',
+    ];
+  }
+
+  /**
+   * Tests the upgrade path for updating empty description to NULL.
+   */
+  public function testRunUpdates(): void {
+    $vocabulary = Vocabulary::load('tags');
+    $this->assertInstanceOf(Vocabulary::class, $vocabulary);
+
+    $this->assertSame("\n", $vocabulary->get('description'));
+    $this->runUpdates();
+
+    $vocabulary = Vocabulary::load('tags');
+    $this->assertInstanceOf(Vocabulary::class, $vocabulary);
+
+    $this->assertNull($vocabulary->get('description'));
+    $this->assertSame('', $vocabulary->getDescription());
+  }
+
+}
diff --git a/core/modules/taxonomy/tests/src/Kernel/VocabularyValidationTest.php b/core/modules/taxonomy/tests/src/Kernel/VocabularyValidationTest.php
index 7de121d2dffe..761802fe13f8 100644
--- a/core/modules/taxonomy/tests/src/Kernel/VocabularyValidationTest.php
+++ b/core/modules/taxonomy/tests/src/Kernel/VocabularyValidationTest.php
@@ -12,6 +12,11 @@
  */
 class VocabularyValidationTest extends ConfigEntityValidationTestBase {
 
+  /**
+   * {@inheritdoc}
+   */
+  protected static array $propertiesWithOptionalValues = ['description'];
+
   /**
    * {@inheritdoc}
    */
-- 
GitLab