diff --git a/src/Attributes.php b/src/Attributes.php index ffe22eea..67fcbd17 100644 --- a/src/Attributes.php +++ b/src/Attributes.php @@ -4,8 +4,10 @@ use ArrayAccess; use ArrayIterator; +use Closure; use InvalidArgumentException; use IteratorAggregate; +use ReflectionFunction; use Traversable; use function ipl\Stdlib\get_php_type; @@ -509,6 +511,53 @@ public function getIterator(): Traversable return new ArrayIterator($this->attributes); } + /** + * Rebind all callbacks that point to `$oldThisId` to `$newThis` + * + * @param int $oldThisId + * @param object $newThis + */ + public function rebind(int $oldThisId, object $newThis): void + { + $this->rebindCallbacks($this->callbacks, $oldThisId, $newThis); + $this->rebindCallbacks($this->setterCallbacks, $oldThisId, $newThis); + } + + /** + * Loops over all `$callbacks`, binds them to `$newThis` only where `$oldThisId` matches. The callbacks are + * modified directly on the `$callbacks` reference. + * + * @param callable[] $callbacks + * @param int $oldThisId + * @param object $newThis + */ + private function rebindCallbacks(array &$callbacks, int $oldThisId, object $newThis): void + { + foreach ($callbacks as &$callback) { + if (! $callback instanceof Closure) { + if (is_array($callback) && ! is_string($callback[0])) { + if (spl_object_id($callback[0]) === $oldThisId) { + $callback[0] = $newThis; + } + } + + continue; + } + + $closureThis = (new ReflectionFunction($callback)) + ->getClosureThis(); + + // Closure is most likely static + if ($closureThis === null) { + continue; + } + + if (spl_object_id($closureThis) === $oldThisId) { + $callback = $callback->bindTo($newThis); + } + } + } + public function __clone() { foreach ($this->attributes as &$attribute) { diff --git a/src/BaseHtmlElement.php b/src/BaseHtmlElement.php index 41b6e4b5..3d57a797 100644 --- a/src/BaseHtmlElement.php +++ b/src/BaseHtmlElement.php @@ -75,6 +75,9 @@ abstract class BaseHtmlElement extends HtmlDocument /** @var string Tag of element. Set this property in order to provide the element's tag when extending this class */ protected $tag; + /** @var int Holds an ID to identify itself, used to get the ID of the Object for comparison when cloning */ + protected $thisRefId; + /** * Get the attributes of the element * @@ -83,6 +86,8 @@ abstract class BaseHtmlElement extends HtmlDocument public function getAttributes() { if ($this->attributes === null) { + $this->thisRefId = spl_object_id($this); + $default = $this->getDefaultAttributes(); if (empty($default)) { $this->attributes = new Attributes(); @@ -105,6 +110,8 @@ public function getAttributes() */ public function setAttributes($attributes) { + $this->thisRefId = spl_object_id($this); + $this->attributes = Attributes::wantAttributes($attributes); $this->attributeCallbacksRegistered = false; @@ -359,6 +366,11 @@ public function __clone() if ($this->attributes !== null) { $this->attributes = clone $this->attributes; + + // `$this->thisRefId` is the ID to this Object prior of cloning, `$this` is the newly cloned Object + $this->attributes->rebind($this->thisRefId, $this); + + $this->thisRefId = spl_object_id($this); } } } diff --git a/src/FormElement/Collection.php b/src/FormElement/Collection.php new file mode 100644 index 00000000..52f63d2d --- /dev/null +++ b/src/FormElement/Collection.php @@ -0,0 +1,161 @@ +setAddElement('add_element', [ + * 'required' => false, + * 'label' => 'Add Trigger', + * 'options' => [null => 'Please choose', 'first' => 'First Option'], + * 'class' => 'autosubmit' + * ]); + * + * $collection->onAssembleGroup(function ($group, $addElement, $removeElement) { + * $group->addElement($addElement); + * $group->addElement('input', 'test_input'); + * }); + * + * $form + * ->registerElement($collection) + * ->addHtml($collection) + * ``` + */ +class Collection extends FieldsetElement +{ + protected const GROUP_CSS_CLASS = 'form-element-collection'; + + /** @var callable */ + protected $onAssembleGroup; + + /** @var array */ + protected $addElement = [ + 'type' => null, + 'name' => null, + 'options' => null + ]; + + /** @var array */ + protected $removeElement = [ + 'type' => null, + 'name' => null, + 'options' => null + ]; + + /** @var string[] */ + protected $defaultAttributes = [ + 'class' => 'collection' + ]; + + /** + * @param callable $callback + * + * @return void + */ + public function onAssembleGroup(callable $callback): void + { + $this->onAssembleGroup = $callback; + } + + /** + * @param string $typeOrElement + * @param string|null $name + * @param null $options + * + * @return $this + */ + public function setAddElement(string $typeOrElement, string $name = null, $options = null): self + { + $this->addElement = ['type' => $typeOrElement, 'name' => $name, 'options' => $options]; + + return $this; + } + + /** + * @param string $typeOrElement + * @param string|null $name + * @param null $options + * + * @return $this + */ + public function setRemoveElement(string $typeOrElement, string $name = null, $options = null): self + { + $this->removeElement = ['type' => $typeOrElement, 'name' => $name, 'options' => $options]; + + return $this; + } + + /** + * @param $group + * @param $addElement + * @param $removeElement + * + * @return $this + */ + protected function assembleGroup($group, $addElement, $removeElement): self + { + if (is_callable($this->onAssembleGroup)) { + call_user_func($this->onAssembleGroup, $group, $addElement, $removeElement); + } + + return $this; + } + + protected function assemble() + { + $values = $this->getPopulatedValues(); + + $valid = true; + foreach ($values as $key => $items) { + if ($this->removeElement !== null && isset($items[0][$this->removeElement['name']])) { + continue; + } + + $group = $this->addGroup($key); + + if (empty($group->getValue($this->addElement['name']))) { + $valid = false; + } + } + + if ($valid) { + $lastKey = $values ? key(array_slice($values, -1, 1, true)) + 1 : 0; + $this->addGroup($lastKey); + } + } + + protected function addGroup($key): FieldsetElement + { + $group = new FieldsetElement( + $key, + Attributes::create(['class' => static::GROUP_CSS_CLASS]) + ); + + $this + ->registerElement($group) + ->assembleGroup( + $group, + $this->addElement['type'] ? $this->createElement( + $this->addElement['type'], + $this->addElement['name'], + $this->addElement['options'] + ) : null, + $this->removeElement['type'] ? $this->createElement( + $this->removeElement['type'], + $this->removeElement['name'], + $this->removeElement['options'] + ) : null + ) + ->addHtml($group); + + return $group; + } +} diff --git a/src/FormElement/FormElements.php b/src/FormElement/FormElements.php index 2f8e4e5a..f1c14c52 100644 --- a/src/FormElement/FormElements.php +++ b/src/FormElement/FormElements.php @@ -342,6 +342,16 @@ public function getPopulatedValue($name, $default = null) : $default; } + /** + * Get all populated values of the element + * + * @return array + */ + public function getPopulatedValues(): array + { + return $this->populatedValues; + } + /** * Clear populated value of the given element * diff --git a/src/FormElement/SelectElement.php b/src/FormElement/SelectElement.php index e6b4f217..1751b7b2 100644 --- a/src/FormElement/SelectElement.php +++ b/src/FormElement/SelectElement.php @@ -2,7 +2,6 @@ namespace ipl\Html\FormElement; -use InvalidArgumentException; use ipl\Html\Attributes; use ipl\Html\Common\MultipleAttribute; use ipl\Html\Html; @@ -235,4 +234,46 @@ protected function registerAttributeCallbacks(Attributes $attributes) $this->registerMultipleAttributeCallback($attributes); } + + private function parseOptionFromContent($content): array + { + $result = []; + + if ($content->getTag() === 'optgroup') { + $label = $content->getAttributes()->get('label')->getValue(); + + foreach ($content->getContent() as $item) { + $result[$label][$item->getValue()] = $item->getLabel(); + } + + return $result; + } + + /** @var SelectOption $content */ + $result[$content->getValue()] = $content->getLabel(); + + return $result; + } + + public function __clone() + { + foreach ($this->options as &$option) { + $option = clone $option; + } + + $rawOptions = []; + foreach ($this->optionContent as $content) { + if (is_array($content)) { + foreach ($content as $contentEntry) { + $rawOptions += $this->parseOptionFromContent($contentEntry); + } + } + + $rawOptions += $this->parseOptionFromContent($content); + } + + $this->setOptions($rawOptions); + + parent::__clone(); + } } diff --git a/tests/AttributesTest.php b/tests/AttributesTest.php index 8ce23e8b..61556e20 100644 --- a/tests/AttributesTest.php +++ b/tests/AttributesTest.php @@ -158,4 +158,75 @@ public function testClone(): void $cloneCone->render() ); } + + public function testAttributesAreDeepCloned() + { + $attributes = Attributes::create(['class' => 'one']); + + $clone = clone $attributes; + $clone->add('class', 'two'); + + $this->assertNotSame( + $attributes->get('class'), + $clone->get('class'), + 'Attribute instances are not cloned' + ); + $this->assertSame( + 'one', + $attributes->get('class')->getValue(), + 'Attribute instances are not cloned correctly' + ); + $this->assertSame( + ['one', 'two'], + $clone->get('class')->getValue(), + 'Attribute instances are not cloned correctly' + ); + } + + public function testCallbacksOfClonedAttributesPointToTheirClone() + { + $element = new class extends BaseHtmlElement { + protected $value; + + protected $noGetterOrSetter; + + public function setValue($value) + { + $this->value = $value; + } + + public function getValue() + { + return $this->value; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + $attributes->registerAttributeCallback('value', [$this, 'getValue'], [$this, 'setValue']); + $attributes->registerAttributeCallback('data-ngos', function () { + return $this->noGetterOrSetter; + }, function ($value) { + $this->noGetterOrSetter = $value; + }); + } + }; + + $element->setAttribute('value', 'foo'); + + $clone = clone $element; + + $clone->setAttribute('value', 'bar') + ->setAttribute('data-ngos', true); + + $this->assertSame( + ' value="foo"', + $element->getAttributes()->render(), + 'Attribute callbacks are not rebound to their new owner' + ); + $this->assertSame( + ' value="bar" data-ngos', + $clone->getAttributes()->render(), + 'Attribute callbacks are not rebound to their new owner' + ); + } } diff --git a/tests/CloneTest.php b/tests/CloneTest.php new file mode 100644 index 00000000..a4e08c80 --- /dev/null +++ b/tests/CloneTest.php @@ -0,0 +1,155 @@ +getAttributes()->set('class', 'original_class'); + + $firstClone = clone $original; + $firstClone->getAttributes()->set('class', 'first_clone_class'); + + $secondClone = clone $firstClone; + $secondClone->getAttributes()->set('class', 'second_clone_class'); + + $originalHtml = <<<'HTML' +

+

+HTML; + + $firstCloneHtml = <<<'HTML' +

+

+HTML; + + + $secondCloneHtml = <<<'HTML' +

+

+HTML; + + $this->assertHtml($originalHtml, $original); + $this->assertHtml($firstCloneHtml, $firstClone); + $this->assertHtml($secondCloneHtml, $secondClone); + } + + public function testElementCallbacksCloning(): void + { + $element = new CloningDummyElement(); + $element->getAttributes(); + + $clone = clone $element; + + $this->assertCallbacksFor($element); + $this->assertCallbacksFor($clone); + } + + public function testCloningAttributes(): void + { + $original = Attributes::create([Attribute::create('class', 'class01')]); + + $clone = clone $original; + foreach ($clone->getAttributes() as $attribute) { + if ($attribute->getName() === 'class') { + $attribute->setValue('class02'); + } + } + + $this->assertSame($original->get('class')->getValue(), 'class01'); + $this->assertSame($clone->get('class')->getValue(), 'class02'); + } + + protected function getCallbackThis(callable $callback): ?object + { + if (! $callback instanceof Closure) { + if (is_array($callback) && ! is_string($callback[0])) { + return $callback[0]; + } else { + return null; + } + } + + return (new ReflectionFunction($callback)) + ->getClosureThis(); + } + + protected function isCallbackGlobalOrStatic(callable $callback): bool + { + if (! $callback instanceof Closure) { + if (is_array($callback) && ! is_string($callback[0])) { + return false; + } + } else { + $closureThis = (new ReflectionFunction($callback)) + ->getClosureThis(); + + if ($closureThis) { + return false; + } + } + + return true; + } + + protected function getAttributeCallback(Attributes $attributes, string $name): callable + { + $callbacksProperty = new ReflectionProperty(get_class($attributes), 'callbacks'); + $callbacksProperty->setAccessible(true); + $callbacks = $callbacksProperty->getValue($attributes); + + return $callbacks[$name]; + } + + protected function assertCallbacksFor(CloningDummyElement $element) + { + $this->assertCallbackBelongsTo($element->getAttributes(), 'test-instance-scope-noop-inline', $element); + $this->assertCallbackBelongsTo( + $element->getAttributes(), + 'test-instance-noop-attribute', + $element + ); + $this->assertGlobalOrStaticCallback( + $element->getAttributes(), + 'test-closure-static-scope-noop' + ); + $this->assertGlobalOrStaticCallback( + $element->getAttributes(), + 'test-closure-instance-scope-noop' + ); + } + + protected function assertGlobalOrStaticCallback(Attributes $attributes, string $callbackName) + { + $callback = $this->getAttributeCallback($attributes, $callbackName); + $this->assertTrue($this->isCallbackGlobalOrStatic($callback)); + } + + protected function assertCallbackBelongsTo(Attributes $attributes, string $callbackName, object $owner) + { + $callback = $this->getAttributeCallback($attributes, $callbackName); + $callbackThis = $this->getCallbackThis($callback); + $this->assertSame($callbackThis, $owner); + } +} diff --git a/tests/CollectionTest.php b/tests/CollectionTest.php new file mode 100644 index 00000000..23821615 --- /dev/null +++ b/tests/CollectionTest.php @@ -0,0 +1,397 @@ +label = "Test Collection Label"; + $this->form = (new Form())->setDefaultElementDecorator(new SimpleFormElementDecorator()); + } + + public function testCanBeConstructed() + { + $collection = new Collection('testCollection'); + $this->assertInstanceOf(Collection::class, $collection); + } + + public function testSetLabel() + { + $collection = new Collection('testCollection'); + + $this->assertSame($collection, $collection->setLabel($this->label)); + $this->assertSame($this->label, $collection->getLabel()); + + $this->form->addHtml($collection); + + $expected = <<<'HTML' +
+
+
+
+ +HTML; + $this->assertHtml($expected, $this->form); + } + + public function testNoAddTriggerProvided() + { + $this->expectException(InvalidArgumentException::class); + + $collection = new Collection('testCollection'); + $collection->onAssembleGroup(function ($group, $addElement, $removeElement) { + // Throws Exception, because $addElement is null. + $group->addElement($addElement); + }); + + $collection->render(); + } + + public function testNoRemoveTriggerProvided() + { + $this->expectException(InvalidArgumentException::class); + + $collection = new Collection('testCollection'); + $collection->onAssembleGroup(function ($group, $addElement, $removeElement) { + // Throws Exception, because $removeElement is null. + $group->addElement($removeElement); + }); + + $collection->render(); + } + + public function testAddTrigger() + { + $collection = new Collection('testCollection'); + $collection + ->setAddElement('select', 'add_element', [ + 'required' => false, + 'label' => 'Add Trigger', + 'options' => [null => 'Please choose', 'first' => 'First Option'], + 'class' => 'autosubmit' + ]) + ->onAssembleGroup(function ($group, $addElement, $removeElement) { + $group + ->addElement($addElement) + ->addElement('input', 'test_input', [ + 'label' => 'Test Input' + ]); + }); + + $this->form->addHtml($collection); + + $expected = <<<'HTML' +
+
+
+ + +
+
+
+HTML; + + $this->assertHtml($expected, $this->form); + } + + public function testRemoveTrigger() + { + $collection = new Collection('testCollection'); + $collection + ->setRemoveElement('submitButton', 'remove_trigger', [ + 'label' => 'Remove Trigger', + ]) + ->onAssembleGroup(function ($group, $addElement, $removeElement) { + $group->addElement('input', 'test_input', [ + 'label' => 'Test Input' + ]); + + $group->addElement($removeElement); + }); + + $this->form->addHtml($collection); + + $expected = <<<'HTML' +
+
+
+ + +
+
+
+HTML; + + $this->assertHtml($expected, $this->form); + } + + public function testFullCollection() + { + $collection = (new Collection('testCollection')) + ->setAddElement('select', 'add_element', [ + 'required' => false, + 'label' => 'Add Trigger', + 'options' => [null => 'Please choose', 'first' => 'First Option'], + 'class' => 'autosubmit' + ]) + ->setRemoveElement('submitButton', 'remove_trigger', [ + 'label' => 'Remove Trigger', + 'value' => 'Remove Trigger' + ]); + + $collection->onAssembleGroup(function ($group, $addElement, $removeElement) { + $group->addElement($addElement); + + $group->addElement('input', 'test_input', [ + 'label' => 'Test Input' + ]); + $group->addElement('input', 'test_select', [ + 'label' => 'Test Select' + ]); + + $group->addElement($removeElement); + }); + + $this->form->addHtml($collection); + + $expected = <<<'HTML' +
+
+
+ + + + +
+
+
+HTML; + + $this->assertHtml($expected, $this->form); + } + + public function testMultipleCollections() + { + $collection = (new Collection('testCollection')) + ->setAddElement('select', 'add_element', [ + 'required' => false, + 'label' => 'Add Trigger', + 'options' => [null => 'Please choose', 'first' => 'First Option'] + ]); + + $collection->onAssembleGroup(function ($group, $addElement, $removeElement) { + $group->addElement($addElement); + + $inner = (new Collection('innerCollection')) + ->setLabel('Inner Collection') + ->setAddElement('submitButton', 'inner_add_trigger', [ + 'label' => 'Inner Add Trigger' + ]); + + $inner->onAssembleGroup(function ($innerGroup, $innerAddElement, $innerRemoveElement) { + $innerGroup->addElement($innerAddElement); + $innerGroup->addElement('input', 'test_input'); + }); + + $group->addElement($inner); + $group->addElement('input', 'test_input'); + }); + + $this->form->addHtml($collection); + + $expected = <<<'HTML' +
+
+
+ +
+
+ + +
+
+ +
+
+
+HTML; + + $this->assertHtml($expected, $this->form); + } + + public function testPopulatingCollection(): void + { + $collection = (new Collection('testCollection')) + ->setAddElement('select', 'test_select1', [ + 'options' => [ + 'key1' => 'value1', + 'key2' => 'value2' + ] + ]); + + $collection->onAssembleGroup(function ($group, $addElement) { + $group->addElement(new InputElement('test_input')); + $group->addElement($addElement); + $group->addElement(new SelectElement('test_select2', [ + 'options' => [ + 'key3' => 'value3', + 'key4' => 'value4' + ] + ])); + $group->addElement('select', 'test_select3', [ + 'options' => [ + 'key5' => 'value5', + 'key6' => 'value6' + ] + ]); + }); + + $this->form + ->registerElement($collection) + ->addHtml($collection) + ->populate([ + 'testCollection' => [ + [ + 'test_input' => 'test_value', + 'test_select1' => '', + 'test_select2' => 'key4', + 'test_select3' => 'key6' + ] + ] + ]); + + $expected = <<<'HTML' +
+
+
+ + + + +
+
+
+HTML; + + $this->assertHtml($expected, $this->form); + } + + public function testCollidingElementNames(): void + { + $firstCollection = (new Collection('first_collection')) + ->setAddElement('select', 'add_element', ['options' => ['key1' => 'value1', 'key2' => 'value2']]); + $secondCollection = (new Collection('second_collection')) + ->setAddElement('select', 'add_element', ['options' => ['key1' => 'value1', 'key2' => 'value2']]); + + $firstCollection->onAssembleGroup(function ($group, $addElement) { + $group->addElement($addElement); + }); + + $secondCollection->onAssembleGroup(function ($group, $addElement) { + $group->addElement($addElement); + }); + + $this->form + ->registerElement($firstCollection) + ->addHtml($firstCollection) + ->registerElement($secondCollection) + ->addHtml($secondCollection) + ->addElement(new SubmitButtonElement('add_element')) + ->populate([ + 'first_collection' => [ + [ + 'add_element' => 'key1' + ] + ], + 'second_collection' => [ + [ + 'add_element' => 'key2' + ], + [ + 'add_element' => 'key1' + ] + ] + ]); + + $expected = <<<'HTML' +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+
+HTML; + + $this->assertHtml($expected, $this->form); + } +} diff --git a/tests/FormElement/FieldsetElementTest.php b/tests/FormElement/FieldsetElementTest.php index 005e577d..01248869 100644 --- a/tests/FormElement/FieldsetElementTest.php +++ b/tests/FormElement/FieldsetElementTest.php @@ -187,6 +187,27 @@ public function testDecoratorPropagationWithDivDecorator(): void +HTML; + + $this->assertHtml($expected, $form); + } + + public function testLegendDecoration(): void + { + $fieldset = (new FieldsetElement('test_fieldset'))->setLabel('fieldset_label'); + + $form = (new Form()) + ->setDefaultElementDecorator(new DivDecorator()) + ->addElement($fieldset); + + $expected = <<< 'HTML' +
+
+
+ fieldset_label +
+
+
HTML; $this->assertHtml($expected, $form); diff --git a/tests/FormElement/RadioElementTest.php b/tests/FormElement/RadioElementTest.php index 61570f3c..fc4e3f3d 100644 --- a/tests/FormElement/RadioElementTest.php +++ b/tests/FormElement/RadioElementTest.php @@ -16,8 +16,8 @@ class RadioElementTest extends TestCase public function testRendersElementCorrectly() { $radio = new RadioElement('test', [ - 'label' => 'Test', - 'options' => [ + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar', 'yes' => 'Yes' @@ -35,8 +35,8 @@ public function testRendersElementCorrectly() public function testNumbersOfTypeIntOrStringAsOptionKeysAreHandledEqually() { $radio = new RadioElement('test', [ - 'label' => 'Test', - 'options' => [ + 'label' => 'Test', + 'options' => [ '1' => 'Foo', 2 => 'Bar', 3 => 'Yes' @@ -77,13 +77,13 @@ public function testNumbersOfTypeIntOrStringAsOptionKeysAreHandledEqually() public function testSetValueAddsTheCheckedAttribute() { $radio = new RadioElement('test', [ - 'label' => 'Test', - 'options' => [ + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar', 'yes' => 'Yes' ], - 'value' => 'bar' + 'value' => 'bar' ]); $html = <<<'HTML' @@ -115,9 +115,9 @@ public function testSetValueAddsTheCheckedAttribute() public function testDisabledRadioOptions() { $radio = new RadioElement('test', [ - 'label' => 'Test', - 'disabledOptions' => ['foo', 'bar', 'yes'], - 'options' => [ + 'label' => 'Test', + 'disabledOptions' => ['foo', 'bar', 'yes'], + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar', 'yes' => 'Yes', @@ -134,14 +134,14 @@ public function testDisabledRadioOptions() $this->assertHtml($html, $radio); $radio = new RadioElement('test', [ - 'label' => 'Test', - 'options' => [ + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar', 'yes' => 'Yes', 'no' => 'No' ], - 'value' => 'bar' + 'value' => 'bar' ]); $radio->getOption('yes')->setDisabled(); @@ -168,9 +168,9 @@ public function testDisabledRadioOptions() public function testNonCallbackAttributesOfTheElementAreAppliedToEachOption() { $radio = new RadioElement('test', [ - 'label' => 'Test', - 'class' => 'blue', - 'options' => [ + 'label' => 'Test', + 'class' => 'blue', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar', 'yes' => 'Yes', @@ -192,8 +192,8 @@ public function testNonCallbackAttributesOfTheElementAreAppliedToEachOption() public function testAddCssClassToTheLabelOfASpecificOption() { $radio = new RadioElement('test', [ - 'label' => 'Test', - 'options' => [ + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar', 'yes' => 'Yes', @@ -219,9 +219,9 @@ public function testAddCssClassToTheLabelOfASpecificOption() public function testAddAttributesToASpecificOption() { $radio = new RadioElement('test', [ - 'label' => 'Test', - 'class' => 'blue', - 'options' => [ + 'label' => 'Test', + 'class' => 'blue', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar', 'yes' => 'Yes', @@ -245,13 +245,13 @@ public function testRadioNotValidIfCheckedValueIsInvalid() { StaticTranslator::$instance = new NoopTranslator(); $radio = new RadioElement('test', [ - 'label' => 'Test', - 'options' => [ + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar', 'yes' => 'Yes' ], - 'value' => 'bar' + 'value' => 'bar' ]); $this->assertTrue($radio->isValid()); @@ -267,13 +267,13 @@ public function testRadioNotValidIfCheckedValueIsDisabled() { StaticTranslator::$instance = new NoopTranslator(); $radio = new RadioElement('test', [ - 'label' => 'Test', - 'options' => [ + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar', 'yes' => 'Yes' ], - 'value' => 'bar' + 'value' => 'bar' ]); $radio->setValue('yes'); @@ -287,11 +287,11 @@ public function testNullAndTheEmptyStringAreEquallyHandled() $form = new Form(); $form->addElement('radio', 'radio', [ 'options' => ['' => 'Please choose'], - 'value' => '' + 'value' => '' ]); $form->addElement('radio', 'radio2', [ 'options' => [null => 'Please choose'], - 'value' => null + 'value' => null ]); /** @var RadioElement $radio */ @@ -364,12 +364,12 @@ public function testSetOptionsResetsOptions() public function testOrderOfOptionsAndDisabledOptionsDoesNotMatter() { $radio = new RadioElement('test', [ - 'label' => 'Test', - 'options' => [ + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar' ], - 'disabledOptions' => ['foo', 'bar'] + 'disabledOptions' => ['foo', 'bar'] ]); $html = <<<'HTML' @@ -379,9 +379,9 @@ public function testOrderOfOptionsAndDisabledOptionsDoesNotMatter() $this->assertHtml($html, $radio); $radio = new RadioElement('test', [ - 'disabledOptions' => ['foo', 'bar'], - 'label' => 'Test', - 'options' => [ + 'disabledOptions' => ['foo', 'bar'], + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar' ] @@ -451,4 +451,42 @@ public function testGetOptionGetValueAndElementGetValueHandleNullAndTheEmptyStri $this->assertNull($radio->getValue()); $this->assertNull($radio->getOption(null)->getValue()); } + + public function testCloning(): void + { + $form = new Form(); + + $radio = new RadioElement('radio', [ + 'options' => [ + 'key1' => 'value1', + 'key2' => 'value2' + ] + ]); + + $clone = clone $radio; + $clone->setName('clone'); + $clone->setOptions([ + 'key3' => 'value3', + 'key4' => 'value4' + ]); + + $form + ->addElement($radio) + ->addElement($clone) + ->populate([ + 'radio' => 'key1', + 'clone' => 'key4' + ]); + + $expected = <<<'HTML' +
+ + + + +
+HTML; + + $this->assertHtml($expected, $form); + } } diff --git a/tests/FormElement/SelectElementTest.php b/tests/FormElement/SelectElementTest.php index 3f8689c2..5199ffd4 100644 --- a/tests/FormElement/SelectElementTest.php +++ b/tests/FormElement/SelectElementTest.php @@ -43,10 +43,10 @@ public function testOptionValidity() 'label' => 'Customer', 'value' => '3', 'options' => [ - null => 'Please choose', - '1' => 'The one', - '4' => 'Four', - '5' => 'Hi five', + null => 'Please choose', + '1' => 'The one', + '4' => 'Four', + '5' => 'Hi five', 'sub' => [ 'Down' => 'Here' ] @@ -72,10 +72,10 @@ public function testSelectingDisabledOptionIsNotPossible() 'label' => 'Customer', 'value' => '4', 'options' => [ - null => 'Please choose', - '1' => 'The one', - '4' => 'Four', - '5' => 'Hi five', + null => 'Please choose', + '1' => 'The one', + '4' => 'Four', + '5' => 'Hi five', 'sub' => [ 'Down' => 'Here' ] @@ -92,13 +92,13 @@ public function testNestedOptions() $select = new SelectElement('elname', [ 'label' => 'Customer', 'options' => [ - null => 'Please choose', + null => 'Please choose', 'Some Options' => [ - '1' => 'The one', - '4' => 'Four', + '1' => 'The one', + '4' => 'Four', ], 'More options' => [ - '5' => 'Hi five', + '5' => 'Hi five', ] ], ]); @@ -124,13 +124,13 @@ public function testDisabledNestedOptions() $select = new SelectElement('elname', [ 'label' => 'Customer', 'options' => [ - null => 'Please choose', + null => 'Please choose', 'Some options' => [ - '1' => 'The one', - '4' => 'Four', + '1' => 'The one', + '4' => 'Four', ], 'More options' => [ - '5' => 'Hi five', + '5' => 'Hi five', ] ], ]); @@ -159,17 +159,17 @@ public function testDeeplyDisabledNestedOptions() $select = new SelectElement('elname', [ 'label' => 'Customer', 'options' => [ - null => 'Please choose', + null => 'Please choose', 'Some options' => [ - '1' => 'The one', - '4' => [ + '1' => 'The one', + '4' => [ 'Deeper' => [ '4x4' => 'Fourfour', ], ], ], 'More options' => [ - '5' => 'Hi five', + '5' => 'Hi five', ] ], ]); @@ -277,8 +277,8 @@ public function testSetValueSelectsAnOption() public function testSetArrayAsValueWithoutMultipleAttributeThrowsException() { $select = new SelectElement('elname', [ - 'label' => 'Customer', - 'options' => [ + 'label' => 'Customer', + 'options' => [ null => 'Please choose', '1' => 'The one', '4' => 'Four', @@ -297,9 +297,9 @@ public function testSetArrayAsValueWithoutMultipleAttributeThrowsException() public function testSetNonArrayAsValueWithMultipleAttributeThrowsException() { $select = new SelectElement('elname', [ - 'label' => 'Customer', - 'multiple' => true, - 'options' => [ + 'label' => 'Customer', + 'multiple' => true, + 'options' => [ null => 'Please choose', '1' => 'The one', '4' => 'Four', @@ -445,11 +445,11 @@ public function testNullAndTheEmptyStringAreEquallyHandled() $form = new Form(); $form->addElement('select', 'select', [ 'options' => ['' => 'Please choose'], - 'value' => '' + 'value' => '' ]); $form->addElement('select', 'select2', [ 'options' => [null => 'Please choose'], - 'value' => null + 'value' => null ]); /** @var SelectElement $select */ @@ -512,10 +512,10 @@ public function testDisablingOptionsIsWorking() { $form = new Form(); $form->addElement('select', 'select', [ - 'options' => ['' => 'Please choose', 'foo' => 'FOO', 'bar' => 'BAR'], - 'disabledOptions' => [''], - 'required' => true, - 'value' => '' + 'options' => ['' => 'Please choose', 'foo' => 'FOO', 'bar' => 'BAR'], + 'disabledOptions' => [''], + 'required' => true, + 'value' => '' ]); $html = <<<'HTML' @@ -558,12 +558,12 @@ public function testNullAndTheEmptyStringAreAlsoEquallyHandledWhileDisablingOpti public function testOrderOfOptionsAndDisabledOptionsDoesNotMatter() { $select = new SelectElement('test', [ - 'label' => 'Test', - 'options' => [ + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar' ], - 'disabledOptions' => ['foo', 'bar'] + 'disabledOptions' => ['foo', 'bar'] ]); $html = <<<'HTML' @@ -575,9 +575,9 @@ public function testOrderOfOptionsAndDisabledOptionsDoesNotMatter() $this->assertHtml($html, $select); $select = new SelectElement('test', [ - 'disabledOptions' => ['foo', 'bar'], - 'label' => 'Test', - 'options' => [ + 'disabledOptions' => ['foo', 'bar'], + 'label' => 'Test', + 'options' => [ 'foo' => 'Foo', 'bar' => 'Bar' ] @@ -615,4 +615,69 @@ public function testGetOptionReturnsPreviouslySetOption() $this->assertNull($select->getOption('')->getValue()); $this->assertSame('car', $select->getOption('car')->getValue()); } + + public function testCloning(): void + { + $form = new Form(); + + $select = new SelectElement('select', [ + 'multiple' => true, + 'options' => [ + 'first' => [ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3' + ], + 'second' => [ + 'key4' => 'value4', + 'key5' => 'value5' + ] + ] + ]); + $select->setDisabledOptions(['key2']); + + $clone = (clone $select) + ->setName('clone') + ->setDisabledOptions([]); + + $clone->getAttributes()->set('multiple', false); + $clone->getOption('key4')->getAttributes()->set('class', 'test_class'); + + $form + ->addElement($select) + ->addElement($clone) + ->populate([ + 'select' => ['key2', 'key4'], + 'clone' => 'key3' + ]); + + $expected = <<<'HTML' +
+ + +
+HTML; + + $this->assertHtml($expected, $form); + } } diff --git a/tests/Lib/CloningDummyElement.php b/tests/Lib/CloningDummyElement.php new file mode 100644 index 00000000..98d648ee --- /dev/null +++ b/tests/Lib/CloningDummyElement.php @@ -0,0 +1,34 @@ +registerAttributeCallback('test-instance-scope-noop-inline', function () { + return 'inline'; + }); + $attributes->registerAttributeCallback('test-instance-noop-attribute', [$this, 'staticMethod']); + $attributes->registerAttributeCallback( + 'test-closure-static-scope-noop', + Closure::fromCallable(self::class . '::staticMethod') + ); + + $attributes->registerAttributeCallback( + 'test-closure-instance-scope-noop', + Closure::fromCallable([$this, 'staticMethod']) + ); + } +}