From 0f8727fb06b946f8b888c14d8b8be388752b5ab4 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 1 Dec 2023 16:19:57 +0300 Subject: [PATCH 1/5] improve --- .gitignore | 3 +-- CHANGELOG.md | 2 +- README.md | 6 +++--- composer.json | 16 ++++++++-------- phpunit.xml.dist | 33 ++++++++++++++++----------------- 5 files changed, 29 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 8c81ab2..b37074d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,4 @@ phpunit.phar # local phpunit config /phpunit.xml # phpunit cache -.phpunit.result.cache -/phpunit.cache +/.phpunit.cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8fbfe..7cd14a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# _____ Change Log +# Yii Form Model Change Log ## 1.0.0 under development diff --git a/README.md b/README.md index 8e5b84f..e6e8bbb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ -

Yii _____

+

Yii Form Model


@@ -26,7 +26,7 @@ The package ... The package could be installed with composer: ```shell -composer require yiisoft/_____ +composer require yiisoft/form-model ``` ## General usage @@ -74,7 +74,7 @@ Use [ComposerRequireChecker](https://github.com/maglnet/ComposerRequireChecker) ## License -The Yii _____ is free software. It is released under the terms of the BSD License. +The Yii Form Model is free software. It is released under the terms of the BSD License. Please see [`LICENSE`](./LICENSE.md) for more information. Maintained by [Yii Software](https://www.yiiframework.com/). diff --git a/composer.json b/composer.json index 461dab0..1556cc5 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "yiisoft/_____", + "name": "yiisoft/form-model", "type": "library", "description": "_____", "keywords": [ @@ -31,21 +31,21 @@ "php": "^8.1" }, "require-dev": { - "maglnet/composer-require-checker": "^4.6", - "phpunit/phpunit": "^9.5", - "rector/rector": "^0.17.11", - "roave/infection-static-analysis-plugin": "^1.16", + "maglnet/composer-require-checker": "^4.7", + "phpunit/phpunit": "^10.5", + "rector/rector": "^0.18.11", + "roave/infection-static-analysis-plugin": "^1.34", "spatie/phpunit-watcher": "^1.23", - "vimeo/psalm": "^4.30|^5.7" + "vimeo/psalm": "^5.16" }, "autoload": { "psr-4": { - "Yiisoft\\_____\\": "src" + "Yiisoft\\FormModel\\": "src" } }, "autoload-dev": { "psr-4": { - "Yiisoft\\_____\\Tests\\": "tests" + "Yiisoft\\FormModel\\Tests\\": "tests" } }, "config": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 976580a..4c10342 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,31 +1,30 @@ - - - + ./tests - + - ./src + ./src - + From 675e5b28cc6be45606956d5a05d4bc36f9830d9f Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 1 Dec 2023 17:23:22 +0300 Subject: [PATCH 2/5] Copy from `yiisoft/form` --- composer.json | 11 +- src/.gitkeep | 0 ...ropertyNotSupportNestedValuesException.php | 18 + .../StaticObjectPropertyException.php | 13 + .../UndefinedArrayElementException.php | 13 + .../UndefinedObjectPropertyException.php | 13 + src/Exception/ValueNotFoundException.php | 11 + src/Field.php | 315 +++++++++++++ src/Field/ErrorSummary.php | 241 ++++++++++ src/FormHydrator.php | 50 ++ src/FormModel.php | 328 +++++++++++++ src/FormModelInputData.php | 176 +++++++ src/FormModelInterface.php | 114 +++++ src/ValidationRulesEnricher.php | 311 +++++++++++++ tests/.gitkeep | 0 .../UndefinedObjectPropertyExceptionTest.php | 21 + tests/FieldTest.php | 433 ++++++++++++++++++ tests/FormModelInputDataTest.php | 41 ++ tests/FormModelTest.php | 417 +++++++++++++++++ tests/Support/Dto/Coordinates.php | 24 + tests/Support/Form/CustomFormNameForm.php | 15 + tests/Support/Form/DefaultFormNameForm.php | 11 + tests/Support/Form/FormWithNestedProperty.php | 65 +++ .../Support/Form/FormWithNestedStructures.php | 24 + tests/Support/Form/LoginForm.php | 99 ++++ tests/Support/Form/NestedForm.php | 21 + tests/Support/Form/NonNamespacedForm.php | 9 + tests/Support/Form/TestForm.php | 77 ++++ tests/Support/StubInputField.php | 20 + tests/Support/TestHelper.php | 29 ++ 30 files changed, 2918 insertions(+), 2 deletions(-) delete mode 100644 src/.gitkeep create mode 100644 src/Exception/PropertyNotSupportNestedValuesException.php create mode 100644 src/Exception/StaticObjectPropertyException.php create mode 100644 src/Exception/UndefinedArrayElementException.php create mode 100644 src/Exception/UndefinedObjectPropertyException.php create mode 100644 src/Exception/ValueNotFoundException.php create mode 100644 src/Field.php create mode 100644 src/Field/ErrorSummary.php create mode 100644 src/FormHydrator.php create mode 100644 src/FormModel.php create mode 100644 src/FormModelInputData.php create mode 100644 src/FormModelInterface.php create mode 100644 src/ValidationRulesEnricher.php delete mode 100644 tests/.gitkeep create mode 100644 tests/Exception/UndefinedObjectPropertyExceptionTest.php create mode 100644 tests/FieldTest.php create mode 100644 tests/FormModelInputDataTest.php create mode 100644 tests/FormModelTest.php create mode 100644 tests/Support/Dto/Coordinates.php create mode 100644 tests/Support/Form/CustomFormNameForm.php create mode 100644 tests/Support/Form/DefaultFormNameForm.php create mode 100644 tests/Support/Form/FormWithNestedProperty.php create mode 100644 tests/Support/Form/FormWithNestedStructures.php create mode 100644 tests/Support/Form/LoginForm.php create mode 100644 tests/Support/Form/NestedForm.php create mode 100644 tests/Support/Form/NonNamespacedForm.php create mode 100644 tests/Support/Form/TestForm.php create mode 100644 tests/Support/StubInputField.php create mode 100644 tests/Support/TestHelper.php diff --git a/composer.json b/composer.json index 1556cc5..b871423 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,13 @@ "minimum-stability": "dev", "prefer-stable": true, "require": { - "php": "^8.1" + "php": "^8.1", + "yiisoft/form": "^1.0@dev", + "yiisoft/html": "^3.3", + "yiisoft/hydrator": "dev-master", + "yiisoft/hydrator-validator": "dev-master", + "yiisoft/strings": "^2.3", + "yiisoft/validator": "^1.1" }, "require-dev": { "maglnet/composer-require-checker": "^4.7", @@ -36,7 +42,8 @@ "rector/rector": "^0.18.11", "roave/infection-static-analysis-plugin": "^1.34", "spatie/phpunit-watcher": "^1.23", - "vimeo/psalm": "^5.16" + "vimeo/psalm": "^5.16", + "yiisoft/test-support": "^3.0" }, "autoload": { "psr-4": { diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/Exception/PropertyNotSupportNestedValuesException.php b/src/Exception/PropertyNotSupportNestedValuesException.php new file mode 100644 index 0000000..8287fe5 --- /dev/null +++ b/src/Exception/PropertyNotSupportNestedValuesException.php @@ -0,0 +1,18 @@ +value; + } +} diff --git a/src/Exception/StaticObjectPropertyException.php b/src/Exception/StaticObjectPropertyException.php new file mode 100644 index 0000000..d06bc5b --- /dev/null +++ b/src/Exception/StaticObjectPropertyException.php @@ -0,0 +1,13 @@ +content($content); + } + + return $field; + } + + final public static function buttonGroup(array $config = [], ?string $theme = null): ButtonGroup + { + return ButtonGroup::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME); + } + + final public static function checkbox( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Checkbox { + return Checkbox::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function checkboxList( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): CheckboxList { + return CheckboxList::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function date( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Date { + return Date::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function dateTime( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): DateTime { + return DateTime::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function dateTimeLocal( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): DateTimeLocal { + return DateTimeLocal::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function email( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Email { + return Email::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function errorSummary( + FormModelInterface $formModel, + array $config = [], + ?string $theme = null, + ): ErrorSummary { + return ErrorSummary::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->validationResult($formModel->getValidationResult()); + } + + final public static function fieldset(array $config = [], ?string $theme = null): Fieldset + { + return Fieldset::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME); + } + + final public static function file( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): File { + return File::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function hidden( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Hidden { + return Hidden::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function image(array $config = [], ?string $theme = null): Image + { + return Image::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME); + } + + final public static function number( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Number { + return Number::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function password( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Password { + return Password::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function radioList( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): RadioList { + return RadioList::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function range( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Range { + return Range::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function resetButton( + ?string $content = null, + array $config = [], + ?string $theme = null, + ): ResetButton { + $field = ResetButton::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME); + + if ($content !== null) { + $field = $field->content($content); + } + + return $field; + } + + final public static function select( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Select { + return Select::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function submitButton( + ?string $content = null, + array $config = [], + ?string $theme = null, + ): SubmitButton { + $field = SubmitButton::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME); + + if ($content !== null) { + $field = $field->content($content); + } + + return $field; + } + + final public static function telephone( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Telephone { + return Telephone::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function text( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Text { + return Text::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function textarea( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Textarea { + return Textarea::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function time( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Time { + return Time::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function url( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Url { + return Url::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function label( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Label { + return Label::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function hint( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Hint { + return Hint::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } + + final public static function error( + FormModelInterface $formModel, + string $property, + array $config = [], + ?string $theme = null, + ): Error { + return Error::widget(config: $config, theme: $theme ?? static::DEFAULT_THEME) + ->inputData(new FormModelInputData($formModel, $property)); + } +} diff --git a/src/Field/ErrorSummary.php b/src/Field/ErrorSummary.php new file mode 100644 index 0000000..5799638 --- /dev/null +++ b/src/Field/ErrorSummary.php @@ -0,0 +1,241 @@ +validationResult = $result; + return $new; + } + + /** + * Whether error content should be HTML-encoded. + */ + public function encode(bool $value): self + { + $new = clone $this; + $new->encode = $value; + return $new; + } + + /** + * Whether to show all errors. + */ + public function showAllErrors(bool $value = true): self + { + $new = clone $this; + $new->showAllErrors = $value; + return $new; + } + + /** + * Specific properties to be filtered out when rendering the error summary. + * + * @param array $names The property names to be included in error summary. + */ + public function onlyProperties(string ...$names): self + { + $new = clone $this; + $new->onlyProperties = $names; + return $new; + } + + /** + * Use only common errors when rendering the error summary. + */ + public function onlyCommonErrors(): self + { + $new = clone $this; + $new->onlyProperties = ['']; + return $new; + } + + /** + * Set the footer text for the error summary + */ + public function footer(string $value): self + { + $new = clone $this; + $new->footer = $value; + return $new; + } + + /** + * Set footer attributes for the error summary. + * + * @param array $values Attribute values indexed by attribute names. + * + * See {@see Html::renderTagAttributes} for details on how attributes are being rendered. + */ + public function footerAttributes(array $values): self + { + $new = clone $this; + $new->footerAttributes = $values; + return $new; + } + + /** + * Set the header text for the error summary + */ + public function header(string $value): self + { + $new = clone $this; + $new->header = $value; + return $new; + } + + /** + * Set header attributes for the error summary. + * + * @param array $values Attribute values indexed by attribute names. + * + * See {@see Html::renderTagAttributes} for details on how attributes are being rendered. + */ + public function headerAttributes(array $values): self + { + $new = clone $this; + $new->headerAttributes = $values; + return $new; + } + + /** + * Set errors list container attributes. + * + * @param array $attributes Attribute values indexed by attribute names. + * + * See {@see Html::renderTagAttributes} for details on how attributes are being rendered. + */ + public function listAttributes(array $attributes): self + { + $new = clone $this; + $new->listAttributes = $attributes; + return $new; + } + + /** + * Add one or more CSS classes to the list container tag. + * + * @param string|null ...$class One or many CSS classes. + */ + public function addListClass(?string ...$class): self + { + $new = clone $this; + Html::addCssClass($new->listAttributes, $class); + return $new; + } + + /** + * Replace current list container tag CSS classes with a new set of classes. + * + * @param string|null ...$class One or many CSS classes. + */ + public function listClass(?string ...$class): static + { + $new = clone $this; + $new->listAttributes['class'] = array_filter($class, static fn ($c) => $c !== null); + return $new; + } + + protected function generateContent(): ?string + { + $messages = $this->collectErrors(); + if (empty($messages)) { + return null; + } + + $content = []; + + if ($this->header !== '') { + $content[] = Html::p($this->header, $this->headerAttributes)->render(); + } + + $content[] = Html::ul() + ->attributes($this->listAttributes) + ->strings($messages, [], $this->encode) + ->render(); + + if ($this->footer !== '') { + $content[] = Html::p($this->footer, $this->footerAttributes)->render(); + } + + return implode("\n", $content); + } + + /** + * Return array of the validation errors. + * + * @return string[] Array of the validation errors. + */ + private function collectErrors(): array + { + if ($this->validationResult === null) { + return []; + } + + if ($this->showAllErrors) { + $errors = $this->getAllErrors(); + } elseif ($this->onlyProperties !== []) { + $errors = array_intersect_key($this->getFirstErrors(), array_flip($this->onlyProperties)); + } else { + $errors = $this->getFirstErrors(); + } + + /** + * If there are the same error messages for different properties, array_unique will leave gaps between + * sequential keys. Applying array_values to reorder array keys. + * + * @var string[] + */ + return array_values(array_unique($errors)); + } + + private function getAllErrors(): array + { + if ($this->onlyProperties === []) { + return $this->validationResult?->getErrorMessages() ?? []; + } + + $result = []; + foreach ($this->validationResult?->getErrorMessagesIndexedByPath() ?? [] as $property => $messages) { + if (in_array($property, $this->onlyProperties, true)) { + $result[] = $messages; + } + } + + return array_merge(...$result); + } + + private function getFirstErrors(): array + { + $result = []; + foreach ($this->validationResult?->getErrorMessagesIndexedByPath() ?? [] as $property => $messages) { + if (isset($messages[0])) { + $result[$property] = $messages[0]; + } + } + return $result; + } +} diff --git a/src/FormHydrator.php b/src/FormHydrator.php new file mode 100644 index 0000000..9e89629 --- /dev/null +++ b/src/FormHydrator.php @@ -0,0 +1,50 @@ +getFormName(); + if ($scope === '') { + $hydrateData = $data; + } else { + if (!isset($data[$scope]) || !is_array($data[$scope])) { + return false; + } + $hydrateData = $data[$scope]; + } + + $this->hydrator->hydrate($model, new ArrayData($hydrateData, $map, $strict)); + + return true; + } +} diff --git a/src/FormModel.php b/src/FormModel.php new file mode 100644 index 0000000..72b8039 --- /dev/null +++ b/src/FormModel.php @@ -0,0 +1,328 @@ +readPropertyMetaValue(self::META_HINT, $property) ?? ''; + } + + /** + * Returns the property hints. + * + * Property hints are mainly used for display purpose. For example, given a property `isPublic`, we can declare + * a hint `Whether the post should be visible for not logged-in users`, which provides user-friendly description of + * the property meaning and can be displayed to end users. + * + * Unlike label hint will not be generated, if its explicit declaration is omitted. + * + * Note, in order to inherit hints defined in the parent class, a child class needs to merge the parent hints with + * child hints using functions such as `array_merge()`. + * + * @return array Property hints (name => hint) + * + * @psalm-return array + */ + public function getPropertyHints(): array + { + return []; + } + + /** + * Returns the text label for the specified property. + * + * @param string $property The property name. + * + * @return string The property label. + */ + public function getPropertyLabel(string $property): string + { + return $this->readPropertyMetaValue(self::META_LABEL, $property) ?? $this->generatePropertyLabel($property); + } + + /** + * Returns the property labels. + * + * Attribute labels are mainly used for display purpose. For example, given a property `firstName`, we can + * declare a label `First Name` which is more user-friendly and can be displayed to end users. + * + * By default, a property label is generated automatically. This method allows you to + * explicitly specify property labels. + * + * Note, in order to inherit labels defined in the parent class, a child class needs to merge the parent labels + * with child labels using functions such as `array_merge()`. + * + * @return array Property labels (name => label) + * + * @psalm-return array + */ + public function getPropertyLabels(): array + { + return []; + } + + /** + * Returns the text placeholder for the specified property. + * + * @param string $property The property name. + * + * @return string The property placeholder. + */ + public function getPropertyPlaceholder(string $property): string + { + return $this->readPropertyMetaValue(self::META_PLACEHOLDER, $property) ?? ''; + } + + public function getPropertyValue(string $property): mixed + { + try { + return $this->readPropertyValue($property); + } catch (PropertyNotSupportNestedValuesException $exception) { + return $exception->getValue() === null + ? null + : throw $exception; + } catch (UndefinedArrayElementException) { + return null; + } + } + + /** + * Returns the property placeholders. + * + * @return array Property placeholders (name => placeholder) + * + * @psalm-return array + */ + public function getPropertyPlaceholders(): array + { + return []; + } + + /** + * Returns the form name that this model class should use. + * + * The form name is mainly used by {@see \Yiisoft\Form\InputData\HtmlForm} to determine how to name the input + * fields for the properties in a model. + * If the form name is "A" and a property name is "b", then the corresponding input name would be "A[b]". + * If the form name is an empty string, then the input name would be "b". + * + * The purpose of the above naming schema is that for forms which contain multiple different models, the properties + * of each model are grouped in sub-arrays of the POST-data, and it is easier to differentiate between them. + * + * By default, this method returns the model class name (without the namespace part) as the form name. You may + * override it when the model is used in different forms. + * + * @return string The form name of this model class. + */ + public function getFormName(): string + { + if (str_contains(static::class, '@anonymous')) { + return ''; + } + + $className = strrchr(static::class, '\\'); + if ($className === false) { + return static::class; + } + + return substr($className, 1); + } + + /** + * If there is such property in the set. + */ + public function hasProperty(string $property): bool + { + try { + $this->readPropertyValue($property); + } catch (ValueNotFoundException) { + return false; + } + return true; + } + + public function isValid(): bool + { + return (bool) $this->getValidationResult()?->isValid(); + } + + /** + * @throws UndefinedArrayElementException + * @throws UndefinedObjectPropertyException + * @throws StaticObjectPropertyException + * @throws PropertyNotSupportNestedValuesException + * @throws ValueNotFoundException + */ + private function readPropertyValue(string $path): mixed + { + $path = $this->normalizePath($path); + + $value = $this; + $keys = [[static::class, $this]]; + foreach ($path as $key) { + $keys[] = [$key, $value]; + + if (is_array($value)) { + if (array_key_exists($key, $value)) { + $value = $value[$key]; + continue; + } + throw new UndefinedArrayElementException($this->makePropertyPathString($keys)); + } + + if (is_object($value)) { + $class = new ReflectionClass($value); + try { + $property = $class->getProperty($key); + } catch (ReflectionException) { + throw new UndefinedObjectPropertyException($this->makePropertyPathString($keys)); + } + if ($property->isStatic()) { + throw new StaticObjectPropertyException($this->makePropertyPathString($keys)); + } + $value = $property->getValue($value); + continue; + } + + array_pop($keys); + throw new PropertyNotSupportNestedValuesException($this->makePropertyPathString($keys), $value); + } + + return $value; + } + + private function readPropertyMetaValue(int $metaKey, string $path): ?string + { + $path = $this->normalizePath($path); + + $value = $this; + $n = 0; + foreach ($path as $key) { + if ($value instanceof self) { + $nestedAttribute = implode('.', array_slice($path, $n)); + $data = match ($metaKey) { + self::META_LABEL => $value->getPropertyLabels(), + self::META_HINT => $value->getPropertyHints(), + self::META_PLACEHOLDER => $value->getPropertyPlaceholders(), + default => throw new InvalidArgumentException('Invalid meta key.'), + }; + if (array_key_exists($nestedAttribute, $data)) { + return $data[$nestedAttribute]; + } + } + + $class = new ReflectionClass($value); + try { + $property = $class->getProperty($key); + } catch (ReflectionException) { + return null; + } + if ($property->isStatic()) { + return null; + } + + $value = $property->getValue($value); + if (!is_object($value)) { + return null; + } + + $n++; + } + + return null; + } + + /** + * Generates a user-friendly property label based on the give property name. + * + * This is done by replacing underscores, dashes and dots with blanks and changing the first letter of each word to + * upper case. + * + * For example, 'department_name' or 'DepartmentName' will generate 'Department Name'. + * + * @param string $property The property name. + * + * @return string The property label. + */ + private function generatePropertyLabel(string $property): string + { + if (self::$inflector === null) { + self::$inflector = new Inflector(); + } + + return StringHelper::uppercaseFirstCharacterInEachWord( + self::$inflector->toWords($property) + ); + } + + /** + * @return string[] + */ + private function normalizePath(string $path): array + { + $path = str_replace(['][', '['], '.', rtrim($path, ']')); + return StringHelper::parsePath($path); + } + + /** + * @psalm-param array $keys + */ + private function makePropertyPathString(array $keys): string + { + $path = ''; + foreach ($keys as $key) { + if ($path !== '') { + if (is_object($key[1])) { + $path .= '::' . $key[0]; + } elseif (is_array($key[1])) { + $path .= '[' . $key[0] . ']'; + } + } else { + $path = (string) $key[0]; + } + } + return $path; + } +} diff --git a/src/FormModelInputData.php b/src/FormModelInputData.php new file mode 100644 index 0000000..e75ebef --- /dev/null +++ b/src/FormModelInputData.php @@ -0,0 +1,176 @@ +validationRules === null) { + $rules = RulesNormalizer::normalize(null, $this->model); + $this->validationRules = $rules[$this->property] ?? []; + } + return $this->validationRules; + } + + /** + * Generates an appropriate input name. + * + * This method generates a name that can be used as the input name to collect user input. The name is generated + * according to the form and the property names. For example, if the form name is `Post` + * then the input name generated for the `content` property would be `Post[content]`. + * + * See {@see getPropertyName()} for explanation of property expression. + * + * @throws InvalidArgumentException If the property name contains non-word characters or empty form name for + * tabular inputs. + * @return string|null The generated input name. + */ + public function getName(): ?string + { + $data = $this->parseProperty($this->property); + $formName = $this->model->getFormName(); + + if ($formName === '' && $data['prefix'] === '') { + return $this->property; + } + + if ($formName !== '') { + return "$formName{$data['prefix']}[{$data['name']}]{$data['suffix']}"; + } + + throw new InvalidArgumentException('formName() cannot be empty for tabular inputs.'); + } + + /** + * @throws UndefinedObjectPropertyException + * @throws StaticObjectPropertyException + * @throws PropertyNotSupportNestedValuesException + * @throws ValueNotFoundException + */ + public function getValue(): mixed + { + $parsedName = $this->parseProperty($this->property); + return $this->model->getPropertyValue($parsedName['name'] . $parsedName['suffix']); + } + + public function getLabel(): ?string + { + return $this->model->getPropertyLabel($this->getPropertyName()); + } + + public function getHint(): ?string + { + return $this->model->getPropertyHint($this->getPropertyName()); + } + + public function getPlaceholder(): ?string + { + $placeholder = $this->model->getPropertyPlaceholder($this->getPropertyName()); + return $placeholder === '' ? null : $placeholder; + } + + /** + * Generates an appropriate input ID. + * + * This method converts the result {@see getName()} into a valid input ID. + * + * For example, if {@see getInputName()} returns `Post[content]`, this method will return `post-content`. + * + * @throws InvalidArgumentException If the property name contains non-word characters. + * @return string|null The generated input ID. + */ + public function getId(): ?string + { + $name = $this->getName(); + if ($name === null) { + return null; + } + + $name = mb_strtolower($name, 'UTF-8'); + return str_replace(['[]', '][', '[', ']', ' ', '.'], ['', '-', '-', '', '-', '-'], $name); + } + + public function isValidated(): bool + { + return $this->model->getValidationResult() !== null; + } + + public function getValidationErrors(): array + { + /** @psalm-var list */ + return $this->model + ->getValidationResult() + ?->getAttributeErrorMessages($this->getPropertyName()) + ?? []; + } + + private function getPropertyName(): string + { + $property = $this->parseProperty($this->property)['name']; + + if (!$this->model->hasProperty($property)) { + throw new InvalidArgumentException('Property "' . $property . '" does not exist.'); + } + + return $property; + } + + /** + * This method parses a property expression and returns an associative array containing + * real property name, prefix and suffix. + * For example: `['name' => 'content', 'prefix' => '', 'suffix' => '[0]']` + * + * A property expression is a property name prefixed and/or suffixed with array indexes. It is mainly used in + * tabular data input and/or input of array type. Below are some examples: + * + * - `[0]content` is used in tabular data input to represent the "content" property for the first model in tabular + * input; + * - `dates[0]` represents the first array element of the "dates" property; + * - `[0]dates[0]` represents the first array element of the "dates" property for the first model in tabular + * input. + * + * @param string $property The property name or expression + * + * @throws InvalidArgumentException If the property name contains non-word characters. + * @return string[] The property name, prefix and suffix. + */ + private function parseProperty(string $property): array + { + if (!preg_match('/(^|.*\])([\w\.\+\-_]+)(\[.*|$)/u', $property, $matches)) { + throw new InvalidArgumentException('Property name must contain word characters only.'); + } + return [ + 'name' => $matches[2], + 'prefix' => $matches[1], + 'suffix' => $matches[3], + ]; + } +} diff --git a/src/FormModelInterface.php b/src/FormModelInterface.php new file mode 100644 index 0000000..7c2c32e --- /dev/null +++ b/src/FormModelInterface.php @@ -0,0 +1,114 @@ + hint) + * + * @psalm-return array + */ + public function getPropertyHints(): array; + + /** + * Returns the text label for the specified property. + * + * @param string $property The property name. + * + * @return string The property label. + */ + public function getPropertyLabel(string $property): string; + + /** + * Returns the property labels. + * + * Property labels are mainly used for display purpose. For example, given a property `firstName`, we can + * declare a label `First Name` which is more user-friendly and can be displayed to end users. + * + * By default, a property label is generated automatically. This method allows you to + * explicitly specify property labels. + * + * Note, in order to inherit labels defined in the parent class, a child class needs to merge the parent labels + * with child labels using functions such as `array_merge()`. + * + * @return array Property labels (name => label) + * + * {@see getPropertyLabel()} + * + * @psalm-return array + */ + public function getPropertyLabels(): array; + + /** + * Returns the text placeholder for the specified property. + * + * @param string $property The property name. + * + * @return string The property placeholder. + */ + public function getPropertyPlaceholder(string $property): string; + + /** + * @throws UndefinedObjectPropertyException + * @throws StaticObjectPropertyException + * @throws PropertyNotSupportNestedValuesException + * @throws ValueNotFoundException + */ + public function getPropertyValue(string $property): mixed; + + /** + * Returns the property placeholders. + * + * @return array Property placeholder (name => placeholder) + * + * @psalm-return array + */ + public function getPropertyPlaceholders(): array; + + /** + * Returns the form name that this model class should use. + * + * The form name is mainly used by {@see \Yiisoft\Form\InputData\HtmlForm} to determine how to name the input + * fields for the properties in a model. + * If the form name is "A" and a property name is "b", then the corresponding input name would be "A[b]". + * If the form name is an empty string, then the input name would be "b". + * + * The purpose of the above naming schema is that for forms which contain multiple different models, the properties + * of each model are grouped in sub-arrays of the POST-data, and it is easier to differentiate between them. + * + * By default, this method returns the model class name (without the namespace part) as the form name. You may + * override it when the model is used in different forms. + * + * @return string The form name of this model class. + */ + public function getFormName(): string; + + /** + * If there is such property in the set. + */ + public function hasProperty(string $property): bool; + + public function isValid(): bool; +} diff --git a/src/ValidationRulesEnricher.php b/src/ValidationRulesEnricher.php new file mode 100644 index 0000000..46ccca6 --- /dev/null +++ b/src/ValidationRulesEnricher.php @@ -0,0 +1,311 @@ +getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + } + return $enrichment; + } + + if ($field instanceof Email) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + + if ($rule instanceof Length) { + if (null !== $min = $rule->getMin()) { + $enrichment['inputAttributes']['minlength'] = $min; + } + if (null !== $max = $rule->getMax()) { + $enrichment['inputAttributes']['maxlength'] = $max; + } + } + + if ($rule instanceof Regex) { + if (!$rule->isNot()) { + $enrichment['inputAttributes']['pattern'] = Html::normalizeRegexpPattern( + $rule->getPattern() + ); + } + } + } + return $enrichment; + } + + if ($field instanceof File) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + } + return $enrichment; + } + + if ($field instanceof Number) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + + if ($rule instanceof NumberRule) { + if (null !== $min = $rule->getMin()) { + $enrichment['inputAttributes']['min'] = $min; + } + if (null !== $max = $rule->getMax()) { + $enrichment['inputAttributes']['max'] = $max; + } + } + } + return $enrichment; + } + + if ($field instanceof Password) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + + if ($rule instanceof Length) { + if (null !== $min = $rule->getMin()) { + $enrichment['inputAttributes']['minlength'] = $min; + } + if (null !== $max = $rule->getMax()) { + $enrichment['inputAttributes']['maxlength'] = $max; + } + } + + if ($rule instanceof Regex) { + if (!$rule->isNot()) { + $enrichment['inputAttributes']['pattern'] = Html::normalizeRegexpPattern( + $rule->getPattern() + ); + } + } + } + return $enrichment; + } + + if ($field instanceof Range) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + + if ($rule instanceof NumberRule) { + if (null !== $min = $rule->getMin()) { + $enrichment['inputAttributes']['min'] = $min; + } + if (null !== $max = $rule->getMax()) { + $enrichment['inputAttributes']['max'] = $max; + } + } + } + return $enrichment; + } + + if ($field instanceof Select) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + } + return $enrichment; + } + + if ($field instanceof Telephone) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + + if ($rule instanceof Length) { + if (null !== $min = $rule->getMin()) { + $enrichment['inputAttributes']['minlength'] = $min; + } + if (null !== $max = $rule->getMax()) { + $enrichment['inputAttributes']['maxlength'] = $max; + } + } + + if ($rule instanceof Regex) { + if (!$rule->isNot()) { + $enrichment['inputAttributes']['pattern'] = Html::normalizeRegexpPattern( + $rule->getPattern() + ); + } + } + } + return $enrichment; + } + + if ($field instanceof Text) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + + if ($rule instanceof Length) { + if (null !== $min = $rule->getMin()) { + $enrichment['inputAttributes']['minlength'] = $min; + } + if (null !== $max = $rule->getMax()) { + $enrichment['inputAttributes']['maxlength'] = $max; + } + } + + if ($rule instanceof Regex) { + if (!$rule->isNot()) { + $enrichment['inputAttributes']['pattern'] = Html::normalizeRegexpPattern( + $rule->getPattern() + ); + } + } + } + return $enrichment; + } + + if ($field instanceof Textarea) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + + if ($rule instanceof Length) { + if (null !== $min = $rule->getMin()) { + $enrichment['inputAttributes']['minlength'] = $min; + } + if (null !== $max = $rule->getMax()) { + $enrichment['inputAttributes']['maxlength'] = $max; + } + } + } + return $enrichment; + } + + if ($field instanceof Url) { + $enrichment = []; + foreach ($rules as $rule) { + if ($rule instanceof WhenInterface && $rule->getWhen() !== null) { + continue; + } + + if ($rule instanceof Required) { + $enrichment['inputAttributes']['required'] = true; + } + + if ($rule instanceof Length) { + if (null !== $min = $rule->getMin()) { + $enrichment['inputAttributes']['minlength'] = $min; + } + if (null !== $max = $rule->getMax()) { + $enrichment['inputAttributes']['maxlength'] = $max; + } + } + + $pattern = null; + if ($rule instanceof UrlRule) { + $pattern = $rule->isIdnEnabled() ? null : $rule->getPattern(); + } + if ($pattern === null && $rule instanceof Regex) { + if (!$rule->isNot()) { + $pattern = $rule->getPattern(); + } + } + if ($pattern !== null) { + $enrichment['inputAttributes']['pattern'] = Html::normalizeRegexpPattern($pattern); + } + } + return $enrichment; + } + + return null; + } +} diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Exception/UndefinedObjectPropertyExceptionTest.php b/tests/Exception/UndefinedObjectPropertyExceptionTest.php new file mode 100644 index 0000000..811ef22 --- /dev/null +++ b/tests/Exception/UndefinedObjectPropertyExceptionTest.php @@ -0,0 +1,21 @@ +assertSame( + 'Undefined object property: "test".', + $exception->getMessage() + ); + } +} diff --git a/tests/FieldTest.php b/tests/FieldTest.php new file mode 100644 index 0000000..fd9787b --- /dev/null +++ b/tests/FieldTest.php @@ -0,0 +1,433 @@ +render(); + $this->assertSame( + << + + + HTML, + $result + ); + } + + public function testButtonGroup(): void + { + $result = Field::buttonGroup() + ->buttons( + Html::resetButton('Reset Data'), + Html::submitButton('Send'), + ) + ->render(); + + $this->assertSame( + << + + + + HTML, + $result + ); + } + + public function testCheckbox(): void + { + $result = Field::checkbox(new TestForm(), 'blue')->render(); + $this->assertSame( + << + + + HTML, + $result + ); + } + + public function testCheckboxList(): void + { + $result = Field::checkboxList(new TestForm(), 'color2') + ->items([ + 'red' => 'Red', + 'blue' => 'Blue', + ]) + ->render(); + + $expected = <<<'HTML' +
+ +
+ + +
+
+ HTML; + + $this->assertSame($expected, $result); + } + + public function testDate(): void + { + $result = Field::date(new TestForm(), 'birthday')->render(); + $this->assertSame( + << + + + + HTML, + $result + ); + } + + public function testDateTime(): void + { + $result = Field::dateTime(new TestForm(), 'xDate')->render(); + $this->assertSame( + << + + + + HTML, + $result + ); + } + + public function testDateTimeLocal(): void + { + $result = Field::dateTimeLocal(new TestForm(), 'partyDate')->render(); + $this->assertSame( + << + + + + HTML, + $result + ); + } + + public function testEmail(): void + { + $result = Field::email(new TestForm(), 'mainEmail')->render(); + $this->assertSame( + << + + +
Email for notifications.
+ + HTML, + $result + ); + } + + public function testErrorSummary(): void + { + $result = Field::errorSummary(TestForm::validated()) + ->onlyProperties('name') + ->render(); + + $expected = <<<'HTML' +
+

Please fix the following errors:

+
    +
  • Value cannot be blank.
  • +
+
+ HTML; + + $this->assertSame($expected, $result); + } + + public function testFieldset(): void + { + $result = Field::fieldset() + ->legend('Choose your color') + ->render(); + + $expected = <<<'HTML' +
+
+ Choose your color +
+
+ HTML; + + $this->assertSame($expected, $result); + } + + public function testFile(): void + { + $result = Field::file(new TestForm(), 'avatar')->render(); + $this->assertSame( + << + + + + HTML, + $result + ); + } + + public function testHidden(): void + { + $result = Field::hidden(new TestForm(), 'key')->render(); + $this->assertSame( + '', + $result + ); + } + + public function testImage(): void + { + $result = Field::image() + ->src('btn.png') + ->alt('Go') + ->render(); + $this->assertSame( + << + + + HTML, + $result + ); + } + + public function testNumber(): void + { + $result = Field::number(new TestForm(), 'age')->render(); + $this->assertSame( + << + + +
Full years.
+ + HTML, + $result + ); + } + + public function testPassword(): void + { + $result = Field::password(new TestForm(), 'oldPassword')->render(); + $this->assertSame( + << + + +
Enter your old password.
+ + HTML, + $result + ); + } + + public function testRadioList(): void + { + $result = Field::radioList(new TestForm(), 'color') + ->items([ + 'red' => 'Red', + 'blue' => 'Blue', + ]) + ->render(); + + $expected = <<<'HTML' +
+ +
+ + +
+
Color of box.
+
+ HTML; + + $this->assertSame($expected, $result); + } + + public function testRange(): void + { + $result = Field::range(new TestForm(), 'volume') + ->min(1) + ->max(100) + ->render(); + $this->assertSame( + << + + + + HTML, + $result + ); + } + + public function testResetButton(): void + { + $result = Field::resetButton('Reset form') + ->render(); + $this->assertSame( + << + + + HTML, + $result + ); + } + + public function testSelect(): void + { + $result = Field::select(new TestForm(), 'count') + ->optionsData([ + 1 => 'One', + 2 => 'Two', + ]) + ->render(); + $this->assertSame( + << + + + + HTML, + $result + ); + } + + public function testSubmitButton(): void + { + $result = Field::submitButton('Go!') + ->render(); + $this->assertSame( + << + + + HTML, + $result + ); + } + + public function testTelephone(): void + { + $result = Field::telephone(new TestForm(), 'number')->render(); + $this->assertSame( + << + + +
Enter your phone.
+ + HTML, + $result + ); + } + + public function testText(): void + { + $result = Field::text(new TestForm(), 'name')->render(); + $this->assertSame( + << + + +
Input your full name.
+ + HTML, + $result + ); + } + + public function testTextarea(): void + { + $result = Field::textarea(new TestForm(), 'desc')->render(); + $this->assertSame( + << + + + + HTML, + $result + ); + } + + public function testTime(): void + { + $html = Field::time(new TestForm(), 'startTime')->render(); + + $expected = << + + + + HTML; + + $this->assertSame($expected, $html); + } + + public function testUrl(): void + { + $result = Field::url(new TestForm(), 'site')->render(); + $this->assertSame( + << + + +
Enter your site URL.
+ + HTML, + $result + ); + } + + public function testLabel(): void + { + $result = Field::label(new TestForm(), 'name')->render(); + $this->assertSame('', $result); + } + + public function testHint(): void + { + $result = Field::hint(new TestForm(), 'name')->render(); + $this->assertSame('
Input your full name.
', $result); + } + + public function testError(): void + { + $result = Field::error(TestForm::validated(), 'name')->render(); + $this->assertSame('
Value cannot be blank.
', $result); + } +} diff --git a/tests/FormModelInputDataTest.php b/tests/FormModelInputDataTest.php new file mode 100644 index 0000000..aabc86d --- /dev/null +++ b/tests/FormModelInputDataTest.php @@ -0,0 +1,41 @@ +assertSame($expectedName, $inputData->getName()); + $this->assertSame($expectedId, $inputData->getId()); + } +} diff --git a/tests/FormModelTest.php b/tests/FormModelTest.php new file mode 100644 index 0000000..5718c42 --- /dev/null +++ b/tests/FormModelTest.php @@ -0,0 +1,417 @@ +assertSame('', $form->getFormName()); + } + + public function testCustomFormName(): void + { + $form = new CustomFormNameForm(); + $this->assertSame('my-best-form-name', $form->getFormName()); + } + + public function testDefaultFormName(): void + { + $form = new DefaultFormNameForm(); + $this->assertSame('DefaultFormNameForm', $form->getFormName()); + } + + public function testArrayValue(): void + { + $expected = <<<'HTML' +
+ + +
+ HTML; + + $result = StubInputField::widget() + ->inputData(new FormModelInputData(new NestedForm(), 'letters[0]')) + ->render(); + + $this->assertSame($expected, $result); + } + + public function testNonExistArrayValue(): void + { + $widget = StubInputField::widget()->inputData(new FormModelInputData(new NestedForm(), 'letters[1]')); + + $result = $widget->render(); + + $this->assertSame( + << + + + + HTML, + $result + ); + } + + public function testArrayValueIntoObject(): void + { + $expected = <<<'HTML' +
+ + +
+ HTML; + + $result = StubInputField::widget() + ->inputData(new FormModelInputData(new NestedForm(), 'object[numbers][1]')) + ->render(); + + $this->assertSame($expected, $result); + } + + public function testGetAttributeHint(): void + { + $form = new LoginForm(); + + $this->assertSame('Write your id or email.', $form->getPropertyHint('login')); + $this->assertSame('Write your password.', $form->getPropertyHint('password')); + $this->assertEmpty($form->getPropertyHint('noExist')); + } + + public function testGetAttributeLabel(): void + { + $form = new LoginForm(); + + $this->assertSame('Login:', $form->getPropertyLabel('login')); + $this->assertSame('Testme', $form->getPropertyLabel('testme')); + } + + public function testGetAttributesLabels(): void + { + $form = new LoginForm(); + + $expected = [ + 'login' => 'Login:', + 'password' => 'Password:', + 'rememberMe' => 'remember Me:', + ]; + + $this->assertSame($expected, $form->getPropertyLabels()); + } + + public function testNestedPropertyOnNull(): void + { + $form = new FormWithNestedProperty(); + + $this->assertFalse($form->hasProperty('id.profile')); + $this->assertNull($form->getPropertyValue('id.profile')); + } + + public function testNestedPropertyOnArray(): void + { + $form = new FormWithNestedProperty(); + + $this->assertFalse($form->hasProperty('meta.profile')); + $this->assertNull($form->getPropertyValue('meta.profile')); + } + + public function testNestedPropertyOnString(): void + { + $form = new FormWithNestedProperty(); + + $this->assertFalse($form->hasProperty('key.profile')); + + $this->expectException(PropertyNotSupportNestedValuesException::class); + $this->expectExceptionMessage( + 'Property "' . FormWithNestedProperty::class . '::key" not support nested values.' + ); + $form->getPropertyValue('key.profile'); + } + + public function testNestedPropertyOnObject(): void + { + $form = new FormWithNestedProperty(); + + $this->assertFalse($form->hasProperty('coordinates.profile')); + + $this->expectException(UndefinedObjectPropertyException::class); + $this->expectExceptionMessage( + 'Undefined object property: "' . FormWithNestedProperty::class . '::coordinates::profile".' + ); + $form->getPropertyValue('coordinates.profile'); + } + + public function testGetNestedAttributeHint(): void + { + $form = new FormWithNestedProperty(); + + $this->assertSame('Write your id or email.', $form->getPropertyHint('user.login')); + } + + public function testGetNestedAttributeLabel(): void + { + $form = new FormWithNestedProperty(); + + $this->assertSame('Login:', $form->getPropertyLabel('user.login')); + } + + public function testGetNestedAttributePlaceHolder(): void + { + $form = new FormWithNestedProperty(); + + $this->assertSame('Type Username or Email.', $form->getPropertyPlaceholder('user.login')); + } + + public function testGetAttributePlaceHolder(): void + { + $form = new LoginForm(); + + $this->assertSame('Type Username or Email.', $form->getPropertyPlaceholder('login')); + $this->assertSame('Type Password.', $form->getPropertyPlaceholder('password')); + $this->assertEmpty($form->getPropertyPlaceholder('noExist')); + } + + public function testGetAttributeValue(): void + { + $form = new LoginForm(); + + $form->login('admin'); + $this->assertSame('admin', $form->getPropertyValue('login')); + + $form->password('123456'); + $this->assertSame('123456', $form->getPropertyValue('password')); + + $form->rememberMe(true); + $this->assertSame(true, $form->getPropertyValue('rememberMe')); + } + + public function testGetAttributeValueException(): void + { + $form = new LoginForm(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Undefined object property: "Yiisoft\FormModel\Tests\Support\Form\LoginForm::noExist".' + ); + $form->getPropertyValue('noExist'); + } + + public function testGetAttributeValueWithNestedAttribute(): void + { + $form = new FormWithNestedProperty(); + + $form->setUserLogin('admin'); + $this->assertSame('admin', $form->getPropertyValue('user.login')); + } + + public function testHasAttribute(): void + { + $form = new LoginForm(); + + $this->assertTrue($form->hasProperty('login')); + $this->assertTrue($form->hasProperty('password')); + $this->assertTrue($form->hasProperty('rememberMe')); + $this->assertFalse($form->hasProperty('noExist')); + $this->assertFalse($form->hasProperty('extraField')); + } + + public function testHasNestedAttribute(): void + { + $form = new FormWithNestedProperty(); + + $this->assertTrue($form->hasProperty('user.login')); + $this->assertTrue($form->hasProperty('user.password')); + $this->assertTrue($form->hasProperty('user.rememberMe')); + $this->assertFalse($form->hasProperty('noexist')); + } + + public function testHasNestedAttributeException(): void + { + $form = new FormWithNestedProperty(); + + $this->assertFalse($form->hasProperty('user.noexist')); + } + + public function testLoad(): void + { + $form = new LoginForm(); + + $this->assertNull($form->getLogin()); + $this->assertNull($form->getPassword()); + $this->assertFalse($form->getRememberMe()); + + $data = [ + 'LoginForm' => [ + 'login' => 'admin', + 'password' => '123456', + 'rememberMe' => true, + 'noExist' => 'noExist', + ], + ]; + + $this->assertTrue(TestHelper::createFormHydrator()->populate($form, $data)); + + $this->assertSame('admin', $form->getLogin()); + $this->assertSame('123456', $form->getPassword()); + $this->assertSame(true, $form->getRememberMe()); + } + + public function testLoadFailedForm(): void + { + $form1 = new LoginForm(); + $form2 = new class () extends FormModel { + }; + + $data1 = [ + 'LoginForm2' => [ + 'login' => 'admin', + 'password' => '123456', + 'rememberMe' => true, + 'noExist' => 'noExist', + ], + ]; + $data2 = []; + + $hydrator = TestHelper::createFormHydrator(); + + $this->assertFalse($hydrator->populate($form1, $data1)); + $this->assertFalse($hydrator->populate($form1, $data2)); + + $this->assertTrue($hydrator->populate($form2, $data1)); + $this->assertTrue($hydrator->populate($form2, $data2)); + } + + public function testLoadWithEmptyScope(): void + { + $form = new class () extends FormModel { + private int $int = 1; + private string $string = 'string'; + private float $float = 3.14; + private bool $bool = true; + }; + TestHelper::createFormHydrator()->populate( + $form, + [ + 'int' => '2', + 'float' => '3.15', + 'bool' => '0', + 'string' => 555, + ], + scope: '', + ); + + $this->assertSame(2, $form->getPropertyValue('int')); + $this->assertSame(3.15, $form->getPropertyValue('float')); + $this->assertSame(false, $form->getPropertyValue('bool')); + $this->assertSame('555', $form->getPropertyValue('string')); + } + + public function testLoadWithNestedProperty(): void + { + $form = new FormWithNestedProperty(); + + $data = [ + 'FormWithNestedProperty' => [ + 'user.login' => 'admin', + ], + ]; + + $this->assertTrue(TestHelper::createFormHydrator()->populate($form, $data)); + $this->assertSame('admin', $form->getUserLogin()); + } + + public function testLoadObjectData(): void + { + $form = new LoginForm(); + + $result = TestHelper::createFormHydrator()->populate($form, new stdClass()); + + $this->assertFalse($result); + } + + public function testLoadNullData(): void + { + $form = new LoginForm(); + + $result = TestHelper::createFormHydrator()->populate($form, null); + + $this->assertFalse($result); + } + + public function testLoadNonArrayScopedData(): void + { + $form = new LoginForm(); + + $result = TestHelper::createFormHydrator()->populate($form, ['LoginForm' => null]); + + $this->assertFalse($result); + } + + public function testNonNamespacedFormName(): void + { + $form = new \NonNamespacedForm(); + $this->assertSame('NonNamespacedForm', $form->getFormName()); + } + + public function testPublicAttributes(): void + { + $form = new class () extends FormModel { + public int $int = 1; + }; + + // check row data value. + TestHelper::createFormHydrator()->populate($form, ['int' => '2']); + $this->assertSame(2, $form->getPropertyValue('int')); + } + + public function testFormWithNestedStructures(): void + { + $form = new FormWithNestedStructures(); + + TestHelper::createFormHydrator()->populate($form, [ + 'FormWithNestedStructures' => [ + 'array' => ['a' => 'b', 'nested' => ['c' => 'd']], + 'coordinates' => ['latitude' => '12.24', 'longitude' => '56.78'], + ], + ]); + + $this->assertSame(['a' => 'b', 'nested' => ['c' => 'd']], $form->getPropertyValue('array')); + + $coordinates = $form->getPropertyValue('coordinates'); + $this->assertInstanceOf(Coordinates::class, $coordinates); + $this->assertSame('12.24', $coordinates->getLatitude()); + $this->assertSame('56.78', $coordinates->getLongitude()); + } +} diff --git a/tests/Support/Dto/Coordinates.php b/tests/Support/Dto/Coordinates.php new file mode 100644 index 0000000..f3fbe4f --- /dev/null +++ b/tests/Support/Dto/Coordinates.php @@ -0,0 +1,24 @@ +latitude; + } + + public function getLongitude(): string + { + return $this->longitude; + } +} diff --git a/tests/Support/Form/CustomFormNameForm.php b/tests/Support/Form/CustomFormNameForm.php new file mode 100644 index 0000000..85ff12f --- /dev/null +++ b/tests/Support/Form/CustomFormNameForm.php @@ -0,0 +1,15 @@ +user = new LoginForm(); + $this->coordinates = new Coordinates(); + } + + public function getPropertyLabels(): array + { + return [ + 'id' => 'ID', + ]; + } + + public function getPropertyHints(): array + { + return [ + 'id' => 'Readonly ID', + ]; + } + + public function getPropertyPlaceholders(): array + { + return [ + 'id' => 'Type ID.', + ]; + } + + public function getRules(): array + { + return [ + 'id' => [new Required()], + 'user' => new Nested(), + ]; + } + + public function setUserLogin(string $login): void + { + $this->user->login($login); + } + + public function getUserLogin(): ?string + { + return $this->user->getLogin(); + } +} diff --git a/tests/Support/Form/FormWithNestedStructures.php b/tests/Support/Form/FormWithNestedStructures.php new file mode 100644 index 0000000..1837746 --- /dev/null +++ b/tests/Support/Form/FormWithNestedStructures.php @@ -0,0 +1,24 @@ +array; + } + + public function getCoordinates(): ?Coordinates + { + return $this->coordinates; + } +} diff --git a/tests/Support/Form/LoginForm.php b/tests/Support/Form/LoginForm.php new file mode 100644 index 0000000..af85239 --- /dev/null +++ b/tests/Support/Form/LoginForm.php @@ -0,0 +1,99 @@ +login; + } + + public function getPassword(): ?string + { + return $this->password; + } + + public function getRememberMe(): bool + { + return $this->rememberMe; + } + + public function login(string $value): void + { + $this->login = $value; + } + + public function password(string $value): void + { + $this->password = $value; + } + + public function rememberMe(bool $value): void + { + $this->rememberMe = $value; + } + + public function getPropertyHints(): array + { + return [ + 'login' => 'Write your id or email.', + 'password' => 'Write your password.', + ]; + } + + public function getPropertyLabels(): array + { + return [ + 'login' => 'Login:', + 'password' => 'Password:', + 'rememberMe' => 'remember Me:', + ]; + } + + public function getPropertyPlaceholders(): array + { + return [ + 'login' => 'Type Username or Email.', + 'password' => 'Type Password.', + ]; + } + + public function getRules(): array + { + return [ + 'login' => $this->loginRules(), + 'password' => $this->passwordRules(), + ]; + } + + private function loginRules(): array + { + return [ + new Required(), + new Length(min: 4, max: 40, lessThanMinMessage: 'Is too short.', greaterThanMaxMessage: 'Is too long.'), + new Email(), + ]; + } + + private function passwordRules(): array + { + return [ + new Required(), + new Length(min: 8, lessThanMinMessage: 'Is too short.'), + ]; + } +} diff --git a/tests/Support/Form/NestedForm.php b/tests/Support/Form/NestedForm.php new file mode 100644 index 0000000..8a22a2c --- /dev/null +++ b/tests/Support/Form/NestedForm.php @@ -0,0 +1,21 @@ +object = new class () { + public string $name = 'Bo'; + public array $numbers = [7, 42]; + }; + } +} diff --git a/tests/Support/Form/NonNamespacedForm.php b/tests/Support/Form/NonNamespacedForm.php new file mode 100644 index 0000000..12b8a01 --- /dev/null +++ b/tests/Support/Form/NonNamespacedForm.php @@ -0,0 +1,9 @@ + [new Required(), new Length(min: 4)], + ]; + } + + public function getPropertyLabels(): array + { + return [ + 'desc' => 'Description', + 'site' => 'Your site', + 'number' => 'Phone', + 'count' => 'Select count', + 'color' => 'Select color', + 'age' => 'Your age', + 'mainEmail' => 'Main email', + 'partyDate' => 'Date of party', + 'xDate' => 'Date X', + 'color2' => 'Select one or more colors', + 'blue' => 'Blue color', + ]; + } + + public function getPropertyHints(): array + { + return [ + 'name' => 'Input your full name.', + 'site' => 'Enter your site URL.', + 'number' => 'Enter your phone.', + 'color' => 'Color of box.', + 'oldPassword' => 'Enter your old password.', + 'age' => 'Full years.', + 'mainEmail' => 'Email for notifications.', + ]; + } + + public static function validated(): self + { + $form = new self(); + (new Validator())->validate($form); + return $form; + } +} diff --git a/tests/Support/StubInputField.php b/tests/Support/StubInputField.php new file mode 100644 index 0000000..6f4d67e --- /dev/null +++ b/tests/Support/StubInputField.php @@ -0,0 +1,20 @@ +getInputData()->getName(), + (string) $this->getInputData()->getValue(), + $this->getInputAttributes() + )->render(); + } +} diff --git a/tests/Support/TestHelper.php b/tests/Support/TestHelper.php new file mode 100644 index 0000000..4ac1b50 --- /dev/null +++ b/tests/Support/TestHelper.php @@ -0,0 +1,29 @@ + Date: Fri, 1 Dec 2023 14:26:12 +0000 Subject: [PATCH 3/5] Apply Rector changes (CI) --- src/Exception/PropertyNotSupportNestedValuesException.php | 2 +- src/FormHydrator.php | 2 +- src/FormModelInputData.php | 4 ++-- tests/Support/Dto/Coordinates.php | 4 ++-- tests/Support/Form/FormWithNestedProperty.php | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Exception/PropertyNotSupportNestedValuesException.php b/src/Exception/PropertyNotSupportNestedValuesException.php index 8287fe5..e7a35b7 100644 --- a/src/Exception/PropertyNotSupportNestedValuesException.php +++ b/src/Exception/PropertyNotSupportNestedValuesException.php @@ -6,7 +6,7 @@ final class PropertyNotSupportNestedValuesException extends ValueNotFoundException { - public function __construct(string $property, private mixed $value) + public function __construct(string $property, private readonly mixed $value) { parent::__construct('Property "' . $property . '" not support nested values.'); } diff --git a/src/FormHydrator.php b/src/FormHydrator.php index 9e89629..73f66ba 100644 --- a/src/FormHydrator.php +++ b/src/FormHydrator.php @@ -15,7 +15,7 @@ final class FormHydrator { public function __construct( - private HydratorInterface $hydrator, + private readonly HydratorInterface $hydrator, ) { } diff --git a/src/FormModelInputData.php b/src/FormModelInputData.php index e75ebef..9c6e4cf 100644 --- a/src/FormModelInputData.php +++ b/src/FormModelInputData.php @@ -23,8 +23,8 @@ final class FormModelInputData implements InputDataInterface private ?iterable $validationRules = null; public function __construct( - private FormModelInterface $model, - private string $property, + private readonly FormModelInterface $model, + private readonly string $property, ) { } diff --git a/tests/Support/Dto/Coordinates.php b/tests/Support/Dto/Coordinates.php index f3fbe4f..a346c2e 100644 --- a/tests/Support/Dto/Coordinates.php +++ b/tests/Support/Dto/Coordinates.php @@ -7,8 +7,8 @@ final class Coordinates { public function __construct( - private string $latitude = '', - private string $longitude = '', + private readonly string $latitude = '', + private readonly string $longitude = '', ) { } diff --git a/tests/Support/Form/FormWithNestedProperty.php b/tests/Support/Form/FormWithNestedProperty.php index 217ec6f..7bb1335 100644 --- a/tests/Support/Form/FormWithNestedProperty.php +++ b/tests/Support/Form/FormWithNestedProperty.php @@ -15,8 +15,8 @@ final class FormWithNestedProperty extends FormModel implements RulesProviderInt private ?int $id = null; private string $key = ''; private array $meta = []; - private Coordinates $coordinates; - private LoginForm $user; + private readonly Coordinates $coordinates; + private readonly LoginForm $user; public function __construct() { From b7cca52e5e6ae7299cb50522aa73508ac31718ab Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 1 Dec 2023 17:28:26 +0300 Subject: [PATCH 4/5] fix --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index b871423..ef2833e 100644 --- a/composer.json +++ b/composer.json @@ -29,6 +29,7 @@ "prefer-stable": true, "require": { "php": "^8.1", + "ext-mbstring": "*", "yiisoft/form": "^1.0@dev", "yiisoft/html": "^3.3", "yiisoft/hydrator": "dev-master", From 5768841d68211de66a57b90a471bc85e2812f90f Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 1 Dec 2023 17:33:18 +0300 Subject: [PATCH 5/5] improve --- rector.php | 2 ++ src/Exception/PropertyNotSupportNestedValuesException.php | 6 ++++-- tests/Support/Form/FormWithNestedProperty.php | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/rector.php b/rector.php index 90bf968..90fea6b 100644 --- a/rector.php +++ b/rector.php @@ -6,6 +6,7 @@ use Rector\Config\RectorConfig; use Rector\Php73\Rector\FuncCall\JsonThrowOnErrorRector; use Rector\Php74\Rector\Closure\ClosureToArrowFunctionRector; +use Rector\Php81\Rector\Property\ReadOnlyPropertyRector; use Rector\Set\ValueObject\LevelSetList; return static function (RectorConfig $rectorConfig): void { @@ -25,5 +26,6 @@ $rectorConfig->skip([ ClosureToArrowFunctionRector::class, JsonThrowOnErrorRector::class, + ReadOnlyPropertyRector::class, ]); }; diff --git a/src/Exception/PropertyNotSupportNestedValuesException.php b/src/Exception/PropertyNotSupportNestedValuesException.php index e7a35b7..17d194d 100644 --- a/src/Exception/PropertyNotSupportNestedValuesException.php +++ b/src/Exception/PropertyNotSupportNestedValuesException.php @@ -6,8 +6,10 @@ final class PropertyNotSupportNestedValuesException extends ValueNotFoundException { - public function __construct(string $property, private readonly mixed $value) - { + public function __construct( + string $property, + private readonly mixed $value, + ) { parent::__construct('Property "' . $property . '" not support nested values.'); } diff --git a/tests/Support/Form/FormWithNestedProperty.php b/tests/Support/Form/FormWithNestedProperty.php index 7bb1335..217ec6f 100644 --- a/tests/Support/Form/FormWithNestedProperty.php +++ b/tests/Support/Form/FormWithNestedProperty.php @@ -15,8 +15,8 @@ final class FormWithNestedProperty extends FormModel implements RulesProviderInt private ?int $id = null; private string $key = ''; private array $meta = []; - private readonly Coordinates $coordinates; - private readonly LoginForm $user; + private Coordinates $coordinates; + private LoginForm $user; public function __construct() {