ENH Looping through arrays in templates

This commit is contained in:
Guy Sartorelli 2024-05-16 16:33:38 +12:00
parent c6aee6c5c7
commit 6ce976a0ee
No known key found for this signature in database
GPG Key ID: F313E3B9504D496A
8 changed files with 279 additions and 16 deletions

View File

@ -77,7 +77,7 @@ class ArrayData extends ViewableData
public function getField($field)
{
$value = $this->array[$field];
if (is_object($value) && !$value instanceof ViewableData) {
if (is_object($value) && !($value instanceof ViewableData) && !is_iterable($value)) {
return new ArrayData($value);
} elseif (ArrayLib::is_associative($value)) {
return new ArrayData($value);

View File

@ -287,6 +287,8 @@ class SSTemplateParser extends Parser implements TemplateParser
if (isset($sub['Call']['CallArguments']) && isset($sub['Call']['CallArguments']['php'])) {
$arguments = $sub['Call']['CallArguments']['php'];
$res['php'] .= "->$method('$property', [$arguments], true)";
} elseif ($property === 'Count') {
$res['php'] .= "->$property()";
} else {
$res['php'] .= "->$method('$property', null, true)";
}

View File

@ -778,6 +778,8 @@ class SSTemplateParser extends Parser implements TemplateParser
if (isset($sub['Call']['CallArguments']) && isset($sub['Call']['CallArguments']['php'])) {
$arguments = $sub['Call']['CallArguments']['php'];
$res['php'] .= "->$method('$property', [$arguments], true)";
} elseif ($property === 'Count') {
$res['php'] .= "->$property()";
} else {
$res['php'] .= "->$method('$property', null, true)";
}
@ -1886,6 +1888,8 @@ class SSTemplateParser extends Parser implements TemplateParser
$res['php'] .= '((bool)'.$sub['php'].')';
} else {
$php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']);
// TODO: kinda hacky - maybe we need a way to pass state down the parse chain so
// Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val
$res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? '');
}
}
@ -5290,6 +5294,8 @@ class SSTemplateParser extends Parser implements TemplateParser
$text = stripslashes($text ?? '');
$text = addcslashes($text ?? '', '\'\\');
// TODO: This is pretty ugly & gets applied on all files not just html. I wonder if we can make this
// non-dynamically calculated
$code = <<<'EOC'
(\SilverStripe\View\SSViewer::getRewriteHashLinksDefault()
? \SilverStripe\Core\Convert::raw2att( preg_replace("/^(\\/)+/", "/", $_SERVER['REQUEST_URI'] ) )
@ -5328,7 +5334,8 @@ EOC;
$this->includeDebuggingComments = $includeDebuggingComments;
// Ignore UTF8 BOM at beginning of string.
// Ignore UTF8 BOM at beginning of string. TODO: Confirm this is needed, make sure SSViewer handles UTF
// (and other encodings) properly
if (substr($string ?? '', 0, 3) == pack("CCC", 0xef, 0xbb, 0xbf)) {
$this->pos = 3;
}

View File

@ -172,6 +172,11 @@ class SSViewer_DataPresenter extends SSViewer_Scope
// Get source for this value
$result = $this->getValueSource($property);
if (!array_key_exists('source', $result)) {
$obj = $this->getItem();
// $Me is a special property. If nothing is providing an override, return the current item.
if ($property === 'Me' && !isset($obj->$property)) {
return ['obj' => $this->getItem()];
}
return null;
}
@ -303,6 +308,29 @@ class SSViewer_DataPresenter extends SSViewer_Scope
*/
public function __call($name, $arguments)
{
// $Count should be handled specially, so we can count raw arrays and iterables
// which aren't ViewableData.
if (empty($arguments) && ($name === 'Count' || $name === 'count')) {
$item = $this->getItem();
$result = null;
if ($item instanceof ViewableData) {
// Respect ViewableData casting
$result = $item->XML_val($name, [], true);
} elseif (is_object($item)) {
// Get the method or property from objects, if there is one
if (ClassInfo::hasMethod($item, $name)) {
$result = $item->$name();
} elseif (isset($item->$name)) {
$result = $item->$name;
}
} elseif (is_countable($item)) {
// Count countables
$result = count($item);
}
$this->resetLocalScope();
return $result;
}
// Extract the method name and parameters
$property = $arguments[0]; // The name of the public function being called
@ -313,7 +341,14 @@ class SSViewer_DataPresenter extends SSViewer_Scope
if ($val) {
$obj = $val['obj'];
if ($name === 'hasValue') {
$result = ($obj instanceof ViewableData) ? $obj->exists() : (bool)$obj;
// Check if a value exists, e.g. <% if $obj %>
if ($obj instanceof ViewableData) {
$result = $obj->exists();
} elseif (is_countable($obj)) {
$result = count($obj) > 0;
} else {
$result = (bool) $obj;
}
} elseif (is_null($obj) || (is_scalar($obj) && !is_string($obj))) {
$result = $obj; // Nulls and non-string scalars don't need casting
} else {

View File

@ -130,6 +130,16 @@ class SSViewer_Scope
if (is_scalar($item)) {
$item = $this->convertScalarToDBField($item);
}
// Wrap arrays
if (is_array($item)) {
if (array_is_list($item)) {
// Wrap in ArrayIterator to respect method signature
$item = new ArrayIterator($item);
} else {
// Wrap in ArrayData so values can be accessed by key in templates
$item = ArrayData::create($item);
}
}
return $item;
}
@ -308,6 +318,8 @@ class SSViewer_Scope
// Item may be an array or a regular IteratorAggregate
if (is_array($this->item)) {
$this->itemIterator = new ArrayIterator($this->item);
} elseif ($this->item instanceof Iterator) {
$this->itemIterator = $this->item;
} else {
$this->itemIterator = $this->item->getIterator();

View File

@ -537,7 +537,7 @@ class ViewableData implements IteratorAggregate
* @param array $arguments
* @param bool $cache Cache this object
* @param string $cacheName a custom cache name
* @return Object|DBField
* @return object|DBField
*/
public function obj($fieldName, $arguments = [], $cache = false, $cacheName = null)
{
@ -558,6 +558,17 @@ class ViewableData implements IteratorAggregate
$value = $this->$fieldName;
}
// Wrap arrays
if (is_array($value)) {
if (array_is_list($value)) {
// Wrap in ArrayIterator to respect method signature
$value = new ArrayIterator($value);
} else {
// Wrap in ArrayData so values can be accessed by key in templates
$value = ArrayData::create($value);
}
}
// Cast object
if (!is_object($value)) {
// Force cast
@ -601,7 +612,13 @@ class ViewableData implements IteratorAggregate
public function hasValue($field, $arguments = [], $cache = true)
{
$result = $this->obj($field, $arguments, $cache);
return $result->exists();
if ($result instanceof ViewableData) {
return $result->exists();
}
if (is_countable($result)) {
return count($result) > 0;
}
return (bool) $result;
}
/**
@ -668,17 +685,6 @@ class ViewableData implements IteratorAggregate
return SSViewer::get_templates_by_class(static::class, $suffix, self::class);
}
/**
* When rendering some objects it is necessary to iterate over the object being rendered, to do this, you need
* access to itself.
*
* @return ViewableData
*/
public function Me()
{
return $this;
}
/**
* Get part of the current classes ancestry to be used as a CSS class.
*

View File

@ -2,6 +2,7 @@
namespace SilverStripe\View\Tests;
use ArrayIterator;
use Exception;
use InvalidArgumentException;
use LogicException;
@ -1016,6 +1017,48 @@ after'
);
}
public function provideIfBlockWithIterable(): array
{
$scenarios = [
'empty array' => [
'iterable' => [],
'inScope' => false,
],
'array' => [
'iterable' => [1, 2, 3],
'inScope' => false,
],
'iterator' => [
'iterable' => new ArrayIterator([1, 2, 3]),
'inScope' => false,
],
'ArrayList' => [
'iterable' => new ArrayList([['Val' => 1], ['Val' => 2], ['Val' => 3]]),
'inScope' => false,
],
];
foreach ($scenarios as $name => $scenario) {
$scenario['inScope'] = true;
$scenarios[$name . ' in scope'] = $scenario;
}
return $scenarios;
}
/**
* @dataProvider provideIfBlockWithIterable
*/
public function testIfBlockWithIterable(iterable $iterable, bool $inScope): void
{
$expected = count($iterable) ? 'has value' : 'no value';
$data = new ArrayData(['Iterable' => $iterable]);
if ($inScope) {
$template = '<% with $Iterable %><% if $Me %>has value<% else %>no value<% end_if %><% end_with %>';
} else {
$template = '<% if $Iterable %>has value<% else %>no value<% end_if %>';
}
$this->assertEqualIgnoringWhitespace($expected, $this->render($template, $data));
}
public function testBaseTagGeneration()
{
// XHTML will have a closed base tag
@ -1331,6 +1374,92 @@ after'
);
}
public function provideLoop(): array
{
return [
'nested array and iterator' => [
'iterable' => [
[
'value 1',
'value 2',
],
new ArrayIterator([
'value 3',
'value 4',
]),
],
'template' => '<% loop $Iterable %><% loop $Me %>$Me<% end_loop %><% end_loop %>',
'expected' => 'value 1 value 2 value 3 value 4',
],
'nested associative arrays' => [
'iterable' => [
[
'Foo' => 'one',
],
[
'Foo' => 'two',
],
[
'Foo' => 'three',
],
],
'template' => '<% loop $Iterable %>$Foo<% end_loop %>',
'expected' => 'one two three',
],
];
}
/**
* @dataProvider provideLoop
*/
public function testLoop(iterable $iterable, string $template, string $expected): void
{
$data = new ArrayData(['Iterable' => $iterable]);
$this->assertEqualIgnoringWhitespace($expected, $this->render($template, $data));
}
public function provideCountIterable(): array
{
$scenarios = [
'empty array' => [
'iterable' => [],
'inScope' => false,
],
'array' => [
'iterable' => [1, 2, 3],
'inScope' => false,
],
'iterator' => [
'iterable' => new ArrayIterator([1, 2, 3]),
'inScope' => false,
],
'ArrayList' => [
'iterable' => new ArrayList([['Val' => 1], ['Val' => 2], ['Val' => 3]]),
'inScope' => false,
],
];
foreach ($scenarios as $name => $scenario) {
$scenario['inScope'] = true;
$scenarios[$name . ' in scope'] = $scenario;
}
return $scenarios;
}
/**
* @dataProvider provideCountIterable
*/
public function testCountIterable(iterable $iterable, bool $inScope): void
{
$expected = count($iterable);
$data = new ArrayData(['Iterable' => $iterable]);
if ($inScope) {
$template = '<% with $Iterable %>$Count<% end_with %>';
} else {
$template = '$Iterable.Count';
}
$this->assertEqualIgnoringWhitespace($expected, $this->render($template, $data));
}
public function testSSViewerBasicIteratorSupport()
{
$data = new ArrayData(
@ -2230,4 +2359,34 @@ EOC;
$this->render('<% loop $Foo %>$Me<% end_loop %>', $data)
);
}
public function testMe(): void
{
$mockArrayData = $this->getMockBuilder(ArrayData::class)->addMethods(['forTemplate'])->getMock();
$mockArrayData->expects($this->once())->method('forTemplate');
$this->render('$Me', $mockArrayData);
}
public function provideAccessAssociativeArrayValues(): array
{
return [
'in scope' => [
true,
],
'not in scope' => [
false,
],
];
}
/**
* @dataProvider provideAccessAssociativeArrayValues
*/
public function testAccessAssociativeArrayValues(bool $inScope): void
{
$data = new ViewableData();
$data->Foo = ['Value1' => '1', 'Value2' => 'two'];
$template = $inScope ? '<% with $Foo %>$Value1 $Value2<% end_with %>' : '$Foo.Value1 $Foo.Value2';
$this->assertEqualIgnoringWhitespace('1 two', $this->render($template, $data));
}
}

View File

@ -2,6 +2,7 @@
namespace SilverStripe\View\Tests;
use ArrayIterator;
use ReflectionMethod;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\Dev\SapphireTest;
@ -278,4 +279,45 @@ class ViewableDataTest extends SapphireTest
$this->assertSame($obj, $viewableData->getDynamicData('abc'));
$this->assertSame($obj, $viewableData->abc);
}
public function provideWrapArrayInObj(): array
{
return [
'empty array' => [
'arr' => [],
'expectedClass' => ArrayIterator::class,
],
'fully indexed array' => [
'arr' => [
'value1',
'value2',
],
'expectedClass' => ArrayIterator::class,
],
'fully associative array' => [
'arr' => [
'v1' => 'value1',
'v2' => 'value2',
],
'expectedClass' => ArrayData::class,
],
'partially associative array' => [
'arr' => [
'value1',
'v2' => 'value2',
],
'expectedClass' => ArrayData::class,
],
];
}
/**
* @dataProvider provideWrapArrayInObj
*/
public function testWrapArrayInObj(array $arr, string $expectedClass): void
{
$viewableData = new ViewableData();
$viewableData->arr = $arr;
$this->assertInstanceOf($expectedClass, $viewableData->obj('arr'));
}
}