Skip to content

Commit 9e086b1

Browse files
committed
feature #1322 [Autocomplete] Allow passing extra options to the 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
2 parents f170c59 + 308daef commit 9e086b1

22 files changed

+480
-74
lines changed

src/Autocomplete/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
- Added `tom-select/dist/css/tom-select.bootstrap4.css` to `autoimport` - this
1212
will cause this to appear in your `controllers.json` file by default, but disabled
1313
see.
14+
- Allow passing `extra_options` key in an array passed as a `3rd` argument of the `->add()` method.
15+
It will be used during the Ajax call to fetch results.
1416

1517
## 2.13.2
1618

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the Symfony package.
7+
*
8+
* (c) Fabien Potencier <[email protected]>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Symfony\UX\Autocomplete\Checksum;
15+
16+
/** @internal */
17+
class ChecksumCalculator
18+
{
19+
public function __construct(private readonly string $secret)
20+
{
21+
}
22+
23+
public function calculateForArray(array $data): string
24+
{
25+
$this->sortKeysRecursively($data);
26+
27+
return base64_encode(hash_hmac('sha256', json_encode($data), $this->secret, true));
28+
}
29+
30+
private function sortKeysRecursively(array &$data): void
31+
{
32+
foreach ($data as &$value) {
33+
if (\is_array($value)) {
34+
$this->sortKeysRecursively($value);
35+
}
36+
}
37+
ksort($data);
38+
}
39+
}

src/Autocomplete/src/Controller/EntityAutocompleteController.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,27 @@
1414
use Symfony\Component\HttpFoundation\JsonResponse;
1515
use Symfony\Component\HttpFoundation\Request;
1616
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
1718
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
1819
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
1920
use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
2021
use Symfony\UX\Autocomplete\AutocompleterRegistry;
22+
use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator;
23+
use Symfony\UX\Autocomplete\Form\AutocompleteChoiceTypeExtension;
24+
use Symfony\UX\Autocomplete\OptionsAwareEntityAutocompleterInterface;
2125

2226
/**
2327
* @author Ryan Weaver <[email protected]>
2428
*/
2529
final class EntityAutocompleteController
2630
{
31+
public const EXTRA_OPTIONS = 'extra_options';
32+
2733
public function __construct(
2834
private AutocompleterRegistry $autocompleteFieldRegistry,
2935
private AutocompleteResultsExecutor $autocompleteResultsExecutor,
3036
private UrlGeneratorInterface $urlGenerator,
37+
private ChecksumCalculator $checksumCalculator,
3138
) {
3239
}
3340

@@ -38,6 +45,11 @@ public function __invoke(string $alias, Request $request): Response
3845
throw new NotFoundHttpException(sprintf('No autocompleter found for "%s". Available autocompleters are: (%s)', $alias, implode(', ', $this->autocompleteFieldRegistry->getAutocompleterNames())));
3946
}
4047

48+
if ($autocompleter instanceof OptionsAwareEntityAutocompleterInterface) {
49+
$extraOptions = $this->getExtraOptions($request);
50+
$autocompleter->setOptions([self::EXTRA_OPTIONS => $extraOptions]);
51+
}
52+
4153
$page = $request->query->getInt('page', 1);
4254
$nextPage = null;
4355

@@ -54,4 +66,48 @@ public function __invoke(string $alias, Request $request): Response
5466
'next_page' => $nextPage,
5567
]);
5668
}
69+
70+
/**
71+
* @return array<string, scalar|array|null>
72+
*/
73+
private function getExtraOptions(Request $request): array
74+
{
75+
if (!$request->query->has(self::EXTRA_OPTIONS)) {
76+
return [];
77+
}
78+
79+
$extraOptions = $this->getDecodedExtraOptions($request->query->getString(self::EXTRA_OPTIONS));
80+
81+
if (!\array_key_exists(AutocompleteChoiceTypeExtension::CHECKSUM_KEY, $extraOptions)) {
82+
throw new BadRequestHttpException('The extra options are missing the checksum.');
83+
}
84+
85+
$this->validateChecksum($extraOptions[AutocompleteChoiceTypeExtension::CHECKSUM_KEY], $extraOptions);
86+
87+
return $extraOptions;
88+
}
89+
90+
/**
91+
* @return array<string, scalar>
92+
*/
93+
private function getDecodedExtraOptions(string $extraOptions): array
94+
{
95+
return json_decode(base64_decode($extraOptions), true, flags: \JSON_THROW_ON_ERROR);
96+
}
97+
98+
/**
99+
* @param array<string, scalar> $extraOptions
100+
*/
101+
private function validateChecksum(string $checksum, array $extraOptions): void
102+
{
103+
$extraOptionsWithoutChecksum = array_filter(
104+
$extraOptions,
105+
fn (string $key) => AutocompleteChoiceTypeExtension::CHECKSUM_KEY !== $key,
106+
\ARRAY_FILTER_USE_KEY,
107+
);
108+
109+
if ($checksum !== $this->checksumCalculator->calculateForArray($extraOptionsWithoutChecksum)) {
110+
throw new BadRequestHttpException('The extra options have been tampered with.');
111+
}
112+
}
57113
}

