diff --git a/src/Template/TwigExtension.php b/src/Template/TwigExtension.php index 1c3e3457f65da99027794a58b6bc3dbeba38edcc..50bf13e310d5a9cc8fd1aa9af1be4eefa5879c28 100644 --- a/src/Template/TwigExtension.php +++ b/src/Template/TwigExtension.php @@ -2,8 +2,11 @@ namespace Drupal\components\Template; +use ArrayAccess; use Exception; +use Twig\Error\RuntimeError; use Twig\Extension\AbstractExtension; +use Twig\TwigFilter; use Twig\TwigFunction; /** @@ -20,6 +23,16 @@ class TwigExtension extends AbstractExtension { ]; } + /** + * {@inheritdoc} + */ + public function getFilters() { + return [ + 'set' => new TwigFilter('set', ['Drupal\components\Template\TwigExtension', 'setFilter']), + 'add' => new TwigFilter('add', ['Drupal\components\Template\TwigExtension', 'addFilter']), + ]; + } + /** * {@inheritdoc} */ @@ -92,4 +105,96 @@ class TwigExtension extends AbstractExtension { return $render_array; } + /** + * Recursively merges an array into the element, replacing existing values. + * + * @code + * {{ form|set( {'element': {'attributes': {'placeholder': 'Label'}}} ) }} + * @endcode + * + * @param array|iterable|\Traversable $element + * The parent renderable array to merge into. + * @param iterable|array $array + * The array to merge. + * + * @return array + * The merged renderable array. + * + * @throws \Twig\Error\RuntimeError + * When $element is not an array or "Traversable". + */ + public static function setFilter($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))); + } + + return array_replace_recursive($element, $array); + } + + /** + * Adds a deeply-nested property on an array. + * + * If the deeply-nested property exists, the existing data will be replaced + * with the new value, unless the existing data is an array. In which case, + * the new value will be merged into the existing array. + * + * @code + * {{ form|add( 'element.attributes.class', 'new-class' ) }} + * @endcode + * + * @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. + * + * @return array + * The merged renderable array. + * + * @throws \Twig\Error\RuntimeError + * When $element is not an array or "Traversable". + */ + 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))); + } + + if ($element instanceof ArrayAccess) { + $filtered_element = clone $element; + } + else { + $filtered_element = $element; + } + + // Convert the dotted path into an array of keys. + $path = explode('.', $path); + $last_path = array_pop($path); + + // Traverse the element down the path, creating arrays as needed. + $child_element =& $filtered_element; + foreach ($path as $child_path) { + if (!isset($child_element[$child_path])) { + $child_element[$child_path] = []; + } + $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 (is_array($value)) { + $child_element[$last_path] = array_merge($child_element[$last_path], $value); + } + else { + $child_element[$last_path][] = $value; + } + } + else { + // Otherwise, replace the target element with the given value. + $child_element[$last_path] = $value; + } + + return $filtered_element; + } + } diff --git a/tests/src/Unit/Template/TwigExtensionTest.php b/tests/src/Unit/Template/TwigExtensionTest.php index daa322db25463afa8db519bdc8777b86627381e5..9c4e866f0d4ec31c64f52e4e709cb6a81b715b64 100644 --- a/tests/src/Unit/Template/TwigExtensionTest.php +++ b/tests/src/Unit/Template/TwigExtensionTest.php @@ -180,4 +180,207 @@ class TwigExtensionTest extends UnitTestCase { } } + /** + * Tests the set filter. + * + * @covers ::setFilter + */ + public function testSetFilter() { + try { + TwigExtension::setFilter('not-an-array', ['key' => 'value']); + $this->fail('Expected Exception, none was thrown.'); + } + catch (Exception $e) { + $this->assertContains('The set filter only works with arrays or "Traversable", got "string" as first argument.', $e->getMessage()); + } + + $element = [ + 'existing' => 'value', + 'element' => [ + 'type' => 'element', + 'attributes' => [ + 'class' => ['old-value-1', 'old-value-2'], + 'id' => 'element', + ], + ], + ]; + $value = [ + 'element' => [ + 'attributes' => [ + 'class' => ['new-value'], + 'placeholder' => 'Label', + ], + ], + ]; + $expected = [ + 'existing' => 'value', + 'element' => [ + 'type' => 'element', + 'attributes' => [ + 'class' => ['new-value', 'old-value-2'], + 'id' => 'element', + 'placeholder' => 'Label', + ], + ], + ]; + try { + $result = TwigExtension::setFilter($element, $value); + $this->assertEquals($expected, $result); + $this->assertEquals(array_replace_recursive($element, $value), $result); + } + catch (Exception $e) { + $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown.'); + } + } + + /** + * Tests the add filter. + * + * @covers ::addFilter + */ + public function testAddFilter() { + try { + TwigExtension::addFilter('not-an-array', 'key', 'value'); + $this->fail('Expected Exception, none was thrown.'); + } + catch (Exception $e) { + $this->assertContains('The add filter only works with arrays or "Traversable", got "string" as first argument.', $e->getMessage()); + } + + $data = [ + 'existing' => 'value', + 'element' => [ + 'type' => 'element', + 'attributes' => [ + 'class' => ['old-value-1', 'old-value-2'], + 'id' => 'element', + ], + ], + ]; + + // Test replacing a value. + $element = $data; + $result = NULL; + $expected = [ + 'existing' => 'value', + 'element' => [ + 'type' => 'element', + 'attributes' => [ + 'class' => ['old-value-1', 'old-value-2'], + 'id' => 'new-value', + ], + ], + ]; + try { + $result = TwigExtension::addFilter($element, 'element.attributes.id', 'new-value'); + } + catch (Exception $e) { + $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown.'); + } + $this->assertEquals($expected, $result, 'Failed to replace a value.'); + + // Test setting a new property on an existing array. + $element = $data; + $result = NULL; + $expected = [ + 'existing' => 'value', + 'element' => [ + 'type' => 'element', + 'attributes' => [ + 'class' => ['old-value-1', 'old-value-2'], + 'id' => 'element', + 'placeholder' => 'new-value', + ], + ], + ]; + try { + $result = TwigExtension::addFilter($element, 'element.attributes.placeholder', 'new-value'); + } + catch (Exception $e) { + $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown.'); + } + $this->assertEquals($expected, $result, 'Failed setting a new property value.'); + + // Test targeting an existing array with a string. + $element = $data; + $result = NULL; + $expected = [ + 'existing' => 'value', + 'element' => [ + 'type' => 'element', + 'attributes' => [ + 'class' => ['old-value-1', 'old-value-2', 'new-value'], + 'id' => 'element', + ], + ], + ]; + try { + $result = TwigExtension::addFilter( + $element, + 'element.attributes.class', + 'new-value' + ); + } + catch (Exception $e) { + $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown.'); + } + $this->assertEquals($expected, $result, 'Failed adding into a targeted array.'); + + // Test targeting an existing array with an array. + $element = $data; + $result = NULL; + $expected = [ + 'existing' => 'value', + 'element' => [ + 'type' => 'element', + 'attributes' => [ + 'class' => [ + 'old-value-1', + 'old-value-2', + 'new-value-1', + 'new-value-2', + ], + 'id' => 'element', + ], + ], + ]; + try { + $result = TwigExtension::addFilter( + $element, + 'element.attributes.class', + ['new-value-1', 'new-value-2'] + ); + } + catch (Exception $e) { + $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown.'); + } + $this->assertEquals($expected, $result, 'Failed merging a targeted array.'); + + // Test targeting a non-existent parent property. + $element = $data; + $result = NULL; + $expected = [ + 'existing' => 'value', + 'element' => [ + 'type' => 'element', + 'attributes' => [ + 'class' => ['old-value-1', 'old-value-2'], + 'id' => 'element', + ], + ], + 'new-element' => ['attributes' => ['class' => ['new-value']]], + ]; + try { + $result = TwigExtension::addFilter( + $element, + 'new-element.attributes.class', + ['new-value'] + ); + } + catch (Exception $e) { + $this->fail('No Exception expected; "' . $e->getMessage() . '" thrown.'); + } + $this->assertEquals($expected, $result, 'Failed adding new branch to an element.'); + } + }