Skip to content

Commit 308daef

Browse files
jakubtobiaszweaverryan
authored andcommitted
[Autocomplete] Allow passing extra options to the autocomplete fields
1 parent 0bae1d8 commit 308daef

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 <fabien@symfony.com>
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 <ryan@symfonycasts.com>
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 <fabien@symfony.com>
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 <fabien@symfony.com>
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 <fabien@symfony.com>
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)