Created
August 10, 2026 19:54
-
-
Save fago/6553332708f0442a3abf2aaa67fc2a82 to your computer and use it in GitHub Desktop.
ce_address_plain CE field formatter (custom_elements_extra_formatters): address field -> locale-ordered multi-line plain text via the commerceguys/addressing formatter (html=false). MCD-1177 / drupal.org MR !194.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| diff --git a/modules/custom_elements_extra_formatters/src/Plugin/CustomElementsFieldFormatter/AddressPlainTextCeFormatter.php b/modules/custom_elements_extra_formatters/src/Plugin/CustomElementsFieldFormatter/AddressPlainTextCeFormatter.php | |
| new file mode 100644 | |
| index 0000000..4e8ea19 | |
| --- /dev/null | |
| +++ b/modules/custom_elements_extra_formatters/src/Plugin/CustomElementsFieldFormatter/AddressPlainTextCeFormatter.php | |
| @@ -0,0 +1,172 @@ | |
| +<?php | |
| + | |
| +namespace Drupal\custom_elements_extra_formatters\Plugin\CustomElementsFieldFormatter; | |
| + | |
| +use CommerceGuys\Addressing\Formatter\DefaultFormatter; | |
| +use Drupal\Core\Field\FieldDefinitionInterface; | |
| +use Drupal\Core\Field\FieldItemListInterface; | |
| +use Drupal\Core\Form\FormStateInterface; | |
| +use Drupal\Core\Language\LanguageInterface; | |
| +use Drupal\custom_elements\CustomElement; | |
| +use Drupal\custom_elements\CustomElementsFieldFormatterBase; | |
| +use Drupal\custom_elements\CustomElementsFieldFormatterUtilsTrait; | |
| +use Symfony\Component\DependencyInjection\ContainerInterface; | |
| + | |
| +/** | |
| + * CE field formatter for address, emitting a multi-line plain-text address. | |
| + * | |
| + * The address module's formatters are HTML-first: 'address_default' escapes | |
| + * every component, wraps it in a span and the whole address in a <p>, while | |
| + * 'address_plain' renders a template with a fixed, non locale-aware field | |
| + * order. A decoupled frontend needs neither. | |
| + * | |
| + * The underlying commerceguys/addressing library formats an address by | |
| + * substituting the components into the country's format string; the HTML | |
| + * wrapping and escaping is only applied afterwards, and only when its 'html' | |
| + * option is enabled. So this formatter uses that library formatter with 'html' | |
| + * disabled, which yields the country-specific component order with one address | |
| + * line per newline and no markup to strip. | |
| + * | |
| + * The frontend renders that directly (e.g. white-space: pre-line) and collapses | |
| + * the newlines where it needs a one-liner (map marker, calendar entry). | |
| + * | |
| + * The plugin only applies to address fields, so it stays inert - and the | |
| + * address module optional - when that field type is not available. | |
| + * | |
| + * @CustomElementsFieldFormatter( | |
| + * id = "ce_address_plain", | |
| + * label = @Translation("Address (plain text)"), | |
| + * field_types = { | |
| + * "address" | |
| + * }, | |
| + * weight = -10 | |
| + * ) | |
| + */ | |
| +class AddressPlainTextCeFormatter extends CustomElementsFieldFormatterBase { | |
| + | |
| + use CustomElementsFieldFormatterUtilsTrait; | |
| + | |
| + /** | |
| + * The address format repository. | |
| + * | |
| + * @var \CommerceGuys\Addressing\AddressFormat\AddressFormatRepositoryInterface | |
| + */ | |
| + protected $addressFormatRepository; | |
| + | |
| + /** | |
| + * The country repository. | |
| + * | |
| + * @var \CommerceGuys\Addressing\Country\CountryRepositoryInterface | |
| + */ | |
| + protected $countryRepository; | |
| + | |
| + /** | |
| + * The subdivision repository. | |
| + * | |
| + * @var \CommerceGuys\Addressing\Subdivision\SubdivisionRepositoryInterface | |
| + */ | |
| + protected $subdivisionRepository; | |
| + | |
| + /** | |
| + * The addressing library's address formatter. | |
| + * | |
| + * @var \CommerceGuys\Addressing\Formatter\FormatterInterface | |
| + */ | |
| + protected $addressFormatter; | |
| + | |
| + /** | |
| + * The language manager. | |
| + * | |
| + * @var \Drupal\Core\Language\LanguageManagerInterface | |
| + */ | |
| + protected $languageManager; | |
| + | |
| + /** | |
| + * {@inheritdoc} | |
| + */ | |
| + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { | |
| + // @phpstan-ignore-next-line | |
| + $instance = new static($configuration, $plugin_id, $plugin_definition); | |
| + $instance->languageManager = $container->get('language_manager'); | |
| + // The address module is an optional dependency, so the plugin must stay | |
| + // instantiable without it. It never applies to any field then, since the | |
| + // address field type does not exist either. | |
| + if ($container->has('address.address_format_repository')) { | |
| + $instance->addressFormatRepository = $container->get('address.address_format_repository'); | |
| + $instance->countryRepository = $container->get('address.country_repository'); | |
| + $instance->subdivisionRepository = $container->get('address.subdivision_repository'); | |
| + } | |
| + return $instance; | |
| + } | |
| + | |
| + /** | |
| + * Gets the addressing library's address formatter. | |
| + * | |
| + * The address module ships the library and services for its repositories, | |
| + * but exposes no service for the formatter itself. | |
| + * | |
| + * @return \CommerceGuys\Addressing\Formatter\FormatterInterface | |
| + * The address formatter. | |
| + */ | |
| + protected function getAddressFormatter() { | |
| + if (!isset($this->addressFormatter)) { | |
| + $this->addressFormatter = new DefaultFormatter( | |
| + $this->addressFormatRepository, | |
| + $this->countryRepository, | |
| + $this->subdivisionRepository | |
| + ); | |
| + } | |
| + return $this->addressFormatter; | |
| + } | |
| + | |
| + /** | |
| + * {@inheritdoc} | |
| + */ | |
| + public static function isApplicable(string $plugin_id, FieldDefinitionInterface $field_definition) { | |
| + return $field_definition->getType() === 'address'; | |
| + } | |
| + | |
| + /** | |
| + * {@inheritdoc} | |
| + */ | |
| + public function build(FieldItemListInterface $items, CustomElement $custom_element, $langcode = NULL) { | |
| + if (empty($langcode)) { | |
| + $langcode = $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_CONTENT)->getId(); | |
| + } | |
| + $values = []; | |
| + foreach ($items as $item) { | |
| + if ($item->isEmpty()) { | |
| + continue; | |
| + } | |
| + // The country name is localized to $langcode, the component order to the | |
| + // address' own locale. | |
| + $values[] = $this->getAddressFormatter()->format($item, [ | |
| + 'locale' => $langcode, | |
| + 'html' => FALSE, | |
| + ]); | |
| + } | |
| + if (!$values) { | |
| + return; | |
| + } | |
| + // The localized country name varies by interface and content language. | |
| + $custom_element->addCacheContexts([ | |
| + 'languages:' . LanguageInterface::TYPE_INTERFACE, | |
| + 'languages:' . LanguageInterface::TYPE_CONTENT, | |
| + ]); | |
| + $this->setMultipleValue($custom_element, $this->getName(), $values); | |
| + } | |
| + | |
| + /** | |
| + * {@inheritdoc} | |
| + */ | |
| + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { | |
| + return []; | |
| + } | |
| + | |
| + /** | |
| + * {@inheritdoc} | |
| + */ | |
| + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { | |
| + } | |
| + | |
| +} | |
| diff --git a/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterTest.php b/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterTest.php | |
| new file mode 100644 | |
| index 0000000..3c1540b | |
| --- /dev/null | |
| +++ b/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterTest.php | |
| @@ -0,0 +1,136 @@ | |
| +<?php | |
| + | |
| +namespace Drupal\Tests\custom_elements_extra_formatters\Kernel; | |
| + | |
| +use Drupal\KernelTests\KernelTestBase; | |
| +use Drupal\Tests\node\Traits\NodeCreationTrait; | |
| +use Drupal\Tests\user\Traits\UserCreationTrait; | |
| +use Drupal\custom_elements\CustomElementGeneratorTrait; | |
| +use Drupal\custom_elements\Entity\EntityCeDisplay; | |
| +use Drupal\field\Entity\FieldConfig; | |
| +use Drupal\field\Entity\FieldStorageConfig; | |
| +use Drupal\node\Entity\NodeType; | |
| + | |
| +/** | |
| + * Tests ce_address_plain renders addresses as multi-line plain text. | |
| + * | |
| + * @group custom_elements | |
| + */ | |
| +class AddressPlainTextCeFormatterTest extends KernelTestBase { | |
| + | |
| + use CustomElementGeneratorTrait; | |
| + use NodeCreationTrait; | |
| + use UserCreationTrait; | |
| + | |
| + /** | |
| + * {@inheritdoc} | |
| + * | |
| + * @var bool | |
| + */ | |
| + protected $strictConfigSchema = TRUE; | |
| + | |
| + /** | |
| + * {@inheritdoc} | |
| + * | |
| + * @var string[] | |
| + */ | |
| + protected static $modules = [ | |
| + 'custom_elements', | |
| + 'custom_elements_extra_formatters', | |
| + 'field', | |
| + 'address', | |
| + 'user', | |
| + 'node', | |
| + 'text', | |
| + 'system', | |
| + 'filter', | |
| + ]; | |
| + | |
| + /** | |
| + * {@inheritdoc} | |
| + */ | |
| + protected function setUp(): void { | |
| + parent::setUp(); | |
| + $this->installEntitySchema('user'); | |
| + $this->installEntitySchema('node'); | |
| + $this->installConfig('node'); | |
| + NodeType::create([ | |
| + 'type' => 'event', | |
| + 'name' => 'Event', | |
| + ])->save(); | |
| + | |
| + FieldStorageConfig::create([ | |
| + 'field_name' => 'field_address_test', | |
| + 'type' => 'address', | |
| + 'entity_type' => 'node', | |
| + 'cardinality' => 1, | |
| + ])->save(); | |
| + FieldConfig::create([ | |
| + 'field_name' => 'field_address_test', | |
| + 'entity_type' => 'node', | |
| + 'bundle' => 'event', | |
| + 'label' => 'Address', | |
| + ])->save(); | |
| + | |
| + EntityCeDisplay::create([ | |
| + 'targetEntityType' => 'node', | |
| + 'customElementName' => 'node', | |
| + 'bundle' => 'event', | |
| + 'mode' => 'full', | |
| + ]) | |
| + ->setComponent('address-plain', [ | |
| + 'field_name' => 'field_address_test', | |
| + 'is_slot' => FALSE, | |
| + 'formatter' => 'ce_address_plain', | |
| + ]) | |
| + ->save(); | |
| + } | |
| + | |
| + /** | |
| + * Addresses are emitted as markup-free, locale-ordered multi-line text. | |
| + */ | |
| + public function testPlainTextAddress() { | |
| + // The Czech format puts the postal code before the locality, and the | |
| + // organization above the street - the fixed order of the address module's | |
| + // 'address_plain' template would get both wrong. The ampersand verifies | |
| + // no HTML escaping leaks into the plain-text output. | |
| + $node = $this->createNode([ | |
| + 'title' => 'Czech event', | |
| + 'type' => 'event', | |
| + 'field_address_test' => [ | |
| + 'country_code' => 'CZ', | |
| + 'organization' => 'Novák & Sons', | |
| + 'address_line1' => '5. května 1640/65', | |
| + 'locality' => 'Praha 4', | |
| + 'postal_code' => '140 21', | |
| + ], | |
| + ]); | |
| + $plain = $this->getCustomElementGenerator()->generate($node, 'full')->getAttribute('address-plain'); | |
| + $this->assertSame("Novák & Sons\n5. května 1640/65\n140 21 Praha 4\nCzechia", $plain); | |
| + | |
| + // A different country yields a different component order, proving the | |
| + // address format is applied rather than a hardcoded layout. | |
| + $node = $this->createNode([ | |
| + 'title' => 'US event', | |
| + 'type' => 'event', | |
| + 'field_address_test' => [ | |
| + 'country_code' => 'US', | |
| + 'address_line1' => '1098 Alta Ave', | |
| + 'locality' => 'Mountain View', | |
| + 'administrative_area' => 'CA', | |
| + 'postal_code' => '94043', | |
| + ], | |
| + ]); | |
| + $plain = $this->getCustomElementGenerator()->generate($node, 'full')->getAttribute('address-plain'); | |
| + $this->assertSame("1098 Alta Ave\nMountain View, CA 94043\nUnited States", $plain); | |
| + | |
| + // An empty address emits no attribute at all. | |
| + $node = $this->createNode([ | |
| + 'title' => 'Event without address', | |
| + 'type' => 'event', | |
| + ]); | |
| + $element = $this->getCustomElementGenerator()->generate($node, 'full'); | |
| + $this->assertNull($element->getAttribute('address-plain')); | |
| + } | |
| + | |
| +} | |
| diff --git a/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterWithoutAddressTest.php b/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterWithoutAddressTest.php | |
| new file mode 100644 | |
| index 0000000..3777d44 | |
| --- /dev/null | |
| +++ b/modules/custom_elements_extra_formatters/tests/src/Kernel/AddressPlainTextCeFormatterWithoutAddressTest.php | |
| @@ -0,0 +1,57 @@ | |
| +<?php | |
| + | |
| +namespace Drupal\Tests\custom_elements_extra_formatters\Kernel; | |
| + | |
| +use Drupal\Core\Field\BaseFieldDefinition; | |
| +use Drupal\KernelTests\KernelTestBase; | |
| +use Drupal\custom_elements_extra_formatters\Plugin\CustomElementsFieldFormatter\AddressPlainTextCeFormatter; | |
| + | |
| +/** | |
| + * Tests ce_address_plain stays inert while the address module is not installed. | |
| + * | |
| + * @group custom_elements | |
| + */ | |
| +class AddressPlainTextCeFormatterWithoutAddressTest extends KernelTestBase { | |
| + | |
| + /** | |
| + * {@inheritdoc} | |
| + * | |
| + * @var string[] | |
| + */ | |
| + protected static $modules = [ | |
| + 'custom_elements', | |
| + 'custom_elements_extra_formatters', | |
| + 'field', | |
| + 'user', | |
| + 'system', | |
| + ]; | |
| + | |
| + /** | |
| + * The plugin is discoverable and instantiable, but applies to nothing. | |
| + */ | |
| + public function testPluginWithoutAddressModule() { | |
| + $this->assertFalse($this->container->has('address.address_format_repository'), 'The address module is not installed.'); | |
| + | |
| + /** @var \Drupal\custom_elements\CustomElementsFieldFormatterPluginManager $manager */ | |
| + $manager = $this->container->get('custom_elements.plugin.manager.field.custom_element_formatter'); | |
| + $this->assertArrayHasKey('ce_address_plain', $manager->getDefinitions()); | |
| + | |
| + // Instantiating must not fail on the missing address services. | |
| + $field_definition = BaseFieldDefinition::create('string')->setName('field_test'); | |
| + $instance = $manager->createInstance('ce_address_plain', [ | |
| + 'field_definition' => $field_definition, | |
| + 'view_mode' => 'full', | |
| + 'name' => 'address-plain', | |
| + 'is_slot' => FALSE, | |
| + ]); | |
| + $this->assertInstanceOf(AddressPlainTextCeFormatter::class, $instance); | |
| + | |
| + // It declines every field, and is not offered for any field type, since | |
| + // the address field type does not exist without the address module. | |
| + $this->assertFalse(AddressPlainTextCeFormatter::isApplicable('ce_address_plain', $field_definition)); | |
| + foreach ($manager->getOptions() as $field_type => $options) { | |
| + $this->assertArrayNotHasKey('ce_address_plain', $options, "Not offered for the $field_type field type."); | |
| + } | |
| + } | |
| + | |
| +} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment