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

Merge branch '3449653-only-twig-visitor' into '2.0.x'

Issue #3449653 by pdureau: Execute normalization when Twig include and embed are used

See merge request !267
parents 71517119 2963fb90
No related branches found
No related tags found
No related merge requests found
Pipeline #349207 canceled
......@@ -5,11 +5,9 @@ declare(strict_types=1);
namespace Drupal\ui_patterns\Element;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Plugin\Component;
use Drupal\Core\Security\TrustedCallbackInterface;
use Drupal\Core\Theme\ComponentPluginManager;
use Drupal\ui_patterns\Plugin\UiPatterns\PropType\SlotPropType;
use Drupal\ui_patterns\PropTypeAdapterPluginManager;
/**
* Our additions to the SDC render element.
......@@ -19,23 +17,26 @@ class ComponentElementAlter implements TrustedCallbackInterface {
/**
* Constructs a ComponentElementAlter.
*/
public function __construct(protected ComponentPluginManager $componentPluginManager, protected PropTypeAdapterPluginManager $adaptersManager) {
}
public function __construct(protected ComponentPluginManager $componentPluginManager) {}
/**
* {@inheritdoc}
*/
public static function trustedCallbacks() {
return ['alter'];
}
/**
* Alter SDC component element.
*
* There ::normalizeProps() methods logic has been moved to
* TwigExtension::normalizeProps() in order to be executed also when
* components are loaded from Twig include or embed.
*/
public function alter(array $element): array {
$element = $this->normalizeSlots($element);
$component = $this->componentPluginManager->find($element['#component']);
$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.
......@@ -64,32 +65,6 @@ class ComponentElementAlter implements TrustedCallbackInterface {
return $element;
}
/**
* Normalize props.
*/
public function normalizeProps(array $element, Component $component): array {
$props = $component->metadata->schema['properties'] ?? [];
foreach ($element["#props"] as $prop_id => $prop) {
if (!isset($props[$prop_id])) {
continue;
}
$definition = $props[$prop_id];
$prop_type = $definition['ui_patterns']['type_definition'];
// Normalizing attributes to an array is not working
// if the prop type is defined by type=Drupal\Core\Template\Attribute
// This should actually be done by the normalize function.
$data = $prop_type->normalize($prop);
if (isset($definition['ui_patterns']['prop_type_adapter'])) {
$prop_type_adapter_id = $definition['ui_patterns']['prop_type_adapter'];
/** @var \Drupal\ui_patterns\PropTypeAdapterInterface $prop_type_adapter */
$prop_type_adapter = $this->adaptersManager->createInstance($prop_type_adapter_id);
$data = $prop_type_adapter->transform($data);
}
$element["#props"][$prop_id] = $data;
}
return $element;
}
/**
* Process #attributes render property.
*
......
<?php
namespace Drupal\ui_patterns\Template;
use Drupal\Core\Plugin\Component;
use Drupal\Core\Template\ComponentNodeVisitor as CoreComponentNodeVisitor;
use Twig\Environment;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FunctionExpression;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\PrintNode;
use Twig\TwigFunction;
/**
* Provides a Node Visitor to change the generated parse-tree.
*/
class ModuleNodeVisitorAfterSdc extends ModuleNodeVisitorBase {
/**
* {@inheritdoc}
*/
public function leaveNode(Node $node, Environment $env): ?Node {
if (!$node instanceof ModuleNode) {
return $node;
}
$component = $this->getComponent($node);
if (!($component instanceof Component)) {
return $node;
}
$line = $node->getTemplateLine();
$function = $this->buildPreprocessPropsFunction($line, $component, $env);
$node = $this->injectFunction($node, $function);
return $node;
}
/**
* {@inheritdoc}
*/
public function getPriority(): int {
$priority = &drupal_static(__METHOD__);
if (!isset($priority)) {
$original_node_visitor = new CoreComponentNodeVisitor($this->componentManager);
// Ensure that this node visitor's priority is higher than core's visitor,
// because this class has to run after core's class.
$priority = $original_node_visitor->getPriority() + 1;
}
return is_numeric($priority) ? (int) $priority : 0;
}
/**
* Build the _ui_patterns_preprocess_props Twig function.
*
* @param int $line
* The line .
* @param \Drupal\Core\Plugin\Component $component
* The component.
* @param \Twig\Environment $env
* A Twig Environment instance.
*
* @return \Twig\Node\Node
* The Twig function.
*/
protected function buildPreprocessPropsFunction(int $line, Component $component, Environment $env): Node {
$component_id = $component->getPluginId();
$function_parameter = new ConstantExpression($component_id, $line);
$function_parameters_node = new Node([$function_parameter]);
$function = new FunctionExpression(
new TwigFunction('_ui_patterns_preprocess_props', [$env->getExtension(TwigExtension::class), 'preprocessProps'], ['needs_context' => TRUE]),
$function_parameters_node,
$line
);
return new PrintNode($function, $line);
}
}
......@@ -4,28 +4,15 @@ namespace Drupal\ui_patterns\Template;
use Drupal\Core\Plugin\Component;
use Drupal\Core\Render\Component\Exception\ComponentNotFoundException;
use Drupal\Core\Template\ComponentNodeVisitor as CoreComponentNodeVisitor;
use Drupal\Core\Theme\ComponentPluginManager;
use Twig\Environment;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FunctionExpression;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\PrintNode;
use Twig\NodeVisitor\NodeVisitorInterface;
use Twig\TwigFunction;
/**
* Provides a ComponentNodeVisitor to change the generated parse-tree.
*
* @internal
* Provides a Node Visitor to change the generated parse-tree.
*/
class ComponentNodeVisitor implements NodeVisitorInterface {
/**
* Node name: expr.
*/
const NODE_NAME_EXPR = 'expr';
abstract class ModuleNodeVisitorBase implements NodeVisitorInterface {
/**
* The component plugin manager.
......@@ -49,38 +36,6 @@ class ComponentNodeVisitor implements NodeVisitorInterface {
return $node;
}
/**
* {@inheritdoc}
*/
public function leaveNode(Node $node, Environment $env): ?Node {
if (!$node instanceof ModuleNode) {
return $node;
}
$component = $this->getComponent($node);
if (!($component instanceof Component)) {
return $node;
}
$line = $node->getTemplateLine();
$function = $this->buildPreprocessPropsFunction($line, $component, $env);
$node = $this->injectFunction($node, $function);
return $node;
}
/**
* {@inheritdoc}
*/
public function getPriority(): int {
$priority = &drupal_static(__METHOD__);
if (!isset($priority)) {
$original_node_visitor = new CoreComponentNodeVisitor($this->componentManager);
// Ensure that this component node visitor's priority is higher than
// core's node visitor class for components, because this class has to run
// core's class.
$priority = $original_node_visitor->getPriority() + 1;
}
return is_numeric($priority) ? (int) $priority : 0;
}
/**
* Finds the SDC for the current module node.
*
......@@ -105,31 +60,6 @@ class ComponentNodeVisitor implements NodeVisitorInterface {
}
}
/**
* Build the _ui_patterns_preprocess_props Twig function.
*
* @param int $line
* The line .
* @param \Drupal\Core\Plugin\Component $component
* The component.
* @param \Twig\Environment $env
* A Twig Environment instance.
*
* @return \Twig\Node\Node
* The Twig function.
*/
protected function buildPreprocessPropsFunction(int $line, Component $component, Environment $env): Node {
$component_id = $component->getPluginId();
$function_parameter = new ConstantExpression($component_id, $line);
$function_parameters_node = new Node([$function_parameter]);
$function = new FunctionExpression(
new TwigFunction('_ui_patterns_preprocess_props', [$env->getExtension(TwigExtension::class), 'preprocessProps'], ['needs_context' => TRUE]),
$function_parameters_node,
$line
);
return new PrintNode($function, $line);
}
/**
* Injects custom Twig nodes into given node as child nodes.
*
......
<?php
namespace Drupal\ui_patterns\Template;
use Drupal\Core\Plugin\Component;
use Drupal\Core\Template\ComponentNodeVisitor as CoreComponentNodeVisitor;
use Twig\Environment;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FunctionExpression;
use Twig\Node\ModuleNode;
use Twig\Node\Node;
use Twig\Node\PrintNode;
use Twig\TwigFunction;
/**
* Provides a Node Visitor to change the generated parse-tree.
*/
class ModuleNodeVisitorBeforeSdc extends ModuleNodeVisitorBase {
/**
* {@inheritdoc}
*/
public function leaveNode(Node $node, Environment $env): ?Node {
if (!$node instanceof ModuleNode) {
return $node;
}
$component = $this->getComponent($node);
if (!($component instanceof Component)) {
return $node;
}
$line = $node->getTemplateLine();
$function = $this->buildNormalizePropsFunction($line, $component, $env);
$node = $this->injectFunction($node, $function);
return $node;
}
/**
* {@inheritdoc}
*/
public function getPriority(): int {
$priority = &drupal_static(__METHOD__);
if (!isset($priority)) {
$original_node_visitor = new CoreComponentNodeVisitor($this->componentManager);
// Ensure that this node visitor's priority is lower than core's visitor,
// because this class has to run before core's class.
$priority = $original_node_visitor->getPriority() - 1;
}
return is_numeric($priority) ? (int) $priority : 0;
}
/**
* Build the _ui_patterns_preprocess_props Twig function.
*
* @param int $line
* The line .
* @param \Drupal\Core\Plugin\Component $component
* The component.
* @param \Twig\Environment $env
* A Twig Environment instance.
*
* @return \Twig\Node\Node
* The Twig function.
*/
protected function buildNormalizePropsFunction(int $line, Component $component, Environment $env): Node {
$component_id = $component->getPluginId();
$function_parameter = new ConstantExpression($component_id, $line);
$function_parameters_node = new Node([$function_parameter]);
$function = new FunctionExpression(
new TwigFunction('_ui_patterns_normalize_props', [$env->getExtension(TwigExtension::class), 'normalizeProps'], ['needs_context' => TRUE]),
$function_parameters_node,
$line
);
return new PrintNode($function, $line);
}
}
......@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace Drupal\ui_patterns\Template;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\ui_patterns\ComponentPluginManager;
use Drupal\ui_patterns\Plugin\UiPatterns\PropType\SlotPropType;
use Drupal\ui_patterns\PropTypeAdapterPluginManager;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
......@@ -23,9 +26,15 @@ class TwigExtension extends AbstractExtension {
*
* @param \Drupal\ui_patterns\ComponentPluginManager $componentManager
* The component plugin manager.
* @param \Drupal\ui_patterns\PropTypeAdapterPluginManager $adapterManager
* The prop type adapter plugin manager.
* @param \Drupal\Core\Messenger\MessengerInterface $messenger
* The messenger.
*/
public function __construct(
protected ComponentPluginManager $componentManager,
protected PropTypeAdapterPluginManager $adapterManager,
protected MessengerInterface $messenger,
) {}
/**
......@@ -40,7 +49,8 @@ class TwigExtension extends AbstractExtension {
*/
public function getNodeVisitors(): array {
return [
new ComponentNodeVisitor($this->componentManager),
new ModuleNodeVisitorBeforeSdc($this->componentManager),
new ModuleNodeVisitorAfterSdc($this->componentManager),
];
}
......@@ -49,7 +59,11 @@ class TwigExtension extends AbstractExtension {
*/
public function getFunctions() {
return [
// @todo Remove component() before 2.0.0 release.
new TwigFunction('component', [$this, 'renderComponent']),
// For ComponentNodeVisitorBeforeSdc.
new TwigFunction('_ui_patterns_normalize_props', [$this, 'normalizeProps'], ['needs_context' => TRUE]),
// For ComponentNodeVisitorAfterSdc.
new TwigFunction('_ui_patterns_preprocess_props', [$this, 'preprocessProps'], ['needs_context' => TRUE]),
];
}
......@@ -80,6 +94,7 @@ class TwigExtension extends AbstractExtension {
* @see \Drupal\Core\Theme\Element\ComponentElement
*/
public function renderComponent(string $component_id, array $slots = [], array $props = []) {
$this->messenger->addWarning("component() Twig function is deprecated in favor of include() function and will be removed before 2.0.0.");
return [
'#type' => 'component',
'#component' => $component_id,
......@@ -88,6 +103,45 @@ class TwigExtension extends AbstractExtension {
];
}
/**
* Normalize props (and slots).
*
* This function must not be used by the templates authors. In a perfect
* world, it would not be necessary to set such a function. We did that to be
* compatible with SDC's ComponentNodeVisitor, in order to execute props
* normalization before SDC's validate_component_props Twig function.
*
* See ModuleNodeVisitorBeforeSdc.
*
* @param array $context
* The context provided to the component.
* @param string $component_id
* The component ID.
*
* @throws \Drupal\Core\Render\Component\Exception\InvalidComponentException
*/
public function normalizeProps(array &$context, string $component_id): void {
$component = $this->componentManager->find($component_id);
$props = $component->metadata->schema['properties'] ?? [];
foreach ($context as $variable => $value) {
if (isset($component->metadata->slots[$variable])) {
$context[$variable] = SlotPropType::normalize($value);
continue;
}
if (!isset($props[$variable])) {
continue;
}
$prop_type = $props[$variable]['ui_patterns']['type_definition'];
$context[$variable] = $prop_type->normalize($value);
if (isset($props[$variable]['ui_patterns']['prop_type_adapter'])) {
$prop_type_adapter_id = $props[$variable]['ui_patterns']['prop_type_adapter'];
/** @var \Drupal\ui_patterns\PropTypeAdapterInterface $prop_type_adapter */
$prop_type_adapter = $this->adapterManager->createInstance($prop_type_adapter_id);
$context[$variable] = $prop_type_adapter->transform($context[$variable]);
}
}
}
/**
* Preprocess props.
*
......@@ -96,6 +150,8 @@ class TwigExtension extends AbstractExtension {
* compatible with SDC's ComponentNodeVisitor, in order to execute props
* preprocessing after SDC's validate_component_props Twig function.
*
* See ModuleNodeVisitorAfterSdc.
*
* @param array $context
* The context provided to the component.
* @param string $component_id
......
......@@ -12,10 +12,10 @@
{% endif %}
{{ message|add_class('alert-link') }}
{% if dismissible %}
{{ component('ui_patterns_test:close_button', {}, {
attributes: create_attribute({
'data-bs-dismiss': 'alert'
}),
{{ include('ui_patterns_test:close_button', {
attributes: {
'data-bs-dismiss': 'alert',
},
aria_label: 'Close'|t,
}) }}
{% endif %}
......
<div class="ui-patterns-test-component">
<div{{ attributes.addClass(["ui-patterns-test-component"]) }}>
<div class="ui-patterns-props-string">
{{ string }}
</div>
......@@ -57,13 +57,13 @@
{% endfor %}
</div>
<div class="ui-patterns-props-attributes_implicit">
{{ attributes_implicit }}
<div{{ attributes_implicit }}></div>
</div>
<div class="ui-patterns-props-attributes_ui_patterns">
{{ attributes_ui_patterns }}
<div{{ attributes_ui_patterns }}></div>
</div>
<div class="ui-patterns-props-attributes_class">
{{ attributes_class }}
<div{{ attributes_class }}></div>
</div>
<div class="ui-patterns-slots-slot">
{{ slot }}
......
<?php
declare(strict_types=1);
namespace Drupal\Tests\ui_patterns\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\ui_patterns\Traits\TestDataTrait;
/**
* Base class to test source plugins.
*
* @group ui_patterns
*/
class TwigVisitorTest extends KernelTestBase {
use TestDataTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'system',
'user',
'text',
'field',
'node',
'ui_patterns',
'ui_patterns_test',
'datetime',
'filter',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installSchema('node', 'node_access');
$this->installEntitySchema('node');
$this->installEntitySchema('user');
$this->installConfig(['system', 'filter']);
}
/**
* Test attributes.
*/
public function testAttributes() : void {
$default_context = [
'prop_string' => $this->randomMachineName(),
'attributes' => [],
];
$twig_templates = [
// Include tag.
'{% include "ui_patterns_test:test-component" with { string: prop_string } %}',
'{% include "ui_patterns_test:test-component" with { attributes: {}, string: prop_string } %}',
'{% include "ui_patterns_test:test-component" with { attributes: create_attribute(), string: prop_string } %}',
'{% include "ui_patterns_test:test-component" with { attributes: "", string: prop_string } %}',
// Embed tag.
"{% embed 'ui_patterns_test:test-component' with { attributes: {}, string: prop_string } only %}
{% block content %}
{{ content }}
{% endblock %}
{% endembed %}",
"{% embed 'ui_patterns_test:test-component' with { attributes: create_attribute(), string: prop_string } only %}
{% block content %}
{{ content }}
{% endblock %}
{% endembed %}",
"{% embed 'ui_patterns_test:test-component' with { attributes: '', string: prop_string } only %}
{% block content %}
{{ content }}
{% endblock %}
{% endembed %}",
"{% embed 'ui_patterns_test:test-component' with { string: prop_string } only %}
{% block content %}
{{ content }}
{% endblock %}
{% endembed %}",
// Include function.
'{{ include("ui_patterns_test:test-component", { string: prop_string }) }}',
'{{ include("ui_patterns_test:test-component", { attributes: {}, string: prop_string }) }}',
'{{ include("ui_patterns_test:test-component", { attributes: create_attribute(), string: prop_string }) }}',
'{{ include("ui_patterns_test:test-component", { attributes: "", string: prop_string }) }}',
];
$render_array = [
'#type' => 'inline_template',
'#context' => $default_context,
];
foreach ($twig_templates as $twig_template) {
$render_array_test = array_merge($render_array, ['#template' => $twig_template]);
$this->assertExpectedOutput(
[
"rendered_value_plain" => $default_context["prop_string"],
"rendered_value" => $default_context["prop_string"],
"assert" => "assertStringContainsString",
],
$render_array_test
);
}
}
}
......@@ -54,7 +54,7 @@ trait TestDataTrait {
*
* @param array $expected_result
* The expected result from the test set.
* @param string $result
* @param mixed $result
* The result.
* @param string $message
* The message.
......@@ -88,17 +88,26 @@ trait TestDataTrait {
}
if (isset($expected_result['rendered_value']) || isset($expected_result['rendered_value_plain'])) {
// $rendered = \Drupal::service('renderer')->renderRoot($result);
$rendered = is_array($result) ? \Drupal::service('renderer')->renderRoot($result) : $result;
$rendered = NULL;
try {
$rendered = is_array($result) ? \Drupal::service('renderer')->renderRoot($result) : $result;
}
catch (\Exception $e) {
// @phpstan-ignore-next-line
$this->assertTrue(FALSE, sprintf("%s: ERROR, failed to render result: %s \n (%s)", $message, $e->getMessage(), print_r($result, TRUE)));
}
if ($rendered instanceof MarkupInterface) {
$rendered = "" . $rendered;
}
$normalized_rendered = self::normalizeMarkupString($rendered);
if (isset($expected_result['rendered_value'])) {
$this->assertContains($expected_result['rendered_value'], [$normalized_rendered], sprintf("%s: '%s' VS '%s' (%s)", $message, $expected_result['rendered_value'], $normalized_rendered, print_r($result, TRUE)));
$message = sprintf("%s: '%s' VS '%s' (%s)", $message, $expected_result['rendered_value'], $normalized_rendered, print_r($result, TRUE));
$this->assertExpectedOutputGeneric($expected_result['rendered_value'], $normalized_rendered, $expected_result, $message);
}
if (isset($expected_result['rendered_value_plain'])) {
$rendered_plain = Xss::filter($normalized_rendered);
$this->assertContains($expected_result['rendered_value_plain'], [$rendered_plain], sprintf("%s: '%s' VS '%s'", $message, $rendered_plain, $normalized_rendered));
$message = sprintf("%s: '%s' VS '%s'", $message, $rendered_plain, $normalized_rendered);
$this->assertExpectedOutputGeneric($expected_result['rendered_value_plain'], $normalized_rendered, $expected_result, $message);
}
$assert_done = TRUE;
}
......@@ -111,6 +120,29 @@ trait TestDataTrait {
}
}
/**
* Assert expected output generic.
*
* @param mixed $expected_argument
* The expected argument.
* @param mixed $computed_data
* The computed data.
* @param array $expected_result_metadata
* The expected result metadata.
* @param string $message
* The message.
*/
protected function assertExpectedOutputGeneric(mixed $expected_argument, mixed $computed_data, array $expected_result_metadata, string $message = ''): void {
$haystack = $computed_data;
if (!isset($expected_result_metadata["assert"])) {
$expected_result_metadata["assert"] = "assertContains";
}
if ($expected_result_metadata["assert"] === "assertContains") {
$haystack = [$computed_data];
}
$this->{$expected_result_metadata["assert"]}($expected_argument, $haystack, $message);
}
/**
* Normalize a string of markup for comparison.
*/
......
......@@ -75,6 +75,8 @@ services:
- { name: twig.extension }
arguments:
- "@plugin.manager.sdc"
- "@plugin.manager.ui_patterns_prop_type_adapter"
- '@messenger'
ui_patterns.sample_entity_generator:
class: Drupal\ui_patterns\Entity\SampleEntityGenerator
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment