From f08ad1b5fdfc20c30b22559e8b1dd54ae9a418ff Mon Sep 17 00:00:00 2001 From: Arthur Taylor Date: Mon, 14 Jul 2025 09:41:53 +0200 Subject: [PATCH] Handle complex expressions in for loops and member expressions In order to render structured data in Vue templates, we need to be able to destructure more complex Javascript expressions both in the target of 'v-for' statments and in Javascript Member Expressions that use 'Computed' values. Here we add support for data structures that use hypens in key names `
`, or numeric key names `
`, or in the case where the lookup key is itself an identifier `{{ data[variable] }}``. Bug: T396098 --- src/JsParsing/ComputedKey.php | 24 +++++++++++++ src/JsParsing/PeastExpressionConverter.php | 13 ++++--- src/JsParsing/VariableAccess.php | 11 +++--- tests/php/TemplatingTest.php | 40 ++++++++++++++++++++++ 4 files changed, 80 insertions(+), 8 deletions(-) create mode 100644 src/JsParsing/ComputedKey.php diff --git a/src/JsParsing/ComputedKey.php b/src/JsParsing/ComputedKey.php new file mode 100644 index 0000000..c1a2c0b --- /dev/null +++ b/src/JsParsing/ComputedKey.php @@ -0,0 +1,24 @@ +expression = $expression; + } + + /** + * @param array $data + * + * @return expression as evaluated in the context of the data + */ + public function evaluate( array $data ) { + return $this->expression->evaluate( $data ); + } + +} diff --git a/src/JsParsing/PeastExpressionConverter.php b/src/JsParsing/PeastExpressionConverter.php index 47480b3..af644b0 100644 --- a/src/JsParsing/PeastExpressionConverter.php +++ b/src/JsParsing/PeastExpressionConverter.php @@ -50,11 +50,15 @@ protected function convertMemberExpression( MemberExpression $expression ) { $parts = []; while ( $expression !== null ) { if ( get_class( $expression ) === MemberExpression::class ) { - $property = $expression->getProperty()->getName(); - array_unshift( $parts, $property ); + if ( $expression->getComputed() ) { + array_unshift( $parts, new ComputedKey( $this->convertExpression( $expression->getProperty() ) ) ); + } else { + $propertyName = $this->convertKeyToLiteral( $expression->getProperty() ); + array_unshift( $parts, new StringLiteral( $propertyName ) ); + } $expression = $expression->getObject(); } elseif ( get_class( $expression ) === Identifier::class ) { - array_unshift( $parts, $expression->getName() ); + array_unshift( $parts, new StringLiteral( $expression->getName() ) ); $expression = null; } else { throw new RuntimeException( @@ -85,7 +89,7 @@ public function convertExpression( Expression $expression ) { UnaryExpression::class => $this->convertUnaryExpression( $expression ), MemberExpression::class => $this->convertMemberExpression( $expression ), PeastStringLiteral::class => new StringLiteral( $expression->getValue() ), - Identifier::class => new VariableAccess( [ $expression->getName() ] ), + Identifier::class => new VariableAccess( [ new StringLiteral( $expression->getName() ) ] ), CallExpression::class => $this->convertCallExpression( $expression ), ObjectExpression::class => $this->convertObjectExpression( $expression ), PeastBooleanLiteral::class => new BooleanLiteral( $expression->getValue() ), @@ -100,6 +104,7 @@ public function convertExpression( Expression $expression ) { protected function convertKeyToLiteral( $key ) { return match( get_class( $key ) ) { PeastStringLiteral::class => $key->getValue(), + PeastNumericLiteral::class => $key->getValue(), Identifier::class => $key->getName(), default => throw new RuntimeException( 'Unable to extract name from dictionary key of type ' . get_class( $key ) diff --git a/src/JsParsing/VariableAccess.php b/src/JsParsing/VariableAccess.php index f97be03..97cc9af 100644 --- a/src/JsParsing/VariableAccess.php +++ b/src/JsParsing/VariableAccess.php @@ -7,7 +7,7 @@ class VariableAccess implements ParsedExpression { /** - * @var string[] + * @var ParsedExpression[] */ private $pathParts; @@ -24,11 +24,14 @@ public function __construct( array $pathParts ) { public function evaluate( array $data ) { $value = $data; foreach ( $this->pathParts as $key ) { - if ( !array_key_exists( $key, $value ) ) { - $expression = implode( '.', $this->pathParts ); + $keyValue = $key->evaluate( $data ); + if ( !array_key_exists( $keyValue, $value ) ) { + $expression = implode( '.', array_map( + static fn ( $part ) => $part->evaluate( $data ), $this->pathParts + ) ); throw new RuntimeException( "Undefined variable '{$expression}'" ); } - $value = $value[$key]; + $value = $value[$keyValue]; } return $value; } diff --git a/tests/php/TemplatingTest.php b/tests/php/TemplatingTest.php index 02ef7d5..cdd49e2 100644 --- a/tests/php/TemplatingTest.php +++ b/tests/php/TemplatingTest.php @@ -255,6 +255,46 @@ public function testTemplateWithForLoopAndMultipleElementsInArrayToIterate_Rende $this->assertSame( '

', $result ); } + public function testTemplateWithForLoopAndMultipleElementsInNestedArrayWithStringKeys_ResolvesVariables() { + $result = $this->createAndRender( + '

', + [ 'list' => [ 'data-values' => [ 1, 2 ] ] ] + ); + + $this->assertSame( '

', $result ); + } + + public function testTemplateWithForLoopAndMultipleElementsInNestedIndexedArray_ResolvesVariables() { + $result = $this->createAndRender( + '

', + [ 'list' => [ [ 3, 4, 5 ], [ 1, 2 ] ] ] + ); + + $this->assertSame( '

', $result ); + } + + public function testForVariableIsAvailableForNestedExpressions() { + $result = $this->createAndRender( + '
' . + '

{{ data[index] }}

' . + '
', + [ 'indexKeys' => [ 'index1', 'index2' ], + 'data' => [ 'index1' => 1, 'index2' => 2 ] ] + ); + $this->assertSame( '

1

2

', $result ); + } + + public function testForVariableIsAvailableForNestedExpressions_NestedDataAccess() { + $result = $this->createAndRender( + '
' . + '

{{ indexKeys[index.key] }}

' . + '
', + [ 'indexKeys' => [ 'value1', 'value2' ], + 'data' => [ 'index1' => [ 'key' => 0 ], 'index2' => [ 'key' => 1 ] ] ] + ); + $this->assertSame( '

value1

value2

', $result ); + } + public function testTemplateWithForLoopMustache_RendersCorrectValues() { $result = $this->createAndRender( '

{{item}}

',