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 [![Latest Stable Version](https://poser.pugx.org/a2lix/auto-form-bundle/v/stable)](https://packagist.org/packages/a2lix/auto-form-bundle) [![Latest Unstable Version](https://poser.pugx.org/a2lix/auto-form-bundle/v/unstable)](https://packagist.org/packages/a2lix/auto-form-bundle) +[![Total Downloads](https://poser.pugx.org/a2lix/auto-form-bundle/downloads)](https://packagist.org/packages/a2lix/auto-form-bundle) [![License](https://poser.pugx.org/a2lix/auto-form-bundle/license)](https://packagist.org/packages/a2lix/auto-form-bundle) +[![Build Status](https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml/badge.svg)](https://github.com/a2lix/AutoFormBundle/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/a2lix/AutoFormBundle/branch/main/graph/badge.svg)](https://codecov.io/gh/a2lix/AutoFormBundle) -[![Total Downloads](https://poser.pugx.org/a2lix/auto-form-bundle/downloads)](https://packagist.org/packages/a2lix/auto-form-bundle) -[![Monthly Downloads](https://poser.pugx.org/a2lix/auto-form-bundle/d/monthly)](https://packagist.org/packages/a2lix/auto-form-bundle) -[![Daily Downloads](https://poser.pugx.org/a2lix/auto-form-bundle/d/daily)](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); } }