diff --git a/Makefile b/Makefile index 2a25f44..c8d9804 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ csf: vendor vendor/bin/codefixer src tests phpstan: vendor - vendor/bin/phpstan analyse -l 9 -c phpstan.neon src + vendor/bin/phpstan analyse -l 10 -c phpstan.neon src tests: vendor vendor/bin/phpunit diff --git a/src/BootstrapForm.php b/src/BootstrapForm.php index 21e5f43..53ec089 100644 --- a/src/BootstrapForm.php +++ b/src/BootstrapForm.php @@ -62,10 +62,7 @@ public function __construct($container = null) ]); $this->elementPrototype = $prototype; - /** - * @param BootstrapForm $form - */ - $this->onError[] = function ($form): void { + $this->onError[] = function (self $form): void { $form->showValidation = $this->autoShowValidation; }; } @@ -161,20 +158,15 @@ public function setAjax(bool $isAjax = true) { $this->isAjax = $isAjax; - BootstrapUtils::standardizeClass($this->getElementPrototype()); - $prototypeClass = $this->getElementPrototype()->class; + $prototype = $this->getElementPrototype(); + $present = BootstrapUtils::hasClass($prototype, $this->ajaxClass); - $present = in_array($this->ajaxClass, $prototypeClass); if ($present && !$isAjax) { - // remove the class - $prototypeClass = array_diff($prototypeClass, [$this->ajaxClass]); + BootstrapUtils::removeClass($prototype, $this->ajaxClass); } elseif (!$present && $isAjax) { - // add class - $prototypeClass[] = $this->ajaxClass; + BootstrapUtils::addClass($prototype, $this->ajaxClass); } - $this->getElementPrototype()->class = $prototypeClass; - return $this; } diff --git a/src/BootstrapRenderer.php b/src/BootstrapRenderer.php index e88e8ec..fee900f 100644 --- a/src/BootstrapRenderer.php +++ b/src/BootstrapRenderer.php @@ -325,11 +325,12 @@ public function renderBegin(): string $el = $this->configElem('form', $this->form->getElementPrototype()); if ($this->form->isMethod('get')) { - $el->action = (string) $el->action; - /** @noinspection PhpUndefinedFieldInspection */ - $query = parse_url($el->action, PHP_URL_QUERY); - /** @noinspection PhpUndefinedFieldInspection */ - $el->action = str_replace('?' . $query, '', $el->action); + // the action may be set as anything printable, a Nette\Application\UI\Link among others + $action = $el->getAttribute('action'); + $action = is_scalar($action) || $action instanceof Stringable ? (string) $action : ''; + + $query = parse_url($action, PHP_URL_QUERY); + $el->action = str_replace('?' . $query, '', $action); $s = ''; $params = ($query === null || $query === false) @@ -616,19 +617,7 @@ protected function fetchConfig(string $key): array */ protected function fetchClasses(Html $el): array { - $class = $el->getAttribute('class'); - - if (is_array($class)) { - return $class; - } - - // class is set, but not as an array - if (is_string($class)) { - return explode(' ', $class); - } - - // class is not set - return []; + return BootstrapUtils::fetchClasses($el); } /** diff --git a/src/BootstrapUtils.php b/src/BootstrapUtils.php index db06ec7..302bd3d 100644 --- a/src/BootstrapUtils.php +++ b/src/BootstrapUtils.php @@ -21,4 +21,56 @@ public static function standardizeClass(Html $control): void } } + /** + * Reads element classes as a list, whichever way the class attribute was set + * + * @return mixed[] + */ + public static function fetchClasses(Html $control): array + { + $class = $control->getAttribute('class'); + + if (is_array($class)) { + return $class; + } + + // class is set, but not as an array + if (is_string($class)) { + return explode(' ', $class); + } + + // class is not set + return []; + } + + /** + * Appends a class to the element + */ + public static function addClass(Html $control, string $class): void + { + $classes = self::fetchClasses($control); + $classes[] = $class; + + $control->class = $classes; + } + + /** + * Removes a class from the element, if it is there at all + */ + public static function removeClass(Html $control, string $class): void + { + $control->class = array_filter( + self::fetchClasses($control), + static fn ($presentClass): bool => $presentClass !== $class + ); + } + + /** + * Whether the element already carries the class + */ + public static function hasClass(Html $control, string $class): bool + { + return in_array($class, self::fetchClasses($control), true); + } + } diff --git a/src/Grid/BootstrapCell.php b/src/Grid/BootstrapCell.php index ae8c77f..eada910 100644 --- a/src/Grid/BootstrapCell.php +++ b/src/Grid/BootstrapCell.php @@ -3,6 +3,7 @@ namespace Contributte\FormsBootstrap\Grid; use Contributte\FormsBootstrap\BootstrapRenderer; +use Contributte\FormsBootstrap\BootstrapUtils; use Contributte\FormsBootstrap\Enums\RendererConfig; use Contributte\FormsBootstrap\Traits\BootstrapContainerTrait; use Nette\ComponentModel\IComponent; @@ -94,7 +95,7 @@ public function render(): Html $renderer = $this->row->getContainer()->getForm()->getRenderer(); $element = $renderer->configElem(RendererConfig::GRID_CELL, $element); - $element->class[] = $this->createClass(); + BootstrapUtils::addClass($element, $this->createClass()); foreach ($this->childControls as $childControl) { $pairHtml = $renderer->renderPair($childControl); @@ -133,7 +134,7 @@ public function setCurrentGroup(?ControlGroup $currentGroup): self */ public function addHtmlClass(string $class) { - $this->elementPrototype->class[] = $class; + BootstrapUtils::addClass($this->elementPrototype, $class); return $this; } diff --git a/src/Inputs/ColorPicker.php b/src/Inputs/ColorPicker.php index 9e40274..7c1649e 100644 --- a/src/Inputs/ColorPicker.php +++ b/src/Inputs/ColorPicker.php @@ -14,9 +14,7 @@ class ColorPicker extends \Nette\Forms\Controls\ColorPicker implements IValidati public function getControl(): Html { $control = parent::getControl(); - BootstrapUtils::standardizeClass($control); - - $control->class[] = 'form-control'; + BootstrapUtils::addClass($control, 'form-control'); return $control; } diff --git a/src/Inputs/DateInput.php b/src/Inputs/DateInput.php index 6def22d..8615d79 100644 --- a/src/Inputs/DateInput.php +++ b/src/Inputs/DateInput.php @@ -2,6 +2,7 @@ namespace Contributte\FormsBootstrap\Inputs; +use Contributte\FormsBootstrap\BootstrapUtils; use Contributte\FormsBootstrap\Enums\DateTimeFormat; use DateTime; use DateTimeInterface; @@ -160,7 +161,7 @@ public function setValue($value) public function getControl(): Html { $control = parent::getControl(); - $control->class[] = implode(' ', static::$additionalHtmlClasses); + BootstrapUtils::addClass($control, implode(' ', static::$additionalHtmlClasses)); return $control; } diff --git a/src/Inputs/DateTimeControl.php b/src/Inputs/DateTimeControl.php index e53ff20..ee037ce 100644 --- a/src/Inputs/DateTimeControl.php +++ b/src/Inputs/DateTimeControl.php @@ -14,9 +14,7 @@ class DateTimeControl extends \Nette\Forms\Controls\DateTimeControl implements I public function getControl(): Html { $control = parent::getControl(); - BootstrapUtils::standardizeClass($control); - - $control->class[] = 'form-control'; + BootstrapUtils::addClass($control, 'form-control'); return $control; } diff --git a/src/Inputs/TextAreaInput.php b/src/Inputs/TextAreaInput.php index 7b15b1e..39f680c 100644 --- a/src/Inputs/TextAreaInput.php +++ b/src/Inputs/TextAreaInput.php @@ -52,9 +52,7 @@ public function setAutocomplete(?bool $bool) public function getControl(): Html { $control = parent::getControl(); - BootstrapUtils::standardizeClass($control); - - $control->class[] = 'form-control'; + BootstrapUtils::addClass($control, 'form-control'); if ($this->autocomplete !== null) { $control->setAttribute('autocomplete', $this->autocomplete ? 'on' : 'off'); } diff --git a/src/Inputs/TextInput.php b/src/Inputs/TextInput.php index 6b5f952..1ff04d6 100644 --- a/src/Inputs/TextInput.php +++ b/src/Inputs/TextInput.php @@ -64,9 +64,7 @@ public function setAutocomplete(?bool $bool) public function getControl(): Html { $control = parent::getControl(); - BootstrapUtils::standardizeClass($control); - - $control->class[] = 'form-control'; + BootstrapUtils::addClass($control, 'form-control'); if (!empty($this->placeholder)) { $control->setAttribute('placeholder', $this->placeholder); } diff --git a/src/Inputs/UploadInput.php b/src/Inputs/UploadInput.php index fc45f27..0810842 100644 --- a/src/Inputs/UploadInput.php +++ b/src/Inputs/UploadInput.php @@ -4,6 +4,7 @@ use Contributte\FormsBootstrap\BootstrapForm; use Contributte\FormsBootstrap\BootstrapRenderer; +use Contributte\FormsBootstrap\BootstrapUtils; use Contributte\FormsBootstrap\Enums\BootstrapVersion; use Contributte\FormsBootstrap\Enums\RendererConfig; use Nette\Application\UI\Form; @@ -49,7 +50,10 @@ public function getControl() { /** @var Html $control */ $control = parent::getControl(); - $control->class = trim($control->class .= BootstrapForm::getBootstrapVersion() === BootstrapVersion::V5 ? ' form-control' : ' custom-file-input'); + BootstrapUtils::addClass( + $control, + BootstrapForm::getBootstrapVersion() === BootstrapVersion::V5 ? 'form-control' : 'custom-file-input' + ); $el = Html::el('div', ['class' => [BootstrapForm::getBootstrapVersion() === BootstrapVersion::V5 ? null : 'custom-file']]); $el->addHtml($control); diff --git a/src/Traits/BootstrapButtonTrait.php b/src/Traits/BootstrapButtonTrait.php index 5e6b064..3aa853c 100644 --- a/src/Traits/BootstrapButtonTrait.php +++ b/src/Traits/BootstrapButtonTrait.php @@ -73,8 +73,7 @@ public function setAllignWithInputControls(bool $allignWithControls = true): voi protected function addBtnClass(Html $element): void { - BootstrapUtils::standardizeClass($element); - $element->class[] = 'btn ' . $this->getBtnClass(); + BootstrapUtils::addClass($element, 'btn ' . $this->getBtnClass()); } } diff --git a/src/Traits/BootstrapContainerTrait.php b/src/Traits/BootstrapContainerTrait.php index 89096ee..14d6225 100644 --- a/src/Traits/BootstrapContainerTrait.php +++ b/src/Traits/BootstrapContainerTrait.php @@ -35,6 +35,7 @@ use Nette\Forms\Controls\TextInput as NetteTextInput; use Nette\Forms\Controls\UploadControl; use Nette\Forms\Form; +use Nette\InvalidArgumentException; use Nette\Utils\Html; /** @@ -201,7 +202,15 @@ public function addEmail( */ public function addInputError(string $componentName, string $message): void { - $this->getComponent($componentName)->addError($message); + $component = $this->getComponent($componentName); + + if (!$component instanceof BaseControl) { + throw new InvalidArgumentException( + sprintf('Component "%s" is not a form control, so it cannot carry an error.', $componentName) + ); + } + + $component->addError($message); } /** diff --git a/tests/BootstrapFormTest.php b/tests/BootstrapFormTest.php index 900e5e8..2681cb1 100644 --- a/tests/BootstrapFormTest.php +++ b/tests/BootstrapFormTest.php @@ -65,6 +65,16 @@ public function testAjaxClassIsAddedAndRemoved(): void $this->assertNotContains('ajax', $form->getElementPrototype()->class); } + public function testAjaxClassIsNotAddedTwice(): void + { + $form = new BootstrapForm(); + + $form->setAjax(true); + $form->setAjax(true); + + $this->assertSame(['ajax'], $form->getElementPrototype()->class); + } + public function testGetRendererReturnsTheBootstrapRenderer(): void { $form = new BootstrapForm(); diff --git a/tests/BootstrapUtilsTest.php b/tests/BootstrapUtilsTest.php index f4b29a6..7a0ab72 100644 --- a/tests/BootstrapUtilsTest.php +++ b/tests/BootstrapUtilsTest.php @@ -15,4 +15,55 @@ public function testStandardizeClass(): void $this->assertEquals(['c1', 'c2'], $html->class); } + public function testFetchClassesReadsEitherShapeOfTheAttribute(): void + { + $this->assertSame(['c1', 'c2'], BootstrapUtils::fetchClasses(Html::el('div', ['class' => 'c1 c2']))); + $this->assertSame(['c1', 'c2'], BootstrapUtils::fetchClasses(Html::el('div', ['class' => ['c1', 'c2']]))); + $this->assertSame([], BootstrapUtils::fetchClasses(Html::el('div'))); + } + + public function testAddClassKeepsWhateverWasThere(): void + { + $html = Html::el('div', ['class' => 'c1 c2']); + + BootstrapUtils::addClass($html, 'c3'); + + $this->assertSame('
', (string) $html); + } + + public function testAddClassOnElementWithoutOne(): void + { + $html = Html::el('div'); + + BootstrapUtils::addClass($html, 'only'); + + $this->assertSame('
', (string) $html); + } + + public function testRemoveClassDropsOnlyTheNamedOne(): void + { + $html = Html::el('div', ['class' => 'keep drop']); + + BootstrapUtils::removeClass($html, 'drop'); + + $this->assertSame('
', (string) $html); + } + + public function testRemoveClassThatIsNotThereChangesNothing(): void + { + $html = Html::el('div', ['class' => 'keep']); + + BootstrapUtils::removeClass($html, 'absent'); + + $this->assertSame('
', (string) $html); + } + + public function testHasClass(): void + { + $html = Html::el('div', ['class' => 'c1 c2']); + + $this->assertTrue(BootstrapUtils::hasClass($html, 'c1')); + $this->assertFalse(BootstrapUtils::hasClass($html, 'c3')); + } + } diff --git a/tests/Traits/BootstrapContainerTraitTest.php b/tests/Traits/BootstrapContainerTraitTest.php index f781856..49eed2b 100644 --- a/tests/Traits/BootstrapContainerTraitTest.php +++ b/tests/Traits/BootstrapContainerTraitTest.php @@ -6,6 +6,7 @@ use Contributte\FormsBootstrap\Inputs\DateTimeControl; use Contributte\FormsBootstrap\Inputs\TextInput; use Contributte\FormsBootstrap\Inputs\UploadInput; +use Nette\InvalidArgumentException; use Tests\BaseTestCase; /** @@ -152,6 +153,16 @@ public function testAddInputErrorAttachesTheErrorToTheNamedControl(): void $this->assertTrue($form->hasErrors()); } + public function testAddInputErrorRejectsComponentThatIsNotControl(): void + { + $form = new BootstrapForm(); + $form->addContainer('sub'); + + $this->expectException(InvalidArgumentException::class); + + $form->addInputError('sub', 'nowhere to put this'); + } + public function testAddColorProducesColorInput(): void { $form = new BootstrapForm();