Skip to content

Commit

Permalink
feature symfony#1322 [Autocomplete] Allow passing extra options to th…
Browse files Browse the repository at this point in the history
…e autocomplete fields (jakubtobiasz)

This PR was squashed before being merged into the 2.x branch.

Discussion
----------

[Autocomplete] Allow passing extra options to the autocomplete fields

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | n/a
| License       | MIT

This PR still needs some tweaks and tests, but I want to validate my idea. Currently, if we pass anything as a third argument in the `->add()` method, it is only used on the form render, and is fully ignored during the AJAX call.
![CleanShot 2023-12-03 at 12 36 59](https://github.com/symfony/ux/assets/80641364/28bc4b75-215c-4588-80b2-0db9eb82dcc6)

While implementing UX's Autocomplete in Sylius, I wanted to complete the following user story
```
Given I want to edit a taxon
When I want to assign a parent taxon to it
And I check a list of available taxa
Then I should see all taxa except the edited one
```

So basically, I have to pass a **current** taxon's ID to the autocomplete's query. As we know, currently it's not possible. So after contacting `@weaverryan`, I decided to implement a mechanism similar to the one from Live Components.

When you pass an array of options as a `3rd` argument, you can use a special `extra_options` key, which is an array consisting `scalars`/`arrays`/`nulls`.

Next, when the form is rendered, I get these values, calculate a checksum for them, then pass them through `json_encode` and `base64_encode` functions. In the end, I glue them to the `url` values in the `\Symfony\UX\Autocomplete\Form\AutocompleteChoiceTypeExtension::finishView` method.

So, basically with the following configuration:
![CleanShot 2023-12-03 at 12 48 51](https://github.com/symfony/ux/assets/80641364/e7b2585c-f052-4803-9473-a83930a4d1c8)

we end up with the following HTML code:
![CleanShot 2023-12-03 at 12 49 36](https://github.com/symfony/ux/assets/80641364/a173220f-19ed-4104-951c-0fee3469768e)

I decided to "glue" the `extra_options` to the URL, as I didn't have to deal with JS. Of course, I do not exclude a chance to refactor it, as a whole method should be refactored anyway.

Finally, the controller decodes the data, checks the checksum and passes the values to the autocomplete's form.

Commits
-------

308daef [Autocomplete] Allow passing extra options to the autocomplete fields
  • Loading branch information
weaverryan committed Feb 8, 2024
2 parents f170c59 + 308daef commit 9e086b1
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 9e086b1

Please sign in to comment.