Skip to content
Snippets Groups Projects
Commit eb8ea0b4 authored by kensae's avatar kensae Committed by Pierre Dureau
Browse files

Issue #3515500 by grimreaper, kensae, pdureau: Prevent element props keys to...

Issue #3515500 by grimreaper, kensae, pdureau: Prevent element props keys to be transformed in render array keys with # in StoriesSyntaxConverter
parent 5c0b47e9
No related branches found
No related tags found
1 merge request!360Prevent props keys from being prefixed with #
Pipeline #497358 passed
......@@ -11,91 +11,268 @@ namespace Drupal\ui_patterns_library;
* Let's put them back.
*
* Before: ["type" => "component", "component" => "example:card"]
* After: ["#type" => "component", "#component" => "example:card"]
* After: ["#type" => "component", "#component" => "example:card"]
*/
class StoriesSyntaxConverter {
/**
* An array with one (and only one) of those keys may be a render array.
*/
const RENDER_KEYS = ["theme", "type", "markup", "plain_text", "#theme", "#type", "#markup", "#plain_text"];
public const RENDER_KEYS = [
'markup',
'plain_text',
'theme',
'type',
'#markup',
'#plain_text',
'#theme',
'#type',
];
public const KNOWN_PROPERTIES = [
'type' => [
'html_tag' => [
'attached',
'attributes',
'tag',
'type',
'value',
'#attached',
'#attributes',
'#tag',
'#type',
'#value',
],
],
'theme' => [
'layout' => [
'attached',
'attributes',
'theme',
'settings',
'#attached',
'#attributes',
'#theme',
'#settings',
],
],
];
/**
* Process stories slots.
* List of render properties which should have been children instead.
*/
public function convertSlots(array $slots): array {
foreach ($slots as $slot_id => $slot) {
if (!is_array($slot)) {
continue;
}
$slots[$slot_id] = $this->convertArray($slot);
}
return $slots;
}
public const PROPERTIES_INSTEAD_OF_CHILDREN = [
'type' => [
'component' => [
'slots',
'#slots',
],
],
'theme' => [
'status_messages' => [
'message_list',
'#message_list',
],
'table' => [
'header',
'rows',
'footer',
'empty',
'caption',
'#header',
'#rows',
'#footer',
'#empty',
'#caption',
],
],
];
/**
* Convert an array.
* Process stories slots.
*/
protected function convertArray(array $array): array {
public function convertSlots(array $array): array {
if ($this->isRenderArray($array)) {
return $this->convertRenderArray($array);
}
foreach ($array as $index => $value) {
if (!is_array($value)) {
if (!\is_array($value)) {
continue;
}
$array[$index] = $this->convertArray($value);
$array[$index] = $this->convertSlots($value);
}
return $array;
}
/**
* Convert a render array.
*
* @param array $renderable
* The render array being processed.
*
* @return array
* The processed render array.
*/
protected function convertRenderArray(array $renderable): array {
foreach ($renderable as $property => $value) {
if (is_array($value)) {
$renderable[$property] = $this->convertArray($value);
$renderable = $this->prepareRenderArray($renderable);
// Weird detection.
if (isset($renderable['type'], self::PROPERTIES_INSTEAD_OF_CHILDREN['type'][$renderable['type']])) {
return $this->convertWeirdRenderArray($renderable, self::PROPERTIES_INSTEAD_OF_CHILDREN['type'][$renderable['type']]);
}
if (isset($renderable['theme'])) {
$baseThemeHook = \explode('__', $renderable['theme'])[0];
if (isset(self::PROPERTIES_INSTEAD_OF_CHILDREN['theme'][$baseThemeHook])) {
return $this->convertWeirdRenderArray($renderable, self::PROPERTIES_INSTEAD_OF_CHILDREN['theme'][$baseThemeHook]);
}
}
// Normal with special case detection.
if (isset($renderable['type'], self::KNOWN_PROPERTIES['type'][$renderable['type']])) {
return $this->convertNormalRenderArray($renderable, self::KNOWN_PROPERTIES['type'][$renderable['type']]);
}
if (isset($renderable['theme'])) {
$baseThemeHook = \explode('__', $renderable['theme'])[0];
if (isset(self::KNOWN_PROPERTIES['theme'][$baseThemeHook])) {
return $this->convertNormalRenderArray($renderable, self::KNOWN_PROPERTIES['theme'][$baseThemeHook]);
}
}
$in_html_tag = (isset($renderable["type"]) && $renderable["type"] === "html_tag");
$html_tag_allowed_render_keys = ["type", "attributes", "tag", "value", "attached"];
return $this->convertNormalRenderArray($renderable, []);
}
/**
* Add property prefix.
*
* @param array $renderable
* The renderable array.
* @param mixed $property
* The property.
*
* @return array
* The array with prefixed property.
*/
protected function convertProperty(array $renderable, mixed $property): array {
if (!\is_string($property)) {
return $renderable;
}
if (\str_starts_with($property, '#')) {
return $renderable;
}
$renderable['#' . $property] = $renderable[$property];
unset($renderable[$property]);
return $renderable;
}
/**
* To convert "normal" render array.
*
* A "normal" render arrays is an array:
* - where properties (key starts with a '#') are not renderables
* - children (key does not start with a '#') are only renderables.
*
* Examples:
* - html_tag which is forbidding renderables in #value
* - layout where every region is a child.
*
* @param array $renderable
* The renderable array.
* @param array $knownProperties
* The list of know properties.
*
* @return array
* The converted array.
*/
protected function convertNormalRenderArray(array $renderable, array $knownProperties): array {
foreach ($renderable as $property => $value) {
if (!is_string($property)) {
continue;
if (empty($knownProperties) && \is_string($property)) {
// Default to add the prefix to every entry.
$renderable = $this->convertProperty($renderable, $property);
}
// html_tag is special.
if ($in_html_tag && !in_array($property, $html_tag_allowed_render_keys)) {
continue;
elseif (\in_array($property, $knownProperties, TRUE)) {
// We add # prefix only to known properties.
$renderable = $this->convertProperty($renderable, $property);
}
if (str_starts_with($property, "#")) {
continue;
elseif (\is_array($value)) {
// Other keys may have children, so let's drill.
$renderable[$property] = $this->convertSlots($value);
}
}
return $renderable;
}
/**
* The "weird" render arrays, where renderables are found only in properties.
*
* Examples: component with #slots, table with #rows...
*
* @param array $renderable
* The renderable array.
* @param array $propertiesWithRenderables
* The list of properties to look for.
*
* @return array
* The updated renderable array.
*/
protected function convertWeirdRenderArray(array $renderable, array $propertiesWithRenderables): array {
foreach ($renderable as $property => $value) {
if (\in_array($property, $propertiesWithRenderables, TRUE) && \is_array($value)) {
$renderable[$property] = $this->convertSlots($value);
}
$renderable["#" . $property] = $value;
unset($renderable[$property]);
// There are no children, so we add a # everywhere.
$renderable = $this->convertProperty($renderable, $property);
}
return $renderable;
}
/**
* Is the array a render array?
*
* @param array $array
* The array being processed.
*
* @return bool
* True if a render array.
*/
protected function isRenderArray(array $array): bool {
if (array_is_list($array)) {
if (\array_is_list($array)) {
return FALSE;
}
// An array needs one, and only one, of those properties to be a render
// array.
$intersect = array_intersect(array_keys($array), self::RENDER_KEYS);
if (count($intersect) != 1) {
$intersect = \array_intersect(\array_keys($array), self::RENDER_KEYS);
if (\count($intersect) != 1) {
return FALSE;
}
// This property has to be a string value.
$property = $intersect[array_key_first($intersect)];
if (!is_string($array[$property])) {
$property = $intersect[\array_key_first($intersect)];
if (!\is_string($array[$property])) {
return FALSE;
}
return TRUE;
}
/**
* Prepare a render array.
*
* Remove # on type and theme, will re-add it later.
*
* @param array $renderable
* The render array.
*
* @return array
* The prepared render array.
*/
protected function prepareRenderArray(array $renderable): array {
if (isset($renderable['#type']) && !isset($renderable['type'])) {
$renderable['type'] = $renderable['#type'];
unset($renderable['#type']);
}
if (isset($renderable['#theme']) && !isset($renderable['theme'])) {
$renderable['theme'] = $renderable['#theme'];
unset($renderable['#theme']);
}
return $renderable;
}
}
......@@ -8,9 +8,10 @@ use Drupal\Tests\UnitTestCase;
use Drupal\ui_patterns_library\StoriesSyntaxConverter;
/**
* @coversDefaultClass \Drupal\ui_patterns\Plugin\UiPatterns\PropType\LinksPropType
* @coversDefaultClass \Drupal\ui_patterns_library\StoriesSyntaxConverter
*
* @group ui_patterns
* @group ui_patterns_library
*/
final class StoriesSyntaxConversionTest extends UnitTestCase {
......@@ -22,7 +23,7 @@ final class StoriesSyntaxConversionTest extends UnitTestCase {
public function testConvertSlot(array $value, array $expected): void {
$converter = new StoriesSyntaxConverter();
$converted = $converter->convertSlots($value);
self::assertEquals($converted, $expected);
self::assertEquals($expected, $converted);
}
/**
......@@ -34,6 +35,8 @@ final class StoriesSyntaxConversionTest extends UnitTestCase {
"Bootstrap accordion" => self::bootstrapAccordion(),
"Bootstrap carousel" => self::bootstrapCarousel(),
"Daisy Grid Row 4" => self::daisyGridRow4(),
"Props special words" => self::componentSpecialProps(),
'Theme layout' => self::themeLayout(),
];
foreach ($data as $label => $test) {
yield $label => [
......@@ -100,6 +103,23 @@ final class StoriesSyntaxConversionTest extends UnitTestCase {
],
],
],
'content_mixed' => [
0 => [
'#type' => 'component',
'#component' => 'ui_suite_bootstrap:accordion_item',
'slots' => [
'title' => 'Accordion Item #1',
'content' => [
'type' => 'html_tag',
'#tag' => 'p',
'value' => 'Mollis pretium lorem primis senectus.',
],
],
'#props' => [
'opened' => TRUE,
],
],
],
];
$expected = [
'content' => [
......@@ -119,6 +139,23 @@ final class StoriesSyntaxConversionTest extends UnitTestCase {
],
],
],
'content_mixed' => [
0 => [
'#type' => 'component',
'#component' => 'ui_suite_bootstrap:accordion_item',
'#slots' => [
'title' => 'Accordion Item #1',
'content' => [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => 'Mollis pretium lorem primis senectus.',
],
],
'#props' => [
'opened' => TRUE,
],
],
],
];
return [
"value" => $slots,
......@@ -163,6 +200,38 @@ final class StoriesSyntaxConversionTest extends UnitTestCase {
],
],
],
'slides_mixed' => [
[
'#type' => 'component',
'component' => 'ui_suite_bootstrap:carousel_item',
'#slots' => [
'image' => [
'theme' => 'image',
'#uri' => 'data:image/svg+xml;base64,PHN2ZyBzdHl=',
],
'caption' => [
[
'type' => 'html_tag',
'#tag' => 'h5',
'0' => [
'#type' => 'html_tag',
'tag' => 'em',
'#value' => 'First slide label',
],
],
[
'0' => [
'#type' => 'html_tag',
'#tag' => 'em',
'value' => 'Nulla vitae elit libero, a pharetra augue mollis interdum.',
],
'type' => 'html_tag',
'#tag' => 'p',
],
],
],
],
],
];
$expected = [
'slides' => [
......@@ -197,6 +266,38 @@ final class StoriesSyntaxConversionTest extends UnitTestCase {
],
],
],
'slides_mixed' => [
[
'#type' => 'component',
'#component' => 'ui_suite_bootstrap:carousel_item',
'#slots' => [
'image' => [
'#theme' => 'image',
'#uri' => 'data:image/svg+xml;base64,PHN2ZyBzdHl=',
],
'caption' => [
[
'#type' => 'html_tag',
'#tag' => 'h5',
'0' => [
'#type' => 'html_tag',
'#tag' => 'em',
'#value' => 'First slide label',
],
],
[
'0' => [
'#type' => 'html_tag',
'#tag' => 'em',
'#value' => 'Nulla vitae elit libero, a pharetra augue mollis interdum.',
],
'#type' => 'html_tag',
'#tag' => 'p',
],
],
],
],
],
];
return [
"value" => $slots,
......@@ -245,7 +346,42 @@ final class StoriesSyntaxConversionTest extends UnitTestCase {
],
],
],
'col_second' => [],
'col_second' => [
'type' => 'component',
'#component' => 'ui_suite_daisyui:card',
'slots' => [
'image' => [
'theme' => 'image',
'#uri' => 'https://img.daisyui.com/images/stock/photo-1606107557195-0e29a4b5b4aa.webp',
'alt' => 'Shoes',
],
'title' => 'Shoes!',
'text' => [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => 'If a dog chews shoes whose shoes does he choose?',
],
'actions' => [
'#type' => 'component',
'component' => 'ui_suite_daisyui:button',
'#slots' => [
'label' => 'Buy Now',
],
'props' => [
'variant' => 'primary',
'size' => 'sm',
],
],
],
'props' => [
'attributes' => [
'class' => [
0 => 'bg-base-100',
1 => 'shadow-xl',
],
],
],
],
'col_third' => [],
'col_fourth' => [],
];
......@@ -286,7 +422,42 @@ final class StoriesSyntaxConversionTest extends UnitTestCase {
],
],
],
'col_second' => [],
'col_second' => [
'#type' => 'component',
'#component' => 'ui_suite_daisyui:card',
'#slots' => [
'image' => [
'#theme' => 'image',
'#uri' => 'https://img.daisyui.com/images/stock/photo-1606107557195-0e29a4b5b4aa.webp',
'#alt' => 'Shoes',
],
'title' => 'Shoes!',
'text' => [
'#type' => 'html_tag',
'#tag' => 'p',
'#value' => 'If a dog chews shoes whose shoes does he choose?',
],
'actions' => [
'#type' => 'component',
'#component' => 'ui_suite_daisyui:button',
'#slots' => [
'label' => 'Buy Now',
],
'#props' => [
'variant' => 'primary',
'size' => 'sm',
],
],
],
'#props' => [
'attributes' => [
'class' => [
0 => 'bg-base-100',
1 => 'shadow-xl',
],
],
],
],
'col_third' => [],
'col_fourth' => [],
];
......@@ -296,4 +467,180 @@ final class StoriesSyntaxConversionTest extends UnitTestCase {
];
}
/**
* Fake component with props with reserved words.
*/
protected static function componentSpecialProps(): array {
$slots = [
'component' => [
'type' => 'component',
'component' => 'foo:bar',
'props' => [
'attributes' => [
'class' => ['bar'],
'type' => 'bar',
],
'tag' => 'bar',
'type' => 'bar',
'value' => 'bar',
'attached' => 'bar',
],
],
'component_mixed' => [
'#type' => 'component',
'component' => 'foo:bar',
'#props' => [
'attributes' => [
'class' => ['bar'],
'type' => 'bar',
],
'tag' => 'bar',
'type' => 'bar',
'value' => 'bar',
'attached' => 'bar',
],
],
];
$expected = [
'component' => [
'#type' => 'component',
'#component' => 'foo:bar',
'#props' => [
'attributes' => [
'class' => [
0 => 'bar',
],
'type' => 'bar',
],
'tag' => 'bar',
'type' => 'bar',
'value' => 'bar',
'attached' => 'bar',
],
],
'component_mixed' => [
'#type' => 'component',
'#component' => 'foo:bar',
'#props' => [
'attributes' => [
'class' => [
0 => 'bar',
],
'type' => 'bar',
],
'tag' => 'bar',
'type' => 'bar',
'value' => 'bar',
'attached' => 'bar',
],
],
];
return [
'value' => $slots,
'expected' => $expected,
];
}
/**
* Theme layout.
*/
protected static function themeLayout(): array {
$value = [
[
'theme' => 'layout',
'my_region' => [
'markup' => 'my markup',
],
'attributes' => [
'class' => [
'my-class',
],
],
],
[
'theme' => 'layout__my_theme',
'my_region' => [
'markup' => 'my markup',
],
'attributes' => [
'class' => [
'my-class',
],
],
],
[
'#theme' => 'layout',
'my_region' => [
'markup' => 'my markup',
],
'attributes' => [
'class' => [
'my-class',
],
],
],
[
'theme' => 'layout__my_theme',
'my_region' => [
'#markup' => 'my markup',
],
'attributes' => [
'class' => [
'my-class',
],
],
],
];
$expected = [
[
'#theme' => 'layout',
'my_region' => [
'#markup' => 'my markup',
],
'#attributes' => [
'class' => [
'my-class',
],
],
],
[
'#theme' => 'layout__my_theme',
'my_region' => [
'#markup' => 'my markup',
],
'#attributes' => [
'class' => [
'my-class',
],
],
],
[
'#theme' => 'layout',
'my_region' => [
'#markup' => 'my markup',
],
'#attributes' => [
'class' => [
'my-class',
],
],
],
[
'#theme' => 'layout__my_theme',
'my_region' => [
'#markup' => 'my markup',
],
'#attributes' => [
'class' => [
'my-class',
],
],
],
];
return [
'value' => $value,
'expected' => $expected,
];
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment