diff --git a/CHANGELOG.md b/CHANGELOG.md index 67dbe43..1ceb45e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,8 @@ ## 1.0.2 under development -- no changes in this release. +- Bug #68: Populating out forms with a hydrator when displaying fields `Field::text($form->nestedForm, 'text')` of + nested forms (@DAGpro) ## 1.0.1 September 13, 2024 diff --git a/composer.json b/composer.json index a491574..0f6bbd2 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,7 @@ "psr/http-message": "^1.0|^2.0", "yiisoft/form": "^1.0", "yiisoft/html": "^3.3", - "yiisoft/hydrator": "^1.3", + "yiisoft/hydrator": "dev-master", "yiisoft/strings": "^2.3", "yiisoft/validator": "^2.1" }, diff --git a/docs/guide/en/displaying-fields.md b/docs/guide/en/displaying-fields.md index 31df1c1..bb69b2d 100644 --- a/docs/guide/en/displaying-fields.md +++ b/docs/guide/en/displaying-fields.md @@ -9,6 +9,13 @@ use Yiisoft\FormModel\FormModel; /** @var FormModel $formModel */ $field = Field::text($formModel, 'login'); + +/** Display fields nested forms */ +$nestedField = Field::text($formModel->nestedForm, 'text'); +/** or dot-notation */ +$nestedField = Field::text($formModel, 'nestedForm.text'); +/** or array notation */ +$nestedField = Field::text($formModel, 'nestedForm[text]'); ``` or factory (`\Yiisoft\FormModel\FieldFactory`): @@ -20,6 +27,13 @@ use Yiisoft\FormModel\FormModel; /** @var FormModel $formModel */ $factory = new FieldFactory(); $factory->text($formModel, 'login'); + +/** Display nested form field */ +$nestedField = $factory->text($formModel->nestedForm, 'text'); +/** or dot-notation */ +$nestedField = $factory->text($formModel, 'nestedForm.text'); +/** or array notation */ +$nestedField = $factory->text($formModel, 'nestedForm[text]'); ``` If you want to customize other properties, such as label, hint, etc., use dedicated methods: diff --git a/src/FormHydrator.php b/src/FormHydrator.php index 9a5a222..478bf4a 100644 --- a/src/FormHydrator.php +++ b/src/FormHydrator.php @@ -7,19 +7,22 @@ use Psr\Http\Message\ServerRequestInterface; use Yiisoft\Hydrator\ArrayData; use Yiisoft\Hydrator\HydratorInterface; +use Yiisoft\Hydrator\ObjectMap; use Yiisoft\Validator\Helper\ObjectParser; use Yiisoft\Validator\Result; +use Yiisoft\Validator\Rule\Nested; use Yiisoft\Validator\RulesProviderInterface; use Yiisoft\Validator\ValidatorInterface; use function array_merge; use function is_array; -use function is_string; /** * Form hydrator fills model with the data and optionally checks the data validity. * * @psalm-import-type MapType from ArrayData + * @psalm-import-type RawRulesMap from ValidatorInterface + * @psalm-import-type NormalizedNestedRulesArray from Nested */ final class FormHydrator { @@ -68,7 +71,9 @@ public function populate( if (!isset($data[$scope]) || !is_array($data[$scope])) { return false; } - $hydrateData = $data[$scope]; + + $filteredData = $this->filterDataNestedForms($model, $data); + $hydrateData = array_merge_recursive((array)$data[$model->getFormName()], $filteredData); } $this->hydrator->hydrate( @@ -186,6 +191,43 @@ public function populateFromPostAndValidate( return $this->populateAndValidate($model, $request->getParsedBody(), $map, $strict, $scope); } + private function filterDataNestedForms(FormModelInterface $formModel, array &$data): array + { + $reflection = new \ReflectionClass($formModel); + $properties = $reflection->getProperties( + \ReflectionProperty::IS_PUBLIC | + \ReflectionProperty::IS_PROTECTED | + \ReflectionProperty::IS_PRIVATE, + ); + + $filteredData = []; + foreach ($properties as $property) { + if ($property->isStatic()) { + continue; + } + + if ($property->isReadOnly()) { + continue; + } + + $propertyValue = $property->getValue($formModel); + if ($propertyValue instanceof FormModelInterface) { + $dataNestedForms = $this->filterDataNestedForms($propertyValue, $data); + if (isset($data[$propertyValue->getFormName()])) { + $filteredData[$property->getName()] = array_merge( + (array)$data[$propertyValue->getFormName()], + $dataNestedForms, + ); + unset($data[$propertyValue->getFormName()]); + } elseif (!empty($dataNestedForms)) { + $filteredData[$property->getName()] = $dataNestedForms; + } + } + } + + return $filteredData; + } + /** * Get a map of object property names mapped to keys in the data array. * @@ -209,46 +251,231 @@ private function createMap(FormModelInterface $model, ?array $userMap, ?bool $st return $userMap; } - $properties = $this->getPropertiesWithRules($model); - $generatedMap = array_combine($properties, $properties); + $map = $this->getMapFromRules($model); if ($userMap === null) { - return $generatedMap; + return $map; } - return array_merge($generatedMap, $userMap); + return $this->mapMerge($userMap, $map); } /** * Extract object property names mapped to keys in the data array based on model validation rules. * * @return array Object property names mapped to keys in the data array. - * @psalm-return array + * @psalm-return MapType */ - private function getPropertiesWithRules(FormModelInterface $model): array + private function getMapFromRules(FormModelInterface $model): array { $parser = new ObjectParser($model, skipStaticProperties: true); - $properties = $this->extractStringKeys($parser->getRules()); + $mapFromAttributes = $this->getMapFromRulesAttributes($parser->getRules()); + + if ($model instanceof RulesProviderInterface) { + $mapFromProvider = $this->getMapFromRulesProvider($model); + return $this->mapMerge($mapFromAttributes, $mapFromProvider); + } - return $model instanceof RulesProviderInterface - ? array_merge($properties, $this->extractStringKeys($model->getRules())) - : $properties; + return $mapFromAttributes; } /** - * Get only string keys from an array. - * - * @return array String keys. - * @psalm-return list + * @psalm-return MapType */ - private function extractStringKeys(iterable $array): array + private function getMapFromRulesAttributes(array $array): array { $result = []; foreach ($array as $key => $_value) { - if (is_string($key)) { - $result[] = $key; + if (is_int($key)) { + continue; + } + $result[$key] = $key; + foreach ($_value as $nestedRule) { + if ($nestedRule instanceof Nested) { + $nestedMap = $this->getNestedMap($nestedRule, [$key]); + if ($nestedMap !== null) { + $result[$key] = new ObjectMap($nestedMap); + } + } + } + } + + return $result; + } + + /** + * @param array $parentKeys + * @psalm-return MapType|null + */ + private function getNestedMap(Nested $rule, array $parentKeys): ?array + { + /** + * @psalm-param $rules NormalizedNestedRulesArray + */ + $rules = $rule->getRules(); + if ($rules === null) { + return null; + } + + $map = []; + foreach ($rules as $key => $nestedRules) { + if (is_int($key)) { + continue; + } + + if (is_array($nestedRules)) { + $keyPath = null; + if (str_contains($key, '.')) { + $keyPath = explode('.', $key); + $key = reset($keyPath); + $dotKeyMap = $this->dotKeyInMap($keyPath, $parentKeys, null); + $map[$key] = $dotKeyMap[$key]; + } else { + $map[$key] = [...$parentKeys, $key]; + } + foreach ($nestedRules as $item) { + if ($item instanceof Nested) { + $pathKeys = $keyPath ?? [$key]; + $nestedMap = $this->getNestedMap($item, [...$parentKeys, ...$pathKeys]); + if (isset($keyPath)) { + $dotKeyMap = $this->dotKeyInMap($keyPath, $parentKeys, $nestedMap); + $map[$key] = $dotKeyMap[$key]; + } elseif ($nestedMap !== null) { + $map[$key] = new ObjectMap($nestedMap); + } + } + } + } + } + + return $map; + } + + /** + * @psalm-param array $keyPath + * @psalm-param array $parentsKeys + * @psalm-param MapType|null $nestedMap + * @psalm-return MapType + */ + private function dotKeyInMap(array $keyPath, array $parentsKeys, ?array $nestedMap): array + { + $dotMap = []; + $reverseKeyPath = array_reverse($keyPath); + foreach ($reverseKeyPath as $key) { + if ($dotMap !== []) { + $dotMap = [$key => new ObjectMap($dotMap)]; + } else { + $dotMap = [ + $key => is_array($nestedMap) ? new ObjectMap($nestedMap) : [...$parentsKeys, ...$keyPath], + ]; } } + + return $dotMap; + } + + /** + * @param array $path + * @psalm-return MapType + */ + private function getMapFromRulesProvider( + RulesProviderInterface $formModel, + array $path = [], + ): array { + $mapModel = []; + /** + * @psalm-param $rules RawRulesMap + */ + $rules = $formModel->getRules(); + foreach ($rules as $key => $rule) { + if (is_int($key)) { + continue; + } + $mapModel[$key] = [...$path, $key]; + if ($rule instanceof Nested) { + $nestedMap = $this->getNestedMap($rule, [...$path, $key]); + if ($nestedMap !== null) { + $mapModel[$key] = new ObjectMap($nestedMap); + } + } elseif (is_array($rule)) { + foreach ($rule as $ruleKey => $item) { + if ($item instanceof Nested) { + $nestedMap = $this->getNestedMap($item, [...$path, $key]); + if ($nestedMap !== null) { + $mapModel[$key] = new ObjectMap($nestedMap); + } + } + } + } + } + + $mapNestedModels = $this->getMapNestedModels($formModel, $path); + + return $this->mapMerge($mapModel, $mapNestedModels); + } + + /** + * @param array $path + * @psalm-return MapType + */ + private function getMapNestedModels(RulesProviderInterface $formModel, array $path): array + { + $reflection = new \ReflectionClass($formModel); + $properties = $reflection->getProperties( + \ReflectionProperty::IS_PUBLIC | + \ReflectionProperty::IS_PROTECTED | + \ReflectionProperty::IS_PRIVATE, + ); + + $propertiesNestedModels = []; + foreach ($properties as $property) { + if ($property->isStatic()) { + continue; + } + + if ($property->isReadOnly()) { + continue; + } + + $propertyValue = $property->getValue($formModel); + if ($propertyValue instanceof RulesProviderInterface) { + $propertiesNestedModels[$property->getName()] = new ObjectMap( + $this->getMapFromRulesProvider( + $propertyValue, + [...$path, $property->getName()], + ), + ); + } + } + + return $propertiesNestedModels; + } + + /** + * @psalm-param MapType $map + * @psalm-param MapType $secondMap + * @psalm-return MapType + */ + private function mapMerge(array $map, array $secondMap): array + { + $result = []; + foreach ($map as $key => $value) { + if (isset($secondMap[$key]) && $value instanceof ObjectMap && $secondMap[$key] instanceof ObjectMap) { + $mergedMap = $this->mapMerge($value->map, $secondMap[$key]->map); + $result[$key] = new ObjectMap($mergedMap); + } elseif (isset($secondMap[$key]) && $secondMap[$key] instanceof ObjectMap) { + $result[$key] = $secondMap[$key]; + } else { + $result[$key] = $value; + } + } + + foreach ($secondMap as $key => $value) { + if (!isset($result[$key])) { + $result[$key] = $value; + } + } + return $result; } } diff --git a/tests/FormHydratorTest.php b/tests/FormHydratorTest.php index c357589..fbac620 100644 --- a/tests/FormHydratorTest.php +++ b/tests/FormHydratorTest.php @@ -10,6 +10,8 @@ use Psr\Http\Message\ServerRequestInterface; use Yiisoft\FormModel\FormModel; use Yiisoft\FormModel\Tests\Support\Form\CarForm; +use Yiisoft\FormModel\Tests\Support\Form\FormsTestCreateMap\MainMapForm; +use Yiisoft\FormModel\Tests\Support\Form\PopulateNestedForm\MainForm; use Yiisoft\FormModel\Tests\Support\TestHelper; use Yiisoft\Validator\Result; use Yiisoft\Validator\Rule\Integer; @@ -146,6 +148,188 @@ public function testPopulateFromPostAndValidate(bool $expected, ServerRequestInt $this->assertSame($expected, $result); } + public static function dataNestedPopulate(): array + { + $factory = new ServerRequestFactory(); + $expected = [ + 'value' => 'mainProperty', + 'firstForm' => 'firstTest', + 'secondForm' => 3, + 'secondForm.string' => 'secondFormString', + ]; + return [ + 'nested-array-data' => [ + $expected, + $factory->createServerRequest('POST', '/')->withParsedBody([ + 'MainForm' => [ + 'value' => $expected['value'], + 'firstForm' => [ + 'value' => $expected['firstForm'], + 'secondForm' => [ + 'value' => $expected['secondForm'], + 'string' => $expected['secondForm.string'], + ], + ], + ], + ]), + ], + 'dot-notation-data' => [ + $expected, + $factory->createServerRequest('POST', '/')->withParsedBody([ + 'MainForm' => [ + 'value' => $expected['value'], + 'firstForm.value' => $expected['firstForm'], + 'firstForm.secondForm.value' => $expected['secondForm'], + 'firstForm.secondForm.string' => $expected['secondForm.string'], + ], + ]), + ], + 'one-level-array-data' => [ + $expected, + $factory->createServerRequest('POST', '/')->withParsedBody([ + 'MainForm' => ['value' => $expected['value']], + 'FirstNestedForm' => ['value' => $expected['firstForm']], + 'SecondNestedForm' => ['value' => $expected['secondForm'], 'string' => $expected['secondForm.string']], + ]), + ], + 'mixed-one-level-and-dot-notation-data' => [ + $expected, + $factory->createServerRequest('POST', '/')->withParsedBody([ + 'MainForm' => [ + 'value' => $expected['value'], + 'firstForm.secondForm.string' => $expected['secondForm.string'], + ], + 'FirstNestedForm' => [ + 'value' => $expected['firstForm'], + 'secondForm.value' => $expected['secondForm'], + ], + ]), + ], + 'mixed-one-level-and-nested-array-data' => [ + $expected, + $factory->createServerRequest('POST', '/')->withParsedBody([ + 'MainForm' => [ + 'value' => $expected['value'], + 'firstForm' => [ + 'value' => $expected['firstForm'], + 'secondForm' => [ + 'string' => $expected['secondForm.string'], + ], + ], + ], + 'SecondNestedForm' => [ + 'value' => $expected['secondForm'], + ], + ]), + ], + ]; + } + + #[DataProvider('dataNestedPopulate')] + public function testPopulateNestedFormFromPost(array $expected, ServerRequestInterface $request): void + { + $form = new MainForm(); + + TestHelper::createFormHydrator()->populateFromPost($form, $request); + $this->assertSame($expected['value'], $form->value); + $this->assertSame($expected['firstForm'], $form->firstNestedForm()->value); + $this->assertSame($expected['secondForm'], $form->firstNestedForm()->secondForm()->value); + $this->assertSame($expected['secondForm.string'], $form->firstNestedForm()->secondForm()->string); + } + + public static function dataNestedFormsCreateMap(): array + { + return [ + 'array-data' => [ + [ + 'MainMapForm' => [ + 'age' => 38, + 'job' => 'developer', + 'firstForm' => [ + 'value' => 'value', + 'secondForm' => [ + 'post' => 'post', + 'author' => 'author', + ], + ], + 'blog' => [ + 'title' => 'title', + 'description' => 'description', + 'post' => [ + 'title' => 'title', + 'content' => 'content', + 'author' => [ + 'name' => 'author', + 'email' => 'author@yiisoft.com', + 'bio' => 'My bio', + ], + ], + ], + 'shop' => [ + 'name' => 'shop', + 'address' => 'address', + 'phone' => 'phone', + 'storage' => [ + 'name' => 'storage', + 'address' => 'address', + 'phone' => 'phone', + ], + ], + ], + ], + ], + 'dot-notation-data' => [ + [ + 'MainMapForm' => [ + 'age' => 38, + 'job' => 'developer', + 'firstForm.value' => 'value', + 'firstForm.secondForm.post' => 'post', + 'firstForm.secondForm.author' => 'author', + 'blog.title' => 'title', + 'blog.description' => 'description', + 'blog.post.title' => 'title', + 'blog.post.content' => 'content', + 'blog.post.author.name' => 'author', + 'blog.post.author.email' => 'author@yiisoft.com', + 'blog.post.author.bio' => 'My bio', + 'shop.name' => 'shop', + 'shop.address' => 'address', + 'shop.phone' => 'phone', + 'shop.storage.name' => 'storage', + 'shop.storage.address' => 'address', + 'shop.storage.phone' => 'phone', + ], + ], + ], + ]; + } + + #[DataProvider('dataNestedFormsCreateMap')] + public function testPopulateNestedFormsWithCreateMap(array $data): void + { + $form = new MainMapForm(); + + TestHelper::createFormHydrator()->populate($form, $data); + + $this->assertSame(38, $form->age); + $this->assertSame('developer', $form->job); + $this->assertSame('title', $form->blog->post->title); + $this->assertSame('content', $form->blog->post->content); + $this->assertSame('author', $form->blog->post->author->name); + $this->assertSame('author@yiisoft.com', $form->blog->post->author->email); + $this->assertSame('My bio', $form->blog->post->author->bio); + $this->assertSame('shop', $form->shop->name); + $this->assertSame('address', $form->shop->address); + $this->assertSame('phone', $form->shop->phone); + $this->assertSame('storage', $form->shop->storage->name); + $this->assertSame('address', $form->shop->storage->address); + $this->assertSame('phone', $form->shop->storage->phone); + $this->assertSame('value', $form->firstForm->value); + $this->assertSame('post', $form->firstForm->secondForm->post); + $this->assertSame('author', $form->firstForm->secondForm->author); + } + public function testPopulateFormWithRulesFromAttributesAndMethod(): void { $form = new class () extends FormModel implements RulesProviderInterface { diff --git a/tests/Support/Form/FormsTestCreateMap/Author.php b/tests/Support/Form/FormsTestCreateMap/Author.php new file mode 100644 index 0000000..004112b --- /dev/null +++ b/tests/Support/Form/FormsTestCreateMap/Author.php @@ -0,0 +1,29 @@ + new Length(min: 3), + 'email' => new Email(), + 'bio' => new Length(min: 3), + ]; + } +} diff --git a/tests/Support/Form/FormsTestCreateMap/Blog.php b/tests/Support/Form/FormsTestCreateMap/Blog.php new file mode 100644 index 0000000..24f3a2e --- /dev/null +++ b/tests/Support/Form/FormsTestCreateMap/Blog.php @@ -0,0 +1,32 @@ + new Length(min: 3), + 'description' => new Length(min: 3), + 'post' => new Nested([ + 'title' => new Required(), + ]), + ]; + } +} diff --git a/tests/Support/Form/FormsTestCreateMap/FirstNestedForm.php b/tests/Support/Form/FormsTestCreateMap/FirstNestedForm.php new file mode 100644 index 0000000..68c3905 --- /dev/null +++ b/tests/Support/Form/FormsTestCreateMap/FirstNestedForm.php @@ -0,0 +1,20 @@ + new Integer(min: 5), + 'job' => new Length(min: 2), + 'blog' => [ + new Nested([ + new FilledAtLeast(['post']), + 'post' => [ + 'author' => new Nested([ + 'name' => new Required(), + 'email' => new Email(), + ]), + ], + ]), + ], + 'shop' => new Nested([ + 'storage' => [ + new FilledAtLeast(['name', 'address']), + 'name' => new Required(), + ], + ]), + ]; + } +} diff --git a/tests/Support/Form/FormsTestCreateMap/Post.php b/tests/Support/Form/FormsTestCreateMap/Post.php new file mode 100644 index 0000000..fb464cf --- /dev/null +++ b/tests/Support/Form/FormsTestCreateMap/Post.php @@ -0,0 +1,29 @@ + new Length(min: 3), + 'content' => new Length(min: 3), + 'author' => new Nested(), + ]; + } +} diff --git a/tests/Support/Form/FormsTestCreateMap/SecondNestedForm.php b/tests/Support/Form/FormsTestCreateMap/SecondNestedForm.php new file mode 100644 index 0000000..7513a48 --- /dev/null +++ b/tests/Support/Form/FormsTestCreateMap/SecondNestedForm.php @@ -0,0 +1,16 @@ + new Length(min: 3), + 'address' => new Length(min: 3), + 'phone' => [new Length(min: 3)], + 'storage' => [ + new FilledAtLeast(['name', 'address']), + 'name' => new Required(), + 'address' => new Required(), + ], + ]; + } +} diff --git a/tests/Support/Form/FormsTestCreateMap/Storage.php b/tests/Support/Form/FormsTestCreateMap/Storage.php new file mode 100644 index 0000000..96d1062 --- /dev/null +++ b/tests/Support/Form/FormsTestCreateMap/Storage.php @@ -0,0 +1,28 @@ + new Length(min: 3), + 'address' => new Length(min: 3), + 'phone' => new Length(min: 3), + ]; + } +} diff --git a/tests/Support/Form/PopulateNestedForm/FirstNestedForm.php b/tests/Support/Form/PopulateNestedForm/FirstNestedForm.php new file mode 100644 index 0000000..82e67ec --- /dev/null +++ b/tests/Support/Form/PopulateNestedForm/FirstNestedForm.php @@ -0,0 +1,37 @@ +secondForm = new SecondNestedForm(); + } + + public function secondForm(): SecondNestedForm + { + return $this->secondForm; + } +} diff --git a/tests/Support/Form/PopulateNestedForm/MainForm.php b/tests/Support/Form/PopulateNestedForm/MainForm.php new file mode 100644 index 0000000..b40435a --- /dev/null +++ b/tests/Support/Form/PopulateNestedForm/MainForm.php @@ -0,0 +1,32 @@ +firstForm = new FirstNestedForm(); + } + + public function firstNestedForm(): FirstNestedForm + { + return $this->firstForm; + } +} diff --git a/tests/Support/Form/PopulateNestedForm/SecondNestedForm.php b/tests/Support/Form/PopulateNestedForm/SecondNestedForm.php new file mode 100644 index 0000000..b411cb2 --- /dev/null +++ b/tests/Support/Form/PopulateNestedForm/SecondNestedForm.php @@ -0,0 +1,31 @@ +