diff --git a/src/Common/MultipleAttribute.php b/src/Common/MultipleAttribute.php new file mode 100644 index 00000000..00a68b2b --- /dev/null +++ b/src/Common/MultipleAttribute.php @@ -0,0 +1,70 @@ +registerMultipleAttributeCallback($attributes); + * } + * } + * ``` + */ +trait MultipleAttribute +{ + /** @var bool Whether the attribute `multiple` is set to `true` */ + protected $multiple = false; + + /** + * Get whether the attribute `multiple` is set to `true` + * + * @return bool + */ + public function isMultiple(): bool + { + return $this->multiple; + } + + /** + * Set the `multiple` attribute + * + * @param bool $multiple + * + * @return $this + */ + public function setMultiple(bool $multiple): self + { + $this->multiple = $multiple; + + return $this; + } + + /** + * Register the callback for `multiple` Attribute + * + * @param Attributes $attributes + */ + protected function registerMultipleAttributeCallback(Attributes $attributes): void + { + $attributes->registerAttributeCallback( + 'multiple', + [$this, 'isMultiple'], + [$this, 'setMultiple'] + ); + } +} diff --git a/src/FormElement/SelectElement.php b/src/FormElement/SelectElement.php index b1a13f91..e6b4f217 100644 --- a/src/FormElement/SelectElement.php +++ b/src/FormElement/SelectElement.php @@ -2,114 +2,128 @@ namespace ipl\Html\FormElement; +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\Common\MultipleAttribute; use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Validator\DeferredInArrayValidator; +use ipl\Validator\ValidatorChain; +use UnexpectedValueException; class SelectElement extends BaseFormElement { + use MultipleAttribute; + protected $tag = 'select'; /** @var SelectOption[] */ protected $options = []; + /** @var SelectOption[]|HtmlElement[] */ protected $optionContent = []; - public function __construct($name, $attributes = null) - { - $this->getAttributes()->registerAttributeCallback( - 'options', - null, - [$this, 'setOptions'] - ); - // ZF1 compatibility: - $this->getAttributes()->registerAttributeCallback( - 'multiOptions', - null, - [$this, 'setOptions'] - ); + /** @var array Disabled select options */ + protected $disabledOptions = []; - parent::__construct($name, $attributes); - } + /** @var array|string|null */ + protected $value; - public function hasOption($value) + /** + * Get the option with specified value + * + * @param string|int|null $value + * + * @return ?SelectOption + */ + public function getOption($value): ?SelectOption { - return isset($this->options[$value]); + return $this->options[$value] ?? null; } - public function validate() + /** + * Set the options from specified values + * + * @param array $options + * + * @return $this + */ + public function setOptions(array $options): self { - $value = $this->getValue(); - if ( - $value !== null && ( - ! ($option = $this->getOption($value)) - || $option->getAttributes()->has('disabled') - ) - ) { - $this->addMessage("'$value' is not allowed here"); - } elseif ($this->isRequired() && $value === null) { - $this->addMessage('This field is required'); - } else { - return parent::validate(); + $this->options = []; + $this->optionContent = []; + foreach ($options as $value => $label) { + $this->optionContent[$value] = $this->makeOption($value, $label); } - return false; + return $this; } - public function deselect() + /** + * Set the specified options as disable + * + * @param array $disabledOptions + * + * @return $this + */ + public function setDisabledOptions(array $disabledOptions): self { - $this->setValue(null); + if (! empty($this->options)) { + foreach ($this->options as $option) { + $optionValue = $option->getValue(); - return $this; - } + $option->setAttribute( + 'disabled', + in_array($optionValue, $disabledOptions, ! is_int($optionValue)) + || ($optionValue === null && in_array('', $disabledOptions, true)) + ); + } - public function disableOption($value) - { - if ($option = $this->getOption($value)) { - $option->getAttributes()->add('disabled', true); - } - if ($this->getValue() == $value) { - $this->addMessage("'$value' is not allowed here"); + $this->disabledOptions = []; + } else { + $this->disabledOptions = $disabledOptions; } return $this; } - public function disableOptions($values) + /** + * Get the value of the element + * + * Returns `array` when the attribute `multiple` is set to `true`, `string` or `null` otherwise + * + * @return array|string|null + */ + public function getValue() { - foreach ($values as $value) { - $this->disableOption($value); + if ($this->isMultiple()) { + return parent::getValue() ?? []; } - return $this; + return parent::getValue(); } - /** - * @param $value - * @return SelectOption|null - */ - public function getOption($value) + public function getValueAttribute() { - if ($this->hasOption($value)) { - return $this->options[$value]; - } else { - return null; - } + // select elements don't have a value attribute + return null; } - /** - * @param array $options - * @return $this - */ - public function setOptions(array $options) + public function getNameAttribute() { - $this->options = []; - $this->optionContent = []; - foreach ($options as $value => $label) { - $this->optionContent[$value] = $this->makeOption($value, $label); - } + $name = $this->getName(); - return $this; + return $this->isMultiple() ? ($name . '[]') : $name; } + /** + * Make the selectOption for the specified value and the label + * + * @param string|int|null $value Value of the option + * @param string|array $label Label of the option + * + * @return SelectOption|HtmlElement + */ protected function makeOption($value, $label) { if (is_array($label)) { @@ -119,25 +133,106 @@ protected function makeOption($value, $label) } return $grp; - } else { - $option = new SelectOption($value, $label); - $option->getAttributes()->registerAttributeCallback('selected', function () use ($option) { - $optionValue = $option->getValue(); + } + + $option = (new SelectOption($value, $label)) + ->setAttribute('disabled', in_array($value, $this->disabledOptions, ! is_int($value))); + + $option->getAttributes()->registerAttributeCallback('selected', function () use ($option) { + return $this->isSelectedOption($option->getValue()); + }); - return is_int($optionValue) - // The loose comparison is required because PHP casts - // numeric strings to integers if used as array keys - ? $this->getValue() == $optionValue - : $this->getValue() === $optionValue; - }); - $this->options[$value] = $option; + $this->options[$value] = $option; - return $this->options[$value]; + return $this->options[$value]; + } + + /** + * Get whether the given option is selected + * + * @param int|string|null $optionValue + * + * @return bool + */ + protected function isSelectedOption($optionValue): bool + { + $value = $this->getValue(); + + if ($optionValue === '') { + $optionValue = null; + } + + if ($this->isMultiple()) { + if (! is_array($value)) { + throw new UnexpectedValueException( + 'Value must be an array when the `multiple` attribute is set to `true`' + ); + } + + return in_array($optionValue, $this->getValue(), ! is_int($optionValue)) + || ($optionValue === null && in_array('', $this->getValue(), true)); + } + + if (is_array($value)) { + throw new UnexpectedValueException( + 'Value cannot be an array without setting the `multiple` attribute to `true`' + ); } + + return is_int($optionValue) + // The loose comparison is required because PHP casts + // numeric strings to integers if used as array keys + ? $value == $optionValue + : $value === $optionValue; + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add( + new DeferredInArrayValidator(function (): array { + $possibleValues = []; + + foreach ($this->options as $option) { + if ($option->getAttributes()->get('disabled')->getValue()) { + continue; + } + + $possibleValues[] = $option->getValue(); + } + + return $possibleValues; + }) + ); } protected function assemble() { $this->addHtml(...array_values($this->optionContent)); } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'options', + null, + [$this, 'setOptions'] + ); + + $attributes->registerAttributeCallback( + 'disabledOptions', + null, + [$this, 'setDisabledOptions'] + ); + + // ZF1 compatibility: + $this->getAttributes()->registerAttributeCallback( + 'multiOptions', + null, + [$this, 'setOptions'] + ); + + $this->registerMultipleAttributeCallback($attributes); + } } diff --git a/src/FormElement/SelectOption.php b/src/FormElement/SelectOption.php index fb56729a..419771bf 100644 --- a/src/FormElement/SelectOption.php +++ b/src/FormElement/SelectOption.php @@ -22,7 +22,7 @@ class SelectOption extends BaseHtmlElement */ public function __construct($value, string $label) { - $this->value = $value; + $this->value = $value === '' ? null : $value; $this->label = $label; $this->getAttributes()->registerAttributeCallback('value', [$this, 'getValue']); diff --git a/tests/DocumentationFormsTest.php b/tests/DocumentationFormsTest.php index 4f202f49..707772a4 100644 --- a/tests/DocumentationFormsTest.php +++ b/tests/DocumentationFormsTest.php @@ -40,7 +40,7 @@ public function testSelectElement() $this->assertHtml( '' - . '' - . '' - . '' - . '' - . '', - $select - ); + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); } public function testOptionValidity() { + StaticTranslator::$instance = new NoopTranslator(); $select = new SelectElement('elname', [ 'label' => 'Customer', 'value' => '3', @@ -61,6 +67,7 @@ public function testOptionValidity() public function testSelectingDisabledOptionIsNotPossible() { + StaticTranslator::$instance = new NoopTranslator(); $select = new SelectElement('elname', [ 'label' => 'Customer', 'value' => '4', @@ -76,7 +83,7 @@ public function testSelectingDisabledOptionIsNotPossible() ]); $this->assertTrue($select->isValid()); - $select->disableOption(4); + $select->getOption(4)->setAttribute('disabled', true); $this->assertFalse($select->isValid()); } @@ -96,19 +103,20 @@ public function testNestedOptions() ], ]); - $this->assertHtml( - '', - $select - ); + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); } public function testDisabledNestedOptions() @@ -127,21 +135,23 @@ public function testDisabledNestedOptions() ], ]); - $select->disableOptions([4, '5']); - - $this->assertHtml( - '', - $select - ); + $select->getOption(4)->setAttribute('disabled', true); + $select->getOption('5')->setAttribute('disabled', true); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); } public function testDeeplyDisabledNestedOptions() @@ -164,25 +174,27 @@ public function testDeeplyDisabledNestedOptions() ], ]); - $select->disableOptions([4, '5']); - - $this->assertHtml( - '', - $select - ); + $select->getOption('4x4')->setAttribute('disabled', true); + $select->getOption(5)->setAttribute('disabled', true); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); } public function testDefaultValueIsSelected() @@ -198,15 +210,16 @@ public function testDefaultValueIsSelected() ] ]); - $this->assertHtml( - '', - $select - ); + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); } public function testSetValueSelectsAnOption() @@ -223,38 +236,383 @@ public function testSetValueSelectsAnOption() $select->setValue('1'); - $this->assertHtml( - '', - $select - ); + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); $select->setValue('5'); - $this->assertHtml( - '', - $select - ); + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); $select->setValue(null); - $this->assertHtml( - '', - $select + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); + } + + public function testSetArrayAsValueWithoutMultipleAttributeThrowsException() + { + $select = new SelectElement('elname', [ + 'label' => 'Customer', + 'options' => [ + null => 'Please choose', + '1' => 'The one', + '4' => 'Four', + '5' => 'Hi five', + ] + ]); + + $select->setValue(['1', 5]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Value cannot be an array without setting the `multiple` attribute to `true`'); + + $select->render(); + } + + public function testSetNonArrayAsValueWithMultipleAttributeThrowsException() + { + $select = new SelectElement('elname', [ + 'label' => 'Customer', + 'multiple' => true, + 'options' => [ + null => 'Please choose', + '1' => 'The one', + '4' => 'Four', + '5' => 'Hi five', + ] + ]); + + $select->setValue(1); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage( + 'Value must be an array when the `multiple` attribute is set to `true`' ); + + $select->render(); + } + + public function testSetArrayAsValueWithMultipleAttributeSetTheOptions() + { + $select = new SelectElement('elname', [ + 'label' => 'Customer', + 'options' => [ + null => 'Please choose', + '1' => 'The one', + '4' => 'Four', + '5' => 'Hi five', + ] + ]); + + $select->setAttribute('multiple', true); + + $select->setValue(['1']); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); + + $select->setValue(['5', 4, 6]); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); + + $select->setValue(null); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); + + $select->setValue([null]); + + $html = <<<'HTML' + +HTML; + $this->assertHtml($html, $select); + + $select->setValue(['']); + $this->assertHtml($html, $select); + } + + public function testLabelCanBeChanged() + { + $option = new SelectOption('value', 'Original label'); + $option->setLabel('New label'); + $this->assertHtml('', $option); + } + + public function testRendersCheckMultipleAttribute() + { + $select = new SelectElement('test', ['multiple' => true]); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); + } + + public function testSetMultipleAttribute() + { + $select = new SelectElement('test'); + + $select->setAttribute('multiple', true); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); + $this->assertTrue($select->isMultiple()); + + $select->setAttribute('multiple', false); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); + $this->assertFalse($select->isMultiple()); + } + + public function testGetValueReturnsAnArrayWhenMultipleAttributeIsSet() + { + $select = new SelectElement('test'); + + $this->assertNull($select->getValue()); + + $select->setAttribute('multiple', true); + $this->assertSame([], $select->getValue()); + } + + public function testNullAndTheEmptyStringAreEquallyHandled() + { + $form = new Form(); + $form->addElement('select', 'select', [ + 'options' => ['' => 'Please choose'], + 'value' => '' + ]); + $form->addElement('select', 'select2', [ + 'options' => [null => 'Please choose'], + 'value' => null + ]); + + /** @var SelectElement $select */ + $select = $form->getElement('select'); + /** @var SelectElement $select2 */ + $select2 = $form->getElement('select2'); + + $this->assertNull($select->getValue()); + $this->assertNull($select2->getValue()); + + $this->assertInstanceOf(SelectOption::class, $select->getOption('')); + $this->assertInstanceOf(SelectOption::class, $select2->getOption(null)); + $this->assertInstanceOf(SelectOption::class, $select->getOption(null)); + $this->assertInstanceOf(SelectOption::class, $select2->getOption('')); + + $this->assertTrue($select->isValid()); + $this->assertTrue($select2->isValid()); + + $select->setValue(null); + $this->assertTrue($select->isValid()); + $select2->setValue(''); + $this->assertTrue($select2->isValid()); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $select); + + $html = <<<'HTML' + +HTML; + $this->assertHtml($html, $select2); + } + + public function testGetOptionGetValueAndElementGetValueHandleNullAndTheEmptyStringEqually() + { + $select = new SelectElement('select'); + $select->setOptions(['' => 'Foo']); + $select->setValue(''); + + $this->assertNull($select->getValue()); + $this->assertNull($select->getOption('')->getValue()); + + $select = new SelectElement('select'); + $select->setOptions([null => 'Foo']); + + $this->assertNull($select->getValue()); + $this->assertNull($select->getOption(null)->getValue()); + } + + /** + * @depends testNullAndTheEmptyStringAreEquallyHandled + */ + public function testDisablingOptionsIsWorking() + { + $form = new Form(); + $form->addElement('select', 'select', [ + 'options' => ['' => 'Please choose', 'foo' => 'FOO', 'bar' => 'BAR'], + 'disabledOptions' => [''], + 'required' => true, + 'value' => '' + ]); + + $html = <<<'HTML' + +HTML; + + $this->assertHtml($html, $form->getElement('select')); + } + + public function testNullAndTheEmptyStringAreAlsoEquallyHandledWhileDisablingOptions() + { + $select = new SelectElement('select'); + $select->setOptions([null => 'Foo', 'bar' => 'Bar']); + $select->setDisabledOptions([null]); + + $this->assertTrue($select->getOption(null)->getAttributes()->get('disabled')->getValue()); + + $select = new SelectElement('select'); + $select->setOptions(['' => 'Foo', 'bar' => 'Bar']); + $select->setDisabledOptions(['']); + + $this->assertTrue($select->getOption('')->getAttributes()->get('disabled')->getValue()); + + $select = new SelectElement('select'); + $select->setOptions([null => 'Foo', 'bar' => 'Bar']); + $select->setDisabledOptions(['']); + + $this->assertTrue($select->getOption(null)->getAttributes()->get('disabled')->getValue()); + $select = new SelectElement('select'); + $select->setOptions(['' => 'Foo', 'bar' => 'Bar']); + $select->setDisabledOptions([null]); + + $this->assertTrue($select->getOption('')->getAttributes()->get('disabled')->getValue()); + } + + public function testOrderOfOptionsAndDisabledOptionsDoesNotMatter() + { + $select = new SelectElement('test', [ + 'label' => 'Test', + 'options' => [ + 'foo' => 'Foo', + 'bar' => 'Bar' + ], + 'disabledOptions' => ['foo', 'bar'] + ]); + + $html = <<<'HTML' + +HTML; + $this->assertHtml($html, $select); + + $select = new SelectElement('test', [ + 'disabledOptions' => ['foo', 'bar'], + 'label' => 'Test', + 'options' => [ + 'foo' => 'Foo', + 'bar' => 'Bar' + ] + ]); + + $this->assertHtml($html, $select); + } + + public function testSetOptionsResetsOptions() + { + $select = new SelectElement('select'); + $select->setOptions(['foo' => 'Foo', 'bar' => 'Bar']); + + $this->assertInstanceOf(SelectOption::class, $select->getOption('foo')); + $this->assertInstanceOf(SelectOption::class, $select->getOption('bar')); + + $select->setOptions(['car' => 'Car', 'train' => 'Train']); + + $this->assertInstanceOf(SelectOption::class, $select->getOption('car')); + $this->assertInstanceOf(SelectOption::class, $select->getOption('train')); + + $this->assertNull($select->getOption('foo')); + } + + public function testGetOptionReturnsPreviouslySetOption() + { + $select = new SelectElement('select'); + $select->setOptions(['' => 'Empty String', 'foo' => 'Foo', 'bar' => 'Bar']); + + $this->assertNull($select->getOption('')->getValue()); + $this->assertSame('foo', $select->getOption('foo')->getValue()); + + $select->setOptions(['' => 'Please Choose', 'car' => 'Car', 'train' => 'Train']); + + $this->assertNull($select->getOption('')->getValue()); + $this->assertSame('car', $select->getOption('car')->getValue()); } }