diff --git a/core/config/schema/core.entity.schema.yml b/core/config/schema/core.entity.schema.yml index 57e5547255..8c9e8c446f 100644 --- a/core/config/schema/core.entity.schema.yml +++ b/core/config/schema/core.entity.schema.yml @@ -369,6 +369,24 @@ field.formatter.settings.entity_reference_label: type: boolean label: 'Link label to the referenced entity' +bundle_entity_with_plural_labels: + type: config_entity + label: 'Bundle' + mapping: + label_singular: + type: label + label: 'Indefinite singular name' + label_plural: + type: label + label: 'Indefinite plural name' + label_count: + type: sequence + nullable: true + label: 'Count labels' + sequence: + type: plural_label + label: 'Count label' + block.settings.field_block:*:*:*: type: block_settings mapping: diff --git a/core/lib/Drupal/Core/Config/Entity/EntityBundleWithPluralLabelsInterface.php b/core/lib/Drupal/Core/Config/Entity/EntityBundleWithPluralLabelsInterface.php new file mode 100644 index 0000000000..d75644c623 --- /dev/null +++ b/core/lib/Drupal/Core/Config/Entity/EntityBundleWithPluralLabelsInterface.php @@ -0,0 +1,44 @@ + "1 item\x03@count items", + * 'items_found' => "1 item was found\x03@count items were found", + * ] + * @endcode + * Note that the context ('default', 'items_found') is an arbitrary string + * identifier used to retrieve the desired version. If there's only one + * context, the context identifier can be omitted: + * @code + * [ + * "1 item\x03@count items", + * ] + * @endcode + * Each value is definite singular/plural count label with the plural variants + * separated by ETX (PluralTranslatableMarkup::DELIMITER). + * + * @var string[]|null + * + * @see \Drupal\Core\StringTranslation\PluralTranslatableMarkup::DELIMITER + */ + protected $label_count; + + /** + * {@inheritdoc} + */ + public function getSingularLabel() { + // Provide a fallback in case label_singular is not set yet. + if (empty($this->label_singular)) { + if ($label = $this->label()) { + $this->label_singular = Unicode::strtolower($label); + } + } + return $this->label_singular; + } + + /** + * {@inheritdoc} + */ + public function getPluralLabel() { + // Provide a fallback in case label_plural is not set yet. + if (empty($this->label_plural)) { + if ($label = $this->label()) { + $arguments = ['@label' => Unicode::strtolower($label)]; + $options = ['langcode' => $this->language()->getId()]; + $this->label_plural = new TranslatableMarkup('@label items', $arguments, $options); + } + } + return $this->label_plural; + } + + /** + * {@inheritdoc} + */ + public function getCountLabel($count, $context = NULL) { + $result = NULL; + $label_count_versions = (array) $this->label_count; + + // If the context was not passed, pickup the first version of count label. + $context = $context ?: key($label_count_versions); + + $index = static::getPluralIndex($count); + if ($index === -1) { + // If the index cannot be computed, fallback to a single plural variant. + $index = $count > 1 ? 1 : 0; + } + + $label_count = empty($label_count_versions[$context]) ? [] : explode(PluralTranslatableMarkup::DELIMITER, $label_count_versions[$context]); + if (!empty($label_count[$index])) { + $result = new FormattableMarkup($label_count[$index], ['@count' => $count]); + } + elseif (($singular = $this->getSingularLabel()) && ($plural = $this->getPluralLabel())) { + $arguments = ['@singular' => $singular, '@plural' => $plural]; + $result = new PluralTranslatableMarkup($count, '1 @singular', '@count @plural', $arguments); + } + return $result; + } + + /** + * Gets the plural index through the gettext formula. + * + * @param int $count + * Number to return plural for. + * + * @return int + * The numeric index of the plural variant to use for this $langcode and + * $count combination or -1 if the language was not found or does not have a + * plural formula. + * + * @todo Remove this method when https://www.drupal.org/node/2766857 gets in. + */ + protected static function getPluralIndex($count) { + // We have to test both if the function and the service exist since in + // certain situations it is possible that locale code might be loaded but + // the service does not exist. For example, where the parent test site has + // locale installed but the child site does not. + // @todo Refactor in https://www.drupal.org/node/2660338 so this code does + // not depend on knowing that the Locale module exists. + if (function_exists('locale_get_plural') && \Drupal::hasService('locale.plural.formula')) { + return locale_get_plural($count); + } + return -1; + } + +} diff --git a/core/modules/book/config/optional/node.type.book.yml b/core/modules/book/config/optional/node.type.book.yml index 0c07a79e8f..ada815bb6d 100644 --- a/core/modules/book/config/optional/node.type.book.yml +++ b/core/modules/book/config/optional/node.type.book.yml @@ -11,3 +11,7 @@ help: '' new_revision: true preview_mode: 1 display_submitted: true +label_singular: 'book page' +label_plural: 'book pages' +label_count: + - "1 book page\x03@count book pages" diff --git a/core/modules/forum/config/optional/node.type.forum.yml b/core/modules/forum/config/optional/node.type.forum.yml index 8ed965df3f..5e0db03df4 100644 --- a/core/modules/forum/config/optional/node.type.forum.yml +++ b/core/modules/forum/config/optional/node.type.forum.yml @@ -11,3 +11,7 @@ help: '' new_revision: false preview_mode: 1 display_submitted: true +label_singular: 'forum topic' +label_plural: 'forum topics' +label_count: + - "1 forum topic\x03@count forum topics" diff --git a/core/modules/node/config/schema/node.schema.yml b/core/modules/node/config/schema/node.schema.yml index b6fb7bc2d0..c9b171746e 100644 --- a/core/modules/node/config/schema/node.schema.yml +++ b/core/modules/node/config/schema/node.schema.yml @@ -9,7 +9,7 @@ node.settings: label: 'Use administration theme when editing or creating content' node.type.*: - type: config_entity + type: bundle_entity_with_plural_labels label: 'Content type' mapping: name: diff --git a/core/modules/node/node.post_update.php b/core/modules/node/node.post_update.php index 43e3cd6acc..6f15718fa6 100644 --- a/core/modules/node/node.post_update.php +++ b/core/modules/node/node.post_update.php @@ -6,6 +6,7 @@ */ use Drupal\Core\Entity\Entity\EntityFormDisplay; +use Drupal\node\Entity\NodeType; /** * Load all form displays for nodes, add status with these settings, save. @@ -27,3 +28,17 @@ function node_post_update_configure_status_field_widget() { ])->save(); } } + +/** + * Add plural label variants to node-type entities. + */ +function node_post_update_plural_variants() { + /** @var \Drupal\node\NodeTypeInterface $node_type */ + foreach (NodeType::loadMultiple() as $node_type) { + $node_type + ->set('label_singular', NULL) + ->set('label_plural', NULL) + ->set('label_count', NULL) + ->save(); + } +} diff --git a/core/modules/node/src/Entity/NodeType.php b/core/modules/node/src/Entity/NodeType.php index e4a4c3b6c1..d7ba42cef0 100644 --- a/core/modules/node/src/Entity/NodeType.php +++ b/core/modules/node/src/Entity/NodeType.php @@ -3,6 +3,7 @@ namespace Drupal\node\Entity; use Drupal\Core\Config\Entity\ConfigEntityBundleBase; +use Drupal\Core\Config\Entity\EntityBundleWithPluralLabelsTrait; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\node\NodeTypeInterface; @@ -41,11 +42,16 @@ * "new_revision", * "preview_mode", * "display_submitted", + * "label_singular", + * "label_plural", + * "label_count", * } * ) */ class NodeType extends ConfigEntityBundleBase implements NodeTypeInterface { + use EntityBundleWithPluralLabelsTrait; + /** * The machine name of this node type. * diff --git a/core/modules/node/src/NodeTypeInterface.php b/core/modules/node/src/NodeTypeInterface.php index df4831e566..80485335a6 100644 --- a/core/modules/node/src/NodeTypeInterface.php +++ b/core/modules/node/src/NodeTypeInterface.php @@ -3,12 +3,13 @@ namespace Drupal\node; use Drupal\Core\Config\Entity\ConfigEntityInterface; +use Drupal\Core\Config\Entity\EntityBundleWithPluralLabelsInterface; use Drupal\Core\Entity\RevisionableEntityBundleInterface; /** * Provides an interface defining a node type entity. */ -interface NodeTypeInterface extends ConfigEntityInterface, RevisionableEntityBundleInterface { +interface NodeTypeInterface extends ConfigEntityInterface, RevisionableEntityBundleInterface, EntityBundleWithPluralLabelsInterface { /** * Determines whether the node type is locked. diff --git a/core/modules/node/tests/src/Functional/Update/NodeUpdateTest.php b/core/modules/node/tests/src/Functional/Update/NodeUpdateTest.php index 223676b31e..2dc8cb8279 100644 --- a/core/modules/node/tests/src/Functional/Update/NodeUpdateTest.php +++ b/core/modules/node/tests/src/Functional/Update/NodeUpdateTest.php @@ -64,4 +64,28 @@ public function testStatusCheckbox() { } } + /** + * Tests node_post_update_plural_variants(). + * + * @see node_post_update_plural_variants() + */ + public function testPostUpdatePluralVariants() { + $properties = ['label_singular', 'label_plural', 'label_count']; + + // Check that plural label variant properties are not present before update. + $node_type = $this->config('node.type.page')->getRawData(); + foreach ($properties as $property) { + $this->assertArrayNotHasKey($property, $node_type); + } + + $this->runUpdates(); + + // Check that plural label variant properties were added as NULL. + $node_type = $this->config('node.type.page')->getRawData(); + foreach ($properties as $property) { + $this->assertArrayHasKey($property, $node_type); + $this->assertNull($node_type[$property]); + } + } + } diff --git a/core/modules/node/tests/src/Unit/NodeTypePluralLabelTest.php b/core/modules/node/tests/src/Unit/NodeTypePluralLabelTest.php new file mode 100644 index 0000000000..ef8ed7b9b1 --- /dev/null +++ b/core/modules/node/tests/src/Unit/NodeTypePluralLabelTest.php @@ -0,0 +1,240 @@ +set('string_translation', $this->getStringTranslationStub()); + \Drupal::setContainer($container); + + $this->language = $this->getMockBuilder(LanguageInterface::class) + ->disableOriginalConstructor() + ->getMock() + ->expects($this->any()) + ->method('getId') + ->will($this->returnValue('en')); + } + + /** + * @covers ::getSingularLabel + * @covers ::getPluralLabel + * @dataProvider providerForTestGetSingularAndPLuralLabel + * + * @param string|null $entity_label + * The entity label. + * @param string|null $singular_label + * The singular label. + * @param string|null $expected_singular + * The expected singular label. + * @param string|null $plural_label + * The plural label. + * @param string|null $expected_plural + * The expected plural label. + */ + public function testGetSingularAndPLuralLabel($entity_label, $singular_label, $expected_singular, $plural_label, $expected_plural) { + $bundle_entity_mock = new TestingBundleMock($entity_label, $singular_label, $plural_label, NULL); + $this->assertEquals($expected_singular, $bundle_entity_mock->getSingularLabel()); + $this->assertEquals($expected_plural, $bundle_entity_mock->getPluralLabel()); + } + + /** + * Provides test cases for self::testGetSingularAndPLuralLabel(). + * + * @return array + * Test cases. + */ + public function providerForTestGetSingularAndPLuralLabel() { + return [ + // No singular/plural labels and a fallback cannot be build. + 'no labels' => [NULL, NULL, NULL, NULL, NULL], + // No singular/plural labels but a fallback label could be build. + 'entity label only' => ['Eye', NULL, 'eye', NULL, 'eye items'], + // No singular label but a fallback singular label could be build. + 'no singular label' => ['Eye', NULL, 'eye', 'eyes', 'eyes'], + // No plural label but a fallback plural label could be build. + 'no plural label' => ['Eye', 'eye', 'eye', NULL, 'eye items'], + // Singular and plural labels were provided. + 'singular/plural labels' => ['Eye', 'eye', 'eye', 'eyes', 'eyes'], + ]; + } + + /** + * @covers ::getCountLabel + * @dataProvider providerForTestGetCountLabel + * + * @param array[]|null $count_labels + * The count label array. + * @param string|null $entity_label + * The entity label. + * @param string|null $singular_label + * The singular label. + * @param string|null $plural_label + * The plural label. + * @param array $expectation + * An array of associative arrays where each value is the expected result + * given a count integer which is the item key. + */ + public function testGetCountLabel(array $count_labels = NULL, $entity_label, $singular_label, $plural_label, array $expectation) { + $count_labels = $count_labels ?: [NULL]; + $bundle_entity_mock = new TestingBundleMock($entity_label, $singular_label, $plural_label, $count_labels); + foreach ($count_labels as $context => $count_label) { + foreach ($expectation[$context] as $count => $expected) { + // Count label doesn't have a context. + if (!$context) { + $this->assertEquals($expected, $bundle_entity_mock->getCountLabel($count)); + } + // Multiple contextualised count labels. + else { + $this->assertEquals($expected, $bundle_entity_mock->getCountLabel($count, $context)); + } + } + } + } + + /** + * Provides test cases for self::testGetCountLabel(). + * + * @return array + * Test cases. + */ + public function providerForTestGetCountLabel() { + return [ + // No singular/plural labels and a fallback cannot be build. + 'no labels' => [NULL, NULL, NULL, NULL, [[1 => NULL, 2 => NULL]]], + // The entity label is used to create fallback singular & plural labels + // and these are used to create the count label fallback. + 'entity label only' => [ + NULL, + 'Eye', + NULL, + NULL, + [[1 => '1 eye', 2 => '2 eye items']], + ], + // In there's no count label set and one of singular or plural is missed, + // it's not possible to create a count fallback label. + 'singular label only' => [ + NULL, + NULL, + 'eye', + NULL, + [[1 => NULL, 2 => NULL]], + ], + 'only singular/plural labels' => [ + NULL, + NULL, + 'blue eye', + 'blue eyes', + [[1 => '1 blue eye', 2 => '2 blue eyes']], + ], + 'count label' => [ + [ + "1 blue eye\x3@count blue eyes", + ], + NULL, + NULL, + NULL, + [[1 => '1 blue eye', 2 => '2 blue eyes']], + ], + // This count label lacks the singular variant. + 'broken count label, no singular variant' => [ + [ + "\x3@count blue eyes", + ], + NULL, + NULL, + NULL, + [[1 => NULL, 2 => '2 blue eyes']], + ], + // This count label lacks the plural variant but is able to compute a + // fallback from the entity label. + 'broken count label, no plural variant but with entity label' => [ + [ + "1 blue eye", + ], + 'Eye', + NULL, + NULL, + [[1 => '1 blue eye', 2 => '2 eye items']], + ], + // This count label lacks the plural variant but is able to compute a + // fallback from the singular label. + 'broken count label, no plural variant but with singular label' => [ + [ + "1 blue eye", + ], + 'Eye', + 'blue eye', + 'blue eyes', + [[1 => '1 blue eye', 2 => '2 blue eyes']], + ], + // Multiple count labels. + 'contextualized count labels' => [ + [ + 'default' => "1 blue eye\x3@count blue eyes", + 'items found' => "1 blue eye was found\x3@count blue eyes were found", + 'no count' => "blue eye\x3" . 'blue eyes', + 'with markup' => "1 blue eye\x3@count blue eyes", + 'with parenthesis' => "blue eye\x3" . 'blue eyes (@count)', + ], + NULL, + NULL, + NULL, + [ + 'default' => [1 => '1 blue eye', 2 => '2 blue eyes'], + 'items found' => [1 => '1 blue eye was found', 2 => '2 blue eyes were found'], + 'no count' => [1 => 'blue eye', 2 => 'blue eyes'], + 'with markup' => [1 => '1 blue eye', 2 => '2 blue eyes'], + 'with parenthesis' => [1 => 'blue eye', 2 => 'blue eyes (2)'], + ], + ], + ]; + } + +} + +// @codingStandardsIgnoreStart + +/** + * Mocks a bundle config entity class that uses the tested trait. + */ +class TestingBundleMock implements EntityBundleWithPluralLabelsInterface { + + use EntityBundleWithPluralLabelsTrait; + + protected $label; + + public function __construct($entity_label, $singular_label, $plural_label, $count_label) { + $this->label = $entity_label; + $this->label_singular = $singular_label; + $this->label_plural = $plural_label; + $this->label_count = $count_label; + } + + public function label() { return $this->label; } + public function language() { return new Language(); } +} + +// @codingStandardsIgnoreEnd diff --git a/core/modules/rest/tests/src/Functional/EntityResource/NodeType/NodeTypeResourceTestBase.php b/core/modules/rest/tests/src/Functional/EntityResource/NodeType/NodeTypeResourceTestBase.php index c374bfb7c0..12df01f571 100644 --- a/core/modules/rest/tests/src/Functional/EntityResource/NodeType/NodeTypeResourceTestBase.php +++ b/core/modules/rest/tests/src/Functional/EntityResource/NodeType/NodeTypeResourceTestBase.php @@ -59,6 +59,9 @@ protected function getExpectedNormalizedEntity() { 'description' => 'Camelids are large, strictly herbivorous animals with slender necks and long legs.', 'display_submitted' => TRUE, 'help' => NULL, + 'label_count' => NULL, + 'label_plural' => NULL, + 'label_singular' => NULL, 'langcode' => 'en', 'name' => 'Camelids', 'new_revision' => TRUE, diff --git a/core/profiles/demo_umami/config/install/node.type.article.yml b/core/profiles/demo_umami/config/install/node.type.article.yml index 1fd439ce71..13de4e6d33 100644 --- a/core/profiles/demo_umami/config/install/node.type.article.yml +++ b/core/profiles/demo_umami/config/install/node.type.article.yml @@ -8,3 +8,7 @@ help: '' new_revision: true preview_mode: 1 display_submitted: true +label_singular: article +label_plural: articles +label_count: + - "1 article\x03@count articles" diff --git a/core/profiles/demo_umami/config/install/node.type.page.yml b/core/profiles/demo_umami/config/install/node.type.page.yml index 57dcc0c992..01dd16a83d 100644 --- a/core/profiles/demo_umami/config/install/node.type.page.yml +++ b/core/profiles/demo_umami/config/install/node.type.page.yml @@ -8,3 +8,7 @@ help: '' new_revision: true preview_mode: 1 display_submitted: false +label_singular: page +label_plural: pages +label_count: + - "1 page\x03@count pages" diff --git a/core/profiles/demo_umami/config/install/node.type.recipe.yml b/core/profiles/demo_umami/config/install/node.type.recipe.yml index 89ed3215c7..6076968d0c 100644 --- a/core/profiles/demo_umami/config/install/node.type.recipe.yml +++ b/core/profiles/demo_umami/config/install/node.type.recipe.yml @@ -14,3 +14,7 @@ help: '' new_revision: true preview_mode: 1 display_submitted: false +label_singular: recipe +label_plural: recipes +label_count: + - "1 recipe\x03@count recipes" diff --git a/core/profiles/standard/config/install/node.type.article.yml b/core/profiles/standard/config/install/node.type.article.yml index 1fd439ce71..13de4e6d33 100644 --- a/core/profiles/standard/config/install/node.type.article.yml +++ b/core/profiles/standard/config/install/node.type.article.yml @@ -8,3 +8,7 @@ help: '' new_revision: true preview_mode: 1 display_submitted: true +label_singular: article +label_plural: articles +label_count: + - "1 article\x03@count articles" diff --git a/core/profiles/standard/config/install/node.type.page.yml b/core/profiles/standard/config/install/node.type.page.yml index 57dcc0c992..01dd16a83d 100644 --- a/core/profiles/standard/config/install/node.type.page.yml +++ b/core/profiles/standard/config/install/node.type.page.yml @@ -8,3 +8,7 @@ help: '' new_revision: true preview_mode: 1 display_submitted: false +label_singular: page +label_plural: pages +label_count: + - "1 page\x03@count pages"