diff --git a/core/config/schema/core.entity.schema.yml b/core/config/schema/core.entity.schema.yml index bf0e12d..60f214b 100644 --- a/core/config/schema/core.entity.schema.yml +++ b/core/config/schema/core.entity.schema.yml @@ -356,3 +356,16 @@ field.formatter.settings.entity_reference_label: type: boolean label: 'Link label to the referenced entity' +bundle_config_entity: + 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: plural_label + label: 'Count label' diff --git a/core/lib/Drupal/Core/Config/Entity/ConfigEntityBundleBase.php b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBundleBase.php index dc17e05..ed1fda1 100644 --- a/core/lib/Drupal/Core/Config/Entity/ConfigEntityBundleBase.php +++ b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBundleBase.php @@ -2,8 +2,12 @@ namespace Drupal\Core\Config\Entity; +use Drupal\Component\Render\FormattableMarkup; +use Drupal\Component\Utility\Unicode; use Drupal\Core\Config\ConfigNameException; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\StringTranslation\PluralTranslatableMarkup; +use Drupal\Core\StringTranslation\TranslatableMarkup; /** * A base class for config entity types that act as bundles. @@ -11,7 +15,28 @@ * Entity types that want to use this base class must use bundle_of in their * annotation to specify for which entity type they are providing bundles for. */ -abstract class ConfigEntityBundleBase extends ConfigEntityBase { +abstract class ConfigEntityBundleBase extends ConfigEntityBase implements ConfigEntityBundleInterface { + + /** + * The indefinite singular name of the bundle. + */ + protected $label_singular; + + /** + * The indefinite plural name of the bundle. + * + * @var string + */ + protected $label_plural; + + /** + * A definite singular/plural count label. + * + * Plural variants are separated by EXT (PluralTranslatableMarkup::DELIMITER). + * + * @var string + */ + protected $label_count; /** * Deletes display if a bundle is deleted. @@ -66,6 +91,50 @@ public static function postDelete(EntityStorageInterface $storage, array $entiti } /** + * {@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) { + $index = $this->getPluralIndex($count); + $label_count = empty($this->label_count) ? [] : explode(PluralTranslatableMarkup::DELIMITER, $this->label_count); + if (isset($label_count[$index])) { + return new FormattableMarkup($label_count[$index], ['@count' => $count]); + } + $arguments = [ + '@singular' => $this->getSingularLabel(), + '@plural' => $this->getPluralLabel(), + ]; + return new PluralTranslatableMarkup($count, '1 @singular', '@count @plural', $arguments); + } + + /** * Acts on an entity before the presave hook is invoked. * * Used before the entity is saved and before invoking the presave hook. @@ -112,4 +181,31 @@ protected function loadDisplays($entity_type_id) { return array(); } + /** + * 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 and use PluralTranslatableMarkup::getPluralIndex() + * when https://www.drupal.org/node/2766857 gets committed. + */ + protected 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/lib/Drupal/Core/Config/Entity/ConfigEntityBundleInterface.php b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBundleInterface.php new file mode 100644 index 0000000..ac8a6be --- /dev/null +++ b/core/lib/Drupal/Core/Config/Entity/ConfigEntityBundleInterface.php @@ -0,0 +1,37 @@ + $type->displaySubmitted(), '#description' => t('Author username and publish date will be displayed.'), ); + $form['display']['plural'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Singular and plural labels'), + '#description' => $this->t('These are lowercase alternatives to the node type label for singular and plural cases.'), + ]; + $form['display']['plural']['label_singular'] = [ + '#type' => 'textfield', + '#title' => $this->t('Singular label'), + '#description' => $this->t("Enter a lowercase label for the singular case. Examples: 'article', 'page'."), + '#default_value' => $type->getSingularLabel(), + ]; + $form['display']['plural']['label_plural'] = [ + '#type' => 'textfield', + '#title' => $this->t('Plural label'), + '#description' => $this->t("Enter a lowercase label for the plural case. Examples: 'articles', 'pages'."), + '#default_value' => $type->getPluralLabel(), + ]; + + $arguments = ['@plural' => $type->getPluralLabel() ?: $this->t('items')]; + $form['display']['label_count'] = [ + '#type' => 'fieldset', + '#title' => $this->t('Count labels'), + '#description' => $this->t('Count labels are used to build a text representation of a certain number of @plural. The @count placeholder is available and will be replaced with the number of @plural.', $arguments), + '#tree' => TRUE, + ]; + $plurals = $this->getNumberOfPlurals($type->language()->getId()); + for ($i = 0; $i < $plurals; $i++) { + $form['display']['label_count'][$i] = [ + '#type' => 'textfield', + '#title' => $this->getPluralVariantLabel($plurals, $i), + '#description' => $this->t('Text to use for this variant, @count will be replaced with the number of @plural.', $arguments), + '#default_value' => $this->getPluralVariantDefaultValue($i), + ]; + } return $this->protectBundleIdElement($form); } @@ -207,6 +242,9 @@ public function validateForm(array &$form, FormStateInterface $form_state) { if ($id == '0') { $form_state->setErrorByName('type', $this->t("Invalid machine-readable name. Enter a name other than %invalid.", array('%invalid' => $id))); } + + // Pack the label_count values as a plural_label. + $form_state->setValue('label_count', implode(PluralTranslatableMarkup::DELIMITER, $form_state->getValue('label_count'))); } /** @@ -254,4 +292,64 @@ public function save(array $form, FormStateInterface $form_state) { $form_state->setRedirectUrl($type->urlInfo('collection')); } + /** + * Returns a plural variant label given the variant delta and variants count. + * + * @param int $plurals + * The total number of plural variants. + * @param int $delta + * The plural variant delta. + * + * @return \Drupal\Component\Render\MarkupInterface + * The variant label. + */ + protected function getPluralVariantLabel($plurals, $delta) { + if ($delta == 0) { + return $this->t('Singular form'); + } + elseif ($plurals == 2 && $delta == 1) { + return $this->t('Plural form'); + } + return $this->formatPlural($delta, 'First plural form', '@count. plural form'); + } + + /** + * Retrieves a plural variant from the backend or assures a decent fallback. + * + * @param int $delta + * The plural variant delta. + * + * @return \Drupal\Component\Render\MarkupInterface|string + * The plural variant default value. + */ + protected function getPluralVariantDefaultValue($delta) { + static $label_count; + + /** @var \Drupal\node\NodeTypeInterface $type */ + $type = $this->getEntity(); + + if (!isset($label_count)) { + $label_count = $type->get('label_count'); + $label_count = $label_count ? explode(PluralTranslatableMarkup::DELIMITER, $label_count) : []; + } + + if (isset($label_count[$delta])) { + // A value was stored in the backend. + return $label_count[$delta]; + } + + $singular = $type->getSingularLabel(); + $plural = $type->getPluralLabel(); + + if ($type->isNew() || ($delta == 0 && empty($singular) || ($delta > 0 && empty($plural)))) { + // Either the node type is not yet saved (we don't even know the entity + // label) or we cannot assure a decent fallback. + return ''; + } + + // Provide a fallback default value. + $arguments = ['@singular' => $singular, '@plural' => $plural]; + return $delta == 0 ? $this->t('1 @singular', $arguments) : $this->t('@count @plural', $arguments); + } + } diff --git a/core/modules/node/src/Tests/NodeTypeTest.php b/core/modules/node/src/Tests/NodeTypeTest.php index 0d2d8de..4e08095 100644 --- a/core/modules/node/src/Tests/NodeTypeTest.php +++ b/core/modules/node/src/Tests/NodeTypeTest.php @@ -245,4 +245,42 @@ public function testNodeTypeNoContentType() { $this->assertEqual(0, count($bundle_info->getBundleInfo('node')), 'The bundle information service has 0 bundles for the Node entity type.'); } + /** + * Tests singular/plural labels. + */ + public function testPluralLabels() { + $account = $this->drupalCreateUser(['administer content types', 'administer node fields']); + $this->drupalLogin($account); + + $edit = [ + 'name' => 'Mouse', + 'type' => 'mouse', + ]; + $this->drupalPostForm('admin/structure/types/add', $edit, t('Save and manage fields')); + + $type = NodeType::load('mouse'); + + // Test fallback values. + $this->assertEqual($type->getSingularLabel(), 'mouse'); + $this->assertEqual($type->getPluralLabel(), 'mouse items'); + $this->assertEqual($type->getCountLabel(1), '1 mouse'); + $this->assertEqual($type->getCountLabel(5), '5 mouse items'); + + $edit = [ + 'label_singular' => 'mouse', + 'label_plural' => 'mice', + 'label_count[0]' => '1 mouse', + 'label_count[1]' => '@count mice', + ]; + $this->drupalPostForm('admin/structure/types/manage/mouse', $edit, t('Save content type')); + + $type = NodeType::load('mouse'); + + // Test user entered values. + $this->assertEqual($type->getSingularLabel(), 'mouse'); + $this->assertEqual($type->getPluralLabel(), 'mice'); + $this->assertEqual($type->getCountLabel(1), '1 mouse'); + $this->assertEqual($type->getCountLabel(5), '5 mice'); + } + } diff --git a/core/modules/node/tests/src/Functional/NodeTypePluralLabelsTranslationTest.php b/core/modules/node/tests/src/Functional/NodeTypePluralLabelsTranslationTest.php new file mode 100644 index 0000000..a420960 --- /dev/null +++ b/core/modules/node/tests/src/Functional/NodeTypePluralLabelsTranslationTest.php @@ -0,0 +1,138 @@ +save(); + + // Create and log in user. + $account = $this->drupalCreateUser([ + 'administer content types', + 'administer node fields', + 'translate configuration', + ]); + $this->drupalLogin($account); + + /** @var \Drupal\locale\PluralFormulaInterface $plural_formula */ + $plural_formula = $this->container->get('locale.plural.formula'); + + // Workaround to register the Romanian and English language formulae in + // state. Without this the number of plurals is not correctly parsed. + $po_header = new PoHeader('ro'); + $plural = $po_header->parsePluralForms('nplurals=3; plural=((n==1)?(0):(((n==0)||(((n%100)>0)&&((n%100)<20)))?(1):2));\n'); + $plural_formula->setPluralFormula('ro', $plural[0], $plural[1]); + $plural = $po_header->parsePluralForms('nplurals=2; plural=(n > 1);\n'); + $plural_formula->setPluralFormula('en', $plural[0], $plural[1]); + } + + /** + * {@inheritdoc} + */ + protected function installParameters() { + $parameters = parent::installParameters(); + // Install the site in Romanian language. + $parameters['parameters']['langcode'] = 'ro'; + return $parameters; + } + + /** + * Tests the node type translation. + */ + public function testNodeTypeTranslation() { + /** @var \Drupal\Core\Language\LanguageManagerInterface $language_manager */ + $language_manager = $this->container->get('language_manager'); + + $this->drupalGet('admin/structure/types/add'); + + // Check that 3 plural variants are exposed for the Romanian language. + $this->assertSession()->fieldExists('label_count[0]'); + $this->assertSession()->fieldExists('label_count[1]'); + $this->assertSession()->fieldExists('label_count[2]'); + $this->assertSession()->fieldNotExists('label_count[3]'); + + $edit = [ + 'name' => 'Copil', + 'type' => 'child', + 'label_singular' => 'copil', + 'label_plural' => 'copii', + 'label_count[0]' => '1 copil', + 'label_count[1]' => '@count copii', + 'label_count[2]' => '@count de copii', + ]; + $this->drupalPostForm(NULL, $edit, t('Save and manage fields')); + + $edit = [ + 'translation[config_names][node.type.child][name]' => 'Child', + 'translation[config_names][node.type.child][label_singular]' => 'child', + 'translation[config_names][node.type.child][label_plural]' => 'children', + 'translation[config_names][node.type.child][label_count][0]' => '1 child', + 'translation[config_names][node.type.child][label_count][1]' => '@count children', + ]; + $this->drupalPostForm('admin/structure/types/manage/child/translate/en/add', $edit, t('Save translation')); + + /** @var \Drupal\node\NodeTypeInterface $node_type */ + $node_type = NodeType::load('child'); + + // Check that the default language (Romanian) original labels were stored. + self::assertEquals($node_type->label(), 'Copil'); + self::assertEquals($node_type->getSingularLabel(), 'copil'); + self::assertEquals($node_type->getPluralLabel(), 'copii'); + self::assertEquals($node_type->getCountLabel(1), '1 copil'); + self::assertEquals($node_type->getCountLabel(5), '5 copii'); + self::assertEquals($node_type->getCountLabel(20), '20 de copii'); + + // Load the English version of the 'child' node-type. + $original_language = $language_manager->getConfigOverrideLanguage(); + $language_manager->setConfigOverrideLanguage(ConfigurableLanguage::load('en')); + /** @var \Drupal\node\NodeTypeInterface $node_type */ + $node_type = NodeType::load('child'); + $language_manager->setConfigOverrideLanguage($original_language); + + // Check that the English translated labels were stored. + self::assertEquals($node_type->label(), 'Child'); + self::assertEquals($node_type->getSingularLabel(), 'child'); + self::assertEquals($node_type->getPluralLabel(), 'children'); + self::assertEquals($node_type->getCountLabel(1), '1 child'); + self::assertEquals($node_type->getCountLabel(5), '5 children'); + self::assertEquals($node_type->getCountLabel(20), '20 children'); + + // Clear the count label second variant to test variant delta preserving. + $edit = ['label_count[1]' => '']; + $this->drupalPostForm($node_type->urlInfo('edit-form'), $edit, t('Save content type')); + + $node_type = NodeType::load('child'); + + // Check that the variants delta has been preserved. + $variants = explode(PluralTranslatableMarkup::DELIMITER, $node_type->get('label_count')); + self::assertEquals($variants[0], '1 copil'); + self::assertEquals($variants[1], ''); + self::assertEquals($variants[2], '@count de copii'); + } + +} diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 2f627be..f34d6b7 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -9,6 +9,7 @@ use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Asset\AttachedAssetsInterface; use Drupal\Core\Cache\Cache; +use Drupal\Core\Config\Entity\ConfigEntityBundleInterface; use Drupal\Core\Queue\QueueGarbageCollectionInterface; use Drupal\Core\Database\Query\AlterableInterface; use Drupal\Core\Extension\Extension; @@ -1416,6 +1417,33 @@ function system_entity_type_build(array &$entity_types) { } /** + * Implements hook_entity_type_alter(). + */ +function system_entity_type_alter(array &$entity_types) { + /** @var Drupal\Core\Config\TypedConfigManagerInterface $typed_config_manager */ + $typed_config_manager = \Drupal::service('config.typed'); + + /** @var \Drupal\Core\Entity\EntityTypeInterface[] $entity_types */ + foreach ($entity_types as $entity_type_id => $entity_type) { + // Automatically add label_singular, label_plural, label_count properties to + // config_export annotation list for bundle entity types that support label + // plural variants. The check is performed by inspecting the bundle entity + // type config schema. + if (is_subclass_of($entity_type->getClass(), ConfigEntityBundleInterface::class)) { + $name = "{$entity_type->getConfigPrefix()}.$entity_type_id"; + $definition = $typed_config_manager->getDefinition($name); + $config_export = $entity_type->get('config_export'); + foreach (['label_singular', 'label_plural', 'label_count'] as $key) { + if (!empty($definition['mapping'][$key]) && !in_array($key, $config_export)) { + $config_export[] = $key; + } + } + $entity_type->set('config_export', $config_export); + } + } +} + +/** * Implements hook_block_view_BASE_BLOCK_ID_alter(). */ function system_block_view_system_main_block_alter(array &$build, BlockPluginInterface $block) { diff --git a/core/profiles/standard/config/install/node.type.article.yml b/core/profiles/standard/config/install/node.type.article.yml index 1fd439c..9d6f11a 100644 --- a/core/profiles/standard/config/install/node.type.article.yml +++ b/core/profiles/standard/config/install/node.type.article.yml @@ -8,3 +8,6 @@ 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 57dcc0c..2c1de80 100644 --- a/core/profiles/standard/config/install/node.type.page.yml +++ b/core/profiles/standard/config/install/node.type.page.yml @@ -8,3 +8,6 @@ help: '' new_revision: true preview_mode: 1 display_submitted: false +label_singular: page +label_plural: pages +label_count: "1 page\x03@count pages"