src/Autocomplete/src/DependencyInjection/AutocompleteExtension.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
2222
use Symfony\UX\Autocomplete\AutocompleteResultsExecutor;
2323
use Symfony\UX\Autocomplete\AutocompleterRegistry;
24+
use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator;
2425
use Symfony\UX\Autocomplete\Controller\EntityAutocompleteController;
2526
use Symfony\UX\Autocomplete\Doctrine\DoctrineRegistryWrapper;
2627
use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory;
@@ -116,6 +117,7 @@ private function registerBasicServices(ContainerBuilder $container): void
116117
new Reference('ux.autocomplete.autocompleter_registry'),
117118
new Reference('ux.autocomplete.results_executor'),
118119
new Reference('router'),
120+
new Reference('ux.autocomplete.checksum_calculator'),
119121
])
120122
->addTag('controller.service_arguments')
121123
;
@@ -127,6 +129,13 @@ private function registerBasicServices(ContainerBuilder $container): void
127129
])
128130
->addTag('maker.command')
129131
;
132+
133+
$container
134+
->register('ux.autocomplete.checksum_calculator', ChecksumCalculator::class)
135+
->setArguments([
136+
'%kernel.secret%',
137+
])
138+
;
130139
}
131140

132141
private function registerFormServices(ContainerBuilder $container): void
@@ -149,6 +158,7 @@ private function registerFormServices(ContainerBuilder $container): void
149158
$container
150159
->register('ux.autocomplete.choice_type_extension', AutocompleteChoiceTypeExtension::class)
151160
->setArguments([
161+
new Reference('ux.autocomplete.checksum_calculator'),
152162
new Reference('translator', ContainerInterface::IGNORE_ON_INVALID_REFERENCE),
153163
])
154164
->addTag('form.type_extension');

src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\Component\OptionsResolver\Options;
2020
use Symfony\Component\OptionsResolver\OptionsResolver;
2121
use Symfony\Contracts\Translation\TranslatorInterface;
22+
use Symfony\UX\Autocomplete\Checksum\ChecksumCalculator;
2223

2324
/**
2425
* Initializes the autocomplete Stimulus controller for any fields with the "autocomplete" option.
@@ -27,8 +28,12 @@
2728
*/
2829
final class AutocompleteChoiceTypeExtension extends AbstractTypeExtension
2930
{
30-
public function __construct(private ?TranslatorInterface $translator = null)
31-
{
31+
public const CHECKSUM_KEY = '@checksum';
32+
33+
public function __construct(
34+
private readonly ChecksumCalculator $checksumCalculator,
35+
private readonly ?TranslatorInterface $translator = null,
36+
) {
3237
}
3338

3439
public static function getExtendedTypes(): iterable
@@ -79,6 +84,10 @@ public function finishView(FormView $view, FormInterface $form, array $options):
7984
$values['min-characters'] = $options['min_characters'];
8085
}
8186

87+
if ($options['extra_options']) {
88+
$values['url'] = $this->getUrlWithExtraOptions($values['url'], $options['extra_options']);
89+
}
90+
8291
$values['loading-more-text'] = $this->trans($options['loading_more_text']);
8392
$values['no-results-found-text'] = $this->trans($options['no_results_found_text']);
8493
$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):
92101
$view->vars['attr'] = $attr;
93102
}
94103

104+
private function getUrlWithExtraOptions(string $url, array $extraOptions): string
105+
{
106+
$this->validateExtraOptions($extraOptions);
107+
108+
$extraOptions[self::CHECKSUM_KEY] = $this->checksumCalculator->calculateForArray($extraOptions);
109+
$extraOptions = base64_encode(json_encode($extraOptions));
110+
111+
return sprintf(
112+
'%s%s%s',
113+
$url,
114+
$this->hasUrlParameters($url) ? '&' : '?',
115+
http_build_query(['extra_options' => $extraOptions]),
116+
);
117+
}
118+
119+
private function hasUrlParameters(string $url): bool
120+
{
121+
$parsedUrl = parse_url($url);
122+
123+
return isset($parsedUrl['query']);
124+
}
125+
126+
private function validateExtraOptions(array $extraOptions): void
127+
{
128+
foreach ($extraOptions as $optionKey => $option) {
129+
if (!\is_scalar($option) && !\is_array($option) && null !== $option) {
130+
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)));
131+
}
132+
133+
if (\is_array($option)) {
134+
$this->validateExtraOptions($option);
135+
}
136+
}
137+
}
138+
95139
public function configureOptions(OptionsResolver $resolver): void
96140
{
97141
$resolver->setDefaults([
@@ -106,6 +150,7 @@ public function configureOptions(OptionsResolver $resolver): void
106150
'min_characters' => null,
107151
'max_results' => 10,
108152
'preload' => 'focus',
153+
'extra_options' => [],
109154
]);
110155

111156
// if autocomplete_url is passed, then HTML options are already supported

src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,18 @@
2222
use Symfony\UX\Autocomplete\Doctrine\EntityMetadata;
2323
use Symfony\UX\Autocomplete\Doctrine\EntityMetadataFactory;
2424
use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
25-
use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
25+
use Symfony\UX\Autocomplete\OptionsAwareEntityAutocompleterInterface;
2626

2727
/**
2828
* An entity auto-completer that wraps a form type to get its information.
2929
*
3030
* @internal
3131
*/
32-
final class WrappedEntityTypeAutocompleter implements EntityAutocompleterInterface
32+
final class WrappedEntityTypeAutocompleter implements OptionsAwareEntityAutocompleterInterface
3333
{
3434
private ?FormInterface $form = null;
3535
private ?EntityMetadata $entityMetadata = null;
36+
private array $options = [];
3637

3738
public function __construct(
3839
private string $formType,
@@ -139,7 +140,7 @@ private function getFormOption(string $name): mixed
139140
private function getForm(): FormInterface
140141
{
141142
if (null === $this->form) {
142-
$this->form = $this->formFactory->create($this->formType);
143+
$this->form = $this->formFactory->create($this->formType, options: $this->options);
143144
}
144145

145146
return $this->form;
@@ -168,4 +169,13 @@ private function getEntityMetadata(): EntityMetadata
168169

169170
return $this->entityMetadata;
170171
}
172+
173+
public function setOptions(array $options): void
174+
{
175+
if (null !== $this->form) {
176+
throw new \LogicException('The options can only be set before the form is created.');
177+
}
178+
179+
$this->options = $options;
180+
}
171181
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Autocomplete;
13+
14+
/**
15+
* Interface for classes that will have an "autocomplete" endpoint exposed with a possibility to pass additional form options.
16+
*/
17+
interface OptionsAwareEntityAutocompleterInterface extends EntityAutocompleterInterface
18+
{
19+
public function setOptions(array $options): void;
20+
}

src/Autocomplete/tests/Fixtures/Autocompleter/CustomGroupByProductAutocompleter.php

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
<?php
22

3-
namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
411

5-
use Doctrine\ORM\EntityRepository;
6-
use Doctrine\ORM\QueryBuilder;
7-
use Symfony\Component\HttpFoundation\RequestStack;
8-
use Symfony\Bundle\SecurityBundle\Security;
9-
use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
10-
use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
11-
use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
12+
namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;
1213

1314
class CustomGroupByProductAutocompleter extends CustomProductAutocompleter
1415
{

src/Autocomplete/tests/Fixtures/Autocompleter/CustomProductAutocompleter.php

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
<?php
22

3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
312
namespace Symfony\UX\Autocomplete\Tests\Fixtures\Autocompleter;
413

514
use Doctrine\ORM\EntityRepository;
615
use Doctrine\ORM\QueryBuilder;
7-
use Symfony\Component\HttpFoundation\RequestStack;
816
use Symfony\Bundle\SecurityBundle\Security;
17+
use Symfony\Component\HttpFoundation\RequestStack;
918
use Symfony\UX\Autocomplete\Doctrine\EntitySearchUtil;
1019
use Symfony\UX\Autocomplete\EntityAutocompleterInterface;
1120
use Symfony\UX\Autocomplete\Tests\Fixtures\Entity\Product;
@@ -14,9 +23,8 @@ class CustomProductAutocompleter implements EntityAutocompleterInterface
1423
{
1524
public function __construct(
1625
private RequestStack $requestStack,
17-
private EntitySearchUtil $entitySearchUtil
18-
)
19-
{
26+
private EntitySearchUtil $entitySearchUtil,
27+
) {
2028
}
2129

2230
public function getEntityClass(): string

0 commit comments

Comments
 (0)