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"