Skip to content

Commit

Permalink
[Autocomplete] Allow passing extra options to the autocomplete fields
Browse files Browse the repository at this point in the history
  • Loading branch information
jakubtobiasz authored and weaverryan committed Feb 8, 2024
1 parent 0bae1d8 commit 308daef
Show file tree
Hide file tree
Showing 22 changed files with 480 additions and 74 deletions.
2 changes: 2 additions & 0 deletions src/Autocomplete/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
39 changes: 39 additions & 0 deletions src/Autocomplete/src/Checksum/ChecksumCalculator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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);
}
}
56 changes: 56 additions & 0 deletions src/Autocomplete/src/Controller/EntityAutocompleteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>
*/
final class EntityAutocompleteController
{
public const EXTRA_OPTIONS = 'extra_options';

public function __construct(
private AutocompleterRegistry $autocompleteFieldRegistry,
private AutocompleteResultsExecutor $autocompleteResultsExecutor,
private UrlGeneratorInterface $urlGenerator,
private ChecksumCalculator $checksumCalculator,
) {
}

Expand All @@ -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;

Expand All @@ -54,4 +66,48 @@ public function __invoke(string $alias, Request $request): Response
'next_page' => $nextPage,
]);
}

/**
* @return array<string, scalar|array|null>
*/
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<string, scalar>
*/
private function getDecodedExtraOptions(string $extraOptions): array
{
return json_decode(base64_decode($extraOptions), true, flags: \JSON_THROW_ON_ERROR);
}

/**
* @param array<string, scalar> $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.');
}
}
}
10 changes: 10 additions & 0 deletions src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')
;
Expand All @@ -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
Expand All @@ -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');
Expand Down
49 changes: 47 additions & 2 deletions src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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']);
Expand All @@ -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([
Expand All @@ -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
Expand Down
16 changes: 13 additions & 3 deletions src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
20 changes: 20 additions & 0 deletions src/Autocomplete/src/OptionsAwareEntityAutocompleterInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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;
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
<?php

namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* 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;
Expand All @@ -14,9 +23,8 @@ class CustomProductAutocompleter implements EntityAutocompleterInterface
{
public function __construct(
private RequestStack $requestStack,
private EntitySearchUtil $entitySearchUtil
)
{
private EntitySearchUtil $entitySearchUtil,
) {
}

public function getEntityClass(): string
Expand Down
Loading

0 comments on commit 308daef

Please sign in to comment.