diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..a17b310
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,22 @@
+FROM php:8.5-cli-trixie
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ file \
+ git \
+ openssh-client \
+ && rm -rf /var/lib/apt/lists/*
+
+ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
+
+RUN set -eux; \
+ install-php-extensions \
+ @composer \
+ apcu \
+ opcache \
+ xdebug \
+ ;
+
+RUN useradd -m vscode
+
+USER vscode
+WORKDIR /workspace
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..0d7f751
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,39 @@
+{
+ "name": "A2LiX - AutoFormBundle",
+ "build": {
+ "dockerfile": "Dockerfile"
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "bmewburn.vscode-intelephense-client",
+ "xdebug.php-debug",
+ "SanderRonde.phpstan-vscode"
+ ],
+ "settings": {
+ "terminal.integrated.defaultProfile.linux": "bash",
+ "intelephense.telemetry.enabled": false,
+ "phpstan.checkValidity": true,
+ "workbench.colorCustomizations": {
+ "statusBar.background": "#ffa600d3",
+ "statusBar.noFolderBackground": "#ffa600d3",
+ "statusBar.debuggingBackground": "#ffa600d3",
+ "activityBar.activeBorder": "#ffa600d3",
+ "activityBarBadge.background": "#ffa600d3",
+ "badge.background": "#ffa600d3",
+ "focusBorder": "#ffa600d3",
+ "progressBar.background": "#ffa600d3",
+ "notificationCenter.border": "#ffa600d3",
+ "notificationsInfoIcon.foreground": "#ffa600d3",
+ "pickerGroup.border": "#ffa600d3",
+ "settings.modifiedItemIndicator": "#ffa600d3",
+ "panelTitle.activeBorder": "#ffa600d3",
+ "list.activeSelectionBackground": "#6e6e6e",
+ "list.inactiveSelectionBackground": "#6e6e6e",
+ "list.hoverBackground": "#6e6e6e",
+ },
+ }
+ }
+ },
+ "remoteUser": "vscode"
+}
diff --git a/.editorconfig b/.editorconfig
index 677e36e..40bd16e 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -7,3 +7,9 @@ indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
+
+[{compose.yaml,compose.*.yaml}]
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0060747..630f56e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,128 +1,142 @@
name: CI
-on: ["push", "pull_request"]
+on:
+ push:
+ pull_request:
-env:
- COMPOSER_ALLOW_SUPERUSER: '1'
- SYMFONY_DEPRECATIONS_HELPER: max[self]=0
+permissions:
+ contents: read
jobs:
- analyze:
- name: Analyze
- runs-on: ubuntu-latest
- container:
- image: php:8.3-alpine
- options: >-
- --tmpfs /tmp:exec
- --tmpfs /var/tmp:exec
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- - name: Install Composer
- run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
- - name: Get Composer Cache Directory
- id: composer-cache
- run: |
- echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- - uses: actions/cache@v3
- with:
- path: ${{ steps.composer-cache.outputs.dir }}
- key: ${{ runner.os }}-composer-8.3-highest-${{ hashFiles('**/composer.json') }}
- restore-keys: |
- ${{ runner.os }}-composer-8.3-highest
- - name: Validate Composer
- run: composer validate
- - name: Install highest dependencies with Composer
- run: composer update --no-progress --no-suggest --ansi
- - name: Disable PHP memory limit
- run: echo 'memory_limit=-1' >> /usr/local/etc/php/php.ini
- - name: Run CS-Fixer
- run: vendor/bin/php-cs-fixer fix --dry-run --diff --format=checkstyle
-
- phpunit:
- name: PHPUnit (PHP ${{ matrix.php }} Deps ${{ matrix.dependencies }})
- runs-on: ubuntu-latest
- container:
- image: php:${{ matrix.php }}-alpine
- options: >-
- --tmpfs /tmp:exec
- --tmpfs /var/tmp:exec
- strategy:
- matrix:
- php:
- - '8.1'
- - '8.2'
- - '8.3'
- dependencies:
- - 'lowest'
- - 'highest'
- include:
- - php: '8.1'
- phpunit-version: 10
- - php: '8.2'
- phpunit-version: 10
- - php: '8.3'
- phpunit-version: 10
- fail-fast: false
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- - name: Install Composer
- run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
- - name: Get Composer Cache Directory
- id: composer-cache
- run: |
- echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- - uses: actions/cache@v3
- with:
- path: ${{ steps.composer-cache.outputs.dir }}
- key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.dependencies }}-${{ hashFiles('**/composer.json') }}
- restore-keys: |
- ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.dependencies }}
- - name: Install lowest dependencies with Composer
- if: matrix.dependencies == 'lowest'
- run: composer update --no-progress --no-suggest --prefer-stable --prefer-lowest --ansi
- - name: Install highest dependencies with Composer
- if: matrix.dependencies == 'highest'
- run: composer update --no-progress --no-suggest --ansi
- - name: Run tests with PHPUnit
- env:
- SYMFONY_MAX_PHPUNIT_VERSION: ${{ matrix.phpunit-version }}
- run: vendor/bin/simple-phpunit --colors=always
-
- # coverage:
- # name: Coverage (PHP 8.3)
- # runs-on: ubuntu-latest
- # container:
- # image: php:8.3-alpine
- # options: >-
- # --tmpfs /tmp:exec
- # --tmpfs /var/tmp:exec
- # steps:
- # - name: Checkout
- # uses: actions/checkout@v4
- # - name: Install pcov PHP extension
- # run: |
- # apk add $PHPIZE_DEPS
- # pecl install pcov
- # docker-php-ext-enable pcov
- # - name: Install Composer
- # run: wget -qO - https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
- # - name: Get Composer Cache Directory
- # id: composer-cache
- # run: |
- # echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- # - uses: actions/cache@v3
- # with:
- # path: ${{ steps.composer-cache.outputs.dir }}
- # key: ${{ runner.os }}-composer-8.3-highest-${{ hashFiles('**/composer.json') }}
- # restore-keys: |
- # ${{ runner.os }}-composer-8.3-highest
- # - name: Install highest dependencies with Composer
- # run: composer update --no-progress --no-suggest --ansi
- # - name: Run coverage with PHPUnit
- # run: vendor/bin/simple-phpunit --coverage-clover ./coverage.xml --colors=always
- # - name: Send code coverage report to Codecov.io
- # uses: codecov/codecov-action@v3
- # with:
- # token: ${{ secrets.CODECOV_TOKEN }}
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.5'
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/composer/files
+ key: ${{ runner.os }}-php-8.5-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-php-8.5-composer-
+
+ - name: Validate composer.json and composer.lock
+ run: composer validate --strict
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Run php-cs-fixer (dry run)
+ run: vendor/bin/php-cs-fixer fix --dry-run --diff --verbose
+
+ static-analysis:
+ name: Static Analysis
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.5'
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/composer/files
+ key: ${{ runner.os }}-php-8.5-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-php-8.5-composer-
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress
+
+ - name: Run phpstan
+ run: vendor/bin/phpstan analyse --memory-limit=2G
+
+ tests:
+ name: PHP ${{ matrix.php }}, Symfony ${{ matrix.symfony }}, deps ${{ matrix.composer_args }}
+ runs-on: ubuntu-latest
+ needs:
+ - lint
+ - static-analysis
+ continue-on-error: ${{ matrix.stability == 'dev' }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ # Symfony 7.4 (stable)
+ - php: '8.5'
+ symfony: '7.4'
+ composer_args: '--prefer-stable'
+ stability: 'stable'
+ coverage: true
+ - php: '8.4'
+ symfony: '7.4'
+ composer_args: '--prefer-stable'
+ stability: 'stable'
+
+ # Symfony 8.0 (stable)
+ - php: '8.5'
+ symfony: '8.0'
+ composer_args: '--prefer-stable'
+ stability: 'stable'
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ coverage: pcov
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ~/.cache/composer/files
+ key: ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.composer_args }}-${{ hashFiles('**/composer.json') }}
+ restore-keys: |
+ ${{ runner.os }}-php-${{ matrix.php }}-symfony-${{ matrix.symfony }}-${{ matrix.composer_args }}-
+
+ - name: symfony/flex is required to install the correct symfony version
+ run: |
+ composer global config --no-plugins allow-plugins.symfony/flex true
+ composer global require symfony/flex --quiet
+
+ - name: Configure Composer stability
+ run: composer config minimum-stability ${{ matrix.stability }}
+
+ - name: Configure Symfony version for symfony/flex
+ run: composer config extra.symfony.require "${{ matrix.symfony }}.*"
+
+ - name: Install dependencies
+ run: composer update ${{ matrix.composer_args }} --no-progress --no-scripts --no-plugins
+
+ - name: Run phpunit
+ run: |
+ if [[ "${{ matrix.coverage }}" == "true" ]]; then
+ mkdir -p build/logs
+ vendor/bin/phpunit --coverage-clover coverage.xml
+ else
+ vendor/bin/phpunit
+ fi
+
+ - name: Upload coverage to Codecov
+ if: matrix.coverage
+ uses: codecov/codecov-action@v5
+ with:
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 83edab6..43a5daf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,5 @@
-.php_cs.cache
.php-cs-fixer.cache
-psalm-phpqa.xml
-.phpunit.result.cache
+.phpunit.cache
composer.lock
vendor/*
-
-.DS_Store
+dump.html
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 7b8eb43..f8a719e 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -11,41 +11,82 @@
$finder = (new PhpCsFixer\Finder())
- ->in(['src', 'tests'])
-;
+ ->in(['src', 'tests']);
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->registerCustomFixers(new PhpCsFixerCustomFixers\Fixers())
->setRules([
- '@PHP82Migration' => true,
+ '@autoPHPMigration:risky' => true,
+ '@autoPHPMigration' => true,
+ '@autoPHPUnitMigration:risky' => true,
'@PhpCsFixer' => true,
'@PhpCsFixer:risky' => true,
+ '@Symfony' => true,
+ '@Symfony:risky' => true,
- // From https://github.com/symfony/demo/blob/main/.php-cs-fixer.dist.php
- 'linebreak_after_opening_tag' => true,
- // 'mb_str_functions' => true,
- 'no_php4_constructor' => true,
- 'no_unreachable_default_argument_value' => true,
- 'no_useless_else' => true,
- 'no_useless_return' => true,
- 'php_unit_strict' => false,
- 'php_unit_internal_class' => false,
- 'php_unit_test_class_requires_covers' => false,
- 'phpdoc_order' => true,
- 'strict_comparison' => true,
- 'strict_param' => true,
- 'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['arrays', 'parameters']],
- 'statement_indentation' => true,
- 'method_chaining_indentation' => true,
+ 'header_comment' => ['header' => $header],
+ 'class_attributes_separation' => ['elements' => ['method' => 'one']],
+ 'class_definition' => ['inline_constructor_arguments' => true],
+ // 'date_time_immutable' => true,
+ 'global_namespace_import' => ['import_classes' => false, 'import_constants' => false, 'import_functions' => false],
'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline', 'attribute_placement' => 'ignore'],
+ 'multiline_whitespace_before_semicolons' => ['strategy' => 'new_line_for_chained_calls'],
+ 'multiline_promoted_properties' => true,
+ 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true, 'allow_unused_params' => true],
+ 'nullable_type_declaration_for_default_null_value' => true,
+ 'numeric_literal_separator' => true,
+ 'operator_linebreak' => ['only_booleans' => true, 'position' => 'beginning'],
+ 'ordered_imports' => ['sort_algorithm' => 'alpha', 'imports_order' => ['class', 'function', 'const']],
+ 'php_unit_data_provider_name' => true,
+ 'php_unit_data_provider_return_type' => true,
+ 'php_unit_data_provider_static' => true,
+ 'php_unit_dedicate_assert' => ['target' => 'newest'],
+ 'php_unit_method_casing' => ['case' => 'camel_case'],
+ 'phpdoc_array_type' => true,
+ 'phpdoc_list_type' => true,
+ 'phpdoc_param_order' => true,
+ 'phpdoc_to_property_type' => ['scalar_types' => true],
+ 'phpdoc_to_return_type' => ['scalar_types' => true],
+ 'phpdoc_var_without_name' => true,
+ 'phpdoc_to_comment' => false,
+ 'single_line_throw' => true,
+ 'string_implicit_backslashes' => false, // Temporary?
+ 'statement_indentation' => true,
+ 'trailing_comma_in_multiline' => ['after_heredoc' => true, 'elements' => ['arrays', 'parameters']],
+ 'use_arrow_functions' => true,
+ 'void_return' => true,
- PhpCsFixerCustomFixers\Fixer\ConstructorEmptyBracesFixer::name() => true,
- PhpCsFixerCustomFixers\Fixer\MultilineCommentOpeningClosingAloneFixer::name() => true,
- PhpCsFixerCustomFixers\Fixer\MultilinePromotedPropertiesFixer::name() => true,
- PhpCsFixerCustomFixers\Fixer\NoDuplicatedImportsFixer::name() => true,
- PhpCsFixerCustomFixers\Fixer\NoImportFromGlobalNamespaceFixer::name() => true,
- PhpCsFixerCustomFixers\Fixer\PhpdocSingleLineVarFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\ClassConstantUsageFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\ConstructorEmptyBracesFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\CommentSurroundedBySpacesFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\DeclareAfterOpeningTagFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\EmptyFunctionBodyFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\MultilineCommentOpeningClosingAloneFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\NoDoctrineMigrationsGeneratedCommentFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\NoDuplicatedArrayKeyFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\NoUselessCommentFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\NoUselessDirnameCallFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\NoUselessDoctrineRepositoryCommentFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\NoUselessParenthesisFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\NoUselessStrlenFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\NoUselessWriteVisibilityFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\PhpUnitAssertArgumentsOrderFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\PhpUnitNoUselessReturnFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\PhpUnitRequiresConstraintFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\PhpdocNoSuperfluousParamFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\PhpdocSelfAccessorFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\PhpdocTypesCommaSpacesFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\PhpdocTypesTrimFixer::name() => true,
+ // PhpCsFixerCustomFixers\Fixer\FunctionParameterSeparationFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\PhpdocPropertySortedFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\PromotedConstructorPropertyFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\ReadonlyPromotedPropertiesFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\SingleSpaceAfterStatementFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\SingleSpaceBeforeStatementFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\StringableInterfaceFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\TrimKeyFixer::name() => true,
+ PhpCsFixerCustomFixers\Fixer\TypedClassConstantFixer::name() => true,
])
->setFinder($finder)
;
diff --git a/README.md b/README.md
index 3ef8110..164e425 100644
--- a/README.md
+++ b/README.md
@@ -1,104 +1,230 @@
-# A2lix Auto Form Bundle
-
-Automate form building.
+# A2lix AutoForm Bundle
[](https://packagist.org/packages/a2lix/auto-form-bundle)
[](https://packagist.org/packages/a2lix/auto-form-bundle)
+[](https://packagist.org/packages/a2lix/auto-form-bundle)
[](https://packagist.org/packages/a2lix/auto-form-bundle)
+[](https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml)
+[](https://codecov.io/gh/a2lix/AutoFormBundle)
-[](https://packagist.org/packages/a2lix/auto-form-bundle)
-[](https://packagist.org/packages/a2lix/auto-form-bundle)
-[](https://packagist.org/packages/a2lix/auto-form-bundle)
+Stop writing boilerplate form code. This bundle provides a single, powerful `AutoType` form type that automatically generates a complete Symfony form from any PHP class.
+
+> [!NOTE]
+> If you need to manage form translations, please see the [A2lix TranslationFormBundle](https://github.com/a2lix/TranslationFormBundle), which is designed to work with this bundle.
+
+> [!TIP]
+> A complete demonstration is also available at [a2lix/demo](https://github.com/a2lix/Demo).
-| Branch | Tools |
-| --- | --- |
-| master | [![Build Status][ci_badge]][ci_link] [![Coverage Status][coverage_badge]][coverage_link] |
## Installation
-Use composer:
+Use Composer to install the bundle:
```bash
composer require a2lix/auto-form-bundle
```
-After the successful installation, add/check the bundle registration:
+## Basic Usage
+
+The simplest way to use `AutoType` is directly in your controller. It will generate a form based on the properties of the entity or DTO you pass it.
```php
-// bundles.php is automatically updated if flex is installed.
// ...
-A2lix\AutoFormBundle\A2lixAutoFormBundle::class => ['all' => true],
+
+class TaskController extends AbstractController
+{
+ public function new(Request $request): Response
+ {
+ $task = new Task(); // Any entity or DTO
+ $form = $this
+ ->createForm(AutoType::class, $task)
+ ->add('save', SubmitType::class)
+ ->handleRequest($request)
+ ;
+
+ // ...
+ }
+}
+```
+
+## How It Works
+
+`AutoType` reads the properties of the class you provide in the `data_class` option. For each property, it intelligently configures a corresponding form field. This gives you a solid foundation that you can then customize in two main ways:
+
+1. **Form Options:** Pass a configuration array directly when you create the form.
+2. **PHP Attributes:** Add `#[AutoTypeCustom]` attributes directly to the properties of your entity or DTO.
+
+Options passed directly to the form will always take precedence over attributes.
+
+## Customization via Form Options
+
+This is the most flexible way to configure your form. Here is a comprehensive example:
+
+```php
// ...
+
+class TaskController extends AbstractController
+{
+ public function new(Request $request, FormFactoryInterface $formFactory): Response
+ {
+ $product = new Product(); // Any entity or DTO
+ $form = $formFactory->createNamed('product', AutoType::class, $product, [
+ // 1. Optional define which properties should be excluded from the form.
+ // Use '*' for an "exclude-by-default" strategy.
+ 'children_excluded' => ['id', 'internalRef'],
+
+ // 2. Optional define which properties should be rendered as embedded forms.
+ // Use '*' to embed all relational properties.
+ 'children_embedded' => static fn (mixed $current) => [...$current, 'category', 'tags'],
+
+ // 3. Optional customize, override, or add fields.
+ 'children' => [
+ // Override an existing property with new options
+ 'description' => [
+ 'child_type' => TextareaType::class, // Force a specific form type
+ 'label' => 'Product Description', // Standard form options
+ 'priority' => 10,
+ ],
+
+ // Add a field that does not exist on the DTO/entity
+ 'terms_and_conditions' => [
+ 'child_type' => CheckboxType::class,
+ 'mapped' => false,
+ 'priority' => -100,
+ ],
+
+ // Completely replace a field's builder with a callable
+ 'price' => function(FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface {
+ // The callable receives the main builder and any options from a potential attribute.
+ // It must return a new FormBuilderInterface instance.
+ return $builder->create('price', MoneyType::class, ['currency' => 'EUR']);
+ },
+
+ // Add a new field to the form
+ 'save' => [
+ 'child_type' => SubmitType::class,
+ ],
+ ],
+
+ // 4. Optional final modifications on the complete form builder.
+ 'builder' => function(FormBuilderInterface $builder, array $classProperties): void {
+ // This callable runs after all children have been added.
+ if (isset($classProperties['code'])) {
+ $builder->remove('code');
+ }
+ },
+ ])->handleRequest($request);
+
+ // ...
+ }
+}
+
```
-## Configuration
+## Customization via `#[AutoTypeCustom]` Attribute
-There is no minimal configuration, so this part is optional. Full list:
+For a more declarative approach, you can place the configuration directly on the properties of your DTO or entity. This keeps the form configuration co-located with your data model.
-```yaml
-# Create a dedicated a2lix.yaml in config/packages with:
+```php
+use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom;
+use Symfony\Component\Form\Extension\Core\Type\TextareaType;
-a2lix_auto_form:
- excluded_fields: [id, locale, translatable] # [1]
+class Product
+{
+ #[AutoTypeCustom(excluded: true)]
+ public private(set) int $id;
+
+ public ?string $name = null;
+
+ #[AutoTypeCustom(type: TextareaType::class, options: ['attr' => ['rows' => 5]])]
+ public ?string $description = null;
+
+ #[AutoTypeCustom(embedded: true)]
+ public Category $category;
+}
```
-1. Optional.
+### Conditional Fields with Groups
-## Usage
+You can conditionally include fields based on groups, similar to how Symfony's `validation_groups` work. This is useful for having different versions of a form (e.g., a "creation" version vs. an "edition" version).
-### In a classic formType
+To enable this, pass a `children_groups` option to your form. This option specifies which groups of fields should be included.
```php
-use A2lix\AutoFormBundle\Form\Type\AutoFormType;
-...
-$builder->add('medias', AutoFormType::class);
+$form = $this->createForm(AutoType::class, $product, [
+ 'children_groups' => ['product:edit'],
+]);
```
-### Advanced examples
+You can then assign fields to one or more groups using either form options or attributes.
+
+#### Via Form Options
+
+Use the `child_groups` option within the `children` configuration:
```php
-use A2lix\AutoFormBundle\Form\Type\AutoFormType;
-...
-$builder->add('medias', AutoFormType::class, [
- 'fields' => [ // [2]
- 'description' => [ // [3.a]
- 'field_type' => 'textarea', // [4]
- 'label' => 'descript.', // [4]
- 'locale_options' => [ // [3.b]
- 'es' => ['label' => 'descripción'] // [4]
- 'fr' => ['display' => false] // [4]
- ]
- ]
+// ...
+'children' => [
+ 'name' => [
+ 'child_groups' => ['product:edit', 'product:create'],
],
- 'excluded_fields' => ['details'] // [2]
-]);
+ 'stock' => [
+ 'child_groups' => ['product:edit'],
+ ],
+],
+// ...
```
-2. Optional. If set, override the default value from config.yml
-3. Optional. If set, override the auto configuration of fields
- - [3.a] Optional. - For a field, applied to all locales
- - [3.b] Optional. - For a specific locale of a field
-4. Optional. Common options of symfony forms (max_length, required, trim, read_only, constraints, ...), which was added 'field_type' and 'display'
+In this example, if `children_groups` is set to `['product:edit']`, both `name` and `stock` will be included. If it's set to `['product:create']`, only `name` will be included.
-## Additional
+#### Via `#[AutoTypeCustom]` Attribute
-### Example
+Use the `groups` property on the attribute:
-See [Demo Bundle](https://github.com/a2lix/Demo) for more examples.
+```php
+use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom;
-## Contribution help
+class Product
+{
+ #[AutoTypeCustom(groups: ['product:edit', 'product:create'])]
+ public ?string $name = null;
+ #[AutoTypeCustom(groups: ['product:edit'])]
+ public ?int $stock = null;
+}
```
-docker run --rm --interactive --tty --volume $PWD:/app --user $(id -u):$(id -g) composer install --ignore-platform-reqs
-docker run --rm --interactive --tty --volume $PWD:/app --user $(id -u):$(id -g) composer run-script phpunit
-docker run --rm --interactive --tty --volume $PWD:/app --user $(id -u):$(id -g) composer run-script cs-fixer
+
+If no `children_groups` option is provided to the form, all fields are included by default, regardless of whether they have groups assigned.
+
+## Advanced Recipes
+
+### Creating a Compound Field with `inherit_data`
+
+You can use a callable in the `children` option to create complex fields that map to the parent object, which is useful for things like date ranges.
+
+```php
+'children' => [
+ '_' => function (FormBuilderInterface $builder): FormBuilderInterface {
+ return $builder
+ ->create('validity_range', FormType::class, ['inherit_data' => true])
+ ->add('startsAt', DateType::class, [/* ... */])
+ ->add('endsAt', DateType::class, [/* ... */]);
+ },
+]
```
-## License
+## Global Configuration
+
+While not required, you can configure the bundle globally. For example, you can define a list of properties to always exclude.
+
+Create a configuration file in `config/packages/a2lix_auto_form.yaml`:
-This package is available under the [MIT license](LICENSE).
+```yaml
+a2lix_auto_form:
+ # Exclude 'id' and 'createdAt' properties from all AutoType forms by default
+ children_excluded: [id, createdAt]
+```
+
+## License
-[ci_badge]: https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml/badge.svg
-[ci_link]: https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml
-[coverage_badge]: https://codecov.io/gh/a2lix/AutoFormBundle/branch/master/graph/badge.svg
-[coverage_link]: https://codecov.io/gh/a2lix/AutoFormBundle/branch/master
+This package is available under the [MIT license](LICENSE).
\ No newline at end of file
diff --git a/UPGRADE.md b/UPGRADE.md
new file mode 100644
index 0000000..25a864e
--- /dev/null
+++ b/UPGRADE.md
@@ -0,0 +1,138 @@
+# Upgrade from 0.x to 1.x
+
+Version 1.x is a complete rewrite of the bundle. It is not backward compatible.
+The bundle is no longer tied to Doctrine and now uses Symfony's PropertyInfo component to guess form types from any PHP object.
+
+## BC BREAK: Minimum Requirements
+
+- **PHP:** `8.4` or higher is required.
+- **Symfony:** `7.4` or higher is required.
+
+## BC BREAK: Composer Dependencies
+
+1. **Update your `composer.json`** to require the new version:
+ ```json
+ {
+ "require": {
+ "a2lix/auto-form-bundle": "^1.0"
+ }
+ }
+ ```
+ Then run `composer update a2lix/auto-form-bundle --with-all-dependencies`.
+
+2. **Decoupled from Doctrine:** The bundle no longer requires `doctrine/persistence` or `symfony/doctrine-bridge`. If your project relies on them, you must now require them explicitly in your own `composer.json`.
+
+## BC BREAK: Form Type Renaming
+
+The main form type has been renamed.
+
+- `A2lix\AutoFormBundle\Form\Type\AutoFormType` is **removed**.
+- Use `A2lix\AutoFormBundle\Form\Type\AutoType` instead.
+
+**Before:**
+```php
+use A2lix\AutoFormBundle\Form\Type\AutoFormType;
+$this->createForm(AutoFormType::class, /* ... */);
+```
+
+**After:**
+```php
+use A2lix\AutoFormBundle\Form\Type\AutoType;
+$this->createForm(AutoType::class, /* ... */);
+```
+
+## BC BREAK: Field Customization
+
+The way to customize fields has completely changed. There are two methods: using PHP attributes (recommended) or using form options.
+
+### Method 1: Using `#[AutoTypeCustom]` Attribute (Recommended)
+
+Customization can be done using the `#[AutoTypeCustom]` PHP attribute directly on your data object's properties.
+
+**Before:**
+```php
+// In your Controller
+$this->createForm(AutoFormType::class, new Product(), [
+ 'fields' => [
+ 'description' => [
+ 'field_type' => TextareaType::class,
+ 'label' => 'Product Description',
+ ],
+ ],
+ 'excluded_fields' => ['createdAt'],
+]);
+```
+
+**After:**
+```php
+// On your data object (Entity or DTO)
+use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom;
+use Symfony\Component\Form\Extension\Core\Type\TextareaType;
+
+class Product
+{
+ #[AutoTypeCustom(
+ type: TextareaType::class,
+ options: ['label' => 'Product Description']
+ )]
+ public string $description;
+
+ #[AutoTypeCustom(display: false)]
+ public \DateTimeImmutable $createdAt;
+
+ // ... other properties
+}
+
+// In your Controller, the configuration is now minimal
+$this->createForm(AutoType::class, new Product())
+```
+
+### Method 2: Using Form Options
+
+Customization is also still possible at the form level by passing options to `AutoType`. **This method will override any `#[AutoTypeCustom]` attributes set on the data object.**
+
+The option keys have been renamed for clarity:
+
+- The main configuration array `fields` is now `children`.
+- Inside a child's configuration, `field_type` is now `child_type`.
+- The `excluded_fields` option is now `children_excluded`.
+
+**Before:**
+```php
+// In your Controller
+$this->createForm(AutoFormType::class, new Product(), [
+ 'fields' => [
+ 'description' => [
+ 'field_type' => TextareaType::class,
+ 'label' => 'Product Description',
+ ],
+ ],
+ 'excluded_fields' => ['createdAt'],
+]);
+```
+
+**After:**
+```php
+// In your Controller
+$this->createForm(AutoType::class, new Product(), [
+ 'children' => [
+ 'description' => [
+ 'child_type' => TextareaType::class,
+ 'label' => 'Product Description', // Note: options are now merged at the top level
+ ],
+ ],
+ 'children_excluded' => ['createdAt'],
+]);
+```
+
+## BC BREAK: Removed Classes and Concepts
+
+The internal architecture was refactored. The following major classes and concepts have been **removed** without direct replacement:
+
+- `A2lix\AutoFormBundle\Form\EventListener\AutoFormListener`
+- `A2lix\AutoFormBundle\Form\Manipulator\DoctrineORMManipulator`
+- `A2lix\AutoFormBundle\ObjectInfo\DoctrineORMInfo`
+
+## BC BREAK: Bundle Configuration
+
+The bundle is now zero-configuration for most use cases. You should **remove** your old configuration file at `config/packages/a2lix_auto_form.yaml`.
diff --git a/composer.json b/composer.json
index 8c55b73..fe488a8 100644
--- a/composer.json
+++ b/composer.json
@@ -2,13 +2,13 @@
"name": "a2lix/auto-form-bundle",
"type": "symfony-bundle",
"description": "Automate form building",
- "keywords": ["symfony", "form", "field", "automate", "automation", "magic", "building"],
+ "keywords": ["symfony", "form", "field", "auto", "automate", "automation", "magic", "building"],
"homepage": "https://github.com/a2lix/AutoFormBundle",
"license": "MIT",
"authors": [
{
"name": "David ALLIX",
- "homepage": "http://a2lix.fr"
+ "homepage": "https://a2lix.fr"
},
{
"name": "Contributors",
@@ -16,24 +16,30 @@
}
],
"require": {
- "php": "^8.1",
- "doctrine/persistence": "^2.0|^3.0|^4.0",
- "symfony/config": "^5.4.30|^6.3|^7.0",
- "symfony/dependency-injection": "^5.4.30|^6.3|^7.0",
- "symfony/doctrine-bridge": "^5.4.30|^6.3|^7.0",
- "symfony/form": "^5.4.30|^6.3|^7.0",
- "symfony/http-kernel": "^5.4.30|^6.3|^7.0"
+ "php": ">=8.4",
+ "symfony/config": "^7.4|^8.0",
+ "symfony/dependency-injection": "^7.4|^8.0",
+ "symfony/form": "^7.4|^8.0",
+ "symfony/http-kernel": "^7.4|^8.0",
+ "symfony/property-info": "^7.4|^8.0",
+ "symfony/type-info": "^7.4|^8.0",
+ "phpdocumentor/reflection-docblock": "^5.6"
},
"require-dev": {
- "doctrine/orm": "^2.15|^3.0",
- "friendsofphp/php-cs-fixer": "^3.45",
- "kubawerlos/php-cs-fixer-custom-fixers": "^3.18",
- "phpstan/phpstan": "^1.10",
- "rector/rector": "^0.18",
- "symfony/cache": "^5.4.30|^6.3|^7.0",
- "symfony/phpunit-bridge": "^5.4.30|^6.3|^7.0",
- "symfony/validator": "^5.4.30|^6.3|^7.0",
- "vimeo/psalm": "^5.18"
+ "doctrine/orm": "^3.5.8",
+ "friendsofphp/php-cs-fixer": "^3.91.3",
+ "kubawerlos/php-cs-fixer-custom-fixers": "^3.35.1",
+ "phpstan/extension-installer": "^1.4.3",
+ "phpstan/phpstan": "^2.1.33",
+ "phpstan/phpstan-phpunit": "^2.0.10",
+ "phpstan/phpstan-strict-rules": "^2.0.7",
+ "phpstan/phpstan-symfony": "^2.0.9",
+ "phpunit/phpunit": "^12.5.2",
+ "rector/rector": "^2.2.14",
+ "symfony/cache": "^7.4.1",
+ "symfony/doctrine-bridge": "^7.4.1",
+ "symfony/validator": "^7.4.2",
+ "symfony/var-dumper": "^7.4"
},
"suggest": {
"a2lix/translation-form-bundle": "For translation form"
@@ -42,17 +48,18 @@
"cs-fixer": [
"php-cs-fixer fix --verbose"
],
- "psalm": [
- "psalm"
+ "phpstan": [
+ "phpstan analyse --memory-limit=2G"
],
"phpunit": [
- "SYMFONY_DEPRECATIONS_HELPER=max[self]=0 simple-phpunit"
+ "phpunit"
]
},
"config": {
"sort-packages": true,
"allow-plugins": {
- "composer/package-versions-deprecated": true
+ "composer/package-versions-deprecated": true,
+ "phpstan/extension-installer": true
}
},
"autoload": {
@@ -63,7 +70,7 @@
},
"extra": {
"branch-alias": {
- "dev-master": "0.x-dev"
+ "dev-main": "1.x-dev"
}
}
}
diff --git a/config/services.php b/config/services.php
new file mode 100644
index 0000000..49e4ec1
--- /dev/null
+++ b/config/services.php
@@ -0,0 +1,40 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Component\DependencyInjection\Loader\Configurator;
+
+use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder;
+use A2lix\AutoFormBundle\Form\Type\AutoType;
+use A2lix\AutoFormBundle\Form\TypeGuesser\TypeInfoTypeGuesser;
+
+return static function (ContainerConfigurator $container): void {
+ $container->services()
+ ->set('a2lix_auto_form.form.builder.auto_type_builder', AutoTypeBuilder::class)
+ ->args([
+ '$propertyInfoExtractor' => service('property_info'),
+ ])
+
+ ->set('a2lix_auto_form.form.type.auto_type', AutoType::class)
+ ->args([
+ '$autoTypeBuilder' => service('a2lix_auto_form.form.builder.auto_type_builder'),
+ '$globalExcludedChildren' => abstract_arg('globalExcludedChildren'),
+ ])
+ ->tag('form.type')
+
+ ->set('a2lix_auto_form.type_guesser.type_info', TypeInfoTypeGuesser::class)
+ ->args([
+ '$typeResolver' => service('type_info.resolver'),
+ ])
+ ->tag('form.type_guesser')
+ ;
+};
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e5b2b7b
--- /dev/null
+++ b/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@a2lix/auto-form-bundle",
+ "version": "1.0.0",
+ "symfony": {
+ "importmap": {
+ "@a2lix/symfony-collection": "^0.6"
+ }
+ }
+}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..7869c99
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,20 @@
+parameters:
+ level: 10
+ paths:
+ - src
+ - tests
+ excludePaths:
+ - src/A2lixAutoFormBundle.php
+ - tests/Fixtures
+
+ # Stricter setup
+ checkTooWideReturnTypesInProtectedAndPublicMethods: true
+ checkUninitializedProperties: true
+ rememberPossiblyImpureFunctionValues: false
+ checkBenevolentUnionTypes: true
+ #reportPossiblyNonexistentGeneralArrayOffset: true
+ reportPossiblyNonexistentConstantArrayOffset: true
+ reportAlwaysTrueInLastCondition: true
+ reportAnyTypeWideningInVarTag: true
+ checkMissingOverrideMethodAttribute: true
+ checkMissingCallableSignature: true
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 9afc01d..541b739 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,35 +1,30 @@
-
-
-
-
- src/
-
-
- src/Resources
-
-
-
-
-
-
-
+ cacheDirectory=".phpunit.cache"
+ executionOrder="depends,defects"
+ requireCoverageMetadata="true"
+ beStrictAboutCoverageMetadata="true"
+ beStrictAboutOutputDuringTests="true"
+ displayDetailsOnPhpunitDeprecations="true"
+ failOnPhpunitDeprecation="true"
+ failOnRisky="true"
+ failOnWarning="true">
+
+
+ tests
+
+
-
-
-
+
+
+ src
+
+
-
-
- tests/
- tests/Fixtures
- tests/tmp
-
-
+
+
+
+
diff --git a/psalm.xml b/psalm.xml
deleted file mode 100644
index c385516..0000000
--- a/psalm.xml
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/rector.php b/rector.php
index e6244a4..2a305c2 100644
--- a/rector.php
+++ b/rector.php
@@ -3,35 +3,35 @@
declare(strict_types=1);
use Rector\Config\RectorConfig;
-use Rector\Core\ValueObject\PhpVersion;
-use Rector\Doctrine\Set\DoctrineSetList;
-use Rector\PHPUnit\Set\PHPUnitLevelSetList;
-use Rector\PHPUnit\Set\PHPUnitSetList;
-use Rector\Set\ValueObject\LevelSetList;
-use Rector\Symfony\Set\SymfonyLevelSetList;
-use Rector\Symfony\Set\SymfonySetList;
-use Rector\Symfony\Set\TwigSetList;
-return static function (RectorConfig $rectorConfig): void {
- $rectorConfig->parallel();
- $rectorConfig->paths([
- __DIR__.'/src',
- __DIR__.'/tests',
- ]);
- $rectorConfig->importNames();
- $rectorConfig->importShortClasses(false);
-
- $rectorConfig->phpVersion(PhpVersion::PHP_82);
- $rectorConfig->sets([
- LevelSetList::UP_TO_PHP_82,
-
- DoctrineSetList::ANNOTATIONS_TO_ATTRIBUTES,
- // DoctrineSetList::DOCTRINE_CODE_QUALITY,
- DoctrineSetList::DOCTRINE_ORM_214,
- DoctrineSetList::DOCTRINE_DBAL_30,
-
- PHPUnitLevelSetList::UP_TO_PHPUNIT_91,
- // PHPUnitSetList::PHPUNIT_CODE_QUALITY,
- // PHPUnitSetList::PHPUNIT_YIELD_DATA_PROVIDER,
- ]);
-};
+return RectorConfig::configure()
+ ->withParallel()
+ ->withPaths([
+ __DIR__ . '/config',
+ __DIR__ . '/src',
+ __DIR__ . '/tests',
+ ])
+ // ->withRootFiles()
+ ->withImportNames(importShortClasses: false, removeUnusedImports: true)
+ ->withPhpSets()
+ ->withAttributesSets(all: true)
+ ->withComposerBased(twig: true, doctrine: true, phpunit: true, symfony: true)
+ ->withPreparedSets(
+ deadCode: true,
+ codeQuality: true,
+ codingStyle: true,
+ typeDeclarations: true,
+ typeDeclarationDocblocks: true,
+ privatization: true,
+ // naming: true,
+ instanceOf: true,
+ earlyReturn: true,
+ strictBooleans: true,
+ // carbon: true,
+ rectorPreset: true,
+ phpunitCodeQuality: true,
+ doctrineCodeQuality: true,
+ symfonyCodeQuality: true,
+ symfonyConfigs: true,
+ )
+;
diff --git a/src/A2lixAutoFormBundle.php b/src/A2lixAutoFormBundle.php
index 9a1c442..c2bb177 100644
--- a/src/A2lixAutoFormBundle.php
+++ b/src/A2lixAutoFormBundle.php
@@ -1,6 +1,4 @@
-rootNode()
+ ->children()
+ ->arrayNode('children_excluded')
+ ->scalarPrototype()->end()
+ ->defaultValue(['id'])
+ ->info('Class properties to exclude from autoType children. (Default: id)')
+ ->end()
+ ->end()
+ ;
+ }
+
+ #[\Override]
+ public function prependExtension(ContainerConfigurator $configurator, ContainerBuilder $container): void
+ {
+ if (!$container->hasExtension('a2lix_translation_form')) {
+ return;
+ }
+
+ $config = $container->getExtensionConfig($this->extensionAlias)[0];
+
+ if (null === ($config['children_excluded'] ?? null)) {
+ $container->prependExtensionConfig($this->extensionAlias, [
+ 'children_excluded' => [
+ 'id',
+ 'newTranslations',
+ 'translatable',
+ 'locale',
+ 'currentLocale',
+ 'defaultLocale',
+ ],
+ ]);
+ }
+ }
+
+ #[\Override]
+ public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void
+ {
+ $container->import('../config/services.php');
+
+ $container->services()
+ ->get('a2lix_auto_form.form.type.auto_type')
+ ->arg('$globalExcludedChildren', $config['children_excluded'])
+ ;
+ }
+
+ #[\Override]
+ public function build(ContainerBuilder $container): void
+ {
+ $container->addCompilerPass($this);
+ }
+
+ #[\Override]
+ public function process(ContainerBuilder $container): void
+ {
+ if (!$container->hasExtension('a2lix_translation_form')) {
+ return;
+ }
+
+ $container->getDefinition('a2lix_auto_form.form.type.auto_type')
+ ->setArgument('$handleTranslationTypes', true)
+ ;
-class A2lixAutoFormBundle extends Bundle {}
+ $config = $container->getExtensionConfig($this->extensionAlias)[0];
+ $container->getDefinition('a2lix_translation_form.form.type.translations_type')
+ ->setArguments([
+ '$globalExcludedChildren' => $config['children_excluded'] ?? [],
+ '$globalEmbeddedChildren' => $config['children_embedded'] ?? [],
+ ])
+ ;
+ }
+}
diff --git a/src/DependencyInjection/A2lixAutoFormExtension.php b/src/DependencyInjection/A2lixAutoFormExtension.php
deleted file mode 100644
index 4dddffd..0000000
--- a/src/DependencyInjection/A2lixAutoFormExtension.php
+++ /dev/null
@@ -1,38 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\DependencyInjection;
-
-use Symfony\Component\Config\Definition\Processor;
-use Symfony\Component\Config\FileLocator;
-use Symfony\Component\DependencyInjection\ContainerBuilder;
-use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
-use Symfony\Component\HttpKernel\DependencyInjection\Extension;
-
-class A2lixAutoFormExtension extends Extension
-{
- public function load(array $configs, ContainerBuilder $container): void
- {
- $processor = new Processor();
- $config = $processor->processConfiguration(new Configuration(), $configs);
-
- $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
- $loader->load('a2lix_form.xml');
- $loader->load('object_info.xml');
-
- $definition = $container->getDefinition('a2lix_auto_form.form.manipulator.doctrine_orm_manipulator');
- $definition->replaceArgument(1, $config['excluded_fields']);
-
- $container->setAlias('a2lix_auto_form.manipulator.default', 'a2lix_auto_form.form.manipulator.doctrine_orm_manipulator');
- }
-}
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
deleted file mode 100644
index ef33d2d..0000000
--- a/src/DependencyInjection/Configuration.php
+++ /dev/null
@@ -1,42 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\DependencyInjection;
-
-use Symfony\Component\Config\Definition\Builder\TreeBuilder;
-use Symfony\Component\Config\Definition\ConfigurationInterface;
-
-class Configuration implements ConfigurationInterface
-{
- public function getConfigTreeBuilder(): TreeBuilder
- {
- $treeBuilder = new TreeBuilder('a2lix_auto_form');
- $rootNode = method_exists(TreeBuilder::class, 'getRootNode') ? $treeBuilder->getRootNode() : $treeBuilder->root('a2lix_auto_form');
-
- $rootNode
- ->children()
- ->arrayNode('excluded_fields')
- ->defaultValue(['id', 'locale', 'translatable'])
- ->beforeNormalization()
- ->ifString()
- ->then(static fn ($v) => preg_split('/\s*,\s*/', (string) $v))
- ->end()
- ->prototype('scalar')
- ->info('Global list of fields to exclude from form generation. (Default: id, locale, translatable)')->end()
- ->end()
- ->end()
- ;
-
- return $treeBuilder;
- }
-}
diff --git a/src/Form/Attribute/AutoTypeCustom.php b/src/Form/Attribute/AutoTypeCustom.php
new file mode 100644
index 0000000..9104895
--- /dev/null
+++ b/src/Form/Attribute/AutoTypeCustom.php
@@ -0,0 +1,51 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Form\Attribute;
+
+use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder;
+
+/**
+ * @phpstan-import-type ChildOptions from AutoTypeBuilder
+ */
+#[\Attribute(\Attribute::TARGET_PROPERTY)]
+final readonly class AutoTypeCustom
+{
+ /**
+ * @param array $options
+ * @param class-string|null $type
+ * @param list|null $groups
+ */
+ public function __construct(
+ private array $options = [],
+ private ?string $type = null,
+ private ?string $name = null,
+ private ?bool $excluded = null,
+ private ?bool $embedded = null,
+ private ?array $groups = null,
+ ) {}
+
+ /**
+ * @return ChildOptions
+ */
+ public function getOptions(): array
+ {
+ /** @var ChildOptions */
+ return [
+ ...$this->options,
+ ...(null !== $this->type ? ['child_type' => $this->type] : []),
+ ...(null !== $this->name ? ['child_name' => $this->name] : []),
+ ...(null !== $this->excluded ? ['child_excluded' => $this->excluded] : []),
+ ...(null !== $this->embedded ? ['child_embedded' => $this->embedded] : []),
+ ...(null !== $this->groups ? ['child_groups' => $this->groups] : []),
+ ];
+ }
+}
diff --git a/src/Form/Builder/AutoTypeBuilder.php b/src/Form/Builder/AutoTypeBuilder.php
new file mode 100644
index 0000000..6e56d07
--- /dev/null
+++ b/src/Form/Builder/AutoTypeBuilder.php
@@ -0,0 +1,345 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Form\Builder;
+
+use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom;
+use A2lix\AutoFormBundle\Form\Type\AutoType;
+use Doctrine\Common\Collections\Collection;
+use Symfony\Component\Form\Extension\Core\Type\CollectionType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\Form\FormInterface;
+use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
+use Symfony\Component\TypeInfo\Type as TypeInfo;
+use Symfony\Component\TypeInfo\TypeIdentifier;
+
+/**
+ * @phpstan-type ChildOptions array{
+ * child_type?: class-string,
+ * child_name?: string,
+ * child_excluded?: bool,
+ * child_embedded?: bool,
+ * child_groups?: list,
+ * ...
+ * }
+ * @phpstan-type ChildBuilderCallable callable(FormBuilderInterface $builder, ?array $propAttributeOptions): FormBuilderInterface
+ * @phpstan-type FormBuilderCallable callable(FormBuilderInterface $builder, list $classProperties): void
+ * @phpstan-type FormOptionsDefaults array{
+ * children: array,
+ * children_excluded: list|"*",
+ * children_embedded: list|"*",
+ * children_groups: list,
+ * builder: FormBuilderCallable|null,
+ * handle_translation_types: bool,
+ * gedmo_only: bool,
+ * }
+ */
+final readonly class AutoTypeBuilder
+{
+ public function __construct(
+ private PropertyInfoExtractorInterface $propertyInfoExtractor,
+ ) {}
+
+ /**
+ * @param FormBuilderInterface $builder
+ * @param FormOptionsDefaults $formOptions
+ */
+ public function buildChildren(FormBuilderInterface $builder, array $formOptions): void
+ {
+ $dataClass = $this->getDataClass($form = $builder->getForm());
+
+ if (null === $classProperties = $this->propertyInfoExtractor->getProperties($dataClass)) {
+ throw new \RuntimeException(\sprintf('Unable to extract properties of "%s".', $dataClass));
+ }
+
+ $refClass = new \ReflectionClass($dataClass);
+ $allChildrenExcluded = '*' === $formOptions['children_excluded'];
+ $allChildrenEmbedded = '*' === $formOptions['children_embedded'];
+ $childrenGroups = $formOptions['children_groups'];
+ $handleTranslationTypes = $formOptions['handle_translation_types'];
+ $gedmoTranslatable = $handleTranslationTypes && (null !== ($refClass->getAttributes('Gedmo\Mapping\Annotation\TranslationEntity')[0] ?? null));
+ $formDepth = $this->getFormDepth($form);
+
+ /** @var list $classProperties */
+ foreach ($classProperties as $classProperty) {
+ // Due to issue with DateTimeImmutable PHP8.4
+ if (!$refClass->hasProperty($classProperty)) {
+ continue;
+ }
+
+ $refProperty = $refClass->getProperty($classProperty);
+
+ // Gedmo Translatable property? Possible continue early
+ if ($gedmoTranslatable) {
+ $hasGedmoAttribute = null !== ($refProperty->getAttributes('Gedmo\Mapping\Annotation\Translatable')[0] ?? null);
+
+ if ($formOptions['gedmo_only'] xor $hasGedmoAttribute) {
+ unset($formOptions['children'][$classProperty]);
+ continue;
+ }
+ }
+
+ $propFormOptions = $formOptions['children'][$classProperty] ?? null;
+ $propAttributeOptions = ($refProperty->getAttributes(AutoTypeCustom::class)[0] ?? null)
+ ?->newInstance()?->getOptions() ?? []
+ ;
+
+ // Custom name?
+ if (null !== ($propAttributeOptions['child_name'] ?? null)) {
+ $propAttributeOptions['property_path'] = $classProperty;
+ }
+
+ // FORM.children[PROP] callable? Add early
+ if (\is_callable($propFormOptions)) {
+ $childBuilder = ($propFormOptions)($builder, $propAttributeOptions);
+ $this->addChild($builder, $childBuilder);
+ unset($formOptions['children'][$classProperty]);
+ continue;
+ }
+
+ /** @var ChildOptions */
+ $childOptions = [
+ ...$propAttributeOptions,
+ ...($propFormOptions ?? []),
+ ];
+
+ // @phpstan-ignore argument.type
+ $formChildExcluded = ((null === $propFormOptions) && ($allChildrenExcluded || \in_array($classProperty, $formOptions['children_excluded'], true)))
+ || ($childOptions['child_excluded'] ?? false);
+
+ // Excluded child? Continue early
+ if ($formChildExcluded) {
+ unset($formOptions['children'][$classProperty]);
+ continue;
+ }
+
+ // Invalid matching group? Continue early
+ $childGroups = $childOptions['child_groups'] ?? ['Default'];
+ if ([] === array_intersect($childrenGroups, $childGroups)) {
+ unset($formOptions['children'][$classProperty]);
+ continue;
+ }
+
+ // PropertyInfo? Enrich childOptions
+ if (null !== $propTypeInfo = $this->propertyInfoExtractor->getType($dataClass, $classProperty)) {
+ $formChildTranslations = $handleTranslationTypes && ('translations' === $classProperty);
+ // @phpstan-ignore argument.type
+ $formChildEmbedded = $allChildrenEmbedded || \in_array($classProperty, $formOptions['children_embedded'], true)
+ || ($childOptions['child_embedded'] ?? false);
+
+ /** @var ChildOptions */
+ $childOptions = match (true) {
+ $formChildTranslations => $this->updateTranslationsChildOptions($dataClass, $gedmoTranslatable, $childOptions),
+ $formChildEmbedded => $this->updateEmbeddedChildOptions($propTypeInfo, $childOptions, $formDepth, $refProperty),
+ default => $childOptions,
+ };
+ }
+
+ $this->addChild($builder, $classProperty, $childOptions);
+ unset($formOptions['children'][$classProperty]);
+ }
+
+ if ($formOptions['gedmo_only']) {
+ return;
+ }
+
+ // Remaining FORM.children[PROP] unrelated to dataClass? E.g: mapped:false OR inherit_data:true
+ foreach ($formOptions['children'] as $childProperty => $childOptions) {
+ // FORM.children[PROP] callable? Continue early
+ if (\is_callable($childOptions)) {
+ $childBuilder = ($childOptions)($builder, null);
+ $this->addChild($builder, $childBuilder);
+ continue;
+ }
+
+ $this->addChild($builder, $childProperty, $childOptions);
+ }
+
+ // FORM.builder callable? Final modifications
+ if (null !== $builderFn = $formOptions['builder']) {
+ ($builderFn)($builder, $classProperties);
+ }
+ }
+
+ /**
+ * @param FormBuilderInterface $builder
+ * @param string|FormBuilderInterface $child
+ * @param ChildOptions $options
+ */
+ private function addChild(FormBuilderInterface $builder, string|FormBuilderInterface $child, array $options = []): void
+ {
+ if ($child instanceof FormBuilderInterface) {
+ $builder->add($child);
+
+ return;
+ }
+
+ [
+ 'child_name' => $name,
+ 'child_type' => $type
+ ] = $options + [
+ 'child_name' => $child,
+ 'child_type' => null,
+ ];
+ unset(
+ $options['child_name'],
+ $options['child_type'],
+ $options['child_excluded'],
+ $options['child_embedded'],
+ $options['child_groups'],
+ );
+
+ $builder->add($name, $type, $options);
+ }
+
+ /**
+ * @param FormInterface $form
+ *
+ * @return class-string
+ */
+ private function getDataClass(FormInterface $form): string
+ {
+ do {
+ if (null !== $dataClass = $form->getConfig()->getDataClass()) {
+ /** @var class-string */
+ return $dataClass;
+ }
+ } while (null !== $form = $form->getParent());
+
+ throw new \RuntimeException('Unable to get dataClass');
+ }
+
+ /**
+ * @param ChildOptions $baseChildOptions
+ *
+ * @return array
+ */
+ private function updateTranslationsChildOptions(
+ string $translatableClass,
+ bool $gedmoTranslatable,
+ array $baseChildOptions,
+ ): array {
+ return [
+ 'child_type' => 'A2lix\TranslationFormBundle\Form\Type\TranslationsType',
+ 'translatable_class' => $translatableClass,
+ 'gedmo' => $gedmoTranslatable,
+ ...$baseChildOptions,
+ ];
+ }
+
+ /**
+ * @param ChildOptions $baseChildOptions
+ *
+ * @return ChildOptions
+ */
+ private function updateEmbeddedChildOptions(
+ TypeInfo $propTypeInfo,
+ array $baseChildOptions,
+ int $formDepth,
+ \ReflectionProperty $refProperty,
+ ): array {
+ // TypeInfo matching native FormType? Abort, guessers are enough
+ if (self::isTypeInfoWithMatchingNativeFormType($propTypeInfo)) {
+ return $baseChildOptions;
+ }
+
+ // Embeddable collection (object or builtin)?
+ if ($propTypeInfo instanceof TypeInfo\CollectionType) {
+ $baseCollOptions = [
+ 'child_type' => CollectionType::class,
+ 'allow_add' => true,
+ 'allow_delete' => true,
+ 'delete_empty' => true,
+ 'by_reference' => false,
+ 'prototype_name' => '__name'.$formDepth.'__',
+ ...$baseChildOptions,
+ ];
+
+ $collValueType = $propTypeInfo->getCollectionValueType();
+
+ // Object?
+ if ($collValueType instanceof TypeInfo\ObjectType) {
+ return [
+ 'entry_type' => AutoType::class,
+ ...$baseCollOptions,
+ 'entry_options' => [
+ 'data_class' => $collValueType->getClassName(),
+ // @phpstan-ignore nullCoalesce.offset
+ ...($baseCollOptions['entry_options'] ?? []),
+ ],
+ ];
+ }
+
+ // Builtin
+ return $baseCollOptions;
+ }
+
+ // Embeddable object
+ /** @var TypeInfo\ObjectType */
+ $innerType = $propTypeInfo instanceof TypeInfo\NullableType ? $propTypeInfo->getWrappedType() : $propTypeInfo;
+
+ if (Collection::class === $innerType->getClassName()) {
+ throw new \RuntimeException(\sprintf('Unprecise PhpDoc Collection detected for "%s:%s". Fix it. For example: "@param Collection $%s"', $refProperty->class, $refProperty->name, $refProperty->name));
+ }
+
+ return [
+ 'child_type' => AutoType::class,
+ 'data_class' => $innerType->getClassName(),
+ 'required' => $propTypeInfo->isNullable(),
+ ...$baseChildOptions,
+ ];
+ }
+
+ private static function isTypeInfoWithMatchingNativeFormType(TypeInfo $propTypeInfo): bool
+ {
+ // Array? Some native FormTypes with high confidence ('multiple' option) can match
+ if ($propTypeInfo instanceof TypeInfo\CollectionType) {
+ $collValueType = $propTypeInfo->getCollectionValueType();
+
+ return $collValueType->isIdentifiedBy(\UnitEnum::class, \DateTimeZone::class);
+ }
+
+ // Builtin? Native FormType should fine
+ if (!$propTypeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) {
+ return true;
+ }
+
+ // Otherwise, some native FormTypes with high confidence can match
+ return $propTypeInfo->isIdentifiedBy(
+ \UnitEnum::class,
+ \DateTime::class,
+ \DateTimeImmutable::class,
+ \DateInterval::class,
+ \DateTimeZone::class,
+ 'Symfony\Component\Uid\Ulid',
+ 'Symfony\Component\Uid\Uuid',
+ 'Symfony\Component\HttpFoundation\File\File',
+ );
+ }
+
+ /**
+ * @param FormInterface $form
+ */
+ private function getFormDepth(FormInterface $form): int
+ {
+ if ($form->isRoot()) {
+ return 0;
+ }
+
+ $depth = 0;
+ while (null !== $formParent = $form->getParent()) {
+ $form = $formParent;
+ ++$depth;
+ }
+
+ return $depth;
+ }
+}
diff --git a/src/Form/EventListener/AutoFormListener.php b/src/Form/EventListener/AutoFormListener.php
deleted file mode 100644
index 9ca6134..0000000
--- a/src/Form/EventListener/AutoFormListener.php
+++ /dev/null
@@ -1,46 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\Form\EventListener;
-
-use A2lix\AutoFormBundle\Form\Manipulator\FormManipulatorInterface;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\Form\FormEvent;
-use Symfony\Component\Form\FormEvents;
-
-class AutoFormListener implements EventSubscriberInterface
-{
- public function __construct(
- private readonly FormManipulatorInterface $formManipulator,
- ) {}
-
- public static function getSubscribedEvents(): array
- {
- return [
- FormEvents::PRE_SET_DATA => 'preSetData',
- ];
- }
-
- public function preSetData(FormEvent $event): void
- {
- $form = $event->getForm();
-
- $fieldsOptions = $this->formManipulator->getFieldsConfig($form);
- foreach ($fieldsOptions as $fieldName => $fieldConfig) {
- $fieldType = $fieldConfig['field_type'] ?? null;
- unset($fieldConfig['field_type']);
-
- $form->add($fieldName, $fieldType, $fieldConfig);
- }
- }
-}
diff --git a/src/Form/Manipulator/DoctrineORMManipulator.php b/src/Form/Manipulator/DoctrineORMManipulator.php
deleted file mode 100644
index aa37e90..0000000
--- a/src/Form/Manipulator/DoctrineORMManipulator.php
+++ /dev/null
@@ -1,117 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\Form\Manipulator;
-
-use A2lix\AutoFormBundle\ObjectInfo\DoctrineORMInfo;
-use Symfony\Component\Form\FormInterface;
-
-class DoctrineORMManipulator implements FormManipulatorInterface
-{
- public function __construct(
- private readonly DoctrineORMInfo $doctrineORMInfo,
- private readonly array $globalExcludedFields = [],
- ) {}
-
- public function getFieldsConfig(FormInterface $form): array
- {
- $class = $this->getDataClass($form);
- $formOptions = $form->getConfig()->getOptions();
-
- // Filtering to remove excludedFields
- $objectFieldsConfig = $this->doctrineORMInfo->getFieldsConfig($class);
- $validObjectFieldsConfig = $this->filteringValidObjectFields($objectFieldsConfig, $formOptions['excluded_fields']);
-
- if (empty($formOptions['fields'])) {
- return $validObjectFieldsConfig;
- }
-
- $fields = [];
-
- foreach ($formOptions['fields'] as $formFieldName => $formFieldConfig) {
- $this->checkFieldIsValid($formFieldName, $formFieldConfig, $validObjectFieldsConfig, $class);
-
- if (null === $formFieldConfig) {
- continue;
- }
-
- // If display undesired, remove
- if (false === ($formFieldConfig['display'] ?? true)) {
- continue;
- }
-
- // Override with formFieldsConfig priority
- $fields[$formFieldName] = $formFieldConfig;
-
- if (isset($validObjectFieldsConfig[$formFieldName])) {
- $fields[$formFieldName] += $validObjectFieldsConfig[$formFieldName];
- }
- }
-
- return $fields + $validObjectFieldsConfig;
- }
-
- private function getDataClass(FormInterface $form): string
- {
- // Simple case, data_class from current form (with ORM Proxy management)
- if (null !== $dataClass = $form->getConfig()->getDataClass()) {
- if (false === $pos = strrpos((string) $dataClass, '\\__CG__\\')) {
- return $dataClass;
- }
-
- return substr((string) $dataClass, $pos + 8);
- }
-
- // Advanced case, loop parent form to get closest fill data_class
- while (null !== $formParent = $form->getParent()) {
- if (null === $dataClass = $formParent->getConfig()->getDataClass()) {
- $form = $formParent;
-
- continue;
- }
-
- return $this->doctrineORMInfo->getAssociationTargetClass($dataClass, (string) $form->getPropertyPath());
- }
-
- throw new \RuntimeException('Unable to get dataClass');
- }
-
- private function filteringValidObjectFields(array $objectFieldsConfig, array $formExcludedFields): array
- {
- $excludedFields = array_merge($this->globalExcludedFields, $formExcludedFields);
-
- $validFields = [];
- foreach ($objectFieldsConfig as $fieldName => $fieldConfig) {
- if (\in_array($fieldName, $excludedFields, true)) {
- continue;
- }
-
- $validFields[$fieldName] = $fieldConfig;
- }
-
- return $validFields;
- }
-
- private function checkFieldIsValid($formFieldName, $formFieldConfig, $validObjectFieldsConfig, $class): void
- {
- if (isset($validObjectFieldsConfig[$formFieldName])) {
- return;
- }
-
- if (false === ($formFieldConfig['mapped'] ?? true)) {
- return;
- }
-
- throw new \RuntimeException(sprintf("Field '%s' doesn't exist in %s", $formFieldName, $class));
- }
-}
diff --git a/src/Form/Type/AutoFormType.php b/src/Form/Type/AutoFormType.php
deleted file mode 100644
index c565f95..0000000
--- a/src/Form/Type/AutoFormType.php
+++ /dev/null
@@ -1,48 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\Form\Type;
-
-use A2lix\AutoFormBundle\Form\EventListener\AutoFormListener;
-use Symfony\Component\Form\AbstractType;
-use Symfony\Component\Form\FormBuilderInterface;
-use Symfony\Component\OptionsResolver\Options;
-use Symfony\Component\OptionsResolver\OptionsResolver;
-
-class AutoFormType extends AbstractType
-{
- public function __construct(
- private readonly AutoFormListener $autoFormListener,
- ) {}
-
- public function buildForm(FormBuilderInterface $builder, array $options): void
- {
- $builder->addEventSubscriber($this->autoFormListener);
- }
-
- public function configureOptions(OptionsResolver $resolver): void
- {
- $resolver->setDefaults([
- 'fields' => [],
- 'excluded_fields' => [],
- ]);
-
- $resolver->setNormalizer('data_class', static function (Options $options, $value): string {
- if (empty($value)) {
- throw new \RuntimeException('Missing "data_class" option of "AutoFormType".');
- }
-
- return $value;
- });
- }
-}
diff --git a/src/Form/Type/AutoType.php b/src/Form/Type/AutoType.php
new file mode 100644
index 0000000..7153b5b
--- /dev/null
+++ b/src/Form/Type/AutoType.php
@@ -0,0 +1,100 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Form\Type;
+
+use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder;
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\Options;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * @phpstan-import-type FormOptionsDefaults from AutoTypeBuilder
+ *
+ * @extends AbstractType
+ */
+final class AutoType extends AbstractType
+{
+ /**
+ * @param list $globalExcludedChildren
+ * @param list $globalEmbeddedChildren
+ */
+ public function __construct(
+ private readonly AutoTypeBuilder $autoTypeBuilder,
+ private readonly array $globalExcludedChildren = [],
+ private readonly array $globalEmbeddedChildren = [],
+ private readonly bool $handleTranslationTypes = false,
+ ) {}
+
+ #[\Override]
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ /** @var FormOptionsDefaults $options */
+ $this->autoTypeBuilder->buildChildren($builder, $options);
+ }
+
+ #[\Override]
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'children' => [],
+ 'children_excluded_' => $this->globalExcludedChildren,
+ 'children_excluded' => null,
+ 'children_embedded_' => $this->globalEmbeddedChildren,
+ 'children_embedded' => null,
+ 'children_groups' => ['Default'],
+ 'builder' => null,
+ 'handle_translation_types' => $this->handleTranslationTypes,
+ 'gedmo_only' => false,
+ ]);
+
+ $resolver->setAllowedTypes('children_excluded', 'string[]|string|callable|null');
+ $resolver->setInfo('children_excluded', 'An array of properties, the * wildcard, or a callable (mixed $current): mixed');
+ $resolver->setNormalizer('children_excluded', static function (Options $options, mixed $value): mixed {
+ if (\is_callable($value)) {
+ return $value($options['children_excluded_']);
+ }
+
+ return $value ?? $options['children_excluded_'];
+ });
+
+ $resolver->setAllowedTypes('children_embedded', 'string[]|string|callable|null');
+ $resolver->setInfo('children_embedded', 'An array of properties, the * wildcard, or a callable (mixed $current): mixed');
+ $resolver->setNormalizer('children_embedded', static function (Options $options, mixed $value): mixed {
+ if (\is_callable($value)) {
+ return $value($options['children_embedded_']);
+ }
+
+ return $value ?? $options['children_embedded_'];
+ });
+
+ $resolver->setAllowedTypes('children_groups', 'string[]|null');
+ $resolver->setAllowedTypes('builder', 'callable|null');
+ $resolver->setInfo('builder', 'A callable (FormBuilderInterface $builder, string[] $classProperties): void');
+
+ // Translation options (translation_form_bundle required)
+ $resolver->setAllowedTypes('handle_translation_types', 'bool');
+ $resolver->setAllowedTypes('gedmo_only', 'bool');
+ // Others defaults FormType:class options
+ $resolver->setNormalizer('data_class', static function (Options $options, ?string $value): string {
+ if (null === $value) {
+ throw new \RuntimeException('Missing "data_class" option of "AutoType".');
+ }
+
+ return $value;
+ });
+ $resolver->setDefault('validation_groups', static function (Options $options): ?array {
+ /** @var list|null */
+ return $options['children_groups'];
+ });
+ }
+}
diff --git a/src/Form/TypeGuesser/TypeInfoTypeGuesser.php b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php
new file mode 100644
index 0000000..3f62091
--- /dev/null
+++ b/src/Form/TypeGuesser/TypeInfoTypeGuesser.php
@@ -0,0 +1,123 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Form\TypeGuesser;
+
+use Symfony\Component\Form\Extension\Core\Type as CoreType;
+use Symfony\Component\Form\FormTypeGuesserInterface;
+use Symfony\Component\Form\Guess\Guess;
+use Symfony\Component\Form\Guess\TypeGuess;
+use Symfony\Component\Form\Guess\ValueGuess;
+use Symfony\Component\TypeInfo\Exception\UnsupportedException;
+use Symfony\Component\TypeInfo\Type as TypeInfo;
+use Symfony\Component\TypeInfo\TypeIdentifier;
+use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface;
+
+final readonly class TypeInfoTypeGuesser implements FormTypeGuesserInterface
+{
+ public function __construct(
+ private TypeResolverInterface $typeResolver,
+ ) {}
+
+ #[\Override]
+ public function guessType(string $class, string $property): ?TypeGuess
+ {
+ /** @var class-string $class */
+ if (null === $typeInfo = $this->getTypeInfo($class, $property)) {
+ return null;
+ }
+
+ // FormTypes handling 'multiple' option
+ if ($typeInfo->isIdentifiedBy(TypeIdentifier::ARRAY)) {
+ if (!$typeInfo instanceof TypeInfo\CollectionType) {
+ throw new \RuntimeException(\sprintf('Unprecise PhpDoc array detected for "%s:%s". Fix it. For example: "@param list $%s"', $class, $property, $property));
+ }
+
+ $collValueType = $typeInfo->getCollectionValueType();
+
+ /** @var TypeInfo\ObjectType $collValueType */
+ return match (true) {
+ $collValueType->isIdentifiedBy(\UnitEnum::class) => new TypeGuess(CoreType\EnumType::class, ['class' => $collValueType->getClassName(), 'multiple' => true], Guess::HIGH_CONFIDENCE),
+ $collValueType->isIdentifiedBy(\DateTimeZone::class) => new TypeGuess(CoreType\TimezoneType::class, ['input' => 'datetimezone', 'multiple' => true], Guess::HIGH_CONFIDENCE),
+ default => new TypeGuess(CoreType\TextType::class, [], Guess::LOW_CONFIDENCE)
+ };
+ }
+
+ if ($typeInfo->isIdentifiedBy(TypeIdentifier::OBJECT)) {
+ if ($typeInfo->isIdentifiedBy(\UnitEnum::class)) {
+ /** @var TypeInfo\ObjectType */
+ $innerType = $typeInfo instanceof TypeInfo\NullableType ? $typeInfo->getWrappedType() : $typeInfo;
+
+ return new TypeGuess(CoreType\EnumType::class, ['class' => $innerType->getClassName()], Guess::HIGH_CONFIDENCE);
+ }
+
+ return match (true) {
+ $typeInfo->isIdentifiedBy(\DateTime::class) => new TypeGuess(CoreType\DateTimeType::class, [], Guess::HIGH_CONFIDENCE),
+ $typeInfo->isIdentifiedBy(\DateTimeImmutable::class) => new TypeGuess(CoreType\DateTimeType::class, ['input' => 'datetime_immutable'], Guess::HIGH_CONFIDENCE),
+ $typeInfo->isIdentifiedBy(\DateInterval::class) => new TypeGuess(CoreType\DateIntervalType::class, [], Guess::HIGH_CONFIDENCE),
+ $typeInfo->isIdentifiedBy(\DateTimeZone::class) => new TypeGuess(CoreType\TimezoneType::class, ['input' => 'datetimezone'], Guess::HIGH_CONFIDENCE),
+ $typeInfo->isIdentifiedBy('Symfony\Component\Uid\Ulid') => new TypeGuess(CoreType\UlidType::class, [], Guess::HIGH_CONFIDENCE),
+ $typeInfo->isIdentifiedBy('Symfony\Component\Uid\Uuid') => new TypeGuess(CoreType\UuidType::class, [], Guess::HIGH_CONFIDENCE),
+ $typeInfo->isIdentifiedBy('Symfony\Component\HttpFoundation\File\File') => new TypeGuess(CoreType\FileType::class, [], Guess::HIGH_CONFIDENCE),
+ default => new TypeGuess(CoreType\TextType::class, [], Guess::LOW_CONFIDENCE)
+ };
+ }
+
+ return match (true) {
+ $typeInfo->isIdentifiedBy(TypeIdentifier::STRING) => new TypeGuess(CoreType\TextType::class, [], Guess::MEDIUM_CONFIDENCE),
+ $typeInfo->isIdentifiedBy(TypeIdentifier::INT) => new TypeGuess(CoreType\IntegerType::class, [], Guess::MEDIUM_CONFIDENCE),
+ $typeInfo->isIdentifiedBy(TypeIdentifier::FLOAT) => new TypeGuess(CoreType\NumberType::class, [], Guess::MEDIUM_CONFIDENCE),
+ $typeInfo->isIdentifiedBy(TypeIdentifier::BOOL) => new TypeGuess(CoreType\CheckboxType::class, [], Guess::HIGH_CONFIDENCE),
+ default => new TypeGuess(CoreType\TextType::class, [], Guess::LOW_CONFIDENCE)
+ };
+ }
+
+ #[\Override]
+ public function guessRequired(string $class, string $property): ?ValueGuess
+ {
+ /** @var class-string $class */
+ if (null === $typeInfo = $this->getTypeInfo($class, $property)) {
+ return null;
+ }
+
+ return new ValueGuess(!$typeInfo->isNullable(), Guess::MEDIUM_CONFIDENCE);
+ }
+
+ #[\Override]
+ public function guessMaxLength(string $class, string $property): ?ValueGuess
+ {
+ return null;
+ }
+
+ #[\Override]
+ public function guessPattern(string $class, string $property): ?ValueGuess
+ {
+ return null;
+ }
+
+ /**
+ * @param class-string $class
+ */
+ private function getTypeInfo(string $class, string $property): ?TypeInfo
+ {
+ try {
+ $refProperty = new \ReflectionProperty($class, $property);
+ } catch (\ReflectionException) {
+ return null;
+ }
+
+ try {
+ return $this->typeResolver->resolve($refProperty);
+ } catch (UnsupportedException) {
+ return null;
+ }
+ }
+}
diff --git a/src/ObjectInfo/DoctrineORMInfo.php b/src/ObjectInfo/DoctrineORMInfo.php
deleted file mode 100644
index cb24946..0000000
--- a/src/ObjectInfo/DoctrineORMInfo.php
+++ /dev/null
@@ -1,93 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\ObjectInfo;
-
-use A2lix\AutoFormBundle\Form\Type\AutoFormType;
-use Doctrine\Persistence\Mapping\ClassMetadata;
-use Doctrine\Persistence\Mapping\ClassMetadataFactory;
-use Symfony\Component\Form\Extension\Core\Type\CollectionType;
-
-class DoctrineORMInfo
-{
- public function __construct(
- private readonly ClassMetadataFactory $classMetadataFactory,
- ) {}
-
- public function getFieldsConfig(string $class): array
- {
- $fieldsConfig = [];
-
- $metadata = $this->classMetadataFactory->getMetadataFor($class);
-
- if (!empty($fields = $metadata->getFieldNames())) {
- $fieldsConfig = array_fill_keys($fields, []);
- }
-
- if (!empty($assocNames = $metadata->getAssociationNames())) {
- $fieldsConfig += $this->getAssocsConfig($metadata, $assocNames);
- }
-
- return $fieldsConfig;
- }
-
- public function getAssociationTargetClass(string $class, string $fieldName): string
- {
- $metadata = $this->classMetadataFactory->getMetadataFor($class);
-
- if (!$metadata->hasAssociation($fieldName)) {
- throw new \RuntimeException(sprintf('Unable to find the association target class of "%s" in %s.', $fieldName, $class));
- }
-
- return $metadata->getAssociationTargetClass($fieldName);
- }
-
- private function getAssocsConfig(ClassMetadata $metadata, array $assocNames): array
- {
- $assocsConfigs = [];
-
- foreach ($assocNames as $assocName) {
- $associationMapping = $metadata->getAssociationMapping($assocName);
-
- if (isset($associationMapping['inversedBy'])) {
- $assocsConfigs[$assocName] = [];
-
- continue;
- }
-
- $class = $metadata->getAssociationTargetClass($assocName);
-
- if ($metadata->isSingleValuedAssociation($assocName)) {
- $assocsConfigs[$assocName] = [
- 'field_type' => AutoFormType::class,
- 'data_class' => $class,
- 'required' => false,
- ];
-
- continue;
- }
-
- $assocsConfigs[$assocName] = [
- 'field_type' => CollectionType::class,
- 'entry_type' => AutoFormType::class,
- 'entry_options' => [
- 'data_class' => $class,
- ],
- 'allow_add' => true,
- 'by_reference' => false,
- ];
- }
-
- return $assocsConfigs;
- }
-}
diff --git a/src/Resources/config/a2lix_form.xml b/src/Resources/config/a2lix_form.xml
deleted file mode 100644
index 3328706..0000000
--- a/src/Resources/config/a2lix_form.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Resources/config/object_info.xml b/src/Resources/config/object_info.xml
deleted file mode 100644
index 044bc91..0000000
--- a/src/Resources/config/object_info.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/Fixtures/Dto/Media1.php b/tests/Fixtures/Dto/Media1.php
new file mode 100644
index 0000000..7e26119
--- /dev/null
+++ b/tests/Fixtures/Dto/Media1.php
@@ -0,0 +1,38 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Tests\Fixtures\Dto;
+
+use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom;
+use Symfony\Component\Form\Extension\Core\Type as CoreType;
+
+class Media1
+{
+ public function __construct(
+ public readonly ?string $id = null,
+ #[AutoTypeCustom(options: ['help' => 'media.url_help'])]
+ public readonly ?string $url = null,
+ #[AutoTypeCustom(type: CoreType\TextareaType::class)]
+ private ?string $description = null,
+ ) {}
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(?string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+}
diff --git a/tests/Fixtures/Dto/Product1.php b/tests/Fixtures/Dto/Product1.php
new file mode 100644
index 0000000..6e6df83
--- /dev/null
+++ b/tests/Fixtures/Dto/Product1.php
@@ -0,0 +1,61 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Tests\Fixtures\Dto;
+
+use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom;
+use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Symfony\Component\Form\Extension\Core\Type as CoreType;
+
+class Product1
+{
+ /**
+ * @param list $tags
+ * @param Collection $mediaColl
+ * @param list $statusList
+ */
+ public function __construct(
+ #[AutoTypeCustom(excluded: true)]
+ public readonly ?string $id = null,
+ public readonly ?string $title = null,
+ #[AutoTypeCustom(type: CoreType\TextareaType::class, name: 'desc', options: ['attr' => ['rows' => 2]])]
+ private ?string $description = null,
+ public readonly ?int $code = null,
+ public readonly array $tags = [],
+ public readonly ?Media1 $mediaMain = null,
+ public ?Collection $mediaColl = null,
+ public readonly ?ProductStatus $status = null,
+ public readonly ?array $statusList = null,
+ #[AutoTypeCustom(groups: ['Default', 'validity'])]
+ public readonly ?\DateTimeImmutable $validityStartAt = null,
+ #[AutoTypeCustom(groups: ['Default', 'validity'])]
+ public readonly ?\DateTimeImmutable $validityEndAt = null,
+ // @phpstan-ignore property.onlyWritten
+ private ?\DateTimeImmutable $createdAt = null,
+ ) {
+ $this->mediaColl ??= new ArrayCollection();
+ $this->createdAt = new \DateTimeImmutable();
+ }
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(?string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+}
diff --git a/tests/Fixtures/Entity/Media.php b/tests/Fixtures/Entity/Media.php
deleted file mode 100644
index 5b70b99..0000000
--- a/tests/Fixtures/Entity/Media.php
+++ /dev/null
@@ -1,75 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\Tests\Fixtures\Entity;
-
-use Doctrine\ORM\Mapping as ORM;
-
-#[ORM\Entity]
-class Media
-{
- #[ORM\Id]
- #[ORM\Column(type: 'integer')]
- #[ORM\GeneratedValue(strategy: 'AUTO')]
- private ?int $id = null;
-
- #[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'medias')]
- private Product $product;
-
- #[ORM\Column(nullable: true)]
- private ?string $url = null;
-
- #[ORM\Column(nullable: true)]
- private ?string $description = null;
-
- public function getId(): ?int
- {
- return $this->id;
- }
-
- public function getProduct(): Product
- {
- return $this->product;
- }
-
- public function setProduct(Product $product): self
- {
- $this->product = $product;
-
- return $this;
- }
-
- public function getUrl(): ?string
- {
- return $this->url;
- }
-
- public function setUrl(?string $url): self
- {
- $this->url = $url;
-
- return $this;
- }
-
- public function getDescription(): ?string
- {
- return $this->description;
- }
-
- public function setDescription(?string $description): self
- {
- $this->description = $description;
-
- return $this;
- }
-}
diff --git a/tests/Fixtures/Entity/Media1.php b/tests/Fixtures/Entity/Media1.php
new file mode 100644
index 0000000..96a00cd
--- /dev/null
+++ b/tests/Fixtures/Entity/Media1.php
@@ -0,0 +1,42 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Tests\Fixtures\Entity;
+
+use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Form\Extension\Core\Type as CoreType;
+
+#[ORM\Entity]
+class Media1
+{
+ #[ORM\Id]
+ #[ORM\Column]
+ #[ORM\GeneratedValue]
+ public private(set) ?int $id = null;
+
+ #[ORM\Column]
+ #[AutoTypeCustom(excluded: true)]
+ public \DateTimeImmutable $createdAt;
+
+ #[ORM\Column]
+ #[AutoTypeCustom(options: ['help' => 'media.url_help'])]
+ public string $url;
+
+ #[ORM\Column(nullable: true)]
+ #[AutoTypeCustom(type: CoreType\TextareaType::class)]
+ public ?string $description = null;
+
+ #[ORM\ManyToOne(targetEntity: Product1::class, inversedBy: 'mediaColl')]
+ #[ORM\JoinColumn(name: 'product_id', referencedColumnName: 'id', nullable: false)]
+ #[AutoTypeCustom(excluded: true)]
+ public Product1 $product;
+}
diff --git a/tests/Fixtures/Entity/Product.php b/tests/Fixtures/Entity/Product.php
deleted file mode 100644
index 898dcdb..0000000
--- a/tests/Fixtures/Entity/Product.php
+++ /dev/null
@@ -1,122 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\Tests\Fixtures\Entity;
-
-use Doctrine\Common\Collections\ArrayCollection;
-use Doctrine\Common\Collections\Collection;
-use Doctrine\ORM\Mapping as ORM;
-
-#[ORM\Entity]
-class Product
-{
- #[ORM\Id]
- #[ORM\Column(type: 'integer')]
- #[ORM\GeneratedValue(strategy: 'AUTO')]
- private ?int $id = null;
-
- #[ORM\Column(nullable: true)]
- private ?string $title = null;
-
- #[ORM\Column(type: 'text', nullable: true)]
- private ?string $description = null;
-
- #[ORM\Column(nullable: true)]
- private ?string $url = null;
-
- #[ORM\ManyToOne(targetEntity: Media::class)]
- private Media $mainMedia;
-
- #[ORM\OneToMany(targetEntity: Media::class, mappedBy: 'product', cascade: ['all'], orphanRemoval: true)]
- private ArrayCollection $medias;
-
- public function __construct()
- {
- $this->medias = new ArrayCollection();
- }
-
- public function getId(): ?int
- {
- return $this->id;
- }
-
- public function getTitle(): ?string
- {
- return $this->title;
- }
-
- public function setTitle(?string $title): self
- {
- $this->title = $title;
-
- return $this;
- }
-
- public function getDescription(): ?string
- {
- return $this->description;
- }
-
- public function setDescription(?string $description): self
- {
- $this->description = $description;
-
- return $this;
- }
-
- public function getUrl(): ?string
- {
- return $this->url;
- }
-
- public function setUrl(?string $url): self
- {
- $this->url = $url;
-
- return $this;
- }
-
- public function getMainMedia(): ?Media
- {
- return $this->mainMedia;
- }
-
- public function setMainMedia(?Media $mainMedia): self
- {
- $this->mainMedia = $mainMedia;
-
- return $this;
- }
-
- public function getMedias(): Collection
- {
- return $this->medias;
- }
-
- public function addMedia(Media $media): self
- {
- if (!$this->medias->contains($media)) {
- $media->setProduct($this);
- $this->medias->add($media);
- }
-
- return $this;
- }
-
- public function removeMedia(Media $media): self
- {
- $this->medias->removeElement($media);
-
- return $this;
- }
-}
diff --git a/tests/Fixtures/Entity/Product1.php b/tests/Fixtures/Entity/Product1.php
new file mode 100644
index 0000000..354c106
--- /dev/null
+++ b/tests/Fixtures/Entity/Product1.php
@@ -0,0 +1,88 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Tests\Fixtures\Entity;
+
+use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom;
+use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\DBAL\Types\Types;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Form\Extension\Core\Type as CoreType;
+
+#[ORM\Entity]
+class Product1
+{
+ #[ORM\Id]
+ #[ORM\Column]
+ #[ORM\GeneratedValue]
+ #[AutoTypeCustom(excluded: true)]
+ public private(set) ?int $id = null;
+
+ #[ORM\Column]
+ public string $title;
+
+ #[ORM\Column(nullable: true)]
+ #[AutoTypeCustom(type: CoreType\TextareaType::class, name: 'desc', options: ['attr' => ['rows' => 2]])]
+ private ?string $description = null;
+
+ #[ORM\Column]
+ public int $code;
+
+ /** @var list */
+ #[ORM\Column]
+ public array $tags = [];
+
+ #[ORM\ManyToOne(targetEntity: Media1::class)]
+ public ?Media1 $mediaMain = null;
+
+ /** @var Collection */
+ #[ORM\OneToMany(targetEntity: Media1::class, mappedBy: 'product', cascade: ['all'], orphanRemoval: true)]
+ public Collection $mediaColl;
+
+ #[ORM\Column(enumType: ProductStatus::class)]
+ public ProductStatus $status;
+
+ /** @var list */
+ #[ORM\Column(type: Types::SIMPLE_ARRAY, enumType: ProductStatus::class)]
+ public array $statusList;
+
+ #[ORM\Column]
+ #[AutoTypeCustom(groups: ['Default', 'validity'])]
+ public \DateTimeImmutable $validityStartAt;
+
+ #[ORM\Column]
+ #[AutoTypeCustom(groups: ['Default', 'validity'])]
+ public \DateTimeImmutable $validityEndAt;
+
+ #[ORM\Column]
+ // @phpstan-ignore property.onlyWritten
+ private \DateTimeImmutable $createdAt;
+
+ public function __construct()
+ {
+ $this->mediaColl = new ArrayCollection();
+ $this->createdAt = new \DateTimeImmutable();
+ }
+
+ public function getDescription(): ?string
+ {
+ return $this->description;
+ }
+
+ public function setDescription(?string $description): self
+ {
+ $this->description = $description;
+
+ return $this;
+ }
+}
diff --git a/src/Form/Manipulator/FormManipulatorInterface.php b/tests/Fixtures/ProductStatus.php
similarity index 51%
rename from src/Form/Manipulator/FormManipulatorInterface.php
rename to tests/Fixtures/ProductStatus.php
index ba16a71..ea1c1d1 100644
--- a/src/Form/Manipulator/FormManipulatorInterface.php
+++ b/tests/Fixtures/ProductStatus.php
@@ -1,6 +1,4 @@
-
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Tests\Fixtures\Type;
+
+use Symfony\Component\Form\AbstractType;
+use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
+use Symfony\Component\Form\FormBuilderInterface;
+use Symfony\Component\OptionsResolver\OptionsResolver;
+
+/**
+ * @extends AbstractType
+ */
+class ValidityRangeType extends AbstractType
+{
+ #[\Override]
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder
+ ->add('validityStartAt', DateTimeType::class, [
+ 'input' => 'datetime_immutable',
+ 'date_widget' => 'single_text',
+ 'time_widget' => 'single_text',
+ 'attr' => ['class' => 'form-grid'],
+ ])
+ ->add('validityEndAt', DateTimeType::class, [
+ 'input' => 'datetime_immutable',
+ 'date_widget' => 'single_text',
+ 'time_widget' => 'single_text',
+ 'attr' => ['class' => 'form-grid'],
+ ])
+ ;
+ }
+
+ #[\Override]
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'inherit_data' => true,
+ ]);
+ }
+}
diff --git a/tests/Form/DataProviderDto.php b/tests/Form/DataProviderDto.php
new file mode 100644
index 0000000..4623e76
--- /dev/null
+++ b/tests/Form/DataProviderDto.php
@@ -0,0 +1,373 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Tests\Form;
+
+use A2lix\AutoFormBundle\Form\Type\AutoType;
+use A2lix\AutoFormBundle\Tests\Fixtures\Dto\Media1;
+use A2lix\AutoFormBundle\Tests\Fixtures\Dto\Product1;
+use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus;
+use Symfony\Component\Form\Extension\Core\Type as CoreType;
+use Symfony\Component\Form\FormBuilderInterface;
+
+final class DataProviderDto
+{
+ /**
+ * @return \Iterator>
+ */
+ public static function provideScenarioCases(): iterable
+ {
+ yield 'Dto - Product1 with default behavior, no options' => [
+ new TestScenario(
+ obj: new Product1(),
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ ],
+ 'tags' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'mediaMain' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'mediaColl' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'status' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'class' => ProductStatus::class,
+ ],
+ 'statusList' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'multiple' => true,
+ 'class' => ProductStatus::class,
+ ],
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'desc' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'attr' => [
+ 'rows' => 2,
+ ],
+ 'property_path' => 'description',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Dto - Product1 with children_embedded = *' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_embedded' => '*',
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ ],
+ 'tags' => [
+ 'expected_type' => CoreType\CollectionType::class,
+ 'entry_type' => CoreType\TextType::class,
+ 'entry_options' => [
+ 'block_name' => 'entry',
+ ],
+ ],
+ 'mediaMain' => [
+ 'expected_type' => AutoType::class,
+ 'expected_children' => [
+ 'url' => [
+ 'expected_type' => CoreType\TextType::class,
+ 'help' => 'media.url_help',
+ ],
+ 'description' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ ],
+ ],
+ ],
+ 'mediaColl' => [
+ 'expected_type' => CoreType\CollectionType::class,
+ 'entry_type' => AutoType::class,
+ 'entry_options' => [
+ 'data_class' => Media1::class,
+ 'block_name' => 'entry',
+ ],
+ ],
+ 'status' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'class' => ProductStatus::class,
+ ],
+ 'statusList' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'multiple' => true,
+ 'class' => ProductStatus::class,
+ ],
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'desc' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'attr' => [
+ 'rows' => 2,
+ ],
+ 'property_path' => 'description',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Dto - Product1 with children_embedded & child_embedded' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_embedded' => ['mediaColl'],
+ 'children' => [
+ 'tags' => [
+ 'child_embedded' => true,
+ ],
+ ],
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ ],
+ 'tags' => [
+ 'expected_type' => CoreType\CollectionType::class,
+ 'entry_type' => CoreType\TextType::class,
+ 'entry_options' => [
+ 'block_name' => 'entry',
+ ],
+ ],
+ 'mediaMain' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'mediaColl' => [
+ 'expected_type' => CoreType\CollectionType::class,
+ 'entry_options' => [
+ 'data_class' => Media1::class,
+ 'block_name' => 'entry',
+ ],
+ ],
+ 'status' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'class' => ProductStatus::class,
+ ],
+ 'statusList' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'multiple' => true,
+ 'class' => ProductStatus::class,
+ ],
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'desc' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'attr' => [
+ 'rows' => 2,
+ ],
+ 'property_path' => 'description',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Dto - Product1 with children_excluded = *' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_excluded' => '*',
+ ],
+ expectedForm: [],
+ ),
+ ];
+
+ yield 'Dto - Product1 with children_excluded = *, custom overrides' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_excluded' => '*',
+ 'children' => [
+ 'title' => [],
+ 'code' => [
+ 'label' => 'product.code_label',
+ 'required' => false,
+ ],
+ 'description' => [
+ 'attr' => [
+ 'rows' => 4,
+ ],
+ ],
+ ],
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ 'label' => 'product.code_label',
+ 'required' => false,
+ ],
+ 'desc' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'attr' => [
+ 'rows' => 4,
+ ],
+ 'property_path' => 'description',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Dto - Product1 with children_excluded & child_excluded' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_excluded' => static fn (array $current) => [...$current, 'tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'],
+ 'children' => [
+ 'code' => [
+ 'child_excluded' => true,
+ ],
+ 'description' => [
+ 'child_excluded' => true,
+ ],
+ ],
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Dto - Product1 with children_groups' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_groups' => ['unknownGrp'],
+ ],
+ expectedForm: [
+ ],
+ ),
+ ];
+
+ yield 'Dto - Product1 with children_groups & child_groups' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_groups' => ['onTheFlyGrp', 'validity'],
+ 'children' => [
+ 'title' => [
+ 'child_groups' => ['onTheFlyGrp'],
+ ],
+ 'code' => [
+ 'child_groups' => ['onTheFlyGrp', 'validity'],
+ ],
+ ],
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ ],
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Dto - Product1 with children & builder callables' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_excluded' => '*',
+ 'children' => [
+ 'description' => static fn (FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface => $builder->create('description', CoreType\TextareaType::class, [
+ 'attr' => $propAttributeOptions['attr'],
+ 'label' => 'product.description_label',
+ ]),
+ '_ignoredNaming_' => static fn (FormBuilderInterface $builder): FormBuilderInterface => $builder
+ ->create('validity_range', CoreType\FormType::class, ['inherit_data' => true])
+ ->add('validityStartAt', CoreType\DateType::class)
+ ->add('validityEndAt', CoreType\DateType::class),
+ 'agreement' => [
+ 'child_type' => CoreType\CheckboxType::class,
+ 'mapped' => false,
+ ],
+ ],
+ 'builder' => static function (FormBuilderInterface $builder, array $classProperties): void {
+ $builder->add('save', CoreType\SubmitType::class);
+ },
+ ],
+ expectedForm: [
+ 'description' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'label' => 'product.description_label',
+ 'attr' => [
+ 'rows' => 2,
+ ],
+ ],
+ 'validity_range' => [
+ 'expected_type' => CoreType\FormType::class,
+ 'expected_children' => [
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateType::class,
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateType::class,
+ ],
+ ],
+ ],
+ 'agreement' => [
+ 'expected_type' => CoreType\CheckboxType::class,
+ 'mapped' => false,
+ ],
+ 'save' => [
+ 'expected_type' => CoreType\SubmitType::class,
+ ],
+ ],
+ ),
+ ];
+ }
+}
diff --git a/tests/Form/DataProviderEntity.php b/tests/Form/DataProviderEntity.php
new file mode 100644
index 0000000..3b4dc53
--- /dev/null
+++ b/tests/Form/DataProviderEntity.php
@@ -0,0 +1,373 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Tests\Form;
+
+use A2lix\AutoFormBundle\Form\Type\AutoType;
+use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media1;
+use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product1;
+use A2lix\AutoFormBundle\Tests\Fixtures\ProductStatus;
+use Symfony\Bridge\Doctrine\Form\Type\EntityType;
+use Symfony\Component\Form\Extension\Core\Type as CoreType;
+use Symfony\Component\Form\FormBuilderInterface;
+
+final class DataProviderEntity
+{
+ /**
+ * @return \Iterator>
+ */
+ public static function provideScenarioCases(): iterable
+ {
+ yield 'Entity - Product1 with default behavior, no options' => [
+ new TestScenario(
+ obj: new Product1(),
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ ],
+ 'tags' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'mediaMain' => [
+ 'expected_type' => EntityType::class,
+ ],
+ 'mediaColl' => [
+ 'expected_type' => EntityType::class,
+ ],
+ 'status' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'class' => ProductStatus::class,
+ ],
+ 'statusList' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'multiple' => true,
+ 'class' => ProductStatus::class,
+ ],
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'desc' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'attr' => [
+ 'rows' => 2,
+ ],
+ 'property_path' => 'description',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Entity - Product1 with children_embedded = *' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_embedded' => '*',
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ ],
+ 'tags' => [
+ 'expected_type' => CoreType\CollectionType::class,
+ 'entry_type' => CoreType\TextType::class,
+ 'entry_options' => [
+ 'block_name' => 'entry',
+ ],
+ ],
+ 'mediaMain' => [
+ 'expected_type' => AutoType::class,
+ 'expected_children' => [
+ 'url' => [
+ 'expected_type' => CoreType\TextType::class,
+ 'help' => 'media.url_help',
+ ],
+ 'description' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ ],
+ ],
+ ],
+ 'mediaColl' => [
+ 'expected_type' => CoreType\CollectionType::class,
+ 'entry_type' => AutoType::class,
+ 'entry_options' => [
+ 'data_class' => Media1::class,
+ 'block_name' => 'entry',
+ ],
+ ],
+ 'status' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'class' => ProductStatus::class,
+ ],
+ 'statusList' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'multiple' => true,
+ 'class' => ProductStatus::class,
+ ],
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'desc' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'attr' => [
+ 'rows' => 2,
+ ],
+ 'property_path' => 'description',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Entity - Product1 with children_embedded & child_embedded' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_embedded' => ['mediaColl'],
+ 'children' => [
+ 'tags' => [
+ 'child_embedded' => true,
+ ],
+ ],
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ ],
+ 'tags' => [
+ 'expected_type' => CoreType\CollectionType::class,
+ 'entry_type' => CoreType\TextType::class,
+ 'entry_options' => [
+ 'block_name' => 'entry',
+ ],
+ ],
+ 'mediaMain' => [
+ 'expected_type' => EntityType::class,
+ ],
+ 'mediaColl' => [
+ 'expected_type' => CoreType\CollectionType::class,
+ 'entry_options' => [
+ 'data_class' => Media1::class,
+ 'block_name' => 'entry',
+ ],
+ ],
+ 'status' => [
+ 'expected_type' => CoreType\EnumType::class,
+ ],
+ 'statusList' => [
+ 'expected_type' => CoreType\EnumType::class,
+ 'multiple' => true,
+ 'class' => ProductStatus::class,
+ ],
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'desc' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'attr' => [
+ 'rows' => 2,
+ ],
+ 'property_path' => 'description',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Entity - Product1 with children_excluded = *' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_excluded' => '*',
+ ],
+ expectedForm: [],
+ ),
+ ];
+
+ yield 'Entity - Product1 with children_excluded = *, custom overrides' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_excluded' => '*',
+ 'children' => [
+ 'title' => [],
+ 'code' => [
+ 'label' => 'product.code_label',
+ 'required' => false,
+ ],
+ 'description' => [
+ 'attr' => [
+ 'rows' => 4,
+ ],
+ ],
+ ],
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ 'label' => 'product.code_label',
+ 'required' => false,
+ ],
+ 'desc' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'attr' => [
+ 'rows' => 4,
+ ],
+ 'property_path' => 'description',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Entity - Product1 with children_excluded & child_excluded' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_excluded' => static fn (array $current) => [...$current, 'tags', 'mediaMain', 'mediaColl', 'status', 'statusList', 'validityStartAt', 'validityEndAt'],
+ 'children' => [
+ 'code' => [
+ 'child_excluded' => true,
+ ],
+ 'description' => [
+ 'child_excluded' => true,
+ ],
+ ],
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Entity - Product1 with children_groups' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_groups' => ['unknownGrp'],
+ ],
+ expectedForm: [
+ ],
+ ),
+ ];
+
+ yield 'Entity - Product1 with children_groups & child_groups' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_groups' => ['onTheFlyGrp', 'validity'],
+ 'children' => [
+ 'title' => [
+ 'child_groups' => ['onTheFlyGrp'],
+ ],
+ 'code' => [
+ 'child_groups' => ['onTheFlyGrp', 'validity'],
+ ],
+ ],
+ ],
+ expectedForm: [
+ 'title' => [
+ 'expected_type' => CoreType\TextType::class,
+ ],
+ 'code' => [
+ 'expected_type' => CoreType\IntegerType::class,
+ ],
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateTimeType::class,
+ 'input' => 'datetime_immutable',
+ ],
+ ],
+ ),
+ ];
+
+ yield 'Entity - Product1 with children & builder callables' => [
+ new TestScenario(
+ obj: new Product1(),
+ formOptions: [
+ 'children_excluded' => '*',
+ 'children' => [
+ 'description' => static fn (FormBuilderInterface $builder, array $propAttributeOptions): FormBuilderInterface => $builder->create('description', CoreType\TextareaType::class, [
+ 'attr' => $propAttributeOptions['attr'],
+ 'label' => 'product.description_label',
+ ]),
+ '_ignoredNaming_' => static fn (FormBuilderInterface $builder): FormBuilderInterface => $builder
+ ->create('validity_range', CoreType\FormType::class, ['inherit_data' => true])
+ ->add('validityStartAt', CoreType\DateType::class)
+ ->add('validityEndAt', CoreType\DateType::class),
+ 'agreement' => [
+ 'child_type' => CoreType\CheckboxType::class,
+ 'mapped' => false,
+ ],
+ ],
+ 'builder' => static function (FormBuilderInterface $builder, array $classProperties): void {
+ $builder->add('save', CoreType\SubmitType::class);
+ },
+ ],
+ expectedForm: [
+ 'description' => [
+ 'expected_type' => CoreType\TextareaType::class,
+ 'label' => 'product.description_label',
+ 'attr' => [
+ 'rows' => 2,
+ ],
+ ],
+ 'validity_range' => [
+ 'expected_type' => CoreType\FormType::class,
+ 'expected_children' => [
+ 'validityStartAt' => [
+ 'expected_type' => CoreType\DateType::class,
+ ],
+ 'validityEndAt' => [
+ 'expected_type' => CoreType\DateType::class,
+ ],
+ ],
+ ],
+ 'agreement' => [
+ 'expected_type' => CoreType\CheckboxType::class,
+ 'mapped' => false,
+ ],
+ 'save' => [
+ 'expected_type' => CoreType\SubmitType::class,
+ ],
+ ],
+ ),
+ ];
+ }
+}
diff --git a/tests/Form/TestScenario.php b/tests/Form/TestScenario.php
new file mode 100644
index 0000000..8c700e6
--- /dev/null
+++ b/tests/Form/TestScenario.php
@@ -0,0 +1,32 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Tests\Form;
+
+/**
+ * @phpstan-type ExpectedChildren = array
+ */
+final readonly class TestScenario
+{
+ /**
+ * @param array $formOptions
+ * @param ExpectedChildren $expectedForm
+ */
+ public function __construct(
+ public ?object $obj,
+ public array $formOptions = [],
+ public array $expectedForm = [],
+ ) {}
+}
diff --git a/tests/Form/Type/AutoFormTypeAdvancedTest.php b/tests/Form/Type/AutoFormTypeAdvancedTest.php
deleted file mode 100755
index 2685e59..0000000
--- a/tests/Form/Type/AutoFormTypeAdvancedTest.php
+++ /dev/null
@@ -1,150 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\Tests\Form\Type;
-
-use A2lix\AutoFormBundle\Form\Type\AutoFormType;
-use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media;
-use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product;
-use A2lix\AutoFormBundle\Tests\Form\TypeTestCase;
-use Symfony\Component\Form\Extension\Core\Type\SubmitType;
-use Symfony\Component\Form\PreloadedExtension;
-
-/**
- * @internal
- */
-final class AutoFormTypeAdvancedTest extends TypeTestCase
-{
- public function testCreationFormWithOverriddenFieldsLabel(): Product
- {
- $form = $this->factory->createBuilder(AutoFormType::class, new Product(), [
- 'fields' => [
- 'mainMedia' => [
- 'label' => 'Main Media',
- ],
- 'url' => [
- 'label' => 'URL/URI',
- ],
- ],
- ])
- ->add('create', SubmitType::class)
- ->getForm()
- ;
-
- $media1 = new Media();
- $media1->setUrl('http://example.org/media1')
- ->setDescription('media1 desc')
- ;
- $media2 = new Media();
- $media2->setUrl('http://example.org/media2')
- ->setDescription('media2 desc')
- ;
- $media3 = new Media();
- $media3->setUrl('http://example.org/media3')
- ->setDescription('media3 desc')
- ;
-
- $product = new Product();
- $product
- ->setUrl('a2lix.fr')
- ->setMainMedia($media3)
- ->addMedia($media1)
- ->addMedia($media2)
- ;
-
- $formData = [
- 'url' => 'a2lix.fr',
- 'mainMedia' => [
- 'url' => 'http://example.org/media3',
- 'description' => 'media3 desc',
- ],
- 'medias' => [
- [
- 'url' => 'http://example.org/media1',
- 'description' => 'media1 desc',
- ],
- [
- 'url' => 'http://example.org/media2',
- 'description' => 'media2 desc',
- ],
- ],
- ];
-
- $form->submit($formData);
- self::assertTrue($form->isSynchronized());
- self::assertEquals($product, $form->getData());
- self::assertEquals('URL/URI', $form->get('url')->getConfig()->getOptions()['label']);
-
- return $product;
- }
-
- public function testCreationFormWithOverriddenFieldsMappedFalse(): Product
- {
- $form = $this->factory->createBuilder(AutoFormType::class, new Product(), [
- 'fields' => [
- 'color' => [
- 'mapped' => false,
- ],
- ],
- ])
- ->add('create', SubmitType::class)
- ->getForm()
- ;
-
- $media1 = new Media();
- $media1->setUrl('http://example.org/media1')
- ->setDescription('media1 desc')
- ;
- $media2 = new Media();
- $media2->setUrl('http://example.org/media2')
- ->setDescription('media2 desc')
- ;
-
- $product = new Product();
- $product->setUrl('a2lix.fr')
- ->addMedia($media1)
- ->addMedia($media2)
- ;
-
- $formData = [
- 'url' => 'a2lix.fr',
- 'color' => 'blue',
- 'medias' => [
- [
- 'url' => 'http://example.org/media1',
- 'description' => 'media1 desc',
- ],
- [
- 'url' => 'http://example.org/media2',
- 'description' => 'media2 desc',
- ],
- ],
- ];
-
- $form->submit($formData);
- self::assertTrue($form->isSynchronized());
- self::assertEquals($product, $form->getData());
- self::assertEquals('blue', $form->get('color')->getData());
-
- return $product;
- }
-
- protected function getExtensions(): array
- {
- $autoFormType = $this->getConfiguredAutoFormType();
-
- return [new PreloadedExtension([
- $autoFormType,
- ], [])];
- }
-}
diff --git a/tests/Form/Type/AutoFormTypeSimpleTest.php b/tests/Form/Type/AutoFormTypeSimpleTest.php
deleted file mode 100755
index 2d9767b..0000000
--- a/tests/Form/Type/AutoFormTypeSimpleTest.php
+++ /dev/null
@@ -1,132 +0,0 @@
-
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace A2lix\AutoFormBundle\Tests\Form\Type;
-
-use A2lix\AutoFormBundle\Form\Type\AutoFormType;
-use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Media;
-use A2lix\AutoFormBundle\Tests\Fixtures\Entity\Product;
-use A2lix\AutoFormBundle\Tests\Form\TypeTestCase;
-use Symfony\Component\Form\Extension\Core\Type\SubmitType;
-use Symfony\Component\Form\PreloadedExtension;
-
-/**
- * @internal
- */
-final class AutoFormTypeSimpleTest extends TypeTestCase
-{
- public function testEmptyForm(): void
- {
- $form = $this->factory->createBuilder(AutoFormType::class, new Product())
- ->add('create', SubmitType::class)
- ->getForm()
- ;
-
- self::assertEquals(['create', 'title', 'description', 'url', 'mainMedia', 'medias'], array_keys($form->all()), 'Fields should matches Product fields');
-
- $mediasFormOptions = $form->get('medias')->getConfig()->getOptions();
- self::assertEquals(AutoFormType::class, $mediasFormOptions['entry_type'], 'Media type should be an AutoType');
- self::assertEquals(Media::class, $mediasFormOptions['entry_options']['data_class'], 'Media should have its right data_class');
- }
-
- public function testCreationForm(): Product
- {
- $form = $this->factory->createBuilder(AutoFormType::class, new Product())
- ->add('create', SubmitType::class)
- ->getForm()
- ;
-
- $media1 = new Media();
- $media1->setUrl('http://example.org/media1')
- ->setDescription('media1 desc')
- ;
- $media2 = new Media();
- $media2->setUrl('http://example.org/media2')
- ->setDescription('media2 desc')
- ;
-
- $product = new Product();
- $product->setUrl('a2lix.fr')
- ->addMedia($media1)
- ->addMedia($media2)
- ;
-
- $formData = [
- 'url' => 'a2lix.fr',
- 'medias' => [
- [
- 'url' => 'http://example.org/media1',
- 'description' => 'media1 desc',
- ],
- [
- 'url' => 'http://example.org/media2',
- 'description' => 'media2 desc',
- ],
- ],
- ];
-
- $form->submit($formData);
- self::assertTrue($form->isSynchronized());
- self::assertEquals($product, $form->getData());
-
- return $product;
- }
-
- /**
- * @depends testCreationForm
- */
- public function testEditionForm(Product $product): void
- {
- $product->getMedias()[0]->setUrl('http://example.org/media1-edit');
- $product->getMedias()[1]->setDescription('media2 desc edit');
-
- $formData = [
- 'url' => 'a2lix.fr',
- 'medias' => [
- [
- 'url' => 'http://example.org/media1-edit',
- 'description' => 'media1 desc',
- ],
- [
- 'url' => 'http://example.org/media2',
- 'description' => 'media2 desc edit',
- ],
- ],
- ];
-
- $form = $this->factory->createBuilder(AutoFormType::class, new Product())
- ->add('create', SubmitType::class)
- ->getForm()
- ;
-
- $form->submit($formData);
- self::assertTrue($form->isSynchronized());
- self::assertEquals($product, $form->getData());
-
- $view = $form->createView();
- $children = $view->children;
-
- foreach (array_keys($formData) as $key) {
- self::assertArrayHasKey($key, $children);
- }
- }
-
- protected function getExtensions(): array
- {
- $autoFormType = $this->getConfiguredAutoFormType();
-
- return [new PreloadedExtension([
- $autoFormType,
- ], [])];
- }
-}
diff --git a/tests/Form/Type/AutoTypeTest.php b/tests/Form/Type/AutoTypeTest.php
new file mode 100755
index 0000000..de4f82b
--- /dev/null
+++ b/tests/Form/Type/AutoTypeTest.php
@@ -0,0 +1,80 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace A2lix\AutoFormBundle\Tests\Form\Type;
+
+use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom;
+use A2lix\AutoFormBundle\Form\Builder\AutoTypeBuilder;
+use A2lix\AutoFormBundle\Form\Type\AutoType;
+use A2lix\AutoFormBundle\Form\TypeGuesser\TypeInfoTypeGuesser;
+use A2lix\AutoFormBundle\Tests\Form\DataProviderDto;
+use A2lix\AutoFormBundle\Tests\Form\DataProviderEntity;
+use A2lix\AutoFormBundle\Tests\Form\TestScenario;
+use A2lix\AutoFormBundle\Tests\Form\TypeTestCase;
+use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\DataProviderExternal;
+use Symfony\Component\Form\FormInterface;
+
+/**
+ * @internal
+ *
+ * @phpstan-import-type ExpectedChildren from TestScenario
+ */
+#[CoversClass(AutoType::class)]
+#[CoversClass(AutoTypeBuilder::class)]
+#[CoversClass(AutoTypeCustom::class)]
+#[CoversClass(TypeInfoTypeGuesser::class)]
+#[AllowMockObjectsWithoutExpectations] // https://github.com/symfony/symfony/issues/62669
+final class AutoTypeTest extends TypeTestCase
+{
+ #[DataProviderExternal(DataProviderDto::class, 'provideScenarioCases')]
+ #[DataProviderExternal(DataProviderEntity::class, 'provideScenarioCases')]
+ public function testScenario(TestScenario $testScenario): void
+ {
+ $form = $this->factory
+ ->createBuilder(AutoType::class, $testScenario->obj, $testScenario->formOptions)
+ ->getForm()
+ ;
+
+ self::assertFormChildren($testScenario->expectedForm, $form->all());
+ }
+
+ /**
+ * @param ExpectedChildren $expectedForm
+ * @param array> $formChildren
+ */
+ private static function assertFormChildren(array $expectedForm, array $formChildren, string $parentPath = ''): void
+ {
+ self::assertSame(array_keys($expectedForm), array_keys($formChildren));
+
+ foreach ($formChildren as $childName => $child) {
+ /** @var string $childName */
+ $expectedChildOptions = $expectedForm[$childName];
+ $childPath = $parentPath.'.'.$childName;
+
+ if (null !== $expectedType = ($expectedChildOptions['expected_type'] ?? null)) {
+ self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class, \sprintf('Type of "%s"', $childPath));
+ }
+
+ if (null !== $expectedChildren = ($expectedChildOptions['expected_children'] ?? null)) {
+ // @phpstan-ignore argument.type
+ self::assertFormChildren($expectedChildren, $child->all(), $childPath);
+ }
+
+ unset($expectedChildOptions['expected_type'], $expectedChildOptions['expected_children']);
+ $actualOptions = $child->getConfig()->getOptions();
+
+ // @phpstan-ignore nullCoalesce.variable, staticMethod.alreadyNarrowedType
+ self::assertSame($expectedChildOptions, array_intersect_key($actualOptions, $expectedChildOptions ?? []), \sprintf('Options of "%s"', $childPath));
+ }
+ }
+}
diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php
index f83e385..e054a01 100755
--- a/tests/Form/TypeTestCase.php
+++ b/tests/Form/TypeTestCase.php
@@ -1,6 +1,4 @@
-dump(
+ new VarCloner()->cloneVar($var),
+ // @phpstan-ignore argument.type
+ @fopen(__DIR__.'/../../dump.html', 'a')
+ );
+ });
+ }
- $validator = $this->createMock(ValidatorInterface::class);
- $validator->method('validate')->willReturn(new ConstraintViolationList());
+ #[\Override]
+ protected function getExtensions(): array
+ {
+ $autoType = new AutoType(
+ new AutoTypeBuilder($this->getPropertyInfoExtractor()),
+ ['id']
+ );
- $this->factory = Forms::createFormFactoryBuilder()
- ->addExtensions($this->getExtensions())
- ->addTypeExtension(
- new FormTypeValidatorExtension($validator)
- )
- ->addTypeGuesser(
- $this->createMock(ValidatorTypeGuesser::class)
- )
- ->getFormFactory()
+ $managerRegistryStub = self::createStub(ManagerRegistry::class);
+ $managerRegistryStub
+ ->method('getManager')
+ ->willReturn($this->getEntityManager())
+ ;
+ $managerRegistryStub
+ ->method('getManagers')
+ ->willReturn(['default' => $this->getEntityManager()])
;
- $this->dispatcher = $this->createMock(EventDispatcherInterface::class);
- $this->builder = new FormBuilder(null, null, $this->dispatcher, $this->factory);
+ return [
+ ...parent::getExtensions(),
+ new DoctrineOrmExtension($managerRegistryStub),
+ new PreloadedExtension(
+ [$autoType],
+ [],
+ new FormTypeGuesserChain([
+ new TypeInfoTypeGuesser(TypeResolver::create()),
+ ]),
+ ),
+ ];
}
- protected function getDoctrineORMManipulator(): DoctrineORMManipulator
+ private function getPropertyInfoExtractor(): PropertyInfoExtractor
{
- if (null !== $this->doctrineORMManipulator) {
- return $this->doctrineORMManipulator;
- }
-
- $config = ORMSetup::createAttributeMetadataConfiguration([__DIR__.'/../Fixtures/Entity'], true);
- $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $config);
- $entityManager = new EntityManager($connection, $config);
- $doctrineORMInfo = new DoctrineORMInfo($entityManager->getMetadataFactory());
+ $doctrineExtractor = new DoctrineExtractor($this->getEntityManager());
+ $reflectionExtractor = new ReflectionExtractor();
- return $this->doctrineORMManipulator = new DoctrineORMManipulator($doctrineORMInfo, ['id', 'locale', 'translatable']);
+ return new PropertyInfoExtractor(
+ listExtractors: [
+ $reflectionExtractor,
+ $doctrineExtractor,
+ ],
+ typeExtractors: [
+ $doctrineExtractor,
+ new PhpStanExtractor(),
+ new PhpDocExtractor(),
+ $reflectionExtractor,
+ ],
+ accessExtractors: [
+ $doctrineExtractor,
+ $reflectionExtractor,
+ ]
+ );
}
- protected function getConfiguredAutoFormType(): AutoFormType
+ private function getEntityManager(): EntityManagerInterface
{
- $autoFormListener = new AutoFormListener($this->getDoctrineORMManipulator());
+ if (null !== $this->entityManager) {
+ return $this->entityManager;
+ }
+
+ $configuration = ORMSetup::createAttributeMetadataConfig([__DIR__.'/../Fixtures/Entity'], true);
+ $configuration->enableNativeLazyObjects(true);
+
+ $connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true], $configuration);
- return new AutoFormType($autoFormListener);
+ return $this->entityManager = new EntityManager($connection, $configuration);
}
}