From 94f5d3d1732dc2fac23aceaeec4ec0fd927ff8fb Mon Sep 17 00:00:00 2001 From: Pierre Dureau <31905-pdureau@users.noreply.drupalcode.org> Date: Tue, 29 Oct 2024 20:42:47 +0000 Subject: [PATCH] Issue #3474822 by pdureau, sea2709, smustgrave, just_like_good_vibes: Normalize attributes values --- src/Element/ComponentElementAlter.php | 25 +-- .../PropType/AttributesPropType.php | 115 +++++++++++++- .../UiPatterns/PropType/LinksPropType.php | 7 +- .../AttributesPropTypeNormalizationTest.php | 143 ++++++++++++++++++ 4 files changed, 262 insertions(+), 28 deletions(-) create mode 100644 tests/src/Unit/PropTypeNormalization/AttributesPropTypeNormalizationTest.php diff --git a/src/Element/ComponentElementAlter.php b/src/Element/ComponentElementAlter.php index 063a23814..f4e944e77 100644 --- a/src/Element/ComponentElementAlter.php +++ b/src/Element/ComponentElementAlter.php @@ -35,9 +35,12 @@ class ComponentElementAlter implements TrustedCallbackInterface { public function alter(array $element): array { $element = $this->normalizeSlots($element); $component = $this->componentPluginManager->find($element['#component']); - $element = $this->processAttributesProp($element, $component); - $element = $this->processAttributesRenderProperty($element); $element = $this->normalizeProps($element, $component); + // Attributes prop must never be empty, to avoid the processing of SDC's + // ComponentsTwigExtension::mergeAdditionalRenderContext() which is adding + // an Attribute PHP object before running the validator. + $element["#props"]["attributes"]['data-component-id'] = $component->getPluginId(); + $element = $this->processAttributesRenderProperty($element); return $element; } @@ -87,24 +90,6 @@ class ComponentElementAlter implements TrustedCallbackInterface { return $element; } - /** - * Process attributes prop. - */ - public function processAttributesProp(array $element, Component $component): array { - $element["#props"]["attributes"] = $element["#props"]["attributes"] ?? []; - // Attribute PHP objects are rendered as strings by SDC ComponentValidator, - // this is raising an error: "InvalidComponentException: String value - // found, but an object is required". - if (is_a($element["#props"]["attributes"], '\Drupal\Core\Template\Attribute')) { - $element["#props"]["attributes"] = $element["#props"]["attributes"]->toArray(); - } - // Attributes prop must never be empty, to avoid the processing of SDC's - // ComponentsTwigExtension::mergeAdditionalRenderContext() which is adding - // an Attribute PHP object. - $element["#props"]["attributes"]['data-component-id'] = $component->getPluginId(); - return $element; - } - /** * Process #attributes render property. * diff --git a/src/Plugin/UiPatterns/PropType/AttributesPropType.php b/src/Plugin/UiPatterns/PropType/AttributesPropType.php index 01149e6cd..66311460b 100644 --- a/src/Plugin/UiPatterns/PropType/AttributesPropType.php +++ b/src/Plugin/UiPatterns/PropType/AttributesPropType.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace Drupal\ui_patterns\Plugin\UiPatterns\PropType; +use Drupal\Component\Render\MarkupInterface; +use Drupal\Core\Render\Element; +use Drupal\Core\Render\RenderableInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Template\Attribute; +use Drupal\Core\Url; use Drupal\ui_patterns\Attribute\PropType; use Drupal\ui_patterns\PropTypePluginBase; @@ -49,12 +53,119 @@ class AttributesPropType extends PropTypePluginBase { plugins are expected to return a mapping to not break SDC prop validation against the prop type schema. */ - if (is_a($value, '\Drupal\Core\Template\Attribute')) { - return $value->toArray(); + if (is_array($value)) { + // Attribute::createAttributeValue() is already normalizing some stuff: + // - 'class' attribute must be a list + // - MarkupInterface values must be resolved. + $value = (new Attribute($value))->toArray(); + } + elseif (is_a($value, '\Drupal\Core\Template\Attribute')) { + // Attribute PHP objects are rendered as strings by SDC ComponentValidator + // this is raising an error: "InvalidComponentException: String value + // found, but an object is required". + $value = $value->toArray(); + } + else { + return []; + } + foreach ($value as $attr => $attr_value) { + $value[$attr] = self::normalizeAttrValue($attr_value); } return $value; } + /** + * Normalize attribute value. + */ + protected static function normalizeAttrValue(mixed $value): mixed { + if (is_object($value)) { + return self::normalizeObject($value); + } + if (is_array($value) && array_is_list($value)) { + return self::normalizeList($value); + } + if (is_array($value) && !array_is_list($value)) { + return self::normalizeMapping($value); + } + // We don't allow markup in attribute value. + return strip_tags((string) $value); + } + + /** + * Normalize list item. + */ + protected static function normalizeListItem(mixed $value): mixed { + if (is_object($value)) { + return self::normalizeObject($value); + } + if (is_array($value) && array_is_list($value)) { + // We encode to JSON because we don't know how deep is the nesting. + return json_encode($value, 0, 3) ?: ""; + } + if (is_array($value) && !array_is_list($value)) { + return self::normalizeRenderArray($value); + } + // Integer and number are always allowed values. + if (is_int($value) || is_float($value)) { + return $value; + } + // We don't allow markup in attribute value. + return strip_tags((string) $value); + } + + /** + * Normalize object attribute value. + */ + protected static function normalizeObject(object $value): array|string { + if ($value instanceof Url) { + return $value->toString(); + } + if ($value instanceof RenderableInterface) { + return static::normalizeRenderArray($value->toRenderable()); + } + if ($value instanceof MarkupInterface) { + return (string) $value; + } + if ($value instanceof \Stringable) { + return (string) $value; + } + // Instead of keeping an unexpected object, we return PHP namespace. + // It will be valid and can inform the component user about its mistake. + return get_class($value); + } + + /** + * Normalize list attribute value. + */ + protected static function normalizeList(array $value): array { + foreach ($value as $index => $item) { + $value[$index] = self::normalizeListItem($item); + } + return $value; + } + + /** + * Normalize mapping attribute value. + */ + protected static function normalizeMapping(array $value): array|string { + if (!empty(Element::properties($value))) { + return static::normalizeRenderArray($value); + } + return static::normalizeList(array_values($value)); + } + + /** + * Normalize render array. + */ + protected static function normalizeRenderArray(array $value): string { + if (!empty(Element::properties($value))) { + $markup = (string) \Drupal::service('renderer')->render($value); + return strip_tags($markup); + } + // We encode to JSON because we don't know how deep is the nesting. + return json_encode($value, 0, 3) ?: ""; + } + /** * {@inheritdoc} */ diff --git a/src/Plugin/UiPatterns/PropType/LinksPropType.php b/src/Plugin/UiPatterns/PropType/LinksPropType.php index 58290aa43..9e10bed72 100644 --- a/src/Plugin/UiPatterns/PropType/LinksPropType.php +++ b/src/Plugin/UiPatterns/PropType/LinksPropType.php @@ -156,12 +156,7 @@ class LinksPropType extends PropTypePluginBase implements ContainerFactoryPlugin unset($item[$property]); return $item; } - // Convert URL objects to strings. - foreach ($item[$property] as $attr => $value) { - if ($value instanceof Url) { - $item[$property][$attr] = $value->toString(); - } - } + $item[$property] = AttributesPropType::normalize($item[$property]); return $item; } diff --git a/tests/src/Unit/PropTypeNormalization/AttributesPropTypeNormalizationTest.php b/tests/src/Unit/PropTypeNormalization/AttributesPropTypeNormalizationTest.php new file mode 100644 index 000000000..be3b387c0 --- /dev/null +++ b/tests/src/Unit/PropTypeNormalization/AttributesPropTypeNormalizationTest.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\ui_patterns\Unit; + +use Drupal\Tests\UnitTestCase; +use Drupal\ui_patterns\Plugin\UiPatterns\PropType\AttributesPropType; + +/** + * @coversDefaultClass \Drupal\ui_patterns\Plugin\UiPatterns\PropType\LinksPropType + * + * @group ui_patterns + */ +final class AttributesPropTypeNormalizationTest extends UnitTestCase { + + /** + * @covers ::normalize + * + * @dataProvider provideNormalizationData + */ + public function testNormalize(array $value, array $expected): void { + $normalized = AttributesPropType::normalize($value); + self::assertEquals($normalized, $expected); + } + + /** + * Provide data for testNormalize. + */ + public static function provideNormalizationData(): \Generator { + $data = [ + "Empty value" => [ + "value" => [], + "expected" => [], + ], + "Standardized primitives, so already OK" => self::standardizedPrimitives(), + "Type transformations" => self::typeTransformation(), + "List array" => self::listArray(), + + ]; + foreach ($data as $label => $test) { + yield $label => [ + $test['value'], + $test['expected'], + ]; + }; + } + + /** + * Standardized primitives, so already OK. + */ + protected static function standardizedPrimitives() { + $value = [ + "foo" => "bar", + "string" => "Lorem ipsum", + "array" => [ + "One", + "Two", + 3, + ], + "integer" => 4, + "float" => 1.4, + ]; + $expected = $value; + return [ + "value" => $value, + "expected" => $expected, + ]; + } + + /** + * Type transformations. + */ + protected static function typeTransformation() { + $value = [ + "true_boolean" => TRUE, + "false_boolean" => FALSE, + "null_boolean" => NULL, + "markup" => "Hello <b>World</b>", + "associative_array" => [ + "Un" => "One", + "Deux" => "Two", + "Trois" => 3, + ], + "nested_array" => [ + "One", + [ + "Two", + "Three", + ], + [ + "deep" => [ + "very deep" => ["foo", "bar"], + ], + ], + ], + ]; + $expected = [ + "true_boolean" => "1", + "false_boolean" => "", + "null_boolean" => "", + "markup" => "Hello World", + "associative_array" => [ + "One", + "Two", + 3, + ], + "nested_array" => [ + "One", + // JSON encoding because we don't know how deep is the nesting. + '["Two","Three"]', + '{"deep":{"very deep":["foo","bar"]}}', + ], + ]; + return [ + "value" => $value, + "expected" => $expected, + ]; + } + + /** + * List array. + */ + protected static function listArray() { + $value = [ + "One", + "Two", + 3, + ]; + // This doesn't look like a valid HTML attribute structure, but we rely on + // Drupal Attribute object normalization here. + $expected = [ + "0" => "One", + "1" => "Two", + "2" => 3, + ]; + return [ + "value" => $value, + "expected" => $expected, + ]; + } + +} -- GitLab