diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php new file mode 100644 index 000000000..7b299d1ef --- /dev/null +++ b/.phpstorm.meta.php @@ -0,0 +1,8 @@ + '@|Nette\Utils\ArrayHash'])); +override(new \Nette\Forms\Container, map(['' => 'Nette\Forms\Controls\BaseControl'])); diff --git a/.travis.yml b/.travis.yml index ccbd1b461..4dec36dfc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -54,12 +54,8 @@ jobs: - stage: Static Analysis (informative) - install: - # Install PHPStan - - travis_retry composer create-project phpstan/phpstan-shim temp/phpstan --no-progress - - travis_retry composer install --no-progress --prefer-dist script: - - php temp/phpstan/phpstan.phar analyse --autoload-file vendor/autoload.php --level 5 src + - composer run-script phpstan - stage: Code Coverage diff --git a/composer.json b/composer.json index 586c63dad..9ec6b2e9d 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ "nette/di": "^3.0", "nette/tester": "^2.0", "latte/latte": "^2.4.1", - "tracy/tracy": "^2.4" + "tracy/tracy": "^2.4", + "phpstan/phpstan-nette": "^0.12" }, "conflict": { "nette/di": "<3.0-stable" @@ -33,6 +34,10 @@ "classmap": ["src/"] }, "minimum-stability": "dev", + "scripts": { + "phpstan": "phpstan analyse --level 5 --configuration tests/phpstan.neon src", + "tester": "tester tests -s" + }, "extra": { "branch-alias": { "dev-master": "3.0-dev" diff --git a/readme.md b/readme.md index ba57a09ed..0c8911a80 100644 --- a/readme.md +++ b/readme.md @@ -39,6 +39,12 @@ composer require nette/forms It requires PHP version 7.1 and supports PHP up to 7.4. +Client-side support can be installed with npm or yarn: + +``` +npm install nette-forms +``` + Usage ----- diff --git a/src/Bridges/FormsLatte/FormMacros.php b/src/Bridges/FormsLatte/FormMacros.php index 07bba735c..4ef32f3cc 100644 --- a/src/Bridges/FormsLatte/FormMacros.php +++ b/src/Bridges/FormsLatte/FormMacros.php @@ -25,7 +25,7 @@ * - {inputError name} * - {formContainer name} ... {/formContainer} */ -class FormMacros extends MacroSet +final class FormMacros extends MacroSet { public static function install(Latte\Compiler $compiler): void { diff --git a/src/Forms/Controls/BaseControl.php b/src/Forms/Controls/BaseControl.php index 2dde349da..975fc5cb2 100644 --- a/src/Forms/Controls/BaseControl.php +++ b/src/Forms/Controls/BaseControl.php @@ -147,7 +147,7 @@ protected function getHttpData($type, string $htmlTail = null) */ public function getHtmlName(): string { - return Nette\Forms\Helpers::generateHtmlName($this->lookupPath(Form::class)); + return $this->control->name ?? Nette\Forms\Helpers::generateHtmlName($this->lookupPath(Form::class)); } @@ -347,6 +347,9 @@ public function getHtmlId() public function setHtmlAttribute(string $name, $value = true) { $this->control->$name = $value; + if ($name === 'name' && ($form = $this->getForm(false)) && !$this->isDisabled() && $form->isAnchored() && $form->isSubmitted()) { + $this->loadHttpData(); + } return $this; } diff --git a/src/Forms/Controls/HiddenField.php b/src/Forms/Controls/HiddenField.php index 8dd898095..1058ff203 100644 --- a/src/Forms/Controls/HiddenField.php +++ b/src/Forms/Controls/HiddenField.php @@ -20,6 +20,9 @@ class HiddenField extends BaseControl /** @var bool */ private $persistValue; + /** @var bool */ + private $nullable = false; + public function __construct($persistentValue = null) { @@ -41,16 +44,50 @@ public function __construct($persistentValue = null) */ public function setValue($value) { - if (!is_scalar($value) && $value !== null && !method_exists($value, '__toString')) { + if ($value === null) { + $value = ''; + } elseif (!is_scalar($value) && !method_exists($value, '__toString')) { throw new Nette\InvalidArgumentException(sprintf("Value must be scalar or null, %s given in field '%s'.", gettype($value), $this->name)); } if (!$this->persistValue) { - $this->value = (string) $value; + $this->value = $value; } return $this; } + /** + * Returns control's value. + * @return mixed + */ + public function getValue() + { + return $this->nullable && $this->value === '' ? null : $this->value; + } + + + /** + * Sets whether getValue() returns null instead of empty string. + * @return static + */ + public function setNullable(bool $value = true) + { + $this->nullable = $value; + return $this; + } + + + /** + * Appends input string filter callback. + * @return static + */ + public function addFilter(callable $filter) + { + $this->getRules()->addFilter($filter); + return $this; + } + + /** * Generates control's HTML element. */ @@ -61,7 +98,7 @@ public function getControl(): Nette\Utils\Html return $el->addAttributes([ 'name' => $this->getHtmlName(), 'disabled' => $this->isDisabled(), - 'value' => $this->value, + 'value' => (string) $this->value, ]); } diff --git a/src/Forms/Controls/UploadControl.php b/src/Forms/Controls/UploadControl.php index 3e5f082b2..f2aa985fe 100644 --- a/src/Forms/Controls/UploadControl.php +++ b/src/Forms/Controls/UploadControl.php @@ -11,6 +11,7 @@ use Nette; use Nette\Forms; +use Nette\Forms\Form; use Nette\Http\FileUpload; @@ -33,8 +34,9 @@ public function __construct($label = null, bool $multiple = false) $this->control->multiple = $multiple; $this->setOption('type', 'file'); $this->addRule([$this, 'isOk'], Forms\Validator::$messages[self::VALID]); + $this->addRule(Form::MAX_FILE_SIZE, null, Forms\Helpers::iniGetSize('upload_max_filesize')); - $this->monitor(Forms\Form::class, function (Forms\Form $form): void { + $this->monitor(Form::class, function (Form $form): void { if (!$form->isMethod('post')) { throw new Nette\InvalidStateException('File upload requires method POST.'); } @@ -48,7 +50,7 @@ public function __construct($label = null, bool $multiple = false) */ public function loadHttpData(): void { - $this->value = $this->getHttpData(Nette\Forms\Form::DATA_FILE); + $this->value = $this->getHttpData(Form::DATA_FILE); if ($this->value === null) { $this->value = new FileUpload(null); } @@ -103,10 +105,16 @@ public function isOk(): bool */ public function addRule($validator, $errorMessage = null, $arg = null) { - if ($validator === Forms\Form::IMAGE) { + if ($validator === Form::IMAGE) { $this->control->accept = implode(', ', FileUpload::IMAGE_MIME_TYPES); - } elseif ($validator === Forms\Form::MIME_TYPE) { + } elseif ($validator === Form::MIME_TYPE) { $this->control->accept = implode(', ', (array) $arg); + } elseif ($validator === Form::MAX_FILE_SIZE) { + if ($arg > Forms\Helpers::iniGetSize('upload_max_filesize')) { + $ini = ini_get('upload_max_filesize'); + trigger_error("Value of MAX_FILE_SIZE ($arg) is greater than value of directive upload_max_filesize ($ini).", E_USER_WARNING); + } + $this->getRules()->removeRule($validator); } return parent::addRule($validator, $errorMessage, $arg); } diff --git a/src/Forms/Form.php b/src/Forms/Form.php index 8f53c8b4e..a3d3b5d41 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -203,6 +203,17 @@ public function isMethod(string $method): bool } + /** + * Changes forms's HTML attribute. + * @return static + */ + public function setHtmlAttribute(string $name, $value = true) + { + $this->getElementPrototype()->$name = $value; + return $this; + } + + /** * Cross-Site Request Forgery (CSRF) form protection. */ @@ -486,11 +497,7 @@ public function validateMaxPostSize(): void if (!$this->submittedBy || !$this->isMethod('post') || empty($_SERVER['CONTENT_LENGTH'])) { return; } - $maxSize = ini_get('post_max_size'); - $units = ['k' => 10, 'm' => 20, 'g' => 30]; - if (isset($units[$ch = strtolower(substr($maxSize, -1))])) { - $maxSize = (int) $maxSize << $units[$ch]; - } + $maxSize = Helpers::iniGetSize('post_max_size'); if ($maxSize > 0 && $maxSize < $_SERVER['CONTENT_LENGTH']) { $this->addError(sprintf(Validator::$messages[self::MAX_FILE_SIZE], $maxSize)); } @@ -623,6 +630,7 @@ public function __toString(): string throw $e; } trigger_error('Exception in ' . __METHOD__ . "(): {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}", E_USER_ERROR); + return ''; } } diff --git a/src/Forms/Helpers.php b/src/Forms/Helpers.php index fc8973ec4..dc488db32 100644 --- a/src/Forms/Helpers.php +++ b/src/Forms/Helpers.php @@ -217,4 +217,15 @@ private static function prepareAttrs(?array $attrs, string $name): array } return [$dynamic, '<' . $name . Html::el(null, $attrs)->attributes()]; } + + + /** @internal */ + public static function iniGetSize(string $name): int + { + $value = ini_get($name); + $units = ['k' => 10, 'm' => 20, 'g' => 30]; + return isset($units[$ch = strtolower(substr($value, -1))]) + ? (int) $value << $units[$ch] + : (int) $value; + } } diff --git a/src/Forms/Rules.php b/src/Forms/Rules.php index 6c2c8c9da..576745978 100644 --- a/src/Forms/Rules.php +++ b/src/Forms/Rules.php @@ -92,6 +92,26 @@ public function addRule($validator, $errorMessage = null, $arg = null) } + /** + * Removes a validation rule for the current control. + * @param callable|string $validator + * @return static + */ + public function removeRule($validator) + { + if ($validator === Form::REQUIRED) { + $this->required = null; + } else { + foreach ($this->rules as $i => $rule) { + if (!$rule->branch && $rule->validator === $validator) { + unset($this->rules[$i]); + } + } + } + return $this; + } + + /** * Adds a validation condition and returns new branch. * @return static new branch diff --git a/src/Forms/Validator.php b/src/Forms/Validator.php index 1b8157510..66acfdd62 100644 --- a/src/Forms/Validator.php +++ b/src/Forms/Validator.php @@ -65,14 +65,17 @@ public static function formatMessage(Rule $rule, bool $withValue = true) $message = $translator->translate($message, is_int($rule->arg) ? $rule->arg : null); } - $message = preg_replace_callback('#%(name|label|value|\d+\$[ds]|[ds])#', function (array $m) use ($rule, $withValue) { + $message = preg_replace_callback('#%(name|label|value|\d+\$[ds]|[ds])#', function (array $m) use ($rule, $withValue, $translator) { static $i = -1; switch ($m[1]) { case 'name': return $rule->control->getName(); case 'label': if ($rule->control instanceof Controls\BaseControl) { - $caption = $rule->control->translate($rule->control->getCaption()); - return rtrim($caption instanceof Nette\Utils\Html ? $caption->getText() : $caption, ':'); + $caption = $rule->control->getCaption(); + $caption = $caption instanceof Nette\Utils\IHtmlString + ? $caption->getText() + : ($translator ? $translator->translate($caption) : $caption); + return rtrim($caption, ':'); } return ''; case 'value': return $withValue ? $rule->control->getValue() : $m[0]; diff --git a/tests/Forms/Controls.BaseControl.phpt b/tests/Forms/Controls.BaseControl.phpt index e6b5b850b..d6d71d101 100644 --- a/tests/Forms/Controls.BaseControl.phpt +++ b/tests/Forms/Controls.BaseControl.phpt @@ -145,3 +145,51 @@ test(function () { // disabled & submitted Assert::same('default', $input->getValue()); }); + + +test(function () { + $form = new Form; + $form->setTranslator(new class implements Nette\Localization\ITranslator { + public function translate($s, ...$parameters): string + { + return strtolower($s); + } + }); + + Validator::$messages[Form::FILLED] = '"%label" field is required.'; + + $input = $form->addSelect('list1', 'LIST', [ + 'a' => 'First', + 0 => 'Second', + ])->setRequired(); + + $input->validate(); + + Assert::match('', (string) $input->getLabel()); + Assert::same(['"list" field is required.'], $input->getErrors()); + + $input = $form->addSelect('list2', 'LIST', [ + 'a' => 'First', + 0 => 'Second', + ])->setTranslator(null) + ->setRequired(); + + $input->validate(); + + Assert::match('', (string) $input->getLabel()); + Assert::same(['"list" field is required.'], $input->getErrors()); +}); + + +test(function () { // change HTML name + $_POST = ['b' => '123', 'send' => '']; + $form = new Form; + $form->addSubmit('send', 'Send'); + $input = $form->addText('a'); + + Assert::same('', $input->getValue()); + $input->setHtmlAttribute('name', 'b'); + Assert::same('123', $input->getValue()); + + Assert::match('', (string) $input->getControl()); +}); diff --git a/tests/Forms/Controls.HiddenField.loadData.phpt b/tests/Forms/Controls.HiddenField.loadData.phpt index ee9633938..bb116a14a 100644 --- a/tests/Forms/Controls.HiddenField.loadData.phpt +++ b/tests/Forms/Controls.HiddenField.loadData.phpt @@ -68,9 +68,36 @@ test(function () { // setValue() and invalid argument test(function () { // object $form = new Form; $input = $form->addHidden('hidden') - ->setValue(new Nette\Utils\DateTime('2013-07-05')); + ->setValue($data = new Nette\Utils\DateTime('2013-07-05')); - Assert::same('2013-07-05 00:00:00', $input->getValue()); + Assert::same($data, $input->getValue()); +}); + + +test(function () { // object from string by filter + $date = new Nette\Utils\DateTime('2013-07-05'); + $_POST = ['text' => (string) $date]; + $form = new Form; + $input = $form->addHidden('text'); + $input->addFilter(function ($value) { + return $value ? new \Nette\Utils\DateTime($value) : $value; + }); + + Assert::same((string) $date, $input->getValue()); + $input->validate(); + Assert::equal($date, $input->getValue()); +}); + + +test(function () { // int from string + $_POST = ['text' => '10']; + $form = new Form; + $input = $form->addHidden('text'); + $input->addRule($form::INTEGER); + + Assert::same('10', $input->getValue()); + $input->validate(); + Assert::equal(10, $input->getValue()); }); @@ -81,3 +108,21 @@ test(function () { // persistent Assert::same('persistent', $input->getValue()); }); + + +test(function () { // nullable + $form = new Form; + $input = $form->addHidden('hidden'); + $input->setValue(''); + $input->setNullable(); + Assert::null($input->getValue()); +}); + + +test(function () { // nullable + $form = new Form; + $input = $form->addHidden('hidden'); + $input->setValue(null); + $input->setNullable(); + Assert::null($input->getValue()); +}); diff --git a/tests/Forms/Controls.HiddenField.render.phpt b/tests/Forms/Controls.HiddenField.render.phpt index afd617c30..3834db9ed 100644 --- a/tests/Forms/Controls.HiddenField.render.phpt +++ b/tests/Forms/Controls.HiddenField.render.phpt @@ -69,3 +69,12 @@ test(function () { // rendering options $input->getControl(); Assert::true($input->getOption('rendered')); }); + + +test(function () { // object + $form = new Form; + $input = $form->addHidden('hidden') + ->setValue(new Nette\Utils\DateTime('2013-07-05')); + + Assert::same('', (string) $input->getControl()); +}); diff --git a/tests/Forms/Controls.UploadControl.loadData.phpt b/tests/Forms/Controls.UploadControl.loadData.phpt index 7c8ba58f1..3990f8816 100644 --- a/tests/Forms/Controls.UploadControl.loadData.phpt +++ b/tests/Forms/Controls.UploadControl.loadData.phpt @@ -234,3 +234,13 @@ test(function () { // validators on multiple files Assert::true(Validator::validateImage($input)); }); + + +test(function () { // validators on multiple files + $form = new Form; + $input = $form->addUpload('invalid1'); + + $rules = iterator_to_array($input->getRules()); + Assert::count(2, $rules); + Assert::same($form::MAX_FILE_SIZE, $rules[1]->validator); +}); diff --git a/tests/Forms/Controls.UploadControl.render.phpt b/tests/Forms/Controls.UploadControl.render.phpt index 154cef7e3..c34ddeaed 100644 --- a/tests/Forms/Controls.UploadControl.render.phpt +++ b/tests/Forms/Controls.UploadControl.render.phpt @@ -32,7 +32,7 @@ test(function () { Assert::same('', (string) $input->getLabel('Another label')); Assert::type(Html::class, $input->getControl()); - Assert::same('', (string) $input->getControl()); + Assert::match('', (string) $input->getControl()); }); @@ -40,7 +40,7 @@ test(function () { // multiple $form = new Form; $input = $form->addMultiUpload('file', 'Label'); - Assert::same('', (string) $input->getControl()); + Assert::match('', (string) $input->getControl()); }); @@ -58,21 +58,20 @@ test(function () { // Html with translator test(function () { // validation rules $form = new Form; $input = $form->addUpload('file')->setRequired('required'); - - Assert::same('', (string) $input->getControl()); + Assert::match('', (string) $input->getControl()); }); test(function () { // accepted files $form = new Form; $input = $form->addUpload('file1')->addRule(Form::MIME_TYPE, null, 'image/*'); - Assert::same('', (string) $input->getControl()); + Assert::match('', (string) $input->getControl()); $input = $form->addUpload('file2')->addRule(Form::MIME_TYPE, null, ['image/*', 'text/html']); - Assert::same('', (string) $input->getControl()); + Assert::match('', (string) $input->getControl()); $input = $form->addUpload('file3')->addRule(Form::IMAGE); - Assert::same('', (string) $input->getControl()); + Assert::match('', (string) $input->getControl()); }); @@ -81,7 +80,7 @@ test(function () { // container $container = $form->addContainer('container'); $input = $container->addUpload('file'); - Assert::same('', (string) $input->getControl()); + Assert::match('', (string) $input->getControl()); }); diff --git a/tests/Forms/Forms.renderer.1.expect b/tests/Forms/Forms.renderer.1.expect index 2f0d6c3ed..a87dbecee 100644 --- a/tests/Forms/Forms.renderer.1.expect +++ b/tests/Forms/Forms.renderer.1.expect @@ -110,7 +110,7 @@