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..2c48972 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,42 @@ +{ + "name": "A2LiX - TranslationFormBundle", + "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", + "mounts": [ + "source=/home/david/Works/a2lix/AutoFormBundle,target=/workspaces/AutoFormBundle,type=bind,consistency=cached", + ] +} 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/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 039861a..0000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,44 +0,0 @@ - - - - -### Environment - -#### Composer packages - -``` -$ composer show -# Put the result here. -``` - -#### PHP version - -``` -$ php -v -# Put the result here. -``` - -## Subject - - - -## Steps to reproduce - -## Expected results - -## Actual results - - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index b930a73..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,54 +0,0 @@ - - - -I am targeting this branch, because {reason}. - - - -Closes #{put_issue_number_here} - -## Changelog - - - - -```markdown -### Added - -### Changed - -### Deprecated - -### Removed - -### Fixed - -### Security -``` - -## To do - - - -- [ ] Update the tests -- [ ] Update the documentation -- [ ] Add an upgrade note - -## Subject - - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a2bfff..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 89afc15..2f9af0c 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 badeba4..fa350a6 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,123 @@ -# A2lix Translation Form Bundle - -Translate your doctrine objects easily with some helps +# A2lix TranslationForm Bundle [![Latest Stable Version](https://poser.pugx.org/a2lix/translation-form-bundle/v/stable)](https://packagist.org/packages/a2lix/translation-form-bundle) [![Latest Unstable Version](https://poser.pugx.org/a2lix/translation-form-bundle/v/unstable)](https://packagist.org/packages/a2lix/translation-form-bundle) -[![License](https://poser.pugx.org/a2lix/translation-form-bundle/license)](https://packagist.org/packages/a2lix/translation-form-bundle) - [![Total Downloads](https://poser.pugx.org/a2lix/translation-form-bundle/downloads)](https://packagist.org/packages/a2lix/translation-form-bundle) -[![Monthly Downloads](https://poser.pugx.org/a2lix/translation-form-bundle/d/monthly)](https://packagist.org/packages/a2lix/translation-form-bundle) -[![Daily Downloads](https://poser.pugx.org/a2lix/translation-form-bundle/d/daily)](https://packagist.org/packages/a2lix/translation-form-bundle) - -| Branch | Tools | -| --- | --- | -| 3.x (master) | [![Build Status][v3_ci_badge]][v3_ci_link] [![Coverage Status][v3_coverage_badge]][v3_coverage_link] | +[![License](https://poser.pugx.org/a2lix/translation-form-bundle/license)](https://packagist.org/packages/a2lix/translation-form-bundle) +[![Build Status](https://github.com/a2lix/TranslationFormBundle/actions/workflows/ci.yml/badge.svg)](https://github.com/a2lix/TranslationFormBundle/actions/workflows/ci.yml) -## Screenshot example +A small, flexible Symfony bundle that helps you build forms to manage translations for Doctrine entities. It integrates with common translation strategies (Gedmo Personal Translation and Knp DoctrineBehaviors) and provides form types, helpers and Twig components to make working with multilingual data easier. -![A2LiX Translation Form Screenshot](/a2lix_translationForm.jpg) +Key features +- Easy form handling for translatable entities (Knp & Gedmo strategies). +- Support for one-record-per-locale patterns via `TranslationsFormsType`. +- `TranslatedEntityType` for entity choice labels using translations. +- Centralized locale configuration via `LocaleProvider`. +- Twig helpers and a `LocaleSwitcher` component. -## Support +> [!NOTE] +> Use [A2lixAutoFormBundle](https://github.com/a2lix/AutoFormBundle) for automatic form generation and customization. -* `3.x` depends on [AutoFormBundle](https://github.com/a2lix/AutoFormBundle) and has higher requirements (PHP8.1+, Symfony5.4+/6.3+/7.0+). It is compatible with [KnpLabs](https://github.com/KnpLabs/DoctrineBehaviors#translatable), [A2lix](https://github.com/a2lix/I18nDoctrineBundle) and [Prezent](https://github.com/Prezent/doctrine-translatable-bundle) +> [!TIP] +> A complete demonstration is also available at [a2lix/demo](https://github.com/a2lix/Demo). ## Installation -Use composer: - +- Install the bundle with Composer: ```bash composer require a2lix/translation-form-bundle ``` -After the successful installation, add/check the bundle registration: - -```php -// Symfony >= 4.0 in bundles.php -// ... -A2lix\AutoFormBundle\A2lixAutoFormBundle::class => ['all' => true], -A2lix\TranslationFormBundle\A2lixTranslationFormBundle::class => ['all' => true], -// ... - -// Symfony >= 3.4 in AppKernel::registerBundles() -$bundles = array( -// ... -new A2lix\AutoFormBundle\A2lixAutoFormBundle(), -new A2lix\TranslationFormBundle\A2lixTranslationFormBundle(), -// ... -``` - -## Configuration +## Basic configuration -There is no minimal configuration. Full list of optional parameters: +Add a minimal configuration in `config/packages/a2lix.yaml`: ```yaml -# Symfony >= 4.0. Create a dedicated a2lix.yaml in config/packages with: -# Symfony >= 3.4. Add in your app/config/config.yml: - a2lix_translation_form: - locale_provider: default # [1] - locales: [en, fr, es, de] # [1-a] - default_locale: en # [1-b] - required_locales: [fr] # [1-c] - templating: "@A2lixTranslationForm/bootstrap_4_layout.html.twig" # [2] + enabled_locales: [en, fr, de] # Optional. Default from framework.enabled_locales + default_locale: en # Optional. Default from framework.default_locale + required_locales: [en] # Optional. Default [] + # templating: "@A2lixTranslationForm/bootstrap_5_layout.html.twig" ``` -1. Custom locale provider service id. Default one relies on [1-*] values: - - [1-a] List of translations locales to display - - [1-b] Default locale - - [1-c] List of required translations locales -2. The default template is Twitter Bootstrap compatible. You can redefine your own here - -## Usage +If you keep the default setup, the bundle will automatically prepend the chosen form theme (Bootstrap 5 layout by default) into Twig's `form_themes`. -### In a classic formType +Compatibility: Gedmo & Knp -```php -use A2lix\TranslationFormBundle\Form\Type\TranslationsType; -... -$builder->add('translations', TranslationsType::class); -``` +- [Gedmo PersonalTranslation](https://github.com/doctrine-extensions/DoctrineExtensions/blob/main/doc/translatable.md#personal-translations): Fully supported. When using Gedmo's personal translation mapping, the bundle renders translation fields as separate translation objects and manages creation and removal of `Gedmo` translation entities. +- [KnpDoctrineBehaviors](https://github.com/KnpLabs/DoctrineBehaviors/blob/master/docs/translatable.md): Fully supported. For Knp-style translations (one translation object per locale), fields are bound directly to locale forms. -### Advanced examples +## Usage examples +#### TranslationsType (Knp or Gedmo) ```php use A2lix\TranslationFormBundle\Form\Type\TranslationsType; -... +use Symfony\Component\Form\Extension\Core\Type\FormType; + $builder->add('translations', TranslationsType::class, [ - 'locales' => ['en', 'fr', 'es', 'de'], // [1] - 'default_locale' => ['en'], // [1] - 'required_locales' => ['fr'], // [1] - '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] - ] - ] - ], - 'excluded_fields' => ['details'], // [2] - 'locale_labels' => [ // [5] - 'fr' => 'Français', - 'en' => 'English', - ], + 'translatable_class' => App\Entity\Post::class, + // Optional: + // 'locale_labels' => ['en' => 'English', 'fr' => 'Français'], + // 'theming_granularity' => 'field', // or 'locale_field' ]); ``` -1. Optional. If set, override the default value from config.yml -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' -5. Optional. Set the labels for the translation tabs. Default to the name of the locale. Translation keys can be used here. - -## Additional - -### TranslationsFormsType - -A different approach for entities which don't share fields untranslated. No strategy used here, only a locale field in your entity. - +#### TranslationsFormsType (one-record-per-locale) ```php use A2lix\TranslationFormBundle\Form\Type\TranslationsFormsType; -... -$builder->add('translations', TranslationsFormsType::class, [ - 'locales' => ['en', 'fr', 'es', 'de'], // [1] - 'default_locale' => ['en'] // [1] - 'required_locales' => ['fr'], // [1] - 'form_type' => ProductMediaType::class, // [2 - Mandatory] - 'form_options' => [ // [2bis] - 'context' => 'pdf' - ] + +$builder->add('medias', TranslationsFormsType::class, [ + 'form_type' => App\Form\CompanyMediaType::class, + 'form_options' => [ + 'data_class' => App\Entity\CompanyMediaLocale::class, + ], ]); ``` -1. Optional. If set, override the default value from config.yml -2. Mandatory. A real form type that you have to do - - [2bis] Optional. - An array of options that you can set to your form - -### TranslatedEntityType - -Modified version of the native 'entity' symfony form type to translate the label in the current locale by reading translations - +#### TranslatedEntityType (entity choices with translation labels) ```php use A2lix\TranslationFormBundle\Form\Type\TranslatedEntityType; -... -$builder->add('medias', TranslatedEntityType::class, [ - 'class' => 'A2lix\DemoTranslationBundle\Entity\Media', // [1 - Mandatory] - 'translation_property' => 'title', // [2 - Mandatory] - 'multiple' => true, // [3] + +$builder->add('category', TranslatedEntityType::class, [ + 'class' => App\Entity\Category::class, + 'translation_property' => 'title', ]); ``` -1. Path of the translatable class -2. Property/Method of the translatable class that will be display -3. Common options of the 'entity' Symfony form type (multiple, ...) +#### Locale selection widget +```php +use A2lix\TranslationFormBundle\Form\Type\TranslationsLocalesSelectorType; -### Example +$builder->add('locales', TranslationsLocalesSelectorType::class, [ + // uses the bundle's LocaleProvider to populate choices +]); +``` -See [Demo Bundle](https://github.com/a2lix/Demo) for more examples. +## Twig helpers & components +Locale rendering function: +```twig +{{ locale_render('en') }} {# -> 'English' (localized) #} +{{ locale_render('en', 'locale_upper') }} {# -> 'EN' #} +{{ locale_render('fr', 'locale_name_title') }}{# -> 'Français' #} +``` -## Contribution help +LocaleSwitcher component: +```twig +{# Render basic badges #} + -``` -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 +{# Render dropdown #} + ``` -## License +## LocaleProvider + +The bundle centralizes locale configuration through a `LocaleProviderInterface`. By default, `SimpleLocaleProvider` is registered and configured from bundle settings. You can replace it with your own service by changing `locale_provider` in the bundle configuration. -This package is available under the [MIT license](LICENSE). +## Integration with AutoFormBundle -[v3_ci_badge]: https://github.com/a2lix/TranslationFormBundle/actions/workflows/ci.yml/badge.svg -[v3_ci_link]: https://github.com/a2lix/TranslationFormBundle/actions/workflows/ci.yml -[v3_coverage_badge]: https://codecov.io/gh/a2lix/TranslationFormBundle/branch/master/graph/badge.svg -[v3_coverage_link]: https://codecov.io/gh/a2lix/TranslationFormBundle/branch/master +This bundle integrates cleanly with `a2lix/auto-form-bundle`. When using `AutoType` with translatable entities, `TranslationsType` and `TranslationsFormsType` can be automatically configured and rendered based on entity metadata and bundle options. + +## License +This package is available under the MIT license — see the LICENSE file. \ No newline at end of file diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md deleted file mode 100644 index c1cbead..0000000 --- a/UPGRADE-3.0.md +++ /dev/null @@ -1,91 +0,0 @@ -UPGRADE FROM 2.x to 3.x -======================= - -## BC Breaks - -### 1. New bundle requirements -```A2lix\AutoFormBundle\A2lixAutoFormBundle``` is required as an additional Bundle in your AppKernel. - -If using Symfony 4+ -```php -// config/bundles.php - -return [ - // ... - A2lix\AutoFormBundle\A2lixAutoFormBundle::class => ['all' => true], - // ... -]; -``` - -If using Symfony 3 -```php -// app/AppKernel.php - -public function registerBundles() -{ - return [ - // ... - new A2lix\AutoFormBundle\A2lixAutoFormBundle(), - // ... - ]; -} -``` - -### 2. Config -```manager_registry``` option is no longer part of the ```a2lix_translation_form``` configuration file. - -### 3. Creating forms with FormBuilder -```exclude_fields``` form option of `A2lix\TranslationFormBundle\Form\Type\TranslationsType` -was renamed to ```excluded_fields```. - -You should fix all usages of this option otherwise exception will be thrown when building the form. - -```php -$builder->add('translations', TranslationsType::class, [ - 'excluded_fields' => ['details'], // use correct option name -]); -``` - -### 4. Overriding / extending services -Version 2 used parameters like `a2lix_translation_form.default.*.translations.class` to configure listeners, form types -and other services and you could override service classes by simply setting those parameters values in your app. - -**Version 3 does not provide those parameters anymore**, so if you want to override any service provided by this bundle, -you have to do it in a compiler pass. E. g.: -```php -hasDefinition('a2lix_translation_form.form.event_listener.translations_listener')) { - // change service definition to use your own implementation of TranslationsListener - $container->getDefinition('a2lix_translation_form.form.event_listener.translations_listener') - ->setClass(\App\EventListener\TranslationsListener::class); - } - } -} -``` -You also have to register this compiler pass in your Kernel. - -See [How to Work with Compiler Passes](https://symfony.com/doc/current/service_container/compiler_passes.html) -in Symfony docs for more details. - -### 5. Bootstrap 4 -Translations form template provided with v3 of this bundle requires Bootstrap v4. If you are using Bootstrap v3 -and cannot upgrade at the moment, you should consider using custom template. -In particular this is the case when you're using TranslationFormBundle with SonataAdmin, which ships with -Bootstrap v3. - -You can copy `default.html.twig` template from `2.x` branch into your app or create your own template -and configure translation form to use it: -```yaml -a2lix_translation_form: - templating: "Translation/bootstrap_3_layout.html.twig" -``` diff --git a/UPGRADE-4.0.md b/UPGRADE-4.0.md new file mode 100644 index 0000000..89dad99 --- /dev/null +++ b/UPGRADE-4.0.md @@ -0,0 +1,98 @@ +UPGRADE FROM 3.x to 4.x +======================= + +## Breaking Changes + +### PHP and Symfony Versions + +- **Requires PHP 8.4+** +- **Requires Symfony 7.4|8.0** + +### Configuration + +The bundle configuration has been updated. Update your `config/packages/a2lix.yaml`: + +**Before (v3):** +```yaml +a2lix_translation_form: + locales: [en, fr, de] + default_locale: en +``` + +**After (v4):** +```yaml +a2lix_translation_form: + enabled_locales: [en, fr, de] # Optional. Default from framework.enabled_locales + default_locale: en # Optional. Default from framework.default_locale + required_locales: [en] # Optional. Default [] +``` + +Key changes: +- `locales` → `enabled_locales` + +### Bootstrap 5 Template + +The default template has changed from Bootstrap 4 to Bootstrap 5: + +**Before:** +```yaml +templating: "@A2lixTranslationForm/bootstrap_4_layout.html.twig" +``` + +**After:** +```yaml +templating: "@A2lixTranslationForm/bootstrap_5_layout.html.twig" +``` + +Or use the framework-agnostic layout (No JS required, compatible with Gecko browsers): +```yaml +templating: "@A2lixTranslationForm/native_layout.html.twig" +``` + +The Bootstrap 5 template uses the new tab API with `data-bs-toggle` and `data-bs-target` attributes. + +### Form Types Changes + +#### TranslationsType + +New options have been added with this type that rely on AutoType from [A2lixAutoFormBundle](https://github.com/a2lix/AutoFormBundle): + +```php +$builder->add('translations', TranslationsType::class, [ + 'translatable_class' => MyEntity::class, // Required ! + 'gedmo' => true, // Required only if Gedmo ! + + 'default_locale' => ... // Optional. From LocaleExtension + 'enabled_locales' => ... // Optional. From LocaleExtension + 'required_locales' => ... // Optional. From LocaleExtension + 'locale_labels' => ... // Optional. From LocaleExtension + 'theming_granularity' => ... // Optional. From LocaleExtension + 'children_excluded' => ... // Optional. See AutoFormBundle + 'children_embedded' => ... // Optional. See AutoFormBundle + 'children_groups' => ... // Optional. See AutoFormBundle + 'children' => ... // Optional. See AutoFormBundle + 'builder' => ... // Optional. See AutoFormBundle +]); +``` + +Key changes: +- `translatable_class` is now required + + +### LocaleProvider System + +The LocaleProviderInterface::getLocales() method has been renamed to LocaleProviderInterface::getEnabledLocales() + +### Twig Components + +#### LocaleSwitcher Component (New) + +New Twig component for locale switching: + +```twig + +``` + +Available renders: +- `basic`: Inline badges +- `dropdown`: Bootstrap dropdown menu diff --git a/assets/a2lix_translation.css b/assets/a2lix_translation.css new file mode 100644 index 0000000..c636e3f --- /dev/null +++ b/assets/a2lix_translation.css @@ -0,0 +1,51 @@ +.a2lix_translations { + details { + summary { + border-bottom: 2px solid transparent; + cursor: pointer; + list-style: none; + padding: .5rem; + white-space: nowrap; + + &:hover { + border-bottom-color: lightskyblue; + } + } + + ::details-content { + padding: .5rem; + } + + &:open > summary { + border-bottom-color: dodgerblue; + font-weight: 700; + } + + &:not(:open)::details-content { + display: none; + } + } +} + +@media screen and (width >= 1024px) { + .a2lix_translations { + column-gap: 1rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + + details { + display: contents; + + summary { + grid-row: 1; + } + + &::details-content { + grid-row: 2; + grid-column: 1 / -1; + padding: 1rem; + } + } + } +} + diff --git a/composer.json b/composer.json index 156fb52..af73bd0 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "translatable", "form", "knplabs", + "gedmo", "prezent" ], "homepage": "https://github.com/a2lix/TranslationFormBundle", @@ -26,47 +27,59 @@ } ], "require": { - "php": "^8.1", - "a2lix/auto-form-bundle": "^0.4", - "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/event-dispatcher": "^5.4.30|^6.3|^7.0", - "symfony/form": "^5.4.30|^6.3|^7.0", - "symfony/http-foundation": "^5.4.30|^6.3|^7.0", - "symfony/http-kernel": "^5.4.30|^6.3|^7.0", - "symfony/options-resolver": "^5.4.30|^6.3|^7.0" + "php": "^8.4", + "a2lix/auto-form-bundle": "^1.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/doctrine-bridge": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/options-resolver": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/ux-twig-component": "^2.31" }, "require-dev": { - "doctrine/orm": "^2.15", - "friendsofphp/php-cs-fixer": "^3.45", - "knplabs/doctrine-behaviors": "^2.3", - "kubawerlos/php-cs-fixer-custom-fixers": "^3.18", - "matthiasnoback/symfony-dependency-injection-test": "^5.0", - "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", + "knplabs/doctrine-behaviors": "^3.0.0", + "kubawerlos/php-cs-fixer-custom-fixers": "^3.35.1", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-doctrine": "^2.0.12", + "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", + "stof/doctrine-extensions-bundle": "^1.14", + "symfony/cache": "^7.4.1|^8.0.1", + "symfony/validator": "^7.4|^8.0.2", + "symfony/var-dumper": "^7.4" }, "suggest": { "knplabs/doctrine-behaviors": "For Knp strategy", + "stof/doctrine-extensions-bundle": "For Gedmo strategy", "prezent/doctrine-translatable-bundle": "For Prezent strategy" }, "scripts": { "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 + "sort-packages": true, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "phpstan/extension-installer": true + } }, "autoload": { "psr-4": { "A2lix\\TranslationFormBundle\\": "src/" } @@ -76,7 +89,7 @@ }, "extra": { "branch-alias": { - "dev-master": "3.x-dev" + "dev-main": "4.x-dev" } } } diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..18fbc9e --- /dev/null +++ b/config/services.php @@ -0,0 +1,85 @@ + + * + * 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\TranslationFormBundle\Form\Extension\LocaleExtension; +use A2lix\TranslationFormBundle\Form\Type\TranslatedEntityType; +use A2lix\TranslationFormBundle\Form\Type\TranslationsFormsType; +use A2lix\TranslationFormBundle\Form\Type\TranslationsLocalesSelectorType; +use A2lix\TranslationFormBundle\Form\Type\TranslationsType; +use A2lix\TranslationFormBundle\LocaleProvider\LocaleProviderInterface; +use A2lix\TranslationFormBundle\LocaleProvider\SimpleLocaleProvider; +use A2lix\TranslationFormBundle\Twig\Components\LocaleSwitcher; +use A2lix\TranslationFormBundle\Twig\LocaleExtension as TwigLocaleExtension; + +return static function (ContainerConfigurator $container): void { + $container->services() + // Locale Provider + ->set('a2lix_translation_form.locale_provider.simple_locale_provider', SimpleLocaleProvider::class) + ->args([ + '$locales' => abstract_arg('locales'), + '$defaultLocale' => abstract_arg('defaultLocale'), + '$requiredLocales' => abstract_arg('requiredLocales'), + ]) + ->alias(LocaleProviderInterface::class, 'a2lix_translation_form.locale_provider.simple_locale_provider') + + // Form Extensions + ->set('a2lix_translation_form.form.extension.locale_extension', LocaleExtension::class) + ->args([ + '$localeProvider' => service(LocaleProviderInterface::class), + ]) + ->tag('form.type_extension') + + // Form Types + ->set('a2lix_translation_form.form.type.translations_type', TranslationsType::class) + ->args([ + '$globalExcludedChildren' => abstract_arg('globalExcludedChildren'), + '$globalEmbeddedChildren' => abstract_arg('globalEmbeddedChildren'), + ]) + ->tag('form.type') + + ->set('a2lix_translation_form.form.type.translations_forms_type', TranslationsFormsType::class) + ->tag('form.type') + + ->set('a2lix_translation_form.form.type.translations_locales_selector_type', TranslationsLocalesSelectorType::class) + ->args([ + '$localeProvider' => service(LocaleProviderInterface::class), + ]) + ->tag('form.type') + + ->set('a2lix_translation_form.form.type.translated_entity_type', TranslatedEntityType::class) + ->args([ + '$localeSwitcher' => service('translation.locale_switcher'), + ]) + ->tag('form.type') + + // Twig Components + ->set(LocaleSwitcher::class) + ->args([ + '$localeProvider' => service(LocaleProviderInterface::class), + ]) + ->tag('twig.component', [ + 'key' => 'A2lixTranslationForm:LocaleSwitcher', + 'template' => '@A2lixTranslationForm/components/LocaleSwitcher.html.twig', + ]) + + // Twig Extension + ->set(TwigLocaleExtension::class) + ->args([ + '$localeSwitcher' => service('translation.locale_switcher'), + ]) + ->tag('twig.attribute_extension') + ->tag('twig.runtime') + ; +}; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..c17521b --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,25 @@ +parameters: + level: 10 + paths: + - src + - tests + excludePaths: + - src/A2lixTranslationFormBundle.php + - src/Helper + - tests + scanFiles: + - stubs/GedmoTranslatable.stub + - stubs/GedmoTranslation.stub + - stubs/KnpTranslation.stub + + # 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 b1de987..541b739 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,34 +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/ - tests/Fixtures - tests/tmp + + tests + + + + src + + + + + + + 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/A2lixTranslationFormBundle.php b/src/A2lixTranslationFormBundle.php index 9d27abf..91342b2 100644 --- a/src/A2lixTranslationFormBundle.php +++ b/src/A2lixTranslationFormBundle.php @@ -1,6 +1,4 @@ -rootNode() + ->children() + ->scalarNode('locale_provider') + ->defaultValue('a2lix_translation_form.locale_provider.simple_locale_provider') + ->info('Set your own LocaleProvider service identifier if required') + ->end() + ->scalarNode('default_locale') + ->defaultNull() + ->info('Set your own default locale if different from the default framework.default_locale. eg: en') + ->end() + ->arrayNode('enabled_locales') + ->scalarPrototype() + ->end() + ->info('Set your own list of locales (default locale included) if different from the default framework.enabled_locales. eg: [en, fr, de, es]') + ->end() + ->arrayNode('required_locales') + ->scalarPrototype() + ->end() + ->info('Set the list of required locales to manage. eg: [en]') + ->end() + ->scalarNode('templating') + ->defaultValue('@A2lixTranslationForm/native_layout.html.twig') + ->info('Set your own template path if required') + ->end() + ->end() + ; + } + + #[\Override] + public function prependExtension(ContainerConfigurator $configurator, ContainerBuilder $container): void + { + $config = $container->getExtensionConfig($this->extensionAlias); + + if ($container->hasExtension('twig')) { + $container->prependExtensionConfig('twig', [ + 'form_themes' => [ + $config['templating'] ?? '@A2lixTranslationForm/bootstrap_5_layout.html.twig', + ], + ]); + } + } + + #[\Override] + public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void { - parent::build($container); + $container->import('../config/services.php'); + + $this->configureLocaleProvider($config, $container, $builder); + } + + private function configureLocaleProvider(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void + { + // Custom? + if ('a2lix_translation_form.locale_provider.simple_locale_provider' !== $config['locale_provider']) { + $container->services() + ->remove('a2lix_translation_form.locale_provider.simple_locale_provider') + ->alias(LocaleProviderInterface::class, $config['locale_provider']) + ; + + return; + } + + // SimpleLocaleProvider + $enabledLocales = $config['enabled_locales'] ?: $builder->getParameter('kernel.enabled_locales'); + foreach ($enabledLocales as $locale) { + if (!Locales::exists($locale)) { + throw new \InvalidArgumentException(\sprintf('Configuration error in a2lix_translation_form: The locale "%s" is not a valid country code or locale code recognized by the Symfony Intl component.', $locale)); + } + } + + $defaultLocale = $config['default_locale'] ?? $builder->getParameter('kernel.default_locale'); + if (!\in_array($defaultLocale, $enabledLocales, true)) { + throw new \InvalidArgumentException(\sprintf('Configuration error in a2lix_translation_form: The list of locales must contain the determined default locale "%s"', $defaultLocale)); + } - $container->addCompilerPass(new TemplatingPass()); - $container->addCompilerPass(new LocaleProviderPass()); + $container->services() + ->get($config['locale_provider']) + ->args([ + '$defaultLocale' => $defaultLocale, + '$enabledLocales' => $enabledLocales, + '$requiredLocales' => $config['required_locales'], + ]) + ; } } diff --git a/src/DependencyInjection/A2lixTranslationFormExtension.php b/src/DependencyInjection/A2lixTranslationFormExtension.php deleted file mode 100644 index affd5ac..0000000 --- a/src/DependencyInjection/A2lixTranslationFormExtension.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\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 A2lixTranslationFormExtension 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'); - - $container->setParameter('a2lix_translation_form.locale_provider', $config['locale_provider']); - $container->setParameter('a2lix_translation_form.locales', $config['locales']); - $container->setParameter('a2lix_translation_form.required_locales', $config['required_locales']); - $container->setParameter('a2lix_translation_form.default_locale', $config['default_locale'] ?: - $container->getParameter('kernel.default_locale')); - - $container->setParameter('a2lix_translation_form.templating', $config['templating']); - } -} diff --git a/src/DependencyInjection/Compiler/LocaleProviderPass.php b/src/DependencyInjection/Compiler/LocaleProviderPass.php deleted file mode 100644 index 7c6ce8b..0000000 --- a/src/DependencyInjection/Compiler/LocaleProviderPass.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\TranslationFormBundle\DependencyInjection\Compiler; - -use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; - -class LocaleProviderPass implements CompilerPassInterface -{ - final public const DEFAULT_LOCALE_PROVIDER_KEY = 'default'; - - public function process(ContainerBuilder $container): void - { - $localeProvider = $container->getParameter('a2lix_translation_form.locale_provider'); - - if (self::DEFAULT_LOCALE_PROVIDER_KEY !== $localeProvider) { - $container->setAlias('a2lix_translation_form.locale_provider.default', $localeProvider); - - return; - } - - $definition = $container->getDefinition('a2lix_translation_form.locale.simple_provider'); - $definition->setArguments([ - $container->getParameter('a2lix_translation_form.locales'), - $container->getParameter('a2lix_translation_form.default_locale'), - $container->getParameter('a2lix_translation_form.required_locales'), - ]); - - $container->setAlias('a2lix_translation_form.locale_provider.default', 'a2lix_translation_form.locale.simple_provider'); - } -} diff --git a/src/DependencyInjection/Compiler/TemplatingPass.php b/src/DependencyInjection/Compiler/TemplatingPass.php deleted file mode 100644 index bb9887d..0000000 --- a/src/DependencyInjection/Compiler/TemplatingPass.php +++ /dev/null @@ -1,34 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\DependencyInjection\Compiler; - -use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; - -class TemplatingPass implements CompilerPassInterface -{ - public function process(ContainerBuilder $container): void - { - if (false !== ($template = $container->getParameter('a2lix_translation_form.templating'))) { - $resources = $container->getParameter('twig.form.resources'); - - if (\in_array($template, $resources, true)) { - return; - } - - $resources[] = $template; - $container->setParameter('twig.form.resources', $resources); - } - } -} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php deleted file mode 100644 index 42696e0..0000000 --- a/src/DependencyInjection/Configuration.php +++ /dev/null @@ -1,63 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\DependencyInjection; - -use A2lix\TranslationFormBundle\DependencyInjection\Compiler\LocaleProviderPass; -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_translation_form'); - $rootNode = method_exists(TreeBuilder::class, 'getRootNode') ? $treeBuilder->getRootNode() : $treeBuilder->root('a2lix_translation_form'); - - $rootNode - ->children() - ->scalarNode('locale_provider') - ->defaultValue(LocaleProviderPass::DEFAULT_LOCALE_PROVIDER_KEY) - ->info('Set your own LocaleProvider service identifier if required') - ->end() - ->scalarNode('default_locale') - ->defaultNull() - ->info('Set your own default locale if different from the SymfonyFramework locale. eg: en') - ->end() - ->arrayNode('locales') - ->beforeNormalization() - ->ifString() - ->then(static fn ($v) => preg_split('/\s*,\s*/', (string) $v)) - ->end() - ->requiresAtLeastOneElement() - ->prototype('scalar')->end() - ->info('Set the list of locales to manage (default locale included). eg: [en, fr, de, es]') - ->end() - ->arrayNode('required_locales') - ->beforeNormalization() - ->ifString() - ->then(static fn ($v) => preg_split('/\s*,\s*/', (string) $v)) - ->end() - ->prototype('scalar')->end() - ->info('Set the list of required locales to manage. eg: [en]') - ->end() - ->scalarNode('templating') - ->defaultValue('@A2lixTranslationForm/bootstrap_4_layout.html.twig') - ->info('Set your own template path if required') - ->end() - ->end() - ; - - return $treeBuilder; - } -} diff --git a/src/Form/EventListener/TranslationsFormsListener.php b/src/Form/EventListener/TranslationsFormsListener.php deleted file mode 100644 index 3a68b3b..0000000 --- a/src/Form/EventListener/TranslationsFormsListener.php +++ /dev/null @@ -1,66 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Form\EventListener; - -use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\Form\FormEvent; -use Symfony\Component\Form\FormEvents; - -class TranslationsFormsListener implements EventSubscriberInterface -{ - public static function getSubscribedEvents(): array - { - return [ - FormEvents::PRE_SET_DATA => 'preSetData', - FormEvents::SUBMIT => 'submit', - ]; - } - - public function preSetData(FormEvent $event): void - { - $form = $event->getForm(); - $formOptions = $form->getConfig()->getOptions(); - - foreach ($formOptions['locales'] as $locale) { - $form->add( - $locale, - $formOptions['form_type'], - $formOptions['form_options'] + [ - 'required' => \in_array($locale, $formOptions['required_locales'], true), - ] - ); - } - } - - public function submit(FormEvent $event): void - { - $form = $event->getForm(); - $formOptions = $form->getConfig()->getOptions(); - - $data = $event->getData(); - - foreach ($data as $locale => $translation) { - // Remove useless Translation object - if ((method_exists($translation, 'isEmpty') && $translation->isEmpty() && !\in_array($locale, $formOptions['required_locales'], true)) // Knp - || empty($translation) // Default - ) { - $data->removeElement($translation); - - continue; - } - - $translation->setLocale($locale); - } - } -} diff --git a/src/Form/EventListener/TranslationsListener.php b/src/Form/EventListener/TranslationsListener.php deleted file mode 100644 index d200f6e..0000000 --- a/src/Form/EventListener/TranslationsListener.php +++ /dev/null @@ -1,134 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Form\EventListener; - -use A2lix\AutoFormBundle\Form\Manipulator\FormManipulatorInterface; -use A2lix\AutoFormBundle\Form\Type\AutoFormType; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Symfony\Component\Form\FormEvent; -use Symfony\Component\Form\FormEvents; -use Symfony\Component\Form\FormInterface; - -class TranslationsListener implements EventSubscriberInterface -{ - public function __construct( - private readonly FormManipulatorInterface $formManipulator, - ) {} - - public static function getSubscribedEvents(): array - { - return [ - FormEvents::PRE_SET_DATA => 'preSetData', - FormEvents::SUBMIT => 'submit', - ]; - } - - public function preSetData(FormEvent $event): void - { - $form = $event->getForm(); - - if (null === $formParent = $form->getParent()) { - throw new \RuntimeException('Parent form missing'); - } - - $formOptions = $form->getConfig()->getOptions(); - $fieldsOptions = $this->getFieldsOptions($form, $formOptions); - $translationClass = $this->getTranslationClass($formParent); - - foreach ($formOptions['locales'] as $locale) { - if (!isset($fieldsOptions[$locale])) { - continue; - } - - $form->add($locale, AutoFormType::class, [ - 'data_class' => $translationClass, - 'label' => $formOptions['locale_labels'][$locale] ?? null, - 'required' => \in_array($locale, $formOptions['required_locales'], true), - 'block_name' => ('field' === $formOptions['theming_granularity']) ? 'locale' : null, - 'fields' => $fieldsOptions[$locale], - 'excluded_fields' => $formOptions['excluded_fields'], - ]); - } - } - - public function submit(FormEvent $event): void - { - $form = $event->getForm(); - $formOptions = $form->getConfig()->getOptions(); - - $data = $event->getData(); - - foreach ($data as $locale => $translation) { - // Remove useless Translation object - if ((method_exists($translation, 'isEmpty') && $translation->isEmpty() && !\in_array($locale, $formOptions['required_locales'], true)) // Knp - || empty($translation) // Default - ) { - $data->removeElement($translation); - - continue; - } - - $translation->setLocale($locale); - } - } - - public function getFieldsOptions(FormInterface $form, array $formOptions): array - { - $fieldsOptions = []; - - $fieldsConfig = $this->formManipulator->getFieldsConfig($form); - foreach ($fieldsConfig as $fieldName => $fieldConfig) { - // Simplest case: General options for all locales - if (!isset($fieldConfig['locale_options'])) { - foreach ($formOptions['locales'] as $locale) { - $fieldsOptions[$locale][$fieldName] = $fieldConfig; - } - - continue; - } - - // Custom options by locale - $localesFieldOptions = $fieldConfig['locale_options']; - unset($fieldConfig['locale_options']); - - foreach ($formOptions['locales'] as $locale) { - $localeFieldOptions = $localesFieldOptions[$locale] ?? []; - if (!isset($localeFieldOptions['display']) || (true === $localeFieldOptions['display'])) { - $fieldsOptions[$locale][$fieldName] = $localeFieldOptions + $fieldConfig; - } - } - } - - return $fieldsOptions; - } - - private function getTranslationClass(FormInterface $form): string - { - do { - $translatableClass = $form->getConfig()->getDataClass(); - } while ((null === $translatableClass) && $form->getConfig()->getInheritData() && (null !== $form = $form->getParent())); - - // Knp - if (method_exists($translatableClass, 'getTranslationEntityClass')) { - return $translatableClass::getTranslationEntityClass(); - } - - // Gedmo - if (method_exists($translatableClass, 'getTranslationClass')) { - return $translatableClass::getTranslationClass(); - } - - return $translatableClass.'Translation'; - } -} diff --git a/src/Form/Extension/LocaleExtension.php b/src/Form/Extension/LocaleExtension.php new file mode 100644 index 0000000..6d0fb5d --- /dev/null +++ b/src/Form/Extension/LocaleExtension.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Form\Extension; + +use A2lix\TranslationFormBundle\Form\Type\TranslationsFormsType; +use A2lix\TranslationFormBundle\Form\Type\TranslationsType; +use A2lix\TranslationFormBundle\LocaleProvider\LocaleProviderInterface; +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\Form\FormView; +use Symfony\Component\OptionsResolver\OptionsResolver; + +/** + * @phpstan-type LocaleFormOptionsDefaults array{ + * default_locale: string, + * enabled_locales: list, + * required_locales: list, + * locale_labels: array|null, + * theming_granularity: string, + * ... + * } + */ +class LocaleExtension extends AbstractTypeExtension +{ + public function __construct( + private readonly LocaleProviderInterface $localeProvider, + ) {} + + #[\Override] + public static function getExtendedTypes(): iterable + { + yield TranslationsFormsType::class; + yield TranslationsType::class; + } + + #[\Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'default_locale' => $this->localeProvider->getDefaultLocale(), + 'enabled_locales' => $this->localeProvider->getEnabledLocales(), + 'required_locales' => $this->localeProvider->getRequiredLocales(), + 'locale_labels' => null, + 'theming_granularity' => 'field', + ]); + + $resolver->setAllowedTypes('locale_labels', 'array|null'); + $resolver->setAllowedValues('theming_granularity', ['field', 'locale_field']); + } + + #[\Override] + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['default_locale'] = $options['default_locale']; + $view->vars['required_locales'] = $options['required_locales']; + } +} diff --git a/src/Form/Type/TranslatedEntityType.php b/src/Form/Type/TranslatedEntityType.php index 95daded..1fe3f57 100644 --- a/src/Form/Type/TranslatedEntityType.php +++ b/src/Form/Type/TranslatedEntityType.php @@ -1,6 +1,4 @@ - + */ class TranslatedEntityType extends AbstractType { public function __construct( - private readonly RequestStack $requestStack, + private readonly LocaleSwitcher $localeSwitcher, ) {} + #[\Override] public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'translation_path' => 'translations', - 'query_builder' => static fn (EntityRepository $er) => $er->createQueryBuilder('e') + // EntityType + 'query_builder' => static fn (EntityRepository $er): QueryBuilder => $er->createQueryBuilder('e') ->select('e, t') ->join('e.translations', 't'), - 'choice_label' => function (Options $options) { - if (null === ($request = $this->requestStack->getCurrentRequest())) { - throw new \RuntimeException('Error while getting request'); - } - - return $options['translation_path'].'['.$request->getLocale().'].'.$options['translation_property']; - }, + 'choice_label' => fn (Options $options): string => \sprintf( + '%s[%s].%s', + $options['translation_path'], // @phpstan-ignore argument.type + $this->localeSwitcher->getLocale(), + $options['translation_property'], // @phpstan-ignore argument.type + ), + // Adds + 'translation_path' => 'translations', ]); + $resolver->setAllowedTypes('translation_path', 'string'); $resolver->setRequired('translation_property'); + $resolver->setAllowedTypes('translation_property', 'string'); } + #[\Override] public function getParent(): string { return EntityType::class; } + #[\Override] public function getBlockPrefix(): string { return 'a2lix_translatedEntity'; diff --git a/src/Form/Type/TranslationsFormsType.php b/src/Form/Type/TranslationsFormsType.php index 6e94959..ee48caf 100644 --- a/src/Form/Type/TranslationsFormsType.php +++ b/src/Form/Type/TranslationsFormsType.php @@ -1,6 +1,4 @@ -, + * required_locales: list, + * locale_labels: array|null, + * theming_granularity: string, + * form_options: array, + * form_type: string, + * ... + * } + * + * @extends AbstractType + */ class TranslationsFormsType extends AbstractType { - public function __construct( - private readonly TranslationsFormsListener $translationsFormsListener, - private readonly LocaleProviderInterface $localeProvider, - ) {} - - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $builder->addEventSubscriber($this->translationsFormsListener); - } - - public function buildView(FormView $view, FormInterface $form, array $options): void - { - $view->vars['default_locale'] = $options['default_locale']; - $view->vars['required_locales'] = $options['required_locales']; - } - + #[\Override] public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ + // FormType 'by_reference' => false, - 'empty_data' => static fn (FormInterface $form) => new ArrayCollection(), - 'locales' => $this->localeProvider->getLocales(), - 'default_locale' => $this->localeProvider->getDefaultLocale(), - 'required_locales' => $this->localeProvider->getRequiredLocales(), + 'empty_data' => new ArrayCollection(), + // Adds 'form_options' => [], ]); + $resolver->setAllowedTypes('form_options', 'array'); $resolver->setRequired('form_type'); + $resolver->setAllowedTypes('form_type', 'string'); + } + + #[\Override] + public function buildForm(FormBuilderInterface $builder, array $options): void + { + /** @var FormOptionsDefaults $options */ + $options = $options; + + foreach ($options['enabled_locales'] as $locale) { + $builder->add($locale, $options['form_type'], [ + ...$options['form_options'], + 'setter' => static function (Collection $translationColl, ?OneLocaleInterface $translation, FormInterface $form) use ($locale): void { + if (null === $translation) { + return; + } + + if ($translation->isEmpty()) { + $translationColl->removeElement($translation); - $resolver->setNormalizer('form_options', static function (Options $options, $value): array { - // Check mandatory data_class option when AutoFormType use - if (($options['form_type'] instanceof AutoFormType) && !isset($value['data_class'])) { - throw new \RuntimeException('Missing "data_class" option under "form_options" of TranslationsFormsType. Required when "form_type" use "AutoFormType".'); - } + return; + } - return $value; - }); + $translation->locale = $locale; // @phpstan-ignore property.notFound + $translationColl->add($translation); + }, + // LocaleExtension options process + 'label' => $options['locale_labels'][$locale] ?? null, + 'required' => \in_array($locale, $options['required_locales'], true), + 'block_name' => ('field' === $options['theming_granularity']) ? 'locale' : null, + ]); + } } + #[\Override] public function getBlockPrefix(): string { return 'a2lix_translationsForms'; diff --git a/src/Form/Type/TranslationsLocalesSelectorType.php b/src/Form/Type/TranslationsLocalesSelectorType.php index b8bea9f..f89a19b 100644 --- a/src/Form/Type/TranslationsLocalesSelectorType.php +++ b/src/Form/Type/TranslationsLocalesSelectorType.php @@ -1,6 +1,4 @@ - + */ class TranslationsLocalesSelectorType extends AbstractType { public function __construct( private readonly LocaleProviderInterface $localeProvider, ) {} - public function buildView(FormView $view, FormInterface $form, array $options): void - { - $view->vars['default_locale'] = $this->localeProvider->getDefaultLocale(); - } - + #[\Override] public function configureOptions(OptionsResolver $resolver): void { + $enabledLocales = $this->localeProvider->getEnabledLocales(); + $resolver->setDefaults([ - 'choices' => array_combine($this->localeProvider->getLocales(), $this->localeProvider->getLocales()), + // ChoiceType + 'choices' => array_combine($enabledLocales, $enabledLocales), 'expanded' => true, 'multiple' => true, 'attr' => [ @@ -43,11 +43,19 @@ public function configureOptions(OptionsResolver $resolver): void ]); } + #[\Override] + public function buildView(FormView $view, FormInterface $form, array $options): void + { + $view->vars['default_locale'] = $this->localeProvider->getDefaultLocale(); + } + + #[\Override] public function getParent(): string { return ChoiceType::class; } + #[\Override] public function getBlockPrefix(): string { return 'a2lix_translationsLocalesSelector'; diff --git a/src/Form/Type/TranslationsType.php b/src/Form/Type/TranslationsType.php index 4794acf..9f9b90d 100644 --- a/src/Form/Type/TranslationsType.php +++ b/src/Form/Type/TranslationsType.php @@ -1,6 +1,4 @@ -, + * required_locales: list, + * locale_labels: array|null, + * theming_granularity: string, + * translatable_class: class-string, + * translation_class: class-string, + * gedmo: bool, + * children: array, + * children_excluded: list|"*", + * children_embedded: list|"*", + * children_groups: list, + * builder: mixed|null, + * ... + * } + * + * @extends AbstractType + */ class TranslationsType extends AbstractType { + /** + * @param list $globalExcludedChildren + * @param list $globalEmbeddedChildren + */ public function __construct( - private readonly TranslationsListener $translationsListener, - private readonly LocaleProviderInterface $localeProvider, + private readonly array $globalExcludedChildren = [], + private readonly array $globalEmbeddedChildren = [], ) {} + #[\Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + // FormType + 'by_reference' => false, + // AutoType + 'children' => [], + 'children_excluded_' => $this->globalExcludedChildren, + 'children_excluded' => null, + 'children_embedded_' => $this->globalEmbeddedChildren, + 'children_embedded' => null, + 'children_groups' => ['Default'], + 'builder' => null, + ]); + + $resolver->setRequired('translatable_class'); + $resolver->setAllowedTypes('translatable_class', 'string'); + $resolver->setDefault('translation_class', static fn (Options $options): string => self::getTranslationClass($options['translatable_class'])); // @phpstan-ignore argument.type + $resolver->setDefault('gedmo', static fn (Options $options): bool => is_subclass_of( + $options['translation_class'], // @phpstan-ignore argument.type + 'Gedmo\Translatable\Entity\MappedSuperclass\AbstractPersonalTranslation' + )); + $resolver->setDefault('inherit_data', static fn (Options $options): bool => $options['gedmo']); // @phpstan-ignore return.type + $resolver->setDefault('empty_data', static fn (Options $options): ?ArrayCollection => $options['gedmo'] ? null : new ArrayCollection()); + + // AutoType + $resolver->setAllowedTypes('children_excluded', 'string[]|string|callable|null'); + $resolver->setInfo('children_excluded', 'An array of properties, the * wildcard, or a callable (mixed $previousValue): 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 $previousValue): 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'); + } + + #[\Override] public function buildForm(FormBuilderInterface $builder, array $options): void { - $builder->addEventSubscriber($this->translationsListener); + /** @var FormOptionsDefaults $options */ + $options = $options; + + if ($options['gedmo']) { + $this->buildGedmo($builder, $options); + + return; + } + + $this->buildKnp($builder, $options); } - public function buildView(FormView $view, FormInterface $form, array $options): void + #[\Override] + public function getBlockPrefix(): string { - $view->vars['default_locale'] = $options['default_locale']; - $view->vars['required_locales'] = $options['required_locales']; + return 'a2lix_translations'; } - public function configureOptions(OptionsResolver $resolver): void + private static function getTranslationClass(string $translatableClass): string { - $resolver->setDefaults([ - 'by_reference' => false, - 'empty_data' => static fn (FormInterface $form) => new ArrayCollection(), - 'locale_labels' => null, - 'locales' => $this->localeProvider->getLocales(), - 'default_locale' => $this->localeProvider->getDefaultLocale(), - 'required_locales' => $this->localeProvider->getRequiredLocales(), - 'theming_granularity' => 'field', - 'fields' => [], - 'excluded_fields' => [], - ]); + // Knp + if (method_exists($translatableClass, 'getTranslationEntityClass')) { + return $translatableClass::getTranslationEntityClass(); // @phpstan-ignore return.type + } + + // Gedmo + if (method_exists($translatableClass, 'getTranslationClass')) { + return $translatableClass::getTranslationClass(); // @phpstan-ignore return.type + } - $resolver->setAllowedValues('theming_granularity', ['field', 'locale_field']); + return $translatableClass.'Translation'; } - public function getBlockPrefix(): string + /** + * @param FormBuilderInterface $builder + * @param FormOptionsDefaults $options + */ + private function buildKnp(FormBuilderInterface $builder, array $options): void { - return 'a2lix_translations'; + // Build once optimization + $builtChildren = $builder->create('tmp', AutoType::class, [ + 'data_class' => $options['translation_class'], + 'children' => $options['children'], + 'children_excluded' => $options['children_excluded'], + 'children_embedded' => $options['children_embedded'], + 'children_groups' => $options['children_groups'], + 'builder' => $options['builder'], + ])->all(); + + foreach ($options['enabled_locales'] as $locale) { + $localeFormBuilder = $builder->create($locale, FormType::class, [ + 'data_class' => $options['translation_class'], + 'setter' => static fn (...$args) => self::knpLocaleSetter($locale, ...$args), // @phpstan-ignore argument.unpackNonIterable, argument.type + // LocaleExtension options process + 'label' => $options['locale_labels'][$locale] ?? null, + 'required' => \in_array($locale, $options['required_locales'], true), + 'block_name' => ('field' === $options['theming_granularity']) ? 'locale' : null, + ]); + + foreach ($builtChildren as $builtChild) { + $localeFormBuilder->add($builtChild); + } + + $builder->add($localeFormBuilder); + } + } + + /** + * @param FormBuilderInterface $builder + * @param FormOptionsDefaults $options + */ + private function buildGedmo(FormBuilderInterface $builder, array $options): void + { + // Build once optimization + $builtChildren = $builder->create('tmp', AutoType::class, [ + 'data_class' => $options['translatable_class'], + 'children' => $options['children'], + 'children_excluded' => $options['children_excluded'], + 'children_embedded' => $options['children_embedded'], + 'children_groups' => $options['children_groups'], + 'builder' => $options['builder'], + 'gedmo_only' => true, + ])->all(); + + $translationClass = $options['translation_class']; + + foreach ($options['enabled_locales'] as $locale) { + $localeFormBuilder = $builder->create($locale, FormType::class, [ + ...( + $locale === $options['default_locale'] + ? [ + 'inherit_data' => true, + ] : [ + 'getter' => static fn (...$args) => self::gedmoLocaleGetter($locale, ...$args), // @phpstan-ignore argument.unpackNonIterable, argument.type + 'setter' => static fn (...$args) => self::gedmoLocaleSetter(...$args), // @phpstan-ignore argument.unpackNonIterable, argument.type + ] + ), + // LocaleExtension options process + 'label' => $options['locale_labels'][$locale] ?? null, + 'required' => \in_array($locale, $options['required_locales'], true), + 'block_name' => ('field' === $options['theming_granularity']) ? 'locale' : null, + ]); + + foreach ($builtChildren as $builtChild) { + // Translatable fields + if ($locale === $options['default_locale']) { + $localeFormBuilder->add($builtChild); + continue; + } + + // Translation fields/objects + $field = $builtChild->getName(); + $localeFormBuilder->add($builtChild->getName(), $builtChild->getType()->getInnerType()::class, [ + ...$builtChild->getFormConfig()->getOptions(), + 'getter' => static fn (...$args) => self::gedmoFieldGetter($field, ...$args), // @phpstan-ignore argument.unpackNonIterable, argument.type + 'setter' => static fn (array &$translations, ?string $data) => self::gedmoFieldSetter($field, $locale, $translationClass, $translations, $data), // @phpstan-ignore-line + // 'property_path' => sprintf('[%s?].content', $field), + ]); + } + + $builder->add($localeFormBuilder); + } + } + + /** + * @param Collection $translationColl + * @param ?\Stub\KnpTranslation $translation + */ + private static function knpLocaleSetter(string $locale, Collection $translationColl, ?object $translation): void + { + if (null === $translation) { + return; + } + + if ($translation->isEmpty()) { + $translationColl->removeElement($translation); + + return; + } + + $translation->setLocale($locale); + $translationColl->add($translation); + } + + /** + * @param \Stub\GedmoTranslatable $translatable + * + * @return array + */ + private static function gedmoLocaleGetter(string $locale, object $translatable): array + { + /** @var array */ + return $translatable->getTranslations()->reduce( + static function (array $acc, object $item) use ($locale): array { + if ($item->getLocale() !== $locale) { + return $acc; + } + + $acc[$item->getField()] = $item; + + return $acc; + }, + [] + ); + } + + /** + * @param \Stub\GedmoTranslatable $translatable + * @param array $data + */ + private static function gedmoLocaleSetter(object $translatable, array $data): void + { + foreach ($data as $translation) { + if (null === $translation->getContent()) { + $translatable->removeTranslation($translation); + continue; + } + + $translatable->addTranslation($translation); + } + } + + /** + * @param array $translations + */ + private static function gedmoFieldGetter(string $field, array $translations): ?string + { + return ($translations[$field] ?? null)?->getContent(); + } + + /** + * @param class-string<\Stub\GedmoTranslation> $translationClass + * @param array $translations + */ + private static function gedmoFieldSetter( + string $field, + string $locale, + string $translationClass, + array &$translations, + ?string $data, + ): void { + // Update + if (null !== $translation = ($translations[$field] ?? null)) { + $translation->setContent($data); + + return; + } + + // Create + if (null !== $data) { + $translations[$field] = new ($translationClass)($locale, $field, $data); + } } } diff --git a/src/Helper/KnpTranslatableAccessorTrait.php b/src/Helper/KnpTranslatableAccessorTrait.php new file mode 100644 index 0000000..da6ab89 --- /dev/null +++ b/src/Helper/KnpTranslatableAccessorTrait.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Helper; + +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyPathInterface; + +trait KnpTranslatableAccessorTrait +{ + public function __call(string|PropertyPathInterface $method, $arguments) + { + if (null === $this->translate()->id) { + return null; + } + + return PropertyAccess::createPropertyAccessor()->getValue($this->translate(), $method); + } + + public function __get(string|PropertyPathInterface $property) + { + if (null === $this->translate()->id) { + return null; + } + + return PropertyAccess::createPropertyAccessor()->getValue($this->translate(), $property); + } +} diff --git a/src/Helper/OneLocaleInterface.php b/src/Helper/OneLocaleInterface.php new file mode 100644 index 0000000..56f15ea --- /dev/null +++ b/src/Helper/OneLocaleInterface.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Helper; + +interface OneLocaleInterface +{ + public function isEmpty(): bool; +} diff --git a/src/Helper/OneLocaleTrait.php b/src/Helper/OneLocaleTrait.php new file mode 100644 index 0000000..f5b73ee --- /dev/null +++ b/src/Helper/OneLocaleTrait.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Helper; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @phpstan-require-implements OneLocaleInterface + */ +trait OneLocaleTrait +{ + #[ORM\Column(length: 10)] + public string $locale; +} diff --git a/src/Locale/SimpleProvider.php b/src/Locale/SimpleProvider.php deleted file mode 100644 index d25f881..0000000 --- a/src/Locale/SimpleProvider.php +++ /dev/null @@ -1,54 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Locale; - -class SimpleProvider implements LocaleProviderInterface -{ - public function __construct( - private array $locales, - private string $defaultLocale, - private array $requiredLocales = [], - ) { - if (!\in_array($defaultLocale, $locales, true)) { - if (\count($locales)) { - throw new \InvalidArgumentException(sprintf('Default locale `%s` not found within the configured locales `[%s]`. Perhaps you need to add it to your `a2lix_translation_form.locales` bundle configuration?', $defaultLocale, implode(',', $locales))); - } - - throw new \InvalidArgumentException(sprintf('No locales were configured, but expected at least the default locale `%s`. Perhaps you need to add it to your `a2lix_translation_form.locales` bundle configuration?', $defaultLocale)); - } - - if (array_diff($requiredLocales, $locales)) { - throw new \InvalidArgumentException('Required locales should be contained in locales'); - } - - $this->locales = $locales; - $this->defaultLocale = $defaultLocale; - $this->requiredLocales = $requiredLocales; - } - - public function getLocales(): array - { - return $this->locales; - } - - public function getDefaultLocale(): string - { - return $this->defaultLocale; - } - - public function getRequiredLocales(): array - { - return $this->requiredLocales; - } -} diff --git a/src/Locale/LocaleProviderInterface.php b/src/LocaleProvider/LocaleProviderInterface.php similarity index 62% rename from src/Locale/LocaleProviderInterface.php rename to src/LocaleProvider/LocaleProviderInterface.php index de4ab9f..4d378ee 100644 --- a/src/Locale/LocaleProviderInterface.php +++ b/src/LocaleProvider/LocaleProviderInterface.php @@ -1,6 +1,4 @@ - + */ + public function getEnabledLocales(): array; public function getDefaultLocale(): string; + /** + * @return list + */ public function getRequiredLocales(): array; } diff --git a/src/LocaleProvider/SimpleLocaleProvider.php b/src/LocaleProvider/SimpleLocaleProvider.php new file mode 100644 index 0000000..8f6872b --- /dev/null +++ b/src/LocaleProvider/SimpleLocaleProvider.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\LocaleProvider; + +class SimpleLocaleProvider implements LocaleProviderInterface +{ + /** + * @param list $enabledLocales + * @param list $requiredLocales + */ + public function __construct( + private readonly string $defaultLocale, + private readonly array $enabledLocales, + private readonly array $requiredLocales = [], + ) {} + + #[\Override] + public function getDefaultLocale(): string + { + return $this->defaultLocale; + } + + #[\Override] + public function getEnabledLocales(): array + { + return $this->enabledLocales; + } + + #[\Override] + public function getRequiredLocales(): array + { + return $this->requiredLocales; + } +} diff --git a/src/Resources/config/a2lix_form.xml b/src/Resources/config/a2lix_form.xml deleted file mode 100644 index 6bd1b77..0000000 --- a/src/Resources/config/a2lix_form.xml +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Resources/views/bootstrap_3_layout.html.twig b/src/Resources/views/bootstrap_3_layout.html.twig deleted file mode 100644 index 27af08d..0000000 --- a/src/Resources/views/bootstrap_3_layout.html.twig +++ /dev/null @@ -1,34 +0,0 @@ -{% block a2lix_translations_widget %} - {{ form_errors(form) }} - -
- - -
- {% for translationsFields in form %} - {% set locale = translationsFields.vars.name %} - -
- {{ form_errors(translationsFields) }} - {{ form_widget(translationsFields) }} -
- {% endfor %} -
-
-{% endblock %} - -{% block a2lix_translationsForms_widget %} - {{ block('a2lix_translations_widget') }} -{% endblock %} diff --git a/src/Resources/views/bootstrap_4_layout.html.twig b/src/Resources/views/bootstrap_4_layout.html.twig deleted file mode 100644 index 219247c..0000000 --- a/src/Resources/views/bootstrap_4_layout.html.twig +++ /dev/null @@ -1,34 +0,0 @@ -{% block a2lix_translations_widget %} - {{ form_errors(form) }} - -
- - -
- {% for translationsFields in form %} - {% set locale = translationsFields.vars.name %} - -
- {{ form_errors(translationsFields) }} - {{ form_widget(translationsFields) }} -
- {% endfor %} -
-
-{% endblock %} - -{% block a2lix_translationsForms_widget %} - {{ block('a2lix_translations_widget') }} -{% endblock %} diff --git a/src/Resources/views/bootstrap_5_layout.html.twig b/src/Resources/views/bootstrap_5_layout.html.twig deleted file mode 100644 index 13d5e62..0000000 --- a/src/Resources/views/bootstrap_5_layout.html.twig +++ /dev/null @@ -1,34 +0,0 @@ -{% block a2lix_translations_widget %} - {{ form_errors(form) }} - -
- - -
- {% for translationsFields in form %} - {% set locale = translationsFields.vars.name %} - -
- {{ form_errors(translationsFields) }} - {{ form_widget(translationsFields) }} -
- {% endfor %} -
-
-{% endblock %} - -{% block a2lix_translationsForms_widget %} - {{ block('a2lix_translations_widget') }} -{% endblock %} diff --git a/src/Resources/views/macros.html.twig b/src/Resources/views/macros.html.twig deleted file mode 100644 index 93190c3..0000000 --- a/src/Resources/views/macros.html.twig +++ /dev/null @@ -1,40 +0,0 @@ -{# -Example: - -{% import "@A2lixTranslationForm/macros.html.twig" as a2lixTranslations %} - -{{ a2lixTranslations.partialTranslations(editForm.translations, ['title','description']) }} -{{ a2lixTranslations.partialTranslations(editForm.translations, ['url']) }} -#} - -{% macro partialTranslations(form, fieldsNames) %} - {{ form_errors(form) }} - -
- - -
- {% for translationsFields in form %} - {% set locale = translationsFields.vars.name %} - -
- {% for translationsField in translationsFields|filter(translationsField => translationsField.vars.name in fieldsNames) %} - {{ form_row(translationsField) }} - {% endfor %} -
- {% endfor %} -
-
-{% endmacro %} diff --git a/src/Resources/views/macros_bootstrap_3.html.twig b/src/Resources/views/macros_bootstrap_3.html.twig deleted file mode 100644 index d1dd5f2..0000000 --- a/src/Resources/views/macros_bootstrap_3.html.twig +++ /dev/null @@ -1,38 +0,0 @@ -{# -Example: -{% import "@A2lixTranslationForm/macros_bootstrap_3.html.twig" as a2lixTranslations %} -{{ a2lixTranslations.partialTranslations(editForm.translations, ['title','description']) }} -{{ a2lixTranslations.partialTranslations(editForm.translations, ['url']) }} -#} - -{% macro partialTranslations(form, fieldsNames) %} - {{ form_errors(form) }} - -
- - -
- {% for translationsFields in form %} - {% set locale = translationsFields.vars.name %} - -
- {% for translationsField in translationsFields|filter(translationsField => translationsField.vars.name in fieldsNames) %} - {{ form_row(translationsField) }} - {% endfor %} -
- {% endfor %} -
-
-{% endmacro %} diff --git a/src/Resources/views/macros_bootstrap_4.html.twig b/src/Resources/views/macros_bootstrap_4.html.twig deleted file mode 100644 index d4b231e..0000000 --- a/src/Resources/views/macros_bootstrap_4.html.twig +++ /dev/null @@ -1,38 +0,0 @@ -{# -Example: -{% import "@A2lixTranslationForm/macros_bootstrap_4.html.twig" as a2lixTranslations %} -{{ a2lixTranslations.partialTranslations(editForm.translations, ['title','description']) }} -{{ a2lixTranslations.partialTranslations(editForm.translations, ['url']) }} -#} - -{% macro partialTranslations(form, fieldsNames) %} - {{ form_errors(form) }} - -
- - -
- {% for translationsFields in form %} - {% set locale = translationsFields.vars.name %} - -
- {% for translationsField in translationsFields|filter(translationsField => translationsField.vars.name in fieldsNames) %} - {{ form_row(translationsField) }} - {% endfor %} -
- {% endfor %} -
-
-{% endmacro %} diff --git a/src/Resources/views/macros_bootstrap_5.html.twig b/src/Resources/views/macros_bootstrap_5.html.twig deleted file mode 100644 index ac0c822..0000000 --- a/src/Resources/views/macros_bootstrap_5.html.twig +++ /dev/null @@ -1,38 +0,0 @@ -{# -Example: -{% import "@A2lixTranslationForm/macros_bootstrap_5.html.twig" as a2lixTranslations %} -{{ a2lixTranslations.partialTranslations(editForm.translations, ['title','description']) }} -{{ a2lixTranslations.partialTranslations(editForm.translations, ['url']) }} -#} - -{% macro partialTranslations(form, fieldsNames) %} - {{ form_errors(form) }} - -
- - -
- {% for translationsFields in form %} - {% set locale = translationsFields.vars.name %} - -
- {% for translationsField in translationsFields|filter(translationsField => translationsField.vars.name in fieldsNames) %} - {{ form_row(translationsField) }} - {% endfor %} -
- {% endfor %} -
-
-{% endmacro %} diff --git a/src/Twig/Components/LocaleSwitcher.php b/src/Twig/Components/LocaleSwitcher.php new file mode 100644 index 0000000..2692815 --- /dev/null +++ b/src/Twig/Components/LocaleSwitcher.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Twig\Components; + +use A2lix\TranslationFormBundle\LocaleProvider\LocaleProviderInterface; + +class LocaleSwitcher +{ + public string $render = 'basic'; + + public function __construct( + private readonly LocaleProviderInterface $localeProvider, + ) {} + + /** + * @return list + */ + public function getEnabledLocales(): array + { + return $this->localeProvider->getEnabledLocales(); + } +} diff --git a/src/Twig/LocaleExtension.php b/src/Twig/LocaleExtension.php new file mode 100644 index 0000000..30f0869 --- /dev/null +++ b/src/Twig/LocaleExtension.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\TranslationFormBundle\Twig; + +use Symfony\Component\Intl\Locales; +use Symfony\Component\Translation\LocaleSwitcher; +use Twig\Attribute\AsTwigFunction; +use Twig\Extension\CoreExtension; + +final readonly class LocaleExtension +{ + public function __construct( + private LocaleSwitcher $localeSwitcher, + ) {} + + #[AsTwigFunction('locale_render', needsCharset: true)] + public function localeRender(string $charset, string $locale, string $render = 'locale_name_title'): string + { + if (str_starts_with($render, 'locale_name_')) { + $locale = Locales::getName($locale, $this->localeSwitcher->getLocale()); + } + + return match ($render) { + 'locale_upper', 'locale_name_upper' => CoreExtension::upper($charset, $locale), + 'locale_title', 'locale_name_title' => CoreExtension::titleCase($charset, $locale), + default => $locale, + }; + } +} diff --git a/stubs/GedmoTranslatable.stub b/stubs/GedmoTranslatable.stub new file mode 100644 index 0000000..bfdf0d6 --- /dev/null +++ b/stubs/GedmoTranslatable.stub @@ -0,0 +1,15 @@ + + */ + public function getTranslations(): Collection; + public function addTranslation(GedmoTranslation $translation): self; + public function removeTranslation(GedmoTranslation $translation): self; +} diff --git a/stubs/GedmoTranslation.stub b/stubs/GedmoTranslation.stub new file mode 100644 index 0000000..c6f4c90 --- /dev/null +++ b/stubs/GedmoTranslation.stub @@ -0,0 +1,13 @@ + + + +
+ {% for translationsFields in form %} + {% set formLocale = translationsFields.vars.name %} + {% set isActiveLocale = app.locale == formLocale %} + +
+ {{ form_errors(translationsFields) }} + {{ form_widget(translationsFields) }} +
+ {% endfor %} +
+ +{% endblock %} + +{% block a2lix_translationsForms_widget %} + {{ block('a2lix_translations_widget') }} +{% endblock %} diff --git a/templates/components/LocaleSwitcher.html.twig b/templates/components/LocaleSwitcher.html.twig new file mode 100644 index 0000000..7b4da8d --- /dev/null +++ b/templates/components/LocaleSwitcher.html.twig @@ -0,0 +1,41 @@ +{% if this.render == 'basic' %} + + +{% elseif this.render == 'dropdown' %} + +{% endif %} diff --git a/templates/native_layout.html.twig b/templates/native_layout.html.twig new file mode 100644 index 0000000..ceb7e5e --- /dev/null +++ b/templates/native_layout.html.twig @@ -0,0 +1,28 @@ +{# ! With a2lix_translation.css. Currently working with Gecko browsers, others have CSS related bugs to fix #} +{% block a2lix_translations_widget %} + {{ form_errors(form) }} + +
+ {% for translationsFields in form %} + {% set formLocale = translationsFields.vars.name %} + {% set isActiveLocale = app.locale == formLocale %} + {% set isDefaultLocale = formLocale == form.vars.default_locale %} + +
+ + {{ translationsFields.vars.label ?? locale_render(formLocale) }} + {{ isDefaultLocale ? '[Default]'|trans }} + {{ translationsFields.vars.required ? '*' }} + +
+ {{ form_errors(translationsFields) }} + {{ form_widget(translationsFields) }} +
+
+ {% endfor %} +
+{% endblock %} + +{% block a2lix_translationsForms_widget %} + {{ block('a2lix_translations_widget') }} +{% endblock %} diff --git a/tests/DependencyInjection/A2lixTranslationFormExtensionTest.php b/tests/DependencyInjection/A2lixTranslationFormExtensionTest.php deleted file mode 100644 index 0175f60..0000000 --- a/tests/DependencyInjection/A2lixTranslationFormExtensionTest.php +++ /dev/null @@ -1,51 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Tests\DependencyInjection; - -use A2lix\TranslationFormBundle\DependencyInjection\A2lixTranslationFormExtension; -use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase; - -/** - * @internal - */ -final class A2lixTranslationFormExtensionTest extends AbstractExtensionTestCase -{ - public function testAfterLoadingParametersAreSet(): void - { - $this->load(); - $this->assertContainerBuilderHasParameter('a2lix_translation_form.locale_provider', 'default'); - $this->assertContainerBuilderHasParameter('a2lix_translation_form.locales', ['es', 'en']); - $this->assertContainerBuilderHasParameter('a2lix_translation_form.required_locales', []); - $this->assertContainerBuilderHasParameter('a2lix_translation_form.default_locale', 'es'); - $this->assertContainerBuilderHasParameter( - 'a2lix_translation_form.templating', - '@A2lixTranslationForm/bootstrap_4_layout.html.twig' - ); - } - - protected function getContainerExtensions(): array - { - return [ - new A2lixTranslationFormExtension(), - ]; - } - - protected function getMinimalConfiguration(): array - { - return [ - 'locales' => ['es', 'en'], - 'default_locale' => 'es', - ]; - } -} diff --git a/tests/Fixtures/Entity/Category.php b/tests/Fixtures/Entity/Category.php new file mode 100644 index 0000000..dc3eb55 --- /dev/null +++ b/tests/Fixtures/Entity/Category.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Tests\Fixtures\Entity; + +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; +use A2lix\TranslationFormBundle\Helper\KnpTranslatableAccessorTrait; +use A2lix\TranslationFormBundle\Tests\Fixtures\IdTrait; +use Doctrine\ORM\Mapping as ORM; +use Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface; +use Knp\DoctrineBehaviors\Model\Translatable\TranslatableTrait; + +#[ORM\Entity] +class Category implements \Stringable, TranslatableInterface +{ + use IdTrait; + use KnpTranslatableAccessorTrait; + use TranslatableTrait; + + #[ORM\Column] + public string $code; + + #[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'categories')] + #[AutoTypeCustom(excluded: true)] + public ?Company $company = null; + + public function __toString(): string + { + return $this->code; + } +} diff --git a/tests/Fixtures/Entity/CategoryTranslation.php b/tests/Fixtures/Entity/CategoryTranslation.php new file mode 100644 index 0000000..9bc5898 --- /dev/null +++ b/tests/Fixtures/Entity/CategoryTranslation.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Tests\Fixtures\Entity; + +use A2lix\TranslationFormBundle\Tests\Fixtures\IdTrait; +use Doctrine\ORM\Mapping as ORM; +use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface; +use Knp\DoctrineBehaviors\Model\Translatable\TranslationTrait; + +#[ORM\Entity] +class CategoryTranslation implements TranslationInterface +{ + use IdTrait; + use TranslationTrait; + + #[ORM\Column] + public string $title; +} diff --git a/tests/Fixtures/Entity/Company.php b/tests/Fixtures/Entity/Company.php new file mode 100644 index 0000000..1eaf069 --- /dev/null +++ b/tests/Fixtures/Entity/Company.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Tests\Fixtures\Entity; + +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; +use A2lix\TranslationFormBundle\Helper\KnpTranslatableAccessorTrait; +use A2lix\TranslationFormBundle\Tests\Fixtures\IdTrait; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Knp\DoctrineBehaviors\Contract\Entity\TranslatableInterface; +use Knp\DoctrineBehaviors\Model\Translatable\TranslatableTrait; + +#[ORM\Entity] +class Company implements TranslatableInterface +{ + use IdTrait; + use KnpTranslatableAccessorTrait; + use TranslatableTrait; + + #[ORM\Column] + #[AutoTypeCustom(options: ['priority' => 2])] + public string $code; + + #[AutoTypeCustom(options: ['priority' => 1])] + protected $translations; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: Category::class, mappedBy: 'company', cascade: ['all'], orphanRemoval: true)] + #[AutoTypeCustom(options: ['entry_options' => ['label' => false]], embedded: true)] + public Collection $categories; + + /** @var Collection */ + #[ORM\OneToMany(targetEntity: CompanyMediaLocale::class, mappedBy: 'company', cascade: ['all'], orphanRemoval: true, indexBy: 'locale')] + // #[AutoTypeCustom(embedded: true, options: ['entry_options' => ['label' => false, 'children_excluded' => ['id']]])] + // #[AutoTypeCustom(options: ['form_type' => CompanyMediaType::class], type: TranslationsFormsType::class)] + public Collection $medias; + + public function __construct() + { + $this->categories = new ArrayCollection(); + $this->medias = new ArrayCollection(); + } + + public function addCategory(Category $category): self + { + if (!$this->categories->contains($category)) { + $category->company = $this; + $this->categories[] = $category; + } + + return $this; + } + + public function removeCategory(Category $category): self + { + $this->categories->removeElement($category); + + return $this; + } + + public function addMedia(CompanyMediaLocale $media): self + { + $media->company = $this; + $this->medias->set($media->locale, $media); + + return $this; + } + + public function removeMedia(CompanyMediaLocale $media): self + { + $this->medias->removeElement($media); + + return $this; + } + + public function getMedia(?string $targetedLocale = null): ?CompanyMediaLocale + { + $targetedLocale ??= $this->getCurrentLocale(); + + return $this->medias->get($targetedLocale) ?? null; + } +} diff --git a/tests/Fixtures/Entity/CompanyMediaLocale.php b/tests/Fixtures/Entity/CompanyMediaLocale.php new file mode 100644 index 0000000..68f2bf7 --- /dev/null +++ b/tests/Fixtures/Entity/CompanyMediaLocale.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Tests\Fixtures\Entity; + +use A2lix\AutoFormBundle\Form\Attribute\AutoTypeCustom; +use A2lix\TranslationFormBundle\Helper\OneLocaleInterface; +use A2lix\TranslationFormBundle\Helper\OneLocaleTrait; +use A2lix\TranslationFormBundle\Tests\Fixtures\IdTrait; +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class CompanyMediaLocale implements \Stringable, OneLocaleInterface +{ + use IdTrait; + use OneLocaleTrait; + + #[ORM\Column] + public ?string $url; + + #[ORM\ManyToOne(targetEntity: Company::class, inversedBy: 'medias')] + #[AutoTypeCustom(excluded: true)] + public ?Company $company = null; + + public function isEmpty(): bool + { + return null === $this->url; + } + + public function __toString(): string + { + return \sprintf( + '%s', + $this->url, + ); + } +} diff --git a/tests/Fixtures/Entity/CompanyTranslation.php b/tests/Fixtures/Entity/CompanyTranslation.php new file mode 100644 index 0000000..361ff73 --- /dev/null +++ b/tests/Fixtures/Entity/CompanyTranslation.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Tests\Fixtures\Entity; + +use A2lix\TranslationFormBundle\Tests\Fixtures\IdTrait; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Knp\DoctrineBehaviors\Contract\Entity\TranslationInterface; +use Knp\DoctrineBehaviors\Model\Translatable\TranslationTrait; + +#[ORM\Entity] +class CompanyTranslation implements TranslationInterface +{ + use IdTrait; + use TranslationTrait; + + #[ORM\Column] + public ?string $title; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + public ?string $description = null; +} diff --git a/tests/Fixtures/Entity/MediaLocalize.php b/tests/Fixtures/Entity/MediaLocalize.php deleted file mode 100644 index abe18b6..0000000 --- a/tests/Fixtures/Entity/MediaLocalize.php +++ /dev/null @@ -1,90 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Tests\Fixtures\Entity; - -use Doctrine\ORM\Mapping as ORM; - -#[ORM\Entity] -class MediaLocalize -{ - #[ORM\Id] - #[ORM\Column(type: 'integer')] - #[ORM\GeneratedValue(strategy: 'AUTO')] - private ?int $id = null; - - #[ORM\Column(length: 10)] - private ?string $locale = 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 getLocale(): string - { - return $this->locale; - } - - public function setLocale(string $locale): self - { - $this->locale = $locale; - - return $this; - } - - 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/Product.php b/tests/Fixtures/Entity/Product.php index c78c7f0..cb283b1 100644 --- a/tests/Fixtures/Entity/Product.php +++ b/tests/Fixtures/Entity/Product.php @@ -1,6 +1,4 @@ - 1])] + public string $code; - #[ORM\Column(type: 'text', nullable: true)] - private ?string $description = null; + #[ORM\Column] + #[Gedmo\Translatable] + public ?string $title = null; - #[ORM\Column(nullable: true)] - private ?string $url = null; + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Gedmo\Translatable] + public ?string $description = null; - #[ORM\OneToMany(targetEntity: MediaLocalize::class, mappedBy: 'product', indexBy: 'locale', cascade: ['all'], orphanRemoval: true)] - private ArrayCollection $medias; + #[ORM\ManyToOne(targetEntity: Category::class, cascade: ['all'])] + public ?Category $category = null; - #[ORM\OneToMany(targetEntity: ProductTranslation::class, mappedBy: 'object', indexBy: 'locale', cascade: ['all'], orphanRemoval: true)] - private ArrayCollection $translations; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: ProductTranslation::class, mappedBy: 'object', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[AutoTypeCustom(options: ['priority' => 1])] + public Collection $translations; public function __construct() { - $this->medias = new ArrayCollection(); $this->translations = 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; - } - + /** + * @return Collection + */ public function getTranslations(): Collection { return $this->translations; @@ -95,8 +61,7 @@ public function getTranslations(): Collection public function addTranslation(ProductTranslation $translation): self { if (!$this->translations->contains($translation)) { - $translation->setTranslatable($this); - $this->translations->set($translation->getLocale(), $translation); + $this->translations[] = $translation->setObject($this); } return $this; @@ -104,29 +69,7 @@ public function addTranslation(ProductTranslation $translation): self public function removeTranslation(ProductTranslation $translation): self { - $this->translations->removeElement($translation); - - return $this; - } - - public function getMedias(): Collection - { - return $this->medias; - } - - public function addMedia(MediaLocalize $media): self - { - if (!$this->medias->contains($media)) { - $media->setProduct($this); - $this->medias->set($media->getLocale(), $media); - } - - return $this; - } - - public function removeMedia(MediaLocalize $media): self - { - $this->medias->removeElement($media); + $this->translations->removeElement($translation->setObject(null)); return $this; } diff --git a/tests/Fixtures/Entity/ProductTranslation.php b/tests/Fixtures/Entity/ProductTranslation.php index aecdd58..9012d9a 100644 --- a/tests/Fixtures/Entity/ProductTranslation.php +++ b/tests/Fixtures/Entity/ProductTranslation.php @@ -1,6 +1,4 @@ -id; - } - - public function getLocale(): string + public function __construct(string $locale, string $field, string $value) { - return $this->locale; - } - - public function setLocale(string $locale): self - { - $this->locale = $locale; - - return $this; + $this->setLocale($locale)->setField($field)->setContent($value); } - 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 getTranslatable(): Product - { - return $this->translatable; - } - - public function setTranslatable(Product $translatable): self - { - $this->translatable = $translatable; - - return $this; - } + #[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'translations')] + #[ORM\JoinColumn(name: 'object_id', nullable: false, onDelete: 'CASCADE')] + protected $object; } diff --git a/tests/Fixtures/Form/MediaLocalizeType.php b/tests/Fixtures/Form/CompanyMediaType.php similarity index 75% rename from tests/Fixtures/Form/MediaLocalizeType.php rename to tests/Fixtures/Form/CompanyMediaType.php index 59c673e..64627d3 100644 --- a/tests/Fixtures/Form/MediaLocalizeType.php +++ b/tests/Fixtures/Form/CompanyMediaType.php @@ -1,6 +1,4 @@ -add('url') - ->add('description') ; } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'data_class' => MediaLocalize::class, + 'data_class' => CompanyMediaLocale::class, ]); } } diff --git a/tests/Fixtures/IdTrait.php b/tests/Fixtures/IdTrait.php new file mode 100644 index 0000000..7e9bc90 --- /dev/null +++ b/tests/Fixtures/IdTrait.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Tests\Fixtures; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +trait IdTrait +{ + #[ORM\Id] + #[ORM\Column(type: Types::INTEGER)] + #[ORM\GeneratedValue] + public ?int $id = null; +} diff --git a/tests/Form/Type/GedmoTranslationsTypeTest.php b/tests/Form/Type/GedmoTranslationsTypeTest.php new file mode 100644 index 0000000..8b0bc59 --- /dev/null +++ b/tests/Form/Type/GedmoTranslationsTypeTest.php @@ -0,0 +1,202 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Tests\Form\Type; + +use A2lix\AutoFormBundle\Form\Type\AutoType; +use A2lix\TranslationFormBundle\Form\Type\TranslationsType; +use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\Product; +use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\ProductTranslation; +use A2lix\TranslationFormBundle\Tests\Form\TypeTestCase; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\Form\Extension\Core\Type as CoreType; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormInterface; + +/** + * @internal + */ +#[CoversClass(TranslationsType::class)] +#[AllowMockObjectsWithoutExpectations] // https://github.com/symfony/symfony/issues/62669 +final class GedmoTranslationsTypeTest extends TypeTestCase +{ + public function testEmptyManual(): void + { + $form = $this->factory->createBuilder(FormType::class, new Product()) + ->add('code') + ->add('translations', TranslationsType::class, [ + 'translatable_class' => Product::class, + ]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($form); + + // Same with alternative creation + $form = $this->factory->createBuilder(FormType::class, null, ['data_class' => Product::class]) + ->add('code') + ->add('translations', TranslationsType::class, [ + 'translatable_class' => Product::class, + ]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($form); + } + + public function testEmptyAuto(): void + { + $form = $this->factory->createBuilder(AutoType::class, new Product()) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($form); + + // Same with alternative creation + $form = $this->factory->createBuilder(AutoType::class, null, ['data_class' => Product::class]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($form); + } + + public function testModifyManual(): void + { + [$product, $submitData] = $this->commonCreateProductAndSubmit(); + + $form = $this->factory->createBuilder(FormType::class, $product) + ->add('code') + ->add('translations', TranslationsType::class, [ + 'translatable_class' => Product::class, + ]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $form->submit($submitData); + $updProduct = $form->getData(); + + self::assertSame('title en', $updProduct->title, 'Should be unchanged'); + self::assertSame('desc ennnnnnn', $updProduct->description, 'Should be updated'); + self::assertSame('title frrrrrr', self::getGedmoTranslation($updProduct, 'fr', 'title')->getContent(), 'Should be updated'); + self::assertSame(self::getGedmoTranslation($product, 'fr', 'description'), self::getGedmoTranslation($updProduct, 'fr', 'description'), 'Should be unchanged'); + self::assertSame('title deeee', self::getGedmoTranslation($updProduct, 'de', 'title')?->getContent(), 'Should be created'); + self::assertNull(self::getGedmoTranslation($updProduct, 'de', 'description')?->getContent(), 'Should be unchanged'); + } + + public function testModifyAuto(): void + { + [$product, $submitData] = $this->commonCreateProductAndSubmit(); + + $form = $this->factory->createBuilder(AutoType::class, $product) + ->add('save', SubmitType::class) + ->getForm() + ; + + $form->submit($submitData); + $updProduct = $form->getData(); + + self::assertSame('title en', $updProduct->title, 'Should be unchanged'); + self::assertSame('desc ennnnnnn', $updProduct->description, 'Should be updated'); + self::assertSame('title frrrrrr', self::getGedmoTranslation($updProduct, 'fr', 'title')->getContent(), 'Should be updated'); + self::assertSame(self::getGedmoTranslation($product, 'fr', 'description'), self::getGedmoTranslation($updProduct, 'fr', 'description'), 'Should be unchanged'); + self::assertSame('title deeee', self::getGedmoTranslation($updProduct, 'de', 'title')?->getContent(), 'Should be created'); + self::assertNull(self::getGedmoTranslation($updProduct, 'de', 'description')?->getContent(), 'Should be unchanged'); + } + + private function assertCommonFormChildren(FormInterface $form): void + { + self::assertFormChildren( + [ + 'en' => [ + 'expected_type' => FormType::class, + 'expected_children' => [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + ], + ], + ], + 'fr' => [ + 'expected_type' => FormType::class, + 'expected_children' => [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + 'required' => true, + ], + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + ], + ], + ], + 'de' => [ + 'expected_type' => FormType::class, + 'expected_children' => [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + ], + ], + ], + ], + $form->get('translations')->all(), + ); + } + + private function commonCreateProductAndSubmit(): array + { + $product = new Product(); + $product->code = 'code1'; + $product + ->addTranslation(new ProductTranslation('en', 'title', 'title en')) + ->addTranslation(new ProductTranslation('en', 'description', 'desc en')) + ->addTranslation(new ProductTranslation('fr', 'title', 'title fr')) + ->addTranslation(new ProductTranslation('fr', 'description', 'desc fr')) + ; + + $submitData = [ + 'code' => 'code1', + 'translations' => [ + 'en' => [ + 'title' => 'title en', + 'description' => 'desc ennnnnnn', // Upd translatable description + ], + 'fr' => [ + 'title' => 'title frrrrrr', // Upd translation FR title + 'description' => 'desc fr', + ], + 'de' => [ + 'title' => 'title deeee', // New translation DE title + 'description' => '', + ], + ], + ]; + + return [$product, $submitData]; + } + + private static function getGedmoTranslation(object $translatable, string $locale, string $field): ?object + { + return $translatable->getTranslations()->findFirst( + static fn (int $k, $t) => $locale === $t->getLocale() && $field === $t->getField() + ); + } +} diff --git a/tests/Form/Type/KnpTranslationsTypeTest.php b/tests/Form/Type/KnpTranslationsTypeTest.php new file mode 100644 index 0000000..608548f --- /dev/null +++ b/tests/Form/Type/KnpTranslationsTypeTest.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Tests\Form\Type; + +use A2lix\AutoFormBundle\Form\Type\AutoType; +use A2lix\TranslationFormBundle\Form\Type\TranslationsType; +use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\Company; +use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\CompanyTranslation; +use A2lix\TranslationFormBundle\Tests\Form\TypeTestCase; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\Form\Extension\Core\Type as CoreType; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormInterface; + +/** + * @internal + */ +#[CoversClass(TranslationsType::class)] +#[AllowMockObjectsWithoutExpectations] // https://github.com/symfony/symfony/issues/62669 +final class KnpTranslationsTypeTest extends TypeTestCase +{ + public function testEmptyManual(): void + { + $emptyForm = $this->factory->createBuilder(FormType::class, new Company()) + ->add('code') + ->add('translations', TranslationsType::class, [ + 'translatable_class' => Company::class, + ]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($emptyForm); + + // Same with alternative creation + $form = $this->factory->createBuilder(FormType::class, null, ['data_class' => Company::class]) + ->add('code') + ->add('translations', TranslationsType::class, [ + 'translatable_class' => Company::class, + ]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($form); + } + + public function testEmptyAuto(): void + { + $form = $this->factory->createBuilder(AutoType::class, new Company()) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($form); + + // Same with alternative creation + $form = $this->factory->createBuilder(AutoType::class, null, ['data_class' => Company::class]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($form); + } + + public function testModifyManual(): void + { + [$company, $submitData] = $this->commonCreateCompanyAndSubmit(); + + $form = $this->factory->createBuilder(FormType::class, $company) + ->add('code') + ->add('translations', TranslationsType::class, [ + 'translatable_class' => Company::class, + ]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $form->submit($submitData); + $updCompany = $form->getData(); + + self::assertSame('title en', $updCompany->getTranslations()['en']->title, 'Should be unchanged'); + self::assertSame('desc ennnnnnn', $updCompany->getTranslations()['en']->description, 'Should be updated'); + self::assertSame('title frrrrrr', $updCompany->getTranslations()['fr']->title, 'Should be updated'); + self::assertSame($company->getTranslations()['fr']->description, $updCompany->getTranslations()['fr']->description, 'Should be unchanged'); + self::assertSame('title deeee', $updCompany->getTranslations()['de']?->title, 'Should be created'); + self::assertNull($updCompany->getTranslations()['de']?->description, 'Should be unchanged'); + } + + public function testModifyAuto(): void + { + [$company, $submitData] = $this->commonCreateCompanyAndSubmit(); + + $form = $this->factory->createBuilder(AutoType::class, $company) + ->add('save', SubmitType::class) + ->getForm() + ; + + $form->submit($submitData); + $updCompany = $form->getData(); + + self::assertSame('title en', $updCompany->getTranslations()['en']->title, 'Should be unchanged'); + self::assertSame('desc ennnnnnn', $updCompany->getTranslations()['en']->description, 'Should be updated'); + self::assertSame('title frrrrrr', $updCompany->getTranslations()['fr']->title, 'Should be updated'); + self::assertSame($company->getTranslations()['fr']->description, $updCompany->getTranslations()['fr']->description, 'Should be unchanged'); + self::assertSame('title deeee', $updCompany->getTranslations()['de']?->title, 'Should be created'); + self::assertNull($updCompany->getTranslations()['de']?->description, 'Should be unchanged'); + } + + private function assertCommonFormChildren(FormInterface $form): void + { + self::assertFormChildren( + [ + 'en' => [ + 'expected_type' => FormType::class, + 'expected_children' => [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + ], + ], + ], + 'fr' => [ + 'expected_type' => FormType::class, + 'expected_children' => [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + ], + ], + ], + 'de' => [ + 'expected_type' => FormType::class, + 'expected_children' => [ + 'title' => [ + 'expected_type' => CoreType\TextType::class, + ], + 'description' => [ + 'expected_type' => CoreType\TextareaType::class, + ], + ], + ], + ], + $form->get('translations')->all(), + ); + } + + private function commonCreateCompanyAndSubmit(): array + { + $company = new Company(); + $company->code = 'code1'; + + $companyTranslationEn = new CompanyTranslation(); + $companyTranslationEn->setLocale('en'); + $companyTranslationEn->title = 'title en'; + $companyTranslationEn->description = 'description en'; + $companyTranslationFr = new CompanyTranslation(); + $companyTranslationFr->setLocale('fr'); + $companyTranslationFr->title = 'title fr'; + $companyTranslationFr->description = 'description fr'; + + $company->addTranslation($companyTranslationEn); + $company->addTranslation($companyTranslationFr); + + $submitData = [ + 'code' => 'code1', + 'translations' => [ + 'en' => [ + 'title' => 'title en', + 'description' => 'desc ennnnnnn', // Upd translatable description + ], + 'fr' => [ + 'title' => 'title frrrrrr', // Upd translation FR title + 'description' => 'desc fr', + ], + 'de' => [ + 'title' => 'title deeee', // New translation DE title + 'description' => '', + ], + ], + ]; + + return [$company, $submitData]; + } +} diff --git a/tests/Form/Type/TranslationsFormsTypeAdvancedTest.php b/tests/Form/Type/TranslationsFormsTypeAdvancedTest.php deleted file mode 100644 index f5dfb10..0000000 --- a/tests/Form/Type/TranslationsFormsTypeAdvancedTest.php +++ /dev/null @@ -1,69 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Tests\Form\Type; - -use A2lix\TranslationFormBundle\Form\Type\TranslationsFormsType; -use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\Product; -use A2lix\TranslationFormBundle\Tests\Fixtures\Form\MediaLocalizeType; -use A2lix\TranslationFormBundle\Tests\Form\TypeTestCase; -use Symfony\Component\Form\Extension\Core\Type\FormType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\PreloadedExtension; - -/** - * @internal - */ -final class TranslationsFormsTypeAdvancedTest extends TypeTestCase -{ - protected $locales = ['en', 'fr', 'de']; - protected $defaultLocale = 'en'; - protected $requiredLocales = ['en', 'fr']; - - public function testEmptyFormOverrideLocales(): void - { - $overrideLocales = ['en', 'fr', 'es']; - $overrideRequiredLocales = ['en', 'es']; - - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('medias', TranslationsFormsType::class, [ - 'form_type' => MediaLocalizeType::class, - 'locales' => $overrideLocales, - 'required_locales' => $overrideRequiredLocales, - ]) - ->add('save', SubmitType::class) - ->getForm() - ; - - $mediasForm = $form->get('medias')->all(); - $mediasLocales = array_keys($mediasForm); - $mediasRequiredLocales = array_keys(array_filter($mediasForm, static fn ($form) => $form->isRequired())); - - self::assertEquals($overrideLocales, $mediasLocales, 'Locales should be same as config'); - self::assertEquals($overrideRequiredLocales, $mediasRequiredLocales, 'Required locales should be same as config'); - - self::assertEquals(['url', 'description'], array_keys($mediasForm['en']->all()), 'Fields should matches MediaLocalizeType fields'); - self::assertEquals(['url', 'description'], array_keys($mediasForm['fr']->all()), 'Fields should matches MediaLocalizeType fields'); - self::assertEquals(['url', 'description'], array_keys($mediasForm['es']->all()), 'Fields should matches MediaLocalizeType fields'); - } - - protected function getExtensions(): array - { - $translationsFormsType = $this->getConfiguredTranslationsFormsType($this->locales, $this->defaultLocale, $this->requiredLocales); - - return [new PreloadedExtension([ - $translationsFormsType, - ], [])]; - } -} diff --git a/tests/Form/Type/TranslationsFormsTypeSimpleTest.php b/tests/Form/Type/TranslationsFormsTypeSimpleTest.php deleted file mode 100644 index 59ee510..0000000 --- a/tests/Form/Type/TranslationsFormsTypeSimpleTest.php +++ /dev/null @@ -1,171 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Tests\Form\Type; - -use A2lix\TranslationFormBundle\Form\Type\TranslationsFormsType; -use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\MediaLocalize; -use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\Product; -use A2lix\TranslationFormBundle\Tests\Fixtures\Form\MediaLocalizeType; -use A2lix\TranslationFormBundle\Tests\Form\TypeTestCase; -use Symfony\Component\Form\Extension\Core\Type\FormType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\PreloadedExtension; - -/** - * @internal - */ -final class TranslationsFormsTypeSimpleTest extends TypeTestCase -{ - protected $locales = ['en', 'fr', 'de']; - protected $defaultLocale = 'en'; - protected $requiredLocales = ['en', 'fr']; - - public function testEmptyForm(): void - { - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('medias', TranslationsFormsType::class, [ - 'form_type' => MediaLocalizeType::class, - ]) - ->add('save', SubmitType::class) - ->getForm() - ; - - $mediasForm = $form->get('medias')->all(); - $mediasLocales = array_keys($mediasForm); - $mediasRequiredLocales = array_keys(array_filter($mediasForm, static fn ($form) => $form->isRequired())); - - self::assertEquals($this->locales, $mediasLocales, 'Locales should be same as config'); - self::assertEquals($this->requiredLocales, $mediasRequiredLocales, 'Required locales should be same as config'); - - self::assertEquals(['url', 'description'], array_keys($mediasForm['en']->all()), 'Fields should matches MediaLocalizeType fields'); - self::assertEquals(['url', 'description'], array_keys($mediasForm['fr']->all()), 'Fields should matches MediaLocalizeType fields'); - self::assertEquals(['url', 'description'], array_keys($mediasForm['de']->all()), 'Fields should matches MediaLocalizeType fields'); - } - - public function testCreationForm(): Product - { - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('medias', TranslationsFormsType::class, [ - 'form_type' => MediaLocalizeType::class, - ]) - ->add('save', SubmitType::class) - ->getForm() - ; - - $mediaEn = new MediaLocalize(); - $mediaEn->setLocale('en') - ->setUrl('http://en') - ->setDescription('desc en') - ; - $mediaFr = new MediaLocalize(); - $mediaFr->setLocale('fr') - ->setUrl('http://fr') - ->setDescription('desc fr') - ; - $mediaDe = new MediaLocalize(); - $mediaDe->setLocale('de') - ->setUrl('http://de') - ->setDescription('desc de') - ; - - $product = new Product(); - $product->setUrl('a2lix.fr') - ->addMedia($mediaEn) - ->addMedia($mediaFr) - ->addMedia($mediaDe) - ; - - $formData = [ - 'url' => 'a2lix.fr', - 'medias' => [ - 'en' => [ - 'url' => 'http://en', - 'description' => 'desc en', - ], - 'fr' => [ - 'url' => 'http://fr', - 'description' => 'desc fr', - ], - 'de' => [ - 'url' => 'http://de', - 'description' => 'desc de', - ], - ], - ]; - - $form->submit($formData); - self::assertTrue($form->isSynchronized()); - self::assertEquals($product, $form->getData()); - - return $product; - } - - /** - * @depends testCreationForm - */ - public function testEditionForm(Product $product): void - { - $product->getMedias()['en']->setUrl('http://ennnnn'); - $product->getMedias()['fr']->setDescription('desc frrrrrr'); - - $formData = [ - 'url' => 'a2lix.fr', - 'medias' => [ - 'en' => [ - 'url' => 'http://ennnnn', - 'description' => 'desc en', - ], - 'fr' => [ - 'url' => 'http://fr', - 'description' => 'desc frrrrrr', - ], - 'de' => [ - 'url' => 'http://de', - 'description' => 'desc de', - ], - ], - ]; - - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('medias', TranslationsFormsType::class, [ - 'form_type' => MediaLocalizeType::class, - ]) - ->add('save', 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 - { - $translationsFormsType = $this->getConfiguredTranslationsFormsType($this->locales, $this->defaultLocale, $this->requiredLocales); - - return [new PreloadedExtension([ - $translationsFormsType, - ], [])]; - } -} diff --git a/tests/Form/Type/TranslationsFormsTypeTest.php b/tests/Form/Type/TranslationsFormsTypeTest.php new file mode 100644 index 0000000..7040f37 --- /dev/null +++ b/tests/Form/Type/TranslationsFormsTypeTest.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace A2lix\TranslationFormBundle\Tests\Form\Type; + +use A2lix\TranslationFormBundle\Form\Type\TranslationsFormsType; +use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\Company; +use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\CompanyMediaLocale; +use A2lix\TranslationFormBundle\Tests\Fixtures\Form\CompanyMediaType; +use A2lix\TranslationFormBundle\Tests\Form\TypeTestCase; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\Form\Extension\Core\Type as CoreType; +use Symfony\Component\Form\Extension\Core\Type\FormType; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\FormInterface; + +/** + * @internal + */ +#[CoversClass(TranslationsFormsType::class)] +#[AllowMockObjectsWithoutExpectations] // https://github.com/symfony/symfony/issues/62669 +final class TranslationsFormsTypeTest extends TypeTestCase +{ + public function testEmptyManual(): void + { + $form = $this->factory->createBuilder(FormType::class, new Company()) + ->add('code') + ->add('medias', TranslationsFormsType::class, [ + 'form_type' => CompanyMediaType::class, + ]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($form); + + // Same with alternative creation + $form = $this->factory->createBuilder(FormType::class, null, ['data_class' => Company::class]) + ->add('code') + ->add('medias', TranslationsFormsType::class, [ + 'form_type' => CompanyMediaType::class, + ]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $this->assertCommonFormChildren($form); + } + + public function testModifyManual(): void + { + [$company, $submitData] = $this->commonCreateCompanyAndSubmit(); + + $form = $this->factory->createBuilder(FormType::class, $company) + ->add('code') + ->add('medias', TranslationsFormsType::class, [ + 'form_type' => CompanyMediaType::class, + ]) + ->add('save', SubmitType::class) + ->getForm() + ; + + $form->submit($submitData); + $updCompany = $form->getData(); + + self::assertSame('url en', $updCompany->getMedia('en')->url, 'Should be unchanged'); + self::assertSame('url frrrr', $updCompany->getMedia('fr')->url, 'Should be updated'); + self::assertSame('url deee', $updCompany->getMedia('de')->url, 'Should be created'); + } + + private function assertCommonFormChildren(FormInterface $form): void + { + self::assertFormChildren( + [ + 'en' => [ + 'expected_type' => CompanyMediaType::class, + 'expected_children' => [ + 'url' => [ + 'expected_type' => CoreType\TextType::class, + ], + ], + ], + 'fr' => [ + 'expected_type' => CompanyMediaType::class, + 'expected_children' => [ + 'url' => [ + 'expected_type' => CoreType\TextType::class, + ], + ], + ], + 'de' => [ + 'expected_type' => CompanyMediaType::class, + 'expected_children' => [ + 'url' => [ + 'expected_type' => CoreType\TextType::class, + ], + ], + ], + ], + $form->get('medias')->all(), + ); + } + + private function commonCreateCompanyAndSubmit(): array + { + $company = new Company(); + $company->code = 'code1'; + + $companyMediaLocaleEn = new CompanyMediaLocale(); + $companyMediaLocaleEn->id = 1; + $companyMediaLocaleEn->locale = 'en'; + $companyMediaLocaleEn->url = 'url en'; + $companyMediaLocaleFr = new CompanyMediaLocale(); + $companyMediaLocaleFr->id = 2; + $companyMediaLocaleFr->locale = 'fr'; + $companyMediaLocaleFr->url = 'url fr'; + + $company->addMedia($companyMediaLocaleEn); + $company->addMedia($companyMediaLocaleFr); + + $submitData = [ + 'code' => 'code1', + 'medias' => [ + 'en' => [ + 'url' => 'url en', + ], + 'fr' => [ + 'url' => 'url frrrr', // Upd + ], + 'de' => [ + 'url' => 'url deee', // New + ], + ], + ]; + + return [$company, $submitData]; + } +} diff --git a/tests/Form/Type/TranslationsTypeAdvancedTest.php b/tests/Form/Type/TranslationsTypeAdvancedTest.php deleted file mode 100644 index d21746c..0000000 --- a/tests/Form/Type/TranslationsTypeAdvancedTest.php +++ /dev/null @@ -1,106 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Tests\Form\Type; - -use A2lix\TranslationFormBundle\Form\Type\TranslationsType; -use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\Product; -use A2lix\TranslationFormBundle\Tests\Form\TypeTestCase; -use Symfony\Component\Form\Extension\Core\Type\FormType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\PreloadedExtension; - -/** - * @internal - */ -final class TranslationsTypeAdvancedTest extends TypeTestCase -{ - protected $locales = ['en', 'fr', 'de']; - protected $defaultLocale = 'en'; - protected $requiredLocales = ['en', 'fr']; - - public function testEmptyFormOverrideLocales(): void - { - $overrideLocales = ['en', 'fr', 'es']; - $overrideRequiredLocales = ['en', 'es']; - - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('translations', TranslationsType::class, [ - 'locales' => $overrideLocales, - 'required_locales' => $overrideRequiredLocales, - ]) - ->add('save', SubmitType::class) - ->getForm() - ; - - $translationsForm = $form->get('translations')->all(); - $translationsLocales = array_keys($translationsForm); - $translationsRequiredLocales = array_keys(array_filter($translationsForm, static fn ($form) => $form->isRequired())); - - self::assertEquals($overrideLocales, $translationsLocales, 'Locales should be same as config'); - self::assertEquals($overrideRequiredLocales, $translationsRequiredLocales, 'Required locales should be same as config'); - - self::assertEquals(['title', 'description'], array_keys($translationsForm['en']->all()), 'Fields should matches ProductTranslation fields'); - self::assertEquals(['title', 'description'], array_keys($translationsForm['fr']->all()), 'Fields should matches ProductTranslation fields'); - self::assertEquals(['title', 'description'], array_keys($translationsForm['es']->all()), 'Fields should matches ProductTranslation fields'); - } - - public function testEmptyFormOverrideFields(): void - { - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('translations', TranslationsType::class, [ - 'excluded_fields' => ['description'], - ]) - ->add('save', SubmitType::class) - ->getForm() - ; - - $translationsForm = $form->get('translations')->all(); - self::assertEquals(['title'], array_keys($translationsForm['en']->all()), 'Fields should not contains description'); - self::assertEquals(['title'], array_keys($translationsForm['fr']->all()), 'Fields should not contains description'); - self::assertEquals(['title'], array_keys($translationsForm['de']->all()), 'Fields should not contains description'); - } - - public function testLabels(): void - { - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('translations', TranslationsType::class, [ - 'locale_labels' => [ - 'fr' => 'Français', - 'en' => 'English', - ], - ]) - ->add('save', SubmitType::class) - ->getForm() - ; - - $translationsForm = $form->get('translations')->all(); - self::assertEquals('English', $translationsForm['en']->getConfig()->getOptions()['label'], 'Label should be explicitely set'); - self::assertEquals('Français', $translationsForm['fr']->getConfig()->getOptions()['label'], 'Label should be explicitely set'); - self::assertNull($translationsForm['de']->getConfig()->getOptions()['label'], 'Label should default to null'); - } - - protected function getExtensions(): array - { - $translationsType = $this->getConfiguredTranslationsType($this->locales, $this->defaultLocale, $this->requiredLocales); - $autoFormType = $this->getConfiguredAutoFormType(); - - return [new PreloadedExtension([ - $translationsType, - $autoFormType, - ], [])]; - } -} diff --git a/tests/Form/Type/TranslationsTypeSimpleTest.php b/tests/Form/Type/TranslationsTypeSimpleTest.php deleted file mode 100644 index 63b480a..0000000 --- a/tests/Form/Type/TranslationsTypeSimpleTest.php +++ /dev/null @@ -1,166 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Tests\Form\Type; - -use A2lix\TranslationFormBundle\Form\Type\TranslationsType; -use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\Product; -use A2lix\TranslationFormBundle\Tests\Fixtures\Entity\ProductTranslation; -use A2lix\TranslationFormBundle\Tests\Form\TypeTestCase; -use Symfony\Component\Form\Extension\Core\Type\FormType; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\PreloadedExtension; - -/** - * @internal - */ -final class TranslationsTypeSimpleTest extends TypeTestCase -{ - protected $locales = ['en', 'fr', 'de']; - protected $defaultLocale = 'en'; - protected $requiredLocales = ['en', 'fr']; - - public function testEmptyForm(): void - { - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('translations', TranslationsType::class) - ->add('save', SubmitType::class) - ->getForm() - ; - - $translationsForm = $form->get('translations')->all(); - $translationsLocales = array_keys($translationsForm); - $translationsRequiredLocales = array_keys(array_filter($translationsForm, static fn ($form) => $form->isRequired())); - - self::assertEquals($this->locales, $translationsLocales, 'Locales should be same as config'); - self::assertEquals($this->requiredLocales, $translationsRequiredLocales, 'Required locales should be same as config'); - - self::assertEquals(['title', 'description'], array_keys($translationsForm['en']->all()), 'Fields should matches ProductTranslation fields'); - self::assertEquals(['title', 'description'], array_keys($translationsForm['fr']->all()), 'Fields should matches ProductTranslation fields'); - self::assertEquals(['title', 'description'], array_keys($translationsForm['de']->all()), 'Fields should matches ProductTranslation fields'); - } - - public function testCreationForm(): Product - { - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('translations', TranslationsType::class) - ->add('save', SubmitType::class) - ->getForm() - ; - - $productTranslationEn = new ProductTranslation(); - $productTranslationEn->setLocale('en') - ->setTitle('title en') - ->setDescription('desc en') - ; - $productTranslationFr = new ProductTranslation(); - $productTranslationFr->setLocale('fr') - ->setTitle('title fr') - ->setDescription('desc fr') - ; - $productTranslationDe = new ProductTranslation(); - $productTranslationDe->setLocale('de') - ->setTitle('title de') - ->setDescription('desc de') - ; - - $product = new Product(); - $product->setUrl('a2lix.fr') - ->addTranslation($productTranslationEn) - ->addTranslation($productTranslationFr) - ->addTranslation($productTranslationDe) - ; - - $formData = [ - 'url' => 'a2lix.fr', - 'translations' => [ - 'en' => [ - 'title' => 'title en', - 'description' => 'desc en', - ], - 'fr' => [ - 'title' => 'title fr', - 'description' => 'desc fr', - ], - 'de' => [ - 'title' => 'title de', - 'description' => 'desc de', - ], - ], - ]; - - $form->submit($formData); - self::assertTrue($form->isSynchronized()); - self::assertEquals($product, $form->getData()); - - return $product; - } - - /** - * @depends testCreationForm - */ - public function testEditionForm(Product $product): void - { - $product->getTranslations()['en']->setDescription('desc ennnnnnn'); - $product->getTranslations()['fr']->setTitle('title frrrrrr'); - - $formData = [ - 'url' => 'a2lix.fr', - 'translations' => [ - 'en' => [ - 'title' => 'title en', - 'description' => 'desc ennnnnnn', - ], - 'fr' => [ - 'title' => 'title frrrrrr', - 'description' => 'desc fr', - ], - 'de' => [ - 'title' => 'title de', - 'description' => 'desc de', - ], - ], - ]; - - $form = $this->factory->createBuilder(FormType::class, new Product()) - ->add('url') - ->add('translations', TranslationsType::class) - ->add('save', 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 - { - $translationsType = $this->getConfiguredTranslationsType($this->locales, $this->defaultLocale, $this->requiredLocales); - $autoFormType = $this->getConfiguredAutoFormType(); - - return [new PreloadedExtension([ - $translationsType, - $autoFormType, - ], [])]; - } -} diff --git a/tests/Form/TypeTestCase.php b/tests/Form/TypeTestCase.php index 040c11f..40c7510 100644 --- a/tests/Form/TypeTestCase.php +++ b/tests/Form/TypeTestCase.php @@ -1,6 +1,4 @@ -dump( + new VarCloner()->cloneVar($var), + @fopen(__DIR__.'/../../dump.html', 'a') + ); + }); + } - protected function setUp(): void + /** + * @param array> $formChildren + */ + protected static function assertFormChildren(array $expectedForm, array $formChildren, string $parentPath = ''): void { - parent::setUp(); + self::assertSame(array_keys($expectedForm), array_keys($formChildren)); - $validator = $this->getMockBuilder(ValidatorInterface::class) - ->disableOriginalConstructor() - ->getMock() - ; - $validator->method('validate')->willReturn(new ConstraintViolationList()); - - $this->factory = Forms::createFormFactoryBuilder() - ->addExtensions($this->getExtensions()) - ->addTypeExtension( - new FormTypeValidatorExtension($validator) - ) - ->addTypeGuesser( - $this->createMock(ValidatorTypeGuesser::class) - ) - ->getFormFactory() - ; + foreach ($formChildren as $childName => $child) { + /** @var string $childName */ + $expectedChildOptions = $expectedForm[$childName]; + $childPath = $parentPath.'.'.$childName; - $this->dispatcher = $this->getMockBuilder(EventDispatcherInterface::class) - ->disableOriginalConstructor() - ->getMock() - ; - $this->builder = new FormBuilder(null, null, $this->dispatcher, $this->factory); - } + if (null !== $expectedType = ($expectedChildOptions['expected_type'] ?? null)) { + self::assertSame($expectedType, $child->getConfig()->getType()->getInnerType()::class, \sprintf('Type of "%s"', $childPath)); + } - protected function getDoctrineORMFormManipulator(): DoctrineORMManipulator - { - if (null !== $this->doctrineORMManipulator) { - return $this->doctrineORMManipulator; - } + if (null !== $expectedChildren = ($expectedChildOptions['expected_children'] ?? null)) { + // @phpstan-ignore argument.type + self::assertFormChildren($expectedChildren, $child->all(), $childPath); + } - $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()); + unset($expectedChildOptions['expected_type'], $expectedChildOptions['expected_children']); + $actualOptions = $child->getConfig()->getOptions(); - return $this->doctrineORMManipulator = new DoctrineORMManipulator($doctrineORMInfo, ['id', 'locale', 'translatable']); + // @phpstan-ignore nullCoalesce.variable, staticMethod.alreadyNarrowedType + self::assertSame($expectedChildOptions, array_intersect_key($actualOptions, $expectedChildOptions ?? []), \sprintf('Options of "%s"', $childPath)); + } } - protected function getConfiguredAutoFormType(): AutoFormType + #[\Override] + protected function getExtensions(): array { - $autoFormListener = new AutoFormListener($this->getDoctrineORMFormManipulator()); + $autoType = new AutoType( + new AutoTypeBuilder($this->getPropertyInfoExtractor()), + globalExcludedChildren: $this->globalExcludedChildren, + handleTranslationTypes: true, + ); + + $managerRegistryStub = self::createStub(ManagerRegistry::class); + $managerRegistryStub + ->method('getManager') + ->willReturn($this->getEntityManager()) + ; + $managerRegistryStub + ->method('getManagers') + ->willReturn(['default' => $this->getEntityManager()]) + ; - return new AutoFormType($autoFormListener); + $localeProvider = new SimpleLocaleProvider($this->defaultLocale, $this->enabledLocales, $this->requiredLocales); + $localeExtension = new LocaleExtension($localeProvider); + $translationsType = new TranslationsType( + globalExcludedChildren: $this->globalExcludedChildren, + ); + $translationsFormsType = new TranslationsFormsType(); + + return [ + ...parent::getExtensions(), + new DoctrineOrmExtension($managerRegistryStub), + new PreloadedExtension( + [$autoType, $translationsType, $translationsFormsType], + [ + TranslationsType::class => [$localeExtension], + TranslationsFormsType::class => [$localeExtension], + ], + new FormTypeGuesserChain([ + new TypeInfoTypeGuesser(TypeResolver::create()), + ]), + ), + ]; } - protected function getConfiguredTranslationsType(array $locales, string $defaultLocale, array $requiredLocales): TranslationsType + private function getPropertyInfoExtractor(): PropertyInfoExtractor { - $translationsListener = new TranslationsListener($this->getDoctrineORMFormManipulator()); - $localProvider = new SimpleProvider($locales, $defaultLocale, $requiredLocales); - - return new TranslationsType($translationsListener, $localProvider); + $doctrineExtractor = new DoctrineExtractor($this->getEntityManager()); + $reflectionExtractor = new ReflectionExtractor(); + + return new PropertyInfoExtractor( + listExtractors: [ + $reflectionExtractor, + $doctrineExtractor, + ], + typeExtractors: [ + $doctrineExtractor, + new PhpStanExtractor(), + new PhpDocExtractor(), + $reflectionExtractor, + ], + accessExtractors: [ + $doctrineExtractor, + $reflectionExtractor, + ] + ); } - protected function getConfiguredTranslationsFormsType(array $locales, string $defaultLocale, array $requiredLocales): TranslationsFormsType + private function getEntityManager(): EntityManagerInterface { - $translationsFormsListener = new TranslationsFormsListener(); - $localProvider = new SimpleProvider($locales, $defaultLocale, $requiredLocales); + 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 TranslationsFormsType($translationsFormsListener, $localProvider); + return $this->entityManager = new EntityManager($connection, $configuration); } } diff --git a/tests/Locale/SimpleProviderTest.php b/tests/Locale/SimpleProviderTest.php deleted file mode 100644 index 2016b6b..0000000 --- a/tests/Locale/SimpleProviderTest.php +++ /dev/null @@ -1,97 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace A2lix\TranslationFormBundle\Tests\Locale; - -use A2lix\TranslationFormBundle\Locale\SimpleProvider; -use PHPUnit\Framework\TestCase; - -/** - * @internal - */ -final class SimpleProviderTest extends TestCase -{ - protected $locales; - protected $defaultLocale; - protected $requiredLocales; - protected $provider; - - protected function setUp(): void - { - $this->locales = ['es', 'en', 'pt']; - $this->defaultLocale = 'en'; - $this->requiredLocales = ['es', 'en']; - - $this->provider = new SimpleProvider($this->locales, $this->defaultLocale, $this->requiredLocales); - } - - public function testDefaultLocaleIsInLocales(): void - { - // Get mock, without the constructor being called - $mock = $this->getMockBuilder(SimpleProvider::class) - ->disableOriginalConstructor() - ->getMock() - ; - - // Set expectations for constructor calls - $this->expectException('InvalidArgumentException'); - $this->expectExceptionMessage('Default locale `de` not found within the configured locales `[es,en]`.' - .' Perhaps you need to add it to your `a2lix_translation_form.locales` bundle configuration?'); - - // Now call the constructor - $reflectedClass = new \ReflectionClass(SimpleProvider::class); - $constructor = $reflectedClass->getConstructor(); - $constructor->invoke($mock, ['es', 'en'], 'de', []); - } - - public function testRequiredLocaleAreInLocales(): void - { - // Get mock, without the constructor being called - $mock = $this->getMockBuilder(SimpleProvider::class) - ->disableOriginalConstructor() - ->getMock() - ; - - // Set expectations for constructor calls - $this->expectException('InvalidArgumentException'); - $this->expectExceptionMessage('Required locales should be contained in locales'); - - // Now call the constructor - $reflectedClass = new \ReflectionClass(SimpleProvider::class); - $constructor = $reflectedClass->getConstructor(); - $constructor->invoke($mock, ['es', 'en'], 'en', ['en', 'pt']); - } - - public function testGetLocales(): void - { - $expected = $this->provider->getLocales(); - $locales = $this->locales; - - self::assertSame(array_diff($expected, $locales), array_diff($locales, $expected)); - } - - public function testGetDefaultLocale(): void - { - $expected = $this->provider->getDefaultLocale(); - - self::assertSame($this->defaultLocale, $expected); - } - - public function getRequiredLocales(): void - { - $expected = $this->provider->getDefaultLocale(); - $requiredLocales = $this->requiredLocales; - - self::assertSame(array_diff($expected, $requiredLocales), array_diff($requiredLocales, $expected)); - } -}