diff --git a/src/Template/TwigExtension.php b/src/Template/TwigExtension.php index dd7e44d37287c321a1f739bbb7cc5676345610b0..f23b9379d283547f8b53af9f083f658cf807922a 100644 --- a/src/Template/TwigExtension.php +++ b/src/Template/TwigExtension.php @@ -26,6 +26,9 @@ class TwigExtension extends AbstractExtension { */ public function getFilters() { return [ + 'recursive_merge' => new TwigFilter('recursive_merge', [ + 'Drupal\components\Template\TwigExtension', 'recursiveMergeFilter', + ]), 'set' => new TwigFilter('set', [ 'Drupal\components\Template\TwigExtension', 'setFilter', ]), @@ -111,7 +114,7 @@ class TwigExtension extends AbstractExtension { * Recursively merges an array into the element, replacing existing values. * * @code - * {{ form|set( {'element': {'attributes': {'placeholder': 'Label'}}} ) }} + * {{ form|recursive_merge( {'element': {'attributes': {'placeholder': 'Label'}}} ) }} * @endcode * * @param array|iterable|\Traversable $element @@ -125,14 +128,52 @@ class TwigExtension extends AbstractExtension { * @throws \Twig\Error\RuntimeError * When $element is not an array or "Traversable". */ - public static function setFilter($element, $array) { + public static function recursiveMergeFilter($element, $array) { if (!twig_test_iterable($element)) { - throw new RuntimeError(sprintf('The set filter only works with arrays or "Traversable", got "%s" as first argument.', gettype($element))); + throw new RuntimeError(sprintf('The recursive_merge filter only works on arrays or "Traversable" objects, got "%s".', gettype($element))); } return array_replace_recursive($element, $array); } + /** + * Sets a deeply-nested property on an array. + * + * If the deeply-nested property exists, the existing data will be replaced + * with the new value. + * + * @code + * {{ form|set( 'element.#attributes.placeholder', 'Label' ) }} + * @endcode + * + * @param array|iterable|\Traversable $element + * The parent renderable array to set into. + * @param string|iterable|array $path + * The dotted-path to the deeply nested element to set. (Or an array value + * to merge, if using the backwards-compatible 2.x syntax.) + * @param mixed $value + * The value to set. + * + * @return array + * The merged renderable array. + * + * @throws \Twig\Error\RuntimeError + * When $element is not an array or "Traversable". + */ + public static function setFilter($element, $path, $value = NULL) { + if (!twig_test_iterable($element)) { + throw new RuntimeError(sprintf('The set filter only works on arrays or "Traversable" objects, got "%s".', gettype($element))); + } + + // Backwards-compatibility with old 8.x-2.x version of set filter. + if (!is_string($path)) { + @trigger_error('Calling the "set" filter with an array is deprecated in components:8.x-2.3 and will be removed in components:3.0.0. Update to the new syntax or use the "recursive_merge" filter instead. See https://www.drupal.org/project/components/issues/3209440', E_USER_DEPRECATED); + return self::recursiveMergeFilter($element, $path); + } + + return self::addOrSetFilter($element, $path, $value, FALSE); + } + /** * Adds a deeply-nested property on an array. * @@ -141,7 +182,7 @@ class TwigExtension extends AbstractExtension { * the new value will be merged into the existing array. * * @code - * {{ form|add( 'element.attributes.class', 'new-class' ) }} + * {{ form|add( 'element.#attributes.class', 'new-class' ) }} * @endcode * * @param array|iterable|\Traversable $element @@ -159,9 +200,28 @@ class TwigExtension extends AbstractExtension { */ public static function addFilter($element, string $path, $value) { if (!twig_test_iterable($element)) { - throw new RuntimeError(sprintf('The add filter only works with arrays or "Traversable", got "%s" as first argument.', gettype($element))); + throw new RuntimeError(sprintf('The add filter only works on arrays or "Traversable" objects, got "%s".', gettype($element))); } + return self::addOrSetFilter($element, $path, $value, TRUE); + } + + /** + * Helper function for the set/add filters. + * + * @param array|iterable|\Traversable $element + * The parent renderable array to merge into. + * @param string $path + * The dotted-path to the deeply nested element to replace. + * @param mixed $value + * The value to set. + * @param bool $is_add_filter + * Which filter is being called. + * + * @return array + * The merged renderable array. + */ + protected static function addOrSetFilter($element, string $path, $value, $is_add_filter = FALSE) { if ($element instanceof \ArrayAccess) { $filtered_element = clone $element; } @@ -182,8 +242,9 @@ class TwigExtension extends AbstractExtension { $child_element =& $child_element[$child_path]; } - // If the targeted child element is an array, add the value to it. - if (isset($child_element[$last_path]) && is_array($child_element[$last_path])) { + // If this is the add() filter and if the targeted child element is an + // array, add the value to it. + if ($is_add_filter && isset($child_element[$last_path]) && is_array($child_element[$last_path])) { if (is_array($value)) { $child_element[$last_path] = array_merge($child_element[$last_path], $value); } diff --git a/tests/modules/components_twig_extension_test/components_twig_extension_test.module b/tests/modules/components_twig_extension_test/components_twig_extension_test.module index 1915d47413d758c2b6749f496c4bb472088a5894..b934fad5d5d814b505c15e8d991fe964d1d9e956 100644 --- a/tests/modules/components_twig_extension_test/components_twig_extension_test.module +++ b/tests/modules/components_twig_extension_test/components_twig_extension_test.module @@ -12,6 +12,9 @@ function components_twig_extension_test_theme($existing, $type, $theme, $path): $items['components_twig_extension_test_add_filter'] = [ 'render element' => 'element', ]; + $items['components_twig_extension_test_recursive_merge_filter'] = [ + 'render element' => 'element', + ]; $items['components_twig_extension_test_set_filter'] = [ 'render element' => 'element', ]; diff --git a/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-recursive-merge-filter.html.twig b/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-recursive-merge-filter.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..073b154068d86785317b184e4403cafd74304012 --- /dev/null +++ b/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-recursive-merge-filter.html.twig @@ -0,0 +1 @@ +{{ element|recursive_merge( {'list': {'#items': [{'#attributes': {'class': 'new-class'}}]}} ) }} diff --git a/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-set-filter.html.twig b/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-set-filter.html.twig index cb2396cbc130b47e944c76f565c3a24ffbf7be39..0de424d30aa6e2783917f199ecaaa12d023765f5 100644 --- a/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-set-filter.html.twig +++ b/tests/modules/components_twig_extension_test/templates/components-twig-extension-test-set-filter.html.twig @@ -1 +1 @@ -{{ element|set( {'list': {'#items': [{'#attributes': {'class': 'new-class'}}]}} ) }} +{{ element|set( 'list.#items.0.#attributes', { 'class': 'new-class' } ) }} diff --git a/tests/src/Kernel/TwigExtensionTest.php b/tests/src/Kernel/TwigExtensionTest.php index f2b1f89702e690c86660990b8e95f1f6a95ffb62..249fb5d3ee1618c82cc9aeb8eb5f58fa5594056f 100644 --- a/tests/src/Kernel/TwigExtensionTest.php +++ b/tests/src/Kernel/TwigExtensionTest.php @@ -50,6 +50,36 @@ class TwigExtensionTest extends ComponentsKernelTestBase { } } + /** + * Ensures the Twig "recursive_merge" filter works inside a Drupal instance. + * + * @covers ::recursiveMergeFilter + */ + public function testRecursiveMergeFilter() { + try { + $element = [ + '#theme' => 'components_twig_extension_test_recursive_merge_filter', + 'list' => [ + '#theme' => 'item_list', + '#items' => [ + [ + '#type' => 'container', + '#attributes' => [ + 'id' => 'the_element_id', + 'class' => ['original-container-class'], + ], + ], + ], + ], + ]; + $result = $this->render($element); + } + catch (\Exception $e) { + $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown during: ' . $this->getName()); + } + $this->assertStringContainsString('<div id="the_element_id" class="new-class"></div>', $result); + } + /** * Ensures the Twig "set" filter works inside a Drupal instance. * @@ -66,6 +96,7 @@ class TwigExtensionTest extends ComponentsKernelTestBase { [ '#type' => 'container', '#attributes' => [ + 'id' => 'the_element_id', 'class' => ['original-container-class'], ], ], diff --git a/tests/src/Unit/TwigExtensionFiltersTest.php b/tests/src/Unit/TwigExtensionFiltersTest.php index b82bba60247736ce5c6f4e0969168d784d5cd462..bf21eefb29d87710f29a79ec9db504651114f8bb 100644 --- a/tests/src/Unit/TwigExtensionFiltersTest.php +++ b/tests/src/Unit/TwigExtensionFiltersTest.php @@ -47,6 +47,94 @@ class TwigExtensionFiltersTest extends UnitTestCase { $this->coreExtension = new CoreExtension(); } + /** + * Tests exceptions during recursive_merge filter. + * + * @covers ::recursiveMergeFilter + */ + public function testRecursiveMergeFilterException() { + try { + TwigExtension::recursiveMergeFilter('not-an-array', ['key' => 'value']); + $exception = FALSE; + } + catch (\Exception $e) { + $this->assertStringContainsString('The recursive_merge filter only works on arrays or "Traversable" objects, got "string".', $e->getMessage()); + $exception = TRUE; + } + if (!$exception) { + $this->fail('Expected Exception, none was thrown.'); + } + } + + /** + * Tests the recursive_merge filter. + * + * @param array $element + * The element to alter. + * @param array $value + * The value to set. + * @param array $expected + * The expected result. + * + * @covers ::recursiveMergeFilter + * + * @dataProvider providerTestRecursiveMergeFilter + */ + public function testRecursiveMergeFilter(array $element, array $value, array $expected) { + $result = NULL; + try { + $result = TwigExtension::recursiveMergeFilter($element, $value); + } + catch (\Exception $e) { + $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown during: ' . $this->getName()); + } + $this->assertEquals($expected, $result, $this->getName()); + $this->assertEquals(array_replace_recursive($element, $value), $result, $this->getName()); + } + + /** + * Data provider for testRecursiveMergeFilter(). + * + * @see testRecursiveMergeFilter() + */ + public function providerTestRecursiveMergeFilter(): array { + return [ + 'Recursively sets values' => [ + 'element' => [ + 'existing' => 'value', + 'element' => [ + '#type' => 'element', + '#attributes' => [ + 'class' => ['old-value-1', 'old-value-2'], + 'id' => 'element', + ], + ], + ], + 'value' => [ + 'extra' => 'extra-value', + 'element' => [ + '#attributes' => [ + 'class' => ['new-value'], + 'placeholder' => 'Label', + ], + ], + ], + 'expected' => [ + 'existing' => 'value', + 'extra' => 'extra-value', + 'element' => [ + '#type' => 'element', + '#attributes' => [ + 'class' => ['new-value', 'old-value-2'], + 'id' => 'element', + 'placeholder' => 'Label', + ], + ], + ], + ], + ]; + } + /** * Tests exceptions during set filter. * @@ -54,11 +142,11 @@ class TwigExtensionFiltersTest extends UnitTestCase { */ public function testSetFilterException() { try { - TwigExtension::setFilter('not-an-array', ['key' => 'value']); + TwigExtension::setFilter('not-an-array', 'key', 'value'); $exception = FALSE; } catch (\Exception $e) { - $needle = 'The set filter only works with arrays or "Traversable", got "string" as first argument.'; + $needle = 'The set filter only works on arrays or "Traversable" objects, got "string".'; if (method_exists($this, 'assertStringContainsString')) { $this->assertStringContainsString($needle, $e->getMessage()); } @@ -77,7 +165,10 @@ class TwigExtensionFiltersTest extends UnitTestCase { * * @param array $element * The element to alter. - * @param array $value + * @param string|array $path + * The dotted-path to the deeply nested element to set. (Or an array value + * to merge, if using the backwards-compatible 8.x-2.x syntax.) + * @param mixed $value * The value to set. * @param array $expected * The expected result. @@ -86,16 +177,15 @@ class TwigExtensionFiltersTest extends UnitTestCase { * * @dataProvider providerTestSetFilter */ - public function testSetFilter(array $element, array $value, array $expected) { + public function testSetFilter(array $element, $path, $value, array $expected) { $result = NULL; try { - $result = TwigExtension::setFilter($element, $value); + $result = TwigExtension::setFilter($element, $path, $value); } catch (\Exception $e) { $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown during: ' . $this->getName()); } $this->assertEquals($expected, $result, $this->getName()); - $this->assertEquals(array_replace_recursive($element, $value), $result, $this->getName()); } /** @@ -105,32 +195,78 @@ class TwigExtensionFiltersTest extends UnitTestCase { */ public function providerTestSetFilter(): array { return [ - 'Recursively sets values' => [ + 'Sets a new value' => [ + 'element' => [ + 'element' => [ + '#type' => 'element', + '#attributes' => [ + 'class' => ['old-value-1', 'old-value-2'], + 'id' => 'element', + ], + ], + ], + 'path' => 'element.#attributes.placeholder', + 'value' => 'Label', + 'expected' => [ + 'element' => [ + '#type' => 'element', + '#attributes' => [ + 'class' => ['old-value-1', 'old-value-2'], + 'id' => 'element', + 'placeholder' => 'Label', + ], + ], + ], + ], + 'Replaces a targeted array' => [ + 'element' => [ + 'element' => [ + '#type' => 'element', + '#attributes' => [ + 'class' => ['old-value-1', 'old-value-2'], + 'id' => 'element', + ], + ], + ], + 'path' => 'element.#attributes.class', + 'value' => ['new-value'], + 'expected' => [ + 'element' => [ + '#type' => 'element', + '#attributes' => [ + 'class' => ['new-value'], + 'id' => 'element', + ], + ], + ], + ], + 'Uses 8.x-2.x syntax for backwards-compatibility' => [ 'element' => [ 'existing' => 'value', 'element' => [ - 'type' => 'element', - 'attributes' => [ + '#type' => 'element', + '#attributes' => [ 'class' => ['old-value-1', 'old-value-2'], 'id' => 'element', ], ], ], - 'value' => [ + 'path' => [ 'extra' => 'extra-value', 'element' => [ - 'attributes' => [ + '#attributes' => [ 'class' => ['new-value'], 'placeholder' => 'Label', ], ], ], + 'value' => NULL, 'expected' => [ 'existing' => 'value', 'extra' => 'extra-value', 'element' => [ - 'type' => 'element', - 'attributes' => [ + '#type' => 'element', + '#attributes' => [ 'class' => ['new-value', 'old-value-2'], 'id' => 'element', 'placeholder' => 'Label', @@ -152,7 +288,7 @@ class TwigExtensionFiltersTest extends UnitTestCase { $exception = FALSE; } catch (\Exception $e) { - $needle = 'The add filter only works with arrays or "Traversable", got "string" as first argument.'; + $needle = 'The add filter only works on arrays or "Traversable" objects, got "string".'; if (method_exists($this, 'assertStringContainsString')) { $this->assertStringContainsString($needle, $e->getMessage()); } @@ -170,9 +306,9 @@ class TwigExtensionFiltersTest extends UnitTestCase { * Tests the add filter. * * @param string $path - * The dotted-path to the deeply nested element to replace. + * The dotted-path to the deeply nested element to add. * @param mixed $value - * The value to set. + * The value(s) to add. * @param array $expected * The expected render array. * @@ -184,8 +320,8 @@ class TwigExtensionFiltersTest extends UnitTestCase { $element = [ 'existing' => 'value', 'element' => [ - 'type' => 'element', - 'attributes' => [ + '#type' => 'element', + '#attributes' => [ 'class' => ['old-value-1', 'old-value-2'], 'id' => 'element', ], @@ -210,13 +346,13 @@ class TwigExtensionFiltersTest extends UnitTestCase { public function providerTestAddFilter(): array { return [ 'replacing a value' => [ - 'path' => 'element.attributes.id', + 'path' => 'element.#attributes.id', 'value' => 'new-value', 'expected' => [ 'existing' => 'value', 'element' => [ - 'type' => 'element', - 'attributes' => [ + '#type' => 'element', + '#attributes' => [ 'class' => ['old-value-1', 'old-value-2'], 'id' => 'new-value', ], @@ -224,13 +360,13 @@ class TwigExtensionFiltersTest extends UnitTestCase { ], ], 'setting a new property on an existing array' => [ - 'path' => 'element.attributes.placeholder', + 'path' => 'element.#attributes.placeholder', 'value' => 'new-value', 'expected' => [ 'existing' => 'value', 'element' => [ - 'type' => 'element', - 'attributes' => [ + '#type' => 'element', + '#attributes' => [ 'class' => ['old-value-1', 'old-value-2'], 'id' => 'element', 'placeholder' => 'new-value', @@ -239,13 +375,13 @@ class TwigExtensionFiltersTest extends UnitTestCase { ], ], 'targeting an existing array with a string' => [ - 'path' => 'element.attributes.class', + 'path' => 'element.#attributes.class', 'value' => 'new-value', 'expected' => [ 'existing' => 'value', 'element' => [ - 'type' => 'element', - 'attributes' => [ + '#type' => 'element', + '#attributes' => [ 'class' => ['old-value-1', 'old-value-2', 'new-value'], 'id' => 'element', ], @@ -253,13 +389,13 @@ class TwigExtensionFiltersTest extends UnitTestCase { ], ], 'targeting an existing array with an array' => [ - 'path' => 'element.attributes.class', + 'path' => 'element.#attributes.class', 'value' => ['new-value-1', 'new-value-2'], 'expected' => [ 'existing' => 'value', 'element' => [ - 'type' => 'element', - 'attributes' => [ + '#type' => 'element', + '#attributes' => [ 'class' => [ 'old-value-1', 'old-value-2', @@ -272,18 +408,18 @@ class TwigExtensionFiltersTest extends UnitTestCase { ], ], 'targeting a non-existent parent property' => [ - 'path' => 'new-element.attributes.class', + 'path' => 'new-element.#attributes.class', 'value' => ['new-value'], 'expected' => [ 'existing' => 'value', 'element' => [ - 'type' => 'element', - 'attributes' => [ + '#type' => 'element', + '#attributes' => [ 'class' => ['old-value-1', 'old-value-2'], 'id' => 'element', ], ], - 'new-element' => ['attributes' => ['class' => ['new-value']]], + 'new-element' => ['#attributes' => ['class' => ['new-value']]], ], ], ];