From 308daef141c400b003ead408397e47ab158cb049 Mon Sep 17 00:00:00 2001 From: Jacob Tobiasz Date: Sun, 3 Dec 2023 09:18:26 +0100 Subject: [PATCH] [Autocomplete] Allow passing extra options to the autocomplete fields --- src/Autocomplete/CHANGELOG.md | 2 + .../src/Checksum/ChecksumCalculator.php | 39 +++++++++++++ .../EntityAutocompleteController.php | 56 +++++++++++++++++++ .../AutocompleteExtension.php | 10 ++++ .../Form/AutocompleteChoiceTypeExtension.php | 49 +++++++++++++++- .../Form/WrappedEntityTypeAutocompleter.php | 16 +++++- ...tionsAwareEntityAutocompleterInterface.php | 20 +++++++ .../CustomGroupByProductAutocompleter.php | 17 +++--- .../CustomProductAutocompleter.php | 16 ++++-- .../tests/Fixtures/Entity/Category.php | 10 +++- .../tests/Fixtures/Entity/Ingredient.php | 9 +++ .../tests/Fixtures/Entity/Product.php | 9 +++ .../Fixtures/Factory/CategoryFactory.php | 37 +++++++----- .../Fixtures/Factory/IngredientFactory.php | 37 +++++++----- .../tests/Fixtures/Factory/ProductFactory.php | 37 +++++++----- .../Form/AlternateRouteAutocompleteType.php | 11 +++- .../Form/CategoryAutocompleteType.php | 21 +++++-- .../CategoryNoChoiceLabelAutocompleteType.php | 13 ++++- .../Form/IngredientAutocompleteType.php | 25 ++++++++- .../tests/Fixtures/Form/ProductType.php | 23 +++++++- .../AutocompleteFormRenderingTest.php | 56 ++++++++++++++++++- .../Calculator/ChecksumCalculatorTest.php | 41 ++++++++++++++ 22 files changed, 480 insertions(+), 74 deletions(-) create mode 100644 src/Autocomplete/src/Checksum/ChecksumCalculator.php create mode 100644 src/Autocomplete/src/OptionsAwareEntityAutocompleterInterface.php create mode 100644 src/Autocomplete/tests/Unit/Calculator/ChecksumCalculatorTest.php diff --git a/src/Autocomplete/CHANGELOG.md b/src/Autocomplete/CHANGELOG.md index 905b13d451..39032dcc02 100644 --- a/src/Autocomplete/CHANGELOG.md +++ b/src/Autocomplete/CHANGELOG.md @@ -11,6 +11,8 @@ - Added `tom-select/dist/css/tom-select.bootstrap4.css` to `autoimport` - this will cause this to appear in your `controllers.json` file by default, but disabled see. +- Allow passing `extra_options` key in an array passed as a `3rd` argument of the `->add()` method. + It will be used during the Ajax call to fetch results. ## 2.13.2 diff --git a/src/Autocomplete/src/Checksum/ChecksumCalculator.php b/src/Autocomplete/src/Checksum/ChecksumCalculator.php new file mode 100644 index 0000000000..d86ff57038 --- /dev/null +++ b/src/Autocomplete/src/Checksum/ChecksumCalculator.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 Symfony\UX\Autocomplete\Checksum; + +/** @internal */ +class ChecksumCalculator +{ + public function __construct(private readonly string $secret) + { + } + + public function calculateForArray(array $data): string + { + $this->sortKeysRecursively($data); + + return base64_encode(hash_hmac('sha256', json_encode($data), $this->secret, true)); + } + + private function sortKeysRecursively(array &$data): void + { + foreach ($data as &$value) { + if (\is_array($value)) { + $this->sortKeysRecursively($value); + } + } + ksort($data); + } +} diff --git a/src/Autocomplete/src/Controller/EntityAutocompleteController.php b/src/Autocomplete/src/Controller/EntityAutocompleteController.php index e65cb6dad1..6188ca21f7 100644 --- a/src/Autocomplete/src/Controller/EntityAutocompleteController.php +++ b/src/Autocomplete/src/Controller/EntityAutocompleteController.php @@ -14,20 +14,27 @@ use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\UX\Autocomplete\AutocompleteResultsExecutor; use Symfony\UX\Autocomplete\AutocompleterRegistry; +use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator; +use Symfony\UX\Autocomplete\Form\AutocompleteChoiceTypeExtension; +use Symfony\UX\Autocomplete\OptionsAwareEntityAutocompleterInterface; /** * @author Ryan Weaver */ final class EntityAutocompleteController { + public const EXTRA_OPTIONS = 'extra_options'; + public function __construct( private AutocompleterRegistry $autocompleteFieldRegistry, private AutocompleteResultsExecutor $autocompleteResultsExecutor, private UrlGeneratorInterface $urlGenerator, + private ChecksumCalculator $checksumCalculator, ) { } @@ -38,6 +45,11 @@ public function __invoke(string $alias, Request $request): Response throw new NotFoundHttpException(sprintf('No autocompleter found for "%s". Available autocompleters are: (%s)', $alias, implode(', ', $this->autocompleteFieldRegistry->getAutocompleterNames()))); } + if ($autocompleter instanceof OptionsAwareEntityAutocompleterInterface) { + $extraOptions = $this->getExtraOptions($request); + $autocompleter->setOptions([self::EXTRA_OPTIONS => $extraOptions]); + } + $page = $request->query->getInt('page', 1); $nextPage = null; @@ -54,4 +66,48 @@ public function __invoke(string $alias, Request $request): Response 'next_page' => $nextPage, ]); } + + /** + * @return array + */ + private function getExtraOptions(Request $request): array + { + if (!$request->query->has(self::EXTRA_OPTIONS)) { + return []; + } + + $extraOptions = $this->getDecodedExtraOptions($request->query->getString(self::EXTRA_OPTIONS)); + + if (!\array_key_exists(AutocompleteChoiceTypeExtension::CHECKSUM_KEY, $extraOptions)) { + throw new BadRequestHttpException('The extra options are missing the checksum.'); + } + + $this->validateChecksum($extraOptions[AutocompleteChoiceTypeExtension::CHECKSUM_KEY], $extraOptions); + + return $extraOptions; + } + + /** + * @return array + */ + private function getDecodedExtraOptions(string $extraOptions): array + { + return json_decode(base64_decode($extraOptions), true, flags: \JSON_THROW_ON_ERROR); + } + + /** + * @param array $extraOptions + */ + private function validateChecksum(string $checksum, array $extraOptions): void + { + $extraOptionsWithoutChecksum = array_filter( + $extraOptions, + fn (string $key) => AutocompleteChoiceTypeExtension::CHECKSUM_KEY !== $key, + \ARRAY_FILTER_USE_KEY, + ); + + if ($checksum !== $this->checksumCalculator->calculateForArray($extraOptionsWithoutChecksum)) { + throw new BadRequestHttpException('The extra options have been tampered with.'); + } + } } diff --git a/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php index 859f1c4ecb..c44103c850 100644 --- a/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php +++ b/src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php @@ -21,6 +21,7 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\UX\Autocomplete\AutocompleteResultsExecutor; use Symfony\UX\Autocomplete\AutocompleterRegistry; +use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator; use Symfony\UX\Autocomplete\Controller\EntityAutocompleteController; use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper; use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory; @@ -116,6 +117,7 @@ private function registerBasicServices(ContainerBuilder $container): void new Reference('ux.autocomplete.autocompleter_registry'), new Reference('ux.autocomplete.results_executor'), new Reference('router'), + new Reference('ux.autocomplete.checksum_calculator'), ]) ->addTag('controller.service_arguments') ; @@ -127,6 +129,13 @@ private function registerBasicServices(ContainerBuilder $container): void ]) ->addTag('maker.command') ; + + $container + ->register('ux.autocomplete.checksum_calculator', ChecksumCalculator::class) + ->setArguments([ + '%kernel.secret%', + ]) + ; } private function registerFormServices(ContainerBuilder $container): void @@ -149,6 +158,7 @@ private function registerFormServices(ContainerBuilder $container): void $container ->register('ux.autocomplete.choice_type_extension', AutocompleteChoiceTypeExtension::class) ->setArguments([ + new Reference('ux.autocomplete.checksum_calculator'), new Reference('translator', ContainerInterface::IGNORE_ON_INVALID_REFERENCE), ]) ->addTag('form.type_extension'); diff --git a/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php b/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php index d22dd04397..9c40fd3927 100644 --- a/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php +++ b/src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php @@ -19,6 +19,7 @@ use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator; /** * Initializes the autocomplete Stimulus controller for any fields with the "autocomplete" option. @@ -27,8 +28,12 @@ */ final class AutocompleteChoiceTypeExtension extends AbstractTypeExtension { - public function __construct(private ?TranslatorInterface $translator = null) - { + public const CHECKSUM_KEY = '@checksum'; + + public function __construct( + private readonly ChecksumCalculator $checksumCalculator, + private readonly ?TranslatorInterface $translator = null, + ) { } public static function getExtendedTypes(): iterable @@ -79,6 +84,10 @@ public function finishView(FormView $view, FormInterface $form, array $options): $values['min-characters'] = $options['min_characters']; } + if ($options['extra_options']) { + $values['url'] = $this->getUrlWithExtraOptions($values['url'], $options['extra_options']); + } + $values['loading-more-text'] = $this->trans($options['loading_more_text']); $values['no-results-found-text'] = $this->trans($options['no_results_found_text']); $values['no-more-results-text'] = $this->trans($options['no_more_results_text']); @@ -92,6 +101,41 @@ public function finishView(FormView $view, FormInterface $form, array $options): $view->vars['attr'] = $attr; } + private function getUrlWithExtraOptions(string $url, array $extraOptions): string + { + $this->validateExtraOptions($extraOptions); + + $extraOptions[self::CHECKSUM_KEY] = $this->checksumCalculator->calculateForArray($extraOptions); + $extraOptions = base64_encode(json_encode($extraOptions)); + + return sprintf( + '%s%s%s', + $url, + $this->hasUrlParameters($url) ? '&' : '?', + http_build_query(['extra_options' => $extraOptions]), + ); + } + + private function hasUrlParameters(string $url): bool + { + $parsedUrl = parse_url($url); + + return isset($parsedUrl['query']); + } + + private function validateExtraOptions(array $extraOptions): void + { + foreach ($extraOptions as $optionKey => $option) { + if (!\is_scalar($option) && !\is_array($option) && null !== $option) { + throw new \InvalidArgumentException(sprintf('Extra option with key "%s" must be a scalar value, an array or null. Got "%s".', $optionKey, get_debug_type($option))); + } + + if (\is_array($option)) { + $this->validateExtraOptions($option); + } + } + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ @@ -106,6 +150,7 @@ public function configureOptions(OptionsResolver $resolver): void 'min_characters' => null, 'max_results' => 10, 'preload' => 'focus', + 'extra_options' => [], ]); // if autocomplete_url is passed, then HTML options are already supported diff --git a/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php b/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php index 61bb2bd8b6..66615bda0f 100644 --- a/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php +++ b/src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php @@ -22,17 +22,18 @@ use Symfony\UX\Autocomplete\Doctrine\EntityMetadata; use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory; use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil; -use Symfony\UX\Autocomplete\EntityAutocompleterInterface; +use Symfony\UX\Autocomplete\OptionsAwareEntityAutocompleterInterface; /** * An entity auto-completer that wraps a form type to get its information. * * @internal */ -final class WrappedEntityTypeAutocompleter implements EntityAutocompleterInterface +final class WrappedEntityTypeAutocompleter implements OptionsAwareEntityAutocompleterInterface { private ?FormInterface $form = null; private ?EntityMetadata $entityMetadata = null; + private array $options = []; public function __construct( private string $formType, @@ -139,7 +140,7 @@ private function getFormOption(string $name): mixed private function getForm(): FormInterface { if (null === $this->form) { - $this->form = $this->formFactory->create($this->formType); + $this->form = $this->formFactory->create($this->formType, options: $this->options); } return $this->form; @@ -168,4 +169,13 @@ private function getEntityMetadata(): EntityMetadata return $this->entityMetadata; } + + public function setOptions(array $options): void + { + if (null !== $this->form) { + throw new \LogicException('The options can only be set before the form is created.'); + } + + $this->options = $options; + } } diff --git a/src/Autocomplete/src/OptionsAwareEntityAutocompleterInterface.php b/src/Autocomplete/src/OptionsAwareEntityAutocompleterInterface.php new file mode 100644 index 0000000000..0a0b78754a --- /dev/null +++ b/src/Autocomplete/src/OptionsAwareEntityAutocompleterInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete; + +/** + * Interface for classes that will have an "autocomplete" endpoint exposed with a possibility to pass additional form options. + */ +interface OptionsAwareEntityAutocompleterInterface extends EntityAutocompleterInterface +{ + public function setOptions(array $options): void; +} diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomGroupByProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomGroupByProductAutocompleter.php index c4407f0065..da942cabc7 100644 --- a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomGroupByProductAutocompleter.php +++ b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomGroupByProductAutocompleter.php @@ -1,14 +1,15 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Doctrine\ORM\EntityRepository; -use Doctrine\ORM\QueryBuilder; -use Symfony\Component\HttpFoundation\RequestStack; -use Symfony\Bundle\SecurityBundle\Security; -use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil; -use Symfony\UX\Autocomplete\EntityAutocompleterInterface; -use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product; +namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter; class CustomGroupByProductAutocompleter extends CustomProductAutocompleter { diff --git a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php index 3ae0877a51..912a864c0e 100644 --- a/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php +++ b/src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php @@ -1,11 +1,20 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\QueryBuilder; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil; use Symfony\UX\Autocomplete\EntityAutocompleterInterface; use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product; @@ -14,9 +23,8 @@ class CustomProductAutocompleter implements EntityAutocompleterInterface { public function __construct( private RequestStack $requestStack, - private EntitySearchUtil $entitySearchUtil - ) - { + private EntitySearchUtil $entitySearchUtil, + ) { } public function getEntityClass(): string diff --git a/src/Autocomplete/tests/Fixtures/Entity/Category.php b/src/Autocomplete/tests/Fixtures/Entity/Category.php index 7774cf927f..174341cb95 100644 --- a/src/Autocomplete/tests/Fixtures/Entity/Category.php +++ b/src/Autocomplete/tests/Fixtures/Entity/Category.php @@ -1,10 +1,18 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; -use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity()] diff --git a/src/Autocomplete/tests/Fixtures/Entity/Ingredient.php b/src/Autocomplete/tests/Fixtures/Entity/Ingredient.php index bc810d5351..edea1c0327 100644 --- a/src/Autocomplete/tests/Fixtures/Entity/Ingredient.php +++ b/src/Autocomplete/tests/Fixtures/Entity/Ingredient.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity; use Doctrine\ORM\Mapping as ORM; diff --git a/src/Autocomplete/tests/Fixtures/Entity/Product.php b/src/Autocomplete/tests/Fixtures/Entity/Product.php index 8640290b98..7db8056e25 100644 --- a/src/Autocomplete/tests/Fixtures/Entity/Product.php +++ b/src/Autocomplete/tests/Fixtures/Entity/Product.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Entity; use Doctrine\Common\Collections\ArrayCollection; diff --git a/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php b/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php index e24abe0905..b79630664e 100644 --- a/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php +++ b/src/Autocomplete/tests/Fixtures/Factory/CategoryFactory.php @@ -1,30 +1,39 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory; use Doctrine\ORM\EntityRepository; use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Category; -use Zenstruck\Foundry\RepositoryProxy; use Zenstruck\Foundry\ModelFactory; use Zenstruck\Foundry\Proxy; +use Zenstruck\Foundry\RepositoryProxy; /** * @extends ModelFactory * - * @method static Category|Proxy createOne(array $attributes = []) - * @method static Category[]|Proxy[] createMany(int $number, array|callable $attributes = []) - * @method static Category|Proxy find(object|array|mixed $criteria) - * @method static Category|Proxy findOrCreate(array $attributes) - * @method static Category|Proxy first(string $sortedField = 'id') - * @method static Category|Proxy last(string $sortedField = 'id') - * @method static Category|Proxy random(array $attributes = []) - * @method static Category|Proxy randomOrCreate(array $attributes = [])) - * @method static Category[]|Proxy[] all() - * @method static Category[]|Proxy[] findBy(array $attributes) - * @method static Category[]|Proxy[] randomSet(int $number, array $attributes = [])) - * @method static Category[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) + * @method static Category|Proxy createOne(array $attributes = []) + * @method static Category[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Category|Proxy find(object|array|mixed $criteria) + * @method static Category|Proxy findOrCreate(array $attributes) + * @method static Category|Proxy first(string $sortedField = 'id') + * @method static Category|Proxy last(string $sortedField = 'id') + * @method static Category|Proxy random(array $attributes = []) + * @method static Category|Proxy randomOrCreate(array $attributes = [])) + * @method static Category[]|Proxy[] all() + * @method static Category[]|Proxy[] findBy(array $attributes) + * @method static Category[]|Proxy[] randomSet(int $number, array $attributes = [])) + * @method static Category[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) * @method static EntityRepository|RepositoryProxy repository() - * @method Category|Proxy create(array|callable $attributes = []) + * @method Category|Proxy create(array|callable $attributes = []) */ final class CategoryFactory extends ModelFactory { diff --git a/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php b/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php index ee238ce1e1..4669fa9899 100644 --- a/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php +++ b/src/Autocomplete/tests/Fixtures/Factory/IngredientFactory.php @@ -1,31 +1,40 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory; use Doctrine\ORM\EntityRepository; use Symfony\Component\Uid\UuidV4; use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Ingredient; -use Zenstruck\Foundry\RepositoryProxy; use Zenstruck\Foundry\ModelFactory; use Zenstruck\Foundry\Proxy; +use Zenstruck\Foundry\RepositoryProxy; /** * @extends ModelFactory * - * @method static Ingredient|Proxy createOne(array $attributes = []) - * @method static Ingredient[]|Proxy[] createMany(int $number, array|callable $attributes = []) - * @method static Ingredient|Proxy find(object|array|mixed $criteria) - * @method static Ingredient|Proxy findOrCreate(array $attributes) - * @method static Ingredient|Proxy first(string $sortedField = 'id') - * @method static Ingredient|Proxy last(string $sortedField = 'id') - * @method static Ingredient|Proxy random(array $attributes = []) - * @method static Ingredient|Proxy randomOrCreate(array $attributes = [])) - * @method static Ingredient[]|Proxy[] all() - * @method static Ingredient[]|Proxy[] findBy(array $attributes) - * @method static Ingredient[]|Proxy[] randomSet(int $number, array $attributes = [])) - * @method static Ingredient[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) + * @method static Ingredient|Proxy createOne(array $attributes = []) + * @method static Ingredient[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Ingredient|Proxy find(object|array|mixed $criteria) + * @method static Ingredient|Proxy findOrCreate(array $attributes) + * @method static Ingredient|Proxy first(string $sortedField = 'id') + * @method static Ingredient|Proxy last(string $sortedField = 'id') + * @method static Ingredient|Proxy random(array $attributes = []) + * @method static Ingredient|Proxy randomOrCreate(array $attributes = [])) + * @method static Ingredient[]|Proxy[] all() + * @method static Ingredient[]|Proxy[] findBy(array $attributes) + * @method static Ingredient[]|Proxy[] randomSet(int $number, array $attributes = [])) + * @method static Ingredient[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) * @method static EntityRepository|RepositoryProxy repository() - * @method Ingredient|Proxy create(array|callable $attributes = []) + * @method Ingredient|Proxy create(array|callable $attributes = []) */ final class IngredientFactory extends ModelFactory { diff --git a/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php b/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php index 4b99ac1f02..975c10be79 100644 --- a/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php +++ b/src/Autocomplete/tests/Fixtures/Factory/ProductFactory.php @@ -1,30 +1,39 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Factory; use Doctrine\ORM\EntityRepository; use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product; -use Zenstruck\Foundry\RepositoryProxy; use Zenstruck\Foundry\ModelFactory; use Zenstruck\Foundry\Proxy; +use Zenstruck\Foundry\RepositoryProxy; /** * @extends ModelFactory * - * @method static Product|Proxy createOne(array $attributes = []) - * @method static Product[]|Proxy[] createMany(int $number, array|callable $attributes = []) - * @method static Product|Proxy find(object|array|mixed $criteria) - * @method static Product|Proxy findOrCreate(array $attributes) - * @method static Product|Proxy first(string $sortedField = 'id') - * @method static Product|Proxy last(string $sortedField = 'id') - * @method static Product|Proxy random(array $attributes = []) - * @method static Product|Proxy randomOrCreate(array $attributes = [])) - * @method static Product[]|Proxy[] all() - * @method static Product[]|Proxy[] findBy(array $attributes) - * @method static Product[]|Proxy[] randomSet(int $number, array $attributes = [])) - * @method static Product[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) + * @method static Product|Proxy createOne(array $attributes = []) + * @method static Product[]|Proxy[] createMany(int $number, array|callable $attributes = []) + * @method static Product|Proxy find(object|array|mixed $criteria) + * @method static Product|Proxy findOrCreate(array $attributes) + * @method static Product|Proxy first(string $sortedField = 'id') + * @method static Product|Proxy last(string $sortedField = 'id') + * @method static Product|Proxy random(array $attributes = []) + * @method static Product|Proxy randomOrCreate(array $attributes = [])) + * @method static Product[]|Proxy[] all() + * @method static Product[]|Proxy[] findBy(array $attributes) + * @method static Product[]|Proxy[] randomSet(int $number, array $attributes = [])) + * @method static Product[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])) * @method static EntityRepository|RepositoryProxy repository() - * @method Product|Proxy create(array|callable $attributes = []) + * @method Product|Proxy create(array|callable $attributes = []) */ final class ProductFactory extends ModelFactory { diff --git a/src/Autocomplete/tests/Fixtures/Form/AlternateRouteAutocompleteType.php b/src/Autocomplete/tests/Fixtures/Form/AlternateRouteAutocompleteType.php index 264dafdccb..2ef4025187 100644 --- a/src/Autocomplete/tests/Fixtures/Form/AlternateRouteAutocompleteType.php +++ b/src/Autocomplete/tests/Fixtures/Form/AlternateRouteAutocompleteType.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Form; use Symfony\Component\Form\AbstractType; @@ -15,7 +24,7 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'class' => Ingredient::class, - 'choice_label' => function(Ingredient $ingredient) { + 'choice_label' => function (Ingredient $ingredient) { return $ingredient->getName(); }, 'multiple' => true, diff --git a/src/Autocomplete/tests/Fixtures/Form/CategoryAutocompleteType.php b/src/Autocomplete/tests/Fixtures/Form/CategoryAutocompleteType.php index f4f1c5e3ff..5f96dc3f36 100644 --- a/src/Autocomplete/tests/Fixtures/Form/CategoryAutocompleteType.php +++ b/src/Autocomplete/tests/Fixtures/Form/CategoryAutocompleteType.php @@ -1,15 +1,24 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Form; use Doctrine\ORM\EntityRepository; -use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; -use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Category; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\AbstractType; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\OptionsResolver\OptionsResolver; -use Symfony\Bundle\SecurityBundle\Security; use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; +use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; +use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Category; #[AsEntityAutocompleteField] class CategoryAutocompleteType extends AbstractType @@ -22,15 +31,15 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'class' => Category::class, - 'choice_label' => function(Category $category) { + 'choice_label' => function (Category $category) { return ''.$category->getName().''; }, - 'query_builder' => function(EntityRepository $repository) { + 'query_builder' => function (EntityRepository $repository) { return $repository->createQueryBuilder('category') ->andWhere('category.name LIKE :search') ->setParameter('search', '%foo%'); }, - 'security' => function(Security $security) { + 'security' => function (Security $security) { if ($this->requestStack->getCurrentRequest()?->query->get('enforce_test_security')) { return $security->isGranted('ROLE_USER'); } diff --git a/src/Autocomplete/tests/Fixtures/Form/CategoryNoChoiceLabelAutocompleteType.php b/src/Autocomplete/tests/Fixtures/Form/CategoryNoChoiceLabelAutocompleteType.php index 4ca1a4793f..9b80ea8496 100644 --- a/src/Autocomplete/tests/Fixtures/Form/CategoryNoChoiceLabelAutocompleteType.php +++ b/src/Autocomplete/tests/Fixtures/Form/CategoryNoChoiceLabelAutocompleteType.php @@ -1,12 +1,21 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Form; -use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; -use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Category; use Symfony\Component\Form\AbstractType; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; +use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; +use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Category; #[AsEntityAutocompleteField] class CategoryNoChoiceLabelAutocompleteType extends AbstractType diff --git a/src/Autocomplete/tests/Fixtures/Form/IngredientAutocompleteType.php b/src/Autocomplete/tests/Fixtures/Form/IngredientAutocompleteType.php index 2cb4555511..c2b636ceea 100644 --- a/src/Autocomplete/tests/Fixtures/Form/IngredientAutocompleteType.php +++ b/src/Autocomplete/tests/Fixtures/Form/IngredientAutocompleteType.php @@ -1,8 +1,19 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Form; +use Doctrine\ORM\EntityRepository; use Symfony\Component\Form\AbstractType; +use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; @@ -15,10 +26,22 @@ public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'class' => Ingredient::class, - 'choice_label' => function(Ingredient $ingredient) { + 'choice_label' => function (Ingredient $ingredient) { return ''.$ingredient->getName().''; }, 'multiple' => true, + 'query_builder' => function (Options $options): callable { + return function (EntityRepository $repository) use ($options) { + $qb = $repository->createQueryBuilder('o'); + + if ($options['extra_options']['banned_ingredient'] ?? false) { + $qb->andWhere('o.name != :banned_ingredient') + ->setParameter('banned_ingredient', $options['extra_options']['banned_ingredient']); + } + + return $qb; + }; + }, ]); } diff --git a/src/Autocomplete/tests/Fixtures/Form/ProductType.php b/src/Autocomplete/tests/Fixtures/Form/ProductType.php index ce99325d05..c2c5b35bf1 100644 --- a/src/Autocomplete/tests/Fixtures/Form/ProductType.php +++ b/src/Autocomplete/tests/Fixtures/Form/ProductType.php @@ -1,21 +1,38 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\UX\Autocomplete\Tests\Fixtures\Form; -use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product; class ProductType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { $builder - ->add('category', CategoryAutocompleteType::class) - ->add('ingredients', IngredientAutocompleteType::class) + ->add('category', CategoryAutocompleteType::class, [ + 'extra_options' => [ + 'some' => 'cool_option', + ], + ]) + ->add('ingredients', IngredientAutocompleteType::class, [ + 'extra_options' => [ + 'banned_ingredient' => 'Modified Flour', + ], + ]) ->add('portionSize', ChoiceType::class, [ 'choices' => [ 'extra small 🥨' => 'xs', diff --git a/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php b/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php index a7d647edfd..72d801b6f8 100644 --- a/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php +++ b/src/Autocomplete/tests/Functional/AutocompleteFormRenderingTest.php @@ -31,7 +31,7 @@ public function testFieldsRenderWithStimulusController() ->throwExceptions() ->get('/test-form') ->assertElementAttributeContains('#product_category', 'data-controller', 'custom-autocomplete symfony--ux-autocomplete--autocomplete') - ->assertElementAttributeContains('#product_category', 'data-symfony--ux-autocomplete--autocomplete-url-value', '/test/autocomplete/category_autocomplete_type') + ->assertElementAttributeContains('#product_category', 'data-symfony--ux-autocomplete--autocomplete-url-value', '/test/autocomplete/category_autocomplete_type?extra_options=') ->assertElementAttributeContains('#product_category', 'data-symfony--ux-autocomplete--autocomplete-min-characters-value', '2') ->assertElementAttributeContains('#product_category', 'data-symfony--ux-autocomplete--autocomplete-max-results-value', '25') @@ -123,4 +123,58 @@ public function testMultipleDoesNotFailWithoutSelectedChoices() ->assertElementCount('#product_ingredients option', 0) ; } + + public function testItUsesPassedExtraOptions() + { + $ingredient1 = IngredientFactory::createOne(['name' => 'Flour']); + $ingredient2 = IngredientFactory::createOne(['name' => 'Sugar']); + $ingredient3 = IngredientFactory::createOne(['name' => 'Modified Flour']); + + $this->browser() + ->throwExceptions() + ->get('/test-form') + ->assertElementCount('#product_ingredients option', 0) + ->assertNotContains('Flour') + ->assertNotContains('Sugar') + ->assertNotContains('Modified Flour') + // request all three ingredients + ->post('/test-form', [ + 'body' => [ + 'product' => [ + 'ingredients' => [ + (string) $ingredient1->getId(), + (string) $ingredient2->getId(), + (string) $ingredient3->getId(), + ], + ], + ], + ]) + // assert that "Modified Flour" is not included + ->assertElementCount('#product_ingredients option', 2) + ->assertContains('Flour') + ->assertContains('Sugar') + ->assertNotContains('Modified Flour') + ; + } + + public function testItReturnsErrorWhenSendingMalformedExtraOptions(): void + { + $extraOptionsWithoutChecksum = $this->encodeData(['foo' => 'bar']); + $extraOptionsWithInvalidChecksum = $this->encodeData(['foo' => 'bar', '@checksum' => 'invalid']); + $extraOptionsWithValidChecksum = $this->encodeData(['foo' => 'bar', '@checksum' => 'O2nYjcGr/l8GmUuYUSfE52hoyEL0NtDhBzUbn17KVHQ=']); + + $this->browser() + ->post(sprintf('/test/autocomplete/category_autocomplete_type?extra_options=%s', $extraOptionsWithoutChecksum)) + ->assertStatus(400) + ->post(sprintf('/test/autocomplete/category_autocomplete_type?extra_options=%s', $extraOptionsWithInvalidChecksum)) + ->assertStatus(400) + ->post(sprintf('/test/autocomplete/category_autocomplete_type?extra_options=%s', $extraOptionsWithValidChecksum)) + ->assertStatus(200) + ; + } + + private function encodeData(array $data): string + { + return base64_encode(json_encode($data)); + } } diff --git a/src/Autocomplete/tests/Unit/Calculator/ChecksumCalculatorTest.php b/src/Autocomplete/tests/Unit/Calculator/ChecksumCalculatorTest.php new file mode 100644 index 0000000000..58bcd1272c --- /dev/null +++ b/src/Autocomplete/tests/Unit/Calculator/ChecksumCalculatorTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Autocomplete\Tests\Unit\Calculator; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator; + +final class ChecksumCalculatorTest extends TestCase +{ + public function testCalculateChecksumForArray(): void + { + $this->assertSame( + 'tZ34YQKgpttqybzPws0YJCOHV1QtMjQeuyy+rszdhXU=', + $this->createTestSubject()->calculateForArray(['test' => 'test']), + ); + } + + public function testCalculateTheSameChecksumForTheSameArrayButInDifferentOrder(): void + { + $this->assertSame( + $this->createTestSubject()->calculateForArray(['test' => 'test', 'test2' => 'test2']), + $this->createTestSubject()->calculateForArray(['test2' => 'test2', 'test' => 'test']), + ); + } + + private function createTestSubject(): ChecksumCalculator + { + return new ChecksumCalculator('s3cr3t'); + } +